From 25defbafb4a9f67d633a850cff9825bf279f800f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 3 Sep 2026 13:22:21 +0200 Subject: [PATCH 001/566] Make plugin upgrade failures actionable (#4265) * fix(plugins): clarify upgrade diagnostics Keep plugin inspection network-free and consolidate runtime, source, and installed revision details under plugin ls. Legacy entry failures now explain the required v0.8 migration in user-facing language. * test(cli): align hidden plugin status command --- docs/plugins.md | 6 +-- packages/cli/src/cli-surface.test.ts | 2 +- .../cli/src/commands/plugin/index.test.ts | 52 ++++++++++++++++++- packages/cli/src/commands/plugin/index.ts | 42 +++++---------- .../src/server/plugins/index.posix.test.ts | 12 +++++ .../src/server/plugins/runtime.posix.test.ts | 7 +-- packages/server/src/server/plugins/runtime.ts | 2 +- public-docs/cli.md | 8 +-- public-docs/plugins/v0.7/index.md | 3 +- public-docs/plugins/v0.7/reference.md | 1 - public-docs/plugins/v0.8/index.md | 4 +- public-docs/plugins/v0.8/migration.md | 2 +- public-docs/plugins/v0.8/reference.md | 8 +-- 13 files changed, 95 insertions(+), 54 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index b1051c3a7f..2b523a7c1c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -91,7 +91,7 @@ paseo plugin add https://gitlab.com/group/repository.git paseo plugin add https://git.example.com/owner/repository.git paseo plugin add owner/monorepo:plugins/review paseo plugin add owner/repository --ref main -paseo plugin status +paseo plugin ls paseo plugin update review paseo plugin update --all ``` @@ -99,8 +99,8 @@ paseo plugin update --all Append `:relative/path` to the source when the plugin lives below the repository root. Omitting `--ref` tracks the remote's default branch. A branch passed with `--ref` also tracks; -tags and commits stay pinned. `status` fetches tracked refs and reports the installed and available -commits. Removing a Git source deletes Paseo's managed checkout. +tags and commits stay pinned. `ls` reports the installed commit without contacting the remote. +Removing a Git source deletes Paseo's managed checkout. ### Declare Git preparation diff --git a/packages/cli/src/cli-surface.test.ts b/packages/cli/src/cli-surface.test.ts index 3243529c23..676f79234d 100644 --- a/packages/cli/src/cli-surface.test.ts +++ b/packages/cli/src/cli-surface.test.ts @@ -79,9 +79,9 @@ describe("canonical CLI surface", () => { expect(plugin?.commands.map((command) => command.name())).toEqual([ "init", "ls", + "status", "logs", "install", - "status", "update", "reload", "enable", diff --git a/packages/cli/src/commands/plugin/index.test.ts b/packages/cli/src/commands/plugin/index.test.ts index 11e80480f4..02c6c846ce 100644 --- a/packages/cli/src/commands/plugin/index.test.ts +++ b/packages/cli/src/commands/plugin/index.test.ts @@ -1,6 +1,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const listPlugins = vi.fn(async () => []); +const listPlugins = vi.fn(async () => [ + { + id: "git-plugin", + path: "/plugins/git-plugin", + enabled: true, + status: "running" as const, + source: "git" as const, + commit: "1557a34c91e2abcdef", + ref: "main", + }, + { + id: "legacy-plugin", + path: "/plugins/legacy-plugin", + enabled: true, + status: "failed" as const, + error: "This plugin was made for an older version of Paseo", + }, +]); const getPluginLogs = vi.fn(async () => [ { sequence: 1, @@ -51,7 +68,7 @@ describe("plugin management commands", () => { }); it("requires host support before attempting a management RPC", async () => { - await expect(runPluginListCommand({}, {} as never)).rejects.toMatchObject({ + await expect(runPluginListCommand(undefined, {}, {} as never)).rejects.toMatchObject({ code: "DAEMON_UPDATE_REQUIRED", message: "Update the host to use plugin management.", }); @@ -59,6 +76,37 @@ describe("plugin management commands", () => { expect(close).toHaveBeenCalledTimes(1); }); + it("keeps status as a hidden alias for the network-free plugin list", async () => { + features.pluginManagement = true; + const command = createPluginCommand(); + + await command.parseAsync(["status"], { from: "user" }); + + expect(listPlugins).toHaveBeenCalledTimes(1); + expect(command.helpInformation()).not.toContain("status"); + }); + + it("lists runtime state and the installed commit without an upstream commit", async () => { + features.pluginManagement = true; + + const result = await runPluginListCommand(undefined, {}, {} as never); + const output = render(result, { noColor: true }); + + expect(output).toContain("SOURCE"); + expect(output).toContain("COMMIT"); + expect(output).not.toContain("LATEST"); + expect(output).toContain("1557a34c91e2"); + expect(output).toContain("This plugin was made for an older version of Paseo"); + }); + + it("filters the shared ls and status command by plugin ID", async () => { + features.pluginManagement = true; + + const result = await runPluginListCommand("legacy-plugin", {}, {} as never); + + expect(result.data.map((plugin) => plugin.id)).toEqual(["legacy-plugin"]); + }); + it("requires plugin log support before attempting the RPC", async () => { await expect(runPluginLogsCommand("example", {}, {} as never)).rejects.toMatchObject({ code: "DAEMON_UPDATE_REQUIRED", diff --git a/packages/cli/src/commands/plugin/index.ts b/packages/cli/src/commands/plugin/index.ts index 016938d947..608a57e5d1 100644 --- a/packages/cli/src/commands/plugin/index.ts +++ b/packages/cli/src/commands/plugin/index.ts @@ -3,7 +3,6 @@ import path from "node:path"; import type { PluginListItem, PluginLogEntry, - PluginSourceStatusItem, PluginSourceUpdateItem, } from "@getpaseo/protocol/messages"; import { @@ -34,6 +33,9 @@ const pluginSchema: OutputSchema = { { header: "PLUGIN", field: "id", width: 20 }, { header: "STATUS", field: "status", width: 10 }, { header: "ENABLED", field: (plugin) => (plugin.enabled ? "yes" : "no"), width: 8 }, + { header: "SOURCE", field: (plugin) => plugin.source ?? "directory", width: 10 }, + { header: "COMMIT", field: (plugin) => shortCommit(plugin.commit), width: 14 }, + { header: "REF", field: (plugin) => plugin.ref ?? "-", width: 24 }, { header: "DIRECTORY", field: "path", width: 40 }, { header: "ERROR", field: (plugin) => plugin.error ?? "", width: 40 }, ], @@ -60,18 +62,6 @@ function shortCommit(commit: string | undefined): string { return commit?.slice(0, 12) ?? "-"; } -const pluginStatusSchema: OutputSchema = { - idField: "id", - columns: [ - { header: "PLUGIN", field: "id", width: 20 }, - { header: "SOURCE", field: "source", width: 10 }, - { header: "CURRENT", field: (plugin) => shortCommit(plugin.currentCommit), width: 14 }, - { header: "LATEST", field: (plugin) => shortCommit(plugin.latestCommit), width: 14 }, - { header: "COMMITS", field: (plugin) => String(plugin.commitsBehind ?? 0), width: 8 }, - { header: "REF", field: (plugin) => plugin.ref ?? "-", width: 24 }, - ], -}; - const pluginUpdateSchema: OutputSchema = { idField: "id", columns: [ @@ -96,10 +86,13 @@ export async function runPluginInitCommand( } export async function runPluginListCommand( + pluginId: string | undefined, options: PluginOptions, _command: Command, ): Promise> { - const data = await withPluginManagementClient(options.host, (client) => client.listPlugins()); + const plugins = await withPluginManagementClient(options.host, (client) => client.listPlugins()); + const data = pluginId ? plugins.filter((plugin) => plugin.id === pluginId) : plugins; + if (pluginId && data.length === 0) throw new Error(`Plugin is not configured: ${pluginId}`); return { type: "list", data, schema: pluginSchema }; } @@ -146,17 +139,6 @@ async function install( return { type: "single", data, schema: pluginSchema }; } -async function status( - pluginId: string | undefined, - options: PluginOptions, - _command: Command, -): Promise> { - const data = await withPluginSourceClient(options.host, (client) => - client.getPluginSourceStatus(pluginId), - ); - return { type: "list", data, schema: pluginStatusSchema }; -} - async function update( pluginId: string | undefined, options: PluginOptions, @@ -205,7 +187,10 @@ export function createPluginCommand(): Command { .argument("") .option("--id ", "Manifest plugin ID (defaults to the directory name)"), ).action(withOutput(runPluginInitCommand)); - addJsonAndDaemonHostOptions(plugin.command("ls").description("List configured plugins")).action( + addJsonAndDaemonHostOptions( + plugin.command("ls").description("List configured plugins").argument("[id]"), + ).action(withOutput(runPluginListCommand)); + addJsonAndDaemonHostOptions(plugin.command("status", { hidden: true }).argument("[id]")).action( withOutput(runPluginListCommand), ); addJsonAndDaemonHostOptions( @@ -221,13 +206,10 @@ export function createPluginCommand(): Command { .option("--ref ", "Git branch, tag, or commit") .option("--path ", "Legacy form of the :plugin/path source suffix"), ).action(withOutput(install)); - addJsonAndDaemonHostOptions( - plugin.command("status").description("Check plugin source updates").argument("[id]"), - ).action(withOutput(status)); addJsonAndDaemonHostOptions( plugin .command("update") - .description("Update a Git-managed plugin") + .description("Fetch and install Git-managed plugin updates") .argument("[id]") .option("--all", "Update every Git-managed plugin"), ).action(withOutput(update)); diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 33d790a59b..e90663fb72 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -684,6 +684,9 @@ export default function contribute(plugin: unknown) { await writeFile(path.join(invalid, "paseo-plugin.json"), JSON.stringify({})); const missingEntry = await createPlugin("missing-entry", "export default () => () => {};"); await rm(path.join(missingEntry, "index.server.ts")); + const legacy = await createPlugin("legacy-plugin", "export default () => () => {};"); + await rm(path.join(legacy, "index.server.ts")); + await writeFile(path.join(legacy, "index.ts"), "export default () => () => {};"); const startupFailure = await createPlugin( "startup-failure", `export default function contribute(plugin: unknown) { void plugin; throw new Error("startup exploded"); }`, @@ -695,10 +698,19 @@ export default function contribute(plugin: unknown) { await expect(service.installDirectory({ path: missingEntry })).rejects.toThrow( "Plugin entry points are missing", ); + await expect(service.installDirectory({ path: legacy })).rejects.toThrow( + "This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/v0.8/migration", + ); await expect(service.installDirectory({ path: startupFailure })).rejects.toThrow( "startup exploded", ); expect(service.listPlugins()).toEqual([ + expect.objectContaining({ + id: "legacy-plugin", + status: "failed", + error: + "This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/v0.8/migration", + }), expect.objectContaining({ id: "missing-entry", status: "failed", diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 6980a6f7e3..021f695ff7 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -494,7 +494,7 @@ export default function contribute(plugin: unknown) { await runtime.stopAll(); }); - it("rejects an index.ts plugin with the migration path", async () => { + it("explains that an index.ts plugin was made for an older Paseo version", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-")); temporaryDirectories.push(directory); await Promise.all([ @@ -504,10 +504,7 @@ export default function contribute(plugin: unknown) { const runtime = createTestRuntime(); await expect(runtime.startPlugin("legacy", directory)).rejects.toThrow( - "Plugin entry split is required", - ); - await expect(runtime.startPlugin("legacy", directory)).rejects.toThrow( - "https://paseo.sh/docs/plugins/v0.8/migration", + "This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/v0.8/migration", ); }); diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 11951ecad3..1afb0655e5 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -207,7 +207,7 @@ async function resolveEntryPaths(directory: string): Promise<{ const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]); if (legacyEntry) { throw new Error( - "Plugin entry split is required: migrate index.ts to index.client.tsx and/or index.server.ts. See https://paseo.sh/docs/plugins/v0.8/migration", + "This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/v0.8/migration", ); } throw new Error( diff --git a/public-docs/cli.md b/public-docs/cli.md index e0ec925bfe..10065793de 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -154,10 +154,9 @@ paseo plugin install /absolute/path/to/plugin paseo plugin add owner/repository paseo plugin add https://gitlab.com/group/repository.git --ref main paseo plugin add owner/monorepo:plugins/review -paseo plugin status +paseo plugin ls [id] paseo plugin update my-plugin paseo plugin update --all -paseo plugin ls paseo plugin reload my-plugin paseo plugin logs my-plugin paseo plugin disable my-plugin @@ -166,8 +165,9 @@ paseo plugin remove my-plugin ``` GitHub shorthand checks an existing host directory first. Append `:` for a plugin in a -monorepo. `paseo plugin logs ` returns the plugin's recent daemon-side stdout and stderr. Add `--json` for -structured entries, or run `paseo --host plugin logs ` for another daemon. See the +monorepo. `paseo plugin ls [id]` does not contact the remote. `paseo plugin logs ` returns the +plugin's recent daemon-side stdout and stderr. Add `--json` for structured entries, or run +`paseo --host plugin logs ` for another daemon. See the [Plugin reference](/docs/plugins/v0.7/reference) for installation, trust, lifecycle, and log-retention behavior. diff --git a/public-docs/plugins/v0.7/index.md b/public-docs/plugins/v0.7/index.md index dafe44620a..618f7b7b31 100644 --- a/public-docs/plugins/v0.7/index.md +++ b/public-docs/plugins/v0.7/index.md @@ -137,10 +137,9 @@ paseo plugin add owner/repository --ref main Append `:relative/path` to the source when the plugin lives below the repository root. An omitted `--ref` tracks the default branch. Explicit branches track updates; tags and commits are -pinned. Check and apply updates with: +pinned. Apply updates with: ```bash -paseo plugin status paseo plugin update workspace-plugin paseo plugin update --all ``` diff --git a/public-docs/plugins/v0.7/reference.md b/public-docs/plugins/v0.7/reference.md index 1ff7511fda..c29a7792ec 100644 --- a/public-docs/plugins/v0.7/reference.md +++ b/public-docs/plugins/v0.7/reference.md @@ -957,7 +957,6 @@ paseo plugin install /absolute/path/to/plugin --id another-runtime-id paseo plugin add owner/repository paseo plugin add https://git.example.com/owner/repository.git --ref main paseo plugin add owner/monorepo:plugins/review -paseo plugin status [id] paseo plugin update paseo plugin update --all paseo plugin ls diff --git a/public-docs/plugins/v0.8/index.md b/public-docs/plugins/v0.8/index.md index 8a167bfbac..fe0915f0c2 100644 --- a/public-docs/plugins/v0.8/index.md +++ b/public-docs/plugins/v0.8/index.md @@ -231,11 +231,13 @@ Append `:relative/path` when the plugin lives below the repository root. Without default branch is tracked; a branch tracks updates, while a tag or commit stays pinned. ```bash -paseo plugin status +paseo plugin ls paseo plugin update workspace-plugin paseo plugin update --all ``` +`ls` reports runtime state, source details, and the installed commit without contacting the remote. + Paseo compiles TypeScript itself, so most plugins need no build step. A repository that must install a dependency Paseo does not provide, or generate files, declares [`build` commands](/docs/plugins/v0.8/reference#cli-reference) in its manifest. diff --git a/public-docs/plugins/v0.8/migration.md b/public-docs/plugins/v0.8/migration.md index 9cb7ea98c7..bfe844877d 100644 --- a/public-docs/plugins/v0.8/migration.md +++ b/public-docs/plugins/v0.8/migration.md @@ -101,7 +101,7 @@ its registration; that registration belongs in the client entry. | Compiler or load error | Meaning and fix | | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| `Plugin entry split is required` | The directory still has only the old root entry. Create a runtime entry, move registrations, then delete the old file. | +| `This plugin was made for an older version of Paseo` | The directory still has only the old root entry. Create a runtime entry, move registrations, then delete the old file. | | `Plugin entry points are missing: expected index.client.ts or index.client.tsx and/or index.server.ts or index.server.tsx` | No supported entry exists. Add at least one exact filename. | | `server-only module cannot be imported into the plugin client bundle: ` | A client import reaches `server/`. Move the call behind an RPC and import its contract from `shared/`. | | `client-only module cannot be imported into the plugin server bundle: ` | A server import reaches `client/`. Move that registration and import to the client entry. | diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index 963968166b..139def7da2 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -1016,10 +1016,9 @@ paseo plugin install /absolute/path/to/plugin --id another-runtime-id paseo plugin add owner/repository paseo plugin add https://git.example.com/owner/repository.git --ref main paseo plugin add owner/monorepo:plugins/review -paseo plugin status [id] +paseo plugin ls [id] paseo plugin update paseo plugin update --all -paseo plugin ls paseo plugin reload my-plugin paseo plugin logs my-plugin paseo plugin disable my-plugin @@ -1027,6 +1026,9 @@ paseo plugin enable my-plugin paseo plugin remove my-plugin ``` +`ls` reports runtime state, source details, and the installed commit without contacting the remote. +Use `update` when you want Paseo to contact a tracked Git remote and install an available update. + Put `--host ` before a management command when the target is not the CLI's default daemon. `remove` never deletes a directory source; it deletes the managed checkout for a Git source. The install-time `--id` is the runtime ID and allows the same directory or repository to be installed more than once. @@ -1070,7 +1072,7 @@ Use `paseo plugin ls` to read the current status and error. | Symptom | Check | | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| `Plugin entry split is required` | The directory has only an `index.ts` entry. Follow the [migration guide](/docs/plugins/v0.8/migration). | +| `This plugin was made for an older version of Paseo` | The directory has only an `index.ts` entry. Follow the [migration guide](/docs/plugins/v0.8/migration). | | `Plugin entry points are missing` | Neither `index.client.tsx` nor `index.server.ts` exists with that exact name. | | `server-only module cannot be imported into the plugin client bundle` | Client code imports `server/` or a `*.server.*` file. Move the work behind an RPC and import its contract from `shared/`. | | `client-only module cannot be imported into the plugin server bundle` | Server code imports `client/` or a `*.client.*` file. Register that contribution from `index.client.tsx` instead. | From 53c96074791089949f6917cfa55db1d11f212521 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 3 Sep 2026 20:10:47 +0200 Subject: [PATCH 002/566] Control Paseo tools per provider (#4277) Add backend-only exact-provider controls for the Paseo tool catalog across native and MCP-backed agents, including persisted configuration, session snapshots, public documentation, and regression coverage.\n\nCo-authored-by: Matt Cowger --- docs/data-model.md | 39 ++++ docs/providers.md | 4 +- packages/protocol/src/messages.ts | 2 + .../src/paseo-tool-policy-config.test.ts | 59 ++++++ packages/protocol/src/provider-config.ts | 7 + .../src/server/agent/agent-manager.test.ts | 168 ++++++++++++++++++ .../server/src/server/agent/agent-manager.ts | 92 ++++++++-- .../src/server/agent/mcp-server.test.ts | 79 +++++++- .../server/agent/paseo-tool-policy.test.ts | 35 ++++ .../src/server/agent/paseo-tool-policy.ts | 29 +++ .../src/server/agent/tools/paseo-tools.ts | 6 + .../server/src/server/agent/tools/types.ts | 2 + packages/server/src/server/bootstrap.ts | 13 +- .../src/server/daemon-config-store.test.ts | 42 +++++ .../server/src/server/daemon-config-store.ts | 14 +- .../public/schemas/paseo.config.v1.json | 15 ++ public-docs/custom-providers.md | 2 +- public-docs/mcp.md | 53 ++++++ 18 files changed, 639 insertions(+), 22 deletions(-) create mode 100644 packages/protocol/src/paseo-tool-policy-config.test.ts create mode 100644 packages/server/src/server/agent/paseo-tool-policy.test.ts create mode 100644 packages/server/src/server/agent/paseo-tool-policy.ts diff --git a/docs/data-model.md b/docs/data-model.md index 291c58f736..4c86286c2a 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -275,6 +275,45 @@ rather than storing something it cannot describe. That is why the client gates t UI on `server_info.features.agentProfiles` instead of letting a save appear to succeed against an older daemon. +### Agent provider Paseo tools + +`agents.providers` is keyed by the exact provider ID used to launch the agent. The built-in IDs are +`claude`, `codex`, `copilot`, `opencode`, `pi`, and `omp`. Custom provider IDs are their literal +configuration keys, such as `my-claude` or `zai`, not the provider named by `extends`. + +Each entry may include a Paseo-tool policy: + +```json +{ + "agents": { + "providers": { + "my-claude": { + "extends": "claude", + "label": "My Claude", + "paseoTools": { + "enabled": true, + "disabledTools": ["browser_evaluate"] + } + } + } + } +} +``` + +Absent `paseoTools`, or absent fields within it, means Paseo tools are enabled and all tools are +allowed. `enabled: false` disables the provider's Paseo catalog; `disabledTools` lists exact tool +IDs to omit. The policy covers the core and browser catalog, not the voice-only `speak` tool. +Browser tools also require `daemon.browserTools.enabled` and a connected browser host. +This policy controls the catalog presented to an agent. It is not an authorization boundary for +agents that can access the host through a shell. + +`daemon.mcp.injectIntoAgents` is the global override. When it is `false`, no provider receives +Paseo tools; otherwise the provider policy applies. Provider and global policy are resolved when a +session is created, resumed, imported, or reloaded, so configuration changes affect the next +session rather than an already-running one. + +`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available. + ### Git process limits Git process limits are global to one daemon. The start-rate limit defaults to `64` processes per diff --git a/docs/providers.md b/docs/providers.md index c7bde43cd7..df703b406e 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -57,7 +57,9 @@ Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (` Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code. -Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`. +Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. The daemon resolves `agents.providers..paseoTools` by the exact provider ID. The catalog policy belongs to the caller: it filters the tools exposed to the current agent. When that agent calls `create_agent`, the child receives the policy for the child provider ID; the caller's policy is not inherited. + +A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume the already-filtered `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`; the MCP server builds the same policy-filtered catalog for that caller. Filtering is enforced at catalog registration in both paths. Browser tools remain subject to the daemon browser-tools setting and browser-host availability. Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages. diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 954b8acad7..400dd23f5a 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -4,6 +4,7 @@ import { CLIENT_CAPS } from "./client-capabilities.js"; import { AGENT_LIFECYCLE_STATUSES } from "./agent-lifecycle.js"; import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "./agent-title-limits.js"; import { AgentProviderSchema } from "./provider-manifest.js"; +import { ProviderPaseoToolsPolicySchema } from "./provider-config.js"; import { TOOL_CALL_ICON_NAMES } from "./agent-types.js"; import { WORKSPACE_LABEL_COLORS } from "./workspace-labels.js"; import { @@ -118,6 +119,7 @@ const MutableDaemonProviderModelSchema = z const MutableDaemonProviderConfigSchema = z .object({ + paseoTools: ProviderPaseoToolsPolicySchema.optional(), enabled: z.boolean().optional(), additionalModels: z.array(MutableDaemonProviderModelSchema).optional(), }) diff --git a/packages/protocol/src/paseo-tool-policy-config.test.ts b/packages/protocol/src/paseo-tool-policy-config.test.ts new file mode 100644 index 0000000000..d4a8966207 --- /dev/null +++ b/packages/protocol/src/paseo-tool-policy-config.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "vitest"; + +import { MutableDaemonConfigPatchSchema, MutableDaemonConfigSchema } from "./messages.js"; +import { ProviderOverrideSchema, ProviderPaseoToolsPolicySchema } from "./provider-config.js"; + +describe("provider Paseo-tool policy", () => { + test("accepts arbitrary tool IDs and leaves an empty policy enabled by default", () => { + expect( + ProviderPaseoToolsPolicySchema.parse({ + disabledTools: ["future_tool", "browser_future_tool"], + }), + ).toEqual({ + disabledTools: ["future_tool", "browser_future_tool"], + }); + expect(ProviderPaseoToolsPolicySchema.parse({})).toEqual({}); + expect(ProviderOverrideSchema.parse({}).paseoTools).toBeUndefined(); + }); + + test("accepts paseoTools on persisted provider overrides", () => { + expect( + ProviderOverrideSchema.parse({ + extends: "claude", + paseoTools: { + enabled: false, + disabledTools: ["create_workspace"], + }, + }).paseoTools, + ).toEqual({ + enabled: false, + disabledTools: ["create_workspace"], + }); + }); + + test("accepts paseoTools when reading and patching mutable daemon providers", () => { + expect( + MutableDaemonConfigSchema.parse({ + mcp: { injectIntoAgents: true }, + providers: { + codex: { + paseoTools: { enabled: false, disabledTools: ["future_tool"] }, + }, + }, + }).providers.codex?.paseoTools, + ).toEqual({ + enabled: false, + disabledTools: ["future_tool"], + }); + + expect( + MutableDaemonConfigPatchSchema.parse({ + providers: { + codex: { + paseoTools: { disabledTools: ["browser_future_tool"] }, + }, + }, + }).providers?.codex?.paseoTools, + ).toEqual({ disabledTools: ["browser_future_tool"] }); + }); +}); diff --git a/packages/protocol/src/provider-config.ts b/packages/protocol/src/provider-config.ts index 2ab879c31f..ccf73f23fd 100644 --- a/packages/protocol/src/provider-config.ts +++ b/packages/protocol/src/provider-config.ts @@ -28,6 +28,11 @@ export const ProviderRuntimeSettingsSchema = z.object({ disallowedTools: z.array(z.string()).optional(), }); +export const ProviderPaseoToolsPolicySchema = z.object({ + enabled: z.boolean().optional(), + disabledTools: z.array(z.string()).optional(), +}); + const ProviderProfileThinkingOptionSchema = z.object({ id: z.string(), label: z.string(), @@ -53,6 +58,7 @@ export const ProviderOverrideSchema = z.object({ models: z.array(ProviderProfileModelSchema).optional(), additionalModels: z.array(ProviderProfileModelSchema).optional(), disallowedTools: z.array(z.string()).optional(), + paseoTools: ProviderPaseoToolsPolicySchema.optional(), enabled: z.boolean().optional(), order: z.number().optional(), }); @@ -127,6 +133,7 @@ export const AgentProviderRuntimeSettingsMapSchema = z export type ProviderCommand = z.infer; export type ProviderRuntimeSettings = z.infer; +export type ProviderPaseoToolsPolicy = z.infer; export type ProviderProfileModel = z.infer; export type ProviderOverride = z.infer; export type ProviderOverrides = z.infer; diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 33858623fd..2c2faa3b92 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -2785,10 +2785,12 @@ test("reloadAgentSession preserves the live session when its replacement cannot } } + let paseoToolPolicy = { disabledTools: ["list_agents"] }; const manager = new AgentManager({ clients: { codex: new UnsupportedReloadClient() }, registry: new AgentStorage(join(workdir, "agents"), logger), logger, + resolvePaseoToolPolicy: () => paseoToolPolicy, }); try { @@ -2797,6 +2799,7 @@ test("reloadAgentSession preserves the live session when its replacement cannot "00000000-0000-4000-8000-000000000109", { workspaceId: undefined }, ); + paseoToolPolicy = { disabledTools: ["create_agent"] }; await expect( manager.reloadAgentSession(created.id, { @@ -2810,6 +2813,9 @@ test("reloadAgentSession preserves the live session when its replacement cannot expect(original.closed).toBe(false); expect(manager.getAgent(created.id)?.session).toBe(original); expect(manager.getAgent(created.id)?.lifecycle).toBe("idle"); + expect(manager.getPaseoToolPolicy(created.id)).toEqual({ + disabledTools: ["list_agents"], + }); } finally { rmSync(workdir, { recursive: true, force: true }); } @@ -2942,6 +2948,168 @@ test("createAgent allows best-effort internal MCP when the provider session repo rmSync(workdir, { recursive: true, force: true }); }); +test("uses each provider's current policy for new sessions and snapshots it by agent", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const policies = new Map([ + ["codex", { disabledTools: ["list_agents"] }], + ["claude", { enabled: false }], + ]); + + class CaptureClient extends TestAgentClient { + override readonly capabilities = { + ...TEST_CAPABILITIES, + supportsMcpServers: true, + supportsNativePaseoTools: true, + }; + readonly launchContexts: AgentLaunchContext[] = []; + readonly configs: AgentSessionConfig[] = []; + + override async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): Promise { + this.configs.push(config); + if (launchContext) this.launchContexts.push(launchContext); + return new TestAgentSession(config); + } + } + + const codex = new CaptureClient("codex"); + const claude = new CaptureClient("claude"); + const policyInputs: Array<{ callerAgentId?: string; paseoToolPolicy?: unknown }> = []; + const paseoTools: PaseoToolCatalog = { + tools: new Map(), + getTool: () => undefined, + executeTool: async () => { + throw new Error("No tools registered in test catalog"); + }, + }; + const manager = new AgentManager({ + clients: { codex, claude }, + registry: storage, + logger, + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + resolvePaseoToolPolicy: (provider) => policies.get(provider), + paseoToolCatalogFactory: async (context) => { + policyInputs.push(context); + return paseoTools; + }, + }); + + const codexAgent = await manager.createAgent( + { provider: "codex", cwd: workdir }, + "00000000-0000-4000-8000-000000000107", + { workspaceId: undefined }, + ); + const claudeAgent = await manager.createAgent( + { provider: "claude", cwd: workdir }, + "00000000-0000-4000-8000-000000000108", + { workspaceId: undefined }, + ); + + expect(policyInputs).toEqual([ + { callerAgentId: codexAgent.id, paseoToolPolicy: { disabledTools: ["list_agents"] } }, + ]); + expect(codex.launchContexts[0]?.paseoTools).toBe(paseoTools); + expect(claude.launchContexts[0]?.paseoTools).toBeUndefined(); + expect(codex.configs[0]?.mcpServers?.paseo).toBeUndefined(); + expect(claude.configs[0]?.mcpServers).toBeUndefined(); + expect(manager.getPaseoToolPolicy(codexAgent.id)).toEqual({ + disabledTools: ["list_agents"], + }); + expect(manager.getPaseoToolPolicy(claudeAgent.id)).toEqual({ enabled: false }); + + policies.set("codex", { disabledTools: ["create_agent"] }); + const nextCodexAgent = await manager.createAgent( + { provider: "codex", cwd: workdir }, + "00000000-0000-4000-8000-000000000111", + { workspaceId: undefined }, + ); + + expect(manager.getPaseoToolPolicy(codexAgent.id)).toEqual({ + disabledTools: ["list_agents"], + }); + expect(manager.getPaseoToolPolicy(nextCodexAgent.id)).toEqual({ + disabledTools: ["create_agent"], + }); + expect(policyInputs).toEqual([ + { callerAgentId: codexAgent.id, paseoToolPolicy: { disabledTools: ["list_agents"] } }, + { + callerAgentId: nextCodexAgent.id, + paseoToolPolicy: { disabledTools: ["create_agent"] }, + }, + ]); + + await manager.archiveAgent(claudeAgent.id); + expect(manager.getPaseoToolPolicy(claudeAgent.id)).toBeUndefined(); + + rmSync(workdir, { recursive: true, force: true }); +}); + +test("keeps the global Paseo-tools gate outside provider policy and MCP injection", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + + class McpClient extends TestAgentClient { + override readonly capabilities = { + ...TEST_CAPABILITIES, + supportsMcpServers: true, + }; + lastConfig: AgentSessionConfig | null = null; + + override async createSession(config: AgentSessionConfig): Promise { + this.lastConfig = config; + return new TestAgentSession(config); + } + } + + const enabledClient = new McpClient(); + const enabledManager = new AgentManager({ + clients: { codex: enabledClient }, + registry: storage, + logger, + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + resolvePaseoToolPolicy: () => ({ disabledTools: ["list_agents"] }), + }); + const enabledAgent = await enabledManager.createAgent( + { provider: "codex", cwd: workdir }, + "00000000-0000-4000-8000-000000000109", + { workspaceId: undefined }, + ); + + expect(enabledClient.lastConfig?.mcpServers?.paseo).toEqual({ + type: "http", + url: `http://127.0.0.1:6767/mcp/agents?callerAgentId=${enabledAgent.id}`, + }); + + const disabledClient = new McpClient(); + let catalogFactoryCalls = 0; + const disabledManager = new AgentManager({ + clients: { codex: disabledClient }, + registry: storage, + logger, + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + paseoToolsEnabled: false, + resolvePaseoToolPolicy: () => ({ enabled: true }), + paseoToolCatalogFactory: () => { + catalogFactoryCalls += 1; + return paseoTools; + }, + }); + const disabledAgent = await disabledManager.createAgent( + { provider: "codex", cwd: workdir }, + "00000000-0000-4000-8000-000000000110", + { workspaceId: undefined }, + ); + + expect(disabledClient.lastConfig?.mcpServers).toBeUndefined(); + expect(catalogFactoryCalls).toBe(0); + expect(disabledManager.getPaseoToolPolicy(disabledAgent.id)).toEqual({ enabled: false }); + + rmSync(workdir, { recursive: true, force: true }); +}); + test("resumeAgentFromPersistence replaces stored internal paseo MCP with current runtime URL", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 2874d546e6..2a1b2d4e59 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -14,6 +14,7 @@ import { } from "@getpaseo/protocol/agent-labels"; import type { Logger } from "pino"; import type { ProviderOptions, ToolPolicy } from "@getpaseo/protocol/agent-types"; +import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; import { z } from "zod"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; @@ -76,6 +77,7 @@ import { isSystemInjectedEnvelope } from "./agent-prompt.js"; import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; import { resolveCreateAgentTitles } from "./create-agent-title.js"; import type { PaseoToolCatalogFactory } from "./tools/types.js"; +import { isPaseoToolPolicyEnabled } from "./paseo-tool-policy.js"; import { ProviderSubagentStore, type ProviderSubagentDescriptor, @@ -134,6 +136,7 @@ export type AgentRunCancellationResult = interface PreparedSessionConfig { storedConfig: AgentSessionConfig; launchConfig: AgentSessionConfig; + paseoToolPolicy: ProviderPaseoToolsPolicy | undefined; } interface NormalizeConfigOptions { @@ -295,6 +298,7 @@ export interface AgentManagerOptions { mcpAuthToken?: string; paseoToolsEnabled?: boolean; paseoToolCatalogFactory?: PaseoToolCatalogFactory; + resolvePaseoToolPolicy?: (provider: AgentProvider) => ProviderPaseoToolsPolicy | undefined; appendSystemPrompt?: string; agentStreamCoalesceWindowMs?: number; rescueTimeouts?: AgentManagerRescueTimeouts; @@ -706,6 +710,10 @@ export class AgentManager { private readonly mcpAuthToken: string | null; private paseoToolsEnabled = true; private paseoToolCatalogFactory: PaseoToolCatalogFactory | null = null; + private readonly paseoToolPolicies = new Map(); + private readonly resolvePaseoToolPolicy: ( + provider: AgentProvider, + ) => ProviderPaseoToolsPolicy | undefined; private appendSystemPrompt: string; private onAgentAttention?: AgentAttentionCallback; private onAgentArchived?: AgentArchivedCallback; @@ -724,6 +732,7 @@ export class AgentManager { this.mcpBaseUrl = options?.mcpBaseUrl ?? null; this.mcpAuthToken = options?.mcpAuthToken ?? null; this.configurePaseoTools(options); + this.resolvePaseoToolPolicy = options.resolvePaseoToolPolicy ?? (() => undefined); this.appendSystemPrompt = options.appendSystemPrompt ?? ""; this.logger = options.logger.child({ module: "agent", component: "agent-manager" }); this.rescueTimeouts = { @@ -805,6 +814,10 @@ export class AgentManager { this.paseoToolCatalogFactory = factory; } + getPaseoToolPolicy(agentId: string): ProviderPaseoToolsPolicy | undefined { + return this.paseoToolPolicies.get(agentId); + } + /** * Capability token the daemon's own MCP clients must present to the Agent MCP * endpoint when a daemon password is configured. Read by the per-client @@ -1177,7 +1190,7 @@ export class AgentManager { this.assertAcceptingAgentRegistrations(); const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent"); await this.deleteAgentState(resolvedAgentId); - const { storedConfig, launchConfig } = await this.prepareSessionConfig( + const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( config, resolvedAgentId, options?.env, @@ -1186,10 +1199,12 @@ export class AgentManager { const client = await this.requireAvailableClient({ provider: storedConfig.provider, }); + this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy); const launchContext = await this.buildLaunchContext( resolvedAgentId, client, storedConfig.cwd, + paseoToolPolicy, options?.env, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); @@ -1259,7 +1274,7 @@ export class AgentManager { ...overrides, provider: handle.provider, } as AgentSessionConfig; - const { storedConfig, launchConfig } = await this.prepareSessionConfig( + const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( mergedConfig, resolvedAgentId, ); @@ -1271,7 +1286,13 @@ export class AgentManager { `Provider '${handle.provider}' is not available. Please ensure the CLI is installed.`, ); } - const launchContext = await this.buildLaunchContext(resolvedAgentId, client, storedConfig.cwd); + this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy); + const launchContext = await this.buildLaunchContext( + resolvedAgentId, + client, + storedConfig.cwd, + paseoToolPolicy, + ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const session = await client.resumeSession( handle, @@ -1312,14 +1333,20 @@ export class AgentManager { throw new Error(`Provider '${input.provider}' does not support importing sessions`); } - const { storedConfig, launchConfig } = await this.prepareSessionConfig( + const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( { provider: input.provider, cwd: input.cwd, }, resolvedAgentId, ); - const launchContext = await this.buildLaunchContext(resolvedAgentId, client, storedConfig.cwd); + this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy); + const launchContext = await this.buildLaunchContext( + resolvedAgentId, + client, + storedConfig.cwd, + paseoToolPolicy, + ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const imported = await client.importSession( { @@ -1399,21 +1426,35 @@ export class AgentManager { ...overrides, provider, } as AgentSessionConfig; - const { storedConfig, launchConfig } = await this.prepareSessionConfig(refreshConfig, agentId); - const launchContext = await this.buildLaunchContext(agentId, client, storedConfig.cwd); + const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( + refreshConfig, + agentId, + ); + const hadPreviousPaseoToolPolicy = this.paseoToolPolicies.has(agentId); + const previousPaseoToolPolicy = this.paseoToolPolicies.get(agentId); + const launchContext = await this.buildLaunchContext( + agentId, + client, + storedConfig.cwd, + paseoToolPolicy, + ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); + this.paseoToolPolicies.set(agentId, paseoToolPolicy); - const session = handle - ? await client.resumeSession(handle, providerLaunchConfig, launchContext) - : await client.createSession(providerLaunchConfig, launchContext); - await this.requireExternalMcpSupport(session, storedConfig); - + let session: AgentSession | undefined; let handedToRegistration = false; + let replacedExisting = false; try { + session = handle + ? await client.resumeSession(handle, providerLaunchConfig, launchContext) + : await client.createSession(providerLaunchConfig, launchContext); + await this.requireExternalMcpSupport(session, storedConfig); + this.assertAcceptingAgentRegistrations(); this.cancelRunningProviderSubagents(agentId); const closedExisting = this.prepareAgentForClosure(existing, "agent reloaded"); + replacedExisting = true; try { await this.persistSnapshot(closedExisting); } finally { @@ -1444,7 +1485,14 @@ export class AgentManager { attention: preservedAttention, }); } finally { - if (!handedToRegistration) { + if (!replacedExisting) { + if (hadPreviousPaseoToolPolicy) { + this.paseoToolPolicies.set(agentId, previousPaseoToolPolicy); + } else { + this.paseoToolPolicies.delete(agentId); + } + } + if (session && !handedToRegistration) { await this.closeUnregisteredSession(session); } } @@ -3437,6 +3485,7 @@ export class AgentManager { private discardRetainedAgentState(agentId: string): void { this.timelineStore.delete(agentId); + this.paseoToolPolicies.delete(agentId); for (const event of this.providerSubagents.deleteParent(agentId)) { this.dispatch({ type: "provider_subagent", event }); } @@ -4836,15 +4885,21 @@ export class AgentManager { env?: Record, ): Promise { const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { env }); + const paseoToolPolicy = this.paseoToolsEnabled + ? this.resolvePaseoToolPolicy(storedConfig.provider) + : { enabled: false }; const launchConfig = this.applyDaemonAppendSystemPrompt( withRuntimePaseoMcpServer({ config: storedConfig, agentId, - mcpBaseUrl: this.mcpBaseUrl, + mcpBaseUrl: + this.paseoToolsEnabled && isPaseoToolPolicyEnabled(paseoToolPolicy) + ? this.mcpBaseUrl + : null, mcpAuthToken: this.mcpAuthToken, }), ); - return { storedConfig, launchConfig }; + return { storedConfig, launchConfig, paseoToolPolicy }; } private applyDaemonAppendSystemPrompt(config: AgentSessionConfig): AgentSessionConfig { @@ -4864,6 +4919,7 @@ export class AgentManager { agentId: string, client: AgentClient, cwd: string, + paseoToolPolicy: ProviderPaseoToolsPolicy | undefined, env?: Record, ): Promise { const context: AgentLaunchContext = { @@ -4876,10 +4932,14 @@ export class AgentManager { }; if ( this.paseoToolsEnabled && + isPaseoToolPolicyEnabled(paseoToolPolicy) && client.capabilities.supportsNativePaseoTools && this.paseoToolCatalogFactory ) { - context.paseoTools = await this.paseoToolCatalogFactory({ callerAgentId: agentId }); + context.paseoTools = await this.paseoToolCatalogFactory({ + callerAgentId: agentId, + paseoToolPolicy, + }); } return context; } diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 349eb15c53..c5b97ce978 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -971,8 +971,85 @@ describe("browser MCP tools", () => { logger, }); + const client = await connectInMemoryMcpClient(server); + try { + const listedTools = await client.listTools(); + const toolNames = listedTools.tools.map((tool) => tool.name); + + expect(toolNames).not.toContain("browser_list_tabs"); + expect(toolNames).not.toContain("browser_snapshot"); + expect(toolNames).toEqual(expect.arrayContaining(["create_agent", "list_agents"])); + } finally { + await client.close(); + await server.close(); + } + }); + + it("applies provider policy after the browser-tools host gate", async () => { + const agentManager = new BoundaryAgentManagerFake(); + const agentStorage = new BoundaryAgentStorageFake(); + const broker = new FakeBrowserToolsBroker({ + requestId: "req-browser-policy", + ok: true, + result: { command: "list_tabs", tabs: [] }, + }); + const server = await createAgentMcpServer({ + agentManager: agentManager as AgentManager, + agentStorage: agentStorage as AgentStorage, + providerSnapshotManager: + new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager, + browserToolsEnabled: true, + browserToolsBroker: broker as BrowserToolsBroker, + callerAgentId: "agent-1", + paseoToolPolicy: { disabledTools: ["browser_list_tabs"] }, + logger, + }); + expect(lookupTool(server, "browser_list_tabs")).toBeUndefined(); - expect(lookupTool(server, "browser_snapshot")).toBeUndefined(); + expect(lookupTool(server, "browser_snapshot")).toBeDefined(); + }); + + it("filters policy-disabled tools from MCP listing and calls", async () => { + const agentManager = new BoundaryAgentManagerFake(); + const agentStorage = new BoundaryAgentStorageFake(); + const broker = new FakeBrowserToolsBroker({ + requestId: "req-browser-policy-call", + ok: true, + result: { command: "list_tabs", tabs: [] }, + }); + const server = await createAgentMcpServer({ + agentManager: agentManager as AgentManager, + agentStorage: agentStorage as AgentStorage, + providerSnapshotManager: + new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager, + browserToolsEnabled: true, + browserToolsBroker: broker as BrowserToolsBroker, + callerAgentId: "agent-1", + paseoToolPolicy: { disabledTools: ["list_agents", "browser_list_tabs"] }, + logger, + }); + const client = await connectInMemoryMcpClient(server); + + try { + const listedTools = await client.listTools(); + const toolNames = listedTools.tools.map((tool) => tool.name); + + expect(toolNames).not.toContain("list_agents"); + expect(toolNames).not.toContain("browser_list_tabs"); + expect(toolNames).toEqual(expect.arrayContaining(["create_agent", "browser_snapshot"])); + await expect(client.callTool({ name: "list_agents", arguments: {} })).resolves.toEqual({ + content: [{ type: "text", text: "MCP error -32602: Tool list_agents not found" }], + isError: true, + }); + await expect(client.callTool({ name: "browser_list_tabs", arguments: {} })).resolves.toEqual({ + content: [{ type: "text", text: "MCP error -32602: Tool browser_list_tabs not found" }], + isError: true, + }); + expect(broker.calls).toEqual([]); + } finally { + await client.close(); + await server.close(); + } }); it("wires browser tools through the browser tools broker", async () => { diff --git a/packages/server/src/server/agent/paseo-tool-policy.test.ts b/packages/server/src/server/agent/paseo-tool-policy.test.ts new file mode 100644 index 0000000000..d5bc6c648b --- /dev/null +++ b/packages/server/src/server/agent/paseo-tool-policy.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "vitest"; +import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; + +import { isPaseoToolEnabled, resolvePaseoToolPolicy } from "./paseo-tool-policy.js"; + +describe("Paseo tool policy", () => { + test("defaults to all Paseo tools and resolves only the exact provider ID", () => { + const customPolicy = { + enabled: true, + disabledTools: ["list_agents"], + } satisfies ProviderPaseoToolsPolicy; + + expect( + resolvePaseoToolPolicy("custom-claude", { + claude: { paseoTools: { enabled: false } }, + "custom-claude": { paseoTools: customPolicy }, + }), + ).toBe(customPolicy); + expect(resolvePaseoToolPolicy("other-custom", { claude: { paseoTools: customPolicy } })).toBe( + undefined, + ); + expect(isPaseoToolEnabled(undefined, "list_agents")).toBe(true); + }); + + test("applies the provider gate and sparse disabled tools without filtering speak", () => { + expect(isPaseoToolEnabled({ enabled: false }, "list_agents")).toBe(false); + expect(isPaseoToolEnabled({ enabled: false }, "speak")).toBe(true); + expect( + isPaseoToolEnabled({ enabled: true, disabledTools: ["list_agents"] }, "list_agents"), + ).toBe(false); + expect( + isPaseoToolEnabled({ enabled: true, disabledTools: ["list_agents"] }, "create_agent"), + ).toBe(true); + }); +}); diff --git a/packages/server/src/server/agent/paseo-tool-policy.ts b/packages/server/src/server/agent/paseo-tool-policy.ts new file mode 100644 index 0000000000..1089fb76e8 --- /dev/null +++ b/packages/server/src/server/agent/paseo-tool-policy.ts @@ -0,0 +1,29 @@ +import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; + +interface ProviderPaseoToolSettings { + paseoTools?: ProviderPaseoToolsPolicy; +} + +export function resolvePaseoToolPolicy( + providerId: string, + providerSettings: Readonly> | undefined, +): ProviderPaseoToolsPolicy | undefined { + return providerSettings?.[providerId]?.paseoTools; +} + +export function isPaseoToolEnabled( + policy: ProviderPaseoToolsPolicy | undefined, + toolName: string, +): boolean { + if (toolName === "speak") { + return true; + } + if (!isPaseoToolPolicyEnabled(policy)) { + return false; + } + return !policy?.disabledTools?.includes(toolName); +} + +export function isPaseoToolPolicyEnabled(policy: ProviderPaseoToolsPolicy | undefined): boolean { + return policy?.enabled !== false; +} diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index fcc314ac11..2ddadaa1a5 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -92,6 +92,8 @@ import type { PaseoToolExecutionContext, PaseoToolResult, } from "./types.js"; +import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; +import { isPaseoToolEnabled } from "../paseo-tool-policy.js"; export interface PaseoToolHostDependencies { agentManager: AgentManager; @@ -128,6 +130,7 @@ export interface PaseoToolHostDependencies { ) => Promise; browserToolsEnabled?: boolean; browserToolsBroker?: BrowserToolsBroker | null; + paseoToolPolicy?: ProviderPaseoToolsPolicy; paseoHome?: string; worktreesRoot?: string; /** @@ -576,6 +579,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool handlers are schema-validated at registration boundaries. handler: (input: any, context: PaseoToolExecutionContext) => Promise, ) => { + if (!isPaseoToolEnabled(options.paseoToolPolicy, name)) { + return; + } tools.set(name, { name, title: config.title, diff --git a/packages/server/src/server/agent/tools/types.ts b/packages/server/src/server/agent/tools/types.ts index 1eb54dabfd..66a4a23da2 100644 --- a/packages/server/src/server/agent/tools/types.ts +++ b/packages/server/src/server/agent/tools/types.ts @@ -1,4 +1,5 @@ import type { z } from "zod"; +import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; export interface PaseoToolExecutionContext { signal?: AbortSignal; @@ -36,6 +37,7 @@ export interface PaseoToolCatalog { export interface PaseoToolRuntimeContext { callerAgentId?: string; + paseoToolPolicy?: ProviderPaseoToolsPolicy; enableVoiceTools?: boolean; voiceOnly?: boolean; } diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 627a93d4e3..18def1cbc3 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -150,6 +150,7 @@ import { ScheduleService } from "./schedule/service.js"; import { DaemonConfigStore, type MutableDaemonConfig } from "./daemon-config-store.js"; import { createOrchestrationSkills } from "./orchestration-skills/index.js"; import { resolveConfigFromPersisted, type CliConfigOverrides } from "./config.js"; +import { resolvePaseoToolPolicy } from "./agent/paseo-tool-policy.js"; import { BrowserToolsBroker } from "./browser-tools/broker.js"; import { DaemonConfigBrowserToolsPolicy } from "./browser-tools/policy.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; @@ -917,6 +918,8 @@ export async function createPaseoDaemon( workspaceGitService.onWorkspaceStateMayHaveChanged(cwd); }, mcpAuthToken: agentMcpAuthToken, + resolvePaseoToolPolicy: (provider) => + resolvePaseoToolPolicy(provider, daemonConfigStore.get().providers), logger, }); @@ -1391,6 +1394,9 @@ export async function createPaseoDaemon( createPaseoWorktree: createAgentCommandDependencies.createPaseoWorktree, browserToolsEnabled: browserToolsPolicy.isEnabled(), browserToolsBroker, + paseoToolPolicy: + runtime.paseoToolPolicy ?? + (runtime.callerAgentId ? agentManager.getPaseoToolPolicy(runtime.callerAgentId) : undefined), paseoHome: config.paseoHome, worktreesRoot: config.worktreesRoot, callerAgentId: runtime.callerAgentId, @@ -1416,7 +1422,12 @@ export async function createPaseoDaemon( const createAgentMcpSession = async (callerAgentId?: string) => { const agentMcpServer = await createAgentMcpServer( - createAgentToolHostDependencies({ callerAgentId }), + createAgentToolHostDependencies({ + callerAgentId, + paseoToolPolicy: callerAgentId + ? agentManager.getPaseoToolPolicy(callerAgentId) + : undefined, + }), ); // Stateless mode: each HTTP request builds a fresh server + transport that is diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index a8593a2c02..3629a0fd18 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -400,6 +400,48 @@ describe("DaemonConfigStore", () => { }); }); + test("patch persists provider Paseo-tool policy without changing availability", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + writeFileSync( + path.join(paseoHome, "config.json"), + JSON.stringify({ agents: { providers: { claude: { enabled: false } } } }), + ); + const store = new DaemonConfigStore(paseoHome, { + mcp: { injectIntoAgents: true }, + browserTools: { enabled: false }, + providers: { claude: { enabled: false } }, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + }); + + store.patch({ + providers: { + claude: { + paseoTools: { enabled: true, disabledTools: ["list_agents"] }, + }, + }, + }); + store.patch({ + providers: { + claude: { + paseoTools: { disabledTools: ["create_agent"] }, + }, + }, + }); + + expect(store.get().providers.claude).toEqual({ + enabled: false, + paseoTools: { enabled: true, disabledTools: ["create_agent"] }, + }); + expect(loadPersistedConfig(paseoHome).agents?.providers?.claude).toEqual({ + enabled: false, + paseoTools: { enabled: true, disabledTools: ["create_agent"] }, + }); + }); + test("patch removes provider entries from config.json", () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 922d0dac1c..3e285bded0 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -289,9 +289,19 @@ export function applyMutableProviderConfigToOverrides( const nextOverrides: Record = { ...baseOverrides }; for (const [providerId, providerConfig] of Object.entries(mutableProviders ?? {})) { + const previousOverride = nextOverrides[providerId]; + const parsedOverride = ProviderOverrideSchema.strip().parse(providerConfig); nextOverrides[providerId] = { - ...nextOverrides[providerId], - ...ProviderOverrideSchema.strip().parse(providerConfig), + ...previousOverride, + ...parsedOverride, + ...(parsedOverride.paseoTools + ? { + paseoTools: { + ...previousOverride?.paseoTools, + ...parsedOverride.paseoTools, + }, + } + : {}), }; } diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index c8dc40a75c..c1cfbb4b6c 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -326,6 +326,21 @@ "type": "string" } }, + "paseoTools": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "disabledTools": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, "enabled": { "type": "boolean" }, diff --git a/public-docs/custom-providers.md b/public-docs/custom-providers.md index ae66bca850..1c89d8d2a6 100644 --- a/public-docs/custom-providers.md +++ b/public-docs/custom-providers.md @@ -192,4 +192,4 @@ Any agent that speaks [ACP](https://agentclientprotocol.com) over stdio can be a ## Full reference -For the complete field reference (`extends`, `label`, `command`, `env`, `models`, `additionalModels`, `disallowedTools`, `enabled`, `order`), model and thinking-option schemas, and deeper examples for each plan, see [docs/custom-providers.md](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md) on GitHub. +For the complete field reference (`extends`, `label`, `command`, `env`, `models`, `additionalModels`, `disallowedTools`, `paseoTools`, `enabled`, `order`), model and thinking-option schemas, and deeper examples for each plan, see [docs/custom-providers.md](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md) on GitHub. See [Limit Paseo tools by provider](/docs/mcp#limit-paseo-tools-by-provider) for `paseoTools` configuration. diff --git a/public-docs/mcp.md b/public-docs/mcp.md index 309f18e260..ff011f2dfc 100644 --- a/public-docs/mcp.md +++ b/public-docs/mcp.md @@ -16,6 +16,59 @@ Depending on the provider, Paseo delivers the catalog through its native tool in The MCP server itself is controlled by `daemon.mcp.enabled`. Existing agents may need a reload. +## Limit Paseo tools by provider + +Use provider policies when different agent profiles should receive different Paseo tools. Enable +tool injection globally, then add `paseoTools` to the exact provider IDs you launch: + +```json +{ + "$schema": "https://paseo.sh/schemas/paseo.config.v1.json", + "version": 1, + "daemon": { + "mcp": { + "enabled": true, + "injectIntoAgents": true + } + }, + "agents": { + "providers": { + "codex-lead": { + "extends": "codex", + "label": "Codex Lead" + }, + "codex-worker": { + "extends": "codex", + "label": "Codex Worker", + "paseoTools": { + "disabledTools": ["create_agent", "send_agent_prompt", "kill_agent"] + } + }, + "codex-isolated": { + "extends": "codex", + "label": "Codex Isolated", + "paseoTools": { + "enabled": false + } + } + } + } +} +``` + +Run `paseo reload` after editing `~/.paseo/config.json`, then start a new agent or reload an +existing one. A running session keeps the catalog it received at launch. + +Omitting `paseoTools` enables the complete catalog. Set `enabled` to `false` to remove the catalog, +or list exact tool IDs in `disabledTools` to remove selected tools. Custom profiles do not inherit +this policy from `extends`; configure each custom provider ID separately. + +Browser tools still require browser tools to be enabled and a connected browser host. The +voice-only `speak` tool is separate from this policy. + +This setting limits the catalog presented to an agent. It is not a security boundary for an agent +that can access the host through a shell. + ## Mental model Workspaces decide where work happens; agent parentage decides who owns the work. From bb676ef086ac5d2a69486c33e5a315c2bbbd2610 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 3 Sep 2026 20:27:19 +0200 Subject: [PATCH 003/566] Smooth mobile keyboard transitions (#4275) * perf(app): smooth mobile keyboard transitions Animating dock padding forced Fabric and Yoga to relayout the chat tree on every keyboard frame, producing visible stepping and React list churn. Keep the surface full-height and move it as one translated unit, with scroll range and portal measurements updated only after keyboard motion settles.\n\niOS keeps its native keyboard animation driver behind the same component boundary because its Reanimated signal reports the endpoint at animation start. * fix(app): isolate native keyboard implementation --- docs/floating-panels.md | 46 ++++++--- .../app/src/agent-stream/strategy-native.tsx | 57 ++++++----- .../src/components/adaptive-modal-sheet.tsx | 2 +- .../src/components/archived-agent-callout.tsx | 13 +-- .../src/components/keyboard-dock.native.tsx | 12 +-- .../keyboard-translate-view.android.tsx | 27 ++++++ .../keyboard-translate-view.ios.tsx | 33 +++++++ .../components/keyboard-translate-view.tsx | 15 +++ .../components/ui/autocomplete-popover.tsx | 95 +++++++++++-------- .../app/src/composer/draft/workspace-tab.tsx | 19 +--- packages/app/src/composer/index.tsx | 19 ++-- .../app/src/hooks/keyboard-shift-context.ts | 11 +++ .../app/src/hooks/keyboard-shift-policy.ts | 20 ++++ .../hooks/use-keyboard-shift-style.test.ts | 24 +++++ .../app/src/hooks/use-keyboard-shift-style.ts | 65 +++++++++---- .../hooks/use-keyboard-visibility.native.ts | 22 ++++- .../app/src/hooks/use-keyboard-visibility.ts | 2 +- packages/app/src/panels/agent-panel.tsx | 4 +- .../app/src/screens/new-workspace-screen.tsx | 16 +--- 19 files changed, 343 insertions(+), 159 deletions(-) create mode 100644 packages/app/src/components/keyboard-translate-view.android.tsx create mode 100644 packages/app/src/components/keyboard-translate-view.ios.tsx create mode 100644 packages/app/src/components/keyboard-translate-view.tsx diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 9ffe03fddf..69e8f2b105 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -134,19 +134,39 @@ The fix for transforms is Gotcha 3. The fix for context is Gotcha 7. ## Gotcha 3 — Keyboard layout and portal anchors -Move the chat surface with worklet-driven bottom padding from -`KeyboardShiftProvider`. Padding keeps the stream, composer, visual position, -and native hit testing in the same layout. Do not translate the stream and -composer independently. +`KeyboardTranslateView` owns visual keyboard motion. Android uses the +controller's Reanimated signal. iOS uses its native-driver `Animated.event` +signal because the stock iOS Reanimated value changes at move start, while +writing a replacement Reanimated value every frame interrupts UIKit's hide +animation. Keep this platform choice inside `KeyboardTranslateView`. -Do not use the controller's raw keyboard progress for the padding. It can retain -a nonzero value after the keyboard closes. The shared provider normalizes that -state and reconciles native animation-end events. +`KeyboardDock` always keeps the chat surface at full height and wraps it in +`KeyboardTranslateView`. The panel root clips it at the header edge, so rows +slide under the header on open and out from under it on close. Do not swap the +dock to keyboard padding at rest. Changing its height creates either a blank +band or a paused transition when the inverted list updates its viewport. -`measureInWindow` already includes the dock's current padding layout. When a -portal opens, snapshot the current shift and apply only the subsequent shift -delta to its animated `bottom`. Adding the full shift moves the portal twice and -can place it over the composer controls. +Do not animate a layout prop through the keyboard transition. On Android +Fabric, each animated layout update clones the shadow tree and runs Yoga over +the dock subtree. This measured about 10 ms per frame on the emulator and the +mounted layouts arrived in bursts every 2–4 frames. A transform does not dirty +layout. Preserve the translated dock's history scroll range with a far-end +content inset on the inverted stream list. Update that inset only when keyboard +motion settles; never drive it per frame. + +Move the stream and composer together through `KeyboardDock`. Do not translate +them independently. + +`KeyboardShiftProvider` owns the normalized shift used for settled insets and +portal geometry, plus the `isMoving` worklet value. It reconciles native +animation-end events because the controller can retain a nonzero value after +the keyboard closes. + +Measure portal anchors while the dock is at rest. If a popover opens during +motion, re-measure when `isMoving` settles. `measureInWindow` includes the +dock's current layout and transform. Snapshot the shift at measurement time and +apply only the subsequent shift delta to the animated `bottom`. Adding the full +shift moves the portal twice and can place it over the composer controls. The provider also reconciles iOS from the controller's native `onEnd` event. The controller's stock iOS shared values update at move start and during an @@ -155,10 +175,6 @@ leave the last height/progress pair stuck in either the open or closed state. Keep that terminal reconciliation on the UI thread; a later focus or blur must not be required to repair the offset. -Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only -to refresh the snapshot if the keyboard was mid-transition when the popover -opened. - ## Gotcha 4 — Host-relative positioning before platform offsets The generic anchored-overlay rule is: diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index b51954a6d0..8acdd92f4f 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -9,7 +9,6 @@ import { } from "react"; import { FlatList, - Keyboard, Platform, View, type LayoutChangeEvent, @@ -23,6 +22,8 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; import type { StreamItem } from "@/types/stream"; import type { Theme } from "@/styles/theme"; import { useStableEvent } from "@/hooks/use-stable-event"; +import { useSettledKeyboardShift } from "@/hooks/keyboard-shift-context"; +import { resolveStreamKeyboardInset } from "@/hooks/keyboard-shift-policy"; import { useRevisedHistoryRows } from "./history-row-revision"; import { useBottomAnchorController } from "./bottom-anchor-controller"; import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss"; @@ -104,6 +105,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const scrollOffsetYRef = useRef(0); const isUserScrollActiveRef = useRef(false); const scrollKeyboardDismiss = useScrollKeyboardDismiss(); + const settledKeyboardShift = useSettledKeyboardShift(); const userScrollEndFrameIdRef = useRef(null); const programmaticScrollEventBudgetRef = useRef(0); const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false); @@ -273,6 +275,34 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom" ? undefined : DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION; + const streamKeyboardInset = useMemo( + () => + resolveStreamKeyboardInset({ + platform: Platform.OS === "ios" ? "ios" : "android", + settledShift: settledKeyboardShift, + }), + [settledKeyboardShift], + ); + const listContentContainerStyle = useMemo( + () => [ + baseListContentContainerStyle, + { paddingBottom: streamKeyboardInset.contentContainerPaddingBottom }, + ], + [baseListContentContainerStyle, streamKeyboardInset.contentContainerPaddingBottom], + ); + const listInsetProps = useMemo( + () => + streamKeyboardInset.contentInset + ? { + automaticallyAdjustContentInsets: false, + automaticallyAdjustsScrollIndicatorInsets: false, + contentInsetAdjustmentBehavior: "never" as const, + contentInset: streamKeyboardInset.contentInset, + scrollIndicatorInsets: streamKeyboardInset.contentInset, + } + : {}, + [streamKeyboardInset.contentInset], + ); useEffect(() => { streamViewportMetricsRef.current = { @@ -305,27 +335,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }; }, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]); - useEffect(() => { - const keyboardEvents = [ - "keyboardWillShow", - "keyboardWillHide", - "keyboardDidShow", - "keyboardDidHide", - "keyboardWillChangeFrame", - "keyboardDidChangeFrame", - ] as const; - const subscriptions = keyboardEvents.map((eventName) => - Keyboard.addListener(eventName, () => { - markNativeViewportSettling(); - }), - ); - return () => { - for (const subscription of subscriptions) { - subscription.remove(); - } - clearNativeViewportSettling(); - }; - }, [clearNativeViewportSettling, markNativeViewportSettling]); + useEffect(() => () => clearNativeViewportSettling(), [clearNativeViewportSettling]); useEffect(() => { bottomAnchorController.prepareForStickyContentChange(); @@ -560,6 +570,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat // data or the live header changes, preserving the row identities above. return ( snapPoints ?? ["65%", "90%"], [snapPoints]); const compactSafeAreaPadding = useMemo( () => diff --git a/packages/app/src/components/archived-agent-callout.tsx b/packages/app/src/components/archived-agent-callout.tsx index 8982f6e06a..4a93d12a31 100644 --- a/packages/app/src/components/archived-agent-callout.tsx +++ b/packages/app/src/components/archived-agent-callout.tsx @@ -2,11 +2,10 @@ import { useCallback, useMemo, useState } from "react"; import { View, Text } from "react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; -import Animated from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; +import { KeyboardTranslateView } from "@/components/keyboard-translate-view"; import { Button } from "@/components/ui/button"; import type { Theme } from "@/styles/theme"; import { toErrorMessage } from "@/utils/error-messages"; @@ -24,11 +23,9 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout const [isUnarchiving, setIsUnarchiving] = useState(false); const [unarchiveError, setUnarchiveError] = useState(null); - const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" }); - const containerStyle = useMemo( - () => [styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle], - [insets.bottom, keyboardAnimatedStyle], + () => [styles.container, { paddingBottom: insets.bottom }], + [insets.bottom], ); const handleUnarchive = useCallback(async () => { @@ -44,7 +41,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout }, [client, isConnected, isUnarchiving, agentId]); return ( - + @@ -67,7 +64,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout - + ); } diff --git a/packages/app/src/components/keyboard-dock.native.tsx b/packages/app/src/components/keyboard-dock.native.tsx index 4fa9ea4a7a..d26c6892dc 100644 --- a/packages/app/src/components/keyboard-dock.native.tsx +++ b/packages/app/src/components/keyboard-dock.native.tsx @@ -1,21 +1,15 @@ import type { ReactNode } from "react"; import type { ViewProps } from "react-native"; -import Animated, { useAnimatedStyle } from "react-native-reanimated"; -import { useKeyboardShift } from "@/hooks/keyboard-shift-context"; +import { KeyboardTranslateView } from "@/components/keyboard-translate-view"; interface KeyboardDockProps extends ViewProps { children: ReactNode; } export function KeyboardDock({ children, style, ...props }: KeyboardDockProps) { - const { shift } = useKeyboardShift(); - const keyboardPaddingStyle = useAnimatedStyle(() => ({ - paddingBottom: shift.value, - })); - return ( - + {children} - + ); } diff --git a/packages/app/src/components/keyboard-translate-view.android.tsx b/packages/app/src/components/keyboard-translate-view.android.tsx new file mode 100644 index 0000000000..550c15f8e4 --- /dev/null +++ b/packages/app/src/components/keyboard-translate-view.android.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from "react"; +import type { ViewProps } from "react-native"; +import Animated, { useAnimatedStyle } from "react-native-reanimated"; +import { useKeyboardShift } from "@/hooks/keyboard-shift-context"; + +interface KeyboardTranslateViewProps extends ViewProps { + children: ReactNode; + enabled?: boolean; +} + +export function KeyboardTranslateView({ + children, + enabled = true, + style, + ...props +}: KeyboardTranslateViewProps) { + const { shift } = useKeyboardShift(); + const keyboardStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: enabled ? -shift.value : 0 }], + })); + + return ( + + {children} + + ); +} diff --git a/packages/app/src/components/keyboard-translate-view.ios.tsx b/packages/app/src/components/keyboard-translate-view.ios.tsx new file mode 100644 index 0000000000..7a3ba7e992 --- /dev/null +++ b/packages/app/src/components/keyboard-translate-view.ios.tsx @@ -0,0 +1,33 @@ +import { useMemo, type ReactNode } from "react"; +import { Animated, type ViewProps } from "react-native"; +import { useKeyboardAnimation } from "react-native-keyboard-controller"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +interface KeyboardTranslateViewProps extends ViewProps { + children: ReactNode; + enabled?: boolean; +} + +export function KeyboardTranslateView({ + children, + enabled = true, + style, + ...props +}: KeyboardTranslateViewProps) { + const insets = useSafeAreaInsets(); + const { height, progress } = useKeyboardAnimation(); + const translateY = useMemo( + () => Animated.add(height, Animated.multiply(progress, insets.bottom)), + [height, insets.bottom, progress], + ); + const keyboardStyle = useMemo( + () => ({ transform: [{ translateY: enabled ? translateY : 0 }] }), + [enabled, translateY], + ); + + return ( + + {children} + + ); +} diff --git a/packages/app/src/components/keyboard-translate-view.tsx b/packages/app/src/components/keyboard-translate-view.tsx new file mode 100644 index 0000000000..d866fc943c --- /dev/null +++ b/packages/app/src/components/keyboard-translate-view.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; +import { View, type ViewProps } from "react-native"; + +interface KeyboardTranslateViewProps extends ViewProps { + children: ReactNode; + enabled?: boolean; +} + +export function KeyboardTranslateView({ + children, + enabled: _enabled, + ...props +}: KeyboardTranslateViewProps) { + return {children}; +} diff --git a/packages/app/src/components/ui/autocomplete-popover.tsx b/packages/app/src/components/ui/autocomplete-popover.tsx index df9bc52fd1..ad5ae92f51 100644 --- a/packages/app/src/components/ui/autocomplete-popover.tsx +++ b/packages/app/src/components/ui/autocomplete-popover.tsx @@ -1,7 +1,20 @@ -import { useEffect, useMemo, useState, type ReactElement, type RefObject } from "react"; -import { Keyboard, View, useWindowDimensions } from "react-native"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, + type RefObject, +} from "react"; +import { View, useWindowDimensions } from "react-native"; import { Portal } from "@gorhom/portal"; -import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated"; +import Animated, { + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, +} from "react-native-reanimated"; +import { scheduleOnRN } from "react-native-worklets"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet } from "react-native-unistyles"; import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete"; @@ -66,57 +79,57 @@ export function AutocompletePopover({ const windowDimensions = useWindowDimensions(); const safeAreaInsets = useSafeAreaInsets(); const portalHostName = useFloatingPanelPortalHostName(); - const { shift } = useKeyboardShift(); + const { shift, isMoving } = useKeyboardShift(); const measuredShift = useSharedValue(0); + const measurementGeneration = useRef(0); + const canMeasure = visible && (options.length === 0 || selectedIndex >= 0); + + const remeasure = useCallback(() => { + if (!canMeasure) return; + const anchorElement = anchorRef.current; + if (!anchorElement) return; + const generation = measurementGeneration.current; + void Promise.all([ + measureElement(anchorElement), + measureFloatingPanelPortalHost(portalHostName), + ]).then(([anchorRect, hostRect]) => { + if (generation !== measurementGeneration.current || !hostRect) return undefined; + setRelativeAnchorRect({ + x: anchorRect.x - hostRect.x, + y: anchorRect.y - hostRect.y, + width: anchorRect.width, + hostHeight: hostRect.height, + }); + measuredShift.value = shift.value; + return undefined; + }); + }, [anchorRef, canMeasure, measuredShift, portalHostName, shift]); useEffect(() => { - if (!visible || (options.length > 0 && selectedIndex < 0)) { + measurementGeneration.current += 1; + if (!canMeasure) { setRelativeAnchorRect(null); return; } - let cancelled = false; - const remeasure = () => { - const anchorElement = anchorRef.current; - if (!anchorElement) return; - void Promise.all([ - measureElement(anchorElement), - measureFloatingPanelPortalHost(portalHostName), - ]).then(([anchorRect, hostRect]) => { - if (cancelled || !hostRect) return undefined; - setRelativeAnchorRect({ - x: anchorRect.x - hostRect.x, - y: anchorRect.y - hostRect.y, - width: anchorRect.width, - hostHeight: hostRect.height, - }); - measuredShift.value = shift.value; - return undefined; - }); - }; - remeasure(); const raf = requestAnimationFrame(remeasure); - const subscriptions = (["keyboardDidShow", "keyboardDidHide"] as const).map((event) => - Keyboard.addListener(event, () => requestAnimationFrame(remeasure)), - ); return () => { - cancelled = true; + measurementGeneration.current += 1; cancelAnimationFrame(raf); - for (const sub of subscriptions) sub.remove(); }; - }, [ - visible, - options.length, - selectedIndex, - anchorRef, - portalHostName, - measuredShift, - shift, - windowDimensions.width, - windowDimensions.height, - ]); + }, [canMeasure, remeasure, windowDimensions.width, windowDimensions.height]); + + useAnimatedReaction( + () => isMoving.value, + (moving, wasMoving) => { + if (wasMoving && !moving) { + scheduleOnRN(remeasure); + } + }, + [isMoving, remeasure], + ); const baseStyle = useMemo(() => { if (!relativeAnchorRect) return null; diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 167a8886b8..e9047eb60a 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -1,10 +1,9 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native"; import { useTranslation } from "react-i18next"; -import ReanimatedAnimated from "react-native-reanimated"; import { StyleSheet } from "react-native-unistyles"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; +import { KeyboardTranslateView } from "@/components/keyboard-translate-view"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; import invariant from "tiny-invariant"; import { Composer } from "@/composer"; @@ -624,17 +623,9 @@ export function WorkspaceDraftAgentTab({ focusInputRef.current = focus; }, []); - const { style: composerKeyboardStyle } = useKeyboardShiftStyle({ - mode: "translate", - }); - const inputAreaWrapperStyle = useMemo( - () => [ - animatedStaticStyles.inputAreaWrapper, - { paddingBottom: insets.bottom }, - composerKeyboardStyle, - ], - [insets.bottom, composerKeyboardStyle], + () => [animatedStaticStyles.inputAreaWrapper, { paddingBottom: insets.bottom }], + [insets.bottom], ); const handleDropdownCloseFocus = useCallback(() => { @@ -678,7 +669,7 @@ export function WorkspaceDraftAgentTab({ )} - + {importPillPress ? ( @@ -711,7 +702,7 @@ export function WorkspaceDraftAgentTab({ agentControls={composerAgentControls} isCompactLayout={isCompactComposerLayout} /> - + ); } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 989382fe79..5a1d51f1fc 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -34,7 +34,6 @@ import { Paperclip, } from "lucide-react-native"; import * as Clipboard from "expo-clipboard"; -import Animated from "react-native-reanimated"; import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; import { AgentControls, @@ -42,6 +41,7 @@ import { type DraftAgentControlsProps, } from "@/composer/agent-controls"; import { ContextWindowMeter } from "@/components/context-window-meter"; +import { KeyboardTranslateView } from "@/components/keyboard-translate-view"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store"; import { useFilePicker } from "@/hooks/use-file-picker"; @@ -102,7 +102,6 @@ import { import { resolveAgentControlsMode } from "@/composer/agent-controls/mode"; import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; import { resolveActiveSendBehavior } from "./input/state"; -import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; @@ -1839,11 +1838,6 @@ function ComposerContentImpl({ focusMessageInputWithPlatformStrategy(messageInputRef); }, []); - const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ - mode: "translate", - enabled: !externalKeyboardShift, - }); - const isVoiceModeForAgent = resolveIsVoiceModeForAgent(voice, serverId, agentId); const handleToggleRealtimeVoice = useCallback(() => { @@ -2231,10 +2225,6 @@ function ComposerContentImpl({ [githubSearchItems, selectedAttachments, handleToggleGithubItem], ); - const composerContainerStyle = useMemo( - () => [animatedStaticStyles.container, keyboardAnimatedStyle], - [keyboardAnimatedStyle], - ); const inputAreaContainerStyle = useMemo( () => [styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked], [isComposerLocked], @@ -2319,7 +2309,10 @@ function ComposerContentImpl({ focusMessageInputForKeyboardAction={focusMessageInputForKeyboardAction} isMessageInputFocused={isMessageInputFocused} /> - + {/* Input area */} @@ -2413,7 +2406,7 @@ function ComposerContentImpl({ - + ); } diff --git a/packages/app/src/hooks/keyboard-shift-context.ts b/packages/app/src/hooks/keyboard-shift-context.ts index a6eeb53729..9d41f13224 100644 --- a/packages/app/src/hooks/keyboard-shift-context.ts +++ b/packages/app/src/hooks/keyboard-shift-context.ts @@ -3,10 +3,12 @@ import type { SharedValue } from "react-native-reanimated"; export interface KeyboardShiftContextValue { shift: SharedValue; + isMoving: SharedValue; bottomInset: SharedValue; } export const KeyboardShiftContext = createContext(null); +export const SettledKeyboardShiftContext = createContext(null); /** Read the app-wide keyboard inset without loading its native provider implementation. */ export function useKeyboardShift(): KeyboardShiftContextValue { @@ -16,3 +18,12 @@ export function useKeyboardShift(): KeyboardShiftContextValue { } return context; } + +/** Read the keyboard inset published after native keyboard motion has settled. */ +export function useSettledKeyboardShift(): number { + const context = useContext(SettledKeyboardShiftContext); + if (context === null) { + throw new Error("useSettledKeyboardShift must be used inside KeyboardShiftProvider"); + } + return context; +} diff --git a/packages/app/src/hooks/keyboard-shift-policy.ts b/packages/app/src/hooks/keyboard-shift-policy.ts index 68e013e1b1..19c021a26c 100644 --- a/packages/app/src/hooks/keyboard-shift-policy.ts +++ b/packages/app/src/hooks/keyboard-shift-policy.ts @@ -1,5 +1,25 @@ export const DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT = 120; +export function resolveStreamKeyboardInset(input: { + platform: "android" | "ios"; + settledShift: number; +}): { + contentContainerPaddingBottom: number; + contentInset: { bottom: number } | undefined; +} { + const settledShift = Math.max(0, input.settledShift); + if (input.platform === "ios") { + return { + contentContainerPaddingBottom: 0, + contentInset: { bottom: settledShift }, + }; + } + return { + contentContainerPaddingBottom: settledShift, + contentInset: undefined, + }; +} + export function shouldUseCompactExplorerKeyboardPadding(input: { isGit: boolean; explorerTab: "changes" | "files" | "pr"; diff --git a/packages/app/src/hooks/use-keyboard-shift-style.test.ts b/packages/app/src/hooks/use-keyboard-shift-style.test.ts index 96165d7f1f..bada7dd74c 100644 --- a/packages/app/src/hooks/use-keyboard-shift-style.test.ts +++ b/packages/app/src/hooks/use-keyboard-shift-style.test.ts @@ -1,10 +1,34 @@ import { describe, expect, it } from "vitest"; import { + resolveStreamKeyboardInset, shouldReconcileHiddenKeyboardEnd, resolveKeyboardShift, shouldUseCompactExplorerKeyboardPadding, } from "./keyboard-shift-policy"; +describe("resolveStreamKeyboardInset", () => { + it("uses the native scroll inset on iOS without changing content size", () => { + expect(resolveStreamKeyboardInset({ platform: "ios", settledShift: 311 })).toEqual({ + contentContainerPaddingBottom: 0, + contentInset: { bottom: 311 }, + }); + }); + + it("keeps Android's exact content-container padding behavior", () => { + expect(resolveStreamKeyboardInset({ platform: "android", settledShift: 311 })).toEqual({ + contentContainerPaddingBottom: 311, + contentInset: undefined, + }); + }); + + it("does not expose a negative inset", () => { + expect(resolveStreamKeyboardInset({ platform: "ios", settledShift: -1 })).toEqual({ + contentContainerPaddingBottom: 0, + contentInset: { bottom: 0 }, + }); + }); +}); + describe("resolveKeyboardShift", () => { it("keeps the existing open-keyboard offset behavior", () => { expect( diff --git a/packages/app/src/hooks/use-keyboard-shift-style.ts b/packages/app/src/hooks/use-keyboard-shift-style.ts index 6365771573..126ab9e9b3 100644 --- a/packages/app/src/hooks/use-keyboard-shift-style.ts +++ b/packages/app/src/hooks/use-keyboard-shift-style.ts @@ -1,4 +1,4 @@ -import { createElement, useEffect, useMemo, type ReactNode } from "react"; +import { createElement, useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { Platform } from "react-native"; import type { ViewStyle } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -8,16 +8,22 @@ import { } from "react-native-keyboard-controller"; import { useAnimatedStyle, + useAnimatedReaction, useDerivedValue, useSharedValue, type SharedValue, } from "react-native-reanimated"; +import { scheduleOnRN } from "react-native-worklets"; import { DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, resolveKeyboardShift, shouldReconcileHiddenKeyboardEnd, } from "@/hooks/keyboard-shift-policy"; -import { KeyboardShiftContext, useKeyboardShift } from "@/hooks/keyboard-shift-context"; +import { + KeyboardShiftContext, + SettledKeyboardShiftContext, + useKeyboardShift, +} from "@/hooks/keyboard-shift-context"; type KeyboardShiftMode = "translate" | "padding"; @@ -26,24 +32,16 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { const { height: keyboardHeight, progress: keyboardProgress } = useReanimatedKeyboardAnimation(); const bottomInset = useSharedValue(insets.bottom); const isIos = Platform.OS === "ios"; + const isMoving = useSharedValue(false); + const [settledShift, setSettledShift] = useState(0); + const publishSettledShift = useCallback((nextShift: number) => { + setSettledShift((currentShift) => (currentShift === nextShift ? currentShift : nextShift)); + }, []); useEffect(() => { bottomInset.value = insets.bottom; }, [bottomInset, insets.bottom]); - useGenericKeyboardHandler( - { - onEnd: (event) => { - "worklet"; - if (isIos && shouldReconcileHiddenKeyboardEnd(event)) { - keyboardHeight.value = 0; - keyboardProgress.value = 0; - } - }, - }, - [isIos, keyboardHeight, keyboardProgress], - ); - const shift = useDerivedValue(() => { "worklet"; return resolveKeyboardShift({ @@ -55,15 +53,48 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { }); }); + useGenericKeyboardHandler( + { + onStart: () => { + "worklet"; + isMoving.value = true; + }, + onEnd: (event) => { + "worklet"; + if (isIos && shouldReconcileHiddenKeyboardEnd(event)) { + keyboardHeight.value = 0; + keyboardProgress.value = 0; + } + isMoving.value = false; + }, + }, + [isIos, isMoving, keyboardHeight, keyboardProgress], + ); + + useAnimatedReaction( + () => ({ moving: isMoving.value, shift: shift.value }), + (current, previous) => { + if (!current.moving && (previous === null || previous.moving)) { + scheduleOnRN(publishSettledShift, current.shift); + } + }, + [isMoving, publishSettledShift, shift], + ); + const value = useMemo( () => ({ shift, + isMoving, bottomInset, }), - [bottomInset, shift], + [bottomInset, isMoving, shift], ); - return createElement(KeyboardShiftContext.Provider, { value }, children); + return createElement( + KeyboardShiftContext.Provider, + { value }, + createElement(SettledKeyboardShiftContext.Provider, { value: settledShift }, children), + ); } export function useKeyboardShiftStyle(input: { mode: KeyboardShiftMode; enabled?: boolean }): { diff --git a/packages/app/src/hooks/use-keyboard-visibility.native.ts b/packages/app/src/hooks/use-keyboard-visibility.native.ts index cb222d9e0c..f15b0fe7c7 100644 --- a/packages/app/src/hooks/use-keyboard-visibility.native.ts +++ b/packages/app/src/hooks/use-keyboard-visibility.native.ts @@ -1,5 +1,21 @@ -import { useKeyboardState } from "react-native-keyboard-controller"; +import { useSyncExternalStore } from "react"; +import { KeyboardController, KeyboardEvents } from "react-native-keyboard-controller"; -export function useKeyboardVisibility(): boolean { - return useKeyboardState((state) => state.isVisible); +const subscribe = (onStoreChange: () => void) => { + const subscriptions = (["keyboardWillShow", "keyboardDidHide"] as const).map((event) => + KeyboardEvents.addListener(event, onStoreChange), + ); + return () => { + for (const subscription of subscriptions) { + subscription.remove(); + } + }; +}; + +export function useKeyboardVisibility(enabled = true): boolean { + return useSyncExternalStore( + subscribe, + () => (enabled ? KeyboardController.isVisible() : false), + () => false, + ); } diff --git a/packages/app/src/hooks/use-keyboard-visibility.ts b/packages/app/src/hooks/use-keyboard-visibility.ts index 793a521b6f..375734124d 100644 --- a/packages/app/src/hooks/use-keyboard-visibility.ts +++ b/packages/app/src/hooks/use-keyboard-visibility.ts @@ -1,3 +1,3 @@ -export function useKeyboardVisibility(): boolean { +export function useKeyboardVisibility(_enabled = true): boolean { return false; } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 5156ee8f28..2ce4aaff3d 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1390,7 +1390,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ setText={agentInputDraft.replaceText} onRewindComplete={handleRewindComplete} > - + {contentContainer} @@ -1823,6 +1823,8 @@ const styles = StyleSheet.create((theme) => ({ root: { flex: 1, backgroundColor: theme.colors.surface0, + // KeyboardDock translates the chat surface while the keyboard moves; clip it at the header edge. + overflow: "hidden", }, container: { flex: 1, diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 947190ccd0..10261c1eae 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -4,13 +4,13 @@ import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { Pressable, StyleSheet as RNStyleSheet, Text, View } from "react-native"; import type { PressableStateCallbackType } from "react-native"; -import ReanimatedAnimated from "react-native-reanimated"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { createNameId } from "mnemonic-id"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native"; import { Composer } from "@/composer"; +import { KeyboardTranslateView } from "@/components/keyboard-translate-view"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { resolveComposerAttachmentSubmitFormat, @@ -62,7 +62,6 @@ import { useWorkspaceDraftSubmissionStore, type PendingWorkspaceDraftSetup, } from "@/stores/workspace-draft-submission-store"; -import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; import { useFormPreferences } from "@/hooks/use-form-preferences"; @@ -2178,15 +2177,6 @@ export function NewWorkspaceScreen({ [isCompact, insets.bottom], ); - const { style: composerKeyboardStyle } = useKeyboardShiftStyle({ - mode: "translate", - }); - - const centeredStyle = useMemo( - () => [animatedStaticStyles.centered, composerKeyboardStyle], - [composerKeyboardStyle], - ); - const agentControlsWithDisabled = useMemo( () => composerState @@ -2272,7 +2262,7 @@ export function NewWorkspaceScreen({ - + {t("newWorkspace.title")} @@ -2338,7 +2328,7 @@ export function NewWorkspaceScreen({ /> )} {errorMessage ? {errorMessage} : null} - + ); From 75eb9d44a34dffbf5add7562286e2e076c1ae404 Mon Sep 17 00:00:00 2001 From: Trim21 Date: Fri, 4 Sep 2026 03:38:15 +0800 Subject: [PATCH 004/566] feat(agents): surface provider notifications in the timeline (#3411) * feat(pi): surface extension notify in the agent timeline Pi extension ctx.ui.notify() messages were dropped or buffered away and never appeared anywhere in Paseo. Emit them as notification timeline items so they show in the agent chat feed, with info/warning/error levels mapped to activity log styling. * fix id * feat(agents): unify timeline notifications Represent Pi and OpenCode notices as one first-class timeline item instead of provider-specific synthetic tool calls. Gate the new wire variant so older strict clients keep working during version drift. * test(omp): update notice expectations --------- Co-authored-by: Mohamed Boudra --- packages/app/src/agent-stream/view.tsx | 13 +- .../src/agent-stream/web-virtualization.ts | 2 +- packages/app/src/components/message.tsx | 175 ++++------------ .../src/plugins/timeline/projection.test.ts | 42 ++++ .../app/src/plugins/timeline/projection.ts | 6 +- .../app/src/runtime/replica-cache/index.ts | 17 +- packages/app/src/types/stream.test.ts | 93 +++++++++ packages/app/src/types/stream.ts | 60 +++--- packages/client/src/daemon-client.test.ts | 1 + packages/client/src/daemon-client.ts | 1 + packages/protocol/src/agent-types.ts | 5 + packages/protocol/src/client-capabilities.ts | 4 + packages/protocol/src/messages.ts | 6 + .../protocol/src/messages.wire-compat.test.ts | 14 ++ .../src/server/agent/agent-sdk-types.ts | 5 + .../server/agent/providers/omp/agent.test.ts | 10 +- .../src/server/agent/providers/omp/agent.ts | 4 +- .../agent/providers/omp/event-mapper.test.ts | 22 +- .../agent/providers/omp/event-mapper.ts | 37 ++-- .../agent/providers/omp/history-hooks.ts | 4 +- .../providers/omp/history-mapper.test.ts | 23 +-- .../agent/providers/omp/system-notice.test.ts | 70 +++---- .../agent/providers/omp/system-notice.ts | 61 +----- .../server/agent/providers/pi/agent.test.ts | 62 +++++- .../src/server/agent/providers/pi/agent.ts | 19 +- .../selective-timeline-delivery.e2e.test.ts | 81 ++++++++ packages/server/src/server/session.ts | 195 +++++++++++------- 27 files changed, 608 insertions(+), 424 deletions(-) diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index c54bcb5e70..eb68675580 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -32,7 +32,7 @@ import { AssistantMessage, SpeakMessage, UserMessage, - ActivityLog, + Notification, ToolCall, TodoListCard, CompactionMarker, @@ -877,15 +877,8 @@ const AgentStreamViewComponent = forwardRef - ); + case "notification": + return ; case "todo_list": return ; diff --git a/packages/app/src/agent-stream/web-virtualization.ts b/packages/app/src/agent-stream/web-virtualization.ts index d63e1ab84b..21ba5d64dd 100644 --- a/packages/app/src/agent-stream/web-virtualization.ts +++ b/packages/app/src/agent-stream/web-virtualization.ts @@ -70,7 +70,7 @@ export function estimateStreamItemHeight(item: StreamItem): number { return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE; case "todo_list": return 144; - case "activity_log": + case "notification": return 88; case "compaction": return 72; diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 9ca8eaed10..830eb265ea 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -30,13 +30,9 @@ import type MarkdownIt from "markdown-it"; import { type ASTNode, type RenderRules } from "react-native-markdown-display"; import MaskedView from "@react-native-masked-view/masked-view"; import { - Circle, Info, - CheckCircle, XCircle, - FileText, ChevronRight, - ChevronDown, Check, CheckSquare, CircleDot, @@ -59,7 +55,6 @@ import Animated, { withTiming, } from "react-native-reanimated"; import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg"; -import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { MarkdownRenderer, type MarkdownStyles } from "@/components/markdown/renderer"; import type { TaskActivity, TodoEntry, UserMessageImageAttachment } from "@/types/stream"; @@ -173,6 +168,9 @@ const ThemedFileSymlinkIcon = withUnistyles(FileSymlink); const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon); const ThemedChevronRightIcon = withUnistyles(ChevronRight); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const ThemedNotificationInfo = withUnistyles(Info); +const ThemedNotificationWarning = withUnistyles(TriangleAlertIcon); +const ThemedNotificationError = withUnistyles(XCircle); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ @@ -182,6 +180,8 @@ const mutedForegroundColorMapping = (theme: Theme) => ({ color: theme.colors.mutedForeground, }); const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive }); +const infoColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[300] }); +const warningColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] }); const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = ` @keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} { 0% { @@ -2058,41 +2058,28 @@ export const SpeakMessage = memo(function SpeakMessage({ ); }); -interface ActivityLogProps { - type: "system" | "info" | "success" | "error" | "artifact"; +interface NotificationProps { + level: "info" | "warning" | "error"; message: string; - timestamp: number; - metadata?: Record; - artifactId?: string; - artifactType?: string; - title?: string; - onArtifactClick?: (artifactId: string) => void; disableOuterSpacing?: boolean; } -const activityLogStylesheet = StyleSheet.create((theme) => ({ - pressable: { +const notificationStylesheet = StyleSheet.create((theme) => ({ + container: { borderRadius: theme.borderRadius.md, overflow: "hidden", }, - pressableSpacing: { + containerSpacing: { marginBottom: theme.spacing[1], }, - pressableActive: { - opacity: 0.7, - }, - systemBg: { - backgroundColor: "rgba(39, 39, 42, 0.5)", - }, infoBg: { - backgroundColor: "rgba(30, 58, 138, 0.3)", + backgroundColor: "rgba(147, 197, 253, 0.1)", }, - successBg: { - backgroundColor: "rgba(20, 83, 45, 0.3)", + warningBg: { + backgroundColor: "rgba(245, 158, 11, 0.1)", }, - errorBg: {}, - artifactBg: { - backgroundColor: "rgba(30, 58, 138, 0.4)", + errorBg: { + backgroundColor: "rgba(239, 68, 68, 0.1)", }, content: { paddingHorizontal: theme.spacing[3], @@ -2112,137 +2099,63 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({ flex: 1, }, messageText: { + color: theme.colors.foreground, fontSize: theme.fontSize.base, lineHeight: 20, }, - detailsRow: { - flexDirection: "row", - alignItems: "center", - marginTop: theme.spacing[1], - }, - detailsText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - marginRight: theme.spacing[1], - }, - metadataContainer: { - marginTop: theme.spacing[2], - backgroundColor: "rgba(0, 0, 0, 0.5)", - borderRadius: theme.borderRadius.base, - padding: theme.spacing[2], - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - }, - metadataText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.code, - fontFamily: theme.fontFamily.mono, - lineHeight: 16, - }, })); -export const ActivityLog = memo(function ActivityLog({ - type, +export const Notification = memo(function Notification({ + level, message, - timestamp: _timestamp, - metadata, - artifactId, - artifactType, - title, - onArtifactClick, disableOuterSpacing, -}: ActivityLogProps) { - const { t } = useTranslation(); +}: NotificationProps) { const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); - const [isExpanded, setIsExpanded] = useState(false); const typeConfig = { - system: { - bg: activityLogStylesheet.systemBg, - color: "#a1a1aa", - Icon: Circle, + info: { + bg: notificationStylesheet.infoBg, + iconColorMapping: infoColorMapping, + Icon: ThemedNotificationInfo, }, - info: { bg: activityLogStylesheet.infoBg, color: "#60a5fa", Icon: Info }, - success: { - bg: activityLogStylesheet.successBg, - color: "#4ade80", - Icon: CheckCircle, + warning: { + bg: notificationStylesheet.warningBg, + iconColorMapping: warningColorMapping, + Icon: ThemedNotificationWarning, }, error: { - bg: activityLogStylesheet.errorBg, - color: "#f87171", - Icon: XCircle, - }, - artifact: { - bg: activityLogStylesheet.artifactBg, - color: "#93c5fd", - Icon: FileText, + bg: notificationStylesheet.errorBg, + iconColorMapping: destructiveColorMapping, + Icon: ThemedNotificationError, }, }; - const config = typeConfig[type]; + const config = typeConfig[level]; const IconComponent = config.Icon; - const handlePress = useCallback(() => { - if (type === "artifact" && artifactId && onArtifactClick) { - onArtifactClick(artifactId); - } else if (metadata) { - setIsExpanded((prev) => !prev); - } - }, [type, artifactId, onArtifactClick, metadata]); - - const displayMessage = - type === "artifact" && artifactType && title ? `${artifactType}: ${title}` : message; - - const isInteractive = type === "artifact" || metadata; - const pressableStyle = useMemo( + const containerStyle = useMemo( () => [ - activityLogStylesheet.pressable, - !resolvedDisableOuterSpacing && activityLogStylesheet.pressableSpacing, + notificationStylesheet.container, + !resolvedDisableOuterSpacing && notificationStylesheet.containerSpacing, config.bg, - isInteractive && activityLogStylesheet.pressableActive, ], - [resolvedDisableOuterSpacing, config.bg, isInteractive], + [resolvedDisableOuterSpacing, config.bg], ); - const messageTextStyle = useMemo( - () => [activityLogStylesheet.messageText, { color: config.color }], - [config.color], - ); - return ( - - - - - + + + + + - - - {displayMessage} + + + {message} - {metadata && ( - - - {t("message.activity.details")} - - {isExpanded ? ( - - ) : ( - - )} - - )} - {isExpanded && metadata && ( - - - {JSON.stringify(metadata, null, 2)} - - - )} - + ); }); diff --git a/packages/app/src/plugins/timeline/projection.test.ts b/packages/app/src/plugins/timeline/projection.test.ts index ebb2df48c8..2dc95ed339 100644 --- a/packages/app/src/plugins/timeline/projection.test.ts +++ b/packages/app/src/plugins/timeline/projection.test.ts @@ -73,6 +73,48 @@ describe("plugin timeline projection", () => { expect(transform).toHaveBeenCalledOnce(); }); + it("preserves whether a notification row came from an agent error or a notice", () => { + const transform: TimelineItemTransform = vi.fn(() => undefined); + const timestamp = new Date("2026-01-01T00:00:00.000Z"); + + projectPluginTimelineItems( + [ + { + kind: "notification", + sourceType: "error", + id: "error-1", + level: "error", + message: "Agent failed", + timestamp, + }, + { + kind: "notification", + sourceType: "notification", + id: "notice-1", + level: "error", + message: "Extension reported an error", + timestamp, + }, + ], + transform, + ); + + expect(transform).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ item: { type: "error", message: "Agent failed" } }), + ); + expect(transform).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + item: { + type: "notification", + level: "error", + message: "Extension reported an error", + }, + }), + ); + }); + it("reprojects when streaming produces a new source row", () => { const transform: TimelineItemTransform = vi.fn(() => undefined); projectPluginTimelineItems([thought("First")], transform); diff --git a/packages/app/src/plugins/timeline/projection.ts b/packages/app/src/plugins/timeline/projection.ts index 7e60392eef..4323f04075 100644 --- a/packages/app/src/plugins/timeline/projection.ts +++ b/packages/app/src/plugins/timeline/projection.ts @@ -58,8 +58,10 @@ function sourceTimelineItem(item: StreamItem): AgentTimelineItem | null { } case "todo_list": return { type: "todo", items: item.items }; - case "activity_log": - return item.activityType === "error" ? { type: "error", message: item.message } : null; + case "notification": + return item.sourceType === "error" + ? { type: "error", message: item.message } + : { type: "notification", level: item.level, message: item.message }; case "compaction": return { type: "compaction", diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 8029a2506b..26c1f3b5d6 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -104,8 +104,9 @@ const StoredTimelineItemSchema = z.discriminatedUnion("kind", [ }), z.strictObject({ ...TimelineItemBaseShape, - kind: z.literal("activity_log"), - activityType: z.enum(["system", "info", "success", "error"]), + kind: z.literal("notification"), + sourceType: z.enum(["error", "notification"]), + level: z.enum(["info", "warning", "error"]), message: z.string(), }), z.strictObject({ @@ -422,11 +423,12 @@ function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null { items: item.items, activity: item.activity, }; - case "activity_log": + case "notification": return { ...base, kind: item.kind, - activityType: item.activityType, + sourceType: item.sourceType, + level: item.level, message: item.message, }; case "compaction": @@ -513,11 +515,12 @@ function deserializeBuiltinTimelineItem( items: item.items, activity: item.activity, }; - case "activity_log": + case "notification": return { ...base, kind: item.kind, - activityType: item.activityType, + sourceType: item.sourceType, + level: item.level, message: item.message, }; case "compaction": @@ -681,8 +684,6 @@ function isTimelineItemStoredLosslessly(item: StreamItem): boolean { switch (item.kind) { case "user_message": return (item.images?.length ?? 0) === 0 && (item.attachments?.length ?? 0) === 0; - case "activity_log": - return item.metadata === undefined; case "tool_call": return item.payload.source === "agent"; default: diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index e49713f7c1..2bc47c9288 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -2071,3 +2071,96 @@ describe("turn lifecycle events", () => { ); }); }); + +describe("notification timeline items", () => { + it("maps notification items to activity log entries with the matching level", () => { + const timestamp = new Date("2026-07-26T10:00:00.000Z"); + const state = hydrateStreamState( + [ + { + event: { + type: "timeline", + provider: "pi", + item: { type: "notification", level: "info", message: "Search finished" }, + }, + timestamp, + }, + { + event: { + type: "timeline", + provider: "pi", + item: { type: "notification", level: "warning", message: "Command blocked" }, + }, + timestamp, + }, + { + event: { + type: "timeline", + provider: "pi", + item: { type: "notification", level: "error", message: "Turn failed" }, + }, + timestamp, + }, + { + event: { + type: "timeline", + provider: "pi", + item: { type: "notification", level: "info", message: "Default info" }, + }, + timestamp, + }, + ], + { source: "canonical" }, + ); + + expect( + state.map((item) => + item.kind === "notification" + ? { kind: item.kind, level: item.level, message: item.message } + : { kind: item.kind }, + ), + ).toEqual([ + { kind: "notification", level: "info", message: "Search finished" }, + { kind: "notification", level: "warning", message: "Command blocked" }, + { kind: "notification", level: "error", message: "Turn failed" }, + { kind: "notification", level: "info", message: "Default info" }, + ]); + }); + + it("keeps repeated notifications with the same text in the same millisecond", () => { + const timestamp = new Date("2026-07-26T10:00:00.000Z"); + const state = hydrateStreamState( + [ + { + event: { + type: "timeline", + provider: "pi", + item: { type: "notification", level: "warning", message: "Command blocked" }, + }, + timestamp, + }, + { + event: { + type: "timeline", + provider: "pi", + item: { type: "notification", level: "error", message: "Command blocked" }, + }, + timestamp, + }, + ], + { source: "canonical" }, + ); + + expect( + state.map((item) => + item.kind === "notification" + ? { kind: item.kind, level: item.level, message: item.message } + : { kind: item.kind }, + ), + ).toEqual([ + { kind: "notification", level: "warning", message: "Command blocked" }, + { kind: "notification", level: "error", message: "Command blocked" }, + ]); + expect(new Set(state.map((item) => item.id)).size).toBe(state.length); + }); +}); diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index cdde4bdf1c..93c404804f 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -84,7 +84,7 @@ export type StreamItem = | ThoughtItem | ToolCallItem | TodoListItem - | ActivityLogItem + | NotificationItem | CompactionItem | PluginTimelineStreamItem; @@ -771,17 +771,17 @@ export function isAgentToolCallItem(item: StreamItem): item is AgentToolCallItem return item.kind === "tool_call" && item.payload.source === "agent"; } -type ActivityLogType = "system" | "info" | "success" | "error"; +type NotificationLevel = "info" | "warning" | "error"; -export interface ActivityLogItem { - kind: "activity_log"; +export interface NotificationItem { + kind: "notification"; + sourceType: "error" | "notification"; id: string; timelineCursor?: TimelinePosition; turnId?: string; timestamp: Date; - activityType: ActivityLogType; + level: NotificationLevel; message: string; - metadata?: Record; } export interface CompactionItem { @@ -1215,6 +1215,16 @@ function appendAgentToolCall( return [...state, item]; } +function appendNotification(state: StreamItem[], entry: NotificationItem): StreamItem[] { + const index = state.findIndex((existing) => existing.id === entry.id); + if (index >= 0) { + const next = [...state]; + next[index] = entry; + return next; + } + return [...state, entry]; +} + function appendPluginTimelineItem( state: StreamItem[], item: Extract, @@ -1243,16 +1253,6 @@ function appendPluginTimelineItem( return next; } -function appendActivityLog(state: StreamItem[], entry: ActivityLogItem): StreamItem[] { - const index = state.findIndex((existing) => existing.id === entry.id); - if (index >= 0) { - const next = [...state]; - next[index] = entry; - return next; - } - return [...state, entry]; -} - function appendTodoList( state: StreamItem[], provider: AgentProvider, @@ -1459,7 +1459,7 @@ function reduceTimelineCompaction( } const compaction: CompactionItem = { kind: "compaction", - id: createTimelineId("compaction", item.status, timestamp), + id: createUniqueTimelineId(state, "compaction", item.status, timestamp), ...(timelineCursor ? { timelineCursor } : {}), timestamp, status: item.status, @@ -1523,15 +1523,28 @@ function reduceTimelineEvent( ); } case "error": { - const activity: ActivityLogItem = { - kind: "activity_log", - id: createTimelineId("error", item.message ?? "", timestamp), + const notification: NotificationItem = { + kind: "notification", + sourceType: "error", + id: createUniqueTimelineId(state, "error", item.message ?? "", timestamp), ...(timelineCursor ? { timelineCursor } : {}), timestamp, - activityType: "error", + level: "error", message: item.message ?? "Unknown error", }; - return finalizeActiveThoughts(appendActivityLog(state, activity)); + return finalizeActiveThoughts(appendNotification(state, notification)); + } + case "notification": { + const notification: NotificationItem = { + kind: "notification", + sourceType: "notification", + id: createUniqueTimelineId(state, "notification", item.message, timestamp), + ...(timelineCursor ? { timelineCursor } : {}), + timestamp, + level: item.level, + message: item.message, + }; + return finalizeActiveThoughts(appendNotification(state, notification)); } case "compaction": return finalizeActiveThoughts( @@ -1693,7 +1706,8 @@ function getEventItemKind(event: AgentStreamEventPayload): StreamItem["kind"] | case "todo": return "todo_list"; case "error": - return "activity_log"; + case "notification": + return "notification"; case "plugin": return "plugin"; default: diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 149e6b0040..b3d79b023b 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -743,6 +743,7 @@ test("advertises client capabilities in hello", async () => { provider_subagents: true, reasoning_merge_enum: true, terminal_reflowable_snapshot: true, + timeline_notifications: true, browser_host: { supportedCommands: ["list_tabs"], hostKind: "desktop app", diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 62bf674b5f..4b3e4d7e3c 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -5656,6 +5656,7 @@ export class DaemonClient { [CLIENT_CAPS.providerSubagents]: true, [CLIENT_CAPS.projectUpdates]: true, [CLIENT_CAPS.compactProviderSnapshots]: true, + [CLIENT_CAPS.timelineNotifications]: true, ...this.config.capabilities, }, ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}), diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index bce5fa7fcd..57e09f2eca 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -363,6 +363,11 @@ export type AgentTimelineItem = | ToolCallTimelineItem | { type: "todo"; items: AgentTaskItem[] } | { type: "error"; message: string } + | { + type: "notification"; + level: "info" | "warning" | "error"; + message: string; + } | CompactionTimelineItem | PluginTimelineItem; diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index b9c405b4f1..fbf977ced9 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -27,6 +27,10 @@ export const CLIENT_CAPS = { // COMPAT(timelineReplacementInvalidation): added in v0.5.0, remove legacy // reconstructed timeline replay after 2027-02-21 once the client floor supports invalidation. timelineReplacementInvalidation: "timeline_replacement_invalidation", + // COMPAT(timelineNotifications): added in v0.7.2. The daemon omits notification + // timeline items for older clients whose strict timeline union rejects them. + // Remove after 2027-03-03 once the supported client floor is >= v0.7.2. + timelineNotifications: "timeline_notifications", browserHost: "browser_host", } as const; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 400dd23f5a..d6134c95a5 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -734,6 +734,11 @@ export const AgentTimelineItemPayloadSchema: z.ZodType { expect(parsed.capabilities.supportsRewindFiles).toBe(false); expect(parsed.capabilities.supportsRewindBoth).toBe(false); }); + + test("notification timeline items parse their level and message", () => { + expect( + AgentTimelineItemPayloadSchema.parse({ + type: "notification", + level: "warning", + message: "Command blocked by user", + }), + ).toEqual({ + type: "notification", + level: "warning", + message: "Command blocked by user", + }); + }); }); diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index c916228020..0a4b565e39 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -407,6 +407,11 @@ export type AgentTimelineItem = | ToolCallTimelineItem | { type: "todo"; items: AgentTaskItem[] } | { type: "error"; message: string } + | { + type: "notification"; + level: "info" | "warning" | "error"; + message: string; + } | CompactionTimelineItem | PluginTimelineItem; diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 79fc4d8d11..e591b366c0 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -368,7 +368,7 @@ describe("OMP agent client and session", () => { ]); }); - test("renders a live system-notice custom message as a synthetic tool call", async () => { + test("renders a live system-notice custom message as a notification", async () => { const omp = new OmpHarness(); await omp.start(); @@ -387,8 +387,12 @@ describe("OMP agent client and session", () => { ); omp.runtime().acceptCustomMessage("plain custom status text"); - expect(omp.timeline().filter((item) => item.type === "tool_call")).toMatchObject([ - { callId: "omp-notice:DocsSmokeTwo", name: "task_notification", status: "completed" }, + expect(omp.timeline().filter((item) => item.type === "notification")).toEqual([ + { + type: "notification", + level: "info", + message: "Background job DocsSmokeTwo completed", + }, ]); // Non-notice custom messages still fall through as assistant messages. expect(omp.timeline().filter((item) => item.type === "assistant_message")).toMatchObject([ diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 78b79fe9b1..5f85b7c7d0 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -69,7 +69,7 @@ export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-c import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js"; import { shouldDisplayOmpCustomMessage } from "./custom-message.js"; import { getUserMessageText } from "./message-history.js"; -import { mapOmpSystemNoticeToToolCall } from "./system-notice.js"; +import { mapOmpSystemNoticeToNotification } from "./system-notice.js"; import { materializeProviderImage } from "../provider-image-output.js"; import { OmpCliRuntime } from "./cli-runtime.js"; import { listOmpImportableSessions, readOmpImportSessionConfig } from "./session-descriptor.js"; @@ -2019,7 +2019,7 @@ export class OmpAgentSession implements AgentSession { if (text) { const item = mapOmpAdvisorMessageToToolCall(event.message, text) ?? - mapOmpSystemNoticeToToolCall(text); + mapOmpSystemNoticeToNotification(text); this.emit({ type: "timeline", provider: this.provider, diff --git a/packages/server/src/server/agent/providers/omp/event-mapper.test.ts b/packages/server/src/server/agent/providers/omp/event-mapper.test.ts index 4c53ee9c0d..2959969e15 100644 --- a/packages/server/src/server/agent/providers/omp/event-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/event-mapper.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest"; import { mapOmpRuntimeEventToTimelineItem } from "./event-mapper.js"; describe("OMP runtime event mapper", () => { - test("maps notice events to timeline status lines", () => { + test("maps notice events to timeline notifications", () => { const event = { type: "notice", level: "warning", @@ -14,23 +14,9 @@ describe("OMP runtime event mapper", () => { expect(mapOmpRuntimeEventToTimelineItem(event)).toMatchObject({ handled: true, item: { - type: "tool_call", - callId: expect.stringMatching(/^omp-notice:/), - name: "omp_notice", - status: "completed", - error: null, - detail: { - type: "plain_text", - label: "OMP warning notice from anthropic", - text: "Provider quota is getting low", - icon: "sparkles", - }, - metadata: { - synthetic: true, - source: "omp_notice", - level: "warning", - eventSource: "anthropic", - }, + type: "notification", + level: "warning", + message: "Provider quota is getting low", }, }); }); diff --git a/packages/server/src/server/agent/providers/omp/event-mapper.ts b/packages/server/src/server/agent/providers/omp/event-mapper.ts index 83fbf95937..cec883dc69 100644 --- a/packages/server/src/server/agent/providers/omp/event-mapper.ts +++ b/packages/server/src/server/agent/providers/omp/event-mapper.ts @@ -30,7 +30,7 @@ export type OmpRuntimeEventMapping = logReason: string; }; -interface StatusLineNoticeInput { +interface StatusLineInput { callId: string; name: string; label: string; @@ -70,26 +70,13 @@ function mapNoticeEvent(event: unknown): OmpRuntimeEventMapping { if (!parsed.success) { return { handled: true, item: null, logReason: "malformed_omp_notice" }; } - const label = parsed.data.source - ? `OMP ${parsed.data.level} notice from ${parsed.data.source}` - : `OMP ${parsed.data.level} notice`; return { handled: true, - item: buildStatusLineNotice({ - callId: `omp-notice:${hashParts(parsed.data.level, parsed.data.source ?? "", parsed.data.message)}`, - name: "omp_notice", - label, - text: parsed.data.message, - icon: "sparkles", - status: parsed.data.level === "error" ? "failed" : "completed", - error: parsed.data.level === "error" ? parsed.data.message : undefined, - metadata: { - synthetic: true, - source: "omp_notice", - level: parsed.data.level, - ...(parsed.data.source ? { eventSource: parsed.data.source } : {}), - }, - }), + item: { + type: "notification", + level: parsed.data.level, + message: parsed.data.message, + }, }; } @@ -103,7 +90,7 @@ function mapGoalUpdatedEvent(event: unknown): OmpRuntimeEventMapping { const text = formatGoalText(parsed.data, goal); return { handled: true, - item: buildStatusLineNotice({ + item: buildStatusLineItem({ callId: `omp-goal:${goal?.id ?? hashParts(text)}`, name: "omp_goal_updated", label, @@ -127,7 +114,7 @@ function mapAutoRetryStartEvent(event: unknown): OmpRuntimeEventMapping { const data = parsed.data; return { handled: true, - item: buildStatusLineNotice({ + item: buildStatusLineItem({ callId: `omp-auto-retry:${data.attempt}`, name: "omp_auto_retry", label: `OMP retry ${data.attempt}/${data.maxAttempts}`, @@ -155,7 +142,7 @@ function mapAutoRetryEndEvent(event: unknown): OmpRuntimeEventMapping { const status = data.success ? "completed" : "failed"; return { handled: true, - item: buildStatusLineNotice({ + item: buildStatusLineItem({ callId: `omp-auto-retry:${data.attempt}`, name: "omp_auto_retry", label: data.success @@ -183,7 +170,7 @@ function mapRetryFallbackAppliedEvent(event: unknown): OmpRuntimeEventMapping { const data = parsed.data; return { handled: true, - item: buildStatusLineNotice({ + item: buildStatusLineItem({ callId: `omp-retry-fallback:${hashParts(data.role, data.from, data.to)}`, name: "omp_retry_fallback", label: `OMP fallback applied for ${data.role}`, @@ -208,7 +195,7 @@ function mapRetryFallbackSucceededEvent(event: unknown): OmpRuntimeEventMapping const data = parsed.data; return { handled: true, - item: buildStatusLineNotice({ + item: buildStatusLineItem({ callId: `omp-retry-fallback-succeeded:${hashParts(data.role, data.model)}`, name: "omp_retry_fallback", label: `OMP fallback succeeded for ${data.role}`, @@ -257,7 +244,7 @@ function mapAutoCompactionEndEvent(event: unknown): OmpRuntimeEventMapping { }; } -function buildStatusLineNotice(input: StatusLineNoticeInput): OmpTelemetryToolCallItem { +function buildStatusLineItem(input: StatusLineInput): OmpTelemetryToolCallItem { const base = { type: "tool_call" as const, callId: input.callId, diff --git a/packages/server/src/server/agent/providers/omp/history-hooks.ts b/packages/server/src/server/agent/providers/omp/history-hooks.ts index 75b9505c3f..860d64443b 100644 --- a/packages/server/src/server/agent/providers/omp/history-hooks.ts +++ b/packages/server/src/server/agent/providers/omp/history-hooks.ts @@ -1,6 +1,6 @@ import type { OmpHistoryMapperHooks } from "./message-history.js"; import { mapOmpAdvisorMessageToToolCall } from "./advisor-message.js"; -import { mapOmpSystemNoticeToToolCall } from "./system-notice.js"; +import { mapOmpSystemNoticeToNotification } from "./system-notice.js"; import { mapOmpToolDetail } from "./tool-call-mapper.js"; import { resolveOmpEmittedToolCallId } from "./tool-call-id.js"; @@ -8,7 +8,7 @@ export const OMP_HISTORY_MAPPER_HOOKS: OmpHistoryMapperHooks = { mapToolDetail: mapOmpToolDetail, mapCustomMessage: (message, text, provider) => { const item = - mapOmpAdvisorMessageToToolCall(message, text) ?? mapOmpSystemNoticeToToolCall(text); + mapOmpAdvisorMessageToToolCall(message, text) ?? mapOmpSystemNoticeToNotification(text); return item ? { type: "timeline", provider, item } : null; }, resolveToolCallId: resolveOmpEmittedToolCallId, diff --git a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts index db67f0cbe3..47cc41c0d0 100644 --- a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts @@ -88,7 +88,7 @@ describe("OMP history mapper", () => { ]); }); - test("absorbs replayed omp system-notice custom messages as synthetic tool calls", async () => { + test("maps replayed OMP system-notice custom messages to notifications", async () => { const notice = [ "", "Background job DocsSmokeTwo has completed. Resume your work using the result below.", @@ -124,24 +124,9 @@ describe("OMP history mapper", () => { type: "timeline", provider: "omp", item: { - type: "tool_call", - callId: "omp-notice:DocsSmokeTwo", - name: "task_notification", - status: "completed", - detail: { - type: "plain_text", - label: "Background job DocsSmokeTwo completed", - text: notice, - icon: "wrench", - }, - metadata: { - synthetic: true, - source: "omp_system_notice", - taskId: "DocsSmokeTwo", - subagentType: "explore", - status: "completed", - }, - error: null, + type: "notification", + level: "info", + message: "Background job DocsSmokeTwo completed", }, }, { diff --git a/packages/server/src/server/agent/providers/omp/system-notice.test.ts b/packages/server/src/server/agent/providers/omp/system-notice.test.ts index 0125ec08d4..bac0ec5508 100644 --- a/packages/server/src/server/agent/providers/omp/system-notice.test.ts +++ b/packages/server/src/server/agent/providers/omp/system-notice.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { isOmpSystemNotice, mapOmpSystemNoticeToToolCall } from "./system-notice.js"; +import { isOmpSystemNotice, mapOmpSystemNoticeToNotification } from "./system-notice.js"; const COMPLETED_NOTICE = [ "", @@ -24,31 +24,16 @@ describe("omp system notice detection", () => { test("ignores regular prompts, including ones that mention the tag mid-message", () => { expect(isOmpSystemNotice("please fix the bug")).toBe(false); expect(isOmpSystemNotice("what does mean in omp?")).toBe(false); - expect(mapOmpSystemNoticeToToolCall("what does mean in omp?")).toBeNull(); + expect(mapOmpSystemNoticeToNotification("what does mean in omp?")).toBeNull(); }); }); -describe("omp system notice tool call mapping", () => { - test("maps a completed task-result notice to a synthetic completed tool call", () => { - expect(mapOmpSystemNoticeToToolCall(COMPLETED_NOTICE)).toEqual({ - type: "tool_call", - callId: "omp-notice:DocsSmokeTwo", - name: "task_notification", - status: "completed", - detail: { - type: "plain_text", - label: "Background job DocsSmokeTwo completed", - text: COMPLETED_NOTICE, - icon: "wrench", - }, - metadata: { - synthetic: true, - source: "omp_system_notice", - taskId: "DocsSmokeTwo", - subagentType: "explore", - status: "completed", - }, - error: null, +describe("omp system notice notification mapping", () => { + test("maps a completed task-result notice to an info notification", () => { + expect(mapOmpSystemNoticeToNotification(COMPLETED_NOTICE)).toEqual({ + type: "notification", + level: "info", + message: "Background job DocsSmokeTwo completed", }); }); @@ -62,11 +47,11 @@ describe("omp system notice tool call mapping", () => { "", ].join("\n"); - const item = mapOmpSystemNoticeToToolCall(notice); - expect(item).toMatchObject({ - callId: "omp-notice:RepoSmokeOne", - status: "failed", - error: "Background job RepoSmokeOne failed", + const item = mapOmpSystemNoticeToNotification(notice); + expect(item).toEqual({ + type: "notification", + level: "error", + message: "Background job RepoSmokeOne failed", }); }); @@ -80,30 +65,23 @@ describe("omp system notice tool call mapping", () => { "", ].join("\n"); - expect(mapOmpSystemNoticeToToolCall(notice)).toMatchObject({ - callId: "omp-notice:DocsSmokeTwo", - status: "completed", - metadata: { - taskId: "DocsSmokeTwo", - subagentType: "explore", - }, + expect(mapOmpSystemNoticeToNotification(notice)).toEqual({ + type: "notification", + level: "info", + message: "Background job DocsSmokeTwo completed", }); }); - test("maps a notice without a task-result using its first line and a stable hash id", () => { + test("maps a notice without a task-result using its first line", () => { const notice = "\nThe daemon rotated its logs.\n"; - const first = mapOmpSystemNoticeToToolCall(notice); - const second = mapOmpSystemNoticeToToolCall(notice); + const first = mapOmpSystemNoticeToNotification(notice); + const second = mapOmpSystemNoticeToNotification(notice); expect(first).toEqual(second); - expect(first).toMatchObject({ - status: "completed", - detail: { - type: "plain_text", - label: "The daemon rotated its logs.", - text: notice, - }, + expect(first).toEqual({ + type: "notification", + level: "info", + message: "The daemon rotated its logs.", }); - expect(first?.callId).toMatch(/^omp-notice:[0-9a-f]{12}$/); }); }); diff --git a/packages/server/src/server/agent/providers/omp/system-notice.ts b/packages/server/src/server/agent/providers/omp/system-notice.ts index 73dd5e3e55..141ee68afe 100644 --- a/packages/server/src/server/agent/providers/omp/system-notice.ts +++ b/packages/server/src/server/agent/providers/omp/system-notice.ts @@ -1,5 +1,3 @@ -import { createHash } from "node:crypto"; - import type { AgentTimelineItem } from "../../agent-sdk-types.js"; const SYSTEM_NOTICE_OPEN_TAG = ""; @@ -9,7 +7,7 @@ const TASK_RESULT_TAG_PATTERN = /]*)>/i; // with typographic quotes after copy/paste round-trips; accept both. const TASK_RESULT_ATTRIBUTE_PATTERN = /([\w-]+)=["'“‘]([^"'“”‘’]*)["'”’]/g; -type OmpSystemNoticeToolCallItem = Extract; +type OmpSystemNotificationItem = Extract; interface OmpTaskResultSummary { id: string | null; @@ -17,11 +15,6 @@ interface OmpTaskResultSummary { status: string | null; } -type OmpNoticeLifecycle = - | { status: "completed"; error: null } - | { status: "failed"; error: string } - | { status: "canceled"; error: null }; - export function isOmpSystemNotice(text: string): boolean { return text.trimStart().startsWith(SYSTEM_NOTICE_OPEN_TAG); } @@ -62,18 +55,17 @@ function readNoticeFirstLine(text: string): string | null { return null; } -function buildLifecycle( +function notificationLevel( taskResult: OmpTaskResultSummary | null, - label: string, -): OmpNoticeLifecycle { +): OmpSystemNotificationItem["level"] { const status = taskResult?.status?.toLowerCase() ?? null; if (status === "failed" || status === "error") { - return { status: "failed", error: label }; + return "error"; } if (status === "canceled" || status === "cancelled" || status === "stopped") { - return { status: "canceled", error: null }; + return "warning"; } - return { status: "completed", error: null }; + return "info"; } function buildLabel(taskResult: OmpTaskResultSummary | null, text: string): string { @@ -83,46 +75,15 @@ function buildLabel(taskResult: OmpTaskResultSummary | null, text: string): stri return readNoticeFirstLine(text) ?? "System notice"; } -function buildCallId(taskResult: OmpTaskResultSummary | null, text: string): string { - if (taskResult?.id) { - return `omp-notice:${taskResult.id}`; - } - const digest = createHash("sha1").update(text.trim()).digest("hex").slice(0, 12); - return `omp-notice:${digest}`; -} - -export function mapOmpSystemNoticeToToolCall(text: string): OmpSystemNoticeToolCallItem | null { +export function mapOmpSystemNoticeToNotification(text: string): OmpSystemNotificationItem | null { if (!isOmpSystemNotice(text)) { return null; } const taskResult = readTaskResult(text); - const label = buildLabel(taskResult, text); - const lifecycle = buildLifecycle(taskResult, label); - const base = { - type: "tool_call" as const, - callId: buildCallId(taskResult, text), - name: "task_notification", - detail: { - type: "plain_text" as const, - label, - text, - icon: "wrench" as const, - }, - metadata: { - synthetic: true, - source: "omp_system_notice", - ...(taskResult?.id ? { taskId: taskResult.id } : {}), - ...(taskResult?.agent ? { subagentType: taskResult.agent } : {}), - ...(taskResult?.status ? { status: taskResult.status } : {}), - }, + return { + type: "notification", + level: notificationLevel(taskResult), + message: buildLabel(taskResult, text), }; - - if (lifecycle.status === "failed") { - return { ...base, status: "failed", error: lifecycle.error }; - } - if (lifecycle.status === "canceled") { - return { ...base, status: "canceled", error: null }; - } - return { ...base, status: "completed", error: null }; } diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index da2e785ca5..631ce28b9f 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -535,19 +535,60 @@ describe("PiRpcAgentSession", () => { ]); }); - test("ignores Pi RPC fire-and-forget extension UI requests", async () => { - const { pi } = await createSession(); + test("surfaces Pi fire-and-forget notify requests as timeline notifications", async () => { + const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); fakeSession.emit({ type: "extension_ui_request", id: "notify-1", method: "notify", - message: "hello", + message: "Search finished", + notifyType: "info", + }); + fakeSession.emit({ + type: "extension_ui_request", + id: "notify-2", + method: "notify", + message: "Command blocked by user", + notifyType: "warning", + }); + fakeSession.emit({ + type: "extension_ui_request", + id: "notify-3", + method: "notify", + message: "no type", }); expect(fakeSession.extensionUiResponses).toEqual([]); expect(fakeSession.canceledExtensionUiRequests).toEqual([]); + expect(events.timelineItems()).toEqual([ + { type: "notification", level: "info", message: "Search finished" }, + { type: "notification", level: "warning", message: "Command blocked by user" }, + { type: "notification", level: "info", message: "no type" }, + ]); + + await session.close(); + }); + + test("surfaces Pi notify requests emitted during an active turn", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + + await session.startTurn("hello"); + fakeSession.emit({ + type: "extension_ui_request", + id: "notify-live", + method: "notify", + message: "Turn running notice", + notifyType: "error", + }); + + expect(events.timelineItems()).toEqual([ + { type: "notification", level: "error", message: "Turn running notice" }, + ]); + + await session.close(); }); test("streams assistant text, reasoning, and tool calls from Pi events", async () => { @@ -1438,7 +1479,7 @@ describe("PiRpcAgentSession", () => { expect(events.turnCompletedEvents()).toHaveLength(1); }); - test("probes slash prompts without agentInvoked and surfaces buffered notify output", async () => { + test("probes slash prompts without agentInvoked and surfaces notify output immediately", async () => { const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); @@ -1454,13 +1495,16 @@ describe("PiRpcAgentSession", () => { const completion = await events.nextTurnCompletion(); expect(completion).toMatchObject({ type: "turn_completed", turnId }); expect(events.timelineAndCompletionEvents()).toEqual([ + { + type: "timeline", + item: { type: "notification", level: "info", message: "Plan mode enabled" }, + }, { type: "timeline", item: { type: "user_message", text: "/plan on" } }, - { type: "timeline", item: { type: "assistant_message", text: "Plan mode enabled" } }, { type: "turn_completed" }, ]); }); - test("does not synthesize completion when lifecycle starts before the no-turn probe", async () => { + test("surfaces notify requests immediately even when the turn has started", async () => { const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); @@ -1469,13 +1513,15 @@ describe("PiRpcAgentSession", () => { type: "extension_ui_request", id: "notify-buffered", method: "notify", - message: "Should not appear after turn starts", + message: "Shown despite turn start", }); fakeSession.emit({ type: "agent_start" }); await flushTurnScheduling(); expect(events.turnCompletedEvents()).toHaveLength(0); - expect(events.timelineItems()).toEqual([]); + expect(events.timelineItems()).toEqual([ + { type: "notification", level: "info", message: "Shown despite turn start" }, + ]); fakeSession.finishTurn(); await flushTurnScheduling(); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index dc5c84375e..b262af0d76 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -855,6 +855,13 @@ function optionalString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } +function toNotificationLevel(value: unknown): "info" | "warning" | "error" { + if (value === "info" || value === "warning" || value === "error") { + return value; + } + return "info"; +} + function parseExtensionMarkerPayload( message: string, marker: string, @@ -2033,7 +2040,17 @@ export class PiRpcAgentSession implements AgentSession { ) { return; } - this.bufferNoTurnOutput(message); + this.emit({ + type: "timeline", + provider: this.provider, + turnId: this.currentTurnIdForEvent(), + item: { + type: "notification", + level: toNotificationLevel(event.notifyType), + message, + }, + }); + return; } if (this.respondToCombinedAskUserFollowUp(event)) { diff --git a/packages/server/src/server/selective-timeline-delivery.e2e.test.ts b/packages/server/src/server/selective-timeline-delivery.e2e.test.ts index c9d57c4b59..2bbe48662a 100644 --- a/packages/server/src/server/selective-timeline-delivery.e2e.test.ts +++ b/packages/server/src/server/selective-timeline-delivery.e2e.test.ts @@ -129,12 +129,16 @@ async function connect(input: { clientId: string; selective: boolean; timelineReplacementInvalidation?: boolean; + timelineNotifications?: boolean; }): Promise { const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, clientId: input.clientId, capabilities: { [CLIENT_CAPS.selectiveAgentTimeline]: input.selective, + ...(input.timelineNotifications === undefined + ? {} + : { [CLIENT_CAPS.timelineNotifications]: input.timelineNotifications }), ...(input.timelineReplacementInvalidation ? { [CLIENT_CAPS.timelineReplacementInvalidation]: true } : {}), @@ -147,6 +151,83 @@ async function connect(input: { return connected; } +test("notification timeline items are sent only to clients that advertise support", async () => { + await daemon.close(); + daemon = await createTestPaseoDaemon({ + isDev: true, + agentClients: { mock: new MockLoadTestAgentClient() }, + }); + const capable = await connect({ + clientId: "notification-capable", + selective: false, + timelineNotifications: true, + }); + const legacy = await connect({ + clientId: "notification-legacy", + selective: false, + timelineNotifications: false, + }); + const agent = await capable.client.createAgent({ + provider: "mock", + cwd: "/tmp", + title: "Notification compatibility", + model: "ten-second-stream", + }); + capable.clear(); + legacy.clear(); + + await daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "notification", + level: "warning", + message: "Capable clients only", + }); + await daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "assistant_message", + text: "Visible to every client", + }); + await Promise.all([ + capable.next(isAgentStream(agent.id), "capable timeline delivery"), + legacy.next(isAgentStream(agent.id), "legacy timeline delivery"), + ]); + await Promise.all([ + capable.barrier("notification-capable"), + legacy.barrier("notification-legacy"), + ]); + + const capableLiveItems = capable.messages.flatMap((message) => + message.type === "agent_stream" && message.payload.event.type === "timeline" + ? [message.payload.event.item] + : [], + ); + const legacyLiveItems = legacy.messages.flatMap((message) => + message.type === "agent_stream" && message.payload.event.type === "timeline" + ? [message.payload.event.item] + : [], + ); + expect(capableLiveItems.some((item) => item.type === "notification")).toBe(true); + expect(legacyLiveItems.some((item) => item.type === "notification")).toBe(false); + expect(legacyLiveItems).toContainEqual( + expect.objectContaining({ type: "assistant_message", text: "Visible to every client" }), + ); + + const [capableTimeline, legacyTimeline] = await Promise.all([ + capable.client.fetchAgentTimeline(agent.id, { direction: "tail", projection: "canonical" }), + legacy.client.fetchAgentTimeline(agent.id, { direction: "tail", projection: "canonical" }), + ]); + expect(capableTimeline.entries.some((entry) => entry.item.type === "notification")).toBe(true); + expect(legacyTimeline.entries.some((entry) => entry.item.type === "notification")).toBe(false); + expect(legacyTimeline.entries).toContainEqual( + expect.objectContaining({ + item: expect.objectContaining({ + type: "assistant_message", + text: "Visible to every client", + }), + }), + ); + expect(legacyTimeline.window).toEqual(capableTimeline.window); + expect(legacyTimeline.endCursor).toEqual(capableTimeline.endCursor); +}); + test("rewind routes replacement completion by source capability and subscription", async () => { await daemon.close(); daemon = await createTestPaseoDaemon({ diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index bb07240014..c5862381cd 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1165,11 +1165,20 @@ export class Session { return true; } + private supportsTimelineNotifications(source?: object): boolean { + return source + ? this.supportsForSource(CLIENT_CAPS.timelineNotifications, source) + : this.supports(CLIENT_CAPS.timelineNotifications); + } + private forwardAgentStream( event: Extract, serializedEvent: Extract["payload"]["event"], ): void { + const isNotification = + serializedEvent.type === "timeline" && serializedEvent.item.type === "notification"; if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) { + if (isNotification && !this.supportsTimelineNotifications()) return; if (this.usesSelectiveTimelineDelivery() && serializedEvent.type === "attention_required") { this.emit({ type: "agent_attention_required", @@ -1194,6 +1203,7 @@ export class Session { } for (const [source, capabilities] of this.clientCapabilitiesBySource) { + if (isNotification && !capabilities.has(CLIENT_CAPS.timelineNotifications)) continue; const supportsSelectiveDelivery = capabilities.has(CLIENT_CAPS.selectiveAgentTimeline); if (supportsSelectiveDelivery && serializedEvent.type === "attention_required") { this.onMessageToSource(source, { @@ -1625,6 +1635,57 @@ export class Session { } } + private forwardProviderSubagentUpdate( + update: Extract["event"], + ): void { + let message: SessionOutboundMessage; + if (update.type === "upsert") { + message = { + type: "agent.provider_subagents.update", + payload: { kind: "upsert", subagent: update.subagent }, + }; + } else if (update.type === "timeline") { + message = { + type: "agent.provider_subagents.update", + payload: { + kind: "timeline", + parentAgentId: update.parentAgentId, + subagentId: update.subagentId, + provider: update.provider, + item: update.row.item, + timestamp: update.row.timestamp, + seq: update.row.seq, + epoch: update.epoch, + }, + }; + } else { + message = { + type: "agent.provider_subagents.update", + payload: { + kind: "remove", + parentAgentId: update.parentAgentId, + subagentId: update.subagentId, + }, + }; + } + + const isNotification = update.type === "timeline" && update.row.item.type === "notification"; + if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) { + if ( + this.supports(CLIENT_CAPS.providerSubagents) && + (!isNotification || this.supportsTimelineNotifications()) + ) { + this.emit(message); + } + return; + } + for (const [source, capabilities] of this.clientCapabilitiesBySource) { + if (!capabilities.has(CLIENT_CAPS.providerSubagents)) continue; + if (isNotification && !capabilities.has(CLIENT_CAPS.timelineNotifications)) continue; + this.onMessageToSource(source, message); + } + } + private subscribeToAgentEvents(): void { if (this.unsubscribeAgentEvents) { this.unsubscribeAgentEvents(); @@ -1654,39 +1715,7 @@ export class Session { if (event.type === "provider_subagent") { this.emitProviderSubagentWorkspaceUpdate(event.event); - if (!this.supports(CLIENT_CAPS.providerSubagents)) { - return; - } - const update = event.event; - if (update.type === "upsert") { - this.emit({ - type: "agent.provider_subagents.update", - payload: { kind: "upsert", subagent: update.subagent }, - }); - } else if (update.type === "timeline") { - this.emit({ - type: "agent.provider_subagents.update", - payload: { - kind: "timeline", - parentAgentId: update.parentAgentId, - subagentId: update.subagentId, - provider: update.provider, - item: update.row.item, - timestamp: update.row.timestamp, - seq: update.row.seq, - epoch: update.epoch, - }, - }); - } else { - this.emit({ - type: "agent.provider_subagents.update", - payload: { - kind: "remove", - parentAgentId: update.parentAgentId, - subagentId: update.subagentId, - }, - }); - } + this.forwardProviderSubagentUpdate(event.event); return; } @@ -2276,7 +2305,7 @@ export class Session { case "agent.provider_subagents.list.request": return this.handleProviderSubagentListRequest(msg); case "agent.provider_subagents.timeline.get.request": - return this.handleProviderSubagentTimelineRequest(msg); + return this.handleProviderSubagentTimelineRequest(msg, source); case "agent.timeline.set_subscription.request": { const agentIds = [...new Set(msg.agentIds)].sort(); if ( @@ -4052,6 +4081,9 @@ export class Session { source?: object, ): void { for (const row of rows) { + if (row.item.type === "notification" && !this.supportsTimelineNotifications(source)) { + continue; + } const event = serializeAgentStreamEvent({ type: "timeline", provider, @@ -7091,6 +7123,9 @@ export class Session { selectedTimeline.endSeq !== null ? { epoch: selectedTimeline.timeline.epoch, seq: selectedTimeline.endSeq } : null; + const entries = this.supportsTimelineNotifications(source) + ? selectedTimeline.entries + : selectedTimeline.entries.filter((entry) => entry.item.type !== "notification"); this.emitForSource( { @@ -7111,7 +7146,7 @@ export class Session { hasOlder: selectedTimeline.hasOlder, hasNewer: selectedTimeline.hasNewer, ...(msg.mergeWindow === true ? { mergeWindow: true } : {}), - entries: selectedTimeline.entries.map((entry) => { + entries: entries.map((entry) => { const payloadEntry = { provider: snapshot.provider, item: entry.item, @@ -7267,6 +7302,7 @@ export class Session { private async handleProviderSubagentTimelineRequest( msg: Extract, + source?: object, ): Promise { const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail"); try { @@ -7288,49 +7324,58 @@ export class Session { limit: msg.limit ?? (direction === "after" ? 0 : 200), }, ); - this.emit({ - type: "agent.provider_subagents.timeline.get.response", - payload: { - requestId: msg.requestId, - parentAgentId: msg.parentAgentId, - subagentId: msg.subagentId, - provider: descriptor.provider, - direction, - epoch: timeline.epoch, - reset: timeline.reset, - staleCursor: timeline.staleCursor, - gap: timeline.gap, - window: timeline.window, - hasOlder: timeline.hasOlder, - hasNewer: timeline.hasNewer, - rows: timeline.rows.map((row) => ({ - item: row.item, - timestamp: row.timestamp, - seq: row.seq, - })), - error: null, + const rows = this.supportsTimelineNotifications(source) + ? timeline.rows + : timeline.rows.filter((row) => row.item.type !== "notification"); + this.emitForSource( + { + type: "agent.provider_subagents.timeline.get.response", + payload: { + requestId: msg.requestId, + parentAgentId: msg.parentAgentId, + subagentId: msg.subagentId, + provider: descriptor.provider, + direction, + epoch: timeline.epoch, + reset: timeline.reset, + staleCursor: timeline.staleCursor, + gap: timeline.gap, + window: timeline.window, + hasOlder: timeline.hasOlder, + hasNewer: timeline.hasNewer, + rows: rows.map((row) => ({ + item: row.item, + timestamp: row.timestamp, + seq: row.seq, + })), + error: null, + }, }, - }); + source, + ); } catch (error) { - this.emit({ - type: "agent.provider_subagents.timeline.get.response", - payload: { - requestId: msg.requestId, - parentAgentId: msg.parentAgentId, - subagentId: msg.subagentId, - provider: null, - direction, - epoch: "", - reset: false, - staleCursor: false, - gap: false, - window: { minSeq: 0, maxSeq: 0, nextSeq: 0 }, - hasOlder: false, - hasNewer: false, - rows: [], - error: error instanceof Error ? error.message : String(error), + this.emitForSource( + { + type: "agent.provider_subagents.timeline.get.response", + payload: { + requestId: msg.requestId, + parentAgentId: msg.parentAgentId, + subagentId: msg.subagentId, + provider: null, + direction, + epoch: "", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 0, maxSeq: 0, nextSeq: 0 }, + hasOlder: false, + hasNewer: false, + rows: [], + error: error instanceof Error ? error.message : String(error), + }, }, - }); + source, + ); } } From 92442e743517cb4f1c304967bd79e2b9401a6ba6 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 3 Sep 2026 15:50:26 -0700 Subject: [PATCH 005/566] fix(server): reject pending Codex app-server requests on dispose (#4283) The provider refresh deadline aborts a Codex catalog probe by disposing the app-server client, but dispose killed the child without settling in-flight requests. With the request fallback timeout at 14 days, an unanswered `initialize` left `fetchCatalog` pending forever, so the workspace snapshot entry stayed "loading" and the model picker showed "Loading" for every Codex profile with no timeout ever surfacing. Co-authored-by: Claude Fable 5.1 --- ...codex-app-server-agent.spawn-error.test.ts | 29 ++++++++++++++++++- .../codex/app-server-transport.test.ts | 10 +++++++ .../providers/codex/app-server-transport.ts | 15 ++++++---- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts index bd1e0580f7..bff992e2ff 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; +import { runProviderRefreshWithDeadline } from "../provider-refresh-deadline.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { createCodexAppServerChildProcess } from "./codex/test-utils/fake-app-server.js"; describe("CodexAppServerAgentClient spawn error handling", () => { const logger = createTestLogger(); @@ -32,6 +34,31 @@ describe("CodexAppServerAgentClient spawn error handling", () => { } }); + test("fetchCatalog settles when the refresh deadline aborts an unanswered initialize", async () => { + const client = new CodexAppServerAgentClient(logger, { + command: { + mode: "replace", + argv: ["/nonexistent/codex-binary-that-does-not-exist"], + }, + }); + const child = createCodexAppServerChildProcess(); + const kill = vi.spyOn(child, "kill"); + Object.assign(client, { spawnAppServer: async () => child }); + + await expect( + runProviderRefreshWithDeadline({ + label: "Codex", + timeoutMs: 50, + operation: (context) => + client.fetchCatalog( + { scope: "workspace", cwd: "/tmp/codex-models", force: false }, + context, + ), + }), + ).rejects.toThrow("Timed out refreshing Codex after 50ms"); + expect(kill).toHaveBeenCalled(); + }); + test("listImportableSessions rejects gracefully when the codex binary does not exist", async () => { const client = new CodexAppServerAgentClient(logger, { command: { diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts index a6c5d2a8b2..1d7417510b 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts @@ -22,6 +22,16 @@ describe("Codex app-server transport", () => { child.stdin.end(); }); + test("dispose rejects pending requests instead of leaving them hanging", async () => { + const child = createCodexAppServerChildProcess(); + const client = new CodexAppServerClient(child, createTestLogger()); + + const request = client.request("initialize", {}); + await client.dispose(); + + await expect(request).rejects.toThrow("Codex app-server client is closed"); + }); + test.each([ "item/commandExecution/requestApproval", "item/fileChange/requestApproval", diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.ts index e143cd6863..32152e7ea5 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.ts @@ -261,6 +261,7 @@ export class CodexAppServerClient { this.disposed = true; this.unexpectedTerminationHandler = null; this.rl.close(); + this.rejectPending(new Error("Codex app-server client is closed")); try { this.child.stdin.end(); } catch { @@ -290,11 +291,7 @@ export class CodexAppServerClient { } this.disposed = true; this.rl.close(); - for (const pending of this.pending.values()) { - clearTimeout(pending.timer); - pending.reject(error); - } - this.pending.clear(); + this.rejectPending(error); const handler = this.unexpectedTerminationHandler; this.unexpectedTerminationHandler = null; if (!handler) { @@ -307,6 +304,14 @@ export class CodexAppServerClient { } } + private rejectPending(error: Error): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + } + private writeJsonRpcResponse(response: JsonRpcResponse): void { if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) { return; From 140b0bb716205cf0e00c0ff5b6b3c20af6c79413 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 4 Sep 2026 14:23:43 +0200 Subject: [PATCH 006/566] fix(app): keep long agent timelines smooth across autonomous turns Scope provider tool-call identity to its autonomous turn so repeated IDs remain distinct, lifecycle updates still merge correctly, and native timeline lists no longer receive duplicate keys. --- .../timeline/session-stream-reducers.test.ts | 45 +++++--- packages/app/src/types/stream.test.ts | 102 ++++++++++++++++++ packages/app/src/types/stream.ts | 52 ++++++--- 3 files changed, 173 insertions(+), 26 deletions(-) diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index ae2ba9bf53..13897f8454 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -49,6 +49,7 @@ function makeToolCallTimelineEntry( callId: string, status: "running" | "completed", detail: ToolCallDetail, + turnId?: string, ): TimelineResponseEntry { return { seqStart: seq, @@ -62,6 +63,7 @@ function makeToolCallTimelineEntry( detail, error: null, }, + ...(turnId ? { turnId } : {}), timestamp: new Date(1000 + seq).toISOString(), }; } @@ -3197,12 +3199,14 @@ describe("processTimelineResponse", () => { it("coalesces tool call lifecycle rows across the older-page prepend boundary", () => { const callId = "toolu_boundary"; + const turnId = "turn-boundary"; const currentTail = hydrateStreamState( [ { event: { type: "timeline", provider: "claude", + turnId, item: makeToolCallTimelineEntry(3, callId, "completed", { type: "read", filePath: "/tmp/example.ts", @@ -3230,11 +3234,17 @@ describe("processTimelineResponse", () => { startCursor: { seq: 1 }, endCursor: { seq: 2 }, entries: [ - makeToolCallTimelineEntry(1, callId, "running", { - type: "unknown", - input: { file_path: "/tmp/example.ts" }, - output: null, - }), + makeToolCallTimelineEntry( + 1, + callId, + "running", + { + type: "unknown", + input: { file_path: "/tmp/example.ts" }, + output: null, + }, + turnId, + ), ], }, }); @@ -3249,7 +3259,7 @@ describe("processTimelineResponse", () => { })), ).toEqual([ { - id: `agent_tool_${callId}`, + id: `agent_tool_turn:${turnId}/${callId}`, callId, status: "completed", detailType: "read", @@ -3358,6 +3368,8 @@ describe("processTimelineResponse", () => { it("does not coalesce tool call lifecycle rows away from the prepend boundary", () => { const callId = "toolu_not_boundary"; + const olderTurnId = "autonomous-turn-1"; + const currentTurnId = "autonomous-turn-2"; const currentTail = hydrateStreamState( [ { @@ -3368,6 +3380,7 @@ describe("processTimelineResponse", () => { event: { type: "timeline", provider: "claude", + turnId: currentTurnId, item: makeToolCallTimelineEntry(4, callId, "completed", { type: "read", filePath: "/tmp/example.ts", @@ -3395,11 +3408,17 @@ describe("processTimelineResponse", () => { startCursor: { seq: 1 }, endCursor: { seq: 2 }, entries: [ - makeToolCallTimelineEntry(1, callId, "running", { - type: "unknown", - input: { file_path: "/tmp/example.ts" }, - output: null, - }), + makeToolCallTimelineEntry( + 1, + callId, + "running", + { + type: "unknown", + input: { file_path: "/tmp/example.ts" }, + output: null, + }, + olderTurnId, + ), makeTimelineEntry(2, "older chunk "), ], }, @@ -3415,7 +3434,7 @@ describe("processTimelineResponse", () => { ).toEqual([ { kind: "tool_call", - id: `agent_tool_${callId}`, + id: `agent_tool_turn:${olderTurnId}/${callId}`, status: "running", text: null, }, @@ -3427,7 +3446,7 @@ describe("processTimelineResponse", () => { }, { kind: "tool_call", - id: `agent_tool_${callId}`, + id: `agent_tool_turn:${currentTurnId}/${callId}`, status: "completed", text: null, }, diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index 2bc47c9288..cd05fd0826 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -312,6 +312,7 @@ function reasoningTimeline( function canonicalToolTimeline(params: { provider: AgentProvider; callId: string; + turnId?: string; name: string; status: CanonicalToolStatus; input?: unknown; @@ -350,6 +351,7 @@ function canonicalToolTimeline(params: { return { type: "timeline", provider: params.provider, + ...(params.turnId ? { turnId: params.turnId } : {}), item, }; } @@ -584,6 +586,106 @@ describe("stream reducer tool call idempotency", () => { }); describe("stream reducer canonical tool calls", () => { + it("keeps repeated call ids in different turns as distinct timeline rows", () => { + const callId = "tool-reused-across-turns"; + const state = hydrateStreamState([ + { + event: canonicalToolTimeline({ + provider: "claude", + callId, + turnId: "autonomous-turn-1", + name: "Task", + status: "running", + }), + timestamp: new Date("2025-01-01T09:00:00Z"), + }, + { + event: canonicalToolTimeline({ + provider: "claude", + callId, + turnId: "autonomous-turn-2", + name: "Task", + status: "completed", + }), + timestamp: new Date("2025-01-01T09:01:00Z"), + }, + ]); + + const tools = state.filter(isAgentToolCallItem); + expect(tools.map((tool) => tool.turnId)).toEqual(["autonomous-turn-1", "autonomous-turn-2"]); + expect(new Set(tools.map((tool) => tool.id)).size).toBe(2); + }); + + it("merges lifecycle updates for the same call occurrence", () => { + const callId = "tool-updated-within-turn"; + const turnId = "autonomous-turn-1"; + const state = hydrateStreamState([ + { + event: canonicalToolTimeline({ + provider: "claude", + callId, + turnId, + name: "Task", + status: "running", + }), + timestamp: new Date("2025-01-01T09:00:00Z"), + }, + { + event: canonicalToolTimeline({ + provider: "claude", + callId, + turnId, + name: "Task", + status: "completed", + }), + timestamp: new Date("2025-01-01T09:01:00Z"), + }, + ]); + + expect(state.filter(isAgentToolCallItem)).toEqual([ + expect.objectContaining({ + id: `agent_tool_turn:${turnId}/${callId}`, + turnId, + payload: expect.objectContaining({ + data: expect.objectContaining({ callId, status: "completed" }), + }), + }), + ]); + }); + + it("preserves call-id lifecycle merging for events without turn identity", () => { + const callId = "legacy-unscoped-tool"; + const state = hydrateStreamState([ + { + event: canonicalToolTimeline({ + provider: "claude", + callId, + name: "Task", + status: "running", + }), + timestamp: new Date("2025-01-01T09:00:00Z"), + }, + { + event: canonicalToolTimeline({ + provider: "claude", + callId, + name: "Task", + status: "completed", + }), + timestamp: new Date("2025-01-01T09:01:00Z"), + }, + ]); + + expect(state.filter(isAgentToolCallItem)).toEqual([ + expect.objectContaining({ + id: `agent_tool_${callId}`, + payload: expect.objectContaining({ + data: expect.objectContaining({ callId, status: "completed" }), + }), + }), + ]); + }); + it("is deterministic for equivalent hydration sequences", () => { const updates = [ { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 93c404804f..4ebd0972db 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -449,7 +449,11 @@ function mergeRetainedLifecycleItem(tail: StreamItem[], retained: StreamItem): S return null; } if (isAgentToolCallItem(retained)) { - const tailIndex = findExistingTimelineIdentityIndex(tail, retained.payload.data.callId); + const identity = agentToolCallIdentity({ + callId: retained.payload.data.callId, + turnId: retained.turnId, + }); + const tailIndex = findExistingTimelineIdentityIndex(tail, identity); const existing = tail[tailIndex]; if (tailIndex < 0 || !existing || !isAgentToolCallItem(existing)) { return null; @@ -1022,11 +1026,26 @@ function finalizeActiveThoughts(state: StreamItem[]): StreamItem[] { } export function streamTimelineItemIdentity(item: StreamItem): string | null { - if (isAgentToolCallItem(item)) return item.payload.data.callId; + if (isAgentToolCallItem(item)) { + return agentToolCallIdentity({ + callId: item.payload.data.callId, + turnId: item.turnId, + }); + } if (item.kind === "plugin") return `${item.pluginId}/${item.pluginItemId}`; return null; } +interface AgentToolCallIdentityInput { + callId: string; + turnId?: string; +} + +function agentToolCallIdentity(input: AgentToolCallIdentityInput): string { + if (!input.turnId) return input.callId; + return `turn:${encodeURIComponent(input.turnId)}/${encodeURIComponent(input.callId)}`; +} + function findExistingTimelineIdentityIndex(state: StreamItem[], identity: string): number { return state.findIndex((entry) => streamTimelineItemIdentity(entry) === identity); } @@ -1165,13 +1184,18 @@ export function mergeAgentToolCallItem( }; } -function appendAgentToolCall( - state: StreamItem[], - data: AgentToolCallData, - timestamp: Date, - timelineCursor?: TimelinePosition, -): StreamItem[] { - const existingIndex = findExistingTimelineIdentityIndex(state, data.callId); +interface AppendAgentToolCallInput { + state: StreamItem[]; + data: AgentToolCallData; + timestamp: Date; + turnId?: string; + timelineCursor?: TimelinePosition; +} + +function appendAgentToolCall(input: AppendAgentToolCallInput): StreamItem[] { + const { state, data, timestamp, turnId, timelineCursor } = input; + const identity = agentToolCallIdentity({ callId: data.callId, turnId }); + const existingIndex = findExistingTimelineIdentityIndex(state, identity); if (existingIndex >= 0) { const existing = state[existingIndex]; @@ -1200,8 +1224,9 @@ function appendAgentToolCall( const item: ToolCallItem = { kind: "tool_call", - id: `agent_tool_${data.callId}`, + id: `agent_tool_${identity}`, ...(timelineCursor ? { timelineCursor } : {}), + ...(turnId ? { turnId } : {}), timestamp, payload: { source: "agent", @@ -1415,9 +1440,9 @@ function reduceTimelineToolCall( ); } - return appendAgentToolCall( + return appendAgentToolCall({ state, - { + data: { provider: event.provider, callId: item.callId, name: item.name, @@ -1428,7 +1453,8 @@ function reduceTimelineToolCall( }, timestamp, timelineCursor, - ); + turnId: event.turnId, + }); } function reduceTimelineCompaction( From 35d2457d8326b7e4950d5bf5fef6463550d15f67 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 4 Sep 2026 19:56:57 +0200 Subject: [PATCH 007/566] Let plugins provide coding agents (#4314) Allow trusted local plugins to register complete coding-agent providers through the existing agent lifecycle. Includes direct and ACP implementations, SVG provider icons, custom timeline items, runnable examples, and public authoring documentation. Closes #3547 Closes #3556 --- docs/plugins.md | 46 + nix/npm-deps.hash | 2 +- package-lock.json | 10 + .../agent-control-registration.tsx | 3 +- packages/app/src/components/agent-list.tsx | 2 +- .../src/components/import-session-sheet.tsx | 14 +- packages/app/src/components/model-browser.tsx | 50 +- .../src/components/provider-icon-name.test.ts | 44 +- .../app/src/components/provider-icon-name.ts | 27 +- .../app/src/components/provider-icons.test.ts | 33 + packages/app/src/components/provider-icons.ts | 26 +- .../schedules/schedule-form-sheet.tsx | 14 +- .../src/components/schedules/schedule-row.tsx | 14 +- .../components/schedules/schedules-table.tsx | 1 + .../composer/agent-controls/model-sheet.tsx | 2 +- packages/app/src/data/push-router.ts | 2 + .../src/hooks/use-providers-snapshot.test.ts | 19 + .../app/src/hooks/use-providers-snapshot.ts | 3 + packages/app/src/panels/agent-panel.tsx | 2 +- packages/app/src/panels/agent-tracks.tsx | 1 + .../src/panels/provider-subagent-panel.tsx | 2 +- .../screens/settings/providers-section.tsx | 5 +- packages/app/src/subagents/track.tsx | 11 +- packages/desktop/e2e/browser-tabs.e2e.mjs | 2 +- packages/plugin/package.json | 11 + .../plugin/src/acp-internal/connection.ts | 1373 ++++++++++++++ packages/plugin/src/acp.test.ts | 985 ++++++++++ packages/plugin/src/acp.ts | 122 ++ packages/plugin/src/contracts.ts | 2 + packages/plugin/src/provider.ts | 1376 ++++++++++++++ packages/plugin/tsconfig.json | 1 + packages/protocol/src/agent-types.ts | 1 + packages/protocol/src/messages.ts | 1 + .../src/provider-snapshot-codec.test.ts | 1 + .../src/server/agent/agent-sdk-types.ts | 1 + .../src/server/agent/plugin-provider.test.ts | 347 ++++ .../src/server/agent/plugin-provider.ts | 1674 +++++++++++++++++ .../src/server/agent/provider-registry.ts | 1 + .../agent/provider-snapshot-manager.test.ts | 73 + .../server/agent/provider-snapshot-manager.ts | 64 +- .../providers/provider-windows-launch.test.ts | 2 +- packages/server/src/server/bootstrap.ts | 9 + .../src/server/plugins/index.posix.test.ts | 53 + packages/server/src/server/plugins/index.ts | 123 +- .../server/plugins/plugin-process-protocol.ts | 173 +- .../src/server/plugins/plugin-process.ts | 208 +- .../src/server/plugins/provider-icon.test.ts | 84 + .../src/server/plugins/provider-icon.ts | 64 + .../src/server/plugins/runtime.posix.test.ts | 870 ++++++++- packages/server/src/server/plugins/runtime.ts | 543 +++++- .../provider-acp-transformer/README.md | 11 + .../provider-acp-transformer/icon.svg | 4 + .../provider-acp-transformer/index.server.ts | 17 + .../paseo-plugin.json | 6 + .../server/vendor-edit.test.ts | 42 + .../server/vendor-edit.ts | 25 + plugin-examples/provider-direct/README.md | 12 + .../client/provider-result.tsx | 32 + plugin-examples/provider-direct/icon.svg | 4 + .../provider-direct/index.client.tsx | 13 + .../provider-direct/index.server.ts | 7 + .../provider-direct/paseo-plugin.json | 3 + .../provider-direct/server/provider.test.ts | 169 ++ .../provider-direct/server/provider.ts | 401 ++++ .../provider-direct/shared/provider-result.ts | 8 + public-docs/plugins/v0.8/index.md | 5 +- public-docs/plugins/v0.8/providers.md | 407 ++++ public-docs/plugins/v0.8/reference.md | 39 +- 68 files changed, 9577 insertions(+), 125 deletions(-) create mode 100644 packages/app/src/components/provider-icons.test.ts create mode 100644 packages/plugin/src/acp-internal/connection.ts create mode 100644 packages/plugin/src/acp.test.ts create mode 100644 packages/plugin/src/acp.ts create mode 100644 packages/plugin/src/provider.ts create mode 100644 packages/server/src/server/agent/plugin-provider.test.ts create mode 100644 packages/server/src/server/agent/plugin-provider.ts create mode 100644 packages/server/src/server/plugins/provider-icon.test.ts create mode 100644 packages/server/src/server/plugins/provider-icon.ts create mode 100644 plugin-examples/provider-acp-transformer/README.md create mode 100644 plugin-examples/provider-acp-transformer/icon.svg create mode 100644 plugin-examples/provider-acp-transformer/index.server.ts create mode 100644 plugin-examples/provider-acp-transformer/paseo-plugin.json create mode 100644 plugin-examples/provider-acp-transformer/server/vendor-edit.test.ts create mode 100644 plugin-examples/provider-acp-transformer/server/vendor-edit.ts create mode 100644 plugin-examples/provider-direct/README.md create mode 100644 plugin-examples/provider-direct/client/provider-result.tsx create mode 100644 plugin-examples/provider-direct/icon.svg create mode 100644 plugin-examples/provider-direct/index.client.tsx create mode 100644 plugin-examples/provider-direct/index.server.ts create mode 100644 plugin-examples/provider-direct/paseo-plugin.json create mode 100644 plugin-examples/provider-direct/server/provider.test.ts create mode 100644 plugin-examples/provider-direct/server/provider.ts create mode 100644 plugin-examples/provider-direct/shared/provider-result.ts create mode 100644 public-docs/plugins/v0.8/providers.md diff --git a/docs/plugins.md b/docs/plugins.md index 2b523a7c1c..03f38b6172 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -165,6 +165,8 @@ See `public-docs/plugins/v0.8/reference.md`. | `@getpaseo/plugin` | contribution contracts, shared definitions, RPC input/output types, and client data hooks | | `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks | | `@getpaseo/plugin/server` | handler-only types such as `PluginHandlerContext` | +| `@getpaseo/plugin/provider` | provider registration, connection, session, input, event, and timeline contracts | +| `@getpaseo/plugin/acp` | command-backed ACP adapter and focused transformer hooks | The compiler rejects a client import of `server/`, a server import of `client/`, and every `node:` import reachable from client code. Shared modules cannot import runtime-owned modules. A relative @@ -253,6 +255,50 @@ optional client-owned agent and workspace navigation; its absence is the compati older clients. Other navigation remains limited to registered global surfaces and workspace panels. Plugins do not receive Expo Router or workspace-layout store access. +## Contribute a provider + +Register a provider from `index.server.ts`. The provider connection is callback-based and owns all +of its sessions; plugin RPC is not part of the provider data path. + +```ts +import type { PluginServerContext } from "@getpaseo/plugin"; +import type { ProviderRegistration } from "@getpaseo/plugin/provider"; +import { createProvider } from "./server/provider"; + +export default function contribute(server: PluginServerContext) { + server.registerProvider(createProvider() satisfies ProviderRegistration); + return () => {}; +} +``` + +`send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state, +configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`. +Route messages, structured commands, steering, and command side effects through `session.prompt`. +Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in +the opaque `providerOptions` config object. + +Agent refresh closes the current provider session and opens it again with current configuration and +persistence. Providers re-read credentials, environment, global configuration, and MCP servers on +`session.open`; there is no provider reload input. + +For an ACP command, register `runAcpProvider({ id, label, command })` from +`@getpaseo/plugin/acp`. Its transformer hooks cover narrow vendor differences; do not translate the +whole provider event stream. The direct and ACP examples live in `plugin-examples/provider-direct` +and `plugin-examples/provider-acp-transformer`. + +Provider-emitted plugin timeline items use the same renderer registration as transformed and +daemon-appended plugin items. The direct example includes both sides. The renderer-only +`plugin-examples/inline-thinking` example shows that timeline presentation remains independent of a +provider implementation. The public [provider plugin guide](../public-docs/plugins/v0.8/providers.md) +owns author workflow, lifecycle, testing, and distribution guidance. + +`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`. +It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be +self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and +external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are +allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline +SVG or URL. + ## Contribute composer pills Add and remove targeted pills from the client entry lifecycle. `index.client.tsx` runs once per diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 4d257077c9..511b1444e3 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-R5NuJTwMOYPo0i60qhF0lZU+OMHwjnIKf9gI1fCJ9/M= +sha256-1kiHsXpooSqC38MwZP2E7rQK+Zhuv0fUOxUHfnM30wA= diff --git a/package-lock.json b/package-lock.json index 6e9576aba6..a7daa8db3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38799,6 +38799,7 @@ "name": "@getpaseo/plugin", "version": "0.7.2", "dependencies": { + "@agentclientprotocol/sdk": "^1.4.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { @@ -38818,6 +38819,15 @@ "zod": "^4.4.3" } }, + "packages/plugin/node_modules/@agentclientprotocol/sdk": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.4.0.tgz", + "integrity": "sha512-/eufudw+aFY1LKLolT6yFE6UMmYRl7fMJ/DEONSIyR6wI3slHWITBsANRGqXEY8FRzqUxwh7QEaGiZHcJPVThg==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "packages/protocol": { "name": "@getpaseo/protocol", "version": "0.7.2", diff --git a/packages/app/src/command-center/agent-control-registration.tsx b/packages/app/src/command-center/agent-control-registration.tsx index ecc7e8985c..50e61ed9fd 100644 --- a/packages/app/src/command-center/agent-control-registration.tsx +++ b/packages/app/src/command-center/agent-control-registration.tsx @@ -96,7 +96,8 @@ export function useAgentControlCommandCenterActions(input: { provider: controls.provider ?? null, labels: buildAgentControlContributionLabels(t), icons: { - provider: (provider) => getCommandCenterIcon(getProviderIcon(provider)), + provider: (provider) => + getCommandCenterIcon(getProviderIcon(provider, controls.serverId)), thinking: getCommandCenterIcon(ThinkingIcon), planMode: getCommandCenterIcon(PlanModeIcon), mode: (modeId) => diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 5083d63916..58023da386 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -232,7 +232,7 @@ function SessionRow({ const projectName = agent.projectPlacement?.projectName ?? ""; const branch = agent.projectPlacement?.checkout.currentBranch ?? ""; const workspaceName = agent.projectPlacement?.workspaceName ?? ""; - const ProviderIcon = getProviderIcon(agent.provider); + const ProviderIcon = getProviderIcon(agent.provider, agent.serverId); const pendingPermissionCount = agent.pendingPermissionCount ?? 0; const rangesFor = useCallback( (field: AgentSearchMatch["field"]) => diff --git a/packages/app/src/components/import-session-sheet.tsx b/packages/app/src/components/import-session-sheet.tsx index 4ae38a9aea..ebfabd08f5 100644 --- a/packages/app/src/components/import-session-sheet.tsx +++ b/packages/app/src/components/import-session-sheet.tsx @@ -290,12 +290,14 @@ function SheetEmptyState({ title }: { title: string }) { } function ImportSessionSheetRow({ + serverId, entry, disabled, importing, folder, onImportSession, }: { + serverId: string | null; entry: FetchRecentProviderSessionEntry; disabled: boolean; importing: boolean; @@ -308,7 +310,7 @@ function ImportSessionSheetRow({ const title = getSessionTitle(entry); const promptPreview = getPromptPreview(entry); const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt)); - const ProviderIcon = getProviderIcon(entry.providerId); + const ProviderIcon = getProviderIcon(entry.providerId, serverId); const accessibilityState = useMemo( () => (disabled ? DISABLED_ACCESSIBILITY_STATE : undefined), [disabled], @@ -364,12 +366,14 @@ function ImportSessionSheetRow({ } function SessionRows({ + serverId, entries, disabled, importingSessionKey, resolveFolder, onImportSession, }: { + serverId: string | null; entries: ReadonlyArray; disabled: boolean; importingSessionKey: string | null; @@ -381,6 +385,7 @@ function SessionRows({ {entries.map((entry) => ( (); map.set(ALL_FILTER_VALUE, ); for (const provider of filterProviders) { - const ProviderIcon = getProviderIcon(provider); + const ProviderIcon = getProviderIcon(provider, serverId); map.set(provider, ); } return map; - }, [filterProviders, theme.colors.foregroundMuted]); + }, [filterProviders, serverId, theme.colors.foregroundMuted]); const renderFilterOption = useCallback( ({ @@ -736,7 +741,7 @@ export function ImportSessionSheet({ ) : ( (() => { - const ProviderIcon = getProviderIcon(selectedProvider); + const ProviderIcon = getProviderIcon(selectedProvider, serverId); return ; })() )} @@ -773,6 +778,7 @@ export function ImportSessionSheet({ ) : null} {visibleEntries.length > 0 ? ( { + serverId: string | null; view: ModelBrowserView; providers: ProviderSelectorProvider[]; selectedProvider: string; @@ -209,14 +211,16 @@ type ProviderGlyphTone = "muted" | "foreground"; export function ModelProviderGlyph({ provider, + serverId, size, tone = "muted", }: { provider: string; + serverId?: string | null; size: number; tone?: ProviderGlyphTone; }) { - const Icon = getProviderIcon(provider); + const Icon = getProviderIcon(provider, serverId); const color = tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color; return ; @@ -330,7 +334,12 @@ export function useModelBrowser({ return { title: view.providerLabel, leading: ( - + ), back: singleProviderView ? undefined : { onPress: showAll }, actions: ( @@ -389,6 +398,7 @@ export function useModelBrowser({ ); return { + serverId, providers, selectedProvider, selectedModel, @@ -652,6 +662,7 @@ function ModelRowProfileAction({ function ModelRow({ row, + serverId, isSelected, showProviderLabel = false, onPress, @@ -661,6 +672,7 @@ function ModelRow({ onEditProfiles, }: { row: ProviderSelectionModelRow; + serverId: string | null; isSelected: boolean; showProviderLabel?: boolean; onPress: () => void; @@ -672,8 +684,8 @@ function ModelRow({ const { t } = useTranslation(); const [isHovered, setIsHovered] = useState(false); const leadingSlot = useMemo( - () => , - [row.provider], + () => , + [row.provider, serverId], ); const description = showProviderLabel ? buildProviderQualifiedDescription(row) : row.description; @@ -810,6 +822,7 @@ function ModelRow({ function SelectableModelRow({ row, + serverId, isSelected, showProviderLabel, onSelect, @@ -819,6 +832,7 @@ function SelectableModelRow({ onEditProfiles, }: { row: ProviderSelectionModelRow; + serverId: string | null; isSelected: boolean; showProviderLabel?: boolean; onSelect: (provider: string, modelId: string) => void; @@ -833,6 +847,7 @@ function SelectableModelRow({ return ( void; }) { const { t } = useTranslation(); @@ -988,8 +1005,8 @@ function GroupProviderButton({ ); }, [selection, t]); const leadingSlot = useMemo( - () => , - [provider.id], + () => , + [provider.id, serverId], ); const trailingSlot = useMemo( () => ( @@ -1016,9 +1033,11 @@ function GroupProviderButton({ function GroupedProviderRows({ providers, + serverId, onDrillDown, }: { providers: ProviderSelectorProvider[]; + serverId: string | null; onDrillDown: (providerId: string, providerLabel: string) => void; }) { return ( @@ -1026,7 +1045,7 @@ function GroupedProviderRows({ {providers.map((provider, index) => ( {index > 0 ? : null} - + ))} @@ -1111,6 +1130,7 @@ function IndependentProviderList({ children }: { children: React.ReactNode }) { function ModelRowList({ rows, + serverId, selectedProvider, selectedModel, onSelect, @@ -1123,6 +1143,7 @@ function ModelRowList({ onEditProfiles, }: { rows: ProviderSelectionModelRow[]; + serverId: string | null; selectedProvider: string; selectedModel: string; onSelect: (provider: string, modelId: string) => void; @@ -1139,6 +1160,7 @@ function ModelRowList({ ({ item }: { item: ProviderSelectionModelRow }) => ( ; provider: ProviderSelectorProvider | null; profiles: AgentProfilePicker | null; @@ -1318,6 +1343,7 @@ function ProviderModelBrowserContent({ } return ( {t("modelSelector.providers")} ) : null} - + ) : null)} {!hasResults ? : null} @@ -1487,6 +1520,7 @@ export function ModelBrowser({ }: ModelBrowserProps) { return ( { it("returns the built-in identifier for known provider ids", () => { @@ -25,6 +25,48 @@ describe("resolveProviderIconName", () => { it("falls back to the bot icon for unknown custom providers", () => { expect(resolveProviderIconName("custom-claude-profile")).toEqual({ kind: "bot" }); }); + + it("resolves a snapshot SVG for a custom provider", () => { + const svg = ''; + + replaceProviderSnapshotIcons("server-1", [{ provider: "snapshot-provider", iconSvg: svg }]); + + expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({ kind: "svg", svg }); + }); + + it("replaces each host snapshot without leaking icons across hosts", () => { + const secondSvg = ""; + replaceProviderSnapshotIcons("server-1", [ + { provider: "removed-provider", iconSvg: "" }, + { provider: "snapshot-provider", iconSvg: "" }, + { provider: "claude", iconSvg: "" }, + ]); + replaceProviderSnapshotIcons("server-2", [ + { provider: "snapshot-provider", iconSvg: secondSvg }, + ]); + + expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({ + kind: "svg", + svg: "", + }); + expect(resolveProviderIconName("snapshot-provider", "server-2")).toEqual({ + kind: "svg", + svg: secondSvg, + }); + expect(resolveProviderIconName("claude", "server-1")).toEqual({ + kind: "builtin", + id: "claude", + }); + + replaceProviderSnapshotIcons("server-1", [{ provider: "snapshot-provider" }]); + + expect(resolveProviderIconName("removed-provider", "server-1")).toEqual({ kind: "bot" }); + expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({ kind: "bot" }); + expect(resolveProviderIconName("snapshot-provider", "server-2")).toEqual({ + kind: "svg", + svg: secondSvg, + }); + }); }); describe("known provider icon names", () => { diff --git a/packages/app/src/components/provider-icon-name.ts b/packages/app/src/components/provider-icon-name.ts index 02cd2ec874..699865fd01 100644 --- a/packages/app/src/components/provider-icon-name.ts +++ b/packages/app/src/components/provider-icon-name.ts @@ -2,19 +2,44 @@ import { BUILTIN_PROVIDER_ICON_NAMES, KNOWN_PROVIDER_ICON_NAMES, } from "@getpaseo/protocol/provider-icon-names"; +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; export type ProviderIconName = | { kind: "builtin"; id: string } | { kind: "catalog"; id: string } + | { kind: "svg"; svg: string } | { kind: "bot" }; const BUILTIN_PROVIDER_IDS = new Set(BUILTIN_PROVIDER_ICON_NAMES); const KNOWN_PROVIDER_IDS = new Set(KNOWN_PROVIDER_ICON_NAMES); +const providerSnapshotIconSvgsByServer = new Map>(); -export function resolveProviderIconName(provider: string): ProviderIconName { +export function replaceProviderSnapshotIcons( + serverId: string, + entries: readonly Pick[], +): void { + const icons = new Map(); + for (const entry of entries) { + if (entry.iconSvg) { + icons.set(entry.provider, entry.iconSvg); + } + } + providerSnapshotIconSvgsByServer.set(serverId, icons); +} + +export function resolveProviderIconName( + provider: string, + serverId?: string | null, +): ProviderIconName { if (BUILTIN_PROVIDER_IDS.has(provider)) { return { kind: "builtin", id: provider }; } + const iconSvg = serverId + ? providerSnapshotIconSvgsByServer.get(serverId)?.get(provider) + : undefined; + if (iconSvg) { + return { kind: "svg", svg: iconSvg }; + } if (KNOWN_PROVIDER_IDS.has(provider)) { return { kind: "catalog", id: provider }; } diff --git a/packages/app/src/components/provider-icons.test.ts b/packages/app/src/components/provider-icons.test.ts new file mode 100644 index 0000000000..5235218875 --- /dev/null +++ b/packages/app/src/components/provider-icons.test.ts @@ -0,0 +1,33 @@ +import { Bot } from "lucide-react-native"; +import { SvgXml } from "react-native-svg"; +import { describe, expect, it } from "vitest"; +import { replaceProviderSnapshotIcons } from "./provider-icon-name"; +import { getProviderIcon, type ProviderIconComponent } from "./provider-icons"; + +function renderIcon(Component: ProviderIconComponent) { + if (typeof Component !== "function") throw new Error("Expected a function component"); + return (Component as (props: { size: number; color: string }) => unknown)({ + size: 18, + color: "#123456", + }); +} + +describe("getProviderIcon", () => { + it("renders registered snapshot SVG metadata with the requested size and color", () => { + const svg = ''; + replaceProviderSnapshotIcons("server-1", [{ provider: "rendered-provider", iconSvg: svg }]); + + const rendered = renderIcon(getProviderIcon("rendered-provider", "server-1")); + + expect(rendered).toMatchObject({ + type: SvgXml, + props: { xml: svg, width: 18, height: 18, color: "#123456" }, + }); + }); + + it("uses the normal Bot fallback without snapshot SVG metadata", () => { + replaceProviderSnapshotIcons("server-1", [{ provider: "plain-provider" }]); + + expect(getProviderIcon("plain-provider", "server-1")).toBe(Bot); + }); +}); diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index 92d8c1ca38..6671c95d4d 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -34,17 +34,18 @@ const CATALOG_ICON_SVGS = new Map( ); const catalogIconComponents = new Map(); +const snapshotIconComponents = new Map(); -function createCatalogIcon(provider: string, iconSvg: string): ProviderIconComponent { - const CatalogProviderIcon: ProviderIconComponent = ({ size, color }) => +function createSvgIcon(provider: string, iconSvg: string): ProviderIconComponent { + const SvgProviderIcon: ProviderIconComponent = ({ size, color }) => createElement(SvgXml, { xml: iconSvg, width: size, height: size, color, }); - CatalogProviderIcon.displayName = `CatalogProviderIcon(${provider})`; - return CatalogProviderIcon; + SvgProviderIcon.displayName = `SvgProviderIcon(${provider})`; + return SvgProviderIcon; } function getCatalogProviderIcon(provider: string): ProviderIconComponent { @@ -56,18 +57,29 @@ function getCatalogProviderIcon(provider: string): ProviderIconComponent { if (!iconSvg) { return Bot; } - const icon = createCatalogIcon(provider, iconSvg); + const icon = createSvgIcon(provider, iconSvg); catalogIconComponents.set(provider, icon); return icon; } -export function getProviderIcon(provider: string): ProviderIconComponent { - const name = resolveProviderIconName(provider); +function getSnapshotProviderIcon(provider: string, svg: string): ProviderIconComponent { + const cached = snapshotIconComponents.get(provider); + if (cached?.svg === svg) return cached.component; + const component = createSvgIcon(provider, svg); + snapshotIconComponents.set(provider, { svg, component }); + return component; +} + +export function getProviderIcon(provider: string, serverId?: string | null): ProviderIconComponent { + const name = resolveProviderIconName(provider, serverId); if (name.kind === "builtin") { return BUILTIN_PROVIDER_ICONS[name.id]; } if (name.kind === "catalog") { return getCatalogProviderIcon(name.id); } + if (name.kind === "svg") { + return getSnapshotProviderIcon(`${serverId}:${provider}`, name.svg); + } return Bot; } diff --git a/packages/app/src/components/schedules/schedule-form-sheet.tsx b/packages/app/src/components/schedules/schedule-form-sheet.tsx index 6b9290632a..2a3cd97c10 100644 --- a/packages/app/src/components/schedules/schedule-form-sheet.tsx +++ b/packages/app/src/components/schedules/schedule-form-sheet.tsx @@ -661,8 +661,8 @@ function ScheduleTargetFields({ [], ); const modelTriggerLeading = useMemo( - () => , - [state.selectedProvider], + () => , + [state.selectedProvider, state.selectedServerId], ); const renderModelTrigger = useCallback( ({ @@ -1019,11 +1019,17 @@ function ThinkingOptionItem({ ); } -function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null { +function ProviderGlyph({ + provider, + serverId, +}: { + provider: string | null; + serverId: string | null; +}): ReactElement | null { if (!provider) { return null; } - const Icon = getProviderIcon(provider); + const Icon = getProviderIcon(provider, serverId); return ; } diff --git a/packages/app/src/components/schedules/schedule-row.tsx b/packages/app/src/components/schedules/schedule-row.tsx index f2799fcf54..07051a54a0 100644 --- a/packages/app/src/components/schedules/schedule-row.tsx +++ b/packages/app/src/components/schedules/schedule-row.tsx @@ -59,6 +59,7 @@ export interface ScheduleRowActions { } interface ScheduleRowProps extends ScheduleRowActions { + serverId: string; schedule: ScheduleSummary; /** Client-derived target line (agent title / project / shortened path). */ targetLabel: string; @@ -120,11 +121,17 @@ function buildMeta( /** Small provider glyph. Reads the icon color off a StyleSheet object so the * dynamic component (getProviderIcon) stays compliant without useUnistyles. */ -function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null { +function ProviderGlyph({ + provider, + serverId, +}: { + provider: string | null; + serverId: string; +}): ReactElement | null { if (!provider) { return null; } - const Icon = getProviderIcon(provider); + const Icon = getProviderIcon(provider, serverId); return ; } @@ -138,6 +145,7 @@ function ProviderGlyph({ provider }: { provider: string | null }): ReactElement * highlights without reflow. */ export function ScheduleRow({ + serverId, schedule, targetLabel, provider, @@ -189,7 +197,7 @@ export function ScheduleRow({ > - + diff --git a/packages/app/src/components/schedules/schedules-table.tsx b/packages/app/src/components/schedules/schedules-table.tsx index c591b03d42..d91f95cb72 100644 --- a/packages/app/src/components/schedules/schedules-table.tsx +++ b/packages/app/src/components/schedules/schedules-table.tsx @@ -129,6 +129,7 @@ function SchedulesTableRow({ return ( 0 ? getProviderIcon(selectedProvider) : null; + selectedProvider.trim().length > 0 ? getProviderIcon(selectedProvider, serverId) : null; const ModelIcon = ProviderIcon ?? Bot; const rootHeader = useMemo( () => ({ diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index 0af2e8e684..74002bac2e 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -5,6 +5,7 @@ import type { SessionOutboundMessage, } from "@getpaseo/protocol/messages"; import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; +import { replaceProviderSnapshotIcons } from "@/components/provider-icon-name"; import { orderCheckoutDiffFiles } from "@/git/diff-order"; import { daemonConfigQueryKey } from "@/data/daemon-config"; import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing"; @@ -194,6 +195,7 @@ export function applyProvidersSnapshotUpdate(input: { if (input.message.type !== "providers_snapshot_update") { return; } + replaceProviderSnapshotIcons(input.serverId, input.message.payload.entries); const queryKey = providersSnapshotQueryKey(input.serverId, input.message.payload.cwd); input.queryClient.setQueryData(queryKey, { entries: input.message.payload.entries, diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts index 620668e1fb..08eaa5130f 100644 --- a/packages/app/src/hooks/use-providers-snapshot.test.ts +++ b/packages/app/src/hooks/use-providers-snapshot.test.ts @@ -6,6 +6,7 @@ import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; import type { CachedProviderSnapshot, ProviderSnapshotCache } from "@/data/provider-snapshot-cache"; import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query"; +import { resolveProviderIconName } from "@/components/provider-icon-name"; import { applyProvidersSnapshotUpdate, type ProvidersSnapshotUpdate } from "@/data/push-router"; import { fetchProvidersSnapshot, @@ -109,6 +110,24 @@ describe("providersSnapshotQueryKey", () => { }); describe("fetchProvidersSnapshot", () => { + it("registers provider icons from the applied snapshot", async () => { + const svg = ''; + const client = createClient({ + snapshots: [ + providersSnapshot([ + { provider: "snapshot-icon-provider", status: "ready", enabled: true, iconSvg: svg }, + ]), + ], + }); + + await fetchProvidersSnapshot({ client, serverId, cwd: null, cache: createCache() }); + + expect(resolveProviderIconName("snapshot-icon-provider", serverId)).toEqual({ + kind: "svg", + svg, + }); + }); + it("sends no cwd for the home scope", async () => { const client = createClient({ snapshots: [providersSnapshot([])] }); diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index 9dfdae6f37..52fff6e36a 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -21,6 +21,7 @@ import { providersSnapshotQueryRoot, providersSnapshotRequestOptions, } from "@/data/providers-snapshot"; +import { replaceProviderSnapshotIcons } from "@/components/provider-icon-name"; type GetProvidersSnapshotResult = Awaited>; type RefreshProvidersSnapshotResult = Awaited>; @@ -47,8 +48,10 @@ export async function fetchProvidersSnapshot(input: { if (!cached) { throw new ProviderSnapshotCacheMissError(); } + replaceProviderSnapshotIcons(input.serverId, cached.entries); return { ...snapshot, entries: cached.entries }; } + replaceProviderSnapshotIcons(input.serverId, snapshot.entries); if (snapshot.compactSnapshot && snapshot.snapshotHash) { await cache.write({ serverId: input.serverId, diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 2ce4aaff3d..254271aac3 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -362,7 +362,7 @@ function useAgentPanelDescriptor( ); const provider = descriptorState.provider; const label = resolveWorkspaceAgentTabLabel(descriptorState.title); - const icon = getProviderIcon(provider); + const icon = getProviderIcon(provider, context.serverId); return { label: label ?? "", diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index cf505b63bc..962e095b89 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -128,6 +128,7 @@ export const AgentTracks = memo(function AgentTracks({ ({ }); export interface SubagentsTrackProps { + serverId: string; rows: SubagentRow[]; onOpenSubagent: (id: string) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; @@ -44,17 +45,18 @@ const IDLE_ARCHIVE_FINISHED_STATUS: ArchiveFinishedStatus = { kind: "idle" }; /** Leading and action glyphs share one size so rows keep a single icon column. */ const ROW_ICON_SIZE = 14; -function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation { +function buildRowPresentation(row: SubagentRow, serverId: string): WorkspaceTabPresentation { const data = buildSubagentRowPresentationData(row); return { ...data, tooltip: data.label, modified: false, - icon: getProviderIcon(row.provider), + icon: getProviderIcon(row.provider, serverId), }; } export function SubagentsTrack({ + serverId, rows, onOpenSubagent, onOpenProviderSubagent, @@ -95,6 +97,7 @@ export function SubagentsTrack({ void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; @@ -172,6 +176,7 @@ interface SubagentsTrackRowProps { } function SubagentsTrackRow({ + serverId, row, onOpenSubagent, onOpenProviderSubagent, @@ -180,7 +185,7 @@ function SubagentsTrackRow({ }: SubagentsTrackRowProps): ReactElement { const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); - const presentation = useMemo(() => buildRowPresentation(row), [row]); + const presentation = useMemo(() => buildRowPresentation(row, serverId), [row, serverId]); const displayLabel = presentation.titleState === "loading" ? t("common.states.loading") : presentation.label; const handlePress = useCallback(() => { diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 734b286ed3..1985d9d7cf 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -120,7 +120,7 @@ function seedPaseoHome(paseoHome, listen, workspaceRoot) { daemon: { listen, relay: { enabled: false }, - mcp: { enabled: true, injectIntoAgents: false }, + mcp: { enabled: true, injectIntoAgents: true }, browserTools: { enabled: true }, cors: { allowedOrigins: ["*"] }, }, diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 11b88c513d..c71e2c7db6 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -18,6 +18,16 @@ "source": "./src/server.ts", "default": "./dist/server.js" }, + "./provider": { + "types": "./dist/provider.d.ts", + "source": "./src/provider.ts", + "default": "./dist/provider.js" + }, + "./acp": { + "types": "./dist/acp.d.ts", + "source": "./src/acp.ts", + "default": "./dist/acp.js" + }, "./react-native": { "types": "./dist/react-native.d.ts", "source": "./src/react-native.ts", @@ -41,6 +51,7 @@ "test": "vitest run" }, "dependencies": { + "@agentclientprotocol/sdk": "^1.4.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { diff --git a/packages/plugin/src/acp-internal/connection.ts b/packages/plugin/src/acp-internal/connection.ts new file mode 100644 index 0000000000..eec8ec6830 --- /dev/null +++ b/packages/plugin/src/acp-internal/connection.ts @@ -0,0 +1,1373 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { Readable, Writable } from "node:stream"; +import { + ClientSideConnection, + PROTOCOL_VERSION, + ndJsonStream, + type Client, + type AgentCapabilities, + type ContentBlock, + type NewSessionResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionConfigOption, + type SessionNotification, + type SessionUpdate, + type ToolCall, + type ToolCallUpdate, + type Stream, +} from "@agentclientprotocol/sdk"; +import { z } from "zod"; +import type { + AcpConfigAccess, + AcpConfigChange, + AcpToolCallSnapshot, + AcpTransformer, + AcpVendorUpdate, + RunAcpProviderOptions, +} from "../acp.js"; +import { + PROVIDER_PROTOCOL_VERSION, + ProviderEventSchema, + ProviderInputSchema, + negotiateProviderCapabilities, + requireProviderCapabilities, + type ProviderCatalog, + type ProviderCapability, + type ProviderConfigState, + type ProviderConnectRequest, + type ProviderConnection, + type ProviderEvent, + type ProviderInput, + type ProviderPermissionResponse, + type ProviderPersistence, + type ProviderSessionConfig, + type ProviderSetting, + type ProviderTimelineItem, +} from "../provider.js"; + +const ADAPTER_CAPABILITIES = [ + "prompt.message", + "prompt.command", + "session.configure", + "permission", +] as const; + +interface AcpBoundarySession { + runtime: AcpRuntime; + capabilities: readonly ProviderCapability[]; + mutationLane: Promise; +} + +export async function createAcpProviderConnection( + options: RunAcpProviderOptions, + request: ProviderConnectRequest, +): Promise { + if (!request.versions.includes(PROVIDER_PROTOCOL_VERSION)) { + throw new Error(`ACP adapter requires provider protocol ${PROVIDER_PROTOCOL_VERSION}`); + } + const probe = await AcpRuntime.start({ + options, + boundarySessionId: "capability-probe", + env: {}, + emit: () => undefined, + }); + const supportedCapabilities: ProviderCapability[] = [...ADAPTER_CAPABILITIES]; + if (probe.agentCapabilities.promptCapabilities?.image) { + supportedCapabilities.push("prompt.image"); + } + if (probe.agentCapabilities.sessionCapabilities?.list) { + supportedCapabilities.push("session.list"); + } + if (probe.agentCapabilities.loadSession) { + supportedCapabilities.push("session.persistence"); + } + await probe.close(); + const capabilities = negotiateProviderCapabilities(request.capabilities, supportedCapabilities); + const listeners = new Set<(event: ProviderEvent) => void>(); + const sessions = new Map(); + const inFlight = new Set>(); + let closed = false; + let closePromise: Promise | null = null; + const emit = (event: ProviderEvent) => { + if (closed) return; + const parsed = ProviderEventSchema.parse(event); + for (const listener of listeners) listener(parsed); + }; + const state: AcpConnectionState = { + options, + capabilities, + sessions, + emit, + isClosed: () => closed, + }; + + return { + version: PROVIDER_PROTOCOL_VERSION, + capabilities, + async send(input) { + if (closed) throw new Error("ACP provider connection is closed"); + input = ProviderInputSchema.parse(input); + validateAdmission(input, { capabilities, sessions }); + const operation = Promise.resolve().then(async () => { + if (closed) return undefined; + await dispatch(input, state); + return undefined; + }); + const settled = operation.catch((error) => { + emitOperationFailure(input, emit, error); + }); + inFlight.add(settled); + void settled.finally(() => inFlight.delete(settled)); + }, + onEvent(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + async close() { + if (closePromise) return closePromise; + closed = true; + closePromise = (async () => { + await Promise.all(inFlight); + const active = [...sessions.values()]; + sessions.clear(); + await Promise.all(active.map(({ runtime }) => runtime.closeSession())); + listeners.clear(); + })(); + return closePromise; + }, + }; +} + +interface AcpConnectionState { + options: RunAcpProviderOptions; + capabilities: readonly ProviderCapability[]; + sessions: Map; + emit(event: ProviderEvent): void; + isClosed(): boolean; +} + +function validateAdmission( + input: ProviderInput, + state: Pick, +): void { + if (input.type === "session.open") { + if (state.sessions.has(input.sessionId)) + throw new Error(`Session already exists: ${input.sessionId}`); + requireProviderCapabilities(state.capabilities, input); + return; + } + if (!("sessionId" in input)) { + requireProviderCapabilities(state.capabilities, input); + return; + } + const session = requireSession(state as AcpConnectionState, input.sessionId); + requireProviderCapabilities(session.capabilities, input); +} + +function emitOperationFailure( + input: ProviderInput, + emit: (event: ProviderEvent) => void, + error: unknown, +): void { + const failure = { message: describeError(error) }; + if (input.type === "session.prompt") { + emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "failed", error: failure }, + }); + return; + } + if ("requestId" in input) { + emit({ type: "request.failed", requestId: input.requestId, error: failure }); + return; + } + if ("sessionId" in input) { + emit({ type: "session.runtime_failed", sessionId: input.sessionId, error: failure }); + } +} + +async function dispatch(input: ProviderInput, state: AcpConnectionState): Promise { + switch (input.type) { + case "catalog": + await discover(input, state); + return; + case "sessions": + await listSessions(input, state); + return; + case "session.open": + await openSession(input, state); + return; + case "session.prompt": + await requireSession(state, input.sessionId).runtime.prompt(input); + return; + case "session.interrupt": + await requireSession(state, input.sessionId).runtime.interrupt(); + state.emit({ type: "request.completed", requestId: input.requestId }); + return; + case "session.permission": + requireSession(state, input.sessionId).runtime.respondToPermission( + input.permissionId, + input.response, + ); + return; + case "session.configure": { + const session = requireSession(state, input.sessionId); + await mutateSession(session, () => session.runtime.configure(input.changes)); + state.emit({ type: "request.completed", requestId: input.requestId }); + return; + } + case "session.close": { + const session = requireSession(state, input.sessionId); + state.sessions.delete(input.sessionId); + await session.runtime.closeSession(); + state.emit({ type: "session.closed", sessionId: input.sessionId }); + state.emit({ type: "request.completed", requestId: input.requestId }); + return; + } + case "session.archive": + case "session.unarchive": + case "session.revert": + state.emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: `${input.type} is not supported by this ACP provider` }, + }); + } +} + +async function discover( + input: Extract, + state: AcpConnectionState, +): Promise { + const runtime = await AcpRuntime.start({ + options: state.options, + boundarySessionId: "catalog", + env: {}, + emit: state.emit, + }); + try { + let catalog = await runtime.discover(input.cwd); + const config = runtime.configAccess(); + for (const transformer of state.options.transformers ?? []) { + if (transformer.discover) { + catalog = await transformer.discover(catalog, { sessionId: "catalog", config }); + } + } + state.emit({ type: "catalog", requestId: input.requestId, catalog }); + } finally { + await runtime.closeSession(); + } +} + +async function listSessions( + input: Extract, + state: AcpConnectionState, +): Promise { + const runtime = await AcpRuntime.start({ + options: state.options, + boundarySessionId: "sessions", + env: {}, + emit: state.emit, + }); + try { + const response = await runtime.listSessions(input.cwd); + const selected = input.limit ? response.sessions.slice(0, input.limit) : response.sessions; + state.emit({ + type: "sessions", + requestId: input.requestId, + sessions: selected.map((session) => ({ + persistence: nativePersistence(session.sessionId), + cwd: session.cwd, + title: session.title ?? undefined, + updatedAt: session.updatedAt ?? undefined, + })), + }); + } finally { + await runtime.close(); + } +} + +async function openSession( + input: Extract, + state: AcpConnectionState, +): Promise { + if (state.sessions.has(input.sessionId)) + throw new Error(`Session already exists: ${input.sessionId}`); + const runtime = await AcpRuntime.start({ + options: state.options, + boundarySessionId: input.sessionId, + env: input.config.env, + emit: state.emit, + }); + try { + const capabilities = await runtime.open(input, state.capabilities); + if (state.isClosed()) { + await runtime.closeSession(); + return; + } + state.sessions.set(input.sessionId, { + runtime, + capabilities, + mutationLane: Promise.resolve(), + }); + } catch (error) { + await runtime.close(); + throw error; + } +} + +async function mutateSession( + session: AcpBoundarySession, + operation: () => Promise, +): Promise { + const result = session.mutationLane.then(operation); + session.mutationLane = result.catch(() => undefined); + return result; +} + +function requireSession(state: AcpConnectionState, sessionId: string): AcpBoundarySession { + const session = state.sessions.get(sessionId); + if (!session) throw new Error(`Unknown session: ${sessionId}`); + return session; +} + +interface StartRuntimeOptions { + options: RunAcpProviderOptions; + boundarySessionId: string; + env: Readonly>; + emit(event: ProviderEvent): void; +} + +class AcpRuntime { + readonly connection: ClientSideConnection; + agentCapabilities: AgentCapabilities = {}; + nativeSessionId = ""; + private readonly child: ChildProcessWithoutNullStreams | null; + private emit: (event: ProviderEvent) => void; + private readonly messages = new Map(); + private readonly toolCalls = new Map(); + private readonly pendingCompactions = new Set(); + private readonly permissions = new Map< + string, + { request: RequestPermissionRequest; resolve(response: RequestPermissionResponse): void } + >(); + private readonly transformers: readonly AcpTransformer[]; + private configOptions: SessionConfigOption[] = []; + private modes: NewSessionResponse["modes"] = null; + private commandWaiter: (() => void) | null = null; + private messageSequence = 0; + private closing = false; + private processFailed = false; + private configTransaction = false; + private stagedTransformerConfig: ProviderConfigState | null = null; + private readonly closeConnector: () => Promise; + private notificationLane: Promise = Promise.resolve(); + private promptLane: Promise = Promise.resolve(); + private activePrompt: { turnId: string; settled: Promise } | null = null; + + static async start(options: StartRuntimeOptions): Promise { + let child: ChildProcessWithoutNullStreams | null = null; + let spawnFailure: Promise | null = null; + let stream: Stream; + let closeConnector = async () => {}; + if (options.options.command) { + const [executable, ...args] = options.options.command; + child = spawn(executable, args, { + env: { ...process.env, ...options.env }, + stdio: ["pipe", "pipe", "pipe"], + }); + spawnFailure = new Promise((_resolve, reject) => child!.once("error", reject)); + child.stderr.on("data", () => undefined); + const output = Writable.toWeb(child.stdin) as unknown as Parameters[0]; + const input = Readable.toWeb(child.stdout) as unknown as Parameters[1]; + stream = ndJsonStream(output, input); + } else { + const owned = ownConnectorStream(await options.options.connector()); + stream = owned.stream; + closeConnector = owned.close; + } + const runtime = new AcpRuntime(stream, child, closeConnector, options); + try { + const initialize = withTimeout( + runtime.call( + runtime.connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + clientInfo: { name: "paseo", version: "1" }, + }), + ), + options.options.acpOptions?.startupTimeoutMs ?? 10_000, + `ACP provider ${options.options.id} did not initialize`, + ); + const initialized = spawnFailure + ? await Promise.race([initialize, spawnFailure]) + : await initialize; + runtime.agentCapabilities = initialized.agentCapabilities ?? {}; + return runtime; + } catch (error) { + await runtime.close(); + throw error; + } + } + + private constructor( + stream: Stream, + child: ChildProcessWithoutNullStreams | null, + closeConnector: () => Promise, + private readonly options: StartRuntimeOptions, + ) { + this.child = child; + this.closeConnector = closeConnector; + this.emit = options.emit; + this.transformers = options.options.transformers ?? []; + const client: Client = { + requestPermission: (request) => this.requestPermission(request), + sessionUpdate: (notification) => + this.enqueueNotification(() => this.sessionUpdate(notification)), + extNotification: (method, params) => + this.enqueueNotification(() => this.vendorNotification(method, params)), + }; + this.connection = new ClientSideConnection(() => client, stream); + if (!child) void this.connection.closed.then(() => this.handleUnexpectedTransportClose()); + child?.on("error", (error) => { + this.processFailed = true; + this.settlePendingWork(); + if (this.closing || !this.nativeSessionId) return; + this.emit({ + type: "session.runtime_failed", + sessionId: this.options.boundarySessionId, + error: { message: describeError(error) }, + }); + }); + child?.once("close", (code, signal) => { + if (this.closing || this.processFailed || !this.nativeSessionId) return; + this.processFailed = true; + this.settlePendingWork(); + this.emit({ + type: "session.runtime_failed", + sessionId: this.options.boundarySessionId, + error: { message: `ACP process exited (${signal ?? code ?? "unknown"})` }, + }); + }); + } + + async open( + input: Extract, + connectionCapabilities: readonly ProviderCapability[], + ): Promise { + const mcpServers = toAcpMcpServers(input.config); + const nativeSessionId = readNativeSessionId(input.persistence); + const metadata = { + _paseo: { + systemPrompt: input.config.systemPrompt, + providerOptions: input.config.providerOptions, + toolPolicy: input.config.toolPolicy, + persist: input.config.persist, + }, + }; + let response: Pick; + if (nativeSessionId) { + response = await this.call( + this.connection.loadSession({ + sessionId: nativeSessionId, + cwd: input.config.cwd, + mcpServers, + _meta: metadata, + }), + ); + this.nativeSessionId = nativeSessionId; + } else { + const newSession = await this.call( + this.connection.newSession({ + cwd: input.config.cwd, + mcpServers, + _meta: metadata, + }), + ); + response = newSession; + this.nativeSessionId = newSession.sessionId; + } + this.modes = response.modes; + this.configOptions = response.configOptions ?? []; + await this.applyInitialConfig(input.config); + const sessionCapabilities = connectionCapabilities.filter( + (capability) => + capability !== "session.configure" || + this.modes !== null || + this.configOptions.length > 0 || + this.transformers.some((transformer) => transformer.configure !== undefined), + ); + this.emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities: sessionCapabilities, + restoration: "core", + persistence: nativePersistence(this.nativeSessionId), + title: input.config.title, + cwd: input.config.cwd, + }); + this.emitConfig(); + if (this.options.options.acpOptions?.waitForInitialCommands) { + await this.waitForInitialCommands(); + } + this.emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + return sessionCapabilities; + } + + async discover(cwd = process.cwd()): Promise { + const response = await this.call(this.connection.newSession({ cwd, mcpServers: [] })); + this.nativeSessionId = response.sessionId; + this.modes = response.modes; + this.configOptions = response.configOptions ?? []; + return toProviderCatalog(response.modes, response.configOptions ?? []); + } + + prompt(input: Extract): Promise { + const admission = this.promptLane.then(() => this.admitPrompt(input)); + this.promptLane = admission.catch(() => undefined); + return admission; + } + + private async admitPrompt( + input: Extract, + ): Promise { + const active = this.activePrompt; + if (active) { + await this.interrupt(); + await active.settled; + } + const prompt = toAcpPrompt(input.prompt); + const turnId = `acp:${input.prompt.clientMessageId}`; + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: { + type: "user_message", + id: input.prompt.clientMessageId, + text: prompt + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"), + clientMessageId: input.prompt.clientMessageId, + }, + }); + this.emit({ + type: "session.prompt_result", + sessionId: this.options.boundarySessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "turn", turnId }, + }); + this.emit({ + type: "session.turn", + sessionId: this.options.boundarySessionId, + turnId, + state: "started", + }); + const settled = this.call( + this.connection.prompt({ sessionId: this.nativeSessionId, prompt }), + ).then( + (response): void => { + const state = response.stopReason === "cancelled" ? "canceled" : "completed"; + this.terminalizeTransientItems(state); + this.emit({ + type: "session.turn", + sessionId: this.options.boundarySessionId, + turnId, + state, + }); + return undefined; + }, + (error): void => { + this.terminalizeTransientItems("failed"); + this.emit({ + type: "session.turn", + sessionId: this.options.boundarySessionId, + turnId, + state: "failed", + error: { message: describeError(error) }, + }); + return undefined; + }, + ); + this.activePrompt = { turnId, settled }; + void settled.finally(() => { + if (this.activePrompt?.turnId === turnId) this.activePrompt = null; + }); + } + + interrupt(): Promise { + return this.call(this.connection.cancel({ sessionId: this.nativeSessionId })); + } + + listSessions(cwd?: string) { + return this.call(this.connection.listSessions({ cwd })); + } + + async drainNotifications(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); + await this.notificationLane; + } + + respondToPermission( + permissionId: string, + response: Extract["response"], + ): void { + const pending = this.permissions.get(permissionId); + if (!pending) throw new Error(`Unknown ACP permission: ${permissionId}`); + const selected = selectPermissionOption(pending.request.options, response); + if (response.selectedActionId !== undefined && !selected) { + throw new Error( + `ACP permission action '${response.selectedActionId}' does not exist or does not match '${response.behavior}' behavior`, + ); + } + this.permissions.delete(permissionId); + pending.resolve({ + outcome: selected + ? { outcome: "selected", optionId: selected.optionId } + : { outcome: "cancelled" }, + }); + this.emit({ + type: "session.permission_resolved", + sessionId: this.options.boundarySessionId, + permissionId, + }); + } + + async configure( + changes: Extract["changes"], + ): Promise { + const ordered: AcpConfigChange[] = []; + if (changes.model !== undefined) ordered.push({ target: "model", value: changes.model }); + if (changes.mode !== undefined) ordered.push({ target: "mode", value: changes.mode }); + if (changes.thinkingOption !== undefined) { + ordered.push({ target: "thinking", value: changes.thinkingOption }); + } + for (const [id, value] of Object.entries(changes.settings ?? {})) { + ordered.push({ target: "setting", id, value }); + } + const previousModes = this.modes ? structuredClone(this.modes) : null; + const previousOptions = structuredClone(this.configOptions); + this.configTransaction = true; + this.stagedTransformerConfig = null; + let committedConfig: ProviderConfigState | null = null; + try { + for (const change of ordered) await this.applyConfigChange(change); + committedConfig = + this.stagedTransformerConfig ?? toProviderConfigState(this.modes, this.configOptions); + } catch (error) { + try { + await this.restoreConfig(previousModes, previousOptions); + } catch (rollbackError) { + const failure = new AggregateError( + [error, rollbackError], + `ACP configuration failed and rollback could not restore the previous state`, + ); + this.emit({ + type: "session.runtime_failed", + sessionId: this.options.boundarySessionId, + error: { message: describeError(failure) }, + }); + await this.close(); + throw failure; + } + this.modes = previousModes; + this.configOptions = previousOptions; + this.stagedTransformerConfig = null; + throw error; + } finally { + this.configTransaction = false; + } + this.stagedTransformerConfig = null; + this.emitConfigSnapshot(committedConfig); + } + + async closeSession(): Promise { + if (this.nativeSessionId) { + await withTimeout( + this.call(this.connection.closeSession({ sessionId: this.nativeSessionId })), + 1_000, + `ACP session ${this.nativeSessionId} did not close`, + ).catch(() => undefined); + } + await this.close(); + } + + async close(): Promise { + if (this.closing) return; + this.closing = true; + this.settlePendingWork(); + await this.closeConnector(); + const child = this.child; + if (!child || this.processFailed) return; + if (child.exitCode !== null || child.signalCode !== null) return; + const closed = new Promise((resolve) => child.once("close", () => resolve())); + child.kill("SIGTERM"); + if (await settlesWithin(closed, 1_000)) return; + child.kill("SIGKILL"); + if (!(await settlesWithin(closed, 1_000))) { + throw new Error(`ACP provider ${this.options.options.id} did not terminate after SIGKILL`); + } + } + + private async applyInitialConfig(config: ProviderSessionConfig): Promise { + if (config.model) await this.applyConfigChange({ target: "model", value: config.model }); + if (config.mode) await this.applyConfigChange({ target: "mode", value: config.mode }); + if (config.thinkingOption) { + await this.applyConfigChange({ target: "thinking", value: config.thinkingOption }); + } + for (const [id, value] of Object.entries(config.settings)) { + await this.applyConfigChange({ target: "setting", id, value }); + } + } + + private async applyConfigChange(change: AcpConfigChange): Promise { + const access = this.configAccess(); + for (const transformer of this.transformers) { + if ( + (await transformer.configure?.(change, { + sessionId: this.options.boundarySessionId, + config: access, + })) === "handled" + ) { + return; + } + } + if (change.target === "mode") { + if (!change.value || !this.modes) { + throw new Error("ACP session does not expose mode configuration"); + } + await this.call( + this.connection.setSessionMode({ + sessionId: this.nativeSessionId, + modeId: change.value, + }), + ); + this.modes = { ...this.modes, currentModeId: change.value }; + return; + } + let category: "model" | "thought_level" | null = null; + if (change.target === "model") category = "model"; + if (change.target === "thinking") category = "thought_level"; + const configId = + change.target === "setting" + ? change.id + : this.configOptions.find((option) => option.category === category)?.id; + if (!configId) { + throw new Error(`ACP session does not expose ${change.target} configuration`); + } + if (change.value === null) { + throw new Error(`ACP configuration ${configId} cannot be cleared`); + } + const response = await this.call( + this.connection.setSessionConfigOption({ + sessionId: this.nativeSessionId, + configId, + ...(typeof change.value === "boolean" + ? { type: "boolean" as const, value: change.value } + : { value: String(change.value) }), + }), + ); + this.configOptions = response.configOptions; + } + + configAccess(): AcpConfigAccess { + return { + read: async () => configValues(this.configOptions), + set: async (id, value) => { + if (typeof value !== "string" && typeof value !== "boolean") { + throw new Error(`ACP configuration ${id} accepts only string or boolean values`); + } + const response = await this.call( + this.connection.setSessionConfigOption({ + sessionId: this.nativeSessionId, + configId: id, + ...(typeof value === "boolean" ? { type: "boolean" as const, value } : { value }), + }), + ); + this.configOptions = response.configOptions; + }, + }; + } + + private emitConfig(): void { + if (this.configTransaction) return; + this.emitConfigSnapshot(toProviderConfigState(this.modes, this.configOptions)); + } + + private emitConfigSnapshot(config: ProviderConfigState): void { + this.emit({ + type: "session.config", + sessionId: this.options.boundarySessionId, + config, + }); + } + + private async restoreConfig( + modes: NewSessionResponse["modes"], + options: readonly SessionConfigOption[], + ): Promise { + if (modes?.currentModeId && this.modes?.currentModeId !== modes.currentModeId) { + await this.call( + this.connection.setSessionMode({ + sessionId: this.nativeSessionId, + modeId: modes.currentModeId, + }), + ); + } + const currentValues = configValues(this.configOptions); + for (const option of options) { + const value = option.currentValue; + if (currentValues[option.id] === value) continue; + await this.call( + this.connection.setSessionConfigOption({ + sessionId: this.nativeSessionId, + configId: option.id, + ...(typeof value === "boolean" ? { type: "boolean" as const, value } : { value }), + }), + ); + } + } + + private requestPermission(request: RequestPermissionRequest): Promise { + const permissionId = `permission:${request.toolCall.toolCallId}`; + return new Promise((resolve) => { + this.permissions.set(permissionId, { request, resolve }); + this.emit({ + type: "session.permission", + sessionId: this.options.boundarySessionId, + request: { + id: permissionId, + name: request.toolCall.name ?? request.toolCall.title ?? "Tool", + kind: "tool", + title: request.toolCall.title ?? undefined, + input: jsonRecord(request.toolCall.rawInput), + actions: request.options.map((option) => ({ + id: option.optionId, + label: option.name, + behavior: option.kind.startsWith("allow") ? "allow" : "deny", + })), + }, + }); + }); + } + + private sessionUpdate(notification: SessionNotification): void { + if (notification.sessionId !== this.nativeSessionId) return; + this.reduceUpdate(notification.update); + } + + private reduceUpdate(update: SessionUpdate): void { + if ( + update.sessionUpdate === "agent_message_chunk" || + update.sessionUpdate === "agent_thought_chunk" || + update.sessionUpdate === "user_message_chunk" + ) { + if (update.sessionUpdate === "user_message_chunk") return; + if (update.content.type !== "text") return; + const fallbackId = `${update.sessionUpdate}:${++this.messageSequence}`; + const id = update.messageId ?? fallbackId; + const text = `${this.messages.get(id) ?? ""}${update.content.text}`; + this.messages.set(id, text); + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: { + type: update.sessionUpdate === "agent_message_chunk" ? "assistant_message" : "reasoning", + id, + text, + ...(update.sessionUpdate === "agent_message_chunk" + ? { messageId: update.messageId ?? undefined } + : {}), + }, + }); + return; + } + if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") { + this.reduceToolCall(update); + return; + } + this.reduceStateUpdate(update); + } + + private reduceStateUpdate(update: SessionUpdate): void { + if (update.sessionUpdate === "plan") { + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: { + type: "todo", + id: "acp-plan", + items: update.entries.map((entry, index) => ({ + id: String(index), + text: entry.content, + completed: entry.status === "completed", + status: entry.status, + })), + }, + }); + return; + } + if (update.sessionUpdate === "available_commands_update") { + const commands = update.availableCommands.map((command) => ({ + name: command.name, + description: command.description, + argumentHint: command.input?.hint, + })); + this.emit({ type: "session.commands", sessionId: this.options.boundarySessionId, commands }); + this.commandWaiter?.(); + this.commandWaiter = null; + return; + } + if (update.sessionUpdate === "current_mode_update") { + if (this.modes) this.modes = { ...this.modes, currentModeId: update.currentModeId }; + this.emitConfig(); + return; + } + if (update.sessionUpdate === "config_option_update") { + this.configOptions = update.configOptions; + this.emitConfig(); + return; + } + if (update.sessionUpdate === "usage_update") { + this.emit({ + type: "session.usage", + sessionId: this.options.boundarySessionId, + usage: { + contextWindowUsedTokens: update.used, + contextWindowMaxTokens: update.size, + totalCostUsd: update.cost?.currency === "USD" ? update.cost.amount : undefined, + }, + }); + return; + } + if (update.sessionUpdate === "compaction_update") { + if (update.status === "completed") this.pendingCompactions.delete(update.compactionId); + else this.pendingCompactions.add(update.compactionId); + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: { + type: "compaction", + id: update.compactionId, + status: update.status === "completed" ? "completed" : "loading", + }, + }); + } + } + + private terminalizeTransientItems(state: "completed" | "failed" | "canceled"): void { + for (const [id, snapshot] of this.toolCalls) { + if (snapshot.status !== "pending" && snapshot.status !== "in_progress") continue; + const completed: AcpToolCallSnapshot = { + ...snapshot, + status: state === "completed" ? "completed" : "failed", + }; + this.toolCalls.set(id, completed); + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: toolTimelineItem(completed), + }); + } + for (const id of this.pendingCompactions) { + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: { type: "compaction", id, status: "completed" }, + }); + } + this.pendingCompactions.clear(); + } + + private reduceToolCall(update: ToolCall | ToolCallUpdate): void { + const current = this.toolCalls.get(update.toolCallId); + let snapshot = mergeToolCallSnapshot(update, current); + const context = { sessionId: this.options.boundarySessionId }; + for (const transformer of this.transformers) + snapshot = transformer.toolCall?.(snapshot, context) ?? snapshot; + this.toolCalls.set(snapshot.id, snapshot); + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: toolTimelineItem(snapshot), + }); + } + + private vendorNotification(method: string, params: Record): void { + const notification = { method, params: jsonValue(params) }; + const context = { sessionId: this.options.boundarySessionId }; + for (const transformer of this.transformers) { + const transformed = transformer.notification?.(notification, context); + if (!transformed) continue; + const updates = Array.isArray(transformed) ? transformed : [transformed]; + for (const update of updates) this.emitVendorUpdate(update); + } + } + + private emitVendorUpdate(update: AcpVendorUpdate): void { + if (update.type === "commands") { + this.emit({ + type: "session.commands", + sessionId: this.options.boundarySessionId, + commands: [...update.commands], + }); + } else if (update.type === "config") { + if (this.configTransaction) this.stagedTransformerConfig = update.config; + else this.emitConfigSnapshot(update.config); + } else if (update.type === "timeline") { + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: update.item, + }); + } else { + this.emit({ + type: "session.notice", + sessionId: this.options.boundarySessionId, + notice: update.notice, + }); + } + } + + private call(operation: Promise): Promise { + if (this.connection.signal.aborted) { + return Promise.reject(new Error("ACP transport is closed")); + } + return Promise.race([ + operation, + this.connection.closed.then(() => { + throw new Error("ACP transport closed unexpectedly"); + }), + ]); + } + + private enqueueNotification(operation: () => void): Promise { + const result = this.notificationLane.then(operation); + this.notificationLane = result.catch(() => undefined); + return result; + } + + private handleUnexpectedTransportClose(): void { + if (this.closing || this.processFailed) return; + this.processFailed = true; + this.settlePendingWork(); + if (!this.nativeSessionId) return; + this.emit({ + type: "session.runtime_failed", + sessionId: this.options.boundarySessionId, + error: { message: "ACP transport closed unexpectedly" }, + }); + } + + private settlePendingWork(): void { + for (const permission of this.permissions.values()) { + permission.resolve({ outcome: { outcome: "cancelled" } }); + } + this.permissions.clear(); + this.commandWaiter?.(); + this.commandWaiter = null; + } + + private waitForInitialCommands(): Promise { + const timeoutMs = this.options.options.acpOptions?.initialCommandsTimeoutMs ?? 1_000; + const commands = new Promise((resolve) => { + this.commandWaiter = resolve; + }); + const timeout = new Promise((resolve) => { + setTimeout(resolve, timeoutMs); + }); + return Promise.race([commands, timeout]).finally(() => { + this.commandWaiter = null; + }); + } +} + +function selectPermissionOption( + options: RequestPermissionRequest["options"], + response: ProviderPermissionResponse, +): RequestPermissionRequest["options"][number] | null { + if (response.selectedActionId !== undefined) { + return ( + options.find( + (option) => + option.optionId === response.selectedActionId && + permissionOptionBehavior(option) === response.behavior, + ) ?? null + ); + } + return options.find((option) => permissionOptionBehavior(option) === response.behavior) ?? null; +} + +function permissionOptionBehavior( + option: RequestPermissionRequest["options"][number], +): ProviderPermissionResponse["behavior"] { + return option.kind.startsWith("allow") ? "allow" : "deny"; +} + +function ownConnectorStream(source: Stream): { + stream: Stream; + close(): Promise; +} { + const reader = source.readable.getReader(); + const writer = source.writable.getWriter(); + let closed = false; + const stream: Stream = { + readable: new ReadableStream({ + async pull(controller) { + try { + const next = await reader.read(); + if (next.done) controller.close(); + else controller.enqueue(next.value); + } catch (error) { + controller.error(error); + } + }, + cancel: (reason) => reader.cancel(reason), + }), + writable: new WritableStream({ + write: (message) => writer.write(message), + close: () => writer.close(), + abort: (reason) => writer.abort(reason), + }), + }; + return { + stream, + async close() { + if (closed) return; + closed = true; + await Promise.allSettled([ + reader.cancel(new Error("Paseo closed the ACP connector")), + writer.close(), + ]); + }, + }; +} + +function mergeToolCallSnapshot( + update: ToolCall | ToolCallUpdate, + current: AcpToolCallSnapshot | undefined, +): AcpToolCallSnapshot { + return { + id: update.toolCallId, + name: firstText(update.name, current?.name), + title: firstText(update.title, current?.title, update.name) ?? "Tool call", + kind: firstText(update.kind, current?.kind), + status: firstDefined(update.status, current?.status, "pending") ?? "pending", + input: jsonValue(firstDefined(update.rawInput, current?.input, null)), + output: jsonValue(firstDefined(update.rawOutput, current?.output, null)), + locations: update.locations?.map((location) => location.path) ?? current?.locations ?? [], + }; +} + +function firstDefined(...values: Array): Value | undefined { + return values.find((value) => value !== undefined); +} + +function firstText(...values: Array): string | undefined { + return values.find((value): value is string => typeof value === "string"); +} + +function toAcpMcpServers(config: ProviderSessionConfig) { + return Object.entries(config.mcpServers).map(([name, server]) => { + if (server.type === "stdio") { + return { + name, + command: server.command, + args: server.args ?? [], + env: Object.entries(server.env ?? {}).map(([key, value]) => ({ name: key, value })), + }; + } + return { + type: server.type, + name, + url: server.url, + headers: Object.entries(server.headers ?? {}).map(([key, value]) => ({ name: key, value })), + }; + }); +} + +function toAcpPrompt( + prompt: Extract["prompt"], +): ContentBlock[] { + if (prompt.input.type === "command") { + const suffix = prompt.input.arguments ? ` ${prompt.input.arguments}` : ""; + return [{ type: "text", text: `/${prompt.input.name}${suffix}` }]; + } + return prompt.input.content.map((content) => { + if (content.type === "text" || content.type === "image") return content; + return { type: "text", text: JSON.stringify(content) }; + }); +} + +function nativePersistence(sessionId: string): ProviderPersistence { + return { version: 1, data: { sessionId } }; +} + +function readNativeSessionId(persistence: ProviderPersistence | undefined): string | null { + if ( + !persistence || + typeof persistence.data !== "object" || + persistence.data === null || + Array.isArray(persistence.data) + ) + return null; + return typeof persistence.data.sessionId === "string" ? persistence.data.sessionId : null; +} + +function toProviderConfigState( + modes: NewSessionResponse["modes"], + options: readonly SessionConfigOption[], +): ProviderConfigState { + const model = options.find((option) => option.category === "model"); + const thinking = options.find((option) => option.category === "thought_level"); + return { + model: selectedValue(model), + mode: modes?.currentModeId, + thinkingOption: selectedValue(thinking), + models: selectOptions(model).map((option) => ({ id: option.value, label: option.label })), + modes: + modes?.availableModes.map((mode) => ({ + id: mode.id, + label: mode.name, + description: mode.description ?? undefined, + })) ?? [], + thinkingOptions: selectOptions(thinking).map((option) => ({ + id: option.value, + label: option.label, + })), + settings: options + .filter((option) => option.category !== "model" && option.category !== "thought_level") + .map(toProviderSetting), + }; +} + +function toProviderCatalog( + modes: NewSessionResponse["modes"], + options: readonly SessionConfigOption[], +): ProviderCatalog { + const state = toProviderConfigState(modes, options); + return { + models: state.models, + modes: state.modes, + thinkingOptions: state.thinkingOptions, + defaultModel: state.model, + defaultMode: state.mode, + defaultThinkingOption: state.thinkingOption, + }; +} + +function toProviderSetting(option: SessionConfigOption): ProviderSetting { + if (option.type === "boolean") { + return { + type: "toggle", + id: option.id, + label: option.name, + description: option.description ?? undefined, + value: option.currentValue, + }; + } + return { + type: "select", + id: option.id, + label: option.name, + description: option.description ?? undefined, + value: option.currentValue, + options: selectOptions(option), + }; +} + +function selectOptions( + option: SessionConfigOption | undefined, +): Array<{ label: string; value: string }> { + if (!option || option.type === "boolean") return []; + return option.options.flatMap((candidate) => + "group" in candidate + ? candidate.options.map((nested) => ({ label: nested.name, value: nested.value })) + : [{ label: candidate.name, value: candidate.value }], + ); +} + +function selectedValue(option: SessionConfigOption | undefined): string | undefined { + return option && option.type !== "boolean" ? option.currentValue : undefined; +} + +function configValues( + options: readonly SessionConfigOption[], +): Readonly>> { + return Object.fromEntries(options.map((option) => [option.id, option.currentValue])); +} + +function toolTimelineItem(snapshot: AcpToolCallSnapshot): ProviderTimelineItem { + const input = jsonRecord(snapshot.input); + const isEdit = snapshot.kind === "edit" || snapshot.name?.toLowerCase().includes("edit") === true; + const detail = isEdit + ? { + type: "edit" as const, + filePath: + typeof input.filePath === "string" ? input.filePath : (snapshot.locations[0] ?? ""), + oldString: typeof input.oldString === "string" ? input.oldString : undefined, + newString: typeof input.newString === "string" ? input.newString : undefined, + unifiedDiff: typeof input.unifiedDiff === "string" ? input.unifiedDiff : undefined, + } + : { type: "unknown" as const, input: snapshot.input, output: snapshot.output }; + const status = + snapshot.status === "in_progress" || snapshot.status === "pending" + ? "running" + : snapshot.status; + const base = { + type: "tool_call" as const, + id: snapshot.id, + callId: snapshot.id, + name: snapshot.name ?? snapshot.title, + detail, + }; + if (status === "failed") return { ...base, status, error: snapshot.output }; + return { ...base, status, error: null }; +} + +function jsonValue(value: unknown) { + return z.json().parse(value); +} + +function jsonRecord(value: unknown): Record> { + const parsed = jsonValue(value); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {}; + return parsed; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function withTimeout( + promise: Promise, + timeoutMs: number, + message: string, +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(message)), timeoutMs); + promise.then( + (value) => { + clearTimeout(timeout); + resolve(value); + return undefined; + }, + (error) => { + clearTimeout(timeout); + reject(error); + return undefined; + }, + ); + }); +} + +async function settlesWithin(promise: Promise, timeoutMs: number): Promise { + return Promise.race([ + promise.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)), + ]); +} diff --git a/packages/plugin/src/acp.test.ts b/packages/plugin/src/acp.test.ts new file mode 100644 index 0000000000..f8099c9a9d --- /dev/null +++ b/packages/plugin/src/acp.test.ts @@ -0,0 +1,985 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { AgentSideConnection, PROTOCOL_VERSION, type Agent } from "@agentclientprotocol/sdk"; +import type { ProviderConnection, ProviderEvent, ProviderInput } from "./provider.js"; +import { afterEach, describe, expect, it } from "vitest"; +import { runAcpProvider, type AcpStreamMessage } from "./acp.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +async function fakeAcp(source: string): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-acp-test-")); + temporaryDirectories.push(directory); + const executable = path.join(directory, "agent.cjs"); + await writeFile(executable, source, "utf8"); + return executable; +} + +async function connect( + executable: string, + capabilities: string[], + transformers?: Parameters[0]["transformers"], +): Promise<{ connection: ProviderConnection; events: ProviderEvent[] }> { + const registration = runAcpProvider({ + id: "test-acp", + label: "Test ACP", + command: [process.execPath, executable], + transformers, + }); + const connection = await registration.connect({ versions: [1], capabilities }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + return { connection, events }; +} + +function openInput( + settings: Record = {}, +): Extract { + return { + type: "session.open", + requestId: "open-1", + sessionId: "session-1", + config: { cwd: "/repo", env: {}, mcpServers: {}, settings, persist: true }, + history: "skip", + }; +} + +async function waitForEvent( + events: ProviderEvent[], + predicate: (event: ProviderEvent) => boolean, +): Promise { + await expect.poll(() => events.find(predicate), { timeout: 3_000 }).toBeTruthy(); + return events.find(predicate)!; +} + +type AcpRequestMessage = Extract; + +interface ConnectorInstance { + readonly requests: AcpRequestMessage[]; + readsCanceled: boolean; + writesClosed: boolean; + closeFromAgent(): void; + request(method: string, params: unknown): Promise; + respond(request: AcpRequestMessage, result: unknown): void; + notify(method: string, params: unknown): void; +} + +interface ConnectorResponse { + resolve(value: unknown): void; + reject(error: Error): void; +} + +interface ConnectorHarnessOptions { + capabilities?: Record; + configOptions?: unknown[]; + handleMessage?(instance: ConnectorInstance, message: AcpStreamMessage): boolean; + handleRequest?(instance: ConnectorInstance, request: AcpRequestMessage): boolean; +} + +function countRequests(instance: ConnectorInstance, method: string): number { + return instance.requests.filter((request) => request.method === method).length; +} + +function receiveConnectorMessage( + instance: ConnectorInstance, + message: AcpStreamMessage, + options?: ConnectorHarnessOptions, +): void { + if (!("method" in message)) return; + if ("id" in message) instance.requests.push(message as AcpRequestMessage); + if (options?.handleMessage?.(instance, message)) return; + if (!("id" in message)) return; + const request = message as AcpRequestMessage; + if (options?.handleRequest?.(instance, request)) return; + respondToConnectorRequest(instance, request, options); +} + +function respondToConnectorRequest( + instance: ConnectorInstance, + request: AcpRequestMessage, + options?: ConnectorHarnessOptions, +): void { + if (request.method === "initialize") { + instance.respond(request, { + protocolVersion: request.params + ? (request.params as { protocolVersion: number }).protocolVersion + : PROTOCOL_VERSION, + agentCapabilities: options?.capabilities ?? {}, + }); + } else if (request.method === "session/new" || request.method === "session/load") { + instance.respond(request, { + sessionId: "connector-session", + modes: null, + configOptions: options?.configOptions ?? [], + }); + } else if (request.method === "session/prompt") { + instance.respond(request, { stopReason: "end_turn" }); + } else if (request.method === "session/close") { + instance.respond(request, {}); + } else if (request.method === "session/set_config_option") { + const params = request.params as { configId: string; value: string | boolean }; + instance.respond(request, { + configOptions: (options?.configOptions ?? []).map((option) => + (option as { id?: string }).id === params.configId + ? Object.assign({}, option, { currentValue: params.value }) + : option, + ), + }); + } +} + +function connectorHarness(options?: ConnectorHarnessOptions) { + const instances: ConnectorInstance[] = []; + const connector = () => { + let controller!: ReadableStreamDefaultController; + let requestSequence = 0; + const responses = new Map(); + const instance: ConnectorInstance = { + requests: [], + readsCanceled: false, + writesClosed: false, + closeFromAgent: () => controller.close(), + request: (method, params) => { + const response = new Promise((resolve, reject) => { + requestSequence += 1; + responses.set(requestSequence, { resolve, reject }); + controller.enqueue({ jsonrpc: "2.0", id: requestSequence, method, params }); + }); + void response.catch(() => undefined); + return response; + }, + respond: (request, result) => controller.enqueue({ jsonrpc: "2.0", id: request.id, result }), + notify: (method, params) => controller.enqueue({ jsonrpc: "2.0", method, params }), + }; + instances.push(instance); + const readable = new ReadableStream({ + start(value) { + controller = value; + }, + cancel() { + instance.readsCanceled = true; + }, + }); + const writable = new WritableStream({ + write(message) { + if (!("method" in message)) { + if (typeof message.id !== "number") return; + const response = responses.get(message.id); + if (!response) return; + responses.delete(message.id); + if ("error" in message) { + response.reject( + new Error( + message.error.data === undefined + ? message.error.message + : `${message.error.message}: ${JSON.stringify(message.error.data)}`, + ), + ); + } else response.resolve(message.result); + return; + } + receiveConnectorMessage(instance, message, options); + }, + close() { + instance.writesClosed = true; + }, + abort() { + instance.writesClosed = true; + }, + }); + return { readable, writable }; + }; + return { connector, instances }; +} + +const basicAgent = `const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + else if (message.method === "session/new") setTimeout(() => send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: [] } }), 150); + else if (message.method === "session/prompt") send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } }); +});`; + +describe("runAcpProvider", () => { + it("rejects connection cleanly when the ACP executable does not exist", async () => { + const registration = runAcpProvider({ + id: "missing-acp", + label: "Missing ACP", + command: [path.join(tmpdir(), `missing-acp-${process.pid}`)], + acpOptions: { startupTimeoutMs: 250 }, + }); + + await expect( + registration.connect({ versions: [1], capabilities: ["prompt.message"] }), + ).rejects.toThrow(/ENOENT|spawn/u); + }); + + it("accepts an official ACP SDK stream connector instead of a command", async () => { + const connector = () => { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agent: Agent = { + initialize: async () => ({ protocolVersion: PROTOCOL_VERSION, agentCapabilities: {} }), + newSession: async () => ({ sessionId: "sdk-session", modes: null, configOptions: [] }), + prompt: async () => ({ stopReason: "end_turn" }), + cancel: async () => undefined, + }; + const agentConnection = new AgentSideConnection(() => agent, { + writable: agentToClient.writable, + readable: clientToAgent.readable, + }); + void agentConnection; + return { + writable: clientToAgent.writable, + readable: agentToClient.readable, + }; + }; + const registration = runAcpProvider({ + id: "sdk-acp", + label: "SDK ACP", + connector, + }); + + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + + await waitForEvent(events, (event) => event.type === "session.ready"); + expect(connection.capabilities).toEqual(["prompt.message"]); + await connection.close(); + }); + + it("maps permission behavior to the first matching ACP option", async () => { + const harness = connectorHarness(); + const registration = runAcpProvider({ + id: "sdk-acp", + label: "SDK ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message", "permission"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + const allow = harness.instances[1]!.request("session/request_permission", { + sessionId: "connector-session", + toolCall: { + toolCallId: "tool-allow", + title: "Allow tool", + kind: "execute", + status: "pending", + content: [], + rawInput: {}, + }, + options: [ + { optionId: "allow-always", name: "Always allow", kind: "allow_always" }, + { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" }, + ], + }); + await waitForEvent( + events, + (event) => + event.type === "session.permission" && event.request.id === "permission:tool-allow", + ); + await connection.send({ + type: "session.permission", + sessionId: "session-1", + permissionId: "permission:tool-allow", + response: { behavior: "allow" }, + }); + await expect(allow).resolves.toEqual({ + outcome: { outcome: "selected", optionId: "allow-always" }, + }); + + const deny = harness.instances[1]!.request("session/request_permission", { + sessionId: "connector-session", + toolCall: { + toolCallId: "tool-deny", + title: "Deny tool", + kind: "execute", + status: "pending", + content: [], + rawInput: {}, + }, + options: [ + { optionId: "allow-once", name: "Allow", kind: "allow_once" }, + { optionId: "reject-always", name: "Always reject", kind: "reject_always" }, + { optionId: "reject-once", name: "Reject once", kind: "reject_once" }, + ], + }); + await waitForEvent( + events, + (event) => event.type === "session.permission" && event.request.id === "permission:tool-deny", + ); + await connection.send({ + type: "session.permission", + sessionId: "session-1", + permissionId: "permission:tool-deny", + response: { behavior: "deny" }, + }); + await expect(deny).resolves.toEqual({ + outcome: { outcome: "selected", optionId: "reject-always" }, + }); + await connection.close(); + }); + + it("rejects an explicit ACP permission option with the wrong behavior", async () => { + const harness = connectorHarness(); + const registration = runAcpProvider({ + id: "sdk-acp", + label: "SDK ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message", "permission"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + const permission = harness.instances[1]!.request("session/request_permission", { + sessionId: "connector-session", + toolCall: { + toolCallId: "tool-explicit", + title: "Explicit tool", + kind: "execute", + status: "pending", + content: [], + rawInput: {}, + }, + options: [ + { optionId: "allow-once", name: "Allow", kind: "allow_once" }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" }, + ], + }); + await waitForEvent( + events, + (event) => + event.type === "session.permission" && event.request.id === "permission:tool-explicit", + ); + await connection.send({ + type: "session.permission", + sessionId: "session-1", + permissionId: "permission:tool-explicit", + response: { behavior: "allow", selectedActionId: "missing" }, + }); + await expect( + waitForEvent( + events, + (event) => + event.type === "session.runtime_failed" && event.error.message.includes("'missing'"), + ), + ).resolves.toBeDefined(); + + await connection.send({ + type: "session.permission", + sessionId: "session-1", + permissionId: "permission:tool-explicit", + response: { behavior: "allow", selectedActionId: "reject-once" }, + }); + await expect( + waitForEvent( + events, + (event) => + event.type === "session.runtime_failed" && + event.error.message.includes("'reject-once'") && + event.error.message.includes("does not match 'allow' behavior"), + ), + ).resolves.toBeDefined(); + + await connection.send({ + type: "session.permission", + sessionId: "session-1", + permissionId: "permission:tool-explicit", + response: { behavior: "allow", selectedActionId: "allow-once" }, + }); + await expect(permission).resolves.toEqual({ + outcome: { outcome: "selected", optionId: "allow-once" }, + }); + await connection.close(); + }); + + it("closes both connector directions for the probe and live session", async () => { + const harness = connectorHarness(); + const registration = runAcpProvider({ + id: "owned-acp", + label: "Owned ACP", + connector: harness.connector, + }); + + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + expect(harness.instances).toHaveLength(1); + expect(harness.instances[0]).toMatchObject({ readsCanceled: true, writesClosed: true }); + + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + await connection.close(); + + expect(harness.instances).toHaveLength(2); + expect(harness.instances[1]).toMatchObject({ readsCanceled: true, writesClosed: true }); + }); + + it("publishes runtime failure on unexpected connector EOF", async () => { + const harness = connectorHarness(); + const registration = runAcpProvider({ + id: "eof-acp", + label: "EOF ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + harness.instances[1]!.closeFromAgent(); + + await expect( + waitForEvent(events, (event) => event.type === "session.runtime_failed"), + ).resolves.toMatchObject({ + sessionId: "session-1", + error: { message: expect.stringContaining("closed") }, + }); + await connection.close(); + }); + + it("settles an active auto prompt before admitting its auto replacement", async () => { + const order: string[] = []; + let firstPrompt: AcpRequestMessage | null = null; + let activePrompts = 0; + let maximumActivePrompts = 0; + const harness = connectorHarness({ + handleMessage(instance, message) { + if (!("method" in message)) return false; + const request = "id" in message ? (message as AcpRequestMessage) : null; + if (message.method === "session/prompt" && request) { + const promptNumber = firstPrompt ? "second" : "first"; + order.push(`prompt:${promptNumber}`); + activePrompts += 1; + maximumActivePrompts = Math.max(maximumActivePrompts, activePrompts); + if (!firstPrompt) { + firstPrompt = request; + } else { + activePrompts -= 1; + instance.respond(request, { stopReason: "end_turn" }); + } + return true; + } + if (message.method === "session/cancel") { + order.push("cancel"); + activePrompts -= 1; + instance.respond(firstPrompt!, { stopReason: "cancelled" }); + return true; + } + return false; + }, + }); + const registration = runAcpProvider({ + id: "overlap-acp", + label: "Overlap ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "first", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "first" }] }, + }, + }); + await expect.poll(() => countRequests(harness.instances[1]!, "session/prompt")).toBe(1); + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "second", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "second" }] }, + }, + }); + + await waitForEvent( + events, + (event) => + event.type === "session.turn" && + event.turnId === "acp:second" && + event.state === "completed", + ); + expect(order).toEqual(["prompt:first", "cancel", "prompt:second"]); + expect(maximumActivePrompts).toBe(1); + const firstTerminal = events.findIndex( + (event) => + event.type === "session.turn" && event.turnId === "acp:first" && event.state === "canceled", + ); + const secondAdmission = events.findIndex( + (event) => event.type === "session.prompt_result" && event.clientMessageId === "second", + ); + expect(firstTerminal).toBeGreaterThan(-1); + expect(firstTerminal).toBeLessThan(secondAdmission); + await connection.close(); + }); + + it("serializes configuration mutations", async () => { + let releaseMutation: (() => void) | null = null; + const configOptions = [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "a", + options: [ + { name: "A", value: "a" }, + { name: "B", value: "b" }, + ], + }, + ]; + const harness = connectorHarness({ + capabilities: { loadSession: true }, + configOptions, + handleRequest(instance, request) { + if (request.method !== "session/set_config_option") return false; + const value = (request.params as { value: string }).value; + if (value !== "first") return false; + releaseMutation = () => + instance.respond(request, { + configOptions: configOptions.map((option) => + Object.assign({}, option, { currentValue: value }), + ), + }); + return true; + }, + }); + const registration = runAcpProvider({ + id: "lane-acp", + label: "Lane ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message", "session.configure"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await connection.send({ + type: "session.configure", + requestId: "configure-first", + sessionId: "session-1", + changes: { model: "first" }, + }); + await connection.send({ + type: "session.configure", + requestId: "configure-second", + sessionId: "session-1", + changes: { model: "second" }, + }); + await expect + .poll(() => countRequests(harness.instances[1]!, "session/set_config_option")) + .toBe(1); + releaseMutation!(); + await waitForEvent( + events, + (event) => event.type === "request.completed" && event.requestId === "configure-second", + ); + expect( + events + .filter( + (event) => event.type === "request.completed" && event.requestId.startsWith("configure-"), + ) + .map((event) => (event as Extract).requestId), + ).toEqual(["configure-first", "configure-second"]); + + await connection.close(); + }); + + it("does not claim exact permission policy, listing, image, or configuration support", async () => { + const executable = await fakeAcp(basicAgent); + const { connection, events } = await connect(executable, [ + "prompt.message", + "prompt.image", + "session.configure", + "session.list", + "permission.tool_policy", + ]); + + expect(connection.capabilities).toEqual(["prompt.message", "session.configure"]); + await connection.send(openInput()); + const opened = await waitForEvent(events, (event) => event.type === "session.opened"); + expect(opened).toMatchObject({ capabilities: ["prompt.message"] }); + await waitForEvent(events, (event) => event.type === "session.ready"); + await expect( + connection.send({ + type: "session.configure", + requestId: "configure-unsupported", + sessionId: "session-1", + changes: { model: "not-advertised" }, + }), + ).rejects.toThrow("session.configure"); + await connection.close(); + }); + + it("derives optional connection capabilities from ACP initialization", async () => { + const executable = await fakeAcp(`const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { + protocolVersion: message.params.protocolVersion, + agentCapabilities: { + loadSession: true, + promptCapabilities: { image: true }, + sessionCapabilities: { list: {} }, + }, + } }); +});`); + const { connection } = await connect(executable, [ + "prompt.message", + "prompt.image", + "session.list", + "session.persistence", + ]); + + expect(connection.capabilities).toEqual([ + "prompt.message", + "prompt.image", + "session.list", + "session.persistence", + ]); + await connection.close(); + }); + + it("acknowledges admission before session creation completes", async () => { + const executable = await fakeAcp(basicAgent); + const { connection, events } = await connect(executable, ["prompt.message"]); + let ready = false; + const admission = connection.send(openInput()).then(() => { + ready = true; + return undefined; + }); + + await admission; + expect(ready).toBe(true); + expect(events).toEqual([]); + await waitForEvent(events, (event) => event.type === "session.ready"); + await connection.close(); + }); + + it("rejects provider inputs after the ACP connection closes", async () => { + const harness = connectorHarness(); + const registration = runAcpProvider({ + id: "closed-acp", + label: "Closed ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ versions: [1], capabilities: [] }); + + await connection.close(); + await expect(connection.send({ type: "catalog", requestId: "too-late" })).rejects.toThrow( + "ACP provider connection is closed", + ); + expect(harness.instances).toHaveLength(1); + }); + + it("waits for delayed session creation and closes its connector after connection close", async () => { + let delayedNew: AcpRequestMessage | null = null; + const harness = connectorHarness({ + handleRequest(_instance, request) { + if (request.method !== "session/new") return false; + delayedNew = request; + return true; + }, + }); + const registration = runAcpProvider({ + id: "delayed-open-acp", + label: "Delayed open ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await expect.poll(() => delayedNew).not.toBeNull(); + + let closeSettled = false; + const closing = connection.close().then(() => { + closeSettled = true; + return undefined; + }); + await Promise.resolve(); + expect(closeSettled).toBe(false); + harness.instances[1]!.respond(delayedNew!, { + sessionId: "late-session", + modes: null, + configOptions: [], + }); + await closing; + + expect(harness.instances[1]).toMatchObject({ readsCanceled: true, writesClosed: true }); + expect(events).toEqual([]); + }); + + it("refuses unsupported steering before sending an ACP prompt", async () => { + const executable = await fakeAcp(basicAgent); + const { connection, events } = await connect(executable, ["prompt.message"]); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await expect( + connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "steer-1", + delivery: "steer", + input: { type: "message", content: [{ type: "text", text: "change course" }] }, + }, + }), + ).rejects.toThrow("prompt.steer"); + expect(events.some((event) => event.type === "session.prompt_result")).toBe(false); + await connection.close(); + }); + + it("keeps configuration observable state atomic when a later field fails", async () => { + const executable = await fakeAcp(`const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +let model = "a"; +const options = () => [ + { id: "model", name: "Model", category: "model", type: "select", currentValue: model, options: [{ name: "A", value: "a" }, { name: "B", value: "b" }] }, + { id: "verbosity", name: "Verbosity", type: "select", currentValue: "quiet", options: [{ name: "Quiet", value: "quiet" }, { name: "Loud", value: "loud" }] }, +]; +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: options() } }); + else if (message.method === "session/set_config_option" && message.params.configId === "verbosity") send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "verbosity rejected" } }); + else if (message.method === "session/set_config_option") { model = message.params.value; send({ jsonrpc: "2.0", id: message.id, result: { configOptions: options() } }); } +});`); + const { connection, events } = await connect(executable, [ + "prompt.message", + "session.configure", + ]); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await connection.send({ + type: "session.configure", + requestId: "configure-1", + sessionId: "session-1", + changes: { model: "b", settings: { verbosity: "loud" } }, + }); + await waitForEvent( + events, + (event) => event.type === "request.failed" && event.requestId === "configure-1", + ); + const configs = events.filter((event) => event.type === "session.config"); + expect(configs).toHaveLength(1); + expect(configs[0]).toMatchObject({ config: { model: "a" } }); + await connection.close(); + }); + + it("commits transformer config notifications once and discards them on rollback", async () => { + const executable = await fakeAcp(`const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +let model = "a"; +const options = () => [ + { id: "model", name: "Model", category: "model", type: "select", currentValue: model, options: [{ name: "A", value: "a" }, { name: "B", value: "b" }] }, + { id: "late", name: "Late", type: "select", currentValue: "ok", options: [{ name: "Ok", value: "ok" }, { name: "Fail", value: "fail" }] }, +]; +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: options() } }); + else if (message.method === "session/set_config_option" && message.params.configId === "late") send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "late rejected" } }); + else if (message.method === "session/set_config_option") { + model = message.params.value; + send({ jsonrpc: "2.0", method: "vendor/config", params: { model } }); + send({ jsonrpc: "2.0", id: message.id, result: { configOptions: options() } }); + } +});`); + const transformer = { + notification(notification: { method: string; params: unknown }) { + if (notification.method !== "vendor/config") return null; + const params = notification.params as { model: string }; + return { + type: "config" as const, + config: { + model: params.model, + models: [{ id: params.model, label: `Transformer ${params.model}` }], + modes: [], + thinkingOptions: [], + settings: [], + }, + }; + }, + }; + const { connection, events } = await connect( + executable, + ["prompt.message", "session.configure"], + [transformer], + ); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + const initialConfigCount = events.filter((event) => event.type === "session.config").length; + + await connection.send({ + type: "session.configure", + requestId: "transformer-success", + sessionId: "session-1", + changes: { model: "b" }, + }); + await waitForEvent( + events, + (event) => event.type === "request.completed" && event.requestId === "transformer-success", + ); + let configs = events.filter((event) => event.type === "session.config"); + expect(configs).toHaveLength(initialConfigCount + 1); + expect(configs.at(-1)).toMatchObject({ + config: { model: "b", models: [{ label: "Transformer b" }] }, + }); + + await connection.send({ + type: "session.configure", + requestId: "transformer-failure", + sessionId: "session-1", + changes: { model: "a", settings: { late: "fail" } }, + }); + await waitForEvent( + events, + (event) => event.type === "request.failed" && event.requestId === "transformer-failure", + ); + configs = events.filter((event) => event.type === "session.config"); + expect(configs).toHaveLength(initialConfigCount + 1); + expect(configs.at(-1)).toMatchObject({ config: { model: "b" } }); + await connection.close(); + }); + + it("gives discovery transformers the discovered session configuration facade", async () => { + const executable = await fakeAcp(`const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "catalog-native", modes: null, configOptions: [{ id: "flavor", name: "Flavor", type: "select", currentValue: "mint", options: [{ name: "Mint", value: "mint" }] }] } }); +});`); + let discovered: Readonly> | null = null; + const { connection, events } = await connect( + executable, + [], + [ + { + async discover(catalog, context) { + discovered = await context.config.read(); + return catalog; + }, + }, + ], + ); + + await connection.send({ type: "catalog", requestId: "catalog-1" }); + await waitForEvent(events, (event) => event.type === "catalog"); + expect(discovered).toEqual({ flavor: "mint" }); + await connection.close(); + }); + + it("drains heavy ACP stderr while initializing", async () => { + const executable = await fakeAcp( + `process.stderr.write("x".repeat(2 * 1024 * 1024));\n${basicAgent}`, + ); + const { connection, events } = await connect(executable, ["prompt.message"]); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + await connection.close(); + }); + + it("publishes runtime failure when the ACP process dies", async () => { + const executable = await fakeAcp(`const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + else if (message.method === "session/new") { + send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: [] } }); + setTimeout(() => process.exit(17), 20); + } +});`); + const { connection, events } = await connect(executable, ["prompt.message"]); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + const failure = await waitForEvent(events, (event) => event.type === "session.runtime_failed"); + expect(failure).toMatchObject({ + sessionId: "session-1", + error: { message: expect.stringContaining("17") }, + }); + await connection.close(); + }); + + it("escalates shutdown when an ACP process ignores SIGTERM", async () => { + const executable = await fakeAcp(`const readline = require("node:readline"); +process.on("SIGTERM", () => {}); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: [] } }); +});`); + const { connection, events } = await connect(executable, ["prompt.message"]); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + const startedAt = Date.now(); + await connection.close(); + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(900); + expect(Date.now() - startedAt).toBeLessThan(2_500); + }); +}); diff --git a/packages/plugin/src/acp.ts b/packages/plugin/src/acp.ts new file mode 100644 index 0000000000..f737034a44 --- /dev/null +++ b/packages/plugin/src/acp.ts @@ -0,0 +1,122 @@ +import type { JsonValue } from "@getpaseo/protocol/agent-types"; +import type { + ProviderCatalog, + ProviderCommand, + ProviderConfigState, + ProviderNotice, + ProviderRegistration, + ProviderTimelineItem, +} from "./provider.js"; +import { createAcpProviderConnection } from "./acp-internal/connection.js"; + +interface RunAcpProviderBaseOptions { + id: string; + label: string; + description?: string; + icon?: string; + acpOptions?: AcpOptions; + transformers?: readonly AcpTransformer[]; +} + +export type RunAcpProviderOptions = RunAcpProviderBaseOptions & + ( + | { command: readonly [string, ...string[]]; connector?: never } + | { connector: AcpConnector; command?: never } + ); + +export type AcpStreamMessage = + | { jsonrpc: "2.0"; id: string | number | null; method: string; params?: unknown } + | { jsonrpc: "2.0"; method: string; params?: unknown } + | { jsonrpc: "2.0"; id: string | number | null; result: unknown } + | { + jsonrpc: "2.0"; + id: string | number | null; + error: { code: number; message: string; data?: unknown }; + }; + +export interface AcpStream { + writable: WritableStream; + readable: ReadableStream; +} + +export type AcpConnector = () => AcpStream | Promise; + +export interface AcpOptions { + protocol?: "auto" | "v1"; + startupTimeoutMs?: number; + waitForInitialCommands?: boolean; + initialCommandsTimeoutMs?: number; +} + +export interface AcpContext { + sessionId: string; +} + +export interface AcpConfigAccess { + read(): Promise>>; + set(id: string, value: JsonValue): Promise; +} + +export interface AcpDiscoveryContext extends AcpContext { + config: AcpConfigAccess; +} + +export interface AcpConfigureContext extends AcpContext { + config: AcpConfigAccess; +} + +export type AcpConfigChange = + | { target: "model"; value: string | null } + | { target: "mode"; value: string | null } + | { target: "thinking"; value: string | null } + | { target: "setting"; id: string; value: JsonValue }; + +export interface AcpToolCallSnapshot { + id: string; + name?: string; + title: string; + kind?: string; + status: "pending" | "in_progress" | "completed" | "failed"; + input: JsonValue; + output: JsonValue; + locations: readonly string[]; +} + +export type AcpVendorUpdate = + | { type: "commands"; commands: readonly ProviderCommand[] } + | { type: "config"; config: ProviderConfigState } + | { type: "timeline"; item: ProviderTimelineItem } + | { type: "notice"; notice: ProviderNotice }; + +export interface AcpTransformer { + discover?( + catalog: ProviderCatalog, + context: AcpDiscoveryContext, + ): Promise | ProviderCatalog; + configure?(change: AcpConfigChange, context: AcpConfigureContext): Promise<"handled" | "pass">; + notification?( + notification: { method: string; params: JsonValue }, + context: AcpContext, + ): AcpVendorUpdate | readonly AcpVendorUpdate[] | null; + toolCall?(toolCall: AcpToolCallSnapshot, context: AcpContext): AcpToolCallSnapshot; +} + +export function runAcpProvider(options: RunAcpProviderOptions): ProviderRegistration { + const hasCommand = options.command !== undefined; + const hasConnector = options.connector !== undefined; + if (hasCommand === hasConnector) { + throw new Error("ACP provider requires exactly one command or SDK connector"); + } + if (options.command && (options.command.length === 0 || options.command[0].trim().length === 0)) { + throw new Error("ACP provider command must contain an executable"); + } + return { + id: options.id, + label: options.label, + description: options.description, + icon: options.icon, + connect(request) { + return createAcpProviderConnection(options, request); + }, + }; +} diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index dbe5116161..96152aa590 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -3,6 +3,7 @@ import type { PaseoApi } from "@getpaseo/client"; import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types"; import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; import type { PluginRpcContract } from "./rpc.js"; +import type { ProviderRegistration } from "./provider.js"; export interface PluginTheme { readonly colors: { @@ -317,6 +318,7 @@ export interface PluginServerContext { context: PluginHandlerContext, ) => ZodInput | Promise>, ): void; + registerProvider(provider: ProviderRegistration): void; } export type PluginCleanup = () => void | Promise; diff --git a/packages/plugin/src/provider.ts b/packages/plugin/src/provider.ts new file mode 100644 index 0000000000..3051a89bb0 --- /dev/null +++ b/packages/plugin/src/provider.ts @@ -0,0 +1,1376 @@ +import type { JsonValue } from "@getpaseo/protocol/agent-types"; +import { z } from "zod"; + +export const PROVIDER_PROTOCOL_VERSION = 1 as const; + +export const PROVIDER_CAPABILITIES = [ + "prompt.message", + "prompt.command", + "prompt.image", + "prompt.output_schema", + "prompt.steer", + "session.archive", + "session.configure", + "session.list", + "session.persistence", + "session.revert.both", + "session.revert.conversation", + "session.revert.files", + "session.subsession", + "session.unarchive", + "permission", + "permission.tool_policy", + "timeline.plugin", +] as const; + +export type ProviderCapability = (typeof PROVIDER_CAPABILITIES)[number]; + +export interface ProviderRegistration { + id: string; + label: string; + description?: string; + /** Plugin-directory-relative path to a self-contained SVG file. */ + icon?: string; + connect(request: ProviderConnectRequest): Promise; +} + +export interface ProviderConnectRequest { + versions: readonly number[]; + capabilities: readonly string[]; +} + +export interface ProviderConnection { + readonly version: number; + readonly capabilities: readonly string[]; + send(input: ProviderInput): Promise; + onEvent(listener: (event: ProviderEvent) => void): () => void; + close(): Promise; +} + +export interface ProviderPersistence { + version: number; + data: JsonValue; +} + +export type ProviderMcpServerConfig = + | { + type: "stdio"; + command: string; + args?: string[]; + env?: Record; + alwaysLoad?: boolean; + } + | { + type: "http" | "sse"; + url: string; + headers?: Record; + alwaysLoad?: boolean; + }; + +export interface ProviderToolPolicy { + preapproved: Array<{ kind: "mcp"; server: string; tool: string }>; +} + +export interface ProviderSessionConfig { + cwd: string; + env: Readonly>; + systemPrompt?: string; + mcpServers: Readonly>; + toolPolicy?: ProviderToolPolicy; + model?: string; + mode?: string; + thinkingOption?: string; + settings: Readonly>; + providerOptions?: Readonly>; + title?: string; + persist: boolean; +} + +export interface ProviderConfigChanges { + model?: string | null; + mode?: string | null; + thinkingOption?: string | null; + settings?: Readonly>; +} + +export interface ProviderPrompt { + clientMessageId: string; + delivery: "auto" | "steer"; + input: + | { type: "message"; content: ProviderContent[] } + | { type: "command"; name: string; arguments: string }; + outputSchema?: JsonValue; + clearPendingPermissions?: boolean; +} + +interface ProviderForgeChangeRequestAttachment { + type: "forge_change_request"; + mimeType: "application/paseo-forge-change-request"; + forge?: string; + number: number; + title: string; + url: string; + body?: string | null; + projectPath?: string; + baseRefName?: string | null; + headRefName?: string | null; +} + +interface ProviderForgeIssueAttachment { + type: "forge_issue"; + mimeType: "application/paseo-forge-issue"; + forge?: string; + number: number; + title: string; + url: string; + body?: string | null; + projectPath?: string; +} + +interface ProviderGitHubPrAttachment { + type: "github_pr"; + mimeType: "application/github-pr"; + number: number; + title: string; + url: string; + body?: string | null; + baseRefName?: string | null; + headRefName?: string | null; +} + +interface ProviderGitHubIssueAttachment { + type: "github_issue"; + mimeType: "application/github-issue"; + number: number; + title: string; + url: string; + body?: string | null; +} + +interface ProviderTextAttachment { + type: "text"; + mimeType: "text/plain"; + contextKind?: string; + title?: string | null; + text: string; + externalResource?: { + provider: string; + providerLabel: string; + resourceType: string; + id: string; + identifier: string; + title: string; + url: string; + }; +} + +interface ProviderReviewAttachment { + type: "review"; + mimeType: "application/paseo-review"; + cwd: string; + mode: "uncommitted" | "base"; + baseRef?: string | null; + comments: Array<{ + filePath: string; + side: "old" | "new"; + lineNumber: number; + body: string; + context: { + hunkHeader: string; + targetLine: ProviderReviewContextLine; + lines: ProviderReviewContextLine[]; + }; + }>; +} + +interface ProviderReviewContextLine { + oldLineNumber: number | null; + newLineNumber: number | null; + type: "add" | "remove" | "context"; + content: string; +} + +export type ProviderContent = + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string } + | ProviderForgeChangeRequestAttachment + | ProviderForgeIssueAttachment + | ProviderGitHubPrAttachment + | ProviderGitHubIssueAttachment + | ProviderTextAttachment + | ProviderReviewAttachment + | { + type: "uploaded_file"; + id: string; + fileName: string; + mimeType: string; + size: number; + path: string; + }; + +export type ProviderInput = + | { type: "catalog"; requestId: string; cwd?: string } + | { type: "sessions"; requestId: string; query?: string; cwd?: string; limit?: number } + | { + type: "session.open"; + requestId: string; + sessionId: string; + config: ProviderSessionConfig; + persistence?: ProviderPersistence; + history: "replay" | "skip"; + } + | { type: "session.prompt"; sessionId: string; prompt: ProviderPrompt } + | { type: "session.interrupt"; requestId: string; sessionId: string } + | { + type: "session.permission"; + sessionId: string; + permissionId: string; + response: ProviderPermissionResponse; + } + | { + type: "session.configure"; + requestId: string; + sessionId: string; + changes: ProviderConfigChanges; + } + | { + type: "session.revert"; + requestId: string; + sessionId: string; + token: JsonValue; + scope: "conversation" | "files" | "both"; + } + | { type: "session.archive"; requestId: string; persistence: ProviderPersistence } + | { type: "session.unarchive"; requestId: string; persistence: ProviderPersistence } + | { type: "session.close"; requestId: string; sessionId: string }; + +export interface ProviderThinkingOption { + id: string; + label: string; + description?: string; + isDefault?: boolean; + metadata?: Readonly>; +} + +export interface ProviderModel { + id: string; + aliases?: string[]; + isSelectable?: boolean; + label: string; + description?: string; + isDefault?: boolean; + metadata?: Readonly>; + contextWindowMaxTokens?: number; + thinkingOptions?: ProviderThinkingOption[]; + defaultThinkingOptionId?: string; +} + +export interface ProviderMode { + id: string; + label: string; + description?: string; + icon?: string; + colorTier?: string; + isUnattended?: boolean; +} + +export type ProviderSetting = + | { + type: "toggle"; + id: string; + label: string; + description?: string; + value: boolean; + } + | { + type: "select"; + id: string; + label: string; + description?: string; + value: string | null; + options: ReadonlyArray<{ label: string; value: string }>; + }; + +export interface ProviderConfigState { + model?: string; + mode?: string; + thinkingOption?: string; + models: readonly ProviderModel[]; + modes: readonly ProviderMode[]; + thinkingOptions: readonly ProviderThinkingOption[]; + settings: readonly ProviderSetting[]; +} + +export interface ProviderCatalog { + models: readonly ProviderModel[]; + modes: readonly ProviderMode[]; + thinkingOptions?: readonly ProviderThinkingOption[]; + defaultModel?: string; + defaultMode?: string; + defaultThinkingOption?: string; +} + +export interface ProviderSessionSummary { + persistence: ProviderPersistence; + cwd: string; + title?: string; + description?: string; + updatedAt?: string; +} + +export interface ProviderCommand { + name: string; + description: string; + argumentHint?: string; +} + +export interface ProviderError { + message: string; + code?: string; + diagnostic?: string; +} + +export interface ProviderNotice { + id: string; + severity: "info" | "warning" | "error"; + title: string; + description?: string; + dismissed?: boolean; +} + +export interface ProviderPermissionAction { + id: string; + label: string; + behavior: "allow" | "deny"; + variant?: "primary" | "secondary" | "danger"; + intent?: "implement" | "implement_resume" | "dismiss"; +} + +export interface ProviderPermissionRequest { + id: string; + name: string; + kind: "tool" | "plan" | "question" | "mode" | "other"; + title?: string; + description?: string; + input?: Readonly>; + detail?: ProviderToolCallDetail; + suggestions?: Array>; + actions?: ProviderPermissionAction[]; + metadata?: Readonly>; +} + +export type ProviderPermissionResponse = + | { + behavior: "allow"; + selectedActionId?: string; + updatedInput?: Record; + updatedPermissions?: Array>; + } + | { + behavior: "deny"; + selectedActionId?: string; + message?: string; + interrupt?: boolean; + }; + +export interface ProviderUsage { + inputTokens?: number; + cachedInputTokens?: number; + outputTokens?: number; + totalCostUsd?: number; + contextWindowMaxTokens?: number; + contextWindowUsedTokens?: number; +} + +interface ProviderTimelineIdentity { + id: string; + revertToken?: JsonValue; +} + +export type ProviderToolCallDetail = + | { type: "shell"; command: string; cwd?: string; output?: string; exitCode?: number | null } + | { type: "read"; filePath: string; content?: string; offset?: number; limit?: number } + | { + type: "edit"; + filePath: string; + oldString?: string; + newString?: string; + unifiedDiff?: string; + } + | { type: "write"; filePath: string; content?: string } + | { + type: "search"; + query: string; + toolName?: "search" | "grep" | "glob" | "web_search"; + content?: string; + filePaths?: string[]; + webResults?: Array<{ title: string; url: string }>; + annotations?: string[]; + numFiles?: number; + numMatches?: number; + durationMs?: number; + durationSeconds?: number; + truncated?: boolean; + mode?: "content" | "files_with_matches" | "count"; + } + | { + type: "fetch"; + url: string; + prompt?: string; + result?: string; + code?: number; + codeText?: string; + bytes?: number; + durationMs?: number; + } + | { + type: "worktree_setup"; + worktreePath: string; + branchName: string; + log: string; + commands: Array<{ + index: number; + command: string; + cwd: string; + log: string; + status: "running" | "completed" | "failed"; + exitCode: number | null; + durationMs?: number; + }>; + truncated?: boolean; + } + | { + type: "sub_agent"; + subAgentType?: string; + description?: string; + childSessionId?: string; + log: string; + actions?: Array<{ index: number; toolName: string; summary?: string }>; + } + | { + type: "plain_text"; + label?: string; + text?: string; + icon?: + | "wrench" + | "square_terminal" + | "eye" + | "pencil" + | "search" + | "bot" + | "sparkles" + | "brain" + | "mic_vocal"; + } + | { type: "plan"; text: string } + | { type: "unknown"; input: JsonValue; output: JsonValue }; + +type ProviderToolCallItem = ProviderTimelineIdentity & { + type: "tool_call"; + callId: string; + name: string; + detail: ProviderToolCallDetail; + metadata?: Readonly>; +} & ( + | { status: "running" | "completed" | "canceled"; error: null } + | { status: "failed"; error: JsonValue } + ); + +export type ProviderTimelineItem = + | (ProviderTimelineIdentity & { + type: "user_message"; + text: string; + messageId?: string; + clientMessageId?: string; + }) + | (ProviderTimelineIdentity & { type: "assistant_message"; text: string; messageId?: string }) + | (ProviderTimelineIdentity & { type: "reasoning"; text: string }) + | ProviderToolCallItem + | (ProviderTimelineIdentity & { + type: "todo"; + items: Array<{ + text: string; + completed: boolean; + id?: string; + status?: "pending" | "in_progress" | "completed"; + activeForm?: string; + }>; + }) + | (ProviderTimelineIdentity & { type: "error"; message: string }) + | (ProviderTimelineIdentity & { + type: "notification"; + level: "info" | "warning" | "error"; + message: string; + }) + | (ProviderTimelineIdentity & { + type: "compaction"; + status: "loading" | "completed"; + trigger?: "auto" | "manual"; + preTokens?: number; + }) + | (ProviderTimelineIdentity & { + type: "plugin"; + pluginId: string; + kind: string; + version: number; + data: JsonValue; + }); + +export type ProviderEvent = + | { type: "catalog"; requestId: string; catalog: ProviderCatalog } + | { type: "sessions"; requestId: string; sessions: ProviderSessionSummary[] } + | { type: "request.completed"; requestId: string } + | { type: "request.failed"; requestId: string; error: ProviderError } + | { + type: "session.opened"; + requestId?: string; + sessionId: string; + parentSessionId?: string; + capabilities: readonly string[]; + restoration: "core" | "parent"; + persistence?: ProviderPersistence; + title?: string; + description?: string; + cwd: string; + } + | { type: "session.ready"; requestId?: string; sessionId: string } + | { type: "session.closed"; sessionId: string; error?: ProviderError } + | { type: "session.runtime_failed"; sessionId: string; error: ProviderError } + | { type: "session.persistence"; sessionId: string; persistence: ProviderPersistence } + | { + type: "session.prompt_result"; + sessionId: string; + clientMessageId: string; + result: + | { type: "turn"; turnId: string } + | { type: "steer"; turnId: string } + | { type: "completed" } + | { type: "failed"; error: ProviderError }; + } + | { + type: "session.turn"; + sessionId: string; + turnId: string; + state: "started" | "completed" | "failed" | "canceled"; + error?: ProviderError; + } + | { type: "session.usage"; sessionId: string; turnId?: string; usage: ProviderUsage } + | { type: "session.config"; sessionId: string; config: ProviderConfigState } + | { type: "session.commands"; sessionId: string; commands: ProviderCommand[] } + | { type: "session.permission"; sessionId: string; request: ProviderPermissionRequest } + | { type: "session.permission_resolved"; sessionId: string; permissionId: string } + | { type: "session.notice"; sessionId: string; notice: ProviderNotice } + | { type: "timeline.item"; sessionId: string; item: ProviderTimelineItem; timestamp?: string }; + +export function negotiateProviderCapabilities( + offered: readonly string[], + supported: readonly string[], +): readonly ProviderCapability[] { + const known = new Set(PROVIDER_CAPABILITIES); + const supportedCapabilities = new Set(supported.filter((capability) => known.has(capability))); + return offered.filter( + (capability, index): capability is ProviderCapability => + isProviderCapability(capability) && + supportedCapabilities.has(capability) && + offered.indexOf(capability) === index, + ); +} + +export function isProviderCapability(capability: string): capability is ProviderCapability { + return new Set(PROVIDER_CAPABILITIES).has(capability); +} + +function requiredPromptCapabilities(prompt: ProviderPrompt): readonly ProviderCapability[] { + const capabilities: ProviderCapability[] = []; + if (prompt.delivery === "steer") capabilities.push("prompt.steer"); + if (prompt.input.type === "command") capabilities.push("prompt.command"); + else { + capabilities.push("prompt.message"); + if (prompt.input.content.some((part) => part.type === "image")) { + capabilities.push("prompt.image"); + } + } + if (prompt.outputSchema !== undefined) capabilities.push("prompt.output_schema"); + return capabilities; +} + +export function requiredProviderCapabilities(input: ProviderInput): readonly ProviderCapability[] { + switch (input.type) { + case "catalog": + case "session.interrupt": + case "session.close": + return []; + case "sessions": + return ["session.list"]; + case "session.open": { + const capabilities: ProviderCapability[] = []; + if (input.persistence) capabilities.push("session.persistence"); + if (input.config.toolPolicy) capabilities.push("permission.tool_policy"); + return capabilities; + } + case "session.prompt": + return requiredPromptCapabilities(input.prompt); + case "session.permission": + return ["permission"]; + case "session.configure": + return ["session.configure"]; + case "session.revert": + return [`session.revert.${input.scope}`]; + case "session.archive": + return ["session.archive"]; + case "session.unarchive": + return ["session.unarchive"]; + } +} + +export function requireProviderCapabilities( + capabilities: readonly string[], + input: ProviderInput, +): void { + for (const capability of requiredProviderCapabilities(input)) { + if (!capabilities.includes(capability)) { + throw new Error(`Provider does not support ${capability}`); + } + } +} + +const idSchema = z.string().min(1); +const jsonObjectSchema = z.record(z.string(), z.json()); +const providerErrorSchema = z + .object({ message: z.string(), code: z.string().optional(), diagnostic: z.string().optional() }) + .strip(); +const persistenceSchema = z + .object({ version: z.number().int().nonnegative(), data: z.json() }) + .strip(); +const mcpServerSchema = z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("stdio"), + command: z.string(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + alwaysLoad: z.boolean().optional(), + }) + .strict(), + z + .object({ + type: z.literal("http"), + url: z.string(), + headers: z.record(z.string(), z.string()).optional(), + alwaysLoad: z.boolean().optional(), + }) + .strict(), + z + .object({ + type: z.literal("sse"), + url: z.string(), + headers: z.record(z.string(), z.string()).optional(), + alwaysLoad: z.boolean().optional(), + }) + .strict(), +]); +const toolPolicySchema = z + .object({ + preapproved: z.array( + z.object({ kind: z.literal("mcp"), server: idSchema, tool: idSchema }).strict(), + ), + }) + .strict(); +const providerPermissionResponseSchema: z.ZodType = + z.discriminatedUnion("behavior", [ + z + .object({ + behavior: z.literal("allow"), + selectedActionId: z.string().optional(), + updatedInput: jsonObjectSchema.optional(), + updatedPermissions: z.array(jsonObjectSchema).optional(), + }) + .strip(), + z + .object({ + behavior: z.literal("deny"), + selectedActionId: z.string().optional(), + message: z.string().optional(), + interrupt: z.boolean().optional(), + }) + .strip(), + ]); +const sessionConfigSchema = z + .object({ + cwd: z.string(), + env: z.record(z.string(), z.string()), + systemPrompt: z.string().optional(), + mcpServers: z.record(z.string(), mcpServerSchema), + toolPolicy: toolPolicySchema.optional(), + model: z.string().optional(), + mode: z.string().optional(), + thinkingOption: z.string().optional(), + settings: jsonObjectSchema, + providerOptions: jsonObjectSchema.optional(), + title: z.string().optional(), + persist: z.boolean(), + }) + .strict(); +const externalResourceSchema = z + .object({ + provider: z.string(), + providerLabel: z.string(), + resourceType: z.string(), + id: z.string(), + identifier: z.string(), + title: z.string(), + url: z.string(), + }) + .strip(); +const reviewContextLineSchema = z + .object({ + oldLineNumber: z.number().int().positive().nullable(), + newLineNumber: z.number().int().positive().nullable(), + type: z.enum(["add", "remove", "context"]), + content: z.string(), + }) + .strip(); +const providerContentSchema: z.ZodType = z.union([ + z.object({ type: z.literal("text"), text: z.string() }).strict(), + z.object({ type: z.literal("image"), data: z.string(), mimeType: z.string() }).strict(), + z + .object({ + type: z.literal("forge_change_request"), + mimeType: z.literal("application/paseo-forge-change-request"), + forge: z.string().optional(), + number: z.number().int().positive(), + title: z.string(), + url: z.string(), + body: z.string().nullable().optional(), + projectPath: z.string().optional(), + baseRefName: z.string().nullable().optional(), + headRefName: z.string().nullable().optional(), + }) + .strip(), + z + .object({ + type: z.literal("forge_issue"), + mimeType: z.literal("application/paseo-forge-issue"), + forge: z.string().optional(), + number: z.number().int().positive(), + title: z.string(), + url: z.string(), + body: z.string().nullable().optional(), + projectPath: z.string().optional(), + }) + .strip(), + z + .object({ + type: z.literal("github_pr"), + mimeType: z.literal("application/github-pr"), + number: z.number().int().positive(), + title: z.string(), + url: z.string(), + body: z.string().nullable().optional(), + baseRefName: z.string().nullable().optional(), + headRefName: z.string().nullable().optional(), + }) + .strip(), + z + .object({ + type: z.literal("github_issue"), + mimeType: z.literal("application/github-issue"), + number: z.number().int().positive(), + title: z.string(), + url: z.string(), + body: z.string().nullable().optional(), + }) + .strip(), + z + .object({ + type: z.literal("text"), + mimeType: z.literal("text/plain"), + contextKind: z.string().optional(), + title: z.string().nullable().optional(), + text: z.string(), + externalResource: externalResourceSchema.optional(), + }) + .strip(), + z + .object({ + type: z.literal("review"), + mimeType: z.literal("application/paseo-review"), + cwd: z.string(), + mode: z.enum(["uncommitted", "base"]), + baseRef: z.string().nullable().optional(), + comments: z.array( + z + .object({ + filePath: z.string(), + side: z.enum(["old", "new"]), + lineNumber: z.number().int().positive(), + body: z.string(), + context: z + .object({ + hunkHeader: z.string(), + targetLine: reviewContextLineSchema, + lines: z.array(reviewContextLineSchema), + }) + .strip(), + }) + .strip(), + ), + }) + .strip(), + z + .object({ + type: z.literal("uploaded_file"), + id: z.string(), + fileName: z.string(), + mimeType: z.string(), + size: z.number().int().nonnegative(), + path: z.string(), + }) + .strip(), +]); +const promptSchema = z + .object({ + clientMessageId: idSchema, + delivery: z.enum(["auto", "steer"]), + input: z.union([ + z.object({ type: z.literal("message"), content: z.array(providerContentSchema) }).strict(), + z.object({ type: z.literal("command"), name: idSchema, arguments: z.string() }).strict(), + ]), + outputSchema: z.json().optional(), + clearPendingPermissions: z.boolean().optional(), + }) + .strict(); +const configChangesSchema = z + .object({ + model: z.string().nullable().optional(), + mode: z.string().nullable().optional(), + thinkingOption: z.string().nullable().optional(), + settings: jsonObjectSchema.optional(), + }) + .strict(); + +export const ProviderInputSchema: z.ZodType = z.discriminatedUnion("type", [ + z + .object({ type: z.literal("catalog"), requestId: idSchema, cwd: z.string().optional() }) + .strict(), + z + .object({ + type: z.literal("sessions"), + requestId: idSchema, + query: z.string().optional(), + cwd: z.string().optional(), + limit: z.number().int().positive().optional(), + }) + .strict(), + z + .object({ + type: z.literal("session.open"), + requestId: idSchema, + sessionId: idSchema, + config: sessionConfigSchema, + persistence: persistenceSchema.optional(), + history: z.enum(["replay", "skip"]), + }) + .strict(), + z + .object({ type: z.literal("session.prompt"), sessionId: idSchema, prompt: promptSchema }) + .strict(), + z + .object({ type: z.literal("session.interrupt"), requestId: idSchema, sessionId: idSchema }) + .strict(), + z + .object({ + type: z.literal("session.permission"), + sessionId: idSchema, + permissionId: idSchema, + response: providerPermissionResponseSchema, + }) + .strict(), + z + .object({ + type: z.literal("session.configure"), + requestId: idSchema, + sessionId: idSchema, + changes: configChangesSchema, + }) + .strict(), + z + .object({ + type: z.literal("session.revert"), + requestId: idSchema, + sessionId: idSchema, + token: z.json(), + scope: z.enum(["conversation", "files", "both"]), + }) + .strict(), + z + .object({ + type: z.literal("session.archive"), + requestId: idSchema, + persistence: persistenceSchema, + }) + .strict(), + z + .object({ + type: z.literal("session.unarchive"), + requestId: idSchema, + persistence: persistenceSchema, + }) + .strict(), + z.object({ type: z.literal("session.close"), requestId: idSchema, sessionId: idSchema }).strict(), +]); + +const modeSchema = z + .object({ + id: idSchema, + label: z.string(), + description: z.string().optional(), + icon: z.string().optional(), + colorTier: z.string().optional(), + isUnattended: z.boolean().optional(), + }) + .strip(); +const selectOptionSchema = z + .object({ + id: idSchema, + label: z.string(), + description: z.string().optional(), + isDefault: z.boolean().optional(), + metadata: jsonObjectSchema.optional(), + }) + .strip(); +const modelSchema = z + .object({ + id: idSchema, + aliases: z.array(z.string()).optional(), + isSelectable: z.boolean().optional(), + label: z.string(), + description: z.string().optional(), + isDefault: z.boolean().optional(), + metadata: jsonObjectSchema.optional(), + contextWindowMaxTokens: z.number().optional(), + thinkingOptions: z.array(selectOptionSchema).optional(), + defaultThinkingOptionId: z.string().optional(), + }) + .strip(); +const settingSchema = z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("toggle"), + id: idSchema, + label: z.string(), + description: z.string().optional(), + value: z.boolean(), + }) + .strip(), + z + .object({ + type: z.literal("select"), + id: idSchema, + label: z.string(), + description: z.string().optional(), + value: z.string().nullable(), + options: z.array(z.object({ label: z.string(), value: z.string() }).strip()), + }) + .strip(), +]); +const configStateSchema = z + .object({ + model: z.string().optional(), + mode: z.string().optional(), + thinkingOption: z.string().optional(), + models: z.array(modelSchema), + modes: z.array(modeSchema), + thinkingOptions: z.array(selectOptionSchema), + settings: z.array(settingSchema), + }) + .strip(); +const catalogSchema = z + .object({ + models: z.array(modelSchema), + modes: z.array(modeSchema), + thinkingOptions: z.array(selectOptionSchema).optional(), + defaultModel: z.string().optional(), + defaultMode: z.string().optional(), + defaultThinkingOption: z.string().optional(), + }) + .strip(); +const noticeSchema = z + .object({ + id: idSchema, + severity: z.enum(["info", "warning", "error"]), + title: z.string(), + description: z.string().optional(), + dismissed: z.boolean().optional(), + }) + .strip(); +const commandSchema = z + .object({ name: idSchema, description: z.string(), argumentHint: z.string().optional() }) + .strip(); +const usageSchema = z + .object({ + inputTokens: z.number().optional(), + cachedInputTokens: z.number().optional(), + outputTokens: z.number().optional(), + totalCostUsd: z.number().optional(), + contextWindowMaxTokens: z.number().optional(), + contextWindowUsedTokens: z.number().optional(), + }) + .strip(); +const worktreeCommandSchema = z + .object({ + index: z.number().int().positive(), + command: z.string(), + cwd: z.string(), + log: z.string(), + status: z.enum(["running", "completed", "failed"]), + exitCode: z.number().nullable(), + durationMs: z.number().nonnegative().optional(), + }) + .strip(); +const toolCallDetailSchema: z.ZodType = z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("shell"), + command: z.string(), + cwd: z.string().optional(), + output: z.string().optional(), + exitCode: z.number().nullable().optional(), + }) + .strip(), + z + .object({ + type: z.literal("read"), + filePath: z.string(), + content: z.string().optional(), + offset: z.number().optional(), + limit: z.number().optional(), + }) + .strip(), + z + .object({ + type: z.literal("edit"), + filePath: z.string(), + oldString: z.string().optional(), + newString: z.string().optional(), + unifiedDiff: z.string().optional(), + }) + .strip(), + z + .object({ type: z.literal("write"), filePath: z.string(), content: z.string().optional() }) + .strip(), + z + .object({ + type: z.literal("search"), + query: z.string(), + toolName: z.enum(["search", "grep", "glob", "web_search"]).optional(), + content: z.string().optional(), + filePaths: z.array(z.string()).optional(), + webResults: z.array(z.object({ title: z.string(), url: z.string() }).strip()).optional(), + annotations: z.array(z.string()).optional(), + numFiles: z.number().optional(), + numMatches: z.number().optional(), + durationMs: z.number().optional(), + durationSeconds: z.number().optional(), + truncated: z.boolean().optional(), + mode: z.enum(["content", "files_with_matches", "count"]).optional(), + }) + .strip(), + z + .object({ + type: z.literal("fetch"), + url: z.string(), + prompt: z.string().optional(), + result: z.string().optional(), + code: z.number().optional(), + codeText: z.string().optional(), + bytes: z.number().optional(), + durationMs: z.number().optional(), + }) + .strip(), + z + .object({ + type: z.literal("worktree_setup"), + worktreePath: z.string(), + branchName: z.string(), + log: z.string(), + commands: z.array(worktreeCommandSchema), + truncated: z.boolean().optional(), + }) + .strip(), + z + .object({ + type: z.literal("sub_agent"), + subAgentType: z.string().optional(), + description: z.string().optional(), + childSessionId: z.string().optional(), + log: z.string(), + actions: z + .array( + z + .object({ + index: z.number().int().positive(), + toolName: z.string(), + summary: z.string().optional(), + }) + .strip(), + ) + .optional(), + }) + .strip(), + z + .object({ + type: z.literal("plain_text"), + label: z.string().optional(), + text: z.string().optional(), + icon: z + .enum([ + "wrench", + "square_terminal", + "eye", + "pencil", + "search", + "bot", + "sparkles", + "brain", + "mic_vocal", + ]) + .optional(), + }) + .strip(), + z.object({ type: z.literal("plan"), text: z.string() }).strip(), + z.object({ type: z.literal("unknown"), input: z.json(), output: z.json() }).strip(), +]); +const timelineIdentityShape = { id: idSchema, revertToken: z.json().optional() }; +const toolCallBaseShape = { + ...timelineIdentityShape, + type: z.literal("tool_call"), + callId: idSchema, + name: z.string(), + detail: toolCallDetailSchema, + metadata: jsonObjectSchema.optional(), +}; +const timelineItemSchema: z.ZodType = z.union([ + z + .object({ + ...timelineIdentityShape, + type: z.literal("user_message"), + text: z.string(), + messageId: z.string().optional(), + clientMessageId: z.string().optional(), + }) + .strip(), + z + .object({ + ...timelineIdentityShape, + type: z.literal("assistant_message"), + text: z.string(), + messageId: z.string().optional(), + }) + .strip(), + z.object({ ...timelineIdentityShape, type: z.literal("reasoning"), text: z.string() }).strip(), + z.object({ ...toolCallBaseShape, status: z.literal("running"), error: z.null() }).strip(), + z.object({ ...toolCallBaseShape, status: z.literal("completed"), error: z.null() }).strip(), + z.object({ ...toolCallBaseShape, status: z.literal("failed"), error: z.json() }).strip(), + z.object({ ...toolCallBaseShape, status: z.literal("canceled"), error: z.null() }).strip(), + z + .object({ + ...timelineIdentityShape, + type: z.literal("todo"), + items: z.array( + z + .object({ + text: z.string(), + completed: z.boolean(), + id: z.string().optional(), + status: z.enum(["pending", "in_progress", "completed"]).optional(), + activeForm: z.string().optional(), + }) + .strip(), + ), + }) + .strip(), + z.object({ ...timelineIdentityShape, type: z.literal("error"), message: z.string() }).strip(), + z + .object({ + ...timelineIdentityShape, + type: z.literal("notification"), + level: z.enum(["info", "warning", "error"]), + message: z.string(), + }) + .strip(), + z + .object({ + ...timelineIdentityShape, + type: z.literal("compaction"), + status: z.enum(["loading", "completed"]), + trigger: z.enum(["auto", "manual"]).optional(), + preTokens: z.number().optional(), + }) + .strip(), + z + .object({ + ...timelineIdentityShape, + type: z.literal("plugin"), + pluginId: idSchema, + kind: z.string(), + version: z.number(), + data: z.json(), + }) + .strip(), +]); +const permissionActionSchema = z + .object({ + id: idSchema, + label: z.string(), + behavior: z.enum(["allow", "deny"]), + variant: z.enum(["primary", "secondary", "danger"]).optional(), + intent: z.enum(["implement", "implement_resume", "dismiss"]).optional(), + }) + .strip(); +const providerPermissionRequestSchema: z.ZodType = z + .object({ + id: idSchema, + name: z.string(), + kind: z.enum(["tool", "plan", "question", "mode", "other"]), + title: z.string().optional(), + description: z.string().optional(), + input: jsonObjectSchema.optional(), + detail: toolCallDetailSchema.optional(), + suggestions: z.array(jsonObjectSchema).optional(), + actions: z.array(permissionActionSchema).optional(), + metadata: jsonObjectSchema.optional(), + }) + .strip(); + +export const ProviderEventSchema: z.ZodType = z.discriminatedUnion("type", [ + z.object({ type: z.literal("catalog"), requestId: idSchema, catalog: catalogSchema }).strip(), + z + .object({ + type: z.literal("sessions"), + requestId: idSchema, + sessions: z.array( + z + .object({ + persistence: persistenceSchema, + cwd: z.string(), + title: z.string().optional(), + description: z.string().optional(), + updatedAt: z.string().optional(), + }) + .strip(), + ), + }) + .strip(), + z.object({ type: z.literal("request.completed"), requestId: idSchema }).strip(), + z + .object({ type: z.literal("request.failed"), requestId: idSchema, error: providerErrorSchema }) + .strip(), + z + .object({ + type: z.literal("session.opened"), + requestId: idSchema.optional(), + sessionId: idSchema, + parentSessionId: idSchema.optional(), + capabilities: z.array(z.string()), + restoration: z.enum(["core", "parent"]), + persistence: persistenceSchema.optional(), + title: z.string().optional(), + description: z.string().optional(), + cwd: z.string(), + }) + .strip(), + z + .object({ + type: z.literal("session.ready"), + requestId: idSchema.optional(), + sessionId: idSchema, + }) + .strip(), + z + .object({ + type: z.literal("session.closed"), + sessionId: idSchema, + error: providerErrorSchema.optional(), + }) + .strip(), + z + .object({ + type: z.literal("session.runtime_failed"), + sessionId: idSchema, + error: providerErrorSchema, + }) + .strip(), + z + .object({ + type: z.literal("session.persistence"), + sessionId: idSchema, + persistence: persistenceSchema, + }) + .strip(), + z + .object({ + type: z.literal("session.prompt_result"), + sessionId: idSchema, + clientMessageId: idSchema, + result: z.union([ + z.object({ type: z.literal("turn"), turnId: idSchema }).strip(), + z.object({ type: z.literal("steer"), turnId: idSchema }).strip(), + z.object({ type: z.literal("completed") }).strip(), + z.object({ type: z.literal("failed"), error: providerErrorSchema }).strip(), + ]), + }) + .strip(), + z + .object({ + type: z.literal("session.turn"), + sessionId: idSchema, + turnId: idSchema, + state: z.enum(["started", "completed", "failed", "canceled"]), + error: providerErrorSchema.optional(), + }) + .strip(), + z + .object({ + type: z.literal("session.usage"), + sessionId: idSchema, + turnId: idSchema.optional(), + usage: usageSchema, + }) + .strip(), + z + .object({ type: z.literal("session.config"), sessionId: idSchema, config: configStateSchema }) + .strip(), + z + .object({ + type: z.literal("session.commands"), + sessionId: idSchema, + commands: z.array(commandSchema), + }) + .strip(), + z + .object({ + type: z.literal("session.permission"), + sessionId: idSchema, + request: providerPermissionRequestSchema, + }) + .strip(), + z + .object({ + type: z.literal("session.permission_resolved"), + sessionId: idSchema, + permissionId: idSchema, + }) + .strip(), + z + .object({ type: z.literal("session.notice"), sessionId: idSchema, notice: noticeSchema }) + .strip(), + z + .object({ + type: z.literal("timeline.item"), + sessionId: idSchema, + item: timelineItemSchema, + timestamp: z.string().optional(), + }) + .strip(), +]); diff --git a/packages/plugin/tsconfig.json b/packages/plugin/tsconfig.json index b6c46d8b3d..536cb17750 100644 --- a/packages/plugin/tsconfig.json +++ b/packages/plugin/tsconfig.json @@ -4,6 +4,7 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2023", "DOM"], + "types": ["node"], "jsx": "react-jsx", "strict": true, "skipLibCheck": true, diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index 57e09f2eca..83932d3506 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -113,6 +113,7 @@ export interface ProviderSnapshotEntry { fetchedAt?: string; label?: string; description?: string; + iconSvg?: string; defaultModeId?: string | null; } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index d6134c95a5..cabd2eb09e 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -377,6 +377,7 @@ export const ProviderSnapshotEntrySchema = z.object({ fetchedAt: z.string().optional(), label: z.string().optional(), description: z.string().optional(), + iconSvg: z.string().optional(), defaultModeId: z.string().nullable().optional(), }); diff --git a/packages/protocol/src/provider-snapshot-codec.test.ts b/packages/protocol/src/provider-snapshot-codec.test.ts index 2921260851..f1f245c9b7 100644 --- a/packages/protocol/src/provider-snapshot-codec.test.ts +++ b/packages/protocol/src/provider-snapshot-codec.test.ts @@ -18,6 +18,7 @@ function providerEntry(): ProviderSnapshotEntry { provider: "pi", status: "ready", enabled: true, + iconSvg: '', models: [ { provider: "pi", diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 0a4b565e39..433cd7a090 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -125,6 +125,7 @@ export interface ProviderSnapshotEntry { fetchedAt?: string; label?: string; description?: string; + iconSvg?: string; defaultModeId?: string | null; } diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts new file mode 100644 index 0000000000..4c8abd2357 --- /dev/null +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -0,0 +1,347 @@ +import type { + ProviderConnection, + ProviderEvent, + ProviderInput, + ProviderRegistration, +} from "@getpaseo/plugin/provider"; +import { describe, expect, test } from "vitest"; +import { createTestLogger } from "../../test-utils/test-logger.js"; +import type { AgentStreamEvent } from "./agent-sdk-types.js"; +import { PluginAgentClientRegistry } from "./plugin-provider.js"; + +const CAPABILITIES = [ + "prompt.message", + "session.configure", + "session.persistence", + "session.subsession", + "permission", +] as const; + +interface ProviderHarnessOptions { + capabilities?: ProviderConnection["capabilities"]; + completeTurn?: boolean; +} + +function createProviderHarness(options: ProviderHarnessOptions = {}) { + let listener: ((event: ProviderEvent) => void) | null = null; + let closeCount = 0; + const inputs: ProviderInput[] = []; + const emit = (event: ProviderEvent) => listener?.(event); + const capabilities = options.capabilities ?? CAPABILITIES; + + const connection: ProviderConnection = { + version: 1, + capabilities, + async send(input) { + inputs.push(input); + if (input.type === "catalog") { + emit({ + type: "catalog", + requestId: input.requestId, + catalog: { + models: [{ id: "plugin-model", label: "Plugin model" }], + modes: [{ id: "build", label: "Build" }], + thinkingOptions: [{ id: "deep", label: "Deep" }], + defaultModel: "plugin-model", + defaultMode: "build", + defaultThinkingOption: "deep", + }, + }); + return; + } + if (input.type === "session.open") { + emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities, + restoration: "core", + persistence: { version: 1, data: { token: "root" } }, + cwd: input.config.cwd, + }); + emit({ + type: "session.config", + sessionId: input.sessionId, + config: { + model: "plugin-model", + mode: "build", + models: [{ id: "plugin-model", label: "Plugin model" }], + modes: [{ id: "build", label: "Build" }], + thinkingOptions: [], + settings: [ + { + type: "select", + id: "voice", + label: "Voice", + value: "direct", + options: [{ label: "Direct", value: "direct" }], + }, + ], + }, + }); + emit({ + type: "session.opened", + sessionId: "child-1", + parentSessionId: input.sessionId, + capabilities: [], + restoration: "parent", + title: "Plugin child", + cwd: input.config.cwd, + }); + emit({ + type: "timeline.item", + sessionId: "child-1", + item: { type: "assistant_message", id: "child-message", text: "Child result" }, + }); + emit({ + type: "session.turn", + sessionId: "child-1", + turnId: "child-turn", + state: "completed", + }); + emit({ type: "session.ready", sessionId: "child-1" }); + emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + return; + } + if (input.type === "session.prompt") { + emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "turn", turnId: "turn-1" }, + }); + emit({ + type: "session.turn", + sessionId: input.sessionId, + turnId: "turn-1", + state: "started", + }); + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { type: "assistant_message", id: "answer", text: "Hel" }, + }); + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { type: "assistant_message", id: "answer", text: "Hello" }, + }); + emit({ + type: "session.permission", + sessionId: input.sessionId, + request: { id: "permission-1", name: "write", kind: "tool" }, + }); + if (options.completeTurn !== false) { + emit({ + type: "session.turn", + sessionId: input.sessionId, + turnId: "turn-1", + state: "completed", + }); + } + return; + } + if (input.type === "session.permission") { + emit({ + type: "session.permission_resolved", + sessionId: input.sessionId, + permissionId: input.permissionId, + }); + return; + } + if (input.type === "session.configure") { + emit({ + type: "session.config", + sessionId: input.sessionId, + config: { + model: input.changes.model ?? "plugin-model", + mode: "build", + models: [{ id: "plugin-model", label: "Plugin model" }], + modes: [{ id: "build", label: "Build" }], + thinkingOptions: [], + settings: [], + }, + }); + emit({ type: "request.completed", requestId: input.requestId }); + return; + } + if (input.type === "session.close") { + emit({ type: "session.closed", sessionId: input.sessionId }); + } + if ("requestId" in input) { + emit({ type: "request.completed", requestId: input.requestId }); + } + }, + onEvent(nextListener) { + listener = nextListener; + return () => { + if (listener === nextListener) listener = null; + }; + }, + async close() { + closeCount += 1; + }, + }; + + const registration: ProviderRegistration = { + id: "plugin-direct", + label: "Plugin direct", + async connect() { + return connection; + }, + }; + + return { registration, inputs, closeCount: () => closeCount }; +} + +function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"]) { + return events.filter((event) => event.type === type); +} + +describe("PluginAgentClientRegistry", () => { + test("gates persistence operations on negotiated provider capabilities", async () => { + const harness = createProviderHarness({ capabilities: ["session.persistence"] }); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + + await expect(client.isAvailable()).resolves.toBe(true); + expect(client.capabilities).toMatchObject({ + supportsSessionPersistence: true, + supportsSessionListing: false, + }); + await expect(client.listImportableSessions?.()).resolves.toEqual([]); + + const persistence = { + provider: harness.registration.id, + sessionId: 'plugin:{"version":1,"data":{"token":"root"}}', + metadata: { pluginProviderPersistence: { version: 1, data: { token: "root" } } }, + }; + await expect(client.archiveNativeSession?.(persistence)).resolves.toBeUndefined(); + await expect(client.unarchiveNativeSession?.(persistence)).resolves.toBeUndefined(); + expect(harness.inputs).toEqual([]); + await registry.shutdown(); + }); + + test("terminalizes an active turn exactly once when its plugin provider is removed", async () => { + const harness = createProviderHarness({ completeTurn: false }); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + const session = await client.createSession({ + provider: harness.registration.id, + cwd: "/workspace", + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + const run = session.run("hello", { clientMessageId: "active-message" }); + void run.catch(() => undefined); + await expect.poll(() => eventsOfType(events, "turn_started")).toHaveLength(1); + + registry.replace([]); + + await expect + .poll(() => eventsOfType(events, "turn_failed")) + .toEqual([ + expect.objectContaining({ + type: "turn_failed", + provider: harness.registration.id, + turnId: "turn-1", + error: "Provider connection closed", + }), + ]); + await expect(run).rejects.toThrow("Provider connection closed"); + await expect.poll(harness.closeCount).toBe(1); + + registry.replace([]); + expect(eventsOfType(events, "turn_failed")).toHaveLength(1); + }); + + test("adapts callback providers into the existing AgentClient and AgentSession path", async () => { + const harness = createProviderHarness(); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]; + expect(client).toBeDefined(); + + await expect(client!.fetchCatalog({ scope: "global", force: false })).resolves.toMatchObject({ + models: [ + { + provider: "plugin-direct", + id: "plugin-model", + isDefault: true, + thinkingOptions: [{ id: "deep", label: "Deep" }], + defaultThinkingOptionId: "deep", + }, + ], + modes: [{ id: "build" }], + defaultModeId: "build", + }); + + const session = await client!.createSession({ provider: "plugin-direct", cwd: "/workspace" }); + expect(session.features).toEqual([ + expect.objectContaining({ + type: "select", + id: "voice", + options: [{ id: "direct", label: "Direct", value: "direct" }], + }), + ]); + expect(session.describePersistence()).toMatchObject({ + provider: "plugin-direct", + metadata: { pluginProviderPersistence: { version: 1, data: { token: "root" } } }, + }); + + const history: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) history.push(event); + expect(history).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ type: "timeline", id: "child-1" }), + }), + ); + expect(history).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ type: "upsert", id: "child-1", status: "completed" }), + }), + ); + + const events: AgentStreamEvent[] = []; + const unsubscribe = session.subscribe((event) => events.push(event)); + await expect( + session.startTurn("hello", { clientMessageId: "client-message" }), + ).resolves.toEqual({ turnId: "turn-1" }); + expect( + events + .filter((event) => event.type === "timeline") + .map((event) => (event.type === "timeline" ? event.item : null)), + ).toEqual([ + { type: "assistant_message", text: "Hel", messageId: "answer" }, + { type: "assistant_message", text: "lo", messageId: "answer" }, + ]); + expect(session.getPendingPermissions()).toEqual([ + expect.objectContaining({ id: "permission-1", provider: "plugin-direct" }), + ]); + + await session.respondToPermission("permission-1", { behavior: "allow" }); + expect(events).toContainEqual( + expect.objectContaining({ + type: "permission_resolved", + requestId: "permission-1", + resolution: { behavior: "allow" }, + }), + ); + await session.setModel?.("plugin-model"); + expect(await session.getRuntimeInfo()).toMatchObject({ + model: "plugin-model", + modeId: "build", + }); + + unsubscribe(); + await session.close(); + registry.replace([]); + await expect.poll(harness.closeCount).toBe(1); + expect(harness.inputs.map((input) => input.type)).toContain("session.close"); + }); +}); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts new file mode 100644 index 0000000000..c2eb5e08de --- /dev/null +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -0,0 +1,1674 @@ +import { randomUUID } from "node:crypto"; +import type { Logger } from "pino"; +import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types"; +import { z } from "zod"; +import { + PROVIDER_CAPABILITIES, + PROVIDER_PROTOCOL_VERSION, + ProviderEventSchema, + ProviderInputSchema, + requireProviderCapabilities, + type ProviderCapability, + type ProviderConfigChanges, + type ProviderConfigState, + type ProviderConnection, + type ProviderError, + type ProviderEvent, + type ProviderInput, + type ProviderPersistence, + type ProviderPrompt, + type ProviderRegistration, + type ProviderSessionConfig, + type ProviderContent, + type ProviderTimelineItem, +} from "@getpaseo/plugin/provider"; +import type { + AgentCapabilityFlags, + AgentClient, + AgentCreateSessionOptions, + AgentFeature, + AgentLaunchContext, + AgentMode, + AgentModelDefinition, + AgentPermissionRequest, + AgentPermissionResponse, + AgentPersistenceHandle, + AgentPromptInput, + AgentRunOptions, + AgentRunResult, + AgentSession, + AgentSessionConfig, + AgentSlashCommand, + AgentStreamEvent, + AgentTimelineItem, + FetchCatalogOptions, + ImportableProviderSession, + ImportedProviderSession, + ImportProviderSessionContext, + ImportProviderSessionInput, + ListImportableSessionsOptions, + ProviderCatalog, + ProviderRefreshContext, + SteerActiveTurnOptions, + SteerResult, +} from "./agent-sdk-types.js"; +import { + isDefaultAgentCreateConfigUnattended, + resolveDefaultAgentCreateConfig, +} from "./create-agent-mode.js"; +import type { ProviderDefinition } from "./provider-registry.js"; +import { runProviderTurn } from "./providers/provider-runner.js"; + +interface Deferred { + promise: Promise; + resolve(value: Value): void; + reject(error: Error): void; +} + +interface PendingRequest { + kind: ProviderInput["type"]; + sessionId?: string; + resolve(event: ProviderEvent): void; + reject(error: Error): void; +} + +interface PendingOpenDescendantState { + readonly providerSessionIds: Set; + readonly events: ProviderEvent[]; +} + +interface OpenProviderSessionInput { + sessionId: string; + config: ProviderSessionConfig; + persistence?: ProviderPersistence; + history: "replay" | "skip"; +} + +function deferred(): Deferred { + let resolve!: (value: Value) => void; + let reject!: (error: Error) => void; + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }); + return { promise, resolve, reject }; +} + +function providerError(error: ProviderError): Error { + return Object.assign(new Error(error.message), { + code: error.code, + diagnostic: error.diagnostic, + }); +} + +class ProviderRuntime { + private connection: ProviderConnection | null = null; + private connecting: Promise | null = null; + private closed = false; + private generation = 0; + private closePromise: Promise | null = null; + private unsubscribe: (() => void) | null = null; + private readonly sessions = new Map(); + private readonly providerSessions = new Map(); + private readonly sessionListeners = new Set< + ( + session: ProviderRuntimeSession, + event: Extract, + ) => void + >(); + private readonly requests = new Map(); + private readonly sessionRequests = new Map(); + private readonly pendingOpenDescendants = new Map(); + private readonly pendingOpenDescendantsByRoot = new Map< + ProviderRuntimeSession, + PendingOpenDescendantState + >(); + + constructor(private readonly registration: ProviderRegistration) {} + + get negotiatedCapabilities(): readonly string[] { + return this.connection?.capabilities ?? []; + } + + async isAvailable(): Promise { + await this.getConnection(); + return true; + } + + async catalog( + cwd?: string, + signal?: AbortSignal, + ): Promise["catalog"]> { + const event = await this.complete({ type: "catalog", requestId: randomUUID(), cwd }, signal); + if (event.type !== "catalog") throw new Error("Provider returned an invalid catalog response"); + return event.catalog; + } + + async listSessions( + input: { + query?: string; + cwd?: string; + limit?: number; + } = {}, + ): Promise["sessions"]> { + const connection = await this.getConnection(); + if (!connection.capabilities.includes("session.list")) return []; + const event = await this.complete({ type: "sessions", requestId: randomUUID(), ...input }); + if (event.type !== "sessions") + throw new Error("Provider returned an invalid sessions response"); + return event.sessions; + } + + onSessionOpened( + listener: ( + session: ProviderRuntimeSession, + event: Extract, + ) => void, + ): () => void { + this.sessionListeners.add(listener); + return () => this.sessionListeners.delete(listener); + } + + async openSession(input: OpenProviderSessionInput): Promise { + if (this.sessions.has(input.sessionId)) { + throw new Error(`Provider session already exists: ${input.sessionId}`); + } + const connection = await this.getConnection(); + requireProviderCapabilities(connection.capabilities, { + type: "session.open", + requestId: "capability-check", + ...input, + }); + const session = new ProviderRuntimeSession( + this, + connection, + input.sessionId, + input.sessionId, + "core", + ); + this.sessions.set(input.sessionId, session); + this.providerSessions.set(input.sessionId, session); + const requestId = randomUUID(); + const ready = session.beginOpen(requestId); + this.sessionRequests.set(requestId, input.sessionId); + try { + await connection.send({ + type: "session.open", + requestId, + sessionId: input.sessionId, + config: input.config, + persistence: input.persistence, + history: input.history, + }); + await ready; + return session; + } catch (error) { + this.sessionRequests.delete(requestId); + this.discardPendingOpenDescendants(session); + this.sessions.delete(input.sessionId); + this.providerSessions.delete(input.sessionId); + throw error; + } + } + + async archive(persistence: ProviderPersistence): Promise { + const connection = await this.getConnection(); + if (!connection.capabilities.includes("session.archive")) return; + await this.complete({ type: "session.archive", requestId: randomUUID(), persistence }); + } + + async unarchive(persistence: ProviderPersistence): Promise { + const connection = await this.getConnection(); + if (!connection.capabilities.includes("session.unarchive")) return; + await this.complete({ type: "session.unarchive", requestId: randomUUID(), persistence }); + } + + close(): Promise { + if (this.closePromise) return this.closePromise; + this.closed = true; + this.generation += 1; + const connection = this.connection; + const connecting = connection ? null : this.connecting; + this.connection = null; + this.unsubscribe?.(); + this.unsubscribe = null; + for (const session of this.sessions.values()) session.connectionClosed(); + this.sessions.clear(); + this.providerSessions.clear(); + this.pendingOpenDescendants.clear(); + this.pendingOpenDescendantsByRoot.clear(); + this.sessionListeners.clear(); + for (const request of this.requests.values()) request.reject(new Error("Provider closed")); + this.requests.clear(); + this.closePromise = Promise.all([ + connection?.close(), + connecting?.then( + () => undefined, + () => undefined, + ), + ]).then(() => undefined); + return this.closePromise; + } + + async complete( + input: ProviderInput & { requestId: string }, + signal?: AbortSignal, + ): Promise { + signal?.throwIfAborted(); + const connection = await this.getConnection(); + signal?.throwIfAborted(); + const pending = deferred(); + this.requests.set(input.requestId, { + kind: input.type, + sessionId: + "sessionId" in input + ? (this.providerSessions.get(input.sessionId)?.id ?? input.sessionId) + : undefined, + resolve: pending.resolve, + reject: pending.reject, + }); + const abort = () => { + if (!this.requests.delete(input.requestId)) return; + pending.reject( + signal?.reason instanceof Error ? signal.reason : new Error("Provider request aborted"), + ); + }; + signal?.addEventListener("abort", abort, { once: true }); + try { + await connection.send(input); + return await pending.promise; + } catch (error) { + this.requests.delete(input.requestId); + throw error; + } finally { + signal?.removeEventListener("abort", abort); + } + } + + removeSession(sessionId: string, providerSessionId: string): void { + this.sessions.delete(sessionId); + this.providerSessions.delete(providerSessionId); + } + + private getConnection(): Promise { + if (this.closed) return Promise.reject(new Error("Provider runtime is closed")); + if (this.connection) return Promise.resolve(this.connection); + if (this.connecting) return this.connecting; + const generation = this.generation; + const connecting = this.establishConnection(generation); + this.connecting = connecting; + connecting.then( + () => { + if (this.connecting === connecting) this.connecting = null; + return undefined; + }, + () => { + if (this.connecting === connecting) this.connecting = null; + return undefined; + }, + ); + return connecting; + } + + private async establishConnection(generation: number): Promise { + const rawConnection = await this.registration.connect({ + versions: [PROVIDER_PROTOCOL_VERSION], + capabilities: PROVIDER_CAPABILITIES, + }); + if (this.closed || generation !== this.generation) { + await rawConnection.close().catch(() => undefined); + throw new Error("Provider runtime is closed"); + } + try { + this.validateConnection(rawConnection); + } catch (error) { + await rawConnection.close().catch(() => undefined); + throw error; + } + const connection = normalizeConnection(rawConnection); + const unsubscribe = connection.onEvent((event) => this.accept(event)); + if (this.closed || generation !== this.generation) { + unsubscribe(); + await connection.close().catch(() => undefined); + throw new Error("Provider runtime is closed"); + } + this.unsubscribe = unsubscribe; + this.connection = connection; + return connection; + } + + private validateConnection(connection: ProviderConnection): void { + if (connection.version !== PROVIDER_PROTOCOL_VERSION) { + throw new Error(`Provider selected unsupported version ${connection.version}`); + } + } + + private accept(event: ProviderEvent): void { + if (event.type === "request.failed") { + this.failRequest(event); + return; + } + if ( + event.type === "request.completed" || + event.type === "catalog" || + event.type === "sessions" + ) { + this.finishRequest(event); + return; + } + this.finishSessionReadyRequest(event); + if (this.holdPendingOpenDescendant(event)) return; + if (this.acceptProviderChild(event)) return; + if ("sessionId" in event) { + const session = this.providerSessions.get(event.sessionId); + if (!session) return; + event = { + ...event, + sessionId: session.id, + ...(event.type === "session.opened" && event.parentSessionId + ? { parentSessionId: this.providerSessions.get(event.parentSessionId)?.id } + : {}), + } as ProviderEvent; + } + if (event.type === "session.runtime_failed") { + this.failSessionRequests(event.sessionId, providerError(event.error)); + } else if (event.type === "session.closed") { + this.settleClosedSessionRequests( + event.sessionId, + new Error(event.error?.message ?? `Provider session ${event.sessionId} closed`), + event, + ); + } + if ("sessionId" in event) { + const session = this.sessions.get(event.sessionId); + session?.accept(event); + if (event.type === "session.closed" && session) { + this.removeSession(session.id, session.providerId); + } + } + } + + private finishSessionReadyRequest(event: ProviderEvent): void { + if (event.type !== "session.ready" || !event.requestId) return; + this.sessionRequests.delete(event.requestId); + const request = this.requests.get(event.requestId); + if (!request) return; + this.requests.delete(event.requestId); + request.resolve(event); + } + + private acceptProviderChild(event: ProviderEvent): boolean { + if (event.type !== "session.opened" || this.providerSessions.has(event.sessionId)) return false; + const parent = event.parentSessionId + ? this.providerSessions.get(event.parentSessionId) + : undefined; + if (!parent) return true; + const sessionId = randomUUID(); + const session = new ProviderRuntimeSession( + this, + this.connection!, + sessionId, + event.sessionId, + event.restoration, + ); + this.sessions.set(sessionId, session); + this.providerSessions.set(event.sessionId, session); + const mappedEvent = { ...event, sessionId, parentSessionId: parent.id }; + session.accept(mappedEvent); + for (const listener of this.sessionListeners) listener(session, mappedEvent); + return true; + } + + private holdPendingOpenDescendant(event: ProviderEvent): boolean { + if (!("sessionId" in event)) return false; + let state = this.pendingOpenDescendants.get(event.sessionId); + if (event.type === "session.opened" && event.parentSessionId) { + const parent = this.providerSessions.get(event.parentSessionId); + state = + this.pendingOpenDescendants.get(event.parentSessionId) ?? + (parent?.hasPendingOpen() ? this.pendingOpenDescendantStateFor(parent) : undefined); + if (state) { + state.providerSessionIds.add(event.sessionId); + this.pendingOpenDescendants.set(event.sessionId, state); + } + } + if (!state) return false; + state.events.push(event); + return true; + } + + private pendingOpenDescendantStateFor(root: ProviderRuntimeSession): PendingOpenDescendantState { + const existing = this.pendingOpenDescendantsByRoot.get(root); + if (existing) return existing; + const state: PendingOpenDescendantState = { providerSessionIds: new Set(), events: [] }; + this.pendingOpenDescendantsByRoot.set(root, state); + return state; + } + + releasePendingOpenDescendants(root: ProviderRuntimeSession): void { + const state = this.takePendingOpenDescendants(root); + if (!state) return; + for (const event of state.events) this.accept(event); + } + + discardPendingOpenDescendants(root: ProviderRuntimeSession): void { + this.takePendingOpenDescendants(root); + } + + private takePendingOpenDescendants( + root: ProviderRuntimeSession, + ): PendingOpenDescendantState | undefined { + const state = this.pendingOpenDescendantsByRoot.get(root); + if (!state) return undefined; + this.pendingOpenDescendantsByRoot.delete(root); + for (const providerSessionId of state.providerSessionIds) { + if (this.pendingOpenDescendants.get(providerSessionId) === state) { + this.pendingOpenDescendants.delete(providerSessionId); + } + } + return state; + } + + private failSessionRequests(sessionId: string, error: Error): void { + for (const [requestId, request] of this.requests) { + if (request.sessionId !== sessionId) continue; + this.requests.delete(requestId); + request.reject(error); + } + for (const [requestId, pendingSessionId] of this.sessionRequests) { + if (pendingSessionId === sessionId) this.sessionRequests.delete(requestId); + } + } + + private settleClosedSessionRequests( + sessionId: string, + error: Error, + event: Extract, + ): void { + for (const [requestId, request] of this.requests) { + if (request.sessionId !== sessionId) continue; + this.requests.delete(requestId); + if (request.kind === "session.close") request.resolve(event); + else request.reject(error); + } + } + + private failRequest(event: Extract): void { + const request = this.requests.get(event.requestId); + if (request) { + this.requests.delete(event.requestId); + request.reject(providerError(event.error)); + } + if (request?.sessionId) this.sessions.get(request.sessionId)?.requestFailed(event); + const sessionId = this.sessionRequests.get(event.requestId); + if (!sessionId) return; + this.sessionRequests.delete(event.requestId); + this.sessions.get(sessionId)?.requestFailed(event); + } + + private finishRequest( + event: Extract, + ): void { + const request = this.requests.get(event.requestId); + if (!request) return; + this.requests.delete(event.requestId); + request.resolve(event); + } +} + +class ProviderRuntimeSession { + readonly history: ProviderEvent[] = []; + private readonly listeners = new Set<(event: ProviderEvent) => void>(); + private capabilities: readonly string[] = []; + private openRequest: { requestId: string; deferred: Deferred } | null = null; + private readonly prompts = new Map< + string, + Deferred> + >(); + private terminal = false; + config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] }; + commands: Array<{ name: string; description: string; argumentHint?: string }> = []; + persistence: ProviderPersistence | null = null; + + constructor( + private readonly runtime: ProviderRuntime, + private readonly connection: ProviderConnection, + readonly id: string, + private readonly providerSessionId: string, + readonly restoration: "core" | "parent", + ) {} + + get providerId(): string { + return this.providerSessionId; + } + + get negotiatedCapabilities(): readonly string[] { + return this.capabilities; + } + + onEvent(listener: (event: ProviderEvent) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async prompt( + prompt: ProviderPrompt, + ): Promise["result"]> { + requireProviderCapabilities(this.capabilities, { + type: "session.prompt", + sessionId: this.providerSessionId, + prompt, + }); + const pending = deferred>(); + this.prompts.set(prompt.clientMessageId, pending); + try { + await this.connection.send({ + type: "session.prompt", + sessionId: this.providerSessionId, + prompt, + }); + return (await pending.promise).result; + } finally { + this.prompts.delete(prompt.clientMessageId); + } + } + + async configure(changes: ProviderConfigChanges): Promise { + const input: Extract = { + type: "session.configure", + requestId: randomUUID(), + sessionId: this.providerSessionId, + changes, + }; + requireProviderCapabilities(this.capabilities, input); + await this.runtime.complete(input); + } + + async respondToPermission( + permissionId: string, + response: Extract["response"], + ): Promise { + const input: Extract = { + type: "session.permission", + sessionId: this.providerSessionId, + permissionId, + response, + }; + requireProviderCapabilities(this.capabilities, input); + await this.connection.send(input); + } + + async revert(token: ProviderInput & { type: "session.revert" }): Promise { + const input = { ...token, sessionId: this.providerSessionId }; + requireProviderCapabilities(this.capabilities, input); + await this.runtime.complete(input); + } + + async interrupt(): Promise { + await this.runtime.complete({ + type: "session.interrupt", + requestId: randomUUID(), + sessionId: this.providerSessionId, + }); + } + + async close(): Promise { + if (this.restoration === "parent") { + this.runtime.removeSession(this.id, this.providerSessionId); + return; + } + try { + await this.runtime.complete({ + type: "session.close", + requestId: randomUUID(), + sessionId: this.providerSessionId, + }); + } finally { + this.runtime.removeSession(this.id, this.providerSessionId); + } + } + + beginOpen(requestId: string): Promise { + const pending = deferred(); + this.openRequest = { requestId, deferred: pending }; + return pending.promise; + } + + requestFailed(event: Extract): void { + if (this.openRequest?.requestId === event.requestId) { + this.openRequest.deferred.reject(providerError(event.error)); + this.openRequest = null; + this.runtime.discardPendingOpenDescendants(this); + } + } + + accept(event: ProviderEvent): void { + if (event.type === "session.opened") { + event = { ...event, capabilities: this.normalizeSessionCapabilities(event.capabilities) }; + } + if (event.type === "session.opened") { + this.capabilities = [...event.capabilities]; + this.persistence = this.restoration === "core" ? (event.persistence ?? null) : null; + return; + } + const openRequest = this.openRequest; + if ( + event.type === "session.ready" && + openRequest && + openRequest.requestId === event.requestId + ) { + this.openRequest = null; + this.runtime.releasePendingOpenDescendants(this); + openRequest.deferred.resolve(); + return; + } + if (event.type === "session.prompt_result") { + this.prompts.get(event.clientMessageId)?.resolve(event); + return; + } + if (event.type === "session.runtime_failed" || event.type === "session.closed") { + if (this.terminal) return; + this.terminal = true; + this.publish(event); + this.rejectPending(new Error(event.error?.message ?? `Provider session ${this.id} closed`)); + return; + } + this.publish(event); + } + + hasPendingOpen(): boolean { + return this.openRequest !== null; + } + + connectionClosed(error = new Error("Provider connection closed")): void { + if (!this.terminal) { + this.terminal = true; + this.publish({ + type: "session.runtime_failed", + sessionId: this.id, + error: { message: error.message }, + }); + } + this.rejectPending(error); + } + + private rejectPending(error: Error): void { + const openRequest = this.openRequest; + this.openRequest = null; + openRequest?.deferred.reject(error); + this.runtime.discardPendingOpenDescendants(this); + for (const prompt of this.prompts.values()) prompt.reject(error); + this.prompts.clear(); + } + + private normalizeSessionCapabilities(capabilities: readonly string[]): ProviderCapability[] { + const known = PROVIDER_CAPABILITIES.filter((capability) => capabilities.includes(capability)); + for (const capability of known) { + if (!this.connection.capabilities.includes(capability)) { + throw new Error(`Provider session selected unoffered capability ${capability}`); + } + } + return known; + } + + private publish(event: ProviderEvent): void { + if (event.type === "session.config") this.config = event.config; + if (event.type === "session.commands") this.commands = [...event.commands]; + if (event.type === "session.persistence" && this.restoration === "core") { + this.persistence = event.persistence; + } + this.history.push(event); + for (const listener of this.listeners) listener(event); + } +} + +function normalizeConnection(connection: ProviderConnection): ProviderConnection { + const capabilities = PROVIDER_CAPABILITIES.filter((capability) => + connection.capabilities.includes(capability), + ); + return { + version: connection.version, + capabilities, + send: (input) => connection.send(ProviderInputSchema.parse(input)), + onEvent: (listener) => + connection.onEvent((event) => listener(ProviderEventSchema.parse(event))), + close: () => connection.close(), + }; +} + +interface AdaptedPluginProvider { + registration: ProviderRegistration; + client: PluginAgentClient; + definition: ProviderDefinition; +} + +/** Owns the complete plugin-provider adaptation behind the existing core provider boundary. */ +export class PluginAgentClientRegistry { + private readonly providers = new Map(); + + constructor(private readonly logger: Logger) {} + + replace(registrations: readonly ProviderRegistration[]): void { + const incoming = new Map(); + for (const registration of registrations) { + if (incoming.has(registration.id)) { + throw new Error(`Duplicate plugin provider ID: ${registration.id}`); + } + incoming.set(registration.id, registration); + } + + for (const [id, adapted] of this.providers) { + if (incoming.get(id) === adapted.registration) continue; + this.providers.delete(id); + void adapted.client.shutdown().catch((error) => { + this.logger.warn({ err: error, provider: id }, "Failed to stop plugin provider"); + }); + } + + for (const [id, registration] of incoming) { + if (this.providers.has(id)) continue; + const client = new PluginAgentClient(registration); + this.providers.set(id, { + registration, + client, + definition: createPluginProviderDefinition(registration, client), + }); + } + } + + definitions(): Record { + return Object.fromEntries( + [...this.providers].map(([id, provider]) => [id, provider.definition]), + ); + } + + clients(): Record { + return Object.fromEntries([...this.providers].map(([id, provider]) => [id, provider.client])); + } + + has(provider: string): boolean { + return this.providers.has(provider); + } + + async shutdown(): Promise { + const providers = [...this.providers.values()]; + this.providers.clear(); + await Promise.all(providers.map(({ client }) => client.shutdown())); + } +} + +const PluginProviderOptionsSchema: z.ZodType = z.record(z.string(), z.json()); + +function createPluginProviderDefinition( + registration: ProviderRegistration, + client: PluginAgentClient, +): ProviderDefinition { + return { + id: registration.id, + label: registration.label, + description: registration.description ?? `Plugin provider ${registration.label}`, + iconSvg: registration.icon, + defaultModeId: null, + modes: [], + enabled: true, + derivedFromProviderId: null, + optionsSchema: PluginProviderOptionsSchema, + supportsExactMcpPreapproval: true, + validateOptions: (options) => + options === undefined ? undefined : PluginProviderOptionsSchema.parse(options), + applyOptions: (config, options) => ({ ...config, providerOptions: options }), + applyToolPolicy: (config, toolPolicy) => ({ ...config, toolPolicy }), + createClient: () => client, + resolveCreateConfig: resolveDefaultAgentCreateConfig, + isCreateConfigUnattended: isDefaultAgentCreateConfigUnattended, + fetchCatalog: (options, _client, context) => client.fetchCatalog(options, context), + }; +} + +interface PendingChild { + session: ProviderRuntimeSession; + opened: Extract; +} + +class PluginAgentClient implements AgentClient { + readonly provider: string; + private readonly runtime: ProviderRuntime; + private readonly rootsBySession = new Map(); + private readonly pendingChildren: PendingChild[] = []; + + constructor(registration: ProviderRegistration) { + this.provider = registration.id; + this.runtime = new ProviderRuntime(registration); + this.runtime.onSessionOpened((session, opened) => this.acceptChild(session, opened)); + } + + get capabilities(): AgentCapabilityFlags { + return agentCapabilities(this.runtime.negotiatedCapabilities); + } + + async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + options?: AgentCreateSessionOptions, + ): Promise { + return await this.openSession({ + config, + launchContext, + history: "skip", + persist: options?.persistSession !== false, + }); + } + + async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + launchContext?: AgentLaunchContext, + ): Promise { + if (!overrides?.cwd) { + throw new Error(`Plugin provider '${this.provider}' requires cwd to resume a session`); + } + return await this.openSession({ + config: { ...overrides, provider: this.provider, cwd: overrides.cwd }, + launchContext, + persistence: decodePersistence(handle), + history: "replay", + persist: true, + }); + } + + async fetchCatalog( + options: FetchCatalogOptions, + context?: ProviderRefreshContext, + ): Promise { + const catalog = await this.runtime.catalog( + options.scope === "workspace" ? options.cwd : undefined, + context?.signal, + ); + return { + models: catalog.models.map((model) => + mapModel( + this.provider, + model, + catalog.defaultModel, + catalog.thinkingOptions, + catalog.defaultThinkingOption, + ), + ), + modes: catalog.modes.map((mode) => ({ ...mode })), + defaultModeId: catalog.defaultMode, + }; + } + + async isAvailable(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + return await this.runtime.isAvailable(); + } + + async listImportableSessions( + options: ListImportableSessionsOptions = {}, + ): Promise { + const sessions = await this.runtime.listSessions({ + query: options.query, + cwd: options.cwd, + limit: options.limit, + }); + return sessions.map((session) => ({ + providerHandleId: encodePersistence(session.persistence), + cwd: session.cwd, + title: session.title ?? null, + firstPromptPreview: null, + lastPromptPreview: session.description ?? null, + lastActivityAt: parseProviderDate(session.updatedAt), + })); + } + + async importSession( + input: ImportProviderSessionInput, + context: ImportProviderSessionContext, + ): Promise { + const session = await this.openSession({ + config: { ...context.config, provider: this.provider, cwd: input.cwd }, + launchContext: context.launchContext, + persistence: decodePersistenceId(input.providerHandleId), + history: "replay", + persist: true, + }); + const persistence = session.describePersistence(); + if (!persistence) throw new Error(`Plugin provider '${this.provider}' did not persist import`); + return { + session, + config: { ...context.config, provider: this.provider, cwd: input.cwd }, + persistence, + timeline: session.timelineHistory(), + providerSubagentEvents: session.subagentHistory(), + }; + } + + async archiveNativeSession(handle: AgentPersistenceHandle): Promise { + await this.runtime.archive(decodePersistence(handle)); + } + + async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise { + await this.runtime.unarchive(decodePersistence(handle)); + } + + async shutdown(): Promise { + await this.runtime.close(); + } + + private async openSession(input: { + config: AgentSessionConfig; + launchContext?: AgentLaunchContext; + persistence?: ProviderPersistence; + history: "replay" | "skip"; + persist: boolean; + }): Promise { + const sessionId = randomUUID(); + const bridge = await this.runtime.openSession({ + sessionId, + config: mapSessionConfig(input.config, input.launchContext, input.persist), + persistence: input.persistence, + history: input.history, + }); + const session = new PluginAgentSession(this.provider, bridge, () => { + this.rootsBySession.delete(bridge.id); + }); + this.rootsBySession.set(bridge.id, session); + this.attachPendingChildren(); + return session; + } + + private acceptChild( + session: ProviderRuntimeSession, + opened: Extract, + ): void { + const root = opened.parentSessionId + ? this.rootsBySession.get(opened.parentSessionId) + : undefined; + if (!root) { + this.pendingChildren.push({ session, opened }); + return; + } + root.attachChild(session, opened); + this.rootsBySession.set(session.id, root); + this.attachPendingChildren(); + } + + private attachPendingChildren(): void { + let attached = true; + while (attached) { + attached = false; + for (let index = this.pendingChildren.length - 1; index >= 0; index -= 1) { + const pending = this.pendingChildren[index]; + const root = pending.opened.parentSessionId + ? this.rootsBySession.get(pending.opened.parentSessionId) + : undefined; + if (!root) continue; + this.pendingChildren.splice(index, 1); + root.attachChild(pending.session, pending.opened); + this.rootsBySession.set(pending.session.id, root); + attached = true; + } + } + } +} + +class PluginAgentSession implements AgentSession { + private readonly listeners = new Set<(event: AgentStreamEvent) => void>(); + private readonly history: AgentStreamEvent[] = []; + private readonly pendingPermissions = new Map(); + private readonly permissionResponses = new Map(); + private readonly revertTokens = new Map(); + private readonly timelineSnapshots = new Map(); + private readonly childUnsubscribes = new Map void>(); + private readonly childSnapshots = new Map>(); + private unsubscribe: (() => void) | null = null; + private currentTurnId: string | null = null; + private closed = false; + + constructor( + readonly provider: string, + private readonly bridge: ProviderRuntimeSession, + private readonly onClose: () => void, + ) { + for (const event of bridge.history) this.accept(event, false); + this.unsubscribe = bridge.onEvent((event) => this.accept(event, true)); + } + + get id(): string { + return this.describePersistence()?.sessionId ?? this.bridge.id; + } + + get capabilities(): AgentCapabilityFlags { + return agentCapabilities(this.bridge.negotiatedCapabilities); + } + + get features(): AgentFeature[] { + return this.bridge.config.settings.map((setting) => + setting.type === "toggle" + ? { ...setting } + : { + ...setting, + options: setting.options.map((option) => ({ ...option, id: option.value })), + }, + ); + } + + async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { + return await runProviderTurn({ + prompt, + runOptions: options, + startTurn: (nextPrompt, nextOptions) => this.startTurn(nextPrompt, nextOptions), + subscribe: (callback) => this.subscribe(callback), + getSessionId: () => this.id, + }); + } + + async startTurn( + prompt: AgentPromptInput, + options: AgentRunOptions = {}, + ): Promise<{ turnId: string }> { + const clientMessageId = options.clientMessageId ?? randomUUID(); + const result = await this.bridge.prompt({ + clientMessageId, + delivery: "auto", + input: mapPromptInput(prompt, this.bridge.commands), + ...(options.outputSchema === undefined + ? {} + : { outputSchema: toJsonValue(options.outputSchema, "output schema") }), + }); + if (result.type === "failed") throw providerError(result.error); + if (result.type === "turn" || result.type === "steer") return { turnId: result.turnId }; + + const turnId = `command-${clientMessageId}`; + this.publish({ type: "turn_started", provider: this.provider, turnId }); + this.publish({ type: "turn_completed", provider: this.provider, turnId }); + return { turnId }; + } + + async steerActiveTurn( + prompt: AgentPromptInput, + options: SteerActiveTurnOptions, + ): Promise { + const result = await this.bridge.prompt({ + clientMessageId: options.clientMessageId ?? randomUUID(), + delivery: "steer", + input: { type: "message", content: mapPromptContent(prompt) }, + ...(options.outputSchema === undefined + ? {} + : { outputSchema: toJsonValue(options.outputSchema, "output schema") }), + clearPendingPermissions: options.clearPendingPermissions, + }); + return result.type === "steer" && result.turnId === options.expectedTurnId + ? { status: "accepted" } + : { status: "unavailable" }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.listeners.add(callback); + return () => this.listeners.delete(callback); + } + + async *streamHistory(): AsyncGenerator { + for (const event of this.history) yield event; + } + + async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.id, + model: this.bridge.config.model ?? null, + modeId: this.bridge.config.mode ?? null, + thinkingOptionId: this.bridge.config.thinkingOption ?? null, + }; + } + + async getAvailableModes(): Promise { + return this.bridge.config.modes.map((mode) => ({ ...mode })); + } + + async getCurrentMode(): Promise { + return this.bridge.config.mode ?? null; + } + + async setMode(modeId: string): Promise { + await this.bridge.configure({ mode: modeId }); + } + + getPendingPermissions(): AgentPermissionRequest[] { + return [...this.pendingPermissions.values()]; + } + + async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise { + this.permissionResponses.set(requestId, response); + try { + await this.bridge.respondToPermission( + requestId, + toJsonValue(response, "permission response") as Extract< + ProviderInput, + { type: "session.permission" } + >["response"], + ); + } catch (error) { + this.permissionResponses.delete(requestId); + throw error; + } + } + + describePersistence(): AgentPersistenceHandle | null { + return this.bridge.persistence + ? persistenceHandle(this.provider, this.bridge.persistence) + : null; + } + + async interrupt(): Promise { + await this.bridge.interrupt(); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.unsubscribe?.(); + this.unsubscribe = null; + for (const unsubscribe of this.childUnsubscribes.values()) unsubscribe(); + this.childUnsubscribes.clear(); + this.listeners.clear(); + this.onClose(); + await this.bridge.close(); + } + + async listCommands(): Promise { + return this.bridge.commands.map((command) => ({ + ...command, + argumentHint: command.argumentHint ?? "", + kind: "command", + })); + } + + async setModel(modelId: string | null): Promise { + await this.bridge.configure({ model: modelId }); + } + + async setThinkingOption(thinkingOptionId: string | null): Promise { + await this.bridge.configure({ thinkingOption: thinkingOptionId }); + } + + async setFeature(featureId: string, value: unknown): Promise { + await this.bridge.configure({ + settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) }, + }); + } + + async revertConversation(input: { messageId: string }): Promise { + await this.revert(input.messageId, "conversation"); + } + + async revertFiles(input: { messageId: string }): Promise { + await this.revert(input.messageId, "files"); + } + + async revertBoth(input: { messageId: string }): Promise { + await this.revert(input.messageId, "both"); + } + + timelineHistory(): ImportedProviderSession["timeline"] { + return this.history.flatMap((event) => + event.type === "timeline" ? [{ item: event.item, timestamp: event.timestamp }] : [], + ); + } + + subagentHistory(): NonNullable { + return this.history.filter( + (event): event is Extract => + event.type === "provider_subagent", + ); + } + + attachChild( + child: ProviderRuntimeSession, + opened: Extract, + ): void { + const childId = child.providerId; + this.publish({ + type: "provider_subagent", + provider: this.provider, + event: { + type: "upsert", + id: childId, + title: opened.title ?? null, + description: opened.description ?? null, + status: "running", + cwd: opened.cwd, + }, + }); + const snapshots = new Map(); + this.childSnapshots.set(childId, snapshots); + for (const event of child.history) this.acceptChildEvent(childId, event, snapshots); + this.childUnsubscribes.set( + child.id, + child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)), + ); + } + + private accept(event: ProviderEvent, live: boolean): void { + const translated = this.translate(event); + for (const next of translated) { + this.history.push(next); + if (live) this.emit(next); + } + } + + private translate(event: ProviderEvent): AgentStreamEvent[] { + switch (event.type) { + case "session.turn": + return [this.translateTurn(event)]; + case "session.usage": + return [ + { + type: "usage_updated", + provider: this.provider, + usage: { ...event.usage }, + turnId: event.turnId, + }, + ]; + case "timeline.item": + return this.translateTimeline(event); + case "session.permission": + return [this.translatePermission(event)]; + case "session.permission_resolved": + return [this.translatePermissionResolution(event)]; + case "session.config": + return this.translateConfig(event); + case "session.notice": + return this.translateNotice(event); + case "session.runtime_failed": + return [this.translateFailure(event.error)]; + case "session.closed": + return event.error ? [this.translateFailure(event.error)] : []; + default: + return []; + } + } + + private translateTurn(event: Extract): AgentStreamEvent { + if (event.state === "started") { + this.currentTurnId = event.turnId; + return { type: "turn_started", provider: this.provider, turnId: event.turnId }; + } + if (this.currentTurnId === event.turnId) this.currentTurnId = null; + if (event.state === "completed") { + return { type: "turn_completed", provider: this.provider, turnId: event.turnId }; + } + if (event.state === "canceled") { + return { + type: "turn_canceled", + provider: this.provider, + turnId: event.turnId, + reason: event.error?.message ?? "Canceled by provider", + }; + } + return { + type: "turn_failed", + provider: this.provider, + turnId: event.turnId, + error: event.error?.message ?? "Provider turn failed", + code: event.error?.code, + diagnostic: event.error?.diagnostic, + }; + } + + private translateTimeline( + event: Extract, + ): AgentStreamEvent[] { + const item = mapTimelineItem(event.item, this.timelineSnapshots, this.revertTokens); + return item + ? [ + { + type: "timeline", + provider: this.provider, + item, + turnId: this.currentTurnId ?? undefined, + timestamp: event.timestamp, + }, + ] + : []; + } + + private translatePermission( + event: Extract, + ): AgentStreamEvent { + const request: AgentPermissionRequest = { ...event.request, provider: this.provider }; + this.pendingPermissions.set(request.id, request); + return { + type: "permission_requested", + provider: this.provider, + request, + turnId: this.currentTurnId ?? undefined, + }; + } + + private translatePermissionResolution( + event: Extract, + ): AgentStreamEvent { + this.pendingPermissions.delete(event.permissionId); + const resolution = this.permissionResponses.get(event.permissionId) ?? { + behavior: "deny" as const, + message: "Resolved by provider", + }; + this.permissionResponses.delete(event.permissionId); + return { + type: "permission_resolved", + provider: this.provider, + requestId: event.permissionId, + resolution, + turnId: this.currentTurnId ?? undefined, + }; + } + + private translateConfig( + event: Extract, + ): AgentStreamEvent[] { + return [ + { + type: "mode_changed", + provider: this.provider, + currentModeId: event.config.mode ?? null, + availableModes: event.config.modes.map((mode) => ({ ...mode })), + }, + { + type: "model_changed", + provider: this.provider, + runtimeInfo: { + provider: this.provider, + sessionId: this.id, + model: event.config.model ?? null, + modeId: event.config.mode ?? null, + thinkingOptionId: event.config.thinkingOption ?? null, + }, + }, + { + type: "thinking_option_changed", + provider: this.provider, + thinkingOptionId: event.config.thinkingOption ?? null, + }, + ]; + } + + private translateNotice( + event: Extract, + ): AgentStreamEvent[] { + if (event.notice.dismissed) return []; + return [ + { + type: "timeline", + provider: this.provider, + item: { + type: "notification", + level: event.notice.severity, + message: [event.notice.title, event.notice.description].filter(Boolean).join("\n"), + }, + turnId: this.currentTurnId ?? undefined, + }, + ]; + } + + private translateFailure(error: ProviderError): AgentStreamEvent { + return { + type: "turn_failed", + provider: this.provider, + turnId: this.currentTurnId ?? undefined, + error: error.message, + code: error.code, + diagnostic: error.diagnostic, + }; + } + + private acceptChildEvent( + childId: string, + event: ProviderEvent, + snapshots: Map, + ): void { + if (event.type === "timeline.item") { + const item = mapTimelineItem(event.item, snapshots); + if (item) { + this.publish({ + type: "provider_subagent", + provider: this.provider, + event: { type: "timeline", id: childId, item, timestamp: event.timestamp }, + }); + } + return; + } + if (event.type === "session.turn" && event.state !== "started") { + this.publish({ + type: "provider_subagent", + provider: this.provider, + event: { type: "upsert", id: childId, status: event.state }, + }); + return; + } + if (event.type === "session.runtime_failed" || event.type === "session.closed") { + this.publish({ + type: "provider_subagent", + provider: this.provider, + event: { + type: "upsert", + id: childId, + status: event.type === "session.runtime_failed" || event.error ? "failed" : "completed", + }, + }); + } + } + + private async revert(messageId: string, scope: "conversation" | "files" | "both") { + const token = this.revertTokens.get(messageId); + if (token === undefined) throw new Error(`No provider revert token for message ${messageId}`); + await this.bridge.revert({ + type: "session.revert", + requestId: randomUUID(), + sessionId: this.bridge.providerId, + token, + scope, + }); + } + + private publish(event: AgentStreamEvent): void { + this.history.push(event); + this.emit(event); + } + + private emit(event: AgentStreamEvent): void { + for (const listener of this.listeners) listener(event); + } +} + +function agentCapabilities(capabilities: readonly string[]): AgentCapabilityFlags { + const supports = (capability: ProviderCapability) => capabilities.includes(capability); + return { + supportsStreaming: true, + supportsSessionPersistence: supports("session.persistence"), + supportsSessionListing: supports("session.list"), + supportsDynamicModes: supports("session.configure"), + supportsMcpServers: true, + supportsNativePaseoTools: false, + supportsReasoningStream: true, + supportsToolInvocations: true, + supportsRewindConversation: supports("session.revert.conversation"), + supportsRewindFiles: supports("session.revert.files"), + supportsRewindBoth: supports("session.revert.both"), + }; +} + +function mapModel( + provider: string, + model: Extract["catalog"]["models"][number], + defaultModel: string | undefined, + catalogThinkingOptions: Extract["catalog"]["thinkingOptions"], + catalogDefaultThinkingOption: string | undefined, +): AgentModelDefinition { + const thinkingOptions = model.thinkingOptions ?? catalogThinkingOptions; + return { + ...model, + provider, + isDefault: model.isDefault ?? model.id === defaultModel, + metadata: model.metadata ? { ...model.metadata } : undefined, + thinkingOptions: thinkingOptions?.map((option) => Object.assign({}, option)), + defaultThinkingOptionId: model.defaultThinkingOptionId ?? catalogDefaultThinkingOption, + }; +} + +function mapSessionConfig( + config: AgentSessionConfig, + launchContext: AgentLaunchContext | undefined, + persist: boolean, +): ProviderSessionConfig { + return { + cwd: config.cwd, + env: { ...launchContext?.env }, + systemPrompt: combineSystemPrompts(config.systemPrompt, config.daemonAppendSystemPrompt), + mcpServers: { ...config.mcpServers }, + toolPolicy: config.toolPolicy + ? { preapproved: config.toolPolicy.preapproved.map((grant) => ({ ...grant })) } + : undefined, + model: config.model, + mode: config.modeId, + thinkingOption: config.thinkingOptionId, + settings: toJsonObject(config.featureValues ?? {}, "provider settings"), + providerOptions: config.providerOptions + ? toJsonObject(config.providerOptions, "provider options") + : undefined, + title: config.title ?? undefined, + persist, + }; +} + +function combineSystemPrompts( + systemPrompt: string | undefined, + daemonPrompt: string | undefined, +): string | undefined { + const parts = [systemPrompt, daemonPrompt].filter( + (value): value is string => typeof value === "string" && value.trim().length > 0, + ); + return parts.length > 0 ? parts.join("\n\n") : undefined; +} + +function mapPromptInput( + prompt: AgentPromptInput, + commands: readonly { name: string }[], +): ProviderPrompt["input"] { + if (typeof prompt === "string" && prompt.startsWith("/")) { + const separator = prompt.indexOf(" "); + const name = prompt.slice(1, separator === -1 ? undefined : separator); + if (commands.some((command) => command.name === name)) { + return { + type: "command", + name, + arguments: separator === -1 ? "" : prompt.slice(separator + 1), + }; + } + } + return { type: "message", content: mapPromptContent(prompt) }; +} + +function mapPromptContent(prompt: AgentPromptInput): ProviderContent[] { + const content = typeof prompt === "string" ? [{ type: "text" as const, text: prompt }] : prompt; + return toJsonValue(content, "prompt content") as ProviderContent[]; +} + +function mapTimelineItem( + item: ProviderTimelineItem, + snapshots: Map, + revertTokens?: Map, +): AgentTimelineItem | null { + const previous = snapshots.get(item.id); + snapshots.set(item.id, item); + const { id, revertToken, ...withoutIdentity } = item; + if (revertToken !== undefined && revertTokens) { + revertTokens.set(id, revertToken); + if (item.type === "user_message" || item.type === "assistant_message") { + revertTokens.set(item.messageId ?? id, revertToken); + } + } + + if (item.type === "assistant_message" || item.type === "reasoning") { + const previousText = previous?.type === item.type ? previous.text : ""; + const text = item.text.startsWith(previousText) + ? item.text.slice(previousText.length) + : item.text; + if (text.length === 0) return null; + return item.type === "assistant_message" + ? { type: "assistant_message", text, messageId: item.messageId ?? id } + : { type: "reasoning", text }; + } + if (item.type === "user_message") { + if (previous?.type === "user_message" && previous.text === item.text) return null; + return { + type: "user_message", + text: item.text, + messageId: item.messageId ?? id, + clientMessageId: item.clientMessageId, + }; + } + if (item.type === "plugin") { + return { + type: "plugin", + id, + pluginId: item.pluginId, + kind: item.kind, + version: item.version, + data: item.data, + }; + } + return withoutIdentity as AgentTimelineItem; +} + +function persistenceHandle( + provider: string, + persistence: ProviderPersistence, +): AgentPersistenceHandle { + return { + provider, + sessionId: encodePersistence(persistence), + metadata: { pluginProviderPersistence: persistence }, + }; +} + +function encodePersistence(persistence: ProviderPersistence): string { + return `plugin:${JSON.stringify(persistence)}`; +} + +function decodePersistence(handle: AgentPersistenceHandle): ProviderPersistence { + const fromMetadata = handle.metadata?.pluginProviderPersistence; + if (fromMetadata !== undefined) { + return ProviderPersistenceSchema.parse(fromMetadata); + } + return decodePersistenceId(handle.sessionId); +} + +const ProviderPersistenceSchema: z.ZodType = z + .object({ version: z.number().int().nonnegative(), data: z.json() }) + .strict(); + +function decodePersistenceId(id: string): ProviderPersistence { + if (!id.startsWith("plugin:")) throw new Error("Invalid plugin provider persistence handle"); + return ProviderPersistenceSchema.parse(JSON.parse(id.slice("plugin:".length))); +} + +function parseProviderDate(value: string | undefined): Date { + const timestamp = value ? Date.parse(value) : Number.NaN; + return new Date(Number.isFinite(timestamp) ? timestamp : 0); +} + +function toJsonObject(value: unknown, label: string): Record { + const encoded = toJsonValue(value, label); + if (typeof encoded !== "object" || encoded === null || Array.isArray(encoded)) { + throw new Error(`${label} must be a JSON object`); + } + return encoded as Record; +} + +function toJsonValue(value: unknown, label: string): JsonValue { + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new Error(`${label} must be JSON-serializable`); + return JSON.parse(encoded) as JsonValue; +} diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index c65db34b2d..5c9231f095 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -70,6 +70,7 @@ export type { AgentProviderDefinition }; export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition }; export interface ProviderDefinition extends AgentProviderDefinition { + iconSvg?: string; enabled: boolean; /** * The id of another *registered* provider this one extends (e.g. a Z.AI diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 620d1c4960..21f8f51626 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -1,6 +1,7 @@ import { homedir } from "node:os"; import { resolve } from "node:path"; import { describe, expect, test, vi } from "vitest"; +import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { @@ -105,6 +106,29 @@ async function runTestCatalogActivities( } describe("ProviderSnapshotManager public surface", () => { + test("carries a plugin provider icon in snapshot metadata", () => { + const iconSvg = ''; + const registration: ProviderRegistration = { + id: "icon-provider", + label: "Icon Provider", + icon: iconSvg, + async connect() { + throw new Error("not opened by this test"); + }, + }; + const manager = new ProviderSnapshotManager({ logger: createTestLogger() }); + + try { + manager.replacePluginProviders([registration]); + + expect(manager.getSnapshot("/tmp/project")).toContainEqual( + expect.objectContaining({ provider: "icon-provider", iconSvg }), + ); + } finally { + manager.destroy(); + } + }); + test("validates complete Hub agent configurations through the current provider contract", async () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger(), @@ -1708,6 +1732,55 @@ describe("ProviderSnapshotManager cwd routing", () => { } }); + test("registers and unregisters plugin providers without rebuilding built-in clients", async () => { + let listener: ((event: ProviderEvent) => void) | null = null; + const registration: ProviderRegistration = { + id: "plugin-provider", + label: "Plugin provider", + async connect() { + return { + version: 1, + capabilities: [], + async send(input) { + if (input.type !== "catalog") return; + listener?.({ + type: "catalog", + requestId: input.requestId, + catalog: { models: [{ id: "plugin-model", label: "Plugin model" }], modes: [] }, + }); + }, + onEvent(nextListener) { + listener = nextListener; + return () => { + if (listener === nextListener) listener = null; + }; + }, + async close() {}, + }; + }, + }; + const manager = new ProviderSnapshotManager({ logger: createTestLogger() }); + try { + const state = manager.replacePluginProviders([registration]); + expect(state.clients[registration.id]?.provider).toBe(registration.id); + await expect( + manager.getProvider({ provider: registration.id, wait: true }), + ).resolves.toMatchObject({ + provider: registration.id, + source: "custom", + status: "ready", + models: [{ provider: registration.id, id: "plugin-model" }], + }); + + const withoutPlugin = manager.replacePluginProviders([]); + expect(manager.hasProvider(registration.id)).toBe(false); + expect(withoutPlugin.clients[registration.id]).toBeUndefined(); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }); + test("resolveSnapshotCwd normalizes pure drive letters to append backslash on Windows", () => { const resolved = resolveSnapshotCwd("C:"); if (process.platform === "win32") { diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index de7a69be39..5b60f68cca 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -45,6 +45,8 @@ import { type AgentConfigurationValidationInput, validateAgentConfigurationAgainstProvider, } from "./agent-configuration-validator.js"; +import type { ProviderRegistration } from "@getpaseo/plugin/provider"; +import { PluginAgentClientRegistry } from "./plugin-provider.js"; const DEFAULT_REFRESH_TIMEOUT_MS = 120_000; const MAX_REFRESH_TIMEOUT_MS = 2_147_483_647; @@ -219,9 +221,13 @@ export class ProviderSnapshotManager { private providerRegistry: Record; private providerClients: Record; private readonly ownedClients = new Set(); + private readonly pluginProviders: PluginAgentClientRegistry; constructor(options: ProviderSnapshotManagerOptions) { this.logger = options.logger; + this.pluginProviders = new PluginAgentClientRegistry( + options.logger.child({ module: "plugin-providers" }), + ); this.workspaceGitService = options.workspaceGitService; this.managedProcesses = options.managedProcesses; this.openCodeBridge = options.openCodeBridge; @@ -236,7 +242,10 @@ export class ProviderSnapshotManager { this.refreshTimeoutMs, ); this.providerRegistry = this.buildRegistry(); - this.providerClients = { ...this.extraClients } as Record; + this.providerClients = { + ...this.extraClients, + ...this.pluginProviders.clients(), + } as Record; for (const client of Object.values(this.providerClients)) this.ownedClients.add(client); } @@ -322,6 +331,42 @@ export class ProviderSnapshotManager { return { providerDefinitions, clients }; } + replacePluginProviders( + registrations: readonly ProviderRegistration[], + ): AgentManagerProviderState { + for (const registration of registrations) { + if ( + (this.providerRegistry[registration.id] || this.extraClients[registration.id]) && + !this.pluginProviders.has(registration.id) + ) { + throw new Error( + `Plugin provider '${registration.id}' conflicts with a configured provider`, + ); + } + } + this.pluginProviders.replace(registrations); + this.providerRegistry = this.buildRegistry(); + this.providerClients = { + ...this.extraClients, + ...this.pluginProviders.clients(), + } as Record; + for (const client of Object.values(this.providerClients)) this.ownedClients.add(client); + + for (const cwd of this.snapshots.keys()) { + this.providerLoads.delete(cwd); + this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd)); + this.emitChange(cwd); + const target = + cwd === GLOBAL_PROVIDER_SNAPSHOT_KEY + ? createGlobalSnapshotTarget() + : createWorkspaceSnapshotTarget(cwd); + const providers = this.resolveProvidersToWarm(cwd); + if (providers.length > 0) void this.warmUp(target, providers); + } + + return this.getAgentManagerProviderState(); + } + private ensureClient(provider: AgentProvider, definition: ProviderDefinition): AgentClient { const existing = this.providerClients[provider]; if (existing) { @@ -503,7 +548,10 @@ export class ProviderSnapshotManager { // startup-derived runtime settings here would retain removed command/env fields. if (options.replace) this.runtimeSettings = undefined; this.providerRegistry = this.buildRegistry(); - this.providerClients = { ...this.extraClients } as Record; + this.providerClients = { + ...this.extraClients, + ...this.pluginProviders.clients(), + } as Record; for (const cwd of this.snapshots.keys()) { this.providerLoads.delete(cwd); @@ -597,6 +645,13 @@ export class ProviderSnapshotManager { isDev: this.isDev, }); + for (const [provider, definition] of Object.entries(this.pluginProviders.definitions())) { + if (registry[provider]) { + throw new Error(`Plugin provider '${provider}' conflicts with a configured provider`); + } + registry[provider] = definition; + } + for (const [provider, client] of Object.entries(this.extraClients) as Array< [AgentProvider, AgentClient] >) { @@ -680,6 +735,7 @@ export class ProviderSnapshotManager { source: this.getProviderSource(provider), label: definition.label, description: definition.description, + iconSvg: definition.iconSvg, defaultModeId: definition.defaultModeId, error: toErrorMessage(error), }; @@ -712,6 +768,7 @@ export class ProviderSnapshotManager { } private getProviderSource(provider: AgentProvider): ProviderSnapshotEntry["source"] { + if (this.pluginProviders.has(provider)) return "custom"; const isBuiltin = BUILTIN_PROVIDER_IDS.includes(provider); return !isBuiltin && this.providerOverrides?.[provider]?.extends ? "custom" : "builtin"; } @@ -727,6 +784,7 @@ export class ProviderSnapshotManager { source: this.getProviderSource(provider), label: definition?.label, description: definition?.description, + iconSvg: definition?.iconSvg, defaultModeId: definition?.defaultModeId ?? null, }); } @@ -746,6 +804,7 @@ export class ProviderSnapshotManager { source: this.getProviderSource(provider), label: definition?.label, description: definition?.description, + iconSvg: definition?.iconSvg, defaultModeId: definition?.defaultModeId ?? null, }; @@ -914,6 +973,7 @@ export class ProviderSnapshotManager { source: this.getProviderSource(provider), label: definition.label, description: definition.description, + iconSvg: definition.iconSvg, defaultModeId: definition.defaultModeId, }; const setEntry = (entry: ProviderSnapshotEntry) => { diff --git a/packages/server/src/server/agent/providers/provider-windows-launch.test.ts b/packages/server/src/server/agent/providers/provider-windows-launch.test.ts index 93ea5e526c..1f48d7edfe 100644 --- a/packages/server/src/server/agent/providers/provider-windows-launch.test.ts +++ b/packages/server/src/server/agent/providers/provider-windows-launch.test.ts @@ -90,7 +90,7 @@ if (${JSON.stringify(launchMode)} === "piRuntime") { }); process.stdin.on("end", () => process.exit(0)); } else { - console.log("ARGV_OK"); + process.stdout.write("ARGV_OK\\n", () => process.exit(0)); } `, ); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 18def1cbc3..1cfea1c99d 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -922,6 +922,13 @@ export async function createPaseoDaemon( resolvePaseoToolPolicy(provider, daemonConfigStore.get().providers), logger, }); + const syncPluginProviders = () => { + agentManager.updateProviderRegistry( + providerSnapshotManager.replacePluginProviders(pluginRuntime.getProviderRegistrations()), + ); + }; + const unsubscribePluginProviders = + pluginRuntime.subscribeProviderRegistrations(syncPluginProviders); const detachAgentStoragePersistence = attachAgentStoragePersistence( logger, @@ -1745,6 +1752,7 @@ export async function createPaseoDaemon( speechService.start(); scriptHealthMonitor.start(); } catch (error) { + unsubscribePluginProviders(); await pluginRuntime.stopAllPlugins().catch(() => undefined); await serviceProxy.stopStandalone().catch(() => undefined); await agentProviderRuntime.shutdown().catch(() => undefined); @@ -1758,6 +1766,7 @@ export async function createPaseoDaemon( const stop = async () => { await pluginRuntime.stopAllPlugins(); + unsubscribePluginProviders(); await hubRelationships.stop(); workspaceReconciliation.dispose(); scriptHealthMonitor.stop(); diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index e90663fb72..5851a26cfa 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -155,6 +155,59 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) { } describe("PluginService", () => { + it("resolves a provider icon path to sanitized inline SVG", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const directory = await createPlugin( + "provider-icon", + `export default function contribute(server) { + server.registerProvider({ + id: "plugin-agent", + label: "Plugin agent", + icon: "icon.svg", + async connect() { throw new Error("not opened by this test"); }, + }); + return () => {}; +}`, + ); + const iconSvg = ''; + await writeFile(path.join(directory, "icon.svg"), iconSvg); + const service = createService(home); + + await service.start(); + await service.installDirectory({ path: directory }); + + expect(service.getProviderRegistrations()).toMatchObject([ + { id: "plugin-agent", icon: iconSvg }, + ]); + }); + + it("publishes provider registrations only while their plugin is running", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const directory = await createPlugin( + "provider-lifecycle", + `export default function contribute(server) { + server.registerProvider({ + id: "plugin-agent", + label: "Plugin agent", + async connect() { throw new Error("not opened by this test"); }, + }); + return () => {}; +}`, + ); + const service = createService(home); + + await service.start(); + await service.installDirectory({ path: directory }); + expect(service.getProviderRegistrations()).toMatchObject([ + { id: "plugin-agent", label: "Plugin agent" }, + ]); + + await service.disablePlugin("provider-lifecycle"); + expect(service.getProviderRegistrations()).toEqual([]); + }); + it("retains logs when disabled and clears them only when removed", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index df987c903b..e12b8d7386 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { stat } from "node:fs/promises"; import type pino from "pino"; +import type { ProviderRegistration } from "@getpaseo/plugin/provider"; import { PluginIdSchema, type PluginLogEntry, @@ -10,17 +11,24 @@ import { type PluginSourceUpdateItem, } from "@getpaseo/protocol/messages"; import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference"; +import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest"; import type { DaemonConfigStore } from "../daemon-config-store.js"; import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-source.js"; import { readPluginManifest } from "./manifest.js"; import { runPluginBuild } from "./preparation.js"; import { PluginRuntime } from "./runtime.js"; +import type { PluginProviderMetadata } from "./plugin-process-protocol.js"; +import { readPluginProviderIcon } from "./provider-icon.js"; + +const BUILTIN_PROVIDER_ID_SET: ReadonlySet = new Set(BUILTIN_PROVIDER_IDS); interface PluginRuntimePort { catalog(): Array<{ id: string; clientBundle: string }>; invoke(pluginId: string, method: string, input: unknown): Promise; getLogs(pluginId: string): PluginLogEntry[]; clearLogs(pluginId: string): void; + getProviderRegistrations?(pluginId: string): readonly PluginProviderMetadata[]; + connectProvider: PluginRuntime["connectProvider"]; validatePlugin?(path: string): Promise; startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise; stopPluginById(pluginId: string): Promise; @@ -49,6 +57,9 @@ export class PluginService { private readonly logger: pino.Logger; private readonly errors = new Map(); private readonly listeners = new Set<(pluginId: string) => void>(); + private readonly providers = new Map(); + private readonly providerIdsByPlugin = new Map(); + private readonly providerListeners = new Set<() => void>(); private lifecycle = Promise.resolve(); private globalStartsBlocked = true; private started = false; @@ -63,6 +74,7 @@ export class PluginService { this.runtime = dependencies.runtime ?? new PluginRuntime(logger, daemonVersion); this.managedSources = dependencies.managedSources ?? null; this.runtime.subscribe((pluginId, error) => { + this.removeProviderRegistrations(pluginId); if (error) this.errors.set(pluginId, error); this.notify(pluginId); }); @@ -77,6 +89,15 @@ export class PluginService { this.runtime.bindPaseoSessionHost(sessionHost); } + getProviderRegistrations(): readonly ProviderRegistration[] { + return [...this.providers.values()].sort((left, right) => left.id.localeCompare(right.id)); + } + + subscribeProviderRegistrations(listener: () => void): () => void { + this.providerListeners.add(listener); + return () => this.providerListeners.delete(listener); + } + async start(): Promise { if (this.started) return; this.started = true; @@ -252,7 +273,7 @@ export class PluginService { throw new Error("Plugins are globally disabled"); } this.errors.delete(pluginId); - await this.runtime.stopPluginById(pluginId); + await this.stopPlugin(pluginId); await this.startExplicit(pluginId, source.path); this.notify(pluginId); return this.requireItem(pluginId); @@ -276,7 +297,7 @@ export class PluginService { async disablePlugin(pluginId: string): Promise { const source = this.requireSource(pluginId); this.patchSource(pluginId, { ...source, enabled: false }); - const stopping = this.runtime.stopPluginById(pluginId); + const stopping = this.stopPlugin(pluginId); return this.enqueue(async () => { await stopping; this.errors.delete(pluginId); @@ -287,7 +308,7 @@ export class PluginService { async removePlugin(pluginId: string): Promise { this.requireSource(pluginId); - const stopping = this.runtime.stopPluginById(pluginId); + const stopping = this.stopPlugin(pluginId); const sources = { ...this.configStore.get().plugins }; delete sources[pluginId]; this.configStore.patch({ plugins: sources }); @@ -306,17 +327,17 @@ export class PluginService { async stopAllPlugins(): Promise { this.globalStartsBlocked = true; - const stopping = this.runtime.stopAll(); + const stopping = this.stopAll(); await this.enqueue(async () => { await stopping; - await this.runtime.stopAll(); + await this.stopAll(); }); } private handleGlobalSwitch(enabled: boolean): void { if (!enabled) { this.globalStartsBlocked = true; - const stopping = this.runtime.stopAll(); + const stopping = this.stopAll(); for (const id of Object.keys(this.configStore.get().plugins ?? {})) this.notify(id); void this.enqueue(async () => { await stopping; @@ -337,7 +358,7 @@ export class PluginService { if (!source || source.enabled === false || !this.canPublish(pluginId)) return; this.errors.delete(pluginId); try { - await this.runtime.startPlugin(pluginId, source.path, () => this.canPublish(pluginId)); + await this.startPlugin(pluginId, source.path); } catch (error) { if (this.canPublish(pluginId)) this.recordFailure(pluginId, error); } @@ -345,7 +366,7 @@ export class PluginService { private async startExplicit(pluginId: string, sourcePath: string): Promise { try { - await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId)); + await this.startPlugin(pluginId, sourcePath); } catch (error) { if (this.canPublish(pluginId)) { this.recordFailure(pluginId, error); @@ -365,6 +386,88 @@ export class PluginService { ); } + private async startPlugin(pluginId: string, sourcePath: string): Promise { + await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId)); + try { + await this.publishProviderRegistrations(pluginId, sourcePath); + } catch (error) { + try { + this.removeProviderRegistrations(pluginId); + } finally { + await this.runtime.stopPluginById(pluginId); + } + throw error; + } + } + + private stopPlugin(pluginId: string): Promise { + this.removeProviderRegistrations(pluginId); + return this.runtime.stopPluginById(pluginId); + } + + private async stopAll(): Promise { + for (const pluginId of this.providerIdsByPlugin.keys()) { + this.removeProviderRegistrations(pluginId); + } + await this.runtime.stopAll(); + } + + private async publishProviderRegistrations( + pluginId: string, + pluginDirectory: string, + ): Promise { + const metadata = this.runtime.getProviderRegistrations?.(pluginId) ?? []; + const configuredIds = new Set(Object.keys(this.configStore.get().providers)); + for (const provider of metadata) { + if (BUILTIN_PROVIDER_ID_SET.has(provider.id)) { + throw new Error(`Plugin ${pluginId} cannot register builtin provider ID "${provider.id}"`); + } + if (configuredIds.has(provider.id)) { + throw new Error( + `Plugin ${pluginId} cannot register configured provider ID "${provider.id}"`, + ); + } + if (this.providers.has(provider.id)) { + throw new Error(`Plugin ${pluginId} cannot register provider ID "${provider.id}" twice`); + } + } + const registrations = await Promise.all( + metadata.map( + async (provider): Promise => ({ + id: provider.id, + label: provider.label, + description: provider.description, + icon: provider.iconPath + ? await readPluginProviderIcon(pluginDirectory, provider.iconPath) + : undefined, + connect: (request) => this.runtime.connectProvider(pluginId, provider.id, request), + }), + ), + ); + const ids = registrations.map((provider) => provider.id); + for (const provider of registrations) { + this.providers.set(provider.id, { + ...provider, + }); + } + if (ids.length > 0) { + this.providerIdsByPlugin.set(pluginId, ids); + this.notifyProviderRegistrations(); + } + } + + private removeProviderRegistrations(pluginId: string): void { + const ids = this.providerIdsByPlugin.get(pluginId); + if (!ids) return; + this.providerIdsByPlugin.delete(pluginId); + for (const id of ids) this.providers.delete(id); + this.notifyProviderRegistrations(); + } + + private notifyProviderRegistrations(): void { + for (const listener of this.providerListeners) listener(); + } + private enqueue(operation: () => Promise): Promise { const result = this.lifecycle.then(operation, operation); this.lifecycle = result.then( @@ -413,7 +516,7 @@ export class PluginService { const isGloballyEnabled = this.configStore.get().pluginsEnabled === true; const shouldActivate = isEnabled && isGloballyEnabled; if (shouldActivate) { - if (isRunning) await this.runtime.stopPluginById(pluginId); + if (isRunning) await this.stopPlugin(pluginId); try { await this.startExplicit(pluginId, candidate.directory); } catch (error) { @@ -432,7 +535,7 @@ export class PluginService { const activatedSource = this.configStore.get().plugins?.[pluginId]; if (!activatedSource) { - await this.runtime.stopPluginById(pluginId); + await this.stopPlugin(pluginId); await managedSources.discard(candidate); throw new Error(`Plugin is no longer configured: ${pluginId}`); } diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index c9d61336c0..463eec8631 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -1,14 +1,185 @@ +import type { + ProviderConnectRequest, + ProviderEvent, + ProviderInput, +} from "@getpaseo/plugin/provider"; +import { ProviderEventSchema, ProviderInputSchema } from "@getpaseo/plugin/provider"; +import { z } from "zod"; + +export interface PluginProviderMetadata { + id: string; + label: string; + description?: string; + iconPath?: string; +} + export type PluginProcessRequest = | { type: "initialize"; pluginId: string; bundle: string; appVersion: string } | { type: "invoke"; requestId: string; method: string; input: unknown } + | { + type: "provider.connect"; + providerId: string; + connectionId: string; + request: ProviderConnectRequest; + } + | { + type: "provider.send"; + connectionId: string; + acceptanceId: string; + input: ProviderInput; + } + | { type: "provider.close"; connectionId: string } | { type: "shutdown" } | { type: "paseo_frame"; data: string | Uint8Array; isBinary: boolean } | { type: "paseo_close" }; export type PluginProcessMessage = - | { type: "ready"; methods: string[] } + | { type: "ready"; methods: string[]; providers: PluginProviderMetadata[] } | { type: "result"; requestId: string; output: unknown } | { type: "error"; requestId: string; error: string } | { type: "fatal"; error: string } + | { + type: "provider.connected"; + connectionId: string; + version: number; + capabilities: readonly string[]; + } + | { type: "provider.connect_failed"; connectionId: string; error: string } + | { type: "provider.accepted"; connectionId: string; acceptanceId: string } + | { + type: "provider.rejected"; + connectionId: string; + acceptanceId: string; + error: string; + } + | { type: "provider.event"; connectionId: string; event: ProviderEvent } + | { type: "provider.closed"; connectionId: string; error?: string } | { type: "paseo_frame"; data: string | Uint8Array; isBinary: boolean } | { type: "paseo_close" }; + +const providerMetadataSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1), + description: z.string().optional(), + iconPath: z.string().optional(), + }) + .strict(); +const providerConnectRequestSchema = z + .object({ + versions: z.array(z.number().int().positive()), + capabilities: z.array(z.string()), + }) + .strict(); +const frameFields = { + data: z.union([z.string(), z.instanceof(Uint8Array)]), + isBinary: z.boolean(), +}; + +export const PluginProcessRequestSchema: z.ZodType = z.discriminatedUnion( + "type", + [ + z + .object({ + type: z.literal("initialize"), + pluginId: z.string().min(1), + bundle: z.string(), + appVersion: z.string(), + }) + .strict(), + z + .object({ + type: z.literal("invoke"), + requestId: z.string().min(1), + method: z.string().min(1), + input: z.unknown(), + }) + .strict(), + z + .object({ + type: z.literal("provider.connect"), + providerId: z.string().min(1), + connectionId: z.string().min(1), + request: providerConnectRequestSchema, + }) + .strict(), + z + .object({ + type: z.literal("provider.send"), + connectionId: z.string().min(1), + acceptanceId: z.string().min(1), + input: ProviderInputSchema, + }) + .strict(), + z.object({ type: z.literal("provider.close"), connectionId: z.string().min(1) }).strict(), + z.object({ type: z.literal("shutdown") }).strict(), + z.object({ type: z.literal("paseo_frame"), ...frameFields }).strict(), + z.object({ type: z.literal("paseo_close") }).strict(), + ], +); + +export const PluginProcessMessageSchema: z.ZodType = z.discriminatedUnion( + "type", + [ + z + .object({ + type: z.literal("ready"), + methods: z.array(z.string()), + providers: z.array(providerMetadataSchema), + }) + .strict(), + z + .object({ type: z.literal("result"), requestId: z.string().min(1), output: z.unknown() }) + .strict(), + z + .object({ type: z.literal("error"), requestId: z.string().min(1), error: z.string() }) + .strict(), + z.object({ type: z.literal("fatal"), error: z.string() }).strict(), + z + .object({ + type: z.literal("provider.connected"), + connectionId: z.string().min(1), + version: z.number().int().positive(), + capabilities: z.array(z.string()), + }) + .strict(), + z + .object({ + type: z.literal("provider.connect_failed"), + connectionId: z.string().min(1), + error: z.string(), + }) + .strict(), + z + .object({ + type: z.literal("provider.accepted"), + connectionId: z.string().min(1), + acceptanceId: z.string().min(1), + }) + .strict(), + z + .object({ + type: z.literal("provider.rejected"), + connectionId: z.string().min(1), + acceptanceId: z.string().min(1), + error: z.string(), + }) + .strict(), + z + .object({ + type: z.literal("provider.event"), + connectionId: z.string().min(1), + event: ProviderEventSchema, + }) + .strict(), + z + .object({ + type: z.literal("provider.closed"), + connectionId: z.string().min(1), + error: z.string().optional(), + }) + .strict(), + z.object({ type: z.literal("paseo_frame"), ...frameFields }).strict(), + z.object({ type: z.literal("paseo_close") }).strict(), + ], +); diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 1b4082df4d..c03db034b6 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -1,6 +1,15 @@ -import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js"; +import { + PluginProcessRequestSchema, + type PluginProcessMessage, + type PluginProcessRequest, +} from "./plugin-process-protocol.js"; import { createRequire } from "node:module"; import { defineAttachmentSource, defineRpc, type PluginRpcContract } from "@getpaseo/plugin"; +import { + ProviderEventSchema, + type ProviderConnection, + type ProviderRegistration, +} from "@getpaseo/plugin/provider"; import type { PluginHandlerContext } from "@getpaseo/plugin/server"; import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -20,6 +29,12 @@ interface RegisteredRpc { } const handlers = new Map(); +const providers = new Map(); +const providerConnections = new Map< + string, + { connection: ProviderConnection; unsubscribe: () => void } +>(); +const pendingProviderConnections = new Map(); let cleanup: (() => void | Promise) | null = null; let daemonClient: DaemonClient | null = null; let paseo: PaseoApi | null = null; @@ -44,6 +59,12 @@ function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function jsonTransportValue(value: Value): Value { + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new Error("Provider value is not JSON-serializable"); + return JSON.parse(encoded) as Value; +} + function validateMethod(method: string): string { const normalized = method.trim(); if (!/^[a-z][a-z0-9._-]*$/.test(normalized)) { @@ -63,6 +84,106 @@ function register(contract: PluginRpcContract, handler: RpcHandler): void { handlers.set(method, { contract: { ...contract, name: method }, handler }); } +function registerProvider(provider: ProviderRegistration): void { + const id = provider.id.trim(); + if (!/^[a-z][a-z0-9._-]*$/.test(id)) { + throw new Error(`Invalid plugin provider ID: ${provider.id}`); + } + if (!provider.label.trim()) throw new Error(`Plugin provider ${id} requires a label`); + if (typeof provider.connect !== "function") { + throw new Error(`Plugin provider ${id} must implement connect()`); + } + if (providers.has(id)) throw new Error(`Duplicate plugin provider ID: ${id}`); + providers.set(id, { ...provider, id }); +} + +function providerMetadata(provider: ProviderRegistration) { + return { + id: provider.id, + label: provider.label, + description: provider.description, + iconPath: provider.icon, + }; +} + +async function connectProvider( + message: Extract, +): Promise { + if (stopping) throw new Error("Plugin is stopping"); + const provider = providers.get(message.providerId); + if (!provider) throw new Error(`Unknown plugin provider: ${message.providerId}`); + if ( + providerConnections.has(message.connectionId) || + pendingProviderConnections.has(message.connectionId) + ) { + throw new Error(`Duplicate provider connection: ${message.connectionId}`); + } + const pending = { tombstoned: false }; + pendingProviderConnections.set(message.connectionId, pending); + let connection: ProviderConnection; + try { + connection = await provider.connect(message.request); + } catch (error) { + pendingProviderConnections.delete(message.connectionId); + if (pending.tombstoned || stopping) return; + throw error; + } + pendingProviderConnections.delete(message.connectionId); + if (pending.tombstoned || stopping) { + await connection.close().catch(() => undefined); + return; + } + let unsubscribe = () => {}; + unsubscribe = connection.onEvent((event) => { + try { + send({ + type: "provider.event", + connectionId: message.connectionId, + event: ProviderEventSchema.parse(jsonTransportValue(event)), + }); + } catch (error) { + providerConnections.delete(message.connectionId); + unsubscribe(); + void connection.close().catch(() => undefined); + send({ + type: "provider.closed", + connectionId: message.connectionId, + error: describeError(error), + }); + } + }); + providerConnections.set(message.connectionId, { connection, unsubscribe }); + send({ + type: "provider.connected", + connectionId: message.connectionId, + version: connection.version, + capabilities: connection.capabilities, + }); +} + +async function sendProviderInput( + message: Extract, +): Promise { + if (stopping) throw new Error("Plugin is stopping"); + const current = providerConnections.get(message.connectionId); + if (!current) throw new Error(`Unknown provider connection: ${message.connectionId}`); + await current.connection.send(message.input); + send({ + type: "provider.accepted", + connectionId: message.connectionId, + acceptanceId: message.acceptanceId, + }); +} + +async function closeProviderConnection(connectionId: string): Promise { + const current = providerConnections.get(connectionId); + if (!current) return; + providerConnections.delete(connectionId); + current.unsubscribe(); + await current.connection.close(); + send({ type: "provider.closed", connectionId }); +} + const pluginAuthorRuntime = { defineAttachmentSource, defineRpc, @@ -90,7 +211,7 @@ function evaluateBundle(bundle: string): void { if (typeof setup !== "function") { throw new Error("Plugin server bundle must default export a function"); } - const contributedCleanup = setup({ handle: register }); + const contributedCleanup = setup({ handle: register, registerProvider }); if (typeof contributedCleanup !== "function") { throw new Error("Plugin contribution must return a cleanup function"); } @@ -117,12 +238,19 @@ async function initialize(message: Extract left.id.localeCompare(right.id)) + .map(providerMetadata), + }); } async function shutdown(): Promise { if (stopping) return; stopping = true; + for (const pending of pendingProviderConnections.values()) pending.tombstoned = true; const currentCleanup = cleanup; cleanup = null; try { @@ -130,6 +258,7 @@ async function shutdown(): Promise { } catch (error) { console.error("Plugin cleanup failed", error); } + await Promise.all([...providerConnections.keys()].map(closeProviderConnection)); await daemonClient?.close().catch(() => undefined); await sendAndWait({ type: "paseo_close" }); daemonClient = null; @@ -137,7 +266,25 @@ async function shutdown(): Promise { process.disconnect(); } -process.on("message", (message: PluginProcessRequest) => { +process.on("message", (rawMessage: unknown) => { + const parsed = PluginProcessRequestSchema.safeParse(rawMessage); + if (!parsed.success) { + const value = rawMessage as { connectionId?: unknown; acceptanceId?: unknown } | null; + if (value && typeof value.connectionId === "string" && typeof value.acceptanceId === "string") { + send({ + type: "provider.rejected", + connectionId: value.connectionId, + acceptanceId: value.acceptanceId, + error: `Invalid provider input: ${parsed.error.message}`, + }); + void closeProviderConnection(value.connectionId); + return; + } + send({ type: "fatal", error: `Invalid plugin process request: ${parsed.error.message}` }); + void shutdown(); + return; + } + const message = parsed.data; if (message.type === "initialize") { void initialize(message).catch(async (error) => { send({ type: "fatal", error: describeError(error) }); @@ -149,8 +296,59 @@ process.on("message", (message: PluginProcessRequest) => { void shutdown(); return; } + if (stopping) { + if (message.type === "provider.connect") { + send({ + type: "provider.connect_failed", + connectionId: message.connectionId, + error: "Plugin is stopping", + }); + } else if (message.type === "provider.send") { + send({ + type: "provider.rejected", + connectionId: message.connectionId, + acceptanceId: message.acceptanceId, + error: "Plugin is stopping", + }); + } else if (message.type === "provider.close") { + send({ type: "provider.closed", connectionId: message.connectionId }); + } + return; + } + if (message.type === "provider.connect") { + void connectProvider(message).catch((error) => { + if (stopping) return; + send({ + type: "provider.connect_failed", + connectionId: message.connectionId, + error: describeError(error), + }); + }); + return; + } + if (message.type === "provider.send") { + void sendProviderInput(message).catch((error) => { + if (stopping) return; + send({ + type: "provider.rejected", + connectionId: message.connectionId, + acceptanceId: message.acceptanceId, + error: describeError(error), + }); + }); + return; + } + if (message.type === "provider.close") { + void closeProviderConnection(message.connectionId).catch((error) => { + send({ + type: "provider.closed", + connectionId: message.connectionId, + error: describeError(error), + }); + }); + return; + } if (message.type === "paseo_frame" || message.type === "paseo_close") return; - if (stopping) return; const registered = handlers.get(message.method); if (!registered) { send({ diff --git a/packages/server/src/server/plugins/provider-icon.test.ts b/packages/server/src/server/plugins/provider-icon.test.ts new file mode 100644 index 0000000000..4faf9d289c --- /dev/null +++ b/packages/server/src/server/plugins/provider-icon.test.ts @@ -0,0 +1,84 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { readPluginProviderIcon } from "./provider-icon.js"; + +const directories: string[] = []; + +async function writeIcon(contents: string): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-icon-")); + directories.push(directory); + await writeFile(path.join(directory, "icon.svg"), contents); + return directory; +} + +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("readPluginProviderIcon", () => { + test("accepts a self-contained SVG", async () => { + const svg = + ''; + const directory = await writeIcon(svg); + + await expect(readPluginProviderIcon(directory, "icon.svg")).resolves.toBe(svg); + }); + + test.each([ + ["non-SVG document", "hello", "file is not an SVG document"], + ["script", "", "script elements"], + ["foreign object", "", "foreignObject elements"], + ["style", "", "style elements"], + ["event handler", '', "event-handler attributes"], + ["javascript URL", '', "javascript URLs"], + ["external href", '', "external href"], + ])("rejects %s", async (_name, svg, reason) => { + const directory = await writeIcon(svg); + + await expect(readPluginProviderIcon(directory, "icon.svg")).rejects.toThrow( + `icon.svg": ${reason}`, + ); + }); + + test("rejects a path outside the plugin directory", async () => { + const directory = await writeIcon(""); + + await expect(readPluginProviderIcon(directory, "../icon.svg")).rejects.toThrow( + "path leaves the plugin directory", + ); + }); + + test("rejects a symlink that escapes the plugin directory", async () => { + const directory = await writeIcon(""); + const externalDirectory = await mkdtemp(path.join(tmpdir(), "paseo-external-icon-")); + directories.push(externalDirectory); + const externalIcon = path.join(externalDirectory, "external.svg"); + await writeFile(externalIcon, ""); + await symlink(externalIcon, path.join(directory, "linked.svg")); + + await expect(readPluginProviderIcon(directory, "linked.svg")).rejects.toThrow( + "path leaves the plugin directory", + ); + }); + + test("rejects a directory instead of a regular file", async () => { + const directory = await writeIcon(""); + await mkdir(path.join(directory, "nested.svg")); + + await expect(readPluginProviderIcon(directory, "nested.svg")).rejects.toThrow( + "file does not exist or is not a regular file", + ); + }); + + test("rejects an icon larger than 64 KiB", async () => { + const directory = await writeIcon(`${" ".repeat(64 * 1024)}`); + + await expect(readPluginProviderIcon(directory, "icon.svg")).rejects.toThrow( + "file exceeds 64 KiB", + ); + }); +}); diff --git a/packages/server/src/server/plugins/provider-icon.ts b/packages/server/src/server/plugins/provider-icon.ts new file mode 100644 index 0000000000..9a1e3f6714 --- /dev/null +++ b/packages/server/src/server/plugins/provider-icon.ts @@ -0,0 +1,64 @@ +import { readFile, realpath, stat } from "node:fs/promises"; +import path from "node:path"; + +const MAX_ICON_BYTES = 64 * 1024; + +export async function readPluginProviderIcon( + pluginDirectory: string, + iconPath: string, +): Promise { + const resolvedDirectory = await realpath(path.resolve(pluginDirectory)); + const requestedIcon = path.resolve(resolvedDirectory, iconPath); + assertInsidePluginDirectory(resolvedDirectory, requestedIcon, iconPath); + const resolvedIcon = await realpath(requestedIcon).catch(() => null); + if (!resolvedIcon) { + throw iconError(iconPath, "file does not exist or is not a regular file"); + } + assertInsidePluginDirectory(resolvedDirectory, resolvedIcon, iconPath); + + const iconStat = await stat(resolvedIcon); + if (!iconStat.isFile()) { + throw iconError(iconPath, "file does not exist or is not a regular file"); + } + if (iconStat.size > MAX_ICON_BYTES) throw iconError(iconPath, "file exceeds 64 KiB"); + + const svg = await readFile(resolvedIcon, "utf8"); + validateSvg(iconPath, svg); + return svg; +} + +function assertInsidePluginDirectory( + resolvedDirectory: string, + resolvedIcon: string, + iconPath: string, +): void { + const relative = path.relative(resolvedDirectory, resolvedIcon); + const escapesDirectory = + relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); + if (escapesDirectory) throw iconError(iconPath, "path leaves the plugin directory"); +} + +function validateSvg(iconPath: string, svg: string): void { + if (!/^\s*)/i.test(svg)) throw iconError(iconPath, "file is not an SVG document"); + if (/)/i.test(svg)) throw iconError(iconPath, "script elements are not allowed"); + if (/)/i.test(svg)) { + throw iconError(iconPath, "foreignObject elements are not allowed"); + } + if (/)/i.test(svg)) throw iconError(iconPath, "style elements are not allowed"); + if (/\son[a-z0-9_-]*\s*=/i.test(svg)) { + throw iconError(iconPath, "event-handler attributes are not allowed"); + } + if (/javascript\s*:/i.test(svg)) throw iconError(iconPath, "javascript URLs are not allowed"); + + const hrefPattern = /\s(?:href|xlink:href)\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))/gi; + for (const match of svg.matchAll(hrefPattern)) { + const href = match[2] ?? match[3] ?? ""; + if (!href.startsWith("#")) { + throw iconError(iconPath, "external href references are not allowed"); + } + } +} + +function iconError(iconPath: string, reason: string): Error { + return new Error(`Invalid plugin provider icon "${iconPath}": ${reason}`); +} diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 021f695ff7..415c0e5374 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -4,12 +4,19 @@ import { PassThrough } from "node:stream"; import path from "node:path"; import { fileURLToPath } from "node:url"; import pino from "pino"; +import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AgentStreamEvent } from "../agent/agent-sdk-types.js"; +import { PluginAgentClientRegistry } from "../agent/plugin-provider.js"; import { PluginRuntime } from "./runtime.js"; import type { PluginSessionSocket } from "./session-socket.js"; const temporaryDirectories: string[] = []; +function hasCompletedAgentTurn(events: readonly AgentStreamEvent[]): boolean { + return events.some((event) => event.type === "turn_completed"); +} + async function createPlugin(id: string, source: string): Promise { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-")); temporaryDirectories.push(directory); @@ -18,7 +25,20 @@ async function createPlugin(id: string, source: string): Promise { return directory; } -function createReloadChild(name: string, events: string[], methods: string[] = []) { +async function readTextIfPresent(filePath: string): Promise { + try { + return await readFile(filePath, "utf8"); + } catch { + return null; + } +} + +function createReloadChild( + name: string, + events: string[], + methods: string[] = [], + providers: Array<{ id: string; label: string }> = [], +) { const listeners = new Map void>>(); const emit = (event: string, message: unknown) => { for (const listener of listeners.get(event) ?? []) listener(message as never); @@ -32,7 +52,7 @@ function createReloadChild(name: string, events: string[], methods: string[] = [ callback?.(null); if (message.type === "initialize") { events.push(`start:${name}`); - queueMicrotask(() => emit("message", { type: "ready", methods })); + queueMicrotask(() => emit("message", { type: "ready", methods, providers })); } if (message.type === "shutdown") { events.push(`shutdown:${name}`); @@ -59,6 +79,9 @@ function createReloadChild(name: string, events: string[], methods: string[] = [ listeners.set(event, registered); return this; }, + emitMessage(message: unknown) { + emit("message", message); + }, }; } @@ -140,6 +163,42 @@ function lifecycleMessages(runtime: PluginRuntime): string[] { .sort(); } +function hasCompletedTurn(events: readonly ProviderEvent[]): boolean { + return events.some((event) => event.type === "session.turn" && event.state === "completed"); +} + +function hasReadyRequest(events: readonly ProviderEvent[], requestId: string): boolean { + return events.some((event) => event.type === "session.ready" && event.requestId === requestId); +} + +function runtimeFailure(events: readonly ProviderEvent[]): ProviderEvent | undefined { + return events.find((event) => event.type === "session.runtime_failed"); +} + +function providerCloseCount( + messages: readonly { type: string; connectionId?: string }[], + connectionId: string | undefined, +): number { + return messages.filter( + (message) => message.type === "provider.close" && message.connectionId === connectionId, + ).length; +} + +function hasMessageType(messages: readonly { type: string }[], type: string): boolean { + return messages.some((message) => message.type === type); +} + +function adaptRuntimeProvider( + runtime: PluginRuntime, + pluginId: string, + metadata: { id: string; label: string; description?: string; icon?: string }, +): ProviderRegistration { + return { + ...metadata, + connect: (request) => runtime.connectProvider(pluginId, metadata.id, request), + }; +} + afterEach(async () => { await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), @@ -147,6 +206,523 @@ afterEach(async () => { }); describe("PluginRuntime", () => { + it("runs the direct example through the existing AgentClient path", async () => { + const pluginId = "provider-direct-example"; + const directory = fileURLToPath( + new URL("../../../../../plugin-examples/provider-direct/", import.meta.url), + ); + const runtime = createTestRuntime(); + await runtime.startPlugin(pluginId, directory); + const [metadata] = runtime.getProviderRegistrations(pluginId); + expect(metadata).toBeDefined(); + const adapters = new PluginAgentClientRegistry(pino({ level: "silent" })); + adapters.replace([adaptRuntimeProvider(runtime, pluginId, metadata!)]); + const client = adapters.clients()[metadata!.id]; + + const session = await client!.createSession({ provider: metadata!.id, cwd: directory }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await expect(session.startTurn("hello", { clientMessageId: "direct-client" })).resolves.toEqual( + { turnId: "turn-1" }, + ); + await expect.poll(() => hasCompletedAgentTurn(events)).toBe(true); + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: { + type: "assistant_message", + id: undefined, + messageId: "assistant-1", + text: "Echo: hello", + }, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ type: "timeline" }), + }), + ); + + await session.close(); + await adapters.shutdown(); + await runtime.stopAll(); + }); + + it("runs a provider connection through the real plugin subprocess boundary", async () => { + const directory = await createPlugin( + "provider-round-trip", + `import type { PluginServerContext } from "@getpaseo/plugin"; +import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider"; + +const provider: ProviderRegistration = { + id: "direct-example", + label: "Direct example", + icon: "icon.svg", + async connect(request) { + const listeners = new Set<(event: ProviderEvent) => void>(); + const emit = (event: ProviderEvent) => { + for (const listener of listeners) listener(event); + }; + return { + version: request.versions[0] ?? 1, + capabilities: request.capabilities, + async send(input) { + if (input.type === "session.open") { + emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities: request.capabilities, + restoration: "core", + persistence: { version: 1, data: { resume: "native-1" } }, + cwd: input.config.cwd, + futureField: "ignored by this protocol version", + } as ProviderEvent); + emit({ + type: "session.config", + sessionId: input.sessionId, + config: { + model: "example-1", + models: [{ id: "example-1", label: "Example 1" }], + modes: [], + thinkingOptions: [], + settings: [ + { type: "toggle", id: "concise", label: "Concise", value: true }, + ], + }, + }); + emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + return; + } + if (input.type !== "session.prompt") return; + if (input.prompt.input.type === "command") { + emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "completed" }, + }); + return; + } + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { + type: "user_message", + id: "user-1", + text: "hello", + clientMessageId: input.prompt.clientMessageId, + }, + }); + emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "turn", turnId: "turn-1" }, + }); + emit({ + type: "session.opened", + sessionId: "child-1", + parentSessionId: input.sessionId, + capabilities: [], + restoration: "parent", + cwd: "/repo", + }); + emit({ + type: "timeline.item", + sessionId: "child-1", + item: { + type: "plugin", + id: "review-1", + pluginId: "provider-round-trip", + kind: "review", + version: 1, + data: { verdict: "ship" }, + }, + }); + }, + onEvent(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + async close() {}, + }; + }, +}; + +export default function contribute(server: PluginServerContext) { + server.registerProvider(provider); + return () => {}; +}`, + ); + const runtime = createTestRuntime(); + await runtime.startPlugin("provider-round-trip", directory); + + const [registration] = runtime.getProviderRegistrations("provider-round-trip"); + expect(registration).toMatchObject({ + id: "direct-example", + label: "Direct example", + iconPath: "icon.svg", + }); + const connection = await runtime.connectProvider("provider-round-trip", "direct-example", { + versions: [1], + capabilities: [ + "prompt.message", + "prompt.command", + "session.configure", + "session.persistence", + "session.subsession", + "timeline.plugin", + "future.capability", + ], + }); + expect(connection.capabilities).not.toContain("future.capability"); + const events: ProviderEvent[] = []; + const unsubscribe = connection.onEvent((event) => events.push(event)); + + await connection.send({ + type: "session.open", + requestId: "open-1", + sessionId: "root-1", + config: { + cwd: "/repo", + env: {}, + mcpServers: {}, + settings: {}, + persist: true, + }, + history: "replay", + }); + await connection.send({ + type: "session.prompt", + sessionId: "root-1", + prompt: { + clientMessageId: "client-1", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "hello" }] }, + }, + }); + await connection.send({ + type: "session.prompt", + sessionId: "root-1", + prompt: { + clientMessageId: "client-2", + delivery: "auto", + input: { type: "command", name: "compact", arguments: "" }, + }, + }); + + await expect.poll(() => events.length).toBe(8); + expect(events).toContainEqual( + expect.objectContaining({ + type: "session.prompt_result", + clientMessageId: "client-1", + result: { type: "turn", turnId: "turn-1" }, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "session.prompt_result", + clientMessageId: "client-2", + result: { type: "completed" }, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "session.opened", + sessionId: "child-1", + parentSessionId: "root-1", + }), + ); + expect(events.find((event) => event.type === "session.opened")).not.toHaveProperty( + "futureField", + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline.item", + sessionId: "child-1", + item: expect.objectContaining({ type: "plugin", pluginId: "provider-round-trip" }), + }), + ); + + unsubscribe(); + await connection.close(); + await runtime.stopAll(); + }); + + it("adapts an ACP command and example transformer through the AgentClient path", async () => { + const transformerPath = fileURLToPath( + new URL( + "../../../../../plugin-examples/provider-acp-transformer/server/vendor-edit.ts", + import.meta.url, + ), + ); + const directory = await createPlugin( + "provider-acp-round-trip", + `import type { PluginServerContext } from "@getpaseo/plugin"; +import { runAcpProvider } from "@getpaseo/plugin/acp"; +import { vendorEditTransformer } from "./server/vendor-edit.js"; + +export default function contribute(server: PluginServerContext) { + server.registerProvider(runAcpProvider({ + id: "acp-example", + label: "ACP example", + command: [process.execPath, __ACP_COMMAND__], + transformers: [vendorEditTransformer], + })); + return () => {}; +}`, + ); + await mkdir(path.join(directory, "server")); + await writeFile( + path.join(directory, "server/vendor-edit.ts"), + await readFile(transformerPath, "utf8"), + "utf8", + ); + const agentPath = path.join(directory, "fake-acp.cjs"); + await writeFile( + agentPath, + `const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") { + send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + } else if (message.method === "session/new") { + send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: [] } }); + } else if (message.method === "session/prompt") { + send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "native-1", update: { sessionUpdate: "tool_call", toolCallId: "edit-1", name: "vendor_file_edit", title: "Vendor edit", status: "completed", rawInput: { path: "/tmp/example.ts", before: "old", after: "new" } } } }); + send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "native-1", update: { sessionUpdate: "agent_message_chunk", messageId: "assistant-1", content: { type: "text", text: "ACP says hi" } } } }); + send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } }); + } else if (message.method === "session/close" || message.method === "session/cancel") { + send({ jsonrpc: "2.0", id: message.id, result: {} }); + } +});`, + "utf8", + ); + const pluginPath = path.join(directory, "index.server.ts"); + const pluginSource = (await readFile(pluginPath, "utf8")).replace( + "__ACP_COMMAND__", + JSON.stringify(agentPath), + ); + await writeFile(pluginPath, pluginSource, "utf8"); + + const runtime = createTestRuntime(); + await runtime.startPlugin("provider-acp-round-trip", directory); + const connection = await runtime.connectProvider("provider-acp-round-trip", "acp-example", { + versions: [1], + capabilities: ["prompt.message", "session.persistence"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + + await connection.send({ + type: "session.open", + requestId: "open-acp", + sessionId: "paseo-1", + config: { cwd: directory, env: {}, mcpServers: {}, settings: {}, persist: true }, + history: "skip", + }); + await expect.poll(() => hasReadyRequest(events, "open-acp")).toBe(true); + await connection.send({ + type: "session.prompt", + sessionId: "paseo-1", + prompt: { + clientMessageId: "client-acp", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "hello" }] }, + }, + }); + + await expect.poll(() => hasCompletedTurn(events)).toBe(true); + expect(events).toContainEqual( + expect.objectContaining({ + type: "session.prompt_result", + clientMessageId: "client-acp", + result: { type: "turn", turnId: "acp:client-acp" }, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline.item", + item: expect.objectContaining({ + type: "assistant_message", + id: "assistant-1", + text: "ACP says hi", + }), + }), + ); + + await connection.close(); + const [metadata] = runtime.getProviderRegistrations("provider-acp-round-trip"); + const adapters = new PluginAgentClientRegistry(pino({ level: "silent" })); + adapters.replace([adaptRuntimeProvider(runtime, "provider-acp-round-trip", metadata!)]); + const client = adapters.clients()[metadata!.id]; + const session = await client!.createSession({ provider: metadata!.id, cwd: directory }); + const agentEvents: AgentStreamEvent[] = []; + session.subscribe((event) => agentEvents.push(event)); + await expect( + session.startTurn("hello", { clientMessageId: "agent-client-acp" }), + ).resolves.toEqual({ turnId: "acp:agent-client-acp" }); + await expect.poll(() => hasCompletedAgentTurn(agentEvents)).toBe(true); + expect(agentEvents).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ type: "assistant_message", text: "ACP says hi" }), + }), + ); + expect(agentEvents).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ + type: "tool_call", + callId: "edit-1", + detail: { + type: "edit", + filePath: "/tmp/example.ts", + oldString: "old", + newString: "new", + unifiedDiff: undefined, + }, + }), + }), + ); + await session.close(); + await adapters.shutdown(); + await runtime.stopAll(); + }); + + it("rejects a malformed provider event from a real plugin subprocess", async () => { + const directory = await createPlugin( + "malicious-provider", + `import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider"; +let connectionId = ""; +process.on("message", (message: unknown) => { + const value = message as { type?: string; connectionId?: string }; + if (value.type === "provider.connect") connectionId = value.connectionId ?? ""; +}); +const provider: ProviderRegistration = { + id: "malicious", + label: "Malicious", + async connect(request) { + const listeners = new Set<(event: ProviderEvent) => void>(); + const emit = (event: ProviderEvent) => { for (const listener of listeners) listener(event); }; + return { + version: request.versions[0] ?? 1, + capabilities: ["prompt.message"], + async send(input) { + if (input.type === "session.open") { + emit({ type: "session.opened", requestId: input.requestId, sessionId: input.sessionId, capabilities: ["prompt.message"], restoration: "core", cwd: input.config.cwd }); + emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + } else if (input.type === "session.prompt") { + process.send?.({ type: "provider.event", connectionId, event: { type: "invented.event", sessionId: input.sessionId } }); + } + }, + onEvent(listener) { listeners.add(listener); return () => listeners.delete(listener); }, + async close() {}, + }; + }, +}; +export default function contribute(server: any) { server.registerProvider(provider); return () => undefined; }`, + ); + const runtime = createTestRuntime(); + await runtime.startPlugin("malicious-provider", directory); + const connection = await runtime.connectProvider("malicious-provider", "malicious", { + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send({ + type: "session.open", + requestId: "open-malicious", + sessionId: "root-malicious", + config: { cwd: "/repo", env: {}, mcpServers: {}, settings: {}, persist: true }, + history: "skip", + }); + + await expect( + connection.send({ + type: "session.prompt", + sessionId: "root-malicious", + prompt: { + clientMessageId: "malformed-now", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "break transport" }] }, + }, + }), + ).rejects.toThrow("invalid message"); + expect(events).toContainEqual( + expect.objectContaining({ type: "session.runtime_failed", sessionId: "root-malicious" }), + ); + expect(runtime.catalog()).toHaveLength(1); + await runtime.stopAll(); + }); + + it("emits runtime failure for live sessions when a real plugin process dies", async () => { + const directory = await createPlugin( + "dying-provider", + `import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider"; +const provider: ProviderRegistration = { + id: "dying", + label: "Dying", + async connect(request) { + const listeners = new Set<(event: ProviderEvent) => void>(); + const emit = (event: ProviderEvent) => { for (const listener of listeners) listener(event); }; + return { + version: request.versions[0] ?? 1, + capabilities: ["prompt.message"], + async send(input) { + if (input.type === "session.open") { + emit({ type: "session.opened", requestId: input.requestId, sessionId: input.sessionId, capabilities: ["prompt.message"], restoration: "core", cwd: input.config.cwd }); + emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + } + if (input.type === "session.prompt") setTimeout(() => process.exit(17), 10); + }, + onEvent(listener) { listeners.add(listener); return () => listeners.delete(listener); }, + async close() {}, + }; + }, +}; +export default function contribute(server: any) { server.registerProvider(provider); return () => undefined; }`, + ); + const runtime = createTestRuntime(); + await runtime.startPlugin("dying-provider", directory); + const connection = await runtime.connectProvider("dying-provider", "dying", { + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send({ + type: "session.open", + requestId: "open-dying", + sessionId: "root-dying", + config: { cwd: "/repo", env: {}, mcpServers: {}, settings: {}, persist: true }, + history: "skip", + }); + await connection.send({ + type: "session.prompt", + sessionId: "root-dying", + prompt: { + clientMessageId: "die-now", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "bye" }] }, + }, + }); + + await expect + .poll(() => runtimeFailure(events)) + .toMatchObject({ + type: "session.runtime_failed", + sessionId: "root-dying", + error: { message: "Plugin process exited: dying-provider" }, + }); + await runtime.stopAll(); + }); + it("records host-owned plugin lifecycle events", async () => { const directory = await createPlugin( "lifecycle", @@ -382,38 +958,298 @@ export default function contribute(plugin: unknown) { await rm(cleanupFile, { force: true }); }); - it("does not kill a healthy child while its graceful cleanup is still running", async () => { + it("closes a provider connection that resolves during plugin shutdown", async () => { + const suffix = `${process.pid}-${Date.now()}`; + const startedFile = path.join(tmpdir(), `paseo-provider-connect-started-${suffix}`); + const closedFile = path.join(tmpdir(), `paseo-provider-connect-closed-${suffix}`); + const directory = await createPlugin( + "shutdown-connect", + `import { writeFile } from "node:fs/promises"; +import type { ProviderRegistration } from "@getpaseo/plugin/provider"; + +const provider: ProviderRegistration = { + id: "delayed", + label: "Delayed", + async connect() { + await writeFile(${JSON.stringify(startedFile)}, "started"); + await new Promise((resolve) => setTimeout(resolve, 100)); + return { + version: 1, + capabilities: [], + async send() {}, + onEvent() { return () => undefined; }, + async close() { await writeFile(${JSON.stringify(closedFile)}, "closed"); }, + }; + }, +}; + +export default function contribute(server: { registerProvider(provider: ProviderRegistration): void }) { + server.registerProvider(provider); + return () => undefined; +}`, + ); + const runtime = createTestRuntime(); + await runtime.startPlugin("shutdown-connect", directory); + const pending = runtime.connectProvider("shutdown-connect", "delayed", { + versions: [1], + capabilities: [], + }); + await expect.poll(() => readTextIfPresent(startedFile)).toBe("started"); + + const stopping = runtime.stopPluginById("shutdown-connect"); + await expect(pending).rejects.toThrow("Plugin stopped: shutdown-connect"); + await stopping; + + await expect(readFile(closedFile, "utf8")).resolves.toBe("closed"); + await Promise.all([rm(startedFile, { force: true }), rm(closedFile, { force: true })]); + }); + + it("closes a provider connection that arrives after negotiation timed out", async () => { + const directory = await createPlugin( + "late-provider", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const child = createReloadChild( + "late-provider", + [], + [], + [{ id: "provider", label: "Provider" }], + ); + const sent: Array<{ type: string; connectionId?: string }> = []; + const originalSend = child.send.bind(child); + child.send = (message, callback) => { + sent.push(message); + if (message.type === "provider.connect") { + callback?.(null); + return true; + } + return originalSend(message, callback); + }; + const runtime = createTestRuntime({ spawnChild: () => child }); + await runtime.startPlugin("late-provider", directory); + vi.useFakeTimers(); + try { + const rejected = expect( + runtime.connectProvider("late-provider", "provider", { + versions: [1], + capabilities: [], + }), + ).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(30_000); + await rejected; + const connectionId = sent.find( + (message) => message.type === "provider.connect", + )?.connectionId; + expect( + sent.filter( + (message) => message.type === "provider.close" && message.connectionId === connectionId, + ), + ).toHaveLength(1); + + child.emitMessage({ + type: "provider.connected", + connectionId, + version: 1, + capabilities: [], + }); + await vi.advanceTimersByTimeAsync(0); + + expect( + sent.filter( + (message) => message.type === "provider.close" && message.connectionId === connectionId, + ), + ).toHaveLength(2); + child.emitMessage({ type: "provider.closed", connectionId }); + } finally { + await runtime.stopAll(); + vi.useRealTimers(); + } + }); + + it("closes child-side state after invalid provider negotiation", async () => { + const directory = await createPlugin( + "invalid-provider", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const child = createReloadChild( + "invalid-provider", + [], + [], + [{ id: "provider", label: "Provider" }], + ); + const sent: Array<{ type: string; connectionId?: string }> = []; + const originalSend = child.send.bind(child); + child.send = (message, callback) => { + sent.push(message); + if (message.type === "provider.connect") { + callback?.(null); + queueMicrotask(() => + child.emitMessage({ + type: "provider.connected", + connectionId: message.connectionId, + version: 2, + capabilities: [], + }), + ); + return true; + } + return originalSend(message, callback); + }; + const runtime = createTestRuntime({ spawnChild: () => child }); + await runtime.startPlugin("invalid-provider", directory); + + await expect( + runtime.connectProvider("invalid-provider", "provider", { + versions: [1], + capabilities: [], + }), + ).rejects.toThrow("unoffered version"); + const connectionId = sent.find((message) => message.type === "provider.connect")?.connectionId; + await expect.poll(() => sent).toContainEqual({ type: "provider.close", connectionId }); + child.emitMessage({ type: "provider.closed", connectionId }); + await runtime.stopAll(); + }); + + it("closes a late connection after the child rejected its negotiation", async () => { + const directory = await createPlugin( + "rejected-provider", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const child = createReloadChild( + "rejected-provider", + [], + [], + [{ id: "provider", label: "Provider" }], + ); + const sent: Array<{ type: string; connectionId?: string }> = []; + const originalSend = child.send.bind(child); + child.send = (message, callback) => { + sent.push(message); + if (message.type === "provider.connect") { + callback?.(null); + queueMicrotask(() => + child.emitMessage({ + type: "provider.connect_failed", + connectionId: message.connectionId, + error: "provider rejected connection", + }), + ); + return true; + } + return originalSend(message, callback); + }; + const runtime = createTestRuntime({ spawnChild: () => child }); + await runtime.startPlugin("rejected-provider", directory); + + await expect( + runtime.connectProvider("rejected-provider", "provider", { + versions: [1], + capabilities: [], + }), + ).rejects.toThrow("provider rejected connection"); + const connectionId = sent.find((message) => message.type === "provider.connect")?.connectionId; + const closesBeforeLateSuccess = providerCloseCount(sent, connectionId); + + child.emitMessage({ + type: "provider.connected", + connectionId, + version: 1, + capabilities: [], + }); + + await expect + .poll(() => providerCloseCount(sent, connectionId)) + .toBe(closesBeforeLateSuccess + 1); + child.emitMessage({ type: "provider.closed", connectionId }); + await runtime.stopAll(); + }); + + it("closes a late connection after plugin shutdown cancels negotiation", async () => { + const directory = await createPlugin( + "canceled-provider", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const child = createReloadChild( + "canceled-provider", + [], + [], + [{ id: "provider", label: "Provider" }], + ); + const sent: Array<{ type: string; connectionId?: string }> = []; + const originalSend = child.send.bind(child); + child.send = (message, callback) => { + sent.push(message); + if (message.type === "provider.connect" || message.type === "shutdown") { + callback?.(null); + return true; + } + return originalSend(message, callback); + }; + const runtime = createTestRuntime({ spawnChild: () => child }); + await runtime.startPlugin("canceled-provider", directory); + const pending = runtime.connectProvider("canceled-provider", "provider", { + versions: [1], + capabilities: [], + }); + await expect.poll(() => hasMessageType(sent, "provider.connect")).toBe(true); + const connectionId = sent.find((message) => message.type === "provider.connect")?.connectionId; + const rejection = pending.then( + () => null, + (error: unknown) => error, + ); + const stopping = runtime.stopPluginById("canceled-provider"); + try { + await expect.poll(() => providerCloseCount(sent, connectionId)).toBe(1); + await expect(rejection).resolves.toMatchObject({ + message: "Plugin stopped: canceled-provider", + }); + const closesBeforeLateSuccess = providerCloseCount(sent, connectionId); + child.emitMessage({ + type: "provider.connected", + connectionId, + version: 1, + capabilities: [], + }); + await expect + .poll(() => providerCloseCount(sent, connectionId)) + .toBe(closesBeforeLateSuccess + 1); + } finally { + child.kill(); + await stopping; + } + }); + + it("escalates a plugin that ignores graceful shutdown from TERM to KILL", async () => { vi.useFakeTimers(); try { const directory = await createPlugin( "held-cleanup", `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, ); - const events: string[] = []; - const child = createReloadChild("held-cleanup", events); + const child = createReloadChild("held-cleanup", []); const originalSend = child.send.bind(child); - let releaseCleanup = () => undefined; child.send = (message, callback) => { if (message.type !== "shutdown") return originalSend(message, callback); callback?.(null); - events.push("shutdown:held-cleanup"); - releaseCleanup = () => { - child.connected = false; - child.kill(); - child.killed = false; - }; + return true; + }; + const signals: Array = []; + const originalKill = child.kill.bind(child); + child.kill = (signal?: NodeJS.Signals) => { + signals.push(signal); + if (signal === "SIGKILL") return originalKill(); + child.killed = true; return true; }; const runtime = createTestRuntime({ spawnChild: () => child }); await runtime.startPlugin("held-cleanup", directory); const stopping = runtime.stopPluginById("held-cleanup"); - await vi.advanceTimersByTimeAsync(60_000); - expect(child.killed).toBe(false); - - releaseCleanup(); + await vi.advanceTimersByTimeAsync(2_000); + expect(signals).toEqual(["SIGTERM"]); + await vi.advanceTimersByTimeAsync(2_000); await stopping; - expect(events).toContain("shutdown:held-cleanup"); + expect(signals).toEqual(["SIGTERM", "SIGKILL"]); } finally { vi.useRealTimers(); } diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 1afb0655e5..922a529324 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -4,10 +4,23 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { randomUUID } from "node:crypto"; import type pino from "pino"; +import { + PROVIDER_CAPABILITIES, + requireProviderCapabilities, + type ProviderConnectRequest, + type ProviderConnection, + type ProviderEvent, + type ProviderInput, +} from "@getpaseo/plugin/provider"; import type { PluginLogEntry } from "@getpaseo/protocol/messages"; import { compilePlugin } from "./compiler.js"; import { readPluginManifest } from "./manifest.js"; -import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js"; +import type { + PluginProcessMessage, + PluginProcessRequest, + PluginProviderMetadata, +} from "./plugin-process-protocol.js"; +import { PluginProcessMessageSchema } from "./plugin-process-protocol.js"; import { PluginSessionSocket } from "./session-socket.js"; const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const; @@ -16,6 +29,7 @@ const REQUEST_TIMEOUT_MS = 30_000; const MAX_LOG_ENTRIES = 500; const MAX_LOG_BYTES = 256 * 1024; const MAX_LOG_LINE_BYTES = 16 * 1024; +const SOFT_SHUTDOWN_TIMEOUT_MS = 2_000; interface PluginOutputStream { on(event: "data", listener: (chunk: Buffer | string) => void): this; @@ -27,9 +41,9 @@ interface PluginChild { stdout?: PluginOutputStream | null; stderr?: PluginOutputStream | null; send(message: PluginProcessRequest, callback?: (error: Error | null) => void): boolean; - kill(): boolean; + kill(signal?: NodeJS.Signals): boolean; disconnect(): void; - on(event: "message", listener: (message: PluginProcessMessage) => void): this; + on(event: "message", listener: (message: unknown) => void): this; on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; } @@ -43,13 +57,37 @@ interface LoadedPlugin { id: string; clientBundle: string; methods: ReadonlySet; + providers: readonly PluginProviderMetadata[]; child: PluginChild | null; outputCapture: PluginOutputCapture | null; pending: Map; + providerConnections: Map; + providerConnectionTombstones: Set; sessionSocket: PluginSessionSocket | null; sessionClosed: Promise | null; } +interface PendingProviderSend { + input: ProviderInput; + resolve: () => void; + reject: (error: Error) => void; +} + +interface RemoteProviderConnection { + request: ProviderConnectRequest; + capabilities: readonly string[]; + listeners: Set<(event: ProviderEvent) => void>; + pendingSends: Map; + sessions: Map; + connected: boolean; + closed: boolean; + closedPromise: Promise; + resolveConnected(connection: ProviderConnection): void; + rejectConnected(error: Error): void; + resolveClosed(): void; + rejectClosed(error: Error): void; +} + interface PluginRuntimeDependencies { spawnChild?: () => PluginChild; sessionHost?: PluginPaseoSessionHost; @@ -174,6 +212,12 @@ function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function jsonTransportValue(value: Value): Value { + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new Error("Provider value is not JSON-serializable"); + return JSON.parse(encoded) as Value; +} + function send(child: PluginChild, message: PluginProcessRequest): Promise { return new Promise((resolve, reject) => { child.send(message, (error) => { @@ -285,6 +329,70 @@ export class PluginRuntime { .sort((left, right) => left.id.localeCompare(right.id)); } + getProviderRegistrations(pluginId: string): readonly PluginProviderMetadata[] { + return this.plugins.get(pluginId)?.providers ?? []; + } + + async connectProvider( + pluginId: string, + providerId: string, + request: ProviderConnectRequest, + ): Promise { + const loaded = this.plugins.get(pluginId); + if (!loaded?.child) throw new Error(`Plugin is not available: ${pluginId}`); + if (!loaded.providers.some((provider) => provider.id === providerId)) { + throw new Error(`Plugin ${pluginId} does not contribute provider ${providerId}`); + } + const connectionId = randomUUID(); + let resolveConnected!: (connection: ProviderConnection) => void; + let rejectConnected!: (error: Error) => void; + let resolveClosed!: () => void; + let rejectClosed!: (error: Error) => void; + const connected = new Promise((resolve, reject) => { + resolveConnected = resolve; + rejectConnected = reject; + }); + const closed = new Promise((resolve, reject) => { + resolveClosed = resolve; + rejectClosed = reject; + }); + void closed.catch(() => undefined); + const state: RemoteProviderConnection = { + request, + capabilities: [], + listeners: new Set(), + pendingSends: new Map(), + sessions: new Map(), + connected: false, + closed: false, + closedPromise: closed, + resolveConnected, + rejectConnected, + resolveClosed, + rejectClosed, + }; + loaded.providerConnections.set(connectionId, state); + const timeout = setTimeout(() => { + if (state.connected) return; + this.abandonProviderConnect( + loaded, + connectionId, + state, + new Error(`Plugin provider connection timed out: ${pluginId}.${providerId}`), + ); + }, REQUEST_TIMEOUT_MS); + void send(loaded.child, { + type: "provider.connect", + providerId, + connectionId, + request, + }).catch((error) => { + clearTimeout(timeout); + this.abandonProviderConnect(loaded, connectionId, state, error); + }); + return connected.finally(() => clearTimeout(timeout)); + } + getLogs(pluginId: string): PluginLogEntry[] { return ( this.logTails.get(pluginId)?.entries.map((entry) => ({ @@ -345,9 +453,12 @@ export class PluginRuntime { id: pluginId, clientBundle: bundles.clientBundle ?? "", methods: new Set(), + providers: [], child: null, outputCapture: null, pending: new Map(), + providerConnections: new Map(), + providerConnectionTombstones: new Set(), sessionSocket: null, sessionClosed: null, }; @@ -367,51 +478,63 @@ export class PluginRuntime { throw error; }); let loaded: LoadedPlugin | null = null; - let methods: string[]; + let ready: Extract; try { - methods = await new Promise((resolve, reject) => { - let settled = false; - const timeout = setTimeout( - () => fail(new Error(`Plugin ${pluginId} did not initialize`)), - REQUEST_TIMEOUT_MS, - ); - const fail = (error: Error): void => { - if (settled) return; - settled = true; - clearTimeout(timeout); - reject(error); - }; - child.on("message", (message) => { - if (message.type === "paseo_frame") { - sessionSocket.receive(message.data, message.isBinary); - } else if (message.type === "paseo_close") { - sessionSocket.peerClosed(); - } else if (message.type === "ready") { + ready = await new Promise>( + (resolve, reject) => { + let settled = false; + const timeout = setTimeout( + () => fail(new Error(`Plugin ${pluginId} did not initialize`)), + REQUEST_TIMEOUT_MS, + ); + const fail = (error: Error): void => { if (settled) return; settled = true; clearTimeout(timeout); - resolve(message.methods); - } else if (message.type === "fatal") { - fail(new Error(message.error)); - } else if (loaded) { - this.handleChildMessage(loaded, message); - } - }); - child.on("close", () => { - sessionSocket.peerClosed(); - if (!loaded) { - fail(new Error(`Plugin ${pluginId} exited during initialization`)); - return; - } - void this.handleChildClose(loaded); - }); - void send(child, { - type: "initialize", - pluginId, - appVersion: this.daemonVersion, - bundle: serverBundle, - }).catch(fail); - }); + reject(error); + }; + child.on("message", (rawMessage) => { + const parsed = PluginProcessMessageSchema.safeParse(rawMessage); + if (!parsed.success) { + const error = new Error( + `Plugin ${pluginId} sent an invalid message: ${parsed.error.message}`, + ); + if (loaded) this.handleMalformedChildMessage(loaded, rawMessage, error); + else fail(error); + return; + } + const message = parsed.data; + if (message.type === "paseo_frame") { + sessionSocket.receive(message.data, message.isBinary); + } else if (message.type === "paseo_close") { + sessionSocket.peerClosed(); + } else if (message.type === "ready") { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(message); + } else if (message.type === "fatal") { + fail(new Error(message.error)); + } else if (loaded) { + this.handleChildMessage(loaded, message); + } + }); + child.on("close", () => { + sessionSocket.peerClosed(); + if (!loaded) { + fail(new Error(`Plugin ${pluginId} exited during initialization`)); + return; + } + void this.handleChildClose(loaded); + }); + void send(child, { + type: "initialize", + pluginId, + appVersion: this.daemonVersion, + bundle: serverBundle, + }).catch(fail); + }, + ); } catch (error) { sessionSocket.close(); await sessionAttachment.closed; @@ -421,18 +544,28 @@ export class PluginRuntime { loaded = { id: pluginId, clientBundle: bundles.clientBundle ?? "", - methods: new Set(methods), + methods: new Set(ready.methods), + providers: ready.providers ?? [], child, outputCapture, pending, + providerConnections: new Map(), + providerConnectionTombstones: new Set(), sessionSocket, sessionClosed: sessionAttachment.closed, }; - this.logger.info({ pluginId, methods }, "Loaded plugin"); + this.logger.info( + { pluginId, methods: ready.methods, providers: ready.providers }, + "Loaded plugin", + ); return loaded; } private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void { + if (message.type.startsWith("provider.")) { + this.handleProviderMessage(loaded, message); + return; + } if (message.type !== "result" && message.type !== "error") return; const pending = loaded.pending.get(message.requestId); if (!pending) return; @@ -442,6 +575,295 @@ export class PluginRuntime { else pending.reject(new Error(message.error)); } + private handleProviderMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void { + if (!("connectionId" in message)) return; + const state = loaded.providerConnections.get(message.connectionId); + if (!state) { + if (!loaded.providerConnectionTombstones.has(message.connectionId)) return; + if (message.type === "provider.connected" && loaded.child?.connected) { + void send(loaded.child, { + type: "provider.close", + connectionId: message.connectionId, + }).catch(() => undefined); + } + if (message.type === "provider.closed" || message.type === "provider.connect_failed") { + loaded.providerConnectionTombstones.delete(message.connectionId); + } + return; + } + if (message.type === "provider.connected") { + this.finishProviderConnect(loaded, message.connectionId, state, message); + return; + } + if (message.type === "provider.connect_failed") { + this.abandonProviderConnect(loaded, message.connectionId, state, new Error(message.error)); + return; + } + if (message.type === "provider.accepted" || message.type === "provider.rejected") { + const pending = state.pendingSends.get(message.acceptanceId); + if (!pending) return; + state.pendingSends.delete(message.acceptanceId); + if (message.type === "provider.accepted") { + this.trackAcceptedProviderInput(state, pending.input); + pending.resolve(); + } else pending.reject(new Error(message.error)); + return; + } + if (message.type === "provider.event") { + this.publishProviderEvent(loaded, message.connectionId, state, message.event); + return; + } + if (message.type === "provider.closed") { + loaded.providerConnections.delete(message.connectionId); + if (message.error) { + this.failRemoteProviderConnection(state, new Error(message.error)); + return; + } + state.closed = true; + this.rejectProviderSends(state, "Provider connection closed"); + state.resolveClosed(); + } + } + + private finishProviderConnect( + loaded: LoadedPlugin, + connectionId: string, + state: RemoteProviderConnection, + message: Extract, + ): void { + if (!Number.isInteger(message.version) || message.version < 1) { + this.abandonProviderConnect( + loaded, + connectionId, + state, + new Error(`Plugin provider selected invalid version: ${message.version}`), + ); + return; + } + if (!state.request.versions.includes(message.version)) { + this.abandonProviderConnect( + loaded, + connectionId, + state, + new Error(`Plugin provider selected an unoffered version: ${message.version}`), + ); + return; + } + const knownCapabilities = new Set(PROVIDER_CAPABILITIES); + const invalidCapability = message.capabilities.find( + (capability) => + knownCapabilities.has(capability) && !state.request.capabilities.includes(capability), + ); + if (invalidCapability) { + this.abandonProviderConnect( + loaded, + connectionId, + state, + new Error(`Plugin provider selected an unoffered capability: ${invalidCapability}`), + ); + return; + } + const capabilities = PROVIDER_CAPABILITIES.filter((capability) => + message.capabilities.includes(capability), + ); + const connection: ProviderConnection = { + version: message.version, + capabilities, + send: (input) => this.sendProviderInput(loaded, connectionId, state, input), + onEvent(listener) { + state.listeners.add(listener); + return () => state.listeners.delete(listener); + }, + close: () => this.closeProviderConnection(loaded, connectionId, state), + }; + state.capabilities = capabilities; + state.connected = true; + state.resolveConnected(connection); + } + + private abandonProviderConnect( + loaded: LoadedPlugin, + connectionId: string, + state: RemoteProviderConnection, + error: Error, + ): void { + if (state.connected || state.closed) return; + loaded.providerConnections.delete(connectionId); + loaded.providerConnectionTombstones.add(connectionId); + state.closed = true; + state.rejectConnected(error); + state.rejectClosed(error); + if (loaded.child?.connected) { + void send(loaded.child, { type: "provider.close", connectionId }).catch(() => undefined); + } + } + + private sendProviderInput( + loaded: LoadedPlugin, + connectionId: string, + state: RemoteProviderConnection, + input: ProviderInput, + ): Promise { + if (state.closed) return Promise.reject(new Error("Provider connection is closed")); + try { + if (input.type === "session.open" || !("sessionId" in input)) { + requireProviderCapabilities(state.capabilities, input); + } else { + const capabilities = state.sessions.get(input.sessionId); + if (!capabilities) throw new Error(`Unknown provider session: ${input.sessionId}`); + requireProviderCapabilities(capabilities, input); + } + } catch (error) { + return Promise.reject(error); + } + const child = loaded.child; + if (!child) return Promise.reject(new Error(`Plugin has no server entry: ${loaded.id}`)); + const acceptanceId = randomUUID(); + return new Promise((resolve, reject) => { + state.pendingSends.set(acceptanceId, { input, resolve, reject }); + void send(child, { + type: "provider.send", + connectionId, + acceptanceId, + input: jsonTransportValue(input), + }).catch((error) => { + state.pendingSends.delete(acceptanceId); + reject(error); + }); + }); + } + + private async closeProviderConnection( + loaded: LoadedPlugin, + connectionId: string, + state: RemoteProviderConnection, + ): Promise { + if (state.closed) return; + const child = loaded.child; + if (!child) throw new Error(`Plugin has no server entry: ${loaded.id}`); + await send(child, { type: "provider.close", connectionId }); + await state.closedPromise; + } + + private publishProviderEvent( + loaded: LoadedPlugin, + connectionId: string, + state: RemoteProviderConnection, + event: ProviderEvent, + ): void { + if ( + event.type === "timeline.item" && + event.item.type === "plugin" && + event.item.pluginId !== loaded.id + ) { + this.failProviderConnection( + loaded, + connectionId, + state, + new Error( + `Provider plugin ${loaded.id} cannot emit timeline items for ${event.item.pluginId}`, + ), + ); + return; + } + if (event.type === "session.opened") { + const knownCapabilities = new Set(PROVIDER_CAPABILITIES); + const invalidCapability = event.capabilities.find( + (capability) => + knownCapabilities.has(capability) && !state.capabilities.includes(capability), + ); + if (invalidCapability) { + this.failProviderConnection( + loaded, + connectionId, + state, + new Error(`Provider session selected an unoffered capability: ${invalidCapability}`), + ); + return; + } + const selectedCapabilities = event.capabilities; + event = { + ...event, + capabilities: PROVIDER_CAPABILITIES.filter((capability) => + selectedCapabilities.includes(capability), + ), + }; + if ( + event.parentSessionId && + !state.sessions.get(event.parentSessionId)?.includes("session.subsession") + ) { + this.failProviderConnection( + loaded, + connectionId, + state, + new Error(`Provider parent session did not negotiate session.subsession`), + ); + return; + } + } + if (event.type === "session.opened") { + state.sessions.set(event.sessionId, event.capabilities); + } + for (const listener of state.listeners) listener(event); + if (event.type === "session.closed") state.sessions.delete(event.sessionId); + } + + private failProviderConnection( + loaded: LoadedPlugin, + connectionId: string, + state: RemoteProviderConnection, + error: Error, + ): void { + loaded.providerConnections.delete(connectionId); + this.failRemoteProviderConnection(state, error); + if (loaded.child?.connected) { + void send(loaded.child, { type: "provider.close", connectionId }).catch(() => undefined); + } + } + + private trackAcceptedProviderInput(state: RemoteProviderConnection, input: ProviderInput): void { + if (input.type === "session.open" && !state.sessions.has(input.sessionId)) { + state.sessions.set(input.sessionId, []); + } + if (input.type === "session.close") state.sessions.delete(input.sessionId); + } + + private handleMalformedChildMessage( + loaded: LoadedPlugin, + rawMessage: unknown, + error: Error, + ): void { + const connectionId = readConnectionId(rawMessage); + const state = connectionId ? loaded.providerConnections.get(connectionId) : undefined; + if (!connectionId || !state) { + this.appendLog(loaded.id, "stderr", `[paseo] ${error.message}`); + terminatePluginChild(loaded.child!); + return; + } + this.failProviderConnection(loaded, connectionId, state, error); + } + + private failRemoteProviderConnection(state: RemoteProviderConnection, error: Error): void { + if (state.closed) return; + for (const sessionId of state.sessions.keys()) { + const event: ProviderEvent = { + type: "session.runtime_failed", + sessionId, + error: { message: error.message }, + }; + for (const listener of state.listeners) listener(event); + } + state.closed = true; + state.rejectConnected(error); + this.rejectProviderSends(state, error.message); + state.rejectClosed(error); + } + + private rejectProviderSends(state: RemoteProviderConnection, message: string): void { + for (const pending of state.pendingSends.values()) pending.reject(new Error(message)); + state.pendingSends.clear(); + } + private async handleChildClose(loaded: LoadedPlugin): Promise { loaded.sessionSocket?.peerClosed(); const wasPublished = this.plugins.get(loaded.id) === loaded; @@ -449,12 +871,25 @@ export class PluginRuntime { this.plugins.delete(loaded.id); } this.rejectPending(loaded, `Plugin process exited: ${loaded.id}`); + for (const state of loaded.providerConnections.values()) { + this.failRemoteProviderConnection(state, new Error(`Plugin process exited: ${loaded.id}`)); + } + loaded.providerConnections.clear(); await loaded.sessionClosed; if (wasPublished) this.notify(loaded.id, `Plugin process exited: ${loaded.id}`); } private async stopPlugin(loaded: LoadedPlugin): Promise { this.appendLog(loaded.id, "stdout", "[paseo] Stopping plugin"); + for (const [connectionId, state] of loaded.providerConnections) { + if (state.connected) continue; + this.abandonProviderConnect( + loaded, + connectionId, + state, + new Error(`Plugin stopped: ${loaded.id}`), + ); + } const { child, sessionSocket, sessionClosed } = loaded; if (!child || !sessionSocket || !sessionClosed) { this.appendLog(loaded.id, "stdout", "[paseo] Plugin stopped"); @@ -474,7 +909,15 @@ export class PluginRuntime { if (child.connected) { await send(child, { type: "shutdown" }).catch(() => undefined); } - await closed; + let forceTimer: ReturnType | null = setTimeout(() => { + if (!child.killed) child.kill("SIGTERM"); + forceTimer = setTimeout(() => { + child.kill("SIGKILL"); + }, SOFT_SHUTDOWN_TIMEOUT_MS); + }, SOFT_SHUTDOWN_TIMEOUT_MS); + await closed.finally(() => { + if (forceTimer) clearTimeout(forceTimer); + }); sessionSocket.peerClosed(); await sessionClosed; this.appendLog(loaded.id, "stdout", "[paseo] Plugin stopped"); @@ -515,3 +958,9 @@ export class PluginRuntime { for (const listener of this.listeners) listener(pluginId, error); } } + +function readConnectionId(value: unknown): string | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const connectionId = Reflect.get(value, "connectionId"); + return typeof connectionId === "string" ? connectionId : null; +} diff --git a/plugin-examples/provider-acp-transformer/README.md b/plugin-examples/provider-acp-transformer/README.md new file mode 100644 index 0000000000..50c54e37c8 --- /dev/null +++ b/plugin-examples/provider-acp-transformer/README.md @@ -0,0 +1,11 @@ +# ACP provider transformer example + +This server plugin registers an ACP command through `runAcpProvider()`. The helper owns ACP process +and SDK details behind Paseo's callback-based provider connection. + +`server/vendor-edit.ts` shows the intended extension seam: a focused transformer converts one +vendor tool payload into Paseo's ordinary file-edit shape. It does not translate the whole event +stream. + +Replace the placeholder `example-acp --stdio` command with an installed ACP agent before enabling +the plugin. diff --git a/plugin-examples/provider-acp-transformer/icon.svg b/plugin-examples/provider-acp-transformer/icon.svg new file mode 100644 index 0000000000..841eef3aee --- /dev/null +++ b/plugin-examples/provider-acp-transformer/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plugin-examples/provider-acp-transformer/index.server.ts b/plugin-examples/provider-acp-transformer/index.server.ts new file mode 100644 index 0000000000..5b85f30fa5 --- /dev/null +++ b/plugin-examples/provider-acp-transformer/index.server.ts @@ -0,0 +1,17 @@ +import type { PluginServerContext } from "@getpaseo/plugin"; +import { runAcpProvider } from "@getpaseo/plugin/acp"; +import { vendorEditTransformer } from "./server/vendor-edit.js"; + +export default function contribute(server: PluginServerContext) { + server.registerProvider( + runAcpProvider({ + id: "example-acp", + label: "Example ACP", + description: "An ACP command adapted to Paseo's provider boundary", + icon: "icon.svg", + command: ["example-acp", "--stdio"], + transformers: [vendorEditTransformer], + }), + ); + return () => {}; +} diff --git a/plugin-examples/provider-acp-transformer/paseo-plugin.json b/plugin-examples/provider-acp-transformer/paseo-plugin.json new file mode 100644 index 0000000000..9ef350ba02 --- /dev/null +++ b/plugin-examples/provider-acp-transformer/paseo-plugin.json @@ -0,0 +1,6 @@ +{ + "id": "provider-acp-transformer-example", + "name": "ACP provider transformer example", + "version": "1.0.0", + "description": "Wraps an ACP command and normalizes one vendor file-edit shape." +} diff --git a/plugin-examples/provider-acp-transformer/server/vendor-edit.test.ts b/plugin-examples/provider-acp-transformer/server/vendor-edit.test.ts new file mode 100644 index 0000000000..e570e94974 --- /dev/null +++ b/plugin-examples/provider-acp-transformer/server/vendor-edit.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { vendorEditTransformer } from "./vendor-edit.js"; + +describe("vendorEditTransformer", () => { + it("normalizes only the vendor edit payload", () => { + const transformed = vendorEditTransformer.toolCall?.( + { + id: "edit-1", + name: "vendor_file_edit", + title: "Edit file", + status: "completed", + input: { path: "src/app.ts", before: "old", after: "new" }, + output: null, + locations: [], + }, + { sessionId: "session-1" }, + ); + + expect(transformed).toMatchObject({ + kind: "edit", + input: { filePath: "src/app.ts", oldString: "old", newString: "new" }, + }); + }); + + it("preserves a vendor edit with a malformed payload", () => { + const toolCall = { + id: "edit-1", + name: "vendor_file_edit", + title: "Edit file", + status: "completed" as const, + input: { path: "src/app.ts", before: "old" }, + output: null, + locations: [], + }; + + const transformed = vendorEditTransformer.toolCall?.(toolCall, { + sessionId: "session-1", + }); + + expect(transformed).toBe(toolCall); + }); +}); diff --git a/plugin-examples/provider-acp-transformer/server/vendor-edit.ts b/plugin-examples/provider-acp-transformer/server/vendor-edit.ts new file mode 100644 index 0000000000..58908678af --- /dev/null +++ b/plugin-examples/provider-acp-transformer/server/vendor-edit.ts @@ -0,0 +1,25 @@ +import type { AcpTransformer } from "@getpaseo/plugin/acp"; +import { z } from "zod"; + +const vendorEditInputSchema = z.object({ + path: z.string(), + before: z.string(), + after: z.string(), +}); + +export const vendorEditTransformer: AcpTransformer = { + toolCall(toolCall) { + if (toolCall.name !== "vendor_file_edit") return toolCall; + const input = vendorEditInputSchema.safeParse(toolCall.input); + if (!input.success) return toolCall; + return { + ...toolCall, + kind: "edit", + input: { + filePath: input.data.path, + oldString: input.data.before, + newString: input.data.after, + }, + }; + }, +}; diff --git a/plugin-examples/provider-direct/README.md b/plugin-examples/provider-direct/README.md new file mode 100644 index 0000000000..8bd5921330 --- /dev/null +++ b/plugin-examples/provider-direct/README.md @@ -0,0 +1,12 @@ +# Direct provider example + +This plugin implements `ProviderRegistration` directly. It demonstrates connection negotiation, +catalog and session listing, session creation, prompt and command disposition, settings, +persistence and replay, archive actions, and an ordinary child session. + +The provider emits versioned `provider-result` timeline items for its root and child sessions. +`index.client.tsx` registers the Zod schema and React Native renderer for those items. The server +and client share only the item kind and schema under `shared/`. + +The provider receives Paseo host tools through `config.mcpServers`; it does not receive a second +callback tool API. diff --git a/plugin-examples/provider-direct/client/provider-result.tsx b/plugin-examples/provider-direct/client/provider-result.tsx new file mode 100644 index 0000000000..b7d2745f37 --- /dev/null +++ b/plugin-examples/provider-direct/client/provider-result.tsx @@ -0,0 +1,32 @@ +import type { PluginTimelineItemProps } from "@getpaseo/plugin"; +import { useMemo } from "react"; +import { Text, View } from "react-native"; +import type { z } from "zod"; +import { providerResultSchema } from "../shared/provider-result"; + +type ProviderResultData = z.output; + +export function ProviderResult({ item, theme }: PluginTimelineItemProps) { + const styles = useMemo( + () => ({ + card: { + gap: 4, + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: 10, + padding: 12, + backgroundColor: theme.colors.surface1, + }, + label: { color: theme.colors.accent, fontWeight: "600" as const }, + detail: { color: theme.colors.foreground }, + }), + [theme], + ); + + return ( + + {item.data.label} + {item.data.detail} + + ); +} diff --git a/plugin-examples/provider-direct/icon.svg b/plugin-examples/provider-direct/icon.svg new file mode 100644 index 0000000000..0da745c896 --- /dev/null +++ b/plugin-examples/provider-direct/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plugin-examples/provider-direct/index.client.tsx b/plugin-examples/provider-direct/index.client.tsx new file mode 100644 index 0000000000..7954fdecb2 --- /dev/null +++ b/plugin-examples/provider-direct/index.client.tsx @@ -0,0 +1,13 @@ +import type { PluginClientContext } from "@getpaseo/plugin"; +import { ProviderResult } from "./client/provider-result"; +import { providerResultKind, providerResultSchema } from "./shared/provider-result"; + +export default function contribute(client: PluginClientContext) { + client.addTimelineRenderer({ + kind: providerResultKind, + version: 1, + schema: providerResultSchema, + Component: ProviderResult, + }); + return () => {}; +} diff --git a/plugin-examples/provider-direct/index.server.ts b/plugin-examples/provider-direct/index.server.ts new file mode 100644 index 0000000000..61a094391d --- /dev/null +++ b/plugin-examples/provider-direct/index.server.ts @@ -0,0 +1,7 @@ +import type { PluginServerContext } from "@getpaseo/plugin"; +import { createDirectExampleProvider } from "./server/provider"; + +export default function contribute(server: PluginServerContext) { + server.registerProvider(createDirectExampleProvider()); + return () => {}; +} diff --git a/plugin-examples/provider-direct/paseo-plugin.json b/plugin-examples/provider-direct/paseo-plugin.json new file mode 100644 index 0000000000..e9af46c731 --- /dev/null +++ b/plugin-examples/provider-direct/paseo-plugin.json @@ -0,0 +1,3 @@ +{ + "id": "provider-direct-example" +} diff --git a/plugin-examples/provider-direct/server/provider.test.ts b/plugin-examples/provider-direct/server/provider.test.ts new file mode 100644 index 0000000000..ce989f4b4f --- /dev/null +++ b/plugin-examples/provider-direct/server/provider.test.ts @@ -0,0 +1,169 @@ +import type { ProviderEvent } from "@getpaseo/plugin/provider"; +import { describe, expect, it } from "vitest"; +import { createDirectExampleProvider } from "./provider.js"; + +describe("direct provider example", () => { + it("fails steering when no turn is active without creating a turn", async () => { + const connection = await createDirectExampleProvider().connect({ + versions: [1], + capabilities: ["prompt.command", "prompt.message", "prompt.steer"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send({ + type: "session.open", + requestId: "open-1", + sessionId: "session-1", + config: { cwd: "/repo", env: {}, mcpServers: {}, settings: {}, persist: false }, + history: "skip", + }); + const turnEventsBefore = events.filter((event) => event.type === "session.turn").length; + + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "steer-1", + delivery: "steer", + input: { type: "command", name: "reset", arguments: "" }, + }, + }); + + expect(events).toContainEqual({ + type: "session.prompt_result", + sessionId: "session-1", + clientMessageId: "steer-1", + result: { type: "failed", error: { message: "There is no active turn to steer" } }, + }); + expect(events.filter((event) => event.type === "session.turn")).toHaveLength(turnEventsBefore); + expect(events).not.toContainEqual(expect.objectContaining({ type: "session.notice" })); + await connection.close(); + }); + + it("demonstrates settings, commands, persistence, and provider-rendered timeline items", async () => { + const connection = await createDirectExampleProvider().connect({ + versions: [1], + capabilities: [ + "prompt.message", + "prompt.command", + "session.configure", + "session.persistence", + "session.subsession", + "timeline.plugin", + ], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send({ + type: "session.open", + requestId: "open-complete", + sessionId: "session-complete", + config: { + cwd: "/repo", + env: { EXAMPLE: "1" }, + systemPrompt: "Be useful", + mcpServers: {}, + model: "example-1", + mode: "build", + settings: { concise: true, voice: "calm" }, + providerOptions: { privateValue: 1 }, + persist: true, + }, + history: "skip", + }); + expect(events).toContainEqual( + expect.objectContaining({ + type: "session.config", + config: expect.objectContaining({ + settings: expect.arrayContaining([ + expect.objectContaining({ id: "concise", value: true }), + expect.objectContaining({ id: "voice", value: "calm" }), + ]), + }), + }), + ); + expect(events).toContainEqual(expect.objectContaining({ type: "session.commands" })); + + await connection.send({ + type: "session.configure", + requestId: "configure-complete", + sessionId: "session-complete", + changes: { settings: { voice: "direct" } }, + }); + await connection.send({ + type: "session.prompt", + sessionId: "session-complete", + prompt: { + clientMessageId: "command-1", + delivery: "auto", + input: { type: "command", name: "reset", arguments: "" }, + }, + }); + await connection.send({ + type: "session.prompt", + sessionId: "session-complete", + prompt: { + clientMessageId: "message-1", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "hello" }] }, + }, + }); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "session.prompt_result", + clientMessageId: "command-1", + result: { type: "completed" }, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline.item", + item: expect.objectContaining({ type: "user_message", clientMessageId: "message-1" }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline.item", + sessionId: "session-complete", + item: expect.objectContaining({ type: "plugin", kind: "provider-result" }), + }), + ); + const child = events.find( + (event): event is Extract => + event.type === "session.opened" && event.parentSessionId === "session-complete", + ); + expect(child?.sessionId).toMatch(/^[0-9a-f-]{36}$/u); + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline.item", + sessionId: child?.sessionId, + item: expect.objectContaining({ type: "plugin", kind: "provider-result" }), + }), + ); + expect(events).toContainEqual(expect.objectContaining({ type: "session.persistence" })); + await connection.close(); + }); + + it("rejects calls after the example connection closes", async () => { + const connection = await createDirectExampleProvider().connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + + await connection.close(); + + await expect( + connection.send({ type: "catalog", requestId: "catalog-after-close" }), + ).rejects.toThrow("Provider connection is closed"); + await expect( + connection.send({ + type: "session.open", + requestId: "open-after-close", + sessionId: "closed-session", + config: { cwd: "/repo", env: {}, mcpServers: {}, settings: {}, persist: false }, + history: "skip", + }), + ).rejects.toThrow("Provider connection is closed"); + }); +}); diff --git a/plugin-examples/provider-direct/server/provider.ts b/plugin-examples/provider-direct/server/provider.ts new file mode 100644 index 0000000000..6a8bc957cc --- /dev/null +++ b/plugin-examples/provider-direct/server/provider.ts @@ -0,0 +1,401 @@ +import { + negotiateProviderCapabilities, + requireProviderCapabilities, + type ProviderConfigState, + type ProviderConnection, + type ProviderEvent, + type ProviderInput, + type ProviderPersistence, + type ProviderRegistration, + type ProviderSessionConfig, +} from "@getpaseo/plugin/provider"; +import { randomUUID } from "node:crypto"; +import { providerResultKind } from "../shared/provider-result.js"; + +const CAPABILITIES = [ + "prompt.message", + "prompt.command", + "prompt.steer", + "session.archive", + "session.configure", + "session.list", + "session.persistence", + "session.subsession", + "session.unarchive", + "timeline.plugin", +] as const; + +interface ExampleSession { + config: ProviderSessionConfig; + persistence: ProviderPersistence; + turn: number; + activeTurnId: string | null; +} + +export function createDirectExampleProvider(): ProviderRegistration { + return { + id: "direct-example", + label: "Direct provider example", + description: "A complete provider implemented directly against Paseo's provider boundary", + icon: "icon.svg", + async connect(request) { + if (!request.versions.includes(1)) throw new Error("Provider protocol version 1 is required"); + return createConnection(negotiateProviderCapabilities(request.capabilities, CAPABILITIES)); + }, + }; +} + +function createConnection(capabilities: readonly string[]): ProviderConnection { + const listeners = new Set<(event: ProviderEvent) => void>(); + const sessions = new Map(); + const archived = new Set(); + let closed = false; + const emit = (event: ProviderEvent) => { + if (closed) return; + for (const listener of listeners) listener(event); + }; + + return { + version: 1, + capabilities, + async send(input) { + if (closed) throw new Error("Provider connection is closed"); + validateAdmission(input, { sessions, capabilities }); + queueMicrotask(() => { + if (!closed) dispatch(input, { sessions, archived, emit, capabilities }); + }); + }, + onEvent(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + async close() { + if (closed) return; + closed = true; + sessions.clear(); + listeners.clear(); + }, + }; +} + +function validateAdmission( + input: ProviderInput, + state: Pick, +): void { + if (input.type === "session.open") { + if (state.sessions.has(input.sessionId)) { + throw new Error(`Session already exists: ${input.sessionId}`); + } + requireProviderCapabilities(state.capabilities, input); + return; + } + if (!("sessionId" in input)) { + requireProviderCapabilities(state.capabilities, input); + return; + } + const session = state.sessions.get(input.sessionId); + if (!session) throw new Error(`Unknown session: ${input.sessionId}`); + requireProviderCapabilities(state.capabilities, input); +} + +interface ExampleState { + sessions: Map; + archived: Set; + emit(event: ProviderEvent): void; + capabilities: readonly string[]; +} + +function dispatch(input: ProviderInput, state: ExampleState): void { + switch (input.type) { + case "catalog": + state.emit({ + type: "catalog", + requestId: input.requestId, + catalog: { + models: [{ id: "example-1", label: "Example 1" }], + modes: [{ id: "build", label: "Build" }], + defaultModel: "example-1", + defaultMode: "build", + }, + }); + return; + case "sessions": + state.emit({ + type: "sessions", + requestId: input.requestId, + sessions: [...state.sessions.values()].map((session) => ({ + persistence: session.persistence, + cwd: session.config.cwd, + title: session.config.title, + })), + }); + return; + case "session.open": + openSession(input, state); + return; + case "session.prompt": + promptSession(input, state); + return; + case "session.configure": + configureSession(input, state); + return; + case "session.interrupt": + state.emit({ type: "request.completed", requestId: input.requestId }); + return; + case "session.permission": + state.emit({ + type: "session.permission_resolved", + sessionId: input.sessionId, + permissionId: input.permissionId, + }); + return; + case "session.revert": + state.emit({ type: "request.completed", requestId: input.requestId }); + return; + case "session.archive": + state.archived.add(persistenceKey(input.persistence)); + state.emit({ type: "request.completed", requestId: input.requestId }); + return; + case "session.unarchive": + state.archived.delete(persistenceKey(input.persistence)); + state.emit({ type: "request.completed", requestId: input.requestId }); + return; + case "session.close": + state.sessions.delete(input.sessionId); + state.emit({ type: "session.closed", sessionId: input.sessionId }); + state.emit({ type: "request.completed", requestId: input.requestId }); + } +} + +function openSession( + input: Extract, + state: ExampleState, +): void { + const persistence = input.persistence ?? { + version: 1, + data: { nativeSessionId: `example:${input.sessionId}` }, + }; + const session: ExampleSession = { + config: input.config, + persistence, + turn: 0, + activeTurnId: null, + }; + state.sessions.set(input.sessionId, session); + state.emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities: state.capabilities, + restoration: "core", + persistence, + title: input.config.title, + cwd: input.config.cwd, + }); + state.emit({ type: "session.config", sessionId: input.sessionId, config: configState(session) }); + state.emit({ + type: "session.commands", + sessionId: input.sessionId, + commands: [{ name: "reset", description: "Reset the example provider state" }], + }); + if (input.history === "replay" && input.persistence) { + state.emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { type: "assistant_message", id: "replayed-1", text: "Restored from persistence" }, + }); + } + state.emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); +} + +function promptSession( + input: Extract, + state: ExampleState, +): void { + const session = requireSession(state, input.sessionId); + if (input.prompt.delivery === "steer") { + if (!session.activeTurnId) { + state.emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "failed", error: { message: "There is no active turn to steer" } }, + }); + return; + } + state.emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "steer", turnId: session.activeTurnId }, + }); + return; + } + + if (input.prompt.input.type === "command") { + if (input.prompt.input.name !== "reset") { + state.emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "failed", error: { message: "Unknown command" } }, + }); + return; + } + session.turn = 0; + state.emit({ + type: "session.notice", + sessionId: input.sessionId, + notice: { id: "reset", severity: "info", title: "Example state reset" }, + }); + state.emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "completed" }, + }); + return; + } + + session.turn += 1; + const turnId = `turn-${session.turn}`; + session.activeTurnId = turnId; + const text = input.prompt.input.content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"); + state.emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { + type: "user_message", + id: `user-${session.turn}`, + text, + clientMessageId: input.prompt.clientMessageId, + }, + }); + state.emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "turn", turnId }, + }); + state.emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "started" }); + state.emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { type: "assistant_message", id: `assistant-${session.turn}`, text: `Echo: ${text}` }, + }); + state.emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { + type: "plugin", + id: `provider-result-${session.turn}`, + pluginId: "provider-direct-example", + kind: providerResultKind, + version: 1, + data: { label: "Provider result", detail: `Echoed turn ${session.turn}` }, + }, + }); + publishChild(input.sessionId, session.turn, state); + session.persistence = { + version: 1, + data: { nativeSessionId: `example:${input.sessionId}`, turns: session.turn }, + }; + state.emit({ + type: "session.persistence", + sessionId: input.sessionId, + persistence: session.persistence, + }); + state.emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "completed" }); + session.activeTurnId = null; +} + +function publishChild(parentSessionId: string, turn: number, state: ExampleState): void { + const sessionId = randomUUID(); + state.emit({ + type: "session.opened", + sessionId, + parentSessionId, + capabilities: [], + restoration: "parent", + title: "Example child", + cwd: requireSession(state, parentSessionId).config.cwd, + }); + state.emit({ + type: "timeline.item", + sessionId, + item: { + type: "plugin", + id: `child-result-${turn}`, + pluginId: "provider-direct-example", + kind: providerResultKind, + version: 1, + data: { label: "Child result", detail: "Completed" }, + }, + }); + state.emit({ type: "session.ready", sessionId }); +} + +function configureSession( + input: Extract, + state: ExampleState, +): void { + const session = requireSession(state, input.sessionId); + const changes = input.changes; + session.config = { + ...session.config, + model: changes.model === null ? undefined : (changes.model ?? session.config.model), + mode: changes.mode === null ? undefined : (changes.mode ?? session.config.mode), + thinkingOption: + changes.thinkingOption === null + ? undefined + : (changes.thinkingOption ?? session.config.thinkingOption), + settings: changes.settings + ? { ...session.config.settings, ...changes.settings } + : session.config.settings, + }; + state.emit({ type: "session.config", sessionId: input.sessionId, config: configState(session) }); + state.emit({ type: "request.completed", requestId: input.requestId }); +} + +function configState(session: ExampleSession): ProviderConfigState { + return { + model: session.config.model ?? "example-1", + mode: session.config.mode ?? "build", + thinkingOption: session.config.thinkingOption, + models: [{ id: "example-1", label: "Example 1" }], + modes: [{ id: "build", label: "Build" }], + thinkingOptions: [], + settings: [ + { + type: "toggle", + id: "concise", + label: "Concise responses", + value: session.config.settings.concise === true, + }, + { + type: "select", + id: "voice", + label: "Voice", + value: + typeof session.config.settings.voice === "string" ? session.config.settings.voice : null, + options: [ + { label: "Calm", value: "calm" }, + { label: "Direct", value: "direct" }, + ], + }, + ], + }; +} + +function requireSession(state: ExampleState, sessionId: string): ExampleSession { + const session = state.sessions.get(sessionId); + if (!session) throw new Error(`Unknown session: ${sessionId}`); + return session; +} + +function persistenceKey(persistence: ProviderPersistence): string { + return JSON.stringify(persistence); +} diff --git a/plugin-examples/provider-direct/shared/provider-result.ts b/plugin-examples/provider-direct/shared/provider-result.ts new file mode 100644 index 0000000000..40ba0324bb --- /dev/null +++ b/plugin-examples/provider-direct/shared/provider-result.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const providerResultKind = "provider-result"; + +export const providerResultSchema = z.object({ + label: z.string(), + detail: z.string(), +}); diff --git a/public-docs/plugins/v0.8/index.md b/public-docs/plugins/v0.8/index.md index fe0915f0c2..7623a8b476 100644 --- a/public-docs/plugins/v0.8/index.md +++ b/public-docs/plugins/v0.8/index.md @@ -24,7 +24,8 @@ A plugin is a TypeScript project installed into one Paseo daemon. It can add [timeline items](/docs/plugins/v0.8/reference#timeline-items), [themes](/docs/plugins/v0.8/reference#contribute-a-theme), [attachment sources](/docs/plugins/v0.8/reference#add-a-composer-attachment-source), and -[daemon-side RPCs](/docs/plugins/v0.8/reference#add-plugin-specific-backend-behavior). Client +[daemon-side RPCs](/docs/plugins/v0.8/reference#add-plugin-specific-backend-behavior). It can also +[connect a coding agent as a provider](/docs/plugins/v0.8/providers). Client contributions run on every Paseo client connected to that daemon, including mobile. This guide scaffolds a plugin, runs it, and adds a workspace panel to it. @@ -265,6 +266,8 @@ log. ## Next +- [Build a provider plugin](/docs/plugins/v0.8/providers): connect an agent directly or through ACP, + render provider-owned timeline items, test it, and publish it. - [Plugin reference](/docs/plugins/v0.8/reference): every contribution type, its fields, the runtime modules, hosts, and the CLI. - [Migrate a plugin to runtime entries](/docs/plugins/v0.8/migration): move a plugin written against the diff --git a/public-docs/plugins/v0.8/providers.md b/public-docs/plugins/v0.8/providers.md new file mode 100644 index 0000000000..8b488def40 --- /dev/null +++ b/public-docs/plugins/v0.8/providers.md @@ -0,0 +1,407 @@ +--- +title: Build a provider plugin +description: Add a coding agent to Paseo directly or adapt an ACP agent. +nav: Provider plugins +order: 47 +category: Plugins +--- + +# Build a provider plugin + +> **For the upcoming Paseo v0.8 release.** Start with the +> [plugin quickstart](/docs/plugins/v0.8) if you have not built a Paseo plugin before. + +A provider plugin connects a coding agent to Paseo without adding it to Paseo core. Publish the +plugin in a Git repository and users can install and update it with `paseo plugin add` and +`paseo plugin update`. + +Choose one implementation path: + +| Agent | Path | +| ----------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| It already implements ACP | Register it with `runAcpProvider()` and add only narrow vendor transformers. | +| It has a TypeScript SDK, JSON-RPC API, or custom process protocol | Implement `ProviderRegistration` directly. | + +The complete examples are: + +- [`provider-direct`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-direct): sessions, settings, prompts, persistence, child sessions, and a provider-owned timeline renderer; +- [`provider-acp-transformer`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-acp-transformer): an ACP command with a Zod-validated vendor edit transformer; +- [`inline-thinking`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/inline-thinking): a renderer-only plugin that does not implement a provider. + +## Register a direct provider + +Add a server entry: + +```ts +// index.server.ts +import type { PluginServerContext } from "@getpaseo/plugin"; +import { createProvider } from "./server/provider"; + +export default function contribute(server: PluginServerContext) { + server.registerProvider(createProvider()); + return () => {}; +} +``` + +`createProvider()` returns one `ProviderRegistration`: + +```ts +import { + negotiateProviderCapabilities, + type ProviderRegistration, +} from "@getpaseo/plugin/provider"; + +const supported = ["prompt.message"] as const; + +export function createProvider(): ProviderRegistration { + return { + id: "my-agent", + label: "My agent", + icon: "icon.svg", + async connect(request) { + const capabilities = negotiateProviderCapabilities(request.capabilities, supported); + return createConnection(capabilities); + }, + }; +} +``` + +The connection has three operations: + +- `send(input)` accepts work and returns. Results are events, not return values. +- `onEvent(listener)` publishes session state and complete timeline snapshots. +- `close()` stops every session and releases processes, subscriptions, and pending work. + +Keep the native SDK, process, and stream inside the connection implementation. Convert its output +to `ProviderEvent` objects before publishing it. + +## Return models, modes, and thinking options + +Paseo requests the catalog before creating a session. Return the choices needed by the agent form: + +```ts +if (input.type === "catalog") { + emit({ + type: "catalog", + requestId: input.requestId, + catalog: { + models: [ + { id: "agent-large", label: "Agent Large" }, + { id: "agent-small", label: "Agent Small" }, + ], + modes: [ + { id: "build", label: "Build" }, + { id: "plan", label: "Plan" }, + ], + thinkingOptions: [ + { id: "standard", label: "Standard" }, + { id: "extended", label: "Extended" }, + ], + defaultModel: "agent-large", + defaultMode: "build", + defaultThinkingOption: "standard", + }, + }); +} +``` + +Return an empty array for a category the agent does not expose. The selected `model`, `mode`, and +`thinkingOption` arrive in the session configuration. + +The catalog describes choices available before a session exists. Session-specific controls come +later through `session.config`. + +## Open a session + +`session.open` contains the complete launch configuration: working directory, environment, system +prompt, MCP servers, tool policy, model, mode, settings, opaque provider options, and persistence +preference. + +Create the native session with the selected catalog values, then publish its effective state: + +```ts +const nativeSession = await sdk.createSession({ + cwd: input.config.cwd, + model: input.config.model, + mode: input.config.mode, + thinking: input.config.thinkingOption, + systemPrompt: input.config.systemPrompt, + env: input.config.env, + mcpServers: input.config.mcpServers, +}); + +emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities: ["prompt.message"], + restoration: "core", + cwd: input.config.cwd, +}); +emit({ + type: "session.config", + sessionId: input.sessionId, + config: { + model: nativeSession.model, + mode: nativeSession.mode, + thinkingOption: nativeSession.thinking, + models, + modes, + thinkingOptions, + settings: [], + }, +}); +emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); +``` + +`session.config` contains the committed values. Publish what the native agent selected after +normalization, not a copy of the request. + +## Complete the first prompt + +Start with message prompts. Advertise `prompt.message`, pass the content to the native agent, and +publish the resulting lifecycle: + +```ts +const turnId = crypto.randomUUID(); + +emit({ + type: "timeline.item", + sessionId, + item: { + type: "user_message", + id: `user:${turnId}`, + text, + clientMessageId: prompt.clientMessageId, + }, +}); +emit({ + type: "session.prompt_result", + sessionId, + clientMessageId: prompt.clientMessageId, + result: { type: "turn", turnId }, +}); +emit({ type: "session.turn", sessionId, turnId, state: "started" }); + +// Publish complete snapshots as native output changes. +emit({ + type: "timeline.item", + sessionId, + item: { type: "assistant_message", id: `assistant:${turnId}`, text: completeText }, +}); + +emit({ type: "session.turn", sessionId, turnId, state: "completed" }); +``` + +Publish exactly one `session.prompt_result` for each `clientMessageId`. Copy `clientMessageId` onto +the user timeline item so Paseo replaces its optimistic message. Every started turn needs one +terminal `completed`, `failed`, or `canceled` event. + +At this point the provider is usable: users can select a model, open a session, send a message, and +see the response. + +## Add session-specific composer controls + +Add `session.configure` only when the native agent has settings users should change after opening a +session. Publish them in `session.config` as toggle or select controls: + +```ts +emit({ + type: "session.config", + sessionId, + config: { + model: "agent-large", + models: [{ id: "agent-large", label: "Agent Large" }], + modes: [], + thinkingOptions: [], + settings: [ + { + type: "toggle", + id: "fast", + label: "Fast mode", + value: nativeSession.fast, + }, + { + type: "select", + id: "approval", + label: "Approvals", + value: nativeSession.approval, + options: [ + { label: "Ask", value: "ask" }, + { label: "Auto accept", value: "auto" }, + ], + }, + ], + }, +}); +``` + +Paseo renders these controls in the composer. A user change arrives as `session.configure`; apply it +and publish the committed `session.config` before `request.completed`. The provider may change the +available controls at runtime, including after a model change. + +`settings` are user-facing controls. `providerOptions` is opaque JSON supplied at session creation +for provider-specific configuration that Paseo does not render. A provider-specific token budget +belongs in one of those two places; it is not a dedicated prompt field. + +## Add persistence and replay + +Advertise `session.persistence` when a native session can be reopened. Open a new native session +when `session.open.persistence` is absent. Resume the identified native session when it is present. +Return an opaque persistence value in `session.opened` or `session.persistence`; Paseo stores it +without inspecting it. + +When `history` is `"replay"`, publish the native session's existing `timeline.item` snapshots before +`session.ready`. Use `history: "skip"` to open without replaying old rows. + +Paseo refreshes an agent by closing its current provider session and opening it again with current +configuration and persistence. Re-read credentials, environment, global configuration, and MCP +servers during `session.open`. There is no separate reload operation. + +Use `restoration: "core"` when Paseo can reopen a session from persistence. A provider-owned child +uses `restoration: "parent"`; emit it as another `session.opened` event with `parentSessionId` and +recreate it while restoring the parent. + +## Add commands and steering + +All user input arrives through `session.prompt`: + +- `input.type: "message"` carries text, images, and attachments; +- `input.type: "command"` carries a selected command and its arguments; +- `delivery: "steer"` asks you to add input to the active turn; +- `delivery: "auto"` lets you start a turn or complete a command without one. + +Publish `session.commands` to add structured commands to the composer. A command can start a normal +turn or finish as a side effect with a `completed` prompt result. + +Advertise only capabilities you implement. If steering is unsupported, omit `prompt.steer`; Paseo +can replace the active turn instead. + +## Publish timeline items + +Publish complete snapshots through `timeline.item`. Reuse an item `id` when updating streamed text, +a running tool, or a todo list. Paseo derives the live delta and keeps the normal timeline. + +Built-in item types cover user and assistant messages, reasoning, tools, todos, errors, +notifications, and compaction. Use a plugin item when your provider has a presentation that those +types cannot express: + +```ts +emit({ + type: "timeline.item", + sessionId, + item: { + type: "plugin", + id: "review-42", + pluginId: "my-provider-plugin", + kind: "review-verdict", + version: 1, + data: { verdict: "ship", summary: "All checks passed" }, + }, +}); +``` + +Register the renderer independently in `index.client.tsx`: + +```tsx +import type { PluginClientContext } from "@getpaseo/plugin"; +import { z } from "zod"; +import { ReviewVerdict } from "./client/review-verdict"; + +const reviewVerdictSchema = z.object({ + verdict: z.enum(["ship", "hold"]), + summary: z.string(), +}); + +export default function contribute(client: PluginClientContext) { + client.addTimelineRenderer({ + kind: "review-verdict", + version: 1, + schema: reviewVerdictSchema, + Component: ReviewVerdict, + }); + return () => {}; +} +``` + +The same renderer can display items emitted by a provider, a client timeline transformer, or a +daemon timeline append. A renderer does not require a provider implementation. + +## Adapt an ACP agent + +Use the ACP shim when the agent already speaks ACP: + +```ts +import type { PluginServerContext } from "@getpaseo/plugin"; +import { runAcpProvider } from "@getpaseo/plugin/acp"; + +export default function contribute(server: PluginServerContext) { + server.registerProvider( + runAcpProvider({ + id: "vendor-agent", + label: "Vendor agent", + icon: "icon.svg", + command: ["vendor-agent", "acp"], + }), + ); + return () => {}; +} +``` + +The shim owns the ACP process, capability mapping, session lifecycle, prompts, permissions, and +timeline conversion. Do not copy that machinery into the plugin. + +Use `transformers` only for vendor differences ACP cannot describe. Validate vendor payloads with +Zod and leave malformed or unrelated values unchanged: + +```ts +import type { AcpTransformer } from "@getpaseo/plugin/acp"; +import { z } from "zod"; + +const editSchema = z.object({ + path: z.string(), + before: z.string(), + after: z.string(), +}); + +export const vendorEdits: AcpTransformer = { + toolCall(toolCall) { + if (toolCall.name !== "vendor_file_edit") return toolCall; + const input = editSchema.safeParse(toolCall.input); + if (!input.success) return toolCall; + return { + ...toolCall, + kind: "edit", + input: { + filePath: input.data.path, + oldString: input.data.before, + newString: input.data.after, + }, + }; + }, +}; +``` + +## Test and publish + +Test the provider against the real agent, not only mocked frames: + +1. install the plugin as a directory source; +2. confirm its SVG, models, modes, and settings appear; +3. create a session and complete a real prompt; +4. exercise permissions, steering, cancellation, persistence, and replay when supported; +5. render every custom timeline item on desktop and mobile-width layouts; +6. reload and remove the plugin while a session is active and confirm the session terminates. + +Push the plugin to a Git repository. Users install it with: + +```bash +paseo plugin add owner/repository +paseo plugin update my-provider-plugin +``` + +Keep vendor compatibility and releases in that repository. Paseo core should only change when the +provider boundary cannot express a user-facing capability shared by more than one provider. + +See the [plugin reference](/docs/plugins/v0.8/reference#providers) for the exact runtime and SVG +rules. diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index 139def7da2..6e3cd4f000 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -2,7 +2,7 @@ title: Plugin reference description: Local plugin files, client and server runtimes, platform limits, contributions, RPCs, lifecycle, hosts, and CLI commands. nav: Reference -order: 47 +order: 48 category: Plugins --- @@ -142,7 +142,42 @@ Center callbacks can only open surfaces and panels registered by the same plugin ### Server runtime -Paseo provides `@getpaseo/plugin`, `@getpaseo/plugin/server`, and `zod` to server code. Backend contributions run in a daemon subprocess with Node access to the host machine. Keep filesystem, process, credential, and other machine-local work under `server/`. A plugin without `index.server.ts` starts no subprocess. +Paseo provides `@getpaseo/plugin`, `@getpaseo/plugin/server`, +`@getpaseo/plugin/provider`, `@getpaseo/plugin/acp`, and `zod` to server code. Backend +contributions run in a daemon subprocess with Node access to the host machine. Keep filesystem, +process, credential, and other machine-local work under `server/`. A plugin without +`index.server.ts` starts no subprocess. + +### Providers + +Follow [Build a provider plugin](/docs/plugins/v0.8/providers) for direct and ACP implementations, +session lifecycle, composer settings, timeline renderers, testing, and distribution. + +Call `server.registerProvider()` with a `ProviderRegistration` from +`@getpaseo/plugin/provider`. Its connection accepts inputs with `send()` and emits complete state +snapshots through `onEvent()`. `send()` reports acceptance only; prompt disposition, turns, +configuration, persistence, permissions, and failures are events. + +Use the single `session.prompt` input for messages, structured commands, steering, and command side +effects. Repeat `clientMessageId` on the live user timeline item and publish exactly one matching +`session.prompt_result`. Publish provider-created children as sessions with `parentSessionId`. + +Provider settings are toggle/select descriptors that Paseo renders in the composer. Keep +provider-private JSON under `providerOptions`. Host tools arrive as MCP servers in the complete +session config. + +Paseo refreshes an agent by closing its current provider session and opening it with current +configuration and persistence. Providers re-read external state during `session.open`. + +Use `runAcpProvider()` from `@getpaseo/plugin/acp` to adapt a command-backed ACP. Add transformer +hooks only for a vendor's discovery, configuration, notification, or tool-call differences. + +`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`. +It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be +self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and +external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are +allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline +SVG or URL. ## Entry point and cleanup From 9e90b86afa60aeeef05c145653f13cd2eed93896 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 4 Sep 2026 22:17:39 +0200 Subject: [PATCH 008/566] Restore reliable macOS desktop updates (#4322) * fix(desktop): restore macOS app updates Electron 41's Squirrel helper can leave the update handoff dormant on current macOS, so Paseo quits cleanly but relaunches the old bundle. Electron 44 wakes ShipIt through its XPC service. Include updater lifecycle events and ShipIt state in app diagnostics so future handoff failures identify the current version, target version, cache paths, and installer output. * fix(desktop): harden updater diagnostics Keep each ShipIt file's evidence available when another read fails, carry the service-selected version into updater lifecycle logs, and make clean workspace installs resolve the Electron runtime used by desktop tests. * refactor(desktop): name updater install options * refactor(desktop): name updater log payloads * refactor(desktop): name clipboard payload * fix(desktop): surface updater lookup failures * test(desktop): await guest focus transition * fix(desktop): guard macOS update compatibility * test(release): validate desktop manifests before publish * test(desktop): await populated daemon pid file --- .github/workflows/ci.yml | 23 -- .github/workflows/desktop-release.yml | 26 +-- docs/release.md | 10 + nix/npm-deps.hash | 2 +- package-lock.json | 196 ++++-------------- package.json | 1 + .../app/src/desktop/daemon/desktop-daemon.ts | 53 +++++ .../desktop-diagnostic-report.test.ts | 56 ++++- .../diagnostics/desktop-diagnostic-report.ts | 59 +++++- packages/desktop/e2e/browser-tabs.e2e.mjs | 21 +- packages/desktop/e2e/packaged-app-smoke.js | 5 +- packages/desktop/electron-builder.yml | 1 + packages/desktop/package.json | 2 +- .../desktop/src/daemon/daemon-manager.test.ts | 9 + packages/desktop/src/daemon/daemon-manager.ts | 2 + .../src/daemon/desktop-packaging.test.ts | 16 ++ .../desktop/src/diagnostics/updater.test.ts | 141 +++++++++++++ packages/desktop/src/diagnostics/updater.ts | 148 +++++++++++++ .../src/features/app-update-service.test.ts | 20 +- .../src/features/app-update-service.ts | 32 ++- .../desktop/src/features/auto-updater.test.ts | 45 ++++ packages/desktop/src/features/auto-updater.ts | 79 ++++++- .../src/features/browser-capture.test.ts | 19 +- .../desktop/src/features/browser-capture.ts | 19 +- .../src/features/browser-profile.test.ts | 2 +- .../desktop/src/features/browser-profile.ts | 2 +- packages/desktop/src/main.ts | 26 ++- packages/website/src/routes/download.tsx | 11 +- scripts/merge-mac-manifest.mjs | 9 +- scripts/merge-mac-manifest.test.mjs | 1 + scripts/validate-desktop-manifests.mjs | 65 ++++++ scripts/validate-desktop-manifests.test.mjs | 70 +++++++ 32 files changed, 922 insertions(+), 249 deletions(-) create mode 100644 packages/desktop/src/diagnostics/updater.test.ts create mode 100644 packages/desktop/src/diagnostics/updater.ts create mode 100644 scripts/validate-desktop-manifests.mjs create mode 100644 scripts/validate-desktop-manifests.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69689e9c13..c555a61432 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,8 +59,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -80,8 +78,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -107,8 +103,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -137,8 +131,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: &server_test_steps - uses: actions/checkout@v4 with: @@ -180,8 +172,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }} runs-on: windows-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: *server_test_steps desktop-tests-ubuntu: @@ -268,8 +258,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -296,8 +284,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -330,7 +316,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PLAYWRIGHT_SHARD: "1/4" PLAYWRIGHT_ARTIFACT: "1" steps: &playwright_test_steps @@ -378,7 +363,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PLAYWRIGHT_SHARD: "2/4" PLAYWRIGHT_ARTIFACT: "2" steps: *playwright_test_steps @@ -389,7 +373,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PLAYWRIGHT_SHARD: "3/4" PLAYWRIGHT_ARTIFACT: "3" steps: *playwright_test_steps @@ -400,7 +383,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PLAYWRIGHT_SHARD: "4/4" PLAYWRIGHT_ARTIFACT: "4" steps: *playwright_test_steps @@ -410,8 +392,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -435,7 +415,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" PASEO_DICTATION_ENABLED: "0" PASEO_VOICE_MODE_ENABLED: "0" @@ -467,7 +446,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" PASEO_DICTATION_ENABLED: "0" PASEO_VOICE_MODE_ENABLED: "0" @@ -481,7 +459,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" PASEO_DICTATION_ENABLED: "0" PASEO_VOICE_MODE_ENABLED: "0" diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 89f2f83ae6..5c9bc567ba 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -547,28 +547,10 @@ jobs: fi timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")" node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}" - # shellcheck disable=SC2016 - ROLLOUT_HOURS_EXPECTED="$ROLLOUT_HOURS" RELEASE_DATE_EXPECTED="$timestamp" node -e ' - const yaml = require("js-yaml"); - const fs = require("fs"); - const expectedHours = Number(process.env.ROLLOUT_HOURS_EXPECTED); - const expectedDate = process.env.RELEASE_DATE_EXPECTED; - if (!Number.isFinite(expectedHours) || expectedHours < 0) { - throw new Error(`expected non-negative rolloutHours, got ${process.env.ROLLOUT_HOURS_EXPECTED}`); - } - for (const f of process.argv.slice(1)) { - const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {}; - if (m.rolloutHours !== expectedHours) { - throw new Error(`${f}: rolloutHours=${m.rolloutHours}, expected ${expectedHours}`); - } - if (m.releaseDate !== expectedDate) { - throw new Error(`${f}: releaseDate=${m.releaseDate}, expected ${expectedDate}`); - } - if (typeof m.version !== "string" || m.version.length === 0) { - throw new Error(`${f}: missing or invalid version`); - } - } - ' "${files[@]}" + node ../scripts/validate-desktop-manifests.mjs \ + --release-date "$timestamp" \ + --rollout-hours "$ROLLOUT_HOURS" \ + "${files[@]}" gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}" failed_builds=() diff --git a/docs/release.md b/docs/release.md index 6261d22980..342fa9eaf9 100644 --- a/docs/release.md +++ b/docs/release.md @@ -253,6 +253,15 @@ If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its ow If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1. -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast. +### macOS system floor + +The desktop app requires macOS 13 or newer. Keep both release guards when the floor changes: + +- `packages/desktop/electron-builder.yml` writes the macOS version to `LSMinimumSystemVersion` for new installs. +- `scripts/merge-mac-manifest.mjs` writes the matching Darwin kernel version to `minimumSystemVersion` in the update manifest. Existing clients check this before downloading an update. + +macOS 13 maps to Darwin 22. The two values use different version domains; do not copy the macOS version into the update manifest. + ### Limitations - **No pause / kill switch.** To stop new admissions, ship a superseding release. Clients revalidate on quit and will not install the superseded download, but a client that already completed installation cannot be recalled; ship a hotfix `+1` patch. @@ -634,6 +643,7 @@ Each beta entry records what its testers receive. Promotion produces the single - [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset - [ ] GitHub `Desktop Release` workflow for the `v*` tag is green - [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml` +- [ ] `latest-mac.yml` contains the current `minimumSystemVersion` guard - [ ] GitHub `Android APK Release` workflow for the same tag is green - [ ] GitHub `Docker` workflow is green and both the versioned and `latest` images are published - [ ] GitHub `Release Notes Sync` is green and the release body matches the stable changelog entry diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 511b1444e3..3324b8a3a6 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-1kiHsXpooSqC38MwZP2E7rQK+Zhuv0fUOxUHfnM30wA= +sha256-EpfSUhkssURxoUgE9+odbFg3sroXH5bH5S9xClMG2nE= diff --git a/package-lock.json b/package-lock.json index a7daa8db3f..1e60b61b11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@vercel/nft": "^1.5.0", "concurrently": "^9.2.1", "cross-env": "^10.1.0", + "electron": "44.2.0", "get-port-cli": "^3.0.0", "js-yaml": "^4.1.1", "knip": "^5.82.1", @@ -2883,6 +2884,16 @@ "node": ">=0.8.0" } }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@electron/asar": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", @@ -2989,70 +3000,37 @@ } }, "node_modules/@electron/get": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", - "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", "progress": "^2.0.3", - "semver": "^6.2.0", + "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "engines": { - "node": ">=12" + "node": ">=22.12.0" }, "optionalDependencies": { - "global-agent": "^3.0.0" + "undici": "^7.24.4" } }, - "node_modules/@electron/get/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/@electron/get/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/get/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@electron/get/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "node_modules/@electron/get/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", "dev": true, "license": "MIT", "engines": { - "node": ">= 4.0.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@electron/notarize": { @@ -13187,17 +13165,6 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "license": "MIT" }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", @@ -15813,16 +15780,6 @@ "ieee754": "^1.2.1" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -18675,22 +18632,22 @@ } }, "node_modules/electron": { - "version": "41.2.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-41.2.0.tgz", - "integrity": "sha512-0OKLiymqfV0WK68RBXqAm3Myad2TpI5wwxLCBEUcH5Nugo3YfSk7p1Js/AL9266qTz5xZioUnxt9hG8FFwax0g==", + "version": "44.2.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.2.0.tgz", + "integrity": "sha512-oK1icjhapp3xsUZycO9WZaLmd3hJnA7ieobC4mpdtO1pEN+PPGWpA3m1Mgzzuc1wzSD2Wai4zcH4yfpGJqxFMA==", "dev": true, - "hasInstallScript": true, "license": "MIT", "dependencies": { - "@electron/get": "^2.0.0", - "@types/node": "^24.9.0", - "extract-zip": "^2.0.1" + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" }, "bin": { - "electron": "cli.js" + "electron": "cli.js", + "install-electron": "install.js" }, "engines": { - "node": ">= 12.20.55" + "node": ">= 22.12.0" } }, "node_modules/electron-builder": { @@ -18797,19 +18754,19 @@ } }, "node_modules/electron/node_modules/@types/node": { - "version": "24.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", - "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/electron/node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, @@ -21881,43 +21838,6 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/extract-zip/node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/extsprintf": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", @@ -22082,16 +22002,6 @@ "walk-up-path": "^4.0.0" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -29636,13 +29546,6 @@ "url": "https://github.com/sponsors/jet2jet" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -36137,17 +36040,6 @@ "node": ">=12" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -37642,7 +37534,7 @@ "devDependencies": { "@types/node": "24.6.0", "@types/ws": "^8.5.14", - "electron": "41.2.0", + "electron": "44.2.0", "electron-builder": "26.8.1", "typescript": "5.9.3", "unzip-crx-3": "^0.2.0", diff --git a/package.json b/package.json index 375e5f0b82..8a60b786db 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "@vercel/nft": "^1.5.0", "concurrently": "^9.2.1", "cross-env": "^10.1.0", + "electron": "44.2.0", "get-port-cli": "^3.0.0", "js-yaml": "^4.1.1", "knip": "^5.82.1", diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index 83fd0864cf..f465c32ac1 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -34,6 +34,25 @@ export interface DesktopAppLogs { contents: string; } +export interface DesktopUpdaterDiagnosticFile { + path: string; + exists: boolean; + modifiedAt: string | null; + contents: string; + error: string | null; +} + +export interface DesktopUpdaterDiagnostics { + platform: string; + currentVersion: string; + targetVersion: string | null; + targetVersionError: string | null; + shipItDirectory: string | null; + state: DesktopUpdaterDiagnosticFile | null; + stdout: DesktopUpdaterDiagnosticFile | null; + stderr: DesktopUpdaterDiagnosticFile | null; +} + export interface LocalTransportTarget { [key: string]: unknown; transportType: "socket" | "pipe"; @@ -123,6 +142,36 @@ function parseDesktopDaemonLogs(raw: unknown): DesktopDaemonLogs { }; } +function parseDesktopUpdaterDiagnosticFile(raw: unknown): DesktopUpdaterDiagnosticFile | null { + if (raw === null) return null; + if (!isRecord(raw)) { + throw new Error("Unexpected desktop updater diagnostic file response."); + } + return { + path: toStringOrNull(raw.path) ?? "", + exists: raw.exists === true, + modifiedAt: toStringOrNull(raw.modifiedAt), + contents: typeof raw.contents === "string" ? raw.contents : "", + error: toStringOrNull(raw.error), + }; +} + +function parseDesktopUpdaterDiagnostics(raw: unknown): DesktopUpdaterDiagnostics { + if (!isRecord(raw)) { + throw new Error("Unexpected desktop updater diagnostics response."); + } + return { + platform: toStringOrNull(raw.platform) ?? "unknown", + currentVersion: toStringOrNull(raw.currentVersion) ?? "unknown", + targetVersion: toStringOrNull(raw.targetVersion), + targetVersionError: toStringOrNull(raw.targetVersionError), + shipItDirectory: toStringOrNull(raw.shipItDirectory), + state: parseDesktopUpdaterDiagnosticFile(raw.state), + stdout: parseDesktopUpdaterDiagnosticFile(raw.stdout), + stderr: parseDesktopUpdaterDiagnosticFile(raw.stderr), + }; +} + export function shouldUseDesktopDaemon(): boolean { return isElectronRuntime(); } @@ -160,6 +209,10 @@ export async function getDesktopAppLogs(): Promise { }; } +export async function getDesktopUpdaterDiagnostics(): Promise { + return parseDesktopUpdaterDiagnostics(await invokeDesktopCommand("desktop_update_diagnostics")); +} + export async function getCliDaemonStatus(): Promise { const raw = await invokeDesktopCommand("cli_daemon_status"); if (typeof raw !== "string") { diff --git a/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts b/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts index e26e1e776f..1ffa37b950 100644 --- a/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts +++ b/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts @@ -25,6 +25,34 @@ function makeSources(): DesktopDiagnosticSources { logPath: "/logs/Paseo/main.log", contents: "[login-shell-env] start\n[login-shell-env] failed", }), + getUpdaterDiagnostics: async () => ({ + platform: "darwin", + currentVersion: "0.7.0", + targetVersion: "0.7.2", + targetVersionError: null, + shipItDirectory: "/cache/sh.paseo.desktop.ShipIt", + state: { + path: "/cache/sh.paseo.desktop.ShipIt/ShipItState.plist", + exists: true, + modifiedAt: "2026-09-04T11:22:19.000Z", + contents: '{"launchAfterInstallation":true}', + error: null, + }, + stdout: { + path: "/cache/sh.paseo.desktop.ShipIt/ShipIt_stdout.log", + exists: false, + modifiedAt: null, + contents: "", + error: null, + }, + stderr: { + path: "/cache/sh.paseo.desktop.ShipIt/ShipIt_stderr.log", + exists: true, + modifiedAt: "2026-09-01T08:39:20.000Z", + contents: "Installation completed successfully", + error: null, + }, + }), }; } @@ -50,15 +78,33 @@ describe("desktop diagnostic report", () => { await appLogGate; return makeSources().getAppLogs(); }, + getUpdaterDiagnostics: async () => { + calls.push("updater"); + return makeSources().getUpdaterDiagnostics(); + }, }; const resultPromise = collectDesktopDiagnosticSections(sources); - expect(calls).toEqual(["status", "daemonLogs", "appLogs"]); + expect(calls).toEqual(["status", "daemonLogs", "appLogs", "updater"]); releaseAppLogs(); await expect(resultPromise).resolves.toMatchObject({ status: "done" }); }); + test("includes target version lookup errors", async () => { + const sources: DesktopDiagnosticSources = { + ...makeSources(), + getUpdaterDiagnostics: async () => ({ + ...(await makeSources().getUpdaterDiagnostics()), + targetVersion: null, + targetVersionError: "plutil failed", + }), + }; + + const result = await collectDesktopDiagnosticSections(sources); + expect(result.sections.join("\n\n")).toContain("Target version error: plutil failed"); + }); + test("includes the Electron main-process log after the daemon log", async () => { const result = await collectDesktopDiagnosticSections(makeSources()); const report = result.sections.join("\n\n"); @@ -73,6 +119,14 @@ describe("desktop diagnostic report", () => { expect(report.indexOf("Desktop app log tail")).toBeGreaterThan( report.indexOf("Desktop daemon log tail"), ); + expect(report).toContain("Desktop updater\n Platform: darwin"); + expect(report).toContain(" Current version: 0.7.0"); + expect(report).toContain(" Target version: 0.7.2"); + expect(report).toContain("ShipItState.plist"); + expect(report).toContain('{"launchAfterInstallation":true}'); + expect(report).toContain("ShipIt stdout log tail"); + expect(report).toContain(" File not found"); + expect(report).toContain("Installation completed successfully"); }); test("keeps daemon diagnostics when the Electron app log fails", async () => { diff --git a/packages/app/src/diagnostics/desktop-diagnostic-report.ts b/packages/app/src/diagnostics/desktop-diagnostic-report.ts index 8fd26c6746..531ae11fbb 100644 --- a/packages/app/src/diagnostics/desktop-diagnostic-report.ts +++ b/packages/app/src/diagnostics/desktop-diagnostic-report.ts @@ -2,9 +2,12 @@ import { getDesktopAppLogs, getDesktopDaemonLogs, getDesktopDaemonStatus, + getDesktopUpdaterDiagnostics, type DesktopAppLogs, type DesktopDaemonLogs, type DesktopDaemonStatus, + type DesktopUpdaterDiagnosticFile, + type DesktopUpdaterDiagnostics, } from "@/desktop/daemon/desktop-daemon"; import { formatDiagnosticSection } from "./app-diagnostic-report"; @@ -19,12 +22,14 @@ export interface DesktopDiagnosticSources { getStatus: () => Promise; getDaemonLogs: () => Promise; getAppLogs: () => Promise; + getUpdaterDiagnostics: () => Promise; } const DEFAULT_DESKTOP_DIAGNOSTIC_SOURCES: DesktopDiagnosticSources = { getStatus: getDesktopDaemonStatus, getDaemonLogs: getDesktopDaemonLogs, getAppLogs: getDesktopAppLogs, + getUpdaterDiagnostics: getDesktopUpdaterDiagnostics, }; export async function collectDesktopDiagnosticSections( @@ -33,9 +38,10 @@ export async function collectDesktopDiagnosticSections( const sections: string[] = []; let failed = false; - const [daemonResult, appLogsResult] = await Promise.allSettled([ + const [daemonResult, appLogsResult, updaterResult] = await Promise.allSettled([ Promise.all([sources.getStatus(), sources.getDaemonLogs()]), sources.getAppLogs(), + sources.getUpdaterDiagnostics(), ]); if (daemonResult.status === "fulfilled") { @@ -62,12 +68,63 @@ export async function collectDesktopDiagnosticSections( ); } + if (updaterResult.status === "fulfilled") { + sections.push(...formatDesktopUpdaterSections(updaterResult.value)); + } else { + failed = true; + sections.push( + formatDiagnosticSection("Desktop updater", [ + { label: "Error", value: toMessage(updaterResult.reason) }, + ]), + ); + } + return { status: failed ? "failed" : "done", sections, }; } +function formatDesktopUpdaterSections(diagnostics: DesktopUpdaterDiagnostics): string[] { + const updaterDetails = [ + { label: "Platform", value: diagnostics.platform }, + { label: "Current version", value: diagnostics.currentVersion }, + { label: "Target version", value: diagnostics.targetVersion ?? "unknown" }, + { label: "ShipIt directory", value: diagnostics.shipItDirectory ?? "not applicable" }, + ]; + if (diagnostics.targetVersionError) { + updaterDetails.push({ label: "Target version error", value: diagnostics.targetVersionError }); + } + const sections = [formatDiagnosticSection("Desktop updater", updaterDetails)]; + + if (diagnostics.platform !== "darwin") return sections; + + sections.push( + formatUpdaterFileSection("ShipItState.plist", diagnostics.state), + formatUpdaterFileSection("ShipIt stdout log tail", diagnostics.stdout), + formatUpdaterFileSection("ShipIt stderr log tail", diagnostics.stderr), + ); + return sections; +} + +function formatUpdaterFileSection( + title: string, + file: DesktopUpdaterDiagnosticFile | null, +): string { + if (!file) { + return formatDiagnosticSection(title, [{ label: "Status", value: "unavailable" }]); + } + + const header = formatDiagnosticSection(title, [ + { label: "Path", value: file.path || "unknown" }, + { label: "Modified", value: file.modifiedAt ?? "unknown" }, + ]); + if (file.error) return `${header}\n Error: ${file.error}`; + if (!file.exists) return `${header}\n File not found`; + if (!file.contents) return `${header}\n No contents found`; + return `${header}\n${indentBlock(file.contents)}`; +} + function formatDesktopDaemonSections(input: { status: DesktopDaemonStatus; daemonLogs: DesktopDaemonLogs; diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 1985d9d7cf..f0c34060c9 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -262,6 +262,21 @@ async function waitForGuestSelector(client, browserId) { return false; } +async function waitForGuestActiveElement(client, browserId, elementId) { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + const evaluated = await callBrowserTool(client, "browser_evaluate", { + browserId, + function: "() => document.activeElement?.id ?? null", + }); + if (JSON.parse(evaluated.resultJson) === elementId) { + return true; + } + await delay(50); + } + return false; +} + async function createCallerAgent(daemonPort) { const transport = new StreamableHTTPClientTransport( new URL(`http://127.0.0.1:${daemonPort}/mcp/agents`), @@ -492,12 +507,8 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId, ); await clickGuestElement(page, client, browserId, "#typing-target"); - const activeGuestElement = await callBrowserTool(client, "browser_evaluate", { - browserId, - function: "() => document.activeElement?.id ?? null", - }); assert( - JSON.parse(activeGuestElement.resultJson) === "typing-target", + await waitForGuestActiveElement(client, browserId, "typing-target"), "Physical browser click did not focus the guest input", ); const focusedGuest = await page.evaluate( diff --git a/packages/desktop/e2e/packaged-app-smoke.js b/packages/desktop/e2e/packaged-app-smoke.js index 0c5e2b798b..b07b75df4d 100644 --- a/packages/desktop/e2e/packaged-app-smoke.js +++ b/packages/desktop/e2e/packaged-app-smoke.js @@ -222,7 +222,10 @@ async function waitForFile(filePath, label) { const deadline = Date.now() + SMOKE_TIMEOUT_MS; while (Date.now() < deadline) { if (fs.existsSync(filePath)) { - return fs.readFileSync(filePath, "utf8"); + const contents = fs.readFileSync(filePath, "utf8"); + if (contents.trim().length > 0) { + return contents; + } } await delay(250); } diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 7e1cf10b5a..5fa62ef91a 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -37,6 +37,7 @@ publish: repo: paseo mac: artifactName: "Paseo-${version}-${arch}.${ext}" + minimumSystemVersion: "13.0.0" category: public.app-category.developer-tools icon: assets/icon.icns hardenedRuntime: true diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1a81cb001e..72f9ddf9c8 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -37,7 +37,7 @@ "devDependencies": { "@types/node": "24.6.0", "@types/ws": "^8.5.14", - "electron": "41.2.0", + "electron": "44.2.0", "electron-builder": "26.8.1", "typescript": "5.9.3", "unzip-crx-3": "^0.2.0", diff --git a/packages/desktop/src/daemon/daemon-manager.test.ts b/packages/desktop/src/daemon/daemon-manager.test.ts index 70a6bc251c..8ccd2d1062 100644 --- a/packages/desktop/src/daemon/daemon-manager.test.ts +++ b/packages/desktop/src/daemon/daemon-manager.test.ts @@ -515,4 +515,13 @@ describe("daemon-manager commands", () => { ), }); }); + + it("exposes updater diagnostics through the desktop command boundary", () => { + const diagnostics = createDaemonCommandHandlers().desktop_update_diagnostics(); + + expect(diagnostics).toMatchObject({ + platform: process.platform, + currentVersion: "1.2.3", + }); + }); }); diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index c565b226f3..4d3e5531eb 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -38,6 +38,7 @@ import type { DesktopSettings } from "../settings/desktop-settings.js"; import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js"; import { isRunningUnderARM64Translation } from "../system/arm64-translation.js"; import { getDesktopAppLogs } from "../diagnostics/app-logs.js"; +import { getDesktopUpdaterDiagnostics } from "../diagnostics/updater.js"; import { deleteLegacySkillSelection, readLegacySkillSelection, @@ -531,6 +532,7 @@ export function createDaemonCommandHandlers(): Record restartDaemon(), desktop_daemon_logs: () => getDaemonLogs(), desktop_app_logs: () => getDesktopAppLogs(), + desktop_update_diagnostics: () => getDesktopUpdaterDiagnostics(), desktop_get_system_idle_time: () => powerMonitor.getSystemIdleTime() * 1000, cli_daemon_status: () => getCliDaemonStatus(), write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}), diff --git a/packages/desktop/src/daemon/desktop-packaging.test.ts b/packages/desktop/src/daemon/desktop-packaging.test.ts index cbea764dcb..c58c3f05e4 100644 --- a/packages/desktop/src/daemon/desktop-packaging.test.ts +++ b/packages/desktop/src/daemon/desktop-packaging.test.ts @@ -63,6 +63,22 @@ function createFakeMacBundle(options: { includeHelper: boolean }): { } describe("desktop packaging", () => { + it("uses an Electron runtime whose Squirrel handoff explicitly wakes ShipIt", () => { + const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as { + devDependencies?: Record; + }; + const electronVersion = pkg.devDependencies?.electron ?? "0.0.0"; + const electronMajor = Number(electronVersion.split(".")[0]); + + expect(electronMajor).toBeGreaterThanOrEqual(44); + }); + + it("requires macOS 13 or newer in the packaged application", () => { + const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8"); + + expect(config).toContain('minimumSystemVersion: "13.0.0"'); + }); + it("unpacks server zsh shell integration files for external shells", () => { const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8"); diff --git a/packages/desktop/src/diagnostics/updater.test.ts b/packages/desktop/src/diagnostics/updater.test.ts new file mode 100644 index 0000000000..bfe43aceab --- /dev/null +++ b/packages/desktop/src/diagnostics/updater.test.ts @@ -0,0 +1,141 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { collectDesktopUpdaterDiagnostics } from "./updater"; + +let testDirectory = ""; + +afterEach(() => { + if (testDirectory) { + rmSync(testDirectory, { recursive: true, force: true }); + testDirectory = ""; + } +}); + +describe("desktop updater diagnostics", () => { + it("collects the staged version and existing ShipIt evidence", () => { + testDirectory = mkdtempSync(path.join(tmpdir(), "paseo-updater-diagnostics-")); + const shipItDirectory = path.join(testDirectory, "sh.paseo.desktop.ShipIt"); + const updateBundlePath = path.join(shipItDirectory, "update.test", "Paseo.app"); + mkdirSync(shipItDirectory, { recursive: true }); + writeFileSync( + path.join(shipItDirectory, "ShipItState.plist"), + JSON.stringify({ + launchAfterInstallation: true, + updateBundleURL: pathToFileURL(updateBundlePath).href, + }), + ); + writeFileSync(path.join(shipItDirectory, "ShipIt_stdout.log"), "stdout evidence\n"); + writeFileSync(path.join(shipItDirectory, "ShipIt_stderr.log"), "stderr evidence\n"); + + const diagnostics = collectDesktopUpdaterDiagnostics({ + platform: "darwin", + currentVersion: "0.7.0", + cachePath: testDirectory, + readBundleVersion: (bundlePath) => (bundlePath === updateBundlePath ? "0.7.2" : null), + }); + + expect(diagnostics.currentVersion).toBe("0.7.0"); + expect(diagnostics.targetVersion).toBe("0.7.2"); + expect(diagnostics.targetVersionError).toBeNull(); + expect(diagnostics.shipItDirectory).toBe(shipItDirectory); + expect(diagnostics.state).toMatchObject({ exists: true }); + expect(diagnostics.stdout).toMatchObject({ + exists: true, + contents: "stdout evidence", + }); + expect(diagnostics.stderr).toMatchObject({ + exists: true, + contents: "stderr evidence", + }); + expect(diagnostics.state?.modifiedAt).not.toBeNull(); + }); + + it("reports malformed ShipIt state without hiding other evidence", () => { + testDirectory = mkdtempSync(path.join(tmpdir(), "paseo-updater-diagnostics-")); + const shipItDirectory = path.join(testDirectory, "sh.paseo.desktop.ShipIt"); + mkdirSync(shipItDirectory, { recursive: true }); + writeFileSync(path.join(shipItDirectory, "ShipItState.plist"), "not JSON"); + writeFileSync(path.join(shipItDirectory, "ShipIt_stderr.log"), "installer evidence\n"); + + const diagnostics = collectDesktopUpdaterDiagnostics({ + platform: "darwin", + currentVersion: "0.7.0", + cachePath: testDirectory, + readBundleVersion: () => "unused", + }); + + expect(diagnostics.targetVersion).toBeNull(); + expect(diagnostics.targetVersionError).toMatch(/JSON|Unexpected token/); + expect(diagnostics.stderr?.contents).toBe("installer evidence"); + }); + + it("reports bundle version lookup failures", () => { + testDirectory = mkdtempSync(path.join(tmpdir(), "paseo-updater-diagnostics-")); + const shipItDirectory = path.join(testDirectory, "sh.paseo.desktop.ShipIt"); + mkdirSync(shipItDirectory, { recursive: true }); + writeFileSync( + path.join(shipItDirectory, "ShipItState.plist"), + JSON.stringify({ updateBundleURL: pathToFileURL(path.join(shipItDirectory, "Paseo.app")) }), + ); + + const diagnostics = collectDesktopUpdaterDiagnostics({ + platform: "darwin", + currentVersion: "0.7.0", + cachePath: testDirectory, + readBundleVersion: () => { + throw new Error("plutil failed"); + }, + }); + + expect(diagnostics.targetVersion).toBeNull(); + expect(diagnostics.targetVersionError).toBe("plutil failed"); + }); + + it("keeps readable ShipIt evidence when another file cannot be read", () => { + testDirectory = mkdtempSync(path.join(tmpdir(), "paseo-updater-diagnostics-")); + const shipItDirectory = path.join(testDirectory, "sh.paseo.desktop.ShipIt"); + mkdirSync(path.join(shipItDirectory, "ShipIt_stdout.log"), { recursive: true }); + writeFileSync(path.join(shipItDirectory, "ShipIt_stderr.log"), "installer evidence\n"); + + const diagnostics = collectDesktopUpdaterDiagnostics({ + platform: "darwin", + currentVersion: "0.7.0", + cachePath: testDirectory, + readBundleVersion: () => null, + }); + + expect(diagnostics.stdout).toMatchObject({ + exists: true, + contents: "", + error: expect.any(String), + }); + expect(diagnostics.stderr).toMatchObject({ + exists: true, + contents: "installer evidence", + error: null, + }); + }); + + it("does not look for ShipIt files outside macOS", () => { + const diagnostics = collectDesktopUpdaterDiagnostics({ + platform: "linux", + currentVersion: "0.7.2", + cachePath: "/unused", + readBundleVersion: () => null, + }); + + expect(diagnostics).toEqual({ + platform: "linux", + currentVersion: "0.7.2", + targetVersion: null, + targetVersionError: null, + shipItDirectory: null, + state: null, + stdout: null, + stderr: null, + }); + }); +}); diff --git a/packages/desktop/src/diagnostics/updater.ts b/packages/desktop/src/diagnostics/updater.ts new file mode 100644 index 0000000000..465c5d0390 --- /dev/null +++ b/packages/desktop/src/diagnostics/updater.ts @@ -0,0 +1,148 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { app } from "electron"; +import { tailFile } from "./tail-file.js"; + +const SHIPIT_DIRECTORY_NAME = "sh.paseo.desktop.ShipIt"; +const SHIPIT_LOG_TAIL_LINES = 100; + +export interface DesktopUpdaterDiagnosticFile { + path: string; + exists: boolean; + modifiedAt: string | null; + contents: string; + error: string | null; +} + +export interface DesktopUpdaterDiagnostics { + platform: NodeJS.Platform; + currentVersion: string; + targetVersion: string | null; + targetVersionError: string | null; + shipItDirectory: string | null; + state: DesktopUpdaterDiagnosticFile | null; + stdout: DesktopUpdaterDiagnosticFile | null; + stderr: DesktopUpdaterDiagnosticFile | null; +} + +interface DesktopUpdaterDiagnosticEnvironment { + platform: NodeJS.Platform; + currentVersion: string; + cachePath: string; + readBundleVersion(bundlePath: string): string | null; +} + +export function collectDesktopUpdaterDiagnostics( + environment: DesktopUpdaterDiagnosticEnvironment, +): DesktopUpdaterDiagnostics { + const { platform, currentVersion, cachePath, readBundleVersion } = environment; + if (platform !== "darwin") { + return { + platform, + currentVersion, + targetVersion: null, + targetVersionError: null, + shipItDirectory: null, + state: null, + stdout: null, + stderr: null, + }; + } + + const shipItDirectory = path.join(cachePath, SHIPIT_DIRECTORY_NAME); + const state = readDiagnosticFile(path.join(shipItDirectory, "ShipItState.plist")); + const targetVersion = diagnoseTargetVersion(state.contents, readBundleVersion); + return { + platform, + currentVersion, + ...targetVersion, + shipItDirectory, + state, + stdout: readDiagnosticFile(path.join(shipItDirectory, "ShipIt_stdout.log"), true), + stderr: readDiagnosticFile(path.join(shipItDirectory, "ShipIt_stderr.log"), true), + }; +} + +export function getDesktopUpdaterDiagnostics(): DesktopUpdaterDiagnostics { + return collectDesktopUpdaterDiagnostics({ + platform: process.platform, + currentVersion: app.getVersion(), + cachePath: path.join(app.getPath("home"), "Library", "Caches"), + readBundleVersion: readMacBundleVersion, + }); +} + +function readDiagnosticFile(filePath: string, tail = false): DesktopUpdaterDiagnosticFile { + let exists = false; + let modifiedAt: string | null = null; + try { + modifiedAt = statSync(filePath).mtime.toISOString(); + exists = true; + const contents = tail + ? tailFile(filePath, SHIPIT_LOG_TAIL_LINES, { throwOnReadError: true }) + : readFileSync(filePath, "utf8"); + return { path: filePath, exists, modifiedAt, contents, error: null }; + } catch (error) { + if (isMissingFileError(error)) { + return { path: filePath, exists: false, modifiedAt: null, contents: "", error: null }; + } + return { + path: filePath, + exists, + modifiedAt, + contents: "", + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function diagnoseTargetVersion( + stateContents: string, + readBundleVersion: (bundlePath: string) => string | null, +): Pick { + try { + return { + targetVersion: readTargetVersion(stateContents, readBundleVersion), + targetVersionError: null, + }; + } catch (error) { + return { + targetVersion: null, + targetVersionError: error instanceof Error ? error.message : String(error), + }; + } +} + +function readTargetVersion( + stateContents: string, + readBundleVersion: (bundlePath: string) => string | null, +): string | null { + if (!stateContents) return null; + + const state = JSON.parse(stateContents) as unknown; + if (!isRecord(state) || typeof state.updateBundleURL !== "string") return null; + return readBundleVersion(fileURLToPath(state.updateBundleURL)); +} + +function readMacBundleVersion(bundlePath: string): string | null { + return execFileSync( + "/usr/bin/plutil", + [ + "-extract", + "CFBundleShortVersionString", + "raw", + path.join(bundlePath, "Contents", "Info.plist"), + ], + { encoding: "utf8" }, + ).trim(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isMissingFileError(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"; +} diff --git a/packages/desktop/src/features/app-update-service.test.ts b/packages/desktop/src/features/app-update-service.test.ts index e3be85bca0..5f55ff6099 100644 --- a/packages/desktop/src/features/app-update-service.test.ts +++ b/packages/desktop/src/features/app-update-service.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { createAppUpdateService, + type AppUpdateInstallRequest, type AppUpdateRuntime, type AppUpdateRuntimeConfiguration, type RuntimeUpdateInfo, @@ -27,9 +28,10 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime { } | null = null; checkCount = 0; downloadCallCount = 0; + requestedDownloadVersions: string[] = []; downloadedVersions: string[] = []; installedVersions: string[] = []; - installModes: Array<{ isSilent: boolean; isForceRunAfter: boolean }> = []; + installModes: Array<{ targetVersion: string; isSilent: boolean; isForceRunAfter: boolean }> = []; configure(input: AppUpdateRuntimeConfiguration): void { this.configuration = input; @@ -130,8 +132,9 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime { return { ...result, isUpdateAvailable }; } - async downloadUpdate(): Promise { + async downloadUpdate(targetVersion: string): Promise { this.downloadCallCount += 1; + this.requestedDownloadVersions.push(targetVersion); if (this.activeDownload) { return this.activeDownload.promise; } @@ -140,10 +143,10 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime { } } - quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void { + quitAndInstall({ targetVersion, isSilent, isForceRunAfter }: AppUpdateInstallRequest): void { if (this.downloadedUpdate) { this.installedVersions.push(this.downloadedUpdate.version); - this.installModes.push({ isSilent, isForceRunAfter }); + this.installModes.push({ targetVersion, isSilent, isForceRunAfter }); } } } @@ -387,7 +390,9 @@ describe("app update service", () => { expect(installed).toBe(true); expect(runtime.installedVersions).toEqual(["1.2.5"]); - expect(runtime.installModes).toEqual([{ isSilent: true, isForceRunAfter: false }]); + expect(runtime.installModes).toEqual([ + { targetVersion: "1.2.5", isSilent: true, isForceRunAfter: false }, + ]); }); it("does not install an older download while its replacement is still rolling out", async () => { @@ -491,7 +496,9 @@ describe("app update service", () => { expect(result.installed).toBe(true); expect(runtime.installedVersions).toEqual(["1.2.5"]); - expect(runtime.installModes).toEqual([{ isSilent: false, isForceRunAfter: true }]); + expect(runtime.installModes).toEqual([ + { targetVersion: "1.2.5", isSilent: false, isForceRunAfter: true }, + ]); }); it("waits for a stale active download before downloading and installing the rechecked version", async () => { @@ -518,6 +525,7 @@ describe("app update service", () => { expect(result.installed).toBe(true); expect(runtime.downloadedVersions).toEqual(["1.2.4", "1.2.5"]); + expect(runtime.requestedDownloadVersions).toEqual(["1.2.5"]); expect(runtime.installedVersions).toEqual(["1.2.5"]); }); diff --git a/packages/desktop/src/features/app-update-service.ts b/packages/desktop/src/features/app-update-service.ts index 876a5a5553..21789950bf 100644 --- a/packages/desktop/src/features/app-update-service.ts +++ b/packages/desktop/src/features/app-update-service.ts @@ -41,11 +41,17 @@ export interface AppUpdateRuntimeConfiguration { onError(error: unknown): void; } +export interface AppUpdateInstallRequest { + targetVersion: string; + isSilent: boolean; + isForceRunAfter: boolean; +} + export interface AppUpdateRuntime { configure(input: AppUpdateRuntimeConfiguration): void; checkForUpdates(): Promise; - downloadUpdate(): Promise; - quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void; + downloadUpdate(targetVersion: string): Promise; + quitAndInstall(input: AppUpdateInstallRequest): void; } export interface AppUpdateService { @@ -101,15 +107,21 @@ function buildCheckResult(input: { async function performQuitAndInstall( runtime: AppUpdateRuntime, { + targetVersion, onBeforeQuit, restart, }: { + targetVersion: string; onBeforeQuit?: () => Promise; restart: boolean; }, ): Promise { if (onBeforeQuit) await onBeforeQuit(); - runtime.quitAndInstall(/* isSilent */ !restart, /* isForceRunAfter */ restart); + runtime.quitAndInstall({ + targetVersion, + isSilent: !restart, + isForceRunAfter: restart, + }); } function getErrorMessage(error: unknown): string { @@ -356,7 +368,7 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer const attemptedVersion: string = preparingUpdateVersion ?? readyVersion; preparingUpdateVersion ??= readyVersion; try { - await deps.runtime.downloadUpdate(); + await deps.runtime.downloadUpdate(attemptedVersion); } catch (error) { if ( attemptedVersion !== readyVersion && @@ -406,7 +418,11 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer } if (isReadyToInstallVersion(readyVersion)) { - await performQuitAndInstall(deps.runtime, { onBeforeQuit, restart }); + await performQuitAndInstall(deps.runtime, { + targetVersion: readyVersion, + onBeforeQuit, + restart, + }); return { installed: true, version: readyVersion, @@ -426,7 +442,11 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer message: "A newer update was found and will be installed later.", }; } - await performQuitAndInstall(deps.runtime, { onBeforeQuit, restart }); + await performQuitAndInstall(deps.runtime, { + targetVersion: readyVersion, + onBeforeQuit, + restart, + }); return { installed: true, diff --git a/packages/desktop/src/features/auto-updater.test.ts b/packages/desktop/src/features/auto-updater.test.ts index d4c42c0322..6cd6da2c7e 100644 --- a/packages/desktop/src/features/auto-updater.test.ts +++ b/packages/desktop/src/features/auto-updater.test.ts @@ -39,6 +39,7 @@ vi.mock("electron-updater", () => ({ import { bucketFromStagingUserId, checkForAppUpdate, + createAppUpdateLifecycleLogger, resolveStagingUserId, rolloutManifestSchema, shouldAdmitToRollout, @@ -95,6 +96,50 @@ describe("checkForAppUpdate", () => { expect(consoleError).toHaveBeenCalled(); consoleError.mockRestore(); }); + + it("logs the update handoff with current and selected target versions", () => { + const info = vi.fn(); + const lifecycleLog = createAppUpdateLifecycleLogger({ info }); + + lifecycleLog.checkStarted({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + lifecycleLog.checkCompleted({ + currentVersion: "1.2.3", + targetVersion: "1.2.5", + releaseChannel: "stable", + intent: "manual", + hasUpdate: true, + readyToInstall: true, + errorMessage: null, + }); + lifecycleLog.updateDownloaded("1.2.4"); + lifecycleLog.downloadRequested("1.2.5"); + lifecycleLog.quitAndInstallRequested({ + targetVersion: "1.2.5", + isSilent: false, + isForceRunAfter: true, + }); + + expect(info).toHaveBeenCalledWith("[auto-updater] check started", { + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + expect(info).toHaveBeenCalledWith("[auto-updater] update downloaded", { + targetVersion: "1.2.4", + }); + expect(info).toHaveBeenCalledWith("[auto-updater] download requested", { + targetVersion: "1.2.5", + }); + expect(info).toHaveBeenCalledWith("[auto-updater] quitAndInstall requested", { + targetVersion: "1.2.5", + isSilent: false, + isForceRunAfter: true, + }); + }); }); describe("shouldInstallAppUpdateOnQuit", () => { diff --git a/packages/desktop/src/features/auto-updater.ts b/packages/desktop/src/features/auto-updater.ts index 46656a3917..ef71dc25b4 100644 --- a/packages/desktop/src/features/auto-updater.ts +++ b/packages/desktop/src/features/auto-updater.ts @@ -3,10 +3,12 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { app } from "electron"; import { UUID } from "builder-util-runtime"; +import log from "electron-log/main"; import { autoUpdater } from "electron-updater"; import { createAppUpdateService, type AppUpdateCheckResult, + type AppUpdateInstallRequest, type AppUpdateInstallResult, type AppUpdateRuntime, type AppUpdateRuntimeConfiguration, @@ -35,6 +37,48 @@ let cachedStagingUserIdPromise: Promise | null = null; const UPDATE_CHANNEL_NOT_PUBLISHED_CODE = "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND"; +interface AppUpdateLogSink { + info(message: string, details: object): void; +} + +interface AppUpdateCheckLogDetails { + currentVersion: string; + releaseChannel: AppReleaseChannel; + intent: AppUpdateCheckIntent; +} + +interface AppUpdateCheckCompletedLogDetails extends AppUpdateCheckLogDetails { + targetVersion: string; + hasUpdate: boolean; + readyToInstall: boolean; + errorMessage: string | null; +} + +export function createAppUpdateLifecycleLogger(logger: AppUpdateLogSink) { + return { + checkStarted(details: AppUpdateCheckLogDetails): void { + logger.info("[auto-updater] check started", details); + }, + checkCompleted(details: AppUpdateCheckCompletedLogDetails): void { + logger.info("[auto-updater] check completed", details); + }, + updateAvailable(targetVersion: string): void { + logger.info("[auto-updater] update available", { targetVersion }); + }, + updateDownloaded(targetVersion: string): void { + logger.info("[auto-updater] update downloaded", { targetVersion }); + }, + downloadRequested(targetVersion: string): void { + logger.info("[auto-updater] download requested", { targetVersion }); + }, + quitAndInstallRequested(details: AppUpdateInstallRequest): void { + logger.info("[auto-updater] quitAndInstall requested", details); + }, + }; +} + +const updateLifecycleLog = createAppUpdateLifecycleLogger(log); + function isUpdateChannelNotPublished(error: unknown): boolean { return ( typeof error === "object" && @@ -133,10 +177,14 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime { }; autoUpdater.on("update-available", (info) => { - input.onUpdateAvailable(info as RuntimeUpdateInfo); + const updateInfo = info as RuntimeUpdateInfo; + updateLifecycleLog.updateAvailable(updateInfo.version); + input.onUpdateAvailable(updateInfo); }); autoUpdater.on("update-downloaded", (info) => { - input.onUpdateDownloaded(info as RuntimeUpdateInfo); + const updateInfo = info as RuntimeUpdateInfo; + updateLifecycleLog.updateDownloaded(updateInfo.version); + input.onUpdateDownloaded(updateInfo); }); autoUpdater.on("error", (error) => { if (isUpdateChannelNotPublished(error)) return; @@ -158,12 +206,18 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime { } } - downloadUpdate(): Promise { + downloadUpdate(targetVersion: string): Promise { + updateLifecycleLog.downloadRequested(targetVersion); return autoUpdater.downloadUpdate(); } - quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void { + quitAndInstall({ targetVersion, isSilent, isForceRunAfter }: AppUpdateInstallRequest): void { autoUpdater.autoRunAppAfterInstall = isForceRunAfter; + updateLifecycleLog.quitAndInstallRequested({ + targetVersion, + isSilent, + isForceRunAfter, + }); autoUpdater.quitAndInstall(isSilent, isForceRunAfter); } } @@ -197,7 +251,22 @@ export async function checkForAppUpdate({ releaseChannel: AppReleaseChannel; intent: AppUpdateCheckIntent; }): Promise { - return appUpdateService.checkForAppUpdate({ currentVersion, releaseChannel, intent }); + updateLifecycleLog.checkStarted({ currentVersion, releaseChannel, intent }); + const result = await appUpdateService.checkForAppUpdate({ + currentVersion, + releaseChannel, + intent, + }); + updateLifecycleLog.checkCompleted({ + currentVersion, + targetVersion: result.latestVersion, + releaseChannel, + intent, + hasUpdate: result.hasUpdate, + readyToInstall: result.readyToInstall, + errorMessage: result.errorMessage, + }); + return result; } export async function downloadAndInstallUpdate( diff --git a/packages/desktop/src/features/browser-capture.test.ts b/packages/desktop/src/features/browser-capture.test.ts index d14c7734ee..5c7d16a77a 100644 --- a/packages/desktop/src/features/browser-capture.test.ts +++ b/packages/desktop/src/features/browser-capture.test.ts @@ -10,7 +10,7 @@ function image(dataUrl = "data:image/png;base64,capture"): BrowserCaptureImage { } function harness(guest: BrowserCaptureGuest | null = null) { - const clipboard = { write: vi.fn(), writeImage: vi.fn(), writeText: vi.fn() }; + const clipboard = { write: vi.fn(async () => undefined) }; const decodeImage = vi.fn(() => image("data:image/png;base64,clipboard")); const warn = vi.fn(); return { @@ -51,16 +51,21 @@ describe("browser capture service", () => { expect(capturePage).not.toHaveBeenCalled(); }); - it("writes text and a decoded image to the clipboard atomically", () => { + it("writes text and a decoded image to the clipboard atomically", async () => { const { service, clipboard } = harness(); - expect(service.copy({ text: "button", imageDataUrl: "data:image/png;base64,value" })).toBe( - true, - ); + await expect( + service.copy({ text: "button", imageDataUrl: "data:image/png;base64,value" }), + ).resolves.toBe(true); expect(clipboard.write).toHaveBeenCalledWith({ text: "button", image: expect.any(Object), }); - expect(clipboard.writeText).not.toHaveBeenCalled(); - expect(clipboard.writeImage).not.toHaveBeenCalled(); + }); + + it("keeps text-only clipboard writes", async () => { + const { service, clipboard } = harness(); + + await expect(service.copy({ text: "button" })).resolves.toBe(true); + expect(clipboard.write).toHaveBeenCalledWith({ text: "button", image: null }); }); }); diff --git a/packages/desktop/src/features/browser-capture.ts b/packages/desktop/src/features/browser-capture.ts index 792567d424..c2c7326ce7 100644 --- a/packages/desktop/src/features/browser-capture.ts +++ b/packages/desktop/src/features/browser-capture.ts @@ -15,10 +15,13 @@ export interface BrowserCaptureRect { height: number; } +interface BrowserCaptureClipboardPayload { + text: string | null; + image: TImage | null; +} + interface BrowserCaptureClipboard { - write(input: { text: string; image: TImage }): void; - writeImage(image: TImage): void; - writeText(text: string): void; + write(input: BrowserCaptureClipboardPayload): Promise; } interface BrowserCaptureDependencies { @@ -34,7 +37,7 @@ export interface BrowserCaptureService { hostWebContentsId: number; rect: unknown; }): Promise; - copy(payload: unknown): boolean; + copy(payload: unknown): Promise; } function captureRect(value: unknown): BrowserCaptureRect | null { @@ -88,7 +91,7 @@ export function createBrowserCaptureService( } }, - copy(payload) { + async copy(payload) { const { text, imageDataUrl } = copyPayload(payload); let image: TImage | null = null; if (imageDataUrl) { @@ -99,10 +102,8 @@ export function createBrowserCaptureService( dependencies.warn("image-decode-failed", { error }); } } - if (text && image) dependencies.clipboard.write({ text, image }); - else if (image) dependencies.clipboard.writeImage(image); - else if (text) dependencies.clipboard.writeText(text); - else return false; + if (!text && !image) return false; + await dependencies.clipboard.write({ text, image }); return true; }, }; diff --git a/packages/desktop/src/features/browser-profile.test.ts b/packages/desktop/src/features/browser-profile.test.ts index 029a5ae940..09d7c40b43 100644 --- a/packages/desktop/src/features/browser-profile.test.ts +++ b/packages/desktop/src/features/browser-profile.test.ts @@ -159,7 +159,7 @@ describe("clearPaseoBrowserProfile", () => { "localstorage", "serviceworkers", "cachestorage", - "websql", + "shadercache", ], }, ]); diff --git a/packages/desktop/src/features/browser-profile.ts b/packages/desktop/src/features/browser-profile.ts index 307567e512..f8f1d19328 100644 --- a/packages/desktop/src/features/browser-profile.ts +++ b/packages/desktop/src/features/browser-profile.ts @@ -10,7 +10,7 @@ const PASEO_BROWSER_STORAGE_TYPES = [ "localstorage", "serviceworkers", "cachestorage", - "websql", + "shadercache", ] as const; interface BrowserProfileSession { diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index b0610565ca..05230b2556 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -14,6 +14,7 @@ import { app, autoUpdater as electronAutoUpdater, BrowserWindow, + ClipboardItem, clipboard, Menu, ipcMain, @@ -130,6 +131,12 @@ const bootstrapComplete = new Promise((resolve) => { let bootstrapIsComplete = false; app.setName(APP_NAME); +log.info("[desktop] app startup", { + version: app.getVersion(), + platform: process.platform, + arch: process.arch, + isPackaged: app.isPackaged, +}); interface AttachedBrowserInput { browserId: string; @@ -519,7 +526,19 @@ ipcMain.handle("paseo:browser:clear-profile", async (_event, rawLegacyBrowserIds const browserCapture = createBrowserCaptureService({ findGuest: getPaseoBrowserWebContentsForHostWindow, decodeImage: (dataUrl) => nativeImage.createFromDataURL(dataUrl), - clipboard, + clipboard: { + write: async ({ text, image }) => { + const items: Record = {}; + if (text) items["text/plain"] = text; + if (image) { + const png = image.toPNG(); + const bytes = new Uint8Array(png.byteLength); + bytes.set(png); + items["image/png"] = new Blob([bytes], { type: "image/png" }); + } + await clipboard.write([new ClipboardItem(items)]); + }, + }, warn: (event, details) => log.warn(`[browser-capture] ${event}`, details), }); @@ -1031,7 +1050,10 @@ const quitLifecycle = createQuitLifecycle({ }); // electron-updater forwards this event through Electron's built-in autoUpdater. -electronAutoUpdater.on("before-quit-for-update", quitLifecycle.handleBeforeQuitForUpdate); +electronAutoUpdater.on("before-quit-for-update", () => { + log.info("[auto-updater] before-quit-for-update", { currentVersion: app.getVersion() }); + quitLifecycle.handleBeforeQuitForUpdate(); +}); app.on("before-quit", quitLifecycle.handleBeforeQuit); registerExternalQuitSignals({ signals: process, quit: () => app.quit() }); diff --git a/packages/website/src/routes/download.tsx b/packages/website/src/routes/download.tsx index 5efed4de4b..f2623e37a4 100644 --- a/packages/website/src/routes/download.tsx +++ b/packages/website/src/routes/download.tsx @@ -86,10 +86,13 @@ function Download() {
- - - - +
+ + + + + Requires macOS 13 or newer +
{!onBeta && ( diff --git a/scripts/merge-mac-manifest.mjs b/scripts/merge-mac-manifest.mjs index 7ebb92957d..78a5553b56 100644 --- a/scripts/merge-mac-manifest.mjs +++ b/scripts/merge-mac-manifest.mjs @@ -1,13 +1,20 @@ import fs from "node:fs"; import { dump, load } from "js-yaml"; +// electron-updater compares this with os.release(), which reports the Darwin +// kernel version. Darwin 22 is macOS 13 Ventura. +export const MACOS_MINIMUM_DARWIN_VERSION = "22.0.0"; + export function mergeMacManifest(arm64Path, x64Path, outputPath) { const arm64 = load(fs.readFileSync(arm64Path, "utf8")); const x64 = load(fs.readFileSync(x64Path, "utf8")); const files = [...(arm64.files ?? []), ...(x64.files ?? [])].filter( (file, index, all) => all.findIndex((entry) => entry.url === file.url) === index, ); - const output = dump({ ...arm64, files }, { lineWidth: -1, noRefs: true }); + const output = dump( + { ...arm64, files, minimumSystemVersion: MACOS_MINIMUM_DARWIN_VERSION }, + { lineWidth: -1, noRefs: true }, + ); fs.writeFileSync(outputPath, output); return output; } diff --git a/scripts/merge-mac-manifest.test.mjs b/scripts/merge-mac-manifest.test.mjs index f007b77afd..cd8ceaf957 100644 --- a/scripts/merge-mac-manifest.test.mjs +++ b/scripts/merge-mac-manifest.test.mjs @@ -22,6 +22,7 @@ test("preserves unknown fields while merging files by url", () => { mergeMacManifest(arm64Path, x64Path, outputPath); const output = readFileSync(outputPath, "utf8"); assert.match(output, /stagingPercentage: 25/); + assert.match(output, /minimumSystemVersion: 22\.0\.0/); assert.match(output, /url: app-arm64\.zip/); assert.match(output, /url: app-x64\.zip/); } finally { diff --git a/scripts/validate-desktop-manifests.mjs b/scripts/validate-desktop-manifests.mjs new file mode 100644 index 0000000000..ace4a4060b --- /dev/null +++ b/scripts/validate-desktop-manifests.mjs @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { load } from "js-yaml"; +import { MACOS_MINIMUM_DARWIN_VERSION } from "./merge-mac-manifest.mjs"; + +export function validateDesktopManifests({ releaseDate, rolloutHours }, paths) { + if (!Number.isFinite(rolloutHours) || rolloutHours < 0) { + throw new Error(`expected non-negative rolloutHours, got ${rolloutHours}`); + } + + for (const manifestPath of paths) { + const manifest = load(fs.readFileSync(manifestPath, "utf8")) ?? {}; + if (manifest.rolloutHours !== rolloutHours) { + throw new Error( + `${manifestPath}: rolloutHours=${manifest.rolloutHours}, expected ${rolloutHours}`, + ); + } + if (manifest.releaseDate !== releaseDate) { + throw new Error( + `${manifestPath}: releaseDate=${manifest.releaseDate}, expected ${releaseDate}`, + ); + } + if (typeof manifest.version !== "string" || manifest.version.length === 0) { + throw new Error(`${manifestPath}: missing or invalid version`); + } + if ( + manifestPath.endsWith("-mac.yml") && + manifest.minimumSystemVersion !== MACOS_MINIMUM_DARWIN_VERSION + ) { + throw new Error( + `${manifestPath}: minimumSystemVersion=${manifest.minimumSystemVersion}, expected ${MACOS_MINIMUM_DARWIN_VERSION}`, + ); + } + } +} + +function parseArgs(argv) { + const paths = []; + let releaseDate; + let rolloutHours; + + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] === "--release-date") { + releaseDate = argv[++index]; + } else if (argv[index] === "--rollout-hours") { + rolloutHours = Number(argv[++index]); + } else { + paths.push(argv[index]); + } + } + + if (!releaseDate || rolloutHours === undefined || paths.length === 0) { + throw new Error( + "Usage: node scripts/validate-desktop-manifests.mjs --release-date --rollout-hours ", + ); + } + + return { releaseDate, rolloutHours, paths }; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + const { releaseDate, rolloutHours, paths } = parseArgs(process.argv.slice(2)); + validateDesktopManifests({ releaseDate, rolloutHours }, paths); +} diff --git a/scripts/validate-desktop-manifests.test.mjs b/scripts/validate-desktop-manifests.test.mjs new file mode 100644 index 0000000000..2c461ac35d --- /dev/null +++ b/scripts/validate-desktop-manifests.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "vitest"; +import { validateDesktopManifests } from "./validate-desktop-manifests.mjs"; + +const releaseDate = "2026-09-04T00:00:00.000Z"; +const scriptPath = fileURLToPath(new URL("./validate-desktop-manifests.mjs", import.meta.url)); + +function withManifest(contents, run) { + const dir = mkdtempSync(path.join(tmpdir(), "paseo-validate-desktop-manifest-")); + const manifestPath = path.join(dir, "latest-mac.yml"); + try { + writeFileSync(manifestPath, contents); + run(manifestPath); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +} + +test("accepts a guarded macOS update manifest", () => { + withManifest( + `version: 0.7.3\nreleaseDate: '${releaseDate}'\nrolloutHours: 36\nminimumSystemVersion: 22.0.0\n`, + (manifestPath) => { + validateDesktopManifests({ releaseDate, rolloutHours: 36 }, [manifestPath]); + }, + ); +}); + +test("validates release manifests through the workflow CLI", () => { + withManifest( + `version: 0.7.3\nreleaseDate: '${releaseDate}'\nrolloutHours: 36\nminimumSystemVersion: 22.0.0\n`, + (manifestPath) => { + const result = spawnSync( + process.execPath, + [scriptPath, "--release-date", releaseDate, "--rollout-hours", "36", manifestPath], + { encoding: "utf8" }, + ); + + assert.equal(result.status, 0, result.stderr); + }, + ); +}); + +test("rejects a macOS update manifest without the system floor", () => { + withManifest( + `version: 0.7.3\nreleaseDate: '${releaseDate}'\nrolloutHours: 36\n`, + (manifestPath) => { + assert.throws( + () => validateDesktopManifests({ releaseDate, rolloutHours: 36 }, [manifestPath]), + /minimumSystemVersion=undefined, expected 22\.0\.0/, + ); + }, + ); +}); + +test("rejects invalid rollout metadata", () => { + withManifest( + `version: 0.7.3\nreleaseDate: '${releaseDate}'\nrolloutHours: 24\nminimumSystemVersion: 22.0.0\n`, + (manifestPath) => { + assert.throws( + () => validateDesktopManifests({ releaseDate, rolloutHours: 36 }, [manifestPath]), + /rolloutHours=24, expected 36/, + ); + }, + ); +}); From c6106762036e69c0b91005e30673fe5a44a52f30 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 4 Sep 2026 23:27:44 +0200 Subject: [PATCH 009/566] fix(providers): preserve state across configuration reloads (#4332) A provider configuration edit invalidated every cached catalog and could turn healthy providers into false availability errors under the resulting probe burst. Preserve unchanged registry entries, clients, loads, and snapshots, and keep the submitted draft snapshot stable while creation is pending. --- .../src/composer/draft/create-flow.test.ts | 104 ++++++++++++ .../app/src/composer/draft/create-flow.ts | 75 +++++---- .../src/server/agent/plugin-provider.ts | 1 + .../src/server/agent/provider-registry.ts | 4 + .../agent/provider-snapshot-manager.test.ts | 157 +++++++++++++++++- .../server/agent/provider-snapshot-manager.ts | 65 ++++++-- 6 files changed, 359 insertions(+), 47 deletions(-) diff --git a/packages/app/src/composer/draft/create-flow.test.ts b/packages/app/src/composer/draft/create-flow.test.ts index 471d1fc8b9..8587fa3db6 100644 --- a/packages/app/src/composer/draft/create-flow.test.ts +++ b/packages/app/src/composer/draft/create-flow.test.ts @@ -88,6 +88,110 @@ describe("useDraftAgentCreateFlow", () => { expect(onCreateSuccess).toHaveBeenCalledTimes(1); }); + it("keeps the submitted preview when the pending provider selection disappears", () => { + const attempt: DraftCreateAttempt = { + clientMessageId: "msg-provider-handoff", + text: "build this", + timestamp: new Date("2026-05-25T00:00:00.000Z"), + }; + const { result, rerender } = renderHook( + ({ provider }: { provider: string | null }) => + useDraftAgentCreateFlow({ + draftId: "draft-handoff", + getPendingServerId: () => "server-1", + initialAttempt: attempt, + buildDraftAgent: () => { + if (!provider) throw new Error("Select a model"); + return { provider, model: "selected-model" }; + }, + createRequest: async () => ({ agentId: "agent-1", result: { id: "agent-1" } }), + onCreateSuccess: () => undefined, + }), + { initialProps: { provider: "codex" } as { provider: string | null } }, + ); + expect(result.current.draftAgent).toEqual({ provider: "codex", model: "selected-model" }); + rerender({ provider: null }); + expect(result.current.isSubmitting).toBe(true); + expect(result.current.draftAgent).toEqual({ provider: "codex", model: "selected-model" }); + }); + + it("shows an invalid prepared selection as a form error instead of throwing in render", () => { + const createRequest = vi.fn(async () => ({ agentId: "agent-1", result: { id: "agent-1" } })); + const { result } = renderHook(() => + useDraftAgentCreateFlow({ + draftId: "draft-invalid", + getPendingServerId: () => "server-1", + initialAttempt: { + clientMessageId: "msg-invalid", + text: "build this", + timestamp: new Date(0), + }, + buildDraftAgent: () => { + throw new Error("Select a model"); + }, + createRequest, + onCreateSuccess: () => undefined, + }), + ); + expect(result.current.isSubmitting).toBe(false); + expect(result.current.draftAgent).toBeNull(); + expect(result.current.formErrorMessage).toBe("Select a model"); + expect(createRequest).not.toHaveBeenCalled(); + }); + + it("keeps a manual submission stable and lets a failed request retry with a new selection", async () => { + let failRequest!: (error: Error) => void; + const request = new Promise((_resolve, reject) => { + failRequest = reject; + }); + let requestCount = 0; + const { result, rerender } = renderHook( + ({ provider }: { provider: string | null }) => + useDraftAgentCreateFlow({ + draftId: "draft-manual", + getPendingServerId: () => "server-1", + buildDraftAgent: () => { + if (!provider) throw new Error("Select a model"); + return { provider }; + }, + createRequest: async () => { + requestCount++; + if (requestCount === 1) return await request; + return { agentId: "agent-1", result: { id: "agent-1" } }; + }, + onCreateSuccess: () => undefined, + }), + { initialProps: { provider: "codex" } as { provider: string | null } }, + ); + let submission!: Promise; + const captureError = (error: unknown) => error; + await act(async () => { + submission = result.current + .handleCreateFromInput({ text: "build this", attachments: [], cwd: "/repo" }) + .catch(captureError); + }); + rerender({ provider: null }); + expect(result.current.draftAgent).toEqual({ provider: "codex" }); + await act(async () => { + failRequest(new Error("Provider unavailable")); + await submission; + }); + expect(result.current.isSubmitting).toBe(false); + expect(result.current.draftAgent).toBeNull(); + expect(result.current.formErrorMessage).toBe("Provider unavailable"); + rerender({ provider: "claude" }); + await act(async () => { + await result.current.handleCreateFromInput({ + text: "try again", + attachments: [], + cwd: "/repo", + }); + }); + expect(result.current.draftAgent).toEqual({ provider: "claude" }); + expect(result.current.formErrorMessage).toBe(""); + expect(requestCount).toBe(2); + }); + it("allows retrying an empty prompt when the draft still has context attachments", async () => { const attachment = { kind: "chat_history", diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index 89a237e786..d1cfce1161 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -27,23 +27,23 @@ interface CreateAttempt { attachments?: AgentAttachment[]; } -type DraftAgentMachineState = +type DraftAgentMachineState = | { tag: "draft"; errorMessage: string } - | { tag: "creating"; attempt: CreateAttempt }; + | { tag: "creating"; attempt: CreateAttempt; draftAgent: TDraftAgent }; -type DraftAgentMachineEvent = +type DraftAgentMachineEvent = | { type: "DRAFT_SET_ERROR"; message: string } - | { type: "SUBMIT"; attempt: CreateAttempt } + | { type: "SUBMIT"; attempt: CreateAttempt; draftAgent: TDraftAgent } | { type: "CREATE_FAILED"; message: string }; function assertNever(value: never): never { throw new Error(`Unhandled state: ${JSON.stringify(value)}`); } -function reducer( - state: DraftAgentMachineState, - event: DraftAgentMachineEvent, -): DraftAgentMachineState { +function reducer( + state: DraftAgentMachineState, + event: DraftAgentMachineEvent, +): DraftAgentMachineState { switch (event.type) { case "DRAFT_SET_ERROR": { if (state.tag !== "draft") { @@ -52,7 +52,7 @@ function reducer( return { ...state, errorMessage: event.message }; } case "SUBMIT": { - return { tag: "creating", attempt: event.attempt }; + return { tag: "creating", attempt: event.attempt, draftAgent: event.draftAgent }; } case "CREATE_FAILED": { if (state.tag !== "creating") { @@ -65,6 +65,17 @@ function reducer( } } +function prepareCreateAttempt( + attempt: CreateAttempt, + buildDraftAgent: (attempt: CreateAttempt) => TDraftAgent, +): DraftAgentMachineState { + try { + return { tag: "creating", attempt, draftAgent: buildDraftAgent(attempt) }; + } catch (error) { + return { tag: "draft", errorMessage: error instanceof Error ? error.message : String(error) }; + } +} + interface CreateRequestResult { agentId: string | null; result: TCreateResult; @@ -113,11 +124,11 @@ export function useDraftAgentCreateFlow({ }: UseDraftAgentCreateFlowOptions) { const { t } = useTranslation(); const [machine, dispatch] = useReducer( - reducer, + reducer, initialAttempt, - (attempt): DraftAgentMachineState => + (attempt): DraftAgentMachineState => attempt - ? { tag: "creating", attempt } + ? prepareCreateAttempt(attempt, buildDraftAgent) : { tag: "draft", errorMessage: "", @@ -163,12 +174,18 @@ export function useDraftAgentCreateFlow({ ]; }, [machine]); - const draftAgent = useMemo(() => { - if (machine.tag !== "creating") { - return null; - } - return buildDraftAgent(machine.attempt); - }, [buildDraftAgent, machine]); + const draftAgent = machine.tag === "creating" ? machine.draftAgent : null; + const startCreateAttempt = useCallback( + (attempt: CreateAttempt) => { + const prepared = prepareCreateAttempt(attempt, buildDraftAgent); + if (prepared.tag === "draft") { + dispatch({ type: "DRAFT_SET_ERROR", message: prepared.errorMessage }); + throw new Error(prepared.errorMessage); + } + dispatch({ type: "SUBMIT", attempt, draftAgent: prepared.draftAgent }); + }, + [buildDraftAgent], + ); const runCreateAttempt = useCallback( async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => { @@ -179,15 +196,14 @@ export function useDraftAgentCreateFlow({ throw error; } - await onBeforeSubmit?.({ - attempt, - text: attempt.text, - images: attempt.images, - attachments: attempt.attachments, - cwd, - }); - try { + await onBeforeSubmit?.({ + attempt, + text: attempt.text, + images: attempt.images, + attachments: attempt.attachments, + cwd, + }); const createResult = await createRequest({ attempt, text: attempt.text, @@ -287,6 +303,7 @@ export function useDraftAgentCreateFlow({ ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), }; + startCreateAttempt(attempt); setPendingCreateAttempt({ draftId, serverId: pendingServerId, @@ -300,7 +317,6 @@ export function useDraftAgentCreateFlow({ : {}), }); - dispatch({ type: "SUBMIT", attempt }); onCreateStart?.(); await runCreateAttempt({ attempt, cwd }); }, @@ -312,6 +328,7 @@ export function useDraftAgentCreateFlow({ onCreateStart, runCreateAttempt, setPendingCreateAttempt, + startCreateAttempt, t, validateBeforeSubmit, ], @@ -320,11 +337,11 @@ export function useDraftAgentCreateFlow({ const continueCreateFromAttempt = useCallback( async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => { if (!isSubmitting) { - dispatch({ type: "SUBMIT", attempt }); + startCreateAttempt(attempt); } await runCreateAttempt({ attempt, cwd }); }, - [isSubmitting, runCreateAttempt], + [isSubmitting, runCreateAttempt, startCreateAttempt], ); return { diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index c2eb5e08de..a9dd271220 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -805,6 +805,7 @@ function createPluginProviderDefinition( ): ProviderDefinition { return { id: registration.id, + configuration: null, label: registration.label, description: registration.description ?? `Plugin provider ${registration.label}`, iconSvg: registration.icon, diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 5c9231f095..ac70c936cb 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -70,6 +70,8 @@ export type { AgentProviderDefinition }; export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition }; export interface ProviderDefinition extends AgentProviderDefinition { + /** Effective inputs after overrides and inheritance; plugin registrations are owned separately. */ + configuration: Omit | null; iconSvg?: string; enabled: boolean; /** @@ -608,8 +610,10 @@ function createRegistryEntry( const hasStaticModes = resolved.definition.modes.length > 0; + const { createBaseClient: _createBaseClient, contract: _contract, ...configuration } = resolved; return { ...resolved.definition, + configuration, enabled: resolved.enabled, derivedFromProviderId: resolved.derivedFromProviderId, optionsSchema: resolved.contract.optionsSchema, diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 21f8f51626..f30cd6e92c 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -1458,6 +1458,155 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { } }); + test("changing one provider preserves other catalogs and clients across directories", async () => { + const calls = { claude: 0, codex: 0 }; + const clients = Object.fromEntries( + (["claude", "codex"] as const).map((provider) => [ + provider, + createExtraClient(provider, { + async isAvailable() { + return true; + }, + async fetchCatalog() { + calls[provider]++; + return { models: [], modes: [] }; + }, + }), + ]), + ); + const config = { + claude: { enabled: true }, + codex: { enabled: true, command: ["codex"], env: { TEST_SETTING: "same" } }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + omp: { enabled: false }, + }; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: config, + runtimeSettings: { + codex: { command: { mode: "replace", argv: ["codex"] }, env: { TEST_SETTING: "same" } }, + }, + extraClients: clients, + }); + const cwds = [resolve("/tmp/catalog-a"), resolve("/tmp/catalog-b")]; + try { + for (const cwd of cwds) await manager.warmUpSnapshotForCwd({ cwd }); + const getCodexEntry = (cwd: string) => + manager.getSnapshot(cwd).find((entry) => entry.provider === "codex"); + const before = cwds.map(getCodexEntry); + const definition = manager.getAgentManagerProviderState().providerDefinitions.codex; + manager.applyMutableProviderConfig( + { ...config, claude: { enabled: true, label: "Renamed" } }, + { replace: true }, + ); + expect(cwds.map(getCodexEntry)).toEqual(before); + expect(manager.getAgentManagerProviderState().providerDefinitions.codex).toEqual(definition); + for (const cwd of cwds) await manager.warmUpSnapshotForCwd({ cwd }); + expect(calls).toEqual({ claude: 4, codex: 2 }); + const listener = vi.fn(); + manager.on("change", listener); + manager.applyMutableProviderConfig( + { ...config, claude: { label: "Renamed", enabled: true } }, + { replace: true }, + ); + for (const cwd of cwds) await manager.warmUpSnapshotForCwd({ cwd }); + expect(calls).toEqual({ claude: 4, codex: 2 }); + expect(listener).not.toHaveBeenCalled(); + } finally { + manager.destroy(); + } + }); + + test("reload preserves an unchanged lookup in flight through commit and rollback", async () => { + let finish!: () => void; + const gate = new Promise((complete) => { + finish = complete; + }); + let calls = 0; + const config = { + claude: { enabled: false }, + codex: { enabled: true }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + omp: { enabled: false }, + }; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: config, + extraClients: { + codex: createExtraClient("codex", { + async isAvailable() { + return true; + }, + async fetchCatalog() { + calls++; + await gate; + return { models: [], modes: [] }; + }, + }), + }, + }); + const cwd = resolve("/tmp/catalog-pending"); + try { + const pending = manager.warmUpSnapshotForCwd({ cwd }); + await vi.waitFor(() => expect(calls).toBe(1)); + manager.applyMutableProviderConfig( + { ...config, claude: { enabled: false, label: "Changed" } }, + { replace: true }, + ); + const staged = manager.stageMutableProviderConfig( + { ...config, claude: { enabled: false, label: "Rollback" } }, + { replace: true }, + ); + staged.rollback(); + finish(); + await pending; + expect(manager.getSnapshot(cwd).find((e) => e.provider === "codex")?.status).toBe("ready"); + expect(calls).toBe(1); + } finally { + finish(); + manager.destroy(); + } + }); + + test("reload replaces derived clients only when their provider configuration changes", () => { + const config = { + claude: { enabled: true }, + codex: { enabled: true }, + "codex-2": { extends: "codex", label: "Codex 2", enabled: true }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + omp: { enabled: false }, + }; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: config, + }); + try { + const before = manager.getAgentManagerProviderState().clients; + const unchanged = manager.applyMutableProviderConfig(config, { replace: true }).clients; + expect(unchanged.codex).toBe(before.codex); + expect(unchanged["codex-2"]).toBe(before["codex-2"]); + const changed = manager.applyMutableProviderConfig( + { ...config, codex: { enabled: true, env: { CODEX_HOME: "/tmp/other-codex-home" } } }, + { replace: true }, + ).clients; + expect(changed.claude).toBe(before.claude); + expect(changed.codex).not.toBe(before.codex); + expect(changed["codex-2"]).not.toBe(before["codex-2"]); + const { "codex-2": _removedProvider, ...withoutDerived } = config; + const removed = manager.applyMutableProviderConfig(withoutDerived, { replace: true }).clients; + expect(removed["codex-2"]).toBeUndefined(); + expect(removed.claude).toBe(before.claude); + } finally { + manager.destroy(); + } + }); + test("stages provider state without events, then publishes or rolls back", () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger(), @@ -1597,12 +1746,12 @@ describe("ProviderSnapshotManager lifecycle", () => { const listener = vi.fn(); manager.on("change", listener); manager.getSnapshot("/tmp/project"); - manager.applyMutableProviderConfig({}); + manager.applyMutableProviderConfig({ claude: { enabled: false, label: "Changed" } }); const firstCallCount = listener.mock.calls.length; - expect(firstCallCount).toBeGreaterThan(0); + expect(firstCallCount).toBe(1); manager.off("change", listener); - manager.applyMutableProviderConfig({}); + manager.applyMutableProviderConfig({ claude: { enabled: false, label: "Changed again" } }); expect(listener.mock.calls.length).toBe(firstCallCount); } finally { manager.destroy(); @@ -1724,7 +1873,7 @@ describe("ProviderSnapshotManager cwd routing", () => { const listener = vi.fn(); manager.on("change", listener); manager.getSnapshot(); - manager.applyMutableProviderConfig({}); + manager.applyMutableProviderConfig({ claude: { enabled: false, label: "Changed" } }); const cwds = listener.mock.calls.map((call) => call[1]); expect(cwds).toContain(GLOBAL_PROVIDER_SNAPSHOT_KEY); } finally { diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 5b60f68cca..5217a1c738 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -1,6 +1,7 @@ import { EventEmitter } from "node:events"; import { homedir } from "node:os"; import { resolve } from "node:path"; +import { isDeepStrictEqual } from "node:util"; import type { Logger } from "pino"; @@ -546,28 +547,38 @@ export class ProviderSnapshotManager { ); // The mutable config is the complete provider source after startup. Keeping // startup-derived runtime settings here would retain removed command/env fields. - if (options.replace) this.runtimeSettings = undefined; - this.providerRegistry = this.buildRegistry(); - this.providerClients = { - ...this.extraClients, - ...this.pluginProviders.clients(), - } as Record; + const nextRegistry = this.buildRegistry(); + const changedProviders = this.findChangedProviders(previous, nextRegistry); - for (const cwd of this.snapshots.keys()) { - this.providerLoads.delete(cwd); - this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd)); + this.providerRegistry = nextRegistry; + this.providerClients = { ...previous.providerClients }; + for (const provider of changedProviders) delete this.providerClients[provider]; + for (const provider of Object.keys(nextRegistry)) { + if (!changedProviders.has(provider)) { + nextRegistry[provider] = previous.providerRegistry[provider]!; + } + } + Object.assign(this.providerClients, this.extraClients, this.pluginProviders.clients()); + const providersToRefresh = [...changedProviders].filter((provider) => nextRegistry[provider]); + + for (const cwd of snapshotCwds) { + const loads = new Map(this.providerLoads.get(cwd)); + for (const provider of changedProviders) loads.delete(provider); + this.providerLoads.set(cwd, loads); + this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd, changedProviders)); } return { agentManagerState: this.getAgentManagerProviderState(), publish: () => { + if (changedProviders.size === 0) return; for (const cwd of snapshotCwds) { this.emitChange(cwd); const target = cwd === GLOBAL_PROVIDER_SNAPSHOT_KEY ? createGlobalSnapshotTarget() : createWorkspaceSnapshotTarget(cwd); - const providers = this.resolveProvidersToWarm(cwd); + const providers = this.resolveProvidersToWarm(cwd, providersToRefresh); if (providers.length > 0) void this.warmUp(target, providers); } }, @@ -579,6 +590,25 @@ export class ProviderSnapshotManager { } } + private findChangedProviders( + previous: MutableProviderState, + nextRegistry: Record, + ): Set { + const providers = new Set([ + ...Object.keys(previous.providerRegistry), + ...Object.keys(nextRegistry), + ]); + const changed = new Set(); + for (const provider of providers) { + const before = previous.providerRegistry[provider]; + const after = nextRegistry[provider]; + if (!before || !after || !isDeepStrictEqual(before.configuration, after.configuration)) { + changed.add(provider); + } + } + return changed; + } + private captureMutableProviderState(): MutableProviderState { return { baseProviderOverrides: this.baseProviderOverrides, @@ -779,7 +809,7 @@ export class ProviderSnapshotManager { const definition = this.providerRegistry[provider]; entries.set(provider, { provider, - status: "loading", + status: definition?.enabled === false ? "unavailable" : "loading", enabled: definition?.enabled ?? true, source: this.getProviderSource(provider), label: definition?.label, @@ -791,13 +821,20 @@ export class ProviderSnapshotManager { return entries; } - private reconcileSnapshotForRegistry(cwd: string): Map { + private reconcileSnapshotForRegistry( + cwd: string, + changedProviders?: ReadonlySet, + ): Map { const existing = this.snapshots.get(cwd); const entries = new Map(); for (const provider of this.getProviderIds()) { const definition = this.providerRegistry[provider]; const current = existing?.get(provider); + if (current && changedProviders && !changedProviders.has(provider)) { + entries.set(provider, current); + continue; + } const metadata = { provider, enabled: definition?.enabled ?? true, @@ -967,7 +1004,6 @@ export class ProviderSnapshotManager { force: boolean; }): Promise { const { snapshotCwd, catalogScope, provider, definition, load, force } = options; - const snapshot = this.getOrCreateSnapshot(snapshotCwd); const base = { provider, source: this.getProviderSource(provider), @@ -980,7 +1016,8 @@ export class ProviderSnapshotManager { if (!this.isCurrentProviderLoad(snapshotCwd, provider, load)) { return false; } - snapshot.set(provider, entry); + // A config transaction may replace the map while this unchanged provider is loading. + this.getOrCreateSnapshot(snapshotCwd).set(provider, entry); this.emitChange(snapshotCwd); return true; }; From f4b209be4d81d25a6143d12d374d797d485e8faa Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 4 Sep 2026 23:49:26 +0200 Subject: [PATCH 010/566] Preserve nested subagent ownership (#4321) * fix(subagents): preserve recursive provider ownership Provider events retained enough lineage to identify nested work, but the canonical descriptor discarded the direct owner. Preserve that relationship at ingestion so child timelines and hierarchy remain attached to the process that emitted them, including after history replay. * test(e2e): clarify nested ownership journey * test(e2e): separate nested ownership actions * fix(subagents): retain nested ownership across replay Wait and interaction events can reference descendants without spawning them. Preserve the original spawning owner and route persisted background notifications through that same relationship after history reload. * test(subagents): hide provider turn plumbing * fix(subagents): scope nested tracks to their host Plugin-provided icons are host-specific. Carry the panel host through nested child tracks so the rebased ownership UI resolves the same provider presentation as its parent. * fix(workspaces): preserve launched terminal focus --- docs/agent-lifecycle.md | 7 +- .../browser/provider-subagents.real.spec.ts | 27 +++- .../e2e/support/helpers/provider-subagents.ts | 94 +++++++++++ .../app/e2e/support/helpers/rewind-flow.ts | 6 +- .../src/panels/provider-subagent-panel.tsx | 62 +++++++- .../app/src/screens/new-workspace-screen.tsx | 20 ++- packages/app/src/subagents/select.test.ts | 48 ++++++ packages/app/src/subagents/select.ts | 19 ++- packages/protocol/src/messages.ts | 4 + .../server/agent/provider-subagents/store.ts | 4 + .../claude/agent.background-subagent.test.ts | 97 ++++++++++++ ...nested-subagent-ownership.real.e2e.test.ts | 149 ++++++++++++++++++ .../claude/agent.sub-agent-replay.test.ts | 83 ++++++++++ .../server/agent/providers/claude/agent.ts | 113 +++++++++---- .../claude/subagents/live-source.test.ts | 77 +++++++++ .../providers/claude/subagents/live-source.ts | 87 +++++++--- .../providers/claude/subagents/observation.ts | 5 + .../claude/subagents/replay-source.test.ts | 72 +++++---- .../claude/subagents/replay-source.ts | 91 ++++++++--- .../task-notification-tool-call.test.ts | 12 ++ .../claude/task-notification-tool-call.ts | 6 +- .../providers/codex-app-server-agent.test.ts | 143 +++++++++++++++++ .../agent/providers/codex-app-server-agent.ts | 41 ++++- .../agent/providers/opencode-agent.test.ts | 30 +++- .../server/agent/providers/opencode-agent.ts | 10 +- .../test-utils/session-stream-adapter.ts | 12 ++ .../server/src/server/websocket-server.ts | 2 + 27 files changed, 1193 insertions(+), 128 deletions(-) create mode 100644 packages/app/e2e/support/helpers/provider-subagents.ts create mode 100644 packages/server/src/server/agent/providers/claude/agent.nested-subagent-ownership.real.e2e.test.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index d9f9710599..20a823de80 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -156,9 +156,9 @@ The rows combine two kinds of children: parentAgentId === thisAgent.id AND !archivedAt ``` -- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. +- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent. -Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical. +Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical. Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only. @@ -174,7 +174,8 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta - **A resumed task can be announced again with a new `tool_use_id`.** The first Task tool id remains the canonical descriptor and later ids are routing aliases for the same session-scoped task. The resumed prompt is added to that child timeline. - **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`. - **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them. -- **On replay, `/subagents/` holds every descendant, not just this session's children.** `agent-.meta.json` carries `spawnDepth`: `1` is a direct child, `2+` was spawned by another subagent and its `toolUseId` names a Task call made inside its parent's session, which nothing in this transcript can resolve. Replaying those adds rows the live stream never showed, each with no Task card and no recoverable outcome, so they render as running forever. One recorded session showed 10 subagents live and would have replayed 22. +- **Nested ownership comes from the launching sidechain, not `spawn_depth` alone.** A sidechain's Agent or Bash tool call records the direct owner of that `tool_use_id`; the following `task_started` inherits it. This routes a grandchild descriptor and child-owned background notifications without relying on labels or flattening them into the managed parent. +- **On replay, `/subagents/` holds every descendant beside the root.** Resolve the tree one proven generation at a time: the root transcript admits direct children, then each admitted sidechain transcript admits its children by `toolUseId`. `spawnDepth` orders candidates but does not establish ownership. Unresolved sidecars remain excluded as ambient or unrelated work. - **Replay `totalTokens` is a context-size reading, not cumulative spend.** Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as `usage.total_tokens`. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path. Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls. diff --git a/packages/app/e2e/browser/provider-subagents.real.spec.ts b/packages/app/e2e/browser/provider-subagents.real.spec.ts index 46b9d80c3a..08d8a1afe1 100644 --- a/packages/app/e2e/browser/provider-subagents.real.spec.ts +++ b/packages/app/e2e/browser/provider-subagents.real.spec.ts @@ -9,6 +9,11 @@ import { type AgentHandle, type RewindFlowProvider, } from "../support/helpers/rewind-flow"; +import { + expectNestedProviderSubagentOwnership, + launchNestedProviderSubagentOwnershipScenario, + reopenNestedProviderSession, +} from "../support/helpers/provider-subagents"; import { openSubagentsTrack } from "../support/helpers/subagents"; interface ProviderSubagentCase { @@ -26,7 +31,7 @@ const cases: ProviderSubagentCase[] = [ provider: "claude", sentinel: "CLAUDE_CHILD_SENTINEL", expectedName: "sentinel_child", - providerConfig: { model: "opus" }, + providerConfig: { model: "claude-sonnet-5" }, prompt: 'Use Claude Code\'s native Task tool exactly once. Set its subagent_type input to "Explore" and its name input to "sentinel_child". Ask it to reply with exactly CLAUDE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE. Do not use Paseo tools.', }, @@ -147,3 +152,23 @@ test.describe("real provider subagent timelines", () => { }); } }); + +test.describe("real Claude nested subagent ownership", () => { + test.setTimeout(600_000); + + test("keeps a grandchild and its background notification with their direct owners", async ({ + page, + }, testInfo) => { + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-claude-nested-ui-"))); + let handle: AgentHandle | undefined; + + try { + handle = await launchNestedProviderSubagentOwnershipScenario(page, cwd); + await expectNestedProviderSubagentOwnership(page, testInfo, "live"); + await reopenNestedProviderSession(handle); + await expectNestedProviderSubagentOwnership(page, testInfo, "reopened"); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } + }); +}); diff --git a/packages/app/e2e/support/helpers/provider-subagents.ts b/packages/app/e2e/support/helpers/provider-subagents.ts new file mode 100644 index 0000000000..33c084d747 --- /dev/null +++ b/packages/app/e2e/support/helpers/provider-subagents.ts @@ -0,0 +1,94 @@ +import { expect, type Locator, type Page, type TestInfo } from "@playwright/test"; + +import { launchAgent, sendMessage, type AgentHandle } from "./rewind-flow"; +import { openSubagentsTrack } from "./subagents"; + +const NESTED_OWNERSHIP_PROMPT = + "You are ROOT_OWNER. Use Claude Code's native Agent tool exactly once, never Paseo tools. " + + "Name the agent direct_owner and give it this complete task: You are DIRECT_OWNER. Use " + + "Claude Code's native Agent tool exactly once. Name that agent nested_owner and give it " + + "this complete task: You are NESTED_OWNER. Use Bash exactly once to run `sleep 2; printf " + + "'NESTED_BACKGROUND_SENTINEL\\n'` with run_in_background true. Wait for the background " + + "command's completion notification. Use TaskOutput with block true to wait for the Bash task and verify exit code 0 and the sentinel output before replying exactly NESTED_DONE. Do not finish while it is running. Wait for nested_owner " + + "to finish, then reply exactly DIRECT_DONE. Wait for direct_owner to finish, then reply " + + "exactly ROOT_DONE."; + +export async function launchNestedProviderSubagentOwnershipScenario( + page: Page, + cwd: string, +): Promise { + const handle = await launchAgent({ + page, + provider: "claude", + cwd, + mode: "full-access", + providerConfig: { model: "claude-sonnet-5" }, + }); + await sendMessage(handle, NESTED_OWNERSHIP_PROMPT); + await expect(page.getByText("ROOT_DONE", { exact: true }).last()).toBeVisible({ + timeout: 120_000, + }); + return handle; +} + +async function attachScreenshot(page: Page, testInfo: TestInfo, name: string): Promise { + const path = testInfo.outputPath(`${name}.png`); + await page.screenshot({ path }); + await testInfo.attach(name, { path, contentType: "image/png" }); +} + +async function expectOnlySubagent(page: Page, label: RegExp): Promise { + const rows = page.locator('[data-testid^="subagents-track-row-"]'); + await expect(rows).toHaveCount(1); + const row = page.getByTestId("subagents-track-header-panel").getByRole("button", { name: label }); + await expect(row).toBeVisible(); + return row; +} + +async function selectOnlyRootSubagent(page: Page, label: RegExp): Promise { + await openSubagentsTrack(page); + const row = await expectOnlySubagent(page, label); + await row.click(); +} + +async function openOwnedSubagents(page: Page, ownerPanel: Locator): Promise { + await ownerPanel.getByTestId("subagents-track-header").click(); + await expect(page.getByTestId("subagents-track-header-panel")).toBeVisible(); +} + +export async function expectNestedProviderSubagentOwnership( + page: Page, + testInfo: TestInfo, + phase: string, +): Promise { + await expect( + page.getByText("Task notification", { exact: true }).filter({ visible: true }), + ).toHaveCount(0); + await selectOnlyRootSubagent(page, /^direct_owner\b/); + const directPanel = page.getByTestId("provider-subagent-panel").filter({ visible: true }); + await expect(directPanel).toBeVisible(); + + await openOwnedSubagents(page, directPanel); + const nestedRow = await expectOnlySubagent(page, /^nested_owner\b/); + await attachScreenshot(page, testInfo, `nested-ownership-${phase}-tree`); + await nestedRow.click(); + + const nestedPanel = page.locator('[data-testid="provider-subagent-panel"]:visible'); + await expect( + nestedPanel + .getByRole("button", { + name: /^Task notification Background command .*completed \(exit code 0\)/, + }) + .first(), + ).toBeVisible(); + await expect(nestedPanel).toContainText("NESTED_BACKGROUND_SENTINEL"); + await expect(nestedPanel).toContainText("NESTED_DONE"); + await attachScreenshot(page, testInfo, `nested-ownership-${phase}-timeline`); +} + +export async function reopenNestedProviderSession(handle: AgentHandle): Promise { + await handle.client.refreshAgent(handle.agentId); + await handle.page.reload(); + await handle.page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click(); + await expect(handle.page.getByText("ROOT_DONE", { exact: true }).last()).toBeVisible(); +} diff --git a/packages/app/e2e/support/helpers/rewind-flow.ts b/packages/app/e2e/support/helpers/rewind-flow.ts index a9dace4f81..22f4cf5bcd 100644 --- a/packages/app/e2e/support/helpers/rewind-flow.ts +++ b/packages/app/e2e/support/helpers/rewind-flow.ts @@ -318,9 +318,9 @@ export async function assertFileContains(filePath: string, text: string): Promis export async function assertComposerIdle(handle: Pick): Promise { await expectComposerEditable(handle.page); - await expect(handle.page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, { - timeout: 30_000, - }); + await expect( + handle.page.getByRole("button", { name: /^(?:Stop agent|Canceling agent)$/ }), + ).toHaveCount(0, { timeout: 30_000 }); await expect(handle.page.getByTestId("turn-working-indicator")).toHaveCount(0, { timeout: 30_000, }); diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index cc4cb18dd4..80323561ba 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -5,10 +5,18 @@ import invariant from "tiny-invariant"; import { useShallow } from "zustand/react/shallow"; import { AgentStreamView } from "@/agent-stream/view"; import { getProviderIcon } from "@/components/provider-icons"; +import { + resolveComposerTrackControlClearance, + resolveComposerTrackTailClearance, +} from "@/composer/pill-styles"; +import { ComposerTrackBar } from "@/composer/tracks"; +import { useIsCompactFormFactor } from "@/constants/layout"; import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine"; import { usePaneContext } from "@/panels/pane-context"; import { definePanel, type PanelDescriptor } from "@/panels/panel-registry"; import { useSessionStore } from "@/stores/session-store"; +import { useSubagentsForParent } from "@/subagents/select"; +import { SubagentsTrack } from "@/subagents/track"; import { providerSubagentKey, providerSubagentLifecycleStatus, @@ -24,6 +32,38 @@ import type { TurnPresentation } from "@/timeline/turn-liveness"; const EMPTY_PERMISSIONS = new Map(); const EMPTY_STREAM_ITEMS: StreamItem[] = []; +const NOOP_SUBAGENT = () => undefined; + +function resolveChildTrackClearance(childCount: number, isCompact: boolean) { + if (childCount === 0) return { tail: 0, controls: 0 }; + return { + tail: resolveComposerTrackTailClearance(isCompact), + controls: resolveComposerTrackControlClearance(isCompact), + }; +} + +function ProviderSubagentChildTrack({ + serverId, + rows, + onOpenProviderSubagent, +}: { + serverId: string; + rows: ReturnType; + onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; +}) { + if (rows.length === 0) return null; + return ( + + + + ); +} function formatProviderLabel(provider: string): string { return provider @@ -68,7 +108,7 @@ function useProviderSubagentDescriptor( function ProviderSubagentPanel() { const { t } = useTranslation(); - const { serverId, target, openFileInWorkspace } = usePaneContext(); + const { serverId, target, openFileInWorkspace, openTab } = usePaneContext(); invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target"); const key = providerSubagentKey(serverId, target.parentAgentId, target.subagentId); const streamId = `provider:${encodeURIComponent(target.parentAgentId)}:${encodeURIComponent(target.subagentId)}`; @@ -89,6 +129,19 @@ function ProviderSubagentPanel() { // COMPAT(providerSubagents): added in v0.2.11, remove after 2027-01-12. const supported = serverInfo?.features?.providerSubagents === true; const [isLoadingOlder, setIsLoadingOlder] = useState(false); + const isCompact = useIsCompactFormFactor(); + const childRows = useSubagentsForParent({ + serverId, + parentAgentId: target.parentAgentId, + providerParentSubagentId: target.subagentId, + }); + const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact); + const openProviderChild = useCallback( + (parentAgentId: string, subagentId: string) => { + openTab({ kind: "provider_subagent", parentAgentId, subagentId }); + }, + [openTab], + ); useEffect(() => { if (!client || !supported) return; @@ -207,6 +260,13 @@ function ProviderSubagentPanel() { onOpenWorkspaceFile={openFileInWorkspace} readOnly historyPagination={historyPagination} + bottomOverlayTailClearance={childTrackClearance.tail} + bottomOverlayControlClearance={childTrackClearance.controls} + /> + ); diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 10261c1eae..ac84be35f8 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -117,6 +117,11 @@ import { } from "./new-workspace-initial-context"; import { buildNewWorkspaceProjectIconTargets } from "./new-workspace/project-icon-targets"; import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker"; +import { + buildTerminalsQueryKey, + type ListTerminalsPayload, + upsertCreatedTerminalPayload, +} from "./workspace/terminals/state"; const ThemedFolderPlus = withUnistyles(FolderPlus); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -2106,10 +2111,20 @@ export function NewWorkspaceScreen({ undefined, { command: input.command, args: input.args, workspaceId: input.workspaceId }, ); - if (!createdTerminal.terminal) { + const terminal = createdTerminal.terminal; + if (!terminal) { throw new Error(createdTerminal.error ?? t("newWorkspace.errors.createWorktreeFailed")); } - return { terminalId: createdTerminal.terminal.id }; + queryClient.setQueryData( + buildTerminalsQueryKey(selectedServerId, input.workspaceDirectory, input.workspaceId), + (current) => + upsertCreatedTerminalPayload({ + current, + terminal, + workspaceDirectory: input.workspaceDirectory, + }), + ); + return { terminalId: terminal.id }; }, sendTerminalInput: (terminalId, data) => { withConnectedClient().sendTerminalInput(terminalId, { type: "input", data }); @@ -2127,6 +2142,7 @@ export function NewWorkspaceScreen({ }, [ ensureWorkspace, launchTarget, + queryClient, selectedServerId, selectedSourceDirectory, selectedTerminalProfile, diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index 5229acf88d..068210233a 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -128,6 +128,54 @@ describe("selectSubagentsForParent", () => { expect(useProviderSubagentStore.getState().descriptors.size).toBe(1); }); + it("places nested provider children only beneath their direct provider parent", () => { + const store = useProviderSubagentStore.getState(); + const base = { + parentAgentId: "parent-a", + provider: "claude" as const, + title: "general-purpose", + subtitle: null, + status: "running" as const, + createdAt: "2026-09-04T10:00:00.000Z", + updatedAt: "2026-09-04T10:00:00.000Z", + toolCallId: null, + }; + store.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { ...base, id: "direct", description: "Direct", parentSubagentId: null }, + }); + store.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { + ...base, + id: "nested", + description: "Nested", + parentSubagentId: "direct", + }, + }); + + expect( + selectProviderSubagentsForParent( + useProviderSubagentStore.getState(), + { serverId: SERVER_ID, parentAgentId: "parent-a" }, + true, + true, + ).map((row) => row.id), + ).toEqual(["direct"]); + expect( + selectProviderSubagentsForParent( + useProviderSubagentStore.getState(), + { + serverId: SERVER_ID, + parentAgentId: "parent-a", + providerParentSubagentId: "direct", + }, + true, + true, + ).map((row) => row.id), + ).toEqual(["nested"]); + }); + it("returns only non-archived children for the requested parent", () => { setAgents([ makeAgent({ id: "parent-a" }), diff --git a/packages/app/src/subagents/select.ts b/packages/app/src/subagents/select.ts index cc07480b33..b9e1883705 100644 --- a/packages/app/src/subagents/select.ts +++ b/packages/app/src/subagents/select.ts @@ -44,6 +44,8 @@ type ProviderSubagentStoreSnapshot = ReturnType state.sessions[params.serverId]?.serverInfo?.features?.providerSubagents === true, ); + const nestingSupported = useSessionStore( + (state) => + state.sessions[params.serverId]?.serverInfo?.features?.providerSubagentNesting === true, + ); const providerRows = useStoreWithEqualityFn( useProviderSubagentStore, - (state) => selectProviderSubagentsForParent(state, params, supported), + (state) => selectProviderSubagentsForParent(state, params, supported, nestingSupported), equal, ); const client = useSessionStore((state) => state.sessions[params.serverId]?.client ?? null); @@ -145,9 +159,10 @@ export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRo }, [client, params.parentAgentId, params.serverId, 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()); return rows; - }, [paseoRows, providerRows]); + }, [params.providerParentSubagentId, paseoRows, providerRows]); } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index cabd2eb09e..6c9fd5f810 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3493,6 +3493,8 @@ export const ServerInfoStatusPayloadSchema = z agentForkContextCursor: z.boolean().optional(), // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: z.boolean().optional(), + // COMPAT(providerSubagentNesting): added in v0.7, remove gate after 2027-03-04. + providerSubagentNesting: z.boolean().optional(), // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. workspacePinning: z.boolean().optional(), // COMPAT(hubRelationship): added in v0.1.X, drop the gate when floor >= v0.1.X. @@ -4458,6 +4460,8 @@ export const AgentTimelineListPromptsResponseMessageSchema = z.object({ export const ProviderSubagentDescriptorPayloadSchema = z.object({ id: z.string(), parentAgentId: z.string(), + // COMPAT(providerSubagentNesting): added in v0.7, remove optional after 2027-03-04. + parentSubagentId: z.string().nullable().optional(), provider: AgentProviderSchema, title: z.string().nullable(), description: z.string().nullable(), diff --git a/packages/server/src/server/agent/provider-subagents/store.ts b/packages/server/src/server/agent/provider-subagents/store.ts index 8f2ccc60a5..d9a298e790 100644 --- a/packages/server/src/server/agent/provider-subagents/store.ts +++ b/packages/server/src/server/agent/provider-subagents/store.ts @@ -13,6 +13,8 @@ export type ProviderSubagentStatus = "running" | "completed" | "failed" | "cance export interface ProviderSubagentDescriptor { id: string; parentAgentId: string; + /** Direct provider-subagent parent. Null identifies a child of the managed agent. */ + parentSubagentId: string | null; provider: AgentProvider; title: string | null; description: string | null; @@ -38,6 +40,7 @@ export type ProviderSubagentInputEvent = toolCallId?: string | null; cwd?: string | null; subtitle?: string | null; + parentSubagentId?: string | null; timestamp?: string; } | { @@ -123,6 +126,7 @@ export class ProviderSubagentStore { toolCallId: stickyField(event.toolCallId, previous?.toolCallId), cwd: stickyField(event.cwd, previous?.cwd), subtitle: stickyField(event.subtitle, previous?.subtitle), + parentSubagentId: stickyField(event.parentSubagentId, previous?.parentSubagentId), }; this.descriptors.set(key, subagent); return { type: "upsert", subagent }; diff --git a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts index 694c804e38..a767dc9c2a 100644 --- a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts @@ -313,4 +313,101 @@ describe("background Claude subagents", () => { }, }); }); + + test.each([ + { + source: "system task protocol", + notification: { + type: "system", + subtype: "task_notification", + task_id: "bash-task", + tool_use_id: "toolu_bash", + status: "completed", + summary: "sleep 2; printf NESTED_BACKGROUND_SENTINEL", + }, + }, + { + source: "queued user envelope", + notification: { + type: "user", + uuid: "bash-notification", + message: { + role: "user", + content: + "\nbash-task\ntoolu_bash\ncompleted\nsleep 2; printf NESTED_BACKGROUND_SENTINEL\n", + }, + }, + }, + ])( + "routes a child-owned background notification from $source to that child's timeline", + async ({ notification }) => { + queryFactory.mockImplementation(() => + buildQueryMock([ + { type: "system", subtype: "init", session_id: "nested-session" }, + { + type: "system", + subtype: "task_started", + task_id: "direct-task", + tool_use_id: "toolu_direct", + task_type: "local_agent", + subagent_type: "general-purpose", + description: "Run a background command", + spawn_depth: 1, + }, + { + type: "assistant", + parent_tool_use_id: "toolu_direct", + message: { + model: "claude-sonnet-5", + content: [ + { + type: "tool_use", + id: "toolu_bash", + name: "Bash", + input: { command: "sleep 2; printf NESTED_BACKGROUND_SENTINEL" }, + }, + ], + }, + }, + { + type: "system", + subtype: "task_started", + task_id: "bash-task", + tool_use_id: "toolu_bash", + task_type: "local_bash", + owned_by_subagent: true, + is_backgrounded: true, + }, + notification, + { type: "result", subtype: "success", usage: {}, total_cost_usd: 0 }, + ]), + ); + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd() }); + const events = await collectUntilTerminal(streamSession(session, "delegate work")); + await session.close(); + + expect( + events.filter( + (event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.name === "task_notification", + ), + ).toEqual([]); + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ + type: "timeline", + id: "toolu_direct", + item: expect.objectContaining({ type: "tool_call", name: "task_notification" }), + }), + }), + ); + }, + ); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.nested-subagent-ownership.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent.nested-subagent-ownership.real.e2e.test.ts new file mode 100644 index 0000000000..af66ba9ac8 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.nested-subagent-ownership.real.e2e.test.ts @@ -0,0 +1,149 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import pino from "pino"; +import { expect, test } from "vitest"; + +import type { AgentStreamEvent } from "../../agent-sdk-types.js"; +import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js"; +import { ClaudeAgentClient } from "./agent.js"; + +const ROOT_PROMPT = `You are ROOT_OWNER. Use Claude Code's native Agent tool exactly once, never Paseo tools. +Name the agent direct_owner and give it this complete task: + +You are DIRECT_OWNER. Use Claude Code's native Agent tool exactly once. Name that agent nested_owner and give it this complete task: +You are NESTED_OWNER. Use Bash exactly once to run \`sleep 2; printf 'NESTED_BACKGROUND_SENTINEL\\n'\` with run_in_background true. Use TaskOutput with block true to wait for the Bash task, verify its exit code is 0 and output is NESTED_BACKGROUND_SENTINEL, and wait for its completion notification before replying exactly NESTED_DONE. Do not finish while the command is running. +Wait for nested_owner to finish, then reply exactly DIRECT_DONE. + +Wait for direct_owner to finish, then reply exactly ROOT_DONE.`; + +test("attributes a nested Claude child and its background notification to their direct owners", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-claude-nested-ownership-")); + const client = new ClaudeAgentClient({ logger: pino({ level: "trace" }) }); + const session = await client.createSession({ + provider: "claude", + cwd, + model: "claude-sonnet-5", + modeId: "bypassPermissions", + }); + try { + const events = await collectSessionTurnEvents(session, ROOT_PROMPT); + + expect(events).toContainEqual(expect.objectContaining({ type: "turn_completed" })); + expect( + events + .flatMap((event) => + event.type === "timeline" && event.item.type === "assistant_message" + ? [event.item.text] + : [], + ) + .join("") + .trim(), + ).toBe("ROOT_DONE"); + const descriptors = events + .filter((event) => event.type === "provider_subagent" && event.event.type === "upsert") + .map((event) => event.event) + .filter((event) => event.type === "upsert" && event.description); + const direct = descriptors.find((event) => event.description === "direct_owner"); + const nested = descriptors.find((event) => event.description === "nested_owner"); + + expect(direct).toMatchObject({ description: "direct_owner" }); + expect(nested).toMatchObject({ description: "nested_owner" }); + expect(nested?.parentSubagentId).toBe(direct?.id); + expect( + events.filter( + (event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.callId === nested?.id, + ), + ).toEqual([]); + expect( + events.filter( + (event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.name === "task_notification", + ), + ).toEqual([]); + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ + type: "timeline", + id: nested?.id, + item: expect.objectContaining({ + type: "tool_call", + name: "task_notification", + }), + }), + }), + ); + const notification = events + .flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.id === nested?.id && + event.event.item.type === "tool_call" && + event.event.item.name === "task_notification" + ? [event.event.item] + : [], + ) + .at(-1); + expect(notification?.metadata?.status).toBe("completed"); + const outputFile = notification?.metadata?.outputFile; + expect(typeof outputFile).toBe("string"); + expect(readFileSync(outputFile as string, "utf8").trim()).toMatch( + /^NESTED_BACKGROUND_SENTINEL\s+\[exited with code 0\]$/, + ); + + const persistence = session.describePersistence(); + expect(persistence).not.toBeNull(); + await session.close(); + const restored = await client.resumeSession(persistence!, { cwd }); + try { + const replayed: AgentStreamEvent[] = []; + for await (const event of restored.streamHistory()) replayed.push(event); + expect( + replayed.filter( + (event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.name === "task_notification", + ), + ).toEqual([]); + expect(replayed).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ + type: "upsert", + id: nested?.id, + parentSubagentId: direct?.id, + }), + }), + ); + expect(replayed).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ + type: "timeline", + id: nested?.id, + item: expect.objectContaining({ + name: "task_notification", + metadata: expect.objectContaining({ + toolUseId: notification?.metadata?.toolUseId, + status: "completed", + }), + }), + }), + }), + ); + } finally { + await restored.close(); + } + } finally { + await session.close().catch(() => undefined); + rmSync(cwd, { recursive: true, force: true }); + } +}, 360_000); diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts index 4ced29b489..b6c8eea188 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts @@ -259,6 +259,89 @@ describe("ClaudeAgentSession persisted subagent replay", () => { rmSync(tempRoot, { recursive: true, force: true }); }); + test.each(["system", "user", "queue-operation"] as const)( + "restores nested background notification ownership from %s history", + async (form) => { + const nestedId = "toolu_nested"; + const bashId = "toolu_nested_bash"; + const timestamp = "2026-07-26T06:28:01.000Z"; + function notification(toolUseId: string) { + const content = `${toolUseId}-task${toolUseId}completedBackground command completed`; + const records = { + system: { + subtype: "task_notification", + task_id: `${toolUseId}-task`, + tool_use_id: toolUseId, + status: "completed", + summary: "Background command completed", + }, + user: { uuid: `${toolUseId}-notification`, message: { role: "user", content } }, + "queue-operation": { operation: "enqueue", content }, + }; + return JSON.stringify({ type: form, timestamp, ...records[form] }); + } + const subagentDir = writeParentSession([ + taskToolUse(), + taskToolResult(), + notification(bashId), + notification("toolu_root_bash"), + notification("toolu_orphan_bash"), + ]); + function toolEntry(agentId: string, id: string, name: string) { + return JSON.stringify({ + type: "assistant", + isSidechain: true, + agentId, + message: { role: "assistant", content: [{ type: "tool_use", id, name, input: {} }] }, + }); + } + writeSubagent({ + subagentDir, + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [toolEntry(AGENT_ID, nestedId, "Agent")], + }); + writeSubagent({ + subagentDir, + agentId: "nested-agent", + meta: JSON.stringify({ toolUseId: nestedId }), + sidechainLines: [toolEntry("nested-agent", bashId, "Bash")], + }); + writeSubagent({ + subagentDir, + agentId: "orphan-agent", + meta: JSON.stringify({ toolUseId: "missing-parent-tool" }), + sidechainLines: [toolEntry("orphan-agent", "toolu_orphan_bash", "Bash")], + }); + + const replayed = await replayEvents(); + const rootNotifications = replayed.flatMap((event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.name === "task_notification" + ? [event.item.metadata?.toolUseId] + : [], + ); + expect(rootNotifications).toEqual(["toolu_root_bash", "toolu_orphan_bash"]); + const childNotifications = replayed.flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.item.type === "tool_call" && + event.event.item.name === "task_notification" + ? [event.event] + : [], + ); + expect(childNotifications).toEqual([ + expect.objectContaining({ + id: nestedId, + timestamp, + item: expect.objectContaining({ + metadata: expect.objectContaining({ toolUseId: bashId }), + }), + }), + ]); + }, + ); + test("links a subagent to its Task call through the meta sidecar", async () => { writeSession({ parentLines: [taskToolUse(), taskToolResult()], diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index b041c3b233..a5d1bf95da 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -287,6 +287,11 @@ interface AsyncMessageInput { iterable: AsyncIterable; } +interface ClaudeReplayOwnership { + restoredIds: ReadonlySet; + toolOwners: ReadonlyMap; +} + interface PersistedTimelineEntry { item: AgentTimelineItem; timestamp?: string; @@ -4162,6 +4167,9 @@ class ClaudeAgentSession implements AgentSession { private buildSubagentToolCallCard( declaration: Extract, ): AgentStreamEvent | null { + // The launching sidechain already owns and renders this tool call. Mirroring it into the + // managed parent's transcript would flatten both the card and the child relationship. + if (declaration.parentSubagentId) return null; const toolCall = mapClaudeRunningToolCall({ name: "Task", callId: declaration.id, @@ -4283,11 +4291,6 @@ class ClaudeAgentSession implements AgentSession { message: Extract, events: AgentStreamEvent[], ): void { - // TODO: subagent timelines are best-effort. Subagent task_notifications - // arrive without parent_tool_use_id but with tool_use_id pointing at the - // the parent's subagent tool call, so they slip past the sidechain router and pollute - // the parent timeline. Drop them here; eventually thread them into the - // parent tool call's sub_agent log instead. const taskUseId = message.tool_use_id; const cachedTool = taskUseId ? this.toolUseCache.get(taskUseId) : undefined; // The task protocol owns provider-subagent identity. Workflow launch results arrive before @@ -4300,6 +4303,15 @@ class ClaudeAgentSession implements AgentSession { return; } const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(message); + const ownerSubagentId = this.taskProtocolSource.resolveTaskOwner(message.task_id, taskUseId); + if (taskNotificationItem && ownerSubagentId) { + events.push({ + type: "provider_subagent", + provider: "claude", + event: { type: "timeline", id: ownerSubagentId, item: taskNotificationItem }, + }); + return; + } if (taskNotificationItem) { events.push({ type: "timeline", @@ -4333,11 +4345,7 @@ class ClaudeAgentSession implements AgentSession { messageId, }); if (taskNotificationItem) { - events.push({ - type: "timeline", - item: taskNotificationItem, - provider: "claude", - }); + this.appendUserTaskNotificationEvent(taskNotificationItem, events); return; } if (typeof content === "string" && content.length > 0) { @@ -4359,6 +4367,28 @@ class ClaudeAgentSession implements AgentSession { } } + private appendUserTaskNotificationEvent( + item: Extract, + events: AgentStreamEvent[], + ): void { + const taskId = typeof item.metadata?.taskId === "string" ? item.metadata.taskId : undefined; + const toolUseId = + typeof item.metadata?.toolUseId === "string" ? item.metadata.toolUseId : undefined; + if (taskId && this.taskProtocolSource.isDeclaredTask(taskId)) return; + const ownerSubagentId = taskId + ? this.taskProtocolSource.resolveTaskOwner(taskId, toolUseId) + : undefined; + if (ownerSubagentId) { + events.push({ + type: "provider_subagent", + provider: "claude", + event: { type: "timeline", id: ownerSubagentId, item }, + }); + return; + } + events.push({ type: "timeline", item, provider: "claude" }); + } + private appendUserContentArrayEvents( content: ReadonlyArray, messageId: string | undefined, @@ -4807,27 +4837,24 @@ class ClaudeAgentSession implements AgentSession { return; } const content = fs.readFileSync(historyPath, "utf8"); - const restoredProviderSubagentIds = this.ingestPersistedSidechains( + const replay = this.ingestPersistedSidechains( content, readClaudeSidechainHistory(historyPath), ); - this.ingestPersistedHistory(content, restoredProviderSubagentIds); + this.ingestPersistedHistory(content, replay); } catch { // ignore history load failures } } - private ingestPersistedHistory( - content: string, - restoredProviderSubagentIds: ReadonlySet, - ): void { + private ingestPersistedHistory(content: string, replay: ClaudeReplayOwnership): void { if (!content) { return; } const timeline: PersistedTimelineEntry[] = []; for (const line of content.split(/\r?\n/)) { - this.ingestPersistedHistoryLine(line, timeline, restoredProviderSubagentIds); + this.ingestPersistedHistoryLine(line, timeline, replay); } if (timeline.length > 0) { @@ -4839,7 +4866,7 @@ class ClaudeAgentSession implements AgentSession { private ingestPersistedSidechains( parentContent: string, sidechains: ClaudeSidechainHistory, - ): Set { + ): ClaudeReplayOwnership { const parentEntries = parseClaudeHistoryRecords(parentContent).filter( (entry) => entry.isSidechain !== true, ); @@ -4849,16 +4876,18 @@ class ClaudeAgentSession implements AgentSession { // Replay produces the same observations the live task protocol produces, then folds them // with the same function, so identity and status are derived once for both paths. + const subagentReplay = observeReplaySubagents({ + subagents: [...groupClaudeSidechainEntries(sidechainEntries)].map(([agentId, entries]) => ({ + agentId, + meta: sidechains.metaByAgentId.get(agentId) ?? null, + entries, + parentFacts: readClaudeReplayParentFacts(entries as ClaudeHistoryEntry[]), + })), + parent: readClaudeReplayParentFacts(parentEntries), + convertEntry: (entry) => this.convertHistoryEntry(entry as ClaudeHistoryEntry), + }); const observations = [ - ...observeReplaySubagents({ - subagents: [...groupClaudeSidechainEntries(sidechainEntries)].map(([agentId, entries]) => ({ - agentId, - meta: sidechains.metaByAgentId.get(agentId) ?? null, - entries, - })), - parent: readClaudeReplayParentFacts(parentEntries), - convertEntry: (entry) => this.convertHistoryEntry(entry as ClaudeHistoryEntry), - }), + ...subagentReplay.observations, ...observeReplayWorkflows({ workflows: sidechains.workflowContents .map(parseClaudeWorkflowRun) @@ -4880,7 +4909,11 @@ class ClaudeAgentSession implements AgentSession { .filter((observation) => observation.kind === "declared") .map((observation) => observation.id), ); - if (observations.length === 0) return restoredProviderSubagentIds; + const replay = { + restoredIds: restoredProviderSubagentIds, + toolOwners: subagentReplay.toolOwners, + }; + if (observations.length === 0) return replay; this.persistedProviderSubagentEvents.push( ...foldSubagentObservations(observations).map( @@ -4892,13 +4925,13 @@ class ClaudeAgentSession implements AgentSession { ), ); this.historyPending = true; - return restoredProviderSubagentIds; + return replay; } private ingestPersistedHistoryLine( line: string, timeline: PersistedTimelineEntry[], - restoredProviderSubagentIds: ReadonlySet, + replay: ClaudeReplayOwnership, ): void { const trimmed = line.trim(); if (!trimmed) { @@ -4921,11 +4954,29 @@ class ClaudeAgentSession implements AgentSession { return; } const notificationToolUseId = readTaskNotificationToolUseIdFromHistoryRecord(entry); - if (notificationToolUseId && restoredProviderSubagentIds.has(notificationToolUseId)) { + if (notificationToolUseId && replay.restoredIds.has(notificationToolUseId)) { return; } const historyTimestamp = normalizeProviderReplayTimestamp(entry.timestamp); + const notificationOwner = notificationToolUseId + ? replay.toolOwners.get(notificationToolUseId) + : undefined; + if (notificationOwner) { + for (const item of this.convertHistoryEntry(entry)) { + this.persistedProviderSubagentEvents.push({ + type: "provider_subagent", + provider: "claude", + event: { + type: "timeline", + id: notificationOwner, + item, + timestamp: historyTimestamp ?? undefined, + }, + }); + } + return; + } const taskSnapshot = this.taskState.observe(entry); const items = [...(taskSnapshot ? [taskSnapshot] : []), ...this.convertHistoryEntry(entry)]; const isVisibleUserEntry = diff --git a/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts index 3b2ef59a80..1eca4cedc2 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts @@ -180,6 +180,83 @@ describe("ClaudeTaskProtocolSource", () => { ]); }); + it("declares a nested task beneath the sidechain agent that launched it", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe( + taskStarted({ + task_id: "direct-task", + tool_use_id: "toolu_direct", + spawn_depth: 1, + }), + ); + source.observeSidechainFrame( + { + type: "assistant", + parent_tool_use_id: "toolu_direct", + message: { + model: "claude-sonnet-5", + content: [ + { + type: "tool_use", + id: "toolu_nested", + name: "Agent", + input: { name: "nested-owner" }, + }, + ], + }, + } as unknown as SDKMessage, + "toolu_direct", + ); + + expect( + source.observe( + taskStarted({ + task_id: "nested-task", + tool_use_id: "toolu_nested", + spawn_depth: 2, + }), + ), + ).toContainEqual( + expect.objectContaining({ + kind: "declared", + id: "toolu_nested", + parentSubagentId: "toolu_direct", + }), + ); + expect(source.needsSyntheticParentToolCard("toolu_nested")).toBe(false); + }); + + it("resolves a background task to the sidechain agent that launched it", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe( + taskStarted({ task_id: "direct-task", tool_use_id: "toolu_direct", spawn_depth: 1 }), + ); + source.observeSidechainFrame( + { + type: "assistant", + parent_tool_use_id: "toolu_direct", + message: { + model: "claude-sonnet-5", + content: [ + { type: "tool_use", id: "toolu_bash", name: "Bash", input: { command: "sleep 2" } }, + ], + }, + } as unknown as SDKMessage, + "toolu_direct", + ); + source.observe( + taskStarted({ + task_id: "bash-task", + tool_use_id: "toolu_bash", + task_type: "local_bash", + subagent_type: undefined, + owned_by_subagent: true, + }), + ); + + expect(source.resolveTaskOwner("bash-task", "toolu_bash")).toBe("toolu_direct"); + }); + it("does not re-announce a status that already holds", () => { const source = new ClaudeTaskProtocolSource(); source.observe(taskStarted()); diff --git a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts index 308c1a6087..1a44520366 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts @@ -154,6 +154,10 @@ export class ClaudeTaskProtocolSource { private readonly subagentIdByTaskId = new Map(); /** Every announced tool id -> the first tool id that publicly identifies the child. */ private readonly canonicalIdByToolUseId = new Map(); + /** Tool calls made inside a sidechain, keyed to the direct child that emitted them. */ + private readonly ownerSubagentIdByToolUseId = new Map(); + /** Announced tasks inherit the owner recorded for their tool call, including local_bash. */ + private readonly ownerSubagentIdByTaskId = new Map(); /** * Every subagent id this source declared. It is the source's whole vocabulary: an id that is * not in here was either filtered at declaration or never announced, and this source has @@ -229,6 +233,14 @@ export class ClaudeTaskProtocolSource { return subagentId !== undefined && this.declaredIds.has(subagentId); } + /** Resolve a non-subagent task (for example local_bash) to its emitting sidechain. */ + resolveTaskOwner(taskId: string, toolUseId?: string): string | undefined { + return ( + this.ownerSubagentIdByTaskId.get(taskId) ?? + (toolUseId ? this.ownerSubagentIdByToolUseId.get(toolUseId) : undefined) + ); + } + needsSyntheticParentToolCard(subagentId: string): boolean { return !this.idsWithExistingParentToolCard.has(subagentId); } @@ -259,6 +271,8 @@ export class ClaudeTaskProtocolSource { reset(): void { this.subagentIdByTaskId.clear(); this.canonicalIdByToolUseId.clear(); + this.ownerSubagentIdByToolUseId.clear(); + this.ownerSubagentIdByTaskId.clear(); this.declaredIds.clear(); this.workflowTaskIds.clear(); this.lastWorkflowResultByTaskId.clear(); @@ -310,32 +324,50 @@ export class ClaudeTaskProtocolSource { this.sawAnyTask = true; const id = readString(message.tool_use_id); + const parentSubagentId = id ? this.ownerSubagentIdByToolUseId.get(id) : undefined; + if (parentSubagentId) this.ownerSubagentIdByTaskId.set(message.task_id, parentSubagentId); // skip_transcript marks ambient housekeeping the transcript should not show. if (!id || message.skip_transcript === true || !isProviderSubagentTask(message)) return []; this.sawTaskStarted = true; const existingId = this.subagentIdByTaskId.get(message.task_id); if (existingId) { - this.canonicalIdByToolUseId.set(id, existingId); - const observations: SubagentObservation[] = []; - if (this.lastStatusById.get(existingId) !== "running") { - this.lastStatusById.set(existingId, "running"); - observations.push({ kind: "status", id: existingId, status: "running" }); - } - const prompt = - message.task_type === CLAUDE_WORKFLOW_TASK_TYPE - ? readString(message.description) - : readString(message.prompt); - if (prompt) { - observations.push({ - kind: "timeline", - id: existingId, - item: { type: "user_message", text: prompt }, - }); - } - return observations; + return this.observeExistingTaskStart(message, id, existingId); } + return this.observeNewTaskStart(message, id, parentSubagentId); + } + + private observeExistingTaskStart( + message: TaskStartedMessage, + toolUseId: string, + existingId: string, + ): SubagentObservation[] { + this.canonicalIdByToolUseId.set(toolUseId, existingId); + const observations: SubagentObservation[] = []; + if (this.lastStatusById.get(existingId) !== "running") { + this.lastStatusById.set(existingId, "running"); + observations.push({ kind: "status", id: existingId, status: "running" }); + } + const prompt = + message.task_type === CLAUDE_WORKFLOW_TASK_TYPE + ? readString(message.description) + : readString(message.prompt); + if (prompt) { + observations.push({ + kind: "timeline", + id: existingId, + item: { type: "user_message", text: prompt }, + }); + } + return observations; + } + + private observeNewTaskStart( + message: TaskStartedMessage, + id: string, + parentSubagentId: string | undefined, + ): SubagentObservation[] { this.subagentIdByTaskId.set(message.task_id, id); this.canonicalIdByToolUseId.set(id, id); this.declaredIds.add(id); @@ -344,8 +376,10 @@ export class ClaudeTaskProtocolSource { // An explicit `name` on the Task call wins over the agent type, matching how replay titles the // same subagent. Without it a fan-out of five Explores reads as five identical rows. const isWorkflow = message.task_type === CLAUDE_WORKFLOW_TASK_TYPE; - if (isWorkflow) { + if (isWorkflow || parentSubagentId) { this.idsWithExistingParentToolCard.add(id); + } + if (isWorkflow) { this.workflowTaskIds.add(message.task_id); } const title = isWorkflow @@ -359,6 +393,7 @@ export class ClaudeTaskProtocolSource { toolCallId: id, ...(title ? { title } : {}), ...(description ? { description } : {}), + ...(parentSubagentId ? { parentSubagentId } : {}), }, ]; const initialPresentation = title ? { title } : {}; @@ -447,13 +482,23 @@ export class ClaudeTaskProtocolSource { * The model the child is actually running, read off its own assistant frames. * * Routed through the declaration for the same reason status is: a frame carrying the tool_use - * id of a task this source filtered out — ambient housekeeping, a workflow child, a nested - * grandchild announced in someone else's session — would otherwise fold to an upsert with no + * id of a task this source filtered out — ambient housekeeping or a workflow child — would + * otherwise fold to an upsert with no * identity and a defaulted "running" status. That is the nameless, never-finishing row the * declaration filter exists to prevent, arriving by a different door. */ observeSidechainFrame(message: SDKMessage, subagentId: string): SubagentObservation[] { if (message.type !== "assistant" || !this.declaredIds.has(subagentId)) return []; + const content = message.message?.content; + if (Array.isArray(content)) { + for (const block of content) { + if (!block || typeof block !== "object") continue; + const record = block as { type?: unknown; id?: unknown }; + if (record.type === "tool_use" && typeof record.id === "string") { + this.ownerSubagentIdByToolUseId.set(record.id, subagentId); + } + } + } const model = resolveObservedClaudeModelId( typeof message.message?.model === "string" ? message.message.model : undefined, ); diff --git a/packages/server/src/server/agent/providers/claude/subagents/observation.ts b/packages/server/src/server/agent/providers/claude/subagents/observation.ts index 718a9fc10a..147d8690a4 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/observation.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/observation.ts @@ -24,6 +24,8 @@ export type SubagentObservation = /** The task this child was given. What actually distinguishes it from its siblings. */ description?: string; toolCallId?: string; + /** Provider subagent that launched this one. Absent means a direct child of the agent. */ + parentSubagentId?: string; timestamp?: string; } | { kind: "status"; id: string; status: ProviderSubagentStatus; timestamp?: string } @@ -84,6 +86,9 @@ export function foldSubagentObservations( ...(observation.title === undefined ? {} : { title: observation.title }), ...(observation.description === undefined ? {} : { description: observation.description }), ...(observation.toolCallId === undefined ? {} : { toolCallId: observation.toolCallId }), + ...(observation.parentSubagentId === undefined + ? {} + : { parentSubagentId: observation.parentSubagentId }), ...(observation.timestamp ? { timestamp: observation.timestamp } : {}), }); } diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts index 6dceb8d0ce..e28d15b8c1 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts @@ -84,7 +84,7 @@ describe("observeReplaySubagents", () => { subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], parent: parentWithTaskCall(), convertEntry: () => [], - }); + }).observations; expect(observations[0]).toMatchObject({ kind: "declared", @@ -95,25 +95,39 @@ describe("observeReplaySubagents", () => { }); }); - it("replays only this session's own children, not their descendants", () => { - // Claude Code writes every descendant into the same subagents/ directory. A grandchild's - // toolUseId names a Task call made inside its parent's session, so nothing here can resolve - // it: it would replay as an extra row the live stream never showed, with no Task card and no - // outcome. One recorded session showed 10 subagents live and 22 on reopen. + it("replays descendants beneath the sidechain that declared their Task", () => { + const nestedToolUseId = "toolu_012rzYnFZA"; const observations = observeReplaySubagents({ subagents: [ - { agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 }, entries: [] }, + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 }, + entries: [], + parentFacts: { + toolCalls: new Map([ + [nestedToolUseId, { title: "Explore", description: "Nested audit" }], + ]), + linksByAgentId: new Map(), + outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]), + }, + }, { agentId: "a6acb4b898", - meta: { toolUseId: "toolu_012rzYnFZA", agentType: "Explore", spawnDepth: 2 }, + meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 }, entries: [], }, ], parent: parentWithTaskCall(), convertEntry: () => [], - }); + }).observations; - expect([...new Set(observations.map((observation) => observation.id))]).toEqual([TOOL_USE_ID]); + expect(observations).toContainEqual( + expect.objectContaining({ + kind: "declared", + id: nestedToolUseId, + parentSubagentId: TOOL_USE_ID, + }), + ); }); it("keeps a subagent recorded before spawnDepth existed", () => { @@ -121,7 +135,7 @@ describe("observeReplaySubagents", () => { subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], parent: parentWithTaskCall(), convertEntry: () => [], - }); + }).observations; expect(observations[0]).toMatchObject({ kind: "declared", id: TOOL_USE_ID }); }); @@ -137,7 +151,7 @@ describe("observeReplaySubagents", () => { ], parent: emptyParent(), convertEntry: () => [], - }); + }).observations; expect(observations).toEqual([]); }); @@ -153,7 +167,7 @@ describe("observeReplaySubagents", () => { subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], parent, convertEntry: () => [], - }); + }).observations; expect(declared).toMatchObject({ id: TOOL_USE_ID, toolCallId: TOOL_USE_ID, title: "Explore" }); expect(rest.at(-1)).toMatchObject({ kind: "status", status: "completed" }); @@ -164,7 +178,7 @@ describe("observeReplaySubagents", () => { subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], parent: emptyParent(), convertEntry: () => [], - }); + }).observations; expect(observations).toEqual([]); }); @@ -188,7 +202,7 @@ describe("observeReplaySubagents", () => { ], parent: emptyParent(), convertEntry: () => [], - }), + }).observations, ); expect(descriptor).toBeNull(); @@ -214,7 +228,7 @@ describe("observeReplaySubagents", () => { ], parent: emptyParent(), convertEntry: () => [], - }), + }).observations, ); expect(descriptor).toBeNull(); @@ -230,7 +244,7 @@ describe("observeReplaySubagents", () => { outcomesByToolCallId: new Map(), }, convertEntry: () => [], - }), + }).observations, ); expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" }); @@ -253,7 +267,7 @@ describe("observeReplaySubagents", () => { ], parent: emptyParent(), convertEntry: () => [], - }); + }).observations; expect(observations).toEqual([]); }); @@ -276,7 +290,7 @@ describe("observeReplaySubagents", () => { ], parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() }, convertEntry: () => [], - }), + }).observations, ); expect(descriptor).toMatchObject({ status: "completed" }); @@ -287,7 +301,7 @@ describe("observeReplaySubagents", () => { subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() }, convertEntry: () => [], - }); + }).observations; expect(observations.some((o) => o.kind === "status")).toBe(false); }); @@ -296,7 +310,7 @@ describe("observeReplaySubagents", () => { subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], parent: parentWithTaskCall(true), convertEntry: () => [], - }); + }).observations; expect(observations.at(-1)).toMatchObject({ kind: "status", status: "failed" }); }); @@ -311,7 +325,7 @@ describe("observeReplaySubagents", () => { ], parent: parentWithTaskCall(), convertEntry: () => [{ type: "reasoning", text: "thinking" }], - }); + }).observations; expect(observations.find((observation) => observation.kind === "timeline")).toMatchObject({ kind: "timeline", @@ -333,7 +347,7 @@ describe("replay runtime", () => { ], parent: parentWithTaskCall(), convertEntry: () => [], - }); + }).observations; expect(observations.find((o) => o.kind === "subtitle")).toEqual({ kind: "subtitle", @@ -356,7 +370,7 @@ describe("replay runtime", () => { ], parent: parentWithTaskCall(), convertEntry: () => [], - }); + }).observations; expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({ subtitle: "general-purpose · Sonnet 5 · Low", @@ -368,7 +382,7 @@ describe("replay runtime", () => { subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], parent: parentWithTaskCall(), convertEntry: () => [], - }); + }).observations; expect(observations.some((o) => o.kind === "subtitle")).toBe(false); }); @@ -384,7 +398,7 @@ describe("replay runtime", () => { ], parent: parentWithTaskCall(), convertEntry: () => [], - }); + }).observations; expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({ subtitle: "general-purpose · glm-5.1", }); @@ -397,7 +411,7 @@ describe("replay usage", () => { subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }], parent: parentWithTaskCall(), convertEntry: () => [], - }).find((observation) => observation.kind === "subtitle"); + }).observations.find((observation) => observation.kind === "subtitle"); } it("derives the counters Claude Code itself reports, from a real transcript's shape", () => { @@ -532,7 +546,7 @@ describe("replay usage", () => { ], parent: parentWithTaskCall(), convertEntry: () => [], - }); + }).observations; for (const event of foldSubagentObservations(observations)) { store.apply("parent", "claude", event); } @@ -579,7 +593,7 @@ describe("live and replay agree", () => { ], parent: parentWithTaskCall(), convertEntry: () => [], - }); + }).observations; const fromWire = applyToStore(liveObservations); const fromDisk = applyToStore(replayObservations); diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts index 99eb8e6c69..372cbc01b2 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts @@ -142,6 +142,8 @@ export interface ClaudeReplaySubagentInput { agentId: string; meta: ClaudeSubagentMeta | null; entries: ClaudeReplayEntry[]; + /** Task declarations made by this subagent, used to attach its direct children. */ + parentFacts?: ClaudeReplayParentFacts; } export interface ClaudeReplayParentFacts { @@ -229,6 +231,7 @@ function declareSubagent( subagent: ClaudeReplaySubagentInput, link: ParentLink, toolCall: { title?: string; description?: string } | undefined, + parentSubagentId?: string, ): SubagentObservation { const title = toolCall?.title ?? subagent.meta?.agentType; const description = toolCall?.description ?? subagent.meta?.description; @@ -239,6 +242,7 @@ function declareSubagent( ...(title ? { title } : {}), ...(description ? { description } : {}), ...(link.toolCallId ? { toolCallId: link.toolCallId } : {}), + ...(parentSubagentId ? { parentSubagentId } : {}), ...(firstTimestamp ? { timestamp: firstTimestamp } : {}), }; } @@ -274,12 +278,15 @@ function observeSubagent( subagent: ClaudeReplaySubagentInput, parent: ClaudeReplayParentFacts, convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[], + parentSubagentId?: string, ): SubagentObservation[] { const link = resolveParentLink(subagent, parent); if (!link) return []; const toolCall = parent.toolCalls.get(link.toolCallId); - const observations: SubagentObservation[] = [declareSubagent(subagent, link, toolCall)]; + const observations: SubagentObservation[] = [ + declareSubagent(subagent, link, toolCall, parentSubagentId), + ]; const subtitle = observeSubtitle(subagent, link, toolCall?.title ?? subagent.meta?.agentType); if (subtitle) observations.push(subtitle); @@ -311,34 +318,72 @@ function observeSubagent( return observations; } -/** - * Whether this transcript belongs to a child of the session being replayed. - * - * Claude Code writes every descendant into the same `subagents/` directory, not just the session's - * own children, and `spawnDepth` is what separates them: 1 is a direct child, 2+ was spawned by - * another subagent. A grandchild's `toolUseId` names a Task call made inside its parent's session, - * so nothing in this transcript can resolve it — surveyed across recorded sessions, every depth-1 - * id matched a Task tool_use block in the parent transcript and no deeper id did. Replaying them - * anyway adds rows the live stream never showed, each with no Task card and no recoverable outcome. - * One session recorded 10 subagents live and would replay 22. - * - * Absent depth means a session recorded before the field existed: keep the subagent rather than - * lose it, matching how every other missing field here is treated. - */ -function isDirectChild(subagent: ClaudeReplaySubagentInput): boolean { - const depth = subagent.meta?.spawnDepth; - return depth === undefined || depth <= 1; +function recordReplayToolOwners( + owners: Map, + entries: readonly ClaudeReplayEntry[], + subagentId: string, +): void { + for (const entry of entries) { + if (entry.type !== "assistant" || !Array.isArray(entry.message?.content)) continue; + for (const block of entry.message.content) { + if (block?.type === "tool_use" && typeof block.id === "string") { + owners.set(block.id, subagentId); + } + } + } } export function observeReplaySubagents(input: { subagents: readonly ClaudeReplaySubagentInput[]; parent: ClaudeReplayParentFacts; convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[]; -}): SubagentObservation[] { +}): { observations: SubagentObservation[]; toolOwners: ReadonlyMap } { const observations: SubagentObservation[] = []; - for (const subagent of input.subagents) { - if (!isDirectChild(subagent)) continue; - observations.push(...observeSubagent(subagent, input.parent, input.convertEntry)); + const toolOwners = new Map(); + const unresolved = [...input.subagents].sort( + (left, right) => (left.meta?.spawnDepth ?? 1) - (right.meta?.spawnDepth ?? 1), + ); + const resolvedParents = new Map(); + + // Claude stores every descendant beside the root transcript. Resolve one generation at a + // time: a child is admitted only when its tool id is declared by a transcript whose own parent + // has already been proven. This preserves the old boundary against unrelated ambient sidecars. + let madeProgress = true; + while (unresolved.length > 0 && madeProgress) { + madeProgress = false; + for (let index = unresolved.length - 1; index >= 0; index--) { + const subagent = unresolved[index]; + if (!subagent) continue; + const resolved = resolveReplayOwner(subagent, input.parent, resolvedParents); + if (!resolved) continue; + const { ownerId, parent, link } = resolved; + + // Only proven descendants may own notifications; ambient sidecars cannot claim them. + recordReplayToolOwners(toolOwners, subagent.entries, link.id); + observations.push(...observeSubagent(subagent, parent, input.convertEntry, ownerId)); + if (subagent.parentFacts) resolvedParents.set(link.id, subagent.parentFacts); + unresolved.splice(index, 1); + madeProgress = true; + } } - return observations; + return { observations, toolOwners }; +} + +function resolveReplayOwner( + subagent: ClaudeReplaySubagentInput, + rootParent: ClaudeReplayParentFacts, + resolvedParents: ReadonlyMap, +): { + ownerId?: string; + parent: ClaudeReplayParentFacts; + link: NonNullable>; +} | null { + const rootLink = resolveParentLink(subagent, rootParent); + if (rootLink) return { parent: rootParent, link: rootLink }; + + for (const [ownerId, parent] of resolvedParents) { + const link = resolveParentLink(subagent, parent); + if (link) return { ownerId, parent, link }; + } + return null; } diff --git a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts index cbf65194de..d538e14c29 100644 --- a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts +++ b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts @@ -7,6 +7,18 @@ import { } from "./task-notification-tool-call.js"; describe("task-notification-tool-call", () => { + it.each([undefined, "ordinary queued prompt"])( + "ignores queue bookkeeping without a task notification (%s)", + (content) => { + expect( + mapTaskNotificationSystemRecordToToolCall({ + type: "queue-operation", + operation: "enqueue", + content, + }), + ).toBeNull(); + }, + ); it("detects task notification user content in string payloads", () => { expect( isTaskNotificationUserContent( diff --git a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts index d305e72e89..2db4beddb0 100644 --- a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts +++ b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts @@ -178,8 +178,9 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio const systemRecord: TaskNotificationHistoryRecord = parsedRecord.data; const isSystemTaskNotification = systemRecord.type === "system" && systemRecord.subtype === "task_notification"; - const isQueueOperation = systemRecord.type === "queue-operation"; - if (!isSystemTaskNotification && !isQueueOperation) { + const isQueuedTaskNotification = + systemRecord.type === "queue-operation" && isTaskNotificationUserContent(systemRecord.content); + if (!isSystemTaskNotification && !isQueuedTaskNotification) { return null; } const rawText = toNonEmptyString(systemRecord.content); @@ -278,6 +279,7 @@ function toTaskNotificationToolCall( synthetic: true, source: "claude_task_notification", ...(envelope.taskId ? { taskId: envelope.taskId } : {}), + ...(envelope.toolUseId ? { toolUseId: envelope.toolUseId } : {}), ...(envelope.status ? { status: envelope.status } : {}), ...(envelope.outputFile ? { outputFile: envelope.outputFile } : {}), }; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 1046532867..c1b2c6bfde 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -3266,6 +3266,22 @@ describe("Codex app-server provider", () => { turn: { status: "completed" }, }); + const providerSubagents = events.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [], + ); + expect(providerSubagents).toContainEqual( + expect.objectContaining({ + id: "child-thread-root", + parentSubagentId: null, + }), + ); + expect(providerSubagents).toContainEqual( + expect.objectContaining({ + id: "grandchild-thread", + parentSubagentId: "child-thread-root", + }), + ); + const beforeParentCompletes = events .filter((event) => event.type === "timeline" && event.item.type === "tool_call") .map((event) => event.item); @@ -3288,6 +3304,70 @@ describe("Codex app-server provider", () => { }); }); + test("keeps a nested child under its spawning parent after a root interaction", () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + asInternals(session).handleNotification("item/completed", { + threadId: "test-thread", + item: { + type: "subAgentActivity", + id: "spawn-child-root", + kind: "started", + agentThreadId: "child-thread-root", + agentPath: "/root/child", + }, + }); + asInternals(session).handleNotification("item/completed", { + threadId: "child-thread-root", + item: { + type: "subAgentActivity", + id: "spawn-grandchild", + kind: "started", + agentThreadId: "grandchild-thread", + agentPath: "/root/child/grandchild", + }, + }); + asInternals(session).handleNotification("item/completed", { + threadId: "test-thread", + item: { + type: "collabAgentToolCall", + id: "wait-for-grandchild", + tool: "wait", + status: "completed", + receiverThreadIds: ["grandchild-thread"], + agentsStates: { + "grandchild-thread": { status: "running", message: null }, + }, + }, + }); + asInternals(session).handleNotification("item/agentMessage/delta", { + threadId: "grandchild-thread", + itemId: "grandchild-after-wait", + delta: "Still nested.", + }); + + const grandchildUpserts = events.flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "upsert" && + event.event.id === "grandchild-thread" + ? [event.event] + : [], + ); + expect(grandchildUpserts.map((event) => event.parentSubagentId)).toEqual([ + "child-thread-root", + "child-thread-root", + ]); + expect(events.at(-1)).toMatchObject({ + type: "timeline", + item: { + callId: "spawn-child-root", + detail: { type: "sub_agent", log: expect.stringContaining("Still nested.") }, + }, + }); + }); + test("never treats an unmapped foreign terminal as the root terminal", () => { const session = createSession(); const events: AgentStreamEvent[] = []; @@ -4085,6 +4165,69 @@ describe("Codex app-server provider", () => { }); }); + test("restores nested MultiAgentV2 ownership from persisted child threads", async () => { + const session = createSession(); + session.client = { + request: vi.fn(async (method: string, params: unknown) => { + if (method !== "thread/read") { + return {}; + } + const threadId = (params as { threadId?: string }).threadId; + const itemsByThreadId = { + "test-thread": [ + { + type: "subAgentActivity", + id: "spawn-persisted-child", + kind: "started", + agentThreadId: "persisted-child", + agentPath: "/root/persisted-child", + }, + { + type: "collabAgentToolCall", + id: "root-wait-grandchild", + tool: "wait", + status: "completed", + receiverThreadIds: ["persisted-grandchild"], + agentsStates: { "persisted-grandchild": { status: "completed" } }, + }, + ], + "persisted-child": [ + { + type: "subAgentActivity", + id: "spawn-persisted-grandchild", + kind: "started", + agentThreadId: "persisted-grandchild", + agentPath: "/root/persisted-child/grandchild", + }, + ], + } as const; + const items = threadId ? itemsByThreadId[threadId as keyof typeof itemsByThreadId] : []; + return { + thread: { + turns: items ? [{ items }] : [], + }, + }; + }), + }; + + await asInternals(session).loadPersistedHistory(); + + const history: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) { + history.push(event); + } + const upserts = history.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [], + ); + expect(upserts).toEqual([ + expect.objectContaining({ id: "persisted-child", parentSubagentId: null }), + expect.objectContaining({ + id: "persisted-grandchild", + parentSubagentId: "persisted-child", + }), + ]); + }); + test("coalesces persisted MultiAgentV2 activity for one child into one terminal card", async () => { const session = createSession(); session.client = { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 48744f84ac..87a463a11f 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1781,13 +1781,17 @@ function updateHistoricalSubAgentActivity( function readCodexHistoricalSubAgentThreadIds(item: unknown): string[] { const activity = readCodexSubAgentActivity(item); if (activity) { - return [activity.agentThreadId]; + return activity.kind === "started" ? [activity.agentThreadId] : []; } const record = toObjectRecord(item); const normalizedType = normalizeCodexThreadItemType( typeof record?.type === "string" ? record.type : undefined, ); - if (normalizedType !== "collabAgentToolCall" || !Array.isArray(record?.receiverThreadIds)) { + if ( + normalizedType !== "collabAgentToolCall" || + record?.tool !== "spawnAgent" || + !Array.isArray(record.receiverThreadIds) + ) { return []; } return record.receiverThreadIds.filter( @@ -3256,6 +3260,7 @@ interface CodexSubAgentCallState { callId: string; toolCall: ToolCallTimelineItem; parentCallId: string | null; + parentSubagentId: string | null; activityItemIds: Set; pendingCommandOutputDeltas: Map; pendingFileChangeOutputDeltas: Map; @@ -3264,6 +3269,13 @@ interface CodexSubAgentCallState { childThreadIds: Set; } +function resolveCodexParentSubagentId( + parentCallId: string | null, + emittingThreadId: string | null, +): string | null { + return parentCallId ? emittingThreadId : null; +} + interface CodexPendingPermissionHandler { resolve: (value: unknown) => void; kind: "command" | "file" | "question" | "mcp_elicitation" | "plan"; @@ -3799,7 +3811,11 @@ export class CodexAppServerAgentSession implements AgentSession { client: CodexAppServerClientLike, rootRoutes: readonly PersistedSubAgentRoute[], ): Promise { - const queue = rootRoutes.map((route) => ({ route, parentCallId: null as string | null })); + const queue = rootRoutes.map((route) => ({ + route, + parentCallId: null as string | null, + parentSubagentId: null as string | null, + })); const visitedThreadIds = new Set(this.currentThreadId ? [this.currentThreadId] : []); while (queue.length > 0 && visitedThreadIds.size < 100) { const next = queue.shift(); @@ -3811,6 +3827,7 @@ export class CodexAppServerAgentSession implements AgentSession { timelineItem: next.route.toolCall, rawItem: { agentThreadId: next.route.childThreadId }, parentCallId: next.parentCallId, + parentSubagentId: next.parentSubagentId, }); try { const childHistory = await loadCodexThreadHistoryTimeline({ @@ -3822,7 +3839,11 @@ export class CodexAppServerAgentSession implements AgentSession { this.emitProviderSubagentTimeline(next.route.childThreadId, entry.item, entry.timestamp); } for (const route of childHistory.subAgentRoutes) { - queue.push({ route, parentCallId: next.route.toolCall.callId }); + queue.push({ + route, + parentCallId: next.route.toolCall.callId, + parentSubagentId: next.route.childThreadId, + }); } } catch (error) { this.logger.trace( @@ -5354,8 +5375,9 @@ export class CodexAppServerAgentSession implements AgentSession { timelineItem: ToolCallTimelineItem; rawItem: { [key: string]: unknown }; parentCallId: string | null; + parentSubagentId: string | null; }): string[] { - const { timelineItem, rawItem, parentCallId } = params; + const { timelineItem, rawItem, parentCallId, parentSubagentId } = params; if (timelineItem.detail.type !== "sub_agent") { return []; } @@ -5367,6 +5389,7 @@ export class CodexAppServerAgentSession implements AgentSession { callId: timelineItem.callId, toolCall: timelineItem, parentCallId, + parentSubagentId, activityItemIds: new Set(), pendingCommandOutputDeltas: new Map(), pendingFileChangeOutputDeltas: new Map(), @@ -5385,6 +5408,7 @@ export class CodexAppServerAgentSession implements AgentSession { }, }; state.parentCallId ??= parentCallId; + state.parentSubagentId ??= parentSubagentId; const activity = readCodexSubAgentActivity(rawItem); if (activity?.id) { state.activityItemIds.add(activity.id); @@ -5402,6 +5426,10 @@ export class CodexAppServerAgentSession implements AgentSession { new Set(agentThreadId ? [...receiverThreadIds, agentThreadId] : receiverThreadIds), ).filter((threadId) => threadId !== this.currentThreadId); for (const receiverThreadId of childThreadIds) { + const owningCallId = this.subAgentCallIdByChildThreadId.get(receiverThreadId); + if (owningCallId && owningCallId !== timelineItem.callId) { + continue; + } this.subAgentCallIdByChildThreadId.set(receiverThreadId, timelineItem.callId); state.childThreadIds.add(receiverThreadId); this.emitProviderSubagentUpsert(receiverThreadId, state, timelineItem.status); @@ -5620,6 +5648,7 @@ export class CodexAppServerAgentSession implements AgentSession { description: detail.description ?? null, status: providerStatus, toolCallId: state.callId, + parentSubagentId: state.parentSubagentId, }, }); } @@ -6257,6 +6286,7 @@ export class CodexAppServerAgentSession implements AgentSession { timelineItem, rawItem: parsed.item, parentCallId: childSubAgentCallId, + parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId), }) : []; const imageItems = mcpToolResultImagesToTimeline(parsed.item); @@ -6424,6 +6454,7 @@ export class CodexAppServerAgentSession implements AgentSession { timelineItem, rawItem: parsed.item, parentCallId: childSubAgentCallId, + parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId), }); if (childSubAgentCallId) { this.emitStartedProviderSubagentItem(parsed.threadId, timelineItem); diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 1f63c4d08e..8b2acae1bd 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -4414,6 +4414,7 @@ describe("OpenCode provider subagent contract", () => { event: { type: "upsert", id: "ses_child_registry", + parentSubagentId: null, description: "Live child", status: "running", }, @@ -5338,6 +5339,7 @@ describe("OpenCode provider subagent contract", () => { event: { type: "upsert", id: "ses_child_background", + parentSubagentId: null, description: "Plugin child", status: "running", }, @@ -5386,6 +5388,7 @@ describe("OpenCode provider subagent contract", () => { event: { type: "upsert", id: "ses_child_plugin", + parentSubagentId: null, description: "Background plugin child", status: "running", }, @@ -5629,6 +5632,7 @@ describe("OpenCode provider subagent contract", () => { event: { type: "upsert", id: "ses_child_rich", + parentSubagentId: null, title: "explore", description: "Investigate flaky test", status: "running", @@ -5655,7 +5659,12 @@ describe("OpenCode provider subagent contract", () => { { type: "provider_subagent", provider: "opencode", - event: { type: "upsert", id: "ses_child_bare", status: "running" }, + event: { + type: "upsert", + id: "ses_child_bare", + parentSubagentId: null, + status: "running", + }, }, ]); }); @@ -5980,12 +5989,24 @@ describe("OpenCode provider subagent contract", () => { { type: "provider_subagent", provider: "opencode", - event: { type: "upsert", id: "ses_child_a", description: "Child A", status: "completed" }, + event: { + type: "upsert", + id: "ses_child_a", + parentSubagentId: null, + description: "Child A", + status: "completed", + }, }, { type: "provider_subagent", provider: "opencode", - event: { type: "upsert", id: "ses_child_b", description: "Child B", status: "completed" }, + event: { + type: "upsert", + id: "ses_child_b", + parentSubagentId: null, + description: "Child B", + status: "completed", + }, }, { type: "provider_subagent", @@ -5993,6 +6014,7 @@ describe("OpenCode provider subagent contract", () => { event: { type: "upsert", id: "ses_grandchild_a", + parentSubagentId: "ses_child_a", description: "Grandchild A", status: "completed", }, @@ -6055,6 +6077,7 @@ describe("OpenCode provider subagent contract", () => { event: { type: "upsert", id: "ses_child_with_history", + parentSubagentId: null, description: "Historical child", status: "completed", cwd: "/workspace/child", @@ -6142,6 +6165,7 @@ describe("OpenCode provider subagent contract", () => { event: { type: "upsert", id: "ses_child_hydrated_facts", + parentSubagentId: null, description: "Chase the regression", status: "completed", subtitle: "claude-sonnet-5 · Max", diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index ad22023ec0..19d5f95939 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -2423,6 +2423,7 @@ function appendOpenCodeChildSessionDetected( event: { type: "upsert", id: child.id, + parentSubagentId: child.parentSessionId === state.sessionId ? null : child.parentSessionId, ...(title ? { title } : {}), ...(child.title && !presentation.descriptionFromLink ? { description: child.title } : {}), ...(status ? { status } : {}), @@ -4702,8 +4703,13 @@ class OpenCodeAgentSession implements AgentSession { return; } if (event.type === "provider_subagent" && event.event.type === "upsert" && event.event.status) { - if (isDeepStrictEqual(this.childStatuses.get(event.event.id), event.event)) return; - this.childStatuses.set(event.event.id, structuredClone(event.event)); + const previous = this.childStatuses.get(event.event.id); + const current = + event.event.parentSubagentId === undefined && previous?.parentSubagentId !== undefined + ? { ...event.event, parentSubagentId: previous.parentSubagentId } + : event.event; + if (isDeepStrictEqual(previous, current)) return; + this.childStatuses.set(event.event.id, structuredClone(current)); } const turnId = turnIdOverride === null ? null : (turnIdOverride ?? this.activeForegroundTurnId); const tagged = turnId ? { ...event, turnId } : event; diff --git a/packages/server/src/server/agent/providers/test-utils/session-stream-adapter.ts b/packages/server/src/server/agent/providers/test-utils/session-stream-adapter.ts index 5cd550a730..e49a57e309 100644 --- a/packages/server/src/server/agent/providers/test-utils/session-stream-adapter.ts +++ b/packages/server/src/server/agent/providers/test-utils/session-stream-adapter.ts @@ -76,3 +76,15 @@ export async function* streamSession( } } } + +export async function collectSessionTurnEvents( + session: Pick, + prompt: AgentPromptInput, + options?: AgentRunOptions, +): Promise { + const events: AgentStreamEvent[] = []; + for await (const event of streamSession(session, prompt, options)) { + events.push(event); + } + return events; +} diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 8cf4af4e2e..6e5b037526 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1720,6 +1720,8 @@ export class VoiceAssistantWebSocketServer { agentForkContextCursor: true, // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: true, + // COMPAT(providerSubagentNesting): added in v0.7, remove gate after 2027-03-04. + providerSubagentNesting: true, // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. workspacePinning: true, // COMPAT(hubRelationship): added in v0.1.X, drop the gate when floor >= v0.1.X. From 02e611f3dd5ef577199e0f3d333bec63394f83fe Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 5 Sep 2026 11:29:56 +0200 Subject: [PATCH 011/566] Hide empty ACP sessions from session import (#4335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(server): filter empty ACP import sessions ACP metadata probes can leave persisted sessions that later appear in import results. Replay available session history to exclude truly empty entries while retaining sessions whose history is unavailable, and close probe sessions before process teardown. * fix(server): bound ACP session history loading Keep import enrichment inside the provider listing deadline when session histories are inaccessible. Catalog abort cleanup now also waits briefly for an in-flight session creation so the resulting probe session can be closed. * test(server): avoid wall-clock ACP assertion Verify the history budget through the number of attempted protocol loads instead of elapsed process time, so scheduler delays cannot make the regression flaky. * fix(server): allow slower ACP history loads Give native session history more time to replay while retaining a shared enrichment budget below the provider listing deadline. * fix(server): give ACP history replay enough time Use the provider RPC timeout scale for native history loading, with a larger shared budget and provider-list deadline. Keep timeout coverage fast and deterministic with a controlled clock. * fix(server): isolate generous ACP import deadlines Keep the long listing deadline for the picker’s single-provider requests while preserving the short aggregate deadline for callers that fan out across providers. Restore deterministic coverage of unresolved history loads through fake timers and an injected clock boundary. * fix(server): preserve ACP aggregate listings --- .../src/server/agent/agent-manager.test.ts | 4 +- .../server/src/server/agent/agent-manager.ts | 2 +- .../server/agent/providers/acp-agent.test.ts | 246 +++++++++++++- .../src/server/agent/providers/acp-agent.ts | 313 ++++++++++++++++-- .../generic-acp-agent.diagnostic.test.ts | 282 +++++++++++++++- .../agent/providers/generic-acp-agent.ts | 2 + 6 files changed, 809 insertions(+), 40 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 2c2faa3b92..f2c8fc7d05 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -10157,7 +10157,7 @@ test("listImportableSessions returns healthy rows alongside thrown and timed-out }); const resultPromise = manager.listImportableSessions(); - await vi.advanceTimersByTimeAsync(8_000); + await vi.advanceTimersByTimeAsync(90_000); await expect(resultPromise).resolves.toEqual({ sessions: [ @@ -10175,7 +10175,7 @@ test("listImportableSessions returns healthy rows alongside thrown and timed-out { provider: "codex", message: "codex listing failed" }, { provider: "pi", - message: "Timed out listing importable sessions for provider 'pi' after 8000ms", + message: "Timed out listing importable sessions for provider 'pi' after 90000ms", }, ], }); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 2a1b2d4e59..edda3204e4 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -87,7 +87,7 @@ import { withTimeout } from "../../utils/promise-timeout.js"; const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000; const INTERRUPT_SESSION_TIMEOUT_MS = 2_000; -const IMPORTABLE_SESSION_LIST_TIMEOUT_MS = 8_000; +const IMPORTABLE_SESSION_LIST_TIMEOUT_MS = 90_000; const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: false, supportsSessionPersistence: true, diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index ac4b407cf4..1c77a6a25d 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -45,7 +45,11 @@ import { GenericACPAgentClient } from "./generic-acp-agent.js"; import { parseKiroExtensionCommands } from "./kiro-acp-agent.js"; import { transformPiModels } from "./pi/agent.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js"; -import type { AgentCapabilityFlags, AgentPersistenceHandle } from "../agent-sdk-types.js"; +import type { + AgentCapabilityFlags, + AgentPersistenceHandle, + ProviderRefreshContext, +} from "../agent-sdk-types.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { buildStringCommandShellInvocation } from "../../../utils/string-command-shell.js"; import { asInternals } from "../../test-utils/class-mocks.js"; @@ -3516,6 +3520,246 @@ describe("ACPAgentClient probe cleanup", () => { expect(child.stdout.destroyed).toBe(true); expect(child.stderr.destroyed).toBe(true); }); + + test("closes the native catalog probe session before terminating its process", async () => { + const terminator = new FakeTerminator(); + const child = createProbeChildStub(); + const closeSession = vi.fn().mockImplementation(async () => { + expect(terminator.terminated).toEqual([]); + return {}; + }); + + class TestACPAgentClient extends ACPAgentClient { + protected override async spawnProcess(): Promise { + return { + child, + connection: { + newSession: vi.fn().mockResolvedValue({ + sessionId: "catalog-probe-session", + modes: null, + models: null, + configOptions: [], + }), + unstable_closeSession: closeSession, + }, + initialize: { agentCapabilities: { sessionCapabilities: { close: {} } } }, + } as unknown as SpawnedACPProcess; + } + } + + const client = new TestACPAgentClient({ + provider: "claude-acp", + logger: createTestLogger(), + defaultCommand: ["claude", "--acp"], + defaultModes: [], + terminateProcess: terminator.terminate, + }); + + await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/acp-models", force: false }); + + expect(closeSession).toHaveBeenCalledWith({ sessionId: "catalog-probe-session" }); + expect(terminator.terminated).toContain(child); + }); + + test("closes a catalog probe session that finishes being created after refresh aborts", async () => { + const terminator = new FakeTerminator(); + const child = createProbeChildStub(); + const abort = new AbortController(); + let resolveNewSession!: (value: { + sessionId: string; + modes: null; + models: null; + configOptions: []; + }) => void; + const newSession = vi.fn( + () => + new Promise<{ + sessionId: string; + modes: null; + models: null; + configOptions: []; + }>((resolve) => { + resolveNewSession = resolve; + }), + ); + const closeSession = vi.fn().mockResolvedValue({}); + + class TestACPAgentClient extends ACPAgentClient { + protected override async spawnProcess(): Promise { + return { + child, + connection: { newSession, unstable_closeSession: closeSession }, + initialize: { agentCapabilities: { sessionCapabilities: { close: {} } } }, + } as unknown as SpawnedACPProcess; + } + } + + const client = new TestACPAgentClient({ + provider: "claude-acp", + logger: createTestLogger(), + defaultCommand: ["claude", "--acp"], + defaultModes: [], + terminateProcess: terminator.terminate, + }); + const context: ProviderRefreshContext = { + signal: abort.signal, + runActivity: async (_name, operation) => await operation(), + }; + const catalog = client.fetchCatalog( + { scope: "workspace", cwd: "/tmp/acp-models", force: false }, + context, + ); + await vi.waitFor(() => expect(newSession).toHaveBeenCalledOnce()); + + abort.abort(new Error("refresh cancelled")); + resolveNewSession({ + sessionId: "late-catalog-probe-session", + modes: null, + models: null, + configOptions: [], + }); + + await expect(catalog).rejects.toThrow("refresh cancelled"); + expect(closeSession).toHaveBeenCalledWith({ sessionId: "late-catalog-probe-session" }); + expect(terminator.terminated).toContain(child); + }); + + test("closes the native feature probe session before terminating its process", async () => { + const terminator = new FakeTerminator(); + const child = createProbeChildStub(); + const closeSession = vi.fn().mockResolvedValue({}); + + class TestACPAgentClient extends ACPAgentClient { + protected override async spawnProcess(): Promise { + return { + child, + connection: { + newSession: vi.fn().mockResolvedValue({ + sessionId: "feature-probe-session", + modes: null, + models: null, + configOptions: [copilotAgentConfigOption("Probe Agent")], + }), + unstable_closeSession: closeSession, + }, + initialize: { agentCapabilities: { sessionCapabilities: { close: {} } } }, + } as unknown as SpawnedACPProcess; + } + } + + const client = new TestACPAgentClient({ + provider: "copilot", + logger: createTestLogger(), + defaultCommand: ["copilot", "--acp"], + defaultModes: [], + configFeatureOptions: [COPILOT_AGENT_FEATURE_OPTION], + terminateProcess: terminator.terminate, + }); + + await client.listFeatures({ provider: "copilot", cwd: "/tmp/acp-features" }); + + expect(closeSession).toHaveBeenCalledWith({ sessionId: "feature-probe-session" }); + expect(terminator.terminated).toContain(child); + }); + + test("still terminates the probe when native session close fails", async () => { + const terminator = new FakeTerminator(); + const child = createProbeChildStub(); + + class TestACPAgentClient extends ACPAgentClient { + protected override async spawnProcess(): Promise { + return { + child, + connection: { + newSession: vi.fn().mockResolvedValue({ + sessionId: "catalog-probe-session", + modes: null, + models: null, + configOptions: [], + }), + unstable_closeSession: vi.fn().mockRejectedValue(new Error("close failed")), + }, + initialize: { agentCapabilities: { sessionCapabilities: { close: {} } } }, + } as unknown as SpawnedACPProcess; + } + } + + const client = new TestACPAgentClient({ + provider: "claude-acp", + logger: createTestLogger(), + defaultCommand: ["claude", "--acp"], + defaultModes: [], + terminateProcess: terminator.terminate, + }); + + await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/acp-models", force: false }); + + expect(terminator.terminated).toContain(child); + }); + + test("keeps a session visible and cleans up after its history load times out", async () => { + vi.useFakeTimers(); + try { + const terminator = new FakeTerminator(); + const child = createProbeChildStub(); + const loadSession = vi.fn(async () => await new Promise(() => undefined)); + + class TestACPAgentClient extends ACPAgentClient { + protected override async spawnProcess(): Promise { + return { + child, + connection: { + listSessions: vi.fn().mockResolvedValue({ + sessions: [ + { + sessionId: "slow-session", + cwd: "/tmp/acp-import", + title: "Slow but real", + updatedAt: "2026-09-04T20:00:00.000Z", + }, + ], + nextCursor: null, + }), + loadSession, + unstable_closeSession: vi.fn().mockResolvedValue({}), + }, + initialize: { + agentCapabilities: { + loadSession: true, + sessionCapabilities: { list: {}, close: {} }, + }, + }, + } as unknown as SpawnedACPProcess; + } + } + + const client = new TestACPAgentClient({ + provider: "claude-acp", + logger: createTestLogger(), + defaultCommand: ["claude", "--acp"], + defaultModes: [], + terminateProcess: terminator.terminate, + }); + const listing = client.listImportableSessions({ limit: 1 }); + await vi.waitFor(() => expect(loadSession).toHaveBeenCalledOnce()); + + await vi.advanceTimersByTimeAsync(30_000); + + await expect(listing).resolves.toEqual([ + { + providerHandleId: "slow-session", + cwd: "/tmp/acp-import", + title: "Slow but real", + firstPromptPreview: null, + lastPromptPreview: null, + lastActivityAt: new Date("2026-09-04T20:00:00.000Z"), + }, + ]); + expect(terminator.terminated).toContain(child); + } finally { + vi.useRealTimers(); + } + }); }); describe("ACP session/load invariant — cwd and mcpServers always passed", () => { diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index fab367e90a..0adb620136 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -277,6 +277,9 @@ export function buildACPClientCapabilities( // NO_BROWSER is honored by Gemini CLI; other ACP agents ignore it. const PROBE_ENV: Record = { NO_BROWSER: "true" }; const ACP_DIAGNOSTIC_PHASE_TIMEOUT_MS = 20_000; +const ACP_PROBE_CLOSE_TIMEOUT_MS = 2_000; +const ACP_IMPORT_HISTORY_LOAD_TIMEOUT_MS = 30_000; +const ACP_IMPORT_HISTORY_BUDGET_MS = 60_000; function summarizeMalformedACPStdoutError(error: unknown): { type: string; message: string } { return { @@ -439,6 +442,7 @@ interface ACPAgentClientOptions { waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number; terminateProcess?: ProcessTerminator; + now?: () => number; } interface ACPAgentSessionOptions { @@ -493,6 +497,78 @@ interface ACPProcessTransport { spawnError: Promise; } +interface ACPImportPromptPreviews { + firstPromptPreview: string | null; + lastPromptPreview: string | null; + hasConversation: boolean; +} + +interface ACPImportPromptCacheEntry extends ACPImportPromptPreviews { + updatedAt: string | null; +} + +class ACPImportHistoryCollector { + private readonly prompts = new Map< + string, + { + completed: string[]; + current: { messageId: string | null; text: string } | null; + hasConversation: boolean; + } + >(); + + begin(sessionId: string): void { + this.prompts.set(sessionId, { completed: [], current: null, hasConversation: false }); + } + + accept(params: SessionNotification): void { + const state = this.prompts.get(params.sessionId); + if (!state) return; + const update = params.update; + if (isACPConversationUpdate(update)) state.hasConversation = true; + if (update.sessionUpdate !== "user_message_chunk") { + this.flushCurrent(state); + return; + } + + const text = contentBlockToText(update.content); + if (!text) return; + const messageId = update.messageId ?? null; + if (state.current?.messageId && messageId !== state.current.messageId) { + this.flushCurrent(state); + } + state.current ??= { messageId, text: "" }; + state.current.text += text; + } + + finish(sessionId: string): ACPImportPromptPreviews { + const state = this.prompts.get(sessionId); + if (!state) { + return { firstPromptPreview: null, lastPromptPreview: null, hasConversation: false }; + } + this.flushCurrent(state); + this.prompts.delete(sessionId); + return { + firstPromptPreview: normalizeACPImportPromptPreview(state.completed[0] ?? null), + lastPromptPreview: normalizeACPImportPromptPreview(state.completed.at(-1) ?? null), + hasConversation: state.hasConversation, + }; + } + + discard(sessionId: string): void { + this.prompts.delete(sessionId); + } + + private flushCurrent(state: { + completed: string[]; + current: { messageId: string | null; text: string } | null; + hasConversation: boolean; + }): void { + if (state.current?.text.trim()) state.completed.push(state.current.text); + state.current = null; + } +} + export interface ACPToolSnapshot { toolCallId: string; title: string; @@ -826,6 +902,8 @@ export class ACPAgentClient implements AgentClient { private readonly waitForInitialCommands: boolean; private readonly initialCommandsWaitTimeoutMs: number; private readonly extensionCommandsParser?: ACPExtensionCommandsParser; + private readonly importPromptCache = new Map(); + private readonly now: () => number; protected readonly terminateProcess: ProcessTerminator; constructor(options: ACPAgentClientOptions) { @@ -854,6 +932,7 @@ export class ACPAgentClient implements AgentClient { this.waitForInitialCommands = options.waitForInitialCommands ?? false; this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500; this.extensionCommandsParser = options.extensionCommandsParser; + this.now = options.now ?? Date.now; } async createSession( @@ -948,10 +1027,30 @@ export class ACPAgentClient implements AgentClient { ): Promise { const cwd = options.scope === "global" ? homedir() : options.cwd; let probe: UninitializedACPProcess | null = null; + let probeSessionId: string | null = null; + let probeSessionPromise: Promise | null = null; let closePromise: Promise | null = null; const closeProbe = (): Promise => { if (!probe) return Promise.resolve(); - closePromise ??= this.closeProbe(probe); + closePromise ??= (async () => { + if (!probeSessionId && probeSessionPromise) { + try { + probeSessionId = ( + await withTimeout( + probeSessionPromise, + ACP_PROBE_CLOSE_TIMEOUT_MS, + `ACP probe session/new cleanup timed out after ${ACP_PROBE_CLOSE_TIMEOUT_MS}ms`, + ) + ).sessionId; + } catch (error) { + this.logger.debug( + { err: error }, + "ACP probe session/new did not finish during cleanup", + ); + } + } + await this.closeProbe(probe, probeSessionId); + })(); return closePromise; }; const handleAbort = () => void closeProbe().catch(() => undefined); @@ -970,17 +1069,16 @@ export class ACPAgentClient implements AgentClient { ), ); probe = initializedProbe; - const response = await runProviderRefreshActivity(context, "session/new", () => - raceProviderRefreshAbort( - context?.signal, - this.runACPRequest(() => - initializedProbe.connection.newSession({ - cwd, - mcpServers: [], - }), - ), - ), + probeSessionPromise = this.runACPRequest(() => + initializedProbe.connection.newSession({ + cwd, + mcpServers: [], + }), ); + const response = await runProviderRefreshActivity(context, "session/new", () => + raceProviderRefreshAbort(context?.signal, probeSessionPromise!), + ); + probeSessionId = response.sessionId; const transformed = this.transformSessionResponse(response); const derivedModels = deriveModelDefinitionsFromACP( this.provider, @@ -1030,6 +1128,7 @@ export class ACPAgentClient implements AgentClient { this.assertProvider(config); const probe = await this.spawnProcess(PROBE_ENV); + let probeSessionId: string | null = null; try { const response = await this.runACPRequest(() => probe.connection.newSession({ @@ -1037,53 +1136,154 @@ export class ACPAgentClient implements AgentClient { mcpServers: [], }), ); + probeSessionId = response.sessionId; const transformed = this.transformSessionResponse(response); return [ autoAcceptFeature, ...deriveFeaturesFromACP(transformed.configOptions, this.configFeatureOptions), ]; } finally { - await this.closeProbe(probe); + await this.closeProbe(probe, probeSessionId); } } async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { - const probe = await this.spawnProcess(PROBE_ENV); + const history = new ACPImportHistoryCollector(); + const probe = await this.spawnProcess(PROBE_ENV, { + client: this.buildProbeClient((params) => history.accept(params)), + }); try { if (!probe.initialize.agentCapabilities?.sessionCapabilities?.list) { return []; } const sessions: ImportableProviderSession[] = []; - const scanLimit = Math.min(options?.scanLimit ?? options?.limit ?? 500, 500); + const resultLimit = options?.limit ?? Number.POSITIVE_INFINITY; + const scanLimit = Math.min(options?.scanLimit ?? 500, 500); + const canLoadHistory = probe.initialize.agentCapabilities.loadSession === true; + const historyDeadline = this.now() + ACP_IMPORT_HISTORY_BUDGET_MS; + let scanned = 0; let cursor: string | null | undefined; for (;;) { const page: ListSessionsResponse = await this.runACPRequest(() => probe.connection.listSessions(acpSessionListRequest(cursor, options?.cwd)), ); for (const session of page.sessions) { - sessions.push({ - providerHandleId: session.sessionId, - cwd: session.cwd, - title: session.title ?? null, - firstPromptPreview: null, - lastPromptPreview: null, - lastActivityAt: session.updatedAt ? new Date(session.updatedAt) : new Date(0), - }); + if (scanned >= scanLimit || sessions.length >= resultLimit) break; + scanned += 1; + const descriptor = await this.describeImportableSession( + probe, + history, + session, + canLoadHistory, + historyDeadline, + ); + if (descriptor) sessions.push(descriptor); } cursor = page.nextCursor ?? null; if (!cursor) break; - if (sessions.length >= scanLimit) break; + if (scanned >= scanLimit || sessions.length >= resultLimit) break; } - return typeof options?.limit === "number" ? sessions.slice(0, options.limit) : sessions; + return sessions; } finally { await this.closeProbe(probe); } } + private async describeImportableSession( + probe: SpawnedACPProcess, + history: ACPImportHistoryCollector, + session: ListSessionsResponse["sessions"][number], + canLoadHistory: boolean, + historyDeadline: number, + ): Promise { + const loadedPreviews = canLoadHistory + ? await this.loadImportPromptPreviews(probe, history, session, historyDeadline) + : null; + if (loadedPreviews?.hasConversation === false) return null; + return { + providerHandleId: session.sessionId, + cwd: session.cwd, + title: session.title ?? null, + firstPromptPreview: loadedPreviews?.firstPromptPreview ?? null, + lastPromptPreview: loadedPreviews?.lastPromptPreview ?? null, + lastActivityAt: session.updatedAt ? new Date(session.updatedAt) : new Date(0), + }; + } + + private async loadImportPromptPreviews( + probe: SpawnedACPProcess, + history: ACPImportHistoryCollector, + session: ListSessionsResponse["sessions"][number], + historyDeadline: number, + ): Promise { + const updatedAt = session.updatedAt ?? null; + const cached = this.importPromptCache.get(session.sessionId); + if (updatedAt !== null && cached?.updatedAt === updatedAt) { + return { + firstPromptPreview: cached.firstPromptPreview, + lastPromptPreview: cached.lastPromptPreview, + hasConversation: cached.hasConversation, + }; + } + + const remainingMs = historyDeadline - this.now(); + if (remainingMs <= 0) return null; + const loadTimeoutMs = Math.min(ACP_IMPORT_HISTORY_LOAD_TIMEOUT_MS, remainingMs); + + history.begin(session.sessionId); + try { + await withTimeout( + this.runACPRequest(() => + probe.connection.loadSession({ + sessionId: session.sessionId, + cwd: session.cwd, + mcpServers: [], + }), + ), + loadTimeoutMs, + `ACP import history load timed out after ${loadTimeoutMs}ms`, + ); + const previews = history.finish(session.sessionId); + if (updatedAt !== null) { + this.importPromptCache.set(session.sessionId, { updatedAt, ...previews }); + } + return previews; + } catch (error) { + history.discard(session.sessionId); + this.logger.debug( + { err: error, sessionId: session.sessionId }, + "ACP import history load failed; keeping the session visible", + ); + return null; + } finally { + await this.closeLoadedSession(probe, session.sessionId, historyDeadline); + } + } + + private async closeLoadedSession( + probe: SpawnedACPProcess, + sessionId: string, + historyDeadline: number, + ): Promise { + if (!probe.initialize.agentCapabilities?.sessionCapabilities?.close) return; + const remainingMs = historyDeadline - this.now(); + if (remainingMs <= 0) return; + const closeTimeoutMs = Math.min(ACP_PROBE_CLOSE_TIMEOUT_MS, remainingMs); + try { + await withTimeout( + probe.connection.unstable_closeSession({ sessionId }), + closeTimeoutMs, + `ACP loaded session/close timed out after ${closeTimeoutMs}ms`, + ); + } catch (error) { + this.logger.debug({ err: error, sessionId }, "ACP loaded session close failed"); + } + } + async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { return importSessionFromPersistence({ provider: this.provider, @@ -1107,9 +1307,10 @@ export class ACPAgentClient implements AgentClient { options?: { initializeTimeoutMs?: number; onSpawned?: (probe: UninitializedACPProcess) => void; + client?: ACPClient; }, ): Promise { - const transport = await this.spawnTransport(launchEnv); + const transport = await this.spawnTransport(launchEnv, options?.client); const probe: UninitializedACPProcess = { child: transport.child, connection: transport.connection, @@ -1130,7 +1331,10 @@ export class ACPAgentClient implements AgentClient { } } - protected async spawnTransport(launchEnv?: Record): Promise { + protected async spawnTransport( + launchEnv?: Record, + client: ACPClient = this.buildProbeClient(), + ): Promise { const { command, args } = await this.resolveLaunchCommand(); const child = spawnProcess(command, args, { cwd: process.cwd(), @@ -1164,7 +1368,7 @@ export class ACPAgentClient implements AgentClient { Readable.toWeb(child.stdout), { logger: this.logger, provider: this.provider }, ); - const connection = new ClientSideConnection(() => this.buildProbeClient(), stream); + const connection = new ClientSideConnection(() => client, stream); return { child, @@ -1210,12 +1414,16 @@ export class ACPAgentClient implements AgentClient { } } - protected buildProbeClient(): ACPClient { + protected buildProbeClient( + onSessionUpdate?: (params: SessionNotification) => void | Promise, + ): ACPClient { return { async requestPermission(): Promise { return { outcome: { outcome: "cancelled" } }; }, - async sessionUpdate(): Promise {}, + async sessionUpdate(params): Promise { + await onSessionUpdate?.(params); + }, async readTextFile(params: ReadTextFileRequest) { const content = await fs.readFile(params.path, "utf8"); return { content }; @@ -1231,11 +1439,20 @@ export class ACPAgentClient implements AgentClient { }; } - protected async closeProbe(probe: UninitializedACPProcess): Promise { + protected async closeProbe( + probe: UninitializedACPProcess, + sessionId: string | null = null, + ): Promise { try { - if (probe.initialize?.agentCapabilities?.sessionCapabilities?.close) { - // No active session to close here; ignore capability. + if (sessionId && probe.initialize?.agentCapabilities?.sessionCapabilities?.close) { + await withTimeout( + probe.connection.unstable_closeSession({ sessionId }), + ACP_PROBE_CLOSE_TIMEOUT_MS, + `ACP probe session/close timed out after ${ACP_PROBE_CLOSE_TIMEOUT_MS}ms`, + ); } + } catch (error) { + this.logger.debug({ err: error, sessionId }, "ACP probe closeSession failed during cleanup"); } finally { await terminateChildProcess(probe.child, 2_000, this.terminateProcess); } @@ -1259,6 +1476,8 @@ export class ACPAgentClient implements AgentClient { const phaseTimeoutMs = options.phaseTimeoutMs ?? ACP_DIAGNOSTIC_PHASE_TIMEOUT_MS; const cwd = options.cwd ?? homedir(); let transport: ACPProcessTransport | null = null; + let initialize: InitializeResponse | null = null; + let probeSessionId: string | null = null; try { const spawnStartedAt = Date.now(); @@ -1284,7 +1503,7 @@ export class ACPAgentClient implements AgentClient { const initializeStartedAt = Date.now(); try { - await this.initializeTransport(activeTransport, phaseTimeoutMs); + initialize = await this.initializeTransport(activeTransport, phaseTimeoutMs); rows.push({ label: "ACP initialize", value: `ok (${formatDurationMs(initializeStartedAt)})`, @@ -1310,6 +1529,7 @@ export class ACPAgentClient implements AgentClient { phaseTimeoutMs, `ACP session/new timed out after ${phaseTimeoutMs}ms`, ); + probeSessionId = response.sessionId; const transformed = this.transformSessionResponse(response); const models = deriveModelDefinitionsFromACP( this.provider, @@ -1342,7 +1562,15 @@ export class ACPAgentClient implements AgentClient { if (transport) { const cleanupStartedAt = Date.now(); try { - await terminateChildProcess(transport.child, 2_000, this.terminateProcess); + await this.closeProbe( + { + child: transport.child, + connection: transport.connection, + stderrChunks: transport.stderrChunks, + ...(initialize ? { initialize } : {}), + }, + probeSessionId, + ); rows.push({ label: "ACP cleanup", value: `ok (${formatDurationMs(cleanupStartedAt)})`, @@ -3225,6 +3453,23 @@ function contentBlockToText(content: ContentBlock): string { } } +function normalizeACPImportPromptPreview(text: string | null): string | null { + const normalized = text?.trim().replace(/\s+/g, " ") ?? ""; + if (!normalized) return null; + return normalized.length > 160 ? normalized.slice(0, 160) : normalized; +} + +function isACPConversationUpdate(update: SessionUpdate): boolean { + return ( + update.sessionUpdate === "user_message_chunk" || + update.sessionUpdate === "agent_message_chunk" || + update.sessionUpdate === "agent_thought_chunk" || + update.sessionUpdate === "tool_call" || + update.sessionUpdate === "tool_call_update" || + update.sessionUpdate === "plan" + ); +} + function coalesceDefined(next: T | undefined, previous: T | undefined, fallback: T): T { if (next !== undefined) { return next; diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts index 5bac343fdc..b37e1a9a54 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts @@ -17,7 +17,132 @@ function parseInitializeTrace(content: string): Array<{ clientCapabilities: unkn .map((line) => JSON.parse(line) as { clientCapabilities: unknown }); } +function historyBudgetClock(): () => number { + let calls = 0; + return () => (calls++ < 2 ? 0 : 60_001); +} + describe("GenericACPAgentClient diagnostics", () => { + test("filters empty ACP sessions by replaying history and returns prompt previews", async () => { + await withFakeACPAgent("history-list", async (scriptPath, mode, testDir) => { + const closeTracePath = path.join(testDir, "session-close.jsonl"); + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, scriptPath, mode, "", "", closeTracePath, testDir], + }); + + await expect(client.listImportableSessions({ limit: 1 })).resolves.toEqual([ + { + providerHandleId: "conversation-session", + cwd: testDir, + title: "Conversation", + firstPromptPreview: "First prompt", + lastPromptPreview: "Last prompt", + lastActivityAt: new Date("2026-09-04T20:00:00.000Z"), + }, + ]); + await expect(readFile(closeTracePath, "utf8")).resolves.toBe( + `${JSON.stringify({ sessionId: "empty-session" })}\n${JSON.stringify({ sessionId: "conversation-session" })}\n`, + ); + }); + }); + + test("keeps a listed session visible when its ACP history cannot be loaded", async () => { + await withFakeACPAgent("history-load-failure", async (scriptPath, mode, testDir) => { + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, scriptPath, mode, "", "", "", testDir], + }); + + await expect(client.listImportableSessions({ limit: 1 })).resolves.toEqual([ + { + providerHandleId: "stale-session", + cwd: testDir, + title: "Could still be real", + firstPromptPreview: null, + lastPromptPreview: null, + lastActivityAt: new Date("2026-09-04T20:00:00.000Z"), + }, + ]); + }); + }); + + test("keeps sessions with replayed conversation activity but no user text preview", async () => { + await withFakeACPAgent("history-list", async (scriptPath, mode, testDir) => { + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, scriptPath, mode, "", "", "", testDir], + }); + + const result = await client.listImportableSessions({ limit: 2 }); + + expect(result).toMatchObject([ + { providerHandleId: "conversation-session" }, + { providerHandleId: "assistant-only-session" }, + ]); + expect(result[1]).toMatchObject({ + firstPromptPreview: null, + lastPromptPreview: null, + }); + }); + }); + + test("reuses ACP prompt previews while the listed session timestamp is unchanged", async () => { + await withFakeACPAgent("history-list", async (scriptPath, mode, testDir) => { + const loadTracePath = path.join(testDir, "session-load.jsonl"); + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, scriptPath, mode, "", "", "", testDir, loadTracePath], + }); + + const first = await client.listImportableSessions({ limit: 1 }); + const second = await client.listImportableSessions({ limit: 1 }); + + expect(second).toEqual(first); + await expect(readFile(loadTracePath, "utf8")).resolves.toBe( + `${JSON.stringify({ sessionId: "empty-session" })}\n${JSON.stringify({ sessionId: "conversation-session" })}\n`, + ); + }); + }); + + test("bounds history loads by scanLimit when every inspected session is empty", async () => { + await withFakeACPAgent("history-list", async (scriptPath, mode, testDir) => { + const closeTracePath = path.join(testDir, "session-close.jsonl"); + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, scriptPath, mode, "", "", closeTracePath, testDir], + }); + + await expect(client.listImportableSessions({ limit: 1, scanLimit: 1 })).resolves.toEqual([]); + await expect(readFile(closeTracePath, "utf8")).resolves.toBe( + `${JSON.stringify({ sessionId: "empty-session" })}\n`, + ); + }); + }); + + test("bounds the total time spent loading inaccessible session histories", async () => { + await withFakeACPAgent("history-load-failures", async (scriptPath, mode, testDir) => { + const loadTracePath = path.join(testDir, "session-load.jsonl"); + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, scriptPath, mode, "", "", "", testDir, loadTracePath], + now: historyBudgetClock(), + }); + + const sessions = await client.listImportableSessions({ limit: 4 }); + + expect(sessions).toMatchObject([ + { providerHandleId: "failed-session-1" }, + { providerHandleId: "failed-session-2" }, + { providerHandleId: "failed-session-3" }, + { providerHandleId: "failed-session-4" }, + ]); + await expect(readFile(loadTracePath, "utf8")).resolves.toBe( + `${JSON.stringify({ sessionId: "failed-session-1" })}\n`, + ); + }); + }); + test("probes npx-backed agent packages instead of npx itself", () => { expect(buildVersionProbeCommand(["npx", "-y", "@google/gemini-cli@0.41.1", "--acp"])).toEqual({ command: "npx", @@ -57,6 +182,25 @@ describe("GenericACPAgentClient diagnostics", () => { }); }); + test("closes the native diagnostic probe session", async () => { + await withFakeACPAgent("success", async (scriptPath, mode, testDir) => { + const closeTracePath = path.join(testDir, "session-close.jsonl"); + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, scriptPath, mode, "", "", closeTracePath], + providerId: "custom-acp", + label: "Custom ACP", + diagnosticPhaseTimeoutMs: TEST_ACP_TIMEOUT_MS, + }); + + await client.getDiagnostic(); + + await expect(readFile(closeTracePath, "utf8")).resolves.toContain( + JSON.stringify({ sessionId: "session-1" }), + ); + }); + }); + test("reports a hung ACP session/new phase without failing the diagnostic", async () => { await withFakeACPAgent("hang-session", async (scriptPath, mode) => { const client = new GenericACPAgentClient({ @@ -181,7 +325,12 @@ describe("GenericACPAgentClient diagnostics", () => { }); async function withFakeACPAgent( - mode: "success" | "hang-session", + mode: + | "success" + | "hang-session" + | "history-list" + | "history-load-failure" + | "history-load-failures", run: (scriptPath: string, mode: string, testDir: string) => Promise, ): Promise { await withTempDir("paseo-acp-diagnostic-", async (testDir) => { @@ -230,6 +379,9 @@ const readline = require("node:readline"); const mode = process.argv[2]; const pidPath = process.argv[3]; const initializeTracePath = process.argv[4]; +const closeTracePath = process.argv[5]; +const sessionCwd = process.argv[6]; +const loadTracePath = process.argv[7]; if (pidPath) { fs.writeFileSync(pidPath, String(process.pid)); } @@ -239,6 +391,10 @@ function send(id, result) { process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\\n"); } +function sendError(id, code, message) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\\n"); +} + rl.on("line", (line) => { const message = JSON.parse(line); if (message.method === "initialize") { @@ -250,7 +406,13 @@ rl.on("line", (line) => { } send(message.id, { protocolVersion: message.params?.protocolVersion ?? 1, - agentCapabilities: {}, + agentCapabilities: { + loadSession: + mode === "history-list" || + mode === "history-load-failure" || + mode === "history-load-failures", + sessionCapabilities: { close: {}, list: {} }, + }, }); return; } @@ -272,6 +434,122 @@ rl.on("line", (line) => { }, configOptions: [], }); + return; + } + + if (message.method === "session/close") { + if (closeTracePath) { + fs.appendFileSync(closeTracePath, JSON.stringify(message.params) + "\\n"); + } + send(message.id, {}); + return; + } + + if (message.method === "session/list" && mode === "history-list") { + send(message.id, { + sessions: [ + { + sessionId: "empty-session", + cwd: sessionCwd, + title: "Empty", + updatedAt: "2026-09-04T21:00:00.000Z", + }, + { + sessionId: "conversation-session", + cwd: sessionCwd, + title: "Conversation", + updatedAt: "2026-09-04T20:00:00.000Z", + }, + { + sessionId: "assistant-only-session", + cwd: sessionCwd, + title: "Recovered conversation", + updatedAt: "2026-09-04T19:00:00.000Z", + }, + ], + nextCursor: null, + }); + return; + } + + if (message.method === "session/list" && mode === "history-load-failure") { + send(message.id, { + sessions: [{ + sessionId: "stale-session", + cwd: sessionCwd, + title: "Could still be real", + updatedAt: "2026-09-04T20:00:00.000Z", + }], + nextCursor: null, + }); + return; + } + + if (message.method === "session/list" && mode === "history-load-failures") { + send(message.id, { + sessions: [1, 2, 3, 4].map((number) => ({ + sessionId: "failed-session-" + number, + cwd: sessionCwd, + title: "Could still be real " + number, + updatedAt: "2026-09-04T20:00:00.000Z", + })), + nextCursor: null, + }); + return; + } + + if (message.method === "session/load" && mode === "history-list") { + if (loadTracePath) { + fs.appendFileSync( + loadTracePath, + JSON.stringify({ sessionId: message.params.sessionId }) + "\\n", + ); + } + if (message.params.sessionId === "conversation-session") { + for (const [messageId, text] of [["user-1", "First prompt"], ["user-2", "Last prompt"]]) { + process.stdout.write(JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: message.params.sessionId, + update: { + sessionUpdate: "user_message_chunk", + messageId, + content: { type: "text", text }, + }, + }, + }) + "\\n"); + } + } + if (message.params.sessionId === "assistant-only-session") { + process.stdout.write(JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: message.params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Recovered response" }, + }, + }, + }) + "\\n"); + } + send(message.id, { modes: null, models: null, configOptions: [] }); + return; + } + + if ( + message.method === "session/load" && + (mode === "history-load-failure" || mode === "history-load-failures") + ) { + if (loadTracePath) { + fs.appendFileSync( + loadTracePath, + JSON.stringify({ sessionId: message.params.sessionId }) + "\\n", + ); + } + sendError(message.id, -32602, "stale session"); + return; } }); `; diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.ts b/packages/server/src/server/agent/providers/generic-acp-agent.ts index 095d4e10a8..3fcabb596e 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.ts @@ -51,6 +51,7 @@ interface GenericACPAgentClientOptions { configFeatureOptions?: ACPConfigFeatureOption[]; extensionCommandsParser?: ACPExtensionCommandsParser; catalogModelResolver?: ACPCatalogModelResolver; + now?: () => number; } export class GenericACPAgentClient extends ACPAgentClient { @@ -76,6 +77,7 @@ export class GenericACPAgentClient extends ACPAgentClient { configFeatureOptions: options.configFeatureOptions, extensionCommandsParser: options.extensionCommandsParser, catalogModelResolver: options.catalogModelResolver, + now: options.now, }); this.command = options.command; From 6f76a8b1397df391cb532bed6c9e9d456fed88f9 Mon Sep 17 00:00:00 2001 From: Omer Cohen <639682+omercnet@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:44:24 +0300 Subject: [PATCH 012/566] feat(client): expose agent permission responses through the public SDK (#3985) * feat(client): expose agent permission responses * test(client): verify permission response wire message --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/client/src/index.test.ts | 31 +++++++++++++++++++ packages/client/src/index.ts | 11 +++++++ public-docs/sdk/reference.md | 49 ++++++++++++++++--------------- 3 files changed, 67 insertions(+), 24 deletions(-) diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 0dde83cb4d..be5bd06811 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -237,6 +237,37 @@ test("createPaseoApi borrows daemon capabilities without exposing connection own expect("skills" in paseo.agents).toBe(false); }); +test("agent handles send permission responses for their agent", async () => { + const { client, ws } = await connectClient(); + + await client.agents.ref("agent_sdk").respondToPermission({ + requestId: "permission-request", + response: { + behavior: "deny", + selectedActionId: "deny-once", + message: "Not approved", + interrupt: true, + }, + }); + + expect(parseSentFrame(ws.sent.at(-1))).toEqual({ + type: "session", + message: { + type: "agent_permission_response", + agentId: "agent_sdk", + requestId: "permission-request", + response: { + behavior: "deny", + selectedActionId: "deny-once", + message: "Not approved", + interrupt: true, + }, + }, + }); + + await client.close(); +}); + test("project actions list registered projects through the existing RPC", async () => { const { client, ws } = await connectClient(); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 0cd94e5b62..b6b8d8e7b8 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,4 @@ +import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import type { AgentSnapshotPayload, CreateAgentRequestMessage, @@ -238,6 +239,12 @@ export interface PaseoAgentRunOptions extends PaseoAgentSendOptions { } export type PaseoAgentRunResult = WaitForFinishResult; +export type PaseoAgentPermissionResponse = AgentPermissionResponse; + +export interface PaseoAgentRespondToPermissionOptions { + requestId: string; + response: PaseoAgentPermissionResponse; +} export interface PaseoAgentCommandsOptions { requestId?: string; @@ -291,6 +298,7 @@ export interface PaseoAgentHandle { current(): PaseoAgent | null; refresh(requestId?: string): Promise; send(text: string, options?: PaseoAgentSendOptions): Promise; + respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise; /** Sends a prompt and resolves when that turn finishes or needs attention. */ run(text: string, options?: PaseoAgentRunOptions): Promise; /** Waits for the current turn, including one started with `prompt`. */ @@ -668,6 +676,9 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor send: async (text, options) => { await daemonClient.sendAgentMessage(id, text, options); }, + respondToPermission: async ({ requestId, response }) => { + await daemonClient.respondToPermission(id, requestId, response); + }, run: async (text, options) => { const { timeoutMs, ...sendOptions } = options ?? {}; await daemonClient.sendAgentMessage(id, text, sendOptions); diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 869fa06ffd..26c657e7f2 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -72,30 +72,31 @@ Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `o ### Agent handle -| Member | Result | Behavior | -| --------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `id` | `string` | Stable daemon agent ID. | -| `workspaceId` | `string \| null` | Current workspace placement. | -| `cwd` | `string \| null` | Current working directory. | -| `status` | Agent status or `null` | Current lifecycle status. | -| `capabilities` | Capability flags or `null` | What the provider session supports. | -| `availableModes` | Agent modes or `null` | Modes the session can switch to. | -| `pendingPermissions` | Permission requests or `null` | Requests waiting on an answer. | -| `activeTurn` | Active turn or `null` | The turn in flight, with `turnId` and `startedAt`. | -| `lastUsage` | Usage or `null` | Token counts, cost, and context-window use from the last turn. | -| `lastError` | `string \| null` | Last error the daemon recorded for the agent. | -| `features` | Agent features or `null` | Provider feature toggles and selects with their current values. | -| `runtimeInfo` | Runtime info or `null` | Live provider, session ID, model, thinking option, and mode. | -| `archivedAt` | `string \| null` | Archive timestamp; `null` while the agent is active. | -| `current()` | `PaseoAgent \| null` | Current detailed value observed by this handle; never fetches. | -| `refresh(requestId?)` | `PaseoAgentRefetchResult \| null` | Fetches the current agent and project placement. | -| `send(text, options?)` | `Promise` | Resolves when the daemon accepts the prompt. | -| `run(text, options?)` | `PaseoAgentRunResult` | Sends a prompt and waits for that turn. `timeoutMs` controls the wait; it defaults to 10 minutes. | -| `waitForFinish(timeoutMs?)` | `PaseoAgentRunResult` | Waits for the active turn, including an initial prompt. Default timeout: 10 minutes. | -| `commands(options?)` | `PaseoAgentCommandsResult` | Asks the live session for its slash commands and skills, including built-in ones. Options: `requestId`. | -| `subscribe(handler)` | Unsubscribe function | Filters agent-directory updates to this ID and refreshes the handle properties. | -| `archive()` | `{ archivedAt }` | Soft-deletes the agent and closes its runtime. | -| `detach()` | `Promise` | Removes the parent relationship without stopping the agent. | +| Member | Result | Behavior | +| ------------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `id` | `string` | Stable daemon agent ID. | +| `workspaceId` | `string \| null` | Current workspace placement. | +| `cwd` | `string \| null` | Current working directory. | +| `status` | Agent status or `null` | Current lifecycle status. | +| `capabilities` | Capability flags or `null` | What the provider session supports. | +| `availableModes` | Agent modes or `null` | Modes the session can switch to. | +| `pendingPermissions` | Permission requests or `null` | Requests waiting on an answer. | +| `activeTurn` | Active turn or `null` | The turn in flight, with `turnId` and `startedAt`. | +| `lastUsage` | Usage or `null` | Token counts, cost, and context-window use from the last turn. | +| `lastError` | `string \| null` | Last error the daemon recorded for the agent. | +| `features` | Agent features or `null` | Provider feature toggles and selects with their current values. | +| `runtimeInfo` | Runtime info or `null` | Live provider, session ID, model, thinking option, and mode. | +| `archivedAt` | `string \| null` | Archive timestamp; `null` while the agent is active. | +| `current()` | `PaseoAgent \| null` | Current detailed value observed by this handle; never fetches. | +| `refresh(requestId?)` | `PaseoAgentRefetchResult \| null` | Fetches the current agent and project placement. | +| `send(text, options?)` | `Promise` | Resolves when the daemon accepts the prompt. | +| `respondToPermission(options)` | `Promise` | Answers a pending permission by `requestId` with an allow or deny `response`. | +| `run(text, options?)` | `PaseoAgentRunResult` | Sends a prompt and waits for that turn. `timeoutMs` controls the wait; it defaults to 10 minutes. | +| `waitForFinish(timeoutMs?)` | `PaseoAgentRunResult` | Waits for the active turn, including an initial prompt. Default timeout: 10 minutes. | +| `commands(options?)` | `PaseoAgentCommandsResult` | Asks the live session for its slash commands and skills, including built-in ones. Options: `requestId`. | +| `subscribe(handler)` | Unsubscribe function | Filters agent-directory updates to this ID and refreshes the handle properties. | +| `archive()` | `{ archivedAt }` | Soft-deletes the agent and closes its runtime. | +| `detach()` | `Promise` | Removes the parent relationship without stopping the agent. | `workspaceId` through `archivedAt` mirror the last snapshot the handle observed. A handle from `ref()` reads `null` for all of them until `refresh()`, `run()`, `waitForFinish()`, a timeline refetch, or `subscribe()` delivers a snapshot. Optional values in an observed snapshot also read as `null`. Call `current()` when you need the whole snapshot or need to distinguish those states. From 78b285059f6ebd0b257c98bd191df4626721270a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 5 Sep 2026 14:55:19 +0200 Subject: [PATCH 013/566] Fix agent reloads blocked by an active session writer (#4353) * fix(server): keep agent reloads from overlapping session writers Idle runtimes can still own their persisted session writer. Release the original runtime before resuming and retain failed cleanup for retry; a timeout must not authorize another writer. * fix(server): honor closes queued during agent reload Serialize public close with reload so a completed close cannot be undone by replacement registration. Keep archive cleanup inside its existing lifecycle operation. --- docs/agent-lifecycle.md | 4 + .../src/server/agent/agent-manager.test.ts | 294 ++++++++++++++---- .../server/src/server/agent/agent-manager.ts | 94 +++--- .../src/server/agent/agent-prompt.test.ts | 4 +- .../codex-app-server-agent.local.e2e.test.ts | 54 +++- .../providers/codex-app-server-agent.test.ts | 8 +- .../agent/providers/codex-app-server-agent.ts | 2 +- .../codex/app-server-transport.test.ts | 25 +- .../providers/codex/app-server-transport.ts | 6 +- .../agent-reload.native.real.e2e.test.ts | 128 ++++++++ 10 files changed, 513 insertions(+), 106 deletions(-) create mode 100644 packages/server/src/server/daemon-e2e/agent-reload.native.real.e2e.test.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 20a823de80..418e820979 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -21,6 +21,10 @@ the agent runs through `ensureAgentLoaded()`, which resumes the durable provider same Paseo agent ID. Provider history is not appended again when the canonical timeline is already primed. +Reload releases the old runtime before resuming its durable session: an idle provider process can +still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the +replacement. Once closure succeeds, a failed resume leaves the durable agent closed and retryable. + Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle action such as archive, replacement, reload, workspace teardown, or daemon shutdown. diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index f2c8fc7d05..bc38784e48 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1623,7 +1623,7 @@ test("reload leaves a closed durable snapshot when shutdown starts during the sw }).toMatchObject({ agents: [], record: { lastStatus: "closed" }, - replacementSessionClosed: true, + replacementSessionClosed: false, }); } finally { client.finishClosing(); @@ -1633,7 +1633,7 @@ test("reload leaves a closed durable snapshot when shutdown starts during the sw } }); -test("reload closes both sessions when the closed snapshot cannot be persisted", async () => { +test("reload does not create a replacement when the closed snapshot cannot be persisted", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-persist-failure-test-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); @@ -1672,7 +1672,7 @@ test("reload closes both sessions when the closed snapshot cannot be persisted", }).toEqual({ agents: [], originalSessionClosed: true, - replacementSessionClosed: true, + replacementSessionClosed: false, }); } finally { client.finishClosing(); @@ -2244,74 +2244,254 @@ test("setAgentMode persists the selected mode across session reload", async () = expect(reloaded.currentModeId).toBe("full-access"); }); -test("reloadAgentSession completes when the previous session close hangs", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-close-timeout-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class HangingCloseSession extends TestAgentSession { - closeCalled = false; - - override async close(): Promise { - this.closeCalled = true; - await new Promise(() => {}); +test("reload releases the original writer before resuming the same session", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-writer-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + class ExclusiveWriterClient extends TestAgentClient { + current: CloseRecordingTestAgentSession | undefined; + override async createSession(config: AgentSessionConfig): Promise { + this.current = new CloseRecordingTestAgentSession(config); + return this.current; } - } - - class HangingCloseClient extends TestAgentClient { - readonly firstSession = new HangingCloseSession({ - provider: "codex", - cwd: workdir, - }); - resumeSessionCalls = 0; - - override async createSession(): Promise { - return this.firstSession; - } - override async resumeSession( - _handle: AgentPersistenceHandle, + handle: AgentPersistenceHandle, config?: Partial, ): Promise { - this.resumeSessionCalls += 1; - return new TestAgentSession({ + if (this.current && !this.current.closed) { + throw new Error("thread already has an active writer"); + } + this.current = new CloseRecordingTestAgentSession({ provider: "codex", cwd: config?.cwd ?? workdir, }); + this.current.describePersistence = () => ({ provider: "codex", sessionId: handle.sessionId }); + return this.current; } } - - const client = new HangingCloseClient(); - const manager = new AgentManager({ - clients: { - codex: client, - }, - registry: storage, - logger, - rescueTimeouts: { reloadSessionCloseMs: 10 }, - idFactory: () => "00000000-0000-4000-8000-000000000302", - }); - + const client = new ExclusiveWriterClient(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); try { - const snapshot = await manager.createAgent( - { - provider: "codex", - cwd: workdir, - }, - undefined, - { workspaceId: undefined }, - ); - - const reloaded = await manager.reloadAgentSession(snapshot.id); - - expect(reloaded.id).toBe(snapshot.id); - expect(client.firstSession.closeCalled).toBe(true); - expect(client.resumeSessionCalls).toBe(1); + const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + for (let i = 0; i < 3; i++) { + const reloaded = await manager.reloadAgentSession(created.id, undefined, { + rehydrateFromDisk: true, + }); + expect(reloaded.id).toBe(created.id); + expect(reloaded.persistence?.sessionId).toBe(created.persistence?.sessionId); + } + await manager.closeAgent(created.id); } finally { + await client.current?.close(); + await storage.flush(); rmSync(workdir, { recursive: true, force: true }); } }); +test("opening during reload waits for the replacement", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-open-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new HeldReloadCloseClient(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + try { + const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const reloading = manager.reloadAgentSession(created.id); + await client.waitForCloseToStart(); + let opened = false; + const opening = ensureAgentLoaded(created.id, { + agentManager: manager, + agentStorage: storage, + logger, + }).then((agent) => { + opened = true; + return agent; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(opened).toBe(false); + client.finishClosing(); + const replacement = await reloading; + expect((await opening).session).toBe(replacement.session); + await manager.closeAgent(created.id); + } finally { + client.finishClosing(); + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("closing during reload leaves the replacement closed", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-close-race-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new HeldReloadCloseClient(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + try { + const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const reloading = manager.reloadAgentSession(created.id); + await client.waitForCloseToStart(); + const closing = manager.closeAgent(created.id); + client.finishClosing(); + await Promise.all([reloading, closing]); + expect(manager.getAgent(created.id)).toBeNull(); + expect(client.replacementSessionClosed).toBe(true); + expect(await storage.get(created.id)).toMatchObject({ lastStatus: "closed" }); + } finally { + client.finishClosing(); + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("retrying a timed-out reload waits for the original close to finish", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-late-close-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new HeldReloadCloseClient(); + const manager = new AgentManager({ + clients: { codex: client }, + registry: storage, + logger, + rescueTimeouts: { reloadSessionCloseMs: 10 }, + }); + try { + const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + await expect(manager.reloadAgentSession(created.id)).rejects.toThrow("Timed out closing"); + expect(manager.getAgent(created.id)?.lifecycle).toBe("error"); + const retry = manager.reloadAgentSession(created.id); + client.finishClosing(); + expect((await retry).id).toBe(created.id); + expect(client.originalSessionClosed).toBe(true); + await manager.closeAgent(created.id); + } finally { + client.finishClosing(); + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("failed reload retains the closed agent for a later resume", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-recovery-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + class FailingResumeClient extends TestAgentClient { + fail = true; + override async resumeSession( + handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + if (this.fail) throw new Error("resume unavailable"); + return super.resumeSession(handle, config); + } + } + const client = new FailingResumeClient(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + try { + const created = await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Keep me" }, + undefined, + { workspaceId: undefined }, + ); + await expect(manager.reloadAgentSession(created.id)).rejects.toThrow("resume unavailable"); + expect(manager.getAgent(created.id)).toBeNull(); + expect(await storage.get(created.id)).toMatchObject({ + lastStatus: "closed", + title: "Keep me", + persistence: created.persistence, + }); + client.fail = false; + const recovered = await ensureAgentLoaded(created.id, { + agentManager: manager, + agentStorage: storage, + logger, + }); + expect(recovered.id).toBe(created.id); + expect((await storage.get(created.id))?.title).toBe("Keep me"); + await manager.closeAgent(created.id); + } finally { + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test.each(["hang", "reject"])( + "reload does not resume when the previous session close fails: %s", + async (failure) => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-close-timeout-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class HangingCloseSession extends TestAgentSession { + closeCalled = false; + + override async close(): Promise { + this.closeCalled = true; + if (failure === "reject") throw new Error("close rejected"); + await new Promise(() => {}); + } + } + + class HangingCloseClient extends TestAgentClient { + readonly firstSession = new HangingCloseSession({ + provider: "codex", + cwd: workdir, + }); + resumeSessionCalls = 0; + + override async createSession(): Promise { + return this.firstSession; + } + + override async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + this.resumeSessionCalls += 1; + return new TestAgentSession({ + provider: "codex", + cwd: config?.cwd ?? workdir, + }); + } + } + + const client = new HangingCloseClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + rescueTimeouts: { reloadSessionCloseMs: 10 }, + idFactory: () => "00000000-0000-4000-8000-000000000302", + }); + + try { + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); + + for (let i = 0; i < 2; i++) { + await expect(manager.reloadAgentSession(snapshot.id)).rejects.toThrow( + failure === "hang" ? "Timed out closing previous session" : "close rejected", + ); + } + expect(client.firstSession.closeCalled).toBe(true); + expect(client.resumeSessionCalls).toBe(0); + expect(manager.getAgent(snapshot.id)?.session).toBe(client.firstSession); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } + }, +); + test("cancelAgentRun preserves running state when the provider interrupt hangs", async () => { const fixture = await createControlledInterruptFixture({ name: "interrupt-timeout", @@ -2809,7 +2989,7 @@ test("reloadAgentSession preserves the live session when its replacement cannot }), ).rejects.toThrow("Provider 'codex' does not support MCP servers"); - expect(replacement.closed).toBe(true); + expect(replacement.closed).toBe(false); expect(original.closed).toBe(false); expect(manager.getAgent(created.id)?.session).toBe(original); expect(manager.getAgent(created.id)?.lifecycle).toBe("idle"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index edda3204e4..4d3c1447f7 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -704,6 +704,7 @@ export class AgentManager { private readonly backgroundTasks = new Set>(); private readonly agentRegistrationTasks = new Set>(); private readonly inFlightAgentCloses = new Map>(); + private readonly reloadedSessionCloses = new WeakMap>(); private readonly lifecycleMutationTails = new Map>(); private readonly agentStreamCoalescer: AgentStreamCoalescer; private mcpBaseUrl: string | null; @@ -1120,6 +1121,8 @@ export class AgentManager { } async waitForAgentClose(agentId: string): Promise { + // Loading during reload must wait for the replacement, not resume another writer. + await this.lifecycleMutationTails.get(agentId); await this.inFlightAgentCloses?.get(agentId)?.catch(() => undefined); } @@ -1398,7 +1401,9 @@ export class AgentManager { options?: { rehydrateFromDisk?: boolean }, ): Promise { return this.trackAgentRegistrationOperation( - this.reloadAgentSessionInternal(agentId, overrides, options), + this.runLifecycleMutation(agentId, () => + this.reloadAgentSessionInternal(agentId, overrides, options), + ), ); } @@ -1439,28 +1444,32 @@ export class AgentManager { paseoToolPolicy, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); - this.paseoToolPolicies.set(agentId, paseoToolPolicy); + if ( + Object.keys(storedConfig.mcpServers ?? {}).length > 0 && + existing.session.capabilities.supportsMcpServers !== true + ) { + throw new Error(`Provider '${provider}' does not support MCP servers`); + } let session: AgentSession | undefined; + let closedExisting: ManagedAgentClosed | undefined; let handedToRegistration = false; - let replacedExisting = false; try { + // A persisted thread can have only one writer, even when its turn is idle. + await this.closeReloadedSession(existing.session, agentId); + await this.drainSessionEvents(agentId); + this.cancelRunningProviderSubagents(agentId); + closedExisting = this.prepareAgentForClosure(existing, "agent reloaded"); + await this.persistSnapshot(closedExisting); + this.assertAcceptingAgentRegistrations(); + + this.paseoToolPolicies.set(agentId, paseoToolPolicy); session = handle ? await client.resumeSession(handle, providerLaunchConfig, launchContext) : await client.createSession(providerLaunchConfig, launchContext); await this.requireExternalMcpSupport(session, storedConfig); - this.assertAcceptingAgentRegistrations(); - this.cancelRunningProviderSubagents(agentId); - const closedExisting = this.prepareAgentForClosure(existing, "agent reloaded"); - replacedExisting = true; - try { - await this.persistSnapshot(closedExisting); - } finally { - await this.closeReloadedSession(existing.session, agentId); - } - if (rehydrateFromDisk) { // Wipe the in-memory timeline so registerSession mints a new epoch and // hydrateTimelineFromProvider re-streams the freshly read provider history. @@ -1484,41 +1493,49 @@ export class AgentManager { lastError: preservedLastError, attention: preservedAttention, }); + } catch (error) { + if (closedExisting) { + this.emitClosedAgent(closedExisting, { persist: false }); + } else if (this.agents.get(agentId) === existing) { + existing.lifecycle = "error"; + existing.lastError = error instanceof Error ? error.message : String(error); + this.emitState(existing); + } + throw error; } finally { - if (!replacedExisting) { + if (!handedToRegistration) { if (hadPreviousPaseoToolPolicy) { this.paseoToolPolicies.set(agentId, previousPaseoToolPolicy); } else { this.paseoToolPolicies.delete(agentId); } - } - if (session && !handedToRegistration) { - await this.closeUnregisteredSession(session); + if (session) { + await this.closeUnregisteredSession(session); + } } } } private async closeReloadedSession(session: AgentSession, agentId: string): Promise { - try { - const result = await this.waitWithTimeout({ - operation: session.close(), - timeoutMs: this.rescueTimeouts.reloadSessionCloseMs, - onLateError: (error) => { - this.logger.warn( - { err: error, agentId }, - "Previous session close failed after refresh timeout", - ); - }, - }); - - if (result === "timed_out") { + let operation = this.reloadedSessionCloses.get(session); + if (!operation) { + operation = session.close(); + this.reloadedSessionCloses.set(session, operation); + // Keep pending closes across request timeouts; a retry must await the same release. + void operation.catch(() => this.reloadedSessionCloses.delete(session)); + } + const result = await this.waitWithTimeout({ + operation, + timeoutMs: this.rescueTimeouts.reloadSessionCloseMs, + onLateError: (error) => { this.logger.warn( - { agentId, timeoutMs: this.rescueTimeouts.reloadSessionCloseMs }, - "Timed out closing previous session during refresh", + { err: error, agentId }, + "Previous session close failed after refresh timeout", ); - } - } catch (error) { - this.logger.warn({ err: error, agentId }, "Failed to close previous session during refresh"); + }, + }); + if (result === "timed_out") { + throw new Error("Timed out closing previous session during refresh"); } } @@ -1558,7 +1575,10 @@ export class AgentManager { return existing; } - const close = this.closeAgentRuntime(agentId); + const close = this.runLifecycleMutation(agentId, async () => { + // A preceding reload or archive may already have closed the durable agent. + if (this.agents.has(agentId)) await this.closeAgentRuntime(agentId); + }); this.inFlightAgentCloses.set(agentId, close); const clearClose = () => { if (this.inFlightAgentCloses.get(agentId) === close) { @@ -1651,7 +1671,7 @@ export class AgentManager { const { archivedAt } = await this.markRecordArchived(stored); agent.updatedAt = new Date(archivedAt); - await this.closeAgent(agentId); + await this.closeAgentRuntime(agentId); this.discardRetainedAgentState(agentId); await this.cascadeArchiveChildren(agentId); diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index 864813a140..08bd66a79c 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -88,7 +88,7 @@ function createFinishNotificationScenario( Reflect.set(callerAgent, "lifecycle", "idle"); Reflect.set(callerAgent, "config", { title: "Caller Agent" }); - const agentManager: AgentManager = Object.create(AgentManager.prototype); + const agentManager = new AgentManager({ clients: {}, logger: createTestLogger() }); Reflect.set(agentManager, "getAgent", (agentId: string) => { if (agentId === "child-agent") { return childAgent; @@ -468,7 +468,7 @@ it("does not notify archived callers", async () => { const streamAgentSpy = vi.fn(() => (async function* noop() {})()); const replaceAgentRunSpy = vi.fn(() => (async function* noop() {})()); - const agentManager: AgentManager = Object.create(AgentManager.prototype); + const agentManager = new AgentManager({ clients: {}, logger: createTestLogger() }); Reflect.set( agentManager, "getAgent", diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts index 28d0252cf5..0469d9fbe1 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { execFileSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; @@ -6,6 +6,9 @@ import { createServer } from "node:http"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { once } from "node:events"; +import { AgentManager } from "../agent-manager.js"; +import { AgentStorage } from "../agent-storage.js"; + import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js"; @@ -173,7 +176,7 @@ function writeMockCodexConfig(codexHome: string, serverUrl: string): void { path.join(codexHome, "config.toml"), ` model = "mock-model" -approval_policy = "untrusted" +approval_policy = "on-request" sandbox_mode = "read-only" model_provider = "mock_provider" @@ -214,6 +217,53 @@ function waitForEvent(params: { } describe("Codex app-server provider (local e2e)", () => { + test.runIf(isCodexInstalled())( + "reloads a persisted idle thread repeatedly without overlapping writers", + async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-reload-cwd-")); + const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-reload-home-")); + const mockServer = await startMockResponsesServer([assistantMessageSse("saved")]); + const logger = createTestLogger(); + const storage = new AgentStorage(path.join(cwd, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new CodexAppServerAgentClient(logger) }, + registry: storage, + logger, + }); + let agentId: string | undefined; + try { + writeMockCodexConfig(codexHome, mockServer.url); + vi.stubEnv("CODEX_HOME", codexHome); + const created = await manager.createAgent( + { provider: "codex", cwd, modeId: "auto", model: "mock-model" }, + undefined, + { workspaceId: undefined }, + ); + agentId = created.id; + await manager.runAgent(agentId, "save this turn"); + const threadId = manager.getAgent(agentId)?.persistence?.sessionId; + expect(threadId).toBeTruthy(); + for (let i = 0; i < 3; i++) { + const reloaded = await manager.reloadAgentSession(agentId, undefined, { + rehydrateFromDisk: true, + }); + expect(reloaded.persistence?.sessionId).toBe(threadId); + expect(reloaded.lifecycle).toBe("idle"); + } + await manager.runAgent(agentId, "continue after reload"); + expect(manager.getAgent(agentId)?.lifecycle).toBe("idle"); + } finally { + if (agentId && manager.getAgent(agentId)) await manager.closeAgent(agentId); + await storage.flush(); + vi.unstubAllEnvs(); + await mockServer.close(); + rmSync(cwd, { recursive: true, force: true }); + rmSync(codexHome, { recursive: true, force: true }); + } + }, + 30_000, + ); + test.runIf(isCodexInstalled())( "surfaces request_user_input from the app-server as question permissions and timeline tool calls", async () => { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index c1b2c6bfde..15e7cfdf54 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1022,7 +1022,13 @@ describe("Codex app-server provider", () => { child.stderr = new PassThrough() as ChildProcessWithoutNullStreams["stderr"]; child.exitCode = null; child.signalCode = null; - child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"]; + child.kill = vi.fn((signal) => { + if (signal === "SIGKILL") { + child.signalCode = "SIGKILL"; + child.emit("exit", null, "SIGKILL"); + } + return true; + }) as ChildProcessWithoutNullStreams["kill"]; const client = new CodexAppServerClient(child, createTestLogger()); try { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 87a463a11f..0cc71bf1b8 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -4816,12 +4816,12 @@ export class CodexAppServerAgentSession implements AgentSession { private async disposeClient(): Promise { const client = this.client; - this.client = null; this.connected = false; this.currentTurnId = null; if (client) { await client.dispose(); } + this.client = null; } async listCommands(): Promise { diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts index 1d7417510b..5c9dbe956c 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; import { @@ -32,6 +32,29 @@ describe("Codex app-server transport", () => { await expect(request).rejects.toThrow("Codex app-server client is closed"); }); + test("dispose rejects until the child has actually exited", async () => { + vi.useFakeTimers(); + const child = createCodexAppServerChildProcess(); + child.kill = () => true; + const client = new CodexAppServerClient(child, createTestLogger()); + try { + for (let i = 0; i < 2; i++) { + const closing = expect(client.dispose()).rejects.toThrow( + "did not report exit after SIGKILL", + ); + await vi.advanceTimersByTimeAsync(3_000); + await closing; + } + child.exitCode = 0; + child.emit("exit", 0, null); + await expect(client.dispose()).resolves.toBeUndefined(); + } finally { + vi.useRealTimers(); + child.stdout.end(); + child.stderr.end(); + } + }); + test.each([ "item/commandExecution/requestApproval", "item/fileChange/requestApproval", diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.ts index 32152e7ea5..1720118c63 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.ts @@ -257,7 +257,6 @@ export class CodexAppServerClient { } async dispose(): Promise { - if (this.disposed) return; this.disposed = true; this.unexpectedTerminationHandler = null; this.rl.close(); @@ -278,10 +277,7 @@ export class CodexAppServerClient { }, }); if (result === "kill-timeout") { - this.logger.warn( - { timeoutMs: APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS }, - "Codex app-server did not report exit after SIGKILL", - ); + throw new Error("Codex app-server did not report exit after SIGKILL"); } } diff --git a/packages/server/src/server/daemon-e2e/agent-reload.native.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-reload.native.real.e2e.test.ts new file mode 100644 index 0000000000..ac9d2f83cb --- /dev/null +++ b/packages/server/src/server/daemon-e2e/agent-reload.native.real.e2e.test.ts @@ -0,0 +1,128 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { expect, test } from "vitest"; + +import type { AgentClient, AgentSessionConfig } from "../agent/agent-sdk-types.js"; +import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; +import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js"; +import { OpenCodeServerManager } from "../agent/providers/opencode/server-manager.js"; +import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js"; +import { + getRealProviderConfig, + getRealProviderRuntimeSettings, +} from "./real-provider-test-config.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +type NativeProvider = "claude" | "codex" | "opencode"; + +interface ReloadConversation { + rememberToken: () => Promise; + reloadAndRecallToken: (sessionId: string) => Promise; +} + +async function withNativeConversation( + provider: NativeProvider, + run: (conversation: ReloadConversation) => Promise, +): Promise { + const secret = `RELOAD_${provider.toUpperCase()}_7F31`; + const root = mkdtempSync(path.join(tmpdir(), `paseo-reload-${provider}-`)); + const cwd = path.join(root, "workspace"); + mkdirSync(cwd); + const logger = pino({ level: "warn" }); + let daemon: TestPaseoDaemon | undefined; + let client: DaemonClient | undefined; + let openCode: OpenCodeAgentClient | undefined; + let openCodeRuntimeRoot: string | undefined; + try { + let providerClient: AgentClient; + let config: Partial; + if (provider === "claude") { + providerClient = new ClaudeAgentClient({ logger }); + config = { model: "haiku", modeId: "bypassPermissions" }; + } else if (provider === "codex") { + providerClient = new CodexAppServerAgentClient(logger); + config = { modeId: "full-access", thinkingOptionId: "low" }; + } else { + const settings = getRealProviderRuntimeSettings("opencode"); + openCodeRuntimeRoot = path.dirname(settings.env!.XDG_CONFIG_HOME!); + openCode = new OpenCodeAgentClient(logger, settings, { + serverManager: new OpenCodeServerManager({ logger, runtimeSettings: settings }), + }); + providerClient = openCode; + config = getRealProviderConfig("opencode"); + } + daemon = await createTestPaseoDaemon({ + agentClients: { [provider]: providerClient }, + logger, + pluginsEnabled: false, + }); + client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.7.2" }); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "native-reload-qa" } }); + const agent = await client.createAgent({ provider, cwd, ...config }); + process.stdout.write(`${provider}: model=${agent.model ?? "default"}\n`); + const manager = daemon.daemon.agentManager; + const connectedClient = client; + await run({ + rememberToken: async () => { + const first = await manager.runAgent( + agent.id, + `Remember this token for this conversation: ${secret}. Reply with exactly that token. Do not use tools.`, + ); + expect(first.finalText).toContain(secret); + const handle = manager.getAgent(agent.id)?.persistence; + expect(handle?.sessionId).toBeTruthy(); + process.stdout.write(`${provider}: first real turn passed; session=${handle!.sessionId}\n`); + return handle!.sessionId; + }, + reloadAndRecallToken: async (sessionId: string) => { + for (let round = 1; round <= 3; round++) { + await connectedClient.refreshAgent(agent.id); + expect(manager.getAgent(agent.id)?.persistence?.sessionId).toBe(sessionId); + const response = await manager.runAgent( + agent.id, + "What token did I ask you to remember? Reply with only that token. Do not use tools.", + ); + expect(response.finalText).toContain(secret); + const timeline = await connectedClient.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + expect( + timeline.entries.some( + ({ item }) => item.type === "assistant_message" && item.text.includes(secret), + ), + ).toBe(true); + process.stdout.write( + `${provider}: refresh ${round}/3 passed; same session; memory recalled; timeline present\n`, + ); + } + }, + }); + await manager.closeAgent(agent.id); + } finally { + await client?.close(); + await daemon?.close(); + await openCode?.shutdown(); + if (openCodeRuntimeRoot) rmSync(openCodeRuntimeRoot, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } +} + +// Opt in: real requests using local Claude/Codex auth and the OpenRouter test setup. +test + .runIf(process.env.PASEO_NATIVE_RELOAD_QA === "1") + .each(["claude", "codex", "opencode"] as const)( + "%s retains its session and conversation through repeated refresh RPCs", + async (provider) => { + await withNativeConversation(provider, async (conversation) => { + const sessionId = await conversation.rememberToken(); + await conversation.reloadAndRecallToken(sessionId); + }); + }, + 180_000, +); From c8a417c4fbd497ac12bf89de1abb8111606d2c3f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 6 Sep 2026 10:17:58 +0200 Subject: [PATCH 014/566] Address terminals by workspace ID in the SDK and CLI (#4358) * feat(terminals): expose workspace ownership through the SDK and CLI Directory-based terminal creation selected the oldest workspace sharing that path. Expose explicit workspace ownership in the shared public API and CLI, retain ownership and process directories in listings, and acknowledge terminal deletion after teardown. * fix(file-observer): preserve nested shallow reconciliation scopes A parent directory scan does not visit its descendants. Collapsing change scopes discarded nested scans and left coalesced native deletions unseen until a full audit. * test(terminals): extract output waits from end-to-end scenarios --- docs/architecture.md | 2 +- docs/plugins.md | 2 +- packages/cli/src/commands/terminal/capture.ts | 6 +- packages/cli/src/commands/terminal/create.ts | 40 ++-- packages/cli/src/commands/terminal/index.ts | 12 +- packages/cli/src/commands/terminal/kill.ts | 10 +- packages/cli/src/commands/terminal/ls.ts | 18 +- packages/cli/src/commands/terminal/schema.ts | 23 +-- .../cli/src/commands/terminal/send-keys.ts | 43 +--- packages/cli/src/commands/terminal/shared.ts | 9 +- .../cli/tests/39-terminal-workspace.test.ts | 80 ++++++++ packages/cli/tests/helpers/terminal.ts | 19 ++ packages/client/README.md | 2 +- packages/client/src/index.test.ts | 1 + packages/client/src/index.ts | 33 ++- packages/client/src/terminals/index.test.ts | 35 ++++ packages/client/src/terminals/index.ts | 179 +++++++++++++++++ packages/protocol/src/messages.ts | 4 +- .../protocol/src/messages.wire-compat.test.ts | 20 ++ packages/server/package.json | 2 +- .../terminal-workspace-sdk.e2e.test.ts | 190 ++++++++++++++++++ .../internal/native-recursive.test.ts | 71 +++++++ .../internal/native-recursive.ts | 24 ++- .../server/src/server/websocket-server.ts | 1 + .../terminal-session-controller.test.ts | 18 +- .../terminal/terminal-session-controller.ts | 45 ++++- public-docs/cli.md | 19 ++ public-docs/plugins/v0.8/reference.md | 2 +- public-docs/sdk/reference.md | 40 ++++ public-docs/sdk/workspaces.md | 16 ++ 30 files changed, 832 insertions(+), 134 deletions(-) create mode 100644 packages/cli/tests/39-terminal-workspace.test.ts create mode 100644 packages/cli/tests/helpers/terminal.ts create mode 100644 packages/client/src/terminals/index.test.ts create mode 100644 packages/client/src/terminals/index.ts create mode 100644 packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts create mode 100644 packages/server/src/server/file-observer/internal/native-recursive.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 0ed8c46ade..134e8f3532 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,7 +94,7 @@ facade. App and CLI may import the low-level driver from `@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped code imports from `@getpaseo/client`. -`PaseoApi` is the capability-only boundary over workspaces, agents, providers, and config. +`PaseoApi` is the capability-only boundary over workspaces, agents, terminals, providers, and config. `PaseoClient` adds connection lifecycle. App plugin surfaces borrow an API over their selected host's client; plugin subprocesses use the same facade over a host-owned IPC transport. diff --git a/docs/plugins.md b/docs/plugins.md index 03f38b6172..ae8a241c88 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -219,7 +219,7 @@ typed async function. Use the host-provided `@tanstack/react-query` for request Paseo gives each plugin installation its own query client. `usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects, -workspaces, agents, providers, and daemon config. They do not expose connection lifecycle. A surface borrows the +workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle. A surface borrows the selected host's existing connection; switching the screen's host changes both `usePaseo()` and `useRpc()` to that host. An offline selected host fails there and never falls through to another installation. A server handler owns an IPC-backed daemon session for the life of its subprocess. diff --git a/packages/cli/src/commands/terminal/capture.ts b/packages/cli/src/commands/terminal/capture.ts index 2dc8f2180d..98b9f2ca2e 100644 --- a/packages/cli/src/commands/terminal/capture.ts +++ b/packages/cli/src/commands/terminal/capture.ts @@ -55,7 +55,7 @@ async function executeCaptureCommand( terminalId: string, options: TerminalCaptureOptions, ): Promise<{ terminalId: string; lines: string[]; totalLines: number }> { - const { client } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.host); try { const resolvedId = await resolveTerminalId(client, terminalId); @@ -70,7 +70,7 @@ async function executeCaptureCommand( const start = options.scrollback ? 0 : parseLineNumber("--start", options.start); const end = parseLineNumber("--end", options.end); - return await client.captureTerminal(resolvedId, { + return await client.terminals.ref(resolvedId).capture({ ...(start === undefined ? {} : { start }), ...(end === undefined ? {} : { end }), stripAnsi: !options.ansi, @@ -78,7 +78,7 @@ async function executeCaptureCommand( } catch (err) { throw toTerminalCommandError("TERMINAL_CAPTURE_FAILED", "capture terminal output", err); } finally { - await client.close().catch(() => {}); + await close().catch(() => {}); } } diff --git a/packages/cli/src/commands/terminal/create.ts b/packages/cli/src/commands/terminal/create.ts index b71833b871..9524be0223 100644 --- a/packages/cli/src/commands/terminal/create.ts +++ b/packages/cli/src/commands/terminal/create.ts @@ -1,13 +1,14 @@ import type { Command } from "commander"; -import type { SingleResult, CommandError } from "../../output/index.js"; +import type { SingleResult } from "../../output/index.js"; import { connectTerminalClient, toTerminalCommandError, type TerminalCommandOptions, } from "./shared.js"; -import { terminalSchema, type TerminalRow, toTerminalRow } from "./schema.js"; +import { terminalSchema, type TerminalRow } from "./schema.js"; export interface TerminalCreateOptions extends TerminalCommandOptions { + workspace?: string; cwd?: string; name?: string; } @@ -16,37 +17,26 @@ export async function runCreateCommand( options: TerminalCreateOptions, _command: Command, ): Promise> { - const { client } = await connectTerminalClient(options.host); - const cwd = options.cwd ?? process.cwd(); - + const { client, close } = await connectTerminalClient(options.host); try { - const opened = await client.openProject(cwd); - if (!opened.workspace) { - const error: CommandError = { - code: "WORKSPACE_OPEN_FAILED", - message: opened.error ?? "Failed to open workspace", - }; - throw error; - } - - const payload = await client.createTerminal(cwd, options.name, undefined, { - workspaceId: opened.workspace.id, + const cwd = options.cwd ?? (options.workspace ? undefined : process.cwd()); + const workspaceId = + options.workspace ?? (await client.workspaces.open(options.cwd ?? process.cwd())).id; + const terminal = await client.terminals.create({ + workspaceId, + cwd, + name: options.name, }); - if (!payload.terminal) { - const error: CommandError = { - code: "TERMINAL_CREATE_FAILED", - message: payload.error ?? "Failed to create terminal", - }; - throw error; - } + const snapshot = terminal.current(); + if (!snapshot) throw new Error("The daemon did not create a terminal"); return { type: "single", - data: toTerminalRow(payload.terminal), + data: snapshot, schema: terminalSchema, }; } catch (err) { throw toTerminalCommandError("TERMINAL_CREATE_FAILED", "create terminal", err); } finally { - await client.close().catch(() => {}); + await close().catch(() => {}); } } diff --git a/packages/cli/src/commands/terminal/index.ts b/packages/cli/src/commands/terminal/index.ts index ce647f78e6..22ffc39d31 100644 --- a/packages/cli/src/commands/terminal/index.ts +++ b/packages/cli/src/commands/terminal/index.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import { withOutput } from "../../output/index.js"; import { addDaemonHostOption, @@ -18,15 +18,19 @@ export function createTerminalCommand(): Command { terminal .command("ls") .description("List terminals") - .option("--all", "List terminals across all workspaces") - .option("--cwd ", "Workspace directory"), + .addOption( + new Option("--all", "List terminals across all workspaces").conflicts(["cwd", "workspace"]), + ) + .option("--workspace ", "Workspace ID") + .option("--cwd ", "Working directory"), ).action(withOutput(runLsCommand)); addJsonAndDaemonHostOptions( terminal .command("create") .description("Create a terminal") - .option("--cwd ", "Workspace directory") + .option("--workspace ", "Workspace ID") + .option("--cwd ", "Working directory") .option("--name ", "Terminal name"), ).action(withOutput(runCreateCommand)); diff --git a/packages/cli/src/commands/terminal/kill.ts b/packages/cli/src/commands/terminal/kill.ts index 0a574e16d8..30990d9c11 100644 --- a/packages/cli/src/commands/terminal/kill.ts +++ b/packages/cli/src/commands/terminal/kill.ts @@ -13,23 +13,23 @@ export async function runKillCommand( options: TerminalCommandOptions, _command: Command, ): Promise> { - const { client } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.host); try { const resolvedId = await requireTerminalId(client, terminalId); - const payload = await client.killTerminal(resolvedId); + await client.terminals.ref(resolvedId).kill(); return { type: "single", data: { - terminalId: payload.terminalId, - success: payload.success, + terminalId: resolvedId, + success: true, }, schema: terminalKillSchema, }; } catch (err) { throw toTerminalCommandError("TERMINAL_KILL_FAILED", "kill terminal", err); } finally { - await client.close().catch(() => {}); + await close().catch(() => {}); } } diff --git a/packages/cli/src/commands/terminal/ls.ts b/packages/cli/src/commands/terminal/ls.ts index bcb6d90d48..bec0b0047b 100644 --- a/packages/cli/src/commands/terminal/ls.ts +++ b/packages/cli/src/commands/terminal/ls.ts @@ -5,11 +5,10 @@ import { toTerminalCommandError, type TerminalCommandOptions, } from "./shared.js"; -import { terminalSchema, type TerminalRow, toTerminalRow } from "./schema.js"; - -type TerminalListEntry = Parameters[0]; +import { terminalSchema, type TerminalRow } from "./schema.js"; export interface TerminalLsOptions extends TerminalCommandOptions { + workspace?: string; all?: boolean; cwd?: string; } @@ -18,22 +17,19 @@ export async function runLsCommand( options: TerminalLsOptions, _command: Command, ): Promise> { - const { client } = await connectTerminalClient(options.host); - const cwd = options.all ? undefined : (options.cwd ?? process.cwd()); + const { client, close } = await connectTerminalClient(options.host); + const cwd = options.all || options.workspace ? undefined : (options.cwd ?? process.cwd()); try { - const payload = - cwd === undefined ? await client.listTerminals() : await client.listTerminals(cwd); + const payload = await client.terminals.list({ cwd, workspaceId: options.workspace }); return { type: "list", - data: payload.terminals.map((terminal: TerminalListEntry) => - toTerminalRow(terminal, payload.cwd ?? cwd), - ), + data: payload.entries, schema: terminalSchema, }; } catch (err) { throw toTerminalCommandError("TERMINAL_LIST_FAILED", "list terminals", err); } finally { - await client.close().catch(() => {}); + await close().catch(() => {}); } } diff --git a/packages/cli/src/commands/terminal/schema.ts b/packages/cli/src/commands/terminal/schema.ts index 9b777ad196..def9c3c455 100644 --- a/packages/cli/src/commands/terminal/schema.ts +++ b/packages/cli/src/commands/terminal/schema.ts @@ -1,10 +1,7 @@ +import type { PaseoTerminal } from "@getpaseo/client"; import type { OutputSchema } from "../../output/index.js"; -export interface TerminalRow { - id: string; - name: string; - cwd: string; -} +export type TerminalRow = PaseoTerminal; export interface TerminalKillRow { terminalId: string; @@ -17,6 +14,7 @@ export const terminalSchema: OutputSchema = { { header: "ID", field: (row) => row.id.slice(0, 8), width: 8 }, { header: "NAME", field: "name", width: 24 }, { header: "CWD", field: "cwd", width: 48 }, + { header: "WORKSPACE", field: "workspaceId", width: 24 }, ], }; @@ -27,18 +25,3 @@ export const terminalKillSchema: OutputSchema = { { header: "SUCCESS", field: "success", width: 8 }, ], }; - -export function toTerminalRow( - terminal: { - id: string; - name: string; - cwd?: string; - }, - cwd?: string, -): TerminalRow { - return { - id: terminal.id, - name: terminal.name, - cwd: terminal.cwd ?? cwd ?? "-", - }; -} diff --git a/packages/cli/src/commands/terminal/send-keys.ts b/packages/cli/src/commands/terminal/send-keys.ts index f109cece64..5097ea3908 100644 --- a/packages/cli/src/commands/terminal/send-keys.ts +++ b/packages/cli/src/commands/terminal/send-keys.ts @@ -39,7 +39,7 @@ async function executeSendKeysCommand( keys: string[], options: TerminalSendKeysOptions, ): Promise<{ terminalId: string; keysSent: number }> { - const { client } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.host); try { const resolvedId = await resolveTerminalId(client, terminalId); @@ -51,49 +51,16 @@ async function executeSendKeysCommand( }; } - const data = keys.map((key) => resolveKeyToken(key, options.literal === true)).join(""); - client.sendTerminalInput(resolvedId, { type: "input", data }); + const terminal = client.terminals.ref(resolvedId); + const keysSent = options.literal ? terminal.write(keys.join("")) : terminal.sendKeys(keys); return { terminalId: resolvedId, - keysSent: data.length, + keysSent, }; } catch (err) { throw toTerminalCommandError("TERMINAL_SEND_KEYS_FAILED", "send terminal keys", err); } finally { - await client.close().catch(() => {}); - } -} - -function resolveKeyToken(key: string, literal: boolean): string { - if (literal) { - return key; - } - - switch (key) { - case "Enter": - return "\r"; - case "Tab": - return "\t"; - case "Escape": - return "\u001b"; - case "Space": - return " "; - case "BSpace": - return "\u007f"; - case "C-c": - return "\u0003"; - case "C-d": - return "\u0004"; - case "C-z": - return "\u001a"; - case "C-l": - return "\u000c"; - case "C-a": - return "\u0001"; - case "C-e": - return "\u0005"; - default: - return key; + await close().catch(() => {}); } } diff --git a/packages/cli/src/commands/terminal/shared.ts b/packages/cli/src/commands/terminal/shared.ts index 6e04936575..101caf0879 100644 --- a/packages/cli/src/commands/terminal/shared.ts +++ b/packages/cli/src/commands/terminal/shared.ts @@ -1,3 +1,4 @@ +import { createPaseoApi } from "@getpaseo/client"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import type { CommandError, CommandOptions } from "../../output/index.js"; @@ -14,7 +15,7 @@ export async function connectTerminalClient(host?: string) { const daemonHost = getDaemonHost({ host }); try { const client = await connectToDaemon({ host }); - return { client, daemonHost }; + return { client: createPaseoApi(client), daemonHost, close: () => client.close() }; } catch (err) { const message = err instanceof Error ? err.message : String(err); const error: CommandError = { @@ -44,11 +45,11 @@ export function toTerminalCommandError(code: string, action: string, err: unknow } export async function resolveTerminalId( - client: Awaited>, + client: Awaited>["client"], idOrName: string, ): Promise { - const payload = await client.listTerminals(); - return resolveTerminalIdentifier(idOrName, payload.terminals); + const payload = await client.terminals.list(); + return resolveTerminalIdentifier(idOrName, payload.entries); } function resolveTerminalIdentifier(idOrName: string, terminals: TerminalLike[]): string | null { diff --git a/packages/cli/tests/39-terminal-workspace.test.ts b/packages/cli/tests/39-terminal-workspace.test.ts new file mode 100644 index 0000000000..b1221962f3 --- /dev/null +++ b/packages/cli/tests/39-terminal-workspace.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { createPaseoClient } from "@getpaseo/client"; +import { createE2ETestContext } from "./helpers/test-daemon.ts"; +import { waitForTerminalOutput } from "./helpers/terminal.ts"; + +const ctx = await createE2ETestContext({ timeout: 30_000 }); +const sdk = createPaseoClient({ url: `${ctx.wsUrl}/ws`, reconnect: { enabled: false } }); + +async function cli(args: string[]) { + const result = await ctx.paseo([...args, "--json"]); + assert.equal(result.exitCode, 0, result.stderr); + return JSON.parse(result.stdout); +} + +try { + await sdk.connect(); + const first = await sdk.workspaces.create({ + source: { kind: "directory", path: ctx.workDir }, + title: "main", + }); + const second = await sdk.workspaces.create({ + source: { kind: "directory", path: ctx.workDir }, + title: "feature-work", + }); + const main = await cli(["terminal", "create", "--cwd", ctx.workDir, "--name", "Main"]); + assert.equal(main.workspaceId, first.id); + const feature = await cli(["terminal", "create", "--workspace", second.id, "--name", "Feature"]); + assert.deepEqual(feature, { + id: feature.id, + name: "Feature", + cwd: ctx.workDir, + workspaceId: second.id, + }); + assert.deepEqual(await cli(["terminal", "ls", "--workspace", second.id]), [feature]); + assert.deepEqual(await cli(["terminal", "ls", "--cwd", ctx.workDir]), [main, feature]); + assert.deepEqual(await cli(["terminal", "ls", "--all"]), [main, feature]); + const invalid = await ctx.paseo([ + "terminal", + "create", + "--workspace", + "wks_missing", + "--cwd", + ctx.workDir, + "--json", + ]); + assert.notEqual(invalid.exitCode, 0); + assert.match(invalid.stderr, /not active or does not exist/); + const conflict = await ctx.paseo(["terminal", "ls", "--all", "--workspace", second.id]); + assert.notEqual(conflict.exitCode, 0); + + const terminal = await second.terminals.create({ + command: process.execPath, + args: [ + "-e", + "process.stdin.setRawMode(true); process.stdin.resume(); console.log('READY'); let hex = ''; process.stdin.on('data', data => { hex += data.toString('hex'); console.log('HEX:' + hex); });", + ], + }); + await waitForTerminalOutput(ctx.paseo, terminal.id, "READY"); + assert.deepEqual(await cli(["terminal", "send-keys", terminal.id, "-l", "Enter"]), { + terminalId: terminal.id, + keysSent: 5, + }); + await waitForTerminalOutput(ctx.paseo, terminal.id, "HEX:456e746572"); + assert.deepEqual(await cli(["terminal", "send-keys", terminal.id, "Enter"]), { + terminalId: terminal.id, + keysSent: 1, + }); + await waitForTerminalOutput(ctx.paseo, terminal.id, "HEX:456e7465720d"); + assert.deepEqual(await cli(["terminal", "kill", terminal.id]), { + terminalId: terminal.id, + success: true, + }); + await cli(["terminal", "kill", feature.id]); + assert.deepEqual(await cli(["terminal", "ls", "--workspace", second.id]), []); + assert.deepEqual(await cli(["terminal", "ls", "--workspace", first.id]), [main]); + console.log("Terminal workspace CLI: ownership, lists, input, capture, kill, and errors passed"); +} finally { + await sdk.close(); + await ctx.stop(); +} diff --git a/packages/cli/tests/helpers/terminal.ts b/packages/cli/tests/helpers/terminal.ts new file mode 100644 index 0000000000..75a0bf258d --- /dev/null +++ b/packages/cli/tests/helpers/terminal.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import type { createE2ETestContext } from "./test-daemon.ts"; + +export async function waitForTerminalOutput( + paseo: Awaited>["paseo"], + terminalId: string, + text: string, +): Promise { + const deadline = Date.now() + 10_000; + let lines: string[] = []; + while (Date.now() < deadline) { + const result = await paseo(["terminal", "capture", terminalId, "--json"]); + assert.equal(result.exitCode, 0, result.stderr); + lines = JSON.parse(result.stdout).lines; + if (lines.some((line) => line.includes(text))) return; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`Missing terminal output: ${text}\nLast capture:\n${lines.join("\n")}`); +} diff --git a/packages/client/README.md b/packages/client/README.md index 444f1ddc7d..85d6757d42 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -26,7 +26,7 @@ await client.close(); The public API is the package root. Imports under `@getpaseo/client/internal/*` are unsupported implementation details used by Paseo's own packages. -Read the [SDK documentation](https://paseo.sh/docs/sdk) for agents, workspaces, provider discovery, events, recipes, and the API reference. Runnable TypeScript patterns also live in [`examples/`](./examples/README.md). +Read the [SDK documentation](https://paseo.sh/docs/sdk) for agents, workspaces, terminals, provider discovery, events, recipes, and the API reference. Runnable TypeScript patterns also live in [`examples/`](./examples/README.md). ## Runtime diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index be5bd06811..392a53d6b6 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -230,6 +230,7 @@ test("createPaseoApi borrows daemon capabilities without exposing connection own "config", "projects", "providers", + "terminals", "workspaces", ]); expect("connect" in paseo).toBe(false); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index b6b8d8e7b8..3c6bf4b755 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -25,6 +25,22 @@ import type { WorkspaceCreateRequest, } from "@getpaseo/protocol/messages"; import { DaemonClient } from "./daemon-client.js"; +import { + createTerminalActions, + type PaseoTerminalActions, + type PaseoWorkspaceTerminalActions, +} from "./terminals/index.js"; +export type { + PaseoTerminal, + PaseoTerminalActions, + PaseoTerminalHandle, + PaseoTerminalCreateOptions, + PaseoTerminalListOptions, + PaseoTerminalListResult, + PaseoTerminalCaptureOptions, + PaseoTerminalCaptureResult, + PaseoWorkspaceTerminalActions, +} from "./terminals/index.js"; import type { PluginTimelineItem } from "@getpaseo/protocol/agent-types"; import type { FetchAgentsEntry, @@ -141,6 +157,7 @@ export interface PaseoWorkspaceHandle { readonly agents: { create(options: PaseoWorkspaceAgentCreateOptions): Promise; }; + readonly terminals: PaseoWorkspaceTerminalActions; current(): PaseoWorkspace | null; refresh(options?: { requestId?: string }): Promise; setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>; @@ -408,6 +425,7 @@ export interface PaseoConfigActions { } export interface PaseoApi { + readonly terminals: PaseoTerminalActions; readonly workspaces: PaseoWorkspaceActions; readonly projects: PaseoProjectActions; readonly agents: PaseoAgentActions; @@ -463,9 +481,17 @@ export function createPaseoApi(daemonClient: DaemonClient): PaseoApi { }); return createAgentHandle(agent); }; - const createWorkspaceHandle = createWorkspaceHandleFactory(daemonClient, createAgent); + const terminals = createTerminalActions(daemonClient, async (workspaceId) => { + const workspace = await createWorkspaceHandle(workspaceId).refresh(); + if (!workspace?.workspaceDirectory) { + throw new Error(`Workspace ${workspaceId} is not active or has no available directory`); + } + return workspace.workspaceDirectory; + }); + const createWorkspaceHandle = createWorkspaceHandleFactory(daemonClient, createAgent, terminals); return { + terminals, projects: { list: (options) => daemonClient.listProjects(options), }, @@ -531,6 +557,7 @@ type CreateAgent = ( function createWorkspaceHandleFactory( daemonClient: DaemonClient, createAgent: CreateAgent, + terminals: PaseoTerminalActions, ): WorkspaceHandleFactory { return (workspace) => { const id = typeof workspace === "string" ? workspace : workspace.id; @@ -582,6 +609,10 @@ function createWorkspaceHandleFactory( ); }, }, + terminals: { + create: (options) => terminals.create({ ...options, workspaceId: id }), + list: (options) => terminals.list({ ...options, workspaceId: id }), + }, current: () => current, refresh, setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId), diff --git a/packages/client/src/terminals/index.test.ts b/packages/client/src/terminals/index.test.ts new file mode 100644 index 0000000000..23cadbe6a9 --- /dev/null +++ b/packages/client/src/terminals/index.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "vitest"; +import { createTerminalActions } from "./index.js"; + +test("an older host rejects terminal operations before sending requests", async () => { + const unexpectedRequest = () => { + throw new Error("Unexpected daemon request"); + }; + const terminals = createTerminalActions( + { + ensureConnected: () => {}, + getLastServerInfoMessage: () => ({ + status: "server_info", + serverId: "older-host", + features: {}, + }), + createTerminal: unexpectedRequest, + listTerminals: unexpectedRequest, + sendTerminalInput: unexpectedRequest, + captureTerminal: unexpectedRequest, + killTerminal: unexpectedRequest, + }, + unexpectedRequest, + ); + + await expect(terminals.create({ workspaceId: "wks_existing" })).rejects.toThrow( + "Update the host", + ); + await expect(terminals.list({ workspaceId: "wks_existing" })).rejects.toThrow("Update the host"); + const terminal = terminals.ref("terminal-existing"); + expect(terminal.current()).toBeNull(); + expect(() => terminal.write("text")).toThrow("Update the host"); + expect(() => terminal.sendKeys(["Enter"])).toThrow("Update the host"); + expect(() => terminal.capture()).toThrow("Update the host"); + await expect(terminal.kill()).rejects.toThrow("Update the host"); +}); diff --git a/packages/client/src/terminals/index.ts b/packages/client/src/terminals/index.ts new file mode 100644 index 0000000000..e1fd874a63 --- /dev/null +++ b/packages/client/src/terminals/index.ts @@ -0,0 +1,179 @@ +import { z } from "zod"; +import type { DaemonClient } from "../daemon-client.js"; + +const TerminalSchema = z.object({ + id: z.string(), + workspaceId: z.string(), + cwd: z.string(), + name: z.string(), +}); + +export type PaseoTerminal = z.infer; + +export interface PaseoTerminalCreateOptions { + workspaceId: string; + /** Process working directory; defaults to the workspace directory. */ + cwd?: string; + name?: string; + command?: string; + args?: string[]; + size?: { rows: number; cols: number }; + requestId?: string; +} + +export interface PaseoTerminalListOptions { + /** Ownership filter. When supplied, cwd does not restrict the results. */ + workspaceId?: string; + /** Workspace root directory filter for unscoped listings. */ + cwd?: string; + requestId?: string; +} + +export interface PaseoTerminalListResult { + entries: PaseoTerminal[]; + requestId: string; +} + +export interface PaseoTerminalCaptureOptions { + start?: number; + end?: number; + stripAnsi?: boolean; + requestId?: string; +} + +export type PaseoTerminalCaptureResult = Awaited>; + +export interface PaseoTerminalHandle { + readonly id: string; + current(): PaseoTerminal | null; + refresh(options?: { requestId?: string }): Promise; + /** Sends literal input and returns its UTF-16 length. Does not await command execution. */ + write(data: string): number; + /** Expands CLI key tokens; other strings are literal. Returns the input's UTF-16 length. */ + sendKeys(keys: readonly string[]): number; + capture(options?: PaseoTerminalCaptureOptions): Promise; + kill(requestId?: string): Promise; +} + +export interface PaseoTerminalActions { + create(options: PaseoTerminalCreateOptions): Promise; + list(options?: PaseoTerminalListOptions): Promise; + ref(terminal: string | PaseoTerminal): PaseoTerminalHandle; +} + +export interface PaseoWorkspaceTerminalActions { + create(options?: Omit): Promise; + list(options?: { requestId?: string }): Promise; +} + +type TerminalClient = Pick< + DaemonClient, + | "ensureConnected" + | "getLastServerInfoMessage" + | "createTerminal" + | "listTerminals" + | "sendTerminalInput" + | "captureTerminal" + | "killTerminal" +>; + +/** @package */ +export function createTerminalActions( + daemonClient: TerminalClient, + resolveWorkspaceDirectory: (workspaceId: string) => Promise, +): PaseoTerminalActions { + function client(): TerminalClient { + daemonClient.ensureConnected(); + // COMPAT(workspaceTerminals): added in v0.7.3, remove gate after 2027-09-05. + if (daemonClient.getLastServerInfoMessage()?.features?.workspaceTerminals !== true) { + throw new Error("Update the host to use workspace terminals through the SDK."); + } + return daemonClient; + } + + const list = async (options: PaseoTerminalListOptions = {}): Promise => { + const result = await client().listTerminals(options.cwd, options.requestId, { + workspaceId: options.workspaceId, + }); + return { + entries: result.terminals.map((terminal) => TerminalSchema.parse(terminal)), + requestId: result.requestId, + }; + }; + + const ref = (terminal: string | PaseoTerminal): PaseoTerminalHandle => { + const id = typeof terminal === "string" ? terminal : terminal.id; + let current = typeof terminal === "string" ? null : terminal; + const write = (data: string): number => { + client().sendTerminalInput(id, { type: "input", data }); + return data.length; + }; + return { + id, + current: () => current, + refresh: async (options) => { + const result = await list(options); + current = result.entries.find((entry) => entry.id === id) ?? null; + return current; + }, + write, + sendKeys: (keys) => write(keys.map(resolveKeyToken).join("")), + capture: (options = {}) => { + const { requestId, ...captureOptions } = options; + return client().captureTerminal(id, captureOptions, requestId); + }, + kill: async (requestId) => { + const result = await client().killTerminal(id, requestId); + if (!result.success) throw new Error(`Failed to kill terminal ${id}`); + current = null; + }, + }; + }; + + return { + create: async ({ workspaceId, cwd, name, requestId, ...options }) => { + const driver = client(); + if (!workspaceId) throw new Error("workspaceId is required"); + const directory = cwd ?? (await resolveWorkspaceDirectory(workspaceId)); + const result = await driver.createTerminal(directory, name, requestId, { + ...options, + workspaceId, + }); + if (result.error || !result.terminal) { + throw new Error(result.error ?? "The daemon did not create a terminal"); + } + return ref(TerminalSchema.parse(result.terminal)); + }, + list, + ref, + }; +} + +function resolveKeyToken(key: string): string { + switch (key) { + case "Enter": + return "\r"; + case "Tab": + return "\t"; + case "Escape": + return "\u001b"; + case "Space": + return " "; + case "BSpace": + return "\u007f"; + case "C-c": + return "\u0003"; + case "C-d": + return "\u0004"; + case "C-z": + return "\u001a"; + case "C-l": + return "\u000c"; + case "C-a": + return "\u0001"; + case "C-e": + return "\u0005"; + default: + return key; + } +} diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 6c9fd5f810..125a7a27bb 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3408,6 +3408,8 @@ export const ServerInfoStatusPayloadSchema = z workspaceLabels: z.boolean().optional(), // COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02. workspaceSetupRun: z.boolean().optional(), + // COMPAT(workspaceTerminals): added in v0.7.3, remove gate after 2027-09-05. + workspaceTerminals: z.boolean().optional(), // COMPAT(checkoutForgeSetAutoMerge): added in v0.2.0-beta.1. Remove the // feature gate and checkoutGithubSetAutoMerge fallback after 2027-01-17 // once the supported daemon floor is >= v0.2.0. @@ -6024,7 +6026,7 @@ export const ListTerminalsResponseSchema = z.object({ type: z.literal("list_terminals_response"), payload: z.object({ cwd: z.string().optional(), - terminals: z.array(TerminalInfoSchema.omit({ cwd: true })), + terminals: z.array(TerminalInfoSchema.partial({ cwd: true })), requestId: z.string(), }), }); diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index db7059001b..bcfc37d5f8 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -8,6 +8,26 @@ import { WSHelloMessageSchema, } from "./messages.js"; +test("terminal listings accept older rows and retain new per-terminal directories", () => { + const response = { + type: "list_terminals_response", + payload: { + requestId: "terminal-list", + cwd: "/workspace", + terminals: [{ id: "terminal", name: "Shell", workspaceId: "workspace" }], + }, + }; + expect(SessionOutboundMessageSchema.parse(response)).toEqual(response); + const withDirectory = { + ...response, + payload: { + ...response.payload, + terminals: [{ ...response.payload.terminals[0], cwd: "/workspace/subdirectory" }], + }, + }; + expect(SessionOutboundMessageSchema.parse(withDirectory)).toEqual(withDirectory); +}); + const LegacySubAgentToolCallSchema = z.object({ type: z.literal("tool_call"), callId: z.string(), diff --git a/packages/server/package.json b/packages/server/package.json index cd00102b58..40d236670e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -53,7 +53,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\"", + "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", diff --git a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts new file mode 100644 index 0000000000..f27e478bf4 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts @@ -0,0 +1,190 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, expect, test } from "vitest"; +import { createPaseoClient, type PaseoClient } from "@getpaseo/client"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +let daemon: TestPaseoDaemon; +let client: DaemonClient; +let cwd: string; +let sdk: PaseoClient; + +beforeEach(async () => { + cwd = await mkdtemp(path.join(tmpdir(), "terminal-workspace-sdk-")); + daemon = await createTestPaseoDaemon(); + client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await client.connect(); + sdk = createPaseoClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await sdk.connect(); +}); + +afterEach(async () => { + await client.close(); + await sdk.close(); + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); +}); + +test("SDK and workspace handles preserve ownership and actual process directories", async () => { + const first = await createWorkspace("main"); + const second = await createWorkspace("feature-work"); + const nested = path.join(cwd, "nested"); + await mkdir(nested); + const a = await sdk.terminals.create({ workspaceId: first, name: "Main" }); + const b = await sdk.workspaces.ref(second).terminals.create({ name: "Feature", cwd: nested }); + const c = await sdk.terminals.create({ + workspaceId: second, + cwd: daemon.staticDir, + name: "Outside", + }); + expect(a.current()).toEqual({ id: a.id, workspaceId: first, cwd, name: "Main" }); + expect(b.current()).toEqual({ id: b.id, workspaceId: second, cwd: nested, name: "Feature" }); + expect(c.current()).toEqual({ + id: c.id, + workspaceId: second, + cwd: daemon.staticDir, + name: "Outside", + }); + const result = await sdk.workspaces.ref(second).terminals.list({ requestId: "feature-list" }); + expect(result.requestId).toBe("feature-list"); + expect(result.entries).toEqual(expect.arrayContaining([b.current(), c.current()])); + expect(result.entries).toHaveLength(2); + expect((await sdk.terminals.list()).entries).toHaveLength(3); + expect((await sdk.terminals.list({ cwd })).entries).toEqual([a.current(), b.current()]); + const ref = sdk.terminals.ref(b.id); + expect(ref.current()).toBeNull(); + expect(await ref.refresh()).toEqual(b.current()); + await ref.kill(); + expect(await ref.refresh()).toBeNull(); + expect((await sdk.terminals.list({ workspaceId: second })).entries).toEqual([c.current()]); + expect((await sdk.terminals.list({ workspaceId: first })).entries).toEqual([a.current()]); +}); + +test("SDK creates a command terminal and sends literal input and key tokens", async () => { + const workspaceId = await createWorkspace("Input"); + const terminal = await sdk.terminals.create({ + workspaceId, + command: process.execPath, + args: [ + "-e", + "process.stdin.setRawMode(true); process.stdin.resume(); console.log('READY'); let hex = ''; process.stdin.on('data', data => { hex += data.toString('hex'); console.log('HEX:' + hex); });", + ], + size: { rows: 35, cols: 120 }, + }); + const screen = async () => (await terminal.capture({ stripAnsi: true })).lines.join("\n"); + await expect.poll(screen).toContain("READY"); + expect(terminal.write("Enter")).toBe(5); + await expect.poll(screen).toContain("HEX:456e746572"); + expect(terminal.sendKeys(["Enter", "Tab", "Escape", "C-c"])).toBe(4); + await expect.poll(screen).toContain("HEX:456e7465720d091b03"); + const capture = await terminal.capture({ + start: 0, + end: 0, + stripAnsi: true, + requestId: "capture-first", + }); + expect(capture).toMatchObject({ terminalId: terminal.id, requestId: "capture-first" }); + expect(capture.lines).toHaveLength(1); + await terminal.kill(); + expect(terminal.current()).toBeNull(); + expect((await sdk.terminals.list({ workspaceId })).entries).toEqual([]); +}); + +test("terminal creation rejects unknown and archived owners, including explicit cwd overrides", async () => { + const workspaceId = await createWorkspace("Archived"); + await sdk.workspaces.ref(workspaceId).archive(); + for (const id of [workspaceId, "wks_missing"]) { + await expect(sdk.terminals.create({ workspaceId: id })).rejects.toThrow(/not active/); + await expect(sdk.terminals.create({ workspaceId: id, cwd })).rejects.toThrow(/not active/); + const raw = await client.createTerminal(cwd, undefined, undefined, { workspaceId: id }); + expect(raw).toMatchObject({ + terminal: null, + error: `Workspace ${id} is not active or does not exist`, + }); + } + expect((await sdk.terminals.list()).entries).toEqual([]); + expect((await client.fetchWorkspaces()).entries).toEqual([]); +}); + +test("plugin handlers operate terminals through their host-owned Paseo API", async () => { + const workspaceId = await createWorkspace("Plugin workspace"); + const pluginDirectory = path.join(cwd, "plugin"); + await mkdir(pluginDirectory); + await writeFile( + path.join(pluginDirectory, "paseo-plugin.json"), + JSON.stringify({ id: "terminal-sdk" }), + ); + await writeFile( + path.join(pluginDirectory, "index.server.ts"), + ` +import { defineRpc } from "@getpaseo/plugin"; +import { z } from "zod"; +const operate = defineRpc({ name: "operate", input: z.object({ workspaceId: z.string(), command: z.string() }), output: z.object({ workspaceIds: z.array(z.string()), lines: z.array(z.string()), remaining: z.number() }) }); +async function waitForTerminalOutput(terminal, text) { + const deadline = Date.now() + 10000; + while (Date.now() < deadline) { + const capture = await terminal.capture({ stripAnsi: true }); + if (capture.lines.some(line => line.includes(text))) return capture.lines; + await new Promise(resolve => setTimeout(resolve, 20)); + } + throw new Error("Timed out waiting for plugin terminal output: " + text); +} +export default function contribute(server) { + server.handle(operate, async ({ workspaceId, command }, { paseo }) => { + const workspace = paseo.workspaces.ref(workspaceId); + const terminal = await workspace.terminals.create({ command, args: ["-e", "process.stdin.setRawMode(true); process.stdin.resume(); console.log('PLUGIN READY'); let hex = ''; process.stdin.on('data', data => { hex += data.toString('hex'); console.log('PLUGIN:' + hex); });"] }); + try { + await waitForTerminalOutput(terminal, "PLUGIN READY"); + terminal.write("Enter"); + await waitForTerminalOutput(terminal, "PLUGIN:456e746572"); + terminal.sendKeys(["Enter"]); + const lines = await waitForTerminalOutput(terminal, "PLUGIN:456e7465720d"); + const listed = await workspace.terminals.list(); + await paseo.terminals.ref(terminal.id).kill(); + return { workspaceIds: listed.entries.map(entry => entry.workspaceId), lines, remaining: (await workspace.terminals.list()).entries.length }; + } finally { + await terminal.kill(); + } + }); + return () => {}; +}`, + ); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(pluginDirectory); + try { + const result = await client.invokePluginRpc("terminal-sdk", "operate", { + workspaceId, + command: process.execPath, + }); + expect(result).toMatchObject({ + workspaceIds: [workspaceId], + remaining: 0, + lines: expect.arrayContaining([expect.stringContaining("PLUGIN:456e7465720d")]), + }); + } finally { + await client.removePlugin("terminal-sdk"); + } +}, 30_000); + +async function createWorkspace(title: string): Promise { + const result = await client.createWorkspace({ source: { kind: "directory", path: cwd }, title }); + if (!result.workspace) throw new Error(result.error ?? "Workspace creation failed"); + return result.workspace.id; +} + +test("listing by workspace ID keeps terminals in a shared directory separate", async () => { + const first = await createWorkspace("main"); + const second = await createWorkspace("feature-work"); + await client.createTerminal(cwd, "main terminal", undefined, { workspaceId: first }); + const created = await client.createTerminal(cwd, "feature terminal", undefined, { + workspaceId: second, + }); + expect(created.error).toBeNull(); + + const result = await client.listTerminals(undefined, undefined, { workspaceId: second }); + expect(result.terminals).toEqual([ + expect.objectContaining({ id: created.terminal?.id, workspaceId: second, cwd }), + ]); +}); diff --git a/packages/server/src/server/file-observer/internal/native-recursive.test.ts b/packages/server/src/server/file-observer/internal/native-recursive.test.ts new file mode 100644 index 0000000000..c97048ab47 --- /dev/null +++ b/packages/server/src/server/file-observer/internal/native-recursive.test.ts @@ -0,0 +1,71 @@ +import { EventEmitter } from "node:events"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test, vi } from "vitest"; +import { createFileObserver, type FileChange } from "../index.js"; +import { createNativeRecursiveBackend } from "./native-recursive.js"; +import { createObserverPaths } from "./paths.js"; + +// Native watchers may coalesce file removals into change notifications. Keep the +// filesystem real while controlling which notifications reach reconciliation. +test("shallow parent scans retain nested change scopes", async () => { + const root = await mkdtemp(join(tmpdir(), "native-scopes-")); + const paths = createObserverPaths(process.platform); + const removed = [ + join(root, "root.txt"), + join(root, "child", "child.txt"), + join(root, "child", "deep", "deep.txt"), + ]; + await mkdir(join(root, "child", "deep"), { recursive: true }); + await Promise.all(removed.map((path) => writeFile(path, "before"))); + const events: FileChange[] = []; + const notifications = new EventEmitter(); + let active = true; + const observer = createFileObserver(); + const backend = createNativeRecursiveBackend( + { + root, + metrics: observer.getDiagnostics(), + isActive: () => active, + isIgnored: () => false, + isPathInside: paths.isInside, + queueEvent: (type, path) => events.push({ type, path }), + fail: (error) => { + throw error; + }, + }, + paths, + (_root, listener) => { + notifications.on("change", listener); + return { + close: () => { + notifications.removeAllListeners(); + }, + on: (event, onError) => notifications.on(event, onError), + }; + }, + ); + try { + await backend.start(); + await Promise.all(removed.map((path) => rm(path))); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] }); + for (const path of removed) notifications.emit("change", "change", path); + await vi.advanceTimersByTimeAsync(8_000); + vi.useRealTimers(); + await expect + .poll(() => + events + .filter((event) => event.type === "delete") + .map((event) => event.path) + .sort(), + ) + .toEqual([...removed].sort()); + } finally { + vi.useRealTimers(); + active = false; + await backend.close(); + await observer.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/file-observer/internal/native-recursive.ts b/packages/server/src/server/file-observer/internal/native-recursive.ts index e5ef077535..de001b232a 100644 --- a/packages/server/src/server/file-observer/internal/native-recursive.ts +++ b/packages/server/src/server/file-observer/internal/native-recursive.ts @@ -1,4 +1,4 @@ -import { type Dirent, type FSWatcher, watch } from "node:fs"; +import { type Dirent, watch } from "node:fs"; import { readdir, stat } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import type { BackendDiagnostics, ObservationBackend, ObservationHost } from "./contracts.js"; @@ -25,15 +25,27 @@ interface Inventory { files: Set; } +type WatchDirectory = ( + root: string, + listener: (eventType: string, filename: string | null) => void, +) => { + close(): void; + on(event: "error", listener: (error: Error) => void): unknown; +}; + +const watchDirectory: WatchDirectory = (root, listener) => + watch(root, { recursive: true }, listener); + export function createNativeRecursiveBackend( host: ObservationHost, paths: ObserverPaths, + observe: WatchDirectory = watchDirectory, ): ObservationBackend { - return new NativeRecursiveBackend(host, paths); + return new NativeRecursiveBackend(host, paths, observe); } class NativeRecursiveBackend implements ObservationBackend { - private watcher: FSWatcher | null = null; + private watcher: ReturnType | null = null; private files = new Set(); private directories = new Set(); private entries = new Map(); @@ -62,6 +74,7 @@ class NativeRecursiveBackend implements ObservationBackend { constructor( private readonly host: ObservationHost, private readonly paths: ObserverPaths, + private readonly observe: WatchDirectory, ) {} async start(): Promise { @@ -114,7 +127,7 @@ class NativeRecursiveBackend implements ObservationBackend { } private watchRoot(): void { - const watcher = watch(this.host.root, { recursive: true }, (eventType, filename) => { + const watcher = this.observe(this.host.root, (eventType, filename) => { if (!this.host.isActive()) return; this.host.metrics.nativeEventCount += 1; if (!filename) { @@ -262,7 +275,8 @@ class NativeRecursiveBackend implements ObservationBackend { await this.reconcileSubtree(directory, generation); if (!this.canCommit(generation)) return; } - for (const directory of this.paths.collapse(changeScopes)) { + // Parent scans are shallow, so a queued parent cannot cover its children. + for (const directory of new Set(changeScopes)) { const alreadyCovered = forcedLocalScopes.has(directory) || forcedRecursiveScopes.some((scope) => this.host.isPathInside(scope, directory)); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 6e5b037526..ed289eb95e 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1682,6 +1682,7 @@ export class VoiceAssistantWebSocketServer { "terminal-input-mode-replay": true, // COMPAT(terminalSizeOwnership): added in v0.2.6, remove gate after 2027-02-02. "terminal-size-ownership": true, + workspaceTerminals: true, // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X. rewind: true, // COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X. diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index 16ff7b64e6..9fbd7e3d2a 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -300,7 +300,7 @@ describe("terminal-session-controller legacy terminal creation", () => { hasBinaryChannel: () => true, isPathWithinRoot: isSameOrDescendantPath, sessionLogger: createLogger(), - listTerminalWorkspaceRefs: async () => [], + listTerminalWorkspaceRefs: async () => [{ workspaceId: "ws-1", cwd: "/work/repo" }], }); await controller.dispatch({ @@ -548,7 +548,13 @@ describe("terminal-session-controller subdirectory aggregation", () => { payload: { cwd: rootCwd, terminals: [ - { id: "root-term", name: "Terminal 1", workspaceId: "ws-test", activity: null }, + { + id: "root-term", + name: "Terminal 1", + cwd: rootCwd, + workspaceId: "ws-test", + activity: null, + }, ], requestId: "req-root", }, @@ -558,7 +564,13 @@ describe("terminal-session-controller subdirectory aggregation", () => { payload: { cwd: worktreeCwd, terminals: [ - { id: "worktree-term", name: "Feature", workspaceId: "ws-test", activity: null }, + { + id: "worktree-term", + name: "Feature", + cwd: worktreeCwd, + workspaceId: "ws-test", + activity: null, + }, ], requestId: "req-worktree", }, diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 50c4109d06..998c5c93e8 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -426,10 +426,16 @@ export class TerminalSessionController { } try { - const terminals = - typeof msg.cwd === "string" - ? await this.getTerminalsForWorkspaceRoot(msg.cwd, msg.workspaceId) - : await this.getAllTerminalSessions(); + let terminals: TerminalSession[]; + if (msg.workspaceId !== undefined) { + terminals = (await this.getAllTerminalSessions()).filter( + (terminal) => terminal.workspaceId === msg.workspaceId, + ); + } else if (typeof msg.cwd === "string") { + terminals = await this.getTerminalsForWorkspaceRoot(msg.cwd); + } else { + terminals = await this.getAllTerminalSessions(); + } for (const terminal of terminals) { this.ensureExitSubscription(terminal); } @@ -437,7 +443,9 @@ export class TerminalSessionController { type: "list_terminals_response", payload: { ...(msg.cwd ? { cwd: msg.cwd } : {}), - terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)), + terminals: terminals.map((terminal) => + Object.assign(this.toTerminalInfo(terminal), { cwd: terminal.cwd }), + ), requestId: msg.requestId, }, }); @@ -463,7 +471,9 @@ export class TerminalSessionController { const terminalsByDirectory = await Promise.all( directories.map((cwd) => manager.getTerminals(cwd)), ); - return terminalsByDirectory.flat(); + return [ + ...new Map(terminalsByDirectory.flat().map((terminal) => [terminal.id, terminal])).values(), + ]; } private async getTerminalsForWorkspaceRoot( @@ -558,6 +568,11 @@ export class TerminalSessionController { return; } + const workspaces = await this.listTerminalWorkspaceRefs(); + if (!workspaces.some((workspace) => workspace.workspaceId === workspaceId)) { + throw new Error(`Workspace ${workspaceId} is not active or does not exist`); + } + const session = await this.terminalManager.createTerminal({ cwd: msg.cwd, workspaceId, @@ -746,12 +761,24 @@ export class TerminalSessionController { } private async handleKillTerminalRequest(msg: KillTerminalRequest): Promise { - const result = this.killTerminalForClose(msg.terminalId); + let success = false; + if (this.terminalManager) { + try { + this.detachStream(msg.terminalId, { emitExit: true }); + await this.terminalManager.killTerminalAndWait(msg.terminalId); + success = true; + } catch (error) { + this.sessionLogger.error( + { err: error, terminalId: msg.terminalId }, + "Failed to kill terminal", + ); + } + } this.emit({ type: "kill_terminal_response", payload: { - terminalId: result.terminalId, - success: result.success, + terminalId: msg.terminalId, + success, requestId: msg.requestId, }, }); diff --git a/public-docs/cli.md b/public-docs/cli.md index 10065793de..f47270144a 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -128,6 +128,25 @@ paseo workspace archive Add `--forge ` to PR checkout when Paseo cannot infer the forge from the source checkout. See [Git worktrees](/docs/worktrees) for setup hooks and services. +## Terminals + +Use the workspace ID when multiple workspaces share a directory: + +```bash +paseo terminal create --workspace --name Development +paseo terminal ls --workspace --json +paseo terminal send-keys -l "echo ready" +paseo terminal send-keys Enter +paseo terminal capture +paseo terminal kill +``` + +Creation defaults to the workspace directory. Add `--cwd ` to change the process directory while keeping that workspace as the owner. Unknown and archived workspace IDs fail. + +Without `--workspace`, creation opens the project at `--cwd` or the current directory and reuses its oldest active workspace. Listing without `--workspace` filters by `--cwd` or the current directory and can include multiple workspaces. `ls --all` lists every terminal on the host and cannot be combined with directory or workspace filters. + +Create and list results include `id`, `name`, `cwd`, and `workspaceId`. Use `--json` for structured output and the global `--host` option to target another daemon. These commands require a host that supports the [workspace terminal API](/docs/sdk/reference#clientterminals); older hosts return an update message. + ## Workspace scripts List, start, and stop the scripts configured in a workspace's `paseo.json`: diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index 6e3cd4f000..db73adc131 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -843,7 +843,7 @@ function PullRequestAction() { } ``` -The returned API covers projects, workspaces, agents, providers, and daemon config. See the [SDK API reference](/docs/sdk/reference) for its methods. Connection lifecycle methods are intentionally absent because Paseo owns the connection. +The returned API covers projects, workspaces, agents, terminals, providers, and daemon config. See the [SDK API reference](/docs/sdk/reference) for its methods. Connection lifecycle methods are intentionally absent because Paseo owns the connection. ## Add plugin-specific backend behavior diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 26c657e7f2..aff12fa4e5 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -129,6 +129,46 @@ Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `o A workspace handle exposes `id`, `projectId`, `directory`, `name`, `status`, `current()`, `refresh()`, `setTitle(title)`, `archive()`, and `subscribe()`. Pass `null` to `setTitle` to restore the derived workspace name. Use `workspace.agents.create(options)` to create an agent without repeating the workspace ID or directory. +## `client.terminals` + +Terminal operations require a host that supports workspace terminals. An older host receives no terminal request; the SDK throws an update-host error. + +| Method | Result | Behavior | +| ------------------- | ---------------------------------- | ---------------------------------------------------------------------------------- | +| `create(options)` | `Promise` | Creates a terminal owned by the required `workspaceId`. | +| `list(options?)` | `Promise` | Returns `{ entries, requestId }`. Omit filters to list all terminals on this host. | +| `ref(terminalOrId)` | `PaseoTerminalHandle` | Creates a local handle without fetching or attaching a terminal stream. | + +Creation options: + +| Field | Meaning | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `workspaceId` | Required active workspace ID. Unknown and archived IDs fail. | +| `cwd` | Optional absolute process working directory. Defaults to the workspace directory; changing it does not change ownership. | +| `name` | Optional terminal name. | +| `command`, `args` | Optional executable and argument array. Omit them to start the default shell. | +| `size` | Optional initial viewport: `{ rows, cols }`. | +| `requestId` | Optional request correlation ID. | + +List options are `workspaceId`, `cwd`, and `requestId`. `workspaceId` selects ownership, including terminals started outside the workspace directory. When it is present, `cwd` does not restrict the results. Without an ID, `cwd` filters by workspace root directory. Each entry contains `id`, `workspaceId`, `cwd`, and `name`; `cwd` is the terminal's actual starting directory. + +Terminal handles expose: + +| Method | Result | Behavior | +| ------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `current()` | `PaseoTerminal \| null` | Last snapshot from creation, `ref(snapshot)`, or refresh. A handle made from an ID starts empty. | +| `refresh(options?)` | `Promise` | Fetches the terminal snapshot, or `null` when it no longer exists. Accepts `requestId`. | +| `write(data)` | `number` | Sends literal text without interpreting key names. Returns the input's UTF-16 length. | +| `sendKeys(keys)` | `number` | Expands key tokens and sends the combined input. Returns its UTF-16 length. | +| `capture(options?)` | `Promise` | Returns `{ terminalId, lines, totalLines, requestId }`. | +| `kill(requestId?)` | `Promise` | Waits for terminal teardown. Killing an already-removed terminal succeeds. | + +`sendKeys()` recognizes `Enter`, `Tab`, `Escape`, `Space`, `BSpace`, `C-c`, `C-d`, `C-z`, `C-l`, `C-a`, and `C-e`. Other strings pass through literally. Input methods send without waiting for command execution or acknowledging that the terminal consumed the input. + +Capture accepts optional `start`, `end`, `stripAnsi`, and `requestId`. Line bounds are zero-based and inclusive across scrollback and the viewport. Negative bounds count from the end; omitted bounds capture all lines. `stripAnsi` defaults to `true`. A missing terminal returns empty lines. + +Use `workspace.terminals.create(options?)` and `workspace.terminals.list(options?)` to supply the workspace ID from a handle. Creation accepts the same options except `workspaceId`; listing accepts only `requestId`. Plugins get these methods through `usePaseo()` and the handler's `paseo` context. + ## `client.providers` | Method | Result | Behavior | diff --git a/public-docs/sdk/workspaces.md b/public-docs/sdk/workspaces.md index 1bc72c5f02..6775e90b3d 100644 --- a/public-docs/sdk/workspaces.md +++ b/public-docs/sdk/workspaces.md @@ -81,6 +81,22 @@ const agent = await client.agents.create({ The daemon still creates a project and a fresh workspace. Read `agent.workspaceId` when you need the generated workspace ID. +## Start a terminal in a workspace + +```ts +const terminal = await workspace.terminals.create({ name: "Development" }); +terminal.write("echo ready"); +terminal.sendKeys(["Enter"]); + +const { lines } = await terminal.capture(); +const { entries } = await workspace.terminals.list(); +await terminal.kill(); +``` + +Two workspaces may share a directory. The workspace handle supplies the ID that keeps their terminals separate. To use an ID you already have, call `client.workspaces.ref(workspaceId).terminals.create()`. + +See the [terminal API reference](/docs/sdk/reference#clientterminals) for command arguments, working-directory overrides, input, and capture options. + ## List workspaces ```ts From efd6023b90b898963804c6fdb36134402c55590b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 6 Sep 2026 10:18:41 +0200 Subject: [PATCH 015/566] Restore live sessions immediately after app resume (#4160) * perf(app): restore live sessions immediately after resume Foregrounding now reconnects every host without waiting for scheduled backoff, while cached turns, timelines, agents, and workspaces remain internally consistent until authoritative catch-up arrives. Replica persistence is keyed and incremental so ordinary updates avoid whole-directory serialization. * fix(app): preserve turn ownership through session updates Stream turn closure now wakes queued-message delivery, while same-turn snapshots retain local cancellation state. Offline liveness remains visible until an authoritative close arrives. * fix(app): preserve pending chat cache writes and trim resume refactor Directory baseline replacement must preserve independently accepted timeline changes until the deferred flush. Keep the reconnect and persistence fixes while removing the session-data facade and broad read-hook migration. * fix(app): initialize reconnect policy from current app state Initialize visibility before host bootstrap so a background mount cannot leave reconnect enabled. Seed sidebar fixtures through the workspace owner so cache hydration preserves their rows. * test(app): verify submission settlement across reconnect Keep submission acknowledgement separate from turn liveness: disconnect preserves the running turn until authoritative catch-up. Wait for confirmed running chrome before exercising queued cancellation so the alternate-send shortcut cannot race turn startup. * docs: remove cache implementation recap --- docs/data-model.md | 4 +- docs/timeline-sync.md | 8 +- .../browser/agent-message-submission.spec.ts | 14 +- packages/app/src/app/_layout.tsx | 6 + .../src/client-slash-commands/index.test.ts | 2 +- .../app/src/components/add-project-flow.tsx | 11 +- .../sidebar-workspace-list.test.tsx | 4 +- .../src/components/workspace-setup-dialog.tsx | 34 +- ...space-shortcut-targets-subscriber.test.tsx | 10 +- .../app/src/composer/draft/workspace-tab.tsx | 2 +- packages/app/src/composer/index.tsx | 23 +- packages/app/src/contexts/session-context.tsx | 14 +- .../contexts/session-status-tracking.test.ts | 2 +- .../sidebar-workspaces-view-model.test.ts | 5 +- packages/app/src/hooks/use-agent-history.ts | 1 + .../use-agent-screen-state-machine.test.ts | 2 +- .../app/src/hooks/use-aggregated-agents.ts | 5 +- .../app/src/hooks/use-archive-agent.test.ts | 7 +- packages/app/src/hooks/use-archive-agent.ts | 44 +-- packages/app/src/hooks/use-open-project.ts | 21 +- .../navigation/host-runtime-bootstrap.test.ts | 32 ++ .../src/navigation/host-runtime-bootstrap.ts | 12 + packages/app/src/panels/agent-panel.tsx | 13 +- .../plugins/client-state/snapshots.test.ts | 2 +- .../directory-sync/agent-replica.test.ts | 208 ++++++++++- .../runtime/directory-sync/agent-replica.ts | 141 +++++--- .../src/runtime/directory-sync/index.test.ts | 67 +++- .../app/src/runtime/directory-sync/index.ts | 258 +++++++++----- .../directory-sync/internal/agent-store.ts | 216 ++++++++++++ .../directory-sync/workspace-replica.ts | 280 +++++++++------ packages/app/src/runtime/host-runtime.test.ts | 174 ++++++++- packages/app/src/runtime/host-runtime.ts | 95 ++++- .../src/runtime/replica-cache/index.test.ts | 238 +++++++++++-- .../app/src/runtime/replica-cache/index.ts | 329 +++++++++++------- .../app/src/screens/new-workspace-screen.tsx | 10 +- packages/app/src/stores/session-store.test.ts | 69 +++- packages/app/src/stores/session-store.ts | 71 +--- .../workspace-subagents-integration.test.ts | 2 +- .../src/subagents/archive-finished.test.ts | 4 + packages/app/src/subagents/select.test.ts | 4 +- packages/app/src/subagents/select.ts | 2 + .../src/subagents/track-presentation.test.ts | 5 + .../app/src/subagents/track-presentation.ts | 5 +- packages/app/src/test/seed-session.ts | 31 ++ packages/app/src/timeline/replica.test.ts | 53 +++ .../app/src/timeline/viewed-timeline-sync.ts | 10 +- packages/app/src/types/agent-directory.ts | 1 + .../app/src/utils/agent-attention.test.ts | 2 +- .../agent-directory-reconciliation.test.ts | 54 +-- .../utils/agent-directory-reconciliation.ts | 26 +- .../src/utils/agent-directory-sync.test.ts | 160 +++------ .../app/src/utils/agent-directory-sync.ts | 177 +--------- packages/app/src/utils/agent-grouping.test.ts | 30 ++ packages/app/src/utils/agent-grouping.ts | 4 +- .../app/src/utils/agent-snapshots.test.ts | 18 +- packages/app/src/utils/agent-snapshots.ts | 28 +- .../utils/workspace-agent-activity.test.ts | 47 ++- .../app/src/utils/workspace-agent-activity.ts | 7 +- .../workspace-tabs/agent-visibility.test.ts | 2 +- .../legacy-daemon-workspaces.test.ts | 44 --- .../src/workspace/legacy-daemon-workspaces.ts | 110 +----- .../src/workspace/workspace-archive.test.ts | 14 +- .../app/src/workspace/workspace-archive.ts | 5 +- packages/client/src/daemon-client.test.ts | 81 +++++ packages/client/src/daemon-client.ts | 12 + 65 files changed, 2211 insertions(+), 1161 deletions(-) create mode 100644 packages/app/src/runtime/directory-sync/internal/agent-store.ts diff --git a/docs/data-model.md b/docs/data-model.md index 4c86286c2a..5bd96f5252 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -574,8 +574,8 @@ Right-sidebar client state splits on whether it is determined by the directory o The durable client replica uses IndexedDB on browser/Electron and expo-sqlite on native. Rows use the compound key `(serverId, kind, id)`; kinds are `agent`, `workspace`, `project`, `timeline`, and -`checkpoint`. Directory entities have individual rows. Timeline and checkpoint use the singleton id -and have at most one row per host. +`checkpoint`. Directory entities have individual rows, timelines use the agent id, and the checkpoint +uses the singleton id. The store is a typed persistence boundary. It returns values to directory and timeline owners and accepts their explicit commits; it never reads or writes UI state. Reads are scoped to the requested diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 3f0b6e790a..5d3464a30b 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -152,7 +152,7 @@ remaining page through the existing stream reducer. It must not append full proj live prefix. Every path that sends a message to an agent — composer send, dictation accept-and-send, queued -send-now, and the automatic queue drain in `HostRuntime` — goes through +send-now, and the host runtime's automatic queue drain — goes through `dispatchComposerAgentMessage` with a submission writer. There is no second transport for the same product action: calling `client.sendAgentMessage` directly skips the submitted row and the pending footer, and permanently drops attachments because the daemon does not echo them back. @@ -196,9 +196,9 @@ fork positions retain the canonical `turnId` boundaries. The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a -second running state. Disconnect and replica removal remain destructive close boundaries. Elapsed time -comes only from turn liveness, never from submission records or whichever timeline rows happen to be -mounted. +second running state. Disconnect preserves the last replicated turn until cache or network hydration +advances it; replica removal remains the destructive close boundary. Elapsed time comes only from turn +liveness, never from submission records or whichever timeline rows happen to be mounted. The daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the submission's `clientMessageId`, so the row is born with its final identity and remains immutable on diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index 295e07ba88..194fe43d1d 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -5,6 +5,7 @@ import { expectAgentIdle, expectAgentReadyToInterrupt, expectAgentSurfacesIdle, + expectInlineWorkingIndicator, expectRunningAgentChrome, expectVisibleAgentSurfacesIdle, } from "../support/helpers/agent-stream"; @@ -549,9 +550,18 @@ async function expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission const userMessage = await submitMessageWithImage(page, prompt); await gate.waitForHeldServerMessage(); await gate.waitForAgentStreamItem("user_message"); + await gate.waitForAgentStreamEvent("turn_started"); gate.releaseHeldServerMessage(); + await expect(userMessage).toHaveAttribute("aria-busy", "false"); await gate.drop(); - await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + await expectInlineWorkingIndicator(page); + await expect(userMessage).toHaveAttribute("aria-busy", "false"); + + await agent.client.waitForFinish(agent.agentId, 30_000); + gate.setServerMessageSuppressed("agent_status", false); + gate.setServerMessageSuppressed("agent_update", false); + gate.restoreFresh(); + await expectVisibleAgentSurfacesIdle(page); await expect(userMessage).toHaveAttribute("aria-busy", "false"); } finally { gate.restore(); @@ -991,7 +1001,7 @@ test.describe("Agent message submission", () => { await openAgentRoute(page, agent); await expectComposerVisible(page); await submitMessage(page, "Keep running until the queued turn is ready."); - await expectAgentReadyToInterrupt(page); + await expectRunningAgentChrome(page, title); await queueMessage(page, secondPrompt); await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible(); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index c57dba4429..148d431921 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -69,6 +69,7 @@ import { resolveStartupNavigationReady, shouldRunStartupGiveUpTimer, startHostRuntimeBootstrap, + bindHostRuntimeAppState, type StartupBlocker, } from "@/navigation/host-runtime-bootstrap"; import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation"; @@ -372,6 +373,11 @@ async function shouldStartBuiltInDaemon(): Promise { } function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { + useEffect(() => { + const store = getHostRuntimeStore(); + return bindHostRuntimeAppState(store, AppState); + }, []); + useEffect(() => { const store = getHostRuntimeStore(); const daemonStartService = getDaemonStartService({ store }); diff --git a/packages/app/src/client-slash-commands/index.test.ts b/packages/app/src/client-slash-commands/index.test.ts index 768602a3d8..c6e86be582 100644 --- a/packages/app/src/client-slash-commands/index.test.ts +++ b/packages/app/src/client-slash-commands/index.test.ts @@ -53,7 +53,7 @@ function createAgent(overrides: Partial = {}): Agent { parentAgentId: null, labels: {}, ...overrides, - activeTurn: overrides.activeTurn ?? null, + turn: overrides.turn ?? { phase: "idle", cancellationRequestId: null }, }; } diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx index 80cc9075be..4dcd50b019 100644 --- a/packages/app/src/components/add-project-flow.tsx +++ b/packages/app/src/components/add-project-flow.tsx @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { router } from "expo-router"; import type { WorkspaceProjectDescriptorPayload } from "@getpaseo/protocol/messages"; import { @@ -372,7 +373,15 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { const recommendedPaths = useRecommendedProjectPaths(hostId); const openProject = useOpenProject(hostId); const cloneGithubProject = useCloneGithubProject(hostId); - const upsertProject = useSessionStore((store) => store.upsertProject); + const upsertProject = useCallback( + ( + targetServerId: string, + project: Parameters["acceptProjectSnapshot"]>[1], + ) => { + getHostRuntimeStore().acceptProjectSnapshot(targetServerId, project); + }, + [], + ); const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces); const inputRef = useRef(null); const submissionInFlightRef = useRef(false); diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index 94e6f36466..73c008dd22 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -38,7 +38,7 @@ import { } from "@/runtime/host-runtime"; import type { HostProfile } from "@/types/host-connection"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; -import { seedSessionWorkspaces } from "@/test/seed-session"; +import { seedRuntimeWorkspaces } from "@/test/seed-session"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; @@ -167,7 +167,7 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void { act(() => { setHostProfiles([makeHost()]); useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); - seedSessionWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry]))); + seedRuntimeWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry]))); useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true); useSidebarOrderStore.setState({ projectOrder: ["project-a", "project-b"], diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 4dabf067ba..29cd7ea0b7 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; @@ -12,7 +13,11 @@ import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useProjectIcon } from "@/projects/icons"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; +import { + normalizeWorkspaceDescriptor, + useSessionStore, + type WorkspaceDescriptor, +} from "@/stores/session-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; @@ -164,9 +169,13 @@ export function WorkspaceSetupDialog() { const toast = useToast(); const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup); const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup); - const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); + const mergeWorkspaces = useCallback( + (targetServerId: string, workspaces: Iterable) => { + getHostRuntimeStore().acceptWorkspaceSnapshots(targetServerId, Array.from(workspaces)); + }, + [], + ); const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); - const setAgents = useSessionStore((state) => state.setAgents); const [errorMessage, setErrorMessage] = useState(null); const [createdWorkspace, setCreatedWorkspace] = useState { - const next = new Map(previous); - next.set( - agent.id, - applyLegacyDaemonWorkspaceOwnership({ - serverId, - agent: normalizeAgentSnapshot(agent, serverId), - }), - ); - return next; - }); + getHostRuntimeStore().acceptAgentSnapshot( + serverId, + applyLegacyDaemonWorkspaceOwnership({ + serverId, + agent: normalizeAgentSnapshot(agent, serverId), + }), + ); navigateAfterCreation(ensuredWorkspace.id, { kind: "agent", agentId: agent.id }); } catch (error) { const message = toErrorMessage(error); @@ -368,7 +373,6 @@ export function WorkspaceSetupDialog() { getIsStillActive, navigateAfterCreation, serverId, - setAgents, ensureWorkspace, t, toast, diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx index 2cceeae961..7124fd2f0a 100644 --- a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; -import { seedSessionWorkspaces } from "@/test/seed-session"; +import { seedRuntimeWorkspaces } from "@/test/seed-session"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useSidebarViewStore } from "@/stores/sidebar-view-store"; @@ -96,7 +96,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { act(() => { setHostProfiles([hostProfile()]); useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient); - seedSessionWorkspaces( + seedRuntimeWorkspaces( "srv", new Map([ ["ws-1", workspaceDescriptor({ id: "ws-1", name: "Workspace 1" })], @@ -142,7 +142,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { it("publishes status-mode shortcut targets in visual status order", async () => { act(() => { useSidebarViewStore.getState().setGroupMode("status"); - seedSessionWorkspaces( + seedRuntimeWorkspaces( "srv", new Map([ [ @@ -214,11 +214,11 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { setHostProfiles([hostProfile("host-a"), hostProfile("host-b")]); useSessionStore.getState().initializeSession("host-a", null as unknown as DaemonClient); useSessionStore.getState().initializeSession("host-b", null as unknown as DaemonClient); - seedSessionWorkspaces( + seedRuntimeWorkspaces( "host-a", new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]), ); - seedSessionWorkspaces( + seedRuntimeWorkspaces( "host-b", new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]), ); diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index e9047eb60a..9f1c2b172c 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -248,7 +248,7 @@ function buildDraftAgentSnapshot(input: { id: tabId, provider, status: "running", - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt: now, updatedAt: now, lastUserMessageAt: now, diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 5a1d51f1fc..84de49a3b9 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { View, @@ -1487,8 +1488,10 @@ function ComposerContentImpl({ activeTurnBehavior, activeTurnId: activeTurnBehavior === "steer" - ? (useSessionStore.getState().sessions[serverId]?.agents.get(targetAgentId)?.activeTurn - ?.turnId ?? undefined) + ? (selectAgentTurnPresentation( + useSessionStore.getState().sessions[serverId], + targetAgentId, + ).turnId ?? undefined) : undefined, }); onAttentionPromptSend?.(); @@ -1505,8 +1508,6 @@ function ComposerContentImpl({ const isCancellingAgent = useSessionStore( (state) => selectAgentTurnPresentation(state.sessions[serverId], agentId).isCancelling, ); - const beginAgentCancellation = useSessionStore((state) => state.beginAgentCancellation); - const settleAgentCancellation = useSessionStore((state) => state.settleAgentCancellation); const isAgentRunning = hasActiveTurn; // Queueing behind a permission prompt would strand the message: the turn is // parked until the request is answered. @@ -1812,7 +1813,7 @@ function ComposerContentImpl({ isConnected, }); if (!cancellation) return; - const requestId = beginAgentCancellation(serverId, targetAgentId); + const requestId = getHostRuntimeStore().beginAgentCancellation(serverId, targetAgentId); void cancellation .catch((error) => { const message = resolveErrorMessage(error); @@ -1821,18 +1822,10 @@ function ComposerContentImpl({ } }) .finally(() => { - settleAgentCancellation(serverId, targetAgentId, requestId); + getHostRuntimeStore().settleAgentCancellation(serverId, targetAgentId, requestId); }); messageInputRef.current?.focus(); - }, [ - beginAgentCancellation, - client, - isAgentRunning, - isCancellingAgent, - isConnected, - serverId, - settleAgentCancellation, - ]); + }, [client, isAgentRunning, isCancellingAgent, isConnected, serverId]); const focusMessageInputForKeyboardAction = useCallback(() => { focusMessageInputWithPlatformStrategy(messageInputRef); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 475eb26f27..fff3a0f496 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -231,8 +231,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const setIsPlayingAudio = useSessionStore((state) => state.setIsPlayingAudio); const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); - const applyAgentTurnLiveness = useSessionStore((state) => state.applyAgentTurnLiveness); - const clearAgentTurnLiveness = useSessionStore((state) => state.clearAgentTurnLiveness); const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead); const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration); @@ -412,15 +410,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } }, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]); - useEffect( - () => - client.subscribeConnectionStatus((connection) => { - if (connection.status === "connected") return; - clearAgentTurnLiveness(serverId); - }), - [clearAgentTurnLiveness, client, serverId], - ); - const applyWorkspaceSetupProgress = useCallback( (payload: WorkspaceSetupProgressPayload) => { upsertWorkspaceSetupProgress({ serverId, payload }); @@ -544,7 +533,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider { event: streamEvent, seq, epoch, timestamp: parsedTimestamp }, ]); if (turnLiveness.length > 0) { - applyAgentTurnLiveness(serverId, agentId, turnLiveness); + getHostRuntimeStore().applyAgentTurnLiveness(serverId, agentId, turnLiveness); } owner.enqueueStreamEvent(agentId, { event: streamEvent, @@ -797,7 +786,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setIsPlayingAudio, setAgentStreamTail, setAgentStreamHead, - applyAgentTurnLiveness, clearAgentStreamHead, setInitializingAgents, setAgents, diff --git a/packages/app/src/contexts/session-status-tracking.test.ts b/packages/app/src/contexts/session-status-tracking.test.ts index 346127b1ff..dcd150d764 100644 --- a/packages/app/src/contexts/session-status-tracking.test.ts +++ b/packages/app/src/contexts/session-status-tracking.test.ts @@ -8,7 +8,7 @@ function createAgent(status: Agent["status"]): Agent { id: "agent-1", provider: "codex", status, - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt: new Date(0), updatedAt: new Date(0), lastUserMessageAt: null, diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index f9d192dbb9..637d3a035b 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -701,7 +701,10 @@ function agent(input: { id: input.id, provider: "claude" as Agent["provider"], status: input.status, - activeTurn: null, + turn: + input.status === "running" + ? { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null } + : { phase: "idle", cancellationRequestId: null }, createdAt: new Date(0), updatedAt: input.updatedAt ?? new Date(1_000), lastUserMessageAt: null, diff --git a/packages/app/src/hooks/use-agent-history.ts b/packages/app/src/hooks/use-agent-history.ts index b24c5f8ee7..3ac397e719 100644 --- a/packages/app/src/hooks/use-agent-history.ts +++ b/packages/app/src/hooks/use-agent-history.ts @@ -134,6 +134,7 @@ export async function fetchAgentHistoryPage(input: { serverLabel: input.serverId, title: agent.title ?? null, status: agent.status, + turn: agent.turn, lastActivityAt: agent.lastActivityAt, cwd: agent.cwd, workspaceId: agent.workspaceId, diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index a7ed05e906..c23512758e 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -17,7 +17,7 @@ function createAgent(id: string): Agent { id, provider: "claude", status: "running", - activeTurn: { turnId: "turn-1", startedAt: now }, + turn: { phase: "open", turnId: "turn-1", startedAt: now, cancellationRequestId: null }, createdAt: now, updatedAt: now, lastUserMessageAt: now, diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index 1a016e9b13..78cca7fbb3 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -85,6 +85,7 @@ export function useAggregatedAgents(options?: { serverLabel, title: agent.title ?? null, status: agent.status, + turn: agent.turn, lastActivityAt: agent.lastActivityAt, cwd: agent.cwd, workspaceId: agent.workspaceId, @@ -108,8 +109,8 @@ export function useAggregatedAgents(options?: { // Sort by: running agents first, then by most recent activity allAgents.sort((left, right) => { - const leftRunning = left.status === "running"; - const rightRunning = right.status === "running"; + const leftRunning = left.turn.phase === "open"; + const rightRunning = right.turn.phase === "open"; if (leftRunning && !rightRunning) { return -1; } diff --git a/packages/app/src/hooks/use-archive-agent.test.ts b/packages/app/src/hooks/use-archive-agent.test.ts index cd4d433f74..df14f2df38 100644 --- a/packages/app/src/hooks/use-archive-agent.test.ts +++ b/packages/app/src/hooks/use-archive-agent.test.ts @@ -1,6 +1,7 @@ +import { seedSessionHosts } from "@/test/seed-session"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { QueryClient } from "@tanstack/react-query"; -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { Agent } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key"; @@ -41,13 +42,15 @@ function makeAgent(overrides: Partial = {}): Agent { labels: {}, archivedAt: null, ...overrides, - activeTurn: overrides.activeTurn ?? null, + turn: overrides.turn ?? { phase: "idle", cancellationRequestId: null }, }; } describe("useArchiveAgent", () => { + afterEach(() => seedSessionHosts([])); beforeEach(() => { useSessionStore.setState((state) => ({ ...state, sessions: {} })); + seedSessionHosts(["server-a"]); }); it("tracks pending archive state in shared react-query cache", () => { diff --git a/packages/app/src/hooks/use-archive-agent.ts b/packages/app/src/hooks/use-archive-agent.ts index 437ed6df49..92d8e4d2d6 100644 --- a/packages/app/src/hooks/use-archive-agent.ts +++ b/packages/app/src/hooks/use-archive-agent.ts @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback, useMemo } from "react"; import { useMutation, @@ -247,27 +248,7 @@ function getStoredAgentSnapshot(input: ArchiveAgentInput) { function restoreAgentSnapshot( input: ArchiveAgentInput & { agent: ReturnType }, ): void { - const setAgents = useSessionStore.getState().setAgents; - setAgents(input.serverId, (prev) => { - const hasAgent = prev.has(input.agentId); - if (!input.agent) { - if (!hasAgent) { - return prev; - } - const next = new Map(prev); - next.delete(input.agentId); - return next; - } - - const current = prev.get(input.agentId); - if (current === input.agent) { - return prev; - } - - const next = new Map(prev); - next.set(input.agentId, input.agent); - return next; - }); + getHostRuntimeStore().restoreAgentSnapshot(input.serverId, input.agentId, input.agent); } function getArchivedAgentListCacheSnapshot( @@ -324,22 +305,11 @@ function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: strin return; } - const setAgents = useSessionStore.getState().setAgents; - setAgents(input.serverId, (prev) => { - const existing = prev.get(input.agentId); - if (!existing) { - return prev; - } - if (existing.archivedAt && existing.archivedAt.getTime() === archivedAt.getTime()) { - return prev; - } - const next = new Map(prev); - next.set(input.agentId, { - ...existing, - archivedAt, - }); - return next; - }); + getHostRuntimeStore().archiveAgentSnapshot( + input.serverId, + input.agentId, + archivedAt.toISOString(), + ); } interface ApplyArchivedAgentCloseResultsInput { diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index 71a0a78da2..18dc5064f3 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback } from "react"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; @@ -20,7 +21,15 @@ export function useOpenProject( state.sessions[normalizedServerId]?.serverInfo?.features?.stableProjectIdentity === true : false, ); - const upsertProject = useSessionStore((state) => state.upsertProject); + const upsertProject = useCallback( + ( + targetServerId: string, + project: Parameters["acceptProjectSnapshot"]>[1], + ) => { + getHostRuntimeStore().acceptProjectSnapshot(targetServerId, project); + }, + [], + ); const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); return useCallback( @@ -57,7 +66,15 @@ export function useCloneGithubProject( const normalizedServerId = serverId?.trim() ?? ""; const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); - const upsertProject = useSessionStore((state) => state.upsertProject); + const upsertProject = useCallback( + ( + targetServerId: string, + project: Parameters["acceptProjectSnapshot"]>[1], + ) => { + getHostRuntimeStore().acceptProjectSnapshot(targetServerId, project); + }, + [], + ); const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); return useCallback( diff --git a/packages/app/src/navigation/host-runtime-bootstrap.test.ts b/packages/app/src/navigation/host-runtime-bootstrap.test.ts index ed6a8ccbe9..603532086d 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.test.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.test.ts @@ -6,6 +6,7 @@ import { resolveStartupRoute, shouldRunStartupGiveUpTimer, startHostRuntimeBootstrap, + bindHostRuntimeAppState, } from "./host-runtime-bootstrap"; import type { DaemonStartResult, StartDaemonIfEnabledInput } from "@/runtime/daemon-start-service"; @@ -393,3 +394,34 @@ describe("resolveHostIndexRoute", () => { ).toEqual("/open-project"); }); }); + +describe("host runtime app lifecycle", () => { + it.each(["inactive", "background"] as const)( + "starts with reconnect disabled when mounted %s", + (currentState) => { + const visibility: boolean[] = []; + let listener: ((state: "active" | "background") => void) | undefined; + let subscribed = true; + const dispose = bindHostRuntimeAppState( + { setAppVisible: (visible) => visibility.push(visible) }, + { + currentState, + addEventListener: (_event, handler) => { + listener = handler; + return { + remove: () => { + subscribed = false; + }, + }; + }, + }, + ); + expect(visibility).toEqual([false]); + listener?.("active"); + listener?.("background"); + expect(visibility).toEqual([false, true, false]); + dispose(); + expect(subscribed).toBe(false); + }, + ); +}); diff --git a/packages/app/src/navigation/host-runtime-bootstrap.ts b/packages/app/src/navigation/host-runtime-bootstrap.ts index 6fbceb8a21..8ee1f7bfb2 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.ts @@ -1,3 +1,4 @@ +import type { AppState } from "react-native"; import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import type { DaemonStartCondition, @@ -250,3 +251,14 @@ export function resolveStartupRoute(input: ResolveStartupRouteInput): StartupRou return resolveReadyIndexStartupRoute(input); } + +export function bindHostRuntimeAppState( + store: { setAppVisible: (visible: boolean) => void }, + appState: Pick, +): () => void { + const subscription = appState.addEventListener("change", (state) => { + store.setAppVisible(state === "active"); + }); + store.setAppVisible(appState.currentState === "active"); + return () => subscription.remove(); +} diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 254271aac3..c2129c654c 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } 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"; @@ -310,11 +311,7 @@ function storeFetchedAgentDetail(input: { const store = useSessionStore.getState(); if (shouldStoreFetchedAgentInActiveDirectory(hydrated)) { - store.setAgents(input.serverId, (previous) => { - const next = new Map(previous); - next.set(hydrated.id, hydrated); - return next; - }); + getHostRuntimeStore().acceptAgentSnapshot(input.serverId, hydrated); } else { store.setAgentDetails(input.serverId, (previous) => { const next = new Map(previous); @@ -414,11 +411,7 @@ function DraftPanel() { (agentSnapshot: Parameters[0]) => { const normalized = normalizeAgentSnapshot(agentSnapshot, serverId); const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized }); - useSessionStore.getState().setAgents(serverId, (prev) => { - const next = new Map(prev); - next.set(agentSnapshot.id, agent); - return next; - }); + getHostRuntimeStore().acceptAgentSnapshot(serverId, agent); retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id }); }, [retargetCurrentTab, serverId], diff --git a/packages/app/src/plugins/client-state/snapshots.test.ts b/packages/app/src/plugins/client-state/snapshots.test.ts index 26ac4069dc..cbbbaaed4b 100644 --- a/packages/app/src/plugins/client-state/snapshots.test.ts +++ b/packages/app/src/plugins/client-state/snapshots.test.ts @@ -25,7 +25,7 @@ const agent: Agent = { id: "agent-1", provider: "codex", status: "running", - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt: new Date("2026-08-16T10:01:00.000Z"), updatedAt: new Date("2026-08-16T10:02:00.000Z"), lastUserMessageAt: null, diff --git a/packages/app/src/runtime/directory-sync/agent-replica.test.ts b/packages/app/src/runtime/directory-sync/agent-replica.test.ts index c791949827..d7deff40a7 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { useSessionStore } from "@/stores/session-store"; +import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; import { AgentDirectoryReplica } from "./agent-replica"; function payload(title: string): AgentSnapshotPayload { @@ -51,11 +53,38 @@ function entry(agent: AgentSnapshotPayload): FetchAgentsEntry { } describe("AgentDirectoryReplica", () => { + it("does not let a late cache read replace newer live turn state", () => { + const serverId = "agent-replica-late-cache"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); + replica.commitSnapshot([entry(payload("live"))], []); + replica.applyTurnLiveness("agent", { + type: "stream_open", + turn: { turnId: "turn-live", startedAt: null }, + }); + + replica.commitCached(new Map([["agent", normalizeAgentSnapshot(payload("cached"), serverId)]])); + + expect(selectAgentTurnPresentation(store.getSession(serverId), "agent").turnId).toBe( + "turn-live", + ); + store.clearSession(serverId); + }); + it("accepts the requested agent from the authoritative timeline after a cache miss", () => { const serverId = "agent-replica-cache-miss"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - const replica = new AgentDirectoryReplica(serverId, () => undefined); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); expect(replica.submitTimelineAgent(replica.captureTimeline("agent"), payload("network"))).toBe( true, @@ -66,11 +95,174 @@ describe("AgentDirectoryReplica", () => { store.clearSession(serverId); }); + it("persists stream-only turn transitions through the agent owner", () => { + const serverId = "agent-replica-stream-turn"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const commits: DirectoryReplicaMutation[][] = []; + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + (mutations) => commits.push([...mutations]), + ); + replica.commitSnapshot([entry(payload("agent"))], []); + commits.length = 0; + + replica.applyTurnLiveness("agent", { + type: "stream_open", + turn: { turnId: "turn-1", startedAt: new Date("2026-08-31T12:00:00.000Z") }, + }); + + expect(commits).toEqual([ + [ + expect.objectContaining({ + kind: "agent", + type: "upsert", + id: "agent", + value: expect.objectContaining({ + status: "idle", + turn: { + phase: "open", + cancellationRequestId: null, + turnId: "turn-1", + startedAt: new Date("2026-08-31T12:00:00.000Z"), + }, + }), + }), + ], + ]); + expect(selectAgentTurnPresentation(store.getSession(serverId), "agent").isActive).toBe(true); + store.clearSession(serverId); + }); + + it("notifies the queue owner exactly once when a stream turn closes", () => { + const serverId = "agent-replica-stream-stop"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const stopped: string[] = []; + const replica = new AgentDirectoryReplica( + serverId, + (agentId) => stopped.push(agentId), + () => undefined, + ); + replica.commitSnapshot([entry(payload("agent"))], []); + replica.applyTurnLiveness("agent", { + type: "stream_open", + turn: { turnId: "turn-1", startedAt: null }, + }); + + replica.applyTurnLiveness("agent", { type: "stream_close", turnId: "turn-1" }); + replica.applyTurnLiveness("agent", { type: "stream_close", turnId: "turn-1" }); + + expect(stopped).toEqual(["agent"]); + store.clearSession(serverId); + }); + + it("preserves cancellation state when the same active turn is snapshotted", () => { + const serverId = "agent-replica-cancellation-snapshot"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); + replica.commitSnapshot( + [ + entry({ + ...payload("running"), + activeTurn: { turnId: "turn-1", startedAt: null }, + }), + ], + [], + ); + replica.applyTurnLiveness("agent", { type: "cancellation_started", requestId: 7 }); + + replica.applyDelta({ + kind: "upsert", + agent: { + ...payload("same running turn"), + activeTurn: { turnId: "turn-1", startedAt: null }, + }, + project: entry(payload("project")).project, + }); + + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.turn).toMatchObject({ + phase: "open", + turnId: "turn-1", + cancellationRequestId: 7, + }); + + replica.commitSnapshot( + [ + entry({ + ...payload("same turn from refresh"), + activeTurn: { turnId: "turn-1", startedAt: null }, + }), + ], + [], + ); + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.turn).toMatchObject({ + phase: "open", + turnId: "turn-1", + cancellationRequestId: 7, + }); + store.clearSession(serverId); + }); + + it("uses turn liveness for stopped transitions even when protocol status disagrees", () => { + const serverId = "agent-replica-mismatched-status"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const stopped: string[] = []; + const replica = new AgentDirectoryReplica( + serverId, + (agentId) => stopped.push(agentId), + () => undefined, + ); + replica.commitSnapshot( + [ + entry({ + ...payload("open despite idle"), + activeTurn: { turnId: "turn-1", startedAt: null }, + }), + ], + [], + ); + + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")).toMatchObject({ + status: "idle", + turn: { phase: "open", turnId: "turn-1" }, + }); + + replica.applyDelta({ + kind: "upsert", + agent: { ...payload("idle turn despite running"), status: "running", activeTurn: null }, + project: entry(payload("project")).project, + }); + replica.applyDelta({ + kind: "upsert", + agent: { ...payload("status-only update"), status: "idle", activeTurn: null }, + project: entry(payload("project")).project, + }); + + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")).toMatchObject({ + status: "idle", + turn: { phase: "idle" }, + }); + expect(stopped).toEqual(["agent"]); + store.clearSession(serverId); + }); + it("preserves an unchanged running agent's turn identity during catch-up", () => { const serverId = "agent-replica-catch-up"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - const replica = new AgentDirectoryReplica(serverId, () => undefined); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); replica.commitSnapshot( [ entry({ @@ -84,9 +276,11 @@ describe("AgentDirectoryReplica", () => { replica.commitChanges([], [], []); - expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.activeTurn).toEqual({ + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.turn).toEqual({ + phase: "open", turnId: "turn-1", startedAt: new Date("2026-07-17T00:01:00.000Z"), + cancellationRequestId: null, }); store.clearSession(serverId); }); @@ -95,7 +289,11 @@ describe("AgentDirectoryReplica", () => { const serverId = "agent-replica"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - const replica = new AgentDirectoryReplica(serverId, () => undefined); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); replica.commitSnapshot([entry(payload("directory"))], []); const directoryPlacement = useSessionStore .getState() diff --git a/packages/app/src/runtime/directory-sync/agent-replica.ts b/packages/app/src/runtime/directory-sync/agent-replica.ts index c9ff7424a4..1bdf3ea3de 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.ts @@ -2,18 +2,14 @@ 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 { useSessionStore, type Agent } from "@/stores/session-store"; +import type { Agent } from "@/stores/session-store"; import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots"; -import { - applyAgentDirectoryDelta, - type AgentDirectoryDelta, - removeAgentDirectoryReplica, - replaceAgentPendingPermissions, - replaceFetchedAgentDirectory, - upsertAgentReplica, -} from "@/utils/agent-directory-sync"; +import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync"; import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; +import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; +import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; +import { AgentStoreProjection } from "./internal/agent-store"; function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry | null { return agent.projectPlacement @@ -30,28 +26,35 @@ export class AgentDirectoryReplica { private readonly lifecycleVersions = new Map(); private readonly members = new Set(); private readonly pendingCacheReads = new Set(); + private readonly storeProjection: AgentStoreProjection; constructor( private readonly serverId: string, private readonly onStoppedRunning: (agentId: string) => void, - ) {} + private readonly persist: (mutations: readonly DirectoryReplicaMutation[]) => void, + ) { + this.storeProjection = new AgentStoreProjection(serverId); + } captureTimeline(agentId: string): AgentLifecycleToken { return { agentId, version: this.lifecycleVersions.get(agentId) ?? 0 }; } + snapshot(): Map { + return this.storeProjection.snapshot(); + } + captureCache(agentId: string): AgentLifecycleToken { this.pendingCacheReads.add(agentId); return this.captureTimeline(agentId); } commitCached(agents: Map): void { + const merged = this.storeProjection.commitCached(agents); this.members.clear(); - for (const [agentId, agent] of agents) { + for (const agentId of merged.keys()) { this.members.add(agentId); - useSessionStore.getState().setAgentLastActivity(agentId, agent.lastActivityAt); } - useSessionStore.getState().setAgents(this.serverId, agents); } commitCachedAgent(token: AgentLifecycleToken, agent: Agent): boolean { @@ -59,13 +62,8 @@ export class AgentDirectoryReplica { if (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) return false; if (this.members.has(agent.id)) return false; this.members.add(agent.id); - useSessionStore.getState().setAgents(this.serverId, (current) => { - if (current.has(agent.id)) return current; - const next = new Map(current); - next.set(agent.id, agent); - return next; - }); - useSessionStore.getState().setAgentLastActivity(agent.id, agent.lastActivityAt); + this.storeProjection.accept(agent); + this.storeProjection.publishActivity(agent); return true; } @@ -73,7 +71,7 @@ export class AgentDirectoryReplica { if (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) { return false; } - const existing = useSessionStore.getState().sessions[this.serverId]?.agents.get(token.agentId); + const existing = this.storeProjection.get(token.agentId); const timelineAgent = applyLegacyDaemonWorkspaceOwnership({ serverId: this.serverId, agent: normalizeAgentSnapshot(payload, this.serverId), @@ -82,19 +80,20 @@ export class AgentDirectoryReplica { ...timelineAgent, projectPlacement: timelineAgent.projectPlacement ?? existing?.projectPlacement, }; - const accepted = upsertAgentReplica(this.serverId, normalized); + const accepted = this.storeProjection.accept(normalized); this.members.add(accepted.id); - replaceAgentPendingPermissions(this.serverId, accepted); - useSessionStore.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt); + this.storeProjection.replacePendingPermissions(accepted); + this.storeProjection.publishActivity(accepted); if (accepted.archivedAt) { clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId: accepted.id }); } + this.persist([this.agentUpsert(accepted)]); return true; } applyDelta(delta: AgentDirectoryDelta): void { const before = this.members.has(delta.kind === "remove" ? delta.agentId : delta.agent.id); - const result = applyAgentDirectoryDelta({ serverId: this.serverId, delta }); + const result = this.storeProjection.applyDelta(delta); if (delta.kind === "remove") { this.members.delete(delta.agentId); this.advance(delta.agentId); @@ -103,15 +102,28 @@ export class AgentDirectoryReplica { if (!before) this.advance(delta.agent.id); } if (result.stoppedRunning) this.onStoppedRunning(result.agentId); + this.persist( + result.agent + ? [this.agentUpsert(result.agent)] + : [{ kind: "agent", type: "delete", id: result.agentId }], + ); + } + + accept(agent: Agent): Agent { + const accepted = this.storeProjection.accept(agent); + this.members.add(accepted.id); + this.persist([this.agentUpsert(accepted)]); + return accepted; } commitSnapshot( entries: FetchAgentsEntry[], deltas: readonly AgentDirectoryDelta[], + persist = true, ): Map { - const previous = useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map(); - const reconciled = reconcileAgentDirectory({ previous, snapshot: entries, deltas }); - const nextIds = new Set(reconciled.entries.map((entry) => entry.agent.id)); + const previous = this.storeProjection.snapshot(); + const reconciled = reconcileAgentDirectory({ snapshot: entries, deltas }); + const nextIds = new Set(reconciled.map((entry) => entry.agent.id)); for (const agentId of this.pendingCacheReads) { if (!nextIds.has(agentId)) this.advance(agentId); } @@ -122,16 +134,25 @@ export class AgentDirectoryReplica { if (!this.members.has(agentId)) this.advance(agentId); } for (const agentId of previous.keys()) { - if (!nextIds.has(agentId)) removeAgentDirectoryReplica(this.serverId, agentId); + if (!nextIds.has(agentId)) this.storeProjection.remove(agentId); } this.members.clear(); this.pendingCacheReads.clear(); for (const agentId of nextIds) this.members.add(agentId); - const { agents } = replaceFetchedAgentDirectory({ - serverId: this.serverId, - entries: reconciled.entries, - }); - for (const agentId of reconciled.stoppedRunningAgentIds) this.onStoppedRunning(agentId); + const agents = this.storeProjection.replaceFetched(reconciled); + for (const [agentId, previousAgent] of previous) { + if (previousAgent.turn.phase === "open" && agents.get(agentId)?.turn.phase === "idle") { + this.onStoppedRunning(agentId); + } + } + if (persist) { + this.persist([ + ...Array.from(previous.keys()) + .filter((agentId) => !agents.has(agentId)) + .map((id): DirectoryReplicaMutation => ({ kind: "agent", type: "delete", id })), + ...Array.from(agents.values(), (value) => this.agentUpsert(value)), + ]); + } return agents; } @@ -140,7 +161,7 @@ export class AgentDirectoryReplica { removals: readonly { id: string }[], deltas: readonly AgentDirectoryDelta[], ): Map { - const previous = useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map(); + const previous = this.storeProjection.snapshot(); const merged = new Map(); for (const agent of previous.values()) { const entry = projectAgentDirectoryEntry(agent); @@ -151,25 +172,57 @@ export class AgentDirectoryReplica { kind: "remove", agentId: id, })); - return this.commitSnapshot(Array.from(merged.values()), [...removalsAsDeltas, ...deltas]); + const agents = this.commitSnapshot( + Array.from(merged.values()), + [...removalsAsDeltas, ...deltas], + false, + ); + const touchedIds = new Set([ + ...entries.map((entry) => entry.agent.id), + ...removals.map(({ id }) => id), + ...deltas.map((delta) => (delta.kind === "remove" ? delta.agentId : delta.agent.id)), + ]); + this.persist( + Array.from(touchedIds, (id): DirectoryReplicaMutation => { + const value = agents.get(id); + return value ? this.agentUpsert(value) : { kind: "agent", type: "delete", id }; + }), + ); + return agents; } archive(agentId: string, archivedAt: string): void { this.advance(agentId); - useSessionStore.getState().setAgents(this.serverId, (current) => { - const agent = current.get(agentId); - if (!agent) return current; - const next = new Map(current); - next.set(agentId, { ...agent, archivedAt: new Date(archivedAt) }); - return next; - }); + const archived = this.storeProjection.archive(agentId, archivedAt); + if (archived) this.persist([this.agentUpsert(archived)]); clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId }); } remove(agentId: string): void { this.members.delete(agentId); this.advance(agentId); - removeAgentDirectoryReplica(this.serverId, agentId); + this.storeProjection.remove(agentId); + this.persist([{ kind: "agent", type: "delete", id: agentId }]); + } + + applyTurnLiveness( + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], + ): void { + const wasRunning = this.storeProjection.get(agentId)?.turn.phase === "open"; + const accepted = this.storeProjection.applyTurn(agentId, transition); + if (!accepted) return; + this.persist([this.agentUpsert(accepted)]); + if (wasRunning && accepted.turn.phase === "idle") this.onStoppedRunning(agentId); + } + + private agentUpsert(agent: Agent): DirectoryReplicaMutation { + return { + kind: "agent", + type: "upsert", + id: agent.id, + value: agent, + }; } private advance(agentId: string): void { diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 2008abb6a1..1593c4752f 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -8,7 +8,7 @@ import { } from "@/stores/session-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors"; -import type { CachedDirectory } from "@/runtime/replica-cache"; +import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; import { DirectoryRefreshSupersededError, DirectorySync, @@ -182,7 +182,7 @@ afterEach(() => { }); describe("DirectorySync session readiness", () => { - it("paints a demanded cached directory while offline", async () => { + it("restores the cached directory before network demand", async () => { const serverId = "offline-cached-directory"; serverIds.add(serverId); const client = new FakeDirectoryClient(); @@ -224,12 +224,12 @@ describe("DirectorySync session readiness", () => { workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]), projects: new Map([[cachedProject.projectId, cachedProject]]), }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); - await directory.refreshAll(); + await directory.restoreCachedDirectory(); expect( useSessionStore.getState().sessions[serverId]?.workspaces.get(cachedWorkspace.id), @@ -280,7 +280,7 @@ describe("DirectorySync session readiness", () => { workspaces: new Map(), projects: new Map(), }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); @@ -301,7 +301,7 @@ describe("DirectorySync session readiness", () => { const serverId = "script-status-owner"; serverIds.add(serverId); const client = new FakeDirectoryClient(); - const commits: CachedDirectory[] = []; + const commits: DirectoryReplicaMutation[][] = []; const directory = new DirectorySync( serverId, { @@ -318,7 +318,7 @@ describe("DirectorySync session readiness", () => { workspaces: new Map(), projects: new Map(), }), - commitDirectory: (_serverId, value) => commits.push(value), + commitDirectoryMutations: (_serverId, mutations) => commits.push([...mutations]), }, ); directory.connectionChanged({ @@ -344,7 +344,8 @@ describe("DirectorySync session readiness", () => { }); const store = useSessionStore.getState(); store.initializeSession(serverId, client as unknown as DaemonClient, 1); - store.setWorkspaces(serverId, new Map([[workspace.id, workspace]])); + directory.acceptWorkspaces([workspace]); + commits.length = 0; client.emit({ type: "script_status_update", @@ -370,7 +371,17 @@ describe("DirectorySync session readiness", () => { useSessionStore.getState().sessions[serverId]?.workspaces.get(workspace.id)?.scripts[0] ?.lifecycle, ).toBe("running"); - expect(commits.at(-1)?.workspaces.get(workspace.id)?.scripts[0]?.lifecycle).toBe("running"); + const persisted = commits + .flat() + .find( + ( + mutation, + ): mutation is Extract => + mutation.kind === "workspace" && + mutation.type === "upsert" && + mutation.id === workspace.id, + ); + expect(persisted?.value.scripts[0]?.lifecycle).toBe("running"); directory.dispose(); }); @@ -476,7 +487,7 @@ describe("DirectorySync session readiness", () => { projects: new Map([[cachedProject.projectId, cachedProject]]), }; }, - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -556,7 +567,7 @@ describe("DirectorySync session readiness", () => { projects: new Map([[cachedProject.projectId, cachedProject]]), checkpoint: { workspaces: { generation: "g", afterSeq: 7 } }, }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -617,7 +628,7 @@ describe("DirectorySync session readiness", () => { readAgent: async () => undefined, readWorkspace: async () => undefined, readDirectory: () => cacheRead, - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -706,7 +717,7 @@ describe("DirectorySync session readiness", () => { workspaces: new Map(), projects: new Map(), }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -754,7 +765,7 @@ describe("DirectorySync session readiness", () => { workspaces: new Map(), projects: new Map(), }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -807,7 +818,7 @@ describe("DirectorySync session readiness", () => { projects: new Map(), checkpoint: { agents: { generation: "generation", afterSeq: 12 } }, }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -891,7 +902,10 @@ describe("DirectorySync session readiness", () => { const serverId = "project-list-sequence"; serverIds.add(serverId); const client = new FakeDirectoryClient(); - const writes: unknown[] = []; + const writes: Array<{ + mutations: readonly DirectoryReplicaMutation[]; + checkpoint: unknown; + }> = []; const cachedProjects = [ normalizeProjectDescriptor({ projectId: "project-1", @@ -905,6 +919,12 @@ describe("DirectorySync session readiness", () => { projectRootPath: "/repo/two", projectKind: "git", }), + normalizeProjectDescriptor({ + projectId: "untouched-project", + projectDisplayName: "Untouched", + projectRootPath: "/repo/untouched", + projectKind: "git", + }), ]; const directory = new DirectorySync( serverId, @@ -923,7 +943,8 @@ describe("DirectorySync session readiness", () => { projects: new Map(cachedProjects.map((project) => [project.projectId, project])), checkpoint: { projects: { generation: "generation", afterSeq: 4 } }, }), - commitDirectory: (_serverId, value) => writes.push(value.checkpoint), + commitDirectoryMutations: (_serverId, mutations, checkpoint) => + writes.push({ mutations: [...mutations], checkpoint }), }, ); directory.connectionChanged({ @@ -964,9 +985,17 @@ describe("DirectorySync session readiness", () => { sync: { generation: "generation", afterSeq: 4 }, }); const projects = useSessionStore.getState().sessions[serverId]?.projects; - expect(Array.from(projects?.keys() ?? [])).toEqual(["project-1"]); + expect(Array.from(projects?.keys() ?? [])).toEqual(["project-1", "untouched-project"]); expect(projects?.get("project-1")?.projectDisplayName).toBe("New name"); - expect(writes).toContainEqual({ projects: { generation: "generation", afterSeq: 6 } }); + expect(writes.map(({ checkpoint }) => checkpoint)).toContainEqual({ + projects: { generation: "generation", afterSeq: 6 }, + }); + const writtenProjectIds = writes + .flatMap(({ mutations }) => mutations) + .filter((mutation) => mutation.kind === "project") + .map((mutation) => mutation.id); + expect(writtenProjectIds).toEqual(["project-1", "project-2"]); + expect(writtenProjectIds).not.toContain("untouched-project"); directory.dispose(); }); diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index 90648ffd38..abcc98d5a7 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -36,7 +36,9 @@ import type { CachedWorkspace, DirectoryCheckpoint, DirectoryCursor, + DirectoryReplicaMutation, } from "@/runtime/replica-cache"; +import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; const PAGE_LIMIT = 200; const AGENT_SORT: NonNullable = [ @@ -69,6 +71,36 @@ interface AgentPageInfo { afterCursor?: string | null; } +function applyWorkspaceSnapshotPage( + snapshot: WorkspaceDirectorySnapshot, + payload: Awaited>, + firstPage: boolean, +): void { + if (firstPage && payload.sync?.mode !== "changes") snapshot.workspaces.clear(); + if (firstPage) (snapshot.syncModes ??= {}).workspaces = payload.sync?.mode ?? "snapshot"; + for (const entry of payload.entries) { + const workspace = normalizeWorkspaceDescriptor(entry); + snapshot.workspaces.set(workspace.id, workspace); + (snapshot.touchedWorkspaceIds ??= new Set()).add(workspace.id); + (snapshot.touchedProjectIds ??= new Set()).add(workspace.projectId); + } + for (const removal of payload.sync?.removals ?? []) { + snapshot.workspaces.delete(removal.id); + (snapshot.touchedWorkspaceIds ??= new Set()).add(removal.id); + } + if (payload.sync) { + (snapshot.syncCursors ??= {}).workspaces = { + generation: payload.sync.generation, + afterSeq: payload.sync.headSeq, + }; + } + for (const entry of payload.emptyProjects ?? []) { + const project = normalizeProjectDescriptor(entry); + snapshot.projects.set(project.projectId, project); + (snapshot.touchedProjectIds ??= new Set()).add(project.projectId); + } +} + export interface DirectoryConnection { client: DaemonClient | null; status: "online" | "offline"; @@ -79,15 +111,12 @@ export interface DirectoryCheckpointStorage { readAgent(serverId: string, agentId: string): Promise; readWorkspace(serverId: string, workspaceId: string): Promise; readDirectory(serverId: string): Promise; - commitDirectory( + commitDirectoryMutations( serverId: string, - directory: { - agents: Map; - workspaces: Map; - projects: Map; - checkpoint?: DirectoryCheckpoint; - }, + mutations: readonly DirectoryReplicaMutation[], + checkpoint?: DirectoryCheckpoint, ): void; + replaceDirectoryBaseline?(serverId: string, directory: CachedDirectory): void; } export interface RefreshAgentDirectoryInput { @@ -139,7 +168,9 @@ export class DirectorySync { }, private readonly checkpoints?: DirectoryCheckpointStorage, ) { - this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning); + const persist = (mutations: readonly DirectoryReplicaMutation[]) => + this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); + this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning, persist); this.workspaces = new WorkspaceDirectoryReplica(serverId); } @@ -170,7 +201,7 @@ export class DirectorySync { if (!recorded) { this.agents.applyDelta(message.payload); this.noteLiveCursor("agents", message.payload); - this.persistDirectory(); + this.persistCheckpoint(); } }), client.on("workspace_update", (message) => { @@ -179,9 +210,9 @@ export class DirectorySync { this.workspaceRevision += 1; const recorded = this.workspaceTransactions.record(source, message.payload); if (!recorded) { - this.workspaces.applyDelta(message.payload); + this.applyWorkspaceDelta(message.payload); this.noteLiveCursor("workspaces", message.payload); - this.persistDirectory(); + this.persistCheckpoint(); } }), client.on("project.update", (message) => { @@ -190,9 +221,9 @@ export class DirectorySync { this.workspaceRevision += 1; const recorded = this.workspaceTransactions.record(source, message.payload); if (!recorded) { - this.workspaces.applyDelta(message.payload); + this.applyWorkspaceDelta(message.payload); this.noteLiveCursor("projects", message.payload); - this.persistDirectory(); + this.persistCheckpoint(); } }), client.on("script_status_update", (message) => { @@ -205,22 +236,22 @@ export class DirectorySync { }; const recorded = this.workspaceTransactions.record(source, delta); if (!recorded) { - this.workspaces.applyDelta(delta); - this.persistDirectory(); + this.applyWorkspaceDelta(delta); + this.persistCheckpoint(); } }), client.on("agent_deleted", (message) => { if (message.type === "agent_deleted" && this.isCurrent(client, source)) { this.revision += 1; this.agents.remove(message.payload.agentId); - this.persistDirectory(); + this.persistCheckpoint(); } }), client.on("agent_archived", (message) => { if (message.type === "agent_archived" && this.isCurrent(client, source)) { this.revision += 1; this.agents.archive(message.payload.agentId, message.payload.archivedAt); - this.persistDirectory(); + this.persistCheckpoint(); } }), ]; @@ -352,24 +383,9 @@ export class DirectorySync { if (!checkpoints) return Promise.resolve(); this.cacheLoad ??= (async () => { const revision = this.revision; - const initial = useSessionStore.getState().sessions[this.serverId]; - if (!initial) return; - const initialAgents = initial.agents; - const initialWorkspaces = initial.workspaces; - const initialProjects = initial.projects; - const pristine = - initialAgents.size === 0 && initialWorkspaces.size === 0 && initialProjects.size === 0; const cached = await checkpoints.readDirectory(this.serverId); - if (this.cacheAccepted || !pristine || this.revision !== revision) return; - const session = useSessionStore.getState().sessions[this.serverId]; - if ( - !session || - session.agents !== initialAgents || - session.workspaces !== initialWorkspaces || - session.projects !== initialProjects - ) { - return; - } + if (this.cacheAccepted || this.revision !== revision) return; + if (!useSessionStore.getState().sessions[this.serverId]) return; this.agents.commitCached(cached.agents); this.workspaces.commitCached(cached); this.cursors = cached.checkpoint ?? {}; @@ -378,6 +394,21 @@ export class DirectorySync { return this.cacheLoad; } + restoreCachedDirectory(): Promise { + return this.loadCachedDirectory(); + } + + applyAgentTurnLiveness( + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], + ): void { + this.agents.applyTurnLiveness(agentId, transition); + } + + acceptAgent(agent: Agent): Agent { + return this.agents.accept(agent); + } + async fetchTimeline( agentId: string, request: Parameters[1], @@ -387,7 +418,7 @@ export class DirectorySync { const page = await fetchAgentTimelineOnce(client, agentId, request); if (page.agent && this.agents.submitTimelineAgent(token, page.agent)) { this.revision += 1; - this.persistDirectory(); + this.persistCheckpoint(); } return page; } @@ -452,14 +483,25 @@ export class DirectorySync { throw new DirectoryRefreshSupersededError("agent completion was superseded"); } if (completion.snapshot.legacy) { - const store = useSessionStore.getState(); const workspaces = buildLegacyWorkspaces(completion.snapshot.entries); - store.setWorkspaces(this.serverId, workspaces); - store.setProjects( - this.serverId, - Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace), + const previous = this.readWorkspaceState(); + this.workspaces.commitSnapshot( + { + workspaces, + projects: new Map( + Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace).map( + (project) => [project.projectId, project], + ), + ), + }, + [], + ); + const next = this.workspaces.snapshot(); + this.persistWorkspaceChanges( + previous, + new Set([...previous.workspaces.keys(), ...next.workspaces.keys()]), + new Set([...previous.projects.keys(), ...next.projects.keys()]), ); - store.setHasHydratedWorkspaces(this.serverId, true); } const deltas = completion.snapshot.legacy ? completion.deltas.map((delta) => @@ -471,7 +513,7 @@ export class DirectorySync { this.revision += 1; const agents = this.commitAgentSnapshot(completion.snapshot, deltas); this.persistAgentCursors(completion.snapshot, completion.deltas); - this.persistDirectory(); + this.persistCheckpoint(); this.callbacks.markAgentReady(); return { agents, subscriptionId: completion.snapshot.subscriptionId }; } catch (error) { @@ -500,13 +542,16 @@ export class DirectorySync { workspaces: new Map(useSessionStore.getState().sessions[this.serverId]?.workspaces), projects: new Map(useSessionStore.getState().sessions[this.serverId]?.projects), syncCursors: {}, + syncModes: {}, + touchedWorkspaceIds: new Set(), + touchedProjectIds: new Set(), })); try { await this.waitForSessionMetadata(client, source); const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo; if (serverInfo?.features?.workspaceMultiplicity !== true) { const deltas = this.workspaceTransactions.fail(transaction); - if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta); + if (deltas) for (const delta of deltas) this.applyWorkspaceDelta(delta); return; } const supportsProjectList = serverInfo.features?.projectList === true; @@ -521,11 +566,15 @@ export class DirectorySync { input?.subscribe === true, supportsDirectorySync, ); - if (!supportsProjectList) this.buildLegacyProjectSnapshot(transaction.snapshot); + if (!supportsProjectList) { + this.buildLegacyProjectSnapshot(transaction.snapshot); + (transaction.snapshot.syncModes ??= {}).projects = + transaction.snapshot.syncModes?.workspaces; + } this.completeWorkspaceRefresh(client, source, transaction); } catch (error) { const deltas = this.workspaceTransactions.fail(transaction); - if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta); + if (deltas) for (const delta of deltas) this.applyWorkspaceDelta(delta); throw error; } } @@ -547,26 +596,7 @@ export class DirectorySync { ...(supportsDirectorySync ? { sync: this.readCursors().workspaces ?? {} } : {}), }); this.assertWorkspaceTransactionCurrent(client, source, transaction); - if (cursor === null && payload.sync?.mode !== "changes") { - transaction.snapshot.workspaces.clear(); - } - for (const entry of payload.entries) { - const workspace = normalizeWorkspaceDescriptor(entry); - transaction.snapshot.workspaces.set(workspace.id, workspace); - } - for (const removal of payload.sync?.removals ?? []) { - transaction.snapshot.workspaces.delete(removal.id); - } - if (payload.sync) { - (transaction.snapshot.syncCursors ??= {}).workspaces = { - generation: payload.sync.generation, - afterSeq: payload.sync.headSeq, - }; - } - for (const entry of payload.emptyProjects ?? []) { - const project = normalizeProjectDescriptor(entry); - transaction.snapshot.projects.set(project.projectId, project); - } + applyWorkspaceSnapshotPage(transaction.snapshot, payload, cursor === null); if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return; cursor = payload.pageInfo.nextCursor; subscribe = false; @@ -587,6 +617,11 @@ export class DirectorySync { this.refreshAgents({ subscribe: {} }), this.refreshWorkspaces({ subscribe: true }), ]); + this.checkpoints?.replaceDirectoryBaseline?.(this.serverId, { + agents: this.agents.snapshot(), + ...this.workspaces.snapshot(), + checkpoint: this.cursors, + }); if (this.getOnlineConnection()) await this.connectWorkspaceLabels(); } @@ -600,6 +635,33 @@ export class DirectorySync { }); } + acceptWorkspaces(workspaces: readonly WorkspaceDescriptor[]): void { + const mutations = this.workspaces.acceptWorkspaces(workspaces); + this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); + } + + archiveAgent(agentId: string, archivedAt: string): void { + this.agents.archive(agentId, archivedAt); + } + + removeAgent(agentId: string): void { + this.agents.remove(agentId); + } + + acceptProject(project: ProjectDescriptor): void { + const mutations = this.workspaces.acceptProject(project); + this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); + } + + removeWorkspace(workspaceId: string): void { + const mutations = this.workspaces.removeWorkspaceSnapshot(workspaceId); + this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); + } + + markWorkspacesHydrated(hydrated: boolean): void { + useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, hydrated); + } + private async fetchAgents( client: DaemonClient, source: DirectorySourceToken, @@ -709,12 +771,15 @@ export class DirectorySync { ); this.assertWorkspaceTransactionCurrent(client, source, transaction); if (payload.sync?.mode !== "changes") transaction.snapshot.projects.clear(); + (transaction.snapshot.syncModes ??= {}).projects = payload.sync?.mode ?? "snapshot"; for (const entry of payload.projects) { const project = normalizeProjectDescriptor(entry); transaction.snapshot.projects.set(project.projectId, project); + (transaction.snapshot.touchedProjectIds ??= new Set()).add(project.projectId); } for (const removal of payload.sync?.removals ?? []) { transaction.snapshot.projects.delete(removal.id); + (transaction.snapshot.touchedProjectIds ??= new Set()).add(removal.id); } if (payload.sync) { (transaction.snapshot.syncCursors ??= {}).projects = { @@ -738,7 +803,22 @@ export class DirectorySync { } this.revision += 1; this.workspaceRevision += 1; - this.workspaces.commitSnapshot(completion.snapshot, completion.deltas); + const previous = this.readWorkspaceState(); + const deltaMutations = this.workspaces.commitSnapshot(completion.snapshot, completion.deltas); + const next = this.workspaces.snapshot(); + const workspaceIds = + completion.snapshot.syncModes?.workspaces === "changes" + ? new Set(completion.snapshot.touchedWorkspaceIds) + : new Set([...previous.workspaces.keys(), ...next.workspaces.keys()]); + const projectIds = + completion.snapshot.syncModes?.projects === "changes" + ? new Set(completion.snapshot.touchedProjectIds) + : new Set([...previous.projects.keys(), ...next.projects.keys()]); + for (const mutation of deltaMutations) { + if (mutation.kind === "workspace") workspaceIds.add(mutation.id); + if (mutation.kind === "project") projectIds.add(mutation.id); + } + this.persistWorkspaceChanges(previous, workspaceIds, projectIds); for (const [entity, cursor] of Object.entries(completion.snapshot.syncCursors ?? {})) { if (cursor) this.writeCursor(entity as "projects" | "workspaces", cursor); } @@ -747,19 +827,41 @@ export class DirectorySync { const entity = "projectId" in delta || "project" in delta ? "projects" : "workspaces"; this.noteLiveCursor(entity, delta); } - this.persistDirectory(); + this.persistCheckpoint(); } - private persistDirectory(): void { - if (!this.checkpoints) return; - const session = useSessionStore.getState().sessions[this.serverId]; - if (!session) return; - this.checkpoints.commitDirectory(this.serverId, { - agents: session.agents, - workspaces: session.workspaces, - projects: session.projects, - checkpoint: this.cursors, - }); + private persistCheckpoint(): void { + this.checkpoints?.commitDirectoryMutations(this.serverId, [], this.cursors); + } + + private readWorkspaceState(): WorkspaceDirectorySnapshot { + return this.workspaces.snapshot(); + } + + private applyWorkspaceDelta(delta: WorkspaceDirectoryDelta): void { + const mutations = this.workspaces.applyDelta(delta); + this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); + } + + private persistWorkspaceChanges( + previous: WorkspaceDirectorySnapshot, + workspaceIds: Set, + projectIds: Set, + ): void { + const next = this.workspaces.snapshot(); + const mutations: DirectoryReplicaMutation[] = []; + for (const id of workspaceIds) { + const value = next.workspaces.get(id); + if (value) mutations.push({ kind: "workspace", type: "upsert", id, value }); + else if (previous.workspaces.has(id)) + mutations.push({ kind: "workspace", type: "delete", id }); + } + for (const id of projectIds) { + const value = next.projects.get(id); + if (value) mutations.push({ kind: "project", type: "upsert", id, value }); + else if (previous.projects.has(id)) mutations.push({ kind: "project", type: "delete", id }); + } + this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); } private recordAgentSync( @@ -858,7 +960,7 @@ export class DirectorySync { private flushAbortedTransactions(): void { for (const delta of this.agentTransactions.abort()) this.agents.applyDelta(delta); - for (const delta of this.workspaceTransactions.abort()) this.workspaces.applyDelta(delta); + for (const delta of this.workspaceTransactions.abort()) this.applyWorkspaceDelta(delta); } private abortPendingSessionWaits(): void { diff --git a/packages/app/src/runtime/directory-sync/internal/agent-store.ts b/packages/app/src/runtime/directory-sync/internal/agent-store.ts new file mode 100644 index 0000000000..0566da5aaf --- /dev/null +++ b/packages/app/src/runtime/directory-sync/internal/agent-store.ts @@ -0,0 +1,216 @@ +import equal from "fast-deep-equal"; +import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; +import { useSessionStore, 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"; +import { resolveProjectPlacement } from "@/utils/project-placement"; +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 { reduceTurnLiveness, type TurnLivenessTransition } from "@/timeline/turn-liveness"; + +function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent { + if (!previous) return incoming; + const activeTurn = + incoming.turn.phase === "open" + ? { turnId: incoming.turn.turnId, startedAt: incoming.turn.startedAt } + : null; + const turn = reduceTurnLiveness(previous.turn, { type: "snapshot", activeTurn }); + return turn === incoming.turn ? incoming : { ...incoming, turn }; +} + +export class AgentStoreProjection { + constructor(private readonly serverId: string) {} + + snapshot(): Map { + return useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map(); + } + + get(agentId: string): Agent | undefined { + const session = useSessionStore.getState().sessions[this.serverId]; + return session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); + } + + commitCached(agents: Map): Map { + 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); + return merged; + } + + publishActivity(agent: Agent): void { + useSessionStore.getState().setAgentLastActivity(agent.id, agent.lastActivityAt); + } + + accept(agent: Agent): Agent { + let accepted = agent; + useSessionStore.getState().setAgents(this.serverId, (current) => { + const previous = current.get(agent.id); + accepted = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent)); + if (accepted === previous) return current; + const next = new Map(current); + next.set(agent.id, accepted); + return next; + }); + return accepted; + } + + applyDelta(delta: AgentDirectoryDelta): { + agentId: string; + stoppedRunning: boolean; + agent?: Agent; + } { + if (delta.kind === "remove") { + this.remove(delta.agentId); + return { agentId: delta.agentId, stoppedRunning: false }; + } + const normalized = normalizeAgentSnapshot(delta.agent, this.serverId); + const session = useSessionStore.getState().sessions[this.serverId]; + const previous = session?.agents.get(normalized.id) ?? session?.agentDetails.get(normalized.id); + const legacyWorkspaceId = + previous?.workspaceId ?? + Array.from(session?.workspaces.values() ?? []).find( + (workspace) => + session?.serverInfo?.features?.workspaceMultiplicity !== true && + workspace.workspaceDirectory === normalized.cwd, + )?.id; + const accepted = this.accept({ + ...normalized, + workspaceId: normalized.workspaceId ?? legacyWorkspaceId, + projectPlacement: + resolveProjectPlacement({ projectPlacement: delta.project, cwd: normalized.cwd }) ?? + previous?.projectPlacement, + }); + if (accepted.archivedAt) { + clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId: accepted.id }); + } + this.replacePendingPermissions(accepted); + useSessionStore.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt); + return { + agentId: accepted.id, + stoppedRunning: previous?.turn.phase === "open" && accepted.turn.phase === "idle", + agent: accepted, + }; + } + + replaceFetched(entries: FetchAgentsEntry[]): Map { + const { agents: fetched, pendingPermissions } = buildAgentDirectoryState({ + serverId: this.serverId, + entries, + }); + const store = useSessionStore.getState(); + const current = store.sessions[this.serverId]?.agents ?? new Map(); + const agents = new Map(); + for (const [agentId, fetchedAgent] of fetched) { + const existing = current.get(agentId); + const merged = mergeSnapshotTurn(existing, fetchedAgent); + agents.set(agentId, existing && equal(existing, merged) ? existing : merged); + if (fetchedAgent.archivedAt) { + clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId }); + } + } + store.setAgents(this.serverId, agents); + store.setAgentDetails(this.serverId, (previous) => { + let next: Map | null = null; + for (const agentId of agents.keys()) { + if (!previous.has(agentId)) continue; + next ??= new Map(previous); + next.delete(agentId); + } + return next ?? previous; + }); + store.setAgentLastActivityBatch( + new Map(Array.from(agents.values(), (agent) => [agent.id, agent.lastActivityAt])), + ); + store.setPendingPermissions(this.serverId, new Map(pendingPermissions)); + store.setHasHydratedAgents(this.serverId, true); + return agents; + } + + replacePendingPermissions(agent: Agent): void { + const pending = new Map(useSessionStore.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); + } + + remove(agentId: string): void { + const store = useSessionStore.getState(); + const removeKey = (current: Map): Map => { + if (!current.has(agentId)) return current; + const next = new Map(current); + next.delete(agentId); + return next; + }; + clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId }); + store.setAgents(this.serverId, removeKey); + store.setAgentDetails(this.serverId, removeKey); + store.setQueuedMessages(this.serverId, removeKey); + store.setAgentTimelineCursor(this.serverId, removeKey); + store.setInitializingAgents(this.serverId, removeKey); + store.setPendingPermissions(this.serverId, (current) => { + const next = new Map(current); + for (const [key, pending] of next) if (pending.agentId === agentId) next.delete(key); + return next.size === current.size ? current : next; + }); + store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false); + store.setAgentStreamTail(this.serverId, removeKey); + store.clearAgentStreamHead(this.serverId, agentId); + useSessionStore.setState((state) => { + if (!state.agentLastActivity.has(agentId)) return state; + const agentLastActivity = new Map(state.agentLastActivity); + agentLastActivity.delete(agentId); + return { ...state, agentLastActivity }; + }); + useDraftStore.getState().clearDraftInput({ + draftKey: buildDraftStoreKey({ serverId: this.serverId, agentId }), + }); + const initKey = getInitKey(this.serverId, agentId); + if (getInitDeferred(initKey)) { + rejectInitDeferred(initKey, new Error("Agent was removed during initialization")); + } + } + + archive(agentId: string, archivedAt: string): Agent | null { + let archived: Agent | null = null; + useSessionStore.getState().setAgents(this.serverId, (current) => { + const agent = current.get(agentId); + if (!agent) return current; + archived = { + ...agent, + turn: { phase: "idle", cancellationRequestId: null }, + archivedAt: new Date(archivedAt), + }; + const next = new Map(current); + next.set(agentId, archived); + return next; + }); + return archived; + } + + applyTurn( + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], + ): Agent | null { + const transitions = Array.isArray(transition) ? transition : [transition]; + let accepted: Agent | null = null; + useSessionStore.getState().setAgents(this.serverId, (current) => { + const agent = current.get(agentId); + if (!agent) return current; + const turn = transitions.reduce(reduceTurnLiveness, agent.turn); + if (turn === agent.turn) return current; + accepted = { ...agent, turn }; + const next = new Map(current); + next.set(agentId, accepted); + return next; + }); + return accepted; + } +} diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.ts b/packages/app/src/runtime/directory-sync/workspace-replica.ts index bc3a36fd56..d229c196a2 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -16,11 +16,11 @@ import { shouldSuppressWorkspaceForLocalArchive, } from "@/contexts/session-workspace-upserts"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; +import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; export type WorkspaceDirectoryDelta = | Extract["payload"] | { kind: "script_status"; update: ScriptStatusUpdateMessage["payload"] }; -type ProjectDirectoryDelta = Extract["payload"]; export interface WorkspaceDirectorySnapshot { workspaces: Map; @@ -28,67 +28,33 @@ export interface WorkspaceDirectorySnapshot { syncCursors?: Partial< Record<"projects" | "workspaces", { generation: string; afterSeq: number }> >; -} - -function patchWorkspaceScripts( - workspaces: Map, - update: ScriptStatusUpdateMessage["payload"], -): Map { - const workspaceKey = resolveWorkspaceMapKeyByIdentity({ - workspaces, - workspaceId: update.workspaceId, - }); - if (!workspaceKey) return workspaces; - const existing = workspaces.get(workspaceKey); - if (!existing || equal(existing.scripts, update.scripts)) return workspaces; - const next = new Map(workspaces); - next.set(workspaceKey, { - ...existing, - scripts: update.scripts.map((script) => ({ ...script })), - }); - return next; -} - -function applyProjectDelta( - snapshot: WorkspaceDirectorySnapshot, - delta: ProjectDirectoryDelta, -): void { - if (delta.kind === "remove") { - snapshot.projects.delete(delta.projectId); - for (const [workspaceId, workspace] of snapshot.workspaces) { - if (workspace.projectId === delta.projectId) snapshot.workspaces.delete(workspaceId); - } - return; - } - - const project = normalizeProjectDescriptor(delta.project); - snapshot.projects.set(project.projectId, project); - for (const [workspaceId, workspace] of snapshot.workspaces) { - if (workspace.projectId !== project.projectId) continue; - snapshot.workspaces.set(workspaceId, { - ...workspace, - projectDisplayName: project.projectDisplayName, - projectCustomName: project.projectCustomName, - projectCustomIconRevision: project.projectCustomIconRevision, - projectRootPath: project.projectRootPath, - projectKind: project.projectKind, - }); - } + syncModes?: Partial>; + touchedWorkspaceIds?: Set; + touchedProjectIds?: Set; } export class WorkspaceDirectoryReplica { + private workspaces = new Map(); + private projects = new Map(); + private workspaceIdsByProject = new Map>(); + constructor(private readonly serverId: string) {} - applyDelta(delta: WorkspaceDirectoryDelta): void { - const state = this.reconcile(this.read(), [delta]); - this.commit(state, delta.kind === "remove" && "id" in delta ? [delta.id] : []); + applyDelta(delta: WorkspaceDirectoryDelta): DirectoryReplicaMutation[] { + if (delta.kind === "script_status") return this.applyScriptStatus(delta.update); + if ("projectId" in delta || "project" in delta) return this.applyProjectDelta(delta); + if (delta.kind === "remove") return this.removeWorkspace(delta); + return this.upsertWorkspace(normalizeWorkspaceDescriptor(delta.workspace)); } commitCached(input: { workspaces: Map; projects: Map; }): void { - this.commit(input, []); + this.replace({ + workspaces: new Map([...input.workspaces, ...this.workspaces]), + projects: new Map([...input.projects, ...this.projects]), + }); useSessionStore.getState().setHasWorkspaceDirectorySnapshot(this.serverId, true); } @@ -97,79 +63,171 @@ export class WorkspaceDirectoryReplica { project: ProjectDescriptor | undefined, ): void { if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) return; - const snapshot = this.read(); - snapshot.workspaces.set(workspace.id, workspace); - if (project) snapshot.projects.set(project.projectId, project); - this.commit(snapshot, []); + if (project) this.setProject(project); + this.setWorkspace(workspace); } commitSnapshot( snapshot: WorkspaceDirectorySnapshot, deltas: readonly WorkspaceDirectoryDelta[], - ): void { - const removedWorkspaceIds = deltas.flatMap((delta) => - delta.kind === "remove" && "id" in delta ? [delta.id] : [], - ); - this.commit(this.reconcile(snapshot, deltas), removedWorkspaceIds); + ): DirectoryReplicaMutation[] { + this.replace(snapshot); + const mutations = deltas.flatMap((delta) => this.applyDelta(delta)); useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, true); + return mutations; } - private read(): WorkspaceDirectorySnapshot { - const session = useSessionStore.getState().sessions[this.serverId]; - return { - workspaces: new Map(session?.workspaces), - projects: new Map(session?.projects), - }; + snapshot(): WorkspaceDirectorySnapshot { + return { workspaces: this.workspaces, projects: this.projects }; } - private reconcile( - snapshot: WorkspaceDirectorySnapshot, - deltas: readonly WorkspaceDirectoryDelta[], - ): WorkspaceDirectorySnapshot { - let workspaces = new Map(snapshot.workspaces); - const projects = new Map(snapshot.projects); - for (const [workspaceId, workspace] of workspaces) { - if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) { - workspaces.delete(workspaceId); + acceptWorkspaces(workspaces: readonly WorkspaceDescriptor[]): DirectoryReplicaMutation[] { + return workspaces.flatMap((workspace) => this.upsertWorkspace(workspace)); + } + + acceptProject(project: ProjectDescriptor): DirectoryReplicaMutation[] { + this.setProject(project); + return [{ kind: "project", type: "upsert", id: project.projectId, value: project }]; + } + + removeWorkspaceSnapshot(workspaceId: string): DirectoryReplicaMutation[] { + this.deleteWorkspace(workspaceId); + return [{ kind: "workspace", type: "delete", id: workspaceId }]; + } + + private replace(snapshot: WorkspaceDirectorySnapshot): void { + const workspaces = new Map(); + for (const [workspaceId, workspace] of snapshot.workspaces) { + if (!shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) { + workspaces.set(workspaceId, workspace); } } - for (const delta of deltas) { - if (delta.kind === "script_status") { - workspaces = patchWorkspaceScripts(workspaces, delta.update); - continue; - } - if ("projectId" in delta || "project" in delta) { - applyProjectDelta({ workspaces, projects }, delta); - continue; - } - if (delta.kind === "remove") { - workspaces.delete(delta.id); - if (delta.emptyProject) { - const project = normalizeProjectDescriptor(delta.emptyProject); - projects.set(project.projectId, project); - } - if (delta.removedProjectId) { - projects.delete(delta.removedProjectId); - } - continue; - } - const workspace = normalizeWorkspaceDescriptor(delta.workspace); - if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) { - workspaces.delete(workspace.id); - } else { - workspaces.set(workspace.id, workspace); - } - } - return { workspaces, projects }; - } - - private commit(snapshot: WorkspaceDirectorySnapshot, removedWorkspaceIds: string[]): void { + this.workspaces = workspaces; + this.projects = new Map(snapshot.projects); + this.rebuildProjectIndex(); const store = useSessionStore.getState(); - store.setWorkspaces(this.serverId, snapshot.workspaces); - store.setProjects(this.serverId, snapshot.projects.values()); - for (const workspaceId of removedWorkspaceIds) { - clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId }); - useWorkspaceSetupStore.getState().removeWorkspace({ serverId: this.serverId, workspaceId }); + store.setWorkspaces(this.serverId, this.workspaces); + store.setProjects(this.serverId, this.projects.values()); + } + + private applyScriptStatus( + update: ScriptStatusUpdateMessage["payload"], + ): DirectoryReplicaMutation[] { + const workspaceId = resolveWorkspaceMapKeyByIdentity({ + workspaces: this.workspaces, + workspaceId: update.workspaceId, + }); + const workspace = workspaceId ? this.workspaces.get(workspaceId) : undefined; + if (!workspace || equal(workspace.scripts, update.scripts)) return []; + const next = { ...workspace, scripts: update.scripts.map((script) => ({ ...script })) }; + this.setWorkspace(next); + return [{ kind: "workspace", type: "upsert", id: next.id, value: next }]; + } + + private applyProjectDelta( + delta: Extract["payload"], + ): DirectoryReplicaMutation[] { + if (delta.kind === "remove") { + const mutations: DirectoryReplicaMutation[] = [ + { kind: "project", type: "delete", id: delta.projectId }, + ]; + this.projects.delete(delta.projectId); + useSessionStore.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 }); + } + return mutations; + } + + const project = normalizeProjectDescriptor(delta.project); + this.setProject(project); + const mutations: DirectoryReplicaMutation[] = [ + { kind: "project", type: "upsert", id: project.projectId, value: project }, + ]; + for (const workspaceId of this.workspaceIdsByProject.get(project.projectId) ?? []) { + const workspace = this.workspaces.get(workspaceId); + if (!workspace) continue; + const next = { + ...workspace, + projectDisplayName: project.projectDisplayName, + projectCustomName: project.projectCustomName, + projectCustomIconRevision: project.projectCustomIconRevision, + projectRootPath: project.projectRootPath, + projectKind: project.projectKind, + }; + this.setWorkspace(next); + mutations.push({ kind: "workspace", type: "upsert", id: next.id, value: next }); + } + return mutations; + } + + private removeWorkspace( + delta: Extract["payload"] & { + kind: "remove"; + }, + ): DirectoryReplicaMutation[] { + this.deleteWorkspace(delta.id); + const mutations: DirectoryReplicaMutation[] = [ + { kind: "workspace", type: "delete", id: delta.id }, + ]; + if (delta.emptyProject) { + const project = normalizeProjectDescriptor(delta.emptyProject); + this.setProject(project); + mutations.push({ kind: "project", type: "upsert", id: project.projectId, value: project }); + } + if (delta.removedProjectId) { + this.projects.delete(delta.removedProjectId); + useSessionStore.getState().removeProject(this.serverId, delta.removedProjectId); + mutations.push({ kind: "project", type: "delete", id: delta.removedProjectId }); + } + clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId: delta.id }); + useWorkspaceSetupStore + .getState() + .removeWorkspace({ serverId: this.serverId, workspaceId: delta.id }); + return mutations; + } + + private upsertWorkspace(workspace: WorkspaceDescriptor): DirectoryReplicaMutation[] { + if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) { + if (!this.workspaces.has(workspace.id)) return []; + this.deleteWorkspace(workspace.id); + return [{ kind: "workspace", type: "delete", id: workspace.id }]; + } + this.setWorkspace(workspace); + return [{ kind: "workspace", type: "upsert", id: workspace.id, value: workspace }]; + } + + private setWorkspace(workspace: WorkspaceDescriptor): void { + const previous = this.workspaces.get(workspace.id); + if (previous?.projectId !== workspace.projectId) { + this.workspaceIdsByProject.get(previous?.projectId ?? "")?.delete(workspace.id); + } + this.workspaces.set(workspace.id, workspace); + 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]); + } + + 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); + } + + private setProject(project: ProjectDescriptor): void { + this.projects.set(project.projectId, project); + useSessionStore.getState().upsertProject(this.serverId, project); + } + + private rebuildProjectIndex(): void { + this.workspaceIdsByProject = new Map(); + for (const workspace of this.workspaces.values()) { + const workspaceIds = this.workspaceIdsByProject.get(workspace.projectId) ?? new Set(); + workspaceIds.add(workspace.id); + this.workspaceIdsByProject.set(workspace.projectId, workspaceIds); } } } diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 431b806aae..b77d53f646 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -31,6 +31,8 @@ class FakeDaemonClient { private latencyMeasurementFailure: Error | null = null; private latencyMeasurementsRequested: Array<{ timeoutMs?: number }> = []; public connectCalls = 0; + public ensureConnectedCalls = 0; + public reconnectEnabledChanges: boolean[] = []; public fetchAgentsCalls: FetchAgentsOptions[] = []; public fetchAgentsResponses: Array< Awaited> | ReturnType @@ -105,6 +107,7 @@ class FakeDaemonClient { } ensureConnected(): void { + this.ensureConnectedCalls += 1; if (this.state.status !== "connected") { this.setConnectionState({ status: "connected" }); } @@ -170,7 +173,9 @@ class FakeDaemonClient { return result.rttMs; } - setReconnectEnabled(_enabled: boolean): void {} + setReconnectEnabled(enabled: boolean): void { + this.reconnectEnabledChanges.push(enabled); + } getLastLivenessRttMs(): number | null { return this.heartbeatRttMs; @@ -815,6 +820,52 @@ describe("HostRuntimeController", () => { expect(activeClient.latencyMeasurements()).toEqual([]); }); + it("does not create a probe client for the selected connection while it reconnects", async () => { + useHostRuntimeClock(); + const relay: HostConnection = { + id: "relay:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + daemonPublicKeyB64: "pk_test", + }; + const host = makeHost({ connections: [relay], preferredConnectionId: relay.id }); + const activeClient = new FakeDaemonClient(); + activeClient.setConnectionState({ status: "connected" }); + const probeAttempts: string[] = []; + const controller = new HostRuntimeController({ + host, + deps: { + createClient: () => { + throw new Error("the existing client owns the selected connection"); + }, + connectToDaemon: async ({ connection }) => { + probeAttempts.push(connection.id); + return { + client: makeConnectedProbeClient(10) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }; + }, + getClientId: async () => "cid_test_runtime", + }, + }); + + await controller.start({ + autoProbe: false, + initialConnection: { + connectionId: relay.id, + existingClient: activeClient as unknown as DaemonClient, + }, + }); + activeClient.setConnectionState({ status: "disconnected", reason: "network lost" }); + await vi.advanceTimersByTimeAsync(10_000); + + await controller.runProbeCycleNow(); + + expect(probeAttempts).toEqual([]); + expect(controller.getSnapshot().client).toBe(activeClient as unknown as DaemonClient); + }); + it("rejects probes that resolve to a different server id", async () => { const host = makeHost({ serverId: "srv_old", @@ -1435,6 +1486,71 @@ describe("HostRuntimeController", () => { }); describe("HostRuntimeStore", () => { + it("reconnects every registered host immediately on app resume", async () => { + const relay = (suffix: string): HostConnection => ({ + id: `relay:relay-${suffix}.paseo.sh:443`, + type: "relay", + relayEndpoint: `relay-${suffix}.paseo.sh:443`, + daemonPublicKeyB64: `pk_${suffix}`, + }); + const hostAConnection = relay("a"); + const hostBConnection = relay("b"); + const hostA = makeHost({ + serverId: "srv_a", + connections: [hostAConnection], + preferredConnectionId: hostAConnection.id, + }); + const hostB = makeHost({ + serverId: "srv_b", + connections: [hostBConnection], + preferredConnectionId: hostBConnection.id, + }); + const clientA = new FakeDaemonClient(); + const clientB = new FakeDaemonClient(); + clientA.setConnectionState({ status: "connected" }); + clientB.setConnectionState({ status: "connected" }); + const store = new HostRuntimeStore({ + storage: createMemoryHostRuntimeStorage(), + deps: { + createClient: () => { + throw new Error("initial clients are supplied"); + }, + connectToDaemon: async () => { + throw new Error("single-connection hosts reuse their active clients"); + }, + getClientId: async () => "cid_test_runtime", + }, + }); + + store.syncHosts([hostA, hostB], { + initialConnectionByServerId: new Map([ + [ + hostA.serverId, + { connectionId: hostAConnection.id, existingClient: clientA as unknown as DaemonClient }, + ], + [ + hostB.serverId, + { connectionId: hostBConnection.id, existingClient: clientB as unknown as DaemonClient }, + ], + ]), + }); + await waitForHostOnline(store, hostA.serverId); + await waitForHostOnline(store, hostB.serverId); + store.setAppVisible(false); + expect(clientA.reconnectEnabledChanges.at(-1)).toBe(false); + expect(clientB.reconnectEnabledChanges.at(-1)).toBe(false); + clientA.setConnectionState({ status: "disconnected", reason: "backgrounded" }); + clientB.setConnectionState({ status: "disconnected", reason: "backgrounded" }); + + store.setAppVisible(true); + expect(clientA.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientB.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientA.ensureConnectedCalls).toBe(1); + expect(clientB.ensureConnectedCalls).toBe(1); + + store.syncHosts([]); + }); + it("revokes push notifications before removing a host", async () => { const host = makeHost({ connections: [makeHost().connections[0]!] }); const revocation = createDeferred(); @@ -1483,16 +1599,16 @@ describe("HostRuntimeStore", () => { expect(store.getHosts()).toEqual([]); }); - it("loads the host registry without scanning or installing replica rows", async () => { + it("loads the host registry and restores its cached directory", async () => { const host = makeHost(); const storage = createMemoryHostRuntimeStorage(); - const backingStore = createMemoryReplicaRowStore(); let fullScans = 0; + const backingStore = createMemoryReplicaRowStore(); const replicaRowStore: ReplicaRowStore = { ...backingStore, - readAll: async () => { + read: async (...args) => { fullScans += 1; - return backingStore.readAll(); + return backingStore.read(...args); }, }; await storage.setItem("@paseo:daemon-registry", JSON.stringify([host])); @@ -1516,7 +1632,7 @@ describe("HostRuntimeStore", () => { store.boot(); await registryLoaded; - expect(fullScans).toBe(0); + await vi.waitFor(() => expect(fullScans).toBe(1)); expect(useSessionStore.getState().sessions[host.serverId]).toMatchObject({ client: null, hasHydratedAgents: false, @@ -2006,11 +2122,27 @@ describe("HostRuntimeStore", () => { new Map([ [ "legacy-snapshot", - { ...replicaAgent(snapshotAgent.agent, host.serverId), status: "running" }, + { + ...replicaAgent(snapshotAgent.agent, host.serverId), + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + }, ], [ "legacy-buffered", - { ...replicaAgent(bufferedAgent.agent, host.serverId), status: "running" }, + { + ...replicaAgent(bufferedAgent.agent, host.serverId), + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + }, ], ]), ); @@ -2545,11 +2677,27 @@ describe("HostRuntimeStore", () => { new Map([ [ "snapshot-transition", - { ...replicaAgent(snapshotAgent.agent, host.serverId), status: "running" }, + { + ...replicaAgent(snapshotAgent.agent, host.serverId), + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + }, ], [ "buffered-transition", - { ...replicaAgent(bufferedAgent.agent, host.serverId), status: "running" }, + { + ...replicaAgent(bufferedAgent.agent, host.serverId), + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + }, ], ]), ); @@ -3037,12 +3185,14 @@ describe("HostRuntimeStore", () => { }).agent; const staleAgent: Agent = { ...stale, - activeTurn: stale.activeTurn + turn: stale.activeTurn ? { + phase: "open", turnId: stale.activeTurn.turnId, startedAt: stale.activeTurn.startedAt ? new Date(stale.activeTurn.startedAt) : null, + cancellationRequestId: null, } - : null, + : { phase: "idle", cancellationRequestId: null }, serverId: host.serverId, createdAt: new Date(stale.createdAt), updatedAt: new Date(stale.updatedAt), diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index fc434365dd..20fb842e06 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -45,7 +45,13 @@ import { import { getDesktopHost } from "@/desktop/host"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas"; -import { useSessionStore } from "@/stores/session-store"; +import { + useSessionStore, + type Agent, + type WorkspaceDescriptor, + type ProjectDescriptor, +} from "@/stores/session-store"; +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"; @@ -612,6 +618,7 @@ export class HostRuntimeController { private switchRequestVersion = 0; private probeRequestVersion = 0; private probeCycleInFlight: Promise | null = null; + private reconnectEnabled = true; constructor(input: { host: HostProfile; @@ -721,6 +728,11 @@ export class HostRuntimeController { this.activeClient?.ensureConnected(); } + setReconnectEnabled(enabled: boolean): void { + this.reconnectEnabled = enabled; + this.activeClient?.setReconnectEnabled(enabled); + } + markAgentDirectorySyncLoading(): void { const status = this.snapshot.hasEverLoadedAgentDirectory ? "revalidating" : "initial_loading"; this.updateSnapshot({ @@ -970,10 +982,7 @@ export class HostRuntimeController { let shouldCloseClient = false; try { const activeClient = - this.snapshot.connectionStatus === "online" && - this.snapshot.activeConnectionId === connection.id - ? this.snapshot.client - : null; + this.snapshot.activeConnectionId === connection.id ? this.snapshot.client : null; if (activeClient) { connectedClient = activeClient; @@ -1226,9 +1235,6 @@ export class HostRuntimeController { } const nextGeneration = this.snapshot.clientGeneration + 1; - if (existingClient) { - existingClient.setReconnectEnabled(true); - } const client = existingClient ?? this.deps.createClient({ @@ -1237,6 +1243,7 @@ export class HostRuntimeController { clientId, runtimeGeneration: nextGeneration, }); + client.setReconnectEnabled(this.reconnectEnabled); if (!this.isSwitchStillValid(requestVersion, expectedProbeVersion)) { await client.close().catch(() => undefined); @@ -1394,9 +1401,11 @@ export class HostRuntimeStore { private connectionStatusStartedAtByServer = new Map(); private queuedAgentDrainInFlight = new Set(); private directorySyncByServer = new Map(); + private nextCancellationRequestId = 0; private timelineReplicaByServer = new Map(); private configuredOverrideBootstrapInFlight: Promise | null = null; private bootPromise: Promise | null = null; + private appVisible = true; private storage: HostRuntimeStorage; private replicaCache: ReplicaCache; private readonly revokePushNotifications: typeof revokePushNotifications; @@ -1511,6 +1520,12 @@ export class HostRuntimeStore { projectIconCache.setHosts(profiles.map((profile) => profile.serverId)); await projectIconCache.restore(); this.syncHosts(profiles); + for (const profile of profiles) { + void this.directorySyncByServer + .get(profile.serverId) + ?.restoreCachedDirectory() + .catch(() => undefined); + } } catch (error) { console.error("[HostRuntime] Failed to load host registry from storage", error); } finally { @@ -2064,6 +2079,7 @@ export class HostRuntimeStore { deps: this.deps, onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId), }); + controller.setReconnectEnabled(this.appVisible); this.controllers.set(host.serverId, controller); useSessionStore.getState().initializeSession(host.serverId, null); const directory = new DirectorySync( @@ -2215,6 +2231,56 @@ export class HostRuntimeStore { }); } + applyAgentTurnLiveness( + serverId: string, + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], + ): void { + this.directorySyncByServer.get(serverId)?.applyAgentTurnLiveness(agentId, transition); + } + + 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 { + this.applyAgentTurnLiveness(serverId, agentId, { type: "cancellation_settled", requestId }); + } + + acceptAgentSnapshot(serverId: string, agent: Agent): Agent { + return this.requireDirectory(serverId).acceptAgent(agent); + } + + archiveAgentSnapshot(serverId: string, agentId: string, archivedAt: string): void { + this.requireDirectory(serverId).archiveAgent(agentId, archivedAt); + } + + restoreAgentSnapshot(serverId: string, agentId: string, agent: Agent | undefined): void { + const directory = this.requireDirectory(serverId); + if (agent) directory.acceptAgent(agent); + else directory.removeAgent(agentId); + } + + acceptWorkspaceSnapshots(serverId: string, workspaces: readonly WorkspaceDescriptor[]): void { + this.requireDirectory(serverId).acceptWorkspaces(workspaces); + } + + acceptProjectSnapshot(serverId: string, project: ProjectDescriptor): void { + this.requireDirectory(serverId).acceptProject(project); + } + + removeWorkspaceSnapshot(serverId: string, workspaceId: string): void { + this.requireDirectory(serverId).removeWorkspace(workspaceId); + } + + private requireDirectory(serverId: string): DirectorySync { + const directory = this.directorySyncByServer.get(serverId); + if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); + return directory; + } + getSnapshot(serverId: string): HostRuntimeSnapshot | null { return this.controllers.get(serverId)?.getSnapshot() ?? null; } @@ -2274,6 +2340,19 @@ export class HostRuntimeStore { } } + setAppVisible(visible: boolean): void { + this.appVisible = visible; + for (const controller of this.controllers.values()) { + controller.setReconnectEnabled(visible); + } + if (!visible) { + void this.replicaCache.flush(); + return; + } + + this.ensureConnectedAll(); + } + runProbeCycleNow(serverId?: string): Promise { if (serverId) { return this.controllers.get(serverId)?.runProbeCycleNow() ?? Promise.resolve(); diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 56e4546507..df6c3a0aa0 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -7,7 +7,8 @@ import { } from "@/stores/session-store"; import type { StreamItem } from "@/types/stream"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; -import { ReplicaCache, type DirectoryCheckpoint } from "."; +import { ReplicaCache } from "."; +import type { DirectoryCheckpoint } from "@/runtime/replica-cache"; import type { ReplicaHostRows, ReplicaRow, ReplicaRowChanges, ReplicaRowStore } from "./row-store"; const SERVER_ID = "cached-host"; @@ -200,6 +201,45 @@ function timeline(text = "Cached") { }; } +function commitDirectory( + cache: ReplicaCache, + serverId: string, + value: ReturnType, +): void { + cache.commitDirectoryMutations( + serverId, + [ + ...Array.from(value.agents.values(), (cachedAgent) => ({ + kind: "agent" as const, + type: "upsert" as const, + id: cachedAgent.id, + value: cachedAgent, + })), + ...Array.from(value.workspaces.values(), (workspace) => ({ + kind: "workspace" as const, + type: "upsert" as const, + id: workspace.id, + value: workspace, + })), + ...Array.from(value.projects.values(), (project) => ({ + kind: "project" as const, + type: "upsert" as const, + id: project.projectId, + value: project, + })), + ], + value.checkpoint, + ); +} + +function deleteDirectory(cache: ReplicaCache, serverId: string): void { + cache.commitDirectoryMutations(serverId, [ + { kind: "agent", type: "delete", id: "agent-1" }, + { kind: "workspace", type: "delete", id: "workspace-1" }, + { kind: "project", type: "delete", id: "project-1" }, + ]); +} + describe("ReplicaCache", () => { it("does nothing until an owner explicitly commits data", async () => { const storage = new MemoryStorage(); @@ -214,7 +254,7 @@ describe("ReplicaCache", () => { it("round-trips explicit directory and timeline commits", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); - writer.commitDirectory(SERVER_ID, directory()); + commitDirectory(writer, SERVER_ID, directory()); writer.commitTimeline(SERVER_ID, "agent-1", timeline()); await writer.flush(); @@ -229,17 +269,145 @@ describe("ReplicaCache", () => { expect(restoredTimeline).toEqual(timeline()); }); + it("preserves pending timeline updates across directory baseline replacement", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + writer.commitTimeline(SERVER_ID, "agent-1", timeline("Latest visible reply")); + writer.replaceDirectoryBaseline(SERVER_ID, directory()); + await writer.flush(); + + const reader = createCache(storage); + expect(await reader.readTimeline(SERVER_ID, "agent-1")).toEqual( + timeline("Latest visible reply"), + ); + }); + + it("preserves clearing a timeline across directory baseline replacement", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + writer.commitTimeline(SERVER_ID, "agent-1", timeline()); + await writer.flush(); + writer.commitTimeline(SERVER_ID, "agent-1", { ...timeline(), items: [], range: null }); + writer.replaceDirectoryBaseline(SERVER_ID, directory()); + await writer.flush(); + + const reader = createCache(storage); + expect(await reader.readTimeline(SERVER_ID, "agent-1")).toEqual({ + ...timeline(), + items: [], + range: null, + hasOlder: false, + }); + }); + + it("serializes and writes only keyed directory mutations", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + const changed = agent("changed-agent"); + let titleReads = 0; + Object.defineProperty(changed, "title", { + configurable: true, + enumerable: true, + get: () => { + titleReads += 1; + return "Changed agent"; + }, + }); + + cache.commitDirectoryMutations( + SERVER_ID, + [{ kind: "agent", type: "upsert", id: changed.id, value: changed }], + { agents: { generation: "g", afterSeq: 13 } }, + ); + + expect(titleReads).toBe(0); + await cache.flush(); + expect(titleReads).toBe(1); + expect(storage.changes).toHaveLength(1); + expect(storage.changes[0]?.upserts.map(({ kind, id }) => ({ kind, id }))).toEqual([ + { kind: "agent", id: "changed-agent" }, + { kind: "checkpoint", id: "singleton" }, + ]); + expect(storage.changes[0]?.deletes).toEqual([]); + }); + + it("round-trips identified and anonymous open turns without changing protocol status", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const identified = agent("identified"); + identified.turn = { + phase: "open", + turnId: "turn-1", + startedAt: new Date("2026-08-31T12:00:00.000Z"), + cancellationRequestId: null, + }; + const anonymous = agent("anonymous"); + anonymous.turn = { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }; + writer.commitDirectoryMutations(SERVER_ID, [ + { kind: "agent", type: "upsert", id: identified.id, value: identified }, + { kind: "agent", type: "upsert", id: anonymous.id, value: anonymous }, + ]); + await writer.flush(); + + const restored = await createCache(storage).readDirectory(SERVER_ID); + + expect(restored.agents.get("identified")).toMatchObject({ + status: "idle", + turn: { + phase: "open", + turnId: "turn-1", + startedAt: new Date("2026-08-31T12:00:00.000Z"), + cancellationRequestId: null, + }, + }); + expect(restored.agents.get("anonymous")).toMatchObject({ + status: "idle", + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + }); + }); + + it("coalesces timeline values before serialization", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + let textReads = 0; + const changedTimeline = timeline(); + Object.defineProperty(changedTimeline.items[0], "text", { + configurable: true, + enumerable: true, + get: () => { + textReads += 1; + return "Latest"; + }, + }); + + cache.commitTimeline(SERVER_ID, "agent-1", timeline("Old")); + cache.commitTimeline(SERVER_ID, "agent-1", changedTimeline); + + expect(textReads).toBe(0); + await cache.flush(); + expect(textReads).toBe(1); + expect(storage.changes[0]?.upserts.map(({ kind, id }) => ({ kind, id }))).toEqual([ + { kind: "timeline", id: "agent-1" }, + ]); + }); + it("never reads directory rows older than an accepted deferred deletion", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); - cache.commitDirectory(SERVER_ID, { - agents: new Map(), - workspaces: new Map(), - projects: new Map(), - }); + deleteDirectory(cache, SERVER_ID); expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined(); expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined(); @@ -249,15 +417,11 @@ describe("ReplicaCache", () => { it("fails closed when an accepted deletion cannot be persisted before a read", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); storage.nextWriteFailure = new Error("disk busy"); - cache.commitDirectory(SERVER_ID, { - agents: new Map(), - workspaces: new Map(), - projects: new Map(), - }); + deleteDirectory(cache, SERVER_ID); expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined(); }); @@ -265,7 +429,7 @@ describe("ReplicaCache", () => { it("discards a durable read when the host changes while it is in flight", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); const started = deferred(); const release = deferred(); @@ -274,11 +438,7 @@ describe("ReplicaCache", () => { const reading = cache.readAgent(SERVER_ID, "agent-1"); await started.promise; - cache.commitDirectory(SERVER_ID, { - agents: new Map(), - workspaces: new Map(), - projects: new Map(), - }); + deleteDirectory(cache, SERVER_ID); release.resolve(); expect(await reading).toBeUndefined(); @@ -356,7 +516,7 @@ describe("ReplicaCache", () => { it("reads one requested agent and the focused timeline without scanning directory rows", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); - writer.commitDirectory(SERVER_ID, directory()); + commitDirectory(writer, SERVER_ID, directory()); writer.commitTimeline(SERVER_ID, "agent-1", timeline()); await writer.flush(); @@ -372,7 +532,7 @@ describe("ReplicaCache", () => { it("reads one requested workspace and its project without scanning the directory", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); - writer.commitDirectory(SERVER_ID, directory()); + commitDirectory(writer, SERVER_ID, directory()); await writer.flush(); const reader = createCache(storage); @@ -422,9 +582,9 @@ describe("ReplicaCache", () => { const storage = new MemoryStorage(); const writer = new ReplicaCache(storage, noLegacyCleanup); writer.setHosts([SERVER_ID, otherServerId]); - writer.commitDirectory(SERVER_ID, directory()); + commitDirectory(writer, SERVER_ID, directory()); writer.commitTimeline(SERVER_ID, "agent-1", timeline()); - writer.commitDirectory(otherServerId, directory()); + commitDirectory(writer, otherServerId, directory()); await writer.flush(); storage.rows.set(`${SERVER_ID}:agent:agent-1`, { serverId: SERVER_ID, @@ -438,7 +598,7 @@ describe("ReplicaCache", () => { ); const cache = new ReplicaCache(storage, { ...noLegacyCleanup, maxBytes: initialBytes + 100 }); cache.setHosts([SERVER_ID, otherServerId]); - cache.commitDirectory(otherServerId, directory()); + commitDirectory(cache, otherServerId, directory()); await cache.flush(); expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined(); @@ -451,7 +611,8 @@ describe("ReplicaCache", () => { it("atomically removes a targeted corrupt row and its matching persisted cursor", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); - writer.commitDirectory( + commitDirectory( + writer, SERVER_ID, directory({ agents: { generation: "g", afterSeq: 12 }, @@ -480,7 +641,8 @@ describe("ReplicaCache", () => { it("drops only the cursor whose cached entity baseline is corrupt", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); - writer.commitDirectory( + commitDirectory( + writer, SERVER_ID, directory({ agents: { generation: "g", afterSeq: 12 }, @@ -514,7 +676,7 @@ describe("ReplicaCache", () => { const storage = new MemoryStorage(); const cache = createCache(storage); - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); expect(storage.changes).toHaveLength(1); @@ -548,7 +710,7 @@ describe("ReplicaCache", () => { await cache.flush(); storage.onRead = () => { storage.onRead = null; - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); }; expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([ @@ -561,13 +723,23 @@ describe("ReplicaCache", () => { const cache = new ReplicaCache(storage, { ...noLegacyCleanup, maxBytes: 2_500 }); cache.setHosts([SERVER_ID, "other-host"]); const cachedDirectory = directory(); - cache.commitDirectory(SERVER_ID, cachedDirectory); + commitDirectory(cache, SERVER_ID, cachedDirectory); await cache.flush(); - cache.commitDirectory("other-host", cachedDirectory); + commitDirectory(cache, "other-host", cachedDirectory); await cache.flush(); expect([...storage.rows.values()].some((row) => row.serverId === SERVER_ID)).toBe(false); - cache.commitDirectory(SERVER_ID, cachedDirectory); + cache.commitDirectoryMutations( + SERVER_ID, + [{ kind: "agent", type: "upsert", id: "agent-1", value: agent() }], + { agents: { generation: "new-generation", afterSeq: 99 } }, + ); + await cache.flush(); + + expect([...storage.rows.values()].some((row) => row.serverId === SERVER_ID)).toBe(false); + expect((await cache.readDirectory(SERVER_ID)).checkpoint).toBeUndefined(); + + cache.replaceDirectoryBaseline(SERVER_ID, cachedDirectory); await cache.flush(); expect( @@ -588,7 +760,7 @@ describe("ReplicaCache", () => { cache.setHosts([SERVER_ID]); await cache.readAgent(SERVER_ID, "missing"); - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); expect(storage.cleanups).toBe(1); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 26c1f3b5d6..c1db2f8418 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -30,7 +30,45 @@ import { type ReplicaRowStore, } from "./row-store"; -const PERSIST_DELAY_MS = 5_000; +export type DirectoryReplicaMutation = + | { kind: "agent"; type: "upsert"; id: string; value: Agent } + | { kind: "agent"; type: "delete"; id: string } + | { kind: "workspace"; type: "upsert"; id: string; value: WorkspaceDescriptor } + | { kind: "workspace"; type: "delete"; id: string } + | { kind: "project"; type: "upsert"; id: string; value: ProjectDescriptor } + | { kind: "project"; type: "delete"; id: string }; + +export interface CachedDirectory { + agents: Map; + workspaces: Map; + projects: Map; + checkpoint?: DirectoryCheckpoint; +} + +export interface CachedWorkspace { + workspace: WorkspaceDescriptor; + project?: ProjectDescriptor; +} + +export interface DirectoryCursor { + generation: string; + afterSeq: number; +} + +export interface DirectoryCheckpoint { + projects?: DirectoryCursor; + workspaces?: DirectoryCursor; + agents?: DirectoryCursor; +} + +export interface CachedTimeline { + agentId: string; + items: StreamItem[]; + range: AgentTimelineCursorState | null; + hasOlder: boolean; +} + +const PERSIST_DELAY_MS = 1_000; const MAX_TIMELINE_ITEMS = 50; const MAX_CACHE_BYTES = 32 * 1024 * 1024; const IsoDateSchema = z.iso.datetime(); @@ -217,6 +255,16 @@ const StoredAgentSnapshotSchema = z.strictObject({ const StoredAgentSchema = z.strictObject({ snapshot: StoredAgentSnapshotSchema, + turn: z + .discriminatedUnion("phase", [ + z.strictObject({ phase: z.literal("idle") }), + z.strictObject({ + phase: z.literal("open"), + turnId: z.string().nullable(), + startedAt: IsoDateSchema.nullable(), + }), + ]) + .optional(), projectPlacement: StoredProjectPlacementSchema.nullable(), lastActivityAt: IsoDateSchema, }); @@ -313,28 +361,12 @@ interface ReplicaCacheOptions { clearLegacyCache?: () => Promise; } -export interface CachedDirectory { - agents: Map; - workspaces: Map; - projects: Map; - checkpoint?: DirectoryCheckpoint; -} - -export interface CachedWorkspace { - workspace: WorkspaceDescriptor; - project?: ProjectDescriptor; -} - -export interface DirectoryCursor { - generation: string; - afterSeq: number; -} - -export interface DirectoryCheckpoint { - projects?: DirectoryCursor; - workspaces?: DirectoryCursor; - agents?: DirectoryCursor; -} +type StructuredReplicaUpsert = + | { serverId: string; kind: "agent"; id: string; value: Agent } + | { serverId: string; kind: "workspace"; id: string; value: WorkspaceDescriptor } + | { serverId: string; kind: "project"; id: string; value: ProjectDescriptor } + | { serverId: string; kind: "timeline"; id: string; value: CachedTimeline } + | { serverId: string; kind: "checkpoint"; id: string; value: DirectoryCheckpoint }; const DirectoryCursorSchema = z.strictObject({ generation: z.string(), @@ -347,13 +379,6 @@ const DirectoryCheckpointSchema = z.strictObject({ agents: DirectoryCursorSchema.optional(), }); -export interface CachedTimeline { - agentId: string; - items: StreamItem[]; - range: AgentTimelineCursorState | null; - hasOlder: boolean; -} - function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | null { if (!stored) { return null; @@ -560,6 +585,15 @@ function serializeProjectPlacement(agent: Agent): StoredAgent["projectPlacement" return agent.projectPlacement ?? null; } +function serializeAgentTurn(agent: Agent): NonNullable { + if (agent.turn.phase === "idle") return { phase: "idle" }; + return { + phase: "open", + turnId: agent.turn.turnId, + startedAt: agent.turn.startedAt?.toISOString() ?? null, + }; +} + function serializeAgent(agent: Agent): StoredAgent { const snapshot = { id: agent.id, @@ -572,11 +606,11 @@ function serializeAgent(agent: Agent): StoredAgent { updatedAt: agent.updatedAt.toISOString(), lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null, status: agent.status, - ...(agent.activeTurn?.turnId + ...(agent.turn.phase === "open" && agent.turn.turnId ? { activeTurn: { - turnId: agent.activeTurn.turnId, - startedAt: agent.activeTurn.startedAt?.toISOString() ?? null, + turnId: agent.turn.turnId, + startedAt: agent.turn.startedAt?.toISOString() ?? null, }, } : {}), @@ -614,14 +648,27 @@ function serializeAgent(agent: Agent): StoredAgent { }; return { snapshot, + turn: serializeAgentTurn(agent), projectPlacement: serializeProjectPlacement(agent), lastActivityAt: agent.lastActivityAt.toISOString(), }; } function deserializeAgent(serverId: string, stored: StoredAgent): Agent { + const normalized = normalizeAgentSnapshot(stored.snapshot, serverId); + let turn = normalized.turn; + if (stored.turn?.phase === "idle") turn = { phase: "idle", cancellationRequestId: null }; + if (stored.turn?.phase === "open") { + turn = { + phase: "open", + turnId: stored.turn.turnId, + startedAt: stored.turn.startedAt ? new Date(stored.turn.startedAt) : null, + cancellationRequestId: null, + }; + } return { - ...normalizeAgentSnapshot(stored.snapshot, serverId), + ...normalized, + turn, lastActivityAt: new Date(stored.lastActivityAt), projectPlacement: stored.projectPlacement, }; @@ -773,8 +820,10 @@ interface DirectoryReadAccumulator { checkpoint?: DirectoryCheckpoint; } -interface PendingReplicaChanges extends ReplicaRowChanges { - directoryReplacements: Map>; +interface PendingReplicaChanges { + upserts: StructuredReplicaUpsert[]; + deletes: ReplicaRowKey[]; + baselines: Set; } function applyDirectoryRow( @@ -825,9 +874,10 @@ export class ReplicaCache { private readonly storedRows = new Map>(); private readonly hostBytes = new Map(); private readonly hostWriteOrder = new Map(); - private pendingUpserts = new Map(); + private pendingUpserts = new Map(); private pendingDeletes = new Map(); - private pendingDirectoryReplacements = new Map>(); + private pendingBaselines = new Set(); + private readonly invalidatedHosts = new Set(); private readonly maxBytes: number; private totalBytes = 0; private persistTimer: ReturnType | null = null; @@ -1019,39 +1069,65 @@ export class ReplicaCache { }); } - commitDirectory( + commitDirectoryMutations( serverId: string, - directory: { - agents: Map; - workspaces: Map; - projects: Map; - checkpoint?: DirectoryCheckpoint; - }, + mutations: readonly DirectoryReplicaMutation[], + checkpoint?: DirectoryCheckpoint, ): void { if (!this.activeServerIds.has(serverId)) return; + if (this.invalidatedHosts.has(serverId)) return; this.advanceHostRevision(serverId); - this.clearPendingDirectoryChanges(serverId); - const desiredRows: ReplicaRow[] = []; - for (const agent of directory.agents.values()) { - desiredRows.push(this.entityRow(serverId, "agent", agent.id, serializeAgent(agent))); + for (const mutation of mutations) { + if (mutation.type === "delete") { + this.queueEntityDelete(serverId, mutation.kind, mutation.id); + } else { + this.queueUpsert({ + serverId, + kind: mutation.kind, + id: mutation.id, + value: mutation.value, + } as StructuredReplicaUpsert); + } } - for (const workspace of directory.workspaces.values()) { - desiredRows.push( - this.entityRow(serverId, "workspace", workspace.id, serializeWorkspace(workspace)), - ); + if (checkpoint) { + this.queueUpsert({ + serverId, + kind: "checkpoint", + id: REPLICA_SINGLETON_ROW_ID, + value: checkpoint, + }); } - for (const project of directory.projects.values()) { - desiredRows.push( - this.entityRow(serverId, "project", project.projectId, serializeProject(project)), - ); + this.schedulePersist(); + } + + replaceDirectoryBaseline(serverId: string, directory: CachedDirectory): void { + if (!this.activeServerIds.has(serverId)) return; + this.advanceHostRevision(serverId); + // Replacing the directory must not discard independently accepted timeline changes. + for (const [key, row] of this.pendingUpserts) { + if (row.serverId === serverId && row.kind !== "timeline") this.pendingUpserts.delete(key); + } + for (const [key, row] of this.pendingDeletes) { + if (row.serverId === serverId && row.kind !== "timeline") this.pendingDeletes.delete(key); + } + this.pendingBaselines.add(serverId); + for (const [id, value] of directory.agents) { + this.queueUpsert({ serverId, kind: "agent", id, value }); + } + for (const [id, value] of directory.workspaces) { + this.queueUpsert({ serverId, kind: "workspace", id, value }); + } + for (const [id, value] of directory.projects) { + this.queueUpsert({ serverId, kind: "project", id, value }); } if (directory.checkpoint) { - desiredRows.push( - this.entityRow(serverId, "checkpoint", REPLICA_SINGLETON_ROW_ID, directory.checkpoint), - ); + this.queueUpsert({ + serverId, + kind: "checkpoint", + id: REPLICA_SINGLETON_ROW_ID, + value: directory.checkpoint, + }); } - for (const row of desiredRows) this.queueUpsert(row); - this.pendingDirectoryReplacements.set(serverId, new Set(desiredRows.map((row) => rowKey(row)))); this.schedulePersist(); } @@ -1059,9 +1135,7 @@ export class ReplicaCache { if (!this.activeServerIds.has(serverId)) return; if (timeline.agentId !== agentId) throw new Error("Timeline cache key does not match payload"); this.advanceHostRevision(serverId); - const stored = serializeTimeline(timeline); - if (stored) this.queueEntityUpsert(serverId, "timeline", agentId, stored); - else this.queueEntityDelete(serverId, "timeline", agentId); + this.queueUpsert({ serverId, kind: "timeline", id: agentId, value: timeline }); this.schedulePersist(); } @@ -1075,6 +1149,7 @@ export class ReplicaCache { for (const serverId of removed) { this.removeStoredHost(serverId); this.dropPendingHostChanges(serverId); + this.invalidatedHosts.delete(serverId); this.queueOperation(() => this.rowStore.deleteHost(serverId)); } } @@ -1124,12 +1199,15 @@ export class ReplicaCache { try { await this.prepareStore(); await this.ensureStoredIndex(); - const changes = this.materializeDirectoryReplacements(pending); - const boundedChanges = await this.fitChangesToBudget(changes); + const changes = this.materializePendingChanges(pending); + const { changes: boundedChanges, evicted } = await this.fitChangesToBudget(changes); if (boundedChanges.upserts.length > 0 || boundedChanges.deletes.length > 0) { await this.rowStore.apply(boundedChanges); this.applyStoredChanges(boundedChanges); } + for (const serverId of pending.baselines) { + if (!evicted.has(serverId)) this.invalidatedHosts.delete(serverId); + } } catch { this.restorePendingChanges(pending); if (this.hasPendingChanges()) this.schedulePersist(); @@ -1140,39 +1218,11 @@ export class ReplicaCache { await write; } - private queueEntityUpsert( - serverId: string, - kind: ReplicaRowKind, - id: string, - value: unknown, - ): void { - this.queueUpsert(this.entityRow(serverId, kind, id, value)); - } - - private entityRow( - serverId: string, - kind: ReplicaRowKind, - id: string, - value: unknown, - ): ReplicaRow { - return { serverId, kind, id, payload: JSON.stringify(value) }; - } - - private clearPendingDirectoryChanges(serverId: string): void { - const isDirectoryRow = (row: ReplicaRowKey) => row.kind !== "timeline"; - for (const [key, row] of this.pendingUpserts) { - if (row.serverId === serverId && isDirectoryRow(row)) this.pendingUpserts.delete(key); - } - for (const [key, row] of this.pendingDeletes) { - if (row.serverId === serverId && isDirectoryRow(row)) this.pendingDeletes.delete(key); - } - } - private queueEntityDelete(serverId: string, kind: ReplicaRowKind, id: string): void { this.queueDelete({ serverId, kind, id }); } - private queueUpsert(row: ReplicaRow): void { + private queueUpsert(row: StructuredReplicaUpsert): void { const key = pendingRowKey(row); this.pendingDeletes.delete(key); this.pendingUpserts.set(key, row); @@ -1186,9 +1236,7 @@ export class ReplicaCache { private hasPendingChanges(): boolean { return ( - this.pendingUpserts.size > 0 || - this.pendingDeletes.size > 0 || - this.pendingDirectoryReplacements.size > 0 + this.pendingUpserts.size > 0 || this.pendingDeletes.size > 0 || this.pendingBaselines.size > 0 ); } @@ -1201,7 +1249,7 @@ export class ReplicaCache { } private hasPendingHostChanges(serverId: string): boolean { - if (this.pendingDirectoryReplacements.has(serverId)) return true; + if (this.pendingBaselines.has(serverId)) return true; for (const row of this.pendingUpserts.values()) { if (row.serverId === serverId) return true; } @@ -1219,31 +1267,66 @@ export class ReplicaCache { const changes = { upserts: [...this.pendingUpserts.values()], deletes: [...this.pendingDeletes.values()], - directoryReplacements: this.pendingDirectoryReplacements, + baselines: this.pendingBaselines, }; this.pendingUpserts = new Map(); this.pendingDeletes = new Map(); - this.pendingDirectoryReplacements = new Map(); + this.pendingBaselines = new Set(); return changes; } - private materializeDirectoryReplacements(pending: PendingReplicaChanges): ReplicaRowChanges { + private materializePendingChanges(pending: PendingReplicaChanges): ReplicaRowChanges { const deletes = new Map(pending.deletes.map((row) => [pendingRowKey(row), row])); - for (const [serverId, desiredRows] of pending.directoryReplacements) { + const upserts: ReplicaRow[] = []; + for (const upsert of pending.upserts) { + const row = this.materializeUpsert(upsert); + if (row) upserts.push(row); + else deletes.set(pendingRowKey(upsert), upsert); + } + for (const serverId of pending.baselines) { + const replacementKeys = new Set( + upserts.filter((row) => row.serverId === serverId && row.kind !== "timeline").map(rowKey), + ); for (const row of this.storedRows.get(serverId)?.values() ?? []) { - if (row.kind !== "timeline" && !desiredRows.has(rowKey(row))) { + if (row.kind !== "timeline" && !replacementKeys.has(rowKey(row))) { deletes.set(pendingRowKey(row), row); } } } - return { upserts: pending.upserts, deletes: [...deletes.values()] }; + return { upserts, deletes: [...deletes.values()] }; + } + + private materializeUpsert(upsert: StructuredReplicaUpsert): ReplicaRow | null { + let value: unknown; + switch (upsert.kind) { + case "agent": + value = serializeAgent(upsert.value); + break; + case "workspace": + value = serializeWorkspace(upsert.value); + break; + case "project": + value = serializeProject(upsert.value); + break; + case "timeline": + value = serializeTimeline(upsert.value); + if (!value) return null; + break; + case "checkpoint": + value = upsert.value; + break; + } + return { + serverId: upsert.serverId, + kind: upsert.kind, + id: upsert.id, + payload: JSON.stringify(value), + }; } private restorePendingChanges(changes: PendingReplicaChanges): void { - for (const [serverId, desiredRows] of changes.directoryReplacements) { - if (this.activeServerIds.has(serverId) && !this.pendingDirectoryReplacements.has(serverId)) { - this.pendingDirectoryReplacements.set(serverId, desiredRows); - } + for (const serverId of changes.baselines) { + if (this.activeServerIds.has(serverId)) this.pendingBaselines.add(serverId); } for (const key of changes.deletes) { const pendingKey = pendingRowKey(key); @@ -1306,7 +1389,9 @@ export class ReplicaCache { this.hostWriteOrder.set(serverId, true); } - private async fitChangesToBudget(changes: ReplicaRowChanges): Promise { + private async fitChangesToBudget( + changes: ReplicaRowChanges, + ): Promise<{ changes: ReplicaRowChanges; evicted: Set }> { const touchedServerIds = new Set(); for (const key of changes.deletes) touchedServerIds.add(key.serverId); for (const row of changes.upserts) touchedServerIds.add(row.serverId); @@ -1342,15 +1427,17 @@ export class ReplicaCache { projectedTotal -= bytes; projectedBytes.delete(serverId); evicted.add(serverId); + this.invalidatedHosts.add(serverId); if (this.storedRows.has(serverId)) await this.rowStore.deleteHost(serverId); this.removeStoredHost(serverId); - // The next accepted owner commit must rebuild the complete host replica. Retaining the - // directory replacement marker ensures its checkpoint and rows stay atomic. } return { - upserts: changes.upserts.filter((row) => !evicted.has(row.serverId)), - deletes: changes.deletes.filter((key) => !evicted.has(key.serverId)), + changes: { + upserts: changes.upserts.filter((row) => !evicted.has(row.serverId)), + deletes: changes.deletes.filter((key) => !evicted.has(key.serverId)), + }, + evicted, }; } @@ -1362,7 +1449,7 @@ export class ReplicaCache { } private dropPendingHostChanges(serverId: string): void { - this.pendingDirectoryReplacements.delete(serverId); + this.pendingBaselines.delete(serverId); for (const [key, row] of this.pendingUpserts) { if (row.serverId === serverId) this.pendingUpserts.delete(key); } @@ -1373,18 +1460,16 @@ export class ReplicaCache { private renamePendingHostChanges(oldServerId: string, newServerId: string): void { const changes = this.drainPendingChanges(); - for (const [serverId, desiredRows] of changes.directoryReplacements) { - this.pendingDirectoryReplacements.set( - serverId === oldServerId ? newServerId : serverId, - desiredRows, - ); - } for (const key of changes.deletes) { this.queueDelete(key.serverId === oldServerId ? { ...key, serverId: newServerId } : key); } for (const row of changes.upserts) { this.queueUpsert(row.serverId === oldServerId ? { ...row, serverId: newServerId } : row); } + for (const serverId of changes.baselines) { + this.pendingBaselines.add(serverId === oldServerId ? newServerId : serverId); + } + if (this.invalidatedHosts.delete(oldServerId)) this.invalidatedHosts.add(newServerId); } private prepareStore(): Promise { diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index ac84be35f8..9096ae9e94 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import type { ReactElement, RefObject } from "react"; import { useTranslation } from "react-i18next"; @@ -53,7 +54,7 @@ import { navigateToWorkspace, useLastWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; -import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; +import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store"; import { useWorkspace } from "@/stores/session-store-hooks"; import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; @@ -1555,7 +1556,12 @@ export function NewWorkspaceScreen({ const insets = useSafeAreaInsets(); const isCompact = useIsCompactFormFactor(); const toast = useToast(); - const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); + const mergeWorkspaces = useCallback( + (targetServerId: string, workspaces: Iterable) => { + getHostRuntimeStore().acceptWorkspaceSnapshots(targetServerId, Array.from(workspaces)); + }, + [], + ); const { allHosts, selectedServerId, diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 3475f4779c..c53eae4c49 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -8,9 +8,57 @@ import { selectAgentTurnPresentation, selectAgentTimelineState, useSessionStore, + type Agent, type WorkspaceDescriptor, } from "./session-store"; import type { StreamItem } from "../types/stream"; +import { reduceTurnLiveness, type TurnLivenessTransition } from "@/timeline/turn-liveness"; + +function createTestAgent(agentId: string): Agent { + return { + serverId: "test-server", + id: agentId, + provider: "codex", + status: "idle", + turn: { phase: "idle", cancellationRequestId: null }, + createdAt: new Date(0), + updatedAt: new Date(0), + lastUserMessageAt: null, + lastActivityAt: new Date(0), + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + cwd: "/repo", + model: null, + parentAgentId: null, + labels: {}, + }; +} + +function applyTestTurn( + serverId: string, + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], +): void { + useSessionStore.getState().setAgents(serverId, (agents) => { + const agent = agents.get(agentId) ?? createTestAgent(agentId); + const transitions = Array.isArray(transition) ? transition : [transition]; + const turn = transitions.reduce(reduceTurnLiveness, agent.turn); + const next = new Map(agents); + next.set(agentId, { ...agent, status: turn.phase === "open" ? "running" : "idle", turn }); + return next; + }); +} function createWorkspace( input: Partial & Pick, @@ -222,22 +270,25 @@ describe("agent timeline state", () => { it("stores turn liveness transitions without duplicating their policy", () => { initializeTestSession(); const store = useSessionStore.getState(); - store.applyAgentTurnLiveness("test-server", "agent-1", { + applyTestTurn("test-server", "agent-1", { type: "snapshot", activeTurn: { turnId: "turn-1", startedAt: null }, }); - expect(store.getSession("test-server")?.agentTurnLiveness.get("agent-1")).toEqual({ + expect(store.getSession("test-server")?.agents.get("agent-1")?.turn).toEqual({ phase: "open", turnId: "turn-1", startedAt: null, cancellationRequestId: null, }); - store.applyAgentTurnLiveness("test-server", "agent-1", { + applyTestTurn("test-server", "agent-1", { type: "snapshot", activeTurn: null, }); - expect(store.getSession("test-server")?.agentTurnLiveness.has("agent-1")).toBe(false); + expect(store.getSession("test-server")?.agents.get("agent-1")?.turn).toEqual({ + phase: "idle", + cancellationRequestId: null, + }); }); }); @@ -267,27 +318,27 @@ describe("message submission ordering", () => { return; } if (step === "stream-open") { - store.applyAgentTurnLiveness("test-server", agentId, { + applyTestTurn("test-server", agentId, { type: "stream_open", turn: { turnId: "turn-1", startedAt }, }); return; } if (step === "snapshot-open") { - store.applyAgentTurnLiveness("test-server", agentId, { + applyTestTurn("test-server", agentId, { type: "snapshot", activeTurn: { turnId: "turn-1", startedAt }, }); return; } if (step === "snapshot-idle") { - store.applyAgentTurnLiveness("test-server", agentId, { + applyTestTurn("test-server", agentId, { type: "snapshot", activeTurn: null, }); return; } - store.applyAgentTurnLiveness("test-server", agentId, { + applyTestTurn("test-server", agentId, { type: "stream_close", turnId: "turn-1", }); @@ -322,7 +373,7 @@ describe("message submission ordering", () => { store.setAgentStreamState("test-server", agentId, { acknowledgedClientMessageIds: [clientMessageId], }); - store.applyAgentTurnLiveness("test-server", agentId, [ + applyTestTurn("test-server", agentId, [ { type: "stream_close", turnId: "turn-1" }, { type: "snapshot", activeTurn: null }, ]); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index c2328a5b62..3f1ae6ffcf 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -54,12 +54,10 @@ import { type WorkspaceAgentActivity, } from "@/utils/workspace-agent-activity"; import { - applyTurnLivenessTransition, resolveTurnPresentation, TURN_LIVENESS_IDLE, type TurnLiveness, type TurnPresentation, - type TurnLivenessTransition, } from "@/timeline/turn-liveness"; export interface AgentRuntimeInfo { @@ -76,7 +74,7 @@ export interface Agent { id: string; provider: AgentProvider; status: AgentLifecycleStatus; - activeTurn: { turnId: string | null; startedAt: Date | null } | null; + turn: TurnLiveness; createdAt: Date; updatedAt: Date; lastUserMessageAt: Date | null; @@ -332,7 +330,9 @@ export function selectAgentTurnPresentation( agentId: string, ): TurnPresentation { return resolveTurnPresentation( - session?.agentTurnLiveness.get(agentId) ?? TURN_LIVENESS_IDLE, + session?.agents.get(agentId)?.turn ?? + session?.agentDetails.get(agentId)?.turn ?? + TURN_LIVENESS_IDLE, getActiveMessageSubmissions(session?.messageSubmissions.get(agentId)).length > 0, ); } @@ -387,7 +387,6 @@ export interface SessionState { agentStreamTail: Map; agentStreamHead: Map; agentTasks: Map; - agentTurnLiveness: Map; messageSubmissions: Map; agentTimelineCursor: Map; agentTimelineHasOlder: Map; @@ -476,14 +475,6 @@ interface SessionStoreActions { taskSnapshot?: TodoEntry[]; }, ) => void; - applyAgentTurnLiveness: ( - serverId: string, - agentId: string, - transition: TurnLivenessTransition | readonly TurnLivenessTransition[], - ) => void; - beginAgentCancellation: (serverId: string, agentId: string) => number; - settleAgentCancellation: (serverId: string, agentId: string, requestId: number) => void; - clearAgentTurnLiveness: (serverId: string) => void; beginAgentMessageSubmission: ( serverId: string, agentId: string, @@ -644,7 +635,6 @@ function createInitialSessionState( agentStreamTail: new Map(), agentStreamHead: new Map(), agentTasks: new Map(), - agentTurnLiveness: new Map(), messageSubmissions: new Map(), agentTimelineCursor: new Map(), agentTimelineHasOlder: new Map(), @@ -711,7 +701,6 @@ function isSessionServerInfoUnchanged(input: { export const useSessionStore = create()( subscribeWithSelector((set, get) => { - let nextCancellationRequestId = 0; const commitActivityUpdates: AgentLastActivityCommitter = (updates) => { set((prev) => { let nextActivity: Map | null = null; @@ -1058,57 +1047,6 @@ export const useSessionStore = create()( }); }, - applyAgentTurnLiveness: (serverId, agentId, transition) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) return prev; - const agentTurnLiveness = applyTurnLivenessTransition( - session.agentTurnLiveness, - agentId, - transition, - ); - if (agentTurnLiveness === session.agentTurnLiveness) return prev; - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, agentTurnLiveness }, - }, - }; - }); - }, - - beginAgentCancellation: (serverId, agentId) => { - nextCancellationRequestId += 1; - const requestId = nextCancellationRequestId; - get().applyAgentTurnLiveness(serverId, agentId, { - type: "cancellation_started", - requestId, - }); - return requestId; - }, - - settleAgentCancellation: (serverId, agentId, requestId) => { - get().applyAgentTurnLiveness(serverId, agentId, { - type: "cancellation_settled", - requestId, - }); - }, - - clearAgentTurnLiveness: (serverId) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session || session.agentTurnLiveness.size === 0) return prev; - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, agentTurnLiveness: new Map() }, - }, - }; - }); - }, - beginAgentMessageSubmission: (serverId, agentId, message) => { set((prev) => { const session = prev.sessions[serverId]; @@ -1924,6 +1862,7 @@ export const useSessionStore = create()( serverId, title: agent.title ?? null, status: agent.status, + turn: agent.turn, lastActivityAt, cwd: agent.cwd, provider: agent.provider, diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index 29925efd42..5f04a39bd9 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -37,7 +37,7 @@ const AGENT_DEFAULTS: Agent = { id: "agent", provider: "codex", status: "idle", - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt: AGENT_TIMESTAMP, updatedAt: AGENT_TIMESTAMP, lastUserMessageAt: null, diff --git a/packages/app/src/subagents/archive-finished.test.ts b/packages/app/src/subagents/archive-finished.test.ts index 42a3e51ca6..5c1615d96c 100644 --- a/packages/app/src/subagents/archive-finished.test.ts +++ b/packages/app/src/subagents/archive-finished.test.ts @@ -11,6 +11,10 @@ function paseo(id: string, status: PaseoSubagentRow["status"] = "idle"): PaseoSu description: null, subtitle: null, status, + turn: + status === "running" + ? { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null } + : { phase: "idle", cancellationRequestId: null }, requiresAttention: false, createdAt: new Date(), }; diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index 068210233a..6835260ca7 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -13,7 +13,7 @@ const AGENT_DEFAULTS: Agent = { id: "agent", provider: "codex", status: "idle", - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt: AGENT_TIMESTAMP, updatedAt: AGENT_TIMESTAMP, lastUserMessageAt: null, @@ -316,6 +316,7 @@ describe("selectSubagentsForParent", () => { description: null, subtitle: null, status: "running", + turn: { phase: "idle", cancellationRequestId: null }, requiresAttention: true, createdAt, }, @@ -330,6 +331,7 @@ describe("selectSubagentsForParent", () => { "status", "subtitle", "title", + "turn", ]); expect(rows[0]).not.toHaveProperty("onOpen"); expect(rows[0]).not.toHaveProperty("model"); diff --git a/packages/app/src/subagents/select.ts b/packages/app/src/subagents/select.ts index b9e1883705..3bb3bddcb6 100644 --- a/packages/app/src/subagents/select.ts +++ b/packages/app/src/subagents/select.ts @@ -15,6 +15,7 @@ export interface PaseoSubagentRow { description: null; subtitle: null; status: Agent["status"]; + turn: Agent["turn"]; requiresAttention: Agent["requiresAttention"]; createdAt: Agent["createdAt"]; } @@ -60,6 +61,7 @@ function toSubagentRow(agent: Agent): SubagentRow { description: null, subtitle: null, status: agent.status, + turn: agent.turn, requiresAttention: agent.requiresAttention, createdAt: agent.createdAt, }; diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts index 516e2fafb7..df009583be 100644 --- a/packages/app/src/subagents/track-presentation.test.ts +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -19,6 +19,11 @@ function row( description: null, subtitle: null, status: overrides.status ?? "idle", + turn: + overrides.turn ?? + (overrides.status === "running" + ? { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null } + : { phase: "idle", cancellationRequestId: null }), requiresAttention: overrides.requiresAttention ?? false, createdAt: overrides.createdAt ?? new Date("2026-04-20T00:00:00.000Z"), }; diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts index 1e2ddbb8d8..d95082ca39 100644 --- a/packages/app/src/subagents/track-presentation.ts +++ b/packages/app/src/subagents/track-presentation.ts @@ -7,7 +7,10 @@ import { isFinishedSubagent } from "./archive-finished"; import { providerSubagentLifecycleStatus } from "./provider-store"; function presentationStatus(row: SubagentRow) { - if (row.kind === "paseo") return row.status; + if (row.kind === "paseo") { + if (row.turn.phase === "open") return "running"; + return row.status === "running" ? "idle" : row.status; + } return providerSubagentLifecycleStatus(row.status); } diff --git a/packages/app/src/test/seed-session.ts b/packages/app/src/test/seed-session.ts index 23848acd24..d483e01c5d 100644 --- a/packages/app/src/test/seed-session.ts +++ b/packages/app/src/test/seed-session.ts @@ -1,3 +1,5 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { defaultHostAppearance } from "@/hosts/appearance"; import { useSessionStore, type ProjectDescriptor, @@ -39,3 +41,32 @@ export function seedSessionWorkspaces( store.setProjects(serverId, byProjectId.values()); store.setWorkspaces(serverId, workspaces); } + +export function seedSessionHosts(serverIds: readonly string[]): void { + getHostRuntimeStore().syncHosts( + serverIds.map((serverId) => ({ + serverId, + label: serverId, + appearance: defaultHostAppearance(), + lifecycle: {}, + connections: [], + preferredConnectionId: null, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + })), + ); +} + +export function seedRuntimeWorkspaces( + serverId: string, + workspaces: Map, +): void { + const runtime = getHostRuntimeStore(); + for (const id of useSessionStore.getState().sessions[serverId]?.workspaces.keys() ?? []) { + if (!workspaces.has(id)) runtime.removeWorkspaceSnapshot(serverId, id); + } + for (const workspace of workspaces.values()) { + runtime.acceptProjectSnapshot(serverId, projectFromWorkspace(workspace)); + } + runtime.acceptWorkspaceSnapshots(serverId, [...workspaces.values()]); +} diff --git a/packages/app/src/timeline/replica.test.ts b/packages/app/src/timeline/replica.test.ts index daaadd8c08..53d4710347 100644 --- a/packages/app/src/timeline/replica.test.ts +++ b/packages/app/src/timeline/replica.test.ts @@ -173,6 +173,59 @@ describe("viewed timeline persistence", () => { owner.dispose(); }); + it("reopens painted live mutations without persisting authoritative coverage", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + let durable: CachedTimeline | undefined = cachedTimeline(); + let pending: CachedTimeline | undefined; + const storage: TimelineReplicaStorage = { + readTimeline: async () => durable, + commitTimeline: (_serverId, _agentId, timeline) => { + pending = timeline; + }, + }; + const first = createOwner(storage); + first.replaceVisibleAgentIds("test", [AGENT_ID]); + await expect + .poll(() => + selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), + ) + .toMatchObject({ status: "painted" }); + + first.enqueueStreamEvent(AGENT_ID, { + event: { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "live", messageId: "live" }, + } as AgentStreamEventPayload, + seq: 5, + epoch: "epoch-1", + timestamp: new Date("2026-08-26T10:00:01.000Z"), + }); + first.flushStreamAgent(AGENT_ID); + + expect(pending).toMatchObject({ range: null, hasOlder: false }); + expect(pending?.items.map((entry) => entry.id)).toEqual(["cached", expect.any(String)]); + durable = pending; + first.dispose(); + useSessionStore.getState().clearSession(SERVER_ID); + useSessionStore.getState().initializeSession(SERVER_ID, null); + + const reopened = createOwner(storage); + reopened.replaceVisibleAgentIds("test", [AGENT_ID]); + await expect + .poll(() => + selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), + ) + .toMatchObject({ + status: "painted", + items: [ + expect.objectContaining({ id: "cached" }), + expect.objectContaining({ text: "live" }), + ], + }); + reopened.dispose(); + }); + it("does not let a late cache read overwrite newer network state", async () => { useSessionStore.getState().initializeSession(SERVER_ID, null); let release!: (value: CachedTimeline) => void; diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index d6bd3b2a20..323676739b 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -141,13 +141,13 @@ class TimelineReplicaOwner implements TimelineReplica { timelineUpdated(agentId: string): void { const session = useSessionStore.getState().sessions[this.serverId]; const timeline = selectAgentTimelineState(session, agentId); - if (timeline.status !== "synced") return; - this.cachedRanges.delete(agentId); + if (timeline.status === "cold") return; + if (timeline.status === "synced") this.cachedRanges.delete(agentId); this.storage.commitTimeline(this.serverId, agentId, { agentId, items: [...timeline.items, ...(session?.agentStreamHead.get(agentId) ?? [])], - range: timeline.range, - hasOlder: timeline.older === "available", + range: timeline.status === "synced" ? timeline.range : null, + hasOlder: timeline.status === "synced" && timeline.older === "available", }); } } @@ -250,7 +250,7 @@ function finalizeProcessedTimeline(input: { .clearByAgent({ serverId: input.serverId, agentId: input.agentId }); const session = useSessionStore.getState().sessions[input.serverId]; const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId); - if (agent && agent.status !== "running") input.drainQueuedAgentMessage(input.agentId); + if (agent && agent.turn.phase === "idle") input.drainQueuedAgentMessage(input.agentId); } if (input.result.initResolution === "resolve") resolveInitDeferred(input.initKey); } diff --git a/packages/app/src/types/agent-directory.ts b/packages/app/src/types/agent-directory.ts index 19beea5ecb..8ac1870a6f 100644 --- a/packages/app/src/types/agent-directory.ts +++ b/packages/app/src/types/agent-directory.ts @@ -6,6 +6,7 @@ export type AgentDirectoryEntry = Pick< | "serverId" | "title" | "status" + | "turn" | "lastActivityAt" | "cwd" | "workspaceId" diff --git a/packages/app/src/utils/agent-attention.test.ts b/packages/app/src/utils/agent-attention.test.ts index b9a0d8e6be..ac5a6f84f3 100644 --- a/packages/app/src/utils/agent-attention.test.ts +++ b/packages/app/src/utils/agent-attention.test.ts @@ -34,7 +34,7 @@ function createAgent(input: Partial & Pick): Agent { attentionReason: null, attentionTimestamp: null, ...rest, - activeTurn: rest.activeTurn ?? null, + turn: rest.turn ?? { phase: "idle", cancellationRequestId: null }, }; } diff --git a/packages/app/src/utils/agent-directory-reconciliation.test.ts b/packages/app/src/utils/agent-directory-reconciliation.test.ts index bf1e0a53ef..85e311345b 100644 --- a/packages/app/src/utils/agent-directory-reconciliation.test.ts +++ b/packages/app/src/utils/agent-directory-reconciliation.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import type { Agent } from "@/stores/session-store"; import { reconcileAgentDirectory } from "./agent-directory-reconciliation"; function snapshot(id: string, status: AgentSnapshotPayload["status"]): AgentSnapshotPayload { @@ -50,28 +49,9 @@ function entry(id: string, status: AgentSnapshotPayload["status"]): FetchAgentsE }; } -function replica(id: string, status: Agent["status"]): Agent { - return { - ...snapshot(id, status), - activeTurn: null, - serverId: "server", - createdAt: new Date("2026-07-12T10:00:00.000Z"), - updatedAt: new Date("2026-07-12T10:00:00.000Z"), - lastActivityAt: new Date("2026-07-12T10:00:00.000Z"), - lastUserMessageAt: null, - attentionTimestamp: null, - archivedAt: null, - parentAgentId: null, - }; -} - describe("agent directory reconciliation", () => { - it("reports snapshot-only and buffered running transitions exactly once", () => { + it("preserves snapshot and buffered protocol status without interpreting liveness", () => { const result = reconcileAgentDirectory({ - previous: new Map([ - ["snapshot", replica("snapshot", "running")], - ["buffered", replica("buffered", "running")], - ]), snapshot: [entry("snapshot", "idle"), entry("buffered", "running")], deltas: [ { @@ -87,8 +67,7 @@ describe("agent directory reconciliation", () => { ], }); - expect(result.stoppedRunningAgentIds).toEqual(["snapshot", "buffered"]); - expect(result.entries.map(({ agent }) => [agent.id, agent.status])).toEqual([ + expect(result.map(({ agent }) => [agent.id, agent.status])).toEqual([ ["snapshot", "idle"], ["buffered", "idle"], ]); @@ -96,7 +75,6 @@ describe("agent directory reconciliation", () => { it("preserves ordered upserts and removals received after page one", () => { const result = reconcileAgentDirectory({ - previous: new Map(), snapshot: [entry("updated", "idle"), entry("removed", "idle")], deltas: [ { @@ -108,9 +86,7 @@ describe("agent directory reconciliation", () => { ], }); - expect(result.entries.map(({ agent }) => [agent.id, agent.title])).toEqual([ - ["updated", "live"], - ]); + expect(result.map(({ agent }) => [agent.id, agent.title])).toEqual([["updated", "live"]]); }); it("keeps newer page metadata when a stale buffered upsert arrives", () => { @@ -120,7 +96,6 @@ describe("agent directory reconciliation", () => { projectName: "stale project", }; const result = reconcileAgentDirectory({ - previous: new Map([["agent", replica("agent", "running")]]), snapshot: [ { ...entry("agent", "running"), @@ -145,16 +120,14 @@ describe("agent directory reconciliation", () => { }); expect({ - title: result.entries[0]?.agent.title, - status: result.entries[0]?.agent.status, - projectName: result.entries[0]?.project.projectName, - stopped: result.stoppedRunningAgentIds, - }).toEqual({ title: "newer page", status: "running", projectName: "repo", stopped: [] }); + title: result[0]?.agent.title, + status: result[0]?.agent.status, + projectName: result[0]?.project.projectName, + }).toEqual({ title: "newer page", status: "running", projectName: "repo" }); }); it("clears a snapshot stop when a newer buffered upsert is running", () => { const result = reconcileAgentDirectory({ - previous: new Map([["agent", replica("agent", "running")]]), snapshot: [entry("agent", "idle")], deltas: [ { @@ -168,13 +141,11 @@ describe("agent directory reconciliation", () => { ], }); - expect(result.entries[0]?.agent.status).toBe("running"); - expect(result.stoppedRunningAgentIds).toEqual([]); + expect(result[0]?.agent.status).toBe("running"); }); it("accepts usage from a stale buffered upsert without regressing metadata", () => { const result = reconcileAgentDirectory({ - previous: new Map(), snapshot: [ { ...entry("agent", "idle"), @@ -201,9 +172,9 @@ describe("agent directory reconciliation", () => { }); expect({ - title: result.entries[0]?.agent.title, - status: result.entries[0]?.agent.status, - usage: result.entries[0]?.agent.lastUsage, + title: result[0]?.agent.title, + status: result[0]?.agent.status, + usage: result[0]?.agent.lastUsage, }).toEqual({ title: "newer page", status: "idle", @@ -213,7 +184,6 @@ describe("agent directory reconciliation", () => { it("preserves usage when a stale buffered upsert omits it", () => { const result = reconcileAgentDirectory({ - previous: new Map(), snapshot: [ { ...entry("agent", "idle"), @@ -236,6 +206,6 @@ describe("agent directory reconciliation", () => { ], }); - expect(result.entries[0]?.agent.lastUsage).toEqual({ inputTokens: 10, outputTokens: 5 }); + expect(result[0]?.agent.lastUsage).toEqual({ inputTokens: 10, outputTokens: 5 }); }); }); diff --git a/packages/app/src/utils/agent-directory-reconciliation.ts b/packages/app/src/utils/agent-directory-reconciliation.ts index 86130f5d2a..fa34f911bd 100644 --- a/packages/app/src/utils/agent-directory-reconciliation.ts +++ b/packages/app/src/utils/agent-directory-reconciliation.ts @@ -1,39 +1,20 @@ import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; -import type { Agent } from "@/stores/session-store"; import type { AgentDirectoryDelta } from "./agent-directory-sync"; import { acceptAgentDirectoryUpdate } from "./agent-directory-update-policy"; export function reconcileAgentDirectory(input: { - previous: ReadonlyMap; snapshot: FetchAgentsEntry[]; deltas: readonly AgentDirectoryDelta[]; -}): { entries: FetchAgentsEntry[]; stoppedRunningAgentIds: string[] } { +}): FetchAgentsEntry[] { const entries = new Map(input.snapshot.map((entry) => [entry.agent.id, entry])); - const statuses = new Map(Array.from(input.previous, ([id, agent]) => [id, agent.status])); - const stoppedRunningAgentIds = new Set(); - - for (const entry of input.snapshot) { - if (statuses.get(entry.agent.id) === "running" && entry.agent.status !== "running") { - stoppedRunningAgentIds.add(entry.agent.id); - } - statuses.set(entry.agent.id, entry.agent.status); - } for (const delta of input.deltas) { if (delta.kind === "remove") { entries.delete(delta.agentId); - statuses.delete(delta.agentId); - stoppedRunningAgentIds.delete(delta.agentId); continue; } const previousEntry = entries.get(delta.agent.id); const acceptedAgent = acceptAgentDirectoryUpdate(previousEntry?.agent, delta.agent); - if (acceptedAgent.status === "running") { - stoppedRunningAgentIds.delete(delta.agent.id); - } else if (statuses.get(delta.agent.id) === "running") { - stoppedRunningAgentIds.add(delta.agent.id); - } - statuses.set(delta.agent.id, acceptedAgent.status); const previousProject = previousEntry?.project; const acceptedProject = acceptedAgent === delta.agent ? (delta.project ?? previousProject) : previousProject; @@ -55,8 +36,5 @@ export function reconcileAgentDirectory(input: { }); } - return { - entries: Array.from(entries.values()), - stoppedRunningAgentIds: Array.from(stoppedRunningAgentIds), - }; + return Array.from(entries.values()); } diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index 71fb4cc350..c8e2479894 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -8,7 +8,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent"; import { queryClient } from "@/data/query-client"; import { createUserMessage } from "@/types/stream"; -import { applyAgentDirectoryDelta, replaceFetchedAgentDirectory } from "./agent-directory-sync"; +import { AgentStoreProjection } from "@/runtime/directory-sync/internal/agent-store"; function createAgentPayload( input: Partial> & { @@ -79,79 +79,18 @@ function beginPendingSubmission(serverId: string, agentId: string): string { return clientMessageId; } -function applyAgentStatus(input: { - serverId: string; - agentId: string; - status: AgentSnapshotPayload["status"]; - updatedAt: string; - lastUserMessageAt?: string; -}): void { - const agent = createAgentPayload({ - id: input.agentId, - status: input.status, - updatedAt: input.updatedAt, - lastUserMessageAt: input.lastUserMessageAt ?? null, - }); - applyAgentDirectoryDelta({ - serverId: input.serverId, - delta: { kind: "upsert", agent, project: createEntry(agent).project }, - }); -} - -describe("turn liveness authority", () => { - it("normalizes old-daemon status into the shared activity replica", () => { - const serverId = "server-turn-liveness"; - const agentId = "agent-1"; - const startedAt = "2026-07-27T10:00:01.000Z"; - const store = useSessionStore.getState(); - store.initializeSession(serverId, null as unknown as DaemonClient); - applyAgentStatus({ serverId, agentId, status: "idle", updatedAt: startedAt }); - - applyAgentStatus({ - serverId, - agentId, - status: "running", - updatedAt: "2026-07-27T10:00:02.000Z", - lastUserMessageAt: startedAt, - }); - expect(store.getSession(serverId)?.agentTurnLiveness.get(agentId)).toEqual({ - phase: "open", - turnId: null, - startedAt: new Date(startedAt), - cancellationRequestId: null, - }); - - applyAgentStatus({ - serverId, - agentId, - status: "idle", - updatedAt: "2026-07-27T10:00:03.000Z", - }); - - expect(store.getSession(serverId)?.agentTurnLiveness.has(agentId)).toBe(false); - store.clearSession(serverId); - }); -}); - describe("message submission authority", () => { it("does not settle a submission from an unrelated running transition", () => { const serverId = "server-running-is-not-submission-ack"; const agentId = "agent-1"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - applyAgentStatus({ - serverId, - agentId, - status: "idle", - updatedAt: "2026-07-27T10:00:00.000Z", - }); const clientMessageId = beginPendingSubmission(serverId, agentId); - - applyAgentStatus({ - serverId, - agentId, - status: "running", - updatedAt: "2026-07-27T10:00:01.000Z", + const agent = createAgentPayload({ id: agentId, status: "running" }); + new AgentStoreProjection(serverId).applyDelta({ + kind: "upsert", + agent, + project: createEntry(agent).project, }); expect(useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)).toEqual([ @@ -169,12 +108,6 @@ describe("message submission authority", () => { const agentId = "agent-1"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - applyAgentStatus({ - serverId, - agentId, - status: "idle", - updatedAt: "2026-07-27T10:00:00.000Z", - }); const clientMessageId = beginPendingSubmission(serverId, agentId); store.setAgentStreamState(serverId, agentId, { tail: [ @@ -213,10 +146,9 @@ describe("replaceFetchedAgentDirectory", () => { store.initializeSession(serverId, null as unknown as DaemonClient); store.setInitializingAgents(serverId, new Map([["agent", true]])); - replaceFetchedAgentDirectory({ - serverId, - entries: [createEntry(createAgentPayload({ id: "agent" }))], - }); + new AgentStoreProjection(serverId).replaceFetched([ + createEntry(createAgentPayload({ id: "agent" })), + ]); expect(useSessionStore.getState().sessions[serverId]?.initializingAgents.get("agent")).toBe( true, @@ -229,29 +161,24 @@ describe("replaceFetchedAgentDirectory", () => { const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - replaceFetchedAgentDirectory({ - serverId, - entries: [ - createEntry( - createAgentPayload({ - id: "child-1", - labels: { [PARENT_AGENT_ID_LABEL]: "parent-a" }, - }), - ), - ], - }); + const projection = new AgentStoreProjection(serverId); + projection.replaceFetched([ + createEntry( + createAgentPayload({ + id: "child-1", + labels: { [PARENT_AGENT_ID_LABEL]: "parent-a" }, + }), + ), + ]); - replaceFetchedAgentDirectory({ - serverId, - entries: [ - createEntry( - createAgentPayload({ - id: "child-1", - labels: { [PARENT_AGENT_ID_LABEL]: "parent-b" }, - }), - ), - ], - }); + projection.replaceFetched([ + createEntry( + createAgentPayload({ + id: "child-1", + labels: { [PARENT_AGENT_ID_LABEL]: "parent-b" }, + }), + ), + ]); expect( useSessionStore.getState().sessions[serverId]?.agents.get("child-1")?.parentAgentId, @@ -286,7 +213,7 @@ describe("replaceFetchedAgentDirectory", () => { store.setInitializingAgents(serverId, new Map([[agentId, true]])); setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); - applyAgentDirectoryDelta({ serverId, delta: { kind: "remove", agentId } }); + new AgentStoreProjection(serverId).applyDelta({ kind: "remove", agentId }); const session = useSessionStore.getState().sessions[serverId]; expect({ @@ -346,28 +273,27 @@ describe("replaceFetchedAgentDirectory", () => { lastUsage: { inputTokens: 10, outputTokens: 5 }, pendingPermissions: [permission("current-permission")], }); - applyAgentDirectoryDelta({ - serverId, - delta: { kind: "upsert", agent: current, project: createEntry(current).project }, + const projection = new AgentStoreProjection(serverId); + projection.applyDelta({ + kind: "upsert", + agent: current, + project: createEntry(current).project, }); store.flushAgentLastActivity(); setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); - const staleResult = applyAgentDirectoryDelta({ - serverId, - delta: { - kind: "upsert", - agent: { - ...current, - title: "stale", - status: "idle", - updatedAt: "2026-07-12T10:00:00.000Z", - lastUsage: { inputTokens: 20, outputTokens: 8 }, - pendingPermissions: [permission("stale-permission")], - archivedAt: "2026-07-12T10:00:00.000Z", - }, - project: createEntry(current).project, + const staleResult = projection.applyDelta({ + kind: "upsert", + agent: { + ...current, + title: "stale", + status: "idle", + updatedAt: "2026-07-12T10:00:00.000Z", + lastUsage: { inputTokens: 20, outputTokens: 8 }, + pendingPermissions: [permission("stale-permission")], + archivedAt: "2026-07-12T10:00:00.000Z", }, + project: createEntry(current).project, }); store.flushAgentLastActivity(); diff --git a/packages/app/src/utils/agent-directory-sync.ts b/packages/app/src/utils/agent-directory-sync.ts index 2d81158c81..d700a0f510 100644 --- a/packages/app/src/utils/agent-directory-sync.ts +++ b/packages/app/src/utils/agent-directory-sync.ts @@ -1,15 +1,8 @@ -import equal from "fast-deep-equal"; import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; -import { type Agent, useSessionStore } from "@/stores/session-store"; +import type { Agent } from "@/stores/session-store"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { resolveProjectPlacement } from "@/utils/project-placement"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; -import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; -import { queryClient } from "@/data/query-client"; -import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy"; -import { buildDraftStoreKey } from "@/stores/draft-keys"; -import { useDraftStore } from "@/stores/draft-store"; -import { getInitDeferred, getInitKey, rejectInitDeferred } from "@/utils/agent-initialization"; type AgentDirectoryFetchEntry = FetchAgentsEntry; export type AgentDirectoryDelta = Extract< @@ -17,128 +10,6 @@ export type AgentDirectoryDelta = Extract< { type: "agent_update" } >["payload"]; -export function applyAgentDirectoryDelta(input: { serverId: string; delta: AgentDirectoryDelta }): { - agentId: string; - stoppedRunning: boolean; -} { - if (input.delta.kind === "remove") { - removeAgentDirectoryReplica(input.serverId, input.delta.agentId); - return { agentId: input.delta.agentId, stoppedRunning: false }; - } - return upsertAgentDirectoryReplica(input.serverId, input.delta); -} - -type AgentUpsertDelta = Extract; - -function upsertAgentDirectoryReplica( - serverId: string, - delta: AgentUpsertDelta, -): { agentId: string; stoppedRunning: boolean } { - const normalized = normalizeAgentSnapshot(delta.agent, serverId); - const session = useSessionStore.getState().sessions[serverId]; - const previousAgent = - session?.agents.get(normalized.id) ?? session?.agentDetails.get(normalized.id); - const legacyWorkspaceId = - previousAgent?.workspaceId ?? - Array.from(session?.workspaces.values() ?? []).find( - (workspace) => - session?.serverInfo?.features?.workspaceMultiplicity !== true && - workspace.workspaceDirectory === normalized.cwd, - )?.id; - const agent: Agent = { - ...normalized, - workspaceId: normalized.workspaceId ?? legacyWorkspaceId, - projectPlacement: - resolveProjectPlacement({ projectPlacement: delta.project, cwd: normalized.cwd }) ?? - previousAgent?.projectPlacement, - }; - const acceptedAgent = upsertAgentReplica(serverId, agent); - if (acceptedAgent.archivedAt) { - clearArchiveAgentPending({ queryClient, serverId, agentId: acceptedAgent.id }); - } - replaceAgentPendingPermissions(serverId, acceptedAgent); - useSessionStore.getState().setAgentLastActivity(acceptedAgent.id, acceptedAgent.lastActivityAt); - return { - agentId: acceptedAgent.id, - stoppedRunning: previousAgent?.status === "running" && acceptedAgent.status !== "running", - }; -} - -export function upsertAgentReplica(serverId: string, agent: Agent): Agent { - let acceptedAgent = agent; - useSessionStore.getState().setAgents(serverId, (current) => { - const currentAgent = current.get(agent.id); - acceptedAgent = acceptAgentDirectoryUpdate(currentAgent, agent); - if (acceptedAgent === currentAgent) return current; - const next = new Map(current); - next.set(agent.id, acceptedAgent); - return next; - }); - applyAgentTurnSnapshot(serverId, acceptedAgent); - return acceptedAgent; -} - -function applyAgentTurnSnapshot(serverId: string, agent: Agent): void { - useSessionStore.getState().applyAgentTurnLiveness(serverId, agent.id, { - type: "snapshot", - activeTurn: agent.activeTurn, - }); -} - -export function replaceAgentPendingPermissions(serverId: string, agent: Agent): void { - const pendingPermissions = new Map( - useSessionStore.getState().sessions[serverId]?.pendingPermissions, - ); - for (const [key, pending] of pendingPermissions) { - if (pending.agentId === agent.id) pendingPermissions.delete(key); - } - for (const request of agent.pendingPermissions) { - const key = derivePendingPermissionKey(agent.id, request); - pendingPermissions.set(key, { key, agentId: agent.id, request }); - } - useSessionStore.getState().setPendingPermissions(serverId, pendingPermissions); -} - -export function removeAgentDirectoryReplica(serverId: string, agentId: string): void { - const store = useSessionStore.getState(); - clearArchiveAgentPending({ queryClient, serverId, agentId }); - const removeKey = (current: Map): Map => { - if (!current.has(agentId)) return current; - const next = new Map(current); - next.delete(agentId); - return next; - }; - store.setAgents(serverId, removeKey); - store.setAgentDetails(serverId, removeKey); - store.setQueuedMessages(serverId, removeKey); - store.setAgentTimelineCursor(serverId, removeKey); - store.setInitializingAgents(serverId, removeKey); - store.setPendingPermissions(serverId, (current) => { - const next = new Map(current); - for (const [key, pending] of next) { - if (pending.agentId === agentId) next.delete(key); - } - return next.size === current.size ? current : next; - }); - store.setAgentAuthoritativeHistoryApplied(serverId, agentId, false); - store.setAgentStreamTail(serverId, removeKey); - store.clearAgentStreamHead(serverId, agentId); - store.applyAgentTurnLiveness(serverId, agentId, { type: "destructive_close" }); - useSessionStore.setState((state) => { - if (!state.agentLastActivity.has(agentId)) return state; - const agentLastActivity = new Map(state.agentLastActivity); - agentLastActivity.delete(agentId); - return { ...state, agentLastActivity }; - }); - useDraftStore.getState().clearDraftInput({ - draftKey: buildDraftStoreKey({ serverId, agentId }), - }); - const initKey = getInitKey(serverId, agentId); - if (getInitDeferred(initKey)) { - rejectInitDeferred(initKey, new Error("Agent was removed during initialization")); - } -} - interface PendingPermissionEntry { key: string; agentId: string; @@ -175,49 +46,3 @@ export function buildAgentDirectoryState(input: { return { agents, pendingPermissions }; } - -export function replaceFetchedAgentDirectory(input: { - serverId: string; - entries: FetchAgentsEntry[]; -}): { agents: Map } { - const { agents: fetchedAgents, pendingPermissions } = buildAgentDirectoryState(input); - const store = useSessionStore.getState(); - for (const agent of fetchedAgents.values()) { - if (agent.archivedAt) { - clearArchiveAgentPending({ queryClient, serverId: input.serverId, agentId: agent.id }); - } - } - - const currentAgents = store.sessions[input.serverId]?.agents ?? new Map(); - const agents = new Map(); - for (const [agentId, fetchedAgent] of fetchedAgents) { - const current = currentAgents.get(agentId); - agents.set(agentId, current && equal(current, fetchedAgent) ? current : fetchedAgent); - } - - store.setAgents(input.serverId, agents); - for (const agent of agents.values()) { - applyAgentTurnSnapshot(input.serverId, agent); - } - store.setAgentDetails(input.serverId, (prev) => { - let next: Map | null = null; - for (const agentId of agents.keys()) { - if (!prev.has(agentId)) { - continue; - } - next ??= new Map(prev); - next.delete(agentId); - } - return next ?? prev; - }); - - const lastActivityByAgentId = new Map(); - for (const agent of agents.values()) { - lastActivityByAgentId.set(agent.id, agent.lastActivityAt); - } - store.setAgentLastActivityBatch(lastActivityByAgentId); - - store.setPendingPermissions(input.serverId, new Map(pendingPermissions)); - store.setHasHydratedAgents(input.serverId, true); - return { agents }; -} diff --git a/packages/app/src/utils/agent-grouping.test.ts b/packages/app/src/utils/agent-grouping.test.ts index 7df88ada97..f380dc1118 100644 --- a/packages/app/src/utils/agent-grouping.test.ts +++ b/packages/app/src/utils/agent-grouping.test.ts @@ -10,6 +10,14 @@ function makeAgent(overrides: Partial = {}): AggregatedAgent { serverLabel: (overrides as { serverLabel?: string }).serverLabel ?? "server", title: overrides.title ?? null, status: overrides.status ?? ("running" as AggregatedAgent["status"]), + turn: + overrides.turn ?? + ({ + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + } as const), lastActivityAt: overrides.lastActivityAt ?? now, cwd: overrides.cwd ?? "/tmp/repo", provider: overrides.provider ?? ("openai" as AggregatedAgent["provider"]), @@ -63,6 +71,28 @@ describe("deriveProjectDisplayName", () => { }); describe("groupAgents", () => { + it("uses turn liveness instead of protocol status for active grouping", () => { + const old = new Date("2020-01-01T00:00:00.000Z"); + const open = makeAgent({ id: "open", status: "idle", lastActivityAt: old }); + const idle = makeAgent({ + id: "idle", + status: "running", + lastActivityAt: old, + turn: { phase: "idle", cancellationRequestId: null }, + }); + + const grouped = groupAgents([open, idle]); + + const activeIds = grouped.activeGroups + .flatMap((group) => group.agents) + .map((agent) => agent.id); + const inactiveIds = grouped.inactiveGroups + .flatMap((group) => group.agents) + .map((agent) => agent.id); + expect(activeIds).toEqual(["open"]); + expect(inactiveIds).toEqual(["idle"]); + }); + it("groups active agents by remote URL when available", () => { const agents = [ makeAgent({ id: "a1", cwd: "/Users/me/dev/paseo" }), diff --git a/packages/app/src/utils/agent-grouping.ts b/packages/app/src/utils/agent-grouping.ts index a741a81d39..eaca2d1511 100644 --- a/packages/app/src/utils/agent-grouping.ts +++ b/packages/app/src/utils/agent-grouping.ts @@ -264,7 +264,9 @@ const MAX_INACTIVE_PER_PROJECT = 5; */ function isAgentTrulyActive(agent: AggregatedAgent): boolean { return ( - agent.status === "running" || agent.requiresAttention || (agent.pendingPermissionCount ?? 0) > 0 + agent.turn.phase === "open" || + agent.requiresAttention || + (agent.pendingPermissionCount ?? 0) > 0 ); } diff --git a/packages/app/src/utils/agent-snapshots.test.ts b/packages/app/src/utils/agent-snapshots.test.ts index 334e36d9f2..e1be8c5a0c 100644 --- a/packages/app/src/utils/agent-snapshots.test.ts +++ b/packages/app/src/utils/agent-snapshots.test.ts @@ -57,15 +57,25 @@ describe("normalizeAgentSnapshot", () => { activeTurn: { turnId: "turn-1", startedAt }, }), "server-1", - ).activeTurn, - ).toEqual({ turnId: "turn-1", startedAt: new Date(startedAt) }); + ).turn, + ).toEqual({ + phase: "open", + turnId: "turn-1", + startedAt: new Date(startedAt), + cancellationRequestId: null, + }); expect( normalizeAgentSnapshot( createSnapshot({ status: "running", lastUserMessageAt: startedAt }), "server-1", - ).activeTurn, - ).toEqual({ turnId: null, startedAt: new Date(startedAt) }); + ).turn, + ).toEqual({ + phase: "open", + turnId: null, + startedAt: new Date(startedAt), + cancellationRequestId: null, + }); }); it("derives parentAgentId from the parent label while preserving labels", () => { diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index a035a054a2..c1c70c640e 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -1,7 +1,11 @@ import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; -import type { ActiveTurnIdentity } from "@/timeline/turn-liveness"; +import { + TURN_LIVENESS_IDLE, + type ActiveTurnIdentity, + type TurnLiveness, +} from "@/timeline/turn-liveness"; import type { Agent } from "@/stores/session-store"; function normalizeActiveTurn( @@ -18,13 +22,23 @@ function normalizeActiveTurn( return snapshot.status === "running" ? { turnId: null, startedAt: lastUserMessageAt } : null; } +function normalizeTurn( + snapshot: AgentSnapshotPayload, + lastUserMessageAt: Date | null, +): TurnLiveness { + const activeTurn = normalizeActiveTurn(snapshot, lastUserMessageAt); + return activeTurn + ? { phase: "open", ...activeTurn, cancellationRequestId: null } + : TURN_LIVENESS_IDLE; +} + function projectActiveTurn(agent: Agent): Pick { - if (agent.activeTurn === null) return { activeTurn: null }; - if (agent.activeTurn.turnId === null) return {}; + if (agent.turn.phase === "idle") return { activeTurn: null }; + if (agent.turn.turnId === null) return {}; return { activeTurn: { - turnId: agent.activeTurn.turnId, - startedAt: agent.activeTurn.startedAt?.toISOString() ?? null, + turnId: agent.turn.turnId, + startedAt: agent.turn.startedAt?.toISOString() ?? null, }, }; } @@ -88,14 +102,14 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: // COMPAT(agentTurnIdentity): added in v0.2.6, remove after 2027-01-31 once daemon floor >= v0.2.6. // Old daemons expose only status. Normalize that legacy signal once so the rest // of the app consumes one activity shape. - const activeTurn = normalizeActiveTurn(snapshot, lastUserMessageAt); + const turn = normalizeTurn(snapshot, lastUserMessageAt); return { serverId, id: snapshot.id, provider: snapshot.provider, status: snapshot.status, - activeTurn, + turn, createdAt, updatedAt, lastUserMessageAt, diff --git a/packages/app/src/utils/workspace-agent-activity.test.ts b/packages/app/src/utils/workspace-agent-activity.test.ts index 09ada95695..7ccca095f1 100644 --- a/packages/app/src/utils/workspace-agent-activity.test.ts +++ b/packages/app/src/utils/workspace-agent-activity.test.ts @@ -6,6 +6,7 @@ function agent(input: { id: string; workspaceId?: string; status?: Agent["status"]; + turn?: Agent["turn"]; updatedAt: string; attentionTimestamp?: string | null; requiresAttention?: boolean; @@ -19,7 +20,16 @@ function agent(input: { id: input.id, provider: "codex", status: input.status ?? "idle", - activeTurn: input.status === "running" ? { turnId: "turn-1", startedAt: null } : null, + turn: + input.turn ?? + (input.status === "running" + ? { + phase: "open", + turnId: "turn-1", + startedAt: null, + cancellationRequestId: null, + } + : { phase: "idle", cancellationRequestId: null }), createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date(input.updatedAt), lastUserMessageAt: null, @@ -56,6 +66,41 @@ function agent(input: { } describe("workspace agent activity index", () => { + it("uses turn liveness for running while preserving protocol lifecycle states", () => { + const result = buildWorkspaceAgentActivityIndex( + new Map([ + [ + "open", + agent({ + id: "open", + workspaceId: "workspace-open", + status: "idle", + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ], + [ + "idle-error", + agent({ + id: "idle-error", + workspaceId: "workspace-error", + status: "error", + turn: { phase: "idle", cancellationRequestId: null }, + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ], + ]), + ); + + expect(result.get("workspace-open")?.status).toBe("running"); + expect(result.get("workspace-error")?.status).toBe("failed"); + }); + it("keeps the latest active root agent for each workspace", () => { const index = buildWorkspaceAgentActivityIndex( new Map([ diff --git a/packages/app/src/utils/workspace-agent-activity.ts b/packages/app/src/utils/workspace-agent-activity.ts index 3e4355ee3a..69f3c23da4 100644 --- a/packages/app/src/utils/workspace-agent-activity.ts +++ b/packages/app/src/utils/workspace-agent-activity.ts @@ -8,6 +8,11 @@ export interface WorkspaceAgentActivity { enteredAt: Date | null; } +function workspaceAgentStatus(agent: Agent): Agent["status"] { + if (agent.turn.phase === "open") return "running"; + return agent.status === "running" ? "idle" : agent.status; +} + export function buildWorkspaceAgentActivityIndex( agents: ReadonlyMap, previous?: ReadonlyMap, @@ -29,7 +34,7 @@ export function buildWorkspaceAgentActivityIndex( latestActivityAtByWorkspaceId.set(agent.workspaceId, enteredAt); const status = deriveSidebarStateBucket({ - status: agent.status, + status: workspaceAgentStatus(agent), pendingPermissionCount: agent.pendingPermissions.length, requiresAttention: agent.requiresAttention, attentionReason: agent.attentionReason, diff --git a/packages/app/src/workspace-tabs/agent-visibility.test.ts b/packages/app/src/workspace-tabs/agent-visibility.test.ts index 8488c8f515..bece625a89 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.test.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts @@ -23,7 +23,7 @@ function makeAgent(input: { id: input.id, provider: "codex", status: "idle", - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt, updatedAt: createdAt, lastUserMessageAt: null, diff --git a/packages/app/src/workspace/legacy-daemon-workspaces.test.ts b/packages/app/src/workspace/legacy-daemon-workspaces.test.ts index 7f50c4b57a..50dc1a8ca1 100644 --- a/packages/app/src/workspace/legacy-daemon-workspaces.test.ts +++ b/packages/app/src/workspace/legacy-daemon-workspaces.test.ts @@ -5,7 +5,6 @@ import { deriveWorkspaceAgentVisibility } from "@/workspace-tabs/agent-visibilit import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure"; import { applyLegacyDaemonWorkspaceOwnership, - backfillLegacyDaemonWorkspaceDirectoryIfEmpty, buildLegacyDaemonWorkspaceSnapshot, } from "./legacy-daemon-workspaces"; @@ -197,47 +196,4 @@ describe("buildLegacyDaemonWorkspaceSnapshot", () => { expect(stampedUpdate.workspaceId).toBe("/repo/app"); expect(visibility.activeAgentIds).toEqual(new Set(["agent-running"])); }); - - it("does not backfill path-backed workspaces after hydration is cancelled", async () => { - const store = useSessionStore.getState(); - store.initializeSession(SERVER_ID, null as unknown as DaemonClient); - store.updateSessionServerInfo(SERVER_ID, { - serverId: SERVER_ID, - hostname: null, - version: "0.1.96", - }); - let cancelled = false; - let didFetchAgents = false; - const client: Pick = { - fetchAgents: async () => { - didFetchAgents = true; - cancelled = true; - return { - requestId: "req_cancelled_backfill", - subscriptionId: null, - entries: [legacyAgent({ id: "agent-cancelled", cwd: "/repo/app" })], - pageInfo: { - nextCursor: null, - prevCursor: null, - hasMore: false, - }, - }; - }, - }; - - const didBackfill = await backfillLegacyDaemonWorkspaceDirectoryIfEmpty({ - client, - serverId: SERVER_ID, - workspaces: new Map(), - projects: new Map(), - isCancelled: () => cancelled, - }); - - const session = useSessionStore.getState().sessions[SERVER_ID]; - expect(didFetchAgents).toBe(true); - expect(didBackfill).toBe(true); - expect(session?.agents.size).toBe(0); - expect(session?.workspaces.size).toBe(0); - expect(session?.hasHydratedWorkspaces).toBe(false); - }); }); diff --git a/packages/app/src/workspace/legacy-daemon-workspaces.ts b/packages/app/src/workspace/legacy-daemon-workspaces.ts index 6de82a3f3e..8303cfadec 100644 --- a/packages/app/src/workspace/legacy-daemon-workspaces.ts +++ b/packages/app/src/workspace/legacy-daemon-workspaces.ts @@ -7,17 +7,13 @@ import { deriveAgentStateBucket, getWorkspaceStateBucketPriority, } from "@getpaseo/protocol/agent-state-bucket"; -import type { - Agent, - DaemonServerInfo, - ProjectDescriptor, - WorkspaceDescriptor, -} from "@/stores/session-store"; -import { useSessionStore } from "@/stores/session-store"; import { - buildAgentDirectoryState, - replaceFetchedAgentDirectory, -} from "@/utils/agent-directory-sync"; + useSessionStore, + type Agent, + type DaemonServerInfo, + type WorkspaceDescriptor, +} from "@/stores/session-store"; +import { buildAgentDirectoryState } from "@/utils/agent-directory-sync"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; export interface LegacyDaemonWorkspaceSnapshot { @@ -25,23 +21,11 @@ export interface LegacyDaemonWorkspaceSnapshot { workspaces: Map; } -export interface LegacyDaemonWorkspaceFetchResult extends LegacyDaemonWorkspaceSnapshot { - subscriptionId: string | null; -} - interface LegacyDaemonWorkspaceDirectoryReadResult { entries: FetchAgentsEntry[]; subscriptionId: string | null; } -interface LegacyDaemonWorkspaceBackfillInput { - client: Pick; - serverId: string; - workspaces: ReadonlyMap; - projects: ReadonlyMap; - isCancelled?: () => boolean; -} - const LEGACY_AGENT_DIRECTORY_SORT: NonNullable = [ { key: "updated_at", direction: "desc" }, ]; @@ -82,50 +66,6 @@ function shouldBackfillLegacyDaemonWorkspaceDirectory( return serverInfo?.features?.workspaceMultiplicity !== true; } -export async function fetchLegacyDaemonWorkspaceDirectory(input: { - client: Pick; - serverId: string; - subscribe?: FetchAgentsOptions["subscribe"]; - page?: FetchAgentsOptions["page"]; -}): Promise { - const directory = await readLegacyDaemonWorkspaceDirectory(input); - if (!directory) { - throw new Error("Legacy daemon workspace directory fetch was cancelled."); - } - const snapshot = replaceLegacyDaemonWorkspaceDirectory({ - serverId: input.serverId, - entries: directory.entries, - }); - return { ...snapshot, subscriptionId: directory.subscriptionId }; -} - -export async function backfillLegacyDaemonWorkspaceDirectoryIfEmpty( - input: LegacyDaemonWorkspaceBackfillInput, -): Promise { - if (input.workspaces.size > 0 || input.projects.size > 0) { - return false; - } - const serverInfo = useSessionStore.getState().sessions[input.serverId]?.serverInfo; - if (!shouldBackfillLegacyDaemonWorkspaceDirectory(serverInfo)) { - return false; - } - if (input.isCancelled?.()) { - return true; - } - const directory = await readLegacyDaemonWorkspaceDirectory({ - client: input.client, - isCancelled: input.isCancelled, - }); - if (!directory || input.isCancelled?.()) { - return true; - } - replaceLegacyDaemonWorkspaceDirectory({ - serverId: input.serverId, - entries: directory.entries, - }); - return true; -} - export async function readLegacyDaemonWorkspaceDirectory(input: { client: Pick; subscribe?: FetchAgentsOptions["subscribe"]; @@ -182,10 +122,11 @@ export function applyLegacyDaemonWorkspaceOwnership(input: { } const existingAgent = - session?.agents.get(input.agent.id) ?? session?.agentDetails.get(input.agent.id) ?? null; + session?.agents.get(input.agent.id) ?? session?.agentDetails.get(input.agent.id); + const workspaces = session?.workspaces; const workspaceId = existingAgent?.workspaceId ?? - resolveLegacyWorkspaceIdFromAgent(input.agent, session?.workspaces) ?? + resolveLegacyWorkspaceIdFromAgent(input.agent, workspaces) ?? null; if (!workspaceId) { return input.agent; @@ -198,37 +139,6 @@ export function applyLegacyDaemonWorkspaceOwnership(input: { }; } -export function replaceLegacyDaemonWorkspaceDirectory(input: { - serverId: string; - entries: FetchAgentsEntry[]; -}): LegacyDaemonWorkspaceSnapshot { - const entries = stampLegacyWorkspaceIds(input.entries); - const { agents } = replaceFetchedAgentDirectory({ - serverId: input.serverId, - entries, - }); - const workspaces = buildLegacyWorkspaces(entries); - const store = useSessionStore.getState(); - store.setWorkspaces(input.serverId, workspaces); - store.setProjects( - input.serverId, - Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace), - ); - store.setHasHydratedWorkspaces(input.serverId, true); - return { agents, workspaces }; -} - -function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor { - return { - projectId: workspace.projectId, - projectKey: null, - projectDisplayName: workspace.projectDisplayName, - projectCustomName: workspace.projectCustomName ?? null, - projectRootPath: workspace.projectRootPath, - projectKind: workspace.projectKind, - }; -} - function readFetchAgentsHasMore( pageInfo: Awaited>["pageInfo"], ): boolean { @@ -369,7 +279,7 @@ function resolveLegacyWorkspaceId(entry: FetchAgentsEntry): string { function resolveLegacyWorkspaceIdFromAgent( agent: Agent, - workspaces: Map | undefined, + workspaces: ReadonlyMap | undefined, ): string | null { const cwd = normalizeWorkspacePath(agent.cwd); if (!cwd) { diff --git a/packages/app/src/workspace/workspace-archive.test.ts b/packages/app/src/workspace/workspace-archive.test.ts index b4db600e60..e7fe660f3b 100644 --- a/packages/app/src/workspace/workspace-archive.test.ts +++ b/packages/app/src/workspace/workspace-archive.test.ts @@ -1,3 +1,5 @@ +import { seedSessionHosts } from "@/test/seed-session"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -85,10 +87,12 @@ function storedWorkspace(id: string): WorkspaceDescriptor | undefined { } beforeEach(() => { + seedSessionHosts([SERVER_ID, SECOND_SERVER_ID]); useSessionStore.getState().initializeSession(SERVER_ID, {} as DaemonClient); }); afterEach(() => { + seedSessionHosts([]); clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-1" }); clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-2" }); clearWorkspaceArchivePending({ serverId: SECOND_SERVER_ID, workspaceId: "workspace-1" }); @@ -99,7 +103,7 @@ afterEach(() => { describe("archiveWorkspaceOptimistically", () => { it("hides the workspace and marks the archive pending while the daemon call runs", async () => { const archived = workspace(); - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]); const releaseArchive = deferred(); const client = createClient(vi.fn(async () => releaseArchive.promise)); @@ -124,7 +128,7 @@ describe("archiveWorkspaceOptimistically", () => { it("restores the workspace and clears pending state when the daemon rejects the archive", async () => { const archived = workspace(); - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]); const client = createClient( vi.fn(async () => archivePayload({ workspaceId: archived.id, error: "nope" })), ); @@ -154,7 +158,7 @@ describe("archiveWorkspacesOptimistically", () => { workspaceDirectory: "/repo/project/workspace-2", name: "workspace-2", }); - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [first, second]); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [first, second]); const client = createClient( vi.fn(async (workspaceId) => archivePayload({ @@ -183,8 +187,8 @@ describe("archiveWorkspacesOptimistically", () => { name: "workspace-2", }); useSessionStore.getState().initializeSession(SECOND_SERVER_ID, {} as DaemonClient); - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [first]); - useSessionStore.getState().mergeWorkspaces(SECOND_SERVER_ID, [second]); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [first]); + getHostRuntimeStore().acceptWorkspaceSnapshots(SECOND_SERVER_ID, [second]); const archivedByServer = new Map(); const clientFor = (serverId: string) => diff --git a/packages/app/src/workspace/workspace-archive.ts b/packages/app/src/workspace/workspace-archive.ts index 0bb4bcf5f9..a4e5a00e80 100644 --- a/packages/app/src/workspace/workspace-archive.ts +++ b/packages/app/src/workspace/workspace-archive.ts @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { clearWorkspaceArchivePending, markWorkspaceArchivePending, @@ -50,7 +51,7 @@ function hideWorkspaceOptimistically( serverId: workspace.serverId, workspaceId: workspace.workspaceId, }); - useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId); + getHostRuntimeStore().removeWorkspaceSnapshot(workspace.serverId, workspace.workspaceId); return { workspace: snapshot }; } @@ -64,7 +65,7 @@ function restoreOptimisticallyHiddenWorkspace(input: { workspaceId: input.workspaceId, }); if (input.snapshot.workspace) { - useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot.workspace]); + getHostRuntimeStore().acceptWorkspaceSnapshots(input.serverId, [input.snapshot.workspace]); } } diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index b3d79b023b..da173431fe 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -945,6 +945,87 @@ test("does not reconnect after close when ensureConnected is called", async () = expect(client.getConnectionState().status).toBe("disposed"); }); +test("ensureConnected reconnects immediately without leaving the scheduled retry armed", async () => { + useHeartbeatClock(); + try { + const first = createMockTransport(); + const second = createMockTransport(); + const third = createMockTransport(); + const transports = [first, second, third]; + let transportIndex = 0; + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_immediate_reconnect", + reconnect: { enabled: true, baseDelayMs: 1_500, maxDelayMs: 1_500 }, + transportFactory: () => { + const transport = transports[transportIndex]; + if (!transport) throw new Error("unexpected extra reconnect"); + transportIndex += 1; + return transport.transport; + }, + }); + clients.push(client); + + const initialConnect = client.connect(); + first.triggerOpen(); + await initialConnect; + first.triggerClose({ code: 1001, reason: "app resumed" }); + expect(client.getConnectionState().status).toBe("disconnected"); + + client.ensureConnected(); + expect(client.getConnectionState().status).toBe("connecting"); + expect(transportIndex).toBe(2); + + second.triggerOpen(); + expect(client.getConnectionState().status).toBe("connected"); + await vi.advanceTimersByTimeAsync(1_500); + + expect(client.getConnectionState().status).toBe("connected"); + expect(transportIndex).toBe(2); + } finally { + vi.useRealTimers(); + } +}); + +test("disabling reconnect cancels a pending retry until explicitly resumed", async () => { + useHeartbeatClock(); + try { + const first = createMockTransport(); + const second = createMockTransport(); + const transports = [first, second]; + let transportIndex = 0; + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_paused_reconnect", + reconnect: { enabled: true, baseDelayMs: 1_500, maxDelayMs: 1_500 }, + transportFactory: () => { + const transport = transports[transportIndex]; + if (!transport) throw new Error("unexpected extra reconnect"); + transportIndex += 1; + return transport.transport; + }, + }); + clients.push(client); + + const initialConnect = client.connect(); + first.triggerOpen(); + await initialConnect; + first.triggerClose({ code: 1001, reason: "app backgrounded" }); + + client.setReconnectEnabled(false); + await vi.advanceTimersByTimeAsync(1_500); + expect(client.getConnectionState().status).toBe("disconnected"); + expect(transportIndex).toBe(1); + + client.setReconnectEnabled(true); + client.ensureConnected(); + expect(client.getConnectionState().status).toBe("connecting"); + expect(transportIndex).toBe(2); + } finally { + vi.useRealTimers(); + } +}); + test("keeps the transport connected when a session RPC ping times out", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 4b3e4d7e3c..8121b6e91c 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1402,6 +1402,14 @@ export class DaemonClient { ) { return; } + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + if (this.connectPromise) { + this.attemptConnect(); + return; + } void this.connect(); } @@ -5962,6 +5970,10 @@ export class DaemonClient { setReconnectEnabled(enabled: boolean): void { this.config = { ...this.config, reconnect: { ...this.config.reconnect, enabled } }; + if (!enabled && this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } } private scheduleReconnect(input?: { From ab7d66dfeddc3d8a680fbdfdf6017254e9ff9192 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 6 Sep 2026 10:19:40 +0200 Subject: [PATCH 016/566] Let Hub continue agents through the standard daemon API (#4354) * feat(hub): continue agents through standard daemon operations Authorize ordinary agent and workspace recovery operations with hub.execute. Keep conversation routing in Hub and persist generic creation and message receipts so retries cannot silently duplicate work. * fix(hub): expose custom providers to continuing clients Allow Hub to advertise provider support independently of the app version. Document the companion continuation policy and migration. Preserve newly observed files in the native watcher inventory so coalesced deletions remain visible to reconciliation, addressing the Windows CI failure. * fix(hub): allow retries after confirmed local failures Prepare message loading before recording dispatch intent. Remove failed creation receipts only when creation cleanup completed and no live or stored agent exists. Keep ambiguous provider deliveries fail-closed. Exercise native file observation with real filesystem dependencies. --- docs/hub.md | 91 ++++------ docs/permissions.md | 2 +- packages/client/src/daemon-client.test.ts | 22 ++- packages/client/src/daemon-client.ts | 10 ++ packages/protocol/src/client-capabilities.ts | 2 + packages/protocol/src/messages.ts | 7 + .../src/server/agent/create-agent/create.ts | 3 +- .../src/server/agent/requests/index.test.ts | 143 +++++++++++++++ .../server/src/server/agent/requests/index.ts | 138 ++++++++++++++ .../src/server/authorization/index.test.ts | 44 +++++ .../server/src/server/authorization/index.ts | 2 +- .../authorization/operation-permissions.ts | 80 +++++---- .../internal/native-recursive.ts | 8 +- .../file-observer/native-recursive.test.ts | 65 +++++++ .../hub/execution-session.websocket.test.ts | 154 ++++++++++++++-- .../hub/test-utils/relationship-harness.ts | 16 +- packages/server/src/server/session.test.ts | 2 + packages/server/src/server/session.ts | 170 +++++++++--------- .../session.workspace-git-watch.test.ts | 2 + ...on.workspace-resolution-invariants.test.ts | 2 + .../src/server/session.workspaces.test.ts | 10 ++ .../snapshot-mutation-ownership.test.ts | 2 + .../src/server/test-utils/session-stubs.ts | 12 ++ .../server/src/server/websocket-server.ts | 6 + .../server/src/server/wire-compat.test.ts | 2 + public-docs/hub/configuration/hub-yml.md | 24 +++ public-docs/hub/security.md | 10 ++ public-docs/hub/triggers/index.md | 31 ++++ 28 files changed, 862 insertions(+), 198 deletions(-) create mode 100644 packages/server/src/server/agent/requests/index.test.ts create mode 100644 packages/server/src/server/agent/requests/index.ts create mode 100644 packages/server/src/server/file-observer/native-recursive.test.ts diff --git a/docs/hub.md b/docs/hub.md index e1686a873d..dddad153e0 100644 --- a/docs/hub.md +++ b/docs/hub.md @@ -25,69 +25,48 @@ during interactive login or later with `paseo hub permissions grant hub.execute` created before this split migrate their legacy execution scope to `hub.execute`. Hub sessions cannot manage their own relationship or permissions. -## Session grants and execution ownership +## Session grants and agent operations -Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies -semantic permissions: trusted clients receive the owner permission set, while an enrolled Hub -connection receives its persisted permissions. Legacy relationship files carrying -`hub.execution.*` migrate once to `hub.execute` at the persistence boundary. A denied request returns -the ordinary `rpc_error` shape. See [permissions.md](permissions.md). +Hub uses the same authenticated, resumable Session protocol as other clients. Its persisted +`hub.execute` permission authorizes ordinary agent creation, messaging, cancellation, archival, +agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is +daemon-wide; it is not limited to agents created by that Hub. Daemon configuration, terminals, +browser control, and permission management still require their own permissions. See +[permissions.md](permissions.md). -The Hub connection still has a narrow lifecycle boundary: it has no trusted-client hello/resume, -browser, binary, retained-session, or broadcast state. Its outbound execution events include only -agents owned by that daemon identity, so unrelated local agents remain outside the Hub surface. +Clients using this contract check `server_info.features.hubAgentRpc` and +`server_info.features.agentRequestReceipts` once. An older host must be upgraded; do not silently +fall back to creating a fresh agent when continuation was requested. -Each Hub create carries an execution ID. The daemon stores that ID with the agent's relationship -owner before acknowledging creation. Duplicate or replayed creates for the same daemon and -execution resolve to the same durable agent. After a lost response, reconnect, or daemon restart, -the Hub retries `hub.execution.agent.create.request` with the same execution ID. The idempotent -response returns the existing agent and its current state; there is no separate reconciliation RPC. -Transient stream frames are not durably replayed. +Hub owns conversation keys, trigger policy, execution records, and the mapping to workspace/agent +IDs. None of these routing concepts are part of the generic daemon RPCs. Creation accepts ordinary +provider configuration, exact MCP tool policy, environment, and workspace/worktree selection. +Private session configuration is persisted for recovery and is not exposed in agent snapshots. +Provider controls remain provider-native; see [providers.md](providers.md). -Daemon restart preserves the Hub relationship and owned execution identity, but interrupts any -active turn. The daemon persists that agent as `closed`; an idempotent create retry returns the same -daemon, execution, and agent identity with that terminal state. Paseo never stores or automatically -replays the original prompt. A duplicate create returns the existing agent without starting another -turn. +`create_agent_request.idempotencyKey` identifies one creation operation. With a key, omit +`initialPrompt`, persist the returned agent/workspace identity, then deliver the prompt using +`send_agent_message_request` with a stable `messageId`. Request IDs correlate individual attempts; +creation keys and message IDs identify the operation across attempts. A creation key is daemon-wide; +a message ID is scoped to its agent. Reusing either with different arguments is a conflict. -Every Hub execution creates a fresh Paseo workspace. The workspace owns the execution's agents and -terminals. Local checkout and worktree targets select only the workspace backing and isolation; the -Hub cannot select or reuse an existing workspace. Hub creates use the same agent creation path as -trusted clients. They may select any worktree target shape and carry optional MCP server configuration and provider-native -`providerOptions` for the agent session. The daemon keeps that configuration in its private agent -record so provider sessions can recover after a restart; neither ordinary client snapshots and -updates nor Hub projections expose session configuration. See [providers.md](providers.md) for the -supported provider keys. +The daemon journals the assigned agent ID before creation. Concurrent retries share one operation, +and a retry after a lost acknowledgement or restart returns the durable agent without creating a +workspace or starting a turn. Deleting that agent does not make its old creation key reusable. +Confirmed message delivery is also journaled, so retrying its message ID does not submit it again. +An unfinished delivery after restart reports `agent_request_outcome_unknown`: a provider can have +accepted a prompt before the daemon recorded success, so automatic resubmission could duplicate +work. Inspect the agent before choosing a new message ID. Receipts contain identity and request +hashes, never prompts, environment values, or credentials. -Hub tool preapproval is a private, structured list of `{ kind: "mcp", server, tool }` references. -Every reference must name an MCP server injected by the same create request. The daemon translates -only those identities into the selected provider's native approval configuration. The protocol -cannot name or preapprove native tools such as Bash, Edit, or Write. Explicit local or managed ask -and deny policy takes precedence. Providers without exact MCP preapproval support reject unattended -Hub creation instead of broadening access or waiting for an invisible prompt. -When a create request includes a tool policy, a successful response includes -`toolPolicyApplied: true`; absence of that acknowledgement is not success for unattended execution. +Before messaging an archived workspace, call `workspace.recovery.inspect.request`, then +`workspace.recovery.restore.request` and await success. The native message handler unarchives the +agent and loads its persisted provider session. `activeTurnBehavior: "steer"` uses the provider's +native steering behavior, including Paseo's existing behavior when that provider cannot steer. +Execution completion and arrival-specific output authority remain Hub responsibilities. -A Hub workflow may use read-only provider settings for classifier steps. This is defense in depth, -not a security boundary: classifier labels and prompt intent do not authorize tools. Exact MCP grants -and the provider's local or managed policy remain the authorization controls. - -Execution completion policy remains outside the daemon: a completed agent turn does not imply that -the Hub execution is terminal. - -The Hub ends an execution by sending `hub.execution.control.request` with the durable execution ID -and either `interrupt` or `archive`. The daemon resolves the agent from the authenticated daemon -relationship plus that execution ID; callers cannot supply an agent ID or workspace path. Both -actions are idempotent and continue to resolve from stored ownership after daemon restart. -If no execution exists for that authenticated daemon and execution ID, interrupt and archive return -success because the requested stopped or archived state already holds. An execution owned by another -daemon is indistinguishable from a missing execution and is never exposed or affected. - -Interrupt uses the ordinary agent cancellation lifecycle. Archive resolves the execution agent's -required workspaceId and sends it through the shared workspace archive service. The service archives -that workspace's agents and terminals, then removes Paseo-owned backing directories only after their -final active workspace reference disappears. Local checkouts remain on disk; sibling workspaces -sharing a backing directory remain active. +The older `hub.execution.*` RPCs remain accepted for existing clients. Their execution ownership +and create deduplication behavior are unchanged; new continuation work uses the ordinary RPCs. ## Disconnect and revocation diff --git a/docs/permissions.md b/docs/permissions.md index 93f013b698..f4c8f6b048 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -27,7 +27,7 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre | `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts | | `workspace.manage` | Create, rename, archive, and remove projects and workspaces | | `automation.manage` | Schedules, heartbeats, and loops | -| `hub.execute` | The Hub-owned execution lifecycle | +| `hub.execute` | Agent lifecycle, agent/workspace observation, and workspace recovery | 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. diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index da173431fe..f599226744 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -294,6 +294,24 @@ test("advertises consumer-provided browser automation capabilities", async () => }); }); +test("retry-safe creation rejects older hosts before sending any request", async () => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "receipt-gate", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + transport.triggerOpen(); + await connecting; + await expect( + client.createAgent({ provider: "codex", cwd: "/project", idempotencyKey: "creation" }), + ).rejects.toThrow("Update the host to use retry-safe agent creation."); + expect(transport.sent).toEqual([]); +}); + test("Hub management requires daemon support before dispatching requests", async () => { const mock = createMockTransport(); const client = new DaemonClient({ @@ -2586,10 +2604,11 @@ test("sends create_agent_request with workspace and caller identity", async () = clients.push(client); const connectPromise = client.connect(); - mock.triggerOpen(); + mock.triggerOpen({ features: { agentRequestReceipts: true } }); await connectPromise; const createPromise = client.createAgent({ + idempotencyKey: "one-creation", provider: "codex", cwd: "/tmp/project/.paseo/worktrees/feature-a", workspaceId: "ws-feature-a", @@ -2603,6 +2622,7 @@ test("sends create_agent_request with workspace and caller identity", async () = expect(request).toEqual( expect.objectContaining({ type: "create_agent_request", + idempotencyKey: "one-creation", workspaceId: "ws-feature-a", callerAgentId: "parent-agent", }), diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 8121b6e91c..ea4ec2d121 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -366,6 +366,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { workspaceId?: string; callerAgentId?: string; initialPrompt?: string; + idempotencyKey?: string; clientMessageId?: string; outputSchema?: Record; images?: CreateAgentRequestMessage["images"]; @@ -2503,6 +2504,7 @@ export class DaemonClient { // ============================================================================ async createAgent(options: CreateAgentRequestOptions): Promise { + if (options.idempotencyKey !== undefined) this.requireAgentRequestReceipts(); const requestId = this.createRequestId(options.requestId); const config = resolveAgentConfig(options); @@ -2514,6 +2516,7 @@ export class DaemonClient { ...(options.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}), ...(options.callerAgentId !== undefined ? { callerAgentId: options.callerAgentId } : {}), ...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}), + idempotencyKey: options.idempotencyKey, ...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}), ...(options.outputSchema ? { outputSchema: options.outputSchema } : {}), ...(options.images && options.images.length > 0 ? { images: options.images } : {}), @@ -2555,6 +2558,13 @@ export class DaemonClient { return status.agent; } + private requireAgentRequestReceipts(): void { + // COMPAT(agentRequestReceipts): added in v0.7.3; remove gate after 2027-03-05. + if (this.lastServerInfoMessage?.features?.agentRequestReceipts !== true) { + throw new Error("Update the host to use retry-safe agent creation."); + } + } + async deleteAgent(agentId: string): Promise { const requestId = this.createRequestId(); const message = SessionInboundMessageSchema.parse({ diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index fbf977ced9..b67dd347d3 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -1,4 +1,6 @@ export const CLIENT_CAPS = { + // Clients outside the Paseo app version series can explicitly accept provider IDs. + allProviders: "all_providers", // COMPAT(selectiveAgentTimeline): added in v0.1.106. Capable clients receive // agent streams only for their explicit viewed set. Remove after 2027-01-12 // once the supported client floor is >= v0.1.106. diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 125a7a27bb..797cab3908 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1677,6 +1677,8 @@ export type CreateAgentWorktreeTarget = z.infer { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); +async function fixture() { + const directory = await mkdtemp(path.join(tmpdir(), "agent-requests-")); + directories.push(directory); + return { directory, requests: new AgentRequests(directory) }; +} + +test("concurrent and reconstructed creates return the same durable agent", async () => { + const { requests, directory } = await fixture(); + const agents = new Set(); + let creations = 0; + const input = { + key: "create-1", + request: { provider: "test", cwd: "/project" }, + findAgent: async (id: string) => agents.has(id), + create: async (id: string) => { + creations++; + agents.add(id); + }, + }; + const ids = await Promise.all([requests.create(input), requests.create(input)]); + expect(ids[0]).toBe(ids[1]); + expect(await new AgentRequests(directory).create(input)).toBe(ids[0]); + expect(creations).toBe(1); +}); + +test("recovers creation when the agent was persisted before acknowledgement failed", async () => { + const { requests, directory } = await fixture(); + const agents = new Set(); + const input = { + key: "lost-create", + request: {}, + findAgent: async (id: string) => agents.has(id), + create: async (id: string) => { + agents.add(id); + throw new Error("acknowledgement lost"); + }, + }; + await expect(requests.create(input)).rejects.toThrow("acknowledgement lost"); + expect(await new AgentRequests(directory).create(input)).toBe([...agents][0]); + expect(agents.size).toBe(1); +}); + +test("reusing a create key with different configuration is a conflict", async () => { + const { requests } = await fixture(); + const input = { + key: "key", + request: { model: "a" }, + findAgent: async () => true, + create: async () => {}, + }; + await requests.create(input); + await expect(requests.create({ ...input, request: { model: "b" } })).rejects.toThrow( + "agent_request_key_conflict", + ); +}); + +test("message retries survive reconstruction without submitting twice", async () => { + const { requests, directory } = await fixture(); + let deliveries = 0; + const input = { + agentId: "agent", + messageId: "arrival", + request: { text: "hello" }, + send: async () => { + deliveries++; + }, + }; + await Promise.all([requests.send(input), requests.send(input)]); + await new AgentRequests(directory).send(input); + expect(deliveries).toBe(1); + await requests.send({ ...input, agentId: "another" }); + expect(deliveries).toBe(2); +}); + +test("ambiguous provider delivery is never blindly replayed after restart", async () => { + const { requests, directory } = await fixture(); + let deliveries = 0; + const input = { + agentId: "agent", + messageId: "arrival", + request: {}, + send: async () => { + deliveries++; + throw new Error("connection lost"); + }, + }; + await expect(requests.send(input)).rejects.toThrow("connection lost"); + await expect(new AgentRequests(directory).send(input)).rejects.toThrow( + "agent_request_outcome_unknown", + ); + expect(deliveries).toBe(1); +}); + +test("a creation failure with no stored agent can be retried", async () => { + const { requests } = await fixture(); + let available = false; + const input = { + key: "unavailable", + request: {}, + findAgent: async () => false, + create: async () => { + if (!available) throw new Error("provider unavailable"); + }, + }; + await expect(requests.create(input)).rejects.toThrow("provider unavailable"); + available = true; + await expect(requests.create(input)).resolves.toEqual(expect.any(String)); +}); + +test("failed local message preparation does not leave an ambiguous receipt", async () => { + const { requests, directory } = await fixture(); + let available = false; + let sends = 0; + const input = { + agentId: "agent", + messageId: "message", + request: {}, + prepare: async () => { + if (!available) throw new Error("load failed"); + }, + send: async () => { + sends++; + }, + }; + await expect(requests.send(input)).rejects.toThrow("load failed"); + available = true; + await new AgentRequests(directory).send(input); + available = false; + await requests.send(input); + expect(sends).toBe(1); +}); diff --git a/packages/server/src/server/agent/requests/index.ts b/packages/server/src/server/agent/requests/index.ts new file mode 100644 index 0000000000..ad64331c35 --- /dev/null +++ b/packages/server/src/server/agent/requests/index.ts @@ -0,0 +1,138 @@ +import { createHash, randomUUID } from "node:crypto"; +import { readFile, rm } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { writeJsonFileAtomic } from "../../atomic-file.js"; + +const ReceiptSchema = z.object({ + fingerprint: z.string(), + agentId: z.string(), + state: z.enum(["pending", "completed"]), +}); +type Receipt = z.infer; + +/** One daemon-owned request journal, shared by all of its socket sessions. */ +export class AgentRequests { + private readonly pending = new Map>(); + + constructor(private readonly directory: string) {} + + create(input: { + key: string; + request: unknown; + findAgent: (agentId: string) => Promise; + create: (agentId: string) => Promise; + }): Promise { + return this.execute(["create", input.key], input.request, { + agentId: randomUUID(), + recover: input.findAgent, + run: input.create, + retrySafe: async (agentId) => !(await input.findAgent(agentId)), + }); + } + + async send(input: { + agentId: string; + messageId: string; + request: unknown; + send: () => Promise; + prepare?: () => Promise; + }): Promise { + await this.execute(["send", input.agentId, input.messageId], input.request, { + agentId: input.agentId, + // A provider call can take effect before the daemon records its outcome. + // Never repeat that call merely because a process died in this window. + recover: async () => false, + run: input.send, + prepare: input.prepare, + }); + } + + private execute( + identity: string[], + request: unknown, + operation: { + agentId: string; + recover: (agentId: string) => Promise; + run: (agentId: string) => Promise; + prepare?: (() => Promise) | undefined; + retrySafe?: (agentId: string) => Promise; + }, + ): Promise { + const key = digest(identity); + const fingerprint = digest(request); + const previous = this.pending.get(key); + // Serialize even conflicting requests: each caller validates its own fingerprint. + const result = (previous ? previous.catch(() => undefined) : Promise.resolve()).then(() => + this.executeOnce(key, fingerprint, operation), + ); + this.pending.set(key, result); + void result + .finally(() => { + if (this.pending.get(key) === result) this.pending.delete(key); + }) + .catch(() => undefined); + return result; + } + + private async executeOnce( + key: string, + fingerprint: string, + operation: { + agentId: string; + recover: (agentId: string) => Promise; + run: (agentId: string) => Promise; + prepare?: (() => Promise) | undefined; + retrySafe?: (agentId: string) => Promise; + }, + ): Promise { + const file = path.join(this.directory, `${key}.json`); + const existing = await readReceipt(file); + if (existing) { + if (existing.fingerprint !== fingerprint) throw new Error("agent_request_key_conflict"); + if (existing.state === "completed") return existing.agentId; + if (!(await operation.recover(existing.agentId))) { + throw new Error("agent_request_outcome_unknown"); + } + await writeJsonFileAtomic(file, { ...existing, state: "completed" }); + return existing.agentId; + } + await operation.prepare?.(); + const receipt: Receipt = { fingerprint, agentId: operation.agentId, state: "pending" }; + await writeJsonFileAtomic(file, receipt); + try { + await operation.run(receipt.agentId); + } catch (error) { + // Keyed creation has no initial prompt. Once its normal cleanup finished, + // absence of an agent confirms that retrying cannot duplicate one. + if (await operation.retrySafe?.(receipt.agentId)) await rm(file, { force: true }); + throw error; + } + await writeJsonFileAtomic(file, { ...receipt, state: "completed" }); + return receipt.agentId; + } +} + +async function readReceipt(file: string): Promise { + try { + return ReceiptSchema.parse(JSON.parse(await readFile(file, "utf8"))); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; + throw error; + } +} + +function digest(value: unknown): string { + return createHash("sha256") + .update( + JSON.stringify(value, (_key, candidate: unknown) => { + if (candidate !== null && typeof candidate === "object" && !Array.isArray(candidate)) { + return Object.fromEntries( + Object.entries(candidate).sort(([a], [b]) => a.localeCompare(b)), + ); + } + return candidate; + }), + ) + .digest("hex"); +} diff --git a/packages/server/src/server/authorization/index.test.ts b/packages/server/src/server/authorization/index.test.ts index 18e7feca2b..b711cb66c0 100644 --- a/packages/server/src/server/authorization/index.test.ts +++ b/packages/server/src/server/authorization/index.test.ts @@ -26,6 +26,11 @@ function inboundMessage(type: SessionInboundMessage["type"]): SessionInboundMess } function outboundMessage(type: SessionOutboundMessage["type"]): SessionOutboundMessage { + if (type === "status") + return { + type, + payload: { status: "agent_create_failed", error: "test", requestId: "test" }, + } as SessionOutboundMessage; return { type } as SessionOutboundMessage; } @@ -69,6 +74,45 @@ describe("SessionAuthorization", () => { ).toBe(false); }); + test("Hub can operate ordinary agents and recover workspaces without daemon administration", () => { + const authorization = new SessionAuthorization(["hub.execute"]); + for (const type of [ + "create_agent_request", + "send_agent_message_request", + "fetch_agent_request", + "agent.timeline.set_subscription.request", + "workspace.recovery.inspect.request", + "workspace.recovery.restore.request", + ] as const) { + expect(authorization.allowsInbound(inboundMessage(type))).toBe(true); + } + for (const type of [ + "status", + "agent_update", + "agent_stream", + "send_agent_message_response", + "workspace.recovery.restore.response", + ] as const) { + expect(authorization.allowsOutbound(outboundMessage(type))).toBe(true); + } + for (const type of [ + "restart_server_request", + "terminal_input", + "hub.management.daemon.permissions.update.request", + ] as const) { + expect(authorization.allowsInbound(inboundMessage(type))).toBe(false); + } + expect( + authorization.allowsOutbound({ + type: "status", + payload: { status: "shutdown_requested", clientId: "owner", requestId: "shutdown" }, + }), + ).toBe(false); + authorization.replacePermissions([]); + expect(authorization.allowsInbound(inboundMessage("send_agent_message_request"))).toBe(false); + expect(authorization.allowsOutbound(outboundMessage("agent_update"))).toBe(false); + }); + test("correlated authorization errors can always be emitted", () => { const authorization = new SessionAuthorization([]); diff --git a/packages/server/src/server/authorization/index.ts b/packages/server/src/server/authorization/index.ts index 38414f33f9..e79f5d28e7 100644 --- a/packages/server/src/server/authorization/index.ts +++ b/packages/server/src/server/authorization/index.ts @@ -34,7 +34,7 @@ export class SessionAuthorization { } allowsOutbound(message: SessionOutboundMessage): boolean { - return this.allows(requiredPermissionForOutbound(message.type)); + return this.allows(requiredPermissionForOutbound(message)); } replacePermissions(permissions: readonly DaemonPermission[]): void { diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 95995bc65e..dd65f47c9d 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -7,7 +7,7 @@ export type PermissionRequirement = DaemonPermission | readonly DaemonPermission const INBOUND_PERMISSION = { abort_request: "workspace.write", - "agent.config.apply.request": "workspace.write", + "agent.config.apply.request": ["workspace.write", "hub.execute"], "agent.detach.request": "workspace.write", "agent.fork_context.request": "workspace.read", "agent.provider_subagents.list.request": "workspace.read", @@ -20,14 +20,14 @@ const INBOUND_PERMISSION = { "agent.skills.uninstall.request": "daemon.manage", "agent.timeline.list_prompts.request": "workspace.read", "agent.timeline.append.request": "workspace.write", - "agent.timeline.set_subscription.request": "workspace.read", + "agent.timeline.set_subscription.request": ["workspace.read", "hub.execute"], agent_permission_response: "workspace.write", - archive_agent_request: "workspace.write", - archive_workspace_request: "workspace.manage", + archive_agent_request: ["workspace.write", "hub.execute"], + archive_workspace_request: ["workspace.manage", "hub.execute"], audio_played: "workspace.write", branch_suggestions_request: "workspace.read", "browser.automation.execute.response": "workspace.write", - cancel_agent_request: "workspace.write", + cancel_agent_request: ["workspace.write", "hub.execute"], capture_terminal_request: "workspace.read", "chat/create": "workspace.write", "chat/delete": "workspace.write", @@ -58,7 +58,7 @@ const INBOUND_PERMISSION = { clear_agent_attention: "workspace.write", client_heartbeat: "workspace.read", close_items_request: "workspace.manage", - create_agent_request: "workspace.write", + create_agent_request: ["workspace.write", "hub.execute"], create_paseo_worktree_request: "workspace.manage", create_terminal_request: "workspace.write", "daemon.config.reload.request": "daemon.manage", @@ -73,11 +73,11 @@ const INBOUND_PERMISSION = { dictation_stream_start: "workspace.write", directory_suggestions_request: "workspace.read", fetch_agent_history_request: "workspace.read", - fetch_agent_request: "workspace.read", - fetch_agent_timeline_request: "workspace.read", - fetch_agents_request: "workspace.read", + fetch_agent_request: ["workspace.read", "hub.execute"], + fetch_agent_timeline_request: ["workspace.read", "hub.execute"], + fetch_agents_request: ["workspace.read", "hub.execute"], fetch_recent_provider_sessions_request: "workspace.read", - fetch_workspaces_request: "workspace.read", + fetch_workspaces_request: ["workspace.read", "hub.execute"], "file.upload.request": "workspace.write", file_download_token_request: "workspace.read", file_explorer_request: "workspace.read", @@ -159,7 +159,7 @@ const INBOUND_PERMISSION = { "schedule/resume": "automation.manage", "schedule/run-once": "automation.manage", "schedule/update": "automation.manage", - send_agent_message_request: "workspace.write", + send_agent_message_request: ["workspace.write", "hub.execute"], set_agent_feature_request: "workspace.write", set_agent_mode_request: "workspace.write", set_agent_model_request: "workspace.write", @@ -192,8 +192,8 @@ const INBOUND_PERMISSION = { "workspace.label.list.request": "workspace.read", "workspace.label.update.request": "workspace.manage", "workspace.pin.set.request": "workspace.manage", - "workspace.recovery.inspect.request": "workspace.read", - "workspace.recovery.restore.request": "workspace.manage", + "workspace.recovery.inspect.request": ["workspace.read", "hub.execute"], + "workspace.recovery.restore.request": ["workspace.manage", "hub.execute"], "workspace.script.list.request": "workspace.read", "workspace.script.start.request": "workspace.write", "workspace.script.stop.request": "workspace.write", @@ -205,7 +205,7 @@ const INBOUND_PERMISSION = { const OUTBOUND_PERMISSION = { activity_log: "workspace.read", - "agent.config.apply.response": "workspace.write", + "agent.config.apply.response": ["workspace.write", "hub.execute"], "agent.detach.response": "workspace.write", "agent.fork_context.response": "workspace.read", "agent.provider_subagents.list.response": "workspace.read", @@ -219,23 +219,23 @@ const OUTBOUND_PERMISSION = { "agent.skills.uninstall.response": "daemon.manage", "agent.timeline.list_prompts.response": "workspace.read", "agent.timeline.append.response": "workspace.write", - "agent.timeline.replacement": "workspace.read", - "agent.timeline.set_subscription.response": "workspace.read", - agent_archived: "workspace.read", - agent_attention_required: "workspace.read", - agent_deleted: "workspace.read", - agent_permission_request: "workspace.read", - agent_permission_resolved: "workspace.write", - agent_status: "workspace.read", - agent_stream: "workspace.read", - agent_update: "workspace.read", - archive_workspace_response: "workspace.manage", + "agent.timeline.replacement": ["workspace.read", "hub.execute"], + "agent.timeline.set_subscription.response": ["workspace.read", "hub.execute"], + agent_archived: ["workspace.read", "hub.execute"], + agent_attention_required: ["workspace.read", "hub.execute"], + agent_deleted: ["workspace.read", "hub.execute"], + agent_permission_request: ["workspace.read", "hub.execute"], + agent_permission_resolved: ["workspace.write", "hub.execute"], + agent_status: ["workspace.read", "hub.execute"], + agent_stream: ["workspace.read", "hub.execute"], + agent_update: ["workspace.read", "hub.execute"], + archive_workspace_response: ["workspace.manage", "hub.execute"], artifact: "workspace.read", assistant_chunk: "workspace.read", audio_output: "workspace.write", branch_suggestions_response: "workspace.read", "browser.automation.execute.request": "workspace.write", - cancel_agent_response: "workspace.write", + cancel_agent_response: ["workspace.write", "hub.execute"], capture_terminal_response: "workspace.read", "chat/create/response": "workspace.write", "chat/delete/response": "workspace.write", @@ -282,11 +282,11 @@ const OUTBOUND_PERMISSION = { dictation_stream_partial: "workspace.write", directory_suggestions_response: "workspace.read", fetch_agent_history_response: "workspace.read", - fetch_agent_response: "workspace.read", - fetch_agent_timeline_response: "workspace.read", - fetch_agents_response: "workspace.read", + fetch_agent_response: ["workspace.read", "hub.execute"], + fetch_agent_timeline_response: ["workspace.read", "hub.execute"], + fetch_agents_response: ["workspace.read", "hub.execute"], fetch_recent_provider_sessions_response: "workspace.read", - fetch_workspaces_response: "workspace.read", + fetch_workspaces_response: ["workspace.read", "hub.execute"], "file.upload.response": "workspace.write", file_download_token_response: "workspace.read", file_explorer_response: "workspace.read", @@ -370,7 +370,7 @@ const OUTBOUND_PERMISSION = { "schedule/run-once/response": "automation.manage", "schedule/update/response": "automation.manage", script_status_update: "workspace.read", - send_agent_message_response: "workspace.write", + send_agent_message_response: ["workspace.write", "hub.execute"], set_agent_feature_response: "workspace.write", set_agent_mode_response: "workspace.write", set_agent_model_response: "workspace.write", @@ -403,8 +403,8 @@ const OUTBOUND_PERMISSION = { "workspace.label.update": "workspace.read", "workspace.label.update.response": "workspace.manage", "workspace.pin.set.response": "workspace.manage", - "workspace.recovery.inspect.response": "workspace.read", - "workspace.recovery.restore.response": "workspace.manage", + "workspace.recovery.inspect.response": ["workspace.read", "hub.execute"], + "workspace.recovery.restore.response": ["workspace.manage", "hub.execute"], "workspace.script.list.response": "workspace.read", "workspace.script.start.response": "workspace.write", "workspace.script.stop.response": "workspace.write", @@ -412,7 +412,7 @@ const OUTBOUND_PERMISSION = { workspace_setup_progress: "workspace.read", workspace_setup_status_response: "workspace.read", "workspace.setup.run.response": "workspace.write", - workspace_update: "workspace.read", + workspace_update: ["workspace.read", "hub.execute"], write_project_config_response: "workspace.write", } as const satisfies Record; @@ -420,6 +420,14 @@ export function requiredPermissionForInbound(operation: InboundOperation): Permi return INBOUND_PERMISSION[operation]; } -export function requiredPermissionForOutbound(operation: OutboundOperation): PermissionRequirement { - return OUTBOUND_PERMISSION[operation]; +export function requiredPermissionForOutbound( + message: SessionOutboundMessage, +): PermissionRequirement { + // The legacy status envelope carries both agent RPC results and daemon configuration. + if ( + message.type === "status" && + (message.payload.status === "agent_created" || message.payload.status === "agent_create_failed") + ) + return ["workspace.write", "hub.execute"]; + return OUTBOUND_PERMISSION[message.type]; } diff --git a/packages/server/src/server/file-observer/internal/native-recursive.ts b/packages/server/src/server/file-observer/internal/native-recursive.ts index de001b232a..02450503d3 100644 --- a/packages/server/src/server/file-observer/internal/native-recursive.ts +++ b/packages/server/src/server/file-observer/internal/native-recursive.ts @@ -149,7 +149,13 @@ class NativeRecursiveBackend implements ObservationBackend { this.host.metrics.nativeRenameEventCount += 1; const knownDirectory = this.directories.has(path); this.classify(path, (isDirectory) => { - if (!isDirectory) return; + if (!isDirectory) { + // Remember files as soon as we announce them. A coalesced delete must + // still be found by the next audit, even before the first directory scan. + this.files.add(path); + this.entries.get(scope)?.files.add(path); + return; + } if (knownDirectory) this.requestAudit(path, true); else this.requestAudit(scope); }); diff --git a/packages/server/src/server/file-observer/native-recursive.test.ts b/packages/server/src/server/file-observer/native-recursive.test.ts new file mode 100644 index 0000000000..cb442729fc --- /dev/null +++ b/packages/server/src/server/file-observer/native-recursive.test.ts @@ -0,0 +1,65 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { expect, test } from "vitest"; +import type { FileChange } from "./index.js"; +import { createNativeRecursiveBackend } from "./internal/native-recursive.js"; +import { createObserverPaths } from "./internal/paths.js"; + +test("the recursive watcher remembers newly announced files before reconciliation", async () => { + const root = await mkdtemp(join(tmpdir(), "paseo-observer-native-")); + const paths = createObserverPaths(process.platform); + const changes: FileChange[] = []; + let active = true; + const backend = createNativeRecursiveBackend( + { + root, + metrics: { + reconciliationCount: 0, + scopedReconciliationCount: 0, + fullReconciliationCount: 0, + reconciliationFailureCount: 0, + observerFailureCount: 0, + directoryLimitFailureCount: 0, + nativeEventCount: 0, + nativeChangeEventCount: 0, + nativeRenameEventCount: 0, + nativePathlessEventCount: 0, + nativeClassificationCount: 0, + nativeShallowScanCount: 0, + lastReconciliationDurationMs: 0, + maxReconciliationDurationMs: 0, + }, + isActive: () => active, + isIgnored: () => false, + isPathInside: paths.isInside, + queueEvent: (type, path) => { + changes.push({ type, path }); + }, + fail: (error) => { + throw error; + }, + }, + paths, + ); + try { + await backend.start(); + const path = join(root, "new.txt"); + await writeFile(path, "created"); + await expect + .poll(() => changes.some((event) => event.path === path && event.type === "create")) + .toBe(true); + // Consumers have seen this file. A later audit needs it in its comparison + // inventory even if the native watcher coalesces the subsequent delete. + expect(backend.getDiagnostics().nativeTrackedFileCount).toBe(1); + await rm(path); + await backend.updateIgnore(); + await expect + .poll(() => changes.some((event) => event.path === path && event.type === "delete")) + .toBe(true); + } finally { + active = false; + await backend.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/hub/execution-session.websocket.test.ts b/packages/server/src/server/hub/execution-session.websocket.test.ts index 925f1a6780..6e9f4036ed 100644 --- a/packages/server/src/server/hub/execution-session.websocket.test.ts +++ b/packages/server/src/server/hub/execution-session.websocket.test.ts @@ -35,30 +35,148 @@ test("Hub retries one durable daemon execution across concurrency and reconstruc expect(reconstructed.durableAgentCount).toBe(1); }); -test("Hub session applies its scoped authorization to ordinary protocol messages", async () => { +test("Hub execute can steer ordinary agents while unrelated administration stays denied", async () => { const hub = await launchRelationship(); - const localAgentId = await hub.createUnrelatedLocalAgent(); - - const steeringDenial = await hub.deniedSteering(localAgentId); - const browserDenial = await hub.deniedBrowserDispatch(); - - expect(steeringDenial).toEqual({ - requestId: "denied-steer", - requestType: "send_agent_message_request", - error: "Session is not authorized for send_agent_message_request", - code: "access_denied", + const agentId = await hub.createUnrelatedLocalAgent(); + const response = await hub.requestOrdinary({ + type: "send_agent_message_request", + requestId: "ordinary-steer", + agentId, + text: "hello", + messageId: "first-arrival", + activeTurnBehavior: "steer", }); - expect(browserDenial).toEqual({ - requestId: "browser-1", - requestType: "browser.automation.execute.response", - error: "Session is not authorized for browser.automation.execute.response", - code: "access_denied", + expect(response).toMatchObject({ + type: "send_agent_message_response", + payload: { accepted: true }, }); - expect(hub.observedAgentIds()).not.toContain(localAgentId); - expect(hub.observedTrustedLifecycleMessages()).toEqual(["server_info"]); + expect(await hub.deniedBrowserDispatch()).toMatchObject({ code: "access_denied" }); expect(hub.serverInfoPermissions()).toEqual([["hub.execute"]]); }); +test("ordinary Hub create and message retries do not duplicate agents or prompts", async () => { + const hub = await launchRelationship(); + const create = { + type: "create_agent_request", + idempotencyKey: "ordinary-create", + config: { provider: "codex", cwd: hub.repoRoot() }, + }; + const responses = await Promise.all([ + hub.requestOrdinary({ ...create, requestId: "create-first" }), + hub.requestOrdinary({ ...create, requestId: "create-duplicate" }), + ]); + const first = responses[0]; + expect(first).toMatchObject({ type: "status", payload: { status: "agent_created" } }); + if (first?.type !== "status" || first.payload.status !== "agent_created") + throw new Error("Agent was not created"); + const agentId = first.payload.agentId; + expect(responses[1]).toMatchObject({ type: "status", payload: { agentId } }); + expect( + await hub.requestOrdinary({ + type: "fetch_agents_request", + requestId: "observe-agents", + subscribe: { subscriptionId: "hub-agents" }, + }), + ).toMatchObject({ type: "fetch_agents_response" }); + expect( + await hub.requestOrdinary({ + type: "agent.timeline.set_subscription.request", + requestId: "observe-timeline", + agentIds: [agentId], + }), + ).toMatchObject({ type: "agent.timeline.set_subscription.response" }); + const message = { + type: "send_agent_message_request", + agentId, + messageId: "ordinary-arrival", + text: "hello", + activeTurnBehavior: "steer", + }; + expect(await hub.requestOrdinary({ ...message, requestId: "message-first" })).toMatchObject({ + payload: { accepted: true }, + }); + expect(await hub.requestOrdinary({ ...message, requestId: "message-duplicate" })).toMatchObject({ + payload: { accepted: true }, + }); + expect( + hub + .hubMessages() + .some((event) => event.type === "agent_stream" && event.payload.agentId === agentId), + ).toBe(true); + expect(hub.providerPromptTexts().filter((text) => text === "hello")).toHaveLength(1); + expect( + await hub.requestOrdinary({ ...message, text: "changed", requestId: "message-conflict" }), + ).toMatchObject({ payload: { accepted: false } }); +}); + +test("ordinary Hub requests survive daemon restart and restore an archived workspace", async () => { + const hub = await launchRelationship(); + const create = { + type: "create_agent_request", + idempotencyKey: "restorable-agent", + config: { provider: "codex", cwd: hub.repoRoot() }, + worktree: { mode: "branch-off", newBranch: "ordinary-restoration" }, + }; + const response = await hub.requestOrdinary({ ...create, requestId: "restorable-create" }); + if (response.type !== "status" || response.payload.status !== "agent_created") + throw new Error("Agent was not created"); + const { agentId, agent } = response.payload; + if (!agent?.workspaceId) throw new Error("Workspace was not created"); + const workspaceId = agent.workspaceId; + const message = { + type: "send_agent_message_request", + agentId, + messageId: "before-restart", + text: "hello", + activeTurnBehavior: "steer", + }; + expect(await hub.requestOrdinary({ ...message, requestId: "before-restart-send" })).toMatchObject( + { payload: { accepted: true } }, + ); + await hub.restartDaemon(); + await hub.socketDialed(); + hub.connectLatestSocket(); + expect(await hub.requestOrdinary({ ...create, requestId: "replayed-create" })).toMatchObject({ + payload: { agentId }, + }); + expect(await hub.requestOrdinary({ ...message, requestId: "replayed-send" })).toMatchObject({ + payload: { accepted: true }, + }); + expect(hub.providerPromptTexts().filter((text) => text === "hello")).toHaveLength(1); + expect( + await hub.requestOrdinary({ + type: "archive_workspace_request", + workspaceId, + requestId: "ordinary-archive", + }), + ).toMatchObject({ payload: { error: null, archivedAt: expect.any(String) } }); + expect((await hub.worktreeState(agent.cwd)).exists).toBe(false); + expect( + await hub.requestOrdinary({ + type: "workspace.recovery.inspect.request", + workspaceId, + requestId: "inspect-recovery", + }), + ).toMatchObject({ payload: { state: { kind: "recoverable" } } }); + expect( + await hub.requestOrdinary({ + type: "workspace.recovery.restore.request", + workspaceId, + requestId: "restore-workspace", + }), + ).toMatchObject({ payload: { accepted: true } }); + expect((await hub.worktreeState(agent.cwd)).exists).toBe(true); + expect( + await hub.requestOrdinary({ + ...message, + text: "follow up", + messageId: "after-restore", + requestId: "after-restore-send", + }), + ).toMatchObject({ payload: { agentId, accepted: true } }); + expect(hub.providerPromptTexts().filter((text) => text === "follow up")).toHaveLength(1); +}, 20_000); + test("Hub completes the standard hello before rejecting a second hello", async () => { const hub = await launchRelationship(); diff --git a/packages/server/src/server/hub/test-utils/relationship-harness.ts b/packages/server/src/server/hub/test-utils/relationship-harness.ts index d29dfe8062..3113b543ad 100644 --- a/packages/server/src/server/hub/test-utils/relationship-harness.ts +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -160,7 +160,9 @@ class MemoryHubSocket extends EventEmitter implements WebSocketLike, HubSocketCo if (typeof data !== "string") return; const frame = JSON.parse(data) as { type: "session"; message: SessionOutboundMessage }; this.sent.push(frame.message); - this.messageObserved.resolve(); + const observed = this.messageObserved; + this.messageObserved = deferred(); + observed.resolve(); } close(code = 1000): void { @@ -192,7 +194,6 @@ class MemoryHubSocket extends EventEmitter implements WebSocketLike, HubSocketCo ); if (message) return message; await this.messageObserved.promise; - this.messageObserved = deferred(); } } @@ -203,7 +204,6 @@ class MemoryHubSocket extends EventEmitter implements WebSocketLike, HubSocketCo const message = this.sent.find(predicate); if (message) return message; await this.messageObserved.promise; - this.messageObserved = deferred(); } } } @@ -728,6 +728,16 @@ export class HubRelationshipHarness { socket.socket.close(code); } + async requestOrdinary(message: { + type: string; + requestId: string; + [key: string]: unknown; + }): Promise { + const socket = this.latestSocket().socket; + socket.receive(message); + return socket.messageFor(message.requestId); + } + sendHubRequestOnLatest(message: unknown): SessionOutboundMessage[] { const socket = this.latestSocket().socket; socket.receive(message); diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index af9fd2d539..3ab519e4ec 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -1,3 +1,4 @@ +import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; import { execSync } from "child_process"; import { existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs"; import { tmpdir } from "os"; @@ -361,6 +362,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { const messages = options.messages ?? []; const sessionOptions: SessionOptions = { + agentRequests: createAgentRequestsStub(), clientId: options.clientId ?? "test-client", onMessage: (message) => messages.push(message), ...(options.targetedMessages diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c5862381cd..957c2ef36b 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,3 +1,4 @@ +import type { AgentRequests } from "./agent/requests/index.js"; import equal from "fast-deep-equal"; import { v4 as uuidv4 } from "uuid"; import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises"; @@ -461,6 +462,7 @@ export interface SessionOptions { worktreesRoot?: string; agentManager: AgentManager; agentStorage: AgentStorage; + agentRequests: Pick; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; directorySync?: DirectorySyncService; @@ -745,6 +747,7 @@ export class Session { private readonly daemonSession: DaemonSession; private readonly hubExecutionController: HubExecutionController | null; private readonly workspaceScripts: WorkspaceScriptsService; + private readonly agentRequests: Pick; private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch; constructor(options: SessionOptions) { @@ -818,6 +821,7 @@ export class Session { this.onWorkspaceRecovered = onWorkspaceRecovered ?? null; this.pushNotifications = pushNotifications; this.paseoHome = paseoHome; + this.agentRequests = options.agentRequests; this.projectIcons = new ProjectIconReader(paseoHome); this.worktreesRoot = worktreesRoot; this.pluginRuntime = pluginRuntime; @@ -1834,7 +1838,7 @@ export class Session { } private isProviderVisibleToClient(provider: string): boolean { - if (clientSupportsAllProviders(this.appVersion)) { + if (this.supports(CLIENT_CAPS.allProviders) || clientSupportsAllProviders(this.appVersion)) { return true; } return LEGACY_PROVIDER_IDS.has(provider); @@ -3525,10 +3529,69 @@ export class Session { * Handle create agent request */ private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise { + try { + let agent: AgentSnapshotPayload; + if (msg.idempotencyKey !== undefined) { + if (msg.initialPrompt !== undefined) { + throw new Error("Idempotent creation requires sending the initial prompt separately"); + } + const { requestId: _requestId, idempotencyKey, ...request } = msg; + const id = await this.agentRequests.create({ + key: idempotencyKey, + request, + findAgent: async (agentId) => + this.agentManager.getAgent(agentId) != null || + (await this.agentStorage.get(agentId)) !== null, + create: async (agentId) => { + await this.createSessionAgent(msg, agentId); + }, + }); + const record = await this.agentStorage.get(id); + if (!record) throw new Error("Previously created agent no longer exists"); + agent = this.buildStoredAgentPayload(record); + } else { + agent = await this.createSessionAgent(msg); + } + this.emit({ + type: "status", + payload: { + status: "agent_created", + agentId: agent.id, + requestId: msg.requestId, + agent, + }, + }); + } catch (error) { + const wireError = toWorktreeWireError(error); + this.sessionLogger.error({ err: error }, "Failed to create agent"); + this.emit({ + type: "status", + payload: { + status: "agent_create_failed", + requestId: msg.requestId, + error: wireError.message, + errorCode: wireError.code, + }, + }); + this.emit({ + type: "activity_log", + payload: { + id: uuidv4(), + timestamp: new Date(), + type: "error", + content: `Failed to create agent: ${wireError.message}`, + }, + }); + } + } + + private async createSessionAgent( + msg: CreateAgentRequestMessage, + agentId?: string, + ): Promise { const { config, worktreeName, - requestId, initialPrompt, clientMessageId, outputSchema, @@ -3594,6 +3657,7 @@ export class Session { }, { kind: "session", + agentId, config: resolvedIntent.config, workspaceId: resolvedIntent.intent.workspaceId, worktreeName, @@ -3628,50 +3692,17 @@ export class Session { agentId: snapshot.id, createdWorktree, }); - if (requestId) { - const agentPayload = await this.buildAgentPayload(liveSnapshot); - this.emit({ - type: "status", - payload: { - status: "agent_created", - agentId: liveSnapshot.id, - requestId, - agent: agentPayload, - }, - }); - } - this.sessionLogger.info( { agentId: snapshot.id, provider: snapshot.provider }, - `Created agent ${snapshot.id} (${snapshot.provider})`, + "Created agent", ); + return this.buildAgentPayload(liveSnapshot); } catch (error) { await this.createAgentLifecycleDispatch.cleanupCreatedWorktreeAfterFailedAgentCreate({ createdWorktree: createdWorktreeForCleanup, createdAgentId, }); - const wireError = toWorktreeWireError(error); - this.sessionLogger.error({ err: error }, "Failed to create agent"); - if (requestId) { - this.emit({ - type: "status", - payload: { - status: "agent_create_failed", - requestId, - error: wireError.message, - errorCode: wireError.code, - }, - }); - } - this.emit({ - type: "activity_log", - payload: { - id: uuidv4(), - timestamp: new Date(), - type: "error", - content: `Failed to create agent: ${wireError.message}`, - }, - }); + throw error; } } @@ -7465,9 +7496,8 @@ export class Session { }, "agent.session.send_agent_message", ); - let dispatchResult: { disposition: "out_of_band" | "steered" | "turn_started" }; - try { - dispatchResult = await sendPromptToAgent({ + const send = async () => { + const result = await sendPromptToAgent({ agentManager: this.agentManager, agentStorage: this.agentStorage, agentId, @@ -7477,47 +7507,26 @@ export class Session { clearPendingPermissions: true, logger: this.sessionLogger, }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.handleAgentRunError(agentId, error, "Failed to send agent message"); - this.emit({ - type: "send_agent_message_response", - payload: { - requestId: msg.requestId, - agentId, - accepted: false, - error: message, + if (result.disposition === "turn_started") { + await waitForAgentRunStartWithTimeout(this.agentManager, agentId); + } + }; + if (msg.messageId) { + await this.agentRequests.send({ + agentId, + messageId: msg.messageId, + request: { prompt, activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt" }, + prepare: async () => { + await ensureAgentLoaded(agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }); }, + send, }); - return; - } - - if (dispatchResult.disposition !== "turn_started") { - this.emit({ - type: "send_agent_message_response", - payload: { - requestId: msg.requestId, - agentId, - accepted: true, - error: null, - }, - }); - return; - } - - try { - await waitForAgentRunStartWithTimeout(this.agentManager, agentId); - } catch (error) { - this.emit({ - type: "send_agent_message_response", - payload: { - requestId: msg.requestId, - agentId, - accepted: false, - error: errorToFriendlyMessage(error), - }, - }); - return; + } else { + await send(); } this.emit({ @@ -7530,6 +7539,7 @@ export class Session { }, }); } catch (error) { + this.handleAgentRunError(resolved.agentId, error, "Failed to send agent message"); this.emit({ type: "send_agent_message_response", payload: { diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 61dce39c5f..69c548b922 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -1,3 +1,4 @@ +import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; import { describe, expect, test, vi } from "vitest"; import path from "node:path"; import type pino from "pino"; @@ -191,6 +192,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { }; const session = new Session({ + agentRequests: createAgentRequestsStub(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message as { type: string; payload: unknown }), diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index b25912beb6..c444d9ed24 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -1,3 +1,4 @@ +import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; // Invariant tests for cwd → workspace resolution on the open_project_request path. // Each test encodes a default behavior we want from `findOrCreateWorkspaceForDirectory`. // Run to see which invariants the current code already satisfies (green) and which @@ -88,6 +89,7 @@ function createHarness(input: { }; const session = new Session({ + agentRequests: createAgentRequestsStub(), clientId: "test", permissions: OWNER_PERMISSIONS, appVersion: null, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 97166cdf76..ac3a928ac1 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1,3 +1,4 @@ +import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; import { execFileSync } from "node:child_process"; import { existsSync, @@ -636,6 +637,7 @@ function createSessionForWorkspaceTests( const session = asTestSession( new Session({ + agentRequests: createAgentRequestsStub(), clientId: "test-client", permissions: OWNER_PERMISSIONS, appVersion: options.appVersion ?? null, @@ -992,6 +994,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist const emitted: SessionOutboundMessage[] = []; const session = asTestSession( new Session({ + agentRequests: createAgentRequestsStub(), clientId: "test-client", serverId: "test-server", permissions: OWNER_PERMISSIONS, @@ -1145,6 +1148,7 @@ test("create_agent_request launches from an exact subdirectory in a created work const emitted: SessionOutboundMessage[] = []; const session = new Session({ + agentRequests: createAgentRequestsStub(), clientId: "test-client", permissions: OWNER_PERMISSIONS, appVersion: null, @@ -1282,6 +1286,7 @@ test("create_agent_request does not title an existing workspace from the agent p let generateCalls = 0; const session = asTestSession( new Session({ + agentRequests: createAgentRequestsStub(), clientId: "test-client", permissions: OWNER_PERMISSIONS, appVersion: null, @@ -1551,6 +1556,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" const session = asTestSession( new Session({ + agentRequests: createAgentRequestsStub(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -1916,6 +1922,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy const cancelAgentRun = vi.fn(async () => ({ status: "settled" as const })); const session = asTestSession( new Session({ + agentRequests: createAgentRequestsStub(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -2084,6 +2091,7 @@ test("close_items_request archives stored agents that are not currently loaded", const session = asTestSession( new Session({ + agentRequests: createAgentRequestsStub(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -2243,6 +2251,7 @@ test("close_items_request continues after an archive failure", async () => { const killTerminalBestEffort = vi.fn(); const session = asTestSession( new Session({ + agentRequests: createAgentRequestsStub(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -3514,6 +3523,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto const session = asTestSession( new Session({ + agentRequests: createAgentRequestsStub(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts index b08ca5e6d0..8e32c27cbe 100644 --- a/packages/server/src/server/snapshot-mutation-ownership.test.ts +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -1,3 +1,4 @@ +import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; import os from "node:os"; import path from "node:path"; import { mkdtempSync, rmSync } from "node:fs"; @@ -99,6 +100,7 @@ describe("snapshot mutation ownership boundary", () => { const session = asInternals( new Session({ + agentRequests: createAgentRequestsStub(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage, diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index 46db4e1749..a490874ff7 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { vi } from "vitest"; import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; @@ -263,3 +264,14 @@ export function createProviderSnapshotManagerStub(): { destroy, }; } + +export function createAgentRequestsStub(): SessionOptions["agentRequests"] { + return { + async create(input) { + const agentId = randomUUID(); + await input.create(agentId); + return agentId; + }, + send: (input) => input.send(), + }; +} diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index ed289eb95e..ac5f19f66b 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,3 +1,4 @@ +import { AgentRequests } from "./agent/requests/index.js"; import { WebSocket, WebSocketServer } from "ws"; import type { IncomingMessage, Server as HTTPServer } from "http"; import { join } from "path"; @@ -546,6 +547,7 @@ export class VoiceAssistantWebSocketServer { private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined; private readonly agentManager: AgentManager; private readonly agentStorage: AgentStorage; + private readonly agentRequests: AgentRequests; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; private readonly workspaceLabelService: WorkspaceLabelService | null; @@ -666,6 +668,7 @@ export class VoiceAssistantWebSocketServer { this.orchestrationSkills = orchestrationSkills; this.agentManager = agentManager; this.agentStorage = agentStorage; + this.agentRequests = new AgentRequests(join(paseoHome, "agent-requests")); this.projectRegistry = projectRegistry ?? createNoopProjectRegistry(); this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry(); this.workspaceLabelService = workspaceLabelService ?? null; @@ -1409,6 +1412,7 @@ export class VoiceAssistantWebSocketServer { worktreesRoot: this.worktreesRoot, agentManager: this.agentManager, agentStorage: this.agentStorage, + agentRequests: this.agentRequests, projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, workspaceLabelService: this.workspaceLabelService ?? undefined, @@ -1628,6 +1632,8 @@ export class VoiceAssistantWebSocketServer { desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true, ...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}), features: { + agentRequestReceipts: true, + hubAgentRpc: true, // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. directorySync: true, // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index 08cba47149..a51c4646f7 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -1,3 +1,4 @@ +import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; import pino from "pino"; import { z } from "zod"; import { describe, expect, test } from "vitest"; @@ -206,6 +207,7 @@ function createSessionForWireCompatTest(options?: { ]; const session = new Session({ + agentRequests: createAgentRequestsStub(), clientId: "wire-compat-client", permissions: OWNER_PERMISSIONS, clientCapabilities: options?.clientCapabilities ?? null, diff --git a/public-docs/hub/configuration/hub-yml.md b/public-docs/hub/configuration/hub-yml.md index ffc046ef25..0bc82abf16 100644 --- a/public-docs/hub/configuration/hub-yml.md +++ b/public-docs/hub/configuration/hub-yml.md @@ -255,3 +255,27 @@ Keep `environments` in `hub.yml`, convert the environment list to a named map, a Hub does not read TOML or a monolithic `triggers` section, and the CLI does not rewrite either format. See [Workflows](/docs/hub/workflows) for complete routing examples. + +## Agent continuation + +Self-contained dashboard trigger documents accept `run.continuation`: + +| Value | Behavior | +| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `{mode: conversation}` | Default. Reuse the project's agent for the event's conversation; create a new agent when the event has no conversation. | +| `{mode: key, key: "support-${{ paseo.inputs.ticket }}"}` | Reuse the project's agent for the evaluated custom key. | +| `{mode: new}` | Create a new agent for each arrival. | + +Keys use the existing expression syntax and must resolve to a non-empty string of at most 512 characters. Custom keys and provider conversation identities occupy separate namespaces. The same key in different projects does not share an agent. + +An existing session keeps its daemon, agent configuration, target, environment, and tool contracts. Changing those settings for the same key fails with an explanation; choose a different key or **New agent**. Prompts and output destinations belong to each arrival and may change. A worktree branch is chosen when the session is first created and reused on later arrivals. + +Triggers that mint temporary environment credentials, including a `run.github` grant or a connection token in `run.env`, must choose **New agent** when the event has a conversation or uses a custom key. These credentials expire with the execution, and an existing agent's environment cannot be refreshed. Hub-managed reply tools remain available with continuation. + +### Upgrading + +Upgrade the connected Paseo daemons before enabling the new Hub version. Hub requires the daemon's ordinary agent RPC and request receipt capabilities; an older host produces an actionable dispatch error. + +Hub's database migration adds sessions and nullable execution associations. Existing executions retain their saved launch contract and execution-specific MCP endpoint until they finish. New arrivals for existing self-contained trigger documents use the conversation default. Historical agents are not backfilled into sessions. + +Legacy multi-step workflow bundles keep their existing behavior. Converting one to a self-contained trigger writes `mode: new` explicitly; change it in the editor when ready to reuse agents. diff --git a/public-docs/hub/security.md b/public-docs/hub/security.md index e02577f8ee..7b3b9f94a6 100644 --- a/public-docs/hub/security.md +++ b/public-docs/hub/security.md @@ -16,6 +16,16 @@ external event → Hub → daemon → provider process → cwd, filesystem, netw The host, provider credentials, filesystem, network, and resulting actions remain under your control. See [Paseo security](/docs/security) for daemon authentication, pairing, and relay boundaries. +## Choose daemon authority + +Connecting a daemon does not grant Hub permission to run agents. Grant `hub.execute` when you want +Hub automations to use it. This permission allows Hub to create and continue agents, inspect their +state and timelines, and archive or restore workspaces on that daemon. It also applies to existing +agents and workspaces; it is not an isolation boundary around Hub-created work. + +Daemon administration, terminal control, and permission management require separate permissions. +You can revoke `hub.execute` without granting Hub the ability to change its own permissions. + ## Treat requests as untrusted Start with narrow provider filters: diff --git a/public-docs/hub/triggers/index.md b/public-docs/hub/triggers/index.md index eb34349b3f..fa595d34b1 100644 --- a/public-docs/hub/triggers/index.md +++ b/public-docs/hub/triggers/index.md @@ -40,6 +40,37 @@ Changing the daemon or working directory reloads the choices. If an existing tri If the daemon is offline or needs a newer Paseo version, the agent selectors show an error and a retry action. The rest of the trigger and its YAML remain editable. +## Continue the same agent + +Dashboard triggers default to **Same conversation**. Messages in the same Slack or Discord thread, events on the same GitHub issue or pull request, and events on the same Linear issue continue the existing agent in that project. An event without a conversation starts a new agent. + +If the agent is busy, the new prompt steers its current work. If its workspace is archived, Hub asks Paseo to restore it before sending the prompt. Each arrival still has its own deadline, output limits, and completion status. + +Choose **Custom key** to group arrivals by an input, or **New agent** to keep them separate. A self-contained trigger document can express the same choice: + +```yaml +name: support +on: + slack.mention: + filters: + from_users: [U01234567] +run: + target: + daemon: laptop + cwd: /Users/you/code/support + agent: + provider: codex + continuation: + mode: conversation + max_runtime: 30m + idle_timeout: 5m + prompt: Handle this request and call finish_execution when complete. +``` + +The [continuation reference](/docs/hub/configuration/hub-yml#agent-continuation) describes keys and compatibility. The run detail shows whether each arrival created, continued, or restored an agent. + +Hub includes an `executionId` in each prompt. When using a continuing agent, pass that ID to `reply` and `finish_execution`. These tools act on that arrival's destination and contract; an old or unrelated execution ID is rejected. + ## Events | `on` | Fires when | From ae80ec42529c388167f16581dc61fd9de682f1ba Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 6 Sep 2026 10:22:26 +0200 Subject: [PATCH 017/566] Let plugins contribute settings screens (#4357) * feat(plugins): contribute settings screens and shared controls * fix(plugins): localize settings and verify shared UI consumers * test(composer): unmount draft test roots after each case * test(app): keep launch terminals alive until cleanup The fixed ten-second profile lifetime could expire before a busy CI browser attached or finished revisiting the composer. Keep the real process waiting for input until project teardown terminates it. * test(server): await the initial snapshot before a buffered ref event Watcher setup can read checkout facts before the initial refresh publishes a snapshot. Wait for publication before injecting the event so the test consistently exercises a second refresh. --- docs/design.md | 2 +- docs/expo-router.md | 3 + docs/forms.md | 8 + docs/plugins.md | 14 +- .../new-workspace-launch-memory.spec.ts | 6 +- .../new-workspace-launch-terminal.spec.ts | 11 +- .../app/e2e/browser/plugin-settings.spec.ts | 62 ++++++ .../app/e2e/support/helpers/new-workspace.ts | 2 + .../e2e/support/helpers/plugin-settings.ts | 108 +++++++++++ .../settings/agent-profiles-section.tsx | 2 +- packages/app/src/agent-skills/index.tsx | 2 +- .../app/src/agent-skills/selection-sheet.tsx | 2 +- packages/app/src/app/_layout.tsx | 1 + .../plugins/[pluginId]/[screenId].tsx | 22 +++ .../settings/headings}/settings-group.tsx | 2 +- .../settings/headings}/settings-info-tip.tsx | 0 .../settings/headings}/settings-section.tsx | 2 +- .../app/src/components/settings/index.tsx | 182 ++++++++++++++++++ .../composer/draft/input-draft.live.test.tsx | 75 ++++---- .../browser/settings/browser-data-section.tsx | 2 +- .../desktop-notifications-section.tsx | 2 +- .../desktop-permissions-section.tsx | 2 +- .../components/desktop-updates-section.tsx | 2 +- .../components/integrations-section.tsx | 2 +- packages/app/src/i18n/resources/en.ts | 6 + .../app/src/i18n/resources/plugin-settings.ts | 48 +++++ .../app/src/navigation/settings-navigation.ts | 6 +- packages/app/src/plugins/actions.ts | 6 + packages/app/src/plugins/catalog-sync.tsx | 10 + .../app/src/plugins/client-runtime.test.ts | 1 + .../command-center/contributions.test.ts | 4 + packages/app/src/plugins/evaluate.test.ts | 17 +- packages/app/src/plugins/evaluate.ts | 22 ++- packages/app/src/plugins/navigation.ts | 4 + packages/app/src/plugins/react-native/ui.ts | 10 + packages/app/src/plugins/registry.ts | 1 + packages/app/src/plugins/settings/index.tsx | 128 ++++++++++++ packages/app/src/plugins/settings/routes.ts | 6 + .../app/src/plugins/settings/use-settings.ts | 83 ++++++++ .../app/src/plugins/sidebar-groups.test.ts | 1 + .../src/plugins/surface-contribution.test.ts | 1 + .../src/plugins/surface-error-boundary.tsx | 2 + packages/app/src/plugins/theme.test.ts | 1 + .../app/src/plugins/timeline/model.test.ts | 1 + .../app/src/plugins/timeline/view.test.tsx | 10 - packages/app/src/plugins/types.ts | 3 + .../workspace-panels/resolution.test.ts | 1 + .../src/provider-usage/settings-section.tsx | 2 +- .../src/screens/project-settings-screen.tsx | 4 +- .../app/src/screens/projects-screen.test.tsx | 38 +--- packages/app/src/screens/settings-screen.tsx | 31 ++- .../appearance/appearance-section.tsx | 46 ++--- .../appearance/sidebar-nav-section.tsx | 2 +- .../src/screens/settings/editor-section.tsx | 2 +- .../settings/host-appearance-section.tsx | 2 +- .../app/src/screens/settings/host-page.tsx | 2 +- .../settings/keyboard-shortcuts-section.tsx | 2 +- .../settings/layout/layout-section.tsx | 119 +++--------- .../settings/metadata-generation-page.tsx | 2 +- .../screens/settings/plugins-page.test.tsx | 5 + .../app/src/screens/settings/plugins-page.tsx | 70 ++++--- .../settings/providers-section.test.tsx | 2 +- .../screens/settings/providers-section.tsx | 2 +- packages/app/vitest.setup.ts | 17 ++ packages/plugin/package.json | 5 + packages/plugin/src/contracts.ts | 12 ++ packages/plugin/src/host.ts | 1 + packages/plugin/src/index.ts | 8 + packages/plugin/src/settings.ts | 65 +++++++ packages/plugin/src/ui.ts | 56 ++++++ packages/protocol/src/messages.ts | 8 + packages/server/src/server/bootstrap.ts | 1 + packages/server/src/server/plugins/index.ts | 25 ++- .../server/plugins/plugin-process-protocol.ts | 11 +- .../src/server/plugins/plugin-process.ts | 30 ++- .../server/plugins/plugin-sdk-specifiers.ts | 1 + packages/server/src/server/plugins/runtime.ts | 11 +- .../src/server/plugins/settings.e2e.test.ts | 73 +++++++ .../src/server/plugins/settings/index.test.ts | 131 +++++++++++++ .../src/server/plugins/settings/index.ts | 133 +++++++++++++ packages/server/src/server/session.ts | 13 +- .../server/src/server/websocket-server.ts | 1 + .../workspace-git-service.observation.test.ts | 6 +- plugin-examples/settings/README.md | 14 ++ .../settings/client/display-settings.tsx | 148 ++++++++++++++ plugin-examples/settings/index.client.tsx | 21 ++ plugin-examples/settings/index.server.ts | 7 + plugin-examples/settings/paseo-plugin.json | 1 + .../settings/screenshots/android.png | Bin 0 -> 75487 bytes .../settings/screenshots/electron.png | Bin 0 -> 47456 bytes .../settings/screenshots/web-compact.png | Bin 0 -> 20680 bytes .../settings/shared/preferences.ts | 13 ++ public-docs/plugins/v0.8/migration.md | 33 ++-- public-docs/plugins/v0.8/reference.md | 121 +++++++++++- 94 files changed, 1890 insertions(+), 295 deletions(-) create mode 100644 packages/app/e2e/browser/plugin-settings.spec.ts create mode 100644 packages/app/e2e/support/helpers/plugin-settings.ts create mode 100644 packages/app/src/app/settings/hosts/[serverId]/plugins/[pluginId]/[screenId].tsx rename packages/app/src/{screens/settings => components/settings/headings}/settings-group.tsx (96%) rename packages/app/src/{screens/settings => components/settings/headings}/settings-info-tip.tsx (100%) rename packages/app/src/{screens/settings => components/settings/headings}/settings-section.tsx (96%) create mode 100644 packages/app/src/components/settings/index.tsx create mode 100644 packages/app/src/plugins/react-native/ui.ts create mode 100644 packages/app/src/plugins/settings/index.tsx create mode 100644 packages/app/src/plugins/settings/routes.ts create mode 100644 packages/app/src/plugins/settings/use-settings.ts create mode 100644 packages/plugin/src/settings.ts create mode 100644 packages/plugin/src/ui.ts create mode 100644 packages/server/src/server/plugins/settings.e2e.test.ts create mode 100644 packages/server/src/server/plugins/settings/index.test.ts create mode 100644 packages/server/src/server/plugins/settings/index.ts create mode 100644 plugin-examples/settings/README.md create mode 100644 plugin-examples/settings/client/display-settings.tsx create mode 100644 plugin-examples/settings/index.client.tsx create mode 100644 plugin-examples/settings/index.server.ts create mode 100644 plugin-examples/settings/paseo-plugin.json create mode 100644 plugin-examples/settings/screenshots/android.png create mode 100644 plugin-examples/settings/screenshots/electron.png create mode 100644 plugin-examples/settings/screenshots/web-compact.png create mode 100644 plugin-examples/settings/shared/preferences.ts diff --git a/docs/design.md b/docs/design.md index b98e06cb72..0b54be46a3 100644 --- a/docs/design.md +++ b/docs/design.md @@ -120,7 +120,7 @@ Workspace and chat surfaces use the full width — these are working surfaces, n Sections sit apart. `` owns its own bottom margin; the next thing is wrapped in another ``. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong. -A section or group explains itself through the `info` prop on `` or `` — an info icon beside the header that opens a tooltip (`packages/app/src/screens/settings/settings-info-tip.tsx`). A muted paragraph between the header and the card is wrong: it sits in the section's own gap, so it reads as a second heading rather than as prose belonging to the header. Explanatory copy that describes one row belongs to that row, as `settingsStyles.rowHint` inside the card. +A section or group explains itself through the `info` prop on `` or `` — an info icon beside the header that opens a tooltip (`packages/app/src/components/settings/headings/settings-info-tip.tsx`). A muted paragraph between the header and the card is wrong: it sits in the section's own gap, so it reads as a second heading rather than as prose belonging to the header. Explanatory copy that describes one row belongs to that row, as `settingsStyles.rowHint` inside the card. Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight. diff --git a/docs/expo-router.md b/docs/expo-router.md index 835798426a..d6eaf00374 100644 --- a/docs/expo-router.md +++ b/docs/expo-router.md @@ -77,6 +77,9 @@ only use local param fallback during cold mount (`/` or empty pathname), or a hidden workspace can overwrite the remembered workspace before Settings or History returns. +Plugin settings use the distinct `settings/hosts/[serverId]/plugins/[pluginId]/[screenId]` leaf; +Back returns to that host's Plugins page. + Settings detail routes are separate siblings on purpose. Keep `settings/[section]`, the host routes, the projects index, and project detail as distinct route names. `router.dismissTo()` ultimately matches stack entries by diff --git a/docs/forms.md b/docs/forms.md index cbb1c2d951..073dde368f 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -78,6 +78,14 @@ complete`), keyed off the opened snapshot's serverId. Waiting for data is a timezone) once — never in a preview line AND a helper line. - `useUnistyles` is banned (see docs/unistyles.md); lint enforces. +## Settings rows + +Use the named components from `@/components/settings` for settings cards and controls. +`SettingsCard` owns row separators; `SettingsRow` accepts custom content. Heading-only screens +can import `SettingsGroup` and `SettingsSection` directly from their modules in +`@/components/settings/headings/`. +The plugin UI entry exposes the same components; keep them independent of storage and routing. + ## Data gating Aggregate hooks return a discriminated load state: diff --git a/docs/plugins.md b/docs/plugins.md index ae8a241c88..1a101e5c5c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,7 +1,7 @@ # Plugins Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items, -client slash commands, timeline items, composer pills, app themes, and composer attachment sources. +client slash commands, timeline items, composer pills, app themes, composer attachment sources, and settings screens. Paseo executes `index.server.ts` in a subprocess and `index.client.tsx` in every connected app. > **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host. @@ -440,6 +440,18 @@ on several hosts are not coalesced. The selected snapshot submits as a text atta external-resource presentation, so it remains readable if the plugin is removed or an older peer drops the optional presentation fields. +## Contribute settings + +Register ordinary components with `client.addSettingsScreen` and open them with `openSettings`. +The host settings shell owns navigation and layout; plugin content must not add another page +scroll view or header. See the [author contract](../public-docs/plugins/v0.8/reference.md#settings-screens) +and `plugin-examples/settings` for the named UI components and persistence API. + +Settings storage is scoped to the runtime installation ID, never the source path or manifest ID. +Its writer lives with the plugin subprocess, while its directory lives outside managed sources, +so updates and reloads retain values. Settings-change notifications must not enter the catalog +reload path: that path disposes the plugin and would destroy open drafts after every save. + ## Contribute a theme `addTheme` takes a small light or dark palette and a display name. Paseo expands it through the diff --git a/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts b/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts index 39b734d4f9..f2ee89c12f 100644 --- a/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts +++ b/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts @@ -21,13 +21,13 @@ import { type TerminalProfileSeed, } from "../support/helpers/new-workspace-launch"; -// `sleep` keeps the terminal alive long enough for the UI to attach and -// render output before the process exits — see new-workspace-launch-terminal.spec.ts. +// Wait for input until project cleanup kills the terminal. A fixed lifetime +// can expire before a busy browser attaches or finishes revisiting the composer. const PROMPT_PROFILE: TerminalProfile = { id: "e2e-memory-profile", name: "Memory Profile", command: "/bin/sh", - args: ["-c", 'echo remembered: "$0"; sleep 10', "{{{prompt}}}"], + args: ["-c", 'echo remembered: "$0"; exec cat', "{{{prompt}}}"], }; function hasLeftNewWorkspaceRoute(url: URL): boolean { diff --git a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts index dd249cb03a..16b277b537 100644 --- a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts +++ b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts @@ -25,14 +25,13 @@ import { // // The sentinel becomes `$0` (a positional shell parameter, not string // interpolation into the script source), so the prompt reaches the process -// exactly as typed with no injection risk. `sleep` keeps the terminal alive -// long enough for the UI to attach and render the output before it exits — -// a bare `echo` exits before the frontend subscribes to its output. +// exactly as typed with no injection risk. `cat` waits for input until project +// cleanup kills the terminal, so attachment does not race a fixed process lifetime. const PROMPT_PROFILE: TerminalProfile = { id: "e2e-echo-prompt", name: "Echo Prompt", command: "/bin/sh", - args: ["-c", 'echo captured: "$0"; sleep 10', "{{{prompt}}}"], + args: ["-c", 'echo captured: "$0"; exec cat', "{{{prompt}}}"], }; // No sentinel anywhere: takes no prompt, composer goes read-only. @@ -40,7 +39,7 @@ const BARE_PROFILE: TerminalProfile = { id: "e2e-echo-bare", name: "Bare Echo", command: "/bin/sh", - args: ["-c", "echo bare-launch-static-line; sleep 10"], + args: ["-c", "echo bare-launch-static-line; exec cat"], }; test.describe("New workspace: launching a terminal", () => { @@ -88,7 +87,7 @@ test.describe("New workspace: launching a terminal", () => { }); await selectLaunchOption(page, BARE_PROFILE.id); await expect(terminalPromptInput(page)).toHaveCount(0); - await expectTerminalPreviewCommand(page, "/bin/sh -c echo bare-launch-static-line; sleep 10"); + await expectTerminalPreviewCommand(page, "/bin/sh -c echo bare-launch-static-line; exec cat"); await expect(terminalLaunchSubmit(page)).toHaveText("Launch"); }); diff --git a/packages/app/e2e/browser/plugin-settings.spec.ts b/packages/app/e2e/browser/plugin-settings.spec.ts new file mode 100644 index 0000000000..29f42988ab --- /dev/null +++ b/packages/app/e2e/browser/plugin-settings.spec.ts @@ -0,0 +1,62 @@ +import { + test, + openDisplaySettings, + openCompactDisplaySettings, + groupByWorkspace, + hideMetadata, + reloadSavedDisplaySettings, + editTitle, + replaceTitle, + saveTitle, + expectTitleRequired, + expectSavedTitle, + changeMetadataFromAnotherClient, + expectConflictPreservesDraft, + discardTitle, + returnAndReopenSettings, + expectSettingsUnavailable, +} from "../support/helpers/plugin-settings"; + +test("display preferences survive a reload", async ({ page }) => { + await openDisplaySettings(page); + await groupByWorkspace(page); + await hideMetadata(page); + await reloadSavedDisplaySettings(page); +}); + +test("invalid titles stay editable until a valid title is saved", async ({ page }) => { + await openDisplaySettings(page); + await editTitle(page, ""); + await saveTitle(page); + await expectTitleRequired(page); + await replaceTitle(page, "My monitor"); + await saveTitle(page); + await expectSavedTitle(page, "My monitor"); +}); + +test("another client's save preserves a conflicting draft", async ({ page }) => { + await openDisplaySettings(page); + await editTitle(page, "Unsaved title"); + await changeMetadataFromAnotherClient(page); + await saveTitle(page); + await expectConflictPreservesDraft(page, "Unsaved title"); + await discardTitle(page); +}); + +test.describe("compact plugin settings", () => { + test.use({ viewport: { width: 390, height: 844 } }); + + test("Back and plugin re-enable restore the settings screen", async ({ + page, + settingsPlugin, + }) => { + await openCompactDisplaySettings(page); + await groupByWorkspace(page); + await hideMetadata(page); + await returnAndReopenSettings(page); + await settingsPlugin.disablePlugin("settings-example"); + await expectSettingsUnavailable(page); + await settingsPlugin.enablePlugin("settings-example"); + await reloadSavedDisplaySettings(page); + }); +}); diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index 6ac5d89871..05e8cddc9a 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -20,6 +20,8 @@ type NewWorkspaceDaemonClient = Pick< | "getPaseoWorktreeList" | "getDaemonConfig" | "installDirectoryPlugin" + | "disablePlugin" + | "enablePlugin" | "inspectWorkspaceRecovery" | "listProjects" | "on" diff --git a/packages/app/e2e/support/helpers/plugin-settings.ts b/packages/app/e2e/support/helpers/plugin-settings.ts new file mode 100644 index 0000000000..d8dfb020e4 --- /dev/null +++ b/packages/app/e2e/support/helpers/plugin-settings.ts @@ -0,0 +1,108 @@ +import path from "node:path"; +import { expect, test as base, type Page } from "../fixtures"; +import { gotoAppShell, openSettings } from "./app"; +import { goBackInSettings, openCompactSettings, openHostSection } from "./settings"; +import { getServerId } from "./server-id"; +import { connectNewWorkspaceDaemonClient } from "./new-workspace"; + +const directory = path.resolve(__dirname, "../../../../../plugin-examples/settings"); +type SettingsClient = Awaited>; + +export const test = base.extend<{ settingsPlugin: SettingsClient }>({ + settingsPlugin: [ + async ({ e2eWorker, page }, provide, testInfo) => { + void e2eWorker; + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await client.getDaemonConfig(); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + await provide(client); + await page.screenshot({ path: testInfo.outputPath("settings.png") }); + } finally { + await client.removePlugin("settings-example"); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }); + await client.close(); + } + }, + { auto: true }, + ], +}); + +export async function openDisplaySettings(page: Page) { + await gotoAppShell(page); + await openSettings(page); + await openPluginScreen(page); +} +export async function openCompactDisplaySettings(page: Page) { + await gotoAppShell(page); + await openCompactSettings(page, new URL(page.url()).pathname); + await openPluginScreen(page); +} +async function openPluginScreen(page: Page) { + await openHostSection(page, getServerId(), "plugins"); + await page.getByRole("button", { name: "Open", exact: true }).click(); + await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible(); +} +export async function groupByWorkspace(page: Page) { + await page.getByRole("button", { name: "Group agents by", exact: true }).click(); + await page.getByRole("menuitem", { name: "Workspace", exact: true }).click(); + await expect(page.getByText("Grouped by workspace", { exact: true })).toBeVisible(); +} +export async function hideMetadata(page: Page) { + await page.getByRole("switch", { name: "Show metadata" }).click(); + await expect(page.getByText("Metadata hidden", { exact: true })).toBeVisible(); +} +export async function reloadSavedDisplaySettings(page: Page) { + await page.reload(); + await expect(page.getByText("Grouped by workspace", { exact: true })).toBeVisible(); + await expect(page.getByRole("switch", { name: "Show metadata" })).not.toBeChecked(); +} +export async function editTitle(page: Page, title: string) { + await page.getByRole("button", { name: "Edit title", exact: true }).click(); + await replaceTitle(page, title); +} +export async function replaceTitle(page: Page, title: string) { + await page.getByRole("textbox", { name: "Monitor title", exact: true }).fill(title); +} +export async function saveTitle(page: Page) { + await page.getByRole("button", { name: "Save title", exact: true }).click(); +} +export async function expectTitleRequired(page: Page) { + await expect(page.getByText(/Enter a title/).first()).toBeVisible(); +} +export async function expectSavedTitle(page: Page, title: string) { + await expect(page.getByText(title, { exact: true })).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Monitor title", exact: true })).toHaveCount(0); +} +export async function changeMetadataFromAnotherClient(page: Page) { + const peer = await page.context().newPage(); + try { + await peer.goto(page.url()); + await hideMetadata(peer); + await expect(page.getByText("Metadata hidden", { exact: true })).toBeVisible(); + } finally { + await peer.close(); + } +} +export async function expectConflictPreservesDraft(page: Page, title: string) { + await expect(page.getByText(/Settings changed on another client/)).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Monitor title", exact: true })).toHaveValue( + title, + ); +} +export async function discardTitle(page: Page) { + await page.getByRole("button", { name: "Discard", exact: true }).click(); + await expectSavedTitle(page, "Agent monitor"); +} +export async function returnAndReopenSettings(page: Page) { + await goBackInSettings(page); + await expect(page.getByRole("button", { name: "Open", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Open", exact: true }).click(); + await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible(); +} +export async function expectSettingsUnavailable(page: Page) { + await expect( + page.getByText("This plugin settings screen is unavailable.", { exact: true }), + ).toBeVisible(); +} diff --git a/packages/app/src/agent-profiles/settings/agent-profiles-section.tsx b/packages/app/src/agent-profiles/settings/agent-profiles-section.tsx index 37f94bfc0c..0cbee5d4d6 100644 --- a/packages/app/src/agent-profiles/settings/agent-profiles-section.tsx +++ b/packages/app/src/agent-profiles/settings/agent-profiles-section.tsx @@ -7,7 +7,7 @@ import type { AgentProfile } from "@getpaseo/protocol/messages"; import { Button } from "@/components/ui/button"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { settingsStyles } from "@/styles/settings"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { confirmDialog } from "@/utils/confirm-dialog"; diff --git a/packages/app/src/agent-skills/index.tsx b/packages/app/src/agent-skills/index.tsx index 044c6acd1d..04fdbd2294 100644 --- a/packages/app/src/agent-skills/index.tsx +++ b/packages/app/src/agent-skills/index.tsx @@ -7,7 +7,7 @@ import { ArrowUpRight, Blocks, Check, Settings2 } from "lucide-react-native"; import type { AgentSkillOperation, AgentSkillsStatus } from "@getpaseo/protocol/messages"; import type { TFunction } from "i18next"; import { Button } from "@/components/ui/button"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { settingsStyles } from "@/styles/settings"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { confirmDialog } from "@/utils/confirm-dialog"; diff --git a/packages/app/src/agent-skills/selection-sheet.tsx b/packages/app/src/agent-skills/selection-sheet.tsx index 923d098cee..c161747c4c 100644 --- a/packages/app/src/agent-skills/selection-sheet.tsx +++ b/packages/app/src/agent-skills/selection-sheet.tsx @@ -6,7 +6,7 @@ import { Check } from "lucide-react-native"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { settingsStyles } from "@/styles/settings"; import type { Theme } from "@/styles/theme"; import { confirmDialog } from "@/utils/confirm-dialog"; diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 148d431921..f2b0755122 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -902,6 +902,7 @@ function RootStack() { + diff --git a/packages/app/src/app/settings/hosts/[serverId]/plugins/[pluginId]/[screenId].tsx b/packages/app/src/app/settings/hosts/[serverId]/plugins/[pluginId]/[screenId].tsx new file mode 100644 index 0000000000..1fa5ad7f85 --- /dev/null +++ b/packages/app/src/app/settings/hosts/[serverId]/plugins/[pluginId]/[screenId].tsx @@ -0,0 +1,22 @@ +import { useMemo } from "react"; + +import { useLocalSearchParams } from "expo-router"; +import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; +import SettingsScreen from "@/screens/settings-screen"; + +export default function PluginSettingsRoute() { + const { serverId, pluginId, screenId } = useLocalSearchParams<{ + serverId: string; + pluginId: string; + screenId: string; + }>(); + const view = useMemo( + () => ({ kind: "plugin" as const, serverId, pluginId, screenId }), + [serverId, pluginId, screenId], + ); + return ( + + + + ); +} diff --git a/packages/app/src/screens/settings/settings-group.tsx b/packages/app/src/components/settings/headings/settings-group.tsx similarity index 96% rename from packages/app/src/screens/settings/settings-group.tsx rename to packages/app/src/components/settings/headings/settings-group.tsx index 25e4087292..e13bc6017a 100644 --- a/packages/app/src/screens/settings/settings-group.tsx +++ b/packages/app/src/components/settings/headings/settings-group.tsx @@ -1,7 +1,7 @@ import { useMemo, type ReactNode } from "react"; import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { SettingsInfoTip } from "@/screens/settings/settings-info-tip"; +import { SettingsInfoTip } from "./settings-info-tip"; interface SettingsGroupProps { title: string; diff --git a/packages/app/src/screens/settings/settings-info-tip.tsx b/packages/app/src/components/settings/headings/settings-info-tip.tsx similarity index 100% rename from packages/app/src/screens/settings/settings-info-tip.tsx rename to packages/app/src/components/settings/headings/settings-info-tip.tsx diff --git a/packages/app/src/screens/settings/settings-section.tsx b/packages/app/src/components/settings/headings/settings-section.tsx similarity index 96% rename from packages/app/src/screens/settings/settings-section.tsx rename to packages/app/src/components/settings/headings/settings-section.tsx index d3a4a4b01c..28b19f3589 100644 --- a/packages/app/src/screens/settings/settings-section.tsx +++ b/packages/app/src/components/settings/headings/settings-section.tsx @@ -1,7 +1,7 @@ import { useMemo, type ReactNode } from "react"; import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { SettingsInfoTip } from "@/screens/settings/settings-info-tip"; +import { SettingsInfoTip } from "./settings-info-tip"; import { settingsStyles } from "@/styles/settings"; interface SettingsSectionProps { diff --git a/packages/app/src/components/settings/index.tsx b/packages/app/src/components/settings/index.tsx new file mode 100644 index 0000000000..371709f60f --- /dev/null +++ b/packages/app/src/components/settings/index.tsx @@ -0,0 +1,182 @@ +import { + Children, + isValidElement, + useCallback, + useImperativeHandle, + useMemo, + useRef, + type ReactNode, +} from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import type { + SettingsRowProps, + SettingsSwitchProps, + SettingsSelectProps, + SettingsInputProps, + SettingsActionProps, +} from "@getpaseo/plugin/ui"; +import type { EditingTextInputHandle } from "@/components/ui/text-input"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { FormTextInput } from "@/components/ui/form-field"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { settingsStyles } from "@/styles/settings"; +export { SettingsGroup } from "./headings/settings-group"; +export { SettingsSection } from "./headings/settings-section"; + +export function SettingsCard({ children, testID }: { children: ReactNode; testID?: string }) { + return ( + + {Children.toArray(children).map((child, index) => ( + + {child} + + ))} + + ); +} + +export function SettingsRow({ label, hint, error, children, testID }: SettingsRowProps) { + const compact = useIsCompactFormFactor(); + const rowStyle = useMemo(() => [settingsStyles.row, compact && styles.compactRow], [compact]); + return ( + + + {label} + {hint ? {hint} : null} + {error ? ( + + {error} + + ) : null} + + {children ? {children} : null} + + ); +} + +export function SettingsSwitch({ value, onValueChange, disabled, ...row }: SettingsSwitchProps) { + return ( + + + + ); +} + +function SettingsOption({ + option, + selected, + onValueChange, +}: { + option: { label: string; value: Value }; + selected: boolean; + onValueChange(value: Value): void; +}) { + const select = useCallback(() => onValueChange(option.value), [onValueChange, option.value]); + return ( + + {option.label} + + ); +} + +export function SettingsSelect({ + value, + options, + onValueChange, + disabled, + ...row +}: SettingsSelectProps) { + return ( + + + + + {options.find((option) => option.value === value)?.label ?? value} + + + + {options.map((option) => ( + + ))} + + + + ); +} + +export function SettingsInput({ + initialValue, + onChangeText, + placeholder, + disabled, + secureTextEntry, + ref, + ...row +}: SettingsInputProps) { + const compact = useIsCompactFormFactor(); + const input = useRef(null); + useImperativeHandle( + ref, + () => ({ + focus: () => input.current?.focus(), + blur: () => input.current?.blur(), + getText: () => input.current?.getText() ?? "", + replaceText: (text) => input.current?.replaceText(text), + }), + [], + ); + return ( + + + + ); +} + +export function SettingsAction({ actionLabel, onPress, disabled, ...row }: SettingsActionProps) { + return ( + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + compactRow: { flexWrap: "wrap", gap: theme.spacing[3] }, + label: { flexGrow: 1, flexShrink: 1, flexBasis: 160, marginRight: theme.spacing[3] }, + control: { flexShrink: 1, maxWidth: "100%" }, + value: { color: theme.colors.foreground, fontSize: theme.fontSize.base }, + input: { minWidth: 180 }, +})); diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index 65180dce35..3442703d48 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -1,8 +1,8 @@ +/** @vitest-environment jsdom */ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { JSDOM } from "jsdom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { useDraftStore } from "@/stores/draft-store"; import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types"; import { createWorkspaceFileAttachment } from "@/attachments/workspace-file"; @@ -116,26 +116,24 @@ vi.mock("@/hooks/use-agent-form-state", () => ({ }), })); +const mountedRoots = new Set(); +function createTestRoot(container: HTMLElement): Root { + const root = createRoot(container); + mountedRoots.add(root); + return root; +} + +afterEach(async () => { + await act(async () => { + for (const root of mountedRoots) root.unmount(); + mountedRoots.clear(); + }); +}); + let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft; type DraftRecordForTest = ReturnType["drafts"][string]; beforeAll(async () => { - const storage = new Map(); - - Object.defineProperty(globalThis, "window", { - value: { - localStorage: { - getItem: (key: string) => storage.get(key) ?? null, - setItem: (key: string, value: string) => { - storage.set(key, value); - }, - removeItem: (key: string) => { - storage.delete(key); - }, - }, - }, - configurable: true, - }); Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, configurable: true, @@ -147,18 +145,8 @@ beforeAll(async () => { describe("useAgentInputDraft live contract", () => { beforeEach(() => { asyncStorage.clear(); - const dom = new JSDOM("
", { - url: "http://localhost", - }); - - Object.defineProperty(globalThis, "document", { - value: dom.window.document, - configurable: true, - }); - Object.defineProperty(globalThis, "navigator", { - value: dom.window.navigator, - configurable: true, - }); + document.body.innerHTML = "
"; + localStorage.clear(); useDraftStore.setState({ drafts: {}, @@ -206,7 +194,7 @@ describe("useAgentInputDraft live contract", () => { throw new Error("Missing root container"); } - let root: Root | null = createRoot(container); + let root: Root | null = createTestRoot(container); await act(async () => { root!.render( @@ -248,7 +236,7 @@ describe("useAgentInputDraft live contract", () => { root!.unmount(); }); - root = createRoot(container); + root = createTestRoot(container); await act(async () => { root.render( @@ -306,7 +294,7 @@ describe("useAgentInputDraft live contract", () => { throw new Error("Missing root container"); } - const root = createRoot(container); + const root = createTestRoot(container); await act(async () => { root.render( @@ -372,7 +360,7 @@ describe("useAgentInputDraft live contract", () => { throw new Error("Missing root container"); } - const root = createRoot(container); + const root = createTestRoot(container); await act(async () => { root.render( @@ -423,7 +411,7 @@ describe("useAgentInputDraft live contract", () => { throw new Error("Missing root container"); } - const root = createRoot(container); + const root = createTestRoot(container); await act(async () => { root.render( @@ -438,9 +426,16 @@ describe("useAgentInputDraft live contract", () => { }); expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]); - expect(useDraftStore.getState().drafts["draft:attachments"]?.input).toEqual({ - text: "with attachment", - attachments: [{ kind: "image", metadata: image }], + const readPersistedInput = () => useDraftStore.getState().drafts["draft:attachments"]?.input; + await act(async () => { + // Web text publication occurs after paint; attachments save immediately. + await expect.poll(readPersistedInput).toEqual({ + text: "with attachment", + attachments: [{ kind: "image", metadata: image }], + }); + }); + await act(async () => { + root.unmount(); }); }); @@ -505,7 +500,7 @@ describe("useAgentInputDraft live contract", () => { throw new Error("Missing root container"); } - const root = createRoot(container); + const root = createTestRoot(container); await act(async () => { root.render( @@ -559,7 +554,7 @@ describe("useAgentInputDraft live contract", () => { throw new Error("Missing root container"); } - const root = createRoot(container); + const root = createTestRoot(container); await act(async () => { root.render( diff --git a/packages/app/src/desktop/browser/settings/browser-data-section.tsx b/packages/app/src/desktop/browser/settings/browser-data-section.tsx index 0efc3ebf8e..743bd2a737 100644 --- a/packages/app/src/desktop/browser/settings/browser-data-section.tsx +++ b/packages/app/src/desktop/browser/settings/browser-data-section.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { useToast } from "@/contexts/toast-context"; import { getDesktopHost } from "@/desktop/host"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { useBrowserStore } from "@/desktop/browser/store"; import { settingsStyles } from "@/styles/settings"; import { confirmDialog } from "@/utils/confirm-dialog"; diff --git a/packages/app/src/desktop/components/desktop-notifications-section.tsx b/packages/app/src/desktop/components/desktop-notifications-section.tsx index a45d64744d..f3916f8c1e 100644 --- a/packages/app/src/desktop/components/desktop-notifications-section.tsx +++ b/packages/app/src/desktop/components/desktop-notifications-section.tsx @@ -9,7 +9,7 @@ import { Switch } from "@/components/ui/switch"; import { DesktopPermissionRow } from "@/desktop/components/desktop-permission-row"; import { useDesktopPermissions } from "@/desktop/permissions/use-desktop-permissions"; import { useDesktopSettings } from "@/desktop/settings/desktop-settings"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { settingsStyles } from "@/styles/settings"; const ThemedRotateCw = withUnistyles(RotateCw, (theme) => ({ diff --git a/packages/app/src/desktop/components/desktop-permissions-section.tsx b/packages/app/src/desktop/components/desktop-permissions-section.tsx index 41a1da3431..74cc71dc4a 100644 --- a/packages/app/src/desktop/components/desktop-permissions-section.tsx +++ b/packages/app/src/desktop/components/desktop-permissions-section.tsx @@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button"; import { DesktopPermissionRow } from "@/desktop/components/desktop-permission-row"; import { useDesktopPermissions } from "@/desktop/permissions/use-desktop-permissions"; import { settingsStyles } from "@/styles/settings"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; const ThemedRotateCw = withUnistyles(RotateCw, (theme) => ({ size: theme.iconSize.md, diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index b904c628e8..1dec26d5dd 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -5,7 +5,7 @@ import * as Clipboard from "expo-clipboard"; import { useTranslation } from "react-i18next"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { settingsStyles } from "@/styles/settings"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { ArrowUpRight, Copy, FileText, Activity } from "lucide-react-native"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; diff --git a/packages/app/src/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx index 7d859beef4..609aa9add4 100644 --- a/packages/app/src/desktop/components/integrations-section.tsx +++ b/packages/app/src/desktop/components/integrations-section.tsx @@ -6,7 +6,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ArrowUpRight, Check, Terminal } from "lucide-react-native"; import { Button } from "@/components/ui/button"; import { useCliInstall } from "@/desktop/hooks/use-install-status"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { settingsStyles } from "@/styles/settings"; import { openExternalUrl } from "@/utils/open-external-url"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 2516a3f064..cc1f983db2 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1999,6 +1999,12 @@ export const en = { }, plugins: { title: "Plugins", + screens: { + open: "Open", + offline: "Connect to this host to open plugin settings.", + update: "Update this host to use plugin settings.", + unavailable: "This plugin settings screen is unavailable.", + }, trustedTitle: "Plugins are trusted code", trustedDescription: "They run on this host and inside the app without sandboxing. Install only code you trust.", diff --git a/packages/app/src/i18n/resources/plugin-settings.ts b/packages/app/src/i18n/resources/plugin-settings.ts index 7390beac36..26c26fb733 100644 --- a/packages/app/src/i18n/resources/plugin-settings.ts +++ b/packages/app/src/i18n/resources/plugin-settings.ts @@ -14,6 +14,12 @@ const shared = { export const pluginSettings = { ar: { ...shared, + screens: { + open: "فتح", + offline: "اتصل بهذا المضيف لفتح إعدادات الإضافة.", + update: "حدّث هذا المضيف لاستخدام إعدادات الإضافة.", + unavailable: "شاشة إعدادات الإضافة هذه غير متاحة.", + }, title: "الإضافات", trustedTitle: "الإضافات تعليمات برمجية موثوقة", trustedDescription: @@ -61,6 +67,12 @@ export const pluginSettings = { }, es: { ...shared, + screens: { + open: "Abrir", + offline: "Conéctate a este host para abrir los ajustes del plugin.", + update: "Actualiza este host para usar los ajustes del plugin.", + unavailable: "Esta pantalla de ajustes del plugin no está disponible.", + }, title: "Plugins", trustedTitle: "Los plugins son código de confianza", trustedDescription: @@ -108,6 +120,12 @@ export const pluginSettings = { }, fr: { ...shared, + screens: { + open: "Ouvrir", + offline: "Connectez-vous à cet hôte pour ouvrir les paramètres du plugin.", + update: "Mettez à jour cet hôte pour utiliser les paramètres du plugin.", + unavailable: "Cet écran de paramètres du plugin est indisponible.", + }, title: "Plugins", trustedTitle: "Les plugins sont du code de confiance", trustedDescription: @@ -155,6 +173,12 @@ export const pluginSettings = { }, ja: { ...shared, + screens: { + open: "開く", + offline: "このホストに接続してプラグイン設定を開いてください。", + update: "プラグイン設定を使用するには、このホストを更新してください。", + unavailable: "このプラグイン設定画面は利用できません。", + }, title: "プラグイン", trustedTitle: "プラグインは信頼済みコードです", trustedDescription: @@ -202,6 +226,12 @@ export const pluginSettings = { }, ko: { ...shared, + screens: { + open: "열기", + offline: "플러그인 설정을 열려면 이 호스트에 연결하세요.", + update: "플러그인 설정을 사용하려면 이 호스트를 업데이트하세요.", + unavailable: "이 플러그인 설정 화면을 사용할 수 없습니다.", + }, title: "플러그인", trustedTitle: "플러그인은 신뢰하는 코드입니다", trustedDescription: "호스트와 앱 안에서 격리 없이 실행됩니다. 신뢰하는 코드만 설치하세요.", @@ -248,6 +278,12 @@ export const pluginSettings = { }, "pt-BR": { ...shared, + screens: { + open: "Abrir", + offline: "Conecte-se a este host para abrir as configurações do plugin.", + update: "Atualize este host para usar as configurações do plugin.", + unavailable: "Esta tela de configurações do plugin está indisponível.", + }, title: "Plugins", trustedTitle: "Plugins são código confiável", trustedDescription: @@ -295,6 +331,12 @@ export const pluginSettings = { }, ru: { ...shared, + screens: { + open: "Открыть", + offline: "Подключитесь к этому хосту, чтобы открыть настройки плагина.", + update: "Обновите этот хост, чтобы использовать настройки плагина.", + unavailable: "Этот экран настроек плагина недоступен.", + }, title: "Плагины", trustedTitle: "Плагины — доверенный код", trustedDescription: @@ -342,6 +384,12 @@ export const pluginSettings = { }, "zh-CN": { ...shared, + screens: { + open: "打开", + offline: "连接到此主机以打开插件设置。", + update: "更新此主机以使用插件设置。", + unavailable: "此插件设置页面不可用。", + }, title: "插件", trustedTitle: "插件是受信任的代码", trustedDescription: "它们会在此主机和应用内运行,且没有隔离。请仅安装你信任的代码。", diff --git a/packages/app/src/navigation/settings-navigation.ts b/packages/app/src/navigation/settings-navigation.ts index ae7eb6efdd..123b23077a 100644 --- a/packages/app/src/navigation/settings-navigation.ts +++ b/packages/app/src/navigation/settings-navigation.ts @@ -11,6 +11,7 @@ import { } from "@/utils/host-routes"; export type SettingsView = + | { kind: "plugin"; serverId: string; pluginId: string; screenId: string } | { kind: "root" } | { kind: "section"; section: SettingsSectionSlug } | { kind: "host"; serverId: string; section: HostSectionSlug } @@ -32,7 +33,8 @@ export function returnFromSettings(view: SettingsView): void { return; } - const parent = - view.kind === "project" ? buildProjectsSettingsRoute(view.serverId) : buildSettingsRoute(); + let parent: Href = buildSettingsRoute(); + if (view.kind === "plugin") parent = buildSettingsHostSectionRoute(view.serverId, "plugins"); + if (view.kind === "project") parent = buildProjectsSettingsRoute(view.serverId); router.dismissTo(parent as Href); } diff --git a/packages/app/src/plugins/actions.ts b/packages/app/src/plugins/actions.ts index 0d9278a702..7f8678add2 100644 --- a/packages/app/src/plugins/actions.ts +++ b/packages/app/src/plugins/actions.ts @@ -11,6 +11,7 @@ import type { PluginSurfaceRuntime } from "./surface-runtime"; import type { InstalledPlugin } from "./types"; export interface PluginNavigation { + openSettings(pluginId: string, screenId: string): void; openSurface(pluginId: string, surfaceId: string): void; openWorkspacePanel(pluginId: string, panelId: string, location: PluginPanelLocation): void; openAgentPanel( @@ -29,6 +30,11 @@ export function createPluginCapabilities( return { paseo: runtime.paseo, rpc: (contract, input) => callPluginRpc(contract, runtime.invoke, input), + openSettings(screenId) { + if (!plugin.settingsScreens.some((screen) => screen.id === screenId)) + throw new Error(`Plugin settings screen is unavailable: ${screenId}`); + navigation.openSettings(plugin.id, screenId); + }, openSurface(surfaceId) { if (!plugin.surfaces.some((surface) => surface.id === surfaceId)) { throw new Error(`Plugin surface is unavailable: ${surfaceId}`); diff --git a/packages/app/src/plugins/catalog-sync.tsx b/packages/app/src/plugins/catalog-sync.tsx index 3647334f73..a7300d75c2 100644 --- a/packages/app/src/plugins/catalog-sync.tsx +++ b/packages/app/src/plugins/catalog-sync.tsx @@ -1,3 +1,4 @@ +import { pluginSettingsKey } from "./settings/use-settings"; import { useEffect } from "react"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useHostFeature } from "@/runtime/host-features"; @@ -49,6 +50,15 @@ export function PluginCatalogSync({ }; void refresh(); const unsubscribe = client.on("status", (message) => { + if (message.payload.status === "plugin_settings_changed") { + const { pluginId, settingsId } = message.payload; + if (typeof settingsId === "string") { + const plugin = pluginRegistry + .getSnapshot() + .find((item) => item.serverId === serverId && item.id === pluginId); + void plugin?.queryClient.invalidateQueries({ queryKey: pluginSettingsKey(settingsId) }); + } + } if (message.payload.status === "plugin_catalog_changed") { const pluginId = message.payload.pluginId; if (typeof pluginId === "string") void refresh(pluginId); diff --git a/packages/app/src/plugins/client-runtime.test.ts b/packages/app/src/plugins/client-runtime.test.ts index 2be61bbc2e..34fe7d706e 100644 --- a/packages/app/src/plugins/client-runtime.test.ts +++ b/packages/app/src/plugins/client-runtime.test.ts @@ -17,6 +17,7 @@ function installation(): InstalledPlugin { clientBundle: "bundle", queryClient: new QueryClient(), cleanup: () => undefined, + settingsScreens: [], surfaces: [], sidebarItems: [], workspacePanels: [], diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index 51a6df5029..575dbdb99d 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -62,6 +62,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin { clientBundle: "bundle", queryClient: new QueryClient(), cleanup: () => {}, + settingsScreens: [], surfaces: [{ id: "main", Component: () => null }], sidebarItems: [], workspacePanels: [ @@ -143,6 +144,7 @@ describe("plugin Command Center contributions", () => { runtime: createRuntime, state: stateSource(), navigation: { + openSettings() {}, openSurface() {}, openWorkspacePanel() {}, openAgentPanel() {}, @@ -193,6 +195,7 @@ describe("plugin Command Center contributions", () => { workspaceId: workspace.id, agentId: agent.id, navigation: { + openSettings() {}, openSurface(pluginId, surfaceId) { opened.push(`${pluginId}/surface/${surfaceId}`); }, @@ -224,6 +227,7 @@ describe("plugin Command Center contributions", () => { workspaceId: workspace.id, agentId: agent.id, navigation: { + openSettings() {}, openSurface() {}, openWorkspacePanel() {}, openAgentPanel() {}, diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index 698053102a..1d042284aa 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -4,6 +4,7 @@ import { runPluginClientBundle, type PluginClientRuntime } from "./evaluate"; const runtime = { paseo: {}, async rpc() {}, + openSettings() {}, openSurface() {}, openPanel() {}, addComposerPill() { @@ -16,7 +17,7 @@ function evaluatePluginClientBundle(id: string, source: string) { } function bundle(body: string): string { - return `(function() { + return `(function(require) { const module = { exports: {} }; module.exports.default = function(plugin) { ${body}; return function() {}; }; return module.exports; @@ -24,6 +25,17 @@ function bundle(body: string): string { } describe("evaluatePluginClientBundle", () => { + it("accepts memoized settings screens", () => { + const plugin = evaluatePluginClientBundle( + "settings", + bundle(` + const Component = require("react").memo(function Settings() { return null; }); + plugin.addSettingsScreen({ id: "display", title: "Display", icon: "Settings", Component }); + `), + ); + expect(plugin.settingsScreens.map((screen) => screen.id)).toEqual(["display"]); + }); + it("returns idempotent removers for every client registration", () => { let pillCount = 0; const plugin = runPluginClientBundle( @@ -33,6 +45,7 @@ describe("evaluatePluginClientBundle", () => { const schema = { safeParse(value) { return { success: true, data: value }; } }; globalThis.__pluginRemovals = [ plugin.addSurface("main", Component), + plugin.addSettingsScreen({ id: "display", title: "Display", icon: "Settings", Component }), plugin.addSidebarItem({ id: "main", title: "Main", icon: "Blocks", surface: "main" }), plugin.addWorkspacePanel({ id: "panel", title: "Panel", icon: "Blocks", context: "workspace", Component }), plugin.addCommandCenterItem({ id: "command", title: "Command", icon: "Blocks", context: "global", onSelect() {} }), @@ -59,6 +72,7 @@ describe("evaluatePluginClientBundle", () => { expect( [ plugin.surfaces, + plugin.settingsScreens, plugin.sidebarItems, plugin.workspacePanels, plugin.commandCenterItems, @@ -77,6 +91,7 @@ describe("evaluatePluginClientBundle", () => { expect( [ plugin.surfaces, + plugin.settingsScreens, plugin.sidebarItems, plugin.workspacePanels, plugin.commandCenterItems, diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index 14ace0607a..e989d44966 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -1,3 +1,6 @@ +import * as pluginUiRuntime from "./react-native/ui"; +import { useSettings } from "./settings/use-settings"; +import { defineSettings } from "@getpaseo/plugin"; import * as React from "react"; import * as ReactJsxRuntime from "react/jsx-runtime"; // eslint-disable-next-line no-restricted-imports -- plugin client runtime injects host ReactNative. @@ -70,7 +73,7 @@ function requireId(value: string, label: string): string { export type PluginClientRuntime = Pick< PluginClientContext, - "paseo" | "rpc" | "openSurface" | "openPanel" | "addComposerPill" + "paseo" | "rpc" | "openSettings" | "openSurface" | "openPanel" | "addComposerPill" >; export function runPluginClientBundle( @@ -81,6 +84,7 @@ export function runPluginClientBundle( ): EvaluatedPlugin { const collector: Omit = { surfaces: [], + settingsScreens: [], sidebarItems: [], workspacePanels: [], commandCenterItems: [], @@ -91,6 +95,7 @@ export function runPluginClientBundle( timelineRenderers: [], }; const surfaceIds = new Set(); + const settingsScreenIds = new Set(); const sidebarItemIds = new Set(); const workspacePanelIds = new Set(); const commandCenterItemIds = new Set(); @@ -122,6 +127,17 @@ export function runPluginClientBundle( } const pluginContext: PluginClientContext = { ...runtime, + addSettingsScreen(contribution) { + const screenId = requireId(contribution.id, "settings screen id"); + if (settingsScreenIds.has(screenId)) + throw new Error(`Duplicate settings screen: ${screenId}`); + if (!contribution.title.trim()) throw new Error(`Invalid settings screen: ${screenId}`); + resolvePluginIcon(contribution.icon); + settingsScreenIds.add(screenId); + return register(collector.settingsScreens, { ...contribution, id: screenId }, () => + settingsScreenIds.delete(screenId), + ); + }, addSurface(surfaceId: string, Component: ComponentType) { const normalizedId = requireId(surfaceId, "surface id"); if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate surface: ${normalizedId}`); @@ -336,6 +352,7 @@ export function runPluginClientBundle( }, }; const runtimeRequire = (name: string): unknown => { + if (name === "@getpaseo/plugin/ui") return pluginUiRuntime; if (name === "react") return React; if (name === "react/jsx-runtime") return ReactJsxRuntime; if (name === "react-native") return ReactNative; @@ -343,6 +360,8 @@ export function runPluginClientBundle( return { defineAttachmentSource, defineRpc, + defineSettings, + useSettings, Icon, usePaseo, useAgent, @@ -406,6 +425,7 @@ export function runPluginClientBundle( id, cleanup, surfaces: collector.surfaces, + settingsScreens: collector.settingsScreens, sidebarItems: collector.sidebarItems, workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"], commandCenterItems: collector.commandCenterItems, diff --git a/packages/app/src/plugins/navigation.ts b/packages/app/src/plugins/navigation.ts index cd1f9c129b..e155384d52 100644 --- a/packages/app/src/plugins/navigation.ts +++ b/packages/app/src/plugins/navigation.ts @@ -1,3 +1,4 @@ +import { buildPluginSettingsRoute } from "./settings/routes"; import { router } from "expo-router"; import type { PluginPanelLocation } from "@getpaseo/plugin"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; @@ -19,6 +20,9 @@ export function createPluginNavigation(input: { return { mode: "pane" as const, paneId }; } return { + openSettings(pluginId, screenId) { + router.push(buildPluginSettingsRoute(serverId, pluginId, screenId)); + }, openSurface(pluginId, surfaceId) { router.push(buildPluginSurfaceRoute(serverId, pluginId, { kind: "surface", id: surfaceId })); }, diff --git a/packages/app/src/plugins/react-native/ui.ts b/packages/app/src/plugins/react-native/ui.ts new file mode 100644 index 0000000000..644cdf7632 --- /dev/null +++ b/packages/app/src/plugins/react-native/ui.ts @@ -0,0 +1,10 @@ +export { + SettingsGroup, + SettingsSection, + SettingsCard, + SettingsRow, + SettingsSwitch, + SettingsSelect, + SettingsInput, + SettingsAction, +} from "@/components/settings"; diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index d7f5845e5a..8f6981e9ee 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -73,6 +73,7 @@ class PluginRegistry { queryClient: new QueryClient(), cleanup: () => undefined, surfaces: [], + settingsScreens: [], sidebarItems: [], workspacePanels: [], commandCenterItems: [], diff --git a/packages/app/src/plugins/settings/index.tsx b/packages/app/src/plugins/settings/index.tsx new file mode 100644 index 0000000000..175daa994d --- /dev/null +++ b/packages/app/src/plugins/settings/index.tsx @@ -0,0 +1,128 @@ +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Platform, Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { router } from "expo-router"; +import type { PluginHostProps } from "@getpaseo/plugin"; +import { SettingsAction } from "@/components/settings"; +import { Button } from "@/components/ui/button"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useHostFeature } from "@/runtime/host-features"; +import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; +import type { Theme } from "@/styles/theme"; +import { useInstalledPlugin } from "../registry"; +import { PluginRuntimeBoundary } from "../runtime-boundary"; +import { SurfaceErrorBoundary } from "../surface-error-boundary"; +import { createPluginSurfaceRuntime } from "../surface-runtime"; +import { toPluginTheme } from "../theme"; +import { buildPluginSettingsRoute } from "./routes"; + +interface SettingsIdentity { + serverId: string; + pluginId: string; + screenId: string; +} + +function SettingsLink({ + serverId, + pluginId, + screenId, + title, +}: SettingsIdentity & { title: string }) { + const { t } = useTranslation(); + const open = useCallback( + () => router.push(buildPluginSettingsRoute(serverId, pluginId, screenId)), + [serverId, pluginId, screenId], + ); + return ( + + ); +} + +export function PluginSettingsLinks({ serverId, pluginId }: Omit) { + const plugin = useInstalledPlugin(serverId, pluginId); + const supported = useHostFeature(serverId, "pluginSettings"); + if (!supported || !plugin) return null; + return ( + <> + {plugin.settingsScreens.map((screen) => ( + + ))} + + ); +} + +function SettingsContent({ + serverId, + pluginId, + screenId, + theme, +}: SettingsIdentity & { theme: PluginHostProps["theme"] }) { + const { t } = useTranslation(); + const plugin = useInstalledPlugin(serverId, pluginId); + const client = useHostRuntimeClient(serverId); + const connected = useHostRuntimeIsConnected(serverId); + const supported = useHostFeature(serverId, "pluginSettings"); + const compact = useIsCompactFormFactor(); + const hosts = useHosts(); + const [attempt, setAttempt] = useState(0); + const screen = plugin?.settingsScreens.find((item) => item.id === screenId); + const retry = useCallback(() => setAttempt((value) => value + 1), []); + const renderError = useCallback( + (error: string) => ( + + {error} + + + ), + [retry, t], + ); + const host = useMemo( + () => ({ + id: serverId, + label: hosts.find((candidate) => candidate.serverId === serverId)?.label ?? serverId, + }), + [hosts, serverId], + ); + const platform = Platform.OS === "ios" || Platform.OS === "android" ? Platform.OS : "web"; + const layout = useMemo( + () => ({ compact, platform }), + [compact, platform], + ); + if (!connected) + return {t("settings.plugins.screens.offline")}; + // COMPAT(pluginSettings): added in v0.8, remove after 2027-03-05. + if (!supported) return {t("settings.plugins.screens.update")}; + const runtime = createPluginSurfaceRuntime(client, pluginId); + if (!plugin || !screen || !runtime) + return {t("settings.plugins.screens.unavailable")}; + const Component = screen.Component; + return ( + + + + + + + + ); +} +const ThemedSettingsContent = withUnistyles(SettingsContent); +const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); +export function PluginSettingsContent(props: SettingsIdentity) { + return ; +} +const styles = StyleSheet.create((theme) => ({ message: { color: theme.colors.foregroundMuted } })); diff --git a/packages/app/src/plugins/settings/routes.ts b/packages/app/src/plugins/settings/routes.ts new file mode 100644 index 0000000000..be2fcfe897 --- /dev/null +++ b/packages/app/src/plugins/settings/routes.ts @@ -0,0 +1,6 @@ +export function buildPluginSettingsRoute(serverId: string, pluginId: string, screenId: string) { + return { + pathname: "/settings/hosts/[serverId]/plugins/[pluginId]/[screenId]" as const, + params: { serverId, pluginId, screenId }, + }; +} diff --git a/packages/app/src/plugins/settings/use-settings.ts b/packages/app/src/plugins/settings/use-settings.ts new file mode 100644 index 0000000000..8c19a4f7f2 --- /dev/null +++ b/packages/app/src/plugins/settings/use-settings.ts @@ -0,0 +1,83 @@ +import { useMemo } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRpc, type SettingsDefinition, type SettingsState } from "@getpaseo/plugin"; +import { settingsRpc } from "@getpaseo/plugin/host"; +import { useReplicaQuery } from "@/data/query"; +import { z, type ZodType } from "zod"; + +export const pluginSettingsKey = (id: string) => ["plugin-settings", id] as const; +function message(error: unknown): string { + if (error instanceof z.ZodError) return error.issues.map((issue) => issue.message).join("\n"); + return error instanceof Error ? error.message : String(error); +} + +export function useSettings( + definition: SettingsDefinition, +): SettingsState { + const rpc = useMemo(() => settingsRpc(definition.id), [definition.id]); + const read = useRpc(rpc.read); + const write = useRpc(rpc.write); + const reset = useRpc(rpc.reset); + const client = useQueryClient(); + const key = pluginSettingsKey(definition.id); + const query = useReplicaQuery({ + queryKey: key, + pushEvent: "plugin_settings_changed", + queryFn: () => read({}), + retry: false, + }); + const mutation = useMutation({ + mutationFn: async ( + input: { revision: string; values: z.output } | { revision: string }, + ) => { + const result = + "values" in input + ? await write({ + revision: input.revision, + values: z.json().parse(await definition.schema.parseAsync(input.values)), + }) + : await reset(input); + if (result.status !== "saved") throw new Error(result.error); + client.setQueryData(key, { ...result, status: "ready" }); + }, + }); + async function save(values: z.output, revision: string) { + try { + await mutation.mutateAsync({ values, revision }); + return true; + } catch { + return false; + } + } + async function resetValues() { + if (!query.data) return false; + try { + await mutation.mutateAsync({ revision: query.data.revision }); + return true; + } catch { + return false; + } + } + const actions = { + saving: mutation.isPending, + saveError: mutation.error ? message(mutation.error) : null, + save, + reset: resetValues, + reload: async () => { + mutation.reset(); + await query.refetch(); + }, + }; + if (query.isPending) return { ...actions, status: "loading" }; + if (query.isError) return { ...actions, status: "error", error: message(query.error) }; + if (query.data.status === "invalid") return { ...actions, ...query.data }; + const parsed = definition.schema.safeParse(query.data.values); + if (!parsed.success) + return { + ...actions, + status: "invalid", + revision: query.data.revision, + error: parsed.error.message, + }; + return { ...actions, ...query.data, values: parsed.data }; +} diff --git a/packages/app/src/plugins/sidebar-groups.test.ts b/packages/app/src/plugins/sidebar-groups.test.ts index dbecea7662..eb9ff4fa45 100644 --- a/packages/app/src/plugins/sidebar-groups.test.ts +++ b/packages/app/src/plugins/sidebar-groups.test.ts @@ -10,6 +10,7 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin { serverId, clientBundle: serverId, queryClient: new QueryClient(), + settingsScreens: [], surfaces: [{ id: "surface", Component: () => null }], sidebarItems: [ { diff --git a/packages/app/src/plugins/surface-contribution.test.ts b/packages/app/src/plugins/surface-contribution.test.ts index 653bac17bd..685b57d3c2 100644 --- a/packages/app/src/plugins/surface-contribution.test.ts +++ b/packages/app/src/plugins/surface-contribution.test.ts @@ -18,6 +18,7 @@ function installation( clientBundle: serverId, queryClient: new QueryClient(), cleanup: () => undefined, + settingsScreens: [], surfaces: surfaces.map((id) => ({ id, Component: () => null })), sidebarItems: sidebarItems.map((item) => ({ ...item, diff --git a/packages/app/src/plugins/surface-error-boundary.tsx b/packages/app/src/plugins/surface-error-boundary.tsx index 7a3db639ea..89c6f6b468 100644 --- a/packages/app/src/plugins/surface-error-boundary.tsx +++ b/packages/app/src/plugins/surface-error-boundary.tsx @@ -7,6 +7,7 @@ interface SurfaceErrorBoundaryProps { installation: object; resetKey?: unknown; Surface: unknown; + renderError?(error: string): ReactNode; } interface SurfaceErrorBoundaryState { @@ -40,6 +41,7 @@ export class SurfaceErrorBoundary extends Component< render() { if (this.state.error) { + if (this.props.renderError) return this.props.renderError(this.state.error); return Plugin failed: {this.state.error}; } return this.props.children; diff --git a/packages/app/src/plugins/theme.test.ts b/packages/app/src/plugins/theme.test.ts index 6081a33e86..4272f8e95e 100644 --- a/packages/app/src/plugins/theme.test.ts +++ b/packages/app/src/plugins/theme.test.ts @@ -50,6 +50,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install serverId, clientBundle: serverId, queryClient: new QueryClient(), + settingsScreens: [], surfaces: [], sidebarItems: [], workspacePanels: [], diff --git a/packages/app/src/plugins/timeline/model.test.ts b/packages/app/src/plugins/timeline/model.test.ts index 27e734d40d..5db3d3b327 100644 --- a/packages/app/src/plugins/timeline/model.test.ts +++ b/packages/app/src/plugins/timeline/model.test.ts @@ -13,6 +13,7 @@ function plugin(input: { clientBundle: "bundle", queryClient: new QueryClient(), cleanup: () => {}, + settingsScreens: [], surfaces: [], sidebarItems: [], workspacePanels: [], diff --git a/packages/app/src/plugins/timeline/view.test.tsx b/packages/app/src/plugins/timeline/view.test.tsx index 870a51742b..5f8485b8c0 100644 --- a/packages/app/src/plugins/timeline/view.test.tsx +++ b/packages/app/src/plugins/timeline/view.test.tsx @@ -30,16 +30,6 @@ vi.mock("../icons", () => ({ Icon: () => null, resolvePluginIcon: () => () => null, })); -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: (theme: object) => object) => - factory({ - colors: { foregroundMuted: "gray", statusDanger: "red" }, - spacing: [0, 4, 8, 12, 16], - }), - }, - withUnistyles: (Component: React.ComponentType) => Component, -})); import { pluginRegistry } from "../registry"; import { PluginTimelineItemView } from "./view"; diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 0006fe553a..9334520ed0 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -7,6 +7,7 @@ import type { PluginComposerPillContribution, PluginSidebarContribution, PluginSurfaceContribution, + PluginSettingsScreenContribution, PluginThemeContribution, PluginTimelineRendererContribution, PluginTimelineTransformerContribution, @@ -22,6 +23,7 @@ export interface EvaluatedPlugin { id: string; cleanup: PluginCleanup; surfaces: PluginSurfaceContribution[]; + settingsScreens: PluginSettingsScreenContribution[]; sidebarItems: PluginSidebarContribution[]; workspacePanels: EvaluatedPluginWorkspacePanelContribution[]; commandCenterItems: PluginCommandCenterItemContribution[]; @@ -45,6 +47,7 @@ export type { PluginComposerPillContribution, PluginSidebarContribution, PluginSurfaceContribution, + PluginSettingsScreenContribution, PluginThemeContribution, PluginTimelineRendererContribution, PluginTimelineTransformerContribution, diff --git a/packages/app/src/plugins/workspace-panels/resolution.test.ts b/packages/app/src/plugins/workspace-panels/resolution.test.ts index 8ca204b8ba..36f1e35282 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.test.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.test.ts @@ -10,6 +10,7 @@ function installed(): InstalledPlugin { clientBundle: "bundle", queryClient: new QueryClient(), cleanup: () => {}, + settingsScreens: [], surfaces: [], sidebarItems: [], workspacePanels: [ diff --git a/packages/app/src/provider-usage/settings-section.tsx b/packages/app/src/provider-usage/settings-section.tsx index cb8e8f6b55..0b3c35ec3e 100644 --- a/packages/app/src/provider-usage/settings-section.tsx +++ b/packages/app/src/provider-usage/settings-section.tsx @@ -5,7 +5,7 @@ import { StyleSheet } from "react-native-unistyles"; import { Alert } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { settingsStyles } from "@/styles/settings"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { providerUsageCopy } from "./copy"; import { ProviderUsageList } from "./list"; import type { ProviderUsageView } from "./types"; diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index 591c955269..2c177568cc 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -28,8 +28,8 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda import { ProjectEditSheet } from "@/components/project-edit-sheet"; import { EditingTextInput as TextInput } from "@/components/ui/text-input"; import { SettingsTextAreaCard } from "@/components/settings-textarea"; -import { SettingsGroup } from "@/screens/settings/settings-group"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsGroup } from "@/components/settings/headings/settings-group"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { settingsStyles } from "@/styles/settings"; import { useProjects } from "@/hooks/use-projects"; import type { ProjectEditFormSnapshot } from "@/projects/edit-form"; diff --git a/packages/app/src/screens/projects-screen.test.tsx b/packages/app/src/screens/projects-screen.test.tsx index 0533d7c810..17c22e906f 100644 --- a/packages/app/src/screens/projects-screen.test.tsx +++ b/packages/app/src/screens/projects-screen.test.tsx @@ -7,27 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ProjectHostEntry, ProjectSummary, WorkspaceSummary } from "@/utils/projects"; import type { ProjectHostError, UseProjectsResult } from "@/hooks/use-projects"; -const { theme, projectsState, push } = vi.hoisted(() => ({ - theme: { - spacing: { 0: 0, 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 }, - iconSize: { sm: 14, md: 20 }, - fontSize: { xs: 11, sm: 13, base: 15 }, - fontWeight: { normal: "400" as const, medium: "500" as const }, - borderRadius: { sm: 4, md: 6, lg: 8, full: 999 }, - opacity: { 50: 0.5 }, - colors: { - surface0: "#000", - surface1: "#111", - surface2: "#222", - surface3: "#333", - surfaceSidebarHover: "#1a1a1a", - foreground: "#fff", - foregroundMuted: "#aaa", - border: "#444", - accent: "#0a84ff", - palette: { red: { 300: "#ff6b6b" } }, - }, - }, +const { projectsState, push } = vi.hoisted(() => ({ projectsState: { current: { projects: [], @@ -40,7 +20,8 @@ const { theme, projectsState, push } = vi.hoisted(() => ({ push: vi.fn(), })); -vi.mock("react-native", () => { +vi.mock("react-native", async (importOriginal) => { + const actual = await importOriginal(); const passthrough = ({ children, testID, @@ -87,6 +68,7 @@ vi.mock("react-native", () => { }; return { + ...actual, View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => React.createElement("div", { "data-testid": testID }, children), Text: ({ children }: { children?: React.ReactNode }) => @@ -101,21 +83,15 @@ vi.mock("react-native", () => { }; }); -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: unknown) => - typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, - }, - useUnistyles: () => ({ theme }), -})); - -vi.mock("lucide-react-native", () => { +vi.mock("lucide-react-native", async (importOriginal) => { + const actual = await importOriginal(); const icon = (name: string) => { const Icon = () => React.createElement("span", { "data-icon": name }); Icon.displayName = name; return Icon; }; return { + ...actual, ChevronRight: icon("ChevronRight"), MoreVertical: icon("MoreVertical"), ExternalLink: icon("ExternalLink"), diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 5e5e9be474..eff81ebd37 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -48,7 +48,7 @@ import { HostPicker as SharedHostPicker } from "@/components/hosts/host-picker"; import { HostStatusDot } from "@/components/host-status-dot"; import { ScreenTitle } from "@/components/headers/screen-title"; import { HeaderIconBadge } from "@/components/headers/header-icon-badge"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { AppearanceSection } from "@/screens/settings/appearance/appearance-section"; import { LayoutSection } from "@/screens/settings/layout/layout-section"; import { @@ -112,6 +112,9 @@ import { HostWorkspacesPage, HostTerminalsPage, } from "@/screens/settings/host-page"; +import { resolvePluginIcon } from "@/plugins/icons"; +import { PluginSettingsContent } from "@/plugins/settings"; +import { useInstalledPlugins } from "@/plugins/registry"; import { HostPluginsPage } from "@/screens/settings/plugins-page"; import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page"; import ProjectsScreen from "@/screens/projects-screen"; @@ -1066,6 +1069,7 @@ function SettingsSidebar({ let selectedHostSection: HostSectionSlug | null = null; if (view.kind === "host") selectedHostSection = view.section; if (view.kind === "project") selectedHostSection = "projects"; + if (view.kind === "plugin") selectedHostSection = "plugins"; const sidebarBody = ( <> @@ -1204,7 +1208,9 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti const sortedHosts = useSortedHosts(hosts, localServerId); const lastWorkspaceSelection = useLastWorkspaceSelection(); const routedSettingsHostServerId = - view.kind === "host" || view.kind === "project" ? view.serverId : null; + view.kind === "host" || view.kind === "project" || view.kind === "plugin" + ? view.serverId + : null; const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState( routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null, ); @@ -1219,7 +1225,8 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti // The host the four sections scope to: the host on the active view, otherwise // the picker choice, otherwise the connected local daemon, otherwise the first host. const activeHostServerId = useMemo(() => { - if (view.kind === "host" || view.kind === "project") return view.serverId; + if (view.kind === "host" || view.kind === "project" || view.kind === "plugin") + return view.serverId; return resolveActiveHostServerId({ selectedServerId: selectedSettingsHostServerId, localServerId, @@ -1425,11 +1432,21 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti returnFromSettings({ kind: "root" }); }, []); + const installedPlugins = useInstalledPlugins(); const detailHeader = ((): { title: string; Icon: ComponentType<{ size: number; color: string }>; titleAccessory?: ReactNode; } | null => { + if (view.kind === "plugin") { + const screen = installedPlugins + .find((plugin) => plugin.serverId === view.serverId && plugin.id === view.pluginId) + ?.settingsScreens.find((candidate) => candidate.id === view.screenId); + return { + title: `${view.pluginId} · ${screen?.title ?? t("settings.title")}`, + Icon: screen ? resolvePluginIcon(screen.icon) : Blocks, + }; + } if (view.kind === "host") { const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section); if (!item) return null; @@ -1451,6 +1468,14 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti content = isDesktopApp ? : null; } else { content = (() => { + if (view.kind === "plugin") + return ( + + ); if (view.kind === "host") { return renderHostSettingsContent(view, handleHostRemoved); } diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx index 5e9d3d1597..e92a861f78 100644 --- a/packages/app/src/screens/settings/appearance/appearance-section.tsx +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -16,8 +16,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Switch } from "@/components/ui/switch"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsCard, SettingsSwitch } from "@/components/settings"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { useContributedThemes } from "@/appearance/provider"; import { EditingTextInput as TextInput } from "@/components/ui/text-input"; import { @@ -234,17 +234,12 @@ interface AutoExpandReasoningRowProps { function AutoExpandReasoningRow({ value, onChange }: AutoExpandReasoningRowProps) { const { t } = useTranslation(); return ( - - - - {t("settings.general.autoExpandReasoning.label")} - - - {t("settings.general.autoExpandReasoning.description")} - - - - + ); } @@ -256,19 +251,12 @@ interface ChatOutlineRowProps { function ChatOutlineRow({ value, onChange }: ChatOutlineRowProps) { const { t } = useTranslation(); return ( - - - {t("settings.appearance.chatOutline.title")} - - {t("settings.appearance.chatOutline.description")} - - - - + ); } @@ -309,7 +297,7 @@ function ToolCallDetailRow({ value, onChange }: ToolCallDetailRowProps) { const { t } = useTranslation(); const selectedLabel = getToolCallDetailLevelLabel(t, value); return ( - + {t("settings.general.toolCallDetail.label")} @@ -693,7 +681,7 @@ export function AppearanceSection() {
- + ) : null} - + diff --git a/packages/app/src/screens/settings/appearance/sidebar-nav-section.tsx b/packages/app/src/screens/settings/appearance/sidebar-nav-section.tsx index 1733d9ade9..90d6e02344 100644 --- a/packages/app/src/screens/settings/appearance/sidebar-nav-section.tsx +++ b/packages/app/src/screens/settings/appearance/sidebar-nav-section.tsx @@ -17,7 +17,7 @@ import { Shortcut } from "@/components/ui/shortcut"; import { Switch } from "@/components/ui/switch"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { resolvePluginIcon } from "@/plugins/icons"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { builtinSidebarNavLabelKey, builtinSidebarNavShortcutAction, diff --git a/packages/app/src/screens/settings/editor-section.tsx b/packages/app/src/screens/settings/editor-section.tsx index ec926640ee..b9367d81f9 100644 --- a/packages/app/src/screens/settings/editor-section.tsx +++ b/packages/app/src/screens/settings/editor-section.tsx @@ -4,7 +4,7 @@ import { Text, View } from "react-native"; import { Switch } from "@/components/ui/switch"; import { useAppSettings } from "@/hooks/use-settings"; import { settingsStyles } from "@/styles/settings"; -import { SettingsSection } from "./settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; export function EditorSection() { const { t } = useTranslation(); diff --git a/packages/app/src/screens/settings/host-appearance-section.tsx b/packages/app/src/screens/settings/host-appearance-section.tsx index 037be657d4..4b40392f6b 100644 --- a/packages/app/src/screens/settings/host-appearance-section.tsx +++ b/packages/app/src/screens/settings/host-appearance-section.tsx @@ -13,7 +13,7 @@ import { import { AdaptiveRenameModal } from "@/components/rename-modal"; import { WorkspaceMetaRow } from "@/components/sidebar/workspace-meta-row"; import { useToast } from "@/contexts/toast-context"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { HOST_BADGE_DISPLAYS, HOST_COLORS, diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 40eeb4fc77..ffe07c308e 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -59,7 +59,7 @@ import { ProvidersSection } from "@/screens/settings/providers-section"; import { ProviderUsageSettingsSection } from "@/provider-usage/settings-section"; import { useProviderUsage } from "@/provider-usage/use-provider-usage"; import { HostAppearanceSection } from "@/screens/settings/host-appearance-section"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { useSessionStore } from "@/stores/session-store"; import { settingsStyles } from "@/styles/settings"; import type { HostConnection, HostProfile } from "@/types/host-connection"; diff --git a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx index bba32cfa9d..8bba11a1c4 100644 --- a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx +++ b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx @@ -6,7 +6,7 @@ import { MoreHorizontal, Pencil, Undo2, X } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; import { settingsStyles } from "@/styles/settings"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { Button } from "@/components/ui/button"; import { DropdownMenu, diff --git a/packages/app/src/screens/settings/layout/layout-section.tsx b/packages/app/src/screens/settings/layout/layout-section.tsx index f15c451fbc..5c0080bd1d 100644 --- a/packages/app/src/screens/settings/layout/layout-section.tsx +++ b/packages/app/src/screens/settings/layout/layout-section.tsx @@ -1,16 +1,11 @@ -import { Fragment, useCallback } from "react"; -import { Text, View, type PressableStateCallbackType } from "react-native"; +import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { StyleSheet } from "react-native-unistyles"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; -import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; +import { SettingsSection, SettingsCard, SettingsSelect } from "@/components/settings"; import { useAppSettings, type OpenInSidePanePreferences, type PullRequestOpenLocation, } from "@/hooks/use-settings"; -import { SettingsSection } from "@/screens/settings/settings-section"; -import { settingsStyles } from "@/styles/settings"; const SOURCES = [ "explorerFiles", @@ -22,72 +17,38 @@ const SOURCES = [ type LayoutPreferenceSource = keyof OpenInSidePanePreferences | "pullRequests"; -function destinationTriggerStyle({ - pressed, - open, -}: PressableStateCallbackType & { open?: boolean }) { - return [styles.destinationTrigger, (pressed || open) && styles.destinationTriggerActive]; -} - function LayoutPreferenceRow({ source, destination, - first, allowExplorer, onDestinationChange, }: { source: LayoutPreferenceSource; destination: PullRequestOpenLocation; - first: boolean; allowExplorer?: boolean; - onDestinationChange: ( - source: LayoutPreferenceSource, - destination: PullRequestOpenLocation, - ) => void; + onDestinationChange(source: LayoutPreferenceSource, destination: PullRequestOpenLocation): void; }) { const { t } = useTranslation(); - const destinationLabel = t(`settings.layout.openInSidePane.destinations.${destination}`); - const selectMain = useCallback( - () => onDestinationChange(source, "main"), - [onDestinationChange, source], + const options = useMemo(() => { + const destinations = allowExplorer + ? (["main", "side", "explorer"] as const) + : (["main", "side"] as const); + return destinations.map((value) => ({ + value, + label: t(`settings.layout.openInSidePane.destinations.${value}`), + })); + }, [allowExplorer, t]); + const change = useCallback( + (value: PullRequestOpenLocation) => onDestinationChange(source, value), + [source, onDestinationChange], ); - const selectSide = useCallback( - () => onDestinationChange(source, "side"), - [onDestinationChange, source], - ); - const selectExplorer = useCallback( - () => onDestinationChange(source, "explorer"), - [onDestinationChange, source], - ); - const label = t(`settings.layout.openInSidePane.sources.${source}.label`); return ( - - - {label} - - - - {destinationLabel} - - - - {t("settings.layout.openInSidePane.destinations.main")} - - - {t("settings.layout.openInSidePane.destinations.side")} - - {allowExplorer ? ( - - {t("settings.layout.openInSidePane.destinations.explorer")} - - ) : null} - - - + ); } @@ -108,42 +69,22 @@ export function LayoutSection() { ); return ( - - {SOURCES.map((source, index) => ( - - - + + {SOURCES.map((source) => ( + ))} - + ); } - -const styles = StyleSheet.create((theme) => ({ - destinationTrigger: { - paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.md, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - }, - destinationTriggerActive: { - backgroundColor: theme.colors.interactionHighlight, - }, - destinationLabel: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, -})); diff --git a/packages/app/src/screens/settings/metadata-generation-page.tsx b/packages/app/src/screens/settings/metadata-generation-page.tsx index decf4bf6ee..be137726ba 100644 --- a/packages/app/src/screens/settings/metadata-generation-page.tsx +++ b/packages/app/src/screens/settings/metadata-generation-page.tsx @@ -10,7 +10,7 @@ import { SegmentedControl } from "@/components/ui/segmented-control"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { buildSelectableProviderSelectorProviders } from "@/provider-selection/provider-selection"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { settingsStyles } from "@/styles/settings"; const METADATA_GENERATION_DOCS_URL = "https://paseo.sh/docs/metadata-generation"; diff --git a/packages/app/src/screens/settings/plugins-page.test.tsx b/packages/app/src/screens/settings/plugins-page.test.tsx index 65992960da..8ec1e4df6b 100644 --- a/packages/app/src/screens/settings/plugins-page.test.tsx +++ b/packages/app/src/screens/settings/plugins-page.test.tsx @@ -58,6 +58,11 @@ vi.mock("@/components/adaptive-modal-sheet", async () => { vi.mock("react-native-reanimated", () => ({ default: { View: "div" }, + Keyframe: class { + duration() { + return this; + } + }, Easing: { ease: "ease", inOut: (value: unknown) => value }, interpolateColor: (value: number, _input: number[], output: string[]) => value >= 1 ? output[1] : output[0], diff --git a/packages/app/src/screens/settings/plugins-page.tsx b/packages/app/src/screens/settings/plugins-page.tsx index 8ff4ccc27e..086b1f1a53 100644 --- a/packages/app/src/screens/settings/plugins-page.tsx +++ b/packages/app/src/screens/settings/plugins-page.tsx @@ -1,3 +1,4 @@ +import { PluginSettingsLinks } from "@/plugins/settings"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; @@ -14,7 +15,7 @@ import { useFetchQuery } from "@/data/query"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useHostFeature } from "@/runtime/host-features"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { resolvePluginPageState } from "@/screens/settings/plugins-page-state"; import { pluginRegistry, useInstalledPlugins } from "@/plugins/registry"; import { settingsStyles } from "@/styles/settings"; @@ -36,6 +37,7 @@ function pluginRowAction(action: string | undefined): PluginRowAction | undefine function PluginRow({ plugin, + serverId, clientError, pending, pendingAction, @@ -44,6 +46,7 @@ function PluginRow({ supportsLogs, }: { plugin: PluginListItem; + serverId: string; clientError?: string; pending: boolean; pendingAction?: PluginRowAction; @@ -70,37 +73,45 @@ function PluginRow({ if (pendingAction === "enable") toggleLabel = t("settings.plugins.actions.enabling"); else if (pendingAction === "disable") toggleLabel = t("settings.plugins.actions.disabling"); return ( - - - - {plugin.id} - + + + + + {plugin.id} + + + {plugin.path} + {clientError || plugin.error ? ( + {clientError ?? plugin.error} + ) : null} - {plugin.path} - {clientError || plugin.error ? ( - {clientError ?? plugin.error} - ) : null} - - - {supportsLogs ? ( - + ) : null} + - ) : null} - - - + + + + ); } @@ -347,6 +358,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { return ( ({ LoadingSpinner: () => React.createElement("span", { "data-testid": "loading-spinner" }), })); -vi.mock("@/screens/settings/settings-info-tip", () => ({ +vi.mock("@/components/settings/headings/settings-info-tip", () => ({ SettingsInfoTip: () => null, })); diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index ae1ea5e855..69e5caadac 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -31,7 +31,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { useProviderSettingsStore } from "@/stores/provider-settings-store"; import { confirmDialog } from "@/utils/confirm-dialog"; import { filterSelectableModels } from "@/provider-selection/model-catalog"; diff --git a/packages/app/vitest.setup.ts b/packages/app/vitest.setup.ts index feb490d91e..8834ab649f 100644 --- a/packages/app/vitest.setup.ts +++ b/packages/app/vitest.setup.ts @@ -6,6 +6,23 @@ const globalWithTestShims = globalThis as typeof globalThis & Record + Object.assign(new window.EventTarget(), { + media, + matches: false, + onchange: null, + addListener(listener: EventListener) { + this.addEventListener("change", listener); + }, + removeListener(listener: EventListener) { + this.removeEventListener("change", listener); + }, + }); +} + if (typeof globalThis.self === "undefined") { globalWithTestShims.self = globalThis; } diff --git a/packages/plugin/package.json b/packages/plugin/package.json index c71e2c7db6..710bce8339 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -37,6 +37,11 @@ "types": "./dist/host.d.ts", "source": "./src/host.ts", "default": "./dist/host.js" + }, + "./ui": { + "types": "./dist/ui.d.ts", + "source": "./src/ui.ts", + "default": "./dist/ui.js" } }, "publishConfig": { diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index 96152aa590..628d8ee355 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -128,6 +128,7 @@ export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions { } export interface PluginClientContext extends PluginCommandCapabilities { + addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup; addSurface(id: string, Component: ComponentType): PluginCleanup; addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup; addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup; @@ -157,6 +158,13 @@ export type PluginWorkspacePanelContribution = Component: ComponentType; }); +export interface PluginSettingsScreenContribution { + id: string; + title: string; + icon: string; + Component: ComponentType; +} + export interface PluginSurfaceContribution { id: string; Component: ComponentType; @@ -250,6 +258,7 @@ export interface PluginCommandCapabilities { input: ZodInput, ): Promise>; openSurface(id: string): void; + openSettings(id: string): void; } export interface PluginGlobalCommandContext extends PluginCommandCapabilities { @@ -311,6 +320,9 @@ export interface PluginHandlerContext { } export interface PluginServerContext { + registerSettings( + definition: import("./settings.js").SettingsDefinition, + ): void; handle( contract: PluginRpcContract, handler: ( diff --git a/packages/plugin/src/host.ts b/packages/plugin/src/host.ts index 6fd4d7aaac..59e1d84e6d 100644 --- a/packages/plugin/src/host.ts +++ b/packages/plugin/src/host.ts @@ -1,3 +1,4 @@ +export { settingsRpc } from "./settings.js"; import { PluginAttachmentSearchPayloadSchema } from "./attachments.js"; export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js"; export { diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index e54e24e85b..2f0229acc8 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -1,3 +1,10 @@ +import type { SettingsDefinition, SettingsState } from "./settings.js"; +import type { ZodType } from "zod"; +export { defineSettings, type SettingsDefinition, type SettingsState } from "./settings.js"; +export declare function useSettings( + definition: SettingsDefinition, +): SettingsState; + import type { ComponentType } from "react"; import type { PluginAttachmentSourceContribution, PluginIconProps } from "./contracts.js"; @@ -41,6 +48,7 @@ export type { PluginTheme, PluginSidebarContribution, PluginSurfaceContribution, + PluginSettingsScreenContribution, PluginSurfaceProps, PluginThemeColors, PluginThemeContribution, diff --git a/packages/plugin/src/settings.ts b/packages/plugin/src/settings.ts new file mode 100644 index 0000000000..dca51d8245 --- /dev/null +++ b/packages/plugin/src/settings.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; +import type { output, ZodType } from "zod"; +import { defineRpc } from "./rpc.js"; + +export interface SettingsDefinition { + id: string; + scope: "host"; + version: number; + schema: Schema; + /** Convert a previous stored version to the current schema. Runs on the host. */ + migrate?: (values: unknown, fromVersion: number) => unknown | Promise; +} + +export function defineSettings( + definition: SettingsDefinition, +): SettingsDefinition { + if (!/^[a-z][a-z0-9_-]*$/.test(definition.id)) throw new Error("Invalid settings ID"); + if (definition.scope !== "host") throw new Error("Only host-scoped settings are supported"); + if (!Number.isSafeInteger(definition.version) || definition.version < 1) + throw new Error("Invalid settings version"); + return definition; +} + +export const SettingsReadResultSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("ready"), revision: z.string(), values: z.json() }), + z.object({ status: z.literal("invalid"), revision: z.string(), error: z.string() }), +]); +export const SettingsWriteResultSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("saved"), revision: z.string(), values: z.json() }), + z.object({ status: z.literal("conflict"), error: z.string() }), + z.object({ status: z.literal("invalid"), error: z.string() }), +]); +export function settingsRpc(id: string) { + return { + read: defineRpc({ + name: `settings.${id}.read`, + input: z.object({}), + output: SettingsReadResultSchema, + }), + write: defineRpc({ + name: `settings.${id}.write`, + input: z.object({ revision: z.string(), values: z.json() }), + output: SettingsWriteResultSchema, + }), + reset: defineRpc({ + name: `settings.${id}.reset`, + input: z.object({ revision: z.string() }), + output: SettingsWriteResultSchema, + }), + }; +} + +export type SettingsState = ( + | { status: "loading" } + | { status: "error"; error: string } + | { status: "invalid"; error: string; revision: string } + | { status: "ready"; values: output; revision: string } +) & { + saving: boolean; + saveError: string | null; + /** Save an entire document against the revision currently displayed. Never throws. */ + save(values: output, revision: string): Promise; + reset(): Promise; + reload(): Promise; +}; diff --git a/packages/plugin/src/ui.ts b/packages/plugin/src/ui.ts new file mode 100644 index 0000000000..608ed43b12 --- /dev/null +++ b/packages/plugin/src/ui.ts @@ -0,0 +1,56 @@ +import type { ComponentType, ReactNode, Ref } from "react"; + +export interface SettingsSectionProps { + title: string; + info?: ReactNode; + trailing?: ReactNode; + children: ReactNode; + testID?: string; +} +export interface SettingsRowProps { + label: string; + hint?: string; + error?: string | null; + children?: ReactNode; + testID?: string; +} +export interface SettingsSwitchProps extends SettingsRowProps { + value: boolean; + onValueChange(value: boolean): void; + disabled?: boolean; +} +export interface SettingsSelectProps extends SettingsRowProps { + value: Value; + options: readonly { label: string; value: Value }[]; + onValueChange(value: Value): void; + disabled?: boolean; +} +export interface SettingsInputHandle { + focus(): void; + blur(): void; + getText(): string; + replaceText(text: string): void; +} +export interface SettingsInputProps extends SettingsRowProps { + initialValue?: string; + onChangeText(text: string): void; + placeholder?: string; + disabled?: boolean; + secureTextEntry?: boolean; + ref?: Ref; +} +export interface SettingsActionProps extends SettingsRowProps { + actionLabel: string; + onPress(): void; + disabled?: boolean; +} +export declare const SettingsGroup: ComponentType; +export declare const SettingsSection: ComponentType; +export declare const SettingsCard: ComponentType<{ children: ReactNode; testID?: string }>; +export declare const SettingsRow: ComponentType; +export declare const SettingsSwitch: ComponentType; +export declare function SettingsSelect( + props: SettingsSelectProps, +): ReactNode; +export declare const SettingsInput: ComponentType; +export declare const SettingsAction: ComponentType; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 797cab3908..3ad434ce14 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3456,6 +3456,7 @@ export const ServerInfoStatusPayloadSchema = z // A daemon that predates this flag keeps `addTheme` in the server bundle it compiles, // so a theme plugin cannot start there at all. pluginThemes: z.boolean().optional(), + pluginSettings: z.boolean().optional(), pluginTimelineItems: z.boolean().optional(), // COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16. skillManagement: z.boolean().optional(), @@ -3655,6 +3656,12 @@ export const PluginCatalogChangedStatusPayloadSchema = z.object({ pluginId: PluginIdSchema, }); +export const PluginSettingsChangedStatusPayloadSchema = z.object({ + status: z.literal("plugin_settings_changed"), + pluginId: PluginIdSchema, + settingsId: z.string(), +}); + export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [ AgentCreatedStatusPayloadSchema, AgentCreateFailedStatusPayloadSchema, @@ -3664,6 +3671,7 @@ export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [ RestartRequestedStatusPayloadSchema, DaemonConfigChangedStatusPayloadSchema, PluginCatalogChangedStatusPayloadSchema, + PluginSettingsChangedStatusPayloadSchema, ]); export type KnownStatusPayload = z.infer; diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 1cfea1c99d..42121b6404 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -607,6 +607,7 @@ export async function createPaseoDaemon( const browserToolsBroker = new BrowserToolsBroker({}); const pluginRuntime = new PluginService(logger, daemonConfigStore, daemonVersion, { managedSources: new ManagedPluginSources(config.paseoHome), + settingsDirectory: path.join(config.paseoHome, "plugin-settings"), }); const serverId = getOrCreateServerId(config.paseoHome, { logger }); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index e12b8d7386..bb6f59ecdd 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { stat } from "node:fs/promises"; +import { stat, rm } from "node:fs/promises"; import type pino from "pino"; import type { ProviderRegistration } from "@getpaseo/plugin/provider"; import { @@ -38,6 +38,7 @@ interface PluginRuntimePort { } interface PluginServiceDependencies { + settingsDirectory?: string; runtime?: PluginRuntimePort; managedSources?: ManagedPluginSources; } @@ -63,15 +64,23 @@ export class PluginService { private lifecycle = Promise.resolve(); private globalStartsBlocked = true; private started = false; + private readonly settingsListeners = new Set<(pluginId: string, settingsId: string) => void>(); constructor( logger: pino.Logger, private readonly configStore: DaemonConfigStore, daemonVersion: string, - dependencies: PluginServiceDependencies = {}, + private readonly dependencies: PluginServiceDependencies = {}, ) { this.logger = logger.child({ module: "plugin-service" }); - this.runtime = dependencies.runtime ?? new PluginRuntime(logger, daemonVersion); + this.runtime = + dependencies.runtime ?? + new PluginRuntime(logger, daemonVersion, { + settingsDirectory: dependencies.settingsDirectory, + onSettingsChanged: (pluginId, settingsId) => { + for (const listener of this.settingsListeners) listener(pluginId, settingsId); + }, + }); this.managedSources = dependencies.managedSources ?? null; this.runtime.subscribe((pluginId, error) => { this.removeProviderRegistrations(pluginId); @@ -80,6 +89,11 @@ export class PluginService { }); } + subscribeSettings(listener: (pluginId: string, settingsId: string) => void): () => void { + this.settingsListeners.add(listener); + return () => this.settingsListeners.delete(listener); + } + subscribe(listener: (pluginId: string) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); @@ -318,6 +332,11 @@ export class PluginService { this.errors.delete(pluginId); this.notify(pluginId); await this.managedSources?.remove(pluginId); + if (this.dependencies.settingsDirectory) + await rm(path.join(this.dependencies.settingsDirectory, pluginId), { + recursive: true, + force: true, + }); }); } diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index 463eec8631..1a63845d63 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -14,7 +14,13 @@ export interface PluginProviderMetadata { } export type PluginProcessRequest = - | { type: "initialize"; pluginId: string; bundle: string; appVersion: string } + | { + type: "initialize"; + pluginId: string; + bundle: string; + appVersion: string; + settingsDirectory?: string; + } | { type: "invoke"; requestId: string; method: string; input: unknown } | { type: "provider.connect"; @@ -34,6 +40,7 @@ export type PluginProcessRequest = | { type: "paseo_close" }; export type PluginProcessMessage = + | { type: "settings.changed"; settingsId: string } | { type: "ready"; methods: string[]; providers: PluginProviderMetadata[] } | { type: "result"; requestId: string; output: unknown } | { type: "error"; requestId: string; error: string } @@ -85,6 +92,7 @@ export const PluginProcessRequestSchema: z.ZodType = z.dis pluginId: z.string().min(1), bundle: z.string(), appVersion: z.string(), + settingsDirectory: z.string().optional(), }) .strict(), z @@ -121,6 +129,7 @@ export const PluginProcessRequestSchema: z.ZodType = z.dis export const PluginProcessMessageSchema: z.ZodType = z.discriminatedUnion( "type", [ + z.object({ type: z.literal("settings.changed"), settingsId: z.string() }).strict(), z .object({ type: z.literal("ready"), diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index c03db034b6..b048ee1135 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -4,7 +4,13 @@ import { type PluginProcessRequest, } from "./plugin-process-protocol.js"; import { createRequire } from "node:module"; -import { defineAttachmentSource, defineRpc, type PluginRpcContract } from "@getpaseo/plugin"; +import { + defineSettings, + type SettingsDefinition, + defineAttachmentSource, + defineRpc, + type PluginRpcContract, +} from "@getpaseo/plugin"; import { ProviderEventSchema, type ProviderConnection, @@ -21,6 +27,20 @@ import { } from "./plugin-sdk-specifiers.js"; import { createPluginClientId } from "./plugin-session-identity.js"; +import { PluginSettingsStore } from "./settings/index.js"; +let settingsStore: PluginSettingsStore | null = null; +function registerSettings(definition: SettingsDefinition) { + if (!settingsStore) throw new Error("Plugin settings storage is unavailable"); + const handlers = settingsStore.register(definition); + register(handlers.read.contract, handlers.read.handle); + register(handlers.write.contract, (input) => + handlers.write.handle(handlers.write.contract.input.parse(input)), + ); + register(handlers.reset.contract, (input) => + handlers.reset.handle(handlers.reset.contract.input.parse(input)), + ); +} + type RpcHandler = (input: unknown, context: PluginHandlerContext) => unknown | Promise; interface RegisteredRpc { @@ -186,6 +206,7 @@ async function closeProviderConnection(connectionId: string): Promise { const pluginAuthorRuntime = { defineAttachmentSource, + defineSettings, defineRpc, Icon() { throw new Error("Icon is available only in plugin client code"); @@ -211,7 +232,7 @@ function evaluateBundle(bundle: string): void { if (typeof setup !== "function") { throw new Error("Plugin server bundle must default export a function"); } - const contributedCleanup = setup({ handle: register, registerProvider }); + const contributedCleanup = setup({ handle: register, registerProvider, registerSettings }); if (typeof contributedCleanup !== "function") { throw new Error("Plugin contribution must return a cleanup function"); } @@ -237,6 +258,11 @@ async function initialize(message: Extract + send({ type: "settings.changed", settingsId }), + ) + : null; evaluateBundle(message.bundle); send({ type: "ready", diff --git a/packages/server/src/server/plugins/plugin-sdk-specifiers.ts b/packages/server/src/server/plugins/plugin-sdk-specifiers.ts index f4ae72e55b..9961aafc8c 100644 --- a/packages/server/src/server/plugins/plugin-sdk-specifiers.ts +++ b/packages/server/src/server/plugins/plugin-sdk-specifiers.ts @@ -6,6 +6,7 @@ // published — that scope is not ours. Plugins scaffolded against that name still import it, so // both spellings resolve. Remove the @paseo/* entries after 2026-11-19. export const PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS = [ + "@getpaseo/plugin/ui", "@getpaseo/plugin/react-native", "@paseo/plugin/react-native", ] as const; diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 922a529324..102d7b3812 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -89,6 +89,8 @@ interface RemoteProviderConnection { } interface PluginRuntimeDependencies { + settingsDirectory?: string; + onSettingsChanged?: (pluginId: string, settingsId: string) => void; spawnChild?: () => PluginChild; sessionHost?: PluginPaseoSessionHost; } @@ -270,7 +272,7 @@ export class PluginRuntime { constructor( logger: pino.Logger, private readonly daemonVersion: string, - dependencies: PluginRuntimeDependencies = {}, + private readonly dependencies: PluginRuntimeDependencies = {}, ) { this.logger = logger.child({ module: "plugins" }); this.spawnChild = dependencies.spawnChild ?? spawnPluginChild; @@ -532,6 +534,9 @@ export class PluginRuntime { pluginId, appVersion: this.daemonVersion, bundle: serverBundle, + settingsDirectory: this.dependencies.settingsDirectory + ? path.join(this.dependencies.settingsDirectory, pluginId) + : undefined, }).catch(fail); }, ); @@ -562,6 +567,10 @@ export class PluginRuntime { } private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void { + if (message.type === "settings.changed") { + this.dependencies.onSettingsChanged?.(loaded.id, message.settingsId); + return; + } if (message.type.startsWith("provider.")) { this.handleProviderMessage(loaded, message); return; diff --git a/packages/server/src/server/plugins/settings.e2e.test.ts b/packages/server/src/server/plugins/settings.e2e.test.ts new file mode 100644 index 0000000000..baff17d0c3 --- /dev/null +++ b/packages/server/src/server/plugins/settings.e2e.test.ts @@ -0,0 +1,73 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { z } from "zod"; +import { settingsRpc } from "@getpaseo/plugin/host"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +test("two clients share settings, observe changes, and preserve values through plugin lifecycle", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "settings-plugin-")); + const daemon = await createTestPaseoDaemon(); + const first = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.7.2" }); + const second = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.7.2" }); + const rpc = settingsRpc("display"); + const read = async (client: DaemonClient, pluginId = "settings-test") => + rpc.read.output.parse(await client.invokePluginRpc(pluginId, rpc.read.name, {})); + const changed: string[] = []; + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "settings-test" }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + `import { defineSettings } from "@getpaseo/plugin"; +import { z } from "zod"; +export default function(server) { server.registerSettings(defineSettings({ id: "display", scope: "host", version: 1, schema: z.object({ enabled: z.boolean().default(true) }) })); return () => {}; }`, + ); + await first.connect(); + await second.connect(); + second.on("status", (message) => { + if (message.payload.status === "plugin_settings_changed") + changed.push(z.string().parse(message.payload.settingsId)); + }); + await first.patchDaemonConfig({ pluginsEnabled: true }); + await first.installDirectoryPlugin(directory); + const initial = await read(first); + expect(initial).toMatchObject({ status: "ready", values: { enabled: true } }); + expect( + await second.invokePluginRpc("settings-test", rpc.write.name, { + revision: initial.revision, + values: { enabled: false }, + }), + ).toMatchObject({ status: "saved" }); + await expect.poll(() => changed).toEqual(["display"]); + expect(await read(first)).toMatchObject({ values: { enabled: false } }); + expect( + await first.invokePluginRpc("settings-test", rpc.write.name, { + revision: initial.revision, + values: { enabled: true }, + }), + ).toMatchObject({ status: "conflict" }); + await first.reloadPlugin("settings-test"); + expect(await read(first)).toMatchObject({ values: { enabled: false } }); + await first.disablePlugin("settings-test"); + await first.enablePlugin("settings-test"); + expect(await read(first)).toMatchObject({ values: { enabled: false } }); + await first.installDirectoryPlugin(directory, "other-installation"); + expect(await read(first, "other-installation")).toMatchObject({ values: { enabled: true } }); + await first.removePlugin("settings-test"); + await first.installDirectoryPlugin(directory); + expect(await read(first)).toMatchObject({ values: { enabled: true } }); + } catch (error) { + console.error(await first.getPluginLogs("settings-test")); + throw error; + } finally { + await first.close(); + await second.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/settings/index.test.ts b/packages/server/src/server/plugins/settings/index.test.ts new file mode 100644 index 0000000000..c367c93be3 --- /dev/null +++ b/packages/server/src/server/plugins/settings/index.test.ts @@ -0,0 +1,131 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { defineSettings } from "@getpaseo/plugin"; +import { z } from "zod"; +import { PluginSettingsStore } from "./index.js"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); +const definition = defineSettings({ + id: "display", + scope: "host", + version: 1, + schema: z.object({ + enabled: z.boolean().default(true), + count: z.number().int().min(1).default(5), + }), +}); +async function setup() { + const directory = await mkdtemp(path.join(tmpdir(), "plugin-settings-")); + roots.push(directory); + const changes: string[] = []; + const store = new PluginSettingsStore(directory, (id) => changes.push(id)); + return { directory, changes, handlers: store.register(definition) }; +} + +test("defaults, atomic saves, concurrent revisions, and restart persistence", async () => { + const { directory, changes, handlers } = await setup(); + expect(await handlers.read.handle()).toEqual({ + status: "ready", + revision: "missing", + values: { enabled: true, count: 5 }, + }); + const outcomes = await Promise.all([ + handlers.write.handle({ revision: "missing", values: { enabled: false, count: 10 } }), + handlers.write.handle({ revision: "missing", values: { enabled: true, count: 20 } }), + ]); + expect(outcomes.map((result) => result.status)).toEqual(["saved", "conflict"]); + const reopened = new PluginSettingsStore(directory, () => {}).register(definition); + expect(await reopened.read.handle()).toMatchObject({ + status: "ready", + values: { enabled: false, count: 10 }, + }); + expect(changes).toEqual(["display"]); +}); + +test("validation failure preserves disk and leaves the writer usable", async () => { + const { handlers, changes } = await setup(); + expect(await handlers.write.handle({ revision: "missing", values: { count: -1 } })).toMatchObject( + { status: "invalid" }, + ); + expect(await handlers.read.handle()).toMatchObject({ revision: "missing" }); + expect(changes).toEqual([]); + expect(await handlers.write.handle({ revision: "missing", values: { count: 2 } })).toMatchObject({ + status: "saved", + values: { enabled: true, count: 2 }, + }); +}); + +test("migrates once, persists the validated version, and rejects old clients", async () => { + const { directory, handlers } = await setup(); + const saved = await handlers.write.handle({ revision: "missing", values: { count: 7 } }); + if (saved.status !== "saved") throw new Error("save failed"); + let migrations = 0; + const upgraded = new PluginSettingsStore(directory, () => {}).register({ + ...definition, + version: 2, + schema: z.object({ total: z.number().default(0) }), + migrate(values, version) { + migrations++; + expect(version).toBe(1); + return { total: z.object({ count: z.number() }).parse(values).count }; + }, + }); + expect(await upgraded.read.handle()).toMatchObject({ status: "ready", values: { total: 7 } }); + await upgraded.read.handle(); + expect(migrations).toBe(1); + expect( + await handlers.write.handle({ revision: saved.revision, values: { count: 9 } }), + ).toMatchObject({ status: "conflict" }); + const newer = await handlers.read.handle(); + expect(newer).toMatchObject({ + status: "invalid", + error: expect.stringContaining("newer plugin"), + }); + expect( + await handlers.write.handle({ revision: newer.revision, values: { count: 9 } }), + ).toMatchObject({ status: "invalid" }); + expect(await upgraded.read.handle()).toMatchObject({ values: { total: 7 } }); +}); + +test("failed migrations and corrupt data survive reads until an explicit reset", async () => { + const { directory, handlers } = await setup(); + await handlers.write.handle({ revision: "missing", values: { count: 7 } }); + const file = path.join(directory, "display.json"); + const before = await readFile(file, "utf8"); + const upgraded = new PluginSettingsStore(directory, () => {}).register({ + ...definition, + version: 2, + migrate() { + throw new Error("migration failed"); + }, + }); + expect(await upgraded.read.handle()).toMatchObject({ + status: "invalid", + error: "migration failed", + }); + expect(await readFile(file, "utf8")).toBe(before); + await writeFile(file, "broken JSON"); + const invalid = await handlers.read.handle(); + expect(invalid.status).toBe("invalid"); + expect(await readFile(file, "utf8")).toBe("broken JSON"); + expect(await handlers.reset.handle({ revision: invalid.revision })).toMatchObject({ + status: "saved", + values: { enabled: true, count: 5 }, + }); +}); + +test("installation namespaces and definition IDs remain separate", async () => { + const first = await setup(); + const second = await setup(); + await first.handlers.write.handle({ revision: "missing", values: { enabled: false } }); + expect(await second.handlers.read.handle()).toMatchObject({ values: { enabled: true } }); + const store = new PluginSettingsStore(first.directory, () => {}); + store.register(definition); + expect(() => store.register(definition)).toThrow("Duplicate settings"); + expect(() => store.register({ ...definition, id: "../escape" })).toThrow("Invalid settings ID"); +}); diff --git a/packages/server/src/server/plugins/settings/index.ts b/packages/server/src/server/plugins/settings/index.ts new file mode 100644 index 0000000000..8ac418330a --- /dev/null +++ b/packages/server/src/server/plugins/settings/index.ts @@ -0,0 +1,133 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { defineSettings, type SettingsDefinition } from "@getpaseo/plugin"; +import { settingsRpc } from "@getpaseo/plugin/host"; + +const envelopeSchema = z.object({ version: z.number().int().positive(), values: z.json() }); +function message(error: unknown): string { + if (error instanceof z.ZodError) return error.issues.map((issue) => issue.message).join("\n"); + return error instanceof Error ? error.message : String(error); +} +const revisionOf = (raw: string) => createHash("sha256").update(raw).digest("hex"); + +/** One instance per installation. The subprocess lifetime gives writes a single owner. */ +export class PluginSettingsStore { + private readonly definitions = new Map(); + private queue: Promise = Promise.resolve(); + + private readonly directory: string; + private readonly changed: (id: string) => void; + constructor(directory: string, changed: (id: string) => void) { + this.directory = directory; + this.changed = changed; + } + + register(definition: SettingsDefinition) { + defineSettings(definition); + if (this.definitions.has(definition.id)) + throw new Error(`Duplicate settings: ${definition.id}`); + this.definitions.set(definition.id, definition); + const rpc = settingsRpc(definition.id); + return { + read: { contract: rpc.read, handle: () => this.serial(() => this.read(definition)) }, + write: { + contract: rpc.write, + handle: (input: z.output) => + this.serial(() => this.write(definition, input.revision, input.values, "save")), + }, + reset: { + contract: rpc.reset, + handle: (input: z.output) => + this.serial(() => this.write(definition, input.revision, {}, "reset")), + }, + }; + } + + private serial(work: () => Promise): Promise { + const pending = this.queue.then(work); + this.queue = pending.catch(() => undefined); + return pending; + } + + private async stored(id: string) { + try { + const raw = await readFile(path.join(this.directory, `${id}.json`), "utf8"); + return { raw, revision: revisionOf(raw) }; + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") + return { raw: null, revision: "missing" }; + throw error; + } + } + + private async read( + definition: SettingsDefinition, + ): Promise["read"]["output"]>> { + const stored = await this.stored(definition.id); + try { + const envelope = stored.raw === null ? null : envelopeSchema.parse(JSON.parse(stored.raw)); + let values: unknown = envelope?.values ?? {}; + if (envelope && envelope.version !== definition.version) { + if (envelope.version > definition.version) + throw new Error("Settings were saved by a newer plugin version"); + if (!definition.migrate) + throw new Error(`Settings version ${envelope.version} requires a migration`); + values = await definition.migrate(values, envelope.version); + } + const parsed = z.json().parse(await definition.schema.parseAsync(values)); + const revision = + envelope && envelope.version !== definition.version + ? await this.persist(definition, parsed) + : stored.revision; + return { status: "ready", values: parsed, revision }; + } catch (error) { + return { status: "invalid", revision: stored.revision, error: message(error) }; + } + } + + private async write( + definition: SettingsDefinition, + revision: string, + values: unknown, + intent: "save" | "reset", + ): Promise["write"]["output"]>> { + const stored = await this.stored(definition.id); + if (stored.revision !== revision) + return { + status: "conflict", + error: "Settings changed on another client. Reload before saving again.", + }; + let parsed: z.output>; + try { + if (intent === "save" && stored.raw !== null) { + const envelope = envelopeSchema.parse(JSON.parse(stored.raw)); + if (envelope.version !== definition.version) + throw new Error("Reload or reset settings before saving a different schema version"); + } + parsed = z.json().parse(await definition.schema.parseAsync(values)); + } catch (error) { + return { status: "invalid", error: message(error) }; + } + return { status: "saved", values: parsed, revision: await this.persist(definition, parsed) }; + } + + private async persist( + definition: SettingsDefinition, + values: z.output>, + ) { + await mkdir(this.directory, { recursive: true }); + const target = path.join(this.directory, `${definition.id}.json`); + const temporary = `${target}.${randomUUID()}.tmp`; + const raw = JSON.stringify({ version: definition.version, values }); + try { + await writeFile(temporary, raw, { mode: 0o600 }); + await rename(temporary, target); + } finally { + await rm(temporary, { force: true }); + } + this.changed(definition.id); + return revisionOf(raw); + } +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 957c2ef36b..433c6db141 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -502,6 +502,7 @@ export interface SessionOptions { disablePlugin(pluginId: string): Promise; removePlugin(pluginId: string): Promise; subscribe(listener: (pluginId: string) => void): () => void; + subscribeSettings?(listener: (pluginId: string, settingsId: string) => void): () => void; catalog(): Array<{ id: string; clientBundle: string }>; invokePluginRpc(pluginId: string, method: string, input: unknown): Promise; }; @@ -2219,9 +2220,19 @@ export class Session { pluginRuntime: SessionOptions["pluginRuntime"], ): (() => void) | null { if (!pluginRuntime) return null; - return pluginRuntime.subscribe((pluginId) => { + const catalog = pluginRuntime.subscribe((pluginId) => { this.emit({ type: "status", payload: { status: "plugin_catalog_changed", pluginId } }); }); + const settings = pluginRuntime.subscribeSettings?.((pluginId, settingsId) => { + this.emit({ + type: "status", + payload: { status: "plugin_settings_changed", pluginId, settingsId }, + }); + }); + return () => { + catalog(); + settings?.(); + }; } private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise | undefined { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index ac5f19f66b..8fba3c27ac 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1679,6 +1679,7 @@ export class VoiceAssistantWebSocketServer { pluginLogs: true, // COMPAT(pluginThemes): added in v0.5.0, remove gate after 2027-08-20. pluginThemes: true, + pluginSettings: true, pluginTimelineItems: true, // COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16. skillManagement: true, diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index 8469d300c9..c55faa2bb5 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -889,9 +889,13 @@ describe("WorkspaceGitService checkout observation", () => { }); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await fetchSnapshotRead.promise; + // Observation setup also reads facts. Wait for the initial snapshot to be + // published before injecting an event that must produce a second refresh. await vi.waitFor(() => { - expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); + expect(service.peekSnapshot(REPO_CWD)?.git.currentBranch).toBe("feature"); + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); }); + expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); await vi.waitFor(() => { expect(getWatcherRecordsForDirectory(watcher, GIT_DIR)).toHaveLength(1); diff --git a/plugin-examples/settings/README.md b/plugin-examples/settings/README.md new file mode 100644 index 0000000000..45a896d63e --- /dev/null +++ b/plugin-examples/settings/README.md @@ -0,0 +1,14 @@ +# Settings example + +Install this directory with `paseo plugin install /absolute/path/to/plugin-examples/settings`. +Open Settings → Plugins → settings-example → Display, or run **Configure agent monitor** in Command Center. + +The select and switch save immediately. The title editor keeps a draft and its original revision, +so another client's save produces a conflict instead of overwriting its changes. Close and reopen +the editor to discard the draft and use the latest settings. + +Every UI component is a plain named import. The custom preview shares the same values without +using a form wrapper. Values are shared by clients connected to the same host. + +The same screen on [Android](screenshots/android.png), [Electron](screenshots/electron.png), +and [compact web](screenshots/web-compact.png). diff --git a/plugin-examples/settings/client/display-settings.tsx b/plugin-examples/settings/client/display-settings.tsx new file mode 100644 index 0000000000..f3f20199cc --- /dev/null +++ b/plugin-examples/settings/client/display-settings.tsx @@ -0,0 +1,148 @@ +import { useCallback, useMemo, useState } from "react"; +import { Text, View } from "react-native"; +import { useSettings, type PluginSurfaceProps, type SettingsState } from "@getpaseo/plugin"; +import { + SettingsAction, + SettingsCard, + SettingsInput, + SettingsRow, + SettingsSection, + SettingsSelect, + SettingsSwitch, +} from "@getpaseo/plugin/ui"; +import { preferences } from "../shared/preferences"; + +const grouping = [ + { label: "Project", value: "project" }, + { label: "Workspace", value: "workspace" }, + { label: "None", value: "none" }, +] as const; +type Preferences = Extract, { status: "ready" }>; + +function TitleEditor({ settings, onClose }: { settings: Preferences; onClose(): void }) { + // Keep the draft's revision so another client's save produces a conflict. + const [draft, setDraft] = useState(() => ({ + values: settings.values, + revision: settings.revision, + })); + const changeTitle = useCallback( + (title: string) => + setDraft((current) => ({ ...current, values: { ...current.values, title } })), + [], + ); + const saveTitle = useCallback(async () => { + if (await settings.save(draft.values, draft.revision)) onClose(); + }, [settings, draft, onClose]); + return ( + + + + + + ); +} + +function DisplayControls({ + settings, + theme, +}: { + settings: Preferences; + theme: PluginSurfaceProps["theme"]; +}) { + const style = useMemo(() => ({ color: theme.colors.foreground }), [theme]); + const [editingTitle, setEditingTitle] = useState(false); + const closeTitle = useCallback(() => { + setEditingTitle(false); + void settings.reload(); + }, [settings]); + const toggleTitle = useCallback(() => setEditingTitle((value) => !value), []); + const changeGrouping = useCallback( + (groupBy: Preferences["values"]["groupBy"]) => { + void settings.save({ ...settings.values, groupBy }, settings.revision); + }, + [settings], + ); + const changeMetadata = useCallback( + (showMetadata: boolean) => { + void settings.save({ ...settings.values, showMetadata }, settings.revision); + }, + [settings], + ); + return ( + + + + + + + {editingTitle ? : null} + {settings.saveError && !editingTitle ? ( + + {settings.saveError} + + ) : null} + + + {settings.values.title} + Grouped by {settings.values.groupBy} + + {settings.values.showMetadata ? "Metadata visible" : "Metadata hidden"} + + + + + ); +} + +export function DisplaySettings({ theme }: PluginSurfaceProps) { + const settings = useSettings(preferences); + const style = useMemo(() => ({ color: theme.colors.foreground }), [theme]); + if (settings.status === "loading") return Loading settings…; + if (settings.status !== "ready") + return ( + + {settings.error} + + {settings.status === "invalid" ? ( + + ) : null} + + ); + return ; +} diff --git a/plugin-examples/settings/index.client.tsx b/plugin-examples/settings/index.client.tsx new file mode 100644 index 0000000000..09583a6024 --- /dev/null +++ b/plugin-examples/settings/index.client.tsx @@ -0,0 +1,21 @@ +import type { PluginClientContext } from "@getpaseo/plugin"; +import { DisplaySettings } from "./client/display-settings"; + +export default function contribute(client: PluginClientContext) { + client.addSettingsScreen({ + id: "display", + title: "Display", + icon: "SlidersHorizontal", + Component: DisplaySettings, + }); + client.addCommandCenterItem({ + id: "settings", + title: "Configure agent monitor", + icon: "Settings", + context: "global", + onSelect({ openSettings }) { + openSettings("display"); + }, + }); + return () => {}; +} diff --git a/plugin-examples/settings/index.server.ts b/plugin-examples/settings/index.server.ts new file mode 100644 index 0000000000..13b136c58e --- /dev/null +++ b/plugin-examples/settings/index.server.ts @@ -0,0 +1,7 @@ +import type { PluginServerContext } from "@getpaseo/plugin"; +import { preferences } from "./shared/preferences"; + +export default function contribute(server: PluginServerContext) { + server.registerSettings(preferences); + return () => {}; +} diff --git a/plugin-examples/settings/paseo-plugin.json b/plugin-examples/settings/paseo-plugin.json new file mode 100644 index 0000000000..a654e069da --- /dev/null +++ b/plugin-examples/settings/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "settings-example" } diff --git a/plugin-examples/settings/screenshots/android.png b/plugin-examples/settings/screenshots/android.png new file mode 100644 index 0000000000000000000000000000000000000000..41e09458c73471f32d99ab3ec2bfc6593606819c GIT binary patch literal 75487 zcmeFZXHb+|*Dcyc6a{oEB3UG7B}!Hiu*o?GB}&ej227wJAQ{P$p`pn+Dw1<(T4-`= zpa~M19L{Qd_xI;ioge3(d+XG#kJ`KH5xSqW)|zw7F~(eFSczOv3+2;{Le*(3l}duCz7i1SG{~WGuQu0#;4z;xKS3iS1r5AK?XsDIqJ`p zb1$l1&Yf(ogXyqTmz2? ze*BkjJDeXthUlmN9{ev)gqpSc`zgVi$hJ_-zQ@z()Q46bA;C2N(}{&NhzZN?y3-Tp z`5bJm4AoY~3s|yANEl^i>AD>5SlW)3NR?uGRh~b89$t|x6?~84U}q>I19r4)J+i7Z zR_bowfQ9Rp=dD*C*>P%DDHD*;xTy1YCWo77;kYNo7ZPYPVU+)87vLB&Mcx^pjpSGAl!os8p)s_@XS&Ei=}ro%l*4I|&pk2O=1Vr^^3CnvbmUwi85 z(nR$KBYGbv5n6^`qio|vT3s=muJ&gaY6_e!4vvmQip#4F^v`h+f!TAtG33pH;m z+xQco`A_EEao;)hD%*Q{6sSd9hEMh;nCaU{&`iq z2iLDIS+CtQoEVrRMiy!*DJm*{>=!;iyPba4%_kImZ&H7d7~c3tz-~N;fQBmL_iydF z1`W8D_2luvq3;+UxSg)iQJp@Cqr`h0`ftjU=oRiu7-q^wmv}caHlEm(R!Y0Ll=;A5 z)B}8fPx~fIU&?IhE1i?Odn|)N(B}c(zR)sqU7M+5gY?!fF)a}SJuhFqN(~?)*JZKw zS|7DQW;ot~4i=lVt&Rzvl)tnrv>2F`@>-e+#LXi=%bpLKAWcBZlNi=Zp$X`5n{mSm zo7~VBFHCFK3WM)iZcCiqUqIW{_ROs1YE>d=yysP*s#aEcObutr21m?HOjaiWIEtec zxYa<($AGZ=-&-0v-8@9j&x<1XNz}$Waf{T?&+m#B+&l_ZF!dEH4u(T5c(I2O8bZmh znyZ>=!$mFPwsU{NtBX_EaVx`+Rzfk5n7UIfVKVMdKwVuO1N6o{u3;l<)S_jh%Y>H; zsw0wlZS3#*(Ko5_r?1lG7ZNeTdi>-2NE8JVup2WScXM~w0>f11HX#hR_1as>Y@_v- zR~@6G{rvelg}Ctxgsa4Hzr)0MRtUrL&bJ{i*f`e7&9P(18Y)wDe(#I~^@4h^sxn+1@Xz$LW3+yJ{zzkVVE%L9Do1G8Pls1UEk$pF}jG73U)c(cZ8~{sz#Xid`q&fztb9`b+EO- z2=zl8stsAl_qo#IPx7=wY9r*QNevy_3_){rT-=*{(Lt8{@E8sQD=q4Z7$-5Wog88J zwX}rz$&63!pY$Ut*uhm!<$!qTwbH@7uoAz^W2Ih*pg_X)Y%A^0#OuZF>dX}zD}HwTkUU7>HtAj( zAVl2bfD)3BphWIW^%%|loi@^TVtk4;B8D$GtBb!meUh#DywRBbLo#rsu-qCyf;eqc z(+u&`ojb1BNrMKhSf}U=5Exl_cm~%oR#S+i_4++=sH&4wi6Nz+;`J+uUFQkIVNS$z zwQ7^(V_TWnlR+>7&)GE*$~yM;NQ8l&S=S-U)|(7u9+QH-mDRZTEgHK$=c?)&tK%w1 zr`j>KUk{hx?@jf&d4~;V7IfOzZ)PR?u2_PLs7C0P*_pXhp1%Y^n&%{Kk6}&W>#Fkd z72B`J2NCs--ne+A8_M9vl#wfTn7`8wfoNr8^NenQsXCQR^pTu@(aEj(GS#T(^eMqz z3JNx8jnhKVVo!>_J9REAYfxxt@Aht{T&(6d9<%&)xhaXqtR3r^^R+wfu~a{!E$GrT zkoxRW{ht&NXN<^B;tLN16_dmZu7>>Fs)Id!>R(|qW)me7LSDeCmOXY-cYetS_VQvK zZxn0XmeVXFv+v8bB#U`#()#YGJg&qe0tm#*{%!ze9)S#4Q^;yH3*0(VE=kxes?=H6 zH!>?|5KMn<eK(Irf+t znXNk_Bz6Y;&fpVgrwP_#Z{NNh!5wP3Xj7g~o~JY5lHpvBAFB)u3Wu=(#3oe$Fg68!!5JNm!u9XWHZUyTcojEwwA@ez8@64RSh zqRr7V!EklCFcQQ=iU4YTXddeAPENR zxvzK;0x_Re7h?HJ&f`42vXb|`xg>y+m6i2y=4b=A(Rg-Gt;-=jm;9f!Oh1lrxV7%O z$6_}*zx8NZoZc1vGuc`0t!+&2kb*+Oq9uBGSp8#QpfxU@|8N%0?RvPgEV|~N?#mqg z3;!e$?`Wum9*jSgcAs*s93LN4{Fal$a#C#-ExC7{kdTLqfgu+p@~uCA`WL!lZ~S(G zFn%j7EqyL2NlHPH=eswao;#&5L_q5shxPSg2CuY~Zn`}nIQYiQ)%?2c5xv$zU+hRh zms->7K5;+$@f z#hBV)2j}DTyea&d+l;i872C{JNYk+9=|MIgyzlRO%r$(M49HQ@PB*q81(%t7^kN|| z@n;D6Q1%_WyWvGekIJpliWe?KYt*P)CN(-3TJ$Ck1d+0D)pvhqz&|&*AJxpD==sIP zJWxYgPBp*|*Y>8O6BBKmQ(8kP;4aIm(n2sUB4{4ftyE1;J&J{;ZH()5(_5dlNR{A4 z&iU0<2G+IMA-}!VjzI(=DO-;D%TFqkwe~3hrLwTv8jmNtPv+LRuH^W58W}*V(%Rc! z7U);25|B`L9w?O1u{=@+uvl67RjRDF$I{P(8)GLMcJRFdaF)ld5&~R`weB0e<&!@T z6W791OrA%?3)*v$VXQ|TJix1xlELij)A3uiPfbm+w6(TcxpPgiPEJm)3>T-bpbEKZ zX=yXFGFJ3=gx01SjpSmu@L3j7SZv%9l&4Z(qjKurN7M4_*UJbtEkyqMgq_wa<ZkQprFGII4~h&% z(ym`@dk!WbK(!2)M5JSq+*bx}hp}*QbT7_zbaae0m;_Fq{kc?NP%F@fb6f3y))(rd z2`aaU$Vj!Aw!RdJLR!C5C?0&pNGPHT4&*fQ)H{Z@IgE<+C274_jE+pBVi#^~G+^yu zJ3k1d4`yVf-y8&y;!!sL{tto$y-C3?ad090EaA86enWq=1Nw9UQop+b#~19?wY3(W z$E(%Ohl^XYH`lX&4eQ-yX@5)yO*!5$0bs9-epEaKtjU<;)7pz{1o!t8Jhq8oCq04!m4zSN`ZZOhur6(PZA_8c?s!&C zbEdThhb<)%TwfW|0>NKx!?3=j{%geG^GG@p;*ocr+ZxqQ3pSNq`LEw!Jv5sXb!|M^ zwUN*RXq;ke`W0*&iL$g^@s9O9-IT%C@;wfc5p|+NyWfs`Dn0GwYTa!4k1#37Vo8Do zGPvqk;eNWIr<9S;u4gi~Cpcnj;%n0UyeX%Sm#tz52;sSM7&M{2{p;kp7T3BbD~ZRh z1BIFkib=u?hvP{it`#c>xV?bJ>w*wSyVPfzwAtC&G=i(d564-gmBbD*0*J}2qB!pb zyKPJsfP}%uC9de(;A=IS_K{5^4@9)Ex$r*G^r37e>((&d(gJr-oZ+7#JuxC8ZB4}O z&D1FjAcQ>K%pl!z8#DcRenRUEzzgZ0Y*4>i-QH_$blq<+M+~o_%PYM?`TYtW+ii@O zvriq}9sb}r7t;TbiAh?o(pC-7MRoP{!&NFKaK?v}f>!zAN)qYrQweWM@v(F%fxD4s za>Ipyn5rKXrBQKlIsX2ggx2@71~cXGW9VsRf?rGyzrbR`q^09V(DvB5vr{~?<>cfv zD92rwL&~mYzFgm5=jn{A#n7Zv-MN!qTv%^a3Nxs68TjQsRZd6~N-dHVk|3}$ygUSw zu=1ONF{^>JkK^Ox3{ZaSk>Ly_@eZ}HE6~=W%fT&a^+&s`4*qxbGoXEZwiL2BB~0c=JMb;U}7diF?@F!8Lu#`cqo+Y4ky zJa)9gKH5in8(JW}vT5e$WGTewxGY;w9C*8IAU5Y(mysyLt|4sq=k(A^rRXZl^sC~3 z{8j8mOFo*P?wB-S*;k$Go`jOgBuC!xJJBc%CAY3SO^6rJSm=x{r6y*A8q{b9D*AEg zsHUPWL7qaDjV}?Ck&zL3FMa2b`;nEUTjq1%)Y8(z%Fw)TBvX6W8EVltFKT`wjhUNE zmyyxOj)DA9NM_`VC$!4O5bC+A6nkUVAkQB$LVJ6Ay|z9xmz<>UBgA32>85AW2d2Z& zdLFAN&3t{!a)SwBSN`QlC3P^Xp{iKTf}Q@N*N1Lfrclc+F7v2jz3m!T>`qhavrZ0) zqr2eLnNeV7ZDgONe!j!ZCcxFZQY^?^_x+Uo^!GZt(!SzC8#{M>eroE2bf4W-o%+K= ztd1`%pGMzc&d|>QaG&kvu#Y$)bhAp^iyEVQ9Lkjl16K^SWwWCd3Q>@q;w1}0emK%C z-H6=G)p2Jz1FExV02KuFw22-VRPKi3=Nqs;&tm#B6tE+Q`;BM3Tv57w_TOtaYV@2J zdpm}_9T{dnNr#bJlU6`2nJCmtKVCWHS!&h(5slwwbP@5 zQN@kM^IMx1Yk#5PK*CY&?gmn>p=7wt*(GPEmK1i^DgQDv&xwf%WTiVT>~w)_RQ#;R zcI7HFOOAo}I1QuBjx1zCqqdRI z2PSi~e6!&)<*>G04)oeYrB*Wy4Wi|SAOG0nKX2b=)qVQ5e|jXq&35OXe|A#HjFcl= zLjZzhgA(4nru_a8S1RwMlPOCIuV$Q`o5TH5QvzHpkE7TSv5ngg48) zY`Wnl{N9QqhbG3xJ3H+b!vJj1-^>B&HlB{oGk(~uz3BB=v9nCfQ5c941c5h4x@oiE zw&;q8L9T1GG~)VaqNg6ohS{7g2NTl^%elTMhh0OZU+!@m>YGL`n`87{%4WISDC^xi z&7a7uX{cj7RA{(AIx6-iiJ-1MVE_*tI_k^JXFK=;`5sQW&^UrXf+Aph8)!!?Dzfub zR7A}`Z2~BzIG-&LK_!xxtNkx*t{=b!#CGhw^N!b=ohS9O>Z%;>0XMdmQM~V`b_itkHyX z&J{ARsqbVfrHr6A>ga27Kn!Rp<27*8c47uBX7rM2(HMmQS z4)NPw?CaX(?@5p_BIDA3)QRNHkBW#~6RE&Fxosvd|3%Y$rN?2RlQdyQTG|hxYo=j0 zm|-cPXIN^2AqH8FJOp;aZ&2;j!cu9EGBiv+I9lgg-w4>qQXHy684d-Xc|I%kOOD2L ze7d3LGhV)6ye%9X$|>cDUlgkMG4oTDg1Sk5%gyRG%gL7eQ?pa|&;wo&h%Dlt(f9PV z(1oD*7UOEVSh{X_H*XILuE*WBMs`;5d*$7HT}Y;oi2I7oILH+vdqaNh<QP;IGWL$8D9tyECQVzG<80oQEkA$Z9Wr3Vwe&oaJcQ~+qKmt%Z5MrDn82Wh3 zw#t+iAN-6-mkHC2E~QkB9QYi`oZq?7c`#qow?)eCG{Gb)s&9=d;1`z!gHdibA>9?j z-Q_yf9kgBhT{fD{YO=0c1p#U~JM6HIT4RE_yFqAF)XW*JpnIhcO6@L~ZM57UT@|?u z8oKXOV1YgbQqezDuScyP-<%xc zt~7Y?mVy+zg3njxzi9a!Qn~c&QtcZ~M$EOwyJER9H8+hx5)45T{A?z@eLLJ*j`6 zac=9Ko>gx-LsuDNJrQwV%`Fx7nz3?$J4$$969*6+=7NoMtib~_Ij zIduwaMoD0LW7mzJx7&g_Lxq`}o0~yiAU5$$1}KIl)yW?@I8^VoTc#UvfRehYbOG|9 zP}Zg?9OU?1H74xax9^8jpTXvQEi9N_*T;3T)6%q=Dz4RuT16!$84NcXNz9RPPi-}< z_#Um{|EJ)!Dhquubz%bnDQzv#b_lbdt%r9=mC{dQp4ivvcK5g zH%cSrD&HM1re{!_L8R2Nv<*O)(~sy#Q2DP^htnf#-Rc4tB(W~T?w-${ z%z8kzv@!F5yXW8fX7S~_ev*g*WtM7#k0-zF;IH4iee!o+T!Q!~MlbnG%H9ylQb>|+ zSLp`$FBgOiw*OEYr3rSb^vL_`x6j@?6-+fZOVXb0`Dv1=p4p71(Pk;3X#I{6+LX-%njG~4Ix`_0&QBZI+wq}axtw?L_@+xYr@@h`U_CWVmKZ0?hefk-L< zfjQc>jxIo0a%Fv*=oV<4e9k7^4^g4PZ1Ynflys<3&=5;ebg?P_&orcvYHJO|F!b6e~YJvI@d#B)-w;A<}VraF1|H z2%Vl2`Wc9hD$2^rF-Pc1>q5YBS#=Jx0tLvW{T31dvlUOR-o{wYm-Y$QC7DNgdO&UT zZ3Ohp!!`o)g$?Vg6f510NgMSePWpLQ-Ya{)6n!Xm7Sg43c4~({OH)(wF;+c zk^mxcf|V3E1cD&&Oz>*F{E|dy*G#u_08HIW1VpMJ1(c57`G~K0QhW|`?HbbqA3J75 zRdg@Ezf|By9`@zSa5Ov0w}Ab6qn(H>&;b?AtD15&%XO3YTz_Tc@A?3yBgkZS*KT6C zQpqU&7&X?2M{z2?cJnVkf{0{7O>MXKYCJS73{|x`=L|E%+>E?6*-kNR|4JkD%a^iR z;s(lme)~y%P96?DZ=gaK7?v^xiV-4;dN;eN9P3_w^i$*lo{jdK92BQ?SmMd-3`ki3VG{Q%VT}w)vfUm3HMv)%$iu0*4D) z`HO8V?ep1qguk%)kn~jO8(>{D;F^>ROd$z5XJRMwb93}iP-Vx9*f&sf0-5fg3o`eX z>0}lcR<8+BVaKbTSCFN#>nq+Uq#w+Ag8BGWRYYWANMhzV1qvJrNl4yAL zZdFZ9o^>pjkX@wj538`_RW*Ihf`;8mkSQ#Gf2FHlk8JqosT&~{4+3fK6atKArSqFr zEz1g&`G z_RLBZE4%1)ZxSME9=UG>+O<}`ZhFpVb&_0^Gl2HP;TV$K!LRuZf&TIh4zWVQ`*x#`gGBm5JA$& z*ULSi5>m%auLxj;$z5$N32FuWqpORR-Ao@lrqck3G%@V_4S zfM2(daDDsRpmT^R|*6ntTcuG#<`!C{{q_gLa1-ol_2D(;oJOqSk`XunfS z{tvB{bsObvnmnRFK{$Re-7XCnjZiw7H%Y>P`J`<7@{RWk8c=xnVs&VmfVQmKHf6HD zfa{T}np*iE)JL6)T93^%@!mgySwPll4W%gn^)#J?(G&*&e`w9^#cq|b^@)Ym)qK2Y z0sw*l+CltKmuvyl<$0jKscvY+PZQ(0*`2ld4(U;#?$E;aztd1}zKk)_2CP`#s6?M? zQvZ10`=9%XYby8KUL5vE?HGBgsj1;cL^iZf*kE9#Hyp39r|Tp1h3X%_e%B+wrDqPK z6EGiU3Gvu}Z9)!(f+B4A9Oe5S3Uz(Rn*0`Xz#nYic&dAe*5g6fNx2nr@CzuoJOex* z0zy5f55blBjrQZggoD_hZJY*xW}AhIZ>Y<`FuSU&!1Jk@zCU{(7$;0 z_O0eoZ-N9j8qpS+Ew5Q$tA6(L)j?`szxq4)Izb6+8WHA`DD5s+_rbphg3n@x1B~|2Ypb(Dv*1fXSEW2Mo)=zf|r2$DC78%JfG1yQaAo@fbUp16Z3~yvDC)aKWXxjeS+8T^gNyn|WhYNcz=F7%% zaRTTGH>&5|+S-Z${Dwx@ZC-pB#M2nrDL%4+VN@#m`uV`mhD`On1V>pFYwrx4oo0i| zmUQWW5L-Lr^$43?$L;OSC9KQcuoDr(rML1G!}VW7V$t_49q^|ykwfLk9Jlqc%u58s z^R;WhlK>J{^-EBm4YR88nJ)En(6vp|N%Dn!O@AsazLyAKa~ddTDDLoU{@GT$A|a80 z*5kN`cldGF?Wl(08vYCh2Zf4f&yWzsCL|J>IRP)jnIWwK|E#TBs+yZgjjSOJWtr}2C-<-1*gjN5Wr=rkh_v@6!;a~)I1 ztxurytjjt7<(%+}*Lv+bPv-z89k-}wZ+28?Uw;ylrI>82e@$#}we-*#$p(+s3P*#7a1ELpSR3wj+b)oKcAnU&%n<)TDh3{nWQRlJd_2s znes3sb8P_)$TeP9^NNrv90xt*#e!d>buKo2Z&l%r8D0{mq7-gzOi-9 z-3ylT-d#sVDoJFbT05Y^K8T``oUQZ;Z5zd%8+m|d1 zqb*cSvL9kkmk|;j|4Mj9#T`1AnbX@L`R%kKPQ=gfl#&SY^g0HJ$^D{TqIY=(-Q9^O z1=Q*2_zTukfTki$21CT@x|-zTD|M?e^=Ni2%>$VJLG(}6~ipIgp$W*x;88S~p4#9;GhAozjAoQ{UZy@ZGf_;Wj zOwy?LD(VEHo3llqW!Ff-Si)=8h4)7!_ZvUhkC&93upMB+unnI)5-1Gcre7;q7F zDZsc0yL0LKoz*`Bf+X9B8|kn5OIP2_ifm9UkVqW9JUiLYvSLRp5+6pV@G9UGI*f$)GY3E`#LMV~;PuHiu133rp3!r0ttP;tr zG|>NDGQ^Wqz0)W$(t*;ITi2nZ-m!hfAbx zq<0rzgFO9l%i%XGxVDiWF8b%spA4BHDOc#fZRfo=;jR-cL<@Pa zadii4-)-*#n5v$nwm)|v5W;u<^t2p6AX)vbGTsBBLRdyJ9fIN$?>Hv%e4`x-zf}fA zv`dEuSXpW^+H9kHTr5H67yXx`*)%$>geHLI!CFEGxTx_QM4t}vg}cFF;>RxmHLv#O zO^&ZY(bF)o!#E!wpZtnVApA=<2P^}nFeWfC(D969D4U&)EiWhGCL~J@Fs860oM5Yi z79gJNv*lSAfve(`@6I&jRh*D>mUR7AP-0@@yxyc%Vckvykn>rog;XQ#KGhNIPd-z zw(u@YTsK`NB*Na)2&xL)aKj*QoX6Lb-6MMnF>`qpfjba+X1=nDu}bTdg;V<5GJIaE zzm4DAhCqNpHwRM014KvHhBX1$X$wZ!VcMS~CZ8P&jGES9dV1|1U~o%wVOV_^m&avy zA>GX*?#lsXi70iN&fLkTX3z6S7IPYjpkf&>#C5s{~stq5Gc33!ji ztR02?uRfF(60RlK$*h<-+>sO6f_k8jvimic*<4VGhcR2M*z|3(UTk6Gh*gh~bx^Gi z7!l?F@~l68apl~>^8bdz{l864|A$RZ7~MP{>y!MCVtAT$zo3`fo2oDBWML(*JhD!ha9v|KZ{M|D?_2|GrVfH@*ZVyN{L--n<#b zz{te3^5?r`%lO0dw@Vn{pAG>{1fcX!2T7BSpTE2+A=2V|xi$RE|3XB@($bsyqh68! z`uJi1@e@!)W-|kY6BvJ5VUHg_Ze9EJ>jhrd6&BV7Ma)p+*(n^))aR}PZP5zUIZn>b zYIs|B0w45VaLY^~u#|zH`E_uv#H6HxpF~idPG4W&JkTl-&u5ti&Q_pTsmLC~4TS3F zp{{~AZ$tt-&5#Ssd|PnkXBK^lj;lA$&jImNon;~ygB_M_Zv6as0~mjKXq-WB189)g z*%?xTziadh#Zd`4{i=e^{JdiD#IKvs2iBxqL+&BnsrU>ncE! z8P0&-5fTvr-)*GvJ9dN}b}$2+qx=jMMrxouR;l|>Ta)Bx?2qto zEye@i-xJ`5!X#GU+>Po!K6MNoLqlrsuM$NB2g_9)Nxyipl;U@)Vq;^Y!cTU78^4uj zFNJ;~rWMc6Rm-~1o_6`_m0a6ew||X}Ulab}f5FHNbks8631Rx*Z_pJ5Bd54nU`))f zxN+^8awL-i1Ju#cQMca9H57LU%yyo^_XvrJV=7}~6BCn2f%^cTz|a-8!frcnDvkuK zhF=1QG-yB2t7KzilWk~duo|hW&eJO7Qc_Z4WMo|SaVc|KAI~+YbrW)tBD1it_{eXS z(ciBOdU1d`Z4mEY=Q6BHJs}`|)3&=Nz-(53u$ZubXO4h(YjC7M&)FwAfQT{=FwPMf za*rSD9-)kgz0U)rKP4q#THsoRnh6Q^F~D+&pGja)@h&(`^yRBp^Ca6s@pEp;<`OX& z5ZC1~4FE3MdBzzZJb3#3`**9UhI+TPu}n{A%ox@BVCrj0diiS5LxOkNC?<(?A(4l3 za4?G~1TFEtx1Kn00}S<}P0RX29G*o7FqjMb{>Kli;i_W5I%F9w_Mw0-T0pZ4X4M+v zsubt|8LP0>3;iL7vYP-N0Y>QHAr5~OA{CVLg1&2QyqT3_w;^>v{U)Y>i@S*|BoT~oux$S4J5OJy6I zd<_TX0$qMFQR5F_`E5q-ass=Dblv_=V zu@oAX%Yxn_-T>#S+FEQ3K-j?4hUa7a{UM+oMBxS*S9gUX2?+_91(pXTW@AOK--Ggz z1Bx&8y>^zqd*QHhvNvu5bC#M!Z{oMY3axx?m3pte7{K>1EKm8JA&)(`T{dT>OcKDe zST@z~I)s9}GCe&VujduI zou^w_CNBrmIe-^i*LaeX5AxD=O3ho>>b>`XQ$|YDS%U#|R*ep3>egE!%VF_?edUvu zYS~r*?MheCLGhw87>9C`Be%H*CWZKR6iU#@)*h^#$Cnp>52PR3jq$+&)_+J(kDDm7 zl-{@v7zec}??n53=cA#y#BRu2S06GxSgYqd6O0qTXXX=3H< zivT+AZ@~Ab3B*UxkMkAG=en+#p5jSBCzu}nt^@A^pnIt0wjW=g23d}@^55Y0B@zTF;e)1v@|p7Ac<`}MW%4J!FaR#or2A5kS**?D9FGnE^g?(x6aSRNQ5vH+aT(F z1zwZqq`=&-91yQH!+^-4A`?ob^77>;hFr%;CY^xX5BydP7MQYpPUly^qYNgxVgGx2 zO0HhiWc={W*MVX@#AMfVb=1N{w?3^P3scPaNxe;Jn2ftXyI%-8ng^G|at$ojgTuo- zS!5m|_#}VfY1b2>oB}!)G!F-fwRN2A$<1apxAosMMZ4!$kR=AAOFu`*K#r*?Y6EsC zb?inXs5RN4sv%!*;p4Eeaat&iSRNpNt@C|)rc))0Q2>brCQw8)z$c@3&KTfNiAS|^K{@b3?0VBj{va=e!g;pk!-*3 z@XI1Dtl7{R(>*YdE`zLgwut67GE^N~VO_4Lt#Mg9$NfN!7%TFHRX7{`p4&nSJ;^AW z;Rh6`EEHN?n+&``L{xk%Q+J}X0e3Y3CuK(xpEjKQ+{^2i({ zTmfd1o;TPSPU)s_arqYG;+$vA0j&Ze_mj`xemnC;VoH4I1q(l&D!J=dvghk!?K1lf z!!>lRmg-n19X`|!lw?{NP^aCdqRKy7uT=uzfUDox(=2X@myV9ksQ&qS)wUx2_$MI9 z2paj>mjAMq_RRI`OBM?X35kx4)f#&gE$mw6HzoctAV5|&0b?o`Q2n>{vxp$_%NQum z3DVuAK#F{v_%j^%bINVe04r`*;jeJ2mgo2fU;YD(CazqBbOeo9ZAY(219HRTbAPMCFs|oz4 zN4Y?RMX|Gj7Ar3hpfW>~grO0}CdPcui`_Pr2K83a!Y)gxJZ7EkKs3B?;exR9;+Gy< zxY>Q-$fPebzc%OE)@-T)P%|cPf zn?@jWmH+v3zSZx6^FFShe}kte&@E>(as}}p?N!^3nG z@wz#HLf9q_30tqesa4D${$~H}r7OwWz!wjJD3?U%(6IqYZ!lLa`%6h}Z7tv))B!IK zH@qFoWiYrm1E)ziyaW zpb!Rd(EJ7_;Lw}|cU|g4tveRXpDhkGX)rYTi-#|@7FQA}Mn~&BONHH5OmV*U79QvG zCe>e7y=ex(8d};E8(JkObRfp>bO0#4-{a!qE=o)x3~G0}jt^=|EtEjO$+a0RsaY7& zE1mEKqMmM*!|PC2X=&-szCNYX)6$c(od)FU5Un+va!Cvp^Es5!=d%EPxtDsj{4Ae_b!otJMUWU}t#)Fxg_y zK+C_!U5%;MP4A&t_2<_=Fui-q^6Y7li&o8}f4u;Fx$n~65wg8Yde|dN`s}V}zW?DN z%PukDJDke1D6aaK8%fGU7v!Hm7dSe-&AZBwV0tIQ=4xXKcDBaN&BCpZNp81W40GH| zmg0w?taVibi?_7kl@oZLlbyyhe9KSl_%7(1z&4$pSPfMafNZP~c8)!^77kA;r|=9A z{d5lSs;s(sBh(auq4XL1`b~9pKB2L(kxe}(_0J!N1A@ye zfMRx(37r?x%>axWB|Ym%zD~LhuLVV)79e*(L}KIQRA>vM?Q_IM%aKx37XYw$aB#4^ zvpgj1@#ods?8co{q?|&6Kr{pFbkyR@G=4@y^h^BKt`Xbgo0f^Gg)=}WE$P_y+GuIE zaxyzNz~Di_!L{?4;!&HtZKa-YV4b#|sKG^~+&TZUrpmKo5E?b!XvZ;F0{Y8ozJ%et zc^8uWaQbO6zz`}ZO1rO7DwoVgOUw-I0IdaqOgsI!OL{jK3}Oe(-Ha_26|2FF&!A6- z5emHH1AsuUA3u+&O$+b*FG=NDqYwat^Gvv}uj=%C6GO{!p2vIr&}&bWGfEI;_;;^x zHhTlqfL^&$_zU=V-R}CO`xMAp7cX66go62_1)8p7_2#QV>6Ql~lap+ah|>$u6T1n}kkBRlH&birL#GCm+w}!RZ9BY_v+m2A>Gf(q4&XV}!~I`5BU_s&dtC z&vvm*|JmM)6?^jAB?WwqIFR1trJ`3&tS(S20VLckJ=iymviXDKzZlk(`T*PwtI%T= zOENBx>W?2WU?gn>u8gj2kz9MUF6;hezRKa*@&^k1H~sxs(`nj1-T+ZNys)v+#|Op; z-JO1A@KOPJFmJldbHC^Hfq%1kDeDuy;g@NN%geI0;Jd_uv3h?m+!3CfjM#~~d$51l z**9eb?i{S|b`V!TgAD_GJA*Re_G2~zA3p?A&IEwBmy7AU^LL+*ukT#~CBZ+t!ef|N zO(^7EdQ{m6B*f~uS>{tZ9P>KSN6!7cl9`$1Rs0NppAc;sU8_7QF8(L)!1}roNEJi$ z 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 literal 0 HcmV?d00001 diff --git a/plugin-examples/settings/screenshots/electron.png b/plugin-examples/settings/screenshots/electron.png new file mode 100644 index 0000000000000000000000000000000000000000..0a22ed26b42966f69330a4dd728dd2e9d5b6b913 GIT binary patch literal 47456 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 KUvTc)o&N&2^~wMM literal 0 HcmV?d00001 diff --git a/plugin-examples/settings/screenshots/web-compact.png b/plugin-examples/settings/screenshots/web-compact.png new file mode 100644 index 0000000000000000000000000000000000000000..d0b5174d66c0015992695e00c968929d28b71153 GIT binary patch literal 20680 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 literal 0 HcmV?d00001 diff --git a/plugin-examples/settings/shared/preferences.ts b/plugin-examples/settings/shared/preferences.ts new file mode 100644 index 0000000000..f001cae934 --- /dev/null +++ b/plugin-examples/settings/shared/preferences.ts @@ -0,0 +1,13 @@ +import { defineSettings } from "@getpaseo/plugin"; +import { z } from "zod"; + +export const preferences = defineSettings({ + id: "display", + scope: "host", + version: 1, + schema: z.object({ + groupBy: z.enum(["project", "workspace", "none"]).default("project"), + showMetadata: z.boolean().default(true), + title: z.string().trim().min(1, "Enter a title").max(40).default("Agent monitor"), + }), +}); diff --git a/public-docs/plugins/v0.8/migration.md b/public-docs/plugins/v0.8/migration.md index bfe844877d..cad91d5537 100644 --- a/public-docs/plugins/v0.8/migration.md +++ b/public-docs/plugins/v0.8/migration.md @@ -67,22 +67,23 @@ is a compile error. Use this table as the complete registration checklist. -| Old registration and location | New registration and location | -| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| `plugin.handle(contract, handler)` in the old root entry | `server.handle(contract, handler)` in `index.server.ts` | -| `plugin.addSurface(id, Component)` in the old root entry | `client.addSurface(id, Component)` in `index.client.tsx` | -| `plugin.addSidebarItem(item)` in the old root entry | `client.addSidebarItem(item)` in `index.client.tsx` | -| `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` | -| `plugin.addCommandCenterItem(item)` in the old root entry | `client.addCommandCenterItem(item)` in `index.client.tsx` | -| `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` | -| `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function | -| `client.addComposerPill(pill)` inside the old client callback | `client.addComposerPill(pill)` inside `index.client.tsx` or an imported `client/` function | -| `plugin.addAttachmentSource(source)` in the old root entry | `client.addAttachmentSource(source)` in `index.client.tsx` | -| `plugin.addTheme(theme)` in the old root entry | `client.addTheme(theme)` in `index.client.tsx` | -| `plugin.addTimelineTransformer(transformer)` in the old root entry | `client.addTimelineTransformer(transformer)` in `index.client.tsx` | -| `plugin.addTimelineRenderer(renderer)` in the old root entry | `client.addTimelineRenderer(renderer)` in `index.client.tsx` | -| `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin/server"` in shared files | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin"` | -| `ZodOutput` handler parameter types | `RpcInput` from `@getpaseo/plugin`; `RpcOutput` for return types | +| Old registration and location | New registration and location | +| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `plugin.handle(contract, handler)` in the old root entry | `server.handle(contract, handler)` in `index.server.ts` | +| `plugin.addSurface(id, Component)` in the old root entry | `client.addSurface(id, Component)` in `index.client.tsx` | +| `plugin.addSidebarItem(item)` in the old root entry | `client.addSidebarItem(item)` in `index.client.tsx` | +| `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` | +| `plugin.addCommandCenterItem(item)` in the old root entry | `client.addCommandCenterItem(item)` in `index.client.tsx` | +| `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` | +| `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function | +| `client.addComposerPill(pill)` inside the old client callback | `client.addComposerPill(pill)` inside `index.client.tsx` or an imported `client/` function | +| `plugin.addAttachmentSource(source)` in the old root entry | `client.addAttachmentSource(source)` in `index.client.tsx` | +| New settings screen contribution | `client.addSettingsScreen(screen)` in `index.client.tsx`; see [settings screens](reference#settings-screens) | +| `plugin.addTheme(theme)` in the old root entry | `client.addTheme(theme)` in `index.client.tsx` | +| `plugin.addTimelineTransformer(transformer)` in the old root entry | `client.addTimelineTransformer(transformer)` in `index.client.tsx` | +| `plugin.addTimelineRenderer(renderer)` in the old root entry | `client.addTimelineRenderer(renderer)` in `index.client.tsx` | +| `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin/server"` in shared files | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin"` | +| `ZodOutput` handler parameter types | `RpcInput` from `@getpaseo/plugin`; `RpcOutput` for return types | Import `PluginClientContext` in the client entry and `PluginServerContext` in the server entry. Remove imports of the old context type. `@getpaseo/plugin/server` now exports only handler-side diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index db73adc131..cf3bdf5040 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -85,6 +85,7 @@ Paseo provides these modules to client code: | Module | Use it for | | ------------------------------- | ------------------------------------------------------------------------------------------------------ | | `@getpaseo/plugin` | Contribution contracts, `defineRpc`, `defineAttachmentSource`, `RpcInput`, `RpcOutput`, and data hooks | +| `@getpaseo/plugin/ui` | Named, composable settings components | | `@getpaseo/plugin/react-native` | Paseo UI components and UI hooks | | `@getpaseo/plugin/server` | Handler-only types such as `PluginHandlerContext` | | `@tanstack/react-query` | Request state and caching | @@ -136,9 +137,8 @@ for the whole project and hides the next mistake. Components import `openExterna `window` themselves. `layout.platform` on surface and panel props carries the same value as `Platform.OS` for rendering decisions. -There is no plugin storage API. Browser storage does not persist settings across Paseo clients. -There is also no general host navigation API: plugin code cannot open native Paseo routes. Command -Center callbacks can only open surfaces and panels registered by the same plugin. +Use the [settings API](#settings-screens) for typed host-scoped persistence across clients. +Use `openSettings`, `openSurface`, and `openPanel` for your own registered contributions. ### Server runtime @@ -528,6 +528,121 @@ unpainted. Themes need a host that supports them. A client released before `addTheme` cannot evaluate that client entry and reports `client.addTheme is not a function`. Update the client. +## Settings screens + +Register a component with `client.addSettingsScreen({ id, title, icon, Component })` in +`index.client.tsx`. It appears under **Settings → Plugins → your plugin** on that host. +`id` is unique within the installation; `icon` is a Lucide name. Registration returns an +idempotent remover, and plugin teardown removes remaining screens. + +Call `client.openSettings(id)` or a Command Center callback's `openSettings(id)` to open one +of your own screens. Each installation has its own values and route, even when several hosts +install the same plugin. + +The component receives `PluginSurfaceProps`. Paseo owns the header, back navigation, safe areas, +scrolling, and the centered settings column. Compact windows push a full-screen detail; wide +windows keep the settings sidebar. Render content inside that frame using React Native components. +A disabled or removed plugin leaves an unavailable screen with working Back navigation. + +### Named UI components + +Import settings components from `@getpaseo/plugin/ui`. They work with your own state and RPCs; +no form wrapper or storage binding is required. + +```tsx +import { useState } from "react"; +import { SettingsCard, SettingsSection, SettingsSwitch } from "@getpaseo/plugin/ui"; + +export function DisplaySettings() { + const [visible, setVisible] = useState(true); + return ( + + + + + + ); +} +``` + +| Component | Props and behavior | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `SettingsGroup`, `SettingsSection` | Required `title`, `children`; optional `info` tooltip, `trailing` content, `testID`. Own section spacing and headings. | +| `SettingsCard` | `children`, optional `testID`. Owns the card surface and dividers between direct children. Give mapped rows stable React keys. | +| `SettingsRow` | Required `label`; optional `hint`, `error`, `children`, `testID`. Wrap any custom control or content. | +| `SettingsSwitch` | Row props plus required `value: boolean`, `onValueChange`; optional `disabled`. | +| `SettingsSelect` | Row props plus required string `value`, `options: { label, value }[]`, `onValueChange`; optional `disabled`. Uses Paseo's adaptive menu. | +| `SettingsInput` | Row props plus required `onChangeText`; optional `initialValue`, `placeholder`, `disabled`, `secureTextEntry`, `ref`. | +| `SettingsAction` | Row props plus required `actionLabel`, `onPress`; optional `disabled`. | + +`SettingsInput` owns in-progress text. `initialValue` seeds it when mounted. Its ref exposes +`focus()`, `blur()`, `getText()`, and `replaceText(text)` for explicit programmatic changes. +Keep draft text outside persisted values until the user saves. Custom previews and controls +can sit beside or inside these components. + +### Persisted values + +Define a settings document in `shared/`: + +```ts +import { defineSettings } from "@getpaseo/plugin"; +import { z } from "zod"; + +export const preferences = defineSettings({ + id: "display", + scope: "host", + version: 1, + schema: z.object({ showMetadata: z.boolean().default(true) }), +}); +``` + +Register it with `server.registerSettings(preferences)` in `index.server.ts` before returning +cleanup. This server entry is required for built-in persistence; a screen using its own data +can remain client-only. + +| Definition field | Contract | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | Lowercase identifier matching `[a-z][a-z0-9_-]*`; unique within the installation. | +| `scope` | Required `"host"`. All authorized clients of that host share the document. No per-user, device-local, or cross-host synchronization. | +| `version` | Required positive integer describing the schema, independent of the write revision. | +| `schema` | Zod schema for JSON values. Supply defaults so parsing `{}` produces a complete document. | +| `migrate(values, fromVersion)` | Optional synchronous or asynchronous conversion from an older stored version. Its output must pass the current schema. | + +Call `useSettings(preferences)` in any contributed component. It returns a discriminated state: + +| `status` | Available data | +| --------- | ------------------------------------------------------------------------ | +| `loading` | Read is pending; do not render default values as though they were saved. | +| `ready` | Typed `values` and an opaque `revision`. | +| `invalid` | `error` and `revision`; stored data is preserved. | +| `error` | `error` from the read/connection. | + +Every state also exposes `saving`, `saveError`, and these actions: + +- `save(values, revision): Promise` validates and saves a complete document. Returns + `false` and sets `saveError` on validation, conflict, or transport failure; it does not throw. +- `reset(): Promise` explicitly replaces the document with schema defaults, using the + revision loaded by the hook. It can recover invalid stored data. +- `reload(): Promise` clears the save error and reads again. Your component owns its draft; + reloading does not discard that draft automatically. + +For an immediate toggle, pass `{ ...settings.values, showMetadata }` and `settings.revision` +to `save`. For a draft editor, capture both the values and revision when opening it. Keep that +revision until a save succeeds or the user discards the draft. A stale revision rejects the +save, preserving both the draft and the newer saved values. + +Writes are atomic and validated on the host. Connected clients receive updates without reloading +the plugin. Values survive daemon restart, plugin reload, disable, and updates. Removing an +installation deletes its settings. Reinstalling that ID starts from defaults. + +A missing document uses schema defaults. Invalid data, failed migrations, and unsupported newer +versions produce `invalid` without silently resetting the file. Successful migrations persist +the new version once. These documents are ordinary host-side JSON, not a credential vault. +Settings RPCs use the existing `daemon.manage` permission for plugin execution. + +See the complete [settings example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/settings) +for immediate controls, a draft editor with validation, custom content, and Command Center navigation. + ## Workspace panels Register one panel for workspace or agent context: From e4cd2d1ad08a452e75c3ea316cf4c41d15fe8f60 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 6 Sep 2026 12:34:17 +0200 Subject: [PATCH 018/566] fix(desktop): preserve the packaged macOS Dock icon (#4389) The startup override replaced the bundle icon with a flat PNG even in packaged builds. Limit the override to development so macOS retains the bundle icon appearance. --- packages/desktop/src/main.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 05230b2556..64cf4a9519 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -634,7 +634,8 @@ async function getEffectiveAppIconPath(): Promise { } async function applyAppIcon(): Promise { - if (process.platform !== "darwin") { + // Packaged apps keep the bundle icon and its macOS system appearance. + if (app.isPackaged || process.platform !== "darwin") { return; } From c99b8ad5f49f1db4f3acd7ffea743ecc4b99462e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 6 Sep 2026 13:36:18 +0200 Subject: [PATCH 019/566] fix(app): simplify Changes empty-state copy (#4393) --- packages/app/e2e/browser/changes-pane.spec.ts | 4 +-- packages/app/src/git/diff-pane.tsx | 25 +------------------ packages/app/src/i18n/resources/ar.ts | 3 --- packages/app/src/i18n/resources/en.ts | 3 --- packages/app/src/i18n/resources/es.ts | 3 --- packages/app/src/i18n/resources/fr.ts | 3 --- packages/app/src/i18n/resources/ja.ts | 3 --- packages/app/src/i18n/resources/ko.ts | 3 --- packages/app/src/i18n/resources/pt-BR.ts | 3 --- packages/app/src/i18n/resources/ru.ts | 3 --- packages/app/src/i18n/resources/zh-CN.ts | 3 --- 11 files changed, 3 insertions(+), 53 deletions(-) diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 998d292d44..6b66cda768 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -311,8 +311,8 @@ test("an empty Changes comparison links to the populated comparison", async ({ p await mode.click(); await page.getByTestId("changes-diff-mode-uncommitted").click(); - await expect(tree.getByText("No uncommitted changes", { exact: true })).toBeVisible(); - await expect(panel.getByText("No uncommitted changes", { exact: true })).toBeVisible(); + await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible(); + await expect(panel.getByText("No changes to display", { exact: true })).toBeVisible(); const seeCommitted = tree.getByRole("button", { name: "See committed changes" }); await expect(seeCommitted).toBeVisible(); await seeCommitted.click(); diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index b934d56b25..c5de2585e6 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -1034,25 +1034,6 @@ function ChangesDiffOptions({ options }: { options: ChangesToolbarDiffOptions }) const ThemedRotateCw = withUnistyles(RotateCw); -function computeEmptyMessage( - hideWhitespace: boolean, - diffMode: "uncommitted" | "base", - baseRefLabel: string, - labels: { - hiddenWhitespace: string; - uncommitted: string; - againstBase: (baseRefLabel: string) => string; - }, -): string { - if (hideWhitespace) { - return labels.hiddenWhitespace; - } - if (diffMode === "uncommitted") { - return labels.uncommitted; - } - return labels.againstBase(baseRefLabel); -} - interface DiffBodyContentProps { isStatusLoading: boolean; statusErrorMessage: string | null; @@ -1882,11 +1863,7 @@ export function ChangesSurface({ () => computeCommittedDiffDescription(branchLabel, baseRefLabel), [baseRefLabel, branchLabel], ); - const emptyMessage = computeEmptyMessage(preferences.hideWhitespace, diffMode, baseRefLabel, { - hiddenWhitespace: t("workspace.git.diff.emptyHiddenWhitespace"), - uncommitted: t("workspace.git.diff.emptyUncommitted"), - againstBase: (label) => t("workspace.git.diff.emptyAgainstBase", { baseRef: label }), - }); + const emptyMessage = t("diffViewer.empty"); const emptyAction = computeChangesEmptyAction({ hideWhitespace: preferences.hideWhitespace, diffMode, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index e09ef311de..f159a46f96 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -901,11 +901,8 @@ export const ar: TranslationResources = { refresh: "ينعش", refreshState: "تحديث حالة git و{{brand}}", failedRefresh: "فشل تحديث حالة git.", - emptyHiddenWhitespace: "لا توجد تغييرات مرئية بعد إخفاء المسافة البيضاء", - emptyUncommitted: "لا توجد تغييرات غير ملتزم بها", seeUncommittedChanges: "عرض التغييرات غير الملتزم بها", seeCommittedChanges: "عرض التغييرات الملتزم بها", - emptyAgainstBase: "لا توجد تغييرات مقابل{{baseRef}}", checkingRepository: "فحص المستودع...", notRepository: "ليس مستودع جيت", diffMode: "وضع الفرق", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index cc1f983db2..a78f699d65 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -910,11 +910,8 @@ export const en = { refresh: "Refresh", refreshState: "Refresh git and {{brand}} state", failedRefresh: "Failed to refresh git state.", - emptyHiddenWhitespace: "No visible changes after hiding whitespace", - emptyUncommitted: "No uncommitted changes", seeUncommittedChanges: "See uncommitted changes", seeCommittedChanges: "See committed changes", - emptyAgainstBase: "No changes vs {{baseRef}}", checkingRepository: "Checking repository...", notRepository: "Not a git repository", diffMode: "Diff mode", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 1b7ee5118a..eff34aacac 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -932,11 +932,8 @@ export const es: TranslationResources = { refresh: "Refrescar", refreshState: "Actualizar el estado de git y {{brand}}", failedRefresh: "No se pudo actualizar el estado de git.", - emptyHiddenWhitespace: "No hay cambios visibles después de ocultar espacios en blanco", - emptyUncommitted: "Sin cambios no confirmados", seeUncommittedChanges: "Ver cambios no confirmados", seeCommittedChanges: "Ver cambios confirmados", - emptyAgainstBase: "Sin cambios frente a{{baseRef}}", checkingRepository: "Comprobando repositorio...", notRepository: "No es un repositorio de git", diffMode: "modo diferencial", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 5807c7e86f..f5c7520c90 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -931,11 +931,8 @@ export const fr: TranslationResources = { refresh: "Rafraîchir", refreshState: "Actualiser l'état de git et de {{brand}}", failedRefresh: "Échec de l'actualisation de l'état git.", - emptyHiddenWhitespace: "Aucun changement visible après avoir masqué les espaces", - emptyUncommitted: "Aucune modification non validée", seeUncommittedChanges: "Voir les modifications non validées", seeCommittedChanges: "Voir les modifications validées", - emptyAgainstBase: "Aucun changement par rapport à{{baseRef}}", checkingRepository: "Vérification du référentiel...", notRepository: "Pas un dépôt git", diffMode: "Mode différentiel", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 1e2e95838d..1696058865 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -912,11 +912,8 @@ export const ja: TranslationResources = { refresh: "更新", refreshState: "gitと{{brand}}の状態を更新", failedRefresh: "gitの状態の更新に失敗しました。", - emptyHiddenWhitespace: "空白を非表示にすると変更は表示されません", - emptyUncommitted: "未コミットの変更なし", seeUncommittedChanges: "未コミットの変更を表示", seeCommittedChanges: "コミット済みの変更を表示", - emptyAgainstBase: "{{baseRef}}との差分なし", checkingRepository: "リポジトリを確認中...", notRepository: "gitリポジトリではありません", diffMode: "差分モード", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index a37feac7a3..1f4543ee14 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -908,11 +908,8 @@ export const ko: TranslationResources = { refresh: "새로고침", refreshState: "Git 및 {{brand}} 상태 새로고침", failedRefresh: "Git 상태를 새로고침하지 못했습니다.", - emptyHiddenWhitespace: "공백을 숨긴 후 표시할 변경 사항이 없습니다", - emptyUncommitted: "커밋되지 않은 변경 사항이 없습니다", seeUncommittedChanges: "커밋되지 않은 변경 사항 보기", seeCommittedChanges: "커밋된 변경 사항 보기", - emptyAgainstBase: "{{baseRef}} 대비 변경 사항이 없습니다", checkingRepository: "저장소 확인 중...", notRepository: "Git 저장소가 아닙니다", diffMode: "Diff 모드", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index f6ee9bdc29..293f4ab0a0 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -923,11 +923,8 @@ export const ptBR: TranslationResources = { refresh: "Atualizar", refreshState: "Atualizar estado do git e do {{brand}}", failedRefresh: "Falha ao atualizar estado do git.", - emptyHiddenWhitespace: "Nenhuma alteração visível após ocultar espaços em branco", - emptyUncommitted: "Nenhuma alteração sem commit", seeUncommittedChanges: "Ver alterações sem commit", seeCommittedChanges: "Ver alterações com commit", - emptyAgainstBase: "Nenhuma alteração vs {{baseRef}}", checkingRepository: "Verificando repositório...", notRepository: "Não é um repositório git", diffMode: "Modo de diff", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index eefd8aa147..0d94cf405c 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -916,11 +916,8 @@ export const ru: TranslationResources = { refresh: "Обновить", refreshState: "Обновить состояние Git и {{brand}}", failedRefresh: "Не удалось обновить состояние Git.", - emptyHiddenWhitespace: "После скрытия пробельных изменений видимых изменений нет", - emptyUncommitted: "Нет незафиксированных изменений", seeUncommittedChanges: "Показать незафиксированные изменения", seeCommittedChanges: "Показать зафиксированные изменения", - emptyAgainstBase: "Нет изменений относительно {{baseRef}}", checkingRepository: "Проверяем репозиторий...", notRepository: "Это не репозиторий Git", diffMode: "Режим диффа", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 35e160de59..93397806b9 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -893,11 +893,8 @@ export const zhCN: TranslationResources = { refresh: "刷新", refreshState: "刷新 git 和 {{brand}} 状态", failedRefresh: "刷新 git 状态失败。", - emptyHiddenWhitespace: "隐藏空白差异后没有可见变更", - emptyUncommitted: "没有未 commit 的变更", seeUncommittedChanges: "查看未 commit 的变更", seeCommittedChanges: "查看已 commit 的变更", - emptyAgainstBase: "相对于 {{baseRef}} 没有变更", checkingRepository: "正在检查 repository...", notRepository: "不是 git repository", diffMode: "Diff 模式", From 38c22139bb191f0ad27b11c16776e93504ddd4fd Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 6 Sep 2026 13:48:29 +0200 Subject: [PATCH 020/566] Let plugins control modal layout, scrolling and clipboard actions (#4392) * feat(plugins): support modal body layout, scrolling and clipboard actions * fix(app): keep native scroll dependencies out of web imports * fix(nix): sync dependency hash for plugin native types * test(plugins): express modal QA as user journeys --- docs/plugins.md | 3 +- nix/npm-deps.hash | 2 +- package-lock.json | 7 + .../app/e2e/browser/plugin-modal-body.spec.ts | 163 ++++++++++++++ .../adaptive-modal-sheet-layout.test.ts | 16 +- .../components/adaptive-modal-sheet-layout.ts | 8 +- .../src/components/adaptive-modal-sheet.tsx | 125 ++++++----- packages/app/src/components/model-browser.tsx | 11 +- .../{text-input => }/bottom-sheet-scope.tsx | 2 +- .../index.test.tsx | 2 +- .../ui/isolated-bottom-sheet-modal/index.tsx | 6 +- .../app/src/components/ui/scroll-view.tsx | 61 ++++++ .../app/src/components/ui/scroll-view.web.tsx | 3 + .../components/ui/scrollable-code-surface.tsx | 41 ++-- .../components/ui/text-input/sheet-aware.tsx | 24 ++ .../ui/text-input/text-input.native.test.tsx | 6 +- .../ui/text-input/text-input.native.tsx | 2 +- .../app/src/plugins/react-native/clipboard.ts | 6 + .../src/plugins/react-native/clipboard.web.ts | 6 + .../app/src/plugins/react-native/modal.tsx | 11 +- .../app/src/plugins/react-native/runtime.ts | 14 +- .../src/plugins/react-native/scroll-view.tsx | 25 +++ .../src/plugins/react-native/text-input.tsx | 12 + packages/plugin/package.json | 7 + packages/plugin/src/react-native.ts | 41 +++- plugin-examples/modal-ui/README.md | 26 +++ plugin-examples/modal-ui/client/examples.tsx | 205 ++++++++++++++++++ plugin-examples/modal-ui/index.client.tsx | 21 ++ plugin-examples/modal-ui/paseo-plugin.json | 3 + .../screenshots/android-copy-paste.png | Bin 0 -> 79035 bytes .../screenshots/android-wide-custom-inset.png | Bin 0 -> 70900 bytes .../modal-ui/screenshots/web-custom-inset.png | Bin 0 -> 53973 bytes public-docs/plugins/v0.8/reference.md | 86 +++++++- 33 files changed, 822 insertions(+), 123 deletions(-) create mode 100644 packages/app/e2e/browser/plugin-modal-body.spec.ts rename packages/app/src/components/ui/{text-input => }/bottom-sheet-scope.tsx (79%) create mode 100644 packages/app/src/components/ui/scroll-view.tsx create mode 100644 packages/app/src/components/ui/scroll-view.web.tsx create mode 100644 packages/app/src/components/ui/text-input/sheet-aware.tsx create mode 100644 packages/app/src/plugins/react-native/clipboard.ts create mode 100644 packages/app/src/plugins/react-native/clipboard.web.ts create mode 100644 packages/app/src/plugins/react-native/scroll-view.tsx create mode 100644 packages/app/src/plugins/react-native/text-input.tsx create mode 100644 plugin-examples/modal-ui/README.md create mode 100644 plugin-examples/modal-ui/client/examples.tsx create mode 100644 plugin-examples/modal-ui/index.client.tsx create mode 100644 plugin-examples/modal-ui/paseo-plugin.json create mode 100644 plugin-examples/modal-ui/screenshots/android-copy-paste.png create mode 100644 plugin-examples/modal-ui/screenshots/android-wide-custom-inset.png create mode 100644 plugin-examples/modal-ui/screenshots/web-custom-inset.png diff --git a/docs/plugins.md b/docs/plugins.md index 1a101e5c5c..905f482bc7 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -155,7 +155,8 @@ Shared files import contract helpers and types from `@getpaseo/plugin`. Server h `@getpaseo/plugin/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon set; an unknown name renders nothing so it cannot break the plugin surface. Its controlled modal keeps presentation metadata on `` and body UI in -``. +``. Body layout, sheet-aware scrolling, and clipboard actions follow the +[host UI contract](../public-docs/plugins/v0.8/reference.md#host-ui). Plugin UI runs on desktop and mobile across multiple themes: color every `Text` from `theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`. See `public-docs/plugins/v0.8/reference.md`. diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 3324b8a3a6..a713307bd5 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-EpfSUhkssURxoUgE9+odbFg3sroXH5bH5S9xClMG2nE= +sha256-Vw6bgh+gPdVxySdbawKawr9qZT8QEEo2+okdpuKceMA= diff --git a/package-lock.json b/package-lock.json index 1e60b61b11..37131854b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38700,6 +38700,7 @@ "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.1.0", + "react-native": "0.81.5", "typescript": "^5.9.3", "vitest": "^4.1.6", "zod": "^4.4.3" @@ -38708,7 +38709,13 @@ "@getpaseo/client": "0.7.2", "@getpaseo/protocol": "0.7.2", "react": "19.1.0", + "react-native": ">=0.81.5", "zod": "^4.4.3" + }, + "peerDependenciesMeta": { + "react-native": { + "optional": true + } } }, "packages/plugin/node_modules/@agentclientprotocol/sdk": { diff --git a/packages/app/e2e/browser/plugin-modal-body.spec.ts b/packages/app/e2e/browser/plugin-modal-body.spec.ts new file mode 100644 index 0000000000..591a2472e8 --- /dev/null +++ b/packages/app/e2e/browser/plugin-modal-body.spec.ts @@ -0,0 +1,163 @@ +import path from "node:path"; +import { expect, test as base, type Page } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace"; +import { + openMobileAgentSidebar, + expectMobileAgentSidebarVisible, +} from "../support/helpers/sidebar"; + +async function openExample(page: Page, example: string) { + await page.getByRole("button", { name: `Open ${example}`, exact: true }).click(); + await expect(page.getByText(`Modal example: ${example}`, { exact: true })).toBeVisible(); +} + +async function closeExample(page: Page) { + await page.getByRole("button", { name: "Close", exact: true }).last().click(); + await expect(page.getByRole("button", { name: "Open Form", exact: true })).toBeVisible(); +} + +async function copyAndPaste(page: Page) { + await page.getByRole("button", { name: "Copy text", exact: true }).click(); + await expect(page.getByText("Text copied", { exact: true })).toBeVisible(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("Copied from Paseo"); + await page.getByRole("textbox", { name: "Paste here" }).focus(); + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.press("ControlOrMeta+V"); + await expect(page.getByRole("textbox", { name: "Paste here" })).toHaveValue("Copied from Paseo"); +} + +async function scrollToLastRow(page: Page) { + await page.getByRole("button", { name: "Row 1", exact: true }).hover(); + await page.mouse.wheel(0, 7000); + await expect(page.getByRole("button", { name: "Row 100", exact: true })).toBeInViewport(); + await page.getByRole("button", { name: "Row 100", exact: true }).click(); +} + +async function expectSingleInset(page: Page, left = "24px", bottom = "24px") { + const form = await page.getByTestId("modal-example-form").boundingBox(); + const input = await page.getByRole("textbox", { name: "Paste here" }).boundingBox(); + expect(form).not.toBeNull(); + expect(input?.x).toBe(form?.x); + // The real content View owns the inset, not a third-party contentContainerStyle. + const padding = await page.getByTestId("modal-example-form").evaluate((node) => { + const content = node.parentElement!; + return { + left: getComputedStyle(content).paddingLeft, + bottom: getComputedStyle(content).paddingBottom, + }; + }); + expect(padding).toEqual({ left, bottom }); +} + +async function expectFullBleed(page: Page) { + const padding = await page + .getByRole("button", { name: "Row 1", exact: true }) + .evaluate((node) => { + const content = node.parentElement!; + return { + left: getComputedStyle(content).paddingLeft, + bottom: getComputedStyle(content).paddingBottom, + }; + }); + expect(padding).toEqual({ left: "0px", bottom: "0px" }); +} + +async function exerciseBodies(page: Page) { + await openExample(page, "Form"); + await expectSingleInset(page); + await copyAndPaste(page); + await closeExample(page); + await openExample(page, "Custom inset"); + await expectSingleInset(page, "8px", "40px"); + await closeExample(page); + await openExample(page, "Full bleed"); + await expectFullBleed(page); + await scrollToLastRow(page); + await closeExample(page); + await openExample(page, "ScrollView"); + await scrollToLastRow(page); + await closeExample(page); + await openExample(page, "FlatList"); + await page.getByRole("button", { name: "Jump to last row", exact: true }).click(); + await expect(page.getByRole("button", { name: "Row 100", exact: true })).toBeInViewport(); + await closeExample(page); + await openExample(page, "Horizontal"); + await page.getByRole("button", { name: "Overview", exact: true }).hover(); + await page.mouse.wheel(900, 0); + await expect(page.getByRole("button", { name: "System logs", exact: true })).toBeInViewport(); + await page.getByRole("button", { name: "System logs", exact: true }).click(); + await closeExample(page); + await expect(page.getByText("Selected: System logs", { exact: true })).toBeVisible(); +} + +const test = base.extend<{ modalExample: Page }>({ + modalExample: async ({ page, context }, provide) => { + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await client.getDaemonConfig(); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin( + path.resolve(__dirname, "../../../../plugin-examples/modal-ui"), + ); + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await provide(page); + } finally { + await client.removePlugin("modal-ui-example").catch(() => undefined); + await client + .patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false }) + .catch(() => undefined); + await client.close().catch(() => undefined); + } + }, +}); + +async function openWideExamples(page: Page) { + await page.setViewportSize({ width: 1100, height: 800 }); + await gotoAppShell(page); + await page.getByRole("button", { name: "Modal examples", exact: true }).click(); +} + +async function openCompactExamples(page: Page) { + await page.setViewportSize({ width: 390, height: 844 }); + await openMobileAgentSidebar(page); + await expectMobileAgentSidebarVisible(page); + await page.getByRole("button", { name: "Modal examples", exact: true }).click(); +} + +async function expectDeniedCopyFeedback(page: Page) { + const cdp = await page.context().newCDPSession(page); + try { + const { targetInfo } = await cdp.send("Target.getTargetInfo"); + await cdp.send("Browser.setPermission", { + browserContextId: targetInfo.browserContextId, + permission: { name: "clipboard-write" }, + setting: "denied", + origin: new URL(page.url()).origin, + }); + expect( + await page.evaluate( + async () => + (await navigator.permissions.query({ name: "clipboard-write" as PermissionName })).state, + ), + ).toBe("denied"); + await openExample(page, "Form"); + await page.getByRole("button", { name: "Copy text", exact: true }).click(); + await expect( + page.getByText("Could not copy text. Select the text and use Copy.", { exact: true }), + ).toBeVisible(); + await closeExample(page); + } finally { + await cdp.detach(); + } +} + +test("plugin bodies support padding, scroll ownership, horizontal tabs and clipboard on wide and compact layouts", async ({ + modalExample: page, +}) => { + await openWideExamples(page); + await test.step("wide dialogs", () => exerciseBodies(page)); + await openCompactExamples(page); + await test.step("compact sheets", () => exerciseBodies(page)); + await test.step("denied clipboard writes report failure", () => expectDeniedCopyFeedback(page)); +}); diff --git a/packages/app/src/components/adaptive-modal-sheet-layout.test.ts b/packages/app/src/components/adaptive-modal-sheet-layout.test.ts index 308193d9d8..fbd37c15e7 100644 --- a/packages/app/src/components/adaptive-modal-sheet-layout.test.ts +++ b/packages/app/src/components/adaptive-modal-sheet-layout.test.ts @@ -5,30 +5,26 @@ import { } from "@/components/adaptive-modal-sheet-layout"; describe("getCompactSheetSafeAreaPadding", () => { - it("adds the bottom inset to compact sheet footers", () => { + it("assigns safe clearance to the footer independently of decorative padding", () => { expect( getCompactSheetSafeAreaPadding({ isCompact: true, isKeyboardVisible: false, hasFooter: true, - baseContentPadding: 24, - baseFooterPadding: 12, safeAreaBottom: 34, }), - ).toEqual({ footerPaddingBottom: 46 }); + ).toEqual({ footerPaddingBottom: 34 }); }); - it("adds the bottom inset to compact sheet content when there is no footer", () => { + it("assigns safe clearance to the body only without a footer", () => { expect( getCompactSheetSafeAreaPadding({ isCompact: true, isKeyboardVisible: false, hasFooter: false, - baseContentPadding: 24, - baseFooterPadding: 12, safeAreaBottom: 34, }), - ).toEqual({ contentPaddingBottom: 58 }); + ).toEqual({ contentPaddingBottom: 34 }); }); it("does not add a safe-area band above the compact keyboard", () => { @@ -37,8 +33,6 @@ describe("getCompactSheetSafeAreaPadding", () => { isCompact: true, isKeyboardVisible: true, hasFooter: false, - baseContentPadding: 24, - baseFooterPadding: 12, safeAreaBottom: 34, }), ).toEqual({}); @@ -50,8 +44,6 @@ describe("getCompactSheetSafeAreaPadding", () => { isCompact: false, isKeyboardVisible: false, hasFooter: false, - baseContentPadding: 24, - baseFooterPadding: 12, safeAreaBottom: 34, }), ).toEqual({}); diff --git a/packages/app/src/components/adaptive-modal-sheet-layout.ts b/packages/app/src/components/adaptive-modal-sheet-layout.ts index 123c0d337a..91fc62274e 100644 --- a/packages/app/src/components/adaptive-modal-sheet-layout.ts +++ b/packages/app/src/components/adaptive-modal-sheet-layout.ts @@ -2,8 +2,6 @@ export interface CompactSheetSafeAreaPaddingInput { isCompact: boolean; isKeyboardVisible: boolean; hasFooter: boolean; - baseContentPadding: number; - baseFooterPadding: number; safeAreaBottom: number; } @@ -38,8 +36,6 @@ export function getCompactSheetSafeAreaPadding({ isCompact, isKeyboardVisible, hasFooter, - baseContentPadding, - baseFooterPadding, safeAreaBottom, }: CompactSheetSafeAreaPaddingInput): CompactSheetSafeAreaPadding { if (!isCompact || isKeyboardVisible || safeAreaBottom <= 0) { @@ -47,8 +43,8 @@ export function getCompactSheetSafeAreaPadding({ } if (hasFooter) { - return { footerPaddingBottom: baseFooterPadding + safeAreaBottom }; + return { footerPaddingBottom: safeAreaBottom }; } - return { contentPaddingBottom: baseContentPadding + safeAreaBottom }; + return { contentPaddingBottom: safeAreaBottom }; } diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 8cdab525a4..99c3b5117f 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; -import { Modal, Platform, Pressable, ScrollView, Text, View } from "react-native"; -import type { StyleProp, ViewStyle } from "react-native"; +import { Modal, Platform, Pressable, Text, View } from "react-native"; +import type { DimensionValue, StyleProp, ViewStyle } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { @@ -14,7 +14,6 @@ import { } from "../lib/overlay-root"; import { BottomSheetBackdrop, - BottomSheetScrollView, KEYBOARD_STATUS, useBottomSheetInternal, type BottomSheetBackgroundProps, @@ -30,6 +29,7 @@ import { getBottomSheetVisibleContentHeight, getCompactSheetSafeAreaPadding, } from "@/components/adaptive-modal-sheet-layout"; +import { ScrollView } from "@/components/ui/scroll-view"; import { isWeb } from "@/constants/platform"; import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -67,6 +67,7 @@ export interface SheetHeader { search?: SheetHeaderSearch; } +const SCROLL_CONTENT_GROW = { flexGrow: 1 }; const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject }; const styles = StyleSheet.create((theme) => ({ @@ -79,6 +80,7 @@ const styles = StyleSheet.create((theme) => ({ pointerEvents: "auto" as const, }, desktopCard: { + overflow: "hidden", width: "100%", maxWidth: 520, maxHeight: "85%", @@ -445,8 +447,12 @@ export interface AdaptiveModalSheetProps { testID?: string; /** Override the max width of the desktop card. */ desktopMaxWidth?: number; + /** Bound an author-owned list without changing content-sized first-party dialogs. */ + desktopHeight?: DimensionValue; scrollable?: boolean; presentation?: "push" | "replace"; + /** Full body viewport below the header, including space beyond the content. */ + bodyStyle?: StyleProp; /** Layout intent for the sheet body, composed over the sheet's own content inset. */ contentStyle?: StyleProp; /** Size compact sheet content to the live snap height instead of its largest snap point. */ @@ -466,10 +472,12 @@ export function AdaptiveModalSheet({ snapPoints, testID, desktopMaxWidth, + desktopHeight, scrollable = true, presentation, contentStyle, - sizeContentToCurrentSnapPoint = false, + bodyStyle, + sizeContentToCurrentSnapPoint = true, contextBridge = null, }: AdaptiveModalSheetProps) { const { theme } = useUnistyles(); @@ -484,43 +492,22 @@ export function AdaptiveModalSheet({ isCompact: isMobile, isKeyboardVisible, hasFooter: Boolean(footer), - baseContentPadding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], - baseFooterPadding: theme.spacing[3], safeAreaBottom: insets.bottom, }), - [footer, insets.bottom, isKeyboardVisible, isMobile, theme.spacing], + [footer, insets.bottom, isKeyboardVisible, isMobile], ); - const compactContentStyle = useMemo( - () => [ - contentStyle, - compactSafeAreaPadding.contentPaddingBottom != null - ? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom } - : null, - ], - [compactSafeAreaPadding.contentPaddingBottom, contentStyle], - ); - const compactStaticContentStyle = useMemo( - () => [styles.compactStaticContent, compactContentStyle], - [compactContentStyle], - ); - const desktopScrollContentStyle = useMemo( - () => [styles.contentGrow, contentStyle], - [contentStyle], - ); - const desktopStaticContentStyle = useMemo( - () => [styles.desktopStaticContent, contentStyle], - [contentStyle], - ); - const footerStyle = useMemo( - () => [ - styles.footer, - footerContainerStyle, - compactSafeAreaPadding.footerPaddingBottom != null - ? { paddingBottom: compactSafeAreaPadding.footerPaddingBottom } - : null, - ], - [compactSafeAreaPadding.footerPaddingBottom, footerContainerStyle], + // Safe-area clearance is a separate layer: it must not replace the caller's + // padding (including an explicit zero), and the footer owns it when present. + const bodyClearanceStyle = { paddingBottom: compactSafeAreaPadding.contentPaddingBottom ?? 0 }; + const footerClearanceStyle = useMemo( + () => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }), + [compactSafeAreaPadding.footerPaddingBottom], ); + const footerView = footer ? ( + + {footer} + + ) : null; const handleIndicatorStyle = useMemo( () => ({ backgroundColor: theme.colors.palette.zinc[600] }), [theme.colors.palette.zinc], @@ -554,8 +541,12 @@ export function AdaptiveModalSheet({ ); const desktopCardStyle = useMemo( - () => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }], - [desktopMaxWidth], + () => [ + styles.desktopCard, + desktopHeight != null && { height: desktopHeight }, + desktopMaxWidth != null && { maxWidth: desktopMaxWidth }, + ], + [desktopMaxWidth, desktopHeight], ); const desktopOverlayStyle = useMemo( () => [ @@ -620,18 +611,27 @@ export function AdaptiveModalSheet({ const sheetContent = ( <> - {scrollable ? ( - - {children} - - ) : ( - {children} - )} - {footer ? {footer} : null} + + {scrollable ? ( + + + {children} + + + ) : ( + + + {children} + + + )} + + {footerView} ); @@ -646,6 +646,9 @@ export function AdaptiveModalSheet({ onDismiss={handleDismiss} backdropComponent={renderBackdrop} enablePanDownToClose + // A custom scroll owner must also own body gestures. Gorhom otherwise + // locks even imperative list offsets until the sheet reaches its top snap. + enableContentPanningGesture={scrollable} backgroundComponent={SheetBackground} handleIndicatorStyle={handleIndicatorStyle} keyboardBehavior="extend" @@ -662,24 +665,26 @@ export function AdaptiveModalSheet({ ); } + const desktopStaticStyle = + desktopHeight == null ? styles.desktopStaticContent : styles.compactStaticContent; const cardInner = ( - {scrollable ? ( - + + {scrollable ? ( - {children} + {children} - - ) : ( - {children} - )} - {footer ? {footer} : null} + ) : ( + {children} + )} + + {footerView} ); diff --git a/packages/app/src/components/model-browser.tsx b/packages/app/src/components/model-browser.tsx index ea2eeaec69..f724619c1f 100644 --- a/packages/app/src/components/model-browser.tsx +++ b/packages/app/src/components/model-browser.tsx @@ -13,7 +13,10 @@ import { type StyleProp, type ViewStyle, } from "react-native"; -import { BottomSheetFlatList, BottomSheetScrollView } from "@gorhom/bottom-sheet"; +import { + FlatList as SheetFlatList, + ScrollView as SheetScrollView, +} from "@/components/ui/scroll-view"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { @@ -1190,7 +1193,7 @@ function ModelRowList({ if (isCompact && isNative) { return ( - {allProvidersContent} ) : ( - {allProvidersContent} - + ); } diff --git a/packages/app/src/components/ui/text-input/bottom-sheet-scope.tsx b/packages/app/src/components/ui/bottom-sheet-scope.tsx similarity index 79% rename from packages/app/src/components/ui/text-input/bottom-sheet-scope.tsx rename to packages/app/src/components/ui/bottom-sheet-scope.tsx index 1d25bf7598..cbdaedac5f 100644 --- a/packages/app/src/components/ui/text-input/bottom-sheet-scope.tsx +++ b/packages/app/src/components/ui/bottom-sheet-scope.tsx @@ -2,7 +2,7 @@ import React, { createContext, useContext, type ReactNode } from "react"; const BottomSheetScopeContext = createContext(false); -export function BottomSheetTextInputScope({ children }: { children: ReactNode }) { +export function BottomSheetScope({ children }: { children: ReactNode }) { return {children}; } diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx index 7567601eca..cf820ac86c 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx @@ -58,7 +58,7 @@ vi.mock("@gorhom/bottom-sheet", async () => { }); import { IsolatedBottomSheetModal, type ContextBridge } from "."; -import { useIsInsideBottomSheet } from "@/components/ui/text-input/bottom-sheet-scope"; +import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope"; /** Renders whatever the portal is currently holding, from its own place in the tree. */ function PortalHostProbe() { diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx index 1f70a6babe..18ecff9127 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx @@ -9,7 +9,7 @@ import { type BottomSheetController, createBottomSheetVisibilityTracker, } from "./visibility-tracker"; -import { BottomSheetTextInputScope } from "@/components/ui/text-input/bottom-sheet-scope"; +import { BottomSheetScope } from "@/components/ui/bottom-sheet-scope"; type GorhomBottomSheetModalMethods = ElementRef; @@ -65,9 +65,7 @@ export const IsolatedBottomSheetModal = forwardRef< enableDismissOnClose stackBehavior={presentation} > - - {contextBridge ? contextBridge(children) : children} - + {contextBridge ? contextBridge(children) : children} ); diff --git a/packages/app/src/components/ui/scroll-view.tsx b/packages/app/src/components/ui/scroll-view.tsx new file mode 100644 index 0000000000..d6abed39d9 --- /dev/null +++ b/packages/app/src/components/ui/scroll-view.tsx @@ -0,0 +1,61 @@ +import { forwardRef, type Ref } from "react"; +import { + ScrollView as NativeScrollView, + FlatList as NativeFlatList, + type ScrollViewProps, + type FlatListProps, +} from "react-native"; +import { BottomSheetScrollView, BottomSheetFlatList } from "@gorhom/bottom-sheet"; +import { + ScrollView as GestureScrollView, + FlatList as GestureFlatList, + type NativeViewGestureHandlerProps, +} from "react-native-gesture-handler"; +import { isNative } from "@/constants/platform"; +import { useIsInsideBottomSheet } from "./bottom-sheet-scope"; + +// Both libraries forward to the RN FlatList instance. Their published ref types +// disagree about getScrollResponder (Gorhom) and include the component function +// in the instance intersection (RNGH); keep that type discrepancy at this boundary. +const SheetFlatList = BottomSheetFlatList as unknown as typeof NativeFlatList; +const HorizontalFlatList = GestureFlatList as unknown as ( + props: FlatListProps & NativeViewGestureHandlerProps & { ref?: Ref> }, +) => React.ReactElement; + +/** The presentation owns gesture integration; callers keep the React Native API. */ +export const ScrollView = forwardRef( + function ScrollView(props, ref) { + const inSheet = useIsInsideBottomSheet(); + if (!inSheet) return ; + if (props.horizontal) { + return isNative ? ( + + ) : ( + + ); + } + return ( + + {props.children} + + ); + }, +); + +function SheetAwareFlatList(props: FlatListProps, ref: Ref>) { + const inSheet = useIsInsideBottomSheet(); + if (!inSheet) return ; + if (props.horizontal) { + return isNative ? ( + + ) : ( + + ); + } + return ; +} + +// forwardRef erases the item generic. All three implementations expose the RN list methods. +export const FlatList = forwardRef(SheetAwareFlatList) as ( + props: FlatListProps & { ref?: Ref> }, +) => React.ReactElement; diff --git a/packages/app/src/components/ui/scroll-view.web.tsx b/packages/app/src/components/ui/scroll-view.web.tsx new file mode 100644 index 0000000000..7372623b5c --- /dev/null +++ b/packages/app/src/components/ui/scroll-view.web.tsx @@ -0,0 +1,3 @@ +// Web dialogs use ordinary scrolling at every breakpoint. Keep native gesture +// dependencies out of this module's import graph. +export { ScrollView, FlatList } from "react-native"; diff --git a/packages/app/src/components/ui/scrollable-code-surface.tsx b/packages/app/src/components/ui/scrollable-code-surface.tsx index e55093b9d8..3b99f8bcaf 100644 --- a/packages/app/src/components/ui/scrollable-code-surface.tsx +++ b/packages/app/src/components/ui/scrollable-code-surface.tsx @@ -1,14 +1,8 @@ import { useMemo, type ReactNode } from "react"; -import { - ScrollView, - Text, - View, - type StyleProp, - type TextStyle, - type ViewStyle, -} from "react-native"; +import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { ScrollView } from "./scroll-view"; import { isWeb } from "@/constants/platform"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; @@ -87,7 +81,7 @@ export function ScrollableCodeSurface({ [maxHeight], ); const outerScrollStyle = useMemo( - () => [maxHeightStyle, scrollStyle], + () => [styles.scroll, maxHeightStyle, scrollStyle], [maxHeightStyle, scrollStyle], ); const contentStyle = useMemo( @@ -108,24 +102,21 @@ export function ScrollableCodeSurface({ - - {horizontal ? ( - - {codeContent} - - ) : ( - codeContent - )} + + + {horizontal ? ( + + {codeContent} + + ) : ( + codeContent + )} + ); @@ -171,6 +162,10 @@ function getSurfaceStyle(tone: CodeSurfaceTone): StyleProp { } const styles = StyleSheet.create((theme) => ({ + scroll: { + flexShrink: 1, + minHeight: 0, + }, container: { overflow: "hidden", }, diff --git a/packages/app/src/components/ui/text-input/sheet-aware.tsx b/packages/app/src/components/ui/text-input/sheet-aware.tsx new file mode 100644 index 0000000000..33539b6cbb --- /dev/null +++ b/packages/app/src/components/ui/text-input/sheet-aware.tsx @@ -0,0 +1,24 @@ +import { forwardRef, useCallback } from "react"; +import { TextInput as NativeTextInput, type TextInputProps } from "react-native"; +import { BottomSheetTextInput } from "@gorhom/bottom-sheet"; +import { isNative } from "@/constants/platform"; +import { useIsInsideBottomSheet } from "../bottom-sheet-scope"; + +/** Registers focus with the sheet so the keyboard can move the active input into view. */ +export const SheetAwareTextInput = forwardRef( + function SheetAwareTextInput(props, ref) { + const inSheet = useIsInsideBottomSheet(); + const setRef = useCallback( + (input: NativeTextInput | null | undefined) => { + if (typeof ref === "function") ref(input ?? null); + else if (ref) ref.current = input ?? null; + }, + [ref], + ); + return inSheet && isNative ? ( + + ) : ( + + ); + }, +); diff --git a/packages/app/src/components/ui/text-input/text-input.native.test.tsx b/packages/app/src/components/ui/text-input/text-input.native.test.tsx index a04bd89d50..2b04a84c63 100644 --- a/packages/app/src/components/ui/text-input/text-input.native.test.tsx +++ b/packages/app/src/components/ui/text-input/text-input.native.test.tsx @@ -2,7 +2,7 @@ import React, { act, createRef } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { BottomSheetTextInputScope } from "./bottom-sheet-scope"; +import { BottomSheetScope } from "@/components/ui/bottom-sheet-scope"; import { EditingTextInput } from "./text-input.native"; import type { EditingTextInputHandle } from "./types"; @@ -62,9 +62,9 @@ describe("EditingTextInputNative", () => { root?.render( <> - + - + , ); }); diff --git a/packages/app/src/components/ui/text-input/text-input.native.tsx b/packages/app/src/components/ui/text-input/text-input.native.tsx index c505a9b917..074367a1c9 100644 --- a/packages/app/src/components/ui/text-input/text-input.native.tsx +++ b/packages/app/src/components/ui/text-input/text-input.native.tsx @@ -5,7 +5,7 @@ import PasteInput, { type PastedFile, type PasteTextInputInstance, } from "@mattermost/react-native-paste-input"; -import { useIsInsideBottomSheet } from "./bottom-sheet-scope"; +import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope"; import type { EditingTextInputHandle, EditingTextInputProps } from "./types"; type NativeInput = (TextInput | PasteTextInputInstance) & { diff --git a/packages/app/src/plugins/react-native/clipboard.ts b/packages/app/src/plugins/react-native/clipboard.ts new file mode 100644 index 0000000000..4357a0baa1 --- /dev/null +++ b/packages/app/src/plugins/react-native/clipboard.ts @@ -0,0 +1,6 @@ +export async function copyText(text: string): Promise { + const { setStringAsync } = require("expo-clipboard") as typeof import("expo-clipboard"); + if (!(await setStringAsync(text))) { + throw new Error("Clipboard copy was denied or is unavailable"); + } +} diff --git a/packages/app/src/plugins/react-native/clipboard.web.ts b/packages/app/src/plugins/react-native/clipboard.web.ts new file mode 100644 index 0000000000..7207c2c689 --- /dev/null +++ b/packages/app/src/plugins/react-native/clipboard.web.ts @@ -0,0 +1,6 @@ +// Expo's web fallback reports success even when document.execCommand returns +// false. Use the browser promise so the SDK can promise success or rejection. +export async function copyText(text: string): Promise { + if (!navigator.clipboard) throw new Error("Clipboard copy is unavailable in this browser"); + await navigator.clipboard.writeText(text); +} diff --git a/packages/app/src/plugins/react-native/modal.tsx b/packages/app/src/plugins/react-native/modal.tsx index 38728b56b6..2785531d55 100644 --- a/packages/app/src/plugins/react-native/modal.tsx +++ b/packages/app/src/plugins/react-native/modal.tsx @@ -30,7 +30,12 @@ function useModalContext(): ModalContextValue { return context; } -function ModalContent({ children }: ModalContentProps) { +function ModalContent({ + children, + style, + contentContainerStyle, + scrollable = true, +}: ModalContentProps) { const modal = useModalContext(); const queryClient = useQueryClient(); const toast = useAppToast(); @@ -52,6 +57,10 @@ function ModalContent({ children }: ModalContentProps) { onClose={modal.dismiss} header={modal.header} contextBridge={contextBridge} + bodyStyle={style} + contentStyle={contentContainerStyle} + scrollable={scrollable} + desktopHeight={scrollable ? undefined : "85%"} > {children} diff --git a/packages/app/src/plugins/react-native/runtime.ts b/packages/app/src/plugins/react-native/runtime.ts index 4ac8ef3349..bdf39910a4 100644 --- a/packages/app/src/plugins/react-native/runtime.ts +++ b/packages/app/src/plugins/react-native/runtime.ts @@ -1,6 +1,18 @@ import { Icon } from "../icons"; import { Modal } from "./modal"; +import { ScrollView, FlatList } from "./scroll-view"; +import { TextInput } from "./text-input"; +import { copyText } from "./clipboard"; import { useToast } from "./toast"; import { useRevealedText } from "@/hooks/use-revealed-text"; -export const pluginReactNativeRuntime = { Icon, Modal, useRevealedText, useToast }; +export const pluginReactNativeRuntime = { + Icon, + Modal, + ScrollView, + FlatList, + TextInput, + copyText, + useRevealedText, + useToast, +}; diff --git a/packages/app/src/plugins/react-native/scroll-view.tsx b/packages/app/src/plugins/react-native/scroll-view.tsx new file mode 100644 index 0000000000..40dcc2f192 --- /dev/null +++ b/packages/app/src/plugins/react-native/scroll-view.tsx @@ -0,0 +1,25 @@ +import { forwardRef } from "react"; +import type { + ScrollView as NativeScrollView, + ScrollViewProps, + FlatListProps, + FlatList as NativeFlatList, +} from "react-native"; +import type * as Shared from "@/components/ui/scroll-view"; + +// Plugin evaluation also runs before the native UI is ready. Load presentation +// dependencies at render time, as Modal does, rather than evaluating them on import. +export const ScrollView = forwardRef( + function ScrollView(props, ref) { + const { ScrollView: HostScrollView } = + require("../../components/ui/scroll-view") as typeof Shared; + return ; + }, +); + +export const FlatList = forwardRef, FlatListProps>( + function FlatList(props, ref) { + const { FlatList: HostFlatList } = require("../../components/ui/scroll-view") as typeof Shared; + return ; + }, +) as typeof Shared.FlatList; diff --git a/packages/app/src/plugins/react-native/text-input.tsx b/packages/app/src/plugins/react-native/text-input.tsx new file mode 100644 index 0000000000..0330e68533 --- /dev/null +++ b/packages/app/src/plugins/react-native/text-input.tsx @@ -0,0 +1,12 @@ +import { forwardRef, type ElementRef } from "react"; +import type { TextInputProps } from "react-native"; + +import type { SheetAwareTextInput } from "@/components/ui/text-input/sheet-aware"; + +export const TextInput = forwardRef, TextInputProps>( + function TextInput(props, ref) { + const { SheetAwareTextInput } = + require("../../components/ui/text-input/sheet-aware") as typeof import("../../components/ui/text-input/sheet-aware"); + return ; + }, +); diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 710bce8339..34b481ffda 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -65,6 +65,7 @@ "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.1.0", + "react-native": "0.81.5", "typescript": "^5.9.3", "vitest": "^4.1.6", "zod": "^4.4.3" @@ -73,6 +74,12 @@ "@getpaseo/client": "0.7.2", "@getpaseo/protocol": "0.7.2", "react": "19.1.0", + "react-native": ">=0.81.5", "zod": "^4.4.3" + }, + "peerDependenciesMeta": { + "react-native": { + "optional": true + } } } diff --git a/packages/plugin/src/react-native.ts b/packages/plugin/src/react-native.ts index d582cb724a..ea196c433f 100644 --- a/packages/plugin/src/react-native.ts +++ b/packages/plugin/src/react-native.ts @@ -1,4 +1,22 @@ -import type { ComponentType, FunctionComponent, ReactNode } from "react"; +import type { + ComponentType, + FunctionComponent, + ReactNode, + ForwardRefExoticComponent, + RefAttributes, + ReactElement, + Ref, +} from "react"; +import type { + StyleProp, + ViewStyle, + ScrollView as NativeScrollView, + ScrollViewProps, + FlatList as NativeFlatList, + FlatListProps, + TextInput as NativeTextInput, + TextInputProps, +} from "react-native"; import type { PluginIconProps } from "./contracts.js"; export interface ModalProps { @@ -11,6 +29,12 @@ export interface ModalProps { export interface ModalContentProps { children: ReactNode; + /** Paint the full body below the header. */ + style?: StyleProp; + /** Overrides the default 24px padding and 16px gap. Safe-area clearance stays host-owned. */ + contentContainerStyle?: StyleProp; + /** Default true. Set false for a bounded body with your own ScrollView or FlatList. */ + scrollable?: boolean; } export interface ModalComponent extends FunctionComponent { @@ -35,3 +59,18 @@ export declare function useToast(): ToastApi; export declare function useRevealedText(text: string, phase: "streaming" | "complete"): string; export type { PluginIconProps } from "./contracts.js"; + +/** React Native scrolling with the host's sheet gestures when rendered inside a sheet. */ +export declare const ScrollView: ForwardRefExoticComponent< + ScrollViewProps & RefAttributes +>; +export declare function FlatList( + props: FlatListProps & { ref?: Ref> }, +): ReactElement; +/** Copies text to this client's clipboard. Rejects when copying is unavailable or denied. */ +export declare function copyText(text: string): Promise; + +/** Native input focus integrated with modal keyboard positioning. */ +export declare const TextInput: ForwardRefExoticComponent< + TextInputProps & RefAttributes +>; diff --git a/plugin-examples/modal-ui/README.md b/plugin-examples/modal-ui/README.md new file mode 100644 index 0000000000..96cbbd8b78 --- /dev/null +++ b/plugin-examples/modal-ui/README.md @@ -0,0 +1,26 @@ +# Modal UI example + +Install this directory as a local plugin, then open **Modal examples** from the sidebar or a workspace tab. + +The examples demonstrate default and custom padding, a full-width body, author-owned ScrollView and +FlatList scrolling, horizontal tabs, and clipboard actions with a keyboard-aware input. See the +[host UI reference](../../public-docs/plugins/v0.8/reference.md#host-ui) for the API contract. + +The browser regression installs this exact example in an isolated daemon: + +```sh +cd packages/app +npx playwright test e2e/browser/plugin-modal-body.spec.ts --project=browser --workers=1 +``` + +On Android, open each example and swipe through its content. In FlatList, use **Jump to last row** +before expanding the sheet; row 100 should be visible. Drag the handle to expand the sheet. In Form, +press **Copy text**, long-press the input, and choose **Paste**. The input should contain +“Copied from Paseo”. With the system keyboard enabled, focusing the input should keep it visible. + +These captures show Android copy/paste and custom padding on browser and wide native layouts. +Android API 35 and Chromium were exercised; iOS and Electron were not tested. + +| Android copy/paste | Browser custom padding | Wide Android custom padding | +| ---------------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------- | +| ![Android copy and OS paste](screenshots/android-copy-paste.png) | ![Browser custom padding](screenshots/web-custom-inset.png) | ![Wide Android custom padding](screenshots/android-wide-custom-inset.png) | diff --git a/plugin-examples/modal-ui/client/examples.tsx b/plugin-examples/modal-ui/client/examples.tsx new file mode 100644 index 0000000000..e834983bfc --- /dev/null +++ b/plugin-examples/modal-ui/client/examples.tsx @@ -0,0 +1,205 @@ +import type { PluginSurfaceProps } from "@getpaseo/plugin"; +import { + Modal, + ScrollView, + FlatList, + TextInput, + copyText, + useToast, +} from "@getpaseo/plugin/react-native"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { + Pressable, + Text, + View, + StyleSheet, + type StyleProp, + type ViewStyle, + type TextStyle, + type FlatList as NativeFlatList, +} from "react-native"; + +const modes = [ + "Form", + "Custom inset", + "Full bleed", + "ScrollView", + "FlatList", + "Horizontal", +] as const; +type Mode = (typeof modes)[number]; +const rows = Array.from({ length: 100 }, (_, index) => index + 1); +const tabs = ["Overview", "Configuration", "Activity", "Permissions", "System logs"]; +const styles = StyleSheet.create({ + screen: { flex: 1, padding: 16, gap: 12 }, + button: { padding: 12, borderWidth: 1, borderRadius: 6 }, + fill: { flex: 1, minHeight: 0 }, + customInset: { padding: 8, paddingBottom: 40, gap: 16 }, + fullBleed: { padding: 0, gap: 0 }, + row: { height: 56, padding: 16, borderBottomWidth: 1 }, + form: { gap: 16 }, + input: { borderWidth: 1, padding: 12, borderRadius: 6 }, + tabs: { flexGrow: 0, flexShrink: 0 }, + tab: { padding: 18, minWidth: 160 }, +}); + +export function ModalExamples({ theme }: Pick) { + const [mode, setMode] = useState(null); + const [input, setInput] = useState(""); + const [selected, setSelected] = useState(""); + const list = useRef>(null); + const toast = useToast(); + const color = useMemo(() => ({ color: theme.colors.foreground }), [theme]); + const body = useMemo(() => ({ backgroundColor: theme.colors.surface2 }), [theme]); + const button = useMemo(() => [styles.button, { borderColor: theme.colors.border }], [theme]); + const rowStyle = useMemo(() => [styles.row, { borderColor: theme.colors.border }], [theme]); + const inputStyle = useMemo( + () => [styles.input, color, { borderColor: theme.colors.border }], + [color, theme], + ); + + const copy = useCallback(async () => { + try { + await copyText("Copied from Paseo"); + toast.show("Text copied", { variant: "success" }); + } catch { + toast.error("Could not copy text. Select the text and use Copy."); + } + }, [toast]); + + const row = useCallback( + (item: number) => { + return ( + + ); + }, + [rowStyle, color], + ); + const renderRow = useCallback(({ item }: { item: number }) => row(item), [row]); + const close = useCallback((open: boolean) => { + if (!open) setMode(null); + }, []); + const jumpToEnd = useCallback(() => list.current?.scrollToEnd({ animated: false }), []); + const screen = useMemo(() => [styles.screen, body], [body]); + + let contentStyle: StyleProp = styles.fullBleed; + if (mode === "Form") contentStyle = undefined; + if (mode === "Custom inset") contentStyle = styles.customInset; + + const form = ( + + + Paseo clipboard example + + + Copy text + + + Input: {input || "empty"} + + ); + + return ( + + Modal body examples + {modes.map((example) => ( + + key={example} + value={example} + label={`Open ${example}`} + style={button} + textStyle={color} + onSelect={setMode} + /> + ))} + Selected: {selected || "none"} + + + {mode === "Form" || mode === "Custom inset" ? form : null} + {mode === "Full bleed" ? rows.map(row) : null} + {mode === "ScrollView" ? ( + + {rows.map(row)} + + ) : null} + {mode === "FlatList" ? ( + <> + + Jump to last row + + + + ) : null} + {mode === "Horizontal" ? ( + <> + + {tabs.map((tab) => ( + + ))} + + {form} + {rows.slice(0, 20).map(row)} + + ) : null} + + + + ); +} + +function getRowLayout(_: unknown, index: number) { + return { index, length: 56, offset: index * 56 }; +} + +function ChoiceButton({ + value, + label, + style, + textStyle, + onSelect, +}: { + value: Value; + label: string; + style: StyleProp; + textStyle: StyleProp; + onSelect(value: Value): void; +}) { + const select = useCallback(() => onSelect(value), [onSelect, value]); + return ( + + {label} + + ); +} diff --git a/plugin-examples/modal-ui/index.client.tsx b/plugin-examples/modal-ui/index.client.tsx new file mode 100644 index 0000000000..15bd5ae2c8 --- /dev/null +++ b/plugin-examples/modal-ui/index.client.tsx @@ -0,0 +1,21 @@ +import type { PluginClientContext } from "@getpaseo/plugin"; +import { ModalExamples } from "./client/examples"; + +export default function contribute(plugin: PluginClientContext) { + plugin.addSurface("main", ModalExamples); + plugin.addSidebarItem({ + id: "main", + title: "Modal examples", + icon: "PanelsTopLeft", + surface: "main", + }); + plugin.addWorkspacePanel({ + id: "examples", + title: "Modal examples", + icon: "PanelsTopLeft", + context: "workspace", + locations: ["workspace", "explorer"], + Component: ModalExamples, + }); + return () => {}; +} diff --git a/plugin-examples/modal-ui/paseo-plugin.json b/plugin-examples/modal-ui/paseo-plugin.json new file mode 100644 index 0000000000..b2dc64c17a --- /dev/null +++ b/plugin-examples/modal-ui/paseo-plugin.json @@ -0,0 +1,3 @@ +{ + "id": "modal-ui-example" +} diff --git a/plugin-examples/modal-ui/screenshots/android-copy-paste.png b/plugin-examples/modal-ui/screenshots/android-copy-paste.png new file mode 100644 index 0000000000000000000000000000000000000000..ee5da20bf87d5551cc64f7240e58eb66003c35a3 GIT binary patch literal 79035 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 E1C8KaaR2}S literal 0 HcmV?d00001 diff --git a/plugin-examples/modal-ui/screenshots/android-wide-custom-inset.png b/plugin-examples/modal-ui/screenshots/android-wide-custom-inset.png new file mode 100644 index 0000000000000000000000000000000000000000..0346fdc9ccc0e14dc676136146d1673245f69def GIT binary patch literal 70900 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 literal 0 HcmV?d00001 diff --git a/plugin-examples/modal-ui/screenshots/web-custom-inset.png b/plugin-examples/modal-ui/screenshots/web-custom-inset.png new file mode 100644 index 0000000000000000000000000000000000000000..513dc104c94c8fdce230728888280f86639ea53e GIT binary patch literal 53973 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 | — | Styles the full body viewport, including empty space. Use for backgrounds. | +| `contentContainerStyle` | `StyleProp` | Padding 24, gap 16 | Overrides the content layout. Set `padding: 0, gap: 0` for edge-to-edge rows. | +| `scrollable` | `boolean` | `true` | The host scrolls the body. Set `false` for a bounded body with your own scroller. | + +A plain `Modal.Content` is already padded and scrollable. Avoid adding a second padded wrapper +unless you want another inset. Body styles leave the host header, drag handle, and dismissal controls +intact. The host reserves bottom safe-area space on compact native layouts; setting content padding +to zero removes the decorative inset, not that space. Keyboard clearance is handled separately. + +With `scrollable={false}`, the body fills the available sheet height, and the centered dialog uses +85% of the available height. Use `flex: 1, minHeight: 0` on your list. In this mode the body scrolls +at every sheet height; drag the handle to resize or dismiss the sheet. Default scrolling dialogs stay +content-sized on wide layouts. Presentation follows window size, including narrow desktop windows +and wide tablets. The close button, backdrop, platform back action, web Escape key, and compact sheet gesture dismiss the modal. Dismissal calls `onOpenChange(false)`; the plugin must update `open` to close it. @@ -330,6 +344,72 @@ the modal. Dismissal calls `onOpenChange(false)`; the plugin must update `open` Modal children keep the plugin runtime context. `usePaseo`, `useRpc`, `useWorkspace`, and `useAgent` work inside them. +### Scrolling + +Import `ScrollView` and `FlatList` from `@getpaseo/plugin/react-native` when content can appear in a +Paseo modal. They accept React Native props and refs and integrate with the sheet's gestures. Outside +a sheet they use ordinary React Native scrolling. Do not import bottom-sheet libraries directly. + +Use one vertical scroll owner: either the default modal body, or your own list with +`scrollable={false}`. A fixed-height vertical list nested inside the default scrolling body can compete +with the sheet for gestures on Android. Horizontal scrolling can coexist with the host's vertical body. + +```tsx +import { FlatList, Modal } from "@getpaseo/plugin/react-native"; +import { Text } from "react-native"; + +// Inside your controlled Modal: + + item.id} + renderItem={({ item }) => ( + {item.title} + )} + /> +; +``` + +For horizontal tabs, place `…` inside the +default `Modal.Content`. Keep the content's vertical scrolling on the host. + +### Copy and paste + +`copyText(text): Promise` writes to the clipboard on the device running the app. Call it from a +user action and await it before reporting success. It rejects if the platform denies copying or the +clipboard is unavailable; browser permissions and secure-context requirements still apply. + +```tsx +import { copyText, useToast } from "@getpaseo/plugin/react-native"; + +// Inside your component: +const toast = useToast(); +async function copyResult() { + try { + await copyText(result); + toast.show("Copied", { variant: "success" }); + } catch { + toast.error("Could not copy. Select the text and use Copy."); + } +} +``` + +Programmatic copying and native text selection are separate interactions. Use `` +for long-press selection and OS Copy. Import `TextInput` from `@getpaseo/plugin/react-native` for modal forms. It accepts React Native +input props and refs, supports OS Paste, and registers focus with the native sheet so the keyboard +can raise the form. Outside a sheet it uses the ordinary input. A plain React Native input supports +Paste too, but does not register focus with the sheet; the keyboard can cover it. No clipboard read +API is needed for OS Paste. Avoid DOM clipboard code in native plugins and the deprecated +`Clipboard` export from `react-native`. + +The runnable [modal UI example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/modal-ui) +contains a padded form, full-width rows, a virtualized list, horizontal tabs, and a copy/paste input. + ### Toasts `useToast()` returns two methods: From dd7671be20d180004e7e78bc4b0255eed5775272 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 6 Sep 2026 20:29:41 +0200 Subject: [PATCH 021/566] Remember model selections across workspace creation (#4401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(providers): remember model selections across workspace creation Keep saved model intent independent of catalogue readiness and use the caller-owned host and directory from the first render. Share discovery through provider-owned keys while preserving execution context. Give each target one committed snapshot, publish actual transitions atomically, and share catalogue content across client directory associations. Regression coverage exercises successful repeated creation and provider lifecycle boundaries. * fix(providers): preserve shared cache bodies during eviction Admit directory references with their bodies, retain reference recency during eviction and recovery, and remove the unused persisted index. Refresh known workspace catalogues concurrently. Make repeated-creation picker states explicit and seed mode tests from the installed provider catalogue, preserving assertions across Codex versions. * fix(providers): bound discovery independently per provider Settings refresh repairs all remembered targets, which otherwise starts an unbounded number of discoveries. Admit work after catalogue-key deduplication and keep each provider’s budget independent so a stalled provider cannot block another. Preserve active accounting across configuration changes and settle obsolete queued requests during replacement and shutdown. --- .github/ci-paths.yml | 12 + docs/plugins.md | 6 + docs/providers.md | 49 +- ...w-workspace-codex-mode-preferences.spec.ts | 76 +- .../new-workspace-model-memory.real.spec.ts | 69 + .../new-workspace-model-memory.spec.ts | 109 ++ .../new-workspace-model-recovery.spec.ts | 102 ++ .../app/e2e/support/helpers/model-memory.ts | 71 + .../src/components/workspace-setup-dialog.tsx | 8 - .../composer/draft/input-draft.live.test.tsx | 6 - .../app/src/composer/draft/input-draft.ts | 18 +- .../app/src/composer/draft/workspace-tab.tsx | 23 +- .../src/data/provider-snapshot-cache.test.ts | 343 +++- .../app/src/data/provider-snapshot-cache.ts | 361 +++- packages/app/src/data/providers-snapshot.ts | 114 ++ packages/app/src/data/push-router.test.ts | 29 +- packages/app/src/data/push-router.ts | 60 +- .../app/src/hooks/use-agent-form-state.ts | 225 +-- .../src/hooks/use-providers-snapshot.test.ts | 96 +- .../app/src/hooks/use-providers-snapshot.ts | 92 +- .../resolve-agent-form.test.ts | 225 ++- .../provider-selection/resolve-agent-form.ts | 165 +- packages/app/src/runtime/host-runtime.ts | 1 + .../app/src/schedules/schedule-form-model.ts | 14 - .../app/src/screens/new-workspace-screen.tsx | 12 +- .../src/compat/normalize-provider-models.ts | 6 +- packages/client/src/daemon-client.test.ts | 49 + packages/client/src/daemon-client.ts | 13 +- packages/plugin/src/provider.ts | 6 + packages/protocol/src/client-capabilities.ts | 1 + .../src/messages.providers-snapshot.test.ts | 17 + packages/protocol/src/messages.ts | 3 + .../executable-resolution.test.ts | 9 + .../executable-resolution.ts | 6 +- .../src/server/agent/agent-sdk-types.ts | 8 +- .../mutable-provider-config-owner.test.ts | 46 +- .../agent/mutable-provider-config-owner.ts | 28 +- .../src/server/agent/plugin-provider.ts | 3 + .../src/server/agent/provider-registry.ts | 3 +- .../agent/provider-snapshot-manager.test.ts | 1613 ++++++++++++++++- .../server/agent/provider-snapshot-manager.ts | 851 ++++----- .../server/agent/providers/claude/agent.ts | 5 + .../agent/providers/codex-app-server-agent.ts | 5 + .../src/server/plugins/index.posix.test.ts | 11 +- packages/server/src/server/plugins/index.ts | 8 + .../server/plugins/plugin-process-protocol.ts | 26 + .../src/server/plugins/plugin-process.ts | 24 +- packages/server/src/server/plugins/runtime.ts | 33 +- packages/server/src/server/session.test.ts | 139 +- packages/server/src/server/session.ts | 2 + .../provider/provider-catalog-session.test.ts | 403 +++- .../provider/provider-catalog-session.ts | 162 +- .../src/server/test-utils/session-stubs.ts | 148 +- scripts/ci-workflow.test.mjs | 24 + 54 files changed, 4326 insertions(+), 1612 deletions(-) create mode 100644 packages/app/e2e/browser/new-workspace-model-memory.real.spec.ts create mode 100644 packages/app/e2e/browser/new-workspace-model-memory.spec.ts create mode 100644 packages/app/e2e/browser/new-workspace-model-recovery.spec.ts create mode 100644 packages/app/e2e/support/helpers/model-memory.ts diff --git a/.github/ci-paths.yml b/.github/ci-paths.yml index c156ec0a16..7da0b185cb 100644 --- a/.github/ci-paths.yml +++ b/.github/ci-paths.yml @@ -55,6 +55,18 @@ sdk: - "packages/protocol/**" browser: + - "packages/server/src/server/agent/provider-snapshot-manager.ts" + - "packages/server/src/server/session/provider/provider-catalog-session.ts" + - "packages/client/src/compat/normalize-provider-models.ts" + - "packages/protocol/src/client-capabilities.ts" + - "packages/server/src/server/agent/provider-registry.ts" + - "packages/server/src/server/agent/agent-sdk-types.ts" + - "packages/server/src/server/agent/providers/codex-app-server-agent.ts" + - "packages/server/src/server/agent/providers/claude/agent.ts" + - "packages/server/src/server/agent/plugin-provider.ts" + - "packages/server/src/server/plugins/{index,plugin-process,plugin-process-protocol,runtime}.ts" + - "packages/server/src/executable-resolution/**" + - "packages/plugin/src/provider.ts" - "packages/app/src/!(desktop)/**" - "packages/app/e2e/browser/**" - "packages/app/e2e/support/**" diff --git a/docs/plugins.md b/docs/plugins.md index 905f482bc7..3798d7c055 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -272,6 +272,12 @@ export default function contribute(server: PluginServerContext) { } ``` +Implement optional `ProviderRegistration.getCatalogCacheKey(options)` to share equivalent catalogue +probes. The callback runs in the plugin process before discovery and receives the actual global or +workspace target. Return a key covering effective configuration and execution environment, or +`undefined` for target-specific caching. Ignore `force` when choosing identity. Existing providers +need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh-contract). + `send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state, configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`. Route messages, structured commands, steering, and command side effects through `session.prompt`. diff --git a/docs/providers.md b/docs/providers.md index df703b406e..f47d408d54 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -115,7 +115,18 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star ## Provider Snapshot Refresh Contract -The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory. +Provider snapshots are views of the catalogues needed for a target. Before looking up cached +results or discovery in flight, the manager calls optional `AgentClient.getCatalogCacheKey(options)`. +The provider owns equivalence: equal keys must mean the same availability, models and modes, +including the effective configuration and execution environment. `force` does not change identity. +Omitting the method or returning `undefined` keeps target-specific caching. Codex and Claude's +current host clients share across directories; configured provider identities remain isolated. + +The key chooses storage, never execution. Availability and catalogue discovery receive the actual +`{ scope: "global", force }` or `{ scope: "workspace", cwd, force }` request, including when another +target can share its result. Runtime-aware adapters must use that target for both probing and key +resolution. An explicit home-directory workspace is distinct from a semantic global request. +Plugin callbacks follow the same contract; see [plugin providers](plugins.md#contribute-a-provider). `ProviderSnapshotManager` owns one refresh deadline per provider. The deadline starts before the availability check and covers that check plus the complete catalog probe. Providers that make @@ -124,15 +135,41 @@ aborts the shared refresh signal at the deadline. Providers name active catalog finish subprocess, server, or session cleanup before rejecting. Timeout errors list the operations that were still active when the deadline expired. -Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own. +Catalogue results stay cached by identity until explicit refresh or a change to that provider's configuration. +Keys are resolved on each read so project configuration can select a different cached catalogue. +Selector opening may read a loading or stale query, but does not force provider probing. -Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and cwd, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal. +Saved provider/model choices are user intent. Catalogue failure must not erase them or substitute +another model. Creation reads the caller's host and directory directly; an earlier global snapshot +must not settle a project form's initial selection. -Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace. +The server's provider keys never cross the wire. Clients advertising `provider_snapshot_references` +receive directory-to-hash announcements; an unknown hash uses the existing snapshot request. +The manager detaches each fresh result once and publishes its content identity with the entry; +readers share these values read-only. Each target keeps its published snapshot until membership, +content or discovery freshness changes. All affected targets commit before subscribers run; +subscriber failures are logged without changing discovery or configuration outcomes. +The session compares both sides of a committed transition under the client's current visibility +policy and sends only visible changes. It combines result identities with the client's encoding +and icon policy, without traversing models. Reference hashes exclude freshness; +legacy embedded hashes include it. Only responses that send a body compact the catalogue. +Per-provider `fetchedAt` travels separately for reference clients and still means when discovery succeeded. `generatedAt` +remains the time the response or announcement was generated. Refresh keeps settled entries visible +until the next result, so unchanged discovery sends freshness without another model body. -Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path. +The app's snapshot cache owns compact expansion and stores one body per server/hash, with separate +directory/hash/freshness records under the same byte budget. Missing bodies share a React Query +request; directory queries cancel superseded pulls before accepting pushes. Default SDK clients +keep expanded entries and full updates. Only callers that own materialization opt into wire snapshots. +Keep the full encoding for older clients; compact-snapshot support alone does not imply reference support. -Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only global. +Settings refresh invalidates requested providers across known targets and refreshes them in their +actual execution context, so every connected client receives the refreshed view. Discovery shares +work by provider key and admits at most four concurrent catalogue probes per configured provider, so a stalled provider does not block discovery for others. Configuration replacement invalidates +only changed providers; unchanged entries, clients, and discovery in flight survive. Preparation +leaves installed reads untouched until commit. Plugin replacement uses registration runtime +identity, so unchanged registrations and builtins keep their results. Await the refresh or warmup +promise for completion: equal results, including equal discovery timestamps, emit no transition. --- diff --git a/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts b/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts index ddd415ed97..327666ecb8 100644 --- a/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts +++ b/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts @@ -1,4 +1,6 @@ import { expect, test, type Page } from "../support/fixtures"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; import type { FormPreferences } from "@/create-agent-preferences/preferences"; import { gotoAppShell } from "../support/helpers/app"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; @@ -49,30 +51,52 @@ function getSessionMessage(message: WebSocketMessage): Record | return maybeEnvelope.message as Record; } -async function seedCodexDefaultPermissionPreferences(page: Page): Promise { - await page.addInitScript( - ({ preferencesKey }) => { - localStorage.setItem( - preferencesKey, - JSON.stringify({ - provider: "codex", - providerPreferences: { - codex: { - model: "gpt-5.4-mini", - mode: "auto", - thinkingByModel: { - "gpt-5.4-mini": "low", +async function seedCodexDefaultPermissionPreferences(page: Page, cwd: string): Promise { + const client = await connectDaemonClient({ + clientIdPrefix: "codex-mode-preferences", + }); + try { + await expect + .poll( + async () => + (await client.getProvidersSnapshot({ cwd })).entries.find( + (entry) => entry.provider === "codex", + )?.status, + ) + .toBe("ready"); + const snapshot = await client.getProvidersSnapshot({ cwd }); + const model = snapshot.entries + .find((entry) => entry.provider === "codex") + ?.models?.find((candidate) => candidate.thinkingOptions?.length); + if (!model?.thinkingOptions?.[0]) + throw new Error("Codex catalogue must expose a model with thinking options"); + await page.addInitScript( + ({ preferencesKey, modelId, thinkingOptionId }) => { + localStorage.setItem( + preferencesKey, + JSON.stringify({ + provider: "codex", + providerPreferences: { + codex: { + model: modelId, + mode: "auto", + thinkingByModel: { [modelId]: thinkingOptionId }, }, + mock: { model: "ten-second-stream" }, }, - mock: { - model: "ten-second-stream", - }, - }, - } satisfies FormPreferences), - ); - }, - { preferencesKey: CREATE_AGENT_PREFERENCES_KEY }, - ); + } satisfies FormPreferences), + ); + }, + { + preferencesKey: CREATE_AGENT_PREFERENCES_KEY, + modelId: model.id, + thinkingOptionId: model.thinkingOptions[0].id, + }, + ); + return model.id; + } finally { + await client.close(); + } } async function readCodexModePreference(page: Page): Promise { @@ -156,9 +180,8 @@ test.describe("New workspace Codex mode preferences", () => { }) => { const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" }); const createAgentRecorder = await recordAndBlockCreateAgentRequests(page); - await seedCodexDefaultPermissionPreferences(page); - try { + await seedCodexDefaultPermissionPreferences(page, seeded.repoPath); await gotoAppShell(page); await waitForSidebarHydration(page); await openGlobalNewWorkspaceComposer(page); @@ -193,16 +216,15 @@ test.describe("New workspace Codex mode preferences", () => { test("uses the live Codex agent mode as the next New Workspace default", async ({ page }) => { const seeded = await seedWorkspace({ repoPrefix: "codex-live-mode-preferences-" }); - await seedCodexDefaultPermissionPreferences(page); - try { + const model = await seedCodexDefaultPermissionPreferences(page, seeded.repoPath); const agent = await seeded.client.createAgent({ provider: "codex", cwd: seeded.repoPath, workspaceId: seeded.workspaceId, title: "Codex live mode preference e2e", modeId: "auto", - model: "gpt-5.4-mini", + model, }); await openAgentRoute(page, { diff --git a/packages/app/e2e/browser/new-workspace-model-memory.real.spec.ts b/packages/app/e2e/browser/new-workspace-model-memory.real.spec.ts new file mode 100644 index 0000000000..39a0c9c495 --- /dev/null +++ b/packages/app/e2e/browser/new-workspace-model-memory.real.spec.ts @@ -0,0 +1,69 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { test, expect } from "../support/fixtures"; +import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { + chooseModel, + reselectModel, + startWithoutRememberedModel, + expectCreatedModelAgents, + expectRememberedModel, + expectSavedSelection, +} from "../support/helpers/model-memory"; +import { + openGlobalNewWorkspaceComposer, + submitNewWorkspacePrompt, +} from "../support/helpers/new-workspace"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; + +test("remembers real Codex GPT-6 Astra after repeated successful workspace creation", async ({ + page, +}) => { + test.setTimeout(180_000); + const workspace = await seedWorkspace({ repoPrefix: "codex-astra-memory-" }); + const client = await connectDaemonClient({ clientIdPrefix: "codex-astra-memory" }); + try { + await expect + .poll( + async () => + (await client.getProvidersSnapshot({ cwd: workspace.repoPath })).entries.find( + (entry) => entry.provider === "codex", + )?.status, + { timeout: 60_000 }, + ) + .toBe("ready"); + const snapshot = await client.getProvidersSnapshot({ cwd: workspace.repoPath }); + const codex = snapshot.entries.find((entry) => entry.provider === "codex"); + expect(codex?.status).toBe("ready"); + const astra = codex?.models?.find((model) => model.id === "gpt-6-astra"); + expect(astra?.id).toBe("gpt-6-astra"); + const label = astra!.label; + await startWithoutRememberedModel(page); + await gotoWorkspace(page, workspace.workspaceId); + await waitForSidebarHydration(page); + await openGlobalNewWorkspaceComposer(page); + for (const [index, select] of [chooseModel, reselectModel].entries()) { + const count = index + 1; + await test.step(`create workspace ${count} and return`, async () => { + await select(page, "codex", label); + await expectSavedSelection(page, "codex", "gpt-6-astra"); + await submitNewWorkspacePrompt(page, "Reply with OK. Do not use tools or change files."); + await expect(page).toHaveURL(/\/workspace\//); + await expectCreatedModelAgents(page, client, "codex", "gpt-6-astra", count); + await expect( + page.getByTestId("assistant-message").filter({ hasText: "OK", visible: true }), + ).toBeVisible({ + timeout: 60_000, + }); + await openGlobalNewWorkspaceComposer(page); + await expectSavedSelection(page, "codex", "gpt-6-astra"); + await expectRememberedModel(page, label); + }); + } + await page.screenshot({ path: test.info().outputPath("astra-remembered.png") }); + } finally { + await client.close(); + await workspace.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/new-workspace-model-memory.spec.ts b/packages/app/e2e/browser/new-workspace-model-memory.spec.ts new file mode 100644 index 0000000000..9e86b74ffb --- /dev/null +++ b/packages/app/e2e/browser/new-workspace-model-memory.spec.ts @@ -0,0 +1,109 @@ +import { mkdtemp, rename, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { test, expect } from "../support/fixtures"; +import { seedModelProvider } from "../support/helpers/agent-profiles"; +import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { + openGlobalNewWorkspaceComposer, + submitNewWorkspaceEmpty, + submitNewWorkspacePrompt, +} from "../support/helpers/new-workspace"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; + +import { + chooseModel, + reselectModel, + startWithoutRememberedModel, + expectSavedSelection, + expectCreatedModelAgents, + expectRememberedModel, +} from "../support/helpers/model-memory"; + +const PROVIDER = "model-memory-diagnostic"; +const MODEL = "pi-profile-model"; +const LABEL = "Pi profile model"; + +async function expectProviderStatus(client: DaemonClient, cwd: string | undefined, status: string) { + await expect + .poll( + async () => + (await client.getProvidersSnapshot(cwd ? { cwd } : undefined)).entries.find( + (entry) => entry.provider === PROVIDER, + )?.status, + { timeout: 30_000 }, + ) + .toBe(status); +} + +async function prepareCatalogState( + client: DaemonClient, + executable: string, + cwd: string, + hostStatus: "ready" | "unavailable", +) { + await expectProviderStatus(client, undefined, "ready"); + await expectProviderStatus(client, cwd, "ready"); + await rename(executable, `${executable}.parked`); + await client.refreshProvidersSnapshot({ providers: [PROVIDER] }); + await expectProviderStatus(client, undefined, "unavailable"); + await rename(`${executable}.parked`, executable); + if (hostStatus === "ready") await client.refreshProvidersSnapshot({ providers: [PROVIDER] }); + await client.refreshProvidersSnapshot({ cwd, providers: [PROVIDER] }); + await expectProviderStatus(client, cwd, "ready"); + await expectProviderStatus(client, undefined, hostStatus); +} + +for (const hostStatus of ["ready", "unavailable"] as const) { + test(`remembers the manually selected model after two creations with a ${hostStatus} host catalog and ready project catalog`, async ({ + page, + }) => { + test.setTimeout(150_000); + const workspace = await seedWorkspace({ repoPrefix: "model-memory-scope-" }); + const binDir = await mkdtemp(path.join(tmpdir(), "paseo-model-memory-bin-")); + const executable = path.join(binDir, "provider-node"); + await symlink(process.execPath, executable); + const provider = await seedModelProvider({ + id: PROVIDER, + label: "Model memory diagnostic", + extends: "pi", + command: [executable, path.resolve("e2e/fixtures/fake-pi-rpc.mjs")], + models: [{ id: MODEL, label: LABEL, description: "Remembered selection diagnostic" }], + }); + const client = await connectDaemonClient({ clientIdPrefix: "model-memory" }); + try { + await prepareCatalogState(client, executable, workspace.repoPath, hostStatus); + + await startWithoutRememberedModel(page); + await gotoWorkspace(page, workspace.workspaceId); + await waitForSidebarHydration(page); + await openGlobalNewWorkspaceComposer(page); + for (const [index, select] of [chooseModel, reselectModel].entries()) { + const count = index + 1; + await test.step(`create workspace ${count} and return`, async () => { + await select(page, PROVIDER, LABEL); + await expectSavedSelection(page, PROVIDER, MODEL); + await submitNewWorkspacePrompt(page, "Remember this model on the next workspace"); + await expect(page).toHaveURL(/\/workspace\//); + await expectCreatedModelAgents(page, client, PROVIDER, MODEL, count); + await openGlobalNewWorkspaceComposer(page); + await expectSavedSelection(page, PROVIDER, MODEL); + await expectRememberedModel(page, LABEL); + }); + } + await submitNewWorkspaceEmpty(page); + await expect(page).toHaveURL(/\/workspace\//); + await openGlobalNewWorkspaceComposer(page); + await expectSavedSelection(page, PROVIDER, MODEL); + await expectRememberedModel(page, LABEL); + } finally { + await client.close(); + await provider.restore(); + await workspace.cleanup(); + await rm(binDir, { recursive: true, force: true }); + } + }); +} diff --git a/packages/app/e2e/browser/new-workspace-model-recovery.spec.ts b/packages/app/e2e/browser/new-workspace-model-recovery.spec.ts new file mode 100644 index 0000000000..ad291a715d --- /dev/null +++ b/packages/app/e2e/browser/new-workspace-model-recovery.spec.ts @@ -0,0 +1,102 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { test, expect, type Page } from "../support/fixtures"; +import { + closeModelPicker, + drillIntoProvider, + openModelPicker, + seedModelProvider, +} from "../support/helpers/agent-profiles"; +import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { openGlobalNewWorkspaceComposer } from "../support/helpers/new-workspace"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; +import { expectRememberedModel, expectSavedSelection } from "../support/helpers/model-memory"; + +const PROVIDER = "remembered-model-recovery"; +const MODEL = "remembered-model"; +const LABEL = "Remembered model"; + +async function rememberModel(page: Page) { + await page.addInitScript( + ({ provider, model }) => { + localStorage.setItem( + "@paseo:create-agent-preferences", + JSON.stringify({ + provider, + providerPreferences: { [provider]: { model } }, + }), + ); + }, + { provider: PROVIDER, model: MODEL }, + ); +} + +async function expectRecoveredModelInPicker(page: Page) { + await openModelPicker(page); + await page.getByRole("dialog").getByRole("button", { name: "Back", exact: true }).click(); + await drillIntoProvider(page, PROVIDER); + await expect( + page.getByTestId("combobox-desktop-container").getByText(LABEL, { exact: true }), + ).toBeVisible(); + await closeModelPicker(page); +} + +async function setProviderAvailability(client: DaemonClient, cwd: string, available: boolean) { + await client.patchDaemonConfig({ + providers: { + [PROVIDER]: { + command: [available ? process.execPath : "/missing-paseo-diagnostic-provider"], + }, + }, + }); + for (const scope of [undefined, cwd]) { + await expect + .poll( + async () => { + const snapshot = await client.getProvidersSnapshot(scope ? { cwd: scope } : undefined); + return snapshot.entries.find((entry) => entry.provider === PROVIDER)?.status; + }, + { timeout: 30_000 }, + ) + .toBe(available ? "ready" : "unavailable"); + } +} + +test("restores the remembered model when a provider recovers after New workspace opens", async ({ + page, +}) => { + test.setTimeout(120_000); + const workspace = await seedWorkspace({ repoPrefix: "model-recovery-" }); + const provider = await seedModelProvider({ + id: PROVIDER, + label: "Remembered provider", + models: [{ id: MODEL, label: LABEL, description: "Model restoration diagnostic" }], + command: [process.execPath], + }); + const client = await connectDaemonClient({ clientIdPrefix: "model-recovery" }); + try { + await rememberModel(page); + await gotoWorkspace(page, workspace.workspaceId); + await waitForSidebarHydration(page); + await openGlobalNewWorkspaceComposer(page); + await expectRememberedModel(page, LABEL); + + await gotoWorkspace(page, workspace.workspaceId); + await waitForSidebarHydration(page); + await setProviderAvailability(client, workspace.repoPath, false); + await openGlobalNewWorkspaceComposer(page); + await expect( + page.getByTestId("combined-model-selector").filter({ visible: true }), + ).not.toContainText("Loading..."); + + await setProviderAvailability(client, workspace.repoPath, true); + await expectSavedSelection(page, PROVIDER, MODEL); + await expectRecoveredModelInPicker(page); + await expectRememberedModel(page, LABEL); + } finally { + await client.close(); + await provider.restore(); + await workspace.cleanup(); + } +}); diff --git a/packages/app/e2e/support/helpers/model-memory.ts b/packages/app/e2e/support/helpers/model-memory.ts new file mode 100644 index 0000000000..7a41419db0 --- /dev/null +++ b/packages/app/e2e/support/helpers/model-memory.ts @@ -0,0 +1,71 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { expect, type Page } from "../fixtures"; +import { drillIntoProvider, openModelPicker } from "./agent-profiles"; + +export async function startWithoutRememberedModel(page: Page) { + await page.addInitScript(() => localStorage.removeItem("@paseo:create-agent-preferences")); +} + +export async function chooseModel(page: Page, provider: string, label: string) { + await openModelPicker(page); + await expect( + page.getByRole("dialog").getByRole("button", { name: "Back", exact: true }), + ).toHaveCount(0); + await selectProviderModel(page, provider, label); +} + +export async function reselectModel(page: Page, provider: string, label: string) { + await expectRememberedModel(page, label); + await openModelPicker(page); + await page.getByRole("dialog").getByRole("button", { name: "Back", exact: true }).click(); + await selectProviderModel(page, provider, label); +} + +async function selectProviderModel(page: Page, provider: string, label: string) { + await drillIntoProvider(page, provider); + await page.getByTestId("combobox-desktop-container").getByText(label, { exact: true }).click(); + await expect( + page + .getByRole("button", { name: `Select model (${label})`, exact: true }) + .filter({ visible: true }), + ).toBeVisible(); +} + +export async function expectSavedSelection(page: Page, provider: string, model: string) { + await expect + .poll(() => + page.evaluate(() => + JSON.parse(localStorage.getItem("@paseo:create-agent-preferences") ?? "null"), + ), + ) + .toMatchObject({ provider, providerPreferences: { [provider]: { model } } }); +} + +export async function expectCreatedModelAgents( + page: Page, + client: DaemonClient, + provider: string, + model: string, + count: number, +) { + await expect + .poll( + async () => { + const result = await client.fetchAgents({ scope: "active" }); + return result.entries + .filter(({ agent }) => agent.provider === provider) + .map(({ agent }) => agent.model); + }, + { timeout: 60_000 }, + ) + .toEqual(Array(count).fill(model)); + await expect(page.getByTestId(/^workspace-tab-agent_/).filter({ visible: true })).toHaveCount(1); +} + +export async function expectRememberedModel(page: Page, label: string) { + await expect( + page + .getByRole("button", { name: `Select model (${label})`, exact: true }) + .filter({ visible: true }), + ).toBeVisible(); +} diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 29cd7ea0b7..968b721eb2 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -66,25 +66,18 @@ function resolveWorkspaceTitle({ function buildChatDraftComposerArgs({ serverId, - isConnected, workspaceDirectory, sourceDirectory, pendingWorkspaceSetup, }: { serverId: string; - isConnected: boolean; workspaceDirectory: string | undefined; sourceDirectory: string; pendingWorkspaceSetup: { creationMethod: string } | null; }) { return { initialServerId: serverId || null, - initialValues: - workspaceDirectory || sourceDirectory - ? { workingDir: workspaceDirectory || sourceDirectory } - : undefined, isVisible: pendingWorkspaceSetup !== null, - onlineServerIds: isConnected && serverId ? [serverId] : [], lockedWorkingDir: workspaceDirectory || sourceDirectory || undefined, }; } @@ -195,7 +188,6 @@ export function WorkspaceSetupDialog() { draftKey: `workspace-setup:${serverId}:${sourceDirectory}`, composer: buildChatDraftComposerArgs({ serverId, - isConnected, workspaceDirectory: workspace?.workspaceDirectory, sourceDirectory, pendingWorkspaceSetup, diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index 3442703d48..ef26aaf6fe 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -34,8 +34,6 @@ vi.mock("@/attachments/service", () => ({ vi.mock("@/hooks/use-agent-form-state", () => ({ useAgentFormState: () => ({ selectedServerId: "host-1", - setSelectedServerId: () => undefined, - setSelectedServerIdFromUser: () => undefined, selectedProvider: "codex", setProviderFromUser: () => undefined, selectedMode: "auto", @@ -45,8 +43,6 @@ vi.mock("@/hooks/use-agent-form-state", () => ({ selectedThinkingOptionId: "", setThinkingOptionFromUser: () => undefined, workingDir: "/repo", - setWorkingDir: () => undefined, - setWorkingDirFromUser: () => undefined, providerDefinitions: [{ id: "codex", label: "Codex", modes: [{ id: "auto", label: "Auto" }] }], providerDefinitionMap: new Map(), agentDefinition: undefined, @@ -179,9 +175,7 @@ describe("useAgentInputDraft live contract", () => { draftKey, composer: { initialServerId: "host-1", - initialValues: { workingDir: "/repo" }, isVisible: true, - onlineServerIds: ["host-1"], lockedWorkingDir: "/repo", }, }); diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index c26e18696f..2bfaab0745 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -35,7 +35,6 @@ interface AgentInputDraftComposerOptions { initialValues?: CreateAgentInitialValues; initialFeatureValues?: Record; isVisible?: boolean; - onlineServerIds?: string[]; lockedWorkingDir?: string; } @@ -68,12 +67,13 @@ export interface AgentInputDraft { export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft { const composerOptions = input.composer ?? null; + const workingDir = composerOptions?.lockedWorkingDir?.trim() || ""; const formState = useAgentFormState({ - initialServerId: composerOptions?.initialServerId ?? null, + workingDir, + serverId: composerOptions?.initialServerId ?? null, initialValues: composerOptions?.initialValues, isVisible: composerOptions?.isVisible ?? false, isCreateFlow: true, - onlineServerIds: composerOptions?.onlineServerIds ?? [], }); const draftKey = useMemo( () => @@ -214,17 +214,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr }; }, [draftKey, publishTextReplacement]); - const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? ""; - useEffect(() => { - if (!composerOptions || !lockedWorkingDir) { - return; - } - if (formState.workingDir.trim() === lockedWorkingDir) { - return; - } - formState.setWorkingDir(lockedWorkingDir); - }, [composerOptions, formState, lockedWorkingDir]); - const providerSelection = useMemo( () => ({ provider: formState.selectedProvider, @@ -254,7 +243,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr [effectiveModelId, providerSelection], ); - const workingDir = lockedWorkingDir || formState.workingDir; const { features: draftFeatures, featureValues: draftFeatureValues, diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 9f1c2b172c..93c800cbf9 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -16,7 +16,7 @@ import { useAgentInputDraft } from "@/composer/draft/input-draft"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow"; import { resolveTurnPresentation, TURN_LIVENESS_IDLE } from "@/timeline/turn-liveness"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import { useCreateFlowStore } from "@/stores/create-flow-store"; @@ -55,7 +55,6 @@ import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view"; import { useSettings } from "@/hooks/use-settings"; const EMPTY_PENDING_PERMISSIONS = new Map(); -const EMPTY_ONLINE_SERVER_IDS: string[] = []; const DRAFT_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, supportsSessionPersistence: false, @@ -270,17 +269,10 @@ function buildDraftAgentSnapshot(input: { } function buildDraftInitialValues(input: { - workingDir: string | null; initialSetup: WorkspaceDraftTabSetup | null; }): CreateAgentInitialValues | undefined { - if (!input.workingDir) { - return undefined; - } - if (!input.initialSetup) { - return { workingDir: input.workingDir }; - } + if (!input.initialSetup) return undefined; return { - workingDir: input.workingDir, provider: input.initialSetup.provider, modeId: input.initialSetup.modeId, model: input.initialSetup.model, @@ -298,13 +290,6 @@ function resolveDraftWorkingDirectory(input: { return input.workspaceDirectory; } -function resolveOnlineServerIds(input: { isConnected: boolean; serverId: string }): string[] { - if (!input.isConnected) { - return EMPTY_ONLINE_SERVER_IDS; - } - return [input.serverId]; -} - interface WorkspaceDraftAgentTabProps { serverId: string; workspaceId: string; @@ -341,7 +326,6 @@ export function WorkspaceDraftAgentTab({ const { t } = useTranslation(); const insets = useSafeAreaInsets(); const client = useHostRuntimeClient(serverId); - const isConnected = useHostRuntimeIsConnected(serverId); const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({ workspaceDirectory: w.workspaceDirectory, id: w.id, @@ -353,10 +337,8 @@ export function WorkspaceDraftAgentTab({ initialSetup: draftSetup, }); const draftInitialValues = buildDraftInitialValues({ - workingDir: draftWorkingDirectory, initialSetup: draftSetup, }); - const onlineServerIds = resolveOnlineServerIds({ isConnected, serverId }); const draftStoreKey = useMemo( () => buildDraftStoreKey({ @@ -373,7 +355,6 @@ export function WorkspaceDraftAgentTab({ initialValues: draftInitialValues, initialFeatureValues: draftSetup?.featureValues, isVisible: true, - onlineServerIds, lockedWorkingDir: draftWorkingDirectory ?? undefined, }, }); diff --git a/packages/app/src/data/provider-snapshot-cache.test.ts b/packages/app/src/data/provider-snapshot-cache.test.ts index f92e7e9bf9..b69b0f5d52 100644 --- a/packages/app/src/data/provider-snapshot-cache.test.ts +++ b/packages/app/src/data/provider-snapshot-cache.test.ts @@ -2,16 +2,10 @@ import { describe, expect, it } from "vitest"; import { Buffer } from "buffer"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; -import { z } from "zod"; import { createProviderSnapshotCache, type ProviderSnapshotCache } from "./provider-snapshot-cache"; -const SNAPSHOT_KEY_PREFIX = "@paseo/provider-snapshot/v1:"; -const SNAPSHOT_INDEX_KEY = "@paseo/provider-snapshot-index/v1"; -const SnapshotIndexSchema = z.object({ - version: z.literal(1), - entries: z.array(z.object({ key: z.string(), bytes: z.number(), writtenAt: z.string() })), -}); - +const SNAPSHOT_KEY_PREFIX = "@paseo/provider-snapshot/v2:"; +const SNAPSHOT_INDEX_KEY = "@paseo/provider-snapshot-index/v2"; function createStorage(maxSnapshotBytes = Number.POSITIVE_INFINITY) { const values = new Map(); const stats = { getAllKeysCalls: 0 }; @@ -109,21 +103,17 @@ function snapshotBytes(values: Map): number { ); } -function expectIndexMatchesSnapshots(values: Map): void { - const storedIndex = values.get(SNAPSHOT_INDEX_KEY); - if (!storedIndex) throw new Error("Expected provider snapshot index"); - const index = SnapshotIndexSchema.parse(JSON.parse(storedIndex)); - const storedSnapshots = [...values] - .filter(([key]) => key.startsWith(SNAPSHOT_KEY_PREFIX)) - .map(([key, value]) => ({ - key, - bytes: Buffer.byteLength(key, "utf8") + Buffer.byteLength(value, "utf8"), - })) - .toSorted((left, right) => left.key.localeCompare(right.key)); - const indexedSnapshots = index.entries - .map(({ key, bytes }) => ({ key, bytes })) - .toSorted((left, right) => left.key.localeCompare(right.key)); - expect(indexedSnapshots).toEqual(storedSnapshots); +function expectConsistentSnapshots(values: Map): void { + expect(values.has(SNAPSHOT_INDEX_KEY)).toBe(false); + for (const [key, value] of values) { + if (!key.startsWith(SNAPSHOT_KEY_PREFIX)) continue; + const [serverId, kind] = JSON.parse(key.slice(SNAPSHOT_KEY_PREFIX.length)); + if (kind !== "cwd") continue; + const { hash } = JSON.parse(value); + expect(values.has(`${SNAPSHOT_KEY_PREFIX}${JSON.stringify([serverId, "hash", hash])}`)).toBe( + true, + ); + } } function writeSnapshot( @@ -140,6 +130,94 @@ function writeSnapshot( } describe("provider snapshot cache", () => { + it("removes externally lost body references and retains independent orphan bodies on reconciliation", async () => { + const storage = createStorage(); + const cache = createProviderSnapshotCache(storage); + await writeSnapshot(cache, { + cwd: "/lost", + label: "lost", + generatedAt: "2026-09-01T00:00:00.000Z", + }); + await writeSnapshot(cache, { + cwd: "/orphan", + label: "orphan", + generatedAt: "2026-09-02T00:00:00.000Z", + }); + storage.values.delete(`${SNAPSHOT_KEY_PREFIX}["server-1","hash","lost"]`); + storage.values.delete(`${SNAPSHOT_KEY_PREFIX}["server-1","cwd","/orphan"]`); + + await expect(cache.read("server-1", "/lost")).resolves.toBeNull(); + expect(storage.values.has(`${SNAPSHOT_KEY_PREFIX}["server-1","cwd","/lost"]`)).toBe(false); + const restarted = createProviderSnapshotCache(storage); + await expect(restarted.readHash("server-1", "orphan")).resolves.not.toBeNull(); + expectConsistentSnapshots(storage.values); + }); + + it.each(["live", "restart", "late association"])( + "retains recently reused bodies under pressure (%s)", + async (variant) => { + const storage = createStorage(); + const seed = async (cache: ProviderSnapshotCache) => { + await writeSnapshot(cache, { + cwd: "/a", + label: "aaa", + generatedAt: "2026-09-01T00:00:00.000Z", + }); + await writeSnapshot(cache, { + cwd: "/b", + label: "bbb", + generatedAt: "2026-09-02T00:00:00.000Z", + }); + }; + await seed(createProviderSnapshotCache(storage)); + const maxBytes = snapshotBytes(storage.values); + let cache = createProviderSnapshotCache(storage, { maxBytes }); + await writeSnapshot(cache, { + cwd: "/c", + label: "aaa", + generatedAt: "2026-09-03T00:00:00.000Z", + }); + if (variant === "restart") cache = createProviderSnapshotCache(storage, { maxBytes }); + if (variant === "late association") { + await writeSnapshot(cache, { + cwd: "/a", + label: "aaa", + generatedAt: "2026-09-01T00:00:00.000Z", + }); + } + await writeSnapshot(cache, { + cwd: "/d", + label: "ccc", + generatedAt: "2026-09-04T00:00:00.000Z", + }); + + await expect(cache.read("server-1", "/c")).resolves.toMatchObject({ hash: "aaa" }); + await expect(cache.read("server-1", "/d")).resolves.toMatchObject({ hash: "ccc" }); + await expect(cache.readHash("server-1", "bbb")).resolves.toBeNull(); + expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); + expectConsistentSnapshots(storage.values); + }, + ); + + it("keeps a reusable body when its directory association cannot fit beside it", async () => { + const input = { cwd: "/repo", label: "shared", generatedAt: "2026-09-06T12:00:00.000Z" }; + const stagingStorage = createStorage(); + await writeSnapshot(createProviderSnapshotCache(stagingStorage), input); + const maxBytes = snapshotBytes(stagingStorage.values) - 1; + const storage = createStorage(maxBytes); + const cache = createProviderSnapshotCache(storage, { maxBytes }); + + await writeSnapshot(cache, input); + + await expect(cache.readHash("server-1", "shared")).resolves.not.toBeNull(); + await expect(cache.read("server-1", "/repo")).resolves.toBeNull(); + expect([...storage.values.keys()].filter((key) => key.includes('"cwd"'))).toEqual([]); + expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); + expectConsistentSnapshots(storage.values); + const restarted = createProviderSnapshotCache(storage, { maxBytes }); + await expect(restarted.readHash("server-1", "shared")).resolves.not.toBeNull(); + }); + it("round-trips compact snapshots with shared thinking option references", async () => { const thinkingOptions = [{ id: "high", label: "High", isDefault: true }]; const entries: ProviderSnapshotEntry[] = [ @@ -217,7 +295,7 @@ describe("provider snapshot cache", () => { await expect(cache.read("server-1", "/oldest")).resolves.toBeNull(); await expect(cache.read("server-1", "/newest")).resolves.not.toBeNull(); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); expect(storage.stats.getAllKeysCalls).toBe(1); }); @@ -236,7 +314,7 @@ describe("provider snapshot cache", () => { expect(storage.values.has('@paseo/provider-snapshot/v1:["server-1","/legacy"]')).toBe(false); await expect(cache.read("server-1", "/current")).resolves.not.toBeNull(); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); }); it("clears unindexed snapshots on the first read", async () => { @@ -247,7 +325,7 @@ describe("provider snapshot cache", () => { await expect(cache.read("server-1", "/legacy")).resolves.toBeNull(); expect(storage.values.has('@paseo/provider-snapshot/v1:["server-1","/legacy"]')).toBe(false); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); expect(storage.stats.getAllKeysCalls).toBe(1); }); @@ -266,7 +344,7 @@ describe("provider snapshot cache", () => { ]); await expect(cache.read("server-1", "/current")).resolves.not.toBeNull(); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); expect(storage.stats.getAllKeysCalls).toBe(1); }); @@ -292,8 +370,8 @@ describe("provider snapshot cache", () => { expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); expect( [...storage.values.keys()].filter((key) => key.startsWith(SNAPSHOT_KEY_PREFIX)), - ).toHaveLength(1); - expectIndexMatchesSnapshots(storage.values); + ).toHaveLength(2); + expectConsistentSnapshots(storage.values); }); it("removes the previous value when a replacement exceeds the entire budget", async () => { @@ -314,10 +392,10 @@ describe("provider snapshot cache", () => { await expect(tinyCache.read("server-1", "/repo")).resolves.toBeNull(); expect(snapshotBytes(storage.values)).toBe(0); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); }); - it("resets corrupted index state before accepting another snapshot", async () => { + it("removes obsolete index data before accepting another snapshot", async () => { const storage = createStorage(); storage.values.set(SNAPSHOT_INDEX_KEY, "corrupt"); storage.values.set(`${SNAPSHOT_KEY_PREFIX}legacy`, "legacy"); @@ -331,7 +409,7 @@ describe("provider snapshot cache", () => { expect(storage.values.has(`${SNAPSHOT_KEY_PREFIX}legacy`)).toBe(false); await expect(cache.read("server-1", "/current")).resolves.not.toBeNull(); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); expect(storage.stats.getAllKeysCalls).toBe(1); }); @@ -361,7 +439,7 @@ describe("provider snapshot cache", () => { }); await expect(cache.read("server-1", "/emoji")).resolves.toBeNull(); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); }); it("keeps hundreds of concurrent writes bounded without rescanning snapshots", async () => { @@ -380,7 +458,7 @@ describe("provider snapshot cache", () => { expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); await expect(cache.read("server-1", "/repo-249")).resolves.not.toBeNull(); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); expect(storage.stats.getAllKeysCalls).toBe(1); }); @@ -394,7 +472,7 @@ describe("provider snapshot cache", () => { label: "stable", generatedAt: "2026-08-01T00:00:00.000Z", }); - const failedKey = `${SNAPSHOT_KEY_PREFIX}["server-1","/failed"]`; + const failedKey = `${SNAPSHOT_KEY_PREFIX}["server-1","cwd","/failed"]`; storage.failNext("setItem", timing, failedKey); await writeSnapshot(cache, { @@ -412,34 +490,7 @@ describe("provider snapshot cache", () => { await expect(restarted.read("server-1", "/stable")).resolves.not.toBeNull(); await expect(restarted.read("server-1", "/next")).resolves.not.toBeNull(); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(2_000); - expectIndexMatchesSnapshots(storage.values); - }, - ); - - it.each(["before", "after"] as const)( - "reconciles snapshot records when the derived index write fails %s its side effect", - async (timing) => { - const storage = createStorage(); - const cache = createProviderSnapshotCache(storage, { maxBytes: 2_000 }); - await writeSnapshot(cache, { - cwd: "/stable", - label: "stable", - generatedAt: "2026-08-01T00:00:00.000Z", - }); - storage.failNext("setItem", timing, SNAPSHOT_INDEX_KEY); - - await writeSnapshot(cache, { - cwd: "/orphan-after-index-failure", - label: "orphan", - generatedAt: "2026-08-02T00:00:00.000Z", - }); - - const restarted = createProviderSnapshotCache(storage, { maxBytes: 2_000 }); - await expect( - restarted.read("server-1", "/orphan-after-index-failure"), - ).resolves.not.toBeNull(); - expectIndexMatchesSnapshots(storage.values); - expect(storage.stats.getAllKeysCalls).toBe(2); + expectConsistentSnapshots(storage.values); }, ); @@ -481,7 +532,7 @@ describe("provider snapshot cache", () => { expect(storage.values.get("@paseo/unrelated")).toBe("keep-me"); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); await expect(restarted.read("server-1", "/final")).resolves.not.toBeNull(); }); @@ -508,14 +559,14 @@ describe("provider snapshot cache", () => { await expect(cache.read("server-1", "/newest")).resolves.not.toBeNull(); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); }); it.each(["before", "after"] as const)( "recovers when invalid-record cleanup fails %s deletion", async (timing) => { const storage = createStorage(); - const key = `${SNAPSHOT_KEY_PREFIX}["server-1","/invalid"]`; + const key = `${SNAPSHOT_KEY_PREFIX}["server-1","cwd","/invalid"]`; storage.values.set(key, "invalid"); storage.failNext("multiRemove", timing); const cache = createProviderSnapshotCache(storage); @@ -529,7 +580,165 @@ describe("provider snapshot cache", () => { expect(storage.values.has(key)).toBe(false); await expect(cache.read("server-1", "/recovered")).resolves.not.toBeNull(); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); }, ); }); + +it("stores and expands identical content once across directory associations", async () => { + const storage = createStorage(); + const cache = createProviderSnapshotCache(storage); + for (const cwd of ["/a", "/b", "/c"]) { + await writeSnapshot(cache, { cwd, label: "shared", generatedAt: "2026-09-06T12:00:00.000Z" }); + } + const a = await cache.read("server-1", "/a"); + const b = await cache.read("server-1", "/b"); + expect(a?.entries[0]?.models).toBe(b?.entries[0]?.models); + expect( + [...storage.values.values()].filter((value) => value.includes('"compactSnapshot"')), + ).toHaveLength(1); +}); + +it("coalesces missing bodies and keeps a newer pushed association when an older pull completes", async () => { + const { QueryClient } = await import("@tanstack/react-query"); + const { applyProvidersSnapshotUpdate } = await import("./push-router"); + const { fetchProvidersSnapshot, providersSnapshotQueryKey } = + await import("./providers-snapshot"); + const storage = createStorage(); + const cache = createProviderSnapshotCache(storage); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const serverId = "shared-host"; + const snapshot = (hash: string, fetchedAt = "2026-09-06T12:00:00.000Z") => ({ + entries: [], + snapshotHash: hash, + compactSnapshot: compactProviderSnapshot(snapshotEntries(hash)), + generatedAt: fetchedAt, + fetchedAt: { pi: fetchedAt }, + requestId: hash, + }); + let transfers = 0; + const client = { + async getProvidersSnapshot() { + transfers++; + return snapshot("shared"); + }, + }; + const announce = (cwd: string, hash: string, fetchedAt = "2026-09-06T12:00:00.000Z") => + applyProvidersSnapshotUpdate({ + serverId, + queryClient, + cache, + client, + message: { + type: "providers_snapshot_update", + payload: { + cwd, + entries: [], + snapshotHash: hash, + generatedAt: fetchedAt, + fetchedAt: { pi: fetchedAt }, + }, + }, + }); + try { + await Promise.all(["/a", "/b", "/c"].map((cwd) => announce(cwd, "shared"))); + expect(transfers).toBe(1); + const first = await cache.read(serverId, "/a"); + const second = await cache.read(serverId, "/b"); + expect(first!.entries[0]!.models).toBe(second!.entries[0]!.models); + expect( + queryClient.getQueryData<{ compactSnapshot: unknown }>( + providersSnapshotQueryKey(serverId, "/a"), + )!.compactSnapshot, + ).toBe(first!.compactSnapshot); + expect( + [...storage.values.values()].filter((value) => value.includes('"compactSnapshot"')), + ).toHaveLength(1); + await Promise.all( + ["/a", "/b", "/c"].map((cwd) => announce(cwd, "shared", "2026-09-06T13:00:00.000Z")), + ); + expect(transfers).toBe(1); + expect((await cache.read(serverId, "/a"))!.entries[0]!.fetchedAt).toBe( + "2026-09-06T13:00:00.000Z", + ); + + await queryClient.refetchQueries({ + queryKey: providersSnapshotQueryKey(serverId, "/b"), + exact: true, + }); + expect(transfers).toBe(2); + + let release!: (value: ReturnType) => void; + let began!: () => void; + const started = new Promise((resolve) => { + began = resolve; + }); + const oldClient = { + getProvidersSnapshot() { + began(); + return new Promise>((resolve) => { + release = resolve; + }); + }, + }; + const queryKey = providersSnapshotQueryKey(serverId, "/a"); + const old = queryClient.fetchQuery({ + queryKey, + staleTime: 0, + queryFn: ({ signal }) => + fetchProvidersSnapshot({ + serverId, + cwd: "/a", + queryClient, + cache, + client: oldClient, + signal, + }), + }); + const cancelled = old.catch(() => undefined); + await started; + await applyProvidersSnapshotUpdate({ + serverId, + queryClient, + cache, + client, + message: { type: "providers_snapshot_update", payload: { ...snapshot("new"), cwd: "/a" } }, + }); + release(snapshot("old")); + await cancelled; + // Drain the same public cache queue after the old transport finishes. + await cache.read(serverId, "/a"); + expect((await cache.read(serverId, "/a"))!.hash).toBe("new"); + expect(queryClient.getQueryData(queryKey)).toMatchObject({ + snapshotHash: "new", + entries: [{ models: [{ id: "new" }] }], + }); + expect((await cache.read(serverId, "/b"))!.hash).toBe("shared"); + + const restarted = createProviderSnapshotCache(storage); + expect((await restarted.read(serverId, "/a"))!.hash).toBe("new"); + const evicted = createProviderSnapshotCache(storage, { maxBytes: 1 }); + expect(await evicted.read(serverId, "/b")).toBeNull(); + await applyProvidersSnapshotUpdate({ + serverId, + queryClient, + cache: evicted, + client, + message: { + type: "providers_snapshot_update", + payload: { + cwd: "/b", + entries: [], + snapshotHash: "shared", + generatedAt: "2026-09-06T14:00:00.000Z", + }, + }, + }); + expect(transfers).toBe(3); + expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, "/b"))).toMatchObject({ + entries: [{ models: [{ id: "shared" }] }], + }); + } finally { + queryClient.clear(); + } +}); diff --git a/packages/app/src/data/provider-snapshot-cache.ts b/packages/app/src/data/provider-snapshot-cache.ts index ed4823a39c..04a10303e3 100644 --- a/packages/app/src/data/provider-snapshot-cache.ts +++ b/packages/app/src/data/provider-snapshot-cache.ts @@ -7,11 +7,12 @@ import { } from "@getpaseo/protocol/provider-snapshot-codec"; import { CompactProviderSnapshotSchema } from "@getpaseo/protocol/messages"; import { z } from "zod"; +import type { GetProvidersSnapshotResponseMessage } from "@getpaseo/protocol/messages"; +type SnapshotPayload = GetProvidersSnapshotResponseMessage["payload"]; -const CACHE_VERSION = 1; -const CACHE_KEY_PREFIX = "@paseo/provider-snapshot/v1"; -const CACHE_INDEX_KEY = "@paseo/provider-snapshot-index/v1"; -const CACHE_INDEX_VERSION = 1; +const CACHE_VERSION = 2; +const CACHE_KEY_PREFIX = "@paseo/provider-snapshot/v2"; +const CACHE_INDEX_KEY = "@paseo/provider-snapshot-index/v2"; const DEFAULT_MAX_CACHE_BYTES = 4 * 1024 * 1024; interface ProviderSnapshotStorage { @@ -31,7 +32,8 @@ interface StoredProviderSnapshot { version: typeof CACHE_VERSION; hash: string; generatedAt: string; - compactSnapshot: CompactProviderSnapshot; + compactSnapshot?: CompactProviderSnapshot; + fetchedAt?: Record; } interface ProviderSnapshotIndexEntry { @@ -40,30 +42,31 @@ interface ProviderSnapshotIndexEntry { writtenAt: string; } -interface ProviderSnapshotIndex { - version: typeof CACHE_INDEX_VERSION; - entries: ProviderSnapshotIndexEntry[]; -} - const StoredProviderSnapshotSchema: z.ZodType = z.strictObject({ version: z.literal(CACHE_VERSION), hash: z.string(), generatedAt: z.string().datetime({ offset: true }), - compactSnapshot: CompactProviderSnapshotSchema, + compactSnapshot: CompactProviderSnapshotSchema.optional(), + fetchedAt: z.record(z.string(), z.string()).optional(), }); export interface CachedProviderSnapshot extends StoredProviderSnapshot { + compactSnapshot: CompactProviderSnapshot; entries: ProviderSnapshotEntry[]; } export interface ProviderSnapshotCache { read(serverId: string, cwd: string | null): Promise; + readHash(serverId: string, hash: string): Promise; + materialize(serverId: string, snapshot: SnapshotPayload): Promise; write(input: { serverId: string; cwd: string | null; hash: string; generatedAt: string; compactSnapshot: CompactProviderSnapshot; + fetchedAt?: Record; + signal?: AbortSignal; }): Promise; } @@ -75,22 +78,41 @@ export class ProviderSnapshotCacheMissError extends Error { } function cacheKey(serverId: string, cwd: string | null): string { - return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, cwd])}`; + return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, "cwd", cwd])}`; +} + +function bodyKey(serverId: string, hash: string): string { + return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, "hash", hash])}`; } function storedBytes(key: string, value: string): number { return Buffer.byteLength(key, "utf8") + Buffer.byteLength(value, "utf8"); } -function oldestFirst(left: ProviderSnapshotIndexEntry, right: ProviderSnapshotIndexEntry): number { - const writtenAtOrder = left.writtenAt.localeCompare(right.writtenAt); - return writtenAtOrder !== 0 ? writtenAtOrder : left.key.localeCompare(right.key); +const CacheKeySchema = z.tuple([z.string(), z.enum(["cwd", "hash"]), z.string().nullable()]); + +function decodeCacheKey(key: string) { + return CacheKeySchema.parse(JSON.parse(key.slice(CACHE_KEY_PREFIX.length + 1))); } -function parseStoredProviderSnapshot(value: string): StoredProviderSnapshot | null { +function oldestFirst(left: ProviderSnapshotIndexEntry, right: ProviderSnapshotIndexEntry): number { + const writtenAtOrder = left.writtenAt.localeCompare(right.writtenAt); + if (writtenAtOrder !== 0) return writtenAtOrder; + // References must be evicted before equally recent bodies, regardless of key encoding. + const leftKind = decodeCacheKey(left.key)[1]; + const rightKind = decodeCacheKey(right.key)[1]; + if (leftKind !== rightKind) return leftKind === "cwd" ? -1 : 1; + return left.key.localeCompare(right.key); +} + +function parseStoredProviderSnapshot(key: string, value: string): StoredProviderSnapshot | null { const parsed: unknown = JSON.parse(value); const result = StoredProviderSnapshotSchema.safeParse(parsed); - return result.success ? result.data : null; + if (!result.success) return null; + const [, kind, identity] = decodeCacheKey(key); + const stored = result.data; + if (kind === "hash") return stored.compactSnapshot && identity === stored.hash ? stored : null; + return stored.compactSnapshot ? null : stored; } export function createProviderSnapshotCache( @@ -98,50 +120,76 @@ export function createProviderSnapshotCache( options: ProviderSnapshotCacheOptions = {}, ): ProviderSnapshotCache { const maxBytes = options.maxBytes ?? DEFAULT_MAX_CACHE_BYTES; - let index: ProviderSnapshotIndex | null = null; + let index: ProviderSnapshotIndexEntry[] | null = null; let operationQueue = Promise.resolve(); - - async function persistIndex(entries: ProviderSnapshotIndexEntry[]): Promise { - const nextIndex: ProviderSnapshotIndex = { version: CACHE_INDEX_VERSION, entries }; - await storage.setItem(CACHE_INDEX_KEY, JSON.stringify(nextIndex)); - index = nextIndex; - } + const bodies = new Map(); async function reconcileCache(): Promise { - const keys = (await storage.getAllKeys()).filter((key) => - key.startsWith(`${CACHE_KEY_PREFIX}:`), + const allKeys = await storage.getAllKeys(); + // COMPAT(providerSnapshotCache): added in v0.7.2, remove legacy cleanup after 2027-03-06. + const legacyKeys = allKeys.filter( + (key) => + key.startsWith("@paseo/provider-snapshot/v1:") || + key === "@paseo/provider-snapshot-index/v1" || + key === CACHE_INDEX_KEY, ); + if (legacyKeys.length) await storage.multiRemove(legacyKeys); + const keys = allKeys.filter((key) => key.startsWith(`${CACHE_KEY_PREFIX}:`)); const values = await storage.multiGet(keys); const keysToRemove: string[] = []; const entries: ProviderSnapshotIndexEntry[] = []; + const references: Array<{ key: string; body: string; writtenAt: string }> = []; for (const [key, value] of values) { if (value === null) continue; try { - const stored = parseStoredProviderSnapshot(value); + const stored = parseStoredProviderSnapshot(key, value); if (!stored) { keysToRemove.push(key); continue; } + const [serverId, kind] = decodeCacheKey(key); entries.push({ key, bytes: storedBytes(key, value), writtenAt: stored.generatedAt }); + if (kind === "cwd") + references.push({ + key, + body: bodyKey(serverId, stored.hash), + writtenAt: stored.generatedAt, + }); } catch (error) { - if (!(error instanceof SyntaxError) && !(error instanceof RangeError)) throw error; + if ( + !(error instanceof SyntaxError) && + !(error instanceof RangeError) && + !(error instanceof z.ZodError) + ) + throw error; keysToRemove.push(key); } } - let totalBytes = entries.reduce((total, entry) => total + entry.bytes, 0); - const retainedEntries = [...entries].sort(oldestFirst); + // Reconstruct the dependency ordering before applying a possibly smaller budget. + for (const reference of references) { + const body = entries.find((entry) => entry.key === reference.body); + if (!body) keysToRemove.push(reference.key); + else if (body.writtenAt < reference.writtenAt) body.writtenAt = reference.writtenAt; + } + const retainedEntries = entries + .filter((entry) => !keysToRemove.includes(entry.key)) + .sort(oldestFirst); + let totalBytes = retainedEntries.reduce((total, entry) => total + entry.bytes, 0); while (totalBytes > maxBytes) { const evicted = retainedEntries.shift(); if (!evicted) break; totalBytes -= evicted.bytes; keysToRemove.push(evicted.key); } - if (keysToRemove.length > 0) await storage.multiRemove(keysToRemove); - await persistIndex(retainedEntries); + if (keysToRemove.length > 0) { + for (const removedKey of keysToRemove) bodies.delete(removedKey); + await storage.multiRemove(keysToRemove); + } + index = retainedEntries; } - async function ensureCacheIndex(): Promise { + async function ensureCacheIndex(): Promise { if (index === null) await reconcileCache(); if (index === null) throw new Error("Provider snapshot cache reconciliation failed"); return index; @@ -158,83 +206,210 @@ export function createProviderSnapshotCache( return result; } - async function writeSnapshot(input: { - serverId: string; - cwd: string | null; - hash: string; - generatedAt: string; - compactSnapshot: CompactProviderSnapshot; - }): Promise { + async function writeRecord( + key: string, + input: StoredProviderSnapshot, + requiredBody?: { key: string; input: StoredProviderSnapshot }, + ): Promise { const currentIndex = await ensureCacheIndex(); - const key = cacheKey(input.serverId, input.cwd); - const stored = StoredProviderSnapshotSchema.safeParse({ - version: CACHE_VERSION, - hash: input.hash, - generatedAt: input.generatedAt, - compactSnapshot: input.compactSnapshot, - }); - if (!stored.success) { - await storage.removeItem(key); - await persistIndex(currentIndex.entries.filter((entry) => entry.key !== key)); - return; + function prepare(recordKey: string, record: StoredProviderSnapshot) { + const value = JSON.stringify(record); + return { + key: recordKey, + bytes: storedBytes(recordKey, value), + writtenAt: record.generatedAt, + value, + }; } - const value = JSON.stringify(stored.data); - const incomingBytes = storedBytes(key, value); - const canStoreIncoming = incomingBytes <= maxBytes; - const retainedEntries = currentIndex.entries.filter((entry) => entry.key !== key); - let projectedBytes = retainedEntries.reduce((total, entry) => total + entry.bytes, 0); - if (canStoreIncoming) projectedBytes += incomingBytes; - - const keysToRemove: string[] = []; + const incoming = prepare(key, input); + // The index is committed last: membership proves a body was stored. Reusing it + // needs only its byte count, not another parse, expansion, or storage write. + const body = requiredBody + ? (currentIndex.find((entry) => entry.key === requiredBody.key) ?? + prepare(requiredBody.key, requiredBody.input)) + : undefined; + let admitted: Array = body + ? [body, incoming] + : [incoming]; + if (admitted.reduce((bytes, entry) => bytes + entry.bytes, 0) > maxBytes) { + admitted = body && body.bytes <= maxBytes ? [body] : []; + } else if (body) { + admitted = [ + { + ...body, + writtenAt: body.writtenAt > input.generatedAt ? body.writtenAt : input.generatedAt, + }, + incoming, + ]; + } + const replacedKeys = new Set([key, ...(requiredBody ? [requiredBody.key] : [])]); + const retainedEntries = currentIndex.filter((entry) => !replacedKeys.has(entry.key)); + let projectedBytes = [...retainedEntries, ...admitted].reduce( + (total, entry) => total + entry.bytes, + 0, + ); + const keysToRemove = [...replacedKeys].filter( + (replaced) => !admitted.some((entry) => entry.key === replaced), + ); for (const candidate of [...retainedEntries].sort(oldestFirst)) { if (projectedBytes <= maxBytes) break; projectedBytes -= candidate.bytes; keysToRemove.push(candidate.key); } const removedKeys = new Set(keysToRemove); - const nextEntries = retainedEntries.filter((entry) => !removedKeys.has(entry.key)); + if (keysToRemove.length > 0) { + for (const removedKey of keysToRemove) bodies.delete(removedKey); + await storage.multiRemove(keysToRemove); + } + // Body, then reference, then in-memory index. A failed write can leave a reusable + // orphan body; reconciliation never retains a reference without its body. + for (const entry of admitted) { + if (entry.value !== undefined) await storage.setItem(entry.key, entry.value); + } + index = [ + ...retainedEntries.filter((entry) => !removedKeys.has(entry.key)), + ...admitted.map(({ key: recordKey, bytes, writtenAt }) => ({ + key: recordKey, + bytes, + writtenAt, + })), + ]; + } - if (!canStoreIncoming && currentIndex.entries.some((entry) => entry.key === key)) { - keysToRemove.push(key); + async function readRecord(key: string): Promise { + const currentIndex = await ensureCacheIndex(); + const value = await storage.getItem(key); + if (value === null) return null; + try { + const stored = parseStoredProviderSnapshot(key, value); + if (!stored) throw new SyntaxError("Invalid provider snapshot"); + return stored; + } catch (error) { + if (!(error instanceof SyntaxError) && !(error instanceof RangeError)) throw error; + bodies.delete(key); + await storage.removeItem(key); + index = currentIndex.filter((entry) => entry.key !== key); + return null; } - if (keysToRemove.length > 0) await storage.multiRemove(keysToRemove); - if (canStoreIncoming) { - await storage.setItem(key, value); - nextEntries.push({ key, bytes: incomingBytes, writtenAt: input.generatedAt }); + } + + async function readBody(serverId: string, hash: string): Promise { + const key = bodyKey(serverId, hash); + const resident = bodies.get(key); + if (resident) return resident; + const stored = await readRecord(key); + if (!stored?.compactSnapshot) return null; + try { + const body = { + ...stored, + compactSnapshot: stored.compactSnapshot, + entries: expandProviderSnapshot(stored.compactSnapshot), + }; + bodies.set(key, body); + return body; + } catch (error) { + if (!(error instanceof RangeError)) throw error; + await storage.removeItem(key); + index = null; + return null; + } + } + + async function safely(operation: () => Promise, fallback: T): Promise { + try { + return await runSerialized(operation); + } catch (error) { + if (!(error instanceof Error)) throw error; + bodies.clear(); + return fallback; } - await persistIndex(nextEntries); } return { - async read(serverId, cwd) { - try { - return await runSerialized(async () => { - const currentIndex = await ensureCacheIndex(); - const key = cacheKey(serverId, cwd); - const value = await storage.getItem(key); - if (value === null) return null; - try { - const stored = parseStoredProviderSnapshot(value); - if (!stored) throw new SyntaxError("Invalid provider snapshot"); - return { ...stored, entries: expandProviderSnapshot(stored.compactSnapshot) }; - } catch (error) { - if (!(error instanceof SyntaxError) && !(error instanceof RangeError)) throw error; - await storage.removeItem(key); - await persistIndex(currentIndex.entries.filter((entry) => entry.key !== key)); - return null; - } + async materialize(serverId, snapshot) { + const { snapshotHash: hash, compactSnapshot } = snapshot; + if (!hash) return snapshot; + let body = await safely(async () => { + const cached = await readBody(serverId, hash); + if (cached) return cached; + if (!compactSnapshot) throw new ProviderSnapshotCacheMissError(); + await writeRecord(bodyKey(serverId, hash), { + version: CACHE_VERSION, + hash, + generatedAt: snapshot.generatedAt, + compactSnapshot, }); - } catch (error) { - if (error instanceof Error) return null; - throw error; + return await readBody(serverId, hash); + }, null); + if (!body) { + if (!compactSnapshot) throw new ProviderSnapshotCacheMissError(); + body = { + version: CACHE_VERSION, + hash, + generatedAt: snapshot.generatedAt, + compactSnapshot, + entries: expandProviderSnapshot(compactSnapshot), + }; } + return { + ...snapshot, + compactSnapshot: body.compactSnapshot, + entries: snapshot.fetchedAt + ? body.entries.map((entry) => ({ + ...entry, + fetchedAt: snapshot.fetchedAt![entry.provider], + })) + : body.entries, + }; }, - async write(input) { - try { - await runSerialized(() => writeSnapshot(input)); - } catch (error) { - if (!(error instanceof Error)) throw error; - } + readHash(serverId, hash) { + return safely(() => readBody(serverId, hash), null); + }, + read(serverId, cwd) { + return safely(async () => { + const association = await readRecord(cacheKey(serverId, cwd)); + if (!association) return null; + const body = await readBody(serverId, association.hash); + if (!body) { + // Storage may be removed or corrupted outside this owner's write queue. + await reconcileCache(); + return null; + } + return { + ...body, + ...association, + compactSnapshot: body.compactSnapshot, + entries: association.fetchedAt + ? body.entries.map((entry) => ({ + ...entry, + fetchedAt: association.fetchedAt![entry.provider], + })) + : body.entries, + }; + }, null); + }, + write(input) { + return safely(async () => { + if (input.signal?.aborted) return; + await writeRecord( + cacheKey(input.serverId, input.cwd), + { + version: CACHE_VERSION, + hash: input.hash, + generatedAt: input.generatedAt, + fetchedAt: input.fetchedAt, + }, + { + key: bodyKey(input.serverId, input.hash), + input: { + version: CACHE_VERSION, + hash: input.hash, + generatedAt: input.generatedAt, + compactSnapshot: input.compactSnapshot, + }, + }, + ); + }, undefined); }, }; } diff --git a/packages/app/src/data/providers-snapshot.ts b/packages/app/src/data/providers-snapshot.ts index 5a59be037c..868756ee46 100644 --- a/packages/app/src/data/providers-snapshot.ts +++ b/packages/app/src/data/providers-snapshot.ts @@ -1,3 +1,14 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { GetProvidersSnapshotResponseMessage } from "@getpaseo/protocol/messages"; +import { + providerSnapshotCache, + ProviderSnapshotCacheMissError, + type ProviderSnapshotCache, +} from "./provider-snapshot-cache"; +import { queryClient as singletonQueryClient } from "./query-client"; +import { replaceProviderSnapshotIcons } from "@/components/provider-icon-name"; +import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; @@ -34,3 +45,106 @@ export function providersSnapshotRequestOptions(input: { export function isProvidersSnapshotHomeScope(cwd?: string | null): boolean { return normalizeProvidersSnapshotCwd(cwd) === null; } + +type Snapshot = GetProvidersSnapshotResponseMessage["payload"]; +type SnapshotClient = Pick; + +export async function fetchProvidersSnapshot(input: { + client: SnapshotClient; + serverId: string; + cwd: string | null; + queryClient?: QueryClient; + cache?: ProviderSnapshotCache; + signal?: AbortSignal; + snapshot?: Snapshot; +}): Promise { + const cache = input.cache ?? providerSnapshotCache; + const queryClient = input.queryClient ?? singletonQueryClient; + const cached = input.snapshot ? null : await cache.read(input.serverId, input.cwd); + let snapshot = + input.snapshot ?? + (await input.client.getProvidersSnapshot( + providersSnapshotRequestOptions({ cwd: input.cwd, ifNoneMatch: cached?.hash }), + )); + if (snapshot.snapshotHash && !snapshot.compactSnapshot) { + const known = + cached?.hash === snapshot.snapshotHash + ? cached + : await cache.readHash(input.serverId, snapshot.snapshotHash); + if (known) snapshot = { ...snapshot, compactSnapshot: known.compactSnapshot }; + else if (snapshot.notModified) throw new ProviderSnapshotCacheMissError(); + else { + const hash = snapshot.snapshotHash; + const body = await queryClient.fetchQuery({ + queryKey: ["providerSnapshotContent", input.serverId, hash], + gcTime: 0, + staleTime: 0, + retry: false, + queryFn: async () => { + const response = await input.client.getProvidersSnapshot( + providersSnapshotRequestOptions({ cwd: input.cwd }), + ); + // Materialize before settling so simultaneous directory announcements share + // both the transfer and decoded body through the existing query deduplication. + return cache.materialize(input.serverId, response); + }, + }); + if (body.snapshotHash === hash) { + snapshot = { ...snapshot, compactSnapshot: body.compactSnapshot }; + } else if (normalizeProvidersSnapshotCwd(body.cwd) === input.cwd) { + snapshot = body; + } else { + snapshot = await input.client.getProvidersSnapshot( + providersSnapshotRequestOptions({ cwd: input.cwd }), + ); + } + } + } + input.signal?.throwIfAborted(); + snapshot = await cache.materialize(input.serverId, snapshot); + if (snapshot.compactSnapshot && snapshot.snapshotHash) { + await cache.write({ + serverId: input.serverId, + cwd: input.cwd, + hash: snapshot.snapshotHash, + generatedAt: snapshot.generatedAt, + fetchedAt: snapshot.fetchedAt, + compactSnapshot: snapshot.compactSnapshot, + signal: input.signal, + }); + } + input.signal?.throwIfAborted(); + replaceProviderSnapshotIcons(input.serverId, snapshot.entries); + return snapshot; +} + +export async function refreshAndApplyProvidersSnapshot(input: { + client: SnapshotClient & Pick; + queryClient: QueryClient; + serverId: string; + cwd: string | null; + providers?: AgentProvider[]; + cache?: ProviderSnapshotCache; +}) { + const result = await input.client.refreshProvidersSnapshot( + providersSnapshotRequestOptions(input), + ); + const queryKey = providersSnapshotQueryKey(input.serverId, input.cwd); + await input.queryClient.cancelQueries({ queryKey, exact: true }); + await input.queryClient.fetchQuery({ + queryKey, + staleTime: 0, + queryFn: ({ signal }) => fetchProvidersSnapshot({ ...input, signal }), + }); + void input.queryClient.invalidateQueries({ + queryKey: agentCommandsQueryRoot(input.serverId), + exact: false, + }); + if (isProvidersSnapshotHomeScope(input.cwd)) { + void input.queryClient.invalidateQueries({ + queryKey: providersSnapshotQueryRoot(input.serverId), + exact: false, + }); + } + return result; +} diff --git a/packages/app/src/data/push-router.test.ts b/packages/app/src/data/push-router.test.ts index f468a3f928..7bc9ea2b51 100644 --- a/packages/app/src/data/push-router.test.ts +++ b/packages/app/src/data/push-router.test.ts @@ -96,6 +96,9 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} return { client: { + async getProvidersSnapshot() { + throw new Error("Unexpected snapshot pull"); + }, on, async subscribeCheckoutDiff(cwd, compare, requestOptions) { subscribeCheckoutDiffCalls.push({ @@ -143,7 +146,7 @@ function providerUpdate(generatedAt: string): ProvidersSnapshotUpdateMessage { } describe("server data push router", () => { - it("routes provider snapshot and daemon config payloads until detached", () => { + it("routes provider snapshot and daemon config payloads until detached", async () => { const queryClient = new QueryClient(); const fake = createFakeClient(); const serverId = "server-1"; @@ -157,22 +160,26 @@ describe("server data push router", () => { payload: { status: "daemon_config_changed", config: daemonConfig }, }); - expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId))).toEqual({ - entries: [{ provider: "codex", status: "ready", enabled: true, models: [] }], - generatedAt: "2026-01-01T00:00:00.000Z", - requestId: "providers_snapshot_update", - }); + await expect + .poll(() => queryClient.getQueryData(providersSnapshotQueryKey(serverId))) + .toEqual({ + entries: [{ provider: "codex", status: "ready", enabled: true, models: [] }], + generatedAt: "2026-01-01T00:00:00.000Z", + requestId: "providers_snapshot_update", + }); expect(queryClient.getQueryData(daemonConfigQueryKey(serverId))).toEqual(daemonConfig); expect(queryClient.getQueryState(pairingOfferKey)?.isInvalidated).toBe(true); unmount(); fake.emit(providerUpdate("2026-01-01T00:00:01.000Z")); - expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId))).toEqual({ - entries: [{ provider: "codex", status: "ready", enabled: true, models: [] }], - generatedAt: "2026-01-01T00:00:00.000Z", - requestId: "providers_snapshot_update", - }); + await expect + .poll(() => queryClient.getQueryData(providersSnapshotQueryKey(serverId))) + .toEqual({ + entries: [{ provider: "codex", status: "ready", enabled: true, models: [] }], + generatedAt: "2026-01-01T00:00:00.000Z", + requestId: "providers_snapshot_update", + }); }); it("subscribes active checkout diff queries and writes matching diff events", () => { diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index 74002bac2e..1cdf555874 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -5,13 +5,13 @@ import type { SessionOutboundMessage, } from "@getpaseo/protocol/messages"; import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; -import { replaceProviderSnapshotIcons } from "@/components/provider-icon-name"; import { orderCheckoutDiffFiles } from "@/git/diff-order"; import { daemonConfigQueryKey } from "@/data/daemon-config"; import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing"; -import { providerSnapshotCache, type ProviderSnapshotCache } from "@/data/provider-snapshot-cache"; +import { type ProviderSnapshotCache } from "@/data/provider-snapshot-cache"; import { normalizeProvidersSnapshotCwd, + fetchProvidersSnapshot, providersSnapshotQueryKey, providersSnapshotQueryRoot, } from "@/data/providers-snapshot"; @@ -69,6 +69,7 @@ export interface ServerDataQueryMeta extends Record { export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage; interface ServerDataPushClient { + getProvidersSnapshot: import("@getpaseo/client/internal/daemon-client").DaemonClient["getProvidersSnapshot"]; on( type: TType, handler: (message: Extract) => void, @@ -186,36 +187,40 @@ export function invalidateServerDataQueriesAfterReconnect(input: { } } -export function applyProvidersSnapshotUpdate(input: { +export async function applyProvidersSnapshotUpdate(input: { serverId: string; queryClient: QueryClient; message: ProvidersSnapshotUpdate; + client: Pick; cache?: ProviderSnapshotCache; -}): void { - if (input.message.type !== "providers_snapshot_update") { - return; - } - replaceProviderSnapshotIcons(input.serverId, input.message.payload.entries); - const queryKey = providersSnapshotQueryKey(input.serverId, input.message.payload.cwd); - input.queryClient.setQueryData(queryKey, { - entries: input.message.payload.entries, - generatedAt: input.message.payload.generatedAt, - requestId: "providers_snapshot_update", +}): Promise { + const snapshot = { ...input.message.payload, requestId: "providers_snapshot_update" }; + const cwd = normalizeProvidersSnapshotCwd(snapshot.cwd); + const queryKey = providersSnapshotQueryKey(input.serverId, cwd); + const previous = input.queryClient.getQueryData<{ snapshotHash?: string }>(queryKey); + let announcement: typeof snapshot | undefined = snapshot; + void input.queryClient.cancelQueries({ queryKey, exact: true }); + await input.queryClient.fetchQuery({ + queryKey, + staleTime: 0, + retry: false, + queryFn: ({ signal }) => { + const incoming = announcement; + announcement = undefined; + return fetchProvidersSnapshot({ + ...input, + cwd, + snapshot: incoming, + signal, + }); + }, }); - const { compactSnapshot, snapshotHash } = input.message.payload; - if (compactSnapshot && snapshotHash) { - void (input.cache ?? providerSnapshotCache).write({ - serverId: input.serverId, - cwd: normalizeProvidersSnapshotCwd(input.message.payload.cwd), - hash: snapshotHash, - generatedAt: input.message.payload.generatedAt, - compactSnapshot, + if (!snapshot.snapshotHash || previous?.snapshotHash !== snapshot.snapshotHash) { + void input.queryClient.invalidateQueries({ + queryKey: agentCommandsQueryRoot(input.serverId), + exact: false, }); } - void input.queryClient.invalidateQueries({ - queryKey: agentCommandsQueryRoot(input.serverId), - exact: false, - }); } export function mountServerDataPushRouter(input: PushRouterInput): () => void { @@ -285,10 +290,13 @@ export function mountServerDataPushRouter(input: PushRouterInput): () => void { reconcileSubscriptions(); }); const unsubscribeProviders = input.client.on("providers_snapshot_update", (message) => { - applyProvidersSnapshotUpdate({ + void applyProvidersSnapshotUpdate({ + client: input.client, queryClient: input.queryClient, serverId: input.serverId, message, + }).catch(() => { + /* Query state owns fetch failures; reconnect/refetch repairs them. */ }); }); const unsubscribeDaemonConfig = input.client.on("status", (message) => { diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 456ce249c2..26908bd6a0 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -6,7 +6,6 @@ import type { AgentProvider, ProviderSnapshotEntry, } from "@getpaseo/protocol/agent-types"; -import { useHosts } from "@/runtime/host-runtime"; import { buildProviderDefinitions } from "@/utils/provider-definitions"; import { buildSelectableProviderSelectorProviders, @@ -27,7 +26,6 @@ import { normalizeSelectedModelId, resolveDefaultModelId, mergeSelectedComposerPreferences, - combineInitialValues, buildProviderDefinitionMap, buildProviderDefinitionMapForStatuses, INITIAL_AGENT_FORM_RESOLUTION, @@ -43,18 +41,15 @@ import type { MaterializedAgentProfile } from "@/agent-profiles"; export type { FormInitialValues } from "@/provider-selection/resolve-agent-form"; export interface UseAgentFormStateOptions { - initialServerId?: string | null; + serverId: string | null; + workingDir: string; initialValues?: FormInitialValues; isVisible?: boolean; isCreateFlow?: boolean; - isTargetDaemonReady?: boolean; - onlineServerIds?: string[]; } export interface UseAgentFormStateResult { selectedServerId: string | null; - setSelectedServerId: (value: string | null) => void; - setSelectedServerIdFromUser: (value: string | null) => void; selectedProvider: AgentProvider | null; selectedMode: string; setModeFromUser: (modeId: string) => void; @@ -63,8 +58,6 @@ export interface UseAgentFormStateResult { selectedThinkingOptionId: string; setThinkingOptionFromUser: (thinkingOptionId: string) => void; workingDir: string; - setWorkingDir: (value: string) => void; - setWorkingDirFromUser: (value: string) => void; providerDefinitions: AgentProviderDefinition[]; providerDefinitionMap: Map; agentDefinition?: AgentProviderDefinition; @@ -87,59 +80,6 @@ export interface UseAgentFormStateResult { persistFormPreferences: () => Promise; } -function shouldAutoSelectServerId(input: { - isVisible: boolean; - isCreateFlow: boolean; - isPreferencesLoading: boolean; - userModifiedServerId: boolean; - initialServerId: string | null | undefined; - currentServerId: string | null; -}): boolean { - const { - isVisible, - isCreateFlow, - isPreferencesLoading, - userModifiedServerId, - initialServerId, - currentServerId, - } = input; - if (!isVisible || !isCreateFlow) return false; - if (isPreferencesLoading) return false; - if (userModifiedServerId) return false; - if (initialServerId !== undefined) return false; - if (currentServerId) return false; - return true; -} - -function resolutionIntentKeyPart(value: string | null | undefined): string { - if (value === undefined) return "undefined"; - if (value === null) return "null"; - return value; -} - -function buildResolutionIntentKey(initialValues: FormInitialValues | undefined): string { - if (!initialValues) return "none"; - // workingDir seeds the open request, but locked cwd updates must not re-run - // provider/model/mode resolution after the form has settled. - return [ - resolutionIntentKeyPart(initialValues.serverId), - resolutionIntentKeyPart(initialValues.provider), - resolutionIntentKeyPart(initialValues.modeId), - resolutionIntentKeyPart(initialValues.model), - resolutionIntentKeyPart(initialValues.thinkingOptionId), - ].join("\n"); -} - -function hasSnapshotDataForResolution(input: { - serverId: string | null; - snapshotEntries: ProviderSnapshotEntry[] | undefined; -}): boolean { - if (!input.serverId) { - return false; - } - return input.snapshotEntries !== undefined; -} - function resolveSelectedProviderModes(input: { selectedEntry: ProviderSnapshotEntry | null; provider: AgentProvider | null; @@ -170,7 +110,10 @@ function buildProviderModelsByProvider( ): ProviderModelsByProvider { const map: ProviderModelsByProvider = new Map(); for (const entry of snapshotEntries ?? []) { - map.set(entry.provider, filterSelectableModels(entry.models ?? null)); + map.set( + entry.provider, + entry.status === "ready" ? filterSelectableModels(entry.models ?? null) : null, + ); } return map; } @@ -201,15 +144,8 @@ async function persistProviderPreferences(input: { ); } -export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult { - const { - initialServerId = null, - initialValues, - isVisible = true, - isCreateFlow = true, - isTargetDaemonReady: _isTargetDaemonReady = true, - onlineServerIds = [], - } = options; +export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFormStateResult { + const { serverId, initialValues, workingDir, isVisible = true, isCreateFlow = true } = options; const { preferences, isLoading: isPreferencesLoading, updatePreferences } = useFormPreferences(); const preferenceOverlayRef = useRef(new OptimisticFormPreferences(preferences)); @@ -235,32 +171,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg [updatePreferences], ); - const daemons = useHosts(); - - const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]); - - const [{ form: formState, userModified, resolution }, dispatch] = useReducer( - resolveAgentForm, - initialServerId, - (serverId) => ({ - form: { - serverId, - provider: null, - modeId: "", - model: "", - thinkingOptionId: "", - workingDir: "", - }, - userModified: INITIAL_USER_MODIFIED, - resolution: INITIAL_AGENT_FORM_RESOLUTION, - }), - ); - - useEffect(() => { - if (!isVisible) { - dispatch({ type: "RESET" }); - } - }, [isVisible]); + const [{ form: formState, userModified, resolution }, dispatch] = useReducer(resolveAgentForm, { + form: { provider: null, modeId: "", model: "", thinkingOptionId: "" }, + userModified: INITIAL_USER_MODIFIED, + resolution: INITIAL_AGENT_FORM_RESOLUTION, + }); const { entries: snapshotEntries, @@ -269,7 +184,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg error: snapshotError, refresh: refreshSnapshot, refetchIfStale: refetchSnapshotIfStale, - } = useProvidersSnapshot(formState.serverId, { cwd: formState.workingDir }); + } = useProvidersSnapshot(serverId, { cwd: workingDir }); const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]); const snapshotProviderDefinitions = useMemo( @@ -335,91 +250,31 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg resolution.status === "pending" || snapshotIsLoading || selectedProviderIsLoading; const isAllModelsLoading = isModelSelectionLoading; - const combinedInitialValues = useMemo( - () => combineInitialValues(initialValues, initialServerId), - [initialValues, initialServerId], - ); - const resolutionIntentKey = useMemo( - () => buildResolutionIntentKey(combinedInitialValues), - [combinedInitialValues], - ); - useEffect(() => { - if (!isVisible || !isCreateFlow) { - return; - } - - dispatch({ type: "REQUEST_RESOLUTION" }); - }, [isVisible, isCreateFlow, resolutionIntentKey]); - - useEffect(() => { - if (!isVisible || !isCreateFlow || resolution.status !== "pending") { - return; - } - if (isPreferencesLoading) { - return; - } - if ( - !hasSnapshotDataForResolution({ - serverId: formState.serverId, - snapshotEntries, - }) - ) { - return; - } - dispatch({ - type: "COMPLETE_RESOLUTION", - initialValues: combinedInitialValues, + type: "INPUTS_CHANGED", + serverId, + isVisible, + isCreateFlow, + isPreferencesLoading, + hasSnapshot: snapshotEntries !== undefined, + initialValues, preferences, providerModelsByProvider: snapshotProviderModelsByProvider, allowedProviderMap: snapshotResolvableProviderDefinitionMap, }); }, [ - combinedInitialValues, - formState.serverId, + serverId, + isVisible, isCreateFlow, isPreferencesLoading, - isVisible, - preferences, - resolution.status, snapshotEntries, + initialValues, + preferences, snapshotProviderModelsByProvider, snapshotResolvableProviderDefinitionMap, ]); - const onlineServerIdsKey = onlineServerIds.join("|"); - useEffect(() => { - const canAutoSelectServerId = shouldAutoSelectServerId({ - isVisible, - isCreateFlow, - isPreferencesLoading, - userModifiedServerId: userModified.serverId, - initialServerId: combinedInitialValues?.serverId, - currentServerId: formState.serverId, - }); - if (!canAutoSelectServerId) return; - - const candidate = onlineServerIds.find((id) => validServerIds.has(id)) ?? null; - if (!candidate) return; - - dispatch({ type: "AUTO_SELECT_SERVER", candidateServerId: candidate }); - }, [ - combinedInitialValues?.serverId, - isCreateFlow, - isPreferencesLoading, - isVisible, - onlineServerIds, - onlineServerIdsKey, - formState.serverId, - userModified.serverId, - validServerIds, - ]); - - const setSelectedServerIdFromUser = useCallback((value: string | null) => { - dispatch({ type: "SET_SERVER_ID_FROM_USER", value }); - }, []); - const setProviderAndModelFromUser = useCallback( (provider: AgentProvider, modelId: string) => { if (!selectableProviderDefinitionMap.has(provider)) { @@ -579,18 +434,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg [formState, updateCurrentPreferences], ); - const setWorkingDir = useCallback((value: string) => { - dispatch({ type: "SET_WORKING_DIR", value }); - }, []); - - const setWorkingDirFromUser = useCallback((value: string) => { - dispatch({ type: "SET_WORKING_DIR_FROM_USER", value }); - }, []); - - const setSelectedServerId = useCallback((value: string | null) => { - dispatch({ type: "SET_SERVER_ID", value }); - }, []); - const refreshProviderModels = useCallback( (provider?: AgentProvider) => { void refreshSnapshot(provider ? [provider] : undefined); @@ -626,13 +469,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const isModelLoading = isModelSelectionLoading; const modelError = snapshotError; - const workingDirIsEmpty = !formState.workingDir.trim(); + const workingDirIsEmpty = !workingDir.trim(); return useMemo( () => ({ - selectedServerId: formState.serverId, - setSelectedServerId, - setSelectedServerIdFromUser, + selectedServerId: serverId, selectedProvider: formState.provider, selectedMode: formState.modeId, setModeFromUser, @@ -640,9 +481,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg setModelFromUser, selectedThinkingOptionId: formState.thinkingOptionId, setThinkingOptionFromUser, - workingDir: formState.workingDir, - setWorkingDir, - setWorkingDirFromUser, + workingDir, providerDefinitions, providerDefinitionMap, agentDefinition, @@ -665,19 +504,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg persistFormPreferences, }), [ - formState.serverId, + serverId, formState.provider, formState.modeId, formState.model, formState.thinkingOptionId, - formState.workingDir, - setSelectedServerId, - setSelectedServerIdFromUser, + workingDir, setModeFromUser, setModelFromUser, setThinkingOptionFromUser, - setWorkingDir, - setWorkingDirFromUser, providerDefinitions, providerDefinitionMap, agentDefinition, diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts index 08eaa5130f..83f62b4812 100644 --- a/packages/app/src/hooks/use-providers-snapshot.test.ts +++ b/packages/app/src/hooks/use-providers-snapshot.test.ts @@ -3,7 +3,10 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; -import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; +import { + compactProviderSnapshot, + expandProviderSnapshot, +} from "@getpaseo/protocol/provider-snapshot-codec"; import type { CachedProviderSnapshot, ProviderSnapshotCache } from "@/data/provider-snapshot-cache"; import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query"; import { resolveProviderIconName } from "@/components/provider-icon-name"; @@ -82,12 +85,28 @@ function codexEntry( const readyCodexModel = { provider: "codex", id: "gpt-5.4", label: "GPT-5.4" } as const; const serverId = "server-1"; -function createCache(initial: CachedProviderSnapshot | null = null): ProviderSnapshotCache & { +function createCache( + initial: CachedProviderSnapshot | null = null, + bodyResident = true, +): ProviderSnapshotCache & { writes: Parameters[0][]; } { const writes: Parameters[0][] = []; return { writes, + async readHash(_serverId, hash) { + return bodyResident && initial?.hash === hash ? initial : null; + }, + async materialize(_serverId, snapshot) { + if (initial && initial.hash === snapshot.snapshotHash) + return { ...snapshot, entries: initial.entries }; + return { + ...snapshot, + entries: snapshot.compactSnapshot + ? expandProviderSnapshot(snapshot.compactSnapshot) + : snapshot.entries, + }; + }, async read() { return initial; }, @@ -98,7 +117,7 @@ function createCache(initial: CachedProviderSnapshot | null = null): ProviderSna } describe("providersSnapshotQueryKey", () => { - it("uses separate keys for home and workspace scopes", () => { + it("uses separate keys for home and workspace scopes", async () => { expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId, "home"]); expect(providersSnapshotQueryKey(serverId, "/repo-a")).toEqual([ "providersSnapshot", @@ -149,16 +168,19 @@ describe("fetchProvidersSnapshot", () => { expect(client.getCalls).toEqual([{ cwd: "/repo-a" }]); }); - it("reuses a cached snapshot when the daemon reports its hash unchanged", async () => { + it("reuses a not-modified response even if its body was evicted during the request", async () => { const entries = [codexEntry("ready", [readyCodexModel])]; const compactSnapshot = compactProviderSnapshot(entries); - const cache = createCache({ - version: 1, - hash: "snapshot-hash", - generatedAt: "2026-01-01T00:00:00.000Z", - compactSnapshot, - entries, - }); + const cache = createCache( + { + version: 2, + hash: "snapshot-hash", + generatedAt: "2026-01-01T00:00:00.000Z", + compactSnapshot, + entries, + }, + false, + ); const client = createClient({ snapshots: [ { @@ -180,7 +202,7 @@ describe("fetchProvidersSnapshot", () => { expect(client.getCalls).toEqual([{ cwd: "/repo-a", ifNoneMatch: "snapshot-hash" }]); expect(snapshot.entries).toBe(entries); - expect(cache.writes).toEqual([]); + expect(cache.writes).toHaveLength(1); }); it("persists a changed compact snapshot for the next launch", async () => { @@ -201,7 +223,7 @@ describe("fetchProvidersSnapshot", () => { await fetchProvidersSnapshot({ client, serverId, cwd: "/repo-a", cache }); - expect(cache.writes).toEqual([ + expect(cache.writes.map(({ signal: _signal, ...write }) => write)).toEqual([ { serverId, cwd: "/repo-a", @@ -342,8 +364,9 @@ describe("applyProvidersSnapshotUpdate", () => { }; } - it("routes updates to the home query cache when the message carries no cwd", () => { - applyProvidersSnapshotUpdate({ + it("routes updates to the home query cache when the message carries no cwd", async () => { + await applyProvidersSnapshotUpdate({ + client: createClient(), serverId, queryClient, message: updateMessage([codexEntry("ready", [readyCodexModel])]), @@ -356,16 +379,17 @@ describe("applyProvidersSnapshotUpdate", () => { }); }); - it("routes workspace updates to the matching scope without touching siblings", () => { + it("routes workspace updates to the matching scope without touching siblings", async () => { queryClient.setQueryData(providersSnapshotQueryKey(serverId, "/repo-b"), providersSnapshot([])); - applyProvidersSnapshotUpdate({ + await applyProvidersSnapshotUpdate({ + client: createClient(), serverId, queryClient, message: updateMessage([codexEntry("ready", [readyCodexModel])], "/repo-a"), }); - expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, "/repo-a"))).toEqual({ + expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, "/repo-a"))).toMatchObject({ entries: [codexEntry("ready", [readyCodexModel])], generatedAt: "2026-01-01T00:00:01.000Z", requestId: "providers_snapshot_update", @@ -375,7 +399,7 @@ describe("applyProvidersSnapshotUpdate", () => { ); }); - it("persists compact push updates", () => { + it("persists compact push updates", async () => { const entries = [codexEntry("ready", [readyCodexModel])]; const compactSnapshot = compactProviderSnapshot(entries); const cache = createCache(); @@ -383,9 +407,15 @@ describe("applyProvidersSnapshotUpdate", () => { message.payload.compactSnapshot = compactSnapshot; message.payload.snapshotHash = "push-hash"; - applyProvidersSnapshotUpdate({ serverId, queryClient, message, cache }); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message, + cache, + }); - expect(cache.writes).toEqual([ + expect(cache.writes.map(({ signal: _signal, ...write }) => write)).toEqual([ { serverId, cwd: "/repo-a", @@ -396,7 +426,7 @@ describe("applyProvidersSnapshotUpdate", () => { ]); }); - it("applies Windows daemon updates to app-normalized workspace paths", () => { + it("applies Windows daemon updates to app-normalized workspace paths", async () => { const workspaceCwd = "C:/Users/Ezekiel Bulver/project"; const daemonCwd = "C:\\Users\\Ezekiel Bulver\\project"; queryClient.setQueryData( @@ -404,27 +434,31 @@ describe("applyProvidersSnapshotUpdate", () => { providersSnapshot([codexEntry("loading")]), ); - applyProvidersSnapshotUpdate({ + await applyProvidersSnapshotUpdate({ + client: createClient(), serverId, queryClient, message: updateMessage([codexEntry("ready", [readyCodexModel])], daemonCwd), }); - expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, workspaceCwd))).toEqual({ + expect( + queryClient.getQueryData(providersSnapshotQueryKey(serverId, workspaceCwd)), + ).toMatchObject({ entries: [codexEntry("ready", [readyCodexModel])], generatedAt: "2026-01-01T00:00:01.000Z", requestId: "providers_snapshot_update", }); }); - it("invalidates cached agent commands when a provider snapshot update arrives", () => { + it("invalidates cached agent commands when a provider snapshot update arrives", async () => { const commandsKey = draftAgentCommandsQueryKey({ serverId, draftConfig: { provider: "codex", cwd: "/repo-a" }, }); queryClient.setQueryData(commandsKey, [{ name: "compact", description: "", argumentHint: "" }]); - applyProvidersSnapshotUpdate({ + await applyProvidersSnapshotUpdate({ + client: createClient(), serverId, queryClient, message: updateMessage([codexEntry("ready", [readyCodexModel])], "/repo-a"), @@ -435,7 +469,7 @@ describe("applyProvidersSnapshotUpdate", () => { }); describe("selectorOpenRefetchDecision", () => { - it("refetches stale entries when no provider is selected", () => { + it("refetches stale entries when no provider is selected", async () => { expect( selectorOpenRefetchDecision({ entries: [codexEntry("ready", [readyCodexModel])], @@ -444,13 +478,13 @@ describe("selectorOpenRefetchDecision", () => { ).toBe("refetch-stale"); }); - it("forces a refetch when the selected provider has no entry", () => { + it("forces a refetch when the selected provider has no entry", async () => { expect(selectorOpenRefetchDecision({ entries: [], selectedProvider: "codex" })).toBe( "refetch-always", ); }); - it("forces a refetch when the selected provider is still loading", () => { + it("forces a refetch when the selected provider is still loading", async () => { expect( selectorOpenRefetchDecision({ entries: [codexEntry("loading")], @@ -459,7 +493,7 @@ describe("selectorOpenRefetchDecision", () => { ).toBe("refetch-always"); }); - it("keeps a stale-only refetch when the selected provider is ready with no models", () => { + it("keeps a stale-only refetch when the selected provider is ready with no models", async () => { expect( selectorOpenRefetchDecision({ entries: [codexEntry("ready", [])], @@ -468,7 +502,7 @@ describe("selectorOpenRefetchDecision", () => { ).toBe("refetch-stale"); }); - it("keeps a stale-only refetch when the selected provider is ready with models", () => { + it("keeps a stale-only refetch when the selected provider is ready with models", async () => { expect( selectorOpenRefetchDecision({ entries: [codexEntry("ready", [readyCodexModel])], diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index 52fff6e36a..f79bcbc065 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo } from "react"; -import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import type { AgentProvider, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -9,92 +9,25 @@ import { useSessionStore } from "@/stores/session-store"; import { useReplicaQuery } from "@/data/query"; import { queryClient as singletonQueryClient } from "@/data/query-client"; import { - providerSnapshotCache, - ProviderSnapshotCacheMissError, - type ProviderSnapshotCache, -} from "@/data/provider-snapshot-cache"; -import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; -import { - isProvidersSnapshotHomeScope, normalizeProvidersSnapshotCwd, providersSnapshotQueryKey, providersSnapshotQueryRoot, - providersSnapshotRequestOptions, + fetchProvidersSnapshot, + refreshAndApplyProvidersSnapshot, } from "@/data/providers-snapshot"; -import { replaceProviderSnapshotIcons } from "@/components/provider-icon-name"; -type GetProvidersSnapshotResult = Awaited>; -type RefreshProvidersSnapshotResult = Awaited>; - -export { providersSnapshotQueryKey, providersSnapshotQueryRoot }; +export { + providersSnapshotQueryKey, + providersSnapshotQueryRoot, + fetchProvidersSnapshot, + refreshAndApplyProvidersSnapshot, +}; export type ProvidersSnapshotClient = Pick< DaemonClient, "getProvidersSnapshot" | "refreshProvidersSnapshot" >; -export async function fetchProvidersSnapshot(input: { - client: ProvidersSnapshotClient; - serverId: string; - cwd: string | null; - cache?: ProviderSnapshotCache; -}): Promise { - const cache = input.cache ?? providerSnapshotCache; - const cached = await cache.read(input.serverId, input.cwd); - const snapshot = await input.client.getProvidersSnapshot( - providersSnapshotRequestOptions({ cwd: input.cwd, ifNoneMatch: cached?.hash }), - ); - if (snapshot.notModified) { - if (!cached) { - throw new ProviderSnapshotCacheMissError(); - } - replaceProviderSnapshotIcons(input.serverId, cached.entries); - return { ...snapshot, entries: cached.entries }; - } - replaceProviderSnapshotIcons(input.serverId, snapshot.entries); - if (snapshot.compactSnapshot && snapshot.snapshotHash) { - await cache.write({ - serverId: input.serverId, - cwd: input.cwd, - hash: snapshot.snapshotHash, - generatedAt: snapshot.generatedAt, - compactSnapshot: snapshot.compactSnapshot, - }); - } - return snapshot; -} - -export async function refreshAndApplyProvidersSnapshot(input: { - client: ProvidersSnapshotClient; - queryClient: QueryClient; - serverId: string; - cwd: string | null; - providers?: AgentProvider[]; - cache?: ProviderSnapshotCache; -}): Promise { - const refreshResult = await input.client.refreshProvidersSnapshot( - providersSnapshotRequestOptions({ cwd: input.cwd, providers: input.providers }), - ); - const snapshot = await fetchProvidersSnapshot({ - client: input.client, - serverId: input.serverId, - cwd: input.cwd, - cache: input.cache, - }); - input.queryClient.setQueryData(providersSnapshotQueryKey(input.serverId, input.cwd), snapshot); - void input.queryClient.invalidateQueries({ - queryKey: agentCommandsQueryRoot(input.serverId), - exact: false, - }); - if (isProvidersSnapshotHomeScope(input.cwd)) { - void input.queryClient.invalidateQueries({ - queryKey: providersSnapshotQueryRoot(input.serverId), - exact: false, - }); - } - return refreshResult; -} - export type SelectorOpenRefetchDecision = "refetch-stale" | "refetch-always"; export function selectorOpenRefetchDecision(input: { @@ -148,11 +81,11 @@ export function useProvidersSnapshot( queryKey, enabled: Boolean(enabled && supportsSnapshot && serverId && client && isConnected), pushEvent: "providers_snapshot_update", - queryFn: async () => { + queryFn: async ({ signal }) => { if (!client || !serverId) { throw new Error(t("workspace.terminal.hostDisconnected")); } - return fetchProvidersSnapshot({ client, serverId, cwd }); + return fetchProvidersSnapshot({ client, serverId, cwd, queryClient, signal }); }, }); @@ -216,6 +149,7 @@ export function prefetchProvidersSnapshot( void singletonQueryClient.prefetchQuery({ queryKey, staleTime: Infinity, - queryFn: () => fetchProvidersSnapshot({ client, serverId, cwd }), + queryFn: ({ signal }) => + fetchProvidersSnapshot({ client, serverId, cwd, queryClient: singletonQueryClient, signal }), }); } diff --git a/packages/app/src/provider-selection/resolve-agent-form.test.ts b/packages/app/src/provider-selection/resolve-agent-form.test.ts index 5b6cc2a509..3912d87e13 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.test.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest"; import { resolveAgentForm, resolveFormState, + resolveEffectiveModel, resolveThinkingOptionId, mergeSelectedComposerPreferences, - combineInitialValues, buildProviderDefinitionMap, buildProviderDefinitionMapForStatuses, resolveDefaultModel, @@ -90,12 +90,11 @@ function makeState( ): AgentFormReducerState { return { form: { - serverId: null, provider: null, modeId: "", model: "", thinkingOptionId: "", - workingDir: "", + ...overrides, }, userModified: { ...INITIAL_USER_MODIFIED, ...modified }, @@ -262,35 +261,6 @@ describe("resolveThinkingOptionId", () => { }); }); -describe("combineInitialValues", () => { - it("returns undefined when no initial values and no initial server id", () => { - expect(combineInitialValues(undefined, null)).toBeUndefined(); - }); - - it("does not inject a null serverId override when initialValues are present but serverId is absent", () => { - const combined = combineInitialValues({}, null); - expect(combined).toEqual({}); - expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false); - }); - - it("injects serverId from options when provided", () => { - expect(combineInitialValues({}, "daemon-1")).toEqual({ serverId: "daemon-1" }); - }); - - it("keeps other initial values without forcing serverId", () => { - const combined = combineInitialValues({ workingDir: "/repo" }, null); - expect(combined).toEqual({ workingDir: "/repo" }); - expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false); - }); - - it("respects an explicit serverId override (including null) over initialServerId", () => { - expect(combineInitialValues({ serverId: null }, "daemon-1")).toEqual({ serverId: null }); - expect(combineInitialValues({ serverId: "daemon-2" }, "daemon-1")).toEqual({ - serverId: "daemon-2", - }); - }); -}); - describe("mergeSelectedComposerPreferences", () => { it("stores the selected model for the selected provider", () => { expect( @@ -534,7 +504,7 @@ describe("resolveFormState", () => { expect(resolved.thinkingOptionId).toBe("low"); }); - it("clears an invalid provider instead of falling back to the first allowed provider", () => { + it("preserves the remembered provider when it is absent from the available catalogue", () => { const resolved = resolveFormState( undefined, { provider: "codex" }, @@ -545,7 +515,7 @@ describe("resolveFormState", () => { claudeProviderMap, ); - expect(resolved.provider).toBeNull(); + expect(resolved.provider).toBe("codex"); }); it("preserves a user-selected provider and model while that provider is loading during refresh", () => { @@ -582,12 +552,10 @@ describe("resolveFormState", () => { {}, null, { - serverId: false, provider: true, modeId: true, model: true, thinkingOptionId: true, - workingDir: false, }, makeState({ provider: "codex", @@ -677,7 +645,7 @@ describe("resolveFormState", () => { expect(resolved.modeId).toBe("auto"); }); - it("ignores disabled ready providers when resolving selectable defaults", () => { + it("preserves saved intent even when a provider is disabled", () => { const entries: ProviderSnapshotEntry[] = [ { provider: "codex", @@ -715,8 +683,8 @@ describe("resolveFormState", () => { selectableProviderMap, ); - expect(resolved.provider).toBe("codex"); - expect(resolved.modeId).toBe("auto"); + expect(resolved.provider).toBe("claude"); + expect(resolved.modeId).toBe(""); }); it("excludes disabled providers from the selectable provider map without removing them from snapshot definitions", () => { @@ -752,7 +720,7 @@ describe("resolveFormState", () => { expect(providerDefinitions.map((d) => d.id)).toEqual(["codex", "claude"]); }); - it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => { + it("preserves a user-selected provider when the refreshed snapshot marks it unavailable", () => { const unavailableEntries: ProviderSnapshotEntry[] = [ { provider: "codex", @@ -785,7 +753,13 @@ describe("resolveFormState", () => { undefined, {}, null, - { ...INITIAL_USER_MODIFIED, provider: true }, + { + ...INITIAL_USER_MODIFIED, + provider: true, + modeId: true, + model: true, + thinkingOptionId: true, + }, makeState({ provider: "codex", modeId: "full-access", @@ -796,10 +770,10 @@ describe("resolveFormState", () => { resolvableProviderMap, ); - expect(resolved.provider).toBeNull(); - expect(resolved.modeId).toBe(""); - expect(resolved.model).toBe(""); - expect(resolved.thinkingOptionId).toBe(""); + expect(resolved.provider).toBe("codex"); + expect(resolved.modeId).toBe("full-access"); + expect(resolved.model).toBe("gpt-5.3-codex"); + expect(resolved.thinkingOptionId).toBe("xhigh"); }); it("does not force fallback provider when allowed provider map is empty", () => { @@ -817,8 +791,63 @@ describe("resolveFormState", () => { }); }); +it("keeps the explicit model when a refreshed catalogue no longer lists it", () => { + const resolved = resolveFormState( + undefined, + { provider: "codex", providerPreferences: { codex: { model: "gpt-6-astra" } } }, + CODEX_MODELS, + INITIAL_USER_MODIFIED, + makeState().form, + codexProviderMap, + ); + expect(resolved.model).toBe("gpt-6-astra"); + // Label/persistence lookup must not reinterpret the submitted ID as another model. + expect(resolveEffectiveModel(CODEX_MODELS, resolved.model)).toBeNull(); +}); + describe("resolveAgentForm", () => { describe("resolution state", () => { + it.each(["error", "unavailable"] as const)( + "restores a remembered model after opening against a %s provider snapshot", + (status) => { + const preferences = { + provider: "codex", + providerPreferences: { codex: { model: "gpt-5.3-codex" } }, + }; + const snapshotEntries: ProviderSnapshotEntry[] = [ + { + provider: "codex", + enabled: true, + status, + models: [], + }, + ]; + const opened = resolveAgentForm(makeState(), { + type: "COMPLETE_RESOLUTION", + initialValues: undefined, + preferences, + providerModelsByProvider: makeProviderModelsByProvider([["codex", []]]), + allowedProviderMap: buildProviderDefinitionMapForStatuses({ + snapshotEntries, + providerDefinitions: buildProviderDefinitions(snapshotEntries), + statuses: new Set(["ready", "loading"]), + }), + }); + const recovered = resolveAgentForm(opened, { + type: "COMPLETE_RESOLUTION", + initialValues: undefined, + preferences, + providerModelsByProvider: makeProviderModelsByProvider([["codex", CODEX_MODELS]]), + allowedProviderMap: codexProviderMap, + }); + + expect(recovered.form).toMatchObject({ + provider: "codex", + model: "gpt-5.3-codex", + }); + }, + ); + it("requests resolution without changing the current form values", () => { const state = makeState( { provider: "codex", modeId: "auto", model: "gpt-5.3-codex" }, @@ -833,7 +862,7 @@ describe("resolveAgentForm", () => { }); it("completes a pending open resolution when snapshot models arrive late", () => { - const state = resolveAgentForm(makeState({ serverId: "host-1" }), { + const state = resolveAgentForm(makeState(), { type: "REQUEST_RESOLUTION", }); const next = resolveAgentForm(state, { @@ -855,7 +884,7 @@ describe("resolveAgentForm", () => { }); it("does not change settled selection when a background snapshot has different defaults", () => { - const settled = resolveAgentForm(makeState({ serverId: "host-1" }), { + const settled = resolveAgentForm(makeState(), { type: "COMPLETE_RESOLUTION", initialValues: undefined, preferences: { @@ -885,7 +914,7 @@ describe("resolveAgentForm", () => { }); it("prefills edit hydration from initial values", () => { - const state = makeState({ serverId: "host-1" }); + const state = makeState(); const next = resolveAgentForm(state, { type: "COMPLETE_RESOLUTION", initialValues: { @@ -893,7 +922,6 @@ describe("resolveAgentForm", () => { modeId: "full-access", model: "gpt-5.3-codex", thinkingOptionId: "low", - workingDir: "/repo", }, preferences: { provider: "claude" }, providerModelsByProvider: makeProviderModelsByProvider([["codex", CODEX_MODELS]]), @@ -904,7 +932,6 @@ describe("resolveAgentForm", () => { expect(next.form.modeId).toBe("full-access"); expect(next.form.model).toBe("gpt-5.3-codex"); expect(next.form.thinkingOptionId).toBe("low"); - expect(next.form.workingDir).toBe("/repo"); }); it("keeps a user model change after resolution has completed", () => { @@ -912,7 +939,7 @@ describe("resolveAgentForm", () => { ...CODEX_MODELS, { provider: "codex", id: "gpt-5.4-codex", label: "gpt-5.4-codex" }, ]; - const settled = resolveAgentForm(makeState({ serverId: "host-1" }), { + const settled = resolveAgentForm(makeState(), { type: "COMPLETE_RESOLUTION", initialValues: undefined, preferences: { @@ -958,26 +985,6 @@ describe("resolveAgentForm", () => { }); }); - describe("SET_SERVER_ID", () => { - it("updates serverId without marking it user-modified", () => { - const state = makeState(); - const next = resolveAgentForm(state, { type: "SET_SERVER_ID", value: "host-1" }); - - expect(next.form.serverId).toBe("host-1"); - expect(next.userModified.serverId).toBe(false); - }); - }); - - describe("SET_SERVER_ID_FROM_USER", () => { - it("updates serverId and marks it user-modified", () => { - const state = makeState(); - const next = resolveAgentForm(state, { type: "SET_SERVER_ID_FROM_USER", value: "host-2" }); - - expect(next.form.serverId).toBe("host-2"); - expect(next.userModified.serverId).toBe(true); - }); - }); - describe("SET_PROVIDER_AND_MODEL_FROM_USER", () => { it("sets provider, model, and default mode; marks both modified", () => { const state = makeState(); @@ -1167,51 +1174,6 @@ describe("resolveAgentForm", () => { }); }); - describe("SET_WORKING_DIR", () => { - it("updates workingDir without marking it modified", () => { - const state = makeState(); - const next = resolveAgentForm(state, { type: "SET_WORKING_DIR", value: "/home/user/proj" }); - - expect(next.form.workingDir).toBe("/home/user/proj"); - expect(next.userModified.workingDir).toBe(false); - }); - }); - - describe("SET_WORKING_DIR_FROM_USER", () => { - it("updates workingDir and marks it modified", () => { - const state = makeState(); - const next = resolveAgentForm(state, { - type: "SET_WORKING_DIR_FROM_USER", - value: "/home/user/proj", - }); - - expect(next.form.workingDir).toBe("/home/user/proj"); - expect(next.userModified.workingDir).toBe(true); - }); - }); - - describe("AUTO_SELECT_SERVER", () => { - it("sets serverId when currently null", () => { - const state = makeState({ serverId: null }); - const next = resolveAgentForm(state, { - type: "AUTO_SELECT_SERVER", - candidateServerId: "host-1", - }); - - expect(next.form.serverId).toBe("host-1"); - }); - - it("does not override an already-set serverId", () => { - const state = makeState({ serverId: "existing" }); - const next = resolveAgentForm(state, { - type: "AUTO_SELECT_SERVER", - candidateServerId: "host-1", - }); - - expect(next).toBe(state); - }); - }); - describe("RESET", () => { it("keeps form values but marks them unresolved for the next open", () => { const state = makeState( @@ -1276,3 +1238,34 @@ describe("resolveAgentForm", () => { }); }); }); + +it("owns input readiness, reopening and user edits in the reducer", () => { + const inputs = { + type: "INPUTS_CHANGED" as const, + serverId: "host", + isVisible: true, + isCreateFlow: true, + isPreferencesLoading: true, + hasSnapshot: false, + initialValues: undefined, + preferences: { provider: "codex", providerPreferences: { codex: { model: "astra" } } }, + allowedProviderMap: new Map(), + providerModelsByProvider: new Map(), + }; + let state = resolveAgentForm(makeState(), inputs); + expect(state.resolution.status).toBe("pending"); + state = resolveAgentForm(state, { ...inputs, isPreferencesLoading: false, hasSnapshot: true }); + expect(state.form).toMatchObject({ provider: "codex", model: "astra" }); + state = resolveAgentForm(state, { + type: "SET_MODEL_FROM_USER", + modelId: "manual", + availableModels: null, + providerPrefs: undefined, + }); + state = resolveAgentForm(state, { ...inputs, isPreferencesLoading: false, hasSnapshot: true }); + expect(state.form.model).toBe("manual"); + state = resolveAgentForm(state, { ...inputs, isVisible: false }); + expect(state.resolution.status).toBe("pending"); + state = resolveAgentForm(state, { ...inputs, isPreferencesLoading: false, hasSnapshot: true }); + expect(state.form).toMatchObject({ provider: "codex", model: "astra" }); +}); diff --git a/packages/app/src/provider-selection/resolve-agent-form.ts b/packages/app/src/provider-selection/resolve-agent-form.ts index 9fa187200e..08e88fd7a3 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.ts @@ -12,30 +12,24 @@ import { import { findModelByReference } from "./model-catalog"; export interface FormInitialValues { - serverId?: string | null; provider?: AgentProvider; modeId?: string | null; model?: string | null; thinkingOptionId?: string | null; - workingDir?: string; } export interface FormState { - serverId: string | null; provider: AgentProvider | null; modeId: string; model: string; thinkingOptionId: string; - workingDir: string; } export interface UserModifiedFields { - serverId: boolean; provider: boolean; modeId: boolean; model: boolean; thinkingOptionId: boolean; - workingDir: boolean; } export type ProviderModelsByProvider = Map; @@ -46,15 +40,18 @@ export interface AgentFormReducerState { form: FormState; userModified: UserModifiedFields; resolution: AgentFormResolutionState; + inputs?: { + serverId: string | null; + initialValues: FormInitialValues | undefined; + active: boolean; + }; } export const INITIAL_USER_MODIFIED: UserModifiedFields = { - serverId: false, provider: false, modeId: false, model: false, thinkingOptionId: false, - workingDir: false, }; export const PENDING_AGENT_FORM_RESOLUTION: AgentFormResolutionState = { status: "pending" }; @@ -68,7 +65,21 @@ export const RESOLVABLE_PROVIDER_STATUSES = new Set(["ready"]); +interface AgentFormInputs { + type: "INPUTS_CHANGED"; + serverId: string | null; + isVisible: boolean; + isCreateFlow: boolean; + isPreferencesLoading: boolean; + hasSnapshot: boolean; + initialValues: FormInitialValues | undefined; + preferences: FormPreferences | null; + providerModelsByProvider: ProviderModelsByProvider; + allowedProviderMap: Map; +} + export type AgentFormAction = + | AgentFormInputs | { type: "REQUEST_RESOLUTION" } | { type: "COMPLETE_RESOLUTION"; @@ -77,8 +88,6 @@ export type AgentFormAction = providerModelsByProvider: ProviderModelsByProvider; allowedProviderMap: Map; } - | { type: "SET_SERVER_ID"; value: string | null } - | { type: "SET_SERVER_ID_FROM_USER"; value: string | null } | { type: "SET_PROVIDER_AND_MODEL_FROM_USER"; provider: AgentProvider; @@ -106,9 +115,6 @@ export type AgentFormAction = } | { type: "CLEAR_PROVIDER_SELECTION_FROM_USER" } | { type: "SET_THINKING_OPTION_FROM_USER"; thinkingOptionId: string } - | { type: "SET_WORKING_DIR"; value: string } - | { type: "SET_WORKING_DIR_FROM_USER"; value: string } - | { type: "AUTO_SELECT_SERVER"; candidateServerId: string } | { type: "RESET" }; type CompleteResolutionAction = Extract; @@ -144,7 +150,7 @@ export function resolveEffectiveModel( ): AgentModelDefinition | null { if (!availableModels || availableModels.length === 0) return null; if (!normalizeSelectedModelId(modelId)) return null; - return findModelByReference(availableModels, modelId) ?? resolveDefaultModel(availableModels); + return findModelByReference(availableModels, modelId) ?? null; } function resolvePreferredThinkingOptionId(input: { @@ -216,36 +222,12 @@ export function mergeSelectedComposerPreferences(args: { }); } -export function combineInitialValues( - initialValues: FormInitialValues | undefined, - initialServerId: string | null, -): FormInitialValues | undefined { - const hasExplicitServerId = initialValues?.serverId !== undefined; - const serverIdFromOptions = initialServerId === null ? undefined : initialServerId; - - if (!initialValues && !hasExplicitServerId && serverIdFromOptions === undefined) { - return undefined; - } - - if (hasExplicitServerId) { - return { ...initialValues, serverId: initialValues?.serverId }; - } - - if (serverIdFromOptions !== undefined) { - return { ...initialValues, serverId: serverIdFromOptions }; - } - - return initialValues; -} - export function hasFormStateChanged(prev: FormState, next: FormState): boolean { return ( - prev.serverId !== next.serverId || prev.provider !== next.provider || prev.modeId !== next.modeId || prev.model !== next.model || - prev.thinkingOptionId !== next.thinkingOptionId || - prev.workingDir !== next.workingDir + prev.thinkingOptionId !== next.thinkingOptionId ); } @@ -282,29 +264,11 @@ function resolveProvider(input: { userModified: boolean; initialValues: FormInitialValues | undefined; preferences: FormPreferences | null; - allowedProviderMap: Map; }): AgentProvider | null { - const { currentProvider, userModified, initialValues, preferences, allowedProviderMap } = input; - if (userModified) { - if ( - currentProvider && - allowedProviderMap.size > 0 && - !allowedProviderMap.has(currentProvider) - ) { - return null; - } - return currentProvider; - } - if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) { - return initialValues.provider; - } - if (preferences?.provider && allowedProviderMap.has(preferences.provider)) { - return preferences.provider; - } - if (currentProvider && allowedProviderMap.size > 0 && !allowedProviderMap.has(currentProvider)) { - return null; - } - return currentProvider; + const { currentProvider, userModified, initialValues, preferences } = input; + // Discovery readiness does not change the user's saved or explicit choice. + if (userModified) return currentProvider; + return initialValues?.provider ?? preferences?.provider ?? currentProvider; } function resolveModeId(input: { @@ -340,16 +304,22 @@ function resolveModelField(input: { if (!provider) return ""; const initialModel = normalizeSelectedModelId(initialValues?.model); const preferredModel = normalizeSelectedModelId(providerPrefs?.model); - const defaultModelId = resolveDefaultModelId(availableModels); + // COMPAT(default-model-id): added in v0.7.2, remove after 2026-12-06. + // Older drafts used "default" before providers exposed concrete model IDs. + if ((initialModel || preferredModel) === "default" && availableModels?.length) { + return ( + findModelByReference(availableModels, "default")?.id || resolveDefaultModelId(availableModels) + ); + } if (initialModel) { return !availableModels ? initialModel - : resolveCanonicalModelId(availableModels, initialModel) || defaultModelId; + : resolveCanonicalModelId(availableModels, initialModel) || initialModel; } if (preferredModel) { return !availableModels ? preferredModel - : resolveCanonicalModelId(availableModels, preferredModel) || defaultModelId; + : resolveCanonicalModelId(availableModels, preferredModel) || preferredModel; } return ""; } @@ -403,7 +373,6 @@ export function resolveFormState( userModified: userModified.provider, initialValues, preferences, - allowedProviderMap, }); const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined; @@ -447,14 +416,6 @@ export function resolveFormState( }); } - if (!userModified.serverId && initialValues?.serverId !== undefined) { - result.serverId = initialValues.serverId; - } - - if (!userModified.workingDir && initialValues?.workingDir !== undefined) { - result.workingDir = initialValues.workingDir; - } - return result; } @@ -615,11 +576,45 @@ function applyProfile(state: AgentFormReducerState, action: ApplyProfileAction) }; } +function sameInitialValues(left: FormInitialValues = {}, right: FormInitialValues = {}): boolean { + return ( + left.provider === right.provider && + left.model === right.model && + left.modeId === right.modeId && + left.thinkingOptionId === right.thinkingOptionId + ); +} + +function receiveInputs( + state: AgentFormReducerState, + action: AgentFormInputs, +): AgentFormReducerState { + const active = action.isVisible && action.isCreateFlow; + const previous = state.inputs; + const initial = action.initialValues; + const changed = + previous?.active !== active || + previous.serverId !== action.serverId || + !sameInitialValues(previous.initialValues, initial); + let next = state; + if (changed) { + next = { + ...resolveAgentForm(state, { type: action.isVisible ? "REQUEST_RESOLUTION" : "RESET" }), + inputs: { serverId: action.serverId, initialValues: initial, active }, + }; + } + if (!active || action.isPreferencesLoading || !action.serverId || !action.hasSnapshot) + return next; + return completeResolution(next, { ...action, type: "COMPLETE_RESOLUTION" }); +} + export function resolveAgentForm( state: AgentFormReducerState, action: AgentFormAction, ): AgentFormReducerState { switch (action.type) { + case "INPUTS_CHANGED": + return receiveInputs(state, action); case "REQUEST_RESOLUTION": return { ...state, @@ -630,16 +625,6 @@ export function resolveAgentForm( case "COMPLETE_RESOLUTION": return completeResolution(state, action); - case "SET_SERVER_ID": - return { ...state, form: { ...state.form, serverId: action.value } }; - - case "SET_SERVER_ID_FROM_USER": - return { - ...state, - form: { ...state.form, serverId: action.value }, - userModified: { ...state.userModified, serverId: true }, - }; - case "SET_PROVIDER_AND_MODEL_FROM_USER": { const normalizedModelId = resolveCanonicalModelId(action.providerModels, action.modelId); const nextModelId = normalizedModelId || resolveDefaultModelId(action.providerModels); @@ -730,20 +715,6 @@ export function resolveAgentForm( userModified: { ...state.userModified, thinkingOptionId: true }, }; - case "SET_WORKING_DIR": - return { ...state, form: { ...state.form, workingDir: action.value } }; - - case "SET_WORKING_DIR_FROM_USER": - return { - ...state, - form: { ...state.form, workingDir: action.value }, - userModified: { ...state.userModified, workingDir: true }, - }; - - case "AUTO_SELECT_SERVER": - if (state.form.serverId) return state; - return { ...state, form: { ...state.form, serverId: action.candidateServerId } }; - case "RESET": return { ...state, diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 20fb842e06..ad33968740 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -518,6 +518,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps { runtimeGeneration, capabilities: appCapabilities, trace: nativePerformanceTrace, + providerSnapshots: "wire", } satisfies Omit; if (connection.type === "directSocket" || connection.type === "directPipe") { return new DaemonClient({ diff --git a/packages/app/src/schedules/schedule-form-model.ts b/packages/app/src/schedules/schedule-form-model.ts index 4a889f9ef1..d7d2a115d0 100644 --- a/packages/app/src/schedules/schedule-form-model.ts +++ b/packages/app/src/schedules/schedule-form-model.ts @@ -338,19 +338,16 @@ function isSelectedModelValidForProviders(input: { function normalizeInitialValues(input: { snapshot: ScheduleFormSnapshot; - selectedServerId: string | null; }): FormInitialValues | undefined { const config = newAgentConfig(input.snapshot.schedule); if (!config) { return undefined; } return { - serverId: input.selectedServerId, provider: config.provider, model: config.model ?? null, modeId: config.modeId ?? null, thinkingOptionId: config.thinkingOptionId ?? null, - workingDir: config.cwd, }; } @@ -711,24 +708,20 @@ function buildInitialState(snapshot: ScheduleFormSnapshot): ScheduleFormState { function toFormState(state: ScheduleFormState): FormState { return { - serverId: state.selectedServerId, provider: state.selectedProvider, modeId: state.selectedMode, model: state.selectedModel, thinkingOptionId: state.selectedThinkingOptionId, - workingDir: state.workingDir, }; } function applyResolvedFormState(state: ScheduleFormState, form: FormState): ScheduleFormState { return { ...state, - selectedServerId: form.serverId, selectedProvider: form.provider, selectedMode: form.modeId, selectedModel: form.model, selectedThinkingOptionId: form.thinkingOptionId, - workingDir: form.workingDir, }; } @@ -846,7 +839,6 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo const listeners = new Set<() => void>(); const initialValues = normalizeInitialValues({ snapshot, - selectedServerId: resolveInitialServerId(snapshot), }); let closed = false; let hosts = snapshot.hosts; @@ -1017,11 +1009,6 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo if (closed || state.selectedServerId === serverId) { return; } - userModified = { - ...userModified, - serverId: true, - workingDir: true, - }; publish( clearProviderSelection({ ...state, @@ -1046,7 +1033,6 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo if (!providerScopeChanged && state.selectedProjectOptionId === target.optionId) { return; } - userModified = { ...userModified, serverId: true, workingDir: true }; const nextState = { ...state, selectedServerId: target.serverId, diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 9096ae9e94..ca189cf7b4 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -905,21 +905,16 @@ function buildWorkspaceDraftSetupForCreatedWorkspace(input: { } function buildComposerInitialValues(input: { - workingDir: string | undefined; initialSetup?: WorkspaceDraftTabSetup | null; }): CreateAgentInitialValues | undefined { if (input.initialSetup) { return { - workingDir: input.workingDir ?? input.initialSetup.cwd, provider: input.initialSetup.provider, modeId: input.initialSetup.modeId, model: input.initialSetup.model, thinkingOptionId: input.initialSetup.thinkingOptionId, }; } - if (input.workingDir) { - return { workingDir: input.workingDir }; - } return undefined; } @@ -969,19 +964,17 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise { function buildComposerConfig(input: { serverId: string; - isConnected: boolean; workspaceDirectory: string | null; sourceDirectory: string | null; initialSetup?: WorkspaceDraftTabSetup | null; }): Parameters[0]["composer"] { - const { serverId, isConnected, workspaceDirectory, sourceDirectory, initialSetup } = input; + const { serverId, workspaceDirectory, sourceDirectory, initialSetup } = input; const workingDir = workspaceDirectory || sourceDirectory || undefined; return { initialServerId: serverId || null, - initialValues: buildComposerInitialValues({ workingDir, initialSetup }), + initialValues: buildComposerInitialValues({ initialSetup }), initialFeatureValues: initialSetup?.featureValues, isVisible: true, - onlineServerIds: isConnected && serverId ? [serverId] : [], lockedWorkingDir: workingDir, }; } @@ -1679,7 +1672,6 @@ export function NewWorkspaceScreen({ draftKey, composer: buildComposerConfig({ serverId: selectedServerId, - isConnected, workspaceDirectory: workspace?.workspaceDirectory ?? null, sourceDirectory: selectedSourceDirectory, initialSetup: forkDraftSetup?.setup, diff --git a/packages/client/src/compat/normalize-provider-models.ts b/packages/client/src/compat/normalize-provider-models.ts index 65d4addcbb..023c8681e3 100644 --- a/packages/client/src/compat/normalize-provider-models.ts +++ b/packages/client/src/compat/normalize-provider-models.ts @@ -59,7 +59,8 @@ export function normalizeListProviderModelsPayload( export function normalizeProvidersSnapshotPayload< T extends GetProvidersSnapshotPayload | ProvidersSnapshotUpdatePayload, ->(payload: T): T { +>(payload: T, expand = true): T { + if (payload.compactSnapshot && !expand) return payload; const entries = payload.compactSnapshot ? expandProviderSnapshot(payload.compactSnapshot) : normalizeProviderSnapshotEntries(payload.entries); @@ -68,10 +69,11 @@ export function normalizeProvidersSnapshotPayload< export function normalizeProviderSnapshotUpdateMessage( msg: SessionOutboundMessage, + expand = true, ): SessionOutboundMessage { if (msg.type !== "providers_snapshot_update") { return msg; } - return { ...msg, payload: normalizeProvidersSnapshotPayload(msg.payload) }; + return { ...msg, payload: normalizeProvidersSnapshotPayload(msg.payload, expand) }; } diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index f599226744..e4697dfaf8 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -6169,3 +6169,52 @@ test("waitForFinish with timeout=0 omits timeoutMs and has no client deadline", vi.useRealTimers(); } }); + +test("wire snapshot callers own expansion and receive hash references unchanged", async () => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "wire-catalog", + providerSnapshots: "wire", + reconnect: { enabled: false }, + transportFactory: () => transport.transport, + }); + clients.push(client); + const connected = client.connect(); + transport.triggerOpen({ preserveSent: true }); + await connected; + expect(JSON.parse(assertStr(transport.sent[0])).capabilities.provider_snapshot_references).toBe( + true, + ); + const received: unknown[] = []; + client.on("providers_snapshot_update", (message) => received.push(message.payload)); + const payload = { + entries: [], + snapshotHash: "content", + fetchedAt: { codex: "2026-09-06T12:00:00.000Z" }, + generatedAt: "2026-09-06T12:00:00.000Z", + }; + transport.triggerMessage(wrapSessionMessage({ type: "providers_snapshot_update", payload })); + expect(received).toEqual([payload]); + const request = client.getProvidersSnapshot(); + const sent = parseSentFrame(transport.sent.at(-1)!); + const body = { + ...payload, + compactSnapshot: { + entries: [ + { + provider: "codex", + enabled: true, + status: "ready", + models: [{ id: "astra", label: "Astra" }], + }, + ], + thinkingSets: [], + }, + requestId: sent.requestId, + }; + transport.triggerMessage( + wrapSessionMessage({ type: "get_providers_snapshot_response", payload: body }), + ); + expect(await request).toEqual(body); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index ea4ec2d121..eb0fcbf1a4 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -308,6 +308,9 @@ export type BrowserAutomationExecuteRequestMessage = BrowserAutomationExecuteReq export type BrowserAutomationExecuteResponseMessage = BrowserAutomationExecuteResponse; export interface DaemonClientConfig { + /** Deliver compact bodies/hash references to a caller-owned snapshot cache. + * The default keeps public SDK snapshot entries expanded. */ + providerSnapshots?: "wire"; url: string; clientId: string; clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub"; @@ -4689,7 +4692,7 @@ export class DaemonClient { }, responseType: "get_providers_snapshot_response", }); - return normalizeProvidersSnapshotPayload(payload); + return normalizeProvidersSnapshotPayload(payload, this.config.providerSnapshots !== "wire"); } async getDaemonConfig( @@ -5674,6 +5677,9 @@ export class DaemonClient { [CLIENT_CAPS.providerSubagents]: true, [CLIENT_CAPS.projectUpdates]: true, [CLIENT_CAPS.compactProviderSnapshots]: true, + ...(this.config.providerSnapshots === "wire" + ? { [CLIENT_CAPS.providerSnapshotReferences]: true } + : {}), [CLIENT_CAPS.timelineNotifications]: true, ...this.config.capabilities, }, @@ -6100,7 +6106,10 @@ export class DaemonClient { } private handleSessionMessage(msg: SessionOutboundMessage): void { - const consumerMessage = normalizeProviderSnapshotUpdateMessage(msg); + const consumerMessage = normalizeProviderSnapshotUpdateMessage( + msg, + this.config.providerSnapshots !== "wire", + ); if (consumerMessage.type === "status") { const serverInfo = parseServerInfoStatusPayload(consumerMessage.payload); diff --git a/packages/plugin/src/provider.ts b/packages/plugin/src/provider.ts index 3051a89bb0..1ac5bcbc3c 100644 --- a/packages/plugin/src/provider.ts +++ b/packages/plugin/src/provider.ts @@ -26,6 +26,8 @@ export const PROVIDER_CAPABILITIES = [ export type ProviderCapability = (typeof PROVIDER_CAPABILITIES)[number]; export interface ProviderRegistration { + /** Equal keys share discovery within this provider. Include effective configuration and execution environment. */ + getCatalogCacheKey?(options: ProviderCatalogOptions): Promise; id: string; label: string; description?: string; @@ -34,6 +36,10 @@ export interface ProviderRegistration { connect(request: ProviderConnectRequest): Promise; } +export type ProviderCatalogOptions = + | { scope: "global"; force?: boolean } + | { scope: "workspace"; cwd: string; force?: boolean }; + export interface ProviderConnectRequest { versions: readonly number[]; capabilities: readonly string[]; diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index b67dd347d3..d07ffdf242 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -26,6 +26,7 @@ export const CLIENT_CAPS = { // provider catalogs with shared thinking sets and may revalidate by content hash. // Remove the legacy snapshot encoding after 2027-02-04. compactProviderSnapshots: "compact_provider_snapshots", + providerSnapshotReferences: "provider_snapshot_references", // COMPAT(timelineReplacementInvalidation): added in v0.5.0, remove legacy // reconstructed timeline replay after 2027-02-21 once the client floor supports invalidation. timelineReplacementInvalidation: "timeline_replacement_invalidation", diff --git a/packages/protocol/src/messages.providers-snapshot.test.ts b/packages/protocol/src/messages.providers-snapshot.test.ts index fc87b13b3c..91b08a3cdc 100644 --- a/packages/protocol/src/messages.providers-snapshot.test.ts +++ b/packages/protocol/src/messages.providers-snapshot.test.ts @@ -94,3 +94,20 @@ describe("provider snapshot message schemas", () => { expect(parsed.payload.entries[0]?.enabled).toBe(true); }); }); + +test("accepts a bodyless announcement with separate discovery freshness", async () => { + const { validateWSOutboundMessage } = await import("./validation/ws-outbound.js"); + const message = { + type: "providers_snapshot_update", + payload: { + cwd: "/project", + entries: [], + snapshotHash: "content-hash", + fetchedAt: { codex: "2026-09-06T12:00:00.000Z" }, + generatedAt: "2026-09-06T13:00:00.000Z", + }, + }; + expect(ProvidersSnapshotUpdateMessageSchema.parse(message)).toEqual(message); + const result = validateWSOutboundMessage({ type: "session", message }); + expect(result.success).toBe(true); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 3ad434ce14..4f6415b8c9 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -5874,6 +5874,7 @@ export const GetProvidersSnapshotResponseMessageSchema = z.object({ entries: z.array(ProviderSnapshotEntrySchema), compactSnapshot: CompactProviderSnapshotSchema.optional(), snapshotHash: z.string().optional(), + fetchedAt: z.record(z.string(), z.string()).optional(), notModified: z.boolean().optional(), generatedAt: z.string(), requestId: z.string(), @@ -5888,6 +5889,7 @@ export const ProvidersSnapshotUpdateMessageSchema = z.object({ entries: z.array(ProviderSnapshotEntrySchema), compactSnapshot: CompactProviderSnapshotSchema.optional(), snapshotHash: z.string().optional(), + fetchedAt: z.record(z.string(), z.string()).optional(), generatedAt: z.string(), }), }); @@ -7100,6 +7102,7 @@ export const WSHelloMessageSchema = z.object({ [CLIENT_CAPS.providerSubagents]: z.boolean().optional(), [CLIENT_CAPS.projectUpdates]: z.boolean().optional(), [CLIENT_CAPS.compactProviderSnapshots]: z.boolean().optional(), + [CLIENT_CAPS.providerSnapshotReferences]: z.boolean().optional(), [CLIENT_CAPS.timelineReplacementInvalidation]: z.boolean().optional(), [CLIENT_CAPS.timelineNotifications]: z.boolean().optional(), [CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(), diff --git a/packages/server/src/executable-resolution/executable-resolution.test.ts b/packages/server/src/executable-resolution/executable-resolution.test.ts index c202b4d15d..4434089e98 100644 --- a/packages/server/src/executable-resolution/executable-resolution.test.ts +++ b/packages/server/src/executable-resolution/executable-resolution.test.ts @@ -58,6 +58,15 @@ afterEach(() => { }); describe("findExecutable", () => { + test.runIf(isPlatform("linux"))( + "propagates a failed lookup process instead of reporting a missing executable", + async () => { + await expect(findExecutable("x".repeat(1024 * 1024))).rejects.toMatchObject({ + code: "E2BIG", + }); + }, + ); + describe.skipIf(isPlatform("win32"))("POSIX", () => { test("finds an extensionless executable and skips an earlier non-executable candidate", async () => { const executableDir = makeTempDir(); diff --git a/packages/server/src/executable-resolution/executable-resolution.ts b/packages/server/src/executable-resolution/executable-resolution.ts index a6edcc6e7b..d7af13a3d0 100644 --- a/packages/server/src/executable-resolution/executable-resolution.ts +++ b/packages/server/src/executable-resolution/executable-resolution.ts @@ -30,8 +30,10 @@ async function enumerateCandidatesViaSystemWhich(name: string): Promise; + /** Equal keys share availability and catalogue discovery within this configured client. + * Include the execution environment and effective configuration; omit to use target identity. + * force must not affect identity. Resolve before every cache lookup. */ + getCatalogCacheKey?(options: FetchCatalogOptions): Promise; /** * Discover models and modes together. Implementations may use one upstream * process, separate upstream calls, static modes, or private helpers; callers @@ -775,10 +779,10 @@ export interface AgentClient { context: ImportProviderSessionContext, ): Promise; /** - * Check if this provider is available (CLI binary is installed). + * Check availability in the catalogue target when supplied (CLI binary is installed). * Returns true if available, false otherwise. */ - isAvailable(signal?: AbortSignal): Promise; + isAvailable(signal?: AbortSignal, options?: FetchCatalogOptions): Promise; getDiagnostic?(): Promise<{ diagnostic: string }>; /** * Archive a durable native session (best-effort). Runtime release belongs to AgentSession.close(). diff --git a/packages/server/src/server/agent/mutable-provider-config-owner.test.ts b/packages/server/src/server/agent/mutable-provider-config-owner.test.ts index 4a8c30b1bb..53270c1a78 100644 --- a/packages/server/src/server/agent/mutable-provider-config-owner.test.ts +++ b/packages/server/src/server/agent/mutable-provider-config-owner.test.ts @@ -12,10 +12,12 @@ import type { AgentMode, AgentModelDefinition, FetchCatalogOptions, - ProviderSnapshotEntry, } from "./agent-sdk-types.js"; import { attachMutableProviderConfigOwner } from "./mutable-provider-config-owner.js"; -import { ProviderSnapshotManager } from "./provider-snapshot-manager.js"; +import { + type ProviderSnapshotTransition, + ProviderSnapshotManager, +} from "./provider-snapshot-manager.js"; const tempDirs: string[] = []; const CONTROLLED_PROVIDERS = { @@ -61,15 +63,15 @@ function catalog(modelId: string): Catalog { }; } -function recordCodexEvent(events: string[]): (entries: ProviderSnapshotEntry[]) => void { - return (entries) => { - const codex = entries.find((entry) => entry.provider === "codex"); +function recordCodexEvent(events: string[]): (transition: ProviderSnapshotTransition) => void { + return ({ current }) => { + const codex = current.records.find(({ entry }) => entry.provider === "codex")?.entry; events.push(`${codex?.status}:${codex?.models?.[0]?.id ?? "none"}`); }; } function getCodexSnapshot(manager: ProviderSnapshotManager, cwd: string) { - return manager.getSnapshot(cwd).find((entry) => entry.provider === "codex"); + return manager.getSnapshot(cwd).records.find(({ entry }) => entry.provider === "codex")?.entry; } function mutableConfig(persisted: PersistedConfig): MutableDaemonConfig { @@ -77,7 +79,7 @@ function mutableConfig(persisted: PersistedConfig): MutableDaemonConfig { relay: { enabled: false }, mcp: { enabled: true, injectIntoAgents: false }, browserTools: { enabled: false }, - providers: persisted.agents?.providers ?? CONTROLLED_PROVIDERS, + providers: CONTROLLED_PROVIDERS, metadataGeneration: { providers: [] }, autoArchiveAfterMerge: false, enableTerminalAgentHooks: false, @@ -108,7 +110,7 @@ describe("mutable provider config owner", () => { const cwd = path.resolve("/tmp/provider-config-owner"); manager.getSnapshot(cwd); const events: string[] = []; - manager.on("change", (_entries, changedCwd) => events.push(changedCwd)); + manager.on("change", ({ current }) => events.push(current.cwd)); let agentManagerState = manager.getAgentManagerProviderState(); const unsubscribe = attachMutableProviderConfigOwner({ store, @@ -158,7 +160,6 @@ describe("mutable provider config owner", () => { git: { maxProcessesPerSecond: 64, maxProcessConcurrency: 8 }, }, app: { baseUrl: "https://before.example.test" }, - agents: { providers: { codex: { enabled: true } } }, }; const configPath = path.join(paseoHome, "config.json"); writeFileSync(configPath, `${JSON.stringify(initial, null, 2)}\n`, "utf-8"); @@ -177,7 +178,7 @@ describe("mutable provider config owner", () => { }); manager.getSnapshot("/tmp/provider-config-owner"); const events: string[] = []; - manager.on("change", (_entries, cwd) => events.push(cwd)); + manager.on("change", ({ current }) => events.push(current.cwd)); let registryUpdates = 0; const unsubscribe = attachMutableProviderConfigOwner({ store, @@ -242,6 +243,7 @@ describe("mutable provider config owner", () => { try { const originalRead = manager.getProvider({ cwd, provider: "codex", wait: true }); await vi.waitFor(() => expect(catalogResolvers).toHaveLength(1)); + expect(events).toEqual([]); store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } }); expect(events).toEqual(["loading:none"]); @@ -282,23 +284,42 @@ describe("mutable provider config owner", () => { const cwd = path.resolve("/tmp/provider-config-rollback"); const events: string[] = []; manager.on("change", recordCodexEvent(events)); + const previousState = manager.getAgentManagerProviderState(); + let agentManagerState = previousState; const unsubscribe = attachMutableProviderConfigOwner({ store, providerSnapshotManager: manager, - updateProviderRegistry: () => undefined, + updateProviderRegistry: (state) => { + agentManagerState = state; + }, }); + const observedDuringApply: Array<{ + label: string; + snapshot: ReturnType; + }> = []; const unsubscribeFailure = store.onApply(() => { + observedDuringApply.push({ + label: manager.getProviderLabel("codex"), + snapshot: manager.getSnapshot(cwd), + }); throw new Error("later owner failed"); }); try { const originalRead = manager.getProvider({ cwd, provider: "codex", wait: true }); await vi.waitFor(() => expect(catalogResolvers).toHaveLength(1)); + expect(events).toEqual([]); + const before = manager.getSnapshot(cwd); expect(() => store.patch({ providers: { codex: { enabled: true, label: "Rejected Codex" } } }), ).toThrow("later owner failed"); expect(events).toEqual([]); + expect(observedDuringApply).toEqual([{ label: "Codex", snapshot: before }]); + expect(observedDuringApply[0]!.snapshot).toBe(before); + expect(manager.getSnapshot(cwd)).toBe(before); + expect(agentManagerState).toEqual(previousState); + expect(agentManagerState.clients.codex).toBe(previousState.clients.codex); catalogResolvers[0]?.(catalog("original-model")); await expect(originalRead).resolves.toMatchObject({ @@ -309,8 +330,7 @@ describe("mutable provider config owner", () => { status: "ready", models: [{ id: "original-model" }], }); - expect(events.length).toBeGreaterThan(0); - expect(new Set(events)).toEqual(new Set(["ready:original-model"])); + expect(events).toEqual(["ready:original-model"]); expect(catalogResolvers).toHaveLength(1); } finally { unsubscribeFailure(); diff --git a/packages/server/src/server/agent/mutable-provider-config-owner.ts b/packages/server/src/server/agent/mutable-provider-config-owner.ts index 950a70e3c7..8ae589591c 100644 --- a/packages/server/src/server/agent/mutable-provider-config-owner.ts +++ b/packages/server/src/server/agent/mutable-provider-config-owner.ts @@ -11,35 +11,37 @@ export function attachMutableProviderConfigOwner(options: { providerSnapshotManager: ProviderSnapshotManager; updateProviderRegistry: (state: AgentManagerProviderState) => void; }): () => void { - let publishPendingProviderChange: (() => void) | null = null; + let commitPendingProviderChange: (() => void) | null = null; const unsubscribeApply = options.store.onApply((config, previous, details) => { if (equal(config.providers, previous.providers)) return () => undefined; const previousAgentManagerState = options.providerSnapshotManager.getAgentManagerProviderState(); - const staged = options.providerSnapshotManager.stageMutableProviderConfig(config.providers, { - removeProviders: details.removedProviders, - replace: true, - }); + const prepared = options.providerSnapshotManager.prepareMutableProviderConfig( + config.providers, + { + removeProviders: details.removedProviders, + replace: true, + }, + ); try { - options.updateProviderRegistry(staged.agentManagerState); + options.updateProviderRegistry(prepared.agentManagerState); } catch (error) { - staged.rollback(); + options.updateProviderRegistry(previousAgentManagerState); throw error; } - publishPendingProviderChange = staged.publish; + commitPendingProviderChange = prepared.commit; return () => { - publishPendingProviderChange = null; - staged.rollback(); + commitPendingProviderChange = null; options.updateProviderRegistry(previousAgentManagerState); }; }); const unsubscribeChange = options.store.onChange(() => { - const publish = publishPendingProviderChange; - publishPendingProviderChange = null; - publish?.(); + const commit = commitPendingProviderChange; + commitPendingProviderChange = null; + commit?.(); }); return () => { diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index a9dd271220..7b7df64a22 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -837,7 +837,10 @@ class PluginAgentClient implements AgentClient { private readonly rootsBySession = new Map(); private readonly pendingChildren: PendingChild[] = []; + readonly getCatalogCacheKey?: AgentClient["getCatalogCacheKey"]; + constructor(registration: ProviderRegistration) { + this.getCatalogCacheKey = registration.getCatalogCacheKey?.bind(registration); this.provider = registration.id; this.runtime = new ProviderRuntime(registration); this.runtime.onSessionOpened((session, opened) => this.acceptChild(session, opened)); diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index ac70c936cb..3ac480a872 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -575,7 +575,8 @@ function wrapClientProvider( }; } : undefined, - isAvailable: (signal) => inner.isAvailable(signal), + getCatalogCacheKey: inner.getCatalogCacheKey?.bind(inner), + isAvailable: (signal, options) => inner.isAvailable(signal, options), getDiagnostic: inner.getDiagnostic?.bind(inner), }; } diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index f30cd6e92c..b1e50082da 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -1,3 +1,4 @@ +import pino from "pino"; import { homedir } from "node:os"; import { resolve } from "node:path"; import { describe, expect, test, vi } from "vitest"; @@ -17,6 +18,7 @@ import type { ManagedAgent } from "./agent-manager.js"; import { GLOBAL_PROVIDER_SNAPSHOT_KEY, ProviderSnapshotManager, + type ProviderSnapshot, resolveSnapshotCwd, } from "./provider-snapshot-manager.js"; import { OpenCodeAgentClient } from "./providers/opencode-agent.js"; @@ -29,6 +31,34 @@ const TEST_CAPABILITIES = { supportsReasoningStream: false, supportsToolInvocations: false, } as const; +const CLOSED_PARENT: ManagedAgent = { + id: "parent-agent", + provider: "codex", + cwd: "/tmp/project", + capabilities: TEST_CAPABILITIES, + config: { provider: "codex", cwd: "/tmp/project" }, + createdAt: new Date(0), + updatedAt: new Date(0), + availableModes: [], + currentModeId: null, + pendingPermissions: new Map(), + bufferedPermissionResolutions: new Map(), + inFlightPermissionResponses: new Set(), + pendingReplacement: false, + persistence: null, + historyPrimed: false, + lastUserMessageAt: null, + activeTurnId: null, + activeTurnStartedAt: null, + attention: { requiresAttention: false }, + foregroundTurnWaiters: new Set(), + finalizedForegroundTurnIds: new Set(), + unsubscribeSession: null, + labels: {}, + lifecycle: "closed", + session: null, + activeForegroundTurnId: null, +}; const TEST_REFRESH_TIMEOUT_MS = 120_000; // Builds an AgentClient that can be injected via the public extraClients option. @@ -40,6 +70,8 @@ function createExtraClient( return { provider, capabilities: TEST_CAPABILITIES, + getCatalogCacheKey: + provider === "codex" || provider === "claude" ? async () => "host" : undefined, async createSession() { throw new Error("not implemented"); }, @@ -121,7 +153,7 @@ describe("ProviderSnapshotManager public surface", () => { try { manager.replacePluginProviders([registration]); - expect(manager.getSnapshot("/tmp/project")).toContainEqual( + expect(manager.getSnapshot("/tmp/project").records.map(({ entry }) => entry)).toContainEqual( expect.objectContaining({ provider: "icon-provider", iconSvg }), ); } finally { @@ -253,7 +285,7 @@ describe("ProviderSnapshotManager public surface", () => { test("getSnapshot returns loading entries for built-in providers before warmup", () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger() }); try { - const snapshot = manager.getSnapshot("/tmp/project"); + const snapshot = manager.getSnapshot("/tmp/project").records.map(({ entry }) => entry); const claude = snapshot.find((entry) => entry.provider === "claude"); const codex = snapshot.find((entry) => entry.provider === "codex"); expect(claude?.status).toBe("loading"); @@ -496,6 +528,7 @@ describe("ProviderSnapshotManager public surface", () => { test("defaults provider refreshes to a two-minute deadline", async () => { vi.useFakeTimers(); + let recovered = false; const manager = new ProviderSnapshotManager({ logger: createTestLogger(), providerOverrides: { @@ -505,7 +538,9 @@ describe("ProviderSnapshotManager public surface", () => { pi: { enabled: false }, }, extraClients: { - codex: createExtraClient("codex", { isAvailable: waitUntilAborted }), + codex: createExtraClient("codex", { + isAvailable: (signal) => (recovered ? Promise.resolve(true) : waitUntilAborted(signal)), + }), }, }); @@ -522,6 +557,14 @@ describe("ProviderSnapshotManager public surface", () => { status: "error", error: "Timed out refreshing Codex after 120000ms; pending: availability", }); + recovered = true; + await manager.refreshSnapshotForCwd({ cwd: "/tmp/project", providers: ["codex"] }); + const ready = manager.getSnapshot("/tmp/project"); + const transitions: unknown[] = []; + manager.on("change", (transition) => transitions.push(transition)); + await manager.refreshSnapshotForCwd({ cwd: "/tmp/project", providers: ["codex"] }); + expect(manager.getSnapshot("/tmp/project")).toBe(ready); + expect(transitions).toEqual([]); } finally { manager.destroy(); vi.useRealTimers(); @@ -611,7 +654,8 @@ describe("ProviderSnapshotManager public surface", () => { await manager.refreshSnapshotForCwd({ cwd: "/tmp/project", providers: ["codex"] }); expect(fetchCatalog).toHaveBeenCalledTimes(2); expect( - manager.getSnapshot("/tmp/project").find((entry) => entry.provider === "codex"), + manager.getSnapshot("/tmp/project").records.find(({ entry }) => entry.provider === "codex") + ?.entry, ).toMatchObject({ provider: "codex", status: "ready" }); } finally { manager.destroy(); @@ -889,7 +933,7 @@ describe("ProviderSnapshotManager public surface", () => { { provider: "codex", id: "gpt-5.4-mini", label: "GPT 5.4 Mini" }, ]; const catalogModes: AgentMode[] = [{ id: "agent", label: "Agent" }]; - const fetchCatalog = vi.fn(async () => ({ + const fetchCatalog = vi.fn(async (_options: FetchCatalogOptions) => ({ models: catalogModels, modes: catalogModes, })); @@ -1174,7 +1218,7 @@ describe("ProviderSnapshotManager public surface", () => { async fetchCatalog() { return { models: [] as AgentModelDefinition[], modes: childModes }; }, - async resolveCreateConfig(input) { + resolveCreateConfig(input) { resolverInputs.push(input); return { modeId: input.parent?.isUnattended ? "child-unattended" : undefined, @@ -1196,13 +1240,14 @@ describe("ProviderSnapshotManager public surface", () => { }, }); try { - const parent = { + const parent: ManagedAgent = { + ...CLOSED_PARENT, id: "parent-agent", provider: "claude", currentModeId: "parent-unattended", availableModes: parentModes, config: { provider: "claude", cwd: "/tmp/project" }, - } as ManagedAgent; + }; const resolved = await manager.resolveCreateConfig({ cwd: "/tmp/project", @@ -1252,7 +1297,7 @@ describe("ProviderSnapshotManager public surface", () => { async fetchCatalog() { return { models: [] as AgentModelDefinition[], modes }; }, - async resolveCreateConfig(input) { + resolveCreateConfig(input) { resolverInputs.push(input); return { modeId: input.unattended ? "worker" : undefined, @@ -1317,7 +1362,8 @@ describe("ProviderSnapshotManager public surface", () => { }, }); try { - const parent = { + const parent: ManagedAgent = { + ...CLOSED_PARENT, id: "parent-agent", provider: "opencode", currentModeId: "orchestrator", @@ -1327,7 +1373,7 @@ describe("ProviderSnapshotManager public surface", () => { cwd: "/tmp/project", featureValues: { auto_accept: true }, }, - } as ManagedAgent; + }; const resolved = await manager.resolveCreateConfig({ cwd: "/tmp/project", @@ -1367,9 +1413,12 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { expect(manager.hasProvider("zai-claude")).toBe(true); expect(state.providerDefinitions["zai-claude"]).toMatchObject({ enabled: true }); expect(manager.listRegisteredProviderIds()).toContain("zai-claude"); - expect(manager.getSnapshot().find((entry) => entry.provider === "zai-claude")?.source).toBe( - "custom", - ); + expect( + manager + .getSnapshot() + .records.map(({ entry }) => entry) + .find((entry) => entry.provider === "zai-claude")?.source, + ).toBe("custom"); } finally { manager.destroy(); } @@ -1389,7 +1438,12 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { expect(manager.hasProvider("zai-claude")).toBe(false); expect(state.providerDefinitions["zai-claude"]).toBeUndefined(); - expect(manager.getSnapshot().some((entry) => entry.provider === "zai-claude")).toBe(false); + expect( + manager + .getSnapshot() + .records.map(({ entry }) => entry) + .some((entry) => entry.provider === "zai-claude"), + ).toBe(false); manager.applyMutableProviderConfig({ codex: { enabled: false } }); expect(manager.hasProvider("zai-claude")).toBe(false); @@ -1451,7 +1505,7 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { "zai-claude": { extends: "claude", label: "ZAI", enabled: true }, }); - const cwds = listener.mock.calls.map((call) => call[1]).sort(); + const cwds = [...new Set(listener.mock.calls.map((call) => call[0].current.cwd))].sort(); expect(cwds).toEqual([cwdA, cwdB].sort()); } finally { manager.destroy(); @@ -1494,7 +1548,7 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { try { for (const cwd of cwds) await manager.warmUpSnapshotForCwd({ cwd }); const getCodexEntry = (cwd: string) => - manager.getSnapshot(cwd).find((entry) => entry.provider === "codex"); + manager.getSnapshot(cwd).records.find(({ entry }) => entry.provider === "codex")?.entry; const before = cwds.map(getCodexEntry); const definition = manager.getAgentManagerProviderState().providerDefinitions.codex; manager.applyMutableProviderConfig( @@ -1504,7 +1558,7 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { expect(cwds.map(getCodexEntry)).toEqual(before); expect(manager.getAgentManagerProviderState().providerDefinitions.codex).toEqual(definition); for (const cwd of cwds) await manager.warmUpSnapshotForCwd({ cwd }); - expect(calls).toEqual({ claude: 4, codex: 2 }); + expect(calls).toEqual({ claude: 2, codex: 1 }); const listener = vi.fn(); manager.on("change", listener); manager.applyMutableProviderConfig( @@ -1512,14 +1566,14 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { { replace: true }, ); for (const cwd of cwds) await manager.warmUpSnapshotForCwd({ cwd }); - expect(calls).toEqual({ claude: 4, codex: 2 }); + expect(calls).toEqual({ claude: 2, codex: 1 }); expect(listener).not.toHaveBeenCalled(); } finally { manager.destroy(); } }); - test("reload preserves an unchanged lookup in flight through commit and rollback", async () => { + test("reload preserves an unchanged lookup in flight through commit and discarded preparation", async () => { let finish!: () => void; const gate = new Promise((complete) => { finish = complete; @@ -1557,14 +1611,19 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { { ...config, claude: { enabled: false, label: "Changed" } }, { replace: true }, ); - const staged = manager.stageMutableProviderConfig( + const staged = manager.prepareMutableProviderConfig( { ...config, claude: { enabled: false, label: "Rollback" } }, { replace: true }, ); - staged.rollback(); + expect(staged.agentManagerState.providerDefinitions.claude?.enabled).toBe(false); finish(); await pending; - expect(manager.getSnapshot(cwd).find((e) => e.provider === "codex")?.status).toBe("ready"); + expect( + manager + .getSnapshot(cwd) + .records.map(({ entry }) => entry) + .find((e) => e.provider === "codex")?.status, + ).toBe("ready"); expect(calls).toBe(1); } finally { finish(); @@ -1572,7 +1631,7 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { } }); - test("reload replaces derived clients only when their provider configuration changes", () => { + test("reload replaces derived clients only when their provider configuration changes", async () => { const config = { claude: { enabled: true }, codex: { enabled: true }, @@ -1588,6 +1647,18 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { }); try { const before = manager.getAgentManagerProviderState().clients; + for (const provider of ["codex", "claude", "codex-2"]) { + expect( + await before[provider]!.getCatalogCacheKey?.({ scope: "global", force: false }), + ).toBe("host"); + expect( + await before[provider]!.getCatalogCacheKey?.({ + scope: "workspace", + cwd: resolveSnapshotCwd("/project"), + force: true, + }), + ).toBe("host"); + } const unchanged = manager.applyMutableProviderConfig(config, { replace: true }).clients; expect(unchanged.codex).toBe(before.codex); expect(unchanged["codex-2"]).toBe(before["codex-2"]); @@ -1607,72 +1678,95 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { } }); - test("stages provider state without events, then publishes or rolls back", () => { + test("preparation leaves installed reads untouched and commit installs all target memberships before notification", () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger(), - providerOverrides: { - claude: { enabled: false }, - codex: { enabled: true }, - copilot: { enabled: false }, - opencode: { enabled: false }, - pi: { enabled: false }, - }, + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { codex: createExtraClient("codex") }, }); try { - const cwd = resolve("/tmp/project"); - manager.getSnapshot(cwd); - const events: string[] = []; - manager.on("change", (_entries, changedCwd) => events.push(changedCwd)); - - const rolledBack = manager.stageMutableProviderConfig( - { codex: { enabled: false } }, - { replace: true }, + const cwds = ["/tmp/install-a", "/tmp/install-b"]; + const previous = cwds.map((cwd) => manager.getSnapshot(cwd)); + const observations: Array<{ current: ProviderSnapshot; reads: ProviderSnapshot[] }> = []; + manager.on("change", ({ current }) => + observations.push({ + current, + reads: [manager.getSnapshot(cwds[0]), manager.getSnapshot(cwds[1])], + }), ); - expect(events).toEqual([]); - expect(manager.getAgentManagerProviderState().providerDefinitions.codex).toMatchObject({ - enabled: false, - }); - rolledBack.rollback(); - expect(events).toEqual([]); - expect(manager.getAgentManagerProviderState().providerDefinitions.codex).toMatchObject({ - enabled: true, - }); - - const committed = manager.stageMutableProviderConfig( - { codex: { enabled: false } }, - { replace: true }, - ); - expect(events).toEqual([]); - committed.publish(); - expect(events).toEqual([cwd]); + const config = { + ...PUBLICATION_PROVIDERS, + codex: { enabled: false }, + claude: { enabled: false, label: "Changed" }, + custom: { extends: "codex", label: "Custom", enabled: false }, + }; + const prepared = manager.prepareMutableProviderConfig(config, { replace: true }); + expect(prepared.agentManagerState.providerDefinitions.custom?.enabled).toBe(false); + expect(manager.hasProvider("custom")).toBe(false); + expect(manager.listRegisteredProviderIds()).not.toContain("custom"); + expect(manager.getAgentManagerProviderState().providerDefinitions.codex?.enabled).toBe(true); + expect(cwds.map((cwd) => manager.getSnapshot(cwd))).toEqual(previous); + expect(manager.getSnapshot(cwds[0])).toBe(previous[0]); + expect(observations).toEqual([]); + expect(() => + manager.prepareMutableProviderConfig( + { custom: { extends: "missing-provider" } }, + { replace: true }, + ), + ).toThrow(); + expect(manager.getSnapshot(cwds[0])).toBe(previous[0]); + prepared.commit(); + const committed = cwds.map((cwd) => manager.getSnapshot(cwd)); + expect(observations).toHaveLength(2); + for (const [index, observation] of observations.entries()) { + expect(observation.current).toBe(committed[index]); + expect(observation.reads[0]).toBe(committed[0]); + expect(observation.reads[1]).toBe(committed[1]); + } + expect(manager.hasProvider("custom")).toBe(true); + expect( + committed[0]!.records.map(({ entry }) => [entry.provider, entry.status, entry.label]), + ).toContainEqual(["codex", "unavailable", "Codex"]); + expect(manager.getProviderLabel("claude")).toBe("Changed"); } finally { manager.destroy(); } }); - test("restores provider state when applying a live config fails", () => { + test("subscriber failures cannot change discovery results, roll back commits or starve another listener", async () => { + const logs: string[] = []; + const logger = pino({ level: "warn" }, { write: (line) => logs.push(line) }); const manager = new ProviderSnapshotManager({ - logger: createTestLogger(), - providerOverrides: { - claude: { enabled: false }, - codex: { enabled: true }, - copilot: { enabled: false }, - opencode: { enabled: false }, - pi: { enabled: false }, - }, + logger, + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { codex: createExtraClient("codex", { isAvailable: async () => true }) }, }); - manager.getSnapshot("/tmp/project"); + const transitions: ProviderSnapshot[] = []; manager.on("change", () => { - throw new Error("snapshot consumer failed"); + throw new Error("subscriber failed"); }); - + manager.on("change", ({ current }) => transitions.push(current)); try { - expect(() => - manager.applyMutableProviderConfig({ codex: { enabled: false } }, { replace: true }), - ).toThrow("snapshot consumer failed"); - expect(manager.getAgentManagerProviderState().providerDefinitions.codex).toMatchObject({ - enabled: true, - }); + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/subscribers" }); + expect( + manager + .getSnapshot("/tmp/subscribers") + .records.find(({ entry }) => entry.provider === "codex")!.entry.status, + ).toBe("ready"); + manager.applyMutableProviderConfig( + { ...PUBLICATION_PROVIDERS, codex: { enabled: false } }, + { replace: true }, + ); + expect(manager.getAgentManagerProviderState().providerDefinitions.codex?.enabled).toBe(false); + const statuses = []; + for (const { records } of transitions) { + statuses.push(records.find(({ entry }) => entry.provider === "codex")!.entry.status); + } + expect(statuses).toEqual(["ready", "unavailable"]); + expect(logs.map((line) => JSON.parse(line))).toMatchObject([ + { msg: "Provider snapshot subscriber failed", err: { message: "subscriber failed" } }, + { msg: "Provider snapshot subscriber failed", err: { message: "subscriber failed" } }, + ]); } finally { manager.destroy(); } @@ -1703,23 +1797,24 @@ describe("ProviderSnapshotManager lifecycle", () => { }; const initialClient = trackShutdown(manager.getAgentManagerProviderState().clients.codex); - const published = manager.stageMutableProviderConfig(providerConfig("Published"), { + const published = manager.prepareMutableProviderConfig(providerConfig("Published"), { replace: true, }); const publishedClient = trackShutdown(published.agentManagerState.clients.codex); - published.publish(); + published.commit(); - const rolledBack = manager.stageMutableProviderConfig(providerConfig("Rolled back"), { + const rolledBack = manager.prepareMutableProviderConfig(providerConfig("Rolled back"), { replace: true, }); trackShutdown(rolledBack.agentManagerState.clients.codex); - rolledBack.rollback(); expect(manager.getAgentManagerProviderState().clients.codex).toBe(publishedClient); - const newest = manager.stageMutableProviderConfig(providerConfig("Newest"), { replace: true }); + const newest = manager.prepareMutableProviderConfig(providerConfig("Newest"), { + replace: true, + }); const newestClient = trackShutdown(newest.agentManagerState.clients.codex); - newest.publish(); + newest.commit(); expect(initialClient).not.toBe(publishedClient); expect(manager.getAgentManagerProviderState().clients.codex).toBe(newestClient); @@ -1781,8 +1876,130 @@ describe("ProviderSnapshotManager lifecycle", () => { }); describe("ProviderSnapshotManager cwd routing", () => { + test("deduplicates cold global catalogue discovery across concurrent workspace reads", async () => { + let release!: () => void; + let started!: () => void; + const pending = new Promise((resolvePending) => { + release = resolvePending; + }); + const discoveryStarted = new Promise((resolveStarted) => { + started = resolveStarted; + }); + const requests: FetchCatalogOptions[] = []; + let availabilityProbes = 0; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + async getCatalogCacheKey() { + return "host"; + }, + async isAvailable() { + availabilityProbes++; + return true; + }, + async fetchCatalog(options) { + requests.push(options); + started(); + await pending; + return { + models: [{ provider: "codex", id: "gpt-6-astra", label: "GPT-6 Astra" }], + modes: [], + }; + }, + }), + }, + }); + try { + const reads = Promise.all( + [ + undefined, + resolveSnapshotCwd("/tmp/concurrent-a"), + resolveSnapshotCwd("/tmp/concurrent-b"), + ].map((cwd) => manager.getProvider({ provider: "codex", cwd, wait: true })), + ); + await discoveryStarted; + expect(requests).toEqual([{ scope: "global", force: false }]); + release(); + const entries = await reads; + expect(entries.map((entry) => entry.models?.[0]?.id)).toEqual([ + "gpt-6-astra", + "gpt-6-astra", + "gpt-6-astra", + ]); + expect(requests).toHaveLength(1); + expect(availabilityProbes).toBe(1); + } finally { + release(); + await manager.shutdown(); + manager.destroy(); + } + }); + + test.each(["codex", "custom-codex"])( + "shares %s discovery and refresh recovery across snapshot directories", + async (provider) => { + const requests: FetchCatalogOptions[] = []; + let available = false; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + "custom-codex": { extends: "codex", label: "Custom Codex", enabled: true }, + }, + extraClients: { + [provider]: createExtraClient(provider, { + async getCatalogCacheKey() { + return "host"; + }, + async isAvailable() { + return available; + }, + async fetchCatalog(options) { + requests.push(options); + return { + models: [{ provider, id: "gpt-6-astra", label: "GPT-6 Astra" }], + modes: [], + }; + }, + }), + }, + }); + try { + expect(await manager.getProvider({ provider, wait: true })).toMatchObject({ + status: "unavailable", + }); + available = true; + await manager.refreshSnapshotForCwd({ + cwd: resolveSnapshotCwd("/tmp/model-memory-a"), + providers: [provider], + }); + expect(await manager.getProvider({ provider, wait: true })).toMatchObject({ + status: "ready", + }); + await Promise.all([ + manager.getProvider({ + provider, + cwd: resolveSnapshotCwd("/tmp/model-memory-a"), + wait: true, + }), + manager.getProvider({ + provider, + cwd: resolveSnapshotCwd("/tmp/model-memory-b"), + wait: true, + }), + ]); + expect(requests).toEqual([ + { scope: "workspace", cwd: resolveSnapshotCwd("/tmp/model-memory-a"), force: true }, + ]); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }, + ); + test("settings refresh passes the semantic global scope to providers", async () => { - const fetchCatalog = vi.fn(async () => ({ + const fetchCatalog = vi.fn(async (_options: FetchCatalogOptions) => ({ models: [] as AgentModelDefinition[], modes: [] as AgentMode[], })); @@ -1805,22 +2022,22 @@ describe("ProviderSnapshotManager cwd routing", () => { }); test("global snapshot does not satisfy an explicit home workspace read", async () => { - const fetchCatalog = vi.fn(async () => ({ + const fetchCatalog = vi.fn(async (_options: FetchCatalogOptions) => ({ models: [] as AgentModelDefinition[], modes: [] as AgentMode[], })); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), extraClients: { - codex: createExtraClient("codex", { + pi: createExtraClient("pi", { isAvailable: vi.fn(async () => true), fetchCatalog, }), }, }); try { - await manager.refreshSettingsSnapshot({ providers: ["codex"] }); - await manager.listProviders({ cwd: homedir(), providers: ["codex"], wait: true }); + await manager.refreshSettingsSnapshot({ providers: ["pi"] }); + await manager.listProviders({ cwd: homedir(), providers: ["pi"], wait: true }); expect(fetchCatalog.mock.calls.map((call) => call[0])).toEqual([ expect.objectContaining({ scope: "global", force: true }), @@ -1847,8 +2064,12 @@ describe("ProviderSnapshotManager cwd routing", () => { }, }); try { - const a = manager.getSnapshot("/tmp/project-a"); - const b = manager.getSnapshot("/tmp/project-b"); + const a = manager + .getSnapshot(resolveSnapshotCwd("/tmp/project-a")) + .records.map(({ entry }) => entry); + const b = manager + .getSnapshot(resolveSnapshotCwd("/tmp/project-b")) + .records.map(({ entry }) => entry); expect(a).not.toBe(b); expect(a.map((entry) => entry.provider).sort()).toEqual( b.map((entry) => entry.provider).sort(), @@ -1874,13 +2095,149 @@ describe("ProviderSnapshotManager cwd routing", () => { manager.on("change", listener); manager.getSnapshot(); manager.applyMutableProviderConfig({ claude: { enabled: false, label: "Changed" } }); - const cwds = listener.mock.calls.map((call) => call[1]); + const cwds = listener.mock.calls.map((call) => call[0].current.cwd); expect(cwds).toContain(GLOBAL_PROVIDER_SNAPSHOT_KEY); } finally { manager.destroy(); } }); + test.each([ + { sharing: "shared", expectedCwds: [resolveSnapshotCwd("/tmp/plugin-a")] }, + { + sharing: "distinct", + expectedCwds: [ + undefined, + resolveSnapshotCwd("/tmp/plugin-a"), + resolveSnapshotCwd("/tmp/plugin-b"), + ], + }, + { + sharing: "absent", + expectedCwds: [ + undefined, + resolveSnapshotCwd("/tmp/plugin-a"), + resolveSnapshotCwd("/tmp/plugin-b"), + ], + }, + ])( + "uses the plugin's provider-owned catalogue key: $sharing", + async ({ sharing, expectedCwds }) => { + let listener: ((event: ProviderEvent) => void) | null = null; + const cwds: Array = []; + const registration: ProviderRegistration = { + id: "scoped-plugin", + label: "Scoped plugin", + getCatalogCacheKey: + sharing === "absent" + ? undefined + : async (options) => + sharing === "shared" + ? "host" + : JSON.stringify(options.scope === "workspace" ? options.cwd : null), + async connect() { + return { + version: 1, + capabilities: [], + async send(input) { + if (input.type !== "catalog") return; + cwds.push(input.cwd); + listener?.({ + type: "catalog", + requestId: input.requestId, + catalog: { + models: [{ id: input.cwd ?? "global-model", label: "Model" }], + modes: [], + }, + }); + }, + onEvent(next) { + listener = next; + return () => { + listener = null; + }; + }, + async close() {}, + }; + }, + }; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + codex: { enabled: false }, + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + }); + try { + // Register after workspace views exist, as happens when reloading a plugin. + for (const cwd of [ + resolveSnapshotCwd("/tmp/plugin-a"), + resolveSnapshotCwd("/tmp/plugin-b"), + undefined, + ]) { + await manager.listProviders({ cwd, wait: true }); + } + const published: ProviderSnapshot[] = []; + manager.on("change", ({ current }) => published.push(current)); + manager.replacePluginProviders([registration]); + for (const cwd of [ + undefined, + resolveSnapshotCwd("/tmp/plugin-a"), + resolveSnapshotCwd("/tmp/plugin-b"), + ]) { + const entry = await manager.getProvider({ provider: registration.id, cwd, wait: true }); + expect(entry.status).toBe("ready"); + expect(entry.models?.[0]?.id).toBe( + sharing === "shared" ? resolveSnapshotCwd("/tmp/plugin-a") : (cwd ?? "global-model"), + ); + } + expect(cwds).toEqual(expect.arrayContaining(expectedCwds)); + expect(cwds).toHaveLength(expectedCwds.length); + for (const snapshot of published) { + expect(snapshot.records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + entry: expect.objectContaining({ provider: registration.id }), + }), + ]), + ); + } + expect(new Set(published.map((snapshot) => snapshot.cwd))).toEqual( + new Set([ + GLOBAL_PROVIDER_SNAPSHOT_KEY, + resolveSnapshotCwd("/tmp/plugin-a"), + resolveSnapshotCwd("/tmp/plugin-b"), + ]), + ); + const lastFetchedAt = manager + .getSnapshot(resolveSnapshotCwd("/tmp/plugin-a")) + .records.find(({ entry }) => entry.provider === registration.id)!.entry.fetchedAt!; + await vi.waitFor(() => expect(Date.now()).toBeGreaterThan(Date.parse(lastFetchedAt))); + published.length = 0; + await manager.refreshSnapshotForCwd({ + cwd: resolveSnapshotCwd("/tmp/plugin-a"), + providers: [registration.id], + }); + const affectedCwds = + sharing === "shared" + ? [ + GLOBAL_PROVIDER_SNAPSHOT_KEY, + resolveSnapshotCwd("/tmp/plugin-a"), + resolveSnapshotCwd("/tmp/plugin-b"), + ] + : [resolveSnapshotCwd("/tmp/plugin-a")]; + expect(new Set(published.map((snapshot) => snapshot.cwd))).toEqual(new Set(affectedCwds)); + expect(published).toHaveLength(affectedCwds.length); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }, + ); + test("registers and unregisters plugin providers without rebuilding built-in clients", async () => { let listener: ((event: ProviderEvent) => void) | null = null; const registration: ProviderRegistration = { @@ -1939,3 +2296,1081 @@ describe("ProviderSnapshotManager cwd routing", () => { } }); }); + +describe("provider-owned catalogue identity", () => { + const disabledProviders = { + codex: { enabled: false }, + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }; + + test("shares effective runtime/configuration keys, preserves targets, and isolates provider identities", async () => { + const identities = new Map([ + [resolveSnapshotCwd("/a"), "runtime-1/config-1"], + [resolveSnapshotCwd("/b"), "runtime-1/config-1"], + [resolveSnapshotCwd("/c"), "runtime-2/config-1"], + ]); + const probes: Array<{ provider: string; options: FetchCatalogOptions | undefined }> = []; + const discoveries: Array<{ provider: string; options: FetchCatalogOptions }> = []; + const client = (provider: string) => + createExtraClient(provider, { + async getCatalogCacheKey(options) { + return options.scope === "workspace" ? identities.get(options.cwd) : undefined; + }, + async isAvailable(_signal, options) { + probes.push({ provider, options }); + return true; + }, + async fetchCatalog(options) { + discoveries.push({ provider, options }); + return { + models: [{ provider, id: `${provider}-${discoveries.length}`, label: "Model" }], + modes: [], + }; + }, + }); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + ...disabledProviders, + first: { extends: "codex", label: "First", enabled: true }, + second: { extends: "codex", label: "Second", enabled: true }, + }, + extraClients: { first: client("first"), second: client("second") }, + }); + try { + const read = (cwd: string, provider = "first") => + manager.getProvider({ provider, cwd, wait: true }); + const [a, b, c] = await Promise.all([ + read(resolveSnapshotCwd("/a")), + read(resolveSnapshotCwd("/b")), + read(resolveSnapshotCwd("/c")), + ]); + expect(a.models).toEqual(b.models); + expect(c.models).not.toEqual(a.models); + expect(discoveries).toHaveLength(2); + expect(probes).toEqual(discoveries); + expect(discoveries.map(({ options }) => options)).toEqual([ + { scope: "workspace", cwd: resolveSnapshotCwd("/a"), force: false }, + { scope: "workspace", cwd: resolveSnapshotCwd("/c"), force: false }, + ]); + await read(resolveSnapshotCwd("/a"), "second"); + expect(discoveries).toHaveLength(3); + identities.set(resolveSnapshotCwd("/b"), "runtime-2/config-1"); + expect((await read(resolveSnapshotCwd("/b"))).models).toEqual(c.models); + expect(discoveries).toHaveLength(3); + identities.set(resolveSnapshotCwd("/a"), "runtime-1/config-2"); + expect((await read(resolveSnapshotCwd("/a"))).models).not.toEqual(a.models); + expect(discoveries).toHaveLength(4); + await manager.refreshSnapshotForCwd({ cwd: resolveSnapshotCwd("/b"), providers: ["first"] }); + expect(discoveries).toHaveLength(5); + expect(discoveries[4]?.options).toEqual({ + scope: "workspace", + cwd: resolveSnapshotCwd("/b"), + force: true, + }); + expect((await read(resolveSnapshotCwd("/c"))).models).toEqual( + (await read(resolveSnapshotCwd("/b"))).models, + ); + expect(discoveries).toHaveLength(5); + expect(probes).toEqual(discoveries); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }); + + test("a refresh supersedes an older in-flight result for the same provider key", async () => { + let release!: () => void; + let started!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + const began = new Promise((finish) => { + started = finish; + }); + let calls = 0; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { ...disabledProviders, codex: { enabled: true } }, + extraClients: { + codex: createExtraClient("codex", { + async getCatalogCacheKey() { + return "runtime/config"; + }, + async isAvailable() { + return true; + }, + async fetchCatalog() { + const call = ++calls; + if (call === 1) { + started(); + await pending; + } + return { + models: [{ provider: "codex", id: `model-${call}`, label: "Model" }], + modes: [], + }; + }, + }), + }, + }); + try { + const first = manager.getProvider({ + provider: "codex", + cwd: resolveSnapshotCwd("/a"), + wait: true, + }); + await began; + await manager.refreshSnapshotForCwd({ cwd: resolveSnapshotCwd("/b"), providers: ["codex"] }); + release(); + await first; + for (const cwd of [resolveSnapshotCwd("/a"), resolveSnapshotCwd("/b")]) { + expect( + (await manager.getProvider({ provider: "codex", cwd, wait: true })).models?.[0]?.id, + ).toBe("model-2"); + } + expect(calls).toBe(2); + } finally { + release(); + await manager.shutdown(); + manager.destroy(); + } + }); + + test("key lookup failure detaches a view from its previous shared catalogue", async () => { + let broken = false; + let calls = 0; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { ...disabledProviders, codex: { enabled: true } }, + extraClients: { + codex: createExtraClient("codex", { + async getCatalogCacheKey(options) { + if (broken && options.scope === "workspace" && options.cwd === resolveSnapshotCwd("/a")) + throw new Error("configuration unreadable"); + return "runtime/config"; + }, + async isAvailable() { + return true; + }, + async fetchCatalog() { + calls++; + return { + models: [{ provider: "codex", id: `model-${calls}`, label: "Model" }], + modes: [], + }; + }, + }), + }, + }); + try { + for (const cwd of [resolveSnapshotCwd("/a"), resolveSnapshotCwd("/b")]) + await manager.getProvider({ provider: "codex", cwd, wait: true }); + broken = true; + expect( + await manager.getProvider({ provider: "codex", cwd: resolveSnapshotCwd("/a"), wait: true }), + ).toMatchObject({ status: "error", error: "configuration unreadable" }); + const published: string[] = []; + manager.on("change", ({ current }) => published.push(current.cwd)); + await manager.refreshSnapshotForCwd({ cwd: resolveSnapshotCwd("/b"), providers: ["codex"] }); + expect(published).toEqual([resolveSnapshotCwd("/b")]); + expect(calls).toBe(2); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }); +}); + +test("late key resolution cannot restore an old identity and concurrent readers wait for discovery", async () => { + let releaseKey!: () => void; + let releaseCatalog!: () => void; + let catalogStarted!: () => void; + const keyPending = new Promise((finish) => { + releaseKey = finish; + }); + const catalogPending = new Promise((finish) => { + releaseCatalog = finish; + }); + const began = new Promise((finish) => { + catalogStarted = finish; + }); + let keyCalls = 0; + let probes = 0; + const targets: FetchCatalogOptions[] = []; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + async getCatalogCacheKey() { + if (++keyCalls === 1) { + await keyPending; + return "old-runtime"; + } + return "current-runtime"; + }, + async isAvailable() { + probes++; + return true; + }, + async fetchCatalog(options) { + targets.push(options); + catalogStarted(); + await catalogPending; + return { models: [{ provider: "codex", id: "current", label: "Current" }], modes: [] }; + }, + }), + }, + }); + try { + const first = manager.getProvider({ + provider: "codex", + cwd: resolveSnapshotCwd("/project"), + wait: true, + }); + const second = manager.getProvider({ + provider: "codex", + cwd: resolveSnapshotCwd("/project"), + wait: true, + }); + await began; + const third = manager.getProvider({ + provider: "codex", + cwd: resolveSnapshotCwd("/project"), + wait: true, + }); + releaseKey(); + releaseCatalog(); + const results = await Promise.all([first, second, third]); + expect(results.map((result) => result.models?.[0]?.id)).toEqual([ + "current", + "current", + "current", + ]); + expect(targets).toEqual([ + { scope: "workspace", cwd: resolveSnapshotCwd("/project"), force: false }, + ]); + expect(probes).toBe(1); + } finally { + releaseKey(); + releaseCatalog(); + await manager.shutdown(); + manager.destroy(); + } +}); + +test("settings refresh starts independent workspace discoveries together and shares equivalent catalogues", async () => { + let release!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + let refreshing = false; + const started: string[] = []; + let sharedFetches = 0; + const cwds = [resolveSnapshotCwd("/refresh-a"), resolveSnapshotCwd("/refresh-b")]; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + pi: createExtraClient("pi", { + async isAvailable() { + return true; + }, + async fetchCatalog(options) { + if (refreshing && options.scope === "workspace") { + started.push(options.cwd); + await pending; + } + return { models: [], modes: [] }; + }, + }), + codex: createExtraClient("codex", { + async isAvailable() { + return true; + }, + async fetchCatalog() { + sharedFetches++; + return { models: [], modes: [] }; + }, + }), + }, + }); + let refresh: Promise | undefined; + try { + await Promise.all( + cwds.map((cwd) => manager.warmUpSnapshotForCwd({ cwd, providers: ["pi", "codex"] })), + ); + expect(sharedFetches).toBe(1); + refreshing = true; + refresh = manager.refreshSettingsSnapshot({ providers: ["pi", "codex"] }); + await expect.poll(() => [...started].sort()).toEqual([...cwds].sort()); + expect(sharedFetches).toBe(2); + release(); + await refresh; + } finally { + release(); + await refresh; + await manager.shutdown(); + manager.destroy(); + } +}); + +test("bounds each provider across workspaces without blocking another provider", async () => { + let release!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + let refreshing = false; + let peerFetches = 0; + let active = 0; + let peak = 0; + const started = new Set(); + const updated = new Set(); + const cwds = Array.from({ length: 16 }, (_, index) => resolveSnapshotCwd(`/bounded-${index}`)); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + async isAvailable() { + return true; + }, + async fetchCatalog() { + peerFetches++; + return { models: [], modes: [] }; + }, + }), + pi: createExtraClient("pi", { + async isAvailable(_signal, options) { + if (refreshing && options?.scope === "workspace") { + started.add(options.cwd); + peak = Math.max(peak, ++active); + await pending; + active--; + } + return true; + }, + async fetchCatalog() { + return { + models: [{ provider: "pi", id: refreshing ? "new" : "old", label: "Model" }], + modes: [], + }; + }, + }), + }, + }); + const requests: Promise[] = []; + try { + await Promise.all(cwds.map((cwd) => manager.warmUpSnapshotForCwd({ cwd, providers: ["pi"] }))); + manager.on("change", ({ current }) => { + if ( + current.records.find((record) => record.entry.provider === "pi")?.entry.models?.[0]?.id === + "new" + ) { + updated.add(current.cwd); + } + }); + refreshing = true; + requests.push(manager.refreshSettingsSnapshot({ providers: ["pi"] })); + await expect.poll(() => started.size).toBeGreaterThanOrEqual(4); + requests.push(manager.warmUpSnapshotForCwd({ cwd: "/concurrent-read", providers: ["pi"] })); + expect(peak).toBe(4); + requests.push( + manager.warmUpSnapshotForCwd({ cwd: "/independent-provider", providers: ["codex"] }), + ); + await expect.poll(() => peerFetches).toBe(1); + release(); + await Promise.all(requests); + expect(started.size).toBe(cwds.length + 1); + expect(peak).toBe(4); + // Other sessions receive refreshed views without initiating a scoped read. + expect(cwds.every((cwd) => updated.has(cwd))).toBe(true); + } finally { + release(); + await Promise.all(requests); + await manager.shutdown(); + manager.destroy(); + } +}); + +test.each(["configuration", "shutdown", "destroy"])( + "discards queued discovery after %s", + async (action) => { + let release!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + const calls: string[] = []; + let keys = 0; + const cwds = Array.from({ length: 5 }, (_, index) => resolveSnapshotCwd(`/queued-${index}`)); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { ...PUBLICATION_PROVIDERS, pi: { enabled: true, label: "Initial" } }, + extraClients: { + pi: createExtraClient("pi", { + async getCatalogCacheKey() { + keys++; + return undefined; + }, + async isAvailable() { + return true; + }, + async fetchCatalog(options) { + if (options.scope === "workspace") calls.push(options.cwd); + await pending; + return { models: [], modes: [] }; + }, + }), + }, + }); + const requests = cwds.map((cwd) => manager.warmUpSnapshotForCwd({ cwd, providers: ["pi"] })); + let transitions = 0; + manager.on("change", () => { + transitions++; + }); + try { + await expect.poll(() => keys).toBe(5); + await expect.poll(() => calls.length).toBeGreaterThanOrEqual(4); + expect(calls).toEqual(cwds.slice(0, 4)); + let queuedSettled = false; + void requests[4]!.then(() => { + queuedSettled = true; + return undefined; + }); + if (action === "configuration") { + manager.applyMutableProviderConfig({ + ...PUBLICATION_PROVIDERS, + pi: { enabled: true, label: "Updated" }, + }); + await expect.poll(() => queuedSettled).toBe(true); + // Replacing configuration must not reset the budget for still-active probes. + expect(calls).toEqual(cwds.slice(0, 4)); + release(); + await manager.warmUpSnapshotForCwd({ cwd: cwds[4], providers: ["pi"] }); + await Promise.all(requests); + expect(calls.filter((cwd) => cwd === cwds[4])).toHaveLength(1); + } else { + const beforeStop = transitions; + if (action === "shutdown") await manager.shutdown(); + else manager.destroy(); + await expect.poll(() => queuedSettled).toBe(true); + release(); + await Promise.all(requests); + expect(calls).toEqual(cwds.slice(0, 4)); + expect(transitions).toBe(beforeStop); + } + } finally { + release(); + await Promise.all(requests); + await manager.shutdown(); + manager.destroy(); + } + }, +); + +test("settings invalidation discards pending discovery even when the provider returns the same key", async () => { + let release!: () => void; + let started!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + const began = new Promise((finish) => { + started = finish; + }); + let calls = 0; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + async getCatalogCacheKey() { + return "host"; + }, + async isAvailable() { + return true; + }, + async fetchCatalog() { + const call = ++calls; + if (call === 1) { + started(); + await pending; + } + return { + models: [{ provider: "codex", id: `model-${call}`, label: "Model" }], + modes: [], + }; + }, + }), + }, + }); + try { + const old = manager.getProvider({ + provider: "codex", + cwd: resolveSnapshotCwd("/project"), + wait: true, + }); + await began; + await manager.refreshSettingsSnapshot({ providers: ["codex"] }); + release(); + await old; + expect( + ( + await manager.getProvider({ + provider: "codex", + cwd: resolveSnapshotCwd("/project"), + wait: true, + }) + ).models?.[0]?.id, + ).toBe("model-2"); + expect(calls).toBe(2); + } finally { + release(); + await manager.shutdown(); + manager.destroy(); + } +}); + +test("an unchanged provider publishes a pending key failure after an unrelated config reload", async () => { + let release!: () => void; + let started!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + const began = new Promise((finish) => { + started = finish; + }); + const config = { + codex: { enabled: true }, + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + omp: { enabled: false }, + }; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: config, + extraClients: { + codex: createExtraClient("codex", { + async getCatalogCacheKey() { + started(); + await pending; + throw new Error("unreadable config"); + }, + }), + }, + }); + try { + const read = manager.getProvider({ + provider: "codex", + cwd: resolveSnapshotCwd("/project"), + wait: true, + }); + await began; + manager.applyMutableProviderConfig( + { ...config, claude: { enabled: false, label: "Changed" } }, + { replace: true }, + ); + release(); + expect(await read).toMatchObject({ status: "error", error: "unreadable config" }); + } finally { + release(); + await manager.shutdown(); + manager.destroy(); + } +}); + +test("a concurrent cached read cannot swallow a force refresh while its key is resolving", async () => { + let release!: () => void; + let started!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + const began = new Promise((finish) => { + started = finish; + }); + let keyCalls = 0; + let calls = 0; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + async getCatalogCacheKey() { + if (++keyCalls === 2) { + started(); + await pending; + } + return "host"; + }, + async isAvailable() { + return true; + }, + async fetchCatalog() { + return { + models: [{ provider: "codex", id: `model-${++calls}`, label: "Model" }], + modes: [], + }; + }, + }), + }, + }); + const cwd = resolveSnapshotCwd("/project"); + try { + await manager.getProvider({ provider: "codex", cwd, wait: true }); + const refresh = manager.refreshSnapshotForCwd({ providers: ["codex"], cwd }); + await began; + const read = manager.getProvider({ provider: "codex", cwd, wait: true }); + release(); + await refresh; + expect((await read).models?.[0]?.id).toBe("model-2"); + expect(calls).toBe(2); + } finally { + release(); + await manager.shutdown(); + manager.destroy(); + } +}); + +test.each(["workspace", "settings"])( + "%s refresh retains the settled catalogue and publishes only the next result", + async (scope) => { + let release!: () => void; + let started!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + const began = new Promise((finish) => { + started = finish; + }); + let calls = 0; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { + codex: createExtraClient("codex", { + getCatalogCacheKey: async () => "shared", + async isAvailable() { + return true; + }, + async fetchCatalog() { + if (++calls === 2) { + started(); + await pending; + } + return { + models: [{ provider: "codex", id: "astra", label: `Astra ${calls}` }], + modes: [], + }; + }, + }), + }, + }); + try { + await manager.getProvider({ provider: "codex", cwd: "/project", wait: true }); + const before = manager.getSnapshot("/project"); + await manager.warmUpSnapshotForCwd({ cwd: "/project", providers: ["codex"] }); + const statuses: string[] = []; + manager.on("change", ({ current }) => { + if (current.cwd !== resolveSnapshotCwd("/project")) return; + statuses.push( + current.records.find(({ entry }) => entry.provider === "codex")!.entry.status, + ); + }); + const refresh = + scope === "workspace" + ? manager.refreshSnapshotForCwd({ cwd: "/project", providers: ["codex"] }) + : manager.refreshSettingsSnapshot({ providers: ["codex"] }); + await began; + expect(statuses).toEqual([]); + expect(manager.getSnapshot("/project")).toBe(before); + expect( + await manager.getProvider({ provider: "codex", cwd: "/project", wait: false }), + ).toMatchObject({ status: "ready", models: [{ id: "astra", label: "Astra 1" }] }); + release(); + await refresh; + expect(statuses).toEqual(["ready"]); + expect( + await manager.getProvider({ provider: "codex", cwd: "/project", wait: true }), + ).toMatchObject({ status: "ready", models: [{ id: "astra", label: "Astra 2" }] }); + expect(calls).toBe(2); + } finally { + release(); + manager.destroy(); + } + }, +); + +test("publication detaches provider-retained arrays, thinking options and nested metadata", async () => { + const models: AgentModelDefinition[] = [ + { + provider: "codex", + id: "astra", + label: "Astra", + thinkingOptions: [{ id: "high", label: "High" }], + metadata: { nested: { label: "original" } }, + }, + ]; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog: async () => ({ models, modes: [] }), + }), + }, + }); + try { + const published = await manager.getProvider({ provider: "codex", wait: true }); + const record = manager.getSnapshot().records.find(({ entry }) => entry.provider === "codex")!; + const originalRecord = structuredClone(record); + const original = structuredClone(published); + models.push({ provider: "codex", id: "late", label: "Late" }); + models[0]!.thinkingOptions![0]!.label = "mutated"; + (models[0]!.metadata!.nested as { label: string }).label = "mutated"; + expect(record).toEqual(originalRecord); + expect(published).toBe(record.entry); + expect(published).toEqual(original); + expect(await manager.getProvider({ provider: "codex" })).toEqual(original); + } finally { + await manager.shutdown(); + manager.destroy(); + } +}); + +test("snapshot records share provider results across targets while retaining target-specific catalogues", async () => { + const calls = { codex: 0, opencode: 0 }; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: Object.fromEntries( + (["codex", "opencode"] as const).map((provider) => [ + provider, + createExtraClient(provider, { + isAvailable: async () => true, + fetchCatalog: async (options) => { + calls[provider]++; + const target = options.scope === "workspace" ? options.cwd : "global"; + const id = provider === "codex" ? "shared" : target; + return { models: [{ provider, id, label: id }], modes: [] }; + }, + }), + ]), + ), + }); + try { + const targets = [undefined, "/tmp/identity-a", "/tmp/identity-b"]; + await Promise.all( + targets.map((cwd) => + manager.listProviders({ cwd, providers: ["codex", "opencode"], wait: true }), + ), + ); + const snapshots = targets.map((cwd) => manager.getSnapshot(cwd)); + const shared = snapshots.map( + ({ records }) => records.find(({ entry }) => entry.provider === "codex")!, + ); + expect(shared[0]).toBe(shared[1]); + expect(shared[1]).toBe(shared[2]); + expect( + new Set( + snapshots.map( + ({ records }) => records.find(({ entry }) => entry.provider === "opencode")!.contentHash, + ), + ).size, + ).toBe(3); + expect(calls).toEqual({ codex: 1, opencode: 3 }); + expect(await manager.getProvider({ provider: "codex", cwd: targets[1] })).toBe( + shared[0]!.entry, + ); + } finally { + await manager.shutdown(); + manager.destroy(); + } +}); + +test("result identity covers content, metadata and status while unchanged refresh advances only freshness", async () => { + const model: AgentModelDefinition = { + provider: "codex", + id: "astra", + label: "Astra", + metadata: { revision: 1 }, + }; + let available = true; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => available, + fetchCatalog: async () => ({ models: [model], modes: [] }), + }), + }, + }); + const read = () => manager.getSnapshot().records.find(({ entry }) => entry.provider === "codex")!; + const refresh = () => manager.refreshSettingsSnapshot({ providers: ["codex"] }); + try { + await manager.getProvider({ provider: "codex", wait: true }); + const first = read(); + await vi.waitFor(() => expect(Date.now()).toBeGreaterThan(Date.parse(first.entry.fetchedAt!))); + await refresh(); + const fresh = read(); + expect(fresh).not.toBe(first); + expect(fresh.contentHash).toBe(first.contentHash); + expect(Date.parse(fresh.entry.fetchedAt!)).toBeGreaterThan(Date.parse(first.entry.fetchedAt!)); + model.label = "Astra updated"; + await refresh(); + const content = read(); + expect(content.contentHash).not.toBe(fresh.contentHash); + model.metadata!.revision = 2; + await refresh(); + const metadata = read(); + expect(metadata.contentHash).not.toBe(content.contentHash); + available = false; + await refresh(); + const unavailable = read(); + expect(unavailable.entry.status).toBe("unavailable"); + expect(unavailable.contentHash).not.toBe(metadata.contentHash); + } finally { + await manager.shutdown(); + manager.destroy(); + } +}); + +const PUBLICATION_PROVIDERS = Object.fromEntries( + ["claude", "codex", "copilot", "opencode", "pi", "omp"].map((provider) => [ + provider, + { enabled: provider === "codex" }, + ]), +); + +test("reads retain the target snapshot and cold binding publishes only the resolved transition", async () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { codex: createExtraClient("codex", { isAvailable: async () => true }) }, + }); + const changes: unknown[] = []; + manager.on("change", (change) => changes.push(change)); + try { + const initial = manager.getSnapshot("/tmp/published"); + expect(manager.getSnapshot("/tmp/published")).toBe(initial); + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/published" }); + const ready = manager.getSnapshot("/tmp/published"); + expect(ready).not.toBe(initial); + expect(manager.getSnapshot("/tmp/published")).toBe(ready); + expect(changes).toEqual([{ previous: initial, current: ready }]); + } finally { + manager.destroy(); + } +}); + +test("identical key failures and unavailable results complete without duplicate transitions", async () => { + let keyFails = true; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { + codex: createExtraClient("codex", { + getCatalogCacheKey: async () => { + if (keyFails) throw new Error("same key failure"); + return "host"; + }, + }), + }, + }); + const changes: unknown[] = []; + manager.on("change", (change) => changes.push(change)); + try { + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/failure" }); + const failed = manager.getSnapshot("/tmp/failure"); + changes.length = 0; + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/failure" }); + expect(changes).toEqual([]); + expect(manager.getSnapshot("/tmp/failure")).toBe(failed); + keyFails = false; + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/failure" }); + const unavailable = manager.getSnapshot("/tmp/failure"); + changes.length = 0; + await manager.refreshSnapshotForCwd({ cwd: "/tmp/failure", providers: ["codex"] }); + expect(changes).toEqual([]); + expect(manager.getSnapshot("/tmp/failure")).toBe(unavailable); + } finally { + manager.destroy(); + } +}); + +test("shared result publication installs every target before notifying the first subscriber", async () => { + let label = "before"; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog: async () => ({ + models: [{ provider: "codex", id: "astra", label }], + modes: [], + }), + }), + }, + }); + const targets = ["/tmp/atomic-a", "/tmp/atomic-b"]; + try { + await Promise.all(targets.map((cwd) => manager.warmUpSnapshotForCwd({ cwd }))); + const observed: ProviderSnapshot[][] = []; + manager.on("change", () => observed.push(targets.map((cwd) => manager.getSnapshot(cwd)))); + label = "after"; + await manager.refreshSnapshotForCwd({ cwd: targets[0]!, providers: ["codex"] }); + expect(observed).toHaveLength(2); + for (const snapshots of observed) { + expect(snapshots).toHaveLength(2); + for (const snapshot of snapshots) { + expect( + snapshot.records.find(({ entry }) => entry.provider === "codex")!.entry.models![0]!.label, + ).toBe("after"); + } + } + } finally { + manager.destroy(); + } +}); + +test("plugin runtime replacement invalidates only changed registrations and commits all targets together", async () => { + const calls: string[] = []; + const closed: string[] = []; + function registration(id: string, model: string): ProviderRegistration { + return { + id, + label: id, + getCatalogCacheKey: async () => "host", + async connect() { + let listener: ((event: ProviderEvent) => void) | undefined; + return { + version: 1, + capabilities: [], + async send(input) { + if (input.type !== "catalog") return; + calls.push(model); + listener?.({ + type: "catalog", + requestId: input.requestId, + catalog: { models: [{ id: model, label: model }], modes: [] }, + }); + }, + onEvent(next) { + listener = next; + return () => { + listener = undefined; + }; + }, + async close() { + closed.push(model); + }, + }; + }, + }; + } + const first = registration("first-plugin", "first"); + const second = registration("second-plugin", "second"); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog: async () => { + calls.push("codex"); + return { models: [], modes: [] }; + }, + }), + }, + }); + const cwds = ["/tmp/plugin-install-a", "/tmp/plugin-install-b"]; + const warm = () => Promise.all(cwds.map((cwd) => manager.warmUpSnapshotForCwd({ cwd }))); + try { + manager.replacePluginProviders([first, second]); + await warm(); + const clients = manager.getAgentManagerProviderState().clients; + const snapshots = cwds.map((cwd) => manager.getSnapshot(cwd)); + const observed: ProviderSnapshot[][] = []; + manager.on("change", () => observed.push(cwds.map((cwd) => manager.getSnapshot(cwd)))); + manager.replacePluginProviders([first, second]); + await warm(); + expect(calls.toSorted()).toEqual(["codex", "first", "second"]); + expect(manager.getSnapshot(cwds[0])).toBe(snapshots[0]); + expect(observed).toEqual([]); + manager.replacePluginProviders([registration("first-plugin", "restarted"), second]); + const loading = cwds.map((cwd) => manager.getSnapshot(cwd)); + expect(observed).toHaveLength(2); + for (const reads of observed) { + expect(reads[0]).toBe(loading[0]); + expect(reads[1]).toBe(loading[1]); + } + const nextClients = manager.getAgentManagerProviderState().clients; + expect(nextClients.codex).toBe(clients.codex); + expect(nextClients["second-plugin"]).toBe(clients["second-plugin"]); + expect(nextClients["first-plugin"]).not.toBe(clients["first-plugin"]); + expect(loading[0]!.records.find(({ entry }) => entry.provider === "codex")).toBe( + snapshots[0]!.records.find(({ entry }) => entry.provider === "codex"), + ); + expect(loading[0]!.records.find(({ entry }) => entry.provider === "second-plugin")).toBe( + snapshots[0]!.records.find(({ entry }) => entry.provider === "second-plugin"), + ); + expect( + loading[0]!.records.find(({ entry }) => entry.provider === "first-plugin")!.entry.status, + ).toBe("loading"); + await warm(); + expect(calls.toSorted()).toEqual(["codex", "first", "restarted", "second"]); + expect( + manager.getSnapshot(cwds[0]).records.find(({ entry }) => entry.provider === "first-plugin")! + .entry.models![0]!.id, + ).toBe("restarted"); + manager.replacePluginProviders([second]); + await warm(); + expect(manager.hasProvider("first-plugin")).toBe(false); + expect(closed.toSorted()).toEqual(["first", "restarted"]); + await manager.shutdown(); + expect(closed.toSorted()).toEqual(["first", "restarted", "second"]); + expect(calls.toSorted()).toEqual(["codex", "first", "restarted", "second"]); + } finally { + await manager.shutdown(); + manager.destroy(); + } +}); + +test("binding a settled catalogue publishes once and rebinding an equal settled result publishes nothing", async () => { + let key = "first"; + let probes = 0; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { + codex: createExtraClient("codex", { + getCatalogCacheKey: async () => key, + isAvailable: async () => { + probes++; + return false; + }, + }), + }, + }); + const transitions: unknown[] = []; + manager.on("change", (transition) => transitions.push(transition)); + try { + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/bind-a" }); + transitions.length = 0; + const initial = manager.getSnapshot("/tmp/bind-b"); + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/bind-b" }); + expect(transitions).toEqual([ + { previous: initial, current: manager.getSnapshot("/tmp/bind-b") }, + ]); + expect(probes).toBe(1); + key = "second"; + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/bind-b" }); + const retained = manager.getSnapshot("/tmp/bind-a"); + transitions.length = 0; + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/bind-a" }); + expect(transitions).toEqual([]); + expect(manager.getSnapshot("/tmp/bind-a")).toBe(retained); + expect(probes).toBe(2); + } finally { + manager.destroy(); + } +}); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 5217a1c738..8eb9f20c8a 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -1,9 +1,11 @@ +import { createHash } from "node:crypto"; import { EventEmitter } from "node:events"; import { homedir } from "node:os"; import { resolve } from "node:path"; import { isDeepStrictEqual } from "node:util"; import type { Logger } from "pino"; +import pLimit, { type LimitFunction } from "p-limit"; import { expandTilde } from "../../utils/path.js"; import { withTimeout } from "../../utils/promise-timeout.js"; @@ -90,7 +92,23 @@ function omitProviderOverrides( return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined; } -type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd: string) => void; +/** Published values are shared read-only; provider-owned data is detached at publication. */ +export interface ProviderSnapshotRecord { + readonly entry: ProviderSnapshotEntry; + readonly contentHash: string; +} + +export interface ProviderSnapshot { + readonly cwd: string; + readonly records: readonly ProviderSnapshotRecord[]; +} + +export interface ProviderSnapshotTransition { + readonly previous: ProviderSnapshot; + readonly current: ProviderSnapshot; +} + +type ProviderSnapshotChangeListener = (transition: ProviderSnapshotTransition) => void; export interface ProviderSnapshotManagerOptions { logger: Logger; @@ -126,10 +144,9 @@ interface ApplyMutableProviderConfigOptions { replace?: boolean; } -export interface StagedMutableProviderConfig { +export interface PreparedMutableProviderConfig { agentManagerState: AgentManagerProviderState; - publish(): void; - rollback(): void; + commit(): void; } interface ProviderSnapshotProviderOptions { @@ -182,18 +199,32 @@ interface ProviderLoadOptions { catalogScope: ProviderCatalogScope; force: boolean; } -interface ProviderLoad { +interface CatalogBinding { + key?: string; + failure?: ProviderSnapshotRecord; + /** Refresh intent survives reads that supersede an unfinished key lookup. */ + force: boolean; promise: Promise; } -interface MutableProviderState { - baseProviderOverrides: Record | undefined; - runtimeSettings: AgentProviderRuntimeSettingsMap | undefined; - providerOverrides: Record | undefined; - providerRegistry: Record; - providerClients: Record; - snapshots: Map>; - providerLoads: Map>; +interface ProviderCatalog { + result?: ProviderSnapshotRecord; + stale?: boolean; + load?: Promise; +} + +interface RegistryGeneration { + definitions: Record; + order: readonly AgentProvider[]; + providerStates: ReadonlyMap< + AgentProvider, + { initial: ProviderSnapshotRecord; discoveryLimit: LimitFunction } + >; +} + +interface Target { + bindings: Map; + snapshot: ProviderSnapshot; } type ProviderCatalogScope = { scope: "global" } | { scope: "workspace"; cwd: string }; @@ -204,8 +235,8 @@ interface ProviderSnapshotTarget { } export class ProviderSnapshotManager { - private readonly snapshots = new Map>(); - private readonly providerLoads = new Map>(); + private readonly catalogs = new Map>(); + private readonly targets = new Map(); private readonly events = new EventEmitter(); private destroyed = false; private refreshTimeoutMs: number; @@ -219,7 +250,7 @@ export class ProviderSnapshotManager { private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined; private providerOverrides: Record | undefined; private baseProviderOverrides: Record | undefined; - private providerRegistry: Record; + private generation: RegistryGeneration; private providerClients: Record; private readonly ownedClients = new Set(); private readonly pluginProviders: PluginAgentClientRegistry; @@ -242,7 +273,10 @@ export class ProviderSnapshotManager { options.diagnosticTimeoutMs, this.refreshTimeoutMs, ); - this.providerRegistry = this.buildRegistry(); + this.generation = this.createGeneration( + this.buildRegistry(this.runtimeSettings, this.providerOverrides), + this.providerOverrides, + ); this.providerClients = { ...this.extraClients, ...this.pluginProviders.clients(), @@ -250,7 +284,7 @@ export class ProviderSnapshotManager { for (const client of Object.values(this.providerClients)) this.ownedClients.add(client); } - getSnapshot(cwd?: string): ProviderSnapshotEntry[] { + getSnapshot(cwd?: string): ProviderSnapshot { const target = resolveProviderSnapshotTarget(cwd); return this.getSnapshotForTarget(target); } @@ -259,9 +293,8 @@ export class ProviderSnapshotManager { const snapshotCwd = resolveSnapshotCwd(options.cwd); const target = createWorkspaceSnapshotTarget(snapshotCwd); const providers = this.resolveRefreshProviders(options.providers); - this.resetSnapshotToLoading(snapshotCwd, providers, { preserveExisting: false }); - this.emitChange(snapshotCwd); - await this.refreshProviders(target, providers ?? this.getProviderIds()); + const providersToRefresh = providers ?? this.getProviderIds(); + await this.refreshProviders(target, providersToRefresh); } async refreshSettingsSnapshot( @@ -272,10 +305,21 @@ export class ProviderSnapshotManager { const providers = this.resolveRefreshProviders(options.providers); const providersToRefresh = providers ?? this.getProviderIds(); - this.clearCachedProviders(providers); - this.resetSnapshotToLoading(homeCwd, providers, { preserveExisting: false }); - this.emitChange(homeCwd); + this.getOrCreateTarget(homeCwd); + for (const catalogs of this.catalogs.values()) { + for (const provider of providersToRefresh) { + const catalog = catalogs.get(provider); + if (catalog) catalogs.set(provider, { result: catalog.result, stale: true }); + } + } + // Refresh each known target: provider keys coalesce reads, while target-scoped + // providers must discover again in their own execution context. await this.refreshProviders(target, providersToRefresh); + await Promise.all( + [...this.targets.keys()] + .filter((cwd) => cwd !== homeCwd) + .map((cwd) => this.warmUp(createWorkspaceSnapshotTarget(cwd), providersToRefresh)), + ); } async warmUpSnapshotForCwd(options: ProviderSnapshotWarmUpOptions): Promise { @@ -302,17 +346,24 @@ export class ProviderSnapshotManager { } hasProvider(provider: AgentProvider): boolean { - return Object.prototype.hasOwnProperty.call(this.providerRegistry, provider); + return Object.prototype.hasOwnProperty.call(this.generation.definitions, provider); } getProviderLabel(provider: AgentProvider): string { - return this.providerRegistry[provider]?.label ?? provider; + return this.generation.definitions[provider]?.label ?? provider; } getAgentManagerProviderState(): AgentManagerProviderState { + return this.createAgentManagerState(this.generation.definitions, this.providerClients); + } + + private createAgentManagerState( + definitions: Record, + providerClients: Record, + ): AgentManagerProviderState { const providerDefinitions: AgentManagerProviderState["providerDefinitions"] = {}; const clients: AgentManagerProviderState["clients"] = {}; - for (const [provider, definition] of Object.entries(this.providerRegistry)) { + for (const [provider, definition] of Object.entries(definitions)) { providerDefinitions[provider] = { enabled: definition.enabled, derivedFromProviderId: definition.derivedFromProviderId, @@ -321,7 +372,7 @@ export class ProviderSnapshotManager { applyToolPolicy: definition.applyToolPolicy, }; if (definition.enabled) { - clients[provider] = this.ensureClient(provider, definition); + clients[provider] = this.ensureClient(provider, definition, providerClients); } } for (const [provider, client] of Object.entries(this.extraClients)) { @@ -337,7 +388,7 @@ export class ProviderSnapshotManager { ): AgentManagerProviderState { for (const registration of registrations) { if ( - (this.providerRegistry[registration.id] || this.extraClients[registration.id]) && + (this.generation.definitions[registration.id] || this.extraClients[registration.id]) && !this.pluginProviders.has(registration.id) ) { throw new Error( @@ -345,36 +396,39 @@ export class ProviderSnapshotManager { ); } } + const previousPlugins = this.pluginProviders.definitions(); + const clients = { ...this.providerClients }; + // Materialize fallible installed clients before retiring any plugin runtime. + this.createAgentManagerState(this.generation.definitions, clients); this.pluginProviders.replace(registrations); - this.providerRegistry = this.buildRegistry(); - this.providerClients = { - ...this.extraClients, - ...this.pluginProviders.clients(), - } as Record; - for (const client of Object.values(this.providerClients)) this.ownedClients.add(client); - - for (const cwd of this.snapshots.keys()) { - this.providerLoads.delete(cwd); - this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd)); - this.emitChange(cwd); - const target = - cwd === GLOBAL_PROVIDER_SNAPSHOT_KEY - ? createGlobalSnapshotTarget() - : createWorkspaceSnapshotTarget(cwd); - const providers = this.resolveProvidersToWarm(cwd); - if (providers.length > 0) void this.warmUp(target, providers); + const plugins = this.pluginProviders.definitions(); + const definitions = { ...this.generation.definitions }; + const changed = new Set(); + for (const provider of new Set([...Object.keys(previousPlugins), ...Object.keys(plugins)])) { + if (previousPlugins[provider] !== plugins[provider]) changed.add(provider); + delete definitions[provider]; + delete clients[provider]; } - - return this.getAgentManagerProviderState(); + Object.assign(definitions, plugins); + Object.assign(clients, this.pluginProviders.clients()); + for (const client of Object.values(clients)) this.ownedClients.add(client); + const generation = this.createGeneration(definitions, this.providerOverrides); + const state = this.createAgentManagerState(definitions, clients); + this.installGeneration(generation, clients, changed); + return state; } - private ensureClient(provider: AgentProvider, definition: ProviderDefinition): AgentClient { - const existing = this.providerClients[provider]; + private ensureClient( + provider: AgentProvider, + definition: ProviderDefinition, + clients = this.providerClients, + ): AgentClient { + const existing = clients[provider]; if (existing) { return existing; } const client = definition.createClient(this.logger); - this.providerClients[provider] = client; + clients[provider] = client; this.ownedClients.add(client); return client; } @@ -385,7 +439,10 @@ export class ProviderSnapshotManager { await this.warmUpSnapshotForCwd({ cwd: input.cwd, providers: input.providers }); } const providerFilter = input.providers ? new Set(input.providers) : null; - const entries = this.getSnapshotForTarget(target); + const snapshot = input.wait + ? this.getOrCreateTarget(target.snapshotCwd).snapshot + : this.getSnapshotForTarget(target, input.providers); + const entries = snapshot.records.map(({ entry }) => entry); return providerFilter ? entries.filter((entry) => providerFilter.has(entry.provider)) : entries; } @@ -488,7 +545,7 @@ export class ProviderSnapshotManager { } async getProviderDiagnostic(provider: AgentProvider): Promise { - const definition = this.providerRegistry[provider]; + const definition = this.generation.definitions[provider]; if (!definition) { return { provider, @@ -499,7 +556,7 @@ export class ProviderSnapshotManager { } const baseDiagnosticPromise = this.getBaseProviderDiagnostic(provider, definition); - const snapshotEntryPromise = this.refreshDiagnosticSnapshotEntry(provider, definition); + const snapshotEntryPromise = this.refreshDiagnosticSnapshotEntry(provider); const [baseDiagnostic, entry] = await Promise.all([ baseDiagnosticPromise, snapshotEntryPromise, @@ -515,124 +572,76 @@ export class ProviderSnapshotManager { mutableProviders: MutableDaemonConfig["providers"] | undefined, options: ApplyMutableProviderConfigOptions = {}, ): AgentManagerProviderState { - const staged = this.stageMutableProviderConfig(mutableProviders, options); - try { - staged.publish(); - return staged.agentManagerState; - } catch (error) { - staged.rollback(); - throw error; - } + const prepared = this.prepareMutableProviderConfig(mutableProviders, options); + prepared.commit(); + return prepared.agentManagerState; } - stageMutableProviderConfig( + prepareMutableProviderConfig( mutableProviders: MutableDaemonConfig["providers"] | undefined, options: ApplyMutableProviderConfigOptions = {}, - ): StagedMutableProviderConfig { - const previous = this.captureMutableProviderState(); - const snapshotCwds = Array.from(this.snapshots.keys()); - try { - if (options.replace) { - this.baseProviderOverrides = undefined; - this.runtimeSettings = undefined; - } else { - this.baseProviderOverrides = omitProviderOverrides( - this.baseProviderOverrides, - options.removeProviders ?? [], - ); - } - this.providerOverrides = applyMutableProviderConfigToOverrides( - this.baseProviderOverrides, - mutableProviders, - ); - // The mutable config is the complete provider source after startup. Keeping - // startup-derived runtime settings here would retain removed command/env fields. - const nextRegistry = this.buildRegistry(); - const changedProviders = this.findChangedProviders(previous, nextRegistry); - - this.providerRegistry = nextRegistry; - this.providerClients = { ...previous.providerClients }; - for (const provider of changedProviders) delete this.providerClients[provider]; - for (const provider of Object.keys(nextRegistry)) { - if (!changedProviders.has(provider)) { - nextRegistry[provider] = previous.providerRegistry[provider]!; - } - } - Object.assign(this.providerClients, this.extraClients, this.pluginProviders.clients()); - const providersToRefresh = [...changedProviders].filter((provider) => nextRegistry[provider]); - - for (const cwd of snapshotCwds) { - const loads = new Map(this.providerLoads.get(cwd)); - for (const provider of changedProviders) loads.delete(provider); - this.providerLoads.set(cwd, loads); - this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd, changedProviders)); - } - - return { - agentManagerState: this.getAgentManagerProviderState(), - publish: () => { - if (changedProviders.size === 0) return; - for (const cwd of snapshotCwds) { - this.emitChange(cwd); - const target = - cwd === GLOBAL_PROVIDER_SNAPSHOT_KEY - ? createGlobalSnapshotTarget() - : createWorkspaceSnapshotTarget(cwd); - const providers = this.resolveProvidersToWarm(cwd, providersToRefresh); - if (providers.length > 0) void this.warmUp(target, providers); - } - }, - rollback: () => this.restoreMutableProviderState(previous), - }; - } catch (error) { - this.restoreMutableProviderState(previous); - throw error; - } - } - - private findChangedProviders( - previous: MutableProviderState, - nextRegistry: Record, - ): Set { - const providers = new Set([ - ...Object.keys(previous.providerRegistry), - ...Object.keys(nextRegistry), - ]); + ): PreparedMutableProviderConfig { + const baseProviderOverrides = options.replace + ? undefined + : omitProviderOverrides(this.baseProviderOverrides, options.removeProviders ?? []); + const runtimeSettings = options.replace ? undefined : this.runtimeSettings; + const providerOverrides = applyMutableProviderConfigToOverrides( + baseProviderOverrides, + mutableProviders, + ); + const definitions = this.buildRegistry(runtimeSettings, providerOverrides); const changed = new Set(); - for (const provider of providers) { - const before = previous.providerRegistry[provider]; - const after = nextRegistry[provider]; + const clients = { ...this.providerClients }; + for (const provider of new Set([...this.generation.order, ...Object.keys(definitions)])) { + const before = this.generation.definitions[provider]; + const after = definitions[provider]; if (!before || !after || !isDeepStrictEqual(before.configuration, after.configuration)) { changed.add(provider); + delete clients[provider]; + } else { + definitions[provider] = before; } } - return changed; - } - - private captureMutableProviderState(): MutableProviderState { + Object.assign(clients, this.extraClients, this.pluginProviders.clients()); + const generation = this.createGeneration(definitions, providerOverrides); + const agentManagerState = this.createAgentManagerState(definitions, clients); return { - baseProviderOverrides: this.baseProviderOverrides, - runtimeSettings: this.runtimeSettings, - providerOverrides: this.providerOverrides, - providerRegistry: this.providerRegistry, - providerClients: this.providerClients, - // Preserve the inner map identities: in-flight refreshes close over them. - // Staging replaces active maps instead of mutating these originals. - snapshots: new Map(this.snapshots), - providerLoads: new Map(this.providerLoads), + agentManagerState, + commit: () => { + this.baseProviderOverrides = baseProviderOverrides; + this.runtimeSettings = runtimeSettings; + this.providerOverrides = providerOverrides; + this.installGeneration(generation, clients, changed); + }, }; } - private restoreMutableProviderState(previous: MutableProviderState): void { - this.baseProviderOverrides = previous.baseProviderOverrides; - this.runtimeSettings = previous.runtimeSettings; - this.providerOverrides = previous.providerOverrides; - this.providerRegistry = previous.providerRegistry; - this.providerClients = previous.providerClients; - this.snapshots.clear(); - for (const [cwd, entries] of previous.snapshots) this.snapshots.set(cwd, entries); - this.providerLoads.clear(); - for (const [cwd, loads] of previous.providerLoads) this.providerLoads.set(cwd, loads); + private installGeneration( + generation: RegistryGeneration, + clients: Record, + changed: ReadonlySet, + ): void { + for (const provider of changed) { + this.generation.providerStates.get(provider)?.discoveryLimit.clearQueue(); + } + this.generation = generation; + this.providerClients = clients; + for (const [key, catalogs] of this.catalogs) { + for (const provider of changed) catalogs.delete(provider); + if (catalogs.size === 0) this.catalogs.delete(key); + } + for (const target of this.targets.values()) { + for (const provider of changed) target.bindings.delete(provider); + } + this.publishTargets(this.targets.keys()); + const providers = [...changed].filter((provider) => generation.definitions[provider]); + if (providers.length === 0) return; + for (const cwd of this.targets.keys()) { + void this.warmUp( + resolveProviderSnapshotTarget(cwd === GLOBAL_PROVIDER_SNAPSHOT_KEY ? undefined : cwd), + providers, + ); + } } setRefreshTimeoutMs(refreshTimeoutMs: number | undefined): void { @@ -651,6 +660,8 @@ export class ProviderSnapshotManager { } async shutdown(): Promise { + this.destroyed = true; + for (const state of this.generation.providerStates.values()) state.discoveryLimit.clearQueue(); // Materialize a client per enabled provider so provider-owned resources // (background processes, sockets, etc.) get a chance to release even when // a given provider hasn't been touched yet during this daemon's lifetime. @@ -660,15 +671,19 @@ export class ProviderSnapshotManager { destroy(): void { this.destroyed = true; + for (const state of this.generation.providerStates.values()) state.discoveryLimit.clearQueue(); + this.catalogs.clear(); + this.targets.clear(); this.events.removeAllListeners(); - this.snapshots.clear(); - this.providerLoads.clear(); } - private buildRegistry(): Record { + private buildRegistry( + runtimeSettings: AgentProviderRuntimeSettingsMap | undefined, + providerOverrides: Record | undefined, + ): Record { const registry = buildProviderRegistry(this.logger, { - runtimeSettings: this.runtimeSettings, - providerOverrides: this.providerOverrides, + runtimeSettings, + providerOverrides, workspaceGitService: this.workspaceGitService, managedProcesses: this.managedProcesses, openCodeBridge: this.openCodeBridge, @@ -715,12 +730,15 @@ export class ProviderSnapshotManager { }; } - private getSnapshotForTarget(target: ProviderSnapshotTarget): ProviderSnapshotEntry[] { - const providersToWarm = this.resolveProvidersToWarm(target.snapshotCwd); + private getSnapshotForTarget( + target: ProviderSnapshotTarget, + providers?: AgentProvider[], + ): ProviderSnapshot { + const providersToWarm = this.resolveProvidersToWarm(target.snapshotCwd, providers); if (providersToWarm.length > 0) { void this.warmUp(target, providersToWarm); } - return entriesToArray(this.getOrCreateSnapshot(target.snapshotCwd)); + return this.getOrCreateTarget(target.snapshotCwd).snapshot; } private async getReadyProvider( @@ -740,7 +758,7 @@ export class ProviderSnapshotManager { } private requireProvider(provider: AgentProvider): ProviderDefinition { - const definition = this.providerRegistry[provider]; + const definition = this.generation.definitions[provider]; if (!definition) { throw new Error(`Provider ${provider} is not configured`); } @@ -749,24 +767,15 @@ export class ProviderSnapshotManager { private async refreshDiagnosticSnapshotEntry( provider: AgentProvider, - definition: ProviderDefinition, ): Promise { try { const target = createGlobalSnapshotTarget(); - this.resetSnapshotToLoading(target.snapshotCwd, [provider], { preserveExisting: false }); - this.emitChange(target.snapshotCwd); await this.refreshProviders(target, [provider]); return await this.getProvider({ provider, wait: false }); } catch (error) { return { - provider, + ...this.generation.providerStates.get(provider)!.initial.entry, status: "error", - enabled: definition.enabled, - source: this.getProviderSource(provider), - label: definition.label, - description: definition.description, - iconSvg: definition.iconSvg, - defaultModeId: definition.defaultModeId, error: toErrorMessage(error), }; } @@ -797,74 +806,40 @@ export class ProviderSnapshotManager { } } - private getProviderSource(provider: AgentProvider): ProviderSnapshotEntry["source"] { - if (this.pluginProviders.has(provider)) return "custom"; - const isBuiltin = BUILTIN_PROVIDER_IDS.includes(provider); - return !isBuiltin && this.providerOverrides?.[provider]?.extends ? "custom" : "builtin"; - } - - private createLoadingEntries(): Map { - const entries = new Map(); - for (const provider of this.getProviderIds()) { - const definition = this.providerRegistry[provider]; - entries.set(provider, { - provider, - status: definition?.enabled === false ? "unavailable" : "loading", - enabled: definition?.enabled ?? true, - source: this.getProviderSource(provider), - label: definition?.label, - description: definition?.description, - iconSvg: definition?.iconSvg, - defaultModeId: definition?.defaultModeId ?? null, - }); - } - return entries; - } - - private reconcileSnapshotForRegistry( - cwd: string, - changedProviders?: ReadonlySet, - ): Map { - const existing = this.snapshots.get(cwd); - const entries = new Map(); - - for (const provider of this.getProviderIds()) { - const definition = this.providerRegistry[provider]; - const current = existing?.get(provider); - if (current && changedProviders && !changedProviders.has(provider)) { - entries.set(provider, current); + private createGeneration( + definitions: Record, + overrides: Record | undefined, + ): RegistryGeneration { + const order = Object.keys(definitions); + const providerStates = new Map< + AgentProvider, + { initial: ProviderSnapshotRecord; discoveryLimit: LimitFunction } + >(); + for (const provider of order) { + const definition = definitions[provider]!; + const previous = this.generation?.providerStates.get(provider); + if (this.generation?.definitions[provider] === definition) { + providerStates.set(provider, previous!); continue; } - const metadata = { - provider, - enabled: definition?.enabled ?? true, - source: this.getProviderSource(provider), - label: definition?.label, - description: definition?.description, - iconSvg: definition?.iconSvg, - defaultModeId: definition?.defaultModeId ?? null, - }; - - if (!definition?.enabled) { - entries.set(provider, { - ...metadata, - status: "unavailable", - enabled: false, - }); - continue; - } - - entries.set(provider, { - ...metadata, - status: "loading", - enabled: true, - models: current?.models, - modes: current?.modes, - fetchedAt: current?.fetchedAt, + const custom = + this.pluginProviders.has(provider) || + (!BUILTIN_PROVIDER_IDS.includes(provider) && !!overrides?.[provider]?.extends); + providerStates.set(provider, { + discoveryLimit: previous?.discoveryLimit ?? pLimit({ concurrency: 4, rejectOnClear: true }), + initial: identifyEntry({ + provider, + status: definition.enabled ? "loading" : "unavailable", + enabled: definition.enabled, + source: custom ? "custom" : "builtin", + label: definition.label, + description: definition.description, + iconSvg: definition.iconSvg, + defaultModeId: definition.defaultModeId ?? null, + }), }); } - - return entries; + return { definitions, order, providerStates }; } private async warmUp(target: ProviderSnapshotTarget, providers?: AgentProvider[]): Promise { @@ -891,60 +866,9 @@ export class ProviderSnapshotManager { } private resolveProvidersToWarm(cwd: string, providers?: AgentProvider[]): AgentProvider[] { - const providersToInspect = providers ?? this.getProviderIds(); - const snapshot = this.snapshots.get(cwd); - if (!snapshot) { - this.resetSnapshotToLoading(cwd, providers); - return providersToInspect; - } - - const missingProviders = providersToInspect.filter((provider) => !snapshot.has(provider)); - if (missingProviders.length > 0) { - this.resetSnapshotToLoading(cwd, missingProviders); - } - - return providersToInspect.filter((provider) => snapshot.get(provider)?.status === "loading"); - } - - private clearCachedProviders(providers?: AgentProvider[]): void { - const providerSet = providers ? new Set(providers) : null; - const loadingEntries = this.createLoadingEntries(); - - for (const [cwd, providerLoads] of Array.from(this.providerLoads.entries())) { - if (!providerSet) { - this.providerLoads.delete(cwd); - continue; - } - - for (const provider of providerSet) { - providerLoads.delete(provider); - } - if (providerLoads.size === 0) { - this.providerLoads.delete(cwd); - } - } - - for (const [cwd, snapshot] of this.snapshots.entries()) { - if (!providerSet) { - snapshot.clear(); - for (const [provider, entry] of loadingEntries) { - snapshot.set(provider, entry); - } - this.emitChange(cwd); - continue; - } - - let changed = false; - for (const provider of providerSet) { - const loadingEntry = loadingEntries.get(provider); - if (!loadingEntry) continue; - snapshot.set(provider, loadingEntry); - changed = true; - } - if (changed) { - this.emitChange(cwd); - } - } + this.getOrCreateTarget(cwd); + // Identity is provider-owned and may change without a daemon config reload. + return providers ?? this.getProviderIds(); } private async loadProviders(options: ProviderLoadOptions): Promise { @@ -954,93 +878,137 @@ export class ProviderSnapshotManager { } private loadProvider(options: ProviderLoadOptions & { provider: AgentProvider }): Promise { - const definition = this.providerRegistry[options.provider]; - if (!definition) { - return Promise.resolve(); - } - - const existingLoad = this.getProviderLoad(options.snapshotCwd, options.provider); - if (existingLoad && !options.force) { - return existingLoad.promise; - } - const existingEntry = this.snapshots.get(options.snapshotCwd)?.get(options.provider); - if (existingEntry && existingEntry.status !== "loading" && !options.force) { - return Promise.resolve(); - } - - const load: ProviderLoad = { + if (this.destroyed || !this.generation.definitions[options.provider]) return Promise.resolve(); + const { bindings } = this.getOrCreateTarget(options.snapshotCwd); + const binding: CatalogBinding = { + key: bindings.get(options.provider)?.key, + failure: bindings.get(options.provider)?.failure, + force: options.force || bindings.get(options.provider)?.force === true, promise: Promise.resolve(), }; - this.setProviderLoad(options.snapshotCwd, options.provider, load); - load.promise = Promise.resolve() - .then(() => - this.refreshProvider({ - snapshotCwd: options.snapshotCwd, - catalogScope: options.catalogScope, - provider: options.provider, - definition, - load, - force: options.force, - }), - ) - .finally(() => { - const providerLoads = this.providerLoads.get(options.snapshotCwd); - if (providerLoads?.get(options.provider) === load) { - providerLoads.delete(options.provider); - } - if (providerLoads?.size === 0) { - this.providerLoads.delete(options.snapshotCwd); - } + bindings.set(options.provider, binding); + binding.promise = this.resolveCatalog({ ...options, force: binding.force }, binding); + return binding.promise; + } + + private async resolveCatalog( + options: ProviderLoadOptions & { provider: AgentProvider }, + binding: CatalogBinding, + ): Promise { + const { provider, snapshotCwd, force } = options; + const definition = this.generation.definitions[provider]!; + const initial = this.generation.providerStates.get(provider)!.initial.entry; + this.getOrCreateTarget(snapshotCwd); + if (!definition.enabled) return; + const currentBinding = () => this.targets.get(snapshotCwd)?.bindings.get(provider); + const client = this.ensureClient(provider, definition); + const catalogOptions = createFetchCatalogOptions(options.catalogScope, force); + let key: string; + try { + const sharedKey = client.getCatalogCacheKey + ? await withTimeout( + client.getCatalogCacheKey(catalogOptions), + this.refreshTimeoutMs, + `Timed out resolving ${provider} catalogue key`, + ) + : await Promise.resolve(undefined); + key = JSON.stringify( + sharedKey === undefined ? ["target", snapshotCwd] : ["provider", sharedKey], + ); + } catch (error) { + if (currentBinding() !== binding) return currentBinding()?.promise; + binding.force = false; + binding.key = undefined; + binding.failure = identifyEntry({ + ...this.generation.providerStates.get(provider)!.initial.entry, + status: "error", + error: toErrorMessage(error), }); - return load.promise; + this.publishTargets([snapshotCwd]); + return; + } + // Only the latest key resolution for this target may bind it to a catalogue. + if (currentBinding() !== binding) return currentBinding()?.promise; + binding.force = false; + binding.key = key; + binding.failure = undefined; + let catalogs = this.catalogs.get(key); + if (!catalogs) { + catalogs = new Map(); + this.catalogs.set(key, catalogs); + } + let catalog = catalogs.get(provider); + if (!catalog) { + catalog = {}; + catalogs.set(provider, catalog); + } + this.publishTargets([snapshotCwd]); + if (!force && (catalog.load || (catalog.result && !catalog.stale))) return catalog.load; + catalog.stale = false; + + const current = catalog; + const isCurrent = (): boolean => + !this.destroyed && this.catalogs.get(key)?.get(provider) === current && current.load === load; + const load = this.generation.providerStates + .get(provider)! + .discoveryLimit(() => { + if (!isCurrent()) return; + return this.refreshProvider({ + catalogOptions, + provider, + definition, + initial, + client, + publish: (entry) => { + if (!isCurrent()) return false; + current.result = identifyEntry(structuredClone(entry)); + const boundTargets = [...this.targets].flatMap(([cwd, target]) => + target.bindings.get(provider)?.key === key ? [cwd] : [], + ); + this.publishTargets(boundTargets); + return true; + }, + }); + }) + .finally(() => { + if (current.load === load) current.load = undefined; + }); + current.load = load; + return load; } private async refreshProvider(options: { - snapshotCwd: string; - catalogScope: ProviderCatalogScope; + catalogOptions: FetchCatalogOptions; provider: AgentProvider; definition: ProviderDefinition; - load: ProviderLoad; - force: boolean; + initial: ProviderSnapshotEntry; + client: AgentClient; + publish: (entry: ProviderSnapshotEntry) => boolean; }): Promise { - const { snapshotCwd, catalogScope, provider, definition, load, force } = options; - const base = { + const { + catalogOptions, provider, - source: this.getProviderSource(provider), - label: definition.label, - description: definition.description, - iconSvg: definition.iconSvg, - defaultModeId: definition.defaultModeId, - }; - const setEntry = (entry: ProviderSnapshotEntry) => { - if (!this.isCurrentProviderLoad(snapshotCwd, provider, load)) { - return false; - } - // A config transaction may replace the map while this unchanged provider is loading. - this.getOrCreateSnapshot(snapshotCwd).set(provider, entry); - this.emitChange(snapshotCwd); - return true; - }; + definition, + initial: base, + client, + publish: setEntry, + } = options; try { - if (!definition.enabled) { - setEntry({ ...base, status: "unavailable", enabled: false }); - return; - } - - const client = this.ensureClient(provider, definition); const catalog = await runProviderRefreshWithDeadline({ label: definition.label, timeoutMs: this.refreshTimeoutMs, operation: async (context) => { const available = await context.runActivity("availability", () => - raceProviderRefreshAbort(context.signal, client.isAvailable(context.signal)), + raceProviderRefreshAbort( + context.signal, + client.isAvailable(context.signal, catalogOptions), + ), ); if (!available) { return null; } - const catalogOptions = createFetchCatalogOptions(catalogScope, force); return await definition.fetchCatalog(catalogOptions, client, context); }, }); @@ -1052,7 +1020,7 @@ export class ProviderSnapshotManager { setEntry({ ...base, defaultModeId: - catalog.defaultModeId === undefined ? definition.defaultModeId : catalog.defaultModeId, + catalog.defaultModeId === undefined ? base.defaultModeId : catalog.defaultModeId, status: "ready", enabled: true, models: catalog.models, @@ -1068,93 +1036,64 @@ export class ProviderSnapshotManager { }); if (emitted) { this.logger.warn( - { err: error, provider, cwd: snapshotCwd }, + { err: error, provider, target: catalogOptions }, "Failed to refresh provider snapshot", ); } } } - private getProviderLoad(cwdKey: string, provider: AgentProvider): ProviderLoad | undefined { - return this.providerLoads.get(cwdKey)?.get(provider); - } - - private setProviderLoad(cwdKey: string, provider: AgentProvider, load: ProviderLoad): void { - let providerLoads = this.providerLoads.get(cwdKey); - if (!providerLoads) { - providerLoads = new Map(); - this.providerLoads.set(cwdKey, providerLoads); - } - providerLoads.set(provider, load); - } - - private isCurrentProviderLoad( - cwdKey: string, - provider: AgentProvider, - load: ProviderLoad, - ): boolean { - return this.providerLoads.get(cwdKey)?.get(provider) === load; - } - - private emitChange(cwdKey: string): void { - if (this.destroyed) { - return; - } - const snapshot = this.snapshots.get(cwdKey); - if (!snapshot) { - return; - } - this.events.emit("change", entriesToArray(snapshot), cwdKey); - } - - private getOrCreateSnapshot(cwdKey: string): Map { - const existing = this.snapshots.get(cwdKey); - if (existing) { - return existing; - } - - const created = this.createLoadingEntries(); - this.snapshots.set(cwdKey, created); - return created; - } - - private resetSnapshotToLoading( - cwdKey: string, - providers?: AgentProvider[], - options: { preserveExisting?: boolean } = {}, - ): Map { - const snapshot = this.getOrCreateSnapshot(cwdKey); - const loadingEntries = this.createLoadingEntries(); - const preserveExisting = options.preserveExisting ?? true; - - if (!providers) { - snapshot.clear(); - for (const [provider, entry] of loadingEntries) { - snapshot.set(provider, entry); - } - return snapshot; - } - - for (const provider of providers) { - const loadingEntry = loadingEntries.get(provider); - if (!loadingEntry) continue; - const existing = snapshot.get(provider); - snapshot.set(provider, { - ...loadingEntry, - ...(preserveExisting - ? { - models: existing?.models, - modes: existing?.modes, - fetchedAt: existing?.fetchedAt, - } - : {}), + private publishTargets(cwds: Iterable): void { + if (this.destroyed) return; + const transitions: ProviderSnapshotTransition[] = []; + for (const cwd of cwds) { + const target = this.targets.get(cwd)!; + const records = this.generation.order.map((provider) => { + const binding = target.bindings.get(provider); + const result = binding?.key + ? this.catalogs.get(binding.key)?.get(provider)?.result + : undefined; + return binding?.failure ?? result ?? this.generation.providerStates.get(provider)!.initial; }); + const previous = target.snapshot; + if (sameSnapshotRecords(previous.records, records)) continue; + const current = { cwd, records }; + target.snapshot = current; + transitions.push({ previous, current }); } - return snapshot; + for (const transition of transitions) { + for (const listener of this.events.listeners("change")) { + try { + listener(transition); + } catch (error) { + this.logger.error( + { err: error, cwd: transition.current.cwd }, + "Provider snapshot subscriber failed", + ); + } + } + } + } + + private getOrCreateTarget(cwd: string): Target { + let target = this.targets.get(cwd); + if (!target) { + target = { + bindings: new Map(), + snapshot: { + cwd, + records: this.generation.order.map( + (provider) => this.generation.providerStates.get(provider)!.initial, + ), + }, + }; + this.targets.set(cwd, target); + } + return target; } private getProviderIds(): AgentProvider[] { - return Object.keys(this.providerRegistry); + return [...this.generation.order]; } private resolveRefreshProviders(providers?: AgentProvider[]): AgentProvider[] | undefined { @@ -1220,18 +1159,12 @@ export function isGlobalProviderSnapshotKey(cwd: string): boolean { return cwd === GLOBAL_PROVIDER_SNAPSHOT_KEY; } -function entriesToArray( - entries: Map, -): ProviderSnapshotEntry[] { - return Array.from(entries.values(), cloneEntry); -} - -function cloneEntry(entry: ProviderSnapshotEntry): ProviderSnapshotEntry { - return { - ...entry, - models: entry.models?.map((model) => ({ ...model })), - modes: entry.modes?.map((mode) => ({ ...mode })), - }; +function identifyEntry(entry: ProviderSnapshotEntry): ProviderSnapshotRecord { + const { fetchedAt: _fetchedAt, ...content } = entry; + const contentHash = createHash("sha256") + .update(JSON.stringify(["paseo.provider-result/1", content])) + .digest("base64url"); + return { entry, contentHash }; } function toErrorMessage(error: unknown): string { @@ -1250,3 +1183,21 @@ function formatProviderStatus(entry: ProviderSnapshotEntry): string { if (entry.status === "unavailable") return "Unavailable"; return "Loading"; } + +export function sameSnapshotRecords( + previous: readonly ProviderSnapshotRecord[], + current: readonly ProviderSnapshotRecord[], +): boolean { + return ( + previous.length === current.length && + previous.every((before, index) => { + const after = current[index]!; + return ( + before === after || + (before.entry.provider === after.entry.provider && + before.contentHash === after.contentHash && + before.entry.fetchedAt === after.entry.fetchedAt) + ); + }) + ); +} diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index a5d1bf95da..a7469a75f3 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1563,6 +1563,11 @@ export class ClaudeAgentClient implements AgentClient { }); } + async getCatalogCacheKey(_options: FetchCatalogOptions): Promise { + // This client discovers through host configuration, independent of project cwd. + return "host"; + } + async fetchCatalog( _options: FetchCatalogOptions, context?: ProviderRefreshContext, diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 0cc71bf1b8..adff09dd9c 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -7083,6 +7083,11 @@ export class CodexAppServerAgentClient implements AgentClient { }); } + async getCatalogCacheKey(_options: FetchCatalogOptions): Promise { + // This client discovers through host configuration, independent of project cwd. + return "host"; + } + async fetchCatalog( _options: FetchCatalogOptions, context?: ProviderRefreshContext, diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 5851a26cfa..52477b3b83 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -165,6 +165,7 @@ describe("PluginService", () => { id: "plugin-agent", label: "Plugin agent", icon: "icon.svg", + async getCatalogCacheKey(options) { return options.scope === "workspace" ? "runtime:" + options.cwd : undefined; }, async connect() { throw new Error("not opened by this test"); }, }); return () => {}; @@ -178,8 +179,16 @@ describe("PluginService", () => { await service.installDirectory({ path: directory }); expect(service.getProviderRegistrations()).toMatchObject([ - { id: "plugin-agent", icon: iconSvg }, + { id: "plugin-agent", icon: iconSvg, getCatalogCacheKey: expect.any(Function) }, ]); + const provider = service.getProviderRegistrations()[0]!; + expect(await provider.getCatalogCacheKey!({ scope: "workspace", cwd: "/project-a" })).toBe( + "runtime:/project-a", + ); + expect( + await provider.getCatalogCacheKey!({ scope: "workspace", cwd: "/project-b", force: true }), + ).toBe("runtime:/project-b"); + expect(await provider.getCatalogCacheKey!({ scope: "global" })).toBeUndefined(); }); it("publishes provider registrations only while their plugin is running", async () => { diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index bb6f59ecdd..acfc9eb722 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -29,6 +29,7 @@ interface PluginRuntimePort { clearLogs(pluginId: string): void; getProviderRegistrations?(pluginId: string): readonly PluginProviderMetadata[]; connectProvider: PluginRuntime["connectProvider"]; + getProviderCatalogCacheKey?: PluginRuntime["getProviderCatalogCacheKey"]; validatePlugin?(path: string): Promise; startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise; stopPluginById(pluginId: string): Promise; @@ -456,6 +457,13 @@ export class PluginService { id: provider.id, label: provider.label, description: provider.description, + getCatalogCacheKey: provider.hasCatalogCacheKey + ? (options) => { + if (!this.runtime.getProviderCatalogCacheKey) + throw new Error("Plugin runtime cannot resolve catalogue keys"); + return this.runtime.getProviderCatalogCacheKey(pluginId, provider.id, options); + } + : undefined, icon: provider.iconPath ? await readPluginProviderIcon(pluginDirectory, provider.iconPath) : undefined, diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index 1a63845d63..41462b999e 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -1,5 +1,6 @@ import type { ProviderConnectRequest, + ProviderCatalogOptions, ProviderEvent, ProviderInput, } from "@getpaseo/plugin/provider"; @@ -7,6 +8,7 @@ import { ProviderEventSchema, ProviderInputSchema } from "@getpaseo/plugin/provi import { z } from "zod"; export interface PluginProviderMetadata { + hasCatalogCacheKey?: boolean; id: string; label: string; description?: string; @@ -21,6 +23,12 @@ export type PluginProcessRequest = appVersion: string; settingsDirectory?: string; } + | { + type: "provider.catalog_key"; + requestId: string; + providerId: string; + options: ProviderCatalogOptions; + } | { type: "invoke"; requestId: string; method: string; input: unknown } | { type: "provider.connect"; @@ -70,6 +78,7 @@ const providerMetadataSchema = z label: z.string().min(1), description: z.string().optional(), iconPath: z.string().optional(), + hasCatalogCacheKey: z.boolean().optional(), }) .strict(); const providerConnectRequestSchema = z @@ -95,6 +104,23 @@ export const PluginProcessRequestSchema: z.ZodType = z.dis settingsDirectory: z.string().optional(), }) .strict(), + z + .object({ + type: z.literal("provider.catalog_key"), + requestId: z.string().min(1), + providerId: z.string().min(1), + options: z.discriminatedUnion("scope", [ + z.object({ scope: z.literal("global"), force: z.boolean().optional() }).strict(), + z + .object({ + scope: z.literal("workspace"), + cwd: z.string(), + force: z.boolean().optional(), + }) + .strict(), + ]), + }) + .strict(), z .object({ type: z.literal("invoke"), diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index b048ee1135..473fcc7e0b 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -113,6 +113,12 @@ function registerProvider(provider: ProviderRegistration): void { if (typeof provider.connect !== "function") { throw new Error(`Plugin provider ${id} must implement connect()`); } + if ( + provider.getCatalogCacheKey !== undefined && + typeof provider.getCatalogCacheKey !== "function" + ) { + throw new Error(`Invalid catalogue key callback for plugin provider ${id}`); + } if (providers.has(id)) throw new Error(`Duplicate plugin provider ID: ${id}`); providers.set(id, { ...provider, id }); } @@ -123,6 +129,7 @@ function providerMetadata(provider: ProviderRegistration) { label: provider.label, description: provider.description, iconPath: provider.icon, + hasCatalogCacheKey: provider.getCatalogCacheKey !== undefined, }; } @@ -323,7 +330,9 @@ process.on("message", (rawMessage: unknown) => { return; } if (stopping) { - if (message.type === "provider.connect") { + if (message.type === "provider.catalog_key") { + send({ type: "error", requestId: message.requestId, error: "Plugin is stopping" }); + } else if (message.type === "provider.connect") { send({ type: "provider.connect_failed", connectionId: message.connectionId, @@ -341,6 +350,19 @@ process.on("message", (rawMessage: unknown) => { } return; } + if (message.type === "provider.catalog_key") { + void (async () => { + const provider = providers.get(message.providerId); + if (!provider) throw new Error(`Unknown provider: ${message.providerId}`); + const output = await provider.getCatalogCacheKey?.(message.options); + if (output !== undefined && typeof output !== "string") + throw new Error("Invalid catalogue key"); + send({ type: "result", requestId: message.requestId, output }); + })().catch((error) => + send({ type: "error", requestId: message.requestId, error: describeError(error) }), + ); + return; + } if (message.type === "provider.connect") { void connectProvider(message).catch((error) => { if (stopping) return; diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 102d7b3812..e487368501 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -8,6 +8,7 @@ import { PROVIDER_CAPABILITIES, requireProviderCapabilities, type ProviderConnectRequest, + type ProviderCatalogOptions, type ProviderConnection, type ProviderEvent, type ProviderInput, @@ -415,16 +416,42 @@ export class PluginRuntime { if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); if (!loaded.methods.has(method)) throw new Error(`Plugin ${pluginId} does not contribute RPC ${method}`); + return this.request(loaded, { type: "invoke", requestId: randomUUID(), method, input }); + } + + async getProviderCatalogCacheKey( + pluginId: string, + providerId: string, + options: ProviderCatalogOptions, + ): Promise { + const loaded = this.plugins.get(pluginId); + if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); + const output = await this.request(loaded, { + type: "provider.catalog_key", + requestId: randomUUID(), + providerId, + options, + }); + if (output !== undefined && typeof output !== "string") + throw new Error("Invalid catalogue key from plugin"); + return output; + } + + private request( + loaded: LoadedPlugin, + message: Extract, + ): Promise { const child = loaded.child; + const pluginId = loaded.id; if (!child) throw new Error(`Plugin ${pluginId} has no server entry`); - const requestId = randomUUID(); + const requestId = message.requestId; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { loaded.pending.delete(requestId); - reject(new Error(`Plugin RPC timed out: ${pluginId}.${method}`)); + reject(new Error(`Plugin RPC timed out: ${pluginId}.${message.type}`)); }, REQUEST_TIMEOUT_MS); loaded.pending.set(requestId, { resolve, reject, timeout }); - void send(child, { type: "invoke", requestId, method, input }).catch((error) => { + void send(child, message).catch((error) => { clearTimeout(timeout); loaded.pending.delete(requestId); reject(error); diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 3ab519e4ec..ea16a7f56f 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -42,6 +42,8 @@ import { asGitHubService, asWorkspaceGitService, asDaemonConfigStore, + findByType, + createProviderSnapshot, createProviderSnapshotManagerStub, } from "./test-utils/session-stubs.js"; import { isPlatform } from "../test-utils/platform.js"; @@ -2147,13 +2149,15 @@ describe("session provider refresh cwd routing", () => { getSnapshot, warmUpSnapshotForCwd, } = createProviderSnapshotManagerStub(); - getSnapshot.mockReturnValue([ - { - provider: "codex", - status: "loading", - enabled: true, - }, - ]); + getSnapshot.mockReturnValue( + createProviderSnapshot([ + { + provider: "codex", + status: "loading", + enabled: true, + }, + ]), + ); const session = createSessionForTest({ messages, providerSnapshotManager }); await session.handleMessage({ @@ -2178,13 +2182,15 @@ describe("session provider refresh cwd routing", () => { const messages: unknown[] = []; const { manager: providerSnapshotManager, warmUpSnapshotForCwd } = createProviderSnapshotManagerStub(); - providerSnapshotManager.getSnapshot = vi.fn(() => [ - { - provider: "codex", - status: "loading", - enabled: false, - }, - ]); + providerSnapshotManager.getSnapshot = vi.fn(() => + createProviderSnapshot([ + { + provider: "codex", + status: "loading", + enabled: false, + }, + ]), + ); const session = createSessionForTest({ messages, providerSnapshotManager }); await session.handleMessage({ @@ -2209,13 +2215,15 @@ describe("session provider refresh cwd routing", () => { const messages: unknown[] = []; const { manager: providerSnapshotManager, warmUpSnapshotForCwd } = createProviderSnapshotManagerStub(); - providerSnapshotManager.getSnapshot = vi.fn(() => [ - { - provider: "codex", - status: "loading", - enabled: false, - }, - ]); + providerSnapshotManager.getSnapshot = vi.fn(() => + createProviderSnapshot([ + { + provider: "codex", + status: "loading", + enabled: false, + }, + ]), + ); const session = createSessionForTest({ messages, providerSnapshotManager }); await session.handleMessage({ @@ -2244,23 +2252,27 @@ describe("session provider refresh cwd routing", () => { getSnapshot, warmUpSnapshotForCwd, } = createProviderSnapshotManagerStub(); - getSnapshot.mockReturnValueOnce([ - { - provider: "codex", - status: "loading", - enabled: true, - }, - ]); - getSnapshot.mockReturnValue([ - { - provider: "codex", - status: "ready", - enabled: true, - models: [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4" }], - modes: [], - fetchedAt: "2026-05-28T00:00:00.000Z", - }, - ]); + getSnapshot.mockReturnValueOnce( + createProviderSnapshot([ + { + provider: "codex", + status: "loading", + enabled: true, + }, + ]), + ); + getSnapshot.mockReturnValue( + createProviderSnapshot([ + { + provider: "codex", + status: "ready", + enabled: true, + models: [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4" }], + modes: [], + fetchedAt: "2026-05-28T00:00:00.000Z", + }, + ]), + ); warmUpSnapshotForCwd.mockReturnValue(warmupDeferred.promise); const session = createSessionForTest({ messages, providerSnapshotManager }); @@ -5694,3 +5706,54 @@ describe("agent config setters", () => { }); }); }); + +test("provider snapshots preserve versionless visibility while capabilities update independently", async () => { + const messages: SessionOutboundMessage[] = []; + const { manager } = createProviderSnapshotManagerStub(); + manager.getSnapshot = () => + createProviderSnapshot([ + { + provider: "codex", + status: "ready", + enabled: true, + modes: [{ id: "default", label: "Default", icon: "Sparkles" }], + }, + { provider: "plugin-provider", status: "ready", enabled: true }, + ]); + const session = createSessionForTest({ messages, providerSnapshotManager: manager }); + const read = async () => { + messages.length = 0; + await session.handleMessage({ + type: "get_providers_snapshot_request", + requestId: "visibility", + }); + return findByType(messages, "get_providers_snapshot_response")!.payload; + }; + const versionless = await read(); + expect(versionless.entries.map((entry) => entry.provider)).toEqual(["codex"]); + expect(versionless.entries[0]!.modes![0]!.icon).toBe("ShieldCheck"); + session.updateClientCapabilities({ + [CLIENT_CAPS.customModeIcons]: true, + [CLIENT_CAPS.providerSnapshotReferences]: true, + }); + const iconsOnly = await read(); + expect(iconsOnly.entries.map((entry) => entry.provider)).toEqual(["codex"]); + expect(iconsOnly.entries[0]!.modes![0]!.icon).toBe("Sparkles"); + expect(iconsOnly.snapshotHash).toBeUndefined(); + session.updateAppVersion("0.1.45"); + expect((await read()).entries.map((entry) => entry.provider)).toEqual([ + "codex", + "plugin-provider", + ]); + session.updateClientCapabilities({ + [CLIENT_CAPS.compactProviderSnapshots]: true, + [CLIENT_CAPS.providerSnapshotReferences]: true, + }); + const references = await read(); + expect(references.entries).toEqual([]); + expect(references.compactSnapshot!.entries.map((entry) => entry.provider)).toEqual([ + "codex", + "plugin-provider", + ]); + expect(references.compactSnapshot!.entries[0]!.modes![0]!.icon).toBe("ShieldCheck"); +}); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 433c6db141..bc59f68ee5 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -922,6 +922,8 @@ export class Session { isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider), supportsCustomModeIcons: () => this.supports(CLIENT_CAPS.customModeIcons), supportsCompactProviderSnapshots: () => this.supports(CLIENT_CAPS.compactProviderSnapshots), + supportsProviderSnapshotReferences: () => + this.supports(CLIENT_CAPS.providerSnapshotReferences), listProviderAvailability: () => this.agentManager.listProviderAvailability(), listDraftFeatures: (config) => this.agentManager.listDraftFeatures(config), }, diff --git a/packages/server/src/server/session/provider/provider-catalog-session.test.ts b/packages/server/src/server/session/provider/provider-catalog-session.test.ts index 63000ca0d0..653f46917c 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.test.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.test.ts @@ -5,23 +5,25 @@ import { type ProviderCatalogSessionHost, } from "./provider-catalog-session.js"; import { createStub } from "../../test-utils/class-mocks.js"; -import { findByType } from "../../test-utils/session-stubs.js"; +import { createProviderSnapshot, findByType } from "../../test-utils/session-stubs.js"; import type { SessionOutboundMessage } from "../../messages.js"; import { GLOBAL_PROVIDER_SNAPSHOT_KEY, type ProviderSnapshotManager, + type ProviderSnapshot, + type ProviderSnapshotTransition, } from "../../agent/provider-snapshot-manager.js"; import type { ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js"; -import type { ProviderUsageService } from "../../../services/quota-fetcher/service.js"; +import { ProviderUsageService } from "../../../services/quota-fetcher/service.js"; import { expandProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; -type SnapshotChangeHandler = (entries: ProviderSnapshotEntry[], cwd: string) => void; +type SnapshotChangeHandler = (transition: ProviderSnapshotTransition) => void; interface MakeOptions { visibleProviders?: Set; supportsCustomModeIcons?: boolean; supportsCompactProviderSnapshots?: boolean; - snapshot?: { [K in keyof ProviderSnapshotManager]?: unknown }; + snapshot?: Partial; usage?: { [K in keyof ProviderUsageService]?: unknown }; host?: Partial; } @@ -51,6 +53,7 @@ function makeSubsystem(options: MakeOptions = {}) { emit: (msg) => emitted.push(msg), isProviderVisibleToClient: (provider) => visible.has(provider), supportsCustomModeIcons: () => options.supportsCustomModeIcons ?? false, + supportsProviderSnapshotReferences: () => false, supportsCompactProviderSnapshots: () => options.supportsCompactProviderSnapshots ?? false, listProviderAvailability: async () => [], listDraftFeatures: async () => [], @@ -70,11 +73,11 @@ function makeSubsystem(options: MakeOptions = {}) { logger: pino({ level: "silent" }), }); function pushSnapshotChange( - entries: ProviderSnapshotEntry[], - cwd = GLOBAL_PROVIDER_SNAPSHOT_KEY, + current: ProviderSnapshot, + previous = createProviderSnapshot([], current.cwd), ): void { if (!changeHandler) throw new Error("start() must run before a snapshot change"); - changeHandler(entries, cwd); + changeHandler({ previous, current }); } return { subsystem, emitted, pushSnapshotChange }; } @@ -87,7 +90,7 @@ describe("ProviderCatalogSession", () => { }); subsystem.start(); - pushSnapshotChange(makeEntries()); + pushSnapshotChange(createProviderSnapshot(makeEntries())); const push = findByType(emitted, "providers_snapshot_update"); expect(push?.payload.entries.map((entry) => entry.provider)).toEqual(["codex"]); @@ -101,11 +104,11 @@ describe("ProviderCatalogSession", () => { const { subsystem, emitted, pushSnapshotChange } = makeSubsystem({ visibleProviders: new Set(["codex"]), supportsCustomModeIcons: false, - snapshot: { getSnapshot: () => makeEntries() }, + snapshot: { getSnapshot: () => createProviderSnapshot(makeEntries()) }, }); subsystem.start(); - pushSnapshotChange(makeEntries()); + pushSnapshotChange(createProviderSnapshot(makeEntries())); await subsystem.handleGetProvidersSnapshotRequest({ type: "get_providers_snapshot_request", requestId: "g1", @@ -117,7 +120,7 @@ describe("ProviderCatalogSession", () => { }); it("returns the canonical cwd used by snapshot updates", async () => { - const getSnapshot = vi.fn((_cwd?: string) => makeEntries()); + const getSnapshot = vi.fn((cwd?: string) => createProviderSnapshot(makeEntries(), cwd)); const { subsystem, emitted } = makeSubsystem({ snapshot: { getSnapshot }, }); @@ -140,7 +143,7 @@ describe("ProviderCatalogSession", () => { }); subsystem.start(); - pushSnapshotChange(makeEntries()); + pushSnapshotChange(createProviderSnapshot(makeEntries())); const push = findByType(emitted, "providers_snapshot_update"); expect(push?.payload.entries).toEqual([]); @@ -151,7 +154,7 @@ describe("ProviderCatalogSession", () => { it("preserves custom mode icons when the client supports them", async () => { const { subsystem, emitted } = makeSubsystem({ supportsCustomModeIcons: true, - snapshot: { getSnapshot: () => makeEntries() }, + snapshot: { getSnapshot: () => createProviderSnapshot(makeEntries()) }, }); await subsystem.handleGetProvidersSnapshotRequest({ @@ -184,7 +187,7 @@ describe("ProviderCatalogSession", () => { ]; const { subsystem, emitted } = makeSubsystem({ supportsCompactProviderSnapshots: true, - snapshot: { getSnapshot: () => entries }, + snapshot: { getSnapshot: () => createProviderSnapshot(entries) }, }); await subsystem.handleGetProvidersSnapshotRequest({ @@ -221,7 +224,10 @@ describe("ProviderCatalogSession", () => { // warmUpSnapshotForCwd is intentionally unstubbed: createStub throws if it is called, // so the disabled short-circuit is proven by the absence of a throw. const { subsystem, emitted } = makeSubsystem({ - snapshot: { getSnapshot: () => [{ provider: "codex", status: "loading", enabled: false }] }, + snapshot: { + getSnapshot: () => + createProviderSnapshot([{ provider: "codex", status: "loading", enabled: false }]), + }, }); await subsystem.handleListProviderModelsRequest({ @@ -237,22 +243,23 @@ describe("ProviderCatalogSession", () => { it("hides compatibility-only entries from list_provider_models", async () => { const { subsystem, emitted } = makeSubsystem({ snapshot: { - getSnapshot: () => [ - { - provider: "codex", - status: "ready", - enabled: true, - models: [ - { provider: "codex", id: "gpt-5.4", label: "GPT 5.4" }, - { - provider: "codex", - id: "gpt-5.4-legacy", - label: "GPT 5.4 legacy", - isSelectable: false, - }, - ], - }, - ], + getSnapshot: () => + createProviderSnapshot([ + { + provider: "codex", + status: "ready", + enabled: true, + models: [ + { provider: "codex", id: "gpt-5.4", label: "GPT 5.4" }, + { + provider: "codex", + id: "gpt-5.4-legacy", + label: "GPT 5.4 legacy", + isSelectable: false, + }, + ], + }, + ]), }, }); @@ -267,7 +274,9 @@ describe("ProviderCatalogSession", () => { }); it("preserves missing cwd as the semantic global snapshot for model list reads", async () => { - const getSnapshot = vi.fn(() => [{ provider: "codex", status: "loading", enabled: true }]); + const getSnapshot = vi.fn(() => + createProviderSnapshot([{ provider: "codex", status: "loading", enabled: true }]), + ); const warmUpSnapshotForCwd = vi.fn(async () => {}); const { subsystem } = makeSubsystem({ snapshot: { getSnapshot, warmUpSnapshotForCwd }, @@ -326,3 +335,333 @@ describe("ProviderCatalogSession", () => { expect(res?.payload.requestId).toBe("f1"); }); }); + +it("announces shared content without retransmitting models or hashing discovery freshness", async () => { + const { ProviderSnapshotManager: Manager } = + await import("../../agent/provider-snapshot-manager.js"); + let probes = 0; + let fetches = 0; + const manager = new Manager({ + logger: pino({ level: "silent" }), + providerOverrides: Object.fromEntries( + ["codex", "claude", "pi", "opencode", "copilot", "omp"].map((provider) => [ + provider, + { enabled: provider === "codex" }, + ]), + ), + extraClients: { + codex: { + provider: "codex", + capabilities: { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + }, + async getCatalogCacheKey() { + return "host"; + }, + async isAvailable() { + probes++; + return true; + }, + async fetchCatalog() { + fetches++; + return { models: [{ provider: "codex", id: "astra", label: "Astra" }], modes: [] }; + }, + async createSession() { + throw new Error("Not used"); + }, + async resumeSession() { + throw new Error("Not used"); + }, + }, + }, + }); + const createSession = (emitted: SessionOutboundMessage[], references: boolean) => + new ProviderCatalogSession({ + providerSnapshotManager: manager, + logger: pino({ level: "silent" }), + providerUsageService: new ProviderUsageService({ + logger: pino({ level: "silent" }), + fetchers: [], + }), + host: { + emit(message) { + emitted.push(message); + }, + isProviderVisibleToClient: () => true, + supportsCustomModeIcons: () => true, + supportsCompactProviderSnapshots: () => true, + supportsProviderSnapshotReferences: () => references, + listProviderAvailability: async () => [], + listDraftFeatures: async () => [], + }, + }); + const emitted: SessionOutboundMessage[] = []; + const peerMessages: SessionOutboundMessage[] = []; + const session = createSession(emitted, true); + const peer = createSession(peerMessages, false); + try { + await Promise.all( + [undefined, "/tmp/catalog-a", "/tmp/catalog-b"].map((cwd) => + manager.getProvider({ provider: "codex", cwd, wait: true }), + ), + ); + expect({ probes, fetches }).toEqual({ probes: 1, fetches: 1 }); + session.start(); + peer.start(); + await session.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "cold", + }); + const first = findByType(emitted, "get_providers_snapshot_response")!.payload; + expect( + first.compactSnapshot?.entries.find((entry) => entry.provider === "codex")?.fetchedAt, + ).toBeUndefined(); + expect(first.fetchedAt?.codex).toEqual(expect.any(String)); + await vi.waitFor(() => expect(Date.now()).toBeGreaterThan(Date.parse(first.fetchedAt!.codex!))); + emitted.length = 0; + await manager.refreshSnapshotForCwd({ cwd: "/tmp/catalog-a", providers: ["codex"] }); + expect({ probes, fetches }).toEqual({ probes: 2, fetches: 2 }); + const pushes = emitted.filter((message) => message.type === "providers_snapshot_update"); + expect(pushes).toHaveLength(3); + const peerPushes = peerMessages.filter( + (message) => message.type === "providers_snapshot_update", + ); + expect(peerPushes).toHaveLength(3); + expect( + peerPushes.every(({ payload }) => + payload.compactSnapshot?.entries.some( + (entry) => entry.provider === "codex" && entry.models?.[0]?.id === "astra", + ), + ), + ).toBe(true); + expect(peerPushes[0]!.payload.snapshotHash).not.toBe(pushes[0]!.payload.snapshotHash); + expect(pushes.map(({ payload }) => payload.snapshotHash)).toEqual( + Array(3).fill(first.snapshotHash), + ); + expect( + pushes.flatMap(({ payload }) => (payload.compactSnapshot ? [payload.compactSnapshot] : [])), + ).toEqual([]); + expect(pushes.flatMap(({ payload }) => payload.entries)).toEqual([]); + await session.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "warm", + ifNoneMatch: first.snapshotHash, + }); + const warm = findByType(emitted, "get_providers_snapshot_response")!.payload; + expect(warm.notModified).toBe(true); + expect(warm.compactSnapshot).toBeUndefined(); + expect(warm.fetchedAt?.codex).toEqual(expect.any(String)); + } finally { + session.dispose(); + peer.dispose(); + manager.destroy(); + } +}); + +it.each([false, true])( + "conditional hits do not read model bodies (references=%s)", + async (references) => { + let modelReads = 0; + const entries: ProviderSnapshotEntry[] = [ + { + provider: "codex", + status: "ready", + enabled: true, + modes: [{ id: "default", label: "Default", icon: "Sparkles" }], + get models() { + modelReads++; + return [{ provider: "codex", id: "astra", label: "Astra" }]; + }, + }, + ]; + const snapshot: ProviderSnapshot = { + cwd: GLOBAL_PROVIDER_SNAPSHOT_KEY, + records: [{ entry: entries[0]!, contentHash: "fixture" }], + }; + const { subsystem, emitted, pushSnapshotChange } = makeSubsystem({ + supportsCompactProviderSnapshots: true, + host: { supportsProviderSnapshotReferences: () => references }, + snapshot: { getSnapshot: () => snapshot }, + }); + subsystem.start(); + await subsystem.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "cold", + }); + const first = findByType(emitted, "get_providers_snapshot_response")!.payload; + expect(modelReads).toBeGreaterThan(0); + modelReads = 0; + await subsystem.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "hit", + ifNoneMatch: first.snapshotHash, + }); + expect(modelReads).toBe(0); + pushSnapshotChange(snapshot); + expect(modelReads === 0).toBe(references); + subsystem.dispose(); + }, +); + +it.each([false, true])( + "snapshot identity follows the client view and its freshness policy (references=%s)", + async (references) => { + const entries = makeEntries(); + entries[0]!.fetchedAt = "2026-09-06T10:00:00.000Z"; + entries[0]!.models = [{ provider: "codex", id: "astra", label: "Astra" }]; + let snapshot = createProviderSnapshot(entries); + const clients = [ + makeSubsystem({ + supportsCompactProviderSnapshots: true, + host: { supportsProviderSnapshotReferences: () => references }, + snapshot: { getSnapshot: () => snapshot }, + }), + makeSubsystem({ + supportsCompactProviderSnapshots: true, + host: { supportsProviderSnapshotReferences: () => references }, + snapshot: { getSnapshot: () => snapshot }, + }), + makeSubsystem({ + supportsCompactProviderSnapshots: true, + supportsCustomModeIcons: true, + host: { supportsProviderSnapshotReferences: () => references }, + snapshot: { getSnapshot: () => snapshot }, + }), + makeSubsystem({ + supportsCompactProviderSnapshots: true, + visibleProviders: new Set(["codex", "claude"]), + host: { supportsProviderSnapshotReferences: () => references }, + snapshot: { getSnapshot: () => snapshot }, + }), + ]; + const read = async (client: ReturnType, ifNoneMatch?: string) => { + client.emitted.length = 0; + await client.subsystem.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "view", + ifNoneMatch, + }); + return findByType(client.emitted, "get_providers_snapshot_response")!.payload; + }; + const views = await Promise.all(clients.map((client) => read(client))); + expect(views[0]!.snapshotHash).toBe(views[1]!.snapshotHash); + expect(views[0]!.compactSnapshot).toEqual(views[1]!.compactSnapshot); + expect(new Set(views.map((view) => view.snapshotHash)).size).toBe(3); + expect(expandProviderSnapshot(views[0]!.compactSnapshot!)[0]!.modes![0]!.icon).toBe( + "ShieldCheck", + ); + expect(expandProviderSnapshot(views[2]!.compactSnapshot!)[0]!.modes![0]!.icon).toBe("Sparkles"); + expect( + expandProviderSnapshot(views[3]!.compactSnapshot!).map((entry) => entry.provider), + ).toEqual(["codex", "claude"]); + expect(views[0]!.compactSnapshot!.entries[0]!.fetchedAt).toBe( + references ? undefined : entries[0]!.fetchedAt, + ); + + // Hidden provider changes do not invalidate the visible result. + snapshot = createProviderSnapshot([ + entries[0]!, + { ...entries[1]!, status: "error", error: "hidden" }, + ]); + expect((await read(clients[0]!, views[0]!.snapshotHash)).notModified).toBe(true); + // Same content hash, new embedded timestamp: only reference views can reuse the body. + snapshot = createProviderSnapshot([ + { ...entries[0]!, fetchedAt: "2026-09-06T10:01:00.000Z" }, + entries[1]!, + ]); + expect(snapshot.records[0]!.contentHash).toBe( + createProviderSnapshot(entries).records[0]!.contentHash, + ); + const fresh = await read(clients[0]!, views[0]!.snapshotHash); + expect(fresh.snapshotHash === views[0]!.snapshotHash).toBe(references); + expect(fresh.notModified === true).toBe(references); + expect(fresh.fetchedAt?.codex).toBe(references ? "2026-09-06T10:01:00.000Z" : undefined); + + snapshot = { ...snapshot, records: snapshot.records.toReversed() }; + expect((await read(clients[3]!)).snapshotHash).not.toBe(views[3]!.snapshotHash); + }, +); + +it("reference capability alone preserves full legacy responses and does not alias embedded compact hashes", async () => { + const snapshot = createProviderSnapshot(makeEntries()); + const clients = [ + makeSubsystem({ + host: { supportsProviderSnapshotReferences: () => true }, + snapshot: { getSnapshot: () => snapshot }, + }), + makeSubsystem({ + supportsCompactProviderSnapshots: true, + snapshot: { getSnapshot: () => snapshot }, + }), + makeSubsystem({ + supportsCompactProviderSnapshots: true, + host: { supportsProviderSnapshotReferences: () => true }, + snapshot: { getSnapshot: () => snapshot }, + }), + ]; + for (const client of clients) { + client.subsystem.start(); + client.pushSnapshotChange(snapshot); + await client.subsystem.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "format", + }); + client.subsystem.dispose(); + } + const views = clients.map( + ({ emitted }) => findByType(emitted, "get_providers_snapshot_response")!.payload, + ); + expect(views[0]!.snapshotHash).toBeUndefined(); + expect(views[0]!.entries).toEqual( + findByType(clients[0]!.emitted, "providers_snapshot_update")!.payload.entries, + ); + expect(views[0]!.entries).toHaveLength(1); + expect(views[1]!.snapshotHash).not.toBe(views[2]!.snapshotHash); + expect(expandProviderSnapshot(views[1]!.compactSnapshot!)).toEqual(views[0]!.entries); + expect(expandProviderSnapshot(views[2]!.compactSnapshot!)).toEqual(views[0]!.entries); +}); + +it.each(["full", "embedded", "references"])( + "suppresses hidden-only transitions but publishes visible freshness for %s clients", + (format) => { + const before = createProviderSnapshot([ + { + provider: "codex", + status: "ready", + enabled: true, + fetchedAt: "2026-09-06T10:00:00.000Z", + models: [{ provider: "codex", id: "astra", label: "Astra" }], + }, + { provider: "claude", status: "loading", enabled: true }, + ]); + const hidden = createProviderSnapshot([ + before.records[0]!.entry, + { provider: "claude", status: "error", enabled: true, error: "hidden" }, + ]); + const fresh = createProviderSnapshot([ + { ...before.records[0]!.entry, fetchedAt: "2026-09-06T10:01:00.000Z" }, + hidden.records[1]!.entry, + ]); + const client = makeSubsystem({ + supportsCompactProviderSnapshots: format !== "full", + host: { supportsProviderSnapshotReferences: () => format === "references" }, + }); + client.subsystem.start(); + client.pushSnapshotChange(hidden, before); + expect(client.emitted).toEqual([]); + client.pushSnapshotChange(fresh, hidden); + const update = findByType(client.emitted, "providers_snapshot_update")!.payload; + expect(client.emitted).toHaveLength(1); + const representedFreshness = + update.fetchedAt?.codex ?? + update.compactSnapshot?.entries[0]?.fetchedAt ?? + update.entries[0]?.fetchedAt; + expect(representedFreshness).toBe("2026-09-06T10:01:00.000Z"); + client.subsystem.dispose(); + }, +); diff --git a/packages/server/src/server/session/provider/provider-catalog-session.ts b/packages/server/src/server/session/provider/provider-catalog-session.ts index ed7509a916..f82f1ac56c 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.ts @@ -1,15 +1,15 @@ import type pino from "pino"; import { createHash } from "node:crypto"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; -import { - compactProviderSnapshot, - type CompactProviderSnapshot, -} from "@getpaseo/protocol/provider-snapshot-codec"; +import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; import { isGlobalProviderSnapshotKey, resolveSnapshotCwd, + sameSnapshotRecords, type ProviderSnapshotManager, + type ProviderSnapshot, + type ProviderSnapshotTransition, } from "../../agent/provider-snapshot-manager.js"; import { filterSelectableAgentModels, @@ -47,6 +47,7 @@ export interface ProviderCatalogSessionHost { // COMPAT(customModeIcons): reads clientCapabilities live. supportsCustomModeIcons(): boolean; supportsCompactProviderSnapshots(): boolean; + supportsProviderSnapshotReferences(): boolean; listProviderAvailability(): Promise; listDraftFeatures(config: AgentSessionConfig): Promise; } @@ -58,19 +59,6 @@ export interface ProviderCatalogSessionOptions { logger: pino.Logger; } -interface EncodedProviderSnapshot { - compactSnapshot: CompactProviderSnapshot; - snapshotHash: string; -} - -function encodeProviderSnapshot(entries: ProviderSnapshotEntry[]): EncodedProviderSnapshot { - const compactSnapshot = compactProviderSnapshot(entries); - const snapshotHash = createHash("sha256") - .update(JSON.stringify(compactSnapshot)) - .digest("base64url"); - return { compactSnapshot, snapshotHash }; -} - /** * A client's provider catalog surface: model / mode / feature listing, the providers * snapshot push + pull, provider diagnostics, and usage. The snapshot PUSH (start) and @@ -93,33 +81,13 @@ export class ProviderCatalogSession { } start(): void { - const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd: string) => { - // COMPAT(providersSnapshot): keep provider visibility gating for older clients. - const visibleEntries = entries.filter((entry) => - this.host.isProviderVisibleToClient(entry.provider), - ); - const snapshotCwd = isGlobalProviderSnapshotKey(cwd) ? undefined : cwd; - const clientEntries = this.downgradeEntryModesForClient(visibleEntries); - if (this.host.supportsCompactProviderSnapshots()) { - const encoded = encodeProviderSnapshot(clientEntries); - this.host.emit({ - type: "providers_snapshot_update", - payload: { - ...(snapshotCwd ? { cwd: snapshotCwd } : {}), - entries: [], - ...encoded, - generatedAt: new Date().toISOString(), - }, - }); - return; - } + const handleProviderSnapshotChange = (transition: ProviderSnapshotTransition) => { + const previous = this.visibleSnapshot(transition.previous); + const current = this.visibleSnapshot(transition.current); + if (sameSnapshotRecords(previous.records, current.records)) return; this.host.emit({ type: "providers_snapshot_update", - payload: { - ...(snapshotCwd ? { cwd: snapshotCwd } : {}), - entries: clientEntries, - generatedAt: new Date().toISOString(), - }, + payload: this.snapshotPayload(current), }); }; this.providerSnapshotManager.on("change", handleProviderSnapshotChange); @@ -138,20 +106,79 @@ export class ProviderCatalogSession { // COMPAT(customModeIcons): rewrite icons unknown to v0.1.83 clients (whose MODE_ICONS // map is a closed enum and would render `undefined`, crashing in render). Drop // this and the cap gate when floor >= v0.1.84. - private downgradeModeIconsForClient(modes: T[]): T[] { - if (this.host.supportsCustomModeIcons()) return modes; + private downgradeModeIconsForClient( + modes: T[], + customModeIcons = this.host.supportsCustomModeIcons(), + ): T[] { + if (customModeIcons) return modes; return modes.map((mode) => mode.icon && !LEGACY_MODE_ICONS.has(mode.icon) ? { ...mode, icon: "ShieldCheck" } : mode, ); } - private downgradeEntryModesForClient( - entries: T[], - ): T[] { - if (this.host.supportsCustomModeIcons()) return entries; - return entries.map((entry) => - entry.modes ? { ...entry, modes: this.downgradeModeIconsForClient(entry.modes) } : entry, - ); + private visibleSnapshot(snapshot: ProviderSnapshot): ProviderSnapshot { + // COMPAT(providersSnapshot): use the shell's current visibility policy for both sides. + return { + ...snapshot, + records: snapshot.records.filter(({ entry }) => + this.host.isProviderVisibleToClient(entry.provider), + ), + }; + } + + private snapshotPayload(snapshot: ProviderSnapshot, request?: { ifNoneMatch?: string }) { + const { records } = snapshot; + const customModeIcons = this.host.supportsCustomModeIcons(); + const compact = this.host.supportsCompactProviderSnapshots(); + const references = this.host.supportsProviderSnapshotReferences(); + const envelope = { + ...(!isGlobalProviderSnapshotKey(snapshot.cwd) ? { cwd: snapshot.cwd } : {}), + generatedAt: new Date().toISOString(), + }; + const clientEntries = () => + records.map(({ entry }) => + entry.modes && !customModeIcons + ? { ...entry, modes: this.downgradeModeIconsForClient(entry.modes, customModeIcons) } + : entry, + ); + if (!compact) { + return { ...envelope, entries: clientEntries() }; + } + + // COMPAT(providerSnapshotReferences): added in v0.7.2, remove legacy full + // pushes and embedded freshness after 2027-03-06 once the client floor supports references. + const snapshotHash = createHash("sha256") + .update( + JSON.stringify([ + "paseo.providers-snapshot/1", + references ? "references" : "embedded", + customModeIcons ? "icons" : "legacy-icons", + records.map(({ entry, contentHash }) => [ + entry.provider, + contentHash, + references ? null : (entry.fetchedAt ?? null), + ]), + ]), + ) + .digest("base64url"); + const fetchedAt = references + ? Object.fromEntries( + records.flatMap(({ entry }) => + entry.fetchedAt ? [[entry.provider, entry.fetchedAt]] : [], + ), + ) + : undefined; + const identified = { ...envelope, entries: [], snapshotHash, fetchedAt }; + if (request && request.ifNoneMatch === snapshotHash) { + return { ...identified, notModified: true as const }; + } + if (!request && references) return identified; + + const entries = clientEntries(); + const content = references + ? entries.map(({ fetchedAt: _fetchedAt, ...entry }) => entry) + : entries; + return { ...identified, compactSnapshot: compactProviderSnapshot(content) }; } private emitProviderDisabledResponse( @@ -290,7 +317,7 @@ export class ProviderCatalogSession { ): Promise { const manager = this.providerSnapshotManager; const findEntry = () => - manager.getSnapshot(cwd).find((candidate) => candidate.provider === provider); + manager.getSnapshot(cwd).records.find(({ entry }) => entry.provider === provider)?.entry; let entry = findEntry(); if (entry && !entry.enabled) { @@ -391,37 +418,12 @@ export class ProviderCatalogSession { async handleGetProvidersSnapshotRequest( msg: Extract, ): Promise { - // COMPAT(providersSnapshot): keep legacy provider-list RPCs alongside snapshot flow. - const snapshotCwd = msg.cwd?.trim() ? resolveSnapshotCwd(expandTilde(msg.cwd)) : undefined; - const entries = this.providerSnapshotManager - .getSnapshot(snapshotCwd) - .filter((entry) => this.host.isProviderVisibleToClient(entry.provider)); - const clientEntries = this.downgradeEntryModesForClient(entries); - - if (this.host.supportsCompactProviderSnapshots()) { - const encoded = encodeProviderSnapshot(clientEntries); - const notModified = msg.ifNoneMatch === encoded.snapshotHash; - this.host.emit({ - type: "get_providers_snapshot_response", - payload: { - ...(snapshotCwd ? { cwd: snapshotCwd } : {}), - entries: [], - ...(!notModified ? { compactSnapshot: encoded.compactSnapshot } : {}), - snapshotHash: encoded.snapshotHash, - ...(notModified ? { notModified: true } : {}), - generatedAt: new Date().toISOString(), - requestId: msg.requestId, - }, - }); - return; - } - + const cwd = msg.cwd?.trim() ? resolveSnapshotCwd(expandTilde(msg.cwd)) : undefined; + const snapshot = this.visibleSnapshot(this.providerSnapshotManager.getSnapshot(cwd)); this.host.emit({ type: "get_providers_snapshot_response", payload: { - ...(snapshotCwd ? { cwd: snapshotCwd } : {}), - entries: clientEntries, - generatedAt: new Date().toISOString(), + ...this.snapshotPayload(snapshot, { ifNoneMatch: msg.ifNoneMatch }), requestId: msg.requestId, }, }); diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index a490874ff7..15ed7ecc09 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -3,23 +3,14 @@ import { vi } from "vitest"; import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; -import type { - AgentMode, - AgentModelDefinition, - AgentProvider, - ProviderSnapshotEntry, -} from "../agent/agent-sdk-types.js"; -import type { - AgentManagerProviderState, - ProviderDiagnosticResult, - ResolvedProviderCreateConfig, +import type { ProviderSnapshotEntry } from "../agent/agent-sdk-types.js"; +import type { ProviderSnapshot } from "../agent/provider-snapshot-manager.js"; +import { + GLOBAL_PROVIDER_SNAPSHOT_KEY, + ProviderSnapshotManager, } from "../agent/provider-snapshot-manager.js"; -import { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js"; import type { SessionOptions } from "../session.js"; -import type { - HubExecutionAgentValidationIssue, - SessionOutboundMessage, -} from "@getpaseo/protocol/messages"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { asInternals, createStub } from "./class-mocks.js"; // --------------------------------------------------------------------------- @@ -100,15 +91,15 @@ export function asWorkspaceGitService(stub: { } export function asServiceProxy(stub: { - [K in keyof SessionOptions["serviceProxy"]]?: unknown; -}): SessionOptions["serviceProxy"] { - return createStub(stub); + [K in keyof NonNullable]?: unknown; +}): NonNullable { + return createStub>(stub); } export function asWorkspaceScriptRuntimeStore(stub: { - [K in keyof SessionOptions["scriptRuntimeStore"]]?: unknown; -}): SessionOptions["scriptRuntimeStore"] { - return createStub(stub); + [K in keyof NonNullable]?: unknown; +}): NonNullable { + return createStub>(stub); } // --------------------------------------------------------------------------- @@ -149,74 +140,96 @@ export function findByType( // --------------------------------------------------------------------------- export interface ProviderSnapshotManagerSpies { - getSnapshot: ReturnType>; - refreshSnapshotForCwd: ReturnType>>; - refreshSettingsSnapshot: ReturnType>>; - warmUpSnapshotForCwd: ReturnType>>; - listRegisteredProviderIds: ReturnType>; - hasProvider: ReturnType>; - getProviderLabel: ReturnType>; - getAgentManagerProviderState: ReturnType>; - listProviders: ReturnType>>; - getProvider: ReturnType>>; + getSnapshot: ReturnType>; + refreshSnapshotForCwd: ReturnType>; + refreshSettingsSnapshot: ReturnType< + typeof vi.fn + >; + warmUpSnapshotForCwd: ReturnType>; + listRegisteredProviderIds: ReturnType< + typeof vi.fn + >; + hasProvider: ReturnType>; + getProviderLabel: ReturnType>; + getAgentManagerProviderState: ReturnType< + typeof vi.fn + >; + listProviders: ReturnType>; + getProvider: ReturnType>; validateAgentConfiguration: ReturnType< - typeof vi.fn<[unknown], Promise> + typeof vi.fn >; - listModels: ReturnType>>; - listModes: ReturnType>>; - resolveCreateConfig: ReturnType>>; - resolveDefaultModel: ReturnType>>; - getProviderDiagnostic: ReturnType< - typeof vi.fn<[AgentProvider], Promise> + listModels: ReturnType>; + listModes: ReturnType>; + resolveCreateConfig: ReturnType>; + resolveDefaultModel: ReturnType>; + getProviderDiagnostic: ReturnType>; + applyMutableProviderConfig: ReturnType< + typeof vi.fn >; - applyMutableProviderConfig: ReturnType>; - destroy: ReturnType>; + destroy: ReturnType>; } export function createProviderSnapshotManagerStub(): { manager: ProviderSnapshotManager; } & ProviderSnapshotManagerSpies { - const getSnapshot = vi.fn<[cwd?: string], ProviderSnapshotEntry[]>(() => []); - const refreshSnapshotForCwd = vi.fn<[unknown], Promise>(async () => {}); - const refreshSettingsSnapshot = vi.fn<[unknown], Promise>(async () => {}); - const warmUpSnapshotForCwd = vi.fn<[unknown], Promise>(async () => {}); - const listRegisteredProviderIds = vi.fn<[], AgentProvider[]>(() => []); - const hasProvider = vi.fn<[AgentProvider], boolean>(() => false); - const getProviderLabel = vi.fn<[AgentProvider], string>((provider) => { + const getSnapshot = vi.fn((cwd) => + createProviderSnapshot([], cwd), + ); + const refreshSnapshotForCwd = vi.fn( + async () => {}, + ); + const refreshSettingsSnapshot = vi.fn( + async () => {}, + ); + const warmUpSnapshotForCwd = vi.fn( + async () => {}, + ); + const listRegisteredProviderIds = vi.fn( + () => [], + ); + const hasProvider = vi.fn(() => false); + const getProviderLabel = vi.fn((provider) => { try { return getAgentProviderDefinition(provider).label; } catch { return provider; } }); - const getAgentManagerProviderState = vi.fn<[], AgentManagerProviderState>(() => ({ + const getAgentManagerProviderState = vi.fn< + ProviderSnapshotManager["getAgentManagerProviderState"] + >(() => ({ providerDefinitions: {}, clients: {}, })); - const listProviders = vi.fn<[unknown], Promise>(async () => []); - const getProvider = vi.fn<[unknown], Promise>(async () => { + const listProviders = vi.fn(async () => []); + const getProvider = vi.fn(async () => { throw new Error("createProviderSnapshotManagerStub: getProvider not stubbed"); }); - const validateAgentConfiguration = vi.fn<[unknown], Promise>( + const validateAgentConfiguration = vi.fn( async () => [], ); - const listModels = vi.fn<[unknown], Promise>(async () => []); - const listModes = vi.fn<[unknown], Promise>(async () => []); - const resolveCreateConfig = vi.fn<[unknown], Promise>(async () => ({ + const listModels = vi.fn(async () => []); + const listModes = vi.fn(async () => []); + const resolveCreateConfig = vi.fn(async () => ({ modeId: undefined, featureValues: undefined, })); - const resolveDefaultModel = vi.fn<[unknown], Promise>(async () => undefined); - const getProviderDiagnostic = vi.fn<[AgentProvider], Promise>( + const resolveDefaultModel = vi.fn( + async () => undefined, + ); + const getProviderDiagnostic = vi.fn( async (provider) => ({ provider, diagnostic: "No diagnostic available for this provider." }), ); - const applyMutableProviderConfig = vi.fn<[unknown], AgentManagerProviderState>(() => ({ - providerDefinitions: {}, - clients: {}, - })); + const applyMutableProviderConfig = vi.fn( + () => ({ + providerDefinitions: {}, + clients: {}, + }), + ); const on = vi.fn(); const off = vi.fn(); - const destroy = vi.fn<[], void>(); + const destroy = vi.fn(); const stub = { getSnapshot, refreshSnapshotForCwd, @@ -275,3 +288,16 @@ export function createAgentRequestsStub(): SessionOptions["agentRequests"] { send: (input) => input.send(), }; } + +export function createProviderSnapshot( + entries: ProviderSnapshotEntry[], + cwd = GLOBAL_PROVIDER_SNAPSHOT_KEY, +): ProviderSnapshot { + return { + cwd, + records: entries.map((entry) => { + const { fetchedAt: _fetchedAt, ...content } = entry; + return { entry, contentHash: JSON.stringify(content) }; + }), + }; +} diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 8ba01afbe6..cbc67341f5 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -171,6 +171,18 @@ test("PR routing declares stable behavior ownership", () => { app: ["packages/app/**", "packages/expo-two-way-audio/**"], sdk: ["packages/client/**", "packages/highlight/**", "packages/protocol/**"], browser: [ + "packages/server/src/server/agent/provider-snapshot-manager.ts", + "packages/server/src/server/session/provider/provider-catalog-session.ts", + "packages/client/src/compat/normalize-provider-models.ts", + "packages/protocol/src/client-capabilities.ts", + "packages/server/src/server/agent/provider-registry.ts", + "packages/server/src/server/agent/agent-sdk-types.ts", + "packages/server/src/server/agent/providers/codex-app-server-agent.ts", + "packages/server/src/server/agent/providers/claude/agent.ts", + "packages/server/src/server/agent/plugin-provider.ts", + "packages/server/src/server/plugins/{index,plugin-process,plugin-process-protocol,runtime}.ts", + "packages/server/src/executable-resolution/**", + "packages/plugin/src/provider.ts", "packages/app/src/!(desktop)/**", "packages/app/e2e/browser/**", "packages/app/e2e/support/**", @@ -239,6 +251,18 @@ test("browser and desktop tests have exclusive, directory-owned suites", () => { "packages/app/package.json", ]); assert.deepEqual(filters.browser, [ + "packages/server/src/server/agent/provider-snapshot-manager.ts", + "packages/server/src/server/session/provider/provider-catalog-session.ts", + "packages/client/src/compat/normalize-provider-models.ts", + "packages/protocol/src/client-capabilities.ts", + "packages/server/src/server/agent/provider-registry.ts", + "packages/server/src/server/agent/agent-sdk-types.ts", + "packages/server/src/server/agent/providers/codex-app-server-agent.ts", + "packages/server/src/server/agent/providers/claude/agent.ts", + "packages/server/src/server/agent/plugin-provider.ts", + "packages/server/src/server/plugins/{index,plugin-process,plugin-process-protocol,runtime}.ts", + "packages/server/src/executable-resolution/**", + "packages/plugin/src/provider.ts", "packages/app/src/!(desktop)/**", "packages/app/e2e/browser/**", "packages/app/e2e/support/**", From b403dea32beaed5e1f9a08db52b88d12579c6dd2 Mon Sep 17 00:00:00 2001 From: Omer Cohen <639682+omercnet@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:40:23 +0300 Subject: [PATCH 022/566] feat(client): expose paseo.projects.subscribe() through the public SDK (#3983) * feat(client): expose project update subscriptions * docs(sdk): clarify project subscription initialization * docs(sdk): document race-free project initialization --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/client/src/index.test.ts | 57 +++++++++++++++++++++++++++++++ packages/client/src/index.ts | 10 ++++++ public-docs/sdk/reference.md | 9 +++-- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 392a53d6b6..7c02924c56 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -335,6 +335,63 @@ test("project actions list registered projects through the existing RPC", async await client.close(); }); +test("project actions subscribe to existing project updates", async () => { + const { client, ws } = await connectClient(); + const updates: string[] = []; + const unsubscribe = client.projects.subscribe((update) => { + updates.push(update.kind === "upsert" ? update.project.projectDisplayName : update.projectId); + }); + + ws.message( + sessionMessage({ + type: "project.update", + payload: { + kind: "upsert", + project: { + projectId: "project_sdk", + projectKey: "sdk", + projectDisplayName: "Renamed SDK", + projectCustomName: "Renamed SDK", + projectCustomIconRevision: null, + projectIconRevision: "icon-revision", + projectRootPath: "/repo/sdk", + projectKind: "git", + syncSeq: 9, + }, + generation: "daemon-generation", + seq: 9, + }, + }), + ); + ws.message( + sessionMessage({ + type: "project.update", + payload: { + kind: "remove", + projectId: "project_removed", + generation: "daemon-generation", + seq: 10, + }, + }), + ); + + expect(updates).toEqual(["Renamed SDK", "project_removed"]); + + unsubscribe(); + ws.message( + sessionMessage({ + type: "project.update", + payload: { + kind: "remove", + projectId: "project_after_unsubscribe", + }, + }), + ); + expect(updates).toEqual(["Renamed SDK", "project_removed"]); + + await client.close(); +}); + test("agent actions list the daemon directory without exposing the low-level client", async () => { const { client, ws } = await connectClient(); const listedAgent = createAgent({ title: "Planner" }); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 3c6bf4b755..12615bd44f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -104,6 +104,11 @@ export type PaseoProjectListOptions = Omit["payload"]; +export type PaseoProjectUpdateHandler = (update: PaseoProjectUpdate) => void; export interface PaseoAgentListResult { requestId: string; @@ -173,6 +178,7 @@ export interface PaseoWorkspaceHandle { export interface PaseoProjectActions { list(options?: PaseoProjectListOptions): Promise; + subscribe(handler: PaseoProjectUpdateHandler): () => void; } export interface PaseoWorkspaceActions { @@ -494,6 +500,10 @@ export function createPaseoApi(daemonClient: DaemonClient): PaseoApi { terminals, projects: { list: (options) => daemonClient.listProjects(options), + subscribe: (handler) => + daemonClient.on("project.update", (message) => { + handler(message.payload); + }), }, workspaces: { list: (options) => daemonClient.fetchWorkspaces(options), diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index aff12fa4e5..bc726bd5be 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -112,9 +112,12 @@ Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `o ## `client.projects` -| Method | Result | Behavior | -| ---------------- | ------------------------ | ----------------------------------------------------------------------------- | -| `list(options?)` | `PaseoProjectListResult` | Lists every registered project, including projects with no active workspaces. | +| Method | Result | Behavior | +| -------------------- | ------------------------ | --------------------------------------------------------------------------------------- | +| `list(options?)` | `PaseoProjectListResult` | Lists every registered project, including projects with no active workspaces. | +| `subscribe(handler)` | Unsubscribe function | Listens only for future project upserts/removals. Pair with `list()` for initial state. | + +To build a complete project cache without an initialization gap, subscribe and buffer updates before awaiting `list()`. Initialize the cache from the list result, then apply buffered updates in arrival order. ## `client.workspaces` From c424f82922fcd36aa9cc9e473644bca04417b420 Mon Sep 17 00:00:00 2001 From: Michael Jakl Date: Mon, 7 Sep 2026 08:57:01 +0200 Subject: [PATCH 023/566] fix(pi): settle autonomous Pi turns triggered by extensions (#3849) * Settle autonomous Pi turns triggered by extensions Treat Pi's agent lifecycle as authoritative for extension-triggered turns that do not have a Paseo provider turn ID. Keep locally handled extension commands immediate, but defer autonomous completion until agent_settled. * Fail autonomous Pi turns on process exit * Fix cancellation of autonomous Pi turns * Synchronize Pi cancellation regressions with queue clearing * Release Pi interruption state at its terminal boundary --------- Co-authored-by: Mohamed Boudra --- .../server/agent/providers/pi/agent.test.ts | 190 ++++++++++++++++++ .../src/server/agent/providers/pi/agent.ts | 80 ++++---- 2 files changed, 227 insertions(+), 43 deletions(-) diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 631ce28b9f..234fe59cec 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -895,6 +895,41 @@ describe("PiRpcAgentSession", () => { ]); }); + test("settles an autonomous turn triggered by a Pi extension custom message", async () => { + const { pi, events } = await createSession(); + const fakeSession = pi.latestSession(); + + fakeSession.emit({ type: "agent_start" }); + fakeSession.emit({ type: "turn_start" }); + fakeSession.emit({ + type: "message_end", + message: { + role: "custom", + content: [{ type: "text", text: "Background process completed" }], + }, + }); + fakeSession.finishAgentRun({ + message: { + role: "assistant", + stopReason: "stop", + content: [{ type: "text", text: "Continuation finished" }], + }, + willRetry: false, + }); + + expect(events.timelineItems()).toEqual([ + { type: "assistant_message", text: "Background process completed" }, + ]); + expect(events.turnLifecycleEvents()).toEqual([{ type: "turn_started", turnId: undefined }]); + + fakeSession.settleTurn(); + + expect(events.turnLifecycleEvents()).toEqual([ + { type: "turn_started", turnId: undefined }, + { type: "turn_completed", turnId: undefined }, + ]); + }); + test("canceling a silent Pi extension command leaves the session usable", async () => { const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); @@ -953,6 +988,143 @@ describe("PiRpcAgentSession", () => { }); }); + test.each(["aborted", "error"])( + "canceling autonomous work with Pi stopReason=%s waits for abort and allows a follow-up", + async (stopReason) => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + const abortFinished = Promise.withResolvers(); + const turnFinished = Promise.withResolvers(); + fakeSession.abort = async () => { + fakeSession.finishTurn({ + role: "assistant", + stopReason, + errorMessage: "This operation was aborted", + content: [], + }); + turnFinished.resolve(); + await abortFinished.promise; + }; + fakeSession.emit({ type: "agent_start" }); + fakeSession.emit({ type: "turn_start" }); + + const stopping = session.interrupt(); + await turnFinished.promise; + expect(events.turnLifecycleEvents()).toEqual([{ type: "turn_started", turnId: undefined }]); + abortFinished.resolve(); + await stopping; + expect(events.turnLifecycleEvents()).toEqual([ + { type: "turn_started", turnId: undefined }, + { type: "turn_canceled", turnId: undefined }, + ]); + + const { turnId } = await session.startTurn("follow-up"); + fakeSession.finishTurn({ role: "assistant", stopReason: "stop", content: [] }); + expect(events.turnLifecycleEvents().at(-1)).toEqual({ type: "turn_completed", turnId }); + }, + ); + + test("a natural completion during Stop does not cancel the next autonomous run", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + const abortFinished = Promise.withResolvers(); + const turnFinished = Promise.withResolvers(); + fakeSession.abort = async () => { + fakeSession.finishTurn({ role: "assistant", stopReason: "stop", content: [] }); + turnFinished.resolve(); + await abortFinished.promise; + }; + fakeSession.emit({ type: "agent_start" }); + fakeSession.emit({ type: "turn_start" }); + const stopping = session.interrupt(); + await turnFinished.promise; + fakeSession.emit({ type: "agent_start" }); + fakeSession.emit({ type: "turn_start" }); + abortFinished.resolve(); + await stopping; + fakeSession.finishTurn({ role: "assistant", stopReason: "stop", content: [] }); + + expect(events.turnLifecycleEvents()).toEqual([ + { type: "turn_started", turnId: undefined }, + { type: "turn_completed", turnId: undefined }, + { type: "turn_started", turnId: undefined }, + { type: "turn_completed", turnId: undefined }, + ]); + }); + + test("a completed foreground Stop does not suppress a later autonomous abort", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + fakeSession.abort = async () => { + fakeSession.finishTurn({ role: "assistant", stopReason: "stop", content: [] }); + }; + const { turnId } = await session.startTurn("finish while stopping"); + fakeSession.emit({ type: "agent_start" }); + fakeSession.emit({ type: "turn_start" }); + await session.interrupt(); + fakeSession.emit({ type: "agent_start" }); + fakeSession.emit({ type: "turn_start" }); + fakeSession.finishTurn({ + role: "assistant", + stopReason: "aborted", + errorMessage: "Autonomous run aborted", + content: [], + }); + + expect(events.turnLifecycleEvents()).toEqual([ + { type: "turn_started", turnId }, + { type: "turn_completed", turnId }, + { type: "turn_started", turnId: undefined }, + { type: "turn_failed", turnId: undefined }, + ]); + }); + + test("Pi process exit during Stop emits one autonomous failure", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + fakeSession.abort = async () => { + fakeSession.finishTurn({ + role: "assistant", + stopReason: "error", + errorMessage: "This operation was aborted", + content: [], + }); + fakeSession.emit({ type: "process_exit", error: "Pi process exited" }); + throw new Error("Pi process exited"); + }; + fakeSession.emit({ type: "agent_start" }); + fakeSession.emit({ type: "turn_start" }); + + await expect(session.interrupt()).rejects.toThrow("Pi process exited"); + expect(events.turnLifecycleEvents()).toEqual([ + { type: "turn_started", turnId: undefined }, + { type: "turn_failed", turnId: undefined }, + ]); + await expect(events.nextTurnFailure()).resolves.toMatchObject({ error: "Pi process exited" }); + }); + + test("preserves the autonomous terminal error when abort rejects", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + fakeSession.abort = async () => { + fakeSession.finishTurn({ + role: "assistant", + stopReason: "error", + errorMessage: "Provider disconnected", + content: [], + }); + throw new Error("Abort failed"); + }; + fakeSession.emit({ type: "agent_start" }); + fakeSession.emit({ type: "turn_start" }); + + await expect(session.interrupt()).rejects.toThrow("Abort failed"); + await expect(events.nextTurnFailure()).resolves.toMatchObject({ + turnId: undefined, + error: expect.stringContaining("Provider disconnected"), + }); + }); + test("suppresses late aborted terminal response arriving after interrupt resolves", async () => { const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); @@ -1431,6 +1603,24 @@ describe("PiRpcAgentSession", () => { }); }); + test("fails an autonomous turn when the Pi process exits before settlement", async () => { + const { pi, events } = await createSession(); + const fakeSession = pi.latestSession(); + + fakeSession.emit({ type: "agent_start" }); + fakeSession.emit({ type: "turn_start" }); + fakeSession.emit({ type: "process_exit", error: "Pi exited" }); + + await expect(events.nextTurnFailure()).resolves.toMatchObject({ + error: "Pi exited", + turnId: undefined, + }); + expect(events.turnLifecycleEvents()).toEqual([ + { type: "turn_started", turnId: undefined }, + { type: "turn_failed", turnId: undefined }, + ]); + }); + test("completes locally handled slash commands when agentInvoked is false", async () => { const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index b262af0d76..aaf517422d 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -1258,11 +1258,9 @@ export class PiRpcAgentSession implements AgentSession { private readonly logger: Logger; private readonly usagePoller: PiUsagePoller; private closed = false; - // Pi reports an aborted OpenAI Responses stream before the abort RPC resolves. - // Keep the turn active until that RPC acknowledges the user-requested cancellation. - private interruptingTurnId: string | null = null; - private lastInterruptedTurnId: string | null = null; - private interruptedTerminalError: { turnId: string; error: string } | null = null; + // Pi publishes the terminal before acknowledging abort. Autonomous runs have no + // turn ID; retain their errors too until the cancellation request settles. + private interruptingTurn: { turnId: string | undefined; error: string | null } | null = null; constructor(options: PiRpcAgentSessionOptions) { this.runtimeSession = options.runtimeSession; @@ -1329,7 +1327,6 @@ export class PiRpcAgentSession implements AgentSession { const turnId = randomUUID(); this.activeTurnId = turnId; this.usagePoller.startTurn(); - this.lastInterruptedTurnId = null; this.activeClientMessageId = options?.clientMessageId ?? null; this.activeAssistantMessageId = null; this.activeTurnStarted = false; @@ -1537,11 +1534,10 @@ export class PiRpcAgentSession implements AgentSession { } async interrupt(): Promise { - const turnId = this.activeTurnId; - if (turnId) { - this.interruptingTurnId = turnId; - this.lastInterruptedTurnId = turnId; - } + const turnId = this.activeTurnId ?? undefined; + const interruption: typeof this.interruptingTurn = + this.activeTurnId || this.activeTurnStarted ? { turnId, error: null } : null; + this.interruptingTurn = interruption; try { try { await this.runtimeSession.clearQueue(); @@ -1554,12 +1550,11 @@ export class PiRpcAgentSession implements AgentSession { } await this.runtimeSession.abort(); } catch (error) { - if (this.interruptingTurnId === turnId) { - this.interruptingTurnId = null; + const terminalError = this.interruptingTurn === interruption ? interruption?.error : null; + if (this.interruptingTurn === interruption) { + this.interruptingTurn = null; } - if (this.interruptedTerminalError?.turnId === turnId) { - const terminalError = this.interruptedTerminalError; - this.interruptedTerminalError = null; + if (terminalError) { this.usagePoller.stopTurn(); this.activeTurnId = null; this.activeClientMessageId = null; @@ -1573,12 +1568,17 @@ export class PiRpcAgentSession implements AgentSession { type: "turn_failed", provider: this.provider, turnId, - error: terminalError.error, + error: terminalError, }); } throw error; } - if (turnId && this.activeTurnId === turnId) { + if ( + interruption && + this.interruptingTurn === interruption && + (this.activeTurnId || this.activeTurnStarted) && + (this.activeTurnId ?? undefined) === turnId + ) { this.usagePoller.stopTurn(); this.activeTurnId = null; this.activeClientMessageId = null; @@ -1595,11 +1595,8 @@ export class PiRpcAgentSession implements AgentSession { turnId, }); } - if (this.interruptingTurnId === turnId) { - this.interruptingTurnId = null; - } - if (this.interruptedTerminalError?.turnId === turnId) { - this.interruptedTerminalError = null; + if (this.interruptingTurn === interruption) { + this.interruptingTurn = null; } } @@ -2167,10 +2164,11 @@ export class PiRpcAgentSession implements AgentSession { private handleProcessExit(error: string): void { this.rejectAllExtensionResults(new Error(error)); - if (!this.activeTurnId) { + this.interruptingTurn = null; + if (!this.activeTurnId && !this.activeTurnStarted) { return; } - const turnId = this.activeTurnId; + const turnId = this.activeTurnId ?? undefined; this.usagePoller.stopTurn(); this.activeTurnId = null; this.activeClientMessageId = null; @@ -2290,6 +2288,9 @@ export class PiRpcAgentSession implements AgentSession { event: Extract; turnId: string | undefined; }): void { + if (!this.activeTurnId && !this.activeTurnStarted) { + return; + } if (event.type === "agent_end") { // COMPAT(piAgentSettled): added in v0.5.0, remove after 2027-02-21 once the Pi // floor emits agent_settled and willRetry. @@ -2297,14 +2298,10 @@ export class PiRpcAgentSession implements AgentSession { this.completeTurn(turnId, event.messages ?? []); return; } - if (this.activeTurnId) { - this.pendingSettledMessages = event.messages ?? []; - } + this.pendingSettledMessages = event.messages ?? []; return; } - if (this.activeTurnId) { - this.completeTurn(turnId, this.pendingSettledMessages ?? []); - } + this.completeTurn(turnId, this.pendingSettledMessages ?? []); } private handleToolExecutionEnd( @@ -2415,7 +2412,9 @@ export class PiRpcAgentSession implements AgentSession { item: { type: "assistant_message", text }, }); } - this.completeTurn(turnId, []); + if (!this.activeTurnStarted) { + this.completeTurn(turnId, []); + } return; } } @@ -2458,20 +2457,16 @@ export class PiRpcAgentSession implements AgentSession { } private completeTurn(turnId: string | undefined, messages: PiAgentMessage[]): void { - if (turnId && this.interruptingTurnId === turnId && isPiAbortedTerminalResponse(messages)) { - this.interruptedTerminalError = { - turnId, - error: latestPiErrorMessage(messages) ?? "Pi turn failed", - }; - return; - } + const errorMessage = latestPiErrorMessage(messages); if ( - isPiAbortedTerminalResponse(messages) && - (turnId === this.lastInterruptedTurnId || (!turnId && this.lastInterruptedTurnId !== null)) + this.interruptingTurn && + this.interruptingTurn.turnId === turnId && + (errorMessage || isPiAbortedTerminalResponse(messages)) ) { - this.lastInterruptedTurnId = null; + this.interruptingTurn.error = errorMessage ?? "Pi turn failed"; return; } + this.interruptingTurn = null; this.activeTurnId = null; this.activeClientMessageId = null; this.activeAssistantMessageId = null; @@ -2480,7 +2475,6 @@ export class PiRpcAgentSession implements AgentSession { this.pendingSettledMessages = null; this.pendingSteerSubmissions.length = 0; this.clearNoTurnBuffers(); - const errorMessage = latestPiErrorMessage(messages); if (typeof errorMessage === "string" && errorMessage.length > 0) { this.usagePoller.stopTurn(); this.emit({ From 1c51b52d079977aea4c9c2b20243d30be112f5f1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 7 Sep 2026 15:52:21 +0200 Subject: [PATCH 024/566] Restore cached conversations before reconnecting (#4421) * fix(app): restore cached conversations before reconnecting Give each host one timeline write authority so cache restores, live events and fetched pages publish consistent rows and coverage. Persist a moving 40-entry restart page independently of loaded scrollback, and keep native navigation mounted while startup settings hydrate. * fix(app): recover cache writes after initialization failures Keep accepted writes pending and retry initialization without another UI update. Publish the storage index only after loading succeeds, while queued host identity changes still settle in order. * test(app): wait for sidebar and workspace transitions Viewport intersection and leaving the creation route can precede completed UI transitions. Wait for the layering hit test and selected agent tab before asserting or navigating again. * fix(app): restore provider loading on native clients The native AbortSignal lacks throwIfAborted, so successful provider responses failed before reaching every model selector. Preserve both cancellation boundaries using the supported aborted property and the existing query cancellation error. --- docs/architecture.md | 23 +- docs/data-model.md | 18 +- docs/expo-router.md | 13 +- docs/mobile-panels.md | 4 +- docs/timeline-sync.md | 110 +- docs/unistyles.md | 3 +- .../browser/agent-message-submission.spec.ts | 5 +- .../e2e/browser/composer-autocomplete.spec.ts | 24 +- .../new-workspace-launch-memory.spec.ts | 13 +- .../app/e2e/browser/sidebar-model-b.spec.ts | 5 +- .../support/helpers/agent-timeline-gate.ts | 33 +- .../support/helpers/timeline-pagination.ts | 9 +- .../e2e/support/helpers/timeline-resume.ts | 8 + packages/app/src/app/_layout.tsx | 88 +- .../src/app/h/[serverId]/agent/[agentId].tsx | 6 - .../appearance-style-boundary.test.ts | 57 + .../components/appearance-style-boundary.tsx | 9 + .../compact-explorer-sidebar-host.tsx | 10 +- .../components/ui/menu/menu-anchor.test.ts | 20 +- .../app/src/components/ui/menu/menu-anchor.ts | 27 +- .../src/composer/draft/create-flow.test.ts | 19 +- .../app/src/composer/draft/create-flow.ts | 25 +- packages/app/src/composer/index.tsx | 5 +- .../src/composer/submission/writer.test.ts | 16 +- .../app/src/composer/submission/writer.ts | 71 +- packages/app/src/contexts/session-context.tsx | 60 +- .../app/src/data/providers-snapshot.test.ts | 247 ++ packages/app/src/data/providers-snapshot.ts | 6 +- .../hooks/use-agent-initialization.test.ts | 20 +- packages/app/src/navigation/themed-stack.tsx | 33 +- .../workspace-route-navigation.test.ts | 101 +- .../navigation/workspace-route-navigation.ts | 32 +- .../directory-sync/agent-replica.test.ts | 14 + .../runtime/directory-sync/agent-replica.ts | 70 +- .../src/runtime/directory-sync/index.test.ts | 741 ++++-- .../app/src/runtime/directory-sync/index.ts | 252 +- .../directory-sync/internal/agent-store.ts | 20 +- .../directory-sync/workspace-replica.test.ts | 7 +- .../directory-sync/workspace-replica.ts | 9 - packages/app/src/runtime/host-runtime.test.ts | 349 ++- packages/app/src/runtime/host-runtime.ts | 98 +- .../src/runtime/replica-cache/index.test.ts | 548 ++++- .../app/src/runtime/replica-cache/index.ts | 865 +++---- .../runtime/replica-cache/row-store-sqlite.ts | 17 +- .../replica-cache/row-store.contract.ts | 23 +- .../src/runtime/replica-cache/row-store.ts | 2 +- .../replica-cache/row-store.web.test.ts | 45 +- .../runtime/replica-cache/row-store.web.ts | 65 +- .../screens/workspace/workspace-screen.tsx | 33 +- packages/app/src/stores/session-store.test.ts | 218 +- packages/app/src/stores/session-store.ts | 615 +---- .../legacy-unverifiable-timeline.json | 62 + .../mixed-unpositioned-message.json | 44 + .../fetch-agent-timeline-once.test.ts | 30 - .../src/timeline/fetch-agent-timeline-once.ts | 31 - .../app/src/timeline/replica.contract.test.ts | 2171 +++++++++++++++++ packages/app/src/timeline/replica.test.ts | 698 +++++- packages/app/src/timeline/replica.ts | 752 ++++++ .../timeline/session-stream-reducers.test.ts | 856 ++++++- .../src/timeline/session-stream-reducers.ts | 924 +++---- .../src/timeline/viewed-timeline-sync.test.ts | 327 ++- .../app/src/timeline/viewed-timeline-sync.ts | 637 ++--- packages/app/src/types/stream.test.ts | 117 + packages/app/src/types/stream.ts | 587 +++-- .../src/utils/agent-directory-sync.test.ts | 100 +- .../app/src/utils/agent-directory-sync.ts | 3 + 66 files changed, 9198 insertions(+), 3252 deletions(-) create mode 100644 packages/app/src/timeline/__fixtures__/legacy-unverifiable-timeline.json create mode 100644 packages/app/src/timeline/__fixtures__/mixed-unpositioned-message.json delete mode 100644 packages/app/src/timeline/fetch-agent-timeline-once.test.ts delete mode 100644 packages/app/src/timeline/fetch-agent-timeline-once.ts create mode 100644 packages/app/src/timeline/replica.contract.test.ts create mode 100644 packages/app/src/timeline/replica.ts diff --git a/docs/architecture.md b/docs/architecture.md index 134e8f3532..e83e3d0539 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,21 +113,14 @@ Cross-platform React Native app that connects to one or more daemons. - Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete. - Voice features: dictation (STT) and voice agent (realtime) -Consumers request directory or timeline data without choosing memory, cache, or network. The owner -publishes an accepted cache hit and then reconciles it over the existing network path. A miss or an -invalid row uses that same path. Offline demand still publishes an accepted cache hit and defers -network reconciliation. Cache loading is demand-driven: opening a chat reads its agent row and -focused timeline row, plus the workspace and project rows needed by the route; opening a directory -reads directory rows for that host and establishes its live subscription when connected. Accepted -cached rows satisfy the same consumer-readiness projection as network rows. Host registry startup and -host connection do not create directory demand or install replicas. The directory owner retains -declared surface demand and re-establishes network reconciliation after reconnect; React does not -track connection generations. Late cache reads cannot replace state already advanced by live or -authoritative network data. Owners explicitly persist accepted commits; directory rows and their -checkpoint share one storage transaction. See -[data-model.md](data-model.md#replica-row-store) -for the storage shape and [timeline-sync.md](timeline-sync.md#client-replica-lifetime) for timeline -resume behavior. +Consumers request data from host-lived owners without choosing memory, disk or network. Cached rows +satisfy the same readiness projection as network rows, including offline. Directory restoration +merges under live/local changes; network refresh waits for that baseline. Only completed catch-up +advances the directory checkpoint because a live sequence can follow an offline gap. The owner +retains surface demand across reconnects; React does not track connection generations. Storage +commit and scoped-read guarantees belong to [data-model.md](data-model.md#replica-row-store); +timeline display, resume and lifetime contracts belong to +[timeline-sync.md](timeline-sync.md#client-replica-lifetime). The three directory entity types have independent monotonic sequences and share one daemon generation. The daemon retains only the latest projection per entity and bounded tombstones, not an diff --git a/docs/data-model.md b/docs/data-model.md index 5bd96f5252..7e05a6d1de 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -578,16 +578,20 @@ compound key `(serverId, kind, id)`; kinds are `agent`, `workspace`, `project`, uses the singleton id. The store is a typed persistence boundary. It returns values to directory and timeline owners and -accepts their explicit commits; it never reads or writes UI state. Reads are scoped to the requested -host, kinds, and ids. Opening a cached workspace uses exact workspace and project keys rather than a -directory scan. One invalid row is deleted and returned as a miss without affecting other rows. +accepts their explicit commits; it never reads or writes UI state. +One invalid row is deleted and returned as a miss without affecting other rows. An invalid directory row and its affected checkpoint cursor are repaired in one transaction, so a later launch cannot accept a checkpoint for a partial baseline. Directory changes and their -checkpoint are also applied in one transaction. +checkpoint are also applied in one transaction. Cold reads query only the requested host's directory +or agent timeline. Accepted commits overlay those reads, including changes accepted or persisted +while a read was in flight. Host removal invalidates old reads; reconciliation exposes moved rows +before the disk rename finishes. Disk is write-behind: a change stays pending until the disk +confirms it, so failed writes preserve accepted values. Timeline eligibility is checked without +serialization; the coalesced write encodes the latest accepted page once. -The cache is capped at 32 MiB and evicts whole hosts in least-recently-written order. Budget -bookkeeping may scan opaque row sizes during a deferred write, never during host registry startup or -before a requested cache row can paint. The row store is not encrypted. A cached timeline can contain +The cache is capped at 32 MiB and evicts whole hosts in least-recently-written order. The first +write loads the global row index and accounts for its bytes; requested reads do not await that +work. Later writes update the index. The row store is not encrypted. A cached timeline can contain source code, prompts, and tool output; encrypted-at-rest storage is a separate security decision. ### Draft Store diff --git a/docs/expo-router.md b/docs/expo-router.md index d6eaf00374..4ccdc14d39 100644 --- a/docs/expo-router.md +++ b/docs/expo-router.md @@ -64,7 +64,10 @@ root host route and pass the nested workspace screen when a host route is already mounted, or Expo Router can append extra hidden workspace deck entries. The workspace navigation helper inspects the mounted navigation state to make that decision; if no host route is mounted yet, it falls back to ordinary route -navigation. +navigation. Both paths wait for the root navigation container to be ready. The +workspace navigation owner keeps the latest pending intent across ref +re-registration and applies it on the container's `ready` event; routes must not +add their own readiness retries. Those hidden entries are not harmless: composer floating panels can measure against the wrong deck and disappear offscreen. Follow the @@ -144,6 +147,14 @@ Do not read the active theme with `useUnistyles()` in a layout to build a small `withUnistyles` boundary without subscribing the route tree itself to every Unistyles runtime update. +Navigators keep their identity across appearance changes. `ThemedStack` remounts +each screen's content below the native stack when appearance tokens change; a +keyed wrapper above a stack remounts the stack itself, and Android crashes when +that happens while a FragmentManager transaction is running, which settings +hydration at startup makes likely. A screen that mounts a nested navigator (the +root stack's `h/[serverId]`) is passed in `nestedNavigatorScreens` so the nested +stack owns its own screens. See [unistyles.md](unistyles.md). + ## Regression Shape Pure helper tests are useful but not enough. The failure mode here is native diff --git a/docs/mobile-panels.md b/docs/mobile-panels.md index 447b7108e4..2e6e2f1605 100644 --- a/docs/mobile-panels.md +++ b/docs/mobile-panels.md @@ -33,7 +33,9 @@ The UI worklet owns transient motion: React publishes the active panel only when the canonical target and the UI-thread position agree at the final anchor. Retained content never observes gesture previews, progress, or an unsettled target. The gesture hosts stay mounted; worklets reveal their retained overlays through native styles while -React owns pointer events and accessibility. +React owns pointer events and accessibility. Native panel hosts and their dependent draggable lists +also retain identity across appearance hydration and settings changes. Do not wrap those hosts in +appearance keys; see [Unistyles appearance boundaries](unistyles.md#runtime-theme-patching-for-user-preferences). ## Why one position diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 5d3464a30b..caf7dea383 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -36,11 +36,24 @@ Large unbounded timeline responses can exceed relay frame limits, so catch-up us Page limits are projected-item targets. A tool call lifecycle is one projected item even if it spans many source sequence numbers, and assistant/reasoning chunks are merged before counting. The response carries `seqStart`, `seqEnd`, `sourceSeqRanges`, and `collapsed` so clients can advance sequence cursors without rendering delta rows. -When live delivery detects a sequence gap, the app fetches `direction: "after"`. If the daemon -responds with `hasNewer: true`, the app immediately fetches the next page from `endCursor`. Gap -recovery is complete only when `hasNewer: false`. +When live delivery detects a sequence gap, the app fetches `direction: "after"` from its current +cursor and remembers the sequence the live row reached. If the daemon responds with +`hasNewer: true`, the app immediately fetches the next page from `endCursor`. A catch-up is +complete only when the daemon reports `hasNewer: false` and the accepted coverage has reached +every sequence the live stream reported while the catch-up ran: a page the daemon built before +those rows existed cannot settle them, however it was requested, so the owner continues from the +cursor that page established. One catch-up runs per agent; a gap observed while it is running +raises its obligation instead of starting another request. A non-advancing reply cannot disprove +activity observed while the request was in flight. A newly raised obligation starts the next +page immediately, without an error. A reply that makes no progress against an obligation already +known when it started retains that obligation and uses bounded error backoff. Cancellation (the agent leaves view, +the connection drops) forgets the observed head; the next catch-up starts from the cursor and the +daemon returns everything after it. Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog. +Response completion is independent of row admission: an obsolete tail can finish its initialization +without replacing newer accepted rows. Requests carry the existing init-promise identity, so an old +response cannot settle a replacement initialization. Opening or resuming an agent fetches one bounded latest tail page. Older history remains user-driven by scrolling upward. @@ -82,7 +95,9 @@ authoritative range first. that cursor are applied. Already-covered rows are not replayed. - A true middle gap, epoch change, or rewind atomically replaces stale canonical history with the latest tail. The replacement reconciles positioned live rows beyond its coverage and unresolved - local submissions; it never retains two discontiguous canonical ranges. + local submissions; it never retains two discontiguous canonical ranges. A tail whose window ends + behind the local cursor is a rewind, so it supersedes every positioned row up to that cursor + rather than only the positions it carries; a gap replacement covers only what it carries. The installed tail carries `hasOlder`, so history skipped by a replacement remains reachable through ordinary backward pagination. A backward page is accepted only when it is adjacent to the current @@ -90,39 +105,51 @@ history start; a response requested from a pre-replacement range is stale and is ## Client replica lifetime -The session projection remains host-scoped for as long as the host is registered. The viewed-timeline -owner wraps cached preparation, network catch-up, accepted timeline application, and persistence -behind one interface. React supplies transport and projection operations without selecting a cache -path or issuing a separate persistence notification. +`timeline/replica.ts` is the host-lived timeline write authority. Cache restore, pages, live batches, +submissions and removal publish atomically into the existing Zustand read projection; unchanged +facts retain their references. `viewed-timeline-sync.ts` owns demand and fetch scheduling, available +before a network client so saved history can paint offline. Matching display and verified inputs +share one reduction. Fully covered identified text adopts canonical row identity and metadata; +partial coverage preserves live rows and newer text. Persistence uses the accepted transition, +never mutable UI state. -Removing the host from the registry is the destructive boundary: it stops the runtime and clears the -session and host-scoped setup state together. +Unpositioned live updates do not revoke known in-session source coverage or sequencing: current +daemons also emit out-of-band timeline updates. They can make the restart page display-only, which +grants no resume coverage when reopened. Display pagination remains available, and older pages +cannot certify unpositioned content. Positioned activity ahead of incomplete catch-up preserves the +preceding certified baseline. A cold overflow awaiting its latest tail still accepts live observations; +only an intentionally detached, synchronized history window excludes them. +Epoch changes invalidate old display positions; authoritative resets, including empty windows, +invalidate the old durable baseline. A delayed old-epoch cache cannot replace newer live display. -The timeline owner asks durable replica storage for an agent when that agent becomes visible. An -accepted row paints immediately before subscription acknowledgement or timeline fetch. The stored -range describes those exact items: `startSeq` drives older pagination and `endSeq` drives forward -catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older -history. Code outside the owner does not distinguish cached and network timelines. +The restart window holds at most the latest40 complete entries in source-start order. Loading older +scrollback cannot populate or enlarge it, even when projection merging leaves a short page or its +certificate has been withdrawn. Retained messages can gain complete continuations; overlapping +older tool lifetimes do not pull earlier entries into the window. `startSeq` identifies the source-start +boundary for `before` pagination; `endSeq` is the independently established synchronization position +for `after`. A discarded older tool may finish last, so the latter can exceed every retained row's +completion position. `hasOlder` preserves access to omitted history. -The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail -instead of replaying every missed page. Live gap recovery still pages forward until current. +Cache values without source provenance paint verbatim but cannot authorize resume, including after +new live activity. Canonical replacement restores certification through the ordinary bounded tail +bootstrap; no local sorting or text deduplication repairs saved rows. Disjoint retained ranges keep +the preceding certified baseline. The codec preserves complete text, source chunks and attachment +references; unreconciled local submissions and UI sync generations are not durable. Cache budgeting +and write-behind guarantees belong to [data-model.md](data-model.md#replica-row-store). -If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a -live head, or includes presentation data the cache cannot encode losslessly, persistence drops the -range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that -falsely certifies discarded source rows. A display-only row paints without granting synchronization -authority, so the owner uses the ordinary bounded `tail` bootstrap. +Preparation keeps one promise per agent lifetime. Host identity transfer immediately republishes +retained state; a pending read repeats only when identity changed during that read, because physical +rename may have retired its key. Settled transfers perform no storage read. Directory scope exclusion +ends the lifetime and rejects delayed work but retains the saved timeline; only explicit deletion +removes it. First admission preserves in-flight work; re-admission begins a fresh lifetime. Retained +timeline data alone does not provide offline archived-route metadata. -Live rows received between cache paint and catch-up stay in the separate live head and reconcile with -the authoritative range through the existing forward-page path. The cache does not persist sync -generation or unreconciled local submissions. - -Every daemon-derived live item carries its timeline epoch and sequence position. Bootstrap -replacement keeps only positioned rows newer than the page it installs, while unresolved local -user presentations identify themselves by having client identity without a timeline position. This -prevents a page from duplicating rows it already covers without coupling display continuity to the -shorter-lived submission registry. Unreconciled local presentations are not persisted in the durable -replica cache. +DirectorySync coalesces fetch and acceptance into one operation. The first resume is bounded: a reply +with more history leads to one latest tail, while live gap recovery still pages to completion. A tail +passed by activity accepted during its request is obsolete; a tail behind the request's starting +cursor demonstrates rewind. Client window-replacement intent remains separate from daemon reset. +Covered canonical positions replace matching display history, while positioned rows beyond the page +survive as an unverified overlay. Submission continuity follows the rules below. ## Selective and legacy delivery @@ -144,12 +171,18 @@ version. ## Projected pages reconcile with live presentation -A projected page is canonical state, not a sequence of live deltas. One projected item can overlap -rows already received live—for example, a tool call retained at its original display position while -its completion advances `seqEnd`, followed by a merged assistant message. The app uses -`sourceSeqRanges` to replace overlapping assistant and reasoning projections before applying the -remaining page through the existing stream reducer. It must not append full projected text to a -live prefix. +A projected page is canonical state, not live deltas. Positioned rows carry their source start and +text chunk offsets so page reconciliation replaces covered text while retaining newer chunks. +Tools keep their first-appearance order even when their completion arrives later. Both display lanes +share this source order; a missing canonical unit lands at its source start. + +Message identity alone does not identify a segment: tools can divide same-ID text, and markdown can +split one segment into several rows. For unpositioned text, reconcile only a uniquely matching +contiguous segment using the same markdown boundaries as live rendering. A lagging canonical prefix +leaves newer text unpositioned. Known tool positions constrain ownership; identical unpositioned +segments without a distinguishing position remain ambiguous and are preserved. Text alone never +establishes message identity. Anonymous rows retain the conservative cursor-straddle fallback. +Insertion must not merge an identified segment whose ownership reconciliation already declined. Every path that sends a message to an agent — composer send, dictation accept-and-send, queued send-now, and the host runtime's automatic queue drain — goes through @@ -230,6 +263,7 @@ canonical assistant prefix, it stays in the head lane. No row may be returned in - Server live stream forwarding: `packages/server/src/server/session.ts` - App sync planning: `packages/app/src/timeline/timeline-sync-plan.ts` +- App timeline write authority: `packages/app/src/timeline/replica.ts` - App viewed-agent synchronization: `packages/app/src/timeline/viewed-timeline-sync.ts` - App stream/timeline reducer: `packages/app/src/timeline/session-stream-reducers.ts` - Session wiring: `packages/app/src/contexts/session-context.tsx` diff --git a/docs/unistyles.md b/docs/unistyles.md index af864cf000..3daae333df 100644 --- a/docs/unistyles.md +++ b/docs/unistyles.md @@ -346,7 +346,8 @@ Gotchas: - **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`). - **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with content size (`Math.round(theme.fontSize.content * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes. - **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry. -- **The app shell uses one `AppearanceStyleBoundary`.** Runtime-patched numeric theme values are baked into Unistyles web classes rather than CSS variables, while parsed/memoized content also does not naturally re-run when appearance tokens change. The boundary sits below stable runtime providers in `app/_layout.tsx` and remounts the visual shell once. `applyAppearance` patches the active theme before inactive registry entries so its subscribers receive the committed values in the same update. Do not add local appearance keys or nested boundaries. +- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping. +- **Appearance keys belong below native lifetime owners.** `ThemedStack` wraps screen content through `screenLayout`, preserving navigator identity; nested navigators are listed in `nestedNavigatorScreens` and own their own screen boundaries. The remaining shell surfaces retain their appearance refresh separately from native panel hosts. A key above a native stack can detach its screen container inside a FragmentManager transaction during settings hydration. Keep both navigators and retained panel gesture hosts outside those keys. `applyAppearance` patches the active theme before inactive registry entries so subscribers receive the committed values in the same update. Do not add local appearance keys or a boundary above a navigator. - **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`. ## Patching The Web Runtime diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index 194fe43d1d..ab8a2f85bf 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -37,7 +37,7 @@ import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket- import { gotoAppShell, openSettings, selectModel } from "../support/helpers/app"; import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery"; import { - expectResumeOverflowFallsBackToOneTail, + expectEmptyBaselineBootstrapsWithOneTail, rememberTimelineRequestCounts, } from "../support/helpers/timeline-resume"; import { @@ -484,7 +484,8 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction( await expect(promptRow).toBeVisible(); await expect(response).toBeVisible(); await expectRenderedBefore(promptRow, response); - expectResumeOverflowFallsBackToOneTail(gate, requestsBeforeReturn); + // The initial timeline was empty and live rows were suppressed: there is no resume cursor. + expectEmptyBaselineBootstrapsWithOneTail(gate, requestsBeforeReturn); } finally { gate.setAgentStreamSuppressed(false); gate.restore(); diff --git a/packages/app/e2e/browser/composer-autocomplete.spec.ts b/packages/app/e2e/browser/composer-autocomplete.spec.ts index 4119f7c692..9becf359df 100644 --- a/packages/app/e2e/browser/composer-autocomplete.spec.ts +++ b/packages/app/e2e/browser/composer-autocomplete.spec.ts @@ -94,14 +94,18 @@ interface PopoverFrameRecorderWindow extends Window { __stopComposerAutocompleteFrameRecorder?: () => void; } -async function getTopTestIdAtPoint(page: Page, x: number, y: number) { - return page.evaluate( - ([pointX, pointY]) => { - const element = document.elementFromPoint(pointX, pointY); - return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null; - }, - [x, y], - ); +async function expectAutocompleteCoveredAtPoint(page: Page, x: number, y: number): Promise { + await expect + .poll(() => + page.evaluate( + ([pointX, pointY]) => { + const element = document.elementFromPoint(pointX, pointY); + return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null; + }, + [x, y], + ), + ) + .not.toBe("composer-autocomplete-popover"); } async function installListCommandsStub(page: Page): Promise { @@ -676,13 +680,11 @@ test.describe("Composer autocomplete", () => { const popoverBox = await popover.boundingBox(); expect(popoverBox).not.toBeNull(); - const topTestId = await getTopTestIdAtPoint( + await expectAutocompleteCoveredAtPoint( page, popoverBox!.x + popoverBox!.width / 2, popoverBox!.y + popoverBox!.height / 2, ); - - expect(topTestId).not.toBe("composer-autocomplete-popover"); } finally { await agent.cleanup(); } diff --git a/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts b/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts index f2ee89c12f..77e2a80998 100644 --- a/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts +++ b/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts @@ -1,4 +1,4 @@ -import { test } from "../support/fixtures"; +import { test, expect, type Page } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; import { @@ -30,8 +30,9 @@ const PROMPT_PROFILE: TerminalProfile = { args: ["-c", 'echo remembered: "$0"; exec cat', "{{{prompt}}}"], }; -function hasLeftNewWorkspaceRoute(url: URL): boolean { - return !/\/new(?:\?.*)?$/.test(`${url.pathname}${url.search}`); +async function expectChatLaunchComplete(page: Page): Promise { + const agentTab = page.getByTestId(/^workspace-tab-agent_/).filter({ visible: true }); + await expect(agentTab).toHaveAttribute("aria-selected", "true", { timeout: 30_000 }); } test.describe("New workspace: launch target memory", () => { @@ -78,10 +79,8 @@ test.describe("New workspace: launch target memory", () => { await selectChatLaunch(page); await expectChatLaunchSelected(page); await submitNewWorkspacePrompt(page, "hello from chat"); - // The chat handoff navigates asynchronously (draft -> created agent). Let - // it fully settle before moving on, so it can't fire a late redirect - // that races the next step's navigation. - await page.waitForURL(hasLeftNewWorkspaceRoute, { timeout: 30_000 }); + // Leaving /new can expose a previous workspace before the submitted draft opens. + await expectChatLaunchComplete(page); await openNewWorkspace(); await expectChatLaunchSelected(page); diff --git a/packages/app/e2e/browser/sidebar-model-b.spec.ts b/packages/app/e2e/browser/sidebar-model-b.spec.ts index 33e6ff8ddc..0c7c84b39b 100644 --- a/packages/app/e2e/browser/sidebar-model-b.spec.ts +++ b/packages/app/e2e/browser/sidebar-model-b.spec.ts @@ -96,8 +96,9 @@ test.describe("Model B sidebar shape", () => { try { // Open the workspace and materialize both an agent tab and a terminal tab. await gotoWorkspace(page, mock.workspaceId); - const agentTabs = await getVisibleWorkspaceAgentTabIds(page); - expect(agentTabs).toContain(`workspace-tab-agent_${mock.agentId}`); + await expect + .poll(() => getVisibleWorkspaceAgentTabIds(page)) + .toContain(`workspace-tab-agent_${mock.agentId}`); await clickNewTerminal(page); await expect( diff --git a/packages/app/e2e/support/helpers/agent-timeline-gate.ts b/packages/app/e2e/support/helpers/agent-timeline-gate.ts index 91b6867cf0..11cc4bc3c3 100644 --- a/packages/app/e2e/support/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/support/helpers/agent-timeline-gate.ts @@ -494,18 +494,19 @@ export async function holdAgentOlderTimelinePages( }; } -export async function delayAgentBootstrapTailResponse( +export async function delayAgentBootstrapTimelineResponse( page: Page, agentId: string, ): Promise { - let tailReleased = false; + let bootstrapReleased = false; + let bootstrapResponseSeen = false; let catchUpReleased = false; - const delayedTailForwards: Array<() => void> = []; + const delayedBootstrapForwards: Array<() => void> = []; const delayedCatchUpForwards: Array<() => void> = []; - let resolveDelayedTail: (() => void) | null = null; + let resolveDelayedBootstrap: (() => void) | null = null; let resolveDelayedCatchUp: (() => void) | null = null; - const delayedTail = new Promise((resolve) => { - resolveDelayedTail = resolve; + const delayedBootstrap = new Promise((resolve) => { + resolveDelayedBootstrap = resolve; }); const delayedCatchUp = new Promise((resolve) => { resolveDelayedCatchUp = resolve; @@ -519,10 +520,16 @@ export async function delayAgentBootstrapTailResponse( const payload = sessionMessage ? getPayload(sessionMessage) : null; const isTimelineResponse = sessionMessage?.type === "fetch_agent_timeline_response" && payload?.agentId === agentId; - if (isTimelineResponse && payload.direction === "tail") { - resolveDelayedTail?.(); - if (tailReleased) ws.send(message); - else delayedTailForwards.push(() => ws.send(message)); + // A certified replica bootstraps with after; an empty/display-only replica uses tail. + if ( + isTimelineResponse && + !bootstrapResponseSeen && + (payload.direction === "tail" || payload.direction === "after") + ) { + bootstrapResponseSeen = true; + resolveDelayedBootstrap?.(); + if (bootstrapReleased) ws.send(message); + else delayedBootstrapForwards.push(() => ws.send(message)); return; } if (isTimelineResponse && payload.direction === "after") { @@ -537,14 +544,14 @@ export async function delayAgentBootstrapTailResponse( return { release() { - tailReleased = true; - for (const forward of delayedTailForwards.splice(0)) forward(); + bootstrapReleased = true; + for (const forward of delayedBootstrapForwards.splice(0)) forward(); }, releaseCatchUp() { catchUpReleased = true; for (const forward of delayedCatchUpForwards.splice(0)) forward(); }, - waitForDelayedResponse: () => delayedTail, + waitForDelayedResponse: () => delayedBootstrap, waitForDelayedCatchUp: () => delayedCatchUp, }; } diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts index 0cdd34857c..c6afe03736 100644 --- a/packages/app/e2e/support/helpers/timeline-pagination.ts +++ b/packages/app/e2e/support/helpers/timeline-pagination.ts @@ -2,7 +2,7 @@ import { expect, type Page } from "@playwright/test"; import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; import { readReplicaCache } from "./replica-cache-storage"; import { - delayAgentBootstrapTailResponse, + delayAgentBootstrapTimelineResponse, delayAgentOlderTimelineResponse, holdDaemonHydration, holdAgentOlderTimelinePages, @@ -296,9 +296,10 @@ export async function reloadAgentTimelineFromPersistedReplica( const timeline = cache?.hosts ?.flatMap((host) => host.timelines) .find((candidate) => candidate.agentId === agent.agentId); - return timeline?.items?.length === 50; + // This long fixture fills the latest 40-entry restart page, even after loading scrollback. + return timeline?.items?.length; }) - .toBe(true); + .toBe(40); await page.reload(); await expectTimelinePromptVisible(page, agent.newestPrompt); @@ -503,7 +504,7 @@ export async function holdBootstrapTimelinePage( page: Page, agent: LongTimelineAgent, ): Promise { - return delayAgentBootstrapTailResponse(page, agent.agentId); + return delayAgentBootstrapTimelineResponse(page, agent.agentId); } export async function sendLiveTurnBeforeHydration(agent: LongTimelineAgent): Promise { diff --git a/packages/app/e2e/support/helpers/timeline-resume.ts b/packages/app/e2e/support/helpers/timeline-resume.ts index 73c921776c..e397d17325 100644 --- a/packages/app/e2e/support/helpers/timeline-resume.ts +++ b/packages/app/e2e/support/helpers/timeline-resume.ts @@ -48,6 +48,14 @@ export function expectOneResumeCheckWithoutTail( expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(0); } +export function expectEmptyBaselineBootstrapsWithOneTail( + gate: DaemonWebSocketGate, + before: TimelineRequestCounts, +): void { + expect(gate.getTimelineRequestCount("after") - before.after).toBe(0); + expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(1); +} + export function expectResumeOverflowFallsBackToOneTail( gate: DaemonWebSocketGate, before: TimelineRequestCounts, diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index f2b0755122..d0090178a0 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -28,12 +28,12 @@ import { CommandCenterProvider } from "@/command-center/provider"; import { CommandCenterWorkspaceActions } from "@/command-center/workspace-registration"; import { PluginCommandCenterActions } from "@/plugins/command-center/registration"; import { AddProjectFlowHost } from "@/components/add-project-flow-host"; -import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source"; import { DownloadToast } from "@/components/download-toast"; import { QuittingOverlay } from "@/components/quitting-overlay"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { AppDiagnosticHost } from "@/components/app-diagnostic-host"; +import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import { LeftSidebar } from "@/components/left-sidebar"; import { WindowSidebarMenuToggle } from "@/components/headers/menu-header"; import { DesktopWindowControls } from "@/components/desktop/window-controls"; @@ -551,11 +551,15 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon keyboardShortcutsEnabled={keyboardShortcutsEnabled} /> ); + let themedSidebarChrome = sidebarChrome; + if (isWeb) { + themedSidebarChrome = {sidebarChrome}; + } const workspaceChrome = ( {!isCompactLayout ? ( - {sidebarChrome} + {themedSidebarChrome} ) : null} {usesCompactExplorerHost ? ( @@ -575,42 +579,48 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon ); + // Native panel gesture hosts outlive appearance keys, like native navigators. + // Their tracked styles update in place; web numeric styles still need remounting. const surface = ( {workspaceChrome} - {!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? ( - - - - - - ) : null} - - - {isCompactLayout ? sidebarChrome : null} - - - - - - - - - - - - - - - - - - + + {!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? ( + + + + + + ) : null} + + + + {isCompactLayout ? themedSidebarChrome : null} + + + + + + + + + + + + + + + + + + + + ); @@ -672,7 +682,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { - {children} + {children} ); @@ -883,11 +893,15 @@ const ROOT_STACK_SCREEN_OPTIONS = { headerShown: false, animation: "none" as const, }; +const ROOT_STACK_NESTED_NAVIGATOR_SCREENS = ["h/[serverId]"] as const; function RootStack() { const storeReady = useStoreReady(); return ( - + diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 7f66a07b25..0ad0394143 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -38,12 +38,6 @@ function HostAgentReadyRouteContent() { } return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null; }); - useEffect(() => { - if (!serverId || !agentId || agentWorkspaceId) return; - void getHostRuntimeStore() - .prepareAgentRoute(serverId, agentId) - .catch(() => undefined); - }, [agentId, agentWorkspaceId, serverId]); const shouldLookupAgent = Boolean( serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId, ); diff --git a/packages/app/src/components/appearance-style-boundary.test.ts b/packages/app/src/components/appearance-style-boundary.test.ts index a92278cce5..da6fb85c45 100644 --- a/packages/app/src/components/appearance-style-boundary.test.ts +++ b/packages/app/src/components/appearance-style-boundary.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import ts from "typescript"; import { describe, expect, it } from "vitest"; import { darkTheme } from "@/styles/theme"; import { appearanceStyleBoundaryKey } from "./appearance-style-boundary"; @@ -14,3 +16,58 @@ describe("appearanceStyleBoundaryKey", () => { ); }); }); + +// This is a placement contract against the production JSX, not a simulated native mount. +// Native Gesture Handler/Fabric lifetime behavior is verified on the release app. +describe("retained native gesture host appearance placement", () => { + it.each([ + ["../app/_layout.tsx", ["sidebarChrome", "themedSidebarChrome"]], + [ + "./compact-explorer-sidebar-host.tsx", + ["explorer", "themedExplorer", "CompactExplorerSidebar", "NativeExplorerSidebarDock"], + ], + ] as const)("keeps native hosts outside appearance keys in %s", (path, hosts) => { + const source = ts.createSourceFile( + path, + readFileSync(new URL(path, import.meta.url), "utf8"), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + let hostsFound = 0; + let webKeysFound = 0; + function visit(node: ts.Node) { + let name: string | undefined; + if (ts.isIdentifier(node)) name = node.text; + if (ts.isJsxSelfClosingElement(node)) name = node.tagName.getText(source); + if (name && hosts.some((host) => host === name)) { + hostsFound++; + let webOnly = false; + let keyed = false; + for ( + let child = node, parent = node.parent; + parent; + child = parent, parent = parent.parent + ) { + if ( + ts.isIfStatement(parent) && + parent.expression.getText(source) === "isWeb" && + parent.thenStatement === child + ) + webOnly = true; + if ( + ts.isJsxElement(parent) && + parent.openingElement.tagName.getText(source) === "AppearanceStyleBoundary" + ) + keyed = true; + } + if (keyed && webOnly) webKeysFound++; + expect(keyed && !webOnly, `${name} is remounted on native appearance changes`).toBe(false); + } + ts.forEachChild(node, visit); + } + visit(source); + expect(hostsFound).toBeGreaterThan(0); + expect(webKeysFound).toBeGreaterThan(0); + }); +}); diff --git a/packages/app/src/components/appearance-style-boundary.tsx b/packages/app/src/components/appearance-style-boundary.tsx index f64b3e553f..e4f2a61f73 100644 --- a/packages/app/src/components/appearance-style-boundary.tsx +++ b/packages/app/src/components/appearance-style-boundary.tsx @@ -7,6 +7,15 @@ interface AppearanceStyleBoundaryProps { children: ReactNode; } +// Remounts its children when an appearance token changes. Memoized and parsed content bakes +// tokens in, and on web numeric tokens (font sizes, line heights) are baked into generated +// classes that only a re-render refreshes. Native tracked styles update in place. +// Keep native gesture hosts outside these keys too: shared gesture refs can outlive detached +// dependent views until passive cleanup. It must sit below every native navigator: remounting +// a navigator while settings hydrate +// detaches its screen container inside a FragmentManager transaction and crashes Android. +// `ThemedStack` places one per screen; the app shell places them around the chrome outside the +// navigators. function AppearanceStyleBoundaryBase({ appearanceKey, children }: AppearanceStyleBoundaryProps) { return {children}; } diff --git a/packages/app/src/components/compact-explorer-sidebar-host.tsx b/packages/app/src/components/compact-explorer-sidebar-host.tsx index 3a05f1c265..2dae4b9042 100644 --- a/packages/app/src/components/compact-explorer-sidebar-host.tsx +++ b/packages/app/src/components/compact-explorer-sidebar-host.tsx @@ -21,6 +21,7 @@ import { resolveCompactExplorerSidebarHostModel, type CompactExplorerSidebarHostModel, } from "@/components/compact-explorer-sidebar-host-state"; +import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; interface CompactExplorerOpenGestureSurfaceProps { children: ReactNode; @@ -177,11 +178,16 @@ export function CompactExplorerSidebarHost({ ) : null; + let themedExplorer = explorer; + if (isWeb) { + themedExplorer = {explorer}; + } + if (presentation === "dock") { return ( {children} - {explorer} + {themedExplorer} ); } @@ -194,7 +200,7 @@ export function CompactExplorerSidebarHost({ > {children} - {explorer} + {themedExplorer} ); } diff --git a/packages/app/src/components/ui/menu/menu-anchor.test.ts b/packages/app/src/components/ui/menu/menu-anchor.test.ts index ecdf438fe7..add33fff3a 100644 --- a/packages/app/src/components/ui/menu/menu-anchor.test.ts +++ b/packages/app/src/components/ui/menu/menu-anchor.test.ts @@ -68,10 +68,20 @@ describe("computePosition", () => { describe("getTransformOrigin", () => { it.each([ - ["bottom", "start", "left top"], - ["top", "end", "right bottom"], - ["right", "center", "center center"], - ] as const)("maps %s/%s to %s", (placement, alignment, expected) => { - expect(getTransformOrigin(placement, alignment)).toBe(expected); + ["bottom", "start", [0, 0, 0]], + ["bottom", "center", ["50%", 0, 0]], + ["bottom", "end", ["100%", 0, 0]], + ["top", "start", [0, "100%", 0]], + ["top", "center", ["50%", "100%", 0]], + ["top", "end", ["100%", "100%", 0]], + ["left", "start", [0, "50%", 0]], + ["left", "center", ["50%", "50%", 0]], + ["left", "end", ["100%", "50%", 0]], + ["right", "start", [0, "50%", 0]], + ["right", "center", ["50%", "50%", 0]], + ["right", "end", ["100%", "50%", 0]], + ] as const)("emits a native-ready origin for %s/%s", (placement, alignment, expected) => { + // Theme updates may reach Fabric without React Native's JS string preprocessing. + expect(getTransformOrigin(placement, alignment)).toEqual(expected); }); }); diff --git a/packages/app/src/components/ui/menu/menu-anchor.ts b/packages/app/src/components/ui/menu/menu-anchor.ts index c7548dde68..efdfbc8ab2 100644 --- a/packages/app/src/components/ui/menu/menu-anchor.ts +++ b/packages/app/src/components/ui/menu/menu-anchor.ts @@ -144,18 +144,21 @@ export function computePosition({ return { ...clampToDisplayArea({ ...anchored, contentSize, displayArea }), actualPlacement }; } -export function getTransformOrigin(placement: Placement, alignment: Alignment): string { - let vertical: string; - if (placement === "bottom") vertical = "top"; - else if (placement === "top") vertical = "bottom"; - else vertical = "center"; +export function getTransformOrigin( + placement: Placement, + alignment: Alignment, +): [number | string, number | string, number] { + let vertical: number | string; + if (placement === "bottom") vertical = 0; + else if (placement === "top") vertical = "100%"; + else vertical = "50%"; - let horizontal: string; - if (alignment === "start") horizontal = "left"; - else if (alignment === "end") horizontal = "right"; - else horizontal = "center"; + let horizontal: number | string; + if (alignment === "start") horizontal = 0; + else if (alignment === "end") horizontal = "100%"; + else horizontal = "50%"; - // React Native parses transform-origin positionally (x then y), unlike CSS - // which accepts keyword pairs in either order. - return `${horizontal} ${vertical}`; + // Native theme updates can bypass RN's JS string preprocessing. Supply the native + // array shape directly; Reanimated's web style builder also accepts this form. + return [horizontal, vertical, 0]; } diff --git a/packages/app/src/composer/draft/create-flow.test.ts b/packages/app/src/composer/draft/create-flow.test.ts index 8587fa3db6..929b3e1be0 100644 --- a/packages/app/src/composer/draft/create-flow.test.ts +++ b/packages/app/src/composer/draft/create-flow.test.ts @@ -2,17 +2,34 @@ * @vitest-environment jsdom */ import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import type { UserMessageImageAttachment } from "@/types/stream"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "./create-flow"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { defaultHostAppearance } from "@/hosts/appearance"; + describe("useDraftAgentCreateFlow", () => { beforeEach(() => { useCreateFlowStore.setState({ pendingByDraftId: {} }); + getHostRuntimeStore().syncHosts([ + { + serverId: "server-1", + label: "test", + appearance: defaultHostAppearance(), + lifecycle: {}, + connections: [], + preferredConnectionId: null, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }, + ]); }); + afterEach(() => getHostRuntimeStore().syncHosts([])); + it("renders a prepared new-workspace submission before continuing it", async () => { const image: UserMessageImageAttachment = { id: "image-1", diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index d1cfce1161..8f708744c0 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -6,7 +6,7 @@ import { splitComposerAttachmentsForSubmit, } from "@/composer/attachments/submit"; import { useCreateFlowStore } from "@/stores/create-flow-store"; -import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { createUserMessage, @@ -214,17 +214,18 @@ export function useDraftAgentCreateFlow({ if (createResult.agentId) { updatePendingAgentId({ draftId, agentId: createResult.agentId }); - handoffCreatedAgentMessageSubmission( - pendingServerId, - createResult.agentId, - createUserMessage({ - clientMessageId: attempt.clientMessageId, - text: attempt.text, - timestamp: attempt.timestamp, - images: attempt.images, - attachments: attempt.attachments, - }), - ); + getHostRuntimeStore() + .getTimelineReplica(pendingServerId) + .handoffSubmission( + createResult.agentId, + createUserMessage({ + clientMessageId: attempt.clientMessageId, + text: attempt.text, + timestamp: attempt.timestamp, + images: attempt.images, + attachments: attempt.attachments, + }), + ); markPendingCreateLifecycle({ draftId, lifecycle: "sent" }); } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 84de49a3b9..a1c97957aa 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1484,7 +1484,10 @@ function ComposerContentImpl({ supportsForgeAttachments: supportsForgeSearch, }), encodeImages, - submission: createMessageSubmissionWriter(serverId), + submission: createMessageSubmissionWriter( + serverId, + getHostRuntimeStore().getTimelineReplica(serverId), + ), activeTurnBehavior, activeTurnId: activeTurnBehavior === "steer" diff --git a/packages/app/src/composer/submission/writer.test.ts b/packages/app/src/composer/submission/writer.test.ts index 3ee174d554..1c76186615 100644 --- a/packages/app/src/composer/submission/writer.test.ts +++ b/packages/app/src/composer/submission/writer.test.ts @@ -1,3 +1,4 @@ +import { createTimelineReplica } from "@/timeline/replica"; import { afterEach, expect, test } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useSessionStore } from "@/stores/session-store"; @@ -40,10 +41,17 @@ afterEach(() => { test("tracks submissions when canonical prompts are supported", () => { initializeSession(CANONICAL_ONLY_SERVER_ID, { canonicalSubmittedPrompts: true }); - createMessageSubmissionWriter(CANONICAL_ONLY_SERVER_ID).begin( - "agent-1", - submittedMessage("canonical-only-message"), - ); + createMessageSubmissionWriter( + CANONICAL_ONLY_SERVER_ID, + createTimelineReplica({ + serverId: CANONICAL_ONLY_SERVER_ID, + storage: { + removeTimeline: () => undefined, + readTimeline: async () => undefined, + commitTimeline: () => undefined, + }, + }), + ).begin("agent-1", submittedMessage("canonical-only-message")); expect(submissionIds(CANONICAL_ONLY_SERVER_ID)).toEqual(["canonical-only-message"]); }); diff --git a/packages/app/src/composer/submission/writer.ts b/packages/app/src/composer/submission/writer.ts index f8b40034b6..5cef427fae 100644 --- a/packages/app/src/composer/submission/writer.ts +++ b/packages/app/src/composer/submission/writer.ts @@ -1,72 +1,19 @@ import type { MessageSubmissionWriter } from "@/composer/actions"; import { useSessionStore } from "@/stores/session-store"; -import { - appendSubmittedUserMessage, - removeSubmittedUserMessage, - type UserMessageItem, -} from "@/types/stream"; +import type { TimelineReplica } from "@/timeline/replica"; -function appendUntrackedSubmission(serverId: string, agentId: string, message: UserMessageItem) { - const session = useSessionStore.getState().sessions[serverId]; - if (!session) return; - const tail = session.agentStreamTail.get(agentId) ?? []; - const head = session.agentStreamHead.get(agentId) ?? []; - useSessionStore - .getState() - .setAgentStreamState(serverId, agentId, appendSubmittedUserMessage({ tail, head, message })); -} - -function removeUntrackedSubmission( +export function createMessageSubmissionWriter( serverId: string, - agentId: string, - clientMessageId: string, -): void { - const session = useSessionStore.getState().sessions[serverId]; - if (!session) return; - const tail = session.agentStreamTail.get(agentId) ?? []; - const head = session.agentStreamHead.get(agentId) ?? []; - useSessionStore - .getState() - .setAgentStreamState( - serverId, - agentId, - removeSubmittedUserMessage({ tail, head, clientMessageId }), - ); -} - -function createUntrackedMessageSubmissionWriter(serverId: string): MessageSubmissionWriter { - return { - begin: (agentId, message) => appendUntrackedSubmission(serverId, agentId, message), - accept: () => undefined, - reject: (agentId, clientMessageId) => { - removeUntrackedSubmission(serverId, agentId, clientMessageId); - return "rejected"; - }, - }; -} - -export function createMessageSubmissionWriter(serverId: string): MessageSubmissionWriter { - const supportsTrackedMessageSubmissions = + replica: TimelineReplica, +): MessageSubmissionWriter { + // COMPAT(canonicalSubmittedPrompts): added in v0.2.6; remove the gate after 2027-01-31 once daemon floor >= v0.2.6. + const tracked = useSessionStore.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 { - begin: (agentId, message) => - useSessionStore.getState().beginAgentMessageSubmission(serverId, agentId, message), - accept: (agentId, clientMessageId) => - useSessionStore.getState().acceptAgentMessageSubmission(serverId, agentId, clientMessageId), + begin: (agentId, message) => replica.beginSubmission(agentId, message, tracked), + accept: (agentId, clientMessageId) => replica.acceptSubmission(agentId, clientMessageId), reject: (agentId, clientMessageId) => - useSessionStore.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId), + replica.rejectSubmission(agentId, clientMessageId, tracked), }; } - -export function handoffCreatedAgentMessageSubmission( - serverId: string, - agentId: string, - message: UserMessageItem, -): boolean { - return useSessionStore.getState().handoffCreatedAgentUserMessage(serverId, agentId, message); -} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index fff3a0f496..d484169734 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -15,9 +15,7 @@ import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducer import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { requestTimelineReplacement } from "@/timeline/timeline-replacement"; import { - consumeForcedTimelineTailReplacement, type TimelineDeliveryMode, - type TimelineResponsePayload, type ViewedTimelineOwner, } from "@/timeline/viewed-timeline-sync"; import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages"; @@ -229,16 +227,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider // Zustand store actions const setIsPlayingAudio = useSessionStore((state) => state.setIsPlayingAudio); - const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); - const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); - const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead); const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration); const setAgents = useSessionStore((state) => state.setAgents); const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity); const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions); const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo); - const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync); const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress); // Track focused agent for heartbeat @@ -251,7 +245,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const _sessionStateTimeoutRef = useRef | null>(null); const attentionNotifiedRef = useRef>(new Map()); const appStateRef = useRef(AppState.currentState); - const forcedTimelineTailReplacements = useRef(new Set()); const viewedTimelineSyncRef = useRef(null); const audioOutputBuffersRef = useRef>(new Map()); const activeAudioGroupsRef = useRef>(new Set()); @@ -417,33 +410,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider [serverId, upsertWorkspaceSetupProgress], ); - const applyTimelineResponse = useCallback((receivedPayload: TimelineResponsePayload) => { - const payload = consumeForcedTimelineTailReplacement( - receivedPayload, - forcedTimelineTailReplacements.current, - ); - const owner = viewedTimelineSyncRef.current; - if (!owner) throw new Error("Viewed timeline owner is unavailable"); - owner.applyTimelineResponse(payload); - }, []); - useEffect(() => { const setAgentInitializing = createSetAgentInitializing(serverId, setInitializingAgents); const initialDeliveryMode = getTimelineDeliveryMode( client.getLastServerInfoMessage()?.features?.selectiveAgentTimeline, ); - const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, { + const sync = getHostRuntimeStore().getViewedTimelineOwner(serverId); + const detach = sync.attachTransport({ initialDeliveryMode, setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds), - readCursor: (agentId) => { - const timeline = selectAgentTimelineState( - useSessionStore.getState().sessions[serverId], - agentId, - ); - return timeline.status === "synced" && timeline.range - ? { epoch: timeline.range.epoch, endSeq: timeline.range.endSeq } - : undefined; - }, fetchPage: async (agentId, request) => { const session = useSessionStore.getState().sessions[serverId]; const initKey = getInitKey(serverId, agentId); @@ -474,18 +449,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider throw error; } }, - fetchLatestTail: async (agentId) => { - forcedTimelineTailReplacements.current.add(agentId); - try { - return await getHostRuntimeStore().fetchAgentTimeline( - serverId, - agentId, - planTimelineTailFetch(), - ); - } finally { - forcedTimelineTailReplacements.current.delete(agentId); - } - }, + fetchLatestTail: (agentId) => + getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, planTimelineTailFetch(), true), reportError: (error) => { console.warn("[Session] viewed timeline synchronization failed", { serverId, error }); }, @@ -495,17 +460,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }, }); viewedTimelineSyncRef.current = sync; - setViewedTimelineSync(serverId, sync); sync.setActive(getIsAppVisible(appStateRef.current)); return () => { if (viewedTimelineSyncRef.current === sync) { viewedTimelineSyncRef.current = null; } - setViewedTimelineSync(serverId, null); - sync.dispose(); + detach(); }; - }, [client, serverId, setInitializingAgents, setViewedTimelineSync]); + }, [client, serverId, setInitializingAgents]); useEffect(() => { viewedTimelineSyncRef.current?.setConnected(isConnected); @@ -553,12 +516,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } }); - const unsubAgentTimeline = client.on("fetch_agent_timeline_response", (message) => { - if (message.type !== "fetch_agent_timeline_response") return; - owner.flushStreamAgent(message.payload.agentId); - applyTimelineResponse(message.payload); - }); - const unsubTimelineReplacement = client.on("agent.timeline.replacement", (message) => { if (message.type !== "agent.timeline.replacement") return; void requestTimelineReplacement( @@ -764,7 +721,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider return () => { unsubTimelineReplacement(); unsubAgentStream(); - unsubAgentTimeline(); unsubProviderSubagentUpdate(); unsubAgentAttention(); unsubCheckoutStatusUpdate(); @@ -784,15 +740,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider queryClient, serverId, setIsPlayingAudio, - setAgentStreamTail, - setAgentStreamHead, - clearAgentStreamHead, setInitializingAgents, setAgents, setPendingPermissions, notifyAgentAttention, applyWorkspaceSetupProgress, - applyTimelineResponse, updateSessionServerInfo, toast, voiceRuntime, diff --git a/packages/app/src/data/providers-snapshot.test.ts b/packages/app/src/data/providers-snapshot.test.ts index 1e62958440..c1a8ca1ec2 100644 --- a/packages/app/src/data/providers-snapshot.test.ts +++ b/packages/app/src/data/providers-snapshot.test.ts @@ -1,5 +1,16 @@ +import { createRequire } from "node:module"; +import { QueryClient, CancelledError } from "@tanstack/react-query"; +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import type { GetProvidersSnapshotResponseMessage } from "@getpaseo/protocol/messages"; +import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; +import { createProviderSnapshotCache } from "./provider-snapshot-cache"; +import { + resolveProviderIconName, + replaceProviderSnapshotIcons, +} from "@/components/provider-icon-name"; import { describe, expect, it } from "vitest"; import { + fetchProvidersSnapshot, isProvidersSnapshotHomeScope, normalizeProvidersSnapshotCwd, providersSnapshotQueryKey, @@ -49,3 +60,239 @@ describe("providers snapshot query scope", () => { ); }); }); + +// Resolve the exact CommonJS implementation installed by React Native's setUpXHR. +// Its runtime signal deliberately lacks the newer methods in the DOM typings. +const nativeRequire = createRequire(require.resolve("react-native/package.json")); +const { AbortController: NativeAbortController }: { AbortController: typeof AbortController } = + nativeRequire("abort-controller/dist/abort-controller"); + +function createStorage(onWrite: () => void = () => {}) { + const values = new Map(); + return { + async getItem(key: string) { + return values.get(key) ?? null; + }, + async setItem(key: string, value: string) { + values.set(key, value); + onWrite(); + }, + async removeItem(key: string) { + values.delete(key); + }, + async getAllKeys() { + return [...values.keys()]; + }, + async multiGet(keys: readonly string[]) { + return keys.map((key) => [key, values.get(key) ?? null] as const); + }, + async multiRemove(keys: readonly string[]) { + for (const key of keys) values.delete(key); + }, + }; +} + +const entries: ProviderSnapshotEntry[] = [ + { + provider: "test-provider", + status: "ready", + enabled: true, + iconSvg: '', + models: [ + { provider: "test-provider", id: "test-model", label: "Test model", thinkingOptions: [] }, + ], + }, +]; +const snapshot = { + requestId: "test-request", + entries: [], + cwd: "/repo", + generatedAt: "2026-09-07T00:00:00.000Z", + snapshotHash: "test-hash", + compactSnapshot: compactProviderSnapshot(entries), +} satisfies GetProvidersSnapshotResponseMessage["payload"]; + +describe("provider fetch with runtime abort signals", () => { + it.each([ + ["React Native", NativeAbortController], + ["default", AbortController], + ])( + "publishes ready models and persists the real compact snapshot with %s", + async (_, Controller) => { + const controller = new Controller(); + const cache = createProviderSnapshotCache(createStorage()); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + const serverId = "successful-provider-fetch"; + const queryKey = providersSnapshotQueryKey(serverId, snapshot.cwd); + try { + const result = await queryClient.fetchQuery({ + queryKey, + queryFn: () => + fetchProvidersSnapshot({ + client: { getProvidersSnapshot: async () => snapshot }, + serverId, + cwd: snapshot.cwd, + queryClient, + cache, + signal: controller.signal, + }), + }); + expect(result.entries).toEqual(entries); + expect(queryClient.getQueryData(queryKey)).toEqual(result); + expect((await cache.read(serverId, snapshot.cwd))?.entries).toEqual(entries); + expect(resolveProviderIconName("test-provider", serverId)).toEqual({ + kind: "svg", + svg: entries[0].iconSvg, + }); + } finally { + queryClient.clear(); + replaceProviderSnapshotIcons(serverId, []); + } + }, + ); +}); + +describe.each([ + ["React Native", NativeAbortController], + ["default", AbortController], +])("provider fetch cancellation with %s", (_, Controller) => { + it.each([ + { boundary: "response", abortOnWrite: 0, hasBody: false, hasReference: false }, + { boundary: "body persistence", abortOnWrite: 1, hasBody: true, hasReference: false }, + { boundary: "directory persistence", abortOnWrite: 2, hasBody: true, hasReference: true }, + ])( + "does not publish after cancellation at $boundary", + async ({ abortOnWrite, hasBody, hasReference }) => { + const controller = new Controller(); + let writes = 0; + const storage = createStorage(() => { + writes += 1; + if (writes === abortOnWrite) controller.abort(); + }); + const cache = createProviderSnapshotCache(storage); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + const serverId = "cancelled-provider-fetch"; + const queryKey = providersSnapshotQueryKey(serverId, snapshot.cwd); + const previous = { ...snapshot, entries: [{ ...entries[0], iconSvg: "previous-icon" }] }; + queryClient.setQueryData(queryKey, previous); + replaceProviderSnapshotIcons(serverId, previous.entries); + try { + await expect( + queryClient.fetchQuery({ + queryKey, + queryFn: () => + fetchProvidersSnapshot({ + client: { + getProvidersSnapshot: async () => { + if (abortOnWrite === 0) controller.abort(); + return snapshot; + }, + }, + serverId, + cwd: snapshot.cwd, + queryClient, + cache, + signal: controller.signal, + }), + }), + ).rejects.toBeInstanceOf(CancelledError); + expect(controller.signal.aborted).toBe(true); + expect(queryClient.getQueryData(queryKey)).toEqual(previous); + expect(resolveProviderIconName("test-provider", serverId)).toEqual({ + kind: "svg", + svg: "previous-icon", + }); + expect((await cache.readHash(serverId, "test-hash"))?.entries ?? null).toEqual( + hasBody ? entries : null, + ); + expect((await cache.read(serverId, snapshot.cwd))?.entries ?? null).toEqual( + hasReference ? entries : null, + ); + } finally { + queryClient.clear(); + replaceProviderSnapshotIcons(serverId, []); + } + }, + ); +}); + +it("keeps a replacement query when the cancelled native fetch finishes late", async () => { + const controller = new NativeAbortController(); + const cache = createProviderSnapshotCache(createStorage()); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const serverId = "superseded-provider-fetch"; + const queryKey = providersSnapshotQueryKey(serverId, snapshot.cwd); + let finishResponse = () => {}; + const responseReady = new Promise((resolve) => { + finishResponse = resolve; + }); + let markStarted = () => {}; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + let oldFetch: Promise | undefined; + try { + const oldQuery = queryClient.fetchQuery({ + queryKey, + queryFn: ({ signal }) => { + signal.addEventListener("abort", () => controller.abort(), { once: true }); + oldFetch = fetchProvidersSnapshot({ + client: { + getProvidersSnapshot: async () => { + markStarted(); + await responseReady; + return snapshot; + }, + }, + serverId, + cwd: snapshot.cwd, + queryClient, + cache, + signal: controller.signal, + }); + return oldFetch; + }, + }); + const cancelledQuery = expect(oldQuery).rejects.toBeInstanceOf(CancelledError); + await started; + await queryClient.cancelQueries({ queryKey, exact: true }); + await cancelledQuery; + expect(controller.signal.aborted).toBe(true); + const replacementEntries = [{ ...entries[0], iconSvg: "replacement-icon", models: [] }]; + const replacement = await queryClient.fetchQuery({ + queryKey, + queryFn: ({ signal }) => + fetchProvidersSnapshot({ + client: { + getProvidersSnapshot: async () => ({ + ...snapshot, + snapshotHash: "replacement-hash", + compactSnapshot: compactProviderSnapshot(replacementEntries), + }), + }, + serverId, + cwd: snapshot.cwd, + queryClient, + cache, + signal, + }), + }); + finishResponse(); + await expect(oldFetch).rejects.toBeInstanceOf(CancelledError); + expect(queryClient.getQueryData(queryKey)).toEqual(replacement); + expect(replacement.entries).toEqual(replacementEntries); + expect((await cache.read(serverId, snapshot.cwd))?.entries).toEqual(replacementEntries); + expect(resolveProviderIconName("test-provider", serverId)).toEqual({ + kind: "svg", + svg: "replacement-icon", + }); + } finally { + finishResponse(); + queryClient.clear(); + replaceProviderSnapshotIcons(serverId, []); + } +}); diff --git a/packages/app/src/data/providers-snapshot.ts b/packages/app/src/data/providers-snapshot.ts index 868756ee46..0e9cc26495 100644 --- a/packages/app/src/data/providers-snapshot.ts +++ b/packages/app/src/data/providers-snapshot.ts @@ -1,4 +1,4 @@ -import type { QueryClient } from "@tanstack/react-query"; +import { CancelledError, type QueryClient } from "@tanstack/react-query"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { GetProvidersSnapshotResponseMessage } from "@getpaseo/protocol/messages"; import { @@ -100,7 +100,7 @@ export async function fetchProvidersSnapshot(input: { } } } - input.signal?.throwIfAborted(); + if (input.signal?.aborted) throw new CancelledError(); snapshot = await cache.materialize(input.serverId, snapshot); if (snapshot.compactSnapshot && snapshot.snapshotHash) { await cache.write({ @@ -113,7 +113,7 @@ export async function fetchProvidersSnapshot(input: { signal: input.signal, }); } - input.signal?.throwIfAborted(); + if (input.signal?.aborted) throw new CancelledError(); replaceProviderSnapshotIcons(input.serverId, snapshot.entries); return snapshot; } diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index f2726fba02..899f95c631 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -53,13 +53,15 @@ describe("ensureAgentIsInitialized", () => { const client = new FakeDaemonClient(); const runtime = new FakeTimelineRuntime(); useSessionStore.getState().initializeSession(serverId, client as never); - useSessionStore - .getState() - .setAgentTimelineCursor( - serverId, - new Map([[agentId, { epoch: "epoch-1", startSeq: 1, endSeq: 42 }]]), - ); - useSessionStore.getState().setAgentAuthoritativeHistoryApplied(serverId, agentId, true); + useSessionStore.getState().applyAgentTimelineResponseState(serverId, agentId, { + items: [], + head: [], + range: { epoch: "epoch-1", startSeq: 1, endSeq: 42 }, + older: "none", + newer: false, + synchronized: true, + submissions: [], + }); void ensureAgentIsInitialized({ serverId, @@ -129,7 +131,7 @@ describe("ensureAgentIsInitialized", () => { older: "none", newer: false, synchronized: false, - acknowledgedClientMessageIds: [], + submissions: [], }); void ensureAgentIsInitialized({ @@ -172,7 +174,7 @@ describe("ensureAgentIsInitialized", () => { older: "available", newer: false, synchronized: true, - acknowledgedClientMessageIds: [], + submissions: [], }); void ensureAgentIsInitialized({ diff --git a/packages/app/src/navigation/themed-stack.tsx b/packages/app/src/navigation/themed-stack.tsx index cf83f701c9..010877c061 100644 --- a/packages/app/src/navigation/themed-stack.tsx +++ b/packages/app/src/navigation/themed-stack.tsx @@ -1,15 +1,29 @@ import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; import { Stack } from "expo-router"; -import { type ReactNode, useMemo } from "react"; +import { isValidElement, type ReactElement, type ReactNode, useCallback, useMemo } from "react"; import { withUnistyles } from "react-native-unistyles"; +import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; interface ThemedStackBaseProps { backgroundColor: string; children?: ReactNode; screenOptions?: NativeStackNavigationOptions; + // Screens whose content is another navigator. That navigator owns its own screens' appearance + // boundary; wrapping it here would remount it. + nestedNavigatorScreens?: readonly string[]; } -function ThemedStackBase({ backgroundColor, children, screenOptions }: ThemedStackBaseProps) { +interface ScreenContent { + route: { name: string }; + children: ReactNode; +} + +function ThemedStackBase({ + backgroundColor, + children, + screenOptions, + nestedNavigatorScreens, +}: ThemedStackBaseProps) { const themedScreenOptions = useMemo( () => ({ ...screenOptions, @@ -17,8 +31,21 @@ function ThemedStackBase({ backgroundColor, children, screenOptions }: ThemedSta }), [backgroundColor, screenOptions], ); + // Appearance changes remount each screen's content below the native stack, so the navigator + // and its screen containers keep their identity. See docs/unistyles.md. + const screenLayout = useCallback( + ({ route, children: content }: ScreenContent): ReactElement => { + if (nestedNavigatorScreens?.includes(route.name) && isValidElement(content)) return content; + return {content}; + }, + [nestedNavigatorScreens], + ); - return {children}; + return ( + + {children} + + ); } export const ThemedStack = withUnistyles(ThemedStackBase, (theme) => ({ diff --git a/packages/app/src/navigation/workspace-route-navigation.test.ts b/packages/app/src/navigation/workspace-route-navigation.test.ts index 704345f9a0..a331451ed5 100644 --- a/packages/app/src/navigation/workspace-route-navigation.test.ts +++ b/packages/app/src/navigation/workspace-route-navigation.test.ts @@ -7,23 +7,36 @@ import { function createNavigationRef(rootState: unknown, options: { ready?: boolean } = {}) { const dispatch = vi.fn(); - const navigationRef = { - current: { - isReady: () => options.ready ?? true, - getRootState: () => rootState, - dispatch, + let ready = options.ready ?? true; + const readyListeners = new Set<() => void>(); + const navigation = { + isReady: () => ready, + getRootState: () => rootState, + dispatch, + addListener: (event: string, listener: () => void) => { + if (event === "ready") readyListeners.add(listener); + return () => readyListeners.delete(listener); }, + }; + const navigationRef = { + ...navigation, + current: navigation, } as unknown as NavigationContainerRefWithCurrent; - return { navigationRef, dispatch }; + return { + navigationRef, + dispatch, + becomeReady() { + ready = true; + for (const listener of readyListeners) listener(); + }, + }; } describe("navigateToHostWorkspaceRoute", () => { beforeEach(() => { vi.clearAllMocks(); - registerWorkspaceRouteNavigationRef({ - current: null, - } as unknown as NavigationContainerRefWithCurrent)(); + registerWorkspaceRouteNavigationRef(createNavigationRef({}).navigationRef)(); }); it("falls back to route navigation when no host route is mounted yet", () => { @@ -131,4 +144,74 @@ describe("navigateToHostWorkspaceRoute", () => { }, }); }); + + it("waits for the root navigator before applying a route intent", () => { + const { navigationRef, becomeReady } = createNavigationRef( + { + key: "root-stack", + routes: [{ key: "launcher", name: "index" }], + }, + { ready: false }, + ); + registerWorkspaceRouteNavigationRef(navigationRef); + const dismissTo = vi.fn(); + + navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a", { dismissTo }); + expect(dismissTo).not.toHaveBeenCalled(); + + becomeReady(); + expect(dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a"); + becomeReady(); + expect(dismissTo).toHaveBeenCalledTimes(1); + }); + + it("retains an intent submitted before the root ref registers", () => { + const dismissTo = vi.fn(); + navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a", { dismissTo }); + expect(dismissTo).not.toHaveBeenCalled(); + + const root = createNavigationRef({}, { ready: false }); + const unregister = registerWorkspaceRouteNavigationRef(root.navigationRef); + expect(dismissTo).not.toHaveBeenCalled(); + root.becomeReady(); + expect(dismissTo).toHaveBeenCalledExactlyOnceWith("/h/server-1/workspace/workspace-a"); + unregister(); + }); + + it("keeps only the latest route intent while the root navigator is unavailable", () => { + const { navigationRef, becomeReady } = createNavigationRef({}, { ready: false }); + registerWorkspaceRouteNavigationRef(navigationRef); + const firstDismissTo = vi.fn(); + const latestDismissTo = vi.fn(); + + navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a", { + dismissTo: firstDismissTo, + }); + navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-b", { + dismissTo: latestDismissTo, + }); + becomeReady(); + + expect(firstDismissTo).not.toHaveBeenCalled(); + expect(latestDismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-b"); + }); + + it("preserves a pending route intent across root navigator re-registration", () => { + const first = createNavigationRef({}, { ready: false }); + const unregisterFirst = registerWorkspaceRouteNavigationRef(first.navigationRef); + const dismissTo = vi.fn(); + navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a", { dismissTo }); + + unregisterFirst(); + first.becomeReady(); + expect(dismissTo).not.toHaveBeenCalled(); + + const replacement = createNavigationRef({ + key: "root-stack", + routes: [{ key: "launcher", name: "index" }], + }); + registerWorkspaceRouteNavigationRef(replacement.navigationRef); + + expect(dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a"); + }); }); diff --git a/packages/app/src/navigation/workspace-route-navigation.ts b/packages/app/src/navigation/workspace-route-navigation.ts index 79fc1d8c2f..06d0d23e07 100644 --- a/packages/app/src/navigation/workspace-route-navigation.ts +++ b/packages/app/src/navigation/workspace-route-navigation.ts @@ -1,4 +1,8 @@ -import type { NavigationAction, NavigationContainerRefWithCurrent } from "@react-navigation/native"; +import type { + NavigationAction, + NavigationContainerRef, + NavigationContainerRefWithCurrent, +} from "@react-navigation/native"; import { router, type Href } from "expo-router"; import { encodeWorkspaceIdForPathSegment, @@ -19,12 +23,16 @@ const defaultNavigateToHostWorkspaceRouteDeps: NavigateToHostWorkspaceRouteDeps let rootNavigationRef: NavigationContainerRefWithCurrent | null = null; +let pendingIntent: { route: string; deps: NavigateToHostWorkspaceRouteDeps } | null = null; export function registerWorkspaceRouteNavigationRef( ref: NavigationContainerRefWithCurrent, ): () => void { rootNavigationRef = ref; + const unsubscribe = ref.addListener("ready", flushPendingIntent); + flushPendingIntent(); return () => { + unsubscribe(); if (rootNavigationRef === ref) { rootNavigationRef = null; } @@ -95,10 +103,12 @@ function findStackWithMountedRouteName( return null; } -function dispatchHostWorkspacePopTo(route: string): boolean { +function dispatchHostWorkspacePopTo( + route: string, + navigation: NavigationContainerRef, +): boolean { const selection = parseHostWorkspaceRouteFromPathname(route); - const navigation = rootNavigationRef?.current; - if (!selection || !navigation?.isReady()) { + if (!selection) { return false; } @@ -137,9 +147,15 @@ export function navigateToHostWorkspaceRoute( route: string, deps: NavigateToHostWorkspaceRouteDeps = defaultNavigateToHostWorkspaceRouteDeps, ): void { - if (dispatchHostWorkspacePopTo(route)) { - return; - } + pendingIntent = { route, deps }; + flushPendingIntent(); +} - deps.dismissTo(route); +function flushPendingIntent(): void { + const navigation = rootNavigationRef?.current; + if (!pendingIntent || !navigation?.isReady()) return; + + const { route, deps } = pendingIntent; + pendingIntent = null; + if (!dispatchHostWorkspacePopTo(route, navigation)) deps.dismissTo(route); } diff --git a/packages/app/src/runtime/directory-sync/agent-replica.test.ts b/packages/app/src/runtime/directory-sync/agent-replica.test.ts index d7deff40a7..1f4fdc3cc8 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -61,6 +61,7 @@ describe("AgentDirectoryReplica", () => { serverId, () => undefined, () => undefined, + (agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId), ); replica.commitSnapshot([entry(payload("live"))], []); replica.applyTurnLiveness("agent", { @@ -84,6 +85,7 @@ describe("AgentDirectoryReplica", () => { serverId, () => undefined, () => undefined, + (agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId), ); expect(replica.submitTimelineAgent(replica.captureTimeline("agent"), payload("network"))).toBe( @@ -104,6 +106,7 @@ describe("AgentDirectoryReplica", () => { serverId, () => undefined, (mutations) => commits.push([...mutations]), + (agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId), ); replica.commitSnapshot([entry(payload("agent"))], []); commits.length = 0; @@ -144,6 +147,7 @@ describe("AgentDirectoryReplica", () => { serverId, (agentId) => stopped.push(agentId), () => undefined, + (agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId), ); replica.commitSnapshot([entry(payload("agent"))], []); replica.applyTurnLiveness("agent", { @@ -166,6 +170,7 @@ describe("AgentDirectoryReplica", () => { serverId, () => undefined, () => undefined, + (agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId), ); replica.commitSnapshot( [ @@ -219,6 +224,7 @@ describe("AgentDirectoryReplica", () => { serverId, (agentId) => stopped.push(agentId), () => undefined, + (agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId), ); replica.commitSnapshot( [ @@ -262,6 +268,7 @@ describe("AgentDirectoryReplica", () => { serverId, () => undefined, () => undefined, + (agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId), ); replica.commitSnapshot( [ @@ -289,10 +296,16 @@ describe("AgentDirectoryReplica", () => { const serverId = "agent-replica"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); + const timelineLifetimes: string[] = []; const replica = new AgentDirectoryReplica( serverId, () => undefined, () => undefined, + (agentId) => { + timelineLifetimes.push(`remove:${agentId}`); + useSessionStore.getState().removeAgentTimeline(serverId, agentId); + }, + (agentId) => timelineLifetimes.push(`accept:${agentId}`), ); replica.commitSnapshot([entry(payload("directory"))], []); const directoryPlacement = useSessionStore @@ -319,6 +332,7 @@ describe("AgentDirectoryReplica", () => { expect( useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.projectPlacement, ).toEqual(directoryPlacement); + expect(timelineLifetimes).toEqual(["accept:agent", "remove:agent", "accept:agent"]); store.clearSession(serverId); }); }); diff --git a/packages/app/src/runtime/directory-sync/agent-replica.ts b/packages/app/src/runtime/directory-sync/agent-replica.ts index 1bdf3ea3de..ad41e2ae7c 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.ts @@ -4,7 +4,7 @@ import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; import { queryClient } from "@/data/query-client"; import type { Agent } from "@/stores/session-store"; import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots"; -import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync"; +import { type AgentDirectoryDelta, type AgentRemovalReason } from "@/utils/agent-directory-sync"; import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; @@ -25,52 +25,47 @@ export interface AgentLifecycleToken { export class AgentDirectoryReplica { private readonly lifecycleVersions = new Map(); private readonly members = new Set(); - private readonly pendingCacheReads = new Set(); private readonly storeProjection: AgentStoreProjection; constructor( private readonly serverId: string, private readonly onStoppedRunning: (agentId: string) => void, private readonly persist: (mutations: readonly DirectoryReplicaMutation[]) => void, + removeTimeline: (agentId: string, reason: AgentRemovalReason) => void, + private readonly acceptTimeline: (agentId: string) => void = () => undefined, ) { - this.storeProjection = new AgentStoreProjection(serverId); + this.storeProjection = new AgentStoreProjection(serverId, removeTimeline); } captureTimeline(agentId: string): AgentLifecycleToken { return { agentId, version: this.lifecycleVersions.get(agentId) ?? 0 }; } + isTimelineCurrent(token: AgentLifecycleToken): boolean { + return token.version === (this.lifecycleVersions.get(token.agentId) ?? 0); + } + snapshot(): Map { return this.storeProjection.snapshot(); } - captureCache(agentId: string): AgentLifecycleToken { - this.pendingCacheReads.add(agentId); - return this.captureTimeline(agentId); - } - + // Saved rows sit under whatever the live stream already delivered for this connection. commitCached(agents: Map): void { - const merged = this.storeProjection.commitCached(agents); + const current = new Map( + [...agents].filter(([id]) => !this.lifecycleVersions.has(id) || this.members.has(id)), + ); + const merged = this.storeProjection.commitCached(current); this.members.clear(); for (const agentId of merged.keys()) { this.members.add(agentId); } } - commitCachedAgent(token: AgentLifecycleToken, agent: Agent): boolean { - this.pendingCacheReads.delete(token.agentId); - if (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) return false; - if (this.members.has(agent.id)) return false; - this.members.add(agent.id); - this.storeProjection.accept(agent); - this.storeProjection.publishActivity(agent); - return true; - } - submitTimelineAgent(token: AgentLifecycleToken, payload: AgentSnapshotPayload): boolean { - if (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) { + if (!this.isTimelineCurrent(token)) { return false; } + const startsLifetime = !this.members.has(token.agentId); const existing = this.storeProjection.get(token.agentId); const timelineAgent = applyLegacyDaemonWorkspaceOwnership({ serverId: this.serverId, @@ -81,6 +76,10 @@ export class AgentDirectoryReplica { projectPlacement: timelineAgent.projectPlacement ?? existing?.projectPlacement, }; const accepted = this.storeProjection.accept(normalized); + if (startsLifetime) { + this.resume(accepted.id); + this.acceptTimeline(accepted.id); + } this.members.add(accepted.id); this.storeProjection.replacePendingPermissions(accepted); this.storeProjection.publishActivity(accepted); @@ -99,7 +98,10 @@ export class AgentDirectoryReplica { this.advance(delta.agentId); } else { this.members.add(delta.agent.id); - if (!before) this.advance(delta.agent.id); + if (!before) { + this.resume(delta.agent.id); + this.acceptTimeline(delta.agent.id); + } } if (result.stoppedRunning) this.onStoppedRunning(result.agentId); this.persist( @@ -110,7 +112,12 @@ export class AgentDirectoryReplica { } accept(agent: Agent): Agent { + const startsLifetime = !this.members.has(agent.id); const accepted = this.storeProjection.accept(agent); + if (startsLifetime) { + this.resume(accepted.id); + this.acceptTimeline(accepted.id); + } this.members.add(accepted.id); this.persist([this.agentUpsert(accepted)]); return accepted; @@ -124,22 +131,23 @@ export class AgentDirectoryReplica { const previous = this.storeProjection.snapshot(); const reconciled = reconcileAgentDirectory({ snapshot: entries, deltas }); const nextIds = new Set(reconciled.map((entry) => entry.agent.id)); - for (const agentId of this.pendingCacheReads) { - if (!nextIds.has(agentId)) this.advance(agentId); - } + const startedLifetimes = new Set(); for (const agentId of this.members) { if (!nextIds.has(agentId)) this.advance(agentId); } for (const agentId of nextIds) { - if (!this.members.has(agentId)) this.advance(agentId); + if (!this.members.has(agentId)) { + this.resume(agentId); + startedLifetimes.add(agentId); + } } for (const agentId of previous.keys()) { - if (!nextIds.has(agentId)) this.storeProjection.remove(agentId); + if (!nextIds.has(agentId)) this.storeProjection.remove(agentId, "scope"); } this.members.clear(); - this.pendingCacheReads.clear(); for (const agentId of nextIds) this.members.add(agentId); const agents = this.storeProjection.replaceFetched(reconciled); + for (const agentId of startedLifetimes) this.acceptTimeline(agentId); for (const [agentId, previousAgent] of previous) { if (previousAgent.turn.phase === "open" && agents.get(agentId)?.turn.phase === "idle") { this.onStoppedRunning(agentId); @@ -201,7 +209,7 @@ export class AgentDirectoryReplica { remove(agentId: string): void { this.members.delete(agentId); this.advance(agentId); - this.storeProjection.remove(agentId); + this.storeProjection.remove(agentId, "deleted"); this.persist([{ kind: "agent", type: "delete", id: agentId }]); } @@ -225,6 +233,12 @@ export class AgentDirectoryReplica { }; } + private resume(agentId: string): void { + // First admission keeps requests already in flight. Re-entry invalidates work issued + // during the excluded lifetime as well as work issued before its removal. + if (this.lifecycleVersions.has(agentId)) this.advance(agentId); + } + private advance(agentId: string): void { this.lifecycleVersions.set(agentId, (this.lifecycleVersions.get(agentId) ?? 0) + 1); } diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 1593c4752f..030ddb1225 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -6,9 +6,17 @@ import { normalizeWorkspaceDescriptor, useSessionStore, } from "@/stores/session-store"; -import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots"; import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors"; -import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; +import { ReplicaCache, type DirectoryReplicaMutation } from "@/runtime/replica-cache"; +import { createTimelineReplica } from "@/timeline/replica"; +import { createViewedTimelineOwner } from "@/timeline/viewed-timeline-sync"; +import type { + ReplicaHostRows, + ReplicaRow, + ReplicaRowChanges, + ReplicaRowStore, +} from "@/runtime/replica-cache/row-store"; import { DirectoryRefreshSupersededError, DirectorySync, @@ -18,6 +26,7 @@ import { type WorkspaceFetchResult = Awaited>; type ProjectListResult = Awaited>; type AgentFetchResult = Awaited>; +type TimelineFetchResult = Awaited>; class FakeDirectoryClient { fetchAgentsCalls = 0; @@ -29,6 +38,10 @@ class FakeDirectoryClient { projectResult: ProjectListResult | null = null; private pendingAgentFetch: Promise | null = null; private pendingWorkspaceFetch: Promise | null = null; + private readonly pendingTimelineFetches: Array<{ + promise: Promise; + resolve(result: TimelineFetchResult): void; + }> = []; private readonly handlers = new Map< SessionOutboundMessage["type"], Set<(message: SessionOutboundMessage) => void> @@ -98,6 +111,21 @@ class FakeDirectoryClient { }; } + fetchAgentTimeline(): Promise { + let resolve!: (result: TimelineFetchResult) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + this.pendingTimelineFetches.push({ promise, resolve }); + return promise; + } + + nextTimelineFetch(): { resolve(result: TimelineFetchResult): void } { + const pending = this.pendingTimelineFetches.shift(); + if (!pending) throw new Error("Expected a pending timeline fetch"); + return { resolve: pending.resolve }; + } + async listProjects(options?: unknown): Promise { this.listProjectsCalls += 1; this.lastProjectOptions = options; @@ -121,20 +149,102 @@ class FakeDirectoryClient { } } +class MemoryRowStore implements ReplicaRowStore { + readonly rows = new Map(); + readAllGate: Promise | null = null; + + private key(row: Pick): string { + return `${row.serverId}:${row.kind}:${row.id}`; + } + + async open(): Promise {} + + async read( + serverIds: readonly string[], + kinds: readonly ReplicaRow["kind"][], + ids?: readonly string[], + ): Promise { + await this.readAllGate; + return [...this.rows.values()].filter( + (row) => + serverIds.includes(row.serverId) && + kinds.includes(row.kind) && + (!ids || ids.includes(row.id)), + ); + } + + async readAll(): Promise { + await this.readAllGate; + const hosts = new Map(); + for (const row of this.rows.values()) { + hosts.set(row.serverId, [...(hosts.get(row.serverId) ?? []), row]); + } + return [...hosts].map(([serverId, rows]) => ({ serverId, rows })); + } + + async apply(changes: ReplicaRowChanges): Promise { + for (const key of changes.deletes) this.rows.delete(this.key(key)); + for (const row of changes.upserts) this.rows.set(this.key(row), row); + } + + async deleteHost(serverId: string): Promise { + for (const [key, row] of this.rows) if (row.serverId === serverId) this.rows.delete(key); + } + + async renameHost(oldServerId: string, newServerId: string): Promise { + for (const [key, row] of this.rows) { + if (row.serverId !== oldServerId) continue; + this.rows.delete(key); + const renamed = { ...row, serverId: newServerId }; + this.rows.set(this.key(renamed), renamed); + } + } + + async clear(): Promise { + this.rows.clear(); + } +} + +// A saved directory on disk, read back through the real storage owner with its load gated. +async function savedDirectory( + serverId: string, + directory: Parameters[1], +): Promise<{ cache: ReplicaCache; releaseLoad: () => void }> { + const rowStore = new MemoryRowStore(); + const writer = new ReplicaCache(rowStore, { clearLegacyCache: async () => undefined }); + writer.setHosts([serverId]); + writer.replaceDirectoryBaseline(serverId, directory); + await writer.flush(); + let releaseLoad!: () => void; + rowStore.readAllGate = new Promise((resolve) => { + releaseLoad = resolve; + }); + const cache = new ReplicaCache(rowStore, { clearLegacyCache: async () => undefined }); + cache.setHosts([serverId]); + return { cache, releaseLoad }; +} + +const noopCallbacks = { + onTimelineRequest: () => () => undefined, + onAgentStoppedRunning: () => undefined, + onAgentRemoved: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, +}; + const serverIds = new Set(); -function createDirectory(serverId: string): { +function createDirectory( + serverId: string, + callbacks: ConstructorParameters[1] = noopCallbacks, +): { client: FakeDirectoryClient; directory: DirectorySync; } { serverIds.add(serverId); const client = new FakeDirectoryClient(); - const directory = new DirectorySync(serverId, { - onAgentStoppedRunning: () => undefined, - markAgentLoading: () => undefined, - markAgentReady: () => undefined, - markAgentError: () => undefined, - }); + const directory = new DirectorySync(serverId, callbacks); directory.connectionChanged({ client: client as unknown as DaemonClient, status: "online", @@ -176,12 +286,343 @@ function createAgent(serverId: string, id: string) { }; } +it.each(["snapshot", "changes", "live"] as const)( + "%s scope exclusion retains the saved page but invalidates old work; deletion removes it", + async (exclusion) => { + const serverId = `timeline-scope-${exclusion}`; + serverIds.add(serverId); + useSessionStore.getState().initializeSession(serverId, null); + const rows = new MemoryRowStore(); + const cache = new ReplicaCache(rows, { clearLegacyCache: async () => undefined }); + cache.setHosts([serverId]); + const saved = { + agentId: "agent", + range: { epoch: "epoch", startSeq: 1, endSeq: 1 }, + hasOlder: false, + items: [ + { + kind: "assistant_message" as const, + id: "saved", + text: "saved history", + timestamp: new Date("2026-09-06T00:00:00Z"), + timelineCursor: { epoch: "epoch", seq: 1 }, + source: { startSeq: 1 }, + }, + ], + }; + cache.commitTimeline(serverId, "agent", saved); + await cache.flush(); + const replica = createTimelineReplica({ serverId, storage: cache }); + const viewed = createViewedTimelineOwner({ + serverId, + replica, + replaceDemandedAgentIds: () => undefined, + drainQueuedAgentMessage: () => undefined, + }); + const directory = new DirectorySync( + serverId, + { + ...noopCallbacks, + onAgentRemoved: (...args) => viewed.removeAgent(...args), + onAgentAccepted: (id) => viewed.acceptAgent(id), + }, + cache, + ); + const client = new FakeDirectoryClient(); + useSessionStore.getState().updateSessionClient(serverId, client as unknown as DaemonClient, 1); + useSessionStore.getState().updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true, directorySync: true }, + }); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + directory.acceptAgent(createAgent(serverId, "agent")); + await replica.prepare("agent"); + const stale = directory.fetchTimeline("agent", { + direction: "tail", + limit: 40, + projection: "projected", + }); + const staleReply = client.nextTimelineFetch(); + if (exclusion === "live") { + client.emit({ type: "agent_update", payload: { kind: "remove", agentId: "agent" } }); + } else { + const reply = client.holdAgentFetch(); + const refreshing = directory.refreshAgents(); + await expect.poll(() => client.fetchAgentsCalls).toBe(1); + reply({ + requestId: "scope", + entries: [], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + ...(exclusion === "changes" + ? { + sync: { + generation: "g", + headSeq: 1, + mode: "changes" as const, + removals: [{ id: "agent", seq: 1 }], + }, + } + : {}), + }); + await refreshing; + } + expect(useSessionStore.getState().sessions[serverId]?.agentStreamTail.has("agent")).toBe(false); + expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(false); + viewed.enqueueStreamEvent("agent", { + epoch: "epoch", + seq: 2, + timestamp: new Date("2026-09-06T00:00:01Z"), + event: { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "late work", messageId: "late" }, + }, + }); + viewed.flushStreamAgent("agent"); + expect(useSessionStore.getState().sessions[serverId]?.agentStreamTail.has("agent")).toBe(false); + await cache.flush(); + const reopened = new ReplicaCache(rows, { clearLegacyCache: async () => undefined }); + reopened.setHosts([serverId]); + expect(await reopened.readTimeline(serverId, "agent")).toEqual(saved); + directory.acceptAgent(createAgent(serverId, "agent")); + await replica.prepare("agent"); + staleReply.resolve({ agent: null, hasNewer: false } as TimelineFetchResult); + await expect(stale).rejects.toBeInstanceOf(DirectoryRefreshSupersededError); + expect(useSessionStore.getState().sessions[serverId]?.agentStreamTail.get("agent")).toEqual( + saved.items, + ); + // Deletion must also remove a saved page whose agent is already out of scope. + client.emit({ type: "agent_update", payload: { kind: "remove", agentId: "agent" } }); + client.emit({ type: "agent_deleted", payload: { agentId: "agent", requestId: "delete" } }); + // The subsequent directory remove must not recreate the deleted row. + client.emit({ type: "agent_update", payload: { kind: "remove", agentId: "agent" } }); + await cache.flush(); + const afterDeletion = new ReplicaCache(rows, { clearLegacyCache: async () => undefined }); + afterDeletion.setHosts([serverId]); + expect(await afterDeletion.readTimeline(serverId, "agent")).toBeUndefined(); + viewed.dispose(); + directory.dispose(); + }, +); + +it.each(["snapshot-first", "page-first", "live-first"] as const)( + "first admission preserves the first timeline page (%s)", + async (ordering) => { + const serverId = `first-admission-${ordering}`; + serverIds.add(serverId); + useSessionStore.getState().initializeSession(serverId, null); + const rows = new MemoryRowStore(); + const cache = new ReplicaCache(rows, { clearLegacyCache: async () => undefined }); + cache.setHosts([serverId]); + const replica = createTimelineReplica({ serverId, storage: cache }); + const viewed = createViewedTimelineOwner({ + serverId, + replica, + replaceDemandedAgentIds: () => undefined, + drainQueuedAgentMessage: () => undefined, + }); + let applications = 0; + const { directory, client } = createDirectory(serverId, { + ...noopCallbacks, + onAgentAccepted: (id) => viewed.acceptAgent(id), + onTimelineRequest: (id) => { + const apply = viewed.beginTimelineRequest(id); + return (page) => { + applications++; + apply(page); + }; + }, + }); + useSessionStore.getState().updateSessionClient(serverId, client as unknown as DaemonClient, 1); + useSessionStore.getState().updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true }, + }); + await directory.ready(); + const request = { direction: "tail" as const, limit: 40, projection: "projected" as const }; + const fetching = directory.fetchTimeline("agent", request); + const concurrent = directory.fetchTimeline("agent", request); + const reply = client.nextTimelineFetch(); + const agent = projectAgentSnapshot(createAgent(serverId, "agent")); + const page: TimelineFetchResult = { + requestId: "first", + agentId: "agent", + agent, + direction: "tail", + projection: "projected", + reset: false, + staleCursor: false, + gap: false, + epoch: "epoch", + window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, + startCursor: { epoch: "epoch", seq: 1 }, + endCursor: { epoch: "epoch", seq: 1 }, + hasOlder: false, + hasNewer: false, + error: null, + entries: [ + { + provider: "codex", + item: { type: "assistant_message", text: "first page", messageId: "first" }, + timestamp: "2026-09-06T00:00:00Z", + seqStart: 1, + seqEnd: 1, + sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], + collapsed: [], + }, + ], + }; + if (ordering === "page-first") { + reply.resolve(page); + await fetching; + } + if (ordering === "live-first") + client.emit({ type: "agent_update", payload: { kind: "upsert", agent } }); + else { + const release = client.holdAgentFetch(); + const refreshing = directory.refreshAgents(); + await expect.poll(() => client.fetchAgentsCalls).toBe(1); + release({ + requestId: "directory", + entries: [ + { + agent, + project: { + projectKey: "/repo", + projectName: "repo", + checkout: { + cwd: "/repo", + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }, + }, + ], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + }); + await refreshing; + } + if (ordering !== "page-first") reply.resolve(page); + await expect(Promise.all([fetching, concurrent])).resolves.toEqual([page, page]); + expect(applications).toBe(1); + expect(() => client.nextTimelineFetch()).toThrow("Expected a pending timeline fetch"); + expect( + useSessionStore.getState().sessions[serverId]?.agentStreamTail.get("agent")?.[0], + ).toMatchObject({ text: "first page" }); + viewed.dispose(); + directory.dispose(); + }, +); + +it("rejects an old timeline reply after authoritative agent re-entry", async () => { + const serverId = "timeline-lifetime"; + serverIds.add(serverId); + useSessionStore.getState().initializeSession(serverId, null); + const client = new FakeDirectoryClient(); + const lifetimes: string[] = []; + const directory = new DirectorySync(serverId, { + onTimelineRequest: () => () => undefined, + onAgentStoppedRunning: () => undefined, + onAgentRemoved: (agentId) => lifetimes.push(`remove:${agentId}`), + onAgentAccepted: (agentId) => lifetimes.push(`accept:${agentId}`), + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + directory.acceptAgent(createAgent(serverId, "agent")); + const request = { direction: "tail" as const, limit: 40, projection: "projected" as const }; + const oldFetch = directory.fetchTimeline("agent", request); + const oldReply = client.nextTimelineFetch(); + + directory.removeAgent("agent"); + const excludedFetch = directory.fetchTimeline("agent", request); + const excludedReply = client.nextTimelineFetch(); + directory.acceptAgent(createAgent(serverId, "agent")); + const freshFetch = directory.fetchTimeline("agent", request); + const freshReply = client.nextTimelineFetch(); + const page = { agent: null, hasNewer: false } as TimelineFetchResult; + + oldReply.resolve(page); + await expect(oldFetch).rejects.toBeInstanceOf(DirectoryRefreshSupersededError); + excludedReply.resolve(page); + await expect(excludedFetch).rejects.toBeInstanceOf(DirectoryRefreshSupersededError); + freshReply.resolve(page); + await expect(freshFetch).resolves.toBe(page); + expect(lifetimes).toEqual(["accept:agent", "remove:agent", "accept:agent"]); +}); + afterEach(() => { for (const serverId of serverIds) useSessionStore.getState().clearSession(serverId); serverIds.clear(); }); describe("DirectorySync session readiness", () => { + it("restores other cached workspaces when a live removal arrives during startup", async () => { + const serverId = "cache-with-live-removal"; + serverIds.add(serverId); + const client = new FakeDirectoryClient(); + const workspace = normalizeWorkspaceDescriptor({ + id: "cached-workspace", + projectId: "cached-project", + projectDisplayName: "Cached project", + projectRootPath: "/repo/cached", + workspaceDirectory: "/repo/cached", + projectKind: "git", + workspaceKind: "local_checkout", + name: "cached", + status: "done", + statusEnteredAt: null, + activityAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }); + const { cache, releaseLoad } = await savedDirectory(serverId, { + agents: new Map(), + projects: new Map(), + workspaces: new Map([ + [workspace.id, workspace], + ["deleted-live", { ...workspace, id: "deleted-live" }], + ]), + }); + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); + const directory = new DirectorySync(serverId, noopCallbacks, cache); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + client.emit({ type: "workspace_update", payload: { kind: "remove", id: "deleted-live" } }); + releaseLoad(); + await directory.ready(); + + expect([...useSessionStore.getState().sessions[serverId]!.workspaces.keys()]).toEqual([ + workspace.id, + ]); + expect(selectWorkspaceDirectoryServerIds(useSessionStore.getState(), [serverId])).toEqual([ + serverId, + ]); + expect(client.fetchWorkspacesCalls).toBe(0); + directory.dispose(); + }); + it("restores the cached directory before network demand", async () => { const serverId = "offline-cached-directory"; serverIds.add(serverId); @@ -208,32 +649,24 @@ describe("DirectorySync session readiness", () => { projectRootPath: "/repo/cached", projectKind: "git", }); - const directory = new DirectorySync( - serverId, - { - onAgentStoppedRunning: () => undefined, - markAgentLoading: () => undefined, - markAgentReady: () => undefined, - markAgentError: () => undefined, - }, - { - readAgent: async () => undefined, - readWorkspace: async () => undefined, - readDirectory: async () => ({ - agents: new Map(), - workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]), - projects: new Map([[cachedProject.projectId, cachedProject]]), - }), - commitDirectoryMutations: () => undefined, - }, - ); + const cachedAgent = createAgent(serverId, "agent-1"); + cachedAgent.workspaceId = cachedWorkspace.id; + const directory = new DirectorySync(serverId, noopCallbacks, { + readDirectory: async () => ({ + agents: new Map([[cachedAgent.id, cachedAgent]]), + workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]), + projects: new Map([[cachedProject.projectId, cachedProject]]), + }), + commitDirectoryMutations: () => undefined, + }); useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); - await directory.restoreCachedDirectory(); + await directory.ready(); - expect( - useSessionStore.getState().sessions[serverId]?.workspaces.get(cachedWorkspace.id), - ).toEqual(cachedWorkspace); + const session = useSessionStore.getState().sessions[serverId]; + expect(session?.hasHydratedAgents).toBe(false); + expect(session?.agents.get(cachedAgent.id)).toEqual(cachedAgent); + expect(session?.workspaces.get(cachedWorkspace.id)).toEqual(cachedWorkspace); expect(selectWorkspaceDirectoryServerIds(useSessionStore.getState(), [serverId])).toEqual([ serverId, ]); @@ -242,62 +675,7 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); - it("prepares a cached agent workspace route without sidebar demand", async () => { - const serverId = "cached-agent-route"; - serverIds.add(serverId); - const client = new FakeDirectoryClient(); - const cachedAgent = createAgent(serverId, "agent-1"); - const cachedWorkspace = normalizeWorkspaceDescriptor({ - id: "workspace-1", - projectId: "project-1", - projectDisplayName: "Paseo", - projectRootPath: "/repo", - workspaceDirectory: "/repo", - projectKind: "git", - workspaceKind: "local_checkout", - name: "main", - status: "done", - statusEnteredAt: null, - activityAt: null, - archivingAt: null, - diffStat: null, - scripts: [], - }); - cachedAgent.workspaceId = cachedWorkspace.id; - const directory = new DirectorySync( - serverId, - { - onAgentStoppedRunning: () => undefined, - markAgentLoading: () => undefined, - markAgentReady: () => undefined, - markAgentError: () => undefined, - }, - { - readAgent: async () => cachedAgent, - readWorkspace: async () => ({ workspace: cachedWorkspace }), - readDirectory: async () => ({ - agents: new Map(), - workspaces: new Map(), - projects: new Map(), - }), - commitDirectoryMutations: () => undefined, - }, - ); - useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); - - await directory.prepareAgentRoute(cachedAgent.id); - - const session = useSessionStore.getState().sessions[serverId]; - expect(session?.hasHydratedAgents).toBe(false); - expect(session?.hasWorkspaceDirectorySnapshot).toBe(false); - expect(session?.agents.get(cachedAgent.id)).toEqual(cachedAgent); - expect(session?.workspaces.get(cachedWorkspace.id)).toEqual(cachedWorkspace); - expect(client.fetchAgentsCalls).toBe(0); - expect(client.fetchWorkspacesCalls).toBe(0); - directory.dispose(); - }); - - it("persists accepted script status updates through the directory owner", () => { + it("persists accepted script status updates through the directory owner", async () => { const serverId = "script-status-owner"; serverIds.add(serverId); const client = new FakeDirectoryClient(); @@ -305,14 +683,14 @@ describe("DirectorySync session readiness", () => { const directory = new DirectorySync( serverId, { + onTimelineRequest: () => () => undefined, onAgentStoppedRunning: () => undefined, + onAgentRemoved: () => undefined, markAgentLoading: () => undefined, markAgentReady: () => undefined, markAgentError: () => undefined, }, { - readAgent: async () => undefined, - readWorkspace: async () => undefined, readDirectory: async () => ({ agents: new Map(), workspaces: new Map(), @@ -344,6 +722,7 @@ describe("DirectorySync session readiness", () => { }); const store = useSessionStore.getState(); store.initializeSession(serverId, client as unknown as DaemonClient, 1); + await directory.ready(); directory.acceptWorkspaces([workspace]); commits.length = 0; @@ -471,14 +850,14 @@ describe("DirectorySync session readiness", () => { const directory = new DirectorySync( serverId, { + onTimelineRequest: () => () => undefined, onAgentStoppedRunning: () => undefined, + onAgentRemoved: () => undefined, markAgentLoading: () => undefined, markAgentReady: () => undefined, markAgentError: () => undefined, }, { - readAgent: async () => undefined, - readWorkspace: async () => undefined, readDirectory: async () => { cacheReads += 1; return { @@ -553,14 +932,14 @@ describe("DirectorySync session readiness", () => { const directory = new DirectorySync( serverId, { + onTimelineRequest: () => () => undefined, onAgentStoppedRunning: () => undefined, + onAgentRemoved: () => undefined, markAgentLoading: () => undefined, markAgentReady: () => undefined, markAgentError: () => undefined, }, { - readAgent: async () => undefined, - readWorkspace: async () => undefined, readDirectory: async () => ({ agents: new Map(), workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]), @@ -604,7 +983,7 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); - it("does not use a cached checkpoint when the corresponding cache read loses its race", async () => { + it("advances the saved cursor only through completed catch-up", async () => { const serverId = "late-directory-cache"; serverIds.add(serverId); const client = new FakeDirectoryClient(); @@ -619,14 +998,14 @@ describe("DirectorySync session readiness", () => { const directory = new DirectorySync( serverId, { + onTimelineRequest: () => () => undefined, onAgentStoppedRunning: () => undefined, + onAgentRemoved: () => undefined, markAgentLoading: () => undefined, markAgentReady: () => undefined, markAgentError: () => undefined, }, { - readAgent: async () => undefined, - readWorkspace: async () => undefined, readDirectory: () => cacheRead, commitDirectoryMutations: () => undefined, }, @@ -645,133 +1024,95 @@ describe("DirectorySync session readiness", () => { features: { workspaceMultiplicity: true, directorySync: true }, }); + const releaseNetwork = client.holdWorkspaceFetch(); const refresh = directory.refreshWorkspaces(); await Promise.resolve(); client.emit({ type: "workspace_update", - payload: { kind: "remove", id: "deleted-live" }, + payload: { kind: "remove", id: "deleted-live", generation: "g", seq: 20 }, }); releaseCache({ agents: new Map(), workspaces: new Map(), projects: new Map(), - checkpoint: { workspaces: { generation: "stale", afterSeq: 99 } }, + checkpoint: { workspaces: { generation: "g", afterSeq: 7 } }, + }); + releaseNetwork({ + requestId: "catch-up", + entries: [], + emptyProjects: [], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + sync: { generation: "g", headSeq: 20, mode: "changes", removals: [] }, }); await refresh; - expect(client.lastWorkspaceOptions).not.toHaveProperty("sync.generation"); + expect(client.lastWorkspaceOptions).toMatchObject({ + sync: { generation: "g", afterSeq: 7 }, + }); + client.emit({ + type: "workspace_update", + payload: { kind: "remove", id: "another-removal", generation: "g", seq: 21 }, + }); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "offline", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 2 }, + }); + client.emit({ + type: "workspace_update", + payload: { kind: "remove", id: "after-offline-gap", generation: "g", seq: 40 }, + }); + await directory.refreshWorkspaces(); + expect(client.lastWorkspaceOptions).toMatchObject({ + sync: { generation: "g", afterSeq: 20 }, + }); directory.dispose(); }); - it("does not resurrect an agent deleted while its targeted cache row is loading", async () => { + it("does not resurrect an agent deleted while the baseline is loading", async () => { const serverId = "late-agent-cache"; serverIds.add(serverId); const client = new FakeDirectoryClient(); - const cachedAgent = { - ...normalizeAgentSnapshot( - { - id: "agent-1", - provider: "codex", - cwd: "/repo", - model: null, - createdAt: "2026-08-26T00:00:00.000Z", - updatedAt: "2026-08-26T00:00:00.000Z", - lastUserMessageAt: null, - status: "idle", - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - currentModeId: null, - availableModes: [], - pendingPermissions: [], - persistence: null, - title: "Cached", - labels: {}, - }, - serverId, - ), - projectPlacement: null, - }; - let releaseAgent!: (agent: typeof cachedAgent) => void; - const agentRead = new Promise((resolve) => { - releaseAgent = resolve; + const cachedAgent = createAgent(serverId, "agent-1"); + const { cache, releaseLoad } = await savedDirectory(serverId, { + agents: new Map([[cachedAgent.id, cachedAgent]]), + workspaces: new Map(), + projects: new Map(), }); - const directory = new DirectorySync( - serverId, - { - onAgentStoppedRunning: () => undefined, - markAgentLoading: () => undefined, - markAgentReady: () => undefined, - markAgentError: () => undefined, - }, - { - readAgent: () => agentRead, - readWorkspace: async () => undefined, - readDirectory: async () => ({ - agents: new Map(), - workspaces: new Map(), - projects: new Map(), - }), - commitDirectoryMutations: () => undefined, - }, - ); + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); + const directory = new DirectorySync(serverId, noopCallbacks, cache); directory.connectionChanged({ client: client as unknown as DaemonClient, status: "online", source: { clientGeneration: 1, connectionEpoch: 1 }, }); - useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); - const load = directory.loadCachedAgent(cachedAgent.id); client.emit({ type: "agent_deleted", payload: { agentId: cachedAgent.id, requestId: "delete-live" }, }); - releaseAgent(cachedAgent); - await load; + releaseLoad(); + await directory.ready(); expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(false); directory.dispose(); }); - it("does not resurrect an agent removed by an authoritative snapshot while cache loads", async () => { + it("requests the authoritative snapshot only after the baseline is restored", async () => { const serverId = "late-agent-authoritative-snapshot"; serverIds.add(serverId); const client = new FakeDirectoryClient(); const cachedAgent = createAgent(serverId, "agent-1"); - const releaseNetwork = client.holdAgentFetch(); - let releaseAgent!: (agent: typeof cachedAgent) => void; - const agentRead = new Promise((resolve) => { - releaseAgent = resolve; - }); - const directory = new DirectorySync( - serverId, - { - onAgentStoppedRunning: () => undefined, - markAgentLoading: () => undefined, - markAgentReady: () => undefined, - markAgentError: () => undefined, - }, - { - readAgent: () => agentRead, - readWorkspace: async () => undefined, - readDirectory: async () => ({ - agents: new Map(), - workspaces: new Map(), - projects: new Map(), - }), - commitDirectoryMutations: () => undefined, - }, - ); - directory.connectionChanged({ - client: client as unknown as DaemonClient, - status: "online", - source: { clientGeneration: 1, connectionEpoch: 1 }, + const { cache, releaseLoad } = await savedDirectory(serverId, { + agents: new Map([[cachedAgent.id, cachedAgent]]), + workspaces: new Map(), + projects: new Map(), + checkpoint: { agents: { generation: "g", afterSeq: 5 } }, }); const store = useSessionStore.getState(); store.initializeSession(serverId, client as unknown as DaemonClient, 1); @@ -781,18 +1122,22 @@ describe("DirectorySync session readiness", () => { version: "test", features: { directorySync: true, workspaceMultiplicity: true }, }); + const directory = new DirectorySync(serverId, noopCallbacks, cache); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); const refresh = directory.refreshAgents(); - const load = directory.loadCachedAgent(cachedAgent.id); - releaseNetwork({ - requestId: "authoritative-snapshot", - entries: [], - pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, - }); + await Promise.resolve(); + expect(client.fetchAgentsCalls).toBe(0); + releaseLoad(); await refresh; - releaseAgent(cachedAgent); - await load; + expect(client.lastAgentOptions).toMatchObject({ + sync: { generation: "g", afterSeq: 5 }, + }); expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(false); directory.dispose(); }); @@ -804,14 +1149,14 @@ describe("DirectorySync session readiness", () => { const directory = new DirectorySync( serverId, { + onTimelineRequest: () => () => undefined, onAgentStoppedRunning: () => undefined, + onAgentRemoved: () => undefined, markAgentLoading: () => undefined, markAgentReady: () => undefined, markAgentError: () => undefined, }, { - readAgent: async () => undefined, - readWorkspace: async () => undefined, readDirectory: async () => ({ agents: new Map(), workspaces: new Map(), @@ -929,14 +1274,14 @@ describe("DirectorySync session readiness", () => { const directory = new DirectorySync( serverId, { + onTimelineRequest: () => () => undefined, onAgentStoppedRunning: () => undefined, + onAgentRemoved: () => undefined, markAgentLoading: () => undefined, markAgentReady: () => undefined, markAgentError: () => undefined, }, { - readAgent: async () => undefined, - readWorkspace: async () => undefined, readDirectory: async () => ({ agents: new Map(), workspaces: new Map(), diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index abcc98d5a7..0894de257a 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -3,7 +3,6 @@ import type { FetchAgentsEntry, FetchAgentsOptions, } from "@getpaseo/client/internal/daemon-client"; -import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once"; import { normalizeProjectDescriptor, normalizeWorkspaceDescriptor, @@ -18,7 +17,7 @@ import { shouldUseLegacyDaemonWorkspaceDirectory, stampLegacyWorkspaceIds, } from "@/workspace/legacy-daemon-workspaces"; -import type { AgentDirectoryDelta } from "@/utils/agent-directory-sync"; +import type { AgentDirectoryDelta, AgentRemovalReason } from "@/utils/agent-directory-sync"; import { AgentDirectoryReplica } from "./agent-replica"; import { WorkspaceDirectoryReplica, @@ -33,13 +32,14 @@ import { import { workspaceLabels } from "@/workspace-labels"; import type { CachedDirectory, - CachedWorkspace, DirectoryCheckpoint, DirectoryCursor, DirectoryReplicaMutation, } from "@/runtime/replica-cache"; import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; +type TimelinePage = Awaited>; + const PAGE_LIMIT = 200; const AGENT_SORT: NonNullable = [ { key: "updated_at", direction: "desc" }, @@ -108,8 +108,6 @@ export interface DirectoryConnection { } export interface DirectoryCheckpointStorage { - readAgent(serverId: string, agentId: string): Promise; - readWorkspace(serverId: string, workspaceId: string): Promise; readDirectory(serverId: string): Promise; commitDirectoryMutations( serverId: string, @@ -139,6 +137,7 @@ export class DirectorySync { WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta >(); + private readonly timelineRequests = new Map>(); private readonly agents: AgentDirectoryReplica; private readonly workspaces: WorkspaceDirectoryReplica; private connection: DirectoryConnection = { @@ -148,10 +147,10 @@ export class DirectorySync { }; private unsubscribe: (() => void) | null = null; private readonly abortSessionWaits = new Set<() => void>(); - private cacheLoad: Promise | null = null; - private cacheAccepted = false; - private revision = 0; - private workspaceRevision = 0; + // The saved baseline is restored once, at construction. Live mutations apply as they arrive and + // the baseline merges under them; network refreshes wait for it so their transactions and + // cursors start from the saved state. + private readonly baseline: Promise; private readonly routeDemandIds = new Set(); private readonly fullDemandSources = new Set(); private demandRefresh: Promise | null = null; @@ -161,7 +160,10 @@ export class DirectorySync { constructor( private readonly serverId: string, private readonly callbacks: { + onTimelineRequest: (agentId: string, replaceTail: boolean) => (page: TimelinePage) => void; onAgentStoppedRunning: (agentId: string) => void; + onAgentRemoved: (agentId: string, reason: AgentRemovalReason) => void; + onAgentAccepted?: (agentId: string) => void; markAgentLoading: () => void; markAgentReady: () => void; markAgentError: (error: string) => void; @@ -170,8 +172,19 @@ export class DirectorySync { ) { const persist = (mutations: readonly DirectoryReplicaMutation[]) => this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); - this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning, persist); + this.agents = new AgentDirectoryReplica( + serverId, + callbacks.onAgentStoppedRunning, + persist, + callbacks.onAgentRemoved, + callbacks.onAgentAccepted, + ); this.workspaces = new WorkspaceDirectoryReplica(serverId); + this.baseline = this.restoreBaseline().catch(() => undefined); + } + + ready(): Promise { + return this.baseline; } connectionChanged(connection: DirectoryConnection): boolean { @@ -185,6 +198,7 @@ export class DirectorySync { return false; } this.flushAbortedTransactions(); + this.timelineRequests.clear(); this.unsubscribe?.(); this.unsubscribe = null; workspaceLabels.disconnect(this.serverId); @@ -196,63 +210,39 @@ export class DirectorySync { const subscriptions = [ client.on("agent_update", (message) => { if (message.type !== "agent_update" || !this.isCurrent(client, source)) return; - this.revision += 1; - const recorded = this.agentTransactions.record(source, message.payload); - if (!recorded) { + if (!this.agentTransactions.record(source, message.payload)) { this.agents.applyDelta(message.payload); - this.noteLiveCursor("agents", message.payload); - this.persistCheckpoint(); } }), client.on("workspace_update", (message) => { if (message.type !== "workspace_update" || !this.isCurrent(client, source)) return; - this.revision += 1; - this.workspaceRevision += 1; - const recorded = this.workspaceTransactions.record(source, message.payload); - if (!recorded) { + if (!this.workspaceTransactions.record(source, message.payload)) { this.applyWorkspaceDelta(message.payload); - this.noteLiveCursor("workspaces", message.payload); - this.persistCheckpoint(); } }), client.on("project.update", (message) => { if (message.type !== "project.update" || !this.isCurrent(client, source)) return; - this.revision += 1; - this.workspaceRevision += 1; - const recorded = this.workspaceTransactions.record(source, message.payload); - if (!recorded) { + if (!this.workspaceTransactions.record(source, message.payload)) { this.applyWorkspaceDelta(message.payload); - this.noteLiveCursor("projects", message.payload); - this.persistCheckpoint(); } }), client.on("script_status_update", (message) => { if (message.type !== "script_status_update" || !this.isCurrent(client, source)) return; - this.revision += 1; - this.workspaceRevision += 1; const delta: WorkspaceDirectoryDelta = { kind: "script_status", update: message.payload, }; - const recorded = this.workspaceTransactions.record(source, delta); - if (!recorded) { + if (!this.workspaceTransactions.record(source, delta)) { this.applyWorkspaceDelta(delta); - this.persistCheckpoint(); } }), client.on("agent_deleted", (message) => { - if (message.type === "agent_deleted" && this.isCurrent(client, source)) { - this.revision += 1; - this.agents.remove(message.payload.agentId); - this.persistCheckpoint(); - } + if (message.type !== "agent_deleted" || !this.isCurrent(client, source)) return; + this.agents.remove(message.payload.agentId); }), client.on("agent_archived", (message) => { - if (message.type === "agent_archived" && this.isCurrent(client, source)) { - this.revision += 1; - this.agents.archive(message.payload.agentId, message.payload.archivedAt); - this.persistCheckpoint(); - } + if (message.type !== "agent_archived" || !this.isCurrent(client, source)) return; + this.agents.archive(message.payload.agentId, message.payload.archivedAt); }), ]; this.unsubscribe = () => { @@ -266,9 +256,8 @@ export class DirectorySync { const wasDemanded = this.fullDemandSources.size > 0; if (demanded) this.fullDemandSources.add(source); else this.fullDemandSources.delete(source); - if (!wasDemanded && this.fullDemandSources.size > 0) { - void this.loadCachedDirectory().catch(() => undefined); - if (this.getOnlineConnection()) void this.requestDemandRefresh().catch(() => undefined); + if (!wasDemanded && this.fullDemandSources.size > 0 && this.getOnlineConnection()) { + void this.requestDemandRefresh().catch(() => undefined); } } @@ -288,6 +277,8 @@ export class DirectorySync { } dispose(): void { + this.connection = { ...this.connection, status: "offline" }; + this.timelineRequests.clear(); this.flushAbortedTransactions(); this.abortPendingSessionWaits(); this.unsubscribe?.(); @@ -304,9 +295,7 @@ export class DirectorySync { private requestDemandRefresh(force = false): Promise { if (this.demandRefresh) return this.demandRefresh; if (!this.hasDemand()) return Promise.resolve(); - if (!this.getOnlineConnection()) { - return this.fullDemandSources.size > 0 ? this.loadCachedDirectory() : Promise.resolve(); - } + if (!this.getOnlineConnection()) return this.baseline; const source = this.connection.source; if ( !force && @@ -319,8 +308,8 @@ export class DirectorySync { this.fullDemandSources.size > 0 ? this.refreshAll() : Promise.all([ - this.refreshAgentsInternal({ subscribe: {} }, false), - this.refreshWorkspacesInternal({ subscribe: true }, false), + this.refreshAgents({ subscribe: {} }), + this.refreshWorkspaces({ subscribe: true }), ]).then(() => undefined); this.demandRefresh = refresh .then(() => { @@ -345,57 +334,13 @@ export class DirectorySync { return this.requestDemandRefresh(true); } - async loadCachedAgent(agentId: string): Promise { + private async restoreBaseline(): Promise { if (!this.checkpoints) return; - if (useSessionStore.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]; - if (!session || session.agents.has(agentId)) return; - this.agents.commitCachedAgent(token, agent); - } - - async prepareAgentRoute(agentId: string): Promise { - await this.loadCachedAgent(agentId); - const agent = useSessionStore.getState().sessions[this.serverId]?.agents.get(agentId); - if (agent?.workspaceId) await this.loadCachedWorkspace(agent.workspaceId); - } - - async prepareWorkspaceRoute(workspaceId: string): Promise { - await this.loadCachedWorkspace(workspaceId); - } - - private async loadCachedWorkspace(workspaceId: string): Promise { - if (!this.checkpoints) return; - if (useSessionStore.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]; - if (!session) return; - this.workspaces.commitCachedWorkspace(cached.workspace, cached.project); - } - - private loadCachedDirectory(): Promise { - const checkpoints = this.checkpoints; - if (!checkpoints) return Promise.resolve(); - this.cacheLoad ??= (async () => { - const revision = this.revision; - const cached = await checkpoints.readDirectory(this.serverId); - if (this.cacheAccepted || this.revision !== revision) return; - if (!useSessionStore.getState().sessions[this.serverId]) return; - this.agents.commitCached(cached.agents); - this.workspaces.commitCached(cached); - this.cursors = cached.checkpoint ?? {}; - this.cacheAccepted = true; - })(); - return this.cacheLoad; - } - - restoreCachedDirectory(): Promise { - return this.loadCachedDirectory(); + const cached = await this.checkpoints.readDirectory(this.serverId); + if (!useSessionStore.getState().sessions[this.serverId]) return; + this.agents.commitCached(cached.agents); + this.workspaces.commitCached(cached); + this.cursors = cached.checkpoint ?? {}; } applyAgentTurnLiveness( @@ -412,33 +357,36 @@ export class DirectorySync { async fetchTimeline( agentId: string, request: Parameters[1], - ): Promise>> { - const { client } = this.requireOnline(); + replaceTail = false, + ): Promise { + const { client, source } = this.requireOnline(); const token = this.agents.captureTimeline(agentId); - const page = await fetchAgentTimelineOnce(client, agentId, request); - if (page.agent && this.agents.submitTimelineAgent(token, page.agent)) { - this.revision += 1; - this.persistCheckpoint(); - } - return page; + const key = JSON.stringify({ agentId, lifetime: token.version, request, replaceTail }); + const existing = this.timelineRequests.get(key); + if (existing) return existing; + const apply = this.callbacks.onTimelineRequest(agentId, replaceTail); + const fetching = client.fetchAgentTimeline(agentId, request).then((page) => { + if (!this.isCurrent(client, source) || !this.agents.isTimelineCurrent(token)) { + throw new DirectoryRefreshSupersededError("timeline page no longer current"); + } + if (page.agent) this.agents.submitTimelineAgent(token, page.agent); + apply(page); + return page; + }); + this.timelineRequests.set(key, fetching); + const clear = () => { + if (this.timelineRequests.get(key) === fetching) this.timelineRequests.delete(key); + }; + void fetching.then(clear, clear); + return fetching; } async refreshAgents( input: RefreshAgentDirectoryInput = {}, ): Promise { - return this.refreshAgentsInternal(input, true); - } - - private async refreshAgentsInternal( - input: RefreshAgentDirectoryInput, - loadDirectoryCache: boolean, - ): Promise { - if (loadDirectoryCache) { - this.primeAgentTransactionForCacheLoad(); - await this.loadCachedDirectory(); - } const onlineConnection = this.getOnlineConnection(); if (!onlineConnection) { + await this.baseline; this.callbacks.markAgentReady(); return { agents: new Map(useSessionStore.getState().sessions[this.serverId]?.agents), @@ -446,6 +394,8 @@ export class DirectorySync { }; } const { client, source } = onlineConnection; + // The transaction spans the whole refresh, so a live delta that arrives while the saved + // baseline or the session is still pending is replayed on top of whatever snapshot lands. const transaction = this.agentTransactions.begin(source, () => ({ entries: [], subscriptionId: null, @@ -454,6 +404,7 @@ export class DirectorySync { })); this.callbacks.markAgentLoading(); try { + await this.baseline; await this.waitForSession(client, source); const session = useSessionStore.getState().sessions[this.serverId]; if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) { @@ -510,9 +461,8 @@ export class DirectorySync { : delta, ) : completion.deltas; - this.revision += 1; const agents = this.commitAgentSnapshot(completion.snapshot, deltas); - this.persistAgentCursors(completion.snapshot, completion.deltas); + this.persistAgentCursors(completion.snapshot); this.persistCheckpoint(); this.callbacks.markAgentReady(); return { agents, subscriptionId: completion.snapshot.subscriptionId }; @@ -527,26 +477,27 @@ export class DirectorySync { } async refreshWorkspaces(input?: { subscribe?: boolean }): Promise { - return this.refreshWorkspacesInternal(input, true); - } - - private async refreshWorkspacesInternal( - input: { subscribe?: boolean } | undefined, - loadDirectoryCache: boolean, - ): Promise { - if (loadDirectoryCache) await this.loadCachedDirectory(); const onlineConnection = this.getOnlineConnection(); - if (!onlineConnection) return; + if (!onlineConnection) { + await this.baseline; + return; + } const { client, source } = onlineConnection; + // The transaction spans the whole refresh. Its base for a changes-mode page is the store once + // the saved baseline is in it, so it is captured after that wait rather than at begin. const transaction = this.workspaceTransactions.begin(source, () => ({ - workspaces: new Map(useSessionStore.getState().sessions[this.serverId]?.workspaces), - projects: new Map(useSessionStore.getState().sessions[this.serverId]?.projects), + workspaces: new Map(), + projects: new Map(), syncCursors: {}, syncModes: {}, touchedWorkspaceIds: new Set(), touchedProjectIds: new Set(), })); try { + await this.baseline; + const session = useSessionStore.getState().sessions[this.serverId]; + transaction.snapshot.workspaces = new Map(session?.workspaces); + transaction.snapshot.projects = new Map(session?.projects); await this.waitForSessionMetadata(client, source); const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo; if (serverInfo?.features?.workspaceMultiplicity !== true) { @@ -725,17 +676,6 @@ export class DirectorySync { return { client: this.connection.client, source: this.connection.source }; } - private primeAgentTransactionForCacheLoad(): void { - const connection = this.getOnlineConnection(); - if (!connection) return; - this.agentTransactions.begin(connection.source, () => ({ - entries: [], - subscriptionId: null, - legacy: false, - syncRemovals: [], - })); - } - private supportsDirectorySync(): boolean { return ( useSessionStore.getState().sessions[this.serverId]?.serverInfo?.features?.directorySync === @@ -752,12 +692,8 @@ export class DirectorySync { : this.agents.commitSnapshot(snapshot.entries, deltas); } - private persistAgentCursors( - snapshot: AgentSnapshot, - deltas: readonly AgentDirectoryDelta[], - ): void { + private persistAgentCursors(snapshot: AgentSnapshot): void { if (snapshot.syncCursor) this.writeCursor("agents", snapshot.syncCursor); - for (const delta of deltas) this.noteLiveCursor("agents", delta); } private async fetchProjectSnapshot( @@ -801,8 +737,6 @@ export class DirectorySync { if (completion.kind === "stale") { throw new DirectoryRefreshSupersededError("workspace completion was superseded"); } - this.revision += 1; - this.workspaceRevision += 1; const previous = this.readWorkspaceState(); const deltaMutations = this.workspaces.commitSnapshot(completion.snapshot, completion.deltas); const next = this.workspaces.snapshot(); @@ -822,14 +756,11 @@ export class DirectorySync { for (const [entity, cursor] of Object.entries(completion.snapshot.syncCursors ?? {})) { if (cursor) this.writeCursor(entity as "projects" | "workspaces", cursor); } - for (const delta of completion.deltas) { - if (delta.kind === "script_status") continue; - const entity = "projectId" in delta || "project" in delta ? "projects" : "workspaces"; - this.noteLiveCursor(entity, delta); - } this.persistCheckpoint(); } + // Only a completed catch-up may advance the saved cursor. A live sequence can follow an offline + // gap, so saving it would claim intermediate changes the client never received. private persistCheckpoint(): void { this.checkpoints?.commitDirectoryMutations(this.serverId, [], this.cursors); } @@ -874,17 +805,6 @@ export class DirectorySync { snapshot.syncCursor = { generation: sync.generation, afterSeq: sync.headSeq }; } - private noteLiveCursor( - entity: keyof DirectoryCheckpoint, - payload: { generation?: string; seq?: number }, - ): void { - if (!payload.generation || payload.seq === undefined) return; - this.writeCursor(entity, { - generation: payload.generation, - afterSeq: payload.seq, - }); - } - private readCursors(): DirectoryCheckpoint { return this.cursors; } diff --git a/packages/app/src/runtime/directory-sync/internal/agent-store.ts b/packages/app/src/runtime/directory-sync/internal/agent-store.ts index 0566da5aaf..b4504d9c8b 100644 --- a/packages/app/src/runtime/directory-sync/internal/agent-store.ts +++ b/packages/app/src/runtime/directory-sync/internal/agent-store.ts @@ -2,7 +2,11 @@ import equal from "fast-deep-equal"; import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import { useSessionStore, type Agent } from "@/stores/session-store"; import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy"; -import { buildAgentDirectoryState, type AgentDirectoryDelta } from "@/utils/agent-directory-sync"; +import { + buildAgentDirectoryState, + type AgentDirectoryDelta, + type AgentRemovalReason, +} from "@/utils/agent-directory-sync"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { resolveProjectPlacement } from "@/utils/project-placement"; import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; @@ -23,7 +27,10 @@ function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent } export class AgentStoreProjection { - constructor(private readonly serverId: string) {} + constructor( + private readonly serverId: string, + private readonly removeTimeline: (agentId: string, reason: AgentRemovalReason) => void, + ) {} snapshot(): Map { return useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map(); @@ -65,7 +72,7 @@ export class AgentStoreProjection { agent?: Agent; } { if (delta.kind === "remove") { - this.remove(delta.agentId); + this.remove(delta.agentId, "scope"); return { agentId: delta.agentId, stoppedRunning: false }; } const normalized = normalizeAgentSnapshot(delta.agent, this.serverId); @@ -141,7 +148,7 @@ export class AgentStoreProjection { useSessionStore.getState().setPendingPermissions(this.serverId, pending); } - remove(agentId: string): void { + remove(agentId: string, reason: AgentRemovalReason): void { const store = useSessionStore.getState(); const removeKey = (current: Map): Map => { if (!current.has(agentId)) return current; @@ -153,16 +160,13 @@ export class AgentStoreProjection { store.setAgents(this.serverId, removeKey); store.setAgentDetails(this.serverId, removeKey); store.setQueuedMessages(this.serverId, removeKey); - store.setAgentTimelineCursor(this.serverId, removeKey); + this.removeTimeline(agentId, reason); store.setInitializingAgents(this.serverId, removeKey); store.setPendingPermissions(this.serverId, (current) => { const next = new Map(current); for (const [key, pending] of next) if (pending.agentId === agentId) next.delete(key); return next.size === current.size ? current : next; }); - store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false); - store.setAgentStreamTail(this.serverId, removeKey); - store.clearAgentStreamHead(this.serverId, agentId); useSessionStore.setState((state) => { if (!state.agentLastActivity.has(agentId)) return state; const agentLastActivity = new Map(state.agentLastActivity); diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts index 84f08b22fc..ffb2023736 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts @@ -213,7 +213,7 @@ it("does not invent a null-key project from a workspace update", () => { store.clearSession(serverId); }); -it("does not restore a targeted cached workspace while its archive is pending", () => { +it("does not restore a cached workspace while its archive is pending", () => { const serverId = "cached-workspace-during-archive"; const workspaceId = "archived-workspace"; const store = useSessionStore.getState(); @@ -222,7 +222,10 @@ it("does not restore a targeted cached workspace while its archive is pending", markWorkspaceArchivePending({ serverId, workspaceId }); try { - replica.commitCachedWorkspace(normalizeWorkspaceDescriptor(workspace(workspaceId)), undefined); + replica.commitCached({ + workspaces: new Map([[workspaceId, normalizeWorkspaceDescriptor(workspace(workspaceId))]]), + projects: new Map(), + }); expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false); } finally { diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.ts b/packages/app/src/runtime/directory-sync/workspace-replica.ts index d229c196a2..5e8951edf3 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -58,15 +58,6 @@ export class WorkspaceDirectoryReplica { useSessionStore.getState().setHasWorkspaceDirectorySnapshot(this.serverId, true); } - commitCachedWorkspace( - workspace: WorkspaceDescriptor, - project: ProjectDescriptor | undefined, - ): void { - if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) return; - if (project) this.setProject(project); - this.setWorkspace(workspace); - } - commitSnapshot( snapshot: WorkspaceDirectorySnapshot, deltas: readonly WorkspaceDirectoryDelta[], diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index b77d53f646..a7ba9eb784 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -22,6 +22,8 @@ import { type HostRuntimeStorage, } from "./host-runtime"; import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store"; +import { ReplicaCache } from "./replica-cache"; +import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; class FakeDaemonClient { private state: ConnectionState = { status: "idle" }; @@ -37,6 +39,13 @@ class FakeDaemonClient { public fetchAgentsResponses: Array< Awaited> | ReturnType > = []; + public timelineCalls = 0; + public timelineResponse: ReturnType | null = null; + fetchAgentTimeline(): ReturnType { + this.timelineCalls++; + if (!this.timelineResponse) throw new Error("Missing timeline response"); + return this.timelineResponse; + } public sentAgentMessages: Array> = []; public sendAgentMessageFailures: Error[] = []; public sendAgentMessageResponses: Promise[] = []; @@ -466,16 +475,13 @@ function createMemoryReplicaRowStore(): ReplicaRowStore { `${row.serverId}:${row.kind}:${row.id}`; return { open: async () => undefined, - read: async (serverId, kinds, ids) => { - const acceptedKinds = new Set(kinds); - const acceptedIds = ids ? new Set(ids) : null; - return [...rows.values()].filter( + read: async (serverIds, kinds, ids) => + [...rows.values()].filter( (row) => - row.serverId === serverId && - acceptedKinds.has(row.kind) && - (!acceptedIds || acceptedIds.has(row.id)), - ); - }, + serverIds.includes(row.serverId) && + kinds.includes(row.kind) && + (!ids || ids.includes(row.id)), + ), readAll: async () => { const hosts = new Map(); for (const row of rows.values()) { @@ -1599,15 +1605,74 @@ describe("HostRuntimeStore", () => { expect(store.getHosts()).toEqual([]); }); + it("exposes timeline demand and paints saved history before a network client exists", async () => { + const host = makeHost(); + const storage = createMemoryHostRuntimeStorage({ + "@paseo:daemon-registry": JSON.stringify([host]), + "@paseo:e2e": "1", + }); + const replicaRowStore = createMemoryReplicaRowStore(); + const disk = new ReplicaCache(replicaRowStore, { clearLegacyCache: async () => undefined }); + disk.setHosts([host.serverId]); + disk.commitTimeline(host.serverId, "offline-agent", { + agentId: "offline-agent", + hasOlder: false, + range: { epoch: "offline-epoch", startSeq: 1, endSeq: 1 }, + items: [ + { + kind: "assistant_message", + id: "saved-message", + messageId: "saved-message", + text: "Saved history is available offline", + timestamp: new Date("2026-09-06T10:00:00.000Z"), + timelineCursor: { epoch: "offline-epoch", seq: 1 }, + source: { startSeq: 1 }, + }, + ], + }); + await disk.flush(); + const store = new HostRuntimeStore({ + storage, + replicaRowStore, + deps: { + createClient: () => { + throw new Error("Offline demand must not need a client"); + }, + connectToDaemon: async () => { + throw new Error("Offline demand must not need a connection"); + }, + getClientId: async () => "cid_test_runtime", + }, + }); + try { + const registryLoaded = onceHostListMatches(store, () => store.isHostRegistryLoaded()); + store.boot(); + await registryLoaded; + expect(useSessionStore.getState().sessions[host.serverId]?.client).toBeNull(); + const demand = useSessionStore.getState().sessions[host.serverId]?.viewedTimelineSync; + expect(demand).not.toBeNull(); + if (!demand) throw new Error("Registered hosts must expose offline timeline demand"); + demand.registerVisibleAgentIds("workspace", ["offline-agent"]); + await vi.waitFor(() => { + expect( + useSessionStore.getState().sessions[host.serverId]?.agentStreamTail.get("offline-agent"), + ).toMatchObject([{ text: "Saved history is available offline" }]); + }); + } finally { + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + } + }); + it("loads the host registry and restores its cached directory", async () => { const host = makeHost(); const storage = createMemoryHostRuntimeStorage(); - let fullScans = 0; + let directoryReads = 0; const backingStore = createMemoryReplicaRowStore(); const replicaRowStore: ReplicaRowStore = { ...backingStore, read: async (...args) => { - fullScans += 1; + directoryReads += 1; return backingStore.read(...args); }, }; @@ -1632,7 +1697,7 @@ describe("HostRuntimeStore", () => { store.boot(); await registryLoaded; - await vi.waitFor(() => expect(fullScans).toBe(1)); + await vi.waitFor(() => expect(directoryReads).toBe(1)); expect(useSessionStore.getState().sessions[host.serverId]).toMatchObject({ client: null, hasHydratedAgents: false, @@ -2329,10 +2394,15 @@ describe("HostRuntimeStore", () => { host.serverId, new Map([[recoveredAgent.agent.id, replicaAgent(recoveredAgent.agent, host.serverId)]]), ); - sessionStore.setAgentTimelineCursor( - host.serverId, - new Map([[recoveredAgent.agent.id, { epoch: "epoch", startSeq: 10, endSeq: 20 }]]), - ); + sessionStore.applyAgentTimelineResponseState(host.serverId, recoveredAgent.agent.id, { + items: [], + head: [], + range: { epoch: "epoch", startSeq: 10, endSeq: 20 }, + older: "none", + newer: false, + synchronized: false, + submissions: [], + }); store.syncHosts([host]); await waitForHostOnline(store, host.serverId); const load = store.refreshAgentDirectory({ @@ -2755,8 +2825,9 @@ describe("HostRuntimeStore", () => { getClientId: async () => "cid_drain_submission", }, }); + store.syncHosts([{ ...host, connections: [] }]); const sessionStore = useSessionStore.getState(); - sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 1); sessionStore.updateSessionServerInfo(host.serverId, { serverId: host.serverId, hostname: null, @@ -2802,7 +2873,7 @@ describe("HostRuntimeStore", () => { expect(session?.messageSubmissions.get("agent")).toBeDefined(); send.resolve(); - useSessionStore.getState().clearSession(host.serverId); + store.syncHosts([]); }); it("restores an automatically drained message when sending fails", async () => { @@ -2820,8 +2891,9 @@ describe("HostRuntimeStore", () => { getClientId: async () => "cid_failed_queue_drain", }, }); + store.syncHosts([{ ...host, connections: [] }]); const sessionStore = useSessionStore.getState(); - sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 1); sessionStore.setQueuedMessages( host.serverId, new Map([ @@ -2847,7 +2919,7 @@ describe("HostRuntimeStore", () => { ]); }); - useSessionStore.getState().clearSession(host.serverId); + store.syncHosts([]); }); it("serializes queued-message drains for the same agent", async () => { @@ -2866,8 +2938,9 @@ describe("HostRuntimeStore", () => { getClientId: async () => "cid_serialized_queue_drain", }, }); + store.syncHosts([{ ...host, connections: [] }]); const sessionStore = useSessionStore.getState(); - sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 1); sessionStore.setQueuedMessages( host.serverId, new Map([["agent", [{ id: "first", text: "send once", attachments: [] }]]]), @@ -2884,7 +2957,7 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().sessions[host.serverId]?.queuedMessages.get("agent"), ).toEqual([]); }); - useSessionStore.getState().clearSession(host.serverId); + store.syncHosts([]); }); it("uses legacy GitHub attachments when draining a queue for an old daemon", async () => { @@ -2901,8 +2974,9 @@ describe("HostRuntimeStore", () => { getClientId: async () => "cid_legacy_queue_attachment", }, }); + store.syncHosts([{ ...host, connections: [] }]); const sessionStore = useSessionStore.getState(); - sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 1); sessionStore.updateSessionServerInfo(host.serverId, { serverId: host.serverId, hostname: null, @@ -3141,6 +3215,235 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().clearSession(host.serverId); }); + it.each(["coalesced", "late-tail", "rewind", "daemon-reset"] as const)( + "owns timeline request application (%s)", + async (scenario) => { + const host = makeHost({ + serverId: `request-${scenario}`, + connections: [makeHost().connections[0]!], + }); + const client = new FakeDaemonClient(); + client.setConnectionState({ status: "connected" }); + const store = new HostRuntimeStore({ + replicaRowStore: createMemoryReplicaRowStore(), + deps: { + createClient: () => client as unknown as DaemonClient, + connectToDaemon: async ({ host: current }) => ({ + client: client as unknown as DaemonClient, + serverId: current.serverId, + hostname: null, + }), + getClientId: async () => "test", + }, + }); + store.syncHosts([host]); + await waitForHostOnline(store, host.serverId); + const page: Awaited> = { + requestId: "page", + agentId: "agent", + agent: null, + direction: "tail", + projection: "projected", + reset: false, + staleCursor: false, + gap: false, + epoch: "epoch", + window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, + startCursor: { epoch: "epoch", seq: 1 }, + endCursor: { epoch: "epoch", seq: 1 }, + hasOlder: false, + hasNewer: false, + error: null, + entries: [ + { + provider: "codex", + item: { type: "assistant_message", text: "first", messageId: "first" }, + timestamp: "2026-09-06T00:00:00Z", + seqStart: 1, + seqEnd: 1, + sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], + collapsed: [], + }, + ], + }; + const viewed = store.getViewedTimelineOwner(host.serverId); + if (scenario !== "coalesced") viewed.applyTimelineResponse(page); + const response = createDeferred>>(); + client.timelineResponse = response.promise; + let publications = 0; + const unsubscribe = useSessionStore.subscribe((state, previous) => { + if ( + state.sessions[host.serverId]?.agentStreamTail !== + previous.sessions[host.serverId]?.agentStreamTail + ) + publications++; + }); + const appendLive = () => { + viewed.enqueueStreamEvent("agent", { + epoch: "epoch", + seq: 2, + timestamp: new Date("2026-09-06T00:00:01Z"), + event: { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "new live", messageId: "new-live" }, + }, + }); + viewed.flushStreamAgent("agent"); + }; + if (scenario === "rewind") appendLive(); + const request = planTimelineTailFetch(); + const first = store.fetchAgentTimeline( + host.serverId, + "agent", + request, + scenario === "late-tail", + ); + const second = + scenario === "coalesced" + ? store.fetchAgentTimeline(host.serverId, "agent", request) + : first; + if (scenario === "late-tail" || scenario === "daemon-reset") appendLive(); + if (scenario === "daemon-reset") page.reset = true; + response.resolve(page); + await expect(Promise.all([first, second])).resolves.toEqual([page, page]); + expect(client.timelineCalls).toBe(1); + if (scenario === "coalesced") expect(publications).toBe(1); + else { + const session = useSessionStore.getState().sessions[host.serverId]; + expect( + [ + ...(session?.agentStreamTail.get("agent") ?? []), + ...(session?.agentStreamHead.get("agent") ?? []), + ].map((item) => item.id), + ).toEqual(scenario === "late-tail" ? ["first", "new-live"] : ["first"]); + expect(store.getTimelineReplica(host.serverId).readCursor("agent")?.endSeq).toBe( + scenario === "late-tail" ? 2 : 1, + ); + } + unsubscribe(); + store.syncHosts([]); + }, + ); + + it.each(["settled", "captured", "renamed"] as const)( + "retains scoped-out history after host identity reconciliation (%s) and deletes it only explicitly", + async (ordering) => { + const host = makeHost({ + serverId: "srv_temporary", + connections: [makeHost().connections[0]!], + }); + const resolvedId = "srv_resolved"; + const rows = createMemoryReplicaRowStore(); + const seed = new ReplicaCache(rows, { clearLegacyCache: async () => undefined }); + seed.setHosts([host.serverId]); + const saved = { + agentId: "agent", + hasOlder: false, + range: { epoch: "epoch", startSeq: 1, endSeq: 1 }, + items: [ + { + kind: "assistant_message" as const, + id: "saved", + text: "saved history", + timestamp: new Date("2026-09-06T00:00:00Z"), + timelineCursor: { epoch: "epoch", seq: 1 }, + source: { startSeq: 1 }, + }, + ], + }; + seed.commitTimeline(host.serverId, "agent", saved); + await seed.flush(); + const client = new FakeDaemonClient(); + client.setConnectionState({ status: "connected" }); + const store = new HostRuntimeStore({ + replicaRowStore: rows, + deps: { + createClient: () => client as unknown as DaemonClient, + connectToDaemon: async ({ host: current }) => ({ + client: client as unknown as DaemonClient, + serverId: current.serverId, + hostname: null, + }), + getClientId: async () => "test", + }, + }); + const storedRows = async () => + (await rows.readAll()).find((entry) => entry.serverId === resolvedId)?.rows ?? []; + const hasStoredKind = async (kind: ReplicaRow["kind"]) => + (await storedRows()).some((row) => row.kind === kind); + try { + store.syncHosts([host]); + await waitForHostOnline(store, host.serverId); + const replica = store.getTimelineReplica(host.serverId); + const started = Promise.withResolvers(); + const release = Promise.withResolvers(); + const readRows = rows.read.bind(rows); + let reads = 0; + rows.read = async (...args) => { + if (!args[1].includes("timeline")) return readRows(...args); + reads++; + if (ordering === "settled" || reads !== 1) return readRows(...args); + const captured = ordering === "captured" ? await readRows(...args) : undefined; + started.resolve(); + await release.promise; + return captured ?? readRows(...args); + }; + const preparation = replica.prepare("agent"); + if (ordering === "settled") { + await preparation; + expect( + useSessionStore.getState().sessions[host.serverId]?.agentStreamTail.get("agent"), + ).toEqual(saved.items); + } else await started.promise; + store.reconcileServerId(host.serverId, resolvedId); + if (ordering !== "settled") { + await vi.waitFor(async () => expect(await hasStoredKind("timeline")).toBe(true)); + release.resolve(); + await preparation; + } + expect(replica.prepare("agent")).toBe(preparation); + expect(reads).toBe(ordering === "settled" ? 1 : 2); + expect(store.getTimelineReplica(resolvedId)).toBe(replica); + expect( + useSessionStore.getState().sessions[resolvedId]?.agentStreamTail.get("agent"), + ).toEqual(saved.items); + expect(replica.readCursor("agent")).toEqual({ epoch: "epoch", endSeq: 1 }); + await waitForHostOnline(store, resolvedId); + useSessionStore.getState().updateSessionServerInfo(resolvedId, { + serverId: resolvedId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true }, + }); + store.acceptAgentSnapshot( + resolvedId, + normalizeAgentSnapshot( + makeFetchAgentsEntry({ id: "agent", cwd: "/repo", updatedAt: "2026-09-06T00:00:00Z" }) + .agent, + resolvedId, + ), + ); + await vi.waitFor(async () => expect(await hasStoredKind("agent")).toBe(true), { + timeout: 3000, + }); + await store.refreshAgentDirectory({ serverId: resolvedId }); + store.setAppVisible(false); + await vi.waitFor(async () => expect(await hasStoredKind("agent")).toBe(false)); + const reopened = new ReplicaCache(rows, { clearLegacyCache: async () => undefined }); + reopened.setHosts([resolvedId]); + expect(await reopened.readTimeline(resolvedId, "agent")).toEqual(saved); + store.restoreAgentSnapshot(resolvedId, "agent", undefined); + store.setAppVisible(false); + await vi.waitFor(async () => expect(await hasStoredKind("timeline")).toBe(false)); + } finally { + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + useSessionStore.getState().clearSession(resolvedId); + } + }, + ); + it("replaces stale active session state when active bootstrap omits an agent", async () => { const host = makeHost({ serverId: "srv_archived_rehydrate", diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index ad33968740..7ab9902597 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1,3 +1,4 @@ +import { createTimelineReplica, type TimelineReplica } from "@/timeline/replica"; import { useSyncExternalStore, useMemo } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import equal from "fast-deep-equal/es6"; @@ -70,11 +71,8 @@ import { ReplicaCache } from "@/runtime/replica-cache"; import type { ReplicaRowStore } from "@/runtime/replica-cache/row-store"; import { createReplicaRowStore } from "@/runtime/replica-cache/row-store-factory"; import { - createTimelineReplica, createViewedTimelineOwner, - type TimelineReplica, type ViewedTimelineOwner, - type ViewedTimelineOwnerPorts, } from "@/timeline/viewed-timeline-sync"; import { projectIconCache } from "@/projects/icon-cache"; import { nativePerformanceTrace } from "@/performance/native-trace"; @@ -1404,6 +1402,7 @@ export class HostRuntimeStore { private directorySyncByServer = new Map(); private nextCancellationRequestId = 0; private timelineReplicaByServer = new Map(); + private viewedTimelineByServer = new Map(); private configuredOverrideBootstrapInFlight: Promise | null = null; private bootPromise: Promise | null = null; private appVisible = true; @@ -1521,12 +1520,6 @@ export class HostRuntimeStore { projectIconCache.setHosts(profiles.map((profile) => profile.serverId)); await projectIconCache.restore(); this.syncHosts(profiles); - for (const profile of profiles) { - void this.directorySyncByServer - .get(profile.serverId) - ?.restoreCachedDirectory() - .catch(() => undefined); - } } catch (error) { console.error("[HostRuntime] Failed to load host registry from storage", error); } finally { @@ -1663,11 +1656,17 @@ export class HostRuntimeStore { projectIconCache.reconcileServerId(oldServerId, newServerId); this.directorySyncByServer.get(oldServerId)?.dispose(); this.directorySyncByServer.delete(oldServerId); - this.timelineReplicaByServer.delete(oldServerId); + const timelineReplica = this.getTimelineReplica(oldServerId); + rekeyMap(this.timelineReplicaByServer, oldServerId, newServerId); const directory = new DirectorySync( newServerId, { + onTimelineRequest: (agentId, replaceTail) => + this.getViewedTimelineOwner(newServerId).beginTimelineRequest(agentId, replaceTail), onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId), + onAgentRemoved: (agentId, reason) => + this.getViewedTimelineOwner(newServerId).removeAgent(agentId, reason), + onAgentAccepted: (agentId) => this.getViewedTimelineOwner(newServerId).acceptAgent(agentId), markAgentLoading: () => controller.markAgentDirectorySyncLoading(), markAgentReady: () => controller.markAgentDirectorySyncReady(), markAgentError: (error) => controller.markAgentDirectorySyncError(error), @@ -1675,18 +1674,16 @@ export class HostRuntimeStore { this.replicaCache, ); this.directorySyncByServer.set(newServerId, directory); - this.timelineReplicaByServer.set( - newServerId, - createTimelineReplica({ - serverId: newServerId, - storage: this.replicaCache, - prepareAgent: (agentId) => directory.prepareAgentRoute(agentId), - }), - ); controller.adoptReconciledServerId(newServerId); const snapshot = controller.getSnapshot(); + this.viewedTimelineByServer.get(oldServerId)?.dispose(); + this.viewedTimelineByServer.delete(oldServerId); this.clearHostReplica(oldServerId); - this.syncSessionReplica(newServerId, snapshot); + useSessionStore + .getState() + .initializeSession(newServerId, snapshot.client, snapshot.clientGeneration); + timelineReplica.reconcileServerId(newServerId); + this.installViewedTimelineOwner(newServerId); directory.connectionChanged({ client: snapshot.client, status: snapshot.connectionStatus === "online" ? "online" : "offline", @@ -2057,6 +2054,9 @@ export class HostRuntimeStore { this.connectionStatusStartedAtByServer.delete(serverId); this.directorySyncByServer.get(serverId)?.dispose(); this.directorySyncByServer.delete(serverId); + this.viewedTimelineByServer.get(serverId)?.dispose(); + this.viewedTimelineByServer.delete(serverId); + this.timelineReplicaByServer.get(serverId)?.dispose(); this.timelineReplicaByServer.delete(serverId); this.clearHostReplica(serverId); void controller.stop(); @@ -2086,7 +2086,13 @@ export class HostRuntimeStore { const directory = new DirectorySync( host.serverId, { + onTimelineRequest: (agentId, replaceTail) => + this.getViewedTimelineOwner(host.serverId).beginTimelineRequest(agentId, replaceTail), onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId), + onAgentRemoved: (agentId, reason) => + this.getViewedTimelineOwner(host.serverId).removeAgent(agentId, reason), + onAgentAccepted: (agentId) => + this.getViewedTimelineOwner(host.serverId).acceptAgent(agentId), markAgentLoading: () => controller.markAgentDirectorySyncLoading(), markAgentReady: () => controller.markAgentDirectorySyncReady(), markAgentError: (error) => controller.markAgentDirectorySyncError(error), @@ -2099,9 +2105,9 @@ export class HostRuntimeStore { createTimelineReplica({ serverId: host.serverId, storage: this.replicaCache, - prepareAgent: (agentId) => directory.prepareAgentRoute(agentId), }), ); + this.installViewedTimelineOwner(host.serverId); const initialSnapshot = controller.getSnapshot(); this.lastConnectionStatusByServer.set(host.serverId, initialSnapshot.connectionStatus); this.connectionStatusStartedAtByServer.set(host.serverId, Date.now()); @@ -2213,7 +2219,7 @@ export class HostRuntimeStore { supportsForgeAttachments, }), encodeImages, - submission: createMessageSubmissionWriter(serverId), + submission: createMessageSubmissionWriter(serverId, this.getTimelineReplica(serverId)), }); }, }) @@ -2343,6 +2349,7 @@ export class HostRuntimeStore { setAppVisible(visible: boolean): void { this.appVisible = visible; + for (const owner of this.viewedTimelineByServer.values()) owner.setActive(visible); for (const controller of this.controllers.values()) { controller.setReconnectEnabled(visible); } @@ -2391,49 +2398,40 @@ export class HostRuntimeStore { return () => directory.setDemand(source, false); } - async prepareAgentRoute(serverId: string, agentId: string): Promise { - const directory = this.directorySyncByServer.get(serverId); - if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); - await directory.prepareAgentRoute(agentId); - } - - async prepareWorkspaceRoute(serverId: string, workspaceId: string): Promise { - const directory = this.directorySyncByServer.get(serverId); - if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); - await directory.prepareWorkspaceRoute(workspaceId); - } - - async prepareAgentTimeline(serverId: string, agentId: string): Promise { + getTimelineReplica(serverId: string): TimelineReplica { const replica = this.timelineReplicaByServer.get(serverId); if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`); - await replica.prepare(agentId); + return replica; } - fetchAgentTimeline( + async fetchAgentTimeline( serverId: string, agentId: string, request: Parameters[1], + replaceTail = false, ): Promise>> { const directory = this.directorySyncByServer.get(serverId); if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); - return directory.fetchTimeline(agentId, request); + return directory.fetchTimeline(agentId, request, replaceTail); } - createViewedTimelineOwner( - serverId: string, - ports: ViewedTimelineOwnerPorts, - ): ViewedTimelineOwner { - const directory = this.directorySyncByServer.get(serverId); - if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); - const replica = this.timelineReplicaByServer.get(serverId); - if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`); - return createViewedTimelineOwner({ + getViewedTimelineOwner(serverId: string): ViewedTimelineOwner { + const owner = this.viewedTimelineByServer.get(serverId); + if (!owner) throw new Error(`Unknown host runtime for serverId ${serverId}`); + return owner; + } + + private installViewedTimelineOwner(serverId: string): void { + const owner = createViewedTimelineOwner({ serverId, - replica, - replaceDemandedAgentIds: (agentIds) => directory.setAgentRouteDemand(agentIds), - drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(serverId, agentId), - ports, + replica: this.getTimelineReplica(serverId), + replaceDemandedAgentIds: (ids) => + this.directorySyncByServer.get(serverId)?.setAgentRouteDemand(ids), + drainQueuedAgentMessage: (id) => this.drainQueuedAgentMessage(serverId, id), }); + this.viewedTimelineByServer.set(serverId, owner); + owner.setActive(this.appVisible); + useSessionStore.getState().setViewedTimelineSync(serverId, owner); } private emit(serverId: string): void { diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index df6c3a0aa0..2a603490c6 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -25,41 +25,58 @@ class MemoryStorage implements ReplicaRowStore { readonly rows = new Map(); readonly changes: ReplicaRowChanges[] = []; readonly reads: Array<{ - serverId: string; + serverIds: readonly string[]; kinds: readonly ReplicaRow["kind"][]; ids?: readonly string[]; }> = []; writes = 0; + readAlls = 0; cleanups = 0; nextWriteFailure: Error | null = null; + nextOpenFailure: Error | null = null; + nextReadAllFailure: Error | null = null; + nextDeleteFailure: Error | null = null; readGate: Promise | null = null; - onRead: (() => void) | null = null; + readAllGate: Promise | null = null; + writeGate: Promise | null = null; + renameGate: Promise | null = null; private key(row: Pick): string { return `${row.serverId}:${row.kind}:${row.id}`; } - async open(): Promise {} + async open(): Promise { + if (this.nextOpenFailure) { + const error = this.nextOpenFailure; + this.nextOpenFailure = null; + throw error; + } + } async read( - serverId: string, + serverIds: readonly string[], kinds: readonly ReplicaRow["kind"][], ids?: readonly string[], ): Promise { - this.reads.push({ serverId, kinds, ...(ids ? { ids } : {}) }); - this.onRead?.(); + this.reads.push({ serverIds, kinds, ids }); + const rows = [...this.rows.values()]; await this.readGate; - const acceptedKinds = new Set(kinds); - const acceptedIds = ids ? new Set(ids) : null; - return [...this.rows.values()].filter( + return rows.filter( (row) => - row.serverId === serverId && - acceptedKinds.has(row.kind) && - (!acceptedIds || acceptedIds.has(row.id)), + serverIds.includes(row.serverId) && + kinds.includes(row.kind) && + (!ids || ids.includes(row.id)), ); } async readAll(): Promise { + this.readAlls += 1; + await this.readAllGate; + if (this.nextReadAllFailure) { + const error = this.nextReadAllFailure; + this.nextReadAllFailure = null; + throw error; + } const hosts = new Map(); for (const row of this.rows.values()) { const rows = hosts.get(row.serverId) ?? []; @@ -71,6 +88,7 @@ class MemoryStorage implements ReplicaRowStore { async apply(changes: ReplicaRowChanges): Promise { this.writes += 1; + await this.writeGate; if (this.nextWriteFailure) { const error = this.nextWriteFailure; this.nextWriteFailure = null; @@ -82,10 +100,16 @@ class MemoryStorage implements ReplicaRowStore { } async deleteHost(serverId: string): Promise { + if (this.nextDeleteFailure) { + const error = this.nextDeleteFailure; + this.nextDeleteFailure = null; + throw error; + } for (const [key, row] of this.rows) if (row.serverId === serverId) this.rows.delete(key); } async renameHost(oldServerId: string, newServerId: string): Promise { + await this.renameGate; for (const [key, row] of this.rows) { if (row.serverId !== oldServerId) continue; this.rows.delete(key); @@ -170,6 +194,7 @@ function timelineItem(text = "Cached"): StreamItem { text, timestamp: new Date("2026-07-18T08:02:00.000Z"), timelineCursor: { epoch: "epoch-1", seq: 12 }, + source: { startSeq: 12, chunks: [{ seq: 12, offset: 0 }] }, }; } @@ -241,6 +266,359 @@ function deleteDirectory(cache: ReplicaCache, serverId: string): void { } describe("ReplicaCache", () => { + it("paints requested rows while the unrelated global index is unavailable", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + commitDirectory(writer, SERVER_ID, directory()); + writer.commitTimeline(SERVER_ID, "agent-1", timeline("Requested")); + await writer.flush(); + const release = deferred(); + storage.readAllGate = release.promise; + storage.reads.length = 0; + const reader = createCache(storage); + reader.setHosts([SERVER_ID, "unrelated"]); + reader.commitTimeline("unrelated", "other", { ...timeline("Pending"), agentId: "other" }); + const writing = reader.flush(); + let painted = false; + const reading = Promise.all([ + reader.readTimeline(SERVER_ID, "agent-1"), + reader.readDirectory(SERVER_ID), + ]).then(([saved, restoredDirectory]) => { + expect(saved?.items).toEqual([timelineItem("Requested")]); + expect(restoredDirectory.agents.has("agent-1")).toBe(true); + painted = true; + return undefined; + }); + try { + await expect.poll(() => painted, { timeout: 200 }).toBe(true); + expect(storage.reads).toEqual([ + { serverIds: [SERVER_ID], kinds: ["timeline"], ids: ["agent-1"] }, + { + serverIds: [SERVER_ID], + kinds: ["agent", "workspace", "project", "checkpoint"], + ids: undefined, + }, + ]); + } finally { + release.resolve(); + await Promise.all([writing, reading]); + } + }); + + it("overlays a commit that settles after a scoped read captures old disk rows", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + writer.commitTimeline(SERVER_ID, "agent-1", timeline("Old")); + await writer.flush(); + const reader = createCache(storage); + const release = deferred(); + storage.readGate = release.promise; + const reading = reader.readTimeline(SERVER_ID, "agent-1"); + await expect.poll(() => storage.reads.length).toBe(1); + reader.commitTimeline(SERVER_ID, "agent-1", timeline("New")); + await reader.flush(); + release.resolve(); + expect((await reading)?.items).toEqual([timelineItem("New")]); + }); + + it("invalidates an old scoped read across removal and immediate re-registration", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + writer.commitTimeline(SERVER_ID, "agent-1", timeline("Old lifetime")); + await writer.flush(); + const reader = createCache(storage); + const release = deferred(); + storage.readGate = release.promise; + const reading = reader.readTimeline(SERVER_ID, "agent-1"); + await expect.poll(() => storage.reads.length).toBe(1); + reader.setHosts([]); + reader.setHosts([SERVER_ID]); + release.resolve(); + expect(await reading).toBeUndefined(); + expect(await reader.readTimeline(SERVER_ID, "agent-1")).toBeUndefined(); + await reader.flush(); + }); + + it.each([false, true])( + "uses the same certification for accepted and persisted rows (provenance=%s)", + async (provenance) => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + const items = ["block-1", "block-2"].map((id) => { + const item = timelineItem("Repeated markdown"); + item.id = id; + if (item.kind === "assistant_message") item.messageId = "same-message"; + item.source = provenance ? { startSeq: 12, chunks: [{ seq: 12, offset: 0 }] } : undefined; + return item; + }); + const value = { ...timeline(), items }; + cache.commitTimeline(SERVER_ID, "agent-1", value); + const accepted = await cache.readTimeline(SERVER_ID, "agent-1"); + expect(accepted?.range).toEqual(provenance ? value.range : null); + expect(accepted?.items).toEqual(items); + await cache.flush(); + const persisted = await createCache(storage).readTimeline(SERVER_ID, "agent-1"); + expect(persisted).toEqual(accepted); + }, + ); + + it("serializes only the coalesced certified page, including reads of accepted commits", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + let encodings = 0; + class MeasuredDate extends Date { + override toISOString(): string { + encodings += 1; + return super.toISOString(); + } + } + for (let seq = 1; seq <= 10; seq++) { + const item = { + ...timelineItem(`row ${seq}`), + timestamp: new MeasuredDate("2026-07-18T08:02:00.000Z"), + timelineCursor: { epoch: "epoch", seq }, + source: { startSeq: seq, chunks: [{ seq, offset: 0 }] }, + }; + cache.commitTimeline( + SERVER_ID, + "agent-1", + { + agentId: "agent-1", + items: [item], + range: { epoch: "epoch", startSeq: seq, endSeq: seq }, + hasOlder: true, + }, + true, + ); + expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([item]); + } + expect(encodings).toBe(0); + await cache.flush(); + expect(encodings).toBe(1); + expect((await createCache(storage).readTimeline(SERVER_ID, "agent-1"))?.range?.endSeq).toBe(10); + }); + + it("does not resurrect a removed host when its in-flight write completes", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + const release = deferred(); + storage.writeGate = release.promise; + cache.commitTimeline(SERVER_ID, "agent-1", timeline("Removed history")); + const writing = cache.flush(); + await expect.poll(() => storage.writes).toBe(1); + cache.setHosts([]); + release.resolve(); + await writing; + cache.setHosts([SERVER_ID]); + await cache.flush(); + expect(await cache.readTimeline(SERVER_ID, "agent-1")).toBeUndefined(); + expect(storage.rows.size).toBe(0); + }); + + it("keeps rows written under a host renamed during their write", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + const release = deferred(); + storage.writeGate = release.promise; + cache.commitTimeline(SERVER_ID, "agent-1", timeline("Renamed while writing")); + const writing = cache.flush(); + await expect.poll(() => storage.writes).toBe(1); + cache.reconcileServerId(SERVER_ID, "resolved-host"); + release.resolve(); + await writing; + await cache.flush(); + + expect((await cache.readTimeline("resolved-host", "agent-1"))?.items).toEqual([ + timelineItem("Renamed while writing"), + ]); + expect(await cache.readTimeline(SERVER_ID, "agent-1")).toBeUndefined(); + expect([...storage.rows.keys()]).toEqual(["resolved-host:timeline:agent-1"]); + }); + + it("writes a row replaced during its own write again", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + const release = deferred(); + storage.writeGate = release.promise; + cache.commitTimeline(SERVER_ID, "agent-1", timeline("First")); + const writing = cache.flush(); + await expect.poll(() => storage.writes).toBe(1); + cache.commitTimeline(SERVER_ID, "agent-1", timeline("Second")); + release.resolve(); + await writing; + await cache.flush(); + + expect(storage.writes).toBe(2); + expect((await createCache(storage).readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([ + timelineItem("Second"), + ]); + }); + + it("restores a timeline while another host's disk write is blocked", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + cache.setHosts([SERVER_ID, "other-host"]); + cache.commitTimeline(SERVER_ID, "agent-1", timeline("Saved before restart")); + await cache.flush(); + const release = deferred(); + storage.writeGate = release.promise; + commitDirectory(cache, "other-host", directory()); + const writing = cache.flush(); + await expect.poll(() => storage.writes).toBe(2); + let restored: Awaited>; + const reading = cache.readTimeline(SERVER_ID, "agent-1").then((value) => { + restored = value; + return value; + }); + try { + await expect + .poll(() => restored?.items, { timeout: 200 }) + .toEqual([timelineItem("Saved before restart")]); + } finally { + release.resolve(); + await Promise.all([writing, reading]); + } + }); + + it("reads rows under a resolved host identity before the disk rename finishes", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + writer.commitTimeline(SERVER_ID, "agent-1", timeline("Saved under temporary host ID")); + await writer.flush(); + const reader = createCache(storage); + const release = deferred(); + storage.renameGate = release.promise; + reader.reconcileServerId(SERVER_ID, "resolved-host"); + expect((await reader.readTimeline("resolved-host", "agent-1"))?.items).toEqual([ + timelineItem("Saved under temporary host ID"), + ]); + release.resolve(); + await reader.flush(); + expect(storage.rows.has("resolved-host:timeline:agent-1")).toBe(true); + }); + + it("keeps rename collision precedence and accepted baselines during lazy reads", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + writer.setHosts([SERVER_ID, "resolved-host"]); + writer.commitTimeline(SERVER_ID, "agent-1", timeline("Moved")); + writer.commitTimeline("resolved-host", "agent-1", timeline("Superseded target")); + commitDirectory(writer, SERVER_ID, directory()); + await writer.flush(); + const reader = createCache(storage); + reader.setHosts([SERVER_ID, "resolved-host"]); + const release = deferred(); + storage.renameGate = release.promise; + reader.replaceDirectoryBaseline(SERVER_ID, { + ...directory(), + workspaces: new Map(), + projects: new Map(), + }); + reader.reconcileServerId(SERVER_ID, "resolved-host"); + try { + expect((await reader.readTimeline("resolved-host", "agent-1"))?.items).toEqual([ + timelineItem("Moved"), + ]); + const accepted = await reader.readDirectory("resolved-host"); + expect(accepted.agents.get("agent-1")?.serverId).toBe("resolved-host"); + expect(accepted.workspaces.size).toBe(0); + } finally { + release.resolve(); + await reader.flush(); + } + const reopened = new ReplicaCache(storage, noLegacyCleanup); + reopened.setHosts(["resolved-host"]); + expect((await reopened.readDirectory("resolved-host")).workspaces.size).toBe(0); + expect((await reopened.readTimeline("resolved-host", "agent-1"))?.items).toEqual([ + timelineItem("Moved"), + ]); + }); + + it("returns a committed timeline before it is written", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + cache.commitTimeline(SERVER_ID, "agent-1", timeline("Accepted")); + + expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([ + timelineItem("Accepted"), + ]); + expect(storage.writes).toBe(0); + }); + + it("round-trips a row's source start and chunk provenance", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + cache.commitTimeline(SERVER_ID, "agent-1", { + ...timeline(), + items: [ + { + ...timelineItem("Streamed"), + source: { + startSeq: 10, + chunks: [ + { seq: 10, offset: 0 }, + { seq: 12, offset: 4 }, + ], + }, + }, + ], + }); + await cache.flush(); + + const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1"); + expect(restored?.items[0]).toMatchObject({ source: { startSeq: 10 } }); + expect(restored?.items[0]?.source?.chunks).toEqual([ + { seq: 10, offset: 0 }, + { seq: 12, offset: 4 }, + ]); + }); + + it("persists the owner's forty-entry restart page from a long synced history", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + const items = Array.from({ length: 40 }, (_, index) => ({ + ...timelineItem(`Message ${index}`), + id: `message-${index}`, + timelineCursor: { epoch: "epoch-1", seq: index + 201 }, + source: { startSeq: index + 201, chunks: [{ seq: index + 201, offset: 0 }] }, + })); + cache.commitTimeline(SERVER_ID, "agent-1", { + agentId: "agent-1", + items, + range: { epoch: "epoch-1", startSeq: 201, endSeq: 240 }, + hasOlder: true, + }); + await cache.flush(); + + const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1"); + expect(restored?.items).toHaveLength(40); + expect(restored?.range).toEqual({ epoch: "epoch-1", startSeq: 201, endSeq: 240 }); + expect(restored?.hasOlder).toBe(true); + }); + + it("encodes the owner's certified page without a per-timeline byte ceiling", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + const items = Array.from({ length: 40 }, (_, index) => ({ + ...timelineItem("x".repeat(20_000)), + id: `message-${index}`, + timelineCursor: { epoch: "epoch-1", seq: index + 1 }, + source: { startSeq: index + 1, chunks: [{ seq: index + 1, offset: 0 }] }, + })); + cache.commitTimeline(SERVER_ID, "agent-1", { + agentId: "agent-1", + items, + range: { epoch: "epoch-1", startSeq: 1, endSeq: 40 }, + hasOlder: true, + }); + await cache.flush(); + + const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1"); + expect(restored?.items.map((item) => item.id)).toEqual(items.map((item) => item.id)); + expect(restored?.range).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 40 }); + expect(restored?.hasOlder).toBe(true); + }); + it("does nothing until an owner explicitly commits data", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); @@ -409,9 +787,10 @@ describe("ReplicaCache", () => { deleteDirectory(cache, SERVER_ID); - expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined(); - expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined(); - expect((await cache.readDirectory(SERVER_ID)).projects.size).toBe(0); + const restored = await cache.readDirectory(SERVER_ID); + expect(restored.agents.size).toBe(0); + expect(restored.workspaces.size).toBe(0); + expect(restored.projects.size).toBe(0); }); it("fails closed when an accepted deletion cannot be persisted before a read", async () => { @@ -423,25 +802,23 @@ describe("ReplicaCache", () => { deleteDirectory(cache, SERVER_ID); - expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined(); + expect((await cache.readDirectory(SERVER_ID)).workspaces.size).toBe(0); }); - it("discards a durable read when the host changes while it is in flight", async () => { + it("applies a deletion accepted while a scoped directory read is still loading", async () => { const storage = new MemoryStorage(); - const cache = createCache(storage); - commitDirectory(cache, SERVER_ID, directory()); - await cache.flush(); - const started = deferred(); + const writer = createCache(storage); + commitDirectory(writer, SERVER_ID, directory()); + await writer.flush(); const release = deferred(); - storage.onRead = started.resolve; storage.readGate = release.promise; + const cache = createCache(storage); - const reading = cache.readAgent(SERVER_ID, "agent-1"); - await started.promise; + const reading = cache.readDirectory(SERVER_ID); deleteDirectory(cache, SERVER_ID); release.resolve(); - expect(await reading).toBeUndefined(); + expect((await reading).agents.size).toBe(0); }); it("never reads a timeline older than an accepted deferred replacement", async () => { @@ -513,39 +890,6 @@ describe("ReplicaCache", () => { expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toBeUndefined(); }); - it("reads one requested agent and the focused timeline without scanning directory rows", async () => { - const storage = new MemoryStorage(); - const writer = createCache(storage); - commitDirectory(writer, SERVER_ID, directory()); - writer.commitTimeline(SERVER_ID, "agent-1", timeline()); - await writer.flush(); - - const reader = createCache(storage); - expect((await reader.readAgent(SERVER_ID, "agent-1"))?.id).toBe("agent-1"); - expect((await reader.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([timelineItem()]); - expect(storage.reads).toEqual([ - { serverId: SERVER_ID, kinds: ["agent"], ids: ["agent-1"] }, - { serverId: SERVER_ID, kinds: ["timeline"], ids: ["agent-1"] }, - ]); - }); - - it("reads one requested workspace and its project without scanning the directory", async () => { - const storage = new MemoryStorage(); - const writer = createCache(storage); - commitDirectory(writer, SERVER_ID, directory()); - await writer.flush(); - - const reader = createCache(storage); - const restored = await reader.readWorkspace(SERVER_ID, "workspace-1"); - - expect(restored?.workspace.id).toBe("workspace-1"); - expect(restored?.project?.projectId).toBe("project-1"); - expect(storage.reads).toEqual([ - { serverId: SERVER_ID, kinds: ["workspace"], ids: ["workspace-1"] }, - { serverId: SERVER_ID, kinds: ["project"], ids: ["project-1"] }, - ]); - }); - it("treats a corrupt row as a scoped miss", async () => { const storage = new MemoryStorage(); storage.rows.set(`${SERVER_ID}:agent:agent-1`, { @@ -570,6 +914,7 @@ describe("ReplicaCache", () => { const cache = createCache(storage); const restored = await cache.readDirectory(SERVER_ID); + await cache.flush(); expect(restored.agents.size).toBe(0); expect(restored.projects.get("project-1")?.projectDisplayName).toBe("Paseo"); @@ -601,7 +946,7 @@ describe("ReplicaCache", () => { commitDirectory(cache, otherServerId, directory()); await cache.flush(); - expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined(); + expect((await cache.readDirectory(SERVER_ID)).agents.has("agent-1")).toBe(false); cache.commitTimeline(otherServerId, "agent-1", timeline("x".repeat(1_000))); await cache.flush(); @@ -627,7 +972,9 @@ describe("ReplicaCache", () => { payload: "{bad", }); - expect(await createCache(storage).readAgent(SERVER_ID, "agent-1")).toBeUndefined(); + const cache = createCache(storage); + expect((await cache.readDirectory(SERVER_ID)).agents.has("agent-1")).toBe(false); + await cache.flush(); expect(storage.changes.at(-1)).toMatchObject({ deletes: [{ serverId: SERVER_ID, kind: "agent", id: "agent-1" }], @@ -657,7 +1004,9 @@ describe("ReplicaCache", () => { payload: "{bad", }); - const restored = await createCache(storage).readDirectory(SERVER_ID); + const cache = createCache(storage); + const restored = await cache.readDirectory(SERVER_ID); + await cache.flush(); expect(restored.checkpoint).toEqual({ projects: { generation: "g", afterSeq: 4 }, @@ -688,6 +1037,66 @@ describe("ReplicaCache", () => { ]); }); + it.each(["nextOpenFailure", "nextReadAllFailure"] as const)( + "persists an accepted commit automatically after %s", + async (failure) => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + storage[failure] = new Error("storage busy"); + cache.commitTimeline(SERVER_ID, "agent-1", timeline("Retry me")); + + await cache.flush(); + expect(storage.rows.size).toBe(0); + expect(await cache.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline("Retry me")); + await expect.poll(() => storage.rows.size, { timeout: 2500 }).toBe(1); + + const reopened = createCache(storage); + expect(await reopened.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline("Retry me")); + }, + ); + + it("recovers a partial index load with queued host changes and the exact surviving budget", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + writer.setHosts([SERVER_ID, "stale", "old", "removed"]); + for (const host of [SERVER_ID, "stale", "old", "removed"]) { + writer.commitTimeline(host, "agent-1", timeline("Stored")); + } + await writer.flush(); + const survivorBytes = [...storage.rows.values()] + .filter((row) => row.serverId === SERVER_ID || row.serverId === "old") + .reduce((bytes, row) => bytes + Buffer.byteLength(row.payload), 0); + const cache = createCache(storage, survivorBytes); + cache.setHosts([SERVER_ID, "old", "removed"]); + const release = deferred(); + storage.readAllGate = release.promise; + storage.nextDeleteFailure = new Error("stale host cleanup busy"); + const loadsBefore = storage.readAlls; + cache.commitTimeline(SERVER_ID, "agent-1", timeline("Newest")); + const writing = cache.flush(); + try { + await expect.poll(() => storage.readAlls).toBe(loadsBefore + 1); + cache.reconcileServerId("old", "renamed"); + cache.setHosts([SERVER_ID, "renamed"]); + } finally { + release.resolve(); + await writing; + } + expect(await cache.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline("Newest")); + expect(await cache.readTimeline("renamed", "agent-1")).toEqual(timeline("Stored")); + expect(await cache.readTimeline("removed", "agent-1")).toBeUndefined(); + await expect.poll(() => storage.changes.length, { timeout: 2500 }).toBe(2); + + const reopened = createCache(storage); + reopened.setHosts([SERVER_ID, "renamed"]); + expect([...storage.rows.values()].map((row) => row.serverId).sort()).toEqual([ + SERVER_ID, + "renamed", + ]); + expect(await reopened.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline("Newest")); + expect(await reopened.readTimeline("renamed", "agent-1")).toEqual(timeline("Stored")); + }); + it("retries an explicit commit after a storage failure", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); @@ -703,21 +1112,6 @@ describe("ReplicaCache", () => { ]); }); - it("retries a timeline read invalidated by a concurrent directory commit", async () => { - const storage = new MemoryStorage(); - const cache = createCache(storage); - cache.commitTimeline(SERVER_ID, "agent-1", timeline("Persisted timeline")); - await cache.flush(); - storage.onRead = () => { - storage.onRead = null; - commitDirectory(cache, SERVER_ID, directory()); - }; - - expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([ - timelineItem("Persisted timeline"), - ]); - }); - it("rebuilds every directory row before restoring its checkpoint after eviction", async () => { const storage = new MemoryStorage(); const cache = new ReplicaCache(storage, { ...noLegacyCleanup, maxBytes: 2_500 }); @@ -759,7 +1153,7 @@ describe("ReplicaCache", () => { }); cache.setHosts([SERVER_ID]); - await cache.readAgent(SERVER_ID, "missing"); + await cache.readTimeline(SERVER_ID, "missing"); commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index c1db2f8418..de02987c21 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { AgentStatusSchema, + AgentAttachmentSchema, AgentTimelineItemPayloadSchema, WorkspaceGitHubRuntimePayloadSchema, } from "@getpaseo/protocol/messages"; @@ -45,11 +46,6 @@ export interface CachedDirectory { checkpoint?: DirectoryCheckpoint; } -export interface CachedWorkspace { - workspace: WorkspaceDescriptor; - project?: ProjectDescriptor; -} - export interface DirectoryCursor { generation: string; afterSeq: number; @@ -69,13 +65,32 @@ export interface CachedTimeline { } const PERSIST_DELAY_MS = 1_000; -const MAX_TIMELINE_ITEMS = 50; const MAX_CACHE_BYTES = 32 * 1024 * 1024; const IsoDateSchema = z.iso.datetime(); const TimelinePositionSchema = z.strictObject({ epoch: z.string(), seq: z.number().int().nonnegative(), }); +const RowSourceSchema = z.strictObject({ + startSeq: z.number().int().nonnegative(), + chunks: z + .array( + z.strictObject({ + seq: z.number().int().nonnegative(), + offset: z.number().int().nonnegative(), + }), + ) + .optional(), +}); +const StoredImageSchema = z.strictObject({ + id: z.string(), + mimeType: z.string(), + storageType: z.enum(["web-indexeddb", "desktop-file", "native-file"]), + storageKey: z.string(), + fileName: z.string().nullable().optional(), + byteSize: z.number().nullable().optional(), + createdAt: z.number(), +}); const PluginTimelineDataSchema: z.ZodType = z.lazy(() => z.union([ z.null(), @@ -90,6 +105,8 @@ const PluginTimelineDataSchema: z.ZodType = z.lazy(() => const TimelineItemBaseShape = { id: z.string(), timelineCursor: TimelinePositionSchema.optional(), + // Absent on rows saved before source provenance existed. + source: RowSourceSchema.optional(), // COMPAT(active-turn-membership): absent on caches written before turn membership. turnId: z.string().optional(), timestamp: IsoDateSchema, @@ -115,6 +132,8 @@ const StoredTimelineItemSchema = z.discriminatedUnion("kind", [ z.strictObject({ ...TimelineItemBaseShape, kind: z.literal("user_message"), + images: z.array(StoredImageSchema).optional(), + attachments: z.array(AgentAttachmentSchema).optional(), clientMessageId: z.string().optional(), messageId: z.string().optional(), text: z.string(), @@ -379,10 +398,7 @@ const DirectoryCheckpointSchema = z.strictObject({ agents: DirectoryCursorSchema.optional(), }); -function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | null { - if (!stored) { - return null; - } +function deserializeTimeline(stored: StoredTimeline): CachedTimeline { return { agentId: stored.agentId, items: stored.items.map(deserializeTimelineItem), @@ -395,6 +411,7 @@ function timelineBase(item: StreamItem) { return { id: item.id, ...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}), + ...(item.source ? { source: item.source } : {}), ...(item.turnId ? { turnId: item.turnId } : {}), timestamp: item.timestamp.toISOString(), }; @@ -422,13 +439,7 @@ function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null { const base = timelineBase(item); switch (item.kind) { case "user_message": - return { - ...base, - kind: item.kind, - ...(item.clientMessageId ? { clientMessageId: item.clientMessageId } : {}), - ...(item.messageId ? { messageId: item.messageId } : {}), - text: item.text, - }; + return { ...item, ...base }; case "assistant_message": return { ...base, @@ -490,6 +501,7 @@ function deserializeTimelineItem(item: StoredTimelineItem): StreamItem { return { id: item.id, ...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}), + ...(item.source ? { source: item.source } : {}), ...(item.turnId ? { turnId: item.turnId } : {}), timestamp: new Date(item.timestamp), kind: item.kind, @@ -509,18 +521,13 @@ function deserializeBuiltinTimelineItem( const base = { id: item.id, ...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}), + ...(item.source ? { source: item.source } : {}), ...(item.turnId ? { turnId: item.turnId } : {}), timestamp: new Date(item.timestamp), }; switch (item.kind) { case "user_message": - return { - ...base, - kind: item.kind, - ...(item.clientMessageId ? { clientMessageId: item.clientMessageId } : {}), - ...(item.messageId ? { messageId: item.messageId } : {}), - text: item.text, - }; + return { ...item, ...base }; case "assistant_message": return { ...base, @@ -727,44 +734,43 @@ function serializeProject(project: ProjectDescriptor): StoredProject { }; } -function isTimelineItemStoredLosslessly(item: StreamItem): boolean { - switch (item.kind) { - case "user_message": - return (item.images?.length ?? 0) === 0 && (item.attachments?.length ?? 0) === 0; - case "tool_call": - return item.payload.source === "agent"; - default: - return true; - } -} - -function serializeTimeline(timeline: CachedTimeline): StoredTimeline | null { - const canonicalItems = timeline.items.filter( - (item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item), - ); - const items = canonicalItems.map(serializeTimelineItem).filter((item) => item !== null); +// A saved timeline is either certified or display-only. Certified rows keep the owner's restart page +// with its range, so the next launch resumes with one `after endSeq` page. Everything the range +// cannot vouch for (a discontiguous window or rows the schema cannot encode) falls back to a +// display-only tail that carries no coverage claim. +function isCertifiedTimeline(timeline: CachedTimeline): boolean { const range = timeline.range; - const canPersistCoverage = + return ( range !== null && range.retainedRanges === undefined && - canonicalItems.length <= MAX_TIMELINE_ITEMS && - items.length === canonicalItems.length && - canonicalItems.every( + timeline.items.every( (item) => - isTimelineItemStoredLosslessly(item) && - item.timelineCursor?.epoch === range.epoch && - item.timelineCursor.seq >= range.startSeq && - item.timelineCursor.seq <= range.endSeq, - ) && - canonicalItems.some((item) => item.timelineCursor?.seq === range.endSeq); - return { + (item.kind === "user_message" && isUnreconciledLocalUserMessage(item)) || + ((item.kind !== "tool_call" || item.payload.source === "agent") && + item.source !== undefined && + item.timelineCursor?.epoch === range.epoch && + item.timelineCursor.seq >= range.startSeq && + item.timelineCursor.seq <= range.endSeq), + ) + ); +} + +function serializeTimelinePayload(timeline: CachedTimeline): string { + const items = timeline.items + .filter((item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item)) + .map(serializeTimelineItem) + .filter((item) => item !== null); + const range = timeline.range; + const certified = isCertifiedTimeline(timeline); + return JSON.stringify({ agentId: timeline.agentId, - items: items.slice(-MAX_TIMELINE_ITEMS), - range: canPersistCoverage - ? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq } - : null, - hasOlder: canPersistCoverage ? timeline.hasOlder : false, - }; + items, + range: + certified && range + ? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq } + : null, + hasOlder: certified && timeline.hasOlder, + } satisfies StoredTimeline); } function rowKey(key: Pick): string { @@ -820,12 +826,6 @@ interface DirectoryReadAccumulator { checkpoint?: DirectoryCheckpoint; } -interface PendingReplicaChanges { - upserts: StructuredReplicaUpsert[]; - deletes: ReplicaRowKey[]; - baselines: Set; -} - function applyDirectoryRow( serverId: string, row: ReplicaRow, @@ -868,22 +868,43 @@ function directoryEntityForRow(row: ReplicaRow): keyof DirectoryCheckpoint | und return undefined; } +const DIRECTORY_KINDS: readonly ReplicaRowKind[] = ["agent", "workspace", "project", "checkpoint"]; + +interface PendingRow { + key: ReplicaRowKey; + // Null is an accepted deletion. + upsert: StructuredReplicaUpsert | null; +} + +interface PendingBatch { + rows: Map; + baselines: Map; +} + +// Scoped disk reads are overlaid by accepted commits, including changes that settled while +// a read was in flight. The full disk index serves write-behind budget accounting only. export class ReplicaCache { - private readonly activeServerIds = new Set(); - private readonly hostRevisions = new Map(); + private activeServerIds = new Set(); private readonly storedRows = new Map>(); private readonly hostBytes = new Map(); private readonly hostWriteOrder = new Map(); - private pendingUpserts = new Map(); - private pendingDeletes = new Map(); - private pendingBaselines = new Set(); - private readonly invalidatedHosts = new Set(); - private readonly maxBytes: number; private totalBytes = 0; + private readonly pendingRows = new Map(); + // Accepted commits overlay any disk read, including reads started before a write settled. + private readonly acceptedRows = new Map(); + private readonly acceptedBaselines = new Set(); + private readonly hostSources = new Map>(); + private readonly pendingBaselines = new Map(); + private readonly invalidatedHosts = new Set(); + // Renames queued behind the initial load, so the load keeps the old host's rows for them. + private readonly renamingHosts = new Map(); + private readonly maxBytes: number; private persistTimer: ReturnType | null = null; - private writeQueue: Promise = Promise.resolve(); - private preparePromise: Promise | null = null; - private storedIndexPromise: Promise | null = null; + // Memory readiness: the initial load followed by every host identity change, in order. + private image: Promise | null = null; + // Disk operations run in order; the initial load is the first of them once it starts. + private disk: Promise = Promise.resolve(); + private writes: Promise = Promise.resolve(); constructor( private readonly rowStore: ReplicaRowStore, @@ -895,50 +916,8 @@ export class ReplicaCache { private readonly clearLegacyCache: () => Promise; - async readAgent(serverId: string, agentId: string): Promise { - const rows = await this.readRows(serverId, ["agent"], [agentId]); - const row = rows[0]; - if (!row) return undefined; - try { - const stored = parseStoredPayload(StoredAgentSchema, row.payload); - if (stored.snapshot.id !== row.id) throw new Error("Replica agent row id mismatch"); - return deserializeAgent(serverId, stored); - } catch { - await this.deleteInvalidRow(row); - return undefined; - } - } - - async readWorkspace(serverId: string, workspaceId: string): Promise { - const workspaceRow = (await this.readRows(serverId, ["workspace"], [workspaceId]))[0]; - if (!workspaceRow) return undefined; - let workspace: WorkspaceDescriptor; - try { - const stored = parseStoredPayload(StoredWorkspaceSchema, workspaceRow.payload); - if (stored.id !== workspaceRow.id) throw new Error("Replica workspace row id mismatch"); - workspace = normalizeWorkspaceDescriptor(stored); - } catch { - await this.deleteInvalidRow(workspaceRow); - return undefined; - } - - let project: ProjectDescriptor | undefined; - const projectRow = (await this.readRows(serverId, ["project"], [workspace.projectId]))[0]; - if (projectRow) { - try { - const stored = parseStoredPayload(StoredProjectSchema, projectRow.payload); - if (stored.projectId !== projectRow.id) throw new Error("Replica project row id mismatch"); - project = normalizeProjectDescriptor(stored); - } catch { - await this.deleteInvalidRow(projectRow); - } - } - - return { workspace, ...(project ? { project } : {}) }; - } - async readDirectory(serverId: string): Promise { - const rows = await this.readRows(serverId, ["agent", "workspace", "project", "checkpoint"]); + const rows = await this.readRows(serverId, DIRECTORY_KINDS); const result: DirectoryReadAccumulator = { agents: new Map(), workspaces: new Map(), @@ -947,6 +926,23 @@ export class ReplicaCache { const invalidEntities = new Set(); const invalidRows: ReplicaRow[] = []; for (const row of rows) { + if ("value" in row) { + switch (row.kind) { + case "agent": + result.agents.set(row.id, { ...row.value, serverId }); + break; + case "workspace": + result.workspaces.set(row.id, row.value); + break; + case "project": + result.projects.set(row.id, row.value); + break; + case "checkpoint": + result.checkpoint = row.value; + break; + } + continue; + } try { applyDirectoryRow(serverId, row, result); } catch { @@ -960,113 +956,100 @@ export class ReplicaCache { for (const entity of invalidEntities) delete result.checkpoint[entity]; } if (invalidRows.length > 0) { - await this.repairInvalidDirectoryRows(serverId, invalidRows, result.checkpoint); + this.repairInvalidDirectoryRows(serverId, invalidRows, result.checkpoint); } return result; } async readTimeline(serverId: string, agentId: string): Promise { - const rows = await this.readRows(serverId, ["timeline"], [agentId]); - const row = rows[0]; + const row = (await this.readRows(serverId, ["timeline"], [agentId]))[0]; if (!row) return undefined; - try { - const stored = parseStoredPayload(StoredTimelineSchema, row.payload); - if (stored.agentId !== agentId) return undefined; - return deserializeTimeline(stored) ?? undefined; - } catch { - await this.deleteInvalidRow(row); - return undefined; + let timeline: CachedTimeline; + if ("value" in row) { + if (row.kind !== "timeline") return undefined; + timeline = row.value; + } else { + try { + const stored = parseStoredPayload(StoredTimelineSchema, row.payload); + if (stored.agentId !== agentId) return undefined; + timeline = deserializeTimeline(stored); + } catch { + this.queueDelete(row); + this.schedulePersist(); + return undefined; + } } + const certified = isCertifiedTimeline(timeline); + return { + ...timeline, + items: timeline.items.filter( + (item) => + !(item.kind === "user_message" && isUnreconciledLocalUserMessage(item)) && + !(item.kind === "tool_call" && item.payload.source !== "agent"), + ), + range: certified ? timeline.range : null, + hasOlder: certified && timeline.hasOlder, + }; } private async readRows( serverId: string, kinds: readonly ReplicaRowKind[], ids?: readonly string[], - ): Promise { + ): Promise> { if (!this.activeServerIds.has(serverId)) return []; - try { - await this.prepareStore(); - while (this.activeServerIds.has(serverId)) { - await this.flush(); - const revision = this.hostRevisions.get(serverId) ?? 0; - const rows = await this.rowStore.read(serverId, kinds, ids); - if (this.canReadHostRevision(serverId, revision)) return rows; - } - return []; - } catch { - return []; - } - } - - private async deleteInvalidRow(row: ReplicaRow): Promise { - const invalidEntity = directoryEntityForRow(row); - if (!invalidEntity) { - const changes = { upserts: [], deletes: [row] } satisfies ReplicaRowChanges; - await this.queueOperation(async () => { - await this.rowStore.apply(changes); - this.applyStoredChanges(changes); - }); - return; - } - - const checkpointRow = ( - await this.readRows(row.serverId, ["checkpoint"], [REPLICA_SINGLETON_ROW_ID]) - )[0]; - let checkpoint: DirectoryCheckpoint | undefined; - if (checkpointRow) { + const sources = this.hostSources.get(serverId); + if (!sources) return []; + const sourceOrder = [...sources]; + const accepted = ids + ? kinds.every((kind) => + ids.every((id) => this.acceptedRows.has(pendingRowKey({ serverId, kind, id }))), + ) + : this.acceptedBaselines.has(serverId) && !kinds.includes("timeline"); + let stored: ReplicaRow[] = []; + if (!accepted) { try { - checkpoint = parseStoredPayload(DirectoryCheckpointSchema, checkpointRow.payload); - checkpoint = { ...checkpoint }; - delete checkpoint[invalidEntity]; + await this.rowStore.open(); + stored = await this.rowStore.read(sourceOrder, kinds, ids); } catch { - checkpoint = undefined; + // Accepted commits remain readable even if the disk is unavailable. } } - const changes: ReplicaRowChanges = { - deletes: [row, ...(checkpointRow && !checkpoint ? [checkpointRow] : [])], - upserts: - checkpointRow && checkpoint - ? [ - { - serverId: row.serverId, - kind: "checkpoint", - id: REPLICA_SINGLETON_ROW_ID, - payload: JSON.stringify(checkpoint), - }, - ] - : [], - }; - await this.queueOperation(async () => { - await this.rowStore.apply(changes); - this.applyStoredChanges(changes); - }); + if (this.hostSources.get(serverId) !== sources) return []; + const accepts = (key: ReplicaRowKey) => + key.serverId === serverId && kinds.includes(key.kind) && (!ids || ids.includes(key.id)); + const rows = new Map(); + const replacingBaseline = this.acceptedBaselines.has(serverId); + // Moved rows replace collisions at the destination, even before the disk rename commits. + stored.sort((a, b) => sourceOrder.indexOf(b.serverId) - sourceOrder.indexOf(a.serverId)); + for (const row of stored) { + if (replacingBaseline && row.kind !== "timeline") continue; + rows.set(rowKey(row), { ...row, serverId }); + } + for (const pending of this.acceptedRows.values()) { + if (!accepts(pending.key)) continue; + if (pending.upsert === null) rows.delete(rowKey(pending.key)); + else rows.set(rowKey(pending.key), pending.upsert); + } + return [...rows.values()]; } - private async repairInvalidDirectoryRows( + private repairInvalidDirectoryRows( serverId: string, invalidRows: ReplicaRow[], checkpoint: DirectoryCheckpoint | undefined, - ): Promise { + ): void { + for (const row of invalidRows) this.queueDelete(row); const checkpointWasInvalid = invalidRows.some((row) => row.kind === "checkpoint"); - const changes: ReplicaRowChanges = { - deletes: invalidRows, - upserts: - checkpoint !== undefined && !checkpointWasInvalid - ? [ - { - serverId, - kind: "checkpoint", - id: REPLICA_SINGLETON_ROW_ID, - payload: JSON.stringify(checkpoint), - }, - ] - : [], - }; - await this.queueOperation(async () => { - await this.rowStore.apply(changes); - this.applyStoredChanges(changes); - }); + if (checkpoint !== undefined && !checkpointWasInvalid) { + this.queueUpsert({ + serverId, + kind: "checkpoint", + id: REPLICA_SINGLETON_ROW_ID, + value: checkpoint, + }); + } + this.schedulePersist(); } commitDirectoryMutations( @@ -1076,10 +1059,9 @@ export class ReplicaCache { ): void { if (!this.activeServerIds.has(serverId)) return; if (this.invalidatedHosts.has(serverId)) return; - this.advanceHostRevision(serverId); for (const mutation of mutations) { if (mutation.type === "delete") { - this.queueEntityDelete(serverId, mutation.kind, mutation.id); + this.queueDelete({ serverId, kind: mutation.kind, id: mutation.id }); } else { this.queueUpsert({ serverId, @@ -1102,15 +1084,18 @@ export class ReplicaCache { replaceDirectoryBaseline(serverId: string, directory: CachedDirectory): void { if (!this.activeServerIds.has(serverId)) return; - this.advanceHostRevision(serverId); // Replacing the directory must not discard independently accepted timeline changes. - for (const [key, row] of this.pendingUpserts) { - if (row.serverId === serverId && row.kind !== "timeline") this.pendingUpserts.delete(key); + for (const [key, pending] of this.pendingRows) { + if (pending.key.serverId === serverId && pending.key.kind !== "timeline") { + this.pendingRows.delete(key); + } } - for (const [key, row] of this.pendingDeletes) { - if (row.serverId === serverId && row.kind !== "timeline") this.pendingDeletes.delete(key); + this.pendingBaselines.set(serverId, Symbol("baseline")); + this.acceptedBaselines.add(serverId); + for (const [key, row] of this.acceptedRows) { + if (row.key.serverId === serverId && row.key.kind !== "timeline") + this.acceptedRows.delete(key); } - this.pendingBaselines.add(serverId); for (const [id, value] of directory.agents) { this.queueUpsert({ serverId, kind: "agent", id, value }); } @@ -1131,10 +1116,23 @@ export class ReplicaCache { this.schedulePersist(); } - commitTimeline(serverId: string, agentId: string, timeline: CachedTimeline): void { + removeTimeline(serverId: string, agentId: string): void { + if (!this.activeServerIds.has(serverId)) return; + this.queueDelete({ serverId, kind: "timeline", id: agentId }); + this.schedulePersist(); + } + + commitTimeline( + serverId: string, + agentId: string, + timeline: CachedTimeline, + requireCertified = false, + ): void { if (!this.activeServerIds.has(serverId)) return; if (timeline.agentId !== agentId) throw new Error("Timeline cache key does not match payload"); - this.advanceHostRevision(serverId); + // The timeline authority can retain a good restart snapshot while its richer accepted + // page cannot be represented with truthful coverage. + if (requireCertified && !isCertifiedTimeline(timeline)) return; this.queueUpsert({ serverId, kind: "timeline", id: agentId, value: timeline }); this.schedulePersist(); } @@ -1142,148 +1140,193 @@ export class ReplicaCache { setHosts(serverIds: Iterable): void { const next = new Set(serverIds); const removed = [...this.activeServerIds].filter((serverId) => !next.has(serverId)); - const added = [...next].filter((serverId) => !this.activeServerIds.has(serverId)); - for (const serverId of [...removed, ...added]) this.advanceHostRevision(serverId); - this.activeServerIds.clear(); - for (const serverId of next) this.activeServerIds.add(serverId); + this.activeServerIds = next; + for (const serverId of next) { + if (!this.hostSources.has(serverId)) this.hostSources.set(serverId, new Set([serverId])); + } for (const serverId of removed) { - this.removeStoredHost(serverId); + this.dropAcceptedHost(serverId); this.dropPendingHostChanges(serverId); this.invalidatedHosts.delete(serverId); - this.queueOperation(() => this.rowStore.deleteHost(serverId)); + this.applyToImage(() => this.removeStoredHost(serverId)); + void this.deleteStoredSource(serverId); } } reconcileServerId(oldServerId: string, newServerId: string): void { - this.advanceHostRevision(oldServerId); - this.advanceHostRevision(newServerId); - const rows = this.storedRows.get(oldServerId); - if (rows) { - const newRows = this.storedRows.get(newServerId) ?? new Map(); - this.totalBytes -= - (this.hostBytes.get(oldServerId) ?? 0) + (this.hostBytes.get(newServerId) ?? 0); - this.storedRows.delete(oldServerId); - for (const [key, row] of rows) newRows.set(key, { ...row, serverId: newServerId }); - this.storedRows.set(newServerId, newRows); - const bytes = [...newRows.values()].reduce((sum, row) => sum + rowBytes(row), 0); - this.hostBytes.delete(oldServerId); - this.hostBytes.set(newServerId, bytes); - this.totalBytes += bytes; - this.hostWriteOrder.delete(oldServerId); - this.touchHost(newServerId); - } + if (oldServerId === newServerId) return; this.renamePendingHostChanges(oldServerId, newServerId); + const sources = this.hostSources.get(oldServerId) ?? new Set(); + const retiredSources = new Set(); + this.hostSources.set(oldServerId, retiredSources); + this.hostSources.set(newServerId, new Set([...sources, newServerId])); + if (this.acceptedBaselines.delete(oldServerId)) this.acceptedBaselines.add(newServerId); + for (const [key, row] of this.acceptedRows) { + if (row.key.serverId !== oldServerId) continue; + this.acceptedRows.delete(key); + const renamed = { + key: { ...row.key, serverId: newServerId }, + upsert: row.upsert ? { ...row.upsert, serverId: newServerId } : null, + }; + this.acceptedRows.set(pendingRowKey(renamed.key), renamed); + } if (this.activeServerIds.delete(oldServerId)) this.activeServerIds.add(newServerId); - this.queueOperation(() => this.rowStore.renameHost(oldServerId, newServerId)); + this.renamingHosts.set(oldServerId, newServerId); + this.applyToImage(() => { + this.renamingHosts.delete(oldServerId); + this.renameStoredHost(oldServerId, newServerId); + }); + void this.queueDisk(async () => { + await this.rowStore.renameHost(oldServerId, newServerId); + for (const aliases of this.hostSources.values()) aliases.delete(oldServerId); + if (this.hostSources.get(oldServerId) === retiredSources) retiredSources.add(oldServerId); + }); } async flush(): Promise { await this.persist(); - await this.writeQueue.catch(() => undefined); + await this.disk; } - private async flushPending(): Promise { - await this.persist(); + private ready(): Promise { + this.image ??= this.queueDisk(() => this.loadStoredRows()).then( + () => true, + () => { + this.image = null; + return false; + }, + ); + return this.image; } - private async persist(): Promise { + private async loadStoredRows(): Promise { + // COMPAT(replica-blob-cache): remove after 2026-11 + await this.clearLegacyCache().catch(() => undefined); + const hosts = await this.rowStore.readAll(); + for (const host of hosts) { + if (!this.activeServerIds.has(host.serverId) && !this.renamingHosts.has(host.serverId)) { + await this.rowStore.deleteHost(host.serverId); + } + } + // Publish only after all fallible work succeeds; retries must not count partial loads twice. + for (const host of hosts) { + if (!this.activeServerIds.has(host.serverId) && !this.renamingHosts.has(host.serverId)) { + continue; + } + const rows = new Map(host.rows.map((row) => [rowKey(row), row])); + const bytes = host.rows.reduce((sum, row) => sum + rowBytes(row), 0); + this.storedRows.set(host.serverId, rows); + this.hostBytes.set(host.serverId, bytes); + this.totalBytes += bytes; + this.touchHost(host.serverId); + } + } + + // Keep budget accounting in order with its initial load and host identity changes. + private applyToImage(mutation: () => void): void { + if (this.image === null) { + mutation(); + return; + } + this.image = this.image.then((loaded) => { + // Identity changes still settle when loading fails; the next attempt reads their disk result. + mutation(); + return loaded; + }); + } + + private queueDisk(operation: () => Promise): Promise { + const run = this.disk.then(async () => { + await this.rowStore.open(); + await operation(); + return undefined; + }); + this.disk = run.catch(() => undefined); + return run; + } + + private persist(): Promise { if (this.persistTimer) { clearTimeout(this.persistTimer); this.persistTimer = null; } + const write = this.writes.then(() => this.writePendingChanges()); + this.writes = write.catch(() => undefined); + return write; + } + + private async writePendingChanges(): Promise { if (!this.hasPendingChanges()) return; - const write = this.writeQueue - .catch(() => undefined) - .then(async () => { - const pending = this.drainPendingChanges(); - try { - await this.prepareStore(); - await this.ensureStoredIndex(); - const changes = this.materializePendingChanges(pending); - const { changes: boundedChanges, evicted } = await this.fitChangesToBudget(changes); - if (boundedChanges.upserts.length > 0 || boundedChanges.deletes.length > 0) { - await this.rowStore.apply(boundedChanges); - this.applyStoredChanges(boundedChanges); - } - for (const serverId of pending.baselines) { - if (!evicted.has(serverId)) this.invalidatedHosts.delete(serverId); - } - } catch { - this.restorePendingChanges(pending); - if (this.hasPendingChanges()) this.schedulePersist(); - } - return undefined; - }); - this.writeQueue = write; - await write; + if (!(await this.ready())) { + this.schedulePersist(); + return; + } + const batch: PendingBatch = { + rows: new Map(this.pendingRows), + baselines: new Map(this.pendingBaselines), + }; + const changes = this.materializeBatch(batch); + const { changes: boundedChanges, evicted } = await this.fitChangesToBudget(changes); + if (boundedChanges.upserts.length > 0 || boundedChanges.deletes.length > 0) { + try { + await this.queueDisk(() => this.rowStore.apply(boundedChanges)); + } catch { + this.schedulePersist(); + return; + } + } + // The write commits to the mirror only what is still pending unchanged. A host removed or + // renamed meanwhile already dropped or re-keyed its pending rows, and its disk delete or + // rename is queued behind this write, so the rows just written must not reappear in memory. + // A row replaced meanwhile stays pending and is written again. + const settled = (key: ReplicaRowKey) => { + const pending = batch.rows.get(pendingRowKey(key)); + return pending !== undefined && this.pendingRows.get(pendingRowKey(key)) === pending; + }; + const baselineSettled = (serverId: string) => + this.pendingBaselines.get(serverId) === batch.baselines.get(serverId); + this.applyStoredChanges({ + upserts: boundedChanges.upserts.filter(settled), + deletes: boundedChanges.deletes.filter( + (key) => + settled(key) || (!batch.rows.has(pendingRowKey(key)) && baselineSettled(key.serverId)), + ), + }); + for (const [key, pending] of batch.rows) { + if (this.pendingRows.get(key) === pending) this.pendingRows.delete(key); + } + for (const [serverId, token] of batch.baselines) { + if (this.pendingBaselines.get(serverId) !== token) continue; + this.pendingBaselines.delete(serverId); + if (!evicted.has(serverId)) this.invalidatedHosts.delete(serverId); + } } - private queueEntityDelete(serverId: string, kind: ReplicaRowKind, id: string): void { - this.queueDelete({ serverId, kind, id }); - } - - private queueUpsert(row: StructuredReplicaUpsert): void { - const key = pendingRowKey(row); - this.pendingDeletes.delete(key); - this.pendingUpserts.set(key, row); + private queueUpsert(upsert: StructuredReplicaUpsert): void { + const row = { key: upsert, upsert }; + this.pendingRows.set(pendingRowKey(upsert), row); + this.acceptedRows.set(pendingRowKey(upsert), row); } private queueDelete(key: ReplicaRowKey): void { - const pendingKey = pendingRowKey(key); - this.pendingUpserts.delete(pendingKey); - this.pendingDeletes.set(pendingKey, key); + const { serverId, kind, id } = key; + const row = { key: { serverId, kind, id }, upsert: null }; + this.pendingRows.set(pendingRowKey(key), row); + this.acceptedRows.set(pendingRowKey(key), row); } private hasPendingChanges(): boolean { - return ( - this.pendingUpserts.size > 0 || this.pendingDeletes.size > 0 || this.pendingBaselines.size > 0 - ); + return this.pendingRows.size > 0 || this.pendingBaselines.size > 0; } - private canReadHostRevision(serverId: string, revision: number): boolean { - return ( - this.activeServerIds.has(serverId) && - (this.hostRevisions.get(serverId) ?? 0) === revision && - !this.hasPendingHostChanges(serverId) - ); - } - - private hasPendingHostChanges(serverId: string): boolean { - if (this.pendingBaselines.has(serverId)) return true; - for (const row of this.pendingUpserts.values()) { - if (row.serverId === serverId) return true; - } - for (const row of this.pendingDeletes.values()) { - if (row.serverId === serverId) return true; - } - return false; - } - - private advanceHostRevision(serverId: string): void { - this.hostRevisions.set(serverId, (this.hostRevisions.get(serverId) ?? 0) + 1); - } - - private drainPendingChanges(): PendingReplicaChanges { - const changes = { - upserts: [...this.pendingUpserts.values()], - deletes: [...this.pendingDeletes.values()], - baselines: this.pendingBaselines, - }; - this.pendingUpserts = new Map(); - this.pendingDeletes = new Map(); - this.pendingBaselines = new Set(); - return changes; - } - - private materializePendingChanges(pending: PendingReplicaChanges): ReplicaRowChanges { - const deletes = new Map(pending.deletes.map((row) => [pendingRowKey(row), row])); + private materializeBatch(batch: PendingBatch): ReplicaRowChanges { + const deletes = new Map(); const upserts: ReplicaRow[] = []; - for (const upsert of pending.upserts) { - const row = this.materializeUpsert(upsert); - if (row) upserts.push(row); - else deletes.set(pendingRowKey(upsert), upsert); + for (const pending of batch.rows.values()) { + if (pending.upsert === null) deletes.set(pendingRowKey(pending.key), pending.key); + else upserts.push(materializeUpsert(pending.upsert)); } - for (const serverId of pending.baselines) { + for (const serverId of batch.baselines.keys()) { const replacementKeys = new Set( upserts.filter((row) => row.serverId === serverId && row.kind !== "timeline").map(rowKey), ); @@ -1296,60 +1339,6 @@ export class ReplicaCache { return { upserts, deletes: [...deletes.values()] }; } - private materializeUpsert(upsert: StructuredReplicaUpsert): ReplicaRow | null { - let value: unknown; - switch (upsert.kind) { - case "agent": - value = serializeAgent(upsert.value); - break; - case "workspace": - value = serializeWorkspace(upsert.value); - break; - case "project": - value = serializeProject(upsert.value); - break; - case "timeline": - value = serializeTimeline(upsert.value); - if (!value) return null; - break; - case "checkpoint": - value = upsert.value; - break; - } - return { - serverId: upsert.serverId, - kind: upsert.kind, - id: upsert.id, - payload: JSON.stringify(value), - }; - } - - private restorePendingChanges(changes: PendingReplicaChanges): void { - for (const serverId of changes.baselines) { - if (this.activeServerIds.has(serverId)) this.pendingBaselines.add(serverId); - } - for (const key of changes.deletes) { - const pendingKey = pendingRowKey(key); - if ( - this.activeServerIds.has(key.serverId) && - !this.pendingUpserts.has(pendingKey) && - !this.pendingDeletes.has(pendingKey) - ) { - this.queueDelete(key); - } - } - for (const row of changes.upserts) { - const pendingKey = pendingRowKey(row); - if ( - this.activeServerIds.has(row.serverId) && - !this.pendingUpserts.has(pendingKey) && - !this.pendingDeletes.has(pendingKey) - ) { - this.queueUpsert(row); - } - } - } - private applyStoredChanges(changes: ReplicaRowChanges): void { const touchedServerIds = new Set(); for (const key of changes.deletes) { @@ -1423,12 +1412,13 @@ export class ReplicaCache { while (projectedTotal > this.maxBytes) { const serverId = writeOrder.shift(); if (serverId === undefined) break; - const bytes = projectedBytes.get(serverId) ?? 0; - projectedTotal -= bytes; + projectedTotal -= projectedBytes.get(serverId) ?? 0; projectedBytes.delete(serverId); evicted.add(serverId); this.invalidatedHosts.add(serverId); - if (this.storedRows.has(serverId)) await this.rowStore.deleteHost(serverId); + this.dropPendingHostChanges(serverId); + this.dropAcceptedHost(serverId); + await this.deleteStoredSource(serverId).catch(() => undefined); this.removeStoredHost(serverId); } @@ -1441,6 +1431,22 @@ export class ReplicaCache { }; } + private dropAcceptedHost(serverId: string): void { + this.hostSources.set(serverId, new Set()); + this.acceptedBaselines.delete(serverId); + for (const [key, row] of this.acceptedRows) { + if (row.key.serverId === serverId) this.acceptedRows.delete(key); + } + } + + private deleteStoredSource(serverId: string): Promise { + const sources = this.hostSources.get(serverId); + return this.queueDisk(async () => { + await this.rowStore.deleteHost(serverId); + if (this.hostSources.get(serverId) === sources) sources?.add(serverId); + }); + } + private removeStoredHost(serverId: string): void { this.totalBytes -= this.hostBytes.get(serverId) ?? 0; this.storedRows.delete(serverId); @@ -1448,79 +1454,74 @@ export class ReplicaCache { this.hostWriteOrder.delete(serverId); } + private renameStoredHost(oldServerId: string, newServerId: string): void { + const rows = this.storedRows.get(oldServerId); + if (!rows) return; + const newRows = this.storedRows.get(newServerId) ?? new Map(); + this.removeStoredHost(oldServerId); + this.removeStoredHost(newServerId); + for (const [key, row] of rows) newRows.set(key, { ...row, serverId: newServerId }); + this.storedRows.set(newServerId, newRows); + const bytes = [...newRows.values()].reduce((sum, row) => sum + rowBytes(row), 0); + this.hostBytes.set(newServerId, bytes); + this.totalBytes += bytes; + this.touchHost(newServerId); + } + private dropPendingHostChanges(serverId: string): void { this.pendingBaselines.delete(serverId); - for (const [key, row] of this.pendingUpserts) { - if (row.serverId === serverId) this.pendingUpserts.delete(key); - } - for (const [key, row] of this.pendingDeletes) { - if (row.serverId === serverId) this.pendingDeletes.delete(key); + for (const [key, pending] of this.pendingRows) { + if (pending.key.serverId === serverId) this.pendingRows.delete(key); } } private renamePendingHostChanges(oldServerId: string, newServerId: string): void { - const changes = this.drainPendingChanges(); - for (const key of changes.deletes) { - this.queueDelete(key.serverId === oldServerId ? { ...key, serverId: newServerId } : key); + const renamed = [...this.pendingRows.values()].filter( + (pending) => pending.key.serverId === oldServerId, + ); + for (const pending of renamed) { + this.pendingRows.delete(pendingRowKey(pending.key)); + const key = { ...pending.key, serverId: newServerId }; + this.pendingRows.set(pendingRowKey(key), { + key, + upsert: pending.upsert ? { ...pending.upsert, serverId: newServerId } : null, + }); } - for (const row of changes.upserts) { - this.queueUpsert(row.serverId === oldServerId ? { ...row, serverId: newServerId } : row); - } - for (const serverId of changes.baselines) { - this.pendingBaselines.add(serverId === oldServerId ? newServerId : serverId); + const baseline = this.pendingBaselines.get(oldServerId); + if (baseline) { + this.pendingBaselines.delete(oldServerId); + this.pendingBaselines.set(newServerId, baseline); } if (this.invalidatedHosts.delete(oldServerId)) this.invalidatedHosts.add(newServerId); } - private prepareStore(): Promise { - this.preparePromise ??= (async () => { - await this.rowStore.open(); - // COMPAT(replica-blob-cache): remove after 2026-11 - await this.clearLegacyCache().catch(() => undefined); - })(); - return this.preparePromise; - } - - private ensureStoredIndex(): Promise { - this.storedIndexPromise ??= this.rowStore.readAll().then(async (hosts) => { - this.storedRows.clear(); - this.hostBytes.clear(); - this.hostWriteOrder.clear(); - this.totalBytes = 0; - for (const host of hosts) { - if (!this.activeServerIds.has(host.serverId)) { - await this.rowStore.deleteHost(host.serverId); - continue; - } - const rows = new Map(host.rows.map((row) => [rowKey(row), row])); - const bytes = host.rows.reduce((sum, row) => sum + rowBytes(row), 0); - this.storedRows.set(host.serverId, rows); - this.hostBytes.set(host.serverId, bytes); - this.totalBytes += bytes; - this.touchHost(host.serverId); - } - return undefined; - }); - return this.storedIndexPromise; - } - - private queueOperation(operation: () => Promise): Promise { - this.writeQueue = this.writeQueue - .catch(() => undefined) - .then(async () => { - await this.prepareStore(); - await operation(); - return undefined; - }) - .catch(() => undefined); - return this.writeQueue; - } - private schedulePersist(): void { if (this.persistTimer) return; this.persistTimer = setTimeout(() => { this.persistTimer = null; - void this.flushPending(); + void this.persist(); }, PERSIST_DELAY_MS); } } + +function materializeUpsert(upsert: StructuredReplicaUpsert): ReplicaRow { + let payload: string; + switch (upsert.kind) { + case "agent": + payload = JSON.stringify(serializeAgent(upsert.value)); + break; + case "workspace": + payload = JSON.stringify(serializeWorkspace(upsert.value)); + break; + case "project": + payload = JSON.stringify(serializeProject(upsert.value)); + break; + case "timeline": + payload = serializeTimelinePayload(upsert.value); + break; + case "checkpoint": + payload = JSON.stringify(upsert.value); + break; + } + return { serverId: upsert.serverId, kind: upsert.kind, id: upsert.id, payload }; +} diff --git a/packages/app/src/runtime/replica-cache/row-store-sqlite.ts b/packages/app/src/runtime/replica-cache/row-store-sqlite.ts index daa8ee28ef..e7de78ad28 100644 --- a/packages/app/src/runtime/replica-cache/row-store-sqlite.ts +++ b/packages/app/src/runtime/replica-cache/row-store-sqlite.ts @@ -108,20 +108,17 @@ export function createSqliteReplicaRowStore( } async function read( - serverId: string, + serverIds: readonly string[], kinds: readonly ReplicaRow["kind"][], ids?: readonly string[], ): Promise { - if (kinds.length === 0 || ids?.length === 0) return []; - const placeholders = kinds.map(() => "?").join(", "); - const idClause = ids ? ` AND id IN (${ids.map(() => "?").join(", ")})` : ""; - const storedRows = await getConnection().all( - `SELECT server_id, kind, id, payload FROM rows - WHERE server_id = ? AND kind IN (${placeholders})${idClause} - ORDER BY kind, id`, - [serverId, ...kinds, ...(ids ?? [])], + if (!serverIds.length || !kinds.length || ids?.length === 0) return []; + const placeholders = (values: readonly string[]) => values.map(() => "?").join(","); + const rows = await getConnection().all( + `SELECT server_id, kind, id, payload FROM rows WHERE server_id IN (${placeholders(serverIds)}) AND kind IN (${placeholders(kinds)})${ids ? ` AND id IN (${placeholders(ids)})` : ""} ORDER BY server_id, kind, id`, + [...serverIds, ...kinds, ...(ids ?? [])], ); - return storedRows.map((row) => ({ + return rows.map((row) => ({ serverId: row.server_id, kind: row.kind, id: row.id, diff --git a/packages/app/src/runtime/replica-cache/row-store.contract.ts b/packages/app/src/runtime/replica-cache/row-store.contract.ts index 2e267e812d..ba6848264c 100644 --- a/packages/app/src/runtime/replica-cache/row-store.contract.ts +++ b/packages/app/src/runtime/replica-cache/row-store.contract.ts @@ -55,20 +55,23 @@ export function runReplicaRowStoreContract( ]); }); - it("reads only requested kinds for one host", async () => { + it("reads only requested hosts, kinds and identities", async () => { + const selected = row("server-a", "timeline", "wanted", "page"); await store.apply({ - upserts: [ - row("server-a", "agent", "agent-1", "agent"), - row("server-a", "timeline", "singleton", "timeline"), - row("server-b", "agent", "agent-2", "other host"), - ], deletes: [], + upserts: [ + selected, + row("server-a", "timeline", "other", "unrelated timeline"), + row("server-a", "agent", "wanted", "directory"), + row("server-b", "timeline", "wanted", "other host"), + ], }); - - expect(await store.read("server-a", ["timeline"])).toEqual([ - row("server-a", "timeline", "singleton", "timeline"), + expect(await store.read(["server-a"], ["timeline"], ["wanted"])).toEqual([selected]); + expect(await store.read(["server-a"], ["agent"])).toEqual([ + row("server-a", "agent", "wanted", "directory"), ]); - expect(await store.read("server-a", ["agent"], ["missing"])).toEqual([]); + expect(await store.read([], ["timeline"])).toEqual([]); + expect(await store.read(["server-a"], ["timeline"], [])).toEqual([]); }); it("overwrites an existing row on upsert", async () => { diff --git a/packages/app/src/runtime/replica-cache/row-store.ts b/packages/app/src/runtime/replica-cache/row-store.ts index b9cd02d99c..77b023cc18 100644 --- a/packages/app/src/runtime/replica-cache/row-store.ts +++ b/packages/app/src/runtime/replica-cache/row-store.ts @@ -25,7 +25,7 @@ export interface ReplicaHostRows { export interface ReplicaRowStore { open(): Promise; read( - serverId: string, + serverIds: readonly string[], kinds: readonly ReplicaRowKind[], ids?: readonly string[], ): Promise; diff --git a/packages/app/src/runtime/replica-cache/row-store.web.test.ts b/packages/app/src/runtime/replica-cache/row-store.web.test.ts index 472143ee34..1630fb8e01 100644 --- a/packages/app/src/runtime/replica-cache/row-store.web.test.ts +++ b/packages/app/src/runtime/replica-cache/row-store.web.test.ts @@ -24,28 +24,41 @@ runReplicaRowStoreContract("IndexedDB", async () => { }; }); -it("uses exact IndexedDB keys for targeted rows instead of scanning the host", async () => { - const store = createIndexedDbReplicaRowStore({ +it("reads requested IndexedDB keys without scanning unrelated rows", async () => { + const options = { databaseName: `replica-row-store-targeted-${databaseSequence++}`, schemaVersion: 1, - }); - await store.open(); - await store.apply({ + }; + const writer = createIndexedDbReplicaRowStore(options); + await writer.open(); + await writer.apply({ upserts: [ - { serverId: "server-a", kind: "agent", id: "agent-1", payload: "agent" }, - { serverId: "server-a", kind: "workspace", id: "workspace-1", payload: "workspace" }, + { serverId: "host-a", kind: "timeline", id: "agent", payload: "a" }, + { serverId: "host-b", kind: "timeline", id: "agent", payload: "b" }, + { serverId: "host-a", kind: "timeline", id: "unrelated", payload: "large history" }, + { serverId: "host-a", kind: "agent", id: "agent", payload: "metadata" }, ], deletes: [], }); + const reader = createIndexedDbReplicaRowStore(options); + await reader.open(); const get = vi.spyOn(FakeIDBObjectStore.prototype, "get"); const getAll = vi.spyOn(FakeIDBObjectStore.prototype, "getAll"); - - expect(await store.read("server-a", ["agent"], ["agent-1"])).toEqual([ - { serverId: "server-a", kind: "agent", id: "agent-1", payload: "agent" }, - ]); - expect(get).toHaveBeenCalledWith(["server-a", "agent", "agent-1"]); - expect(getAll).not.toHaveBeenCalled(); - - get.mockRestore(); - getAll.mockRestore(); + const openCursor = vi.spyOn(FakeIDBObjectStore.prototype, "openCursor"); + try { + expect(await reader.read(["host-a", "host-b"], ["timeline"], ["agent"])).toEqual([ + { serverId: "host-a", kind: "timeline", id: "agent", payload: "a" }, + { serverId: "host-b", kind: "timeline", id: "agent", payload: "b" }, + ]); + expect(get.mock.calls).toEqual([ + [["host-a", "timeline", "agent"]], + [["host-b", "timeline", "agent"]], + ]); + expect(getAll).not.toHaveBeenCalled(); + expect(openCursor).not.toHaveBeenCalled(); + } finally { + get.mockRestore(); + getAll.mockRestore(); + openCursor.mockRestore(); + } }); diff --git a/packages/app/src/runtime/replica-cache/row-store.web.ts b/packages/app/src/runtime/replica-cache/row-store.web.ts index 12f484bd91..8b843fc8b6 100644 --- a/packages/app/src/runtime/replica-cache/row-store.web.ts +++ b/packages/app/src/runtime/replica-cache/row-store.web.ts @@ -136,38 +136,6 @@ export function createIndexedDbReplicaRowStore( return Array.from(hosts, ([serverId, hostRows]) => ({ serverId, rows: hostRows })); } - async function read( - serverId: string, - kinds: readonly ReplicaRow["kind"][], - ids?: readonly string[], - ): Promise { - if (kinds.length === 0 || ids?.length === 0) return []; - const transaction = getDatabase().transaction(ROWS_STORE, "readonly"); - const completion = transactionComplete(transaction); - const store = transaction.objectStore(ROWS_STORE); - const requests = ids - ? kinds.flatMap((kind) => - ids.map(async (id) => { - const row = await requestResult( - store.get([serverId, kind, id]), - ); - return row ? [row] : []; - }), - ) - : kinds.map((kind) => - requestResult( - store.getAll(IDBKeyRange.bound([serverId, kind], [serverId, kind, []])), - ), - ); - const rows = (await Promise.all(requests)).flat(); - await completion; - return rows.sort((left, right) => - left.kind === right.kind - ? left.id.localeCompare(right.id) - : left.kind.localeCompare(right.kind), - ); - } - async function apply(changes: ReplicaRowChanges): Promise { await runTransaction(getDatabase(), ROWS_STORE, async (transaction) => { const rows = transaction.objectStore(ROWS_STORE); @@ -180,6 +148,39 @@ export function createIndexedDbReplicaRowStore( }); } + async function read( + serverIds: readonly string[], + kinds: readonly ReplicaRow["kind"][], + ids?: readonly string[], + ): Promise { + const transaction = getDatabase().transaction(ROWS_STORE, "readonly"); + const completion = transactionComplete(transaction); + const store = transaction.objectStore(ROWS_STORE); + const requests: Promise[] = []; + for (const serverId of serverIds) { + for (const kind of kinds) { + if (ids) { + for (const id of ids) { + requests.push( + requestResult(store.get([serverId, kind, id])).then((row) => + row ? [row] : [], + ), + ); + } + } else { + requests.push( + requestResult( + store.getAll(IDBKeyRange.bound([serverId, kind], [serverId, kind, []])), + ), + ); + } + } + } + const rows = (await Promise.all(requests)).flat(); + await completion; + return rows; + } + async function deleteHost(serverId: string): Promise { await runTransaction(getDatabase(), ROWS_STORE, async (transaction) => { const rows = transaction.objectStore(ROWS_STORE); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index a27a1ed418..4f599c3d84 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -61,6 +61,7 @@ import { openWorkspacePullRequest } from "@/workspace-tabs/open-supporting-view" import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; import { traceInstant } from "@/performance/native-trace"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; +import type { ViewedTimelineDemandDeclaration } from "@/timeline/viewed-timeline-sync"; import { canDismissPaneInLayout, collectAllTabs, @@ -1546,12 +1547,6 @@ function WorkspaceScreenContent({ [workspaceId], ); const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId); - useEffect(() => { - if (!normalizedServerId || !normalizedWorkspaceId || workspaceDescriptor) return; - void getHostRuntimeStore() - .prepareWorkspaceRoute(normalizedServerId, normalizedWorkspaceId) - .catch(() => undefined); - }, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor]); const workspaceScripts = getWorkspaceScripts(workspaceDescriptor); const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } = useWorkspaceRouteActions(normalizedServerId); @@ -1914,25 +1909,23 @@ function WorkspaceScreenContent({ routeFocused: isRouteFocused, focusedPaneOnly: syncFocusedPaneOnly, }); - useEffect(() => { - for (const agentId of visibleAgentIds) { - void getHostRuntimeStore() - .prepareAgentTimeline(normalizedServerId, agentId) - .catch(() => undefined); - } - }, [normalizedServerId, visibleAgentIds]); + const visibleTimelineDeclarationRef = useRef(null); useLayoutEffect(() => { if (!persistenceKey || !viewedTimelineSync) { return; } - viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, visibleAgentIds); - }, [persistenceKey, viewedTimelineSync, visibleAgentIds]); - useEffect(() => { - if (!persistenceKey || !viewedTimelineSync) { - return; - } - return () => viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, []); + const declaration = viewedTimelineSync.registerVisibleAgentIds(persistenceKey, []); + visibleTimelineDeclarationRef.current = declaration; + return () => { + if (visibleTimelineDeclarationRef.current === declaration) { + visibleTimelineDeclarationRef.current = null; + } + declaration.dispose(); + }; }, [persistenceKey, viewedTimelineSync]); + useLayoutEffect(() => { + visibleTimelineDeclarationRef.current?.replace(visibleAgentIds); + }, [persistenceKey, viewedTimelineSync, visibleAgentIds]); const setFocusedAgentId = useSessionStore((state) => state.setFocusedAgentId); const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId); const focusedPaneAgentId = useMemo(() => { diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index c53eae4c49..87c50a9155 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -1,3 +1,4 @@ +import { createTimelineReplica, type TimelineReplica } from "@/timeline/replica"; import { afterEach, describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -85,8 +86,37 @@ afterEach(() => { useSessionStore.getState().clearSession("test-server"); }); +let timelineReplica: TimelineReplica; + function initializeTestSession(): void { useSessionStore.getState().initializeSession("test-server", null as unknown as DaemonClient); + timelineReplica = createTimelineReplica({ + serverId: "test-server", + storage: { + removeTimeline: () => undefined, + readTimeline: async () => undefined, + commitTimeline: () => undefined, + }, + }); +} + +function acknowledgeSubmission(agentId: string, clientMessageId: string): void { + timelineReplica.applyEvents( + agentId, + [ + { + seq: undefined, + epoch: undefined, + timestamp: new Date("2026-07-31T10:00:01.000Z"), + event: { + type: "timeline", + provider: "claude", + item: { type: "user_message", text: clientMessageId, messageId: clientMessageId }, + }, + }, + ], + () => undefined, + ); } function getTestSessionReferences() { @@ -141,16 +171,38 @@ describe("agent task state", () => { ); const tasks = [{ id: "1", text: "Inspect", status: "pending" as const, completed: false }]; - useSessionStore - .getState() - .setAgentStreamState("test-server", "agent-1", { taskSnapshot: tasks }); + const applyTasks = (items: typeof tasks) => + timelineReplica.applyEvents( + "agent-1", + [ + { + seq: undefined, + epoch: undefined, + timestamp: new Date("2026-08-11T10:00:00.000Z"), + event: { type: "timeline", provider: "claude", item: { type: "todo", items } }, + }, + ], + () => undefined, + ); + applyTasks(tasks); useSessionStore.getState().setIsPlayingAudio("test-server", true); - useSessionStore - .getState() - .setAgentStreamState("test-server", "agent-1", { taskSnapshot: [...tasks] }); - useSessionStore.getState().setAgentStreamState("test-server", "agent-1", { - taskSnapshot: [{ ...tasks[0], status: "completed", completed: true }], - }); + applyTasks([...tasks]); + timelineReplica.applyEvents( + "agent-1", + [ + { + seq: undefined, + epoch: undefined, + timestamp: new Date("2026-08-11T10:00:01.000Z"), + event: { + type: "timeline", + provider: "claude", + item: { type: "todo", items: [{ ...tasks[0], status: "completed", completed: true }] }, + }, + }, + ], + () => undefined, + ); unsubscribe(); expect(snapshots).toEqual([["Inspect"], ["Inspect"]]); @@ -174,7 +226,7 @@ describe("agent task state", () => { older: "none", newer: false, synchronized: true, - acknowledgedClientMessageIds: [], + submissions: [], }); expect(useSessionStore.getState().sessions["test-server"]?.agentTasks.get("agent-1")).toEqual( @@ -184,6 +236,107 @@ describe("agent task state", () => { }); describe("agent timeline state", () => { + it("preserves unchanged projection maps and only rescans tasks when rows change", () => { + initializeTestSession(); + const store = useSessionStore.getState(); + let scans = 0; + const item: StreamItem = { + get kind() { + scans++; + return "assistant_message" as const; + }, + id: "row", + text: "text", + timestamp: new Date(0), + }; + const input = { + items: [item], + head: [], + range: { epoch: "epoch", startSeq: 1, endSeq: 1 }, + older: "available" as const, + newer: false, + synchronized: true, + submissions: [], + }; + store.applyAgentTimelineResponseState("test-server", "agent-1", input); + const before = useSessionStore.getState(); + const session = before.sessions["test-server"]!; + const scanned = scans; + let notices = 0; + const unsubscribe = useSessionStore.subscribe(() => notices++); + store.applyAgentTimelineResponseState("test-server", "agent-1", { + ...input, + head: [], + range: { ...input.range }, + submissions: [], + }); + expect(useSessionStore.getState()).toBe(before); + expect(notices).toBe(0); + store.applyAgentTimelineResponseState("test-server", "agent-1", { ...input, newer: true }); + const changed = useSessionStore.getState().sessions["test-server"]!; + expect(changed.agentTimelineHasNewer).not.toBe(session.agentTimelineHasNewer); + for (const key of [ + "agentStreamTail", + "agentStreamHead", + "agentTimelineCursor", + "agentTimelineHasOlder", + "agentAuthoritativeHistoryApplied", + "agentHistorySyncGeneration", + "messageSubmissions", + "agentTasks", + ] as const) + expect(changed[key]).toBe(session[key]); + expect(notices).toBe(1); + expect(scans).toBe(scanned); + store.bumpHistorySyncGeneration("test-server"); + const stale = useSessionStore.getState().sessions["test-server"]!; + store.applyAgentTimelineResponseState("test-server", "agent-1", { ...input, newer: true }); + const synced = useSessionStore.getState().sessions["test-server"]!; + expect(synced.agentHistorySyncGeneration.get("agent-1")).toBe(synced.historySyncGeneration); + expect(synced.agentHistorySyncGeneration).not.toBe(stale.agentHistorySyncGeneration); + expect(synced.agentStreamTail).toBe(stale.agentStreamTail); + expect(scans).toBe(scanned); + unsubscribe(); + }); + + it("settles submissions and deletes optional entries while retaining an empty authoritative tail", () => { + initializeTestSession(); + const store = useSessionStore.getState(); + const input = { + items: [], + head: [], + range: null, + older: "none" as const, + newer: false, + synchronized: true, + submissions: [{ clientMessageId: "local", providerAcknowledged: false, rpcSettled: false }], + }; + store.applyAgentTimelineResponseState("test-server", "agent-1", input); + const pending = useSessionStore.getState().sessions["test-server"]!; + store.applyAgentTimelineResponseState("test-server", "agent-1", { + ...input, + submissions: [{ ...input.submissions[0]!, rpcSettled: true }], + }); + const settled = useSessionStore.getState().sessions["test-server"]!; + expect(settled.messageSubmissions.get("agent-1")?.[0]?.rpcSettled).toBe(true); + expect(settled.agentStreamTail).toBe(pending.agentStreamTail); + store.applyAgentTimelineResponseState("test-server", "agent-1", { ...input, submissions: [] }); + const empty = useSessionStore.getState(); + const session = empty.sessions["test-server"]!; + expect(session.agentStreamTail.has("agent-1")).toBe(true); + expect(session.agentStreamHead.has("agent-1")).toBe(false); + expect(session.agentTimelineCursor.has("agent-1")).toBe(false); + expect(session.messageSubmissions.has("agent-1")).toBe(false); + expect(session.agentTasks.has("agent-1")).toBe(false); + store.applyAgentTimelineResponseState("test-server", "agent-1", { + ...input, + items: [], + head: [], + submissions: [], + }); + expect(useSessionStore.getState()).toBe(empty); + }); + it("commits canonical items, range, and older availability as one synced state", () => { initializeTestSession(); const items: StreamItem[] = [ @@ -202,7 +355,7 @@ describe("agent timeline state", () => { older: "available", newer: false, synchronized: true, - acknowledgedClientMessageIds: [], + submissions: [], }); expect( @@ -225,7 +378,7 @@ describe("agent timeline state", () => { older: "none", newer: false, synchronized: true, - acknowledgedClientMessageIds: [], + submissions: [], }); expect( @@ -243,7 +396,7 @@ describe("agent timeline state", () => { older: "available", newer: false, synchronized: true, - acknowledgedClientMessageIds: [], + submissions: [], }); store.applyAgentTimelineResponseState("test-server", "agent-1", { @@ -253,7 +406,7 @@ describe("agent timeline state", () => { older: "unchanged", newer: false, synchronized: false, - acknowledgedClientMessageIds: [], + submissions: [], }); expect( @@ -306,15 +459,12 @@ describe("message submission ordering", () => { ] as const; function applyStep(step: (typeof steps)[number]): void { - const store = useSessionStore.getState(); if (step === "accept") { - store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId); + timelineReplica.acceptSubmission(agentId, clientMessageId); return; } if (step === "canonical-row") { - store.setAgentStreamState("test-server", agentId, { - acknowledgedClientMessageIds: [clientMessageId], - }); + acknowledgeSubmission(agentId, clientMessageId); return; } if (step === "stream-open") { @@ -351,9 +501,7 @@ describe("message submission ordering", () => { for (const ordering of permutations(steps)) { useSessionStore.getState().clearSession("test-server"); initializeTestSession(); - useSessionStore - .getState() - .beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId)); + timelineReplica.beginSubmission(agentId, submittedMessage(clientMessageId), true); let canonicalObserved = false; for (const step of ordering) { @@ -368,11 +516,8 @@ describe("message submission ordering", () => { } } - const store = useSessionStore.getState(); - store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId); - store.setAgentStreamState("test-server", agentId, { - acknowledgedClientMessageIds: [clientMessageId], - }); + timelineReplica.acceptSubmission(agentId, clientMessageId); + acknowledgeSubmission(agentId, clientMessageId); applyTestTurn("test-server", agentId, [ { type: "stream_close", turnId: "turn-1" }, { type: "snapshot", activeTurn: null }, @@ -402,9 +547,7 @@ describe("message submission ordering", () => { }); }); - useSessionStore - .getState() - .beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId)); + timelineReplica.beginSubmission(agentId, submittedMessage(clientMessageId), true); unsubscribe(); expect(notifications).toEqual([{ hasOptimisticRow: true, isActive: true }]); @@ -412,13 +555,10 @@ describe("message submission ordering", () => { it("keeps another unacknowledged submission active after one row is acknowledged", () => { initializeTestSession(); - const store = useSessionStore.getState(); - store.beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId)); - store.beginAgentMessageSubmission("test-server", agentId, submittedMessage("client-2")); - store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId); - store.setAgentStreamState("test-server", agentId, { - acknowledgedClientMessageIds: [clientMessageId], - }); + timelineReplica.beginSubmission(agentId, submittedMessage(clientMessageId), true); + timelineReplica.beginSubmission(agentId, submittedMessage("client-2"), true); + timelineReplica.acceptSubmission(agentId, clientMessageId); + acknowledgeSubmission(agentId, clientMessageId); expect( selectAgentTurnPresentation(useSessionStore.getState().sessions["test-server"], agentId) @@ -430,9 +570,7 @@ describe("message submission ordering", () => { initializeTestSession(); const message = submittedMessage(clientMessageId); - const handedOff = useSessionStore - .getState() - .handoffCreatedAgentUserMessage("test-server", agentId, message); + const handedOff = timelineReplica.handoffSubmission(agentId, message); const session = useSessionStore.getState().sessions["test-server"]; expect({ diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 3f1ae6ffcf..579e7451f4 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -4,22 +4,10 @@ import { subscribeWithSelector } from "zustand/middleware"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; +import { type StreamItem, type TodoEntry } from "@/types/stream"; import { - appendSubmittedUserMessage, - handoffCreatedAgentUserMessageToStream, - removeSubmittedUserMessage, - type StreamItem, - type TodoEntry, - type UserMessageItem, -} from "@/types/stream"; -import { - acceptMessageSubmission, - beginMessageSubmission, getActiveMessageSubmissions, - observeMessageSubmissionCanonical, - rejectMessageSubmission, type MessageSubmissionRecord, - type MessageSubmissionRejectionOutcome, } from "@/composer/submission/model"; import type { PendingPermission } from "@/types/shared"; import type { ComposerAttachment } from "@/attachments/types"; @@ -296,6 +284,18 @@ export interface AgentTimelineCursorState { }>; } +function sameTimelineRange( + current: AgentTimelineCursorState | undefined, + next: AgentTimelineCursorState | null, +): boolean { + return ( + current?.epoch === next?.epoch && + current?.startSeq === next?.startSeq && + current?.endSeq === next?.endSeq && + current?.retainedRanges === next?.retainedRanges + ); +} + export type AgentTimelineState = | { status: "cold" } | { status: "painted"; items: StreamItem[] } @@ -453,74 +453,13 @@ interface SessionStoreActions { setFocusedTerminalId: (serverId: string, terminalId: string | null) => void; // Stream state (head/tail model) - setAgentStreamTail: ( - serverId: string, - state: - | Map - | ((prev: Map) => Map), - ) => void; - setAgentStreamHead: ( - serverId: string, - state: - | Map - | ((prev: Map) => Map), - ) => void; - setAgentStreamState: ( - serverId: string, - agentId: string, - state: { - tail?: StreamItem[]; - head?: StreamItem[]; - acknowledgedClientMessageIds?: readonly string[]; - taskSnapshot?: TodoEntry[]; - }, - ) => void; - beginAgentMessageSubmission: ( - serverId: string, - agentId: string, - message: UserMessageItem, - ) => void; - acceptAgentMessageSubmission: ( - serverId: string, - agentId: string, - clientMessageId: string, - ) => void; - rejectAgentMessageSubmission: ( - serverId: string, - agentId: string, - clientMessageId: string, - ) => MessageSubmissionRejectionOutcome; - handoffCreatedAgentUserMessage: ( - serverId: string, - agentId: string, - message: UserMessageItem, - ) => boolean; - clearAgentStreamHead: (serverId: string, agentId: string) => void; - setAgentTimelineCursor: ( - serverId: string, - state: - | Map - | ((prev: Map) => Map), - ) => void; - setAgentTimelineHasOlder: ( - serverId: string, - state: Map | ((prev: Map) => Map), - ) => void; - setAgentTimelineHasNewer: ( - serverId: string, - state: Map | ((prev: Map) => Map), - ) => void; + setAgentTimelineOlderFetchInFlight: ( serverId: string, state: Map | ((prev: Map) => Map), ) => void; bumpHistorySyncGeneration: (serverId: string) => void; - markAgentHistorySynchronized: (serverId: string, agentId: string) => void; - setAgentAuthoritativeHistoryApplied: ( - serverId: string, - agentId: string, - applied: boolean, - ) => void; + applyAgentTimelineResponseState: ( serverId: string, agentId: string, @@ -531,10 +470,12 @@ interface SessionStoreActions { older: "available" | "none" | "unchanged"; newer: boolean; synchronized: boolean; - acknowledgedClientMessageIds: string[]; + submissions: MessageSubmissionRecord[]; }, ) => void; + removeAgentTimeline: (serverId: string, agentId: string) => void; + // Initializing agents setInitializingAgents: ( serverId: string, @@ -931,354 +872,6 @@ export const useSessionStore = create()( }, // Stream state (head/tail model) - setAgentStreamTail: (serverId, state) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) { - return prev; - } - const nextState = typeof state === "function" ? state(session.agentStreamTail) : state; - if (session.agentStreamTail === nextState) { - return prev; - } - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, agentStreamTail: nextState }, - }, - }; - }); - }, - - setAgentStreamHead: (serverId, state) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) { - return prev; - } - const nextState = typeof state === "function" ? state(session.agentStreamHead) : state; - if (session.agentStreamHead === nextState) { - return prev; - } - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, agentStreamHead: nextState }, - }, - }; - }); - }, - - setAgentStreamState: (serverId, agentId, state) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) { - return prev; - } - - let nextTail = session.agentStreamTail; - let nextHead = session.agentStreamHead; - let changedTail = false; - let changedHead = false; - - if (state.tail !== undefined) { - const existingTail = session.agentStreamTail.get(agentId); - if (existingTail !== state.tail) { - nextTail = new Map(session.agentStreamTail); - nextTail.set(agentId, state.tail); - changedTail = true; - } - } - - if (state.head !== undefined) { - const existingHead = session.agentStreamHead.get(agentId); - const shouldDeleteHead = state.head.length === 0; - if (shouldDeleteHead) { - if (session.agentStreamHead.has(agentId)) { - nextHead = new Map(session.agentStreamHead); - nextHead.delete(agentId); - changedHead = true; - } - } else if (existingHead !== state.head) { - nextHead = new Map(session.agentStreamHead); - nextHead.set(agentId, state.head); - changedHead = true; - } - } - - const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; - const observedSubmissions = observeMessageSubmissionCanonical( - currentSubmissions, - state.acknowledgedClientMessageIds ?? [], - ); - const changedSubmissions = observedSubmissions !== currentSubmissions; - const agentTasks = updateAgentTasks(session.agentTasks, agentId, state.taskSnapshot); - const changedTasks = agentTasks !== session.agentTasks; - - if (!changedTail && !changedHead && !changedSubmissions && !changedTasks) { - return prev; - } - - let messageSubmissions = session.messageSubmissions; - if (changedSubmissions) { - messageSubmissions = new Map(session.messageSubmissions); - if (observedSubmissions.length > 0) { - messageSubmissions.set(agentId, observedSubmissions); - } else { - messageSubmissions.delete(agentId); - } - } - - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { - ...session, - agentStreamTail: nextTail, - agentStreamHead: nextHead, - agentTasks, - messageSubmissions, - }, - }, - }; - }); - }, - - beginAgentMessageSubmission: (serverId, agentId, message) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) return prev; - if (!message.clientMessageId) { - throw new Error("Beginning a message submission requires client identity"); - } - const currentTail = session.agentStreamTail.get(agentId) ?? []; - const currentHead = session.agentStreamHead.get(agentId) ?? []; - const stream = appendSubmittedUserMessage({ - tail: currentTail, - head: currentHead, - message, - }); - const submissions = beginMessageSubmission( - session.messageSubmissions.get(agentId) ?? [], - { clientMessageId: message.clientMessageId }, - ); - const messageSubmissions = new Map(session.messageSubmissions); - messageSubmissions.set(agentId, submissions); - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { - ...session, - agentStreamTail: - stream.tail === currentTail - ? session.agentStreamTail - : new Map(session.agentStreamTail).set(agentId, stream.tail), - agentStreamHead: - stream.head === currentHead - ? session.agentStreamHead - : new Map(session.agentStreamHead).set(agentId, stream.head), - messageSubmissions, - }, - }, - }; - }); - }, - - acceptAgentMessageSubmission: (serverId, agentId, clientMessageId) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) return prev; - const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; - const submissions = acceptMessageSubmission(currentSubmissions, clientMessageId); - if (submissions === currentSubmissions) return prev; - const messageSubmissions = new Map(session.messageSubmissions); - if (submissions.length > 0) { - messageSubmissions.set(agentId, submissions); - } else { - messageSubmissions.delete(agentId); - } - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, messageSubmissions }, - }, - }; - }); - }, - - rejectAgentMessageSubmission: (serverId, agentId, clientMessageId) => { - let outcome: MessageSubmissionRejectionOutcome = "unknown"; - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) return prev; - const currentTail = session.agentStreamTail.get(agentId) ?? []; - const currentHead = session.agentStreamHead.get(agentId) ?? []; - const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; - const result = rejectMessageSubmission(currentSubmissions, clientMessageId); - outcome = result.outcome; - if (outcome === "unknown") return prev; - const stream = - outcome === "rejected" - ? removeSubmittedUserMessage({ - tail: currentTail, - head: currentHead, - clientMessageId, - }) - : { tail: currentTail, head: currentHead }; - const messageSubmissions = new Map(session.messageSubmissions); - if (result.submissions.length > 0) { - messageSubmissions.set(agentId, result.submissions); - } else { - messageSubmissions.delete(agentId); - } - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { - ...session, - agentStreamTail: - stream.tail === currentTail - ? session.agentStreamTail - : new Map(session.agentStreamTail).set(agentId, stream.tail), - agentStreamHead: - stream.head === currentHead - ? session.agentStreamHead - : new Map(session.agentStreamHead).set(agentId, stream.head), - messageSubmissions, - }, - }, - }; - }); - return outcome; - }, - - handoffCreatedAgentUserMessage: (serverId, agentId, message) => { - let didHandoff = false; - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) { - return prev; - } - - const currentTail = session.agentStreamTail.get(agentId) ?? []; - const currentHead = session.agentStreamHead.get(agentId) ?? []; - const result = handoffCreatedAgentUserMessageToStream({ - tail: currentTail, - head: currentHead, - message, - }); - if (!result.changedTail && !result.changedHead) { - return prev; - } - - const nextTail = result.changedTail - ? new Map(session.agentStreamTail).set(agentId, result.tail) - : session.agentStreamTail; - const nextHead = result.changedHead - ? new Map(session.agentStreamHead).set(agentId, result.head) - : session.agentStreamHead; - didHandoff = true; - - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { - ...session, - agentStreamTail: nextTail, - agentStreamHead: nextHead, - }, - }, - }; - }); - return didHandoff; - }, - - clearAgentStreamHead: (serverId, agentId) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) { - return prev; - } - if (!session.agentStreamHead.has(agentId)) { - return prev; - } - const nextHead = new Map(session.agentStreamHead); - nextHead.delete(agentId); - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, agentStreamHead: nextHead }, - }, - }; - }); - }, - - setAgentTimelineCursor: (serverId, state) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) { - return prev; - } - const nextState = - typeof state === "function" ? state(session.agentTimelineCursor) : state; - if (session.agentTimelineCursor === nextState) { - return prev; - } - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, agentTimelineCursor: nextState }, - }, - }; - }); - }, - - setAgentTimelineHasOlder: (serverId, state) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) { - return prev; - } - const nextState = - typeof state === "function" ? state(session.agentTimelineHasOlder) : state; - if (session.agentTimelineHasOlder === nextState) { - return prev; - } - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, agentTimelineHasOlder: nextState }, - }, - }; - }); - }, - - setAgentTimelineHasNewer: (serverId, state) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) return prev; - const nextState = - typeof state === "function" ? state(session.agentTimelineHasNewer) : state; - if (session.agentTimelineHasNewer === nextState) return prev; - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, agentTimelineHasNewer: nextState }, - }, - }; - }); - }, setAgentTimelineOlderFetchInFlight: (serverId, state) => { set((prev) => { @@ -1321,107 +914,58 @@ export const useSessionStore = create()( }); }, - markAgentHistorySynchronized: (serverId, agentId) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) { - return prev; - } - const currentGeneration = session.historySyncGeneration; - const previousGeneration = session.agentHistorySyncGeneration.get(agentId); - if (previousGeneration === currentGeneration) { - return prev; - } - const nextMap = new Map(session.agentHistorySyncGeneration); - nextMap.set(agentId, currentGeneration); - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { - ...session, - agentHistorySyncGeneration: nextMap, - }, - }, - }; - }); - }, - - setAgentAuthoritativeHistoryApplied: (serverId, agentId, applied) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) { - return prev; - } - - const previousApplied = session.agentAuthoritativeHistoryApplied.get(agentId) ?? false; - if (previousApplied === applied) { - return prev; - } - - const nextApplied = new Map(session.agentAuthoritativeHistoryApplied); - if (applied) { - nextApplied.set(agentId, true); - } else { - nextApplied.delete(agentId); - } - - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { - ...session, - agentAuthoritativeHistoryApplied: nextApplied, - }, - }, - }; - }); - }, - applyAgentTimelineResponseState: (serverId, agentId, state) => { set((prev) => { const session = prev.sessions[serverId]; if (!session) return prev; - const nextTail = new Map(session.agentStreamTail); - nextTail.set(agentId, state.items); - const nextHead = new Map(session.agentStreamHead); - if (state.head.length > 0) nextHead.set(agentId, state.head); - else nextHead.delete(agentId); - const nextCursor = new Map(session.agentTimelineCursor); - if (state.range) nextCursor.set(agentId, state.range); - else nextCursor.delete(agentId); - const nextHasOlder = new Map(session.agentTimelineHasOlder); - if (state.older !== "unchanged") { - nextHasOlder.set(agentId, state.older === "available"); - } - const nextHasNewer = new Map(session.agentTimelineHasNewer); - nextHasNewer.set(agentId, state.newer); - const nextAuthoritative = new Map(session.agentAuthoritativeHistoryApplied); - const nextSyncGeneration = new Map(session.agentHistorySyncGeneration); - const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; - const observedSubmissions = observeMessageSubmissionCanonical( - currentSubmissions, - state.acknowledgedClientMessageIds, + let changed = false; + const update = (map: Map, value: T | undefined): Map => { + if (map.get(agentId) === value) return map; + changed = true; + const next = new Map(map); + if (value === undefined) next.delete(agentId); + else next.set(agentId, value); + return next; + }; + const currentTail = session.agentStreamTail.get(agentId); + const items = + currentTail?.length === 0 && state.items.length === 0 ? currentTail : state.items; + const nextTail = update(session.agentStreamTail, items); + const nextHead = update( + session.agentStreamHead, + state.head.length > 0 ? state.head : undefined, ); - let messageSubmissions = session.messageSubmissions; - if (observedSubmissions !== currentSubmissions) { - messageSubmissions = new Map(session.messageSubmissions); - if (observedSubmissions.length > 0) { - messageSubmissions.set(agentId, observedSubmissions); - } else { - messageSubmissions.delete(agentId); - } - } - if (state.synchronized) { - nextAuthoritative.set(agentId, true); - nextSyncGeneration.set(agentId, session.historySyncGeneration); - } - const tasks = latestTasksFromStream([...state.items, ...state.head]); - const agentTasks = new Map(session.agentTasks); - if (tasks.length > 0) agentTasks.set(agentId, tasks); - else agentTasks.delete(agentId); + const currentRange = session.agentTimelineCursor.get(agentId); + const rangeUnchanged = sameTimelineRange(currentRange, state.range); + const nextCursor = rangeUnchanged + ? session.agentTimelineCursor + : update(session.agentTimelineCursor, state.range ?? undefined); + const nextHasOlder = + state.older === "unchanged" + ? session.agentTimelineHasOlder + : update(session.agentTimelineHasOlder, state.older === "available"); + const nextHasNewer = update(session.agentTimelineHasNewer, state.newer); + const nextAuthoritative = state.synchronized + ? update(session.agentAuthoritativeHistoryApplied, true) + : session.agentAuthoritativeHistoryApplied; + const nextSyncGeneration = state.synchronized + ? update(session.agentHistorySyncGeneration, session.historySyncGeneration) + : session.agentHistorySyncGeneration; + const messageSubmissions = update( + session.messageSubmissions, + state.submissions.length > 0 ? state.submissions : undefined, + ); + const rowsChanged = + nextTail !== session.agentStreamTail || nextHead !== session.agentStreamHead; + const agentTasks = rowsChanged + ? updateAgentTasks( + session.agentTasks, + agentId, + latestTasksFromStream([...state.items, ...state.head]), + ) + : session.agentTasks; + if (!changed && agentTasks === session.agentTasks) return prev; return { ...prev, @@ -1444,6 +988,37 @@ export const useSessionStore = create()( }); }, + removeAgentTimeline: (serverId, agentId) => { + set((previous) => { + const session = previous.sessions[serverId]; + if (!session) return previous; + const remove = (map: Map): Map => { + const next = new Map(map); + next.delete(agentId); + return next; + }; + return { + ...previous, + sessions: { + ...previous.sessions, + [serverId]: { + ...session, + agentStreamTail: remove(session.agentStreamTail), + agentStreamHead: remove(session.agentStreamHead), + agentTimelineCursor: remove(session.agentTimelineCursor), + agentTimelineHasOlder: remove(session.agentTimelineHasOlder), + agentTimelineHasNewer: remove(session.agentTimelineHasNewer), + agentTimelineOlderFetchInFlight: remove(session.agentTimelineOlderFetchInFlight), + agentAuthoritativeHistoryApplied: remove(session.agentAuthoritativeHistoryApplied), + agentHistorySyncGeneration: remove(session.agentHistorySyncGeneration), + messageSubmissions: remove(session.messageSubmissions), + agentTasks: remove(session.agentTasks), + }, + }, + }; + }); + }, + // Initializing agents setInitializingAgents: (serverId, state) => { set((prev) => { diff --git a/packages/app/src/timeline/__fixtures__/legacy-unverifiable-timeline.json b/packages/app/src/timeline/__fixtures__/legacy-unverifiable-timeline.json new file mode 100644 index 0000000000..3ffd41ba74 --- /dev/null +++ b/packages/app/src/timeline/__fixtures__/legacy-unverifiable-timeline.json @@ -0,0 +1,62 @@ +{ + "agentId": "dd23a15e-0ece-4713-bff4-2f92b7293552", + "items": [ + { + "id": "e793b735-19ca-4dae-b8a2-ef79ada78b40", + "timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 34 }, + "turnId": "95e36741-5c73-4472-ab3c-3ba03977acfb", + "timestamp": "2026-08-29T11:04:38.577Z", + "kind": "assistant_message", + "messageId": "e793b735-19ca-4dae-b8a2-ef79ada78b40", + "text": "## Cycle 1\n\nI'll take a look at the scroll anchor behavior you described. Let me start by walking through how the conversation list currently handles streaming updates and where the auto-scroll logic actually lives. My instinct is that the anchor is supposed to pin to the bottom only when the user is already there, but I want to confirm that against the code rather\n\n_(end of synthetic stream)_\n" + }, + { + "id": "2cb83cb0-fecd-4f7b-baf4-43d5230be02a", + "timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 69 }, + "turnId": "c05e5c65-91dd-497e-8e59-92e045b4395f", + "timestamp": "2026-08-29T11:19:09.051Z", + "kind": "assistant_message", + "messageId": "2cb83cb0-fecd-4f7b-baf4-43d5230be02a", + "text": "## Cycle 1\n\nI'll take a look at the scroll anchor behavior you described. Let me start by walking through how the conversation list currently handles streaming updates and where the auto-scroll logic actually lives. My instinct is that the anchor is supposed to pin to the bottom only when the user is already there, but I want to confirm that against the code rather than guess. This kind of\n\n_(end of synthetic stream)_\n" + }, + { + "id": "8b0dcf06-f4a7-4238-8ba1-87a4e9daaf83", + "timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 1 }, + "turnId": "95e36741-5c73-4472-ab3c-3ba03977acfb", + "timestamp": "2026-08-29T11:04:36.573Z", + "kind": "user_message", + "messageId": "8b0dcf06-f4a7-4238-8ba1-87a4e9daaf83", + "text": "Exercise the composer keyboard invariant flow" + }, + { + "id": "e793b735-19ca-4dae-b8a2-ef79ada78b40:segment:mte9wyi9", + "timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 34 }, + "turnId": "95e36741-5c73-4472-ab3c-3ba03977acfb", + "timestamp": "2026-08-29T11:04:38.577Z", + "kind": "assistant_message", + "messageId": "e793b735-19ca-4dae-b8a2-ef79ada78b40", + "text": "## Cycle 1\n\nI'll take a look at the scroll anchor behavior you described. Let me start by walking through how the conversation list currently handles streaming updates and where the auto-scroll logic actually lives. My instinct is that the anchor is supposed to pin to the bottom only when the user is already there, but I want to confirm that against the code rather\n\n_(end of synthetic stream)_\n" + }, + { + "id": "msg_1788002346771_1hfh7ud4z", + "timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 35 }, + "turnId": "c05e5c65-91dd-497e-8e59-92e045b4395f", + "timestamp": "2026-08-29T11:19:07.043Z", + "kind": "user_message", + "clientMessageId": "msg_1788002346771_1hfh7ud4z", + "messageId": "msg_1788002346771_1hfh7ud4z", + "text": "keyboard invariant line one\nline two\nline three\nline fourvv" + }, + { + "id": "2cb83cb0-fecd-4f7b-baf4-43d5230be02a:segment:mteafm63", + "timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 69 }, + "turnId": "c05e5c65-91dd-497e-8e59-92e045b4395f", + "timestamp": "2026-08-29T11:19:09.051Z", + "kind": "assistant_message", + "messageId": "2cb83cb0-fecd-4f7b-baf4-43d5230be02a", + "text": "## Cycle 1\n\nI'll take a look at the scroll anchor behavior you described. Let me start by walking through how the conversation list currently handles streaming updates and where the auto-scroll logic actually lives. My instinct is that the anchor is supposed to pin to the bottom only when the user is already there, but I want to confirm that against the code rather than guess. This kind of\n\n_(end of synthetic stream)_\n" + } + ], + "range": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "startSeq": 1, "endSeq": 69 }, + "hasOlder": false +} diff --git a/packages/app/src/timeline/__fixtures__/mixed-unpositioned-message.json b/packages/app/src/timeline/__fixtures__/mixed-unpositioned-message.json new file mode 100644 index 0000000000..e419c1f2c8 --- /dev/null +++ b/packages/app/src/timeline/__fixtures__/mixed-unpositioned-message.json @@ -0,0 +1,44 @@ +{ + "entry": { + "provider": "codex", + "item": { + "type": "assistant_message", + "text": "\n\n---\n\nThe pending-tail check passed: the app requested catch-up after 10197 and recovered the live paragraph, with no subsequent live row delivered. I’m now checking that unpositioned updates can coexist with sequencing in the same session.\n", + "messageId": "msg_050818abff3697f1016a9e2d065a4487d287f39b8cdf226b1a" + }, + "timestamp": "2026-09-07T03:18:31.760Z", + "seqStart": 10223, + "seqEnd": 10243, + "sourceSeqRanges": [ + { + "startSeq": 10223, + "endSeq": 10243 + } + ], + "turnId": "codex-turn-64", + "collapsed": ["assistant_merge"] + }, + "chunks": [ + "\n\n---\n\nThe", + " pending-tail", + " check passed", + ": the", + " app requested", + " catch-up", + " after ", + "10197", + " and recovered the", + " live paragraph", + ", with no", + " subsequent live", + " row delivered", + ". I", + "’m now checking that", + " unposition", + "ed", + " updates can coexist with", + " sequencing in", + " the same", + " session.\n" + ] +} diff --git a/packages/app/src/timeline/fetch-agent-timeline-once.test.ts b/packages/app/src/timeline/fetch-agent-timeline-once.test.ts deleted file mode 100644 index 1ae97e0281..0000000000 --- a/packages/app/src/timeline/fetch-agent-timeline-once.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { expect, test } from "vitest"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { fetchAgentTimelineOnce } from "./fetch-agent-timeline-once"; - -type TimelinePage = Awaited>; - -test("concurrent identical timeline reads share one request", async () => { - let resolvePage: (page: TimelinePage) => void = () => {}; - const page = new Promise((resolve) => { - resolvePage = resolve; - }); - const requests: Array<{ agentId: string; direction: string }> = []; - const client = { - fetchAgentTimeline: async ( - agentId: string, - request: Parameters[1], - ) => { - requests.push({ agentId, direction: request?.direction ?? "tail" }); - return page; - }, - }; - const request = { direction: "tail" as const, limit: 100, projection: "projected" as const }; - - const first = fetchAgentTimelineOnce(client, "agent", request); - const second = fetchAgentTimelineOnce(client, "agent", request); - resolvePage({ hasNewer: false } as TimelinePage); - - await expect(Promise.all([first, second])).resolves.toHaveLength(2); - expect(requests).toEqual([{ agentId: "agent", direction: "tail" }]); -}); diff --git a/packages/app/src/timeline/fetch-agent-timeline-once.ts b/packages/app/src/timeline/fetch-agent-timeline-once.ts deleted file mode 100644 index e0d67b4897..0000000000 --- a/packages/app/src/timeline/fetch-agent-timeline-once.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; - -type TimelineClient = Pick; -type TimelineRequest = Parameters[1]; -type TimelinePage = Awaited>; - -const inFlightByClient = new WeakMap>>(); - -export function fetchAgentTimelineOnce( - client: TimelineClient, - agentId: string, - request: TimelineRequest, -): Promise { - let inFlight = inFlightByClient.get(client); - if (!inFlight) { - inFlight = new Map(); - inFlightByClient.set(client, inFlight); - } - - const key = `${agentId}:${JSON.stringify(request)}`; - const existing = inFlight.get(key); - if (existing) return existing; - - const fetch = client.fetchAgentTimeline(agentId, request); - inFlight.set(key, fetch); - const clear = () => { - if (inFlight.get(key) === fetch) inFlight.delete(key); - }; - void fetch.then(clear, clear); - return fetch; -} diff --git a/packages/app/src/timeline/replica.contract.test.ts b/packages/app/src/timeline/replica.contract.test.ts new file mode 100644 index 0000000000..0abce24fe0 --- /dev/null +++ b/packages/app/src/timeline/replica.contract.test.ts @@ -0,0 +1,2171 @@ +import { + createInitDeferred, + getInitDeferred, + getInitKey, + resolveInitDeferred, +} from "@/utils/agent-initialization"; +import mixedMessage from "./__fixtures__/mixed-unpositioned-message.json"; +import legacyTimeline from "./__fixtures__/legacy-unverifiable-timeline.json"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ReplicaCache, type CachedTimeline } from "@/runtime/replica-cache"; +import type { + ReplicaHostRows, + ReplicaRow, + ReplicaRowChanges, + ReplicaRowStore, +} from "@/runtime/replica-cache/row-store"; +import { loadOlderAgentHistory } from "@/hooks/use-load-older-agent-history"; +import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; +import { createUserMessage, type StreamItem } from "@/types/stream"; +import { + createTimelineReplica, + type TimelineResponsePayload, + type TimelineReplicaStorage, +} from "./replica"; +import { createViewedTimelineOwner } from "./viewed-timeline-sync"; + +const host = "transaction-host"; +const otherHost = "transaction-other"; +const agentId = "agent"; +const timestamp = "2026-09-06T10:00:00.000Z"; + +class MemoryRows implements ReplicaRowStore { + rows = new Map(); + async open() {} + async read( + serverIds: readonly string[], + kinds: readonly ReplicaRow["kind"][], + ids?: readonly string[], + ): Promise { + return [...this.rows.values()].filter( + (row) => + serverIds.includes(row.serverId) && + kinds.includes(row.kind) && + (!ids || ids.includes(row.id)), + ); + } + + async readAll(): Promise { + return [host, otherHost].map((serverId) => ({ + serverId, + rows: [...this.rows.values()].filter((row) => row.serverId === serverId), + })); + } + async apply(changes: ReplicaRowChanges) { + for (const row of changes.deletes) this.rows.delete(`${row.serverId}:${row.kind}:${row.id}`); + for (const row of changes.upserts) this.rows.set(`${row.serverId}:${row.kind}:${row.id}`, row); + } + async deleteHost(serverId: string) { + for (const [key, row] of this.rows) if (row.serverId === serverId) this.rows.delete(key); + } + async renameHost(oldId: string, newId: string) { + const rows = [...this.rows.values()].filter((row) => row.serverId === oldId); + await this.deleteHost(oldId); + await this.apply({ + deletes: [], + upserts: rows.map((row) => ({ + kind: row.kind, + id: row.id, + payload: row.payload, + serverId: newId, + })), + }); + } + async clear() { + this.rows.clear(); + } +} + +function cache(rows: MemoryRows): ReplicaCache { + const result = new ReplicaCache(rows, { clearLegacyCache: async () => undefined }); + result.setHosts([host, otherHost]); + return result; +} + +function saved(): CachedTimeline { + return { + agentId, + hasOlder: false, + range: { epoch: "epoch-1", startSeq: 1, endSeq: 80 }, + items: Array.from( + { length: 80 }, + (_, i): StreamItem => ({ + kind: "assistant_message", + id: `message-${i + 1}`, + messageId: `message-${i + 1}`, + text: `row ${i + 1}`, + timestamp: new Date(timestamp), + timelineCursor: { epoch: "epoch-1", seq: i + 1 }, + source: { startSeq: i + 1, chunks: [{ seq: i + 1, offset: 0 }] }, + }), + ), + }; +} + +function page( + start: number, + end: number, + hasNewer = false, + epoch = "epoch-1", +): TimelineResponsePayload { + return { + requestId: `page-${epoch}-${end}`, + agentId, + agent: null, + direction: "after", + projection: "projected", + reset: false, + epoch, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: end, nextSeq: end + 1 }, + startCursor: { epoch, seq: start }, + endCursor: { epoch, seq: end }, + hasOlder: true, + hasNewer, + error: null, + entries: Array.from({ length: end - start + 1 }, (_, i) => ({ + provider: "claude" as const, + item: { + type: "assistant_message" as const, + text: `row ${start + i}`, + messageId: `message-${start + i}`, + }, + timestamp, + seqStart: start + i, + seqEnd: start + i, + sourceSeqRanges: [{ startSeq: start + i, endSeq: start + i }], + collapsed: [], + })), + }; +} + +function owner(storage: TimelineReplicaStorage, serverId = host) { + const replica = createTimelineReplica({ serverId, storage }); + const viewed = createViewedTimelineOwner({ + serverId, + replica, + replaceDemandedAgentIds: () => undefined, + drainQueuedAgentMessage: () => undefined, + ports: { + initialDeliveryMode: "legacy", + setSubscription: async () => undefined, + fetchPage: async () => ({ hasNewer: false, endCursor: null }), + fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }), + reportError: (error) => { + throw error; + }, + schedule: () => () => undefined, + }, + }); + return { replica, viewed }; +} + +function live(viewed: ReturnType["viewed"], seq: number) { + viewed.enqueueStreamEvent(agentId, { + seq, + epoch: "epoch-1", + timestamp: new Date(timestamp), + event: { + type: "timeline", + provider: "claude", + item: { type: "assistant_message", text: `row ${seq}`, messageId: `message-${seq}` }, + }, + }); +} + +afterEach(() => { + useSessionStore.getState().clearSession(host); + useSessionStore.getState().clearSession(otherHost); +}); + +describe("timeline transaction restart contract", () => { + it.each([false, true])( + "reconciles recorded markdown through display-only reopen (newer live=%s)", + async (newer) => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + const { replica, viewed } = owner(disk); + await replica.prepare(agentId); + viewed.beginTimelineRequest(agentId, true)({ ...page(10219, 10219), direction: "tail" }); + for (const text of [...mixedMessage.chunks, ...(newer ? ["Newer text"] : [])]) { + viewed.enqueueStreamEvent(agentId, { + timestamp: new Date(timestamp), + seq: undefined, + epoch: undefined, + event: { + type: "timeline", + provider: "codex", + item: { + type: "assistant_message", + messageId: mixedMessage.entry.item.messageId, + text, + }, + }, + }); + viewed.flushStreamAgent(agentId); + } + const displayed = () => { + const session = useSessionStore.getState().sessions[host]!; + return [ + ...(session.agentStreamTail.get(agentId) ?? []), + ...(session.agentStreamHead.get(agentId) ?? []), + ]; + }; + const messageRows = (items: StreamItem[]) => + items.filter( + (row) => + row.kind === "assistant_message" && row.messageId === mixedMessage.entry.item.messageId, + ); + expect(messageRows(displayed())).toHaveLength(2); + const expectedTexts = newer + ? messageRows(displayed()).map((row) => ("text" in row ? row.text : "")) + : [mixedMessage.entry.item.text]; + if (newer) expect(expectedTexts.join("\n\n")).toContain("session.\nNewer text"); + const tool = { + type: "tool_call" as const, + name: "shell", + callId: "following-tool", + status: "running" as const, + error: null, + detail: { type: "shell" as const, command: "pwd" }, + }; + // The real tool follows the split message; positions resume on this connection. + viewed.enqueueStreamEvent(agentId, { + seq: undefined, + epoch: undefined, + timestamp: new Date(timestamp), + event: { type: "timeline", provider: "codex", item: tool }, + }); + viewed.flushStreamAgent(agentId); + viewed.applyTimelineResponse({ + ...page(10220, 10262), + entries: [ + { + ...mixedMessage.entry, + provider: "codex", + item: { ...mixedMessage.entry.item, type: "assistant_message" }, + collapsed: ["assistant_merge"], + }, + { + provider: "codex", + item: tool, + timestamp, + seqStart: 10244, + seqEnd: 10262, + sourceSeqRanges: [ + { startSeq: 10244, endSeq: 10244 }, + { startSeq: 10262, endSeq: 10262 }, + ], + collapsed: ["tool_lifecycle"], + }, + ], + }); + expect(messageRows(displayed()).map((row) => ("text" in row ? row.text : ""))).toEqual( + expectedTexts, + ); + expect( + displayed() + .slice(-2) + .map((row) => row.kind), + ).toEqual(["assistant_message", "tool_call"]); + await disk.flush(); + const certified = await cache(rows).readTimeline(host, agentId); + expect(messageRows(certified!.items)).toHaveLength(1); + expect(certified?.range).toEqual({ epoch: "epoch-1", startSeq: 10219, endSeq: 10262 }); + expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 10262 }); + viewed.enqueueStreamEvent(agentId, { + seq: undefined, + epoch: undefined, + timestamp: new Date(timestamp), + event: { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", messageId: "later", text: "later update" }, + }, + }); + viewed.flushStreamAgent(agentId); + await disk.flush(); + const reopened = await cache(rows).readTimeline(host, agentId); + expect(reopened?.range).toBeNull(); + expect(messageRows(reopened!.items).map((row) => ("text" in row ? row.text : ""))).toEqual( + expectedTexts, + ); + viewed.dispose(); + }, + ); + + it("retains positioned gap detection after an out-of-band running setup tool", async () => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + const { replica, viewed } = owner(disk); + await replica.prepare(agentId); + viewed.beginTimelineRequest(agentId, true)({ ...page(41, 80), direction: "tail" }); + viewed.enqueueStreamEvent(agentId, { + seq: undefined, + epoch: undefined, + timestamp: new Date(timestamp), + event: { + type: "timeline", + provider: "claude", + item: { + type: "tool_call", + name: "paseo_worktree_setup", + callId: "setup", + status: "running", + error: null, + detail: { + type: "worktree_setup", + worktreePath: "/repo", + branchName: "branch", + log: "Installing", + commands: [], + }, + }, + }, + }); + viewed.flushStreamAgent(agentId); + expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 80 }); + await disk.flush(); + expect((await cache(rows).readTimeline(host, agentId))?.range).toBeNull(); + // The saved display page itself grants no source coverage on a fresh owner. + const fresh = createTimelineReplica({ serverId: host, storage: cache(rows) }); + await fresh.prepare(agentId); + expect(fresh.readCursor(agentId)).toBeUndefined(); + fresh.dispose(); + const gaps: number[] = []; + replica.applyEvents( + agentId, + [ + { + seq: 82, + epoch: "epoch-1", + timestamp: new Date(timestamp), + event: { + type: "timeline", + provider: "claude", + item: { type: "assistant_message", text: "row 82", messageId: "message-82" }, + }, + }, + ], + (_id, cursor, observed) => { + expect(cursor.endSeq).toBe(80); + gaps.push(observed!); + }, + ); + expect(gaps).toEqual([82]); + viewed.beginTimelineRequest(agentId, true)({ ...page(43, 82), direction: "tail" }); + await disk.flush(); + expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual({ + epoch: "epoch-1", + startSeq: 43, + endSeq: 82, + }); + viewed.dispose(); + }); + + it.each(["live-first", "tail-first"] as const)( + "converges a cold overflow without another live row: %s", + async (ordering) => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + disk.commitTimeline(host, agentId, saved()); + await disk.flush(); + const replica = createTimelineReplica({ serverId: host, storage: disk }); + const requests: number[] = []; + const tailStarted = Promise.withResolvers(); + const releaseTail = Promise.withResolvers(); + const viewed = createViewedTimelineOwner({ + serverId: host, + replica, + replaceDemandedAgentIds: () => undefined, + drainQueuedAgentMessage: () => undefined, + ports: { + initialDeliveryMode: "legacy", + setSubscription: async () => undefined, + fetchPage: async (id, request) => { + const seq = request.direction === "after" ? request.cursor.seq : 0; + requests.push(seq); + const response = + seq === 80 + ? { ...page(81, 120, true), window: { minSeq: 1, maxSeq: 199, nextSeq: 200 } } + : page(200, 200); + viewed.beginTimelineRequest(id)(response); + return response; + }, + fetchLatestTail: async (id) => { + const apply = viewed.beginTimelineRequest(id, true); + tailStarted.resolve(); + await releaseTail.promise; + const response = { ...page(160, 199), direction: "tail" as const }; + apply(response); + return response; + }, + reportError: (error) => { + throw error; + }, + schedule: () => () => undefined, + }, + }); + viewed.registerVisibleAgentIds("screen", [agentId]); + viewed.setConnected(true); + await tailStarted.promise; + if (ordering === "live-first") { + live(viewed, 200); + viewed.flushStreamAgent(agentId); + } + releaseTail.resolve(); + await vi.waitFor(() => + expect(replica.readCursor(agentId)?.endSeq).toBeGreaterThanOrEqual(199), + ); + if (ordering === "tail-first") { + live(viewed, 200); + viewed.flushStreamAgent(agentId); + } + await vi.waitFor(() => expect(replica.readCursor(agentId)?.endSeq).toBe(200)); + await disk.flush(); + const restored = await cache(rows).readTimeline(host, agentId); + expect(restored?.items.some((row) => row.timelineCursor?.seq === 200)).toBe(true); + expect(restored?.range?.endSeq).toBe(200); + expect(requests).toEqual(ordering === "live-first" ? [80, 199] : [80]); + // An intentionally requested older window remains detached after synchronization. + viewed.beginTimelineRequest( + agentId, + true, + )({ ...page(41, 80, true), direction: "tail", mergeWindow: true }); + const before = useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId); + live(viewed, 201); + viewed.flushStreamAgent(agentId); + expect(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)).toBe(before); + expect(replica.readCursor(agentId)?.endSeq).not.toBe(201); + viewed.dispose(); + }, + ); + + it.each([false, true])( + "persists all-unpositioned delivery across restore (pending=%s)", + async (pending) => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + const read = Promise.withResolvers(); + const { replica, viewed } = owner({ + readTimeline: async (...args) => { + await read.promise; + return disk.readTimeline(...args); + }, + commitTimeline: (...args) => disk.commitTimeline(...args), + removeTimeline: (...args) => disk.removeTimeline(...args), + }); + const preparation = replica.prepare(agentId); + if (!pending) { + read.resolve(); + await preparation; + } + for (let i = 1; i <= 3; i++) { + viewed.enqueueStreamEvent(agentId, { + seq: undefined, + epoch: undefined, + timestamp: new Date(timestamp), + event: { + type: "timeline", + provider: "claude", + item: { + type: "assistant_message", + messageId: `legacy-${i}`, + text: `legacy ${i}`, + }, + }, + }); + viewed.flushStreamAgent(agentId); + if (i === 1 && pending) { + read.resolve(); + await preparation; + } + await disk.flush(); + const restored = await cache(rows).readTimeline(host, agentId); + expect(restored?.range).toBeNull(); + expect(restored?.items).toHaveLength(i); + expect(replica.readCursor(agentId)).toBeUndefined(); + } + viewed.dispose(); + }, + ); + + it("keeps accepted source coverage and display-only persistence across a delayed restore", async () => { + useSessionStore.getState().initializeSession(host, null); + const disk = cache(new MemoryRows()); + const read = Promise.withResolvers(); + const { replica, viewed } = owner({ + readTimeline: () => read.promise, + commitTimeline: (...args) => disk.commitTimeline(...args), + removeTimeline: (...args) => disk.removeTimeline(...args), + }); + const preparation = replica.prepare(agentId); + viewed.beginTimelineRequest(agentId, true)({ ...page(1, 3), direction: "tail" }); + viewed.enqueueStreamEvent(agentId, { + seq: undefined, + epoch: undefined, + timestamp: new Date(timestamp), + event: { + type: "timeline", + provider: "claude", + item: { type: "assistant_message", messageId: "legacy", text: "unsequenced" }, + }, + }); + viewed.flushStreamAgent(agentId); + read.resolve({ + ...saved(), + items: saved().items.slice(0, 3), + range: { epoch: "epoch-1", startSeq: 1, endSeq: 3 }, + }); + await preparation; + expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 3 }); + await disk.flush(); + expect((await disk.readTimeline(host, agentId))?.range).toBeNull(); + expect( + (await disk.readTimeline(host, agentId))?.items.some( + (row) => "text" in row && row.text === "unsequenced", + ), + ).toBe(true); + viewed.dispose(); + }); + + it.each(["current", "newer", "flag-only"] as const)( + "settles only the owning initialization after a superseded tail (%s)", + async (lifetime) => { + useSessionStore.getState().initializeSession(host, null); + const disk = cache(new MemoryRows()); + const { replica, viewed } = owner(disk); + await replica.prepare(agentId); + viewed.beginTimelineRequest(agentId, true)({ ...page(1, 3), direction: "tail" }); + const key = getInitKey(host, agentId); + const initial = lifetime === "flag-only" ? undefined : createInitDeferred(key, "tail"); + let settled = false; + initial?.promise.then(() => { + settled = true; + return undefined; + }); + useSessionStore + .getState() + .setInitializingAgents(host, (previous) => new Map(previous).set(agentId, true)); + const accept = viewed.beginTimelineRequest(agentId); + live(viewed, 4); + live(viewed, 5); + viewed.flushStreamAgent(agentId); + // Replacing an initialization already has its own deferred identity. + if (lifetime === "newer") createInitDeferred(key, "tail"); + const current = getInitDeferred(key); + const before = useSessionStore.getState().sessions[host]!; + accept({ ...page(1, 4), direction: "tail" }); + await Promise.resolve(); + const after = useSessionStore.getState().sessions[host]!; + expect(after.agentStreamTail.get(agentId)).toBe(before.agentStreamTail.get(agentId)); + expect(after.agentStreamHead.get(agentId)).toBe(before.agentStreamHead.get(agentId)); + expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 5 }); + expect(after.initializingAgents.get(agentId)).toBe(lifetime === "newer"); + expect(settled).toBe(lifetime === "current"); + expect(getInitDeferred(key)).toBe(lifetime === "newer" ? current : undefined); + resolveInitDeferred(key); + initial?.resolve(); + viewed.dispose(); + }, + ); + + it("preserves display pagination without certifying unsequenced history", async () => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + const { replica, viewed } = owner(disk); + await replica.prepare(agentId); + viewed.beginTimelineRequest(agentId, true)({ ...page(41, 80), direction: "tail" }); + viewed.enqueueStreamEvent(agentId, { + seq: undefined, + epoch: undefined, + timestamp: new Date(timestamp), + event: { + type: "timeline", + provider: "claude", + item: { + type: "assistant_message", + text: "unsequenced", + messageId: "legacy", + }, + }, + }); + viewed.flushStreamAgent(agentId); + const timeline = selectAgentTimelineState(useSessionStore.getState().sessions[host], agentId); + expect(timeline.status).toBe("synced"); + if (timeline.status !== "synced") throw new Error("Expected painted history"); + expect(timeline.range).toEqual({ epoch: "epoch-1", startSeq: 41, endSeq: 80 }); + expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 80 }); + let requested = false; + expect( + await loadOlderAgentHistory(agentId, { + client: { + fetchAgentTimeline: async (id, request) => { + expect(id).toBe(agentId); + expect(request.cursor).toEqual({ epoch: "epoch-1", seq: 41 }); + requested = true; + viewed.applyTimelineResponse({ ...page(1, 40), direction: "before", hasOlder: false }); + }, + }, + cursor: timeline.range ?? undefined, + hasOlder: timeline.older === "available", + isLoadingOlder: false, + setInFlight: () => undefined, + }), + ).toBe(true); + expect(requested).toBe(true); + expect(useSessionStore.getState().sessions[host]?.agentTimelineCursor.get(agentId)).toEqual({ + epoch: "epoch-1", + startSeq: 1, + endSeq: 80, + }); + live(viewed, 82); + viewed.flushStreamAgent(agentId); + await disk.flush(); + const reopened = await cache(rows).readTimeline(host, agentId); + expect(reopened?.range).toBeNull(); + expect(reopened?.items).toHaveLength(40); + expect(reopened?.items.some((row) => (row.source?.startSeq ?? 41) < 41)).toBe(false); + expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 80 }); + viewed.beginTimelineRequest(agentId, true)({ ...page(43, 82), direction: "tail" }); + await disk.flush(); + expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual({ + epoch: "epoch-1", + startSeq: 43, + endSeq: 82, + }); + viewed.dispose(); + }); + + it.each(["live", "cached-live", "page", "empty"] as const)( + "invalidates old display positions on %s epoch/reset after unpositioned activity", + async (transition) => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + if (transition === "cached-live") { + disk.commitTimeline(host, agentId, { + ...saved(), + items: saved().items.slice(40), + range: { epoch: "epoch-1", startSeq: 41, endSeq: 80 }, + hasOlder: true, + }); + await disk.flush(); + } + const { replica, viewed } = owner(disk); + await replica.prepare(agentId); + if (transition !== "cached-live") + viewed.beginTimelineRequest(agentId, true)({ ...page(41, 80), direction: "tail" }); + viewed.enqueueStreamEvent(agentId, { + seq: undefined, + epoch: undefined, + timestamp: new Date(timestamp), + event: { + type: "timeline", + provider: "claude", + item: { type: "assistant_message", text: "unsequenced", messageId: "legacy" }, + }, + }); + viewed.flushStreamAgent(agentId); + if (transition === "live" || transition === "cached-live") { + viewed.enqueueStreamEvent(agentId, { + seq: 1, + epoch: "epoch-2", + timestamp: new Date(timestamp), + event: { + type: "timeline", + provider: "claude", + item: { type: "assistant_message", text: "new epoch", messageId: "new" }, + }, + }); + viewed.flushStreamAgent(agentId); + } else { + const response = page(1, transition === "empty" ? 0 : 1, false, "epoch-2"); + viewed.applyTimelineResponse({ + ...response, + direction: "tail", + reset: true, + hasOlder: false, + ...(transition === "empty" ? { startCursor: null, endCursor: null } : {}), + }); + } + const range = useSessionStore.getState().sessions[host]?.agentTimelineCursor.get(agentId); + expect(range).toEqual( + transition === "page" || transition === "live" + ? { epoch: "epoch-2", startSeq: 1, endSeq: 1 } + : undefined, + ); + expect(useSessionStore.getState().sessions[host]?.agentTimelineHasOlder.get(agentId)).toBe( + false, + ); + await disk.flush(); + const restored = await cache(rows).readTimeline(host, agentId); + if (transition === "cached-live") { + // Until the new epoch is verified, keep the preceding durable baseline. + expect(restored?.items).toHaveLength(40); + expect(restored?.range).toEqual({ epoch: "epoch-1", startSeq: 41, endSeq: 80 }); + } else { + expect(restored?.items).toHaveLength(transition === "empty" ? 0 : 1); + expect(restored?.range).toEqual( + transition === "empty" ? null : { epoch: "epoch-2", startSeq: 1, endSeq: 1 }, + ); + } + viewed.dispose(); + }, + ); + + it("does not publish a non-timeline event that changes no displayed fact", async () => { + useSessionStore.getState().initializeSession(host, null); + const { replica, viewed } = owner(cache(new MemoryRows())); + await replica.prepare(agentId); + viewed.applyTimelineResponse({ ...page(1, 3), direction: "tail" }); + const before = useSessionStore.getState(); + let notifications = 0; + const unsubscribe = useSessionStore.subscribe(() => notifications++); + viewed.enqueueStreamEvent(agentId, { + seq: undefined, + epoch: undefined, + timestamp: new Date(timestamp), + event: { type: "turn_started", provider: "claude", turnId: "turn" }, + }); + viewed.flushStreamAgent(agentId); + expect(useSessionStore.getState()).toBe(before); + expect(notifications).toBe(0); + live(viewed, 4); + viewed.flushStreamAgent(agentId); + expect(notifications).toBe(1); + expect( + useSessionStore.getState().sessions[host]?.agentTimelineCursor.get(agentId)?.endSeq, + ).toBe(4); + unsubscribe(); + viewed.dispose(); + }); + + it.each([false, true])( + "refreshes display-only restarts for unsequenced activity (same message=%s)", + async (sameMessage) => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + const { replica, viewed } = owner(disk); + await replica.prepare(agentId); + viewed.beginTimelineRequest(agentId, true)({ ...page(31, 40), direction: "tail" }); + viewed.applyTimelineResponse({ ...page(1, 30), direction: "before", hasOlder: false }); + for (let batch = 1; batch <= 3; batch++) { + viewed.enqueueStreamEvent(agentId, { + seq: undefined, + epoch: undefined, + timestamp: new Date(timestamp), + event: { + type: "timeline", + provider: "claude", + item: { + type: "assistant_message", + text: `legacy ${batch}`, + messageId: sameMessage ? "message-40" : `legacy-${batch}`, + }, + }, + }); + viewed.flushStreamAgent(agentId); + if (batch === 1) + viewed.applyTimelineResponse({ + ...page(1, 30), + direction: "before", + hasOlder: false, + }); + live(viewed, 40 + batch); + viewed.flushStreamAgent(agentId); + await disk.flush(); + const restored = await cache(rows).readTimeline(host, agentId); + expect(restored?.range).toBeNull(); + expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 40 + batch }); + expect( + restored?.items.some((row) => "text" in row && row.text.includes(`legacy ${batch}`)), + ).toBe(true); + expect( + restored?.items.some((row) => "text" in row && row.text === `row ${40 + batch}`), + ).toBe(true); + expect(restored?.items.some((row) => row.source && row.source.startSeq < 31)).toBe(false); + expect(restored!.items.length).toBeLessThanOrEqual(40); + } + viewed.beginTimelineRequest(agentId, true)({ ...page(31, 43), direction: "tail" }); + await disk.flush(); + const canonical = await cache(rows).readTimeline(host, agentId); + expect(canonical?.range).toEqual({ epoch: "epoch-1", startSeq: 31, endSeq: 43 }); + expect(canonical?.items.map((row) => ("text" in row ? row.text : row.kind))).toEqual( + Array.from({ length: 13 }, (_, i) => `row ${i + 31}`), + ); + // A subsequent positioned gap must keep that certificate through interruption. + live(viewed, 46); + viewed.flushStreamAgent(agentId); + viewed.applyTimelineResponse(page(44, 44, true)); + await disk.flush(); + const partial = await cache(rows).readTimeline(host, agentId); + expect(partial?.range).toEqual({ epoch: "epoch-1", startSeq: 31, endSeq: 44 }); + expect(partial?.items.some((row) => row.timelineCursor?.seq === 46)).toBe(false); + viewed.dispose(); + }, + ); + + it.each(["captured", "renamed", "commit", "deleted", "disposed", "host-removed"] as const)( + "settles a real cache restore across identity transfer: %s", + async (ordering) => { + const rows = new MemoryRows(); + const seed = cache(rows); + seed.commitTimeline(host, agentId, saved()); + await seed.flush(); + const started = Promise.withResolvers(); + const release = Promise.withResolvers(); + const readRows = rows.read.bind(rows); + let reads = 0; + rows.read = async (...args) => { + reads++; + if (reads !== 1) return readRows(...args); + const captured = ordering === "captured" ? await readRows(...args) : undefined; + started.resolve(); + await release.promise; + return captured ?? readRows(...args); + }; + useSessionStore.getState().initializeSession(host, null); + const disk = cache(rows); + const { replica, viewed } = owner(disk); + const preparation = replica.prepare(agentId); + await started.promise; + disk.reconcileServerId(host, otherHost); + useSessionStore.getState().initializeSession(otherHost, null); + replica.reconcileServerId(otherHost); + useSessionStore.getState().clearSession(host); + await disk.flush(); + if (ordering === "commit") { + disk.commitTimeline(otherHost, agentId, { ...saved(), items: saved().items.slice(40) }); + } + if (ordering === "deleted") replica.remove(agentId, "deleted"); + if (ordering === "disposed") replica.dispose(); + if (ordering === "host-removed") disk.setHosts([]); + release.resolve(); + await preparation; + if (ordering === "deleted" || ordering === "disposed" || ordering === "host-removed") { + expect(replica.readCursor(agentId)).toBeUndefined(); + expect( + useSessionStore.getState().sessions[otherHost]?.agentStreamTail.get(agentId) ?? [], + ).toEqual([]); + expect(reads).toBe(1); + viewed.dispose(); + return; + } + expect(replica.prepare(agentId)).toBe(preparation); + expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 80 }); + expect(useSessionStore.getState().sessions[otherHost]?.agentStreamTail.get(agentId)).toEqual( + ordering === "commit" ? saved().items.slice(40) : saved().items, + ); + expect(reads).toBe(ordering === "commit" ? 1 : 2); + viewed.dispose(); + }, + ); + + it.each([false, true])( + "transfers live display and submissions with the owner while restore is pending=%s", + async (pending) => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + disk.commitTimeline(host, agentId, saved()); + await disk.flush(); + let reads = 0; + const read = Promise.withResolvers(); + const { replica, viewed } = owner({ + readTimeline: () => { + reads++; + return read.promise; + }, + commitTimeline: (...args) => disk.commitTimeline(...args), + removeTimeline: (...args) => disk.removeTimeline(...args), + }); + const preparation = replica.prepare(agentId); + if (!pending) { + read.resolve(await disk.readTimeline(host, agentId)); + await preparation; + } + live(viewed, 81); + viewed.flushStreamAgent(agentId); + const message = createUserMessage({ + text: "pending send", + clientMessageId: "local", + timestamp: new Date(timestamp), + }); + replica.beginSubmission(agentId, message, true); + const before = useSessionStore.getState().sessions[host]!; + viewed.dispose(); + useSessionStore.getState().initializeSession(otherHost, null); + disk.reconcileServerId(host, otherHost); + replica.reconcileServerId(otherHost); + useSessionStore.getState().clearSession(host); + const transferred = useSessionStore.getState().sessions[otherHost]!; + expect(transferred.agentStreamTail.get(agentId)).toBe(before.agentStreamTail.get(agentId)); + expect(transferred.agentStreamHead.get(agentId)).toBe(before.agentStreamHead.get(agentId)); + expect(transferred.messageSubmissions.get(agentId)).toEqual( + before.messageSubmissions.get(agentId), + ); + if (pending) read.resolve(await disk.readTimeline(otherHost, agentId)); + await preparation; + expect(replica.prepare(agentId)).toBe(preparation); + expect(reads).toBe(pending ? 2 : 1); + expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 80 }); + const restored = useSessionStore.getState().sessions[otherHost]!; + const display = [ + ...restored.agentStreamTail.get(agentId)!, + ...restored.agentStreamHead.get(agentId)!, + ]; + expect( + display.some((row) => row.kind === "assistant_message" && row.messageId === "message-81"), + ).toBe(true); + expect( + display.some((row) => row.kind === "user_message" && row.clientMessageId === "local"), + ).toBe(true); + expect(useSessionStore.getState().sessions[host]).toBeUndefined(); + expect(replica.rejectSubmission(agentId, "local", true)).toBe("rejected"); + replica.dispose(); + }, + ); + + it("paints the real legacy page without resuming its unverified cursor, then replaces it canonically", async () => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const id = legacyTimeline.agentId; + rows.rows.set(`${host}:timeline:${id}`, { + serverId: host, + kind: "timeline", + id, + payload: JSON.stringify(legacyTimeline), + }); + const disk = cache(rows); + const { replica, viewed } = owner(disk); + await replica.prepare(id); + expect( + useSessionStore + .getState() + .sessions[host]?.agentStreamTail.get(id) + ?.map((row) => row.timelineCursor?.seq), + ).toEqual([34, 69, 1, 34, 35, 69]); + expect(replica.readCursor(id)).toBeUndefined(); + // Live activity cannot grant certification to the old page. + viewed.enqueueStreamEvent(id, { + seq: 70, + epoch: legacyTimeline.range.epoch, + timestamp: new Date(timestamp), + event: { + type: "timeline", + provider: "claude", + item: { type: "assistant_message", messageId: "new", text: "new activity" }, + }, + }); + viewed.flushStreamAgent(id); + await disk.flush(); + expect((await cache(rows).readTimeline(host, id))?.range).toBeNull(); + + const requests: string[] = []; + const canonical = { + ...page(1, 70, false, legacyTimeline.range.epoch), + agentId: id, + direction: "tail" as const, + hasOlder: false, + entries: [ + legacyTimeline.items[2], + legacyTimeline.items[0], + legacyTimeline.items[4], + legacyTimeline.items[1], + ].map((row) => ({ + provider: "claude" as const, + timestamp: row.timestamp, + item: { + type: + row.kind === "user_message" + ? ("user_message" as const) + : ("assistant_message" as const), + text: row.text, + messageId: row.messageId, + }, + seqStart: row.timelineCursor.seq, + seqEnd: row.timelineCursor.seq, + sourceSeqRanges: [{ startSeq: row.timelineCursor.seq, endSeq: row.timelineCursor.seq }], + collapsed: [], + })), + }; + canonical.entries.push({ + provider: "claude", + timestamp, + item: { type: "assistant_message", text: "new activity", messageId: "new" }, + seqStart: 70, + seqEnd: 70, + sourceSeqRanges: [{ startSeq: 70, endSeq: 70 }], + collapsed: [], + }); + viewed.attachTransport({ + initialDeliveryMode: "legacy", + setSubscription: async () => undefined, + fetchPage: async (_id, request) => { + requests.push(request.direction); + viewed.beginTimelineRequest(id)(canonical); + return { hasNewer: false, endCursor: { epoch: canonical.epoch, seq: 70 } }; + }, + fetchLatestTail: async () => { + throw new Error("Unexpected extra tail"); + }, + reportError: (error) => { + throw error; + }, + schedule: () => () => undefined, + }); + viewed.registerVisibleAgentIds("workspace", [id]); + viewed.setConnected(true); + await vi.waitFor(() => expect(viewed.getAgentTimelineStatus(id)).toBe("ready")); + expect(requests).toEqual(["tail"]); + await disk.flush(); + const restored = await cache(rows).readTimeline(host, id); + expect(restored?.range).toEqual({ epoch: canonical.epoch, startSeq: 1, endSeq: 70 }); + expect(restored?.items.map((row) => row.timelineCursor?.seq)).toEqual([1, 34, 35, 69, 70]); + expect( + restored?.items.filter((row) => row.kind === "assistant_message").map((row) => row.text), + ).toEqual([legacyTimeline.items[0].text, legacyTimeline.items[1].text, "new activity"]); + viewed.dispose(); + }); + + it.each(["short", "reasoning", "empty"] as const)( + "keeps a short restart page independent of older browsing (%s)", + async (projection) => { + const snapshots: CachedTimeline[][] = []; + for (const browse of [false, true]) { + useSessionStore.getState().clearSession(host); + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + const { replica, viewed } = owner(disk); + await replica.prepare(agentId); + const tail = { ...page(31, 70), direction: "tail" as const }; + // Forty projected units hydrate to twenty rows: each three thoughts merge before an answer. + if (projection !== "short") { + tail.entries = tail.entries.map((entry, i) => { + if (i % 4 === 3) return entry; + const item: TimelineResponsePayload["entries"][number]["item"] = + projection === "reasoning" + ? { type: "reasoning", text: `thought ${i}` } + : { type: "assistant_message", text: "", messageId: `empty-${i}` }; + return { ...entry, item }; + }); + } else { + tail.entries = tail.entries.slice(30); + tail.startCursor = { epoch: "epoch-1", seq: 61 }; + } + viewed.beginTimelineRequest(agentId, true)(tail); + await disk.flush(); + const initial = await cache(rows).readTimeline(host, agentId); + expect(initial?.items).toHaveLength(projection === "reasoning" ? 20 : 10); + const olderEnd = tail.startCursor!.seq - 1; + if (browse) + viewed.applyTimelineResponse({ + ...page(1, olderEnd), + direction: "before", + hasOlder: false, + window: tail.window, + }); + await disk.flush(); + expect(await cache(rows).readTimeline(host, agentId)).toEqual(initial); + const results: CachedTimeline[] = []; + for (const end of [71, 75, 115]) { + const start = results.length === 0 ? 71 : results.at(-1)!.range!.endSeq + 1; + for (let seq = start; seq <= end; seq++) live(viewed, seq); + viewed.flushStreamAgent(agentId); + await disk.flush(); + const snapshot = await cache(rows).readTimeline(host, agentId); + expect(snapshot?.range).toMatchObject({ epoch: "epoch-1", endSeq: end }); + expect(snapshot?.items.length).toBe(Math.min(40, initial!.items.length + end - 70)); + expect(snapshot?.hasOlder).toBe(true); + results.push(snapshot!); + } + snapshots.push(results); + viewed.dispose(); + replica.dispose(); + useSessionStore.getState().clearSession(host); + useSessionStore.getState().initializeSession(host, null); + const reopened = owner(cache(rows)); + await reopened.replica.prepare(agentId); + expect(reopened.replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 115 }); + expect(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)).toEqual( + results[2].items, + ); + expect(results[2].range?.startSeq).toBe(76); + reopened.viewed.applyTimelineResponse({ ...page(36, 75), direction: "before" }); + expect( + useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)?.[0], + ).toMatchObject({ + messageId: "message-36", + text: "row 36", + }); + reopened.viewed.dispose(); + } + expect(snapshots[1]).toEqual(snapshots[0]); + }, + ); + + it.each([ + ["distinct", "old", "new", ["row 2", "row 3"], 2], + ["same", "same", "same", ["row 1row 2", "row 3"], 1], + ["idless", undefined, undefined, ["row 1row 2", "row 3"], 1], + ] as const)( + "persists complete %s messages at the retained prepend boundary", + async (_, olderId, newerId, texts, startSeq) => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + const { replica, viewed } = owner(disk); + await replica.prepare(agentId); + const tail = { ...page(2, 2), direction: "tail" as const }; + tail.entries[0].item = { type: "assistant_message", text: "row 2", messageId: newerId }; + viewed.beginTimelineRequest(agentId, true)(tail); + const older = { + ...page(1, 1), + direction: "before" as const, + hasOlder: false, + window: tail.window, + }; + older.entries[0].item = { type: "assistant_message", text: "row 1", messageId: olderId }; + viewed.applyTimelineResponse(older); + live(viewed, 3); + viewed.flushStreamAgent(agentId); + await disk.flush(); + const persisted = await cache(rows).readTimeline(host, agentId); + expect( + persisted?.items.map((row) => (row.kind === "assistant_message" ? row.text : row.kind)), + ).toEqual(texts); + expect(persisted?.range).toEqual({ epoch: "epoch-1", startSeq, endSeq: 3 }); + expect(persisted?.items[0].source).toEqual( + startSeq === 1 + ? { + startSeq: 1, + chunks: [ + { seq: 1, offset: 0 }, + { seq: 2, offset: 5 }, + ], + } + : { startSeq: 2, chunks: [{ seq: 2, offset: 0 }] }, + ); + viewed.dispose(); + replica.dispose(); + useSessionStore.getState().clearSession(host); + useSessionStore.getState().initializeSession(host, null); + const reopened = owner(cache(rows)); + await reopened.replica.prepare(agentId); + expect(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)).toEqual( + persisted?.items, + ); + reopened.viewed.dispose(); + }, + ); + + it("bounds overlapping tool history to forty complete entries and retrieves earlier tools by source start", async () => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + const { replica, viewed } = owner(disk); + await replica.prepare(agentId); + const response: TimelineResponsePayload = { ...page(1, 60), direction: "tail" }; + const tool = (start: number, end: number): TimelineResponsePayload["entries"][number] => ({ + provider: "claude", + item: { + type: "tool_call", + callId: `tool-${start}`, + name: "Bash", + status: "completed", + error: null, + detail: { type: "unknown", input: null, output: null }, + }, + timestamp, + seqStart: start, + seqEnd: end, + sourceSeqRanges: [ + { startSeq: start, endSeq: start }, + { startSeq: end, endSeq: end }, + ], + collapsed: ["tool_lifecycle"], + }); + for (let start = 1; start <= 20; start++) response.entries[start - 1] = tool(start, 81 - start); + response.window = { minSeq: 1, maxSeq: 80, nextSeq: 81 }; + response.endCursor = { epoch: "epoch-1", seq: 80 }; + viewed.applyTimelineResponse(response); + await disk.flush(); + const snapshot = await cache(rows).readTimeline(host, agentId); + expect(snapshot?.range).toEqual({ epoch: "epoch-1", startSeq: 21, endSeq: 80 }); + expect(snapshot?.items).toHaveLength(40); + expect(snapshot?.hasOlder).toBe(true); + // The last accepted event completed an excluded older tool. The retained page still + // resumes after 80, while older pagination uses source start 21, not completion order. + viewed.dispose(); + replica.dispose(); + useSessionStore.getState().clearSession(host); + useSessionStore.getState().initializeSession(host, null); + const reopenedDisk = cache(rows); + const reopened = owner(reopenedDisk); + await reopened.replica.prepare(agentId); + expect(reopened.replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 80 }); + expect(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)).toHaveLength( + 40, + ); + reopened.viewed.applyTimelineResponse({ + ...response, + direction: "before", + startCursor: { epoch: "epoch-1", seq: 1 }, + endCursor: { epoch: "epoch-1", seq: 20 }, + entries: response.entries.slice(0, 20), + hasOlder: false, + }); + const restored = useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId); + expect(restored).toHaveLength(60); + expect(restored?.[0]).toMatchObject({ + kind: "tool_call", + source: { startSeq: 1 }, + timelineCursor: { seq: 80 }, + }); + expect((await cache(rows).readTimeline(host, agentId))?.items).toHaveLength(40); + for (let seq = 81; seq <= 120; seq++) live(reopened.viewed, seq); + // A final page establishes sequencing after the restart; the overlaid events reconcile. + reopened.viewed.applyTimelineResponse(page(81, 120)); + reopened.viewed.flushStreamAgent(agentId); + await reopenedDisk.flush(); + const advanced = await cache(rows).readTimeline(host, agentId); + expect(advanced?.range).toEqual({ epoch: "epoch-1", startSeq: 81, endSeq: 120 }); + expect(advanced?.items).toHaveLength(40); + reopened.viewed.dispose(); + }); + + it.each([ + ["tail", true], + ["tail", false], + ["after", true], + ["after", false], + ] as const)( + "shares accepted markdown and following reductions (%s, positioned=%s)", + async (direction, positioned) => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + if (direction === "after") + disk.commitTimeline(host, agentId, { + ...saved(), + items: saved().items.slice(0, 1), + range: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + }); + await disk.flush(); + const restore = Promise.withResolvers(); + const { replica, viewed } = owner({ + readTimeline: async (...args) => { + await restore.promise; + return disk.readTimeline(...args); + }, + commitTimeline: (...args) => disk.commitTimeline(...args), + removeTimeline: (...args) => disk.removeTimeline(...args), + }); + const preparation = replica.prepare(agentId); + if (direction === "tail") { + restore.resolve(); + await preparation; + } + for (const [seq, text] of [ + [2, "para one\n\n"], + [3, "para two"], + ] as const) { + viewed.enqueueStreamEvent(agentId, { + seq: positioned ? seq : undefined, + epoch: positioned ? "epoch-1" : undefined, + timestamp: new Date(timestamp), + event: { + type: "timeline", + provider: "claude", + item: { + type: "assistant_message", + messageId: "markdown", + text, + }, + }, + }); + viewed.flushStreamAgent(agentId); + } + restore.resolve(); + await preparation; + viewed.applyTimelineResponse({ + ...page(2, 3), + direction, + entries: [ + { + ...page(2, 2).entries[0]!, + item: { + type: "assistant_message", + messageId: "markdown", + text: "para one\n\npara two", + }, + seqStart: 2, + seqEnd: 3, + sourceSeqRanges: [{ startSeq: 2, endSeq: 3 }], + }, + ], + }); + const display = () => { + const session = useSessionStore.getState().sessions[host]!; + return [ + ...(session.agentStreamTail.get(agentId) ?? []), + ...(session.agentStreamHead.get(agentId) ?? []), + ]; + }; + const accepted = await disk.readTimeline(host, agentId); + expect(accepted?.items).toStrictEqual(display()); + expect(accepted?.items.at(-1)).toBe(display().at(-1)); + expect(display().at(-1)?.source).toEqual({ startSeq: 2, chunks: [{ seq: 3, offset: 0 }] }); + const control = `${agentId}-control`; + await replica.prepare(control); + viewed.applyTimelineResponse({ ...page(1, 3), direction: "tail", agentId: control }); + function sendMeasured(id: string, seq: number): number { + let textReads = 0; + viewed.enqueueStreamEvent(id, { + seq, + epoch: "epoch-1", + timestamp: new Date(timestamp), + event: { + type: "timeline", + provider: "claude", + item: { + type: "assistant_message", + messageId: `message-${seq}`, + get text() { + textReads++; + return `row ${seq}`; + }, + }, + }, + }); + viewed.flushStreamAgent(id); + return textReads; + } + for (let seq = 4; seq <= 8; seq++) { + const singleReductionReads = sendMeasured(control, seq); + expect(singleReductionReads).toBeGreaterThan(0); + expect(sendMeasured(agentId, seq)).toBe(singleReductionReads); + expect((await disk.readTimeline(host, agentId))?.items.at(-1)).toBe(display().at(-1)); + } + await disk.flush(); + expect((await cache(rows).readTimeline(host, agentId))?.items).toEqual(display()); + viewed.dispose(); + }, + ); + + it.each([true, false])( + "keeps a lagging paragraph separate only until covered (positioned=%s)", + async (positioned) => { + useSessionStore.getState().initializeSession(host, null); + const disk = cache(new MemoryRows()); + const { replica, viewed } = owner(disk); + await replica.prepare(agentId); + for (const [seq, text] of [ + [1, "one\n\n"], + [2, "two"], + [3, " newer"], + ] as const) { + viewed.enqueueStreamEvent(agentId, { + seq: positioned ? seq : undefined, + epoch: positioned ? "epoch-1" : undefined, + timestamp: new Date(timestamp), + event: { + type: "timeline", + provider: "claude", + item: { type: "assistant_message", messageId: "m", text }, + }, + }); + viewed.flushStreamAgent(agentId); + } + const partial = { + ...page(1, 2, true), + direction: "tail" as const, + entries: [ + { + ...page(1, 1).entries[0]!, + item: { type: "assistant_message" as const, messageId: "m", text: "one\n\ntwo" }, + seqEnd: 2, + sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }], + }, + ], + }; + viewed.applyTimelineResponse(partial); + const display = () => { + const session = useSessionStore.getState().sessions[host]!; + return [ + ...(session.agentStreamTail.get(agentId) ?? []), + ...(session.agentStreamHead.get(agentId) ?? []), + ]; + }; + expect(display().some((row) => "text" in row && row.text.includes(" newer"))).toBe(true); + expect((await disk.readTimeline(host, agentId))?.items).not.toEqual(display()); + viewed.applyTimelineResponse({ + ...page(1, 3), + direction: "tail", + entries: [ + { + ...partial.entries[0]!, + item: { type: "assistant_message", messageId: "m", text: "one\n\ntwo newer" }, + seqEnd: 3, + sourceSeqRanges: [{ startSeq: 1, endSeq: 3 }], + }, + ], + }); + expect(display().map((row) => ("text" in row ? row.text : row.kind))).toEqual([ + "one\n\ntwo newer", + ]); + expect((await disk.readTimeline(host, agentId))?.items[0]).toBe(display()[0]); + live(viewed, 4); + viewed.flushStreamAgent(agentId); + expect((await disk.readTimeline(host, agentId))?.items.at(-1)).toBe(display().at(-1)); + viewed.dispose(); + }, + ); + + it.each([true, false])( + "shares canonical same-message segments around a tool (positioned=%s)", + async (positioned) => { + useSessionStore.getState().initializeSession(host, null); + const disk = cache(new MemoryRows()); + const { replica, viewed } = owner(disk); + await replica.prepare(agentId); + const tool = { + type: "tool_call" as const, + callId: "between", + name: "Read", + status: "running" as const, + error: null, + detail: { type: "read" as const, filePath: "/repo/file" }, + }; + const entries: TimelineResponsePayload["entries"] = [ + { + ...page(1, 1).entries[0]!, + item: { type: "assistant_message", messageId: "m", text: "one\n\ntwo" }, + seqEnd: 2, + sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }], + }, + { ...page(3, 3).entries[0]!, item: tool }, + { + ...page(4, 4).entries[0]!, + item: { type: "assistant_message", messageId: "m", text: "three\n\nfour" }, + seqEnd: 5, + sourceSeqRanges: [{ startSeq: 4, endSeq: 5 }], + }, + ]; + for (const entry of entries) { + viewed.enqueueStreamEvent(agentId, { + seq: positioned ? entry.seqEnd : undefined, + epoch: positioned ? "epoch-1" : undefined, + timestamp: new Date(timestamp), + event: { type: "timeline", provider: "claude", item: entry.item }, + }); + viewed.flushStreamAgent(agentId); + } + viewed.applyTimelineResponse({ ...page(1, 5), direction: "tail", entries }); + const session = useSessionStore.getState().sessions[host]!; + const display = [ + ...(session.agentStreamTail.get(agentId) ?? []), + ...(session.agentStreamHead.get(agentId) ?? []), + ]; + const accepted = (await disk.readTimeline(host, agentId))!.items; + expect(display.map((row) => ("text" in row ? row.text : row.kind))).toEqual([ + "one\n\ntwo", + "tool_call", + "three\n\nfour", + ]); + expect(new Set(display.map((row) => row.id)).size).toBe(3); + expect(accepted).toEqual(display); + for (let i = 0; i < display.length; i++) expect(accepted[i]).toBe(display[i]); + live(viewed, 6); + viewed.flushStreamAgent(agentId); + expect((await disk.readTimeline(host, agentId))?.items.at(-1)).toBe( + useSessionStore.getState().sessions[host]?.agentStreamHead.get(agentId)?.at(-1), + ); + viewed.dispose(); + }, + ); + + it.each([false, true])( + "shares accepted rows after synchronization and catch-up (overlay=%s)", + async (overlay) => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + const { replica, viewed } = owner(disk); + await replica.prepare(agentId); + if (overlay) { + live(viewed, 3); + viewed.flushStreamAgent(agentId); + } + viewed.applyTimelineResponse({ ...page(1, 3), direction: "tail" }); + const display = () => [ + ...(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId) ?? []), + ...(useSessionStore.getState().sessions[host]?.agentStreamHead.get(agentId) ?? []), + ]; + const accepted = await disk.readTimeline(host, agentId); + expect(accepted?.items).toStrictEqual(display()); + expect(accepted?.items.at(-1)).toBe(display().at(-1)); + live(viewed, 4); + viewed.flushStreamAgent(agentId); + expect((await disk.readTimeline(host, agentId))?.items.at(-1)).toBe(display().at(-1)); + await disk.flush(); + expect((await cache(rows).readTimeline(host, agentId))?.items).toEqual(display()); + viewed.dispose(); + }, + ); + + it.each(["accept", "reject", "handoff"] as const)( + "keeps submission %s transitions shared through following batches and restart", + async (operation) => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + const { replica, viewed } = owner(disk); + await replica.prepare(agentId); + viewed.applyTimelineResponse({ ...page(1, 3), direction: "tail" }); + const message = createUserMessage({ + clientMessageId: "submission", + text: "prompt", + timestamp: new Date(timestamp), + }); + if (operation === "handoff") replica.handoffSubmission(agentId, message); + else replica.beginSubmission(agentId, message, true); + if (operation === "reject") + expect(replica.rejectSubmission(agentId, "submission", true)).toBe("rejected"); + else { + replica.acceptSubmission(agentId, "submission"); + viewed.enqueueStreamEvent(agentId, { + seq: 4, + epoch: "epoch-1", + timestamp: new Date(timestamp), + event: { + type: "timeline", + provider: "claude", + item: { + type: "user_message", + text: "prompt", + messageId: "provider-message", + clientMessageId: "submission", + }, + }, + }); + viewed.flushStreamAgent(agentId); + } + const display = () => [ + ...(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId) ?? []), + ...(useSessionStore.getState().sessions[host]?.agentStreamHead.get(agentId) ?? []), + ]; + for (let seq = operation === "reject" ? 4 : 5; seq <= 14; seq++) { + live(viewed, seq); + viewed.flushStreamAgent(agentId); + expect((await disk.readTimeline(host, agentId))?.items.at(-1)).toBe(display().at(-1)); + } + await disk.flush(); + expect((await cache(rows).readTimeline(host, agentId))?.items).toEqual(display()); + expect((await cache(rows).readTimeline(host, agentId))?.range?.endSeq).toBe(14); + viewed.dispose(); + }, + ); + + it("reunites a submission with verified history after a cache/live divergence", async () => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + disk.commitTimeline(host, agentId, saved()); + await disk.flush(); + const { replica, viewed } = owner(disk); + await replica.prepare(agentId); + live(viewed, 83); + viewed.flushStreamAgent(agentId); + replica.beginSubmission( + agentId, + createUserMessage({ + clientMessageId: "during-gap", + text: "prompt", + timestamp: new Date(timestamp), + }), + true, + ); + replica.acceptSubmission(agentId, "during-gap"); + await disk.flush(); + expect((await cache(rows).readTimeline(host, agentId))?.range?.endSeq).toBe(80); + const response = page(81, 83); + response.entries[1]!.item = { + type: "user_message", + text: "prompt", + messageId: "canonical", + clientMessageId: "during-gap", + }; + viewed.applyTimelineResponse(response); + for (let seq = 84; seq <= 86; seq++) { + live(viewed, seq); + viewed.flushStreamAgent(agentId); + const session = useSessionStore.getState().sessions[host]; + const display = [ + ...(session?.agentStreamTail.get(agentId) ?? []), + ...(session?.agentStreamHead.get(agentId) ?? []), + ]; + expect((await disk.readTimeline(host, agentId))?.items.at(-1)).toBe(display.at(-1)); + } + await disk.flush(); + const restored = await cache(rows).readTimeline(host, agentId); + expect(restored?.range?.endSeq).toBe(86); + expect(restored?.items.filter((item) => item.kind === "user_message")).toHaveLength(1); + viewed.dispose(); + }); + + it("replaces a requested window while preserving an unverified live overlay", async () => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + disk.commitTimeline(host, agentId, saved()); + await disk.flush(); + const { replica, viewed } = owner(disk); + await replica.prepare(agentId); + const acceptTail = viewed.beginTimelineRequest(agentId, true); + live(viewed, 83); + viewed.flushStreamAgent(agentId); + const response = { ...page(81, 82), direction: "tail" as const }; + acceptTail(response); + expect(response.reset).toBe(false); + const session = useSessionStore.getState().sessions[host]; + expect( + [ + ...(session?.agentStreamTail.get(agentId) ?? []), + ...(session?.agentStreamHead.get(agentId) ?? []), + ].map((item) => item.id), + ).toEqual(["message-81", "message-82", "message-83"]); + await disk.flush(); + const restored = await cache(rows).readTimeline(host, agentId); + expect(restored?.items.map((item) => item.id)).toEqual(["message-81", "message-82"]); + expect(restored?.range).toEqual({ epoch: "epoch-1", startSeq: 81, endSeq: 82 }); + viewed.dispose(); + }); + + it("saves a moving latest page even when that page exceeds 512 KiB and older history is loaded", async () => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + const { replica, viewed } = owner(disk); + await replica.prepare(agentId); + const latest = { ...page(161, 200), direction: "tail" as const }; + for (const entry of latest.entries) { + entry.item = { + type: "assistant_message", + messageId: `message-${entry.seqStart}`, + text: "x".repeat(20_000), + }; + } + viewed.applyTimelineResponse(latest); + await disk.flush(); + expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual({ + epoch: "epoch-1", + startSeq: 161, + endSeq: 200, + }); + viewed.applyTimelineResponse({ ...page(1, 160), direction: "before", hasOlder: false }); + live(viewed, 201); + viewed.flushStreamAgent(agentId); + await disk.flush(); + const savedPage = await cache(rows).readTimeline(host, agentId); + expect(savedPage?.items).toHaveLength(40); + expect(savedPage?.items.at(-1)).toMatchObject({ text: "row 201" }); + expect(savedPage?.range).toEqual({ epoch: "epoch-1", startSeq: 162, endSeq: 201 }); + expect(savedPage?.hasOlder).toBe(true); + viewed.dispose(); + replica.dispose(); + useSessionStore.getState().clearSession(host); + useSessionStore.getState().initializeSession(host, null); + const reopenedDisk = cache(rows); + const reopened = owner(reopenedDisk); + await reopened.replica.prepare(agentId); + expect(reopened.replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 201 }); + expect(useSessionStore.getState().sessions[host]?.agentTimelineCursor.get(agentId)).toEqual({ + epoch: "epoch-1", + startSeq: 162, + endSeq: 201, + }); + reopened.viewed.applyTimelineResponse({ ...page(122, 161), direction: "before" }); + await reopenedDisk.flush(); + expect( + useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)?.[0], + ).toMatchObject({ text: "row 122" }); + expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual({ + epoch: "epoch-1", + startSeq: 162, + endSeq: 201, + }); + reopened.viewed.dispose(); + }); + + it("a live row painted before the resume keeps the resume bounded", async () => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + disk.commitTimeline(host, agentId, saved()); + await disk.flush(); + const calls: string[] = []; + const replica = createTimelineReplica({ serverId: host, storage: disk }); + const viewed = createViewedTimelineOwner({ + serverId: host, + replica, + replaceDemandedAgentIds: () => undefined, + drainQueuedAgentMessage: () => undefined, + ports: { + initialDeliveryMode: "legacy", + setSubscription: async () => undefined, + fetchPage: async (_id, request) => { + const from = request.direction === "after" ? request.cursor.seq : 0; + calls.push(`page ${request.direction} ${from}`); + const end = from + 40; + const hasNewer = end < 600; + viewed.applyTimelineResponse({ + ...page(from + 1, end, hasNewer), + window: { minSeq: 1, maxSeq: 600, nextSeq: 601 }, + }); + return { hasNewer, endCursor: { epoch: "epoch-1", seq: end } }; + }, + fetchLatestTail: async () => { + calls.push("latest-tail"); + viewed.applyTimelineResponse({ ...page(561, 600), direction: "tail", reset: true }); + return { hasNewer: false, endCursor: { epoch: "epoch-1", seq: 600 } }; + }, + reportError: (error) => { + throw error; + }, + schedule: () => () => undefined, + }, + }); + try { + viewed.registerVisibleAgentIds("workspace", [agentId]); + await replica.prepare(agentId); + live(viewed, 590); + viewed.flushStreamAgent(agentId); + await disk.flush(); + expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual(saved().range); + + viewed.setConnected(true); + await vi.waitFor(() => expect(viewed.getAgentTimelineStatus(agentId)).toBe("ready")); + expect(calls).toEqual(["page after 80", "latest-tail"]); + await disk.flush(); + const reopened = await cache(rows).readTimeline(host, agentId); + expect(reopened?.range).toEqual({ epoch: "epoch-1", startSeq: 561, endSeq: 600 }); + expect(reopened?.items.map((item) => ("text" in item ? item.text : null))).toEqual( + Array.from({ length: 40 }, (_, i) => `row ${561 + i}`), + ); + } finally { + viewed.dispose(); + } + }); + + it("invalidates a certified restart on an authoritative empty reset", async () => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + disk.commitTimeline(host, agentId, saved()); + await disk.flush(); + const { replica, viewed } = owner(disk); + await replica.prepare(agentId); + viewed.applyTimelineResponse({ + ...page(1, 0, false, "epoch-2"), + direction: "tail", + reset: true, + startCursor: null, + endCursor: null, + hasOlder: false, + }); + await disk.flush(); + expect(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId) ?? []).toEqual( + [], + ); + expect(await cache(rows).readTimeline(host, agentId)).toMatchObject({ + agentId, + items: [], + range: null, + hasOlder: false, + }); + viewed.dispose(); + }); + + it.each(["cache-first", "live-first"])( + "%s keeps new-epoch activity above an old saved baseline", + async (ordering) => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + disk.commitTimeline(host, agentId, saved()); + await disk.flush(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const { replica, viewed } = owner({ + readTimeline: async (serverId, id) => { + const result = await disk.readTimeline(serverId, id); + await gate; + return result; + }, + commitTimeline: (...args) => disk.commitTimeline(...args), + removeTimeline: (...args) => disk.removeTimeline(...args), + }); + const preparation = replica.prepare(agentId); + if (ordering === "cache-first") { + release(); + await preparation; + } + viewed.enqueueStreamEvent(agentId, { + seq: 1, + epoch: "epoch-2", + timestamp: new Date(timestamp), + event: { + type: "timeline", + provider: "claude", + item: { type: "assistant_message", messageId: "new", text: "new activity" }, + }, + }); + viewed.flushStreamAgent(agentId); + release(); + await preparation; + const session = useSessionStore.getState().sessions[host]; + expect( + [ + ...(session?.agentStreamTail.get(agentId) ?? []), + ...(session?.agentStreamHead.get(agentId) ?? []), + ].map((item) => ("text" in item ? item.text : null)), + ).toEqual(["new activity"]); + await disk.flush(); + expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual(saved().range); + viewed.applyTimelineResponse({ ...page(1, 1, false, "epoch-2"), reset: true }); + await disk.flush(); + expect((await cache(rows).readTimeline(host, agentId))?.range?.epoch).toBe("epoch-2"); + viewed.dispose(); + }, + ); + + it.each(["live-before-cache", "live-after-cache", "queued-before-page"])( + "%s preserves a certified restart after every catch-up transition", + async (ordering) => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + disk.commitTimeline(host, agentId, saved()); + await disk.flush(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const { replica, viewed } = owner({ + removeTimeline: () => undefined, + readTimeline: async (serverId, id) => { + const snapshot = await disk.readTimeline(serverId, id); + await gate; + return snapshot; + }, + commitTimeline: (serverId, id, value, requireCertified) => + disk.commitTimeline(serverId, id, value, requireCertified), + }); + const preparation = replica.prepare(agentId); + const restartAt = async (endSeq: number) => { + await disk.flush(); + const reopened = await cache(rows).readTimeline(host, agentId); + const startSeq = endSeq === 80 ? 1 : endSeq - 39; + expect(reopened?.range).toEqual({ epoch: "epoch-1", startSeq, endSeq }); + expect(reopened?.items.map((row) => row.timelineCursor?.seq)).toEqual( + Array.from({ length: endSeq - startSeq + 1 }, (_, i) => i + startSeq), + ); + }; + if (ordering === "live-before-cache") { + live(viewed, 83); + viewed.flushStreamAgent(agentId); + await restartAt(80); + } + release(); + await preparation; + await restartAt(80); + if (ordering !== "live-before-cache") live(viewed, 83); + if (ordering === "live-after-cache") viewed.flushStreamAgent(agentId); + await restartAt(80); + // Owner flushes the queued live batch before accepting the page. + viewed.applyTimelineResponse(page(81, 81, true)); + await restartAt(81); + viewed.applyTimelineResponse(page(82, 82)); + await restartAt(82); + viewed.applyTimelineResponse(page(83, 83)); + await restartAt(83); + const session = useSessionStore.getState().sessions[host]; + expect( + [ + ...(session?.agentStreamTail.get(agentId) ?? []), + ...(session?.agentStreamHead.get(agentId) ?? []), + ].map((row) => row.timelineCursor?.seq), + ).toEqual(Array.from({ length: 83 }, (_, i) => i + 1)); + viewed.dispose(); + replica.dispose(); + }, + ); + + it("settles local identity and attachments through the same publication and restart boundary", async () => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + disk.commitTimeline(host, agentId, saved()); + await disk.flush(); + const { replica, viewed } = owner(disk); + await replica.prepare(agentId); + const message = createUserMessage({ + clientMessageId: "submission", + text: "local prompt", + timestamp: new Date(timestamp), + attachments: [{ type: "text", mimeType: "text/plain", text: "attachment" }], + images: [ + { + id: "image", + mimeType: "image/png", + storageType: "native-file", + storageKey: "/saved/image.png", + createdAt: 1, + }, + ], + }); + replica.beginSubmission(agentId, message, true); + await disk.flush(); + expect((await cache(rows).readTimeline(host, agentId))?.items).toHaveLength(80); + const canonical = page(81, 81); + canonical.entries[0].item = { + type: "user_message", + text: "canonical prompt", + messageId: "submission", + }; + viewed.applyTimelineResponse(canonical); + expect(replica.rejectSubmission(agentId, "submission", true)).toBe("accepted"); + await disk.flush(); + expect( + useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)?.at(-1), + ).toMatchObject({ + id: message.id, + text: "local prompt", + attachments: message.attachments, + }); + const restored = await cache(rows).readTimeline(host, agentId); + expect(restored?.range).toEqual({ epoch: "epoch-1", startSeq: 42, endSeq: 81 }); + expect(restored?.items.at(-1)).toMatchObject({ + attachments: message.attachments, + images: message.images, + }); + replica.beginSubmission( + agentId, + createUserMessage({ + clientMessageId: "rejected", + text: "rejected", + timestamp: new Date(timestamp), + }), + true, + ); + expect(replica.rejectSubmission(agentId, "rejected", true)).toBe("rejected"); + await disk.flush(); + expect((await cache(rows).readTimeline(host, agentId))?.items).toHaveLength(40); + viewed.dispose(); + }); + + it("ends removed work, accepts a fresh lifetime, and isolates the other host", async () => { + useSessionStore.getState().initializeSession(host, null); + useSessionStore.getState().initializeSession(otherHost, null); + const rows = new MemoryRows(); + const disk = cache(rows); + disk.commitTimeline(host, agentId, saved()); + disk.commitTimeline(otherHost, agentId, saved()); + await disk.flush(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const removed = owner({ + removeTimeline: (serverId, id) => disk.removeTimeline(serverId, id), + readTimeline: async () => { + await gate; + return saved(); + }, + commitTimeline: (serverId, id, value, requireCertified) => + disk.commitTimeline(serverId, id, value, requireCertified), + }); + const other = owner(disk, otherHost); + const pending = removed.replica.prepare(agentId); + live(removed.viewed, 81); + removed.replica.remove(agentId, "deleted"); + release(); + await pending; + removed.viewed.applyTimelineResponse(page(81, 81)); + removed.viewed.dispose(); + await disk.flush(); + expect(await cache(rows).readTimeline(host, agentId)).toBeUndefined(); + expect(useSessionStore.getState().sessions[host]?.agentStreamTail.has(agentId)).toBe(false); + expect(useSessionStore.getState().sessions[host]?.agentStreamHead.has(agentId)).toBe(false); + removed.replica.acceptAgent(agentId); + removed.viewed.applyTimelineResponse({ + ...page(1, 1, false, "epoch-2"), + direction: "tail", + reset: true, + }); + await disk.flush(); + expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual({ + epoch: "epoch-2", + startSeq: 1, + endSeq: 1, + }); + await other.replica.prepare(agentId); + other.viewed.applyTimelineResponse(page(81, 81)); + await disk.flush(); + expect((await cache(rows).readTimeline(otherHost, agentId))?.range?.endSeq).toBe(81); + other.viewed.dispose(); + }); + + it("replaces an old epoch with a smaller verified epoch while retaining pending local submissions", async () => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + disk.commitTimeline(host, agentId, saved()); + await disk.flush(); + const { replica, viewed } = owner(disk); + await replica.prepare(agentId); + const local = createUserMessage({ + clientMessageId: "pending", + text: "pending", + timestamp: new Date(timestamp), + }); + replica.beginSubmission(agentId, local, true); + viewed.applyTimelineResponse({ + ...page(1, 1, false, "epoch-2"), + direction: "tail", + reset: true, + hasOlder: false, + }); + await disk.flush(); + const reopened = await cache(rows).readTimeline(host, agentId); + expect(reopened?.range).toEqual({ epoch: "epoch-2", startSeq: 1, endSeq: 1 }); + expect(reopened?.items).toHaveLength(1); + expect(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)).toContainEqual( + local, + ); + viewed.dispose(); + }); + it("keeps pending submissions across a live epoch reset", async () => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + const { replica, viewed } = owner(disk); + viewed.applyTimelineResponse({ + ...page(1, 1), + direction: "tail", + reset: true, + hasOlder: false, + }); + const local = createUserMessage({ + clientMessageId: "pending", + text: "pending", + timestamp: new Date(timestamp), + }); + replica.beginSubmission(agentId, local, true); + replica.applyEvents( + agentId, + [ + { + seq: 1, + epoch: "epoch-2", + timestamp: new Date(timestamp), + event: { + type: "timeline", + provider: "claude", + item: { type: "assistant_message", text: "new epoch", messageId: "new" }, + }, + }, + ], + () => undefined, + ); + const session = useSessionStore.getState().sessions[host]; + expect([ + ...(session?.agentStreamTail.get(agentId) ?? []), + ...(session?.agentStreamHead.get(agentId) ?? []), + ]).toContainEqual(local); + await disk.flush(); + expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual({ + epoch: "epoch-2", + startSeq: 1, + endSeq: 1, + }); + viewed.dispose(); + }); + + it("a removal during publication wins over the transition's write-behind", async () => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + const { replica, viewed } = owner(disk); + let removed = false; + const stop = useSessionStore.subscribe((state) => { + if (!removed && state.sessions[host]?.agentTimelineCursor.has(agentId)) { + removed = true; + replica.remove(agentId, "deleted"); + } + }); + viewed.applyTimelineResponse({ ...page(1, 1), direction: "tail", reset: true }); + stop(); + await disk.flush(); + expect(await cache(rows).readTimeline(host, agentId)).toBeUndefined(); + expect(useSessionStore.getState().sessions[host]?.agentStreamTail.has(agentId)).toBe(false); + viewed.dispose(); + }); + + it("keeps a certified attachment-bearing replacement above an unread baseline", async () => { + useSessionStore.getState().initializeSession(host, null); + const rows = new MemoryRows(); + const disk = cache(rows); + disk.commitTimeline(host, agentId, saved()); + await disk.flush(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const { replica, viewed } = owner({ + removeTimeline: (serverId, id) => disk.removeTimeline(serverId, id), + readTimeline: async () => { + await gate; + return saved(); + }, + commitTimeline: (serverId, id, value, requireCertified) => + disk.commitTimeline(serverId, id, value, requireCertified), + }); + const pending = replica.prepare(agentId); + const local = createUserMessage({ + clientMessageId: "attachment", + text: "prompt", + timestamp: new Date(timestamp), + attachments: [{ type: "text", text: "data", mimeType: "text/plain" }], + }); + replica.beginSubmission(agentId, local, true); + const response = { ...page(81, 81), direction: "tail" as const, reset: true }; + response.entries[0].item = { type: "user_message", messageId: "attachment", text: "prompt" }; + viewed.applyTimelineResponse(response); + await disk.flush(); + expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual({ + epoch: "epoch-1", + startSeq: 81, + endSeq: 81, + }); + release(); + await pending; + await disk.flush(); + expect((await cache(rows).readTimeline(host, agentId))?.items.at(-1)).toMatchObject({ + attachments: local.attachments, + }); + viewed.dispose(); + }); +}); diff --git a/packages/app/src/timeline/replica.test.ts b/packages/app/src/timeline/replica.test.ts index 53d4710347..c43548a385 100644 --- a/packages/app/src/timeline/replica.test.ts +++ b/packages/app/src/timeline/replica.test.ts @@ -1,28 +1,90 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { + createTimelineReplica, + type TimelineReplicaStorage, + type TimelineResponsePayload, + type TimelineReplica, +} from "./replica"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import type { CachedTimeline } from "@/runtime/replica-cache"; import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; import type { StreamItem } from "@/types/stream"; import { - createTimelineReplica, createViewedTimelineOwner, - type TimelineReplicaStorage, + type TimelinePageResult, type ViewedTimelineOwner, } from "./viewed-timeline-sync"; +import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan"; const SERVER_ID = "timeline-replica-host"; const AGENT_ID = "agent-1"; -function item(id: string, text: string, seq: number): StreamItem { +function item(id: string, text: string, seq: number, messageId?: string): StreamItem { return { kind: "assistant_message", id, + ...(messageId ? { messageId } : {}), text, timestamp: new Date("2026-08-26T10:00:00.000Z"), timelineCursor: { epoch: "epoch-1", seq }, }; } +function paintLive( + replica: TimelineReplica, + text: string, + seq: number, + messageId = `message-${seq}`, +): void { + replica.applyEvents( + AGENT_ID, + [ + { + seq, + epoch: "epoch-1", + timestamp: new Date("2026-08-26T10:00:00.000Z"), + event: { + type: "timeline", + provider: "claude", + item: { type: "assistant_message", text, messageId }, + }, + }, + ], + () => undefined, + ); +} + +function pageAt(seq: number): TimelineResponsePayload { + return { + requestId: `network-${seq}`, + agentId: AGENT_ID, + agent: null, + direction: "tail", + projection: "projected", + reset: false, + epoch: "epoch-1", + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: seq, nextSeq: seq + 1 }, + startCursor: { epoch: "epoch-1", seq: 1 }, + endCursor: { epoch: "epoch-1", seq }, + entries: [ + { + provider: "claude", + item: { type: "assistant_message", text: "network", messageId: "network" }, + timestamp: "2026-08-26T10:00:00.000Z", + seqStart: 1, + seqEnd: seq, + sourceSeqRanges: [{ startSeq: 1, endSeq: seq }], + collapsed: [], + }, + ], + hasOlder: false, + hasNewer: false, + error: null, + }; +} + function cachedTimeline(): CachedTimeline { return { agentId: AGENT_ID, @@ -36,7 +98,6 @@ function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner { const replica = createTimelineReplica({ serverId: SERVER_ID, storage, - prepareAgent: async () => undefined, }); return createViewedTimelineOwner({ serverId: SERVER_ID, @@ -46,7 +107,6 @@ function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner { ports: { initialDeliveryMode: "legacy", setSubscription: async () => undefined, - readCursor: () => undefined, fetchPage: async () => ({ hasNewer: false, endCursor: null }), fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }), reportError: () => undefined, @@ -55,21 +115,379 @@ function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner { }); } -function applySynced(agentId: string, seq: number): void { - useSessionStore.getState().applyAgentTimelineResponseState(SERVER_ID, agentId, { - items: [item(`network-${agentId}`, "network", seq)], - head: [], - range: { epoch: "epoch-1", startSeq: 1, endSeq: seq }, - older: "available", - newer: false, - synchronized: true, - acknowledgedClientMessageIds: [], - }); -} - afterEach(() => useSessionStore.getState().clearSession(SERVER_ID)); describe("viewed timeline persistence", () => { + it("keeps verified history when a final page predates displayed live activity", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + let durable = cachedTimeline(); + const owner = createOwner({ + removeTimeline: () => undefined, + readTimeline: async () => durable, + commitTimeline: (_host, _agent, value) => { + durable = value; + }, + }); + owner.registerVisibleAgentIds("test", [AGENT_ID]); + await vi.waitFor(() => + expect( + selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID).status, + ).toBe("painted"), + ); + owner.enqueueStreamEvent(AGENT_ID, { + seq: 7, + epoch: "epoch-1", + timestamp: new Date("2026-09-06T10:00:00.000Z"), + event: { + type: "timeline", + provider: "claude", + item: { + type: "assistant_message", + text: "future live", + messageId: "future", + }, + }, + }); + owner.flushStreamAgent(AGENT_ID); + expect(durable.range).toEqual(cachedTimeline().range); + owner.applyTimelineResponse({ + requestId: "old-final", + agentId: AGENT_ID, + agent: null, + direction: "after", + projection: "projected", + reset: false, + epoch: "epoch-1", + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: 5, nextSeq: 6 }, + startCursor: { epoch: "epoch-1", seq: 5 }, + endCursor: { epoch: "epoch-1", seq: 5 }, + hasOlder: true, + hasNewer: false, + error: null, + entries: [ + { + provider: "claude", + item: { type: "assistant_message", text: "verified", messageId: "verified" }, + timestamp: "2026-09-06T10:00:00.000Z", + seqStart: 5, + seqEnd: 5, + sourceSeqRanges: [{ startSeq: 5, endSeq: 5 }], + collapsed: [], + }, + ], + }); + expect(durable.range).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 5 }); + expect(durable.items.map((row) => row.timelineCursor?.seq)).toEqual([4, 5]); + owner.dispose(); + }); + + it("recovers the final live response when an in-flight gap page predates turn completion", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + let durable: CachedTimeline | undefined; + const pending: Array<{ + request: ProjectedTimelineForwardFetchPlan; + respond(payload: TimelineResponsePayload): void; + }> = []; + const replica = createTimelineReplica({ + serverId: SERVER_ID, + storage: { + removeTimeline: () => undefined, + readTimeline: async () => undefined, + commitTimeline: (_serverId, _agentId, timeline) => { + durable = timeline; + }, + }, + }); + const owner = createViewedTimelineOwner({ + serverId: SERVER_ID, + replica, + replaceDemandedAgentIds: () => undefined, + drainQueuedAgentMessage: () => undefined, + ports: { + initialDeliveryMode: "legacy", + setSubscription: async () => undefined, + fetchPage: (_agentId, request) => + new Promise((resolve) => { + pending.push({ + request, + respond(payload) { + owner.applyTimelineResponse(payload); + resolve(payload); + }, + }); + }), + fetchLatestTail: async () => { + throw new Error("A contiguous forward gap does not require a reset"); + }, + reportError: (error) => { + throw error; + }, + schedule: () => () => undefined, + }, + }); + const page = (startSeq: number, endSeq: number, text: string): TimelineResponsePayload => ({ + requestId: `page-${endSeq}`, + agentId: AGENT_ID, + agent: null, + direction: "after", + projection: "projected", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: endSeq, nextSeq: endSeq + 1 }, + startCursor: { epoch: "epoch-1", seq: startSeq }, + endCursor: { epoch: "epoch-1", seq: endSeq }, + hasOlder: true, + hasNewer: false, + entries: [ + { + provider: "claude", + item: { type: "assistant_message", text, messageId: `message-${endSeq}` }, + timestamp: "2026-09-06T13:06:04.431Z", + seqStart: startSeq, + seqEnd: endSeq, + sourceSeqRanges: [{ startSeq, endSeq }], + collapsed: [], + }, + ], + error: null, + }); + const live = (seq: number, text: string) => { + owner.enqueueStreamEvent(AGENT_ID, { + seq, + epoch: "epoch-1", + timestamp: new Date("2026-09-06T13:06:12.797Z"), + event: { + type: "timeline", + provider: "claude", + item: { type: "assistant_message", text, messageId: `message-${seq}` }, + }, + }); + owner.flushStreamAgent(AGENT_ID); + }; + + try { + owner.applyTimelineResponse(pageAt(265)); + owner.setConnected(true); + owner.registerVisibleAgentIds("workspace", [AGENT_ID]); + await vi.waitFor(() => expect(pending).toHaveLength(1)); + expect(pending[0].request).toMatchObject({ direction: "after", cursor: { seq: 265 } }); + + // The server has already snapshotted through 276, but that response is still in transit. + live(276, "before final response"); + live(284, "final response"); + owner.enqueueStreamEvent(AGENT_ID, { + seq: undefined, + epoch: undefined, + timestamp: new Date("2026-09-06T13:06:12.804Z"), + event: { type: "turn_completed", provider: "claude" }, + }); + owner.flushStreamAgent(AGENT_ID); + expect(pending).toHaveLength(1); + pending[0].respond(page(266, 276, "before final response")); + + // No more live events arrive. The owner must fetch the known missing end itself. + await vi.waitFor(() => expect(pending).toHaveLength(2)); + expect(pending[1].request).toMatchObject({ direction: "after", cursor: { seq: 276 } }); + pending[1].respond(page(277, 284, "final response")); + await vi.waitFor(() => expect(owner.getAgentTimelineStatus(AGENT_ID)).toBe("ready")); + const session = useSessionStore.getState().sessions[SERVER_ID]; + expect([ + ...(session?.agentStreamTail.get(AGENT_ID) ?? []), + ...(session?.agentStreamHead.get(AGENT_ID) ?? []), + ]).toMatchObject([ + { text: "network" }, + { text: "before final response" }, + { text: "final response" }, + ]); + expect(durable?.range).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 284 }); + expect(durable?.items.at(-1)).toMatchObject({ text: "final response" }); + } finally { + owner.dispose(); + } + }); + + it("paints a display-only cache when live rows arrive during the disk read", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + let release!: (value: CachedTimeline) => void; + const read = new Promise((resolve) => { + release = resolve; + }); + const replica = createTimelineReplica({ + serverId: SERVER_ID, + storage: { + removeTimeline: () => undefined, + readTimeline: () => read, + commitTimeline: () => undefined, + }, + }); + const preparation = replica.prepare(AGENT_ID); + paintLive(replica, "live", 5); + const liveRows = useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID); + release({ ...cachedTimeline(), range: null }); + await preparation; + const session = useSessionStore.getState().sessions[SERVER_ID]; + expect(selectAgentTimelineState(session, AGENT_ID)).toEqual({ + status: "painted", + items: cachedTimeline().items, + }); + expect(session?.agentStreamHead.get(AGENT_ID)).toEqual(liveRows); + expect(replica.readCursor(AGENT_ID)).toBeUndefined(); + }); + + it("reconciles overlapping display-only rows without duplicating saved messages", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const saved = [item("one", "first", 3), item("two", "second", 4)]; + const replica = createTimelineReplica({ + serverId: SERVER_ID, + storage: { + removeTimeline: () => undefined, + readTimeline: async () => ({ ...cachedTimeline(), items: saved, range: null }), + commitTimeline: () => undefined, + }, + }); + paintLive(replica, "first", 3, "one"); + paintLive(replica, "second", 4, "two"); + paintLive(replica, "live", 5, "live"); + const painted = useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID); + await replica.prepare(AGENT_ID); + const session = useSessionStore.getState().sessions[SERVER_ID]; + expect([ + ...(session?.agentStreamTail.get(AGENT_ID) ?? []), + ...(session?.agentStreamHead.get(AGENT_ID) ?? []), + ]).toEqual([saved[0], ...(painted?.slice(1) ?? [])]); + expect(replica.readCursor(AGENT_ID)).toBeUndefined(); + }); + + it("retains an assistant continuation while restoring display-only history", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const replica = createTimelineReplica({ + serverId: SERVER_ID, + storage: { + removeTimeline: () => undefined, + readTimeline: async () => ({ + ...cachedTimeline(), + items: [item("cached", "cached", 4, "msg-1")], + range: null, + }), + commitTimeline: () => undefined, + }, + }); + // The stream resumed mid-message after the save, so the live row holds only the suffix. + paintLive(replica, " and still streaming", 5, "msg-1"); + await replica.prepare(AGENT_ID); + const session = useSessionStore.getState().sessions[SERVER_ID]; + expect([ + ...(session?.agentStreamTail.get(AGENT_ID) ?? []), + ...(session?.agentStreamHead.get(AGENT_ID) ?? []), + ]).toMatchObject([{ id: "msg-1", messageId: "msg-1", text: "cached and still streaming" }]); + expect(replica.readCursor(AGENT_ID)).toBeUndefined(); + }); + + it("reopens a bootstrapped window with tools in the order they first appeared", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + let durable: CachedTimeline | undefined; + const storage: TimelineReplicaStorage = { + removeTimeline: () => undefined, + readTimeline: async () => durable, + commitTimeline: (_serverId, _agentId, timeline) => { + durable = timeline; + }, + }; + const owner = createOwner(storage); + const tool = (callId: string, seqStart: number, seqEnd: number) => ({ + provider: "claude", + item: { + type: "tool_call" as const, + callId, + name: "Bash", + status: "completed" as const, + detail: { type: "unknown" as const, input: null, output: null }, + error: null, + }, + timestamp: "2026-08-26T10:00:00.000Z", + seqStart, + seqEnd, + sourceSeqRanges: [ + { startSeq: seqStart, endSeq: seqStart }, + { startSeq: seqEnd, endSeq: seqEnd }, + ], + collapsed: ["tool_lifecycle" as const], + }); + owner.applyTimelineResponse({ + requestId: "bootstrap", + agentId: AGENT_ID, + agent: null, + direction: "tail", + projection: "projected", + reset: true, + epoch: "epoch-1", + window: { minSeq: 1, maxSeq: 700, nextSeq: 701 }, + startCursor: { epoch: "epoch-1", seq: 675 }, + endCursor: { epoch: "epoch-1", seq: 700 }, + entries: [ + { + provider: "claude", + item: { type: "assistant_message", text: "Starting", messageId: "msg-a" }, + timestamp: "2026-08-26T10:00:00.000Z", + seqStart: 675, + seqEnd: 675, + sourceSeqRanges: [{ startSeq: 675, endSeq: 675 }], + collapsed: [], + }, + tool("first", 676, 700), + tool("second", 677, 690), + { + provider: "claude", + item: { type: "assistant_message", text: "Done", messageId: "msg-b" }, + timestamp: "2026-08-26T10:00:00.000Z", + seqStart: 695, + seqEnd: 695, + sourceSeqRanges: [{ startSeq: 695, endSeq: 695 }], + collapsed: [], + }, + ], + error: null, + hasNewer: false, + hasOlder: true, + staleCursor: false, + gap: false, + }); + const startsOf = (rows: StreamItem[]) => + rows.map((row) => `${row.kind}:${row.source?.startSeq}`); + const synced = selectAgentTimelineState( + useSessionStore.getState().sessions[SERVER_ID], + AGENT_ID, + ); + expect(synced.status === "synced" && startsOf(synced.items)).toEqual([ + "assistant_message:675", + "tool_call:676", + "tool_call:677", + "assistant_message:695", + ]); + expect(durable?.range).toEqual({ epoch: "epoch-1", startSeq: 675, endSeq: 700 }); + owner.dispose(); + useSessionStore.getState().clearSession(SERVER_ID); + useSessionStore.getState().initializeSession(SERVER_ID, null); + + const reopened = createTimelineReplica({ serverId: SERVER_ID, storage }); + await reopened.prepare(AGENT_ID); + const painted = selectAgentTimelineState( + useSessionStore.getState().sessions[SERVER_ID], + AGENT_ID, + ); + expect(painted.status === "painted" && startsOf(painted.items)).toEqual([ + "assistant_message:675", + "tool_call:676", + "tool_call:677", + "assistant_message:695", + ]); + expect(reopened.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 700 }); + }); + it("shares an in-flight cache preparation with the viewed owner", async () => { useSessionStore.getState().initializeSession(SERVER_ID, null); let release!: (value: CachedTimeline) => void; @@ -80,13 +498,13 @@ describe("viewed timeline persistence", () => { const replica = createTimelineReplica({ serverId: SERVER_ID, storage: { + removeTimeline: () => undefined, readTimeline: () => { reads += 1; return read; }, commitTimeline: () => undefined, }, - prepareAgent: async () => undefined, }); const routePreparation = replica.prepare(AGENT_ID); @@ -101,11 +519,12 @@ describe("viewed timeline persistence", () => { it("paints cached history without claiming authoritative synchronization", async () => { useSessionStore.getState().initializeSession(SERVER_ID, null); const owner = createOwner({ + removeTimeline: () => undefined, readTimeline: async () => cachedTimeline(), commitTimeline: () => undefined, }); - owner.replaceVisibleAgentIds("test", [AGENT_ID]); + owner.registerVisibleAgentIds("test", [AGENT_ID]); await expect .poll(() => @@ -120,6 +539,7 @@ describe("viewed timeline persistence", () => { const partial = "```mermaid\nflowchart LR\n Start --> Mid"; const complete = `${partial}dle\n Middle --> Done\n\`\`\``; const owner = createOwner({ + removeTimeline: () => undefined, readTimeline: async () => ({ agentId: AGENT_ID, items: [item("cached", partial, 4)], @@ -129,7 +549,7 @@ describe("viewed timeline persistence", () => { commitTimeline: () => undefined, }); - owner.replaceVisibleAgentIds("test", [AGENT_ID]); + owner.registerVisibleAgentIds("test", [AGENT_ID]); await expect .poll(() => selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), @@ -173,18 +593,114 @@ describe("viewed timeline persistence", () => { owner.dispose(); }); - it("reopens painted live mutations without persisting authoritative coverage", async () => { + it("retains the certified baseline across a close during live catch-up", async () => { useSessionStore.getState().initializeSession(SERVER_ID, null); - let durable: CachedTimeline | undefined = cachedTimeline(); + const savedItems = Array.from({ length: 80 }, (_, index) => + item(`saved-${index + 1}`, `saved ${index + 1}`, index + 1, `msg-${index + 1}`), + ); + let durable: CachedTimeline = { + agentId: AGENT_ID, + items: savedItems, + range: { epoch: "epoch-1", startSeq: 1, endSeq: 80 }, + hasOlder: false, + }; + const storage: TimelineReplicaStorage = { + removeTimeline: () => undefined, + readTimeline: async () => durable, + commitTimeline: (_serverId, _agentId, timeline) => { + durable = timeline; + }, + }; + const first = createOwner(storage); + first.registerVisibleAgentIds("test", [AGENT_ID]); + await expect + .poll(() => + selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), + ) + .toMatchObject({ status: "painted" }); + + // The app receives new live output before its delayed catch-up page fills seq 81. + first.enqueueStreamEvent(AGENT_ID, { + event: { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "live suffix", messageId: "live" }, + } as AgentStreamEventPayload, + seq: 82, + epoch: "epoch-1", + timestamp: new Date("2026-08-26T10:00:01.000Z"), + }); + first.flushStreamAgent(AGENT_ID); + first.dispose(); + useSessionStore.getState().clearSession(SERVER_ID); + useSessionStore.getState().initializeSession(SERVER_ID, null); + + const reopened = createTimelineReplica({ serverId: SERVER_ID, storage }); + await reopened.prepare(AGENT_ID); + + // Saved coverage survives process death; unseen seq 81 is still fetched after 80. + expect(reopened.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 80 }); + const painted = selectAgentTimelineState( + useSessionStore.getState().sessions[SERVER_ID], + AGENT_ID, + ); + expect(painted).toMatchObject({ status: "painted" }); + expect(painted.status === "painted" && painted.items.map((entry) => entry.id)).toEqual( + expect.arrayContaining(savedItems.map((entry) => entry.id)), + ); + }); + + it("keeps the certified snapshot while painted live mutations wait for catch-up", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const commits: CachedTimeline[] = []; + const storage: TimelineReplicaStorage = { + removeTimeline: () => undefined, + readTimeline: async () => cachedTimeline(), + commitTimeline: (_serverId, _agentId, timeline) => { + commits.push(timeline); + }, + }; + const first = createOwner(storage); + first.registerVisibleAgentIds("test", [AGENT_ID]); + await expect + .poll(() => + selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), + ) + .toMatchObject({ status: "painted" }); + + first.enqueueStreamEvent(AGENT_ID, { + event: { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "live", messageId: "live" }, + } as AgentStreamEventPayload, + seq: 5, + epoch: "epoch-1", + timestamp: new Date("2026-08-26T10:00:01.000Z"), + }); + first.flushStreamAgent(AGENT_ID); + + // The live row is painted but never certified, and the saved certificate is untouched. + expect( + useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID), + ).toMatchObject([{ text: "live" }]); + expect(commits).toEqual([]); + first.dispose(); + }); + + it("reopens painted live mutations of a display-only cache without certifying them", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + let durable: CachedTimeline | undefined = { ...cachedTimeline(), range: null }; let pending: CachedTimeline | undefined; const storage: TimelineReplicaStorage = { + removeTimeline: () => undefined, readTimeline: async () => durable, commitTimeline: (_serverId, _agentId, timeline) => { pending = timeline; }, }; const first = createOwner(storage); - first.replaceVisibleAgentIds("test", [AGENT_ID]); + first.registerVisibleAgentIds("test", [AGENT_ID]); await expect .poll(() => selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), @@ -211,7 +727,7 @@ describe("viewed timeline persistence", () => { useSessionStore.getState().initializeSession(SERVER_ID, null); const reopened = createOwner(storage); - reopened.replaceVisibleAgentIds("test", [AGENT_ID]); + reopened.registerVisibleAgentIds("test", [AGENT_ID]); await expect .poll(() => selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), @@ -226,6 +742,74 @@ describe("viewed timeline persistence", () => { reopened.dispose(); }); + it.each(["epoch-1", "obsolete-epoch"])( + "reconciles live rows from %s when display-only history finishes catch-up", + async (liveEpoch) => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const owner = createOwner({ + removeTimeline: () => undefined, + readTimeline: async () => ({ ...cachedTimeline(), range: null }), + commitTimeline: () => undefined, + }); + owner.registerVisibleAgentIds("test", [AGENT_ID]); + await expect + .poll( + () => + selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID) + .status, + ) + .toBe("painted"); + owner.enqueueStreamEvent(AGENT_ID, { + seq: 6, + epoch: liveEpoch, + timestamp: new Date("2026-08-26T10:00:00.000Z"), + event: { + type: "timeline", + provider: "claude", + item: { type: "assistant_message", text: "live after the snapshot", messageId: "live" }, + }, + }); + owner.flushStreamAgent(AGENT_ID); + const liveHead = useSessionStore + .getState() + .sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID); + owner.applyTimelineResponse({ + requestId: "display-only-catch-up", + agentId: AGENT_ID, + agent: null, + direction: "tail", + projection: "projected", + reset: true, + epoch: "epoch-1", + window: { minSeq: 1, maxSeq: 5, nextSeq: 6 }, + startCursor: { epoch: "epoch-1", seq: 5 }, + endCursor: { epoch: "epoch-1", seq: 5 }, + entries: [ + { + provider: "mock", + item: { type: "assistant_message", text: "canonical snapshot" }, + timestamp: "2026-08-26T10:00:00.000Z", + seqStart: 5, + seqEnd: 5, + sourceSeqRanges: [{ startSeq: 5, endSeq: 5 }], + collapsed: [], + }, + ], + error: null, + hasNewer: false, + hasOlder: true, + staleCursor: false, + gap: false, + }); + const session = useSessionStore.getState().sessions[SERVER_ID]; + expect(selectAgentTimelineState(session, AGENT_ID).status).toBe("synced"); + expect(session?.agentStreamHead.get(AGENT_ID) ?? []).toEqual( + liveEpoch === "epoch-1" ? liveHead : [], + ); + owner.dispose(); + }, + ); + it("does not let a late cache read overwrite newer network state", async () => { useSessionStore.getState().initializeSession(SERVER_ID, null); let release!: (value: CachedTimeline) => void; @@ -233,12 +817,13 @@ describe("viewed timeline persistence", () => { release = resolve; }); const owner = createOwner({ + removeTimeline: () => undefined, readTimeline: () => read, commitTimeline: () => undefined, }); - owner.replaceVisibleAgentIds("test", [AGENT_ID]); - applySynced(AGENT_ID, 8); + owner.registerVisibleAgentIds("test", [AGENT_ID]); + owner.applyTimelineResponse(pageAt(8)); release(cachedTimeline()); await expect @@ -258,16 +843,15 @@ describe("viewed timeline persistence", () => { const replica = createTimelineReplica({ serverId: SERVER_ID, storage: { + removeTimeline: () => undefined, readTimeline: () => read, commitTimeline: () => undefined, }, - prepareAgent: async () => undefined, }); const preparation = replica.prepare(AGENT_ID); - useSessionStore.getState().setAgentStreamState(SERVER_ID, AGENT_ID, { - head: [item("live", "live", 5)], - }); + paintLive(replica, "live", 5); + const liveRows = useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID); release(cachedTimeline()); await preparation; @@ -275,9 +859,9 @@ describe("viewed timeline persistence", () => { expect( selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), ).toEqual({ status: "painted", items: cachedTimeline().items }); - expect(useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID)).toEqual([ - item("live", "live", 5), - ]); + expect(useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID)).toEqual( + liveRows, + ); }); it("reconciles a live head that overlaps the cached canonical timeline", async () => { @@ -289,16 +873,23 @@ describe("viewed timeline persistence", () => { const replica = createTimelineReplica({ serverId: SERVER_ID, storage: { + removeTimeline: () => undefined, readTimeline: () => read, commitTimeline: () => undefined, }, - prepareAgent: async () => undefined, }); const preparation = replica.prepare(AGENT_ID); - useSessionStore.getState().setAgentStreamState(SERVER_ID, AGENT_ID, { - head: [item("cached", "cached", 4), item("live", "live", 5)], - }); + paintLive(replica, "cached", 4); + const cachedLive = useSessionStore + .getState() + .sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID) + ?.at(-1); + paintLive(replica, "live", 5); + const live = useSessionStore + .getState() + .sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID) + ?.at(-1); release(cachedTimeline()); await preparation; @@ -306,7 +897,7 @@ describe("viewed timeline persistence", () => { expect([ ...(session?.agentStreamTail.get(AGENT_ID) ?? []), ...(session?.agentStreamHead.get(AGENT_ID) ?? []), - ]).toEqual([item("cached", "cached", 4), item("live", "live", 5)]); + ]).toEqual([cachedLive, live]); expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 }); }); @@ -319,22 +910,18 @@ describe("viewed timeline persistence", () => { const replica = createTimelineReplica({ serverId: SERVER_ID, storage: { + removeTimeline: () => undefined, readTimeline: () => read, commitTimeline: () => undefined, }, - prepareAgent: async () => undefined, }); const preparation = replica.prepare(AGENT_ID); - useSessionStore.getState().applyAgentTimelineResponseState(SERVER_ID, AGENT_ID, { - items: [item("live", "live", 5)], - head: [], - range: null, - older: "none", - newer: false, - synchronized: false, - acknowledgedClientMessageIds: [], - }); + paintLive(replica, "live", 5); + const live = useSessionStore + .getState() + .sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID) + ?.at(-1); release(cachedTimeline()); await preparation; @@ -342,18 +929,20 @@ describe("viewed timeline persistence", () => { expect([ ...(session?.agentStreamTail.get(AGENT_ID) ?? []), ...(session?.agentStreamHead.get(AGENT_ID) ?? []), - ]).toEqual([item("cached", "cached", 4), item("live", "live", 5)]); + ]).toEqual([item("cached", "cached", 4), live]); expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 }); }); it("persists accepted live stream commits through the owner", () => { useSessionStore.getState().initializeSession(SERVER_ID, null); - applySynced(AGENT_ID, 8); const commits: CachedTimeline[] = []; const owner = createOwner({ + removeTimeline: () => undefined, readTimeline: async () => undefined, commitTimeline: (_serverId, _agentId, timeline) => commits.push(timeline), }); + owner.applyTimelineResponse(pageAt(8)); + commits.length = 0; owner.enqueueStreamEvent(AGENT_ID, { event: { type: "timeline", @@ -375,6 +964,7 @@ describe("viewed timeline persistence", () => { useSessionStore.getState().initializeSession(SERVER_ID, null); const keys: string[] = []; const owner = createOwner({ + removeTimeline: () => undefined, readTimeline: async () => undefined, commitTimeline: (_serverId, agentId) => keys.push(agentId), }); @@ -409,12 +999,14 @@ describe("viewed timeline persistence", () => { useSessionStore.getState().initializeSession(SERVER_ID, null); const keys: string[] = []; const owner = createOwner({ + removeTimeline: () => undefined, readTimeline: async () => undefined, commitTimeline: (_serverId, agentId) => keys.push(agentId), }); - applySynced(AGENT_ID, 8); - applySynced("agent-2", 3); + owner.applyTimelineResponse(pageAt(8)); + owner.applyTimelineResponse({ ...pageAt(3), agentId: "agent-2" }); + keys.length = 0; for (const [agentId, seq] of [ [AGENT_ID, 9], ["agent-2", 4], diff --git a/packages/app/src/timeline/replica.ts b/packages/app/src/timeline/replica.ts new file mode 100644 index 0000000000..b4ecb4ed43 --- /dev/null +++ b/packages/app/src/timeline/replica.ts @@ -0,0 +1,752 @@ +import type { CachedTimeline } from "@/runtime/replica-cache"; +import equal from "fast-deep-equal"; +import type { AgentRemovalReason } from "@/utils/agent-directory-sync"; +import { useSessionStore, type AgentTimelineCursorState } from "@/stores/session-store"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { + acceptMessageSubmission, + beginMessageSubmission, + getSendingClientMessageIds, + observeMessageSubmissionCanonical, + rejectMessageSubmission, + type MessageSubmissionRecord, + type MessageSubmissionRejectionOutcome, +} from "@/composer/submission/model"; +import { + appendSubmittedUserMessage, + handoffCreatedAgentUserMessageToStream, + removeSubmittedUserMessage, + replaceWithCanonicalStream, + isUnreconciledLocalUserMessage, + type StreamItem, + type UserMessageItem, +} from "@/types/stream"; +import { + getInitDeferred, + getInitKey, + rejectInitDeferred, + resolveInitDeferred, +} from "@/utils/agent-initialization"; +import { + processAgentStreamEvents, + processTimelineResponse, + processTimelineResponseCompletion, + type AgentStreamReducerEvent, + type AgentStreamReducerSnapshot, + type ProcessTimelineResponseOutput, +} from "./session-stream-reducers"; +import { isTimelineResumeSnapshotAuthoritative } from "./timeline-sync-plan"; +import { TIMELINE_FETCH_PAGE_SIZE } from "./timeline-fetch-policy"; + +export type TimelineResponsePayload = Extract< + SessionOutboundMessage, + { type: "fetch_agent_timeline_response" } +>["payload"]; + +export interface TimelinePageRequest { + cursor: { epoch: string; endSeq: number } | null; + replaceTail: boolean; + initialization: Promise | undefined; +} + +export interface TimelineReplicaStorage { + removeTimeline(serverId: string, agentId: string): void; + readTimeline(serverId: string, agentId: string): Promise; + commitTimeline( + serverId: string, + agentId: string, + timeline: CachedTimeline, + requireCertified?: boolean, + ): void; +} + +interface TimelineRows { + tail: StreamItem[]; + head: StreamItem[]; +} + +interface VerifiedTimeline extends TimelineRows { + range: AgentTimelineCursorState | null; + hasOlder: boolean; +} + +interface AgentTimeline extends TimelineRows { + // Pagination and coverage certainty do not change which older entries belong to the restart page. + restartStartSeq: number | null; + verified: VerifiedTimeline | null; + // Loaded positions support navigation even when the displayed content is uncertifiable. + displayRange: AgentTimelineCursorState | null; + older: boolean; + newer: boolean; + synchronized: boolean; + hasUnsequencedHistory: boolean; + submissions: MessageSubmissionRecord[]; + preparation?: Promise; + restored: boolean; + liveEpoch?: string; +} + +interface PageEffects { + recoverGap( + agentId: string, + cursor: { epoch: string; endSeq: number }, + observedSeq?: number, + ): void; + drainQueuedAgentMessage(agentId: string): void; +} + +function acceptLiveDisplayEpoch( + state: AgentTimeline, + events: AgentStreamReducerEvent[], +): AgentStreamReducerEvent[] { + if (state.synchronized) return events; + // A connection's live epoch owns the display even while its first page is pending. The saved + // epoch remains a restart baseline until a page establishes a replacement. + const epoch = events.findLast((event) => event.event.type === "timeline" && event.epoch)?.epoch; + if (!epoch) return events; + const previousEpoch = state.liveEpoch ?? state.displayRange?.epoch; + if (previousEpoch && previousEpoch !== epoch) { + state.tail = state.tail.filter( + (item) => item.kind === "user_message" && isUnreconciledLocalUserMessage(item), + ); + state.head = state.head.filter( + (item) => item.kind === "user_message" && isUnreconciledLocalUserMessage(item), + ); + state.older = false; + state.displayRange = null; + state.restartStartSeq = null; + state.hasUnsequencedHistory = false; + } + state.liveEpoch = epoch; + return events.filter((event) => !event.epoch || event.epoch === epoch); +} + +function applyVerifiedStreamEvents( + state: AgentTimeline, + events: AgentStreamReducerEvent[], + display: ReturnType, +): boolean { + const previous = state.verified; + const positioned = events.filter( + ({ event, seq, epoch }) => + event.type !== "timeline" || (typeof seq === "number" && typeof epoch === "string"), + ); + const unsequencedChanged = + positioned.length !== events.length && (display.changedTail || display.changedHead); + if (unsequencedChanged) state.hasUnsequencedHistory = true; + if (!previous || !state.synchronized) return unsequencedChanged; + const accepted = + positioned.length === events.length && + state.tail === previous.tail && + state.head === previous.head + ? display + : processAgentStreamEvents({ + events: positioned, + currentTail: previous.tail, + currentHead: previous.head, + currentCursor: previous.range ?? undefined, + hasAuthoritativeBaseline: true, + isDetached: state.newer, + }); + const cursor = accepted.cursor; + const changedEpoch = + cursor !== null && previous.range !== null && cursor.epoch !== previous.range.epoch; + if (changedEpoch) { + Object.assign(display, retainPendingSubmissions(display, state)); + if (accepted !== display) Object.assign(accepted, retainPendingSubmissions(accepted, state)); + state.older = false; + state.hasUnsequencedHistory = unsequencedChanged; + state.restartStartSeq = cursor.startSeq; + } + state.verified = { + tail: accepted.tail, + head: accepted.head, + range: accepted.cursorChanged ? cursor : previous.range, + hasOlder: changedEpoch ? false : previous.hasOlder, + }; + return ( + unsequencedChanged || accepted.changedTail || accepted.changedHead || accepted.cursorChanged + ); +} + +function applySubmissionRows( + state: AgentTimeline, + transition: (rows: TimelineRows) => TimelineRows, +): boolean { + const verified = state.verified; + const shared = verified && state.tail === verified.tail && state.head === verified.head; + const result = transition(state); + const changed = result.tail !== state.tail || result.head !== state.head; + if (verified) { + const accepted = shared ? result : transition(verified); + verified.tail = accepted.tail; + verified.head = accepted.head; + } + state.tail = result.tail; + state.head = result.head; + return changed; +} + +function isSupersededTail( + payload: TimelineResponsePayload, + current: AgentTimelineCursorState | null | undefined, + requestedFrom: { epoch: string; endSeq: number } | null | undefined, +): boolean { + if (payload.direction !== "tail" || payload.reset || payload.mergeWindow || payload.error) + return false; + // A head already behind the request's accepted cursor demonstrates a rewind. A head + // passed by activity accepted during that request is an obsolete reply instead. + return ( + requestedFrom !== undefined && + (requestedFrom === null || + requestedFrom.epoch !== payload.epoch || + payload.window.maxSeq >= requestedFrom.endSeq) && + current?.epoch === payload.epoch && + payload.window.maxSeq < current.endSeq + ); +} + +function invalidatesDurableBaseline( + payload: TimelineResponsePayload, + result: ProcessTimelineResponseOutput, + baseline: VerifiedTimeline | null, +): boolean { + const completeReplacement = + payload.direction === "tail" && + (payload.window.maxSeq < payload.window.minSeq || + (payload.startCursor?.seq === payload.window.minSeq && + payload.endCursor?.seq === payload.window.maxSeq)); + return Boolean( + result.commit === "apply" && + completeReplacement && + (payload.reset || + (baseline?.range && + (payload.epoch !== baseline.range.epoch || payload.window.maxSeq < baseline.range.endSeq))), + ); +} + +function applyAcceptedPageState(input: { + state: AgentTimeline; + payload: TimelineResponsePayload; + result: ProcessTimelineResponseOutput; + verified: VerifiedTimeline; + replaceTail: boolean; + synchronized: boolean; +}): void { + const { state, payload, result, verified, synchronized } = input; + const baseline = state.verified; + const range = verified.range; + const replacesWindow = + input.replaceTail || + payload.reset || + !baseline?.range || + !range || + baseline.range.epoch !== range.epoch || + range.endSeq < baseline.range.endSeq; + // Older pagination can extend an existing baseline, but cannot establish live coverage. + if (payload.direction !== "before" || baseline) { + if (replacesWindow) state.restartStartSeq = range?.startSeq ?? null; + state.verified = verified; + if (payload.direction !== "before") state.hasUnsequencedHistory = false; + } + state.tail = result.tail; + state.head = result.head; + if (result.cursorChanged) state.displayRange = result.cursor ?? null; + if (result.older !== "unchanged") state.older = result.older === "available"; + if (payload.direction !== "before") state.newer = payload.hasNewer; + if (synchronized) state.liveEpoch = payload.epoch; + state.synchronized ||= synchronized; + state.submissions = observeMessageSubmissionCanonical( + state.submissions, + result.acknowledgedClientMessageIds, + ); +} + +/** Host-lived write authority. Zustand is its UI projection; disk is write-behind. */ +export class TimelineReplica { + private readonly agents = new Map(); + private readonly removed = new Set(); + private disposed = false; + + constructor( + private serverId: string, + private readonly storage: TimelineReplicaStorage, + ) {} + + private state(agentId: string): AgentTimeline { + const existing = this.agents.get(agentId); + if (existing) return existing; + const state: AgentTimeline = { + tail: [], + head: [], + older: false, + newer: false, + synchronized: false, + hasUnsequencedHistory: false, + restored: false, + restartStartSeq: null, + verified: null, + displayRange: null, + submissions: [], + }; + this.agents.set(agentId, state); + return state; + } + + private accepts(agentId: string): boolean { + return !this.disposed && !this.removed.has(agentId); + } + + prepare(agentId: string): Promise { + if (!this.accepts(agentId)) return Promise.resolve(); + const state = this.state(agentId); + state.preparation ??= this.restore(agentId, state); + return state.preparation; + } + + private async restore(agentId: string, state: AgentTimeline): Promise { + const serverId = this.serverId; + const stored = await this.storage.readTimeline(serverId, agentId); + if (!this.accepts(agentId) || this.agents.get(agentId) !== state) return; + // Identity transfer retires the old scoped read, even if it captured rows before rename. + if (serverId !== this.serverId) return this.restore(agentId, state); + state.restored = true; + if (state.verified || !stored) { + this.publish(agentId, state, { + persist: Boolean(state.verified) || state.hasUnsequencedHistory, + requireCertified: Boolean(stored?.range), + }); + return; + } + const sameEpoch = !state.liveEpoch || !stored.range || state.liveEpoch === stored.range.epoch; + const replacement = replaceWithCanonicalStream({ + canonical: sameEpoch ? stored.items : [], + previousTail: state.tail, + previousHead: state.head, + sendingClientMessageIds: getSendingClientMessageIds(state.submissions), + preserveContinuity: true, + canonicalCoverage: + sameEpoch && stored.range + ? { epoch: stored.range.epoch, endSeq: stored.range.endSeq } + : "rows-only", + }); + Object.assign(state, { + tail: replacement.tail, + head: replacement.head, + older: sameEpoch && stored.hasOlder, + displayRange: sameEpoch ? stored.range : null, + }); + if (stored.range) { + state.restartStartSeq = stored.range.startSeq; + state.verified = { + tail: stored.items, + head: [], + range: stored.range, + hasOlder: stored.hasOlder, + }; + } + state.submissions = observeMessageSubmissionCanonical( + state.submissions, + replacement.acknowledgedClientMessageIds, + ); + this.publish(agentId, state, { + persist: state.hasUnsequencedHistory, + requireCertified: Boolean(stored.range), + }); + } + + readCursor(agentId: string): { epoch: string; endSeq: number } | undefined { + if (!this.accepts(agentId)) return undefined; + const range = this.state(agentId).verified?.range; + return range ? { epoch: range.epoch, endSeq: range.endSeq } : undefined; + } + + private streamSnapshot(agentId: string): AgentStreamReducerSnapshot { + const state = this.state(agentId); + return { + currentTail: state.tail, + currentHead: state.head, + currentCursor: state.displayRange ?? undefined, + // Before network synchronization, live activity can paint ahead of the saved range. + hasAuthoritativeBaseline: state.synchronized, + isDetached: state.synchronized && state.newer, + }; + } + + applyEvents( + agentId: string, + events: AgentStreamReducerEvent[], + recoverGap: PageEffects["recoverGap"], + ): void { + if (!this.accepts(agentId)) return; + const state = this.state(agentId); + events = acceptLiveDisplayEpoch(state, events); + const display = processAgentStreamEvents({ events, ...this.streamSnapshot(agentId) }); + const previous = state.verified; + const persist = applyVerifiedStreamEvents(state, events, display); + if (display.cursorChanged) state.displayRange = display.cursor; + state.tail = display.tail; + state.head = display.head; + state.submissions = observeMessageSubmissionCanonical( + state.submissions, + display.acknowledgedClientMessageIds, + ); + this.publish(agentId, state, { + persist: persist || !previous, + requireCertified: Boolean(previous?.range && state.verified), + }); + for (const effect of display.sideEffects) { + if (effect.type === "catch_up") recoverGap(agentId, effect.cursor, effect.observedSeq); + } + } + + applyPage( + payload: TimelineResponsePayload, + effects: PageEffects, + request?: TimelinePageRequest, + ): void { + const agentId = payload.agentId; + if (!this.accepts(agentId)) return; + const state = this.state(agentId); + const initKey = getInitKey(this.serverId, agentId); + const deferred = getInitDeferred(initKey); + const session = useSessionStore.getState().sessions[this.serverId]; + const ownsInitialization = !request || request.initialization === deferred?.promise; + const common = { + payload, + replaceTail: request?.replaceTail === true, + currentCursor: state.displayRange ?? undefined, + isInitializing: ownsInitialization && session?.initializingAgents.get(agentId) === true, + hasActiveInitDeferred: ownsInitialization && Boolean(deferred), + initRequestDirection: deferred?.requestDirection ?? ("tail" as const), + sendingClientMessageIds: getSendingClientMessageIds(state.submissions), + }; + if (isSupersededTail(payload, state.verified?.range, request?.cursor)) { + const completion = processTimelineResponseCompletion(common); + settleTimelineInitialization(this.serverId, agentId, completion); + return; + } + const result = processTimelineResponse({ + ...common, + currentTail: state.tail, + currentHead: state.head, + }); + if (result.error) { + settleTimelineInitialization(this.serverId, agentId, result); + return; + } + const baseline = state.verified; + const invalidated = invalidatesDurableBaseline(payload, result, baseline); + if (invalidated) { + this.storage.removeTimeline(this.serverId, agentId); + state.restored = true; + } + const synchronized = isTimelineResumeSnapshotAuthoritative({ + direction: payload.direction, + hasNewer: payload.hasNewer, + error: payload.error, + }); + applyAcceptedPageState({ + state, + payload, + result, + verified: reduceVerifiedPage(state, common, result), + replaceTail: common.replaceTail, + synchronized, + }); + this.publish(agentId, state, { + persist: payload.direction !== "before", + requireCertified: !invalidated && Boolean(baseline?.range), + synchronized, + }); + if (!this.accepts(agentId)) return; + recoverDisplayedGap(agentId, state, effects.recoverGap); + finalizeProcessedTimeline({ + serverId: this.serverId, + agentId, + result, + synchronized, + ...effects, + }); + } + + beginSubmission(agentId: string, message: UserMessageItem, tracked: boolean): void { + if (!this.accepts(agentId)) return; + const state = this.state(agentId); + if (tracked) { + if (!message.clientMessageId) + throw new Error("Beginning a message submission requires client identity"); + state.submissions = beginMessageSubmission(state.submissions, { + clientMessageId: message.clientMessageId, + }); + } + applySubmissionRows(state, (rows) => appendSubmittedUserMessage({ ...rows, message })); + this.publish(agentId, state); + } + + acceptSubmission(agentId: string, clientMessageId: string): void { + if (!this.accepts(agentId)) return; + const state = this.state(agentId); + state.submissions = acceptMessageSubmission(state.submissions, clientMessageId); + this.publish(agentId, state); + } + + rejectSubmission( + agentId: string, + clientMessageId: string, + tracked: boolean, + ): MessageSubmissionRejectionOutcome { + if (!this.accepts(agentId)) return "unknown"; + const state = this.state(agentId); + const rejection = rejectMessageSubmission(state.submissions, clientMessageId); + const outcome = tracked ? rejection.outcome : "rejected"; + state.submissions = rejection.submissions; + if (outcome === "rejected") { + applySubmissionRows(state, (rows) => + removeSubmittedUserMessage({ ...rows, clientMessageId }), + ); + } + this.publish(agentId, state); + return outcome; + } + + handoffSubmission(agentId: string, message: UserMessageItem): boolean { + if (!this.accepts(agentId)) return false; + const state = this.state(agentId); + const changed = applySubmissionRows(state, (rows) => + handoffCreatedAgentUserMessageToStream({ ...rows, message }), + ); + if (!changed) return false; + this.publish(agentId, state, { + persist: Boolean(state.verified), + requireCertified: Boolean(state.verified?.range), + }); + return true; + } + + acceptAgent(agentId: string): void { + if (!this.disposed) this.removed.delete(agentId); + } + + remove(agentId: string, reason: AgentRemovalReason): void { + this.removed.add(agentId); + this.agents.delete(agentId); + if (reason === "deleted") this.storage.removeTimeline(this.serverId, agentId); + useSessionStore.getState().removeAgentTimeline(this.serverId, agentId); + } + + reconcileServerId(serverId: string): void { + this.serverId = serverId; + for (const [agentId, state] of this.agents) { + this.publish(agentId, state, { synchronized: state.synchronized }); + } + } + + dispose(): void { + this.disposed = true; + this.agents.clear(); + } + + private publish( + agentId: string, + state: AgentTimeline, + options: { persist?: boolean; requireCertified?: boolean; synchronized?: boolean } = {}, + ): void { + const { persist = false, requireCertified = false, synchronized = false } = options; + // Unknown display content cannot certify disk, but does not revoke known source coverage. + // While positioned catch-up is incomplete, keep its accepted durable baseline instead. + const displayOnly = state.hasUnsequencedHistory && state.synchronized && !state.newer; + const verified = displayOnly ? null : state.verified; + const range = verified?.range ?? null; + const durable: CachedTimeline | null = + persist && (verified !== null || state.restored) + ? { + agentId, + items: verified ? [...verified.tail, ...verified.head] : [...state.tail, ...state.head], + range, + hasOlder: Boolean(verified?.hasOlder), + } + : null; + // Both outputs are determined before subscribers run; none can authorize persistence by + // changing a mutable store flag between publication and the write-behind decision. + if (durable) { + const page = latestRestartPage(durable, state.restartStartSeq); + if (page.range) state.restartStartSeq = page.range.startSeq; + this.storage.commitTimeline( + this.serverId, + agentId, + page, + (requireCertified && !displayOnly) || !state.restored, + ); + } + useSessionStore.getState().applyAgentTimelineResponseState(this.serverId, agentId, { + items: state.tail, + head: state.head, + range: state.displayRange, + older: state.older ? "available" : "none", + newer: state.newer, + synchronized, + submissions: state.submissions, + }); + } +} + +function latestRestartPage( + timeline: CachedTimeline, + restartStartSeq: number | null, +): CachedTimeline { + const startOf = (item: StreamItem | undefined) => + item?.source?.startSeq ?? item?.timelineCursor?.seq; + const candidates = timeline.items.filter( + (item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item), + ); + const items = candidates.filter( + (item) => + restartStartSeq === null || + (startOf(item) ?? restartStartSeq) >= restartStartSeq || + // A prepend may complete the retained boundary message. Keep that whole item, + // without admitting older tools merely because they finish inside the window. + item.source?.chunks?.some((chunk) => chunk.seq === restartStartSeq), + ); + const first = Math.max(0, items.length - TIMELINE_FETCH_PAGE_SIZE); + const omitted = candidates.length > items.length || first > 0; + const firstSource = startOf(items[first]); + const floor = restartStartSeq ?? timeline.range?.startSeq; + const startSeq = first > 0 ? firstSource : Math.min(floor ?? Infinity, firstSource ?? Infinity); + const range = + timeline.range && startSeq !== undefined && Number.isFinite(startSeq) + ? { ...timeline.range, startSeq: Math.max(timeline.range.startSeq, startSeq) } + : timeline.range; + return { + ...timeline, + items: items.slice(first), + range, + hasOlder: + timeline.hasOlder || + omitted || + Boolean(range && timeline.range && range.startSeq > timeline.range.startSeq), + }; +} + +export function createTimelineReplica(input: { + serverId: string; + storage: TimelineReplicaStorage; +}): TimelineReplica { + return new TimelineReplica(input.serverId, input.storage); +} +function retainPendingSubmissions(rows: TimelineRows, state: AgentTimeline): TimelineRows { + const pending = getSendingClientMessageIds(state.submissions); + for (const row of [...state.tail, ...state.head]) { + if ( + row.kind !== "user_message" || + !row.clientMessageId || + !pending.includes(row.clientMessageId) + ) + continue; + rows = handoffCreatedAgentUserMessageToStream({ ...rows, message: row }); + } + return rows; +} + +function reduceVerifiedPage( + state: AgentTimeline, + common: Omit[0], "currentTail" | "currentHead">, + display: ProcessTimelineResponseOutput, +): VerifiedTimeline { + const baseline = state.verified; + const currentCursor = baseline?.range ?? undefined; + const shared = + (baseline === null && state.tail.length === 0 && state.head.length === 0) || + (baseline?.tail === state.tail && baseline.head === state.head); + let rows: TimelineRows = baseline ?? { tail: [], head: [] }; + for (const row of shared ? [] : [...state.tail, ...state.head]) { + if (row.kind === "user_message" && isUnreconciledLocalUserMessage(row)) { + rows = handoffCreatedAgentUserMessageToStream({ ...rows, message: row }); + } + } + const verified = shared + ? display + : processTimelineResponse({ + ...common, + currentCursor, + currentTail: rows.tail, + currentHead: rows.head, + }); + // Divergence ends when the accepted page reconciles the overlay. Subsequent events share + // one reduction again; no consumer or synchronization flag decides row identity. + if (!shared && equal([...verified.tail, ...verified.head], [...display.tail, ...display.head])) { + verified.tail = display.tail; + verified.head = display.head; + } + const range = verified.cursorChanged ? (verified.cursor ?? null) : (currentCursor ?? null); + return { + tail: verified.tail, + head: verified.head, + range, + hasOlder: verified.older === "unchanged" ? state.older : verified.older === "available", + }; +} + +function recoverDisplayedGap( + agentId: string, + state: AgentTimeline, + recoverGap: PageEffects["recoverGap"], +): void { + const range = state.verified?.range; + if (!range) return; + for (const row of [...state.tail, ...state.head]) { + const cursor = row.timelineCursor; + if (cursor?.epoch === range.epoch && cursor.seq > range.endSeq) + recoverGap(agentId, range, cursor.seq); + } +} + +function clearAgentInitializingFlag(serverId: string, agentId: string): void { + useSessionStore.getState().setInitializingAgents(serverId, (previous) => { + if (previous.get(agentId) !== true) return previous; + const next = new Map(previous); + next.set(agentId, false); + return next; + }); +} + +function settleTimelineInitialization( + serverId: string, + agentId: string, + result: Pick, +): void { + if (result.clearInitializing) clearAgentInitializingFlag(serverId, agentId); + const key = getInitKey(serverId, agentId); + if (result.initResolution === "resolve") resolveInitDeferred(key); + if (result.initResolution === "reject" && result.error) + rejectInitDeferred(key, new Error(result.error)); +} + +function finalizeProcessedTimeline(input: { + serverId: string; + agentId: string; + result: ProcessTimelineResponseOutput; + synchronized: boolean; + recoverGap: ( + agentId: string, + cursor: { epoch: string; endSeq: number }, + observedSeq?: number, + ) => void; + drainQueuedAgentMessage: (agentId: string) => void; +}): void { + for (const effect of input.result.sideEffects) { + if (effect.type === "catch_up") { + input.recoverGap(input.agentId, effect.cursor, effect.observedSeq); + } + } + settleTimelineInitialization(input.serverId, input.agentId, input.result); + if (input.synchronized) { + useCreateFlowStore + .getState() + .clearByAgent({ serverId: input.serverId, agentId: input.agentId }); + const session = useSessionStore.getState().sessions[input.serverId]; + const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId); + if (agent && agent.turn.phase === "idle") input.drainQueuedAgentMessage(input.agentId); + } +} diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 13897f8454..42f07860cf 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -4,6 +4,7 @@ import type { AgentTimelineItem, ToolCallDetail } from "@getpaseo/protocol/agent import { createUserMessage, hydrateStreamState, + rowStartSeq, type AgentToolCallItem, type StreamItem, } from "@/types/stream"; @@ -481,7 +482,12 @@ describe("processTimelineResponse", () => { it("replaces destructively when the same epoch rewinds behind the local cursor", () => { const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [makeAssistantItem("future history", "future-history")], + currentTail: [ + { + ...makeAssistantItem("future history", "future-history"), + timelineCursor: { epoch: "epoch-1", seq: 40 }, + }, + ], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 }, payload: { ...baseTimelineInput.payload, @@ -493,10 +499,37 @@ describe("processTimelineResponse", () => { }, }); - expect(getAssistantTexts(result.tail)).toEqual(["rewound history"]); + expect(getAssistantTexts([...result.tail, ...result.head])).toEqual(["rewound history"]); expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 10 }); }); + it("drops superseded positioned rows when a forced reset tail ends behind the cursor", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [ + { + ...makeAssistantItem("future history", "future-history"), + timelineCursor: { epoch: "epoch-1", seq: 40 }, + }, + ], + currentHead: [makeSubmittedUserMessage("still sending", "sending-prompt")], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 }, + sendingClientMessageIds: ["sending-prompt"], + payload: { + ...baseTimelineInput.payload, + direction: "tail", + reset: true, + window: { minSeq: 1, maxSeq: 10, nextSeq: 11 }, + startCursor: { seq: 1 }, + endCursor: { seq: 10 }, + entries: [makeTimelineEntry(10, "rewound history")], + }, + }); + + expect(getAssistantTexts([...result.tail, ...result.head])).toEqual(["rewound history"]); + expect(getUserTexts([...result.tail, ...result.head])).toEqual(["still sending"]); + }); + it("preserves the canonical end cursor on a projected assistant message", () => { const result = processTimelineResponse({ ...baseTimelineInput, @@ -958,6 +991,13 @@ describe("processTimelineResponse", () => { text: "canonical prefix continuation", timestamp: new Date(2001), timelineCursor: { epoch: "epoch-1", seq: 101 }, + source: { + startSeq: 100, + chunks: [ + { seq: 100, offset: 0 }, + { seq: 101, offset: "canonical prefix".length }, + ], + }, }, ], isInitializing: true, @@ -3038,7 +3078,7 @@ describe("processTimelineResponse", () => { })), ).toEqual([ { - id: "loaded-assistant", + id: "assistant-message", text: "projected response", messageId: "assistant-message", timelineCursor: { epoch: "epoch-1", seq: 500 }, @@ -3197,6 +3237,57 @@ describe("processTimelineResponse", () => { }); }); + it.each([ + ["older", "newer", ["older chunk ", "newer chunk"]], + ["same", "same", ["older chunk newer chunk"]], + [undefined, "newer", ["older chunk newer chunk"]], + ["older", undefined, ["older chunk newer chunk"]], + ])("preserves assistant identity at a prepend boundary (%s, %s)", (olderId, newerId, texts) => { + const older = makeTimelineEntry(1, "older chunk "); + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [ + { + ...makeAssistantItem("newer chunk", "newer"), + messageId: newerId, + timelineCursor: { epoch: "epoch-1", seq: 2 }, + source: { startSeq: 2, chunks: [{ seq: 2, offset: 0 }] }, + }, + ], + currentCursor: { epoch: "epoch-1", startSeq: 2, endSeq: 2 }, + payload: { + ...baseTimelineInput.payload, + direction: "before", + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 1 }, + entries: [ + { + ...older, + item: { type: "assistant_message", text: "older chunk ", messageId: olderId }, + }, + ], + }, + }); + expect(getAssistantTexts(result.tail)).toEqual(texts); + expect(result.tail.map((row) => row.source)).toEqual( + texts.length === 2 + ? [ + { startSeq: 1, chunks: [{ seq: 1, offset: 0 }] }, + { startSeq: 2, chunks: [{ seq: 2, offset: 0 }] }, + ] + : [ + { + startSeq: 1, + chunks: [ + { seq: 1, offset: 0 }, + { seq: 2, offset: 12 }, + ], + }, + ], + ); + }); + it("coalesces tool call lifecycle rows across the older-page prepend boundary", () => { const callId = "toolu_boundary"; const turnId = "turn-boundary"; @@ -4346,12 +4437,13 @@ describe("createAgentStreamReducerQueue", () => { let currentHead: StreamItem[] = []; const queue = createAgentStreamReducerQueue({ - getSnapshot: () => ({ - currentTail, - currentHead, - currentCursor: undefined, - }), - commit: (agentId, result) => { + apply: (agentId, events) => { + const result = processAgentStreamEvents({ + events, + currentTail, + currentHead, + currentCursor: undefined, + }); currentTail = result.tail; currentHead = result.head; commits.push({ @@ -4360,7 +4452,6 @@ describe("createAgentStreamReducerQueue", () => { cursorEndSeq: result.cursor?.endSeq ?? null, }); }, - handleSideEffects: () => {}, scheduleFlush: scheduler.schedule, cancelFlush: scheduler.cancel, }); @@ -4387,17 +4478,17 @@ describe("createAgentStreamReducerQueue", () => { const scheduler = createManualScheduler(); const commits: string[] = []; const queue = createAgentStreamReducerQueue({ - getSnapshot: () => ({ - currentTail: [], - currentHead: [], - currentCursor: undefined, - }), - commit: (agentId, result) => { + apply: (agentId, events) => { + const result = processAgentStreamEvents({ + events, + currentTail: [], + currentHead: [], + currentCursor: undefined, + }); commits.push( `${agentId}:${result.head[0]?.kind === "assistant_message" ? result.head[0].text : ""}`, ); }, - handleSideEffects: () => {}, scheduleFlush: scheduler.schedule, cancelFlush: scheduler.cancel, }); @@ -4416,17 +4507,17 @@ describe("createAgentStreamReducerQueue", () => { let currentCursor: TimelineCursor | undefined; const queue = createAgentStreamReducerQueue({ - getSnapshot: () => ({ - currentTail, - currentHead, - currentCursor, - }), - commit: (_agentId, result) => { + apply: (_agentId, events) => { + const result = processAgentStreamEvents({ + events, + currentTail, + currentHead, + currentCursor, + }); currentTail = result.tail; currentHead = result.head; currentCursor = result.cursor ?? undefined; }, - handleSideEffects: () => {}, scheduleFlush: scheduler.schedule, cancelFlush: scheduler.cancel, }); @@ -4465,17 +4556,17 @@ describe("createAgentStreamReducerQueue", () => { const scheduler = createManualScheduler(); const commits: string[] = []; const queue = createAgentStreamReducerQueue({ - getSnapshot: () => ({ - currentTail: [], - currentHead: [], - currentCursor: undefined, - }), - commit: (agentId, result) => { + apply: (agentId, events) => { + const result = processAgentStreamEvents({ + events, + currentTail: [], + currentHead: [], + currentCursor: undefined, + }); commits.push( `${agentId}:${result.head[0]?.kind === "assistant_message" ? result.head[0].text : ""}`, ); }, - handleSideEffects: () => {}, scheduleFlush: scheduler.schedule, cancelFlush: scheduler.cancel, }); @@ -4487,3 +4578,706 @@ describe("createAgentStreamReducerQueue", () => { expect(scheduler.size).toBe(0); }); }); + +describe("forward pages reconcile live rows by identity and source position", () => { + const detail: ToolCallDetail = { type: "read", filePath: "/tmp/example.ts" }; + const checkpoint = (n: number) => `\n\n---\n\nReplica QA checkpoint ${n}: saved history`; + + function liveBeyondBootstrap() { + return processAgentStreamEvents({ + events: [ + makeStreamReducerEvent(makeAssistantTimelineEvent("\n\n---\n\n", "msg-102"), 675), + makeStreamReducerEvent( + makeAssistantTimelineEvent("Replica QA checkpoint 102: saved history", "msg-102"), + 679, + ), + makeStreamReducerEvent(makeToolCallTimelineEvent("exec-1"), 681), + makeStreamReducerEvent(makeAssistantTimelineEvent("\n\n---\n\n", "msg-103"), 682), + makeStreamReducerEvent( + makeAssistantTimelineEvent("Replica QA checkpoint 103: saved history", "msg-103"), + 685, + ), + makeStreamReducerEvent(makeToolCallTimelineEvent("exec-2"), 687), + ], + currentTail: [], + currentHead: [], + currentCursor: undefined, + hasAuthoritativeBaseline: false, + }); + } + + function projectRows(items: StreamItem[]) { + return items.map((item) => + item.kind === "assistant_message" + ? { kind: item.kind, messageId: item.messageId, text: item.text } + : { kind: item.kind, id: item.id }, + ); + } + + it("replaces split live blocks wholly beyond the cursor instead of appending them again", () => { + const live = liveBeyondBootstrap(); + expect(getAssistantTexts(live.head)).toHaveLength(4); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [ + { + ...makeAssistantItem(checkpoint(101), "msg-101"), + messageId: "msg-101", + timelineCursor: { epoch: "epoch-1", seq: 672 }, + }, + ], + currentHead: live.head, + currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, + payload: { + ...baseTimelineInput.payload, + direction: "after", + epoch: "epoch-1", + startCursor: { seq: 675 }, + endCursor: { seq: 691 }, + entries: [ + { + ...makeTimelineEntry(675, checkpoint(102), "assistant_message", 679), + sourceSeqRanges: [{ startSeq: 675, endSeq: 679 }], + item: { type: "assistant_message", text: checkpoint(102), messageId: "msg-102" }, + }, + makeToolCallTimelineEntry(681, "exec-1", "completed", detail), + { + ...makeTimelineEntry(682, checkpoint(103), "assistant_message", 685), + sourceSeqRanges: [{ startSeq: 682, endSeq: 685 }], + item: { type: "assistant_message", text: checkpoint(103), messageId: "msg-103" }, + }, + makeToolCallTimelineEntry(687, "exec-2", "completed", detail), + { + ...makeTimelineEntry(691, checkpoint(104)), + item: { type: "assistant_message", text: checkpoint(104), messageId: "msg-104" }, + }, + ], + }, + }); + + const rows = [...result.tail, ...result.head]; + expect(projectRows(rows)).toEqual([ + { kind: "assistant_message", messageId: "msg-101", text: checkpoint(101) }, + { kind: "assistant_message", messageId: "msg-102", text: checkpoint(102) }, + { kind: "tool_call", id: "agent_tool_exec-1" }, + { kind: "assistant_message", messageId: "msg-103", text: checkpoint(103) }, + { kind: "tool_call", id: "agent_tool_exec-2" }, + { kind: "assistant_message", messageId: "msg-104", text: checkpoint(104) }, + ]); + expect(new Set(rows.map((item) => item.id)).size).toBe(rows.length); + expect(getAgentToolCalls(rows).map((item) => item.payload.data.status)).toEqual([ + "completed", + "completed", + ]); + expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 600, endSeq: 691 }); + }); + + it.each(["positioned", "unpositioned", "repeated"] as const)( + "preserves same-message segments around a tool (%s)", + (delivery) => { + const beforeText = delivery === "repeated" ? "Repeat" : "Before tool"; + const afterText = delivery === "repeated" ? "Repeat" : "After tool"; + const live = processAgentStreamEvents({ + events: [ + makeStreamReducerEvent(makeAssistantTimelineEvent(beforeText, "msg-a"), 675), + makeStreamReducerEvent(makeToolCallTimelineEvent("exec-1"), 676), + makeStreamReducerEvent(makeAssistantTimelineEvent(afterText, "msg-a"), 677), + ].map((event) => + delivery === "positioned" || (delivery === "repeated" && event.seq === 676) + ? event + : Object.assign(event, { seq: undefined, epoch: undefined }), + ), + currentTail: [], + currentHead: [], + currentCursor: undefined, + hasAuthoritativeBaseline: false, + }); + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: live.tail, + currentHead: live.head, + currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, + payload: { + ...baseTimelineInput.payload, + direction: "after", + window: { minSeq: 1, maxSeq: 677, nextSeq: 678 }, + startCursor: { seq: 675 }, + endCursor: { seq: 677 }, + entries: [ + { + ...makeTimelineEntry(675, beforeText), + item: { type: "assistant_message", text: beforeText, messageId: "msg-a" }, + }, + makeToolCallTimelineEntry(676, "exec-1", "running", detail), + { + ...makeTimelineEntry(677, afterText), + item: { type: "assistant_message", text: afterText, messageId: "msg-a" }, + }, + ], + }, + }); + const ids = [...result.tail, ...result.head].map((row) => row.id); + expect(new Set(ids).size).toBe(ids.length); + expect(projectRows([...result.tail, ...result.head])).toEqual([ + { kind: "assistant_message", messageId: "msg-a", text: beforeText }, + { kind: "tool_call", id: "agent_tool_exec-1" }, + { kind: "assistant_message", messageId: "msg-a", text: afterText }, + ]); + }, + ); + + it("records each unit's first source position on bootstrap", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + isInitializing: true, + hasActiveInitDeferred: true, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + reset: true, + window: { minSeq: 1, maxSeq: 700, nextSeq: 701 }, + startCursor: { seq: 675 }, + endCursor: { seq: 700 }, + entries: [ + { + ...makeTimelineEntry(675, "Before tool"), + item: { type: "assistant_message", text: "Before tool", messageId: "msg-a" }, + }, + { + ...makeToolCallTimelineEntry(676, "exec-1", "completed", detail), + seqEnd: 700, + sourceSeqRanges: [ + { startSeq: 676, endSeq: 676 }, + { startSeq: 700, endSeq: 700 }, + ], + collapsed: ["tool_lifecycle"], + }, + { + ...makeTimelineEntry(677, "After tool"), + item: { type: "assistant_message", text: "After tool", messageId: "msg-a" }, + }, + ], + }, + }); + const rows = [...result.tail, ...result.head]; + expect(rows.map((row) => row.kind)).toEqual([ + "assistant_message", + "tool_call", + "assistant_message", + ]); + expect(rows.map(rowStartSeq)).toEqual([675, 676, 677]); + }); + + it("keeps parallel tools in the order they started when their completions are reversed", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + isInitializing: true, + hasActiveInitDeferred: true, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + reset: true, + window: { minSeq: 1, maxSeq: 700, nextSeq: 701 }, + startCursor: { seq: 676 }, + endCursor: { seq: 700 }, + entries: [ + { + ...makeToolCallTimelineEntry(676, "first", "completed", detail), + seqEnd: 700, + sourceSeqRanges: [ + { startSeq: 676, endSeq: 676 }, + { startSeq: 700, endSeq: 700 }, + ], + collapsed: ["tool_lifecycle"], + }, + { + ...makeToolCallTimelineEntry(677, "second", "completed", detail), + seqEnd: 690, + sourceSeqRanges: [ + { startSeq: 677, endSeq: 677 }, + { startSeq: 690, endSeq: 690 }, + ], + collapsed: ["tool_lifecycle"], + }, + ], + }, + }); + const tools = getAgentToolCalls([...result.tail, ...result.head]); + expect(tools.map((tool) => tool.payload.data.callId)).toEqual(["first", "second"]); + expect(tools.map(rowStartSeq)).toEqual([676, 677]); + expect(tools.map((tool) => tool.timelineCursor?.seq)).toEqual([700, 690]); + }); + + it("keeps an interleaved tool in place when the page arrives with no live rows", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, + payload: { + ...baseTimelineInput.payload, + direction: "after", + window: { minSeq: 1, maxSeq: 700, nextSeq: 701 }, + startCursor: { seq: 675 }, + endCursor: { seq: 700 }, + entries: [ + { + ...makeTimelineEntry(675, "Before tool"), + item: { type: "assistant_message", text: "Before tool", messageId: "msg-a" }, + }, + { + ...makeToolCallTimelineEntry(676, "exec-1", "completed", detail), + seqEnd: 700, + sourceSeqRanges: [ + { startSeq: 676, endSeq: 676 }, + { startSeq: 700, endSeq: 700 }, + ], + collapsed: ["tool_lifecycle"], + }, + { + ...makeTimelineEntry(677, "After tool"), + item: { type: "assistant_message", text: "After tool", messageId: "msg-a" }, + }, + ], + }, + }); + const rows = [...result.tail, ...result.head]; + expect(projectRows(rows)).toEqual([ + { kind: "assistant_message", messageId: "msg-a", text: "Before tool" }, + { kind: "tool_call", id: "agent_tool_exec-1" }, + { kind: "assistant_message", messageId: "msg-a", text: "After tool" }, + ]); + expect(rows.map(rowStartSeq)).toEqual([675, 676, 677]); + }); + + it("keeps an interleaved tool whose completion is newer than text on both sides", () => { + const live = processAgentStreamEvents({ + events: [ + makeStreamReducerEvent(makeAssistantTimelineEvent("Before tool", "msg-a"), 675), + makeStreamReducerEvent(makeToolCallTimelineEvent("exec-1"), 676), + makeStreamReducerEvent(makeAssistantTimelineEvent("After tool", "msg-a"), 677), + makeStreamReducerEvent( + { + ...makeToolCallTimelineEvent("exec-1"), + item: makeToolCallTimelineEntry(676, "exec-1", "completed", detail).item, + } as AgentStreamEventPayload, + 700, + ), + ], + currentTail: [], + currentHead: [], + currentCursor: undefined, + hasAuthoritativeBaseline: false, + }); + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: live.tail, + currentHead: live.head, + currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, + payload: { + ...baseTimelineInput.payload, + direction: "after", + window: { minSeq: 1, maxSeq: 700, nextSeq: 701 }, + startCursor: { seq: 675 }, + endCursor: { seq: 700 }, + entries: [ + { + ...makeTimelineEntry(675, "Before tool"), + item: { type: "assistant_message", text: "Before tool", messageId: "msg-a" }, + }, + { + ...makeToolCallTimelineEntry(676, "exec-1", "completed", detail), + seqEnd: 700, + sourceSeqRanges: [ + { startSeq: 676, endSeq: 676 }, + { startSeq: 700, endSeq: 700 }, + ], + collapsed: ["tool_lifecycle"], + }, + { + ...makeTimelineEntry(677, "After tool"), + item: { type: "assistant_message", text: "After tool", messageId: "msg-a" }, + }, + ], + }, + }); + const rows = [...result.tail, ...result.head]; + expect(projectRows(rows)).toEqual([ + { kind: "assistant_message", messageId: "msg-a", text: "Before tool" }, + { kind: "tool_call", id: "agent_tool_exec-1" }, + { kind: "assistant_message", messageId: "msg-a", text: "After tool" }, + ]); + expect(getAgentToolCalls(rows)[0]?.payload.data.status).toBe("completed"); + }); + + it("keeps a tool call at the position it appeared even when its completion is newer", () => { + const live = processAgentStreamEvents({ + events: [ + makeStreamReducerEvent(makeToolCallTimelineEvent("exec-1"), 676), + makeStreamReducerEvent(makeAssistantTimelineEvent("While the tool runs", "msg-a"), 680), + makeStreamReducerEvent( + { + ...makeToolCallTimelineEvent("exec-1"), + item: { ...makeToolCallTimelineEntry(676, "exec-1", "completed", detail).item }, + } as AgentStreamEventPayload, + 700, + ), + ], + currentTail: [], + currentHead: [], + currentCursor: undefined, + hasAuthoritativeBaseline: false, + }); + const result = processTimelineResponse({ + ...baseTimelineInput, + currentHead: live.head, + currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, + payload: { + ...baseTimelineInput.payload, + direction: "after", + window: { minSeq: 1, maxSeq: 700, nextSeq: 701 }, + startCursor: { seq: 675 }, + endCursor: { seq: 700 }, + entries: [ + { + ...makeToolCallTimelineEntry(676, "exec-1", "completed", detail), + seqEnd: 700, + sourceSeqRanges: [ + { startSeq: 676, endSeq: 676 }, + { startSeq: 700, endSeq: 700 }, + ], + collapsed: ["tool_lifecycle"], + }, + { + ...makeTimelineEntry(680, "While the tool runs"), + item: { type: "assistant_message", text: "While the tool runs", messageId: "msg-a" }, + }, + ], + }, + }); + + expect(projectRows([...result.tail, ...result.head])).toEqual([ + { kind: "tool_call", id: "agent_tool_exec-1" }, + { kind: "assistant_message", messageId: "msg-a", text: "While the tool runs" }, + ]); + expect(getAgentToolCalls([...result.tail, ...result.head])[0]?.payload.data.status).toBe( + "completed", + ); + }); + + it("does not duplicate a notification the page delivers again", () => { + const live = processAgentStreamEvents({ + events: [ + makeStreamReducerEvent( + { + type: "timeline", + provider: "claude", + item: { type: "error", message: "Rate limited" }, + } as AgentStreamEventPayload, + 680, + ), + ], + currentTail: [], + currentHead: [], + currentCursor: undefined, + hasAuthoritativeBaseline: false, + }); + const result = processTimelineResponse({ + ...baseTimelineInput, + currentHead: live.head, + currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, + payload: { + ...baseTimelineInput.payload, + direction: "after", + window: { minSeq: 1, maxSeq: 680, nextSeq: 681 }, + startCursor: { seq: 675 }, + endCursor: { seq: 680 }, + entries: [ + { + ...makeTimelineEntry(680, "Rate limited"), + item: { type: "error", message: "Rate limited" }, + }, + ], + }, + }); + + const notifications = [...result.tail, ...result.head].filter( + (item) => item.kind === "notification", + ); + expect(notifications).toHaveLength(1); + expect(notifications[0]?.id).toBe(live.head[0]?.id); + }); + + it("inserts missed canonical history before newer live messages during reconnect", () => { + const live = processAgentStreamEvents({ + events: [makeStreamReducerEvent(makeAssistantTimelineEvent("Arrived live", "msg-new"), 700)], + currentTail: [], + currentHead: [], + currentCursor: undefined, + hasAuthoritativeBaseline: false, + }); + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [], + currentHead: live.head, + currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, + payload: { + ...baseTimelineInput.payload, + direction: "after", + window: { minSeq: 1, maxSeq: 690, nextSeq: 691 }, + startCursor: { seq: 675 }, + endCursor: { seq: 690 }, + hasNewer: false, + entries: [ + { + ...makeTimelineEntry(675, "Missed while closed", "assistant_message", 679), + item: { type: "assistant_message", text: "Missed while closed", messageId: "msg-old" }, + }, + ], + }, + }); + expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([ + "Missed while closed", + "Arrived live", + ]); + }); + + it.each([ + { + name: "partially overlaps the canonical prefix", + events: [ + makeStreamReducerEvent(makeAssistantTimelineEvent("llo", "msg-a"), 680), + makeStreamReducerEvent(makeAssistantTimelineEvent(" world", "msg-a"), 700), + ], + canonical: "Hello", + expected: "Hello world", + }, + { + name: "repeats the prefix after its coverage", + events: [makeStreamReducerEvent(makeAssistantTimelineEvent("Ha", "msg-a"), 700)], + canonical: "Ha", + expected: "HaHa", + }, + ])( + "reconciles by source coverage when newer live text $name", + ({ events, canonical, expected }) => { + const live = processAgentStreamEvents({ + events, + currentTail: [], + currentHead: [], + currentCursor: undefined, + hasAuthoritativeBaseline: false, + }); + const result = processTimelineResponse({ + ...baseTimelineInput, + currentHead: live.head, + currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, + payload: { + ...baseTimelineInput.payload, + direction: "after", + window: { minSeq: 1, maxSeq: 690, nextSeq: 691 }, + startCursor: { seq: 675 }, + endCursor: { seq: 690 }, + hasNewer: false, + entries: [ + { + ...makeTimelineEntry(675, canonical, "assistant_message", 690), + item: { type: "assistant_message", text: canonical, messageId: "msg-a" }, + }, + ], + }, + }); + expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([expected]); + }, + ); + + it("fills a missing prefix when a lagging final page meets a newer live suffix", () => { + const live = processAgentStreamEvents({ + events: [makeStreamReducerEvent(makeAssistantTimelineEvent(" world", "msg-a"), 700)], + currentTail: [], + currentHead: [], + currentCursor: undefined, + hasAuthoritativeBaseline: false, + }); + const result = processTimelineResponse({ + ...baseTimelineInput, + currentHead: live.head, + currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, + payload: { + ...baseTimelineInput.payload, + direction: "after", + startCursor: { seq: 675 }, + endCursor: { seq: 690 }, + hasNewer: false, + entries: [ + { + ...makeTimelineEntry(675, "Hello", "assistant_message", 690), + item: { type: "assistant_message", text: "Hello", messageId: "msg-a" }, + }, + ], + }, + }); + expect(getAssistantTexts([...result.tail, ...result.head])).toEqual(["Hello world"]); + }); + + it("prefixes only the first block of a split live suffix", () => { + const live = processAgentStreamEvents({ + events: [ + makeStreamReducerEvent(makeAssistantTimelineEvent(" world.\n\n", "msg-a"), 700), + makeStreamReducerEvent(makeAssistantTimelineEvent("Second paragraph", "msg-a"), 701), + ], + currentTail: [], + currentHead: [], + currentCursor: undefined, + hasAuthoritativeBaseline: false, + }); + expect(getAssistantTexts(live.head)).toEqual([" world.", "Second paragraph"]); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentHead: live.head, + currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, + payload: { + ...baseTimelineInput.payload, + direction: "after", + startCursor: { seq: 675 }, + endCursor: { seq: 690 }, + hasNewer: false, + entries: [ + { + ...makeTimelineEntry(675, "Hello", "assistant_message", 690), + item: { type: "assistant_message", text: "Hello", messageId: "msg-a" }, + }, + ], + }, + }); + + const rows = [...result.tail, ...result.head].filter( + (item) => item.kind === "assistant_message", + ); + expect(rows.map((item) => item.text)).toEqual(["Hello world.", "Second paragraph"]); + expect(rows.map((item) => item.id)).toEqual(live.head.map((item) => item.id)); + expect(rows.every((item) => item.messageId === "msg-a")).toBe(true); + }); + + it.each([true, false])("keeps newer text beyond a lagging page (positioned=%s)", (positioned) => { + const live = processAgentStreamEvents({ + events: [ + makeStreamReducerEvent(makeAssistantTimelineEvent("Hello", "msg-a"), 690), + makeStreamReducerEvent(makeAssistantTimelineEvent(" world", "msg-a"), 700), + ].map((event) => + positioned ? event : Object.assign(event, { seq: undefined, epoch: undefined }), + ), + currentTail: [], + currentHead: [], + currentCursor: undefined, + hasAuthoritativeBaseline: false, + }); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentHead: live.head, + currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, + payload: { + ...baseTimelineInput.payload, + direction: "after", + epoch: "epoch-1", + startCursor: { seq: 675 }, + endCursor: { seq: 690 }, + hasNewer: true, + entries: [ + { + ...makeTimelineEntry(690, "Hello"), + item: { type: "assistant_message", text: "Hello", messageId: "msg-a" }, + }, + ], + }, + }); + + const assistants = [...result.tail, ...result.head].filter( + (item) => item.kind === "assistant_message", + ); + expect(assistants).toHaveLength(1); + expect(assistants[0]).toMatchObject({ + messageId: "msg-a", + text: "Hello world", + }); + expect(assistants[0]?.timelineCursor).toEqual( + positioned ? { epoch: "epoch-1", seq: 700 } : undefined, + ); + }); + + it.each([true, false])( + "keeps distinct identities with identical text (positioned=%s)", + (positioned) => { + const live = processAgentStreamEvents({ + events: [makeStreamReducerEvent(makeAssistantTimelineEvent("Done.", "msg-a"), 680)].map( + (event) => + positioned ? event : Object.assign(event, { seq: undefined, epoch: undefined }), + ), + currentTail: [], + currentHead: [], + currentCursor: undefined, + hasAuthoritativeBaseline: false, + }); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentHead: live.head, + currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, + payload: { + ...baseTimelineInput.payload, + direction: "after", + epoch: "epoch-1", + startCursor: { seq: 675 }, + endCursor: { seq: 681 }, + entries: [ + { + ...makeTimelineEntry(681, "Done."), + item: { type: "assistant_message", text: "Done.", messageId: "msg-b" }, + }, + ], + }, + }); + + expect( + [...result.tail, ...result.head] + .filter((item) => item.kind === "assistant_message") + .map((item) => item.messageId), + ).toEqual(["msg-a", "msg-b"]); + }, + ); + it("does not guess which identical unpositioned segment a partial page covers", () => { + const live = processAgentStreamEvents({ + events: [ + makeStreamReducerEvent(makeAssistantTimelineEvent("Repeat", "msg-a"), 675), + makeStreamReducerEvent(makeToolCallTimelineEvent("exec-1"), 676), + makeStreamReducerEvent(makeAssistantTimelineEvent("Repeat", "msg-a"), 677), + ].map((event) => Object.assign(event, { seq: undefined, epoch: undefined })), + currentTail: [], + currentHead: [], + currentCursor: undefined, + hasAuthoritativeBaseline: false, + }); + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: live.tail, + currentHead: live.head, + currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, + payload: { + ...baseTimelineInput.payload, + direction: "after", + startCursor: { seq: 675 }, + endCursor: { seq: 675 }, + entries: [ + { + ...makeTimelineEntry(675, "Repeat"), + item: { type: "assistant_message", text: "Repeat", messageId: "msg-a" }, + }, + ], + }, + }); + const rows = [...result.tail, ...result.head]; + expect(rows.filter((row) => !row.timelineCursor)).toEqual([...live.tail, ...live.head]); + expect(rows.filter((row) => row.timelineCursor).map((row) => rowStartSeq(row))).toEqual([675]); + }); +}); diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index b04ad00f02..cb7a2a663b 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -1,16 +1,25 @@ +import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; -import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; -import type { AssistantMessageItem, StreamItem, TodoEntry } from "@/types/stream"; +import type { + AssistantMessageItem, + StreamItem, + SourcedPosition, + ThoughtItem, + TodoEntry, + UserMessageItem, +} from "@/types/stream"; import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; import { applyStreamEvent, - flushHeadToTail, hydrateStreamState, isAgentToolCallItem, + joinTextRows, mergeAgentToolCallItem, + mergeCanonicalText, replaceWithCanonicalStream, reduceStreamUpdate, + rowStartSeq, streamTimelineItemIdentity, upsertUserMessageAcrossStream, } from "@/types/stream"; @@ -41,12 +50,14 @@ export interface TimelineCursor { // --------------------------------------------------------------------------- export type TimelineReducerSideEffect = - | { type: "catch_up"; cursor: { epoch: string; endSeq: number } } + // `observedSeq` is the sequence the live stream reached that coverage must still catch up to. + | { type: "catch_up"; cursor: { epoch: string; endSeq: number }; observedSeq?: number } | { type: "flush_pending_updates" }; export interface AgentStreamReducerSideEffect { type: "catch_up"; cursor: { epoch: string; endSeq: number }; + observedSeq?: number; } // --------------------------------------------------------------------------- @@ -136,6 +147,7 @@ interface TimelineResponseEntry { } export interface ProcessTimelineResponseInput { + replaceTail?: boolean; payload: { agentId: string; direction: TimelineDirection; @@ -182,6 +194,12 @@ interface TimelineUnit { timestamp: Date; } +interface HydratedTimelineEvent { + event: AgentStreamEventPayload; + timestamp: Date; + timelineCursor: SourcedPosition; +} + interface TimelinePathResult { tail: StreamItem[]; head: StreamItem[]; @@ -306,6 +324,7 @@ function classifySessionTimelineSeq({ function deriveBootstrapTailTimelinePolicy({ direction, reset, + replaceTail, epoch, endCursor, isInitializing, @@ -313,6 +332,7 @@ function deriveBootstrapTailTimelinePolicy({ }: { direction: TimelineDirection; reset: boolean; + replaceTail: boolean; epoch: string; endCursor: { seq: number } | null; isInitializing: boolean; @@ -321,7 +341,7 @@ function deriveBootstrapTailTimelinePolicy({ replace: boolean; catchUpCursor: { epoch: string; endSeq: number } | null; } { - if (reset) { + if (reset || (replaceTail && direction === "tail")) { return { replace: true, catchUpCursor: null }; } @@ -388,11 +408,7 @@ function mergeTimelineWindow(args: { currentTail: StreamItem[]; currentHead: StreamItem[]; currentCursor: TimelineCursor | undefined; - toHydratedEvents: (units: TimelineUnit[]) => Array<{ - event: AgentStreamEventPayload; - timestamp: Date; - timelineCursor: { epoch: string; seq: number }; - }>; + toHydratedEvents: (units: TimelineUnit[]) => HydratedTimelineEvent[]; }): TimelinePathResult { const { timelineUnits, payload, currentTail, currentHead, currentCursor, toHydratedEvents } = args; @@ -444,9 +460,9 @@ function mergeTimelineWindow(args: { const tail = [...reconciled.tail, ...reconciled.page] .map((item, order) => ({ item, order })) .sort((left, right) => { - const leftSeq = left.item.timelineCursor?.seq ?? Number.POSITIVE_INFINITY; - const rightSeq = right.item.timelineCursor?.seq ?? Number.POSITIVE_INFINITY; - return leftSeq - rightSeq || left.order - right.order; + const leftStart = rowStartSeq(left.item) ?? Number.POSITIVE_INFINITY; + const rightStart = rowStartSeq(right.item) ?? Number.POSITIVE_INFINITY; + return leftStart - rightStart || left.order - right.order; }) .map(({ item }) => item); const cursor = mergeTimelineCoverage(currentCursor, { @@ -501,17 +517,58 @@ function shouldResolveTimelineInit({ return responseDirection === initRequestDirection; } +export function processTimelineResponseCompletion( + input: Pick< + ProcessTimelineResponseInput, + "payload" | "isInitializing" | "hasActiveInitDeferred" | "initRequestDirection" + >, +): Pick { + const { payload, isInitializing, hasActiveInitDeferred, initRequestDirection } = input; + if (payload.error) + return { + initResolution: hasActiveInitDeferred ? "reject" : null, + clearInitializing: isInitializing, + error: payload.error, + }; + const resolves = shouldResolveTimelineInit({ + hasActiveInitDeferred, + hasNewer: payload.hasNewer, + isInitializing, + initRequestDirection, + responseDirection: payload.direction, + reset: payload.reset, + }); + const complete = payload.direction !== "after" || !payload.hasNewer; + return { + error: null, + initResolution: resolves ? "resolve" : null, + clearInitializing: (resolves || (isInitializing && !hasActiveInitDeferred)) && complete, + }; +} + +// A tail whose window ends behind the local cursor is a rewind: the daemon no longer has the rows +// between its head and that cursor, so the response supersedes everything up to the cursor it +// rewound behind. Any other page certifies only the positions it carries. +function resolveCanonicalCoverageEnd( + payload: ProcessTimelineResponseInput["payload"], + currentCursor: TimelineCursor | undefined, +): number | null { + if (currentCursor?.epoch === payload.epoch && payload.window.maxSeq < currentCursor.endSeq) { + return currentCursor.endSeq; + } + return payload.endCursor?.seq ?? null; +} + function applyTimelineReplacePath(args: { timelineUnits: TimelineUnit[]; payload: ProcessTimelineResponseInput["payload"]; bootstrapPolicy: ReturnType; currentTail: StreamItem[]; currentHead: StreamItem[]; + currentCursor: TimelineCursor | undefined; sendingClientMessageIds: readonly string[]; preserveContinuity: boolean; - toHydratedEvents: ( - units: TimelineUnit[], - ) => Array<{ event: AgentStreamEventPayload; timestamp: Date }>; + toHydratedEvents: (units: TimelineUnit[]) => HydratedTimelineEvent[]; }): TimelinePathResult { const { timelineUnits, @@ -519,6 +576,7 @@ function applyTimelineReplacePath(args: { bootstrapPolicy, currentTail, currentHead, + currentCursor, sendingClientMessageIds, preserveContinuity, toHydratedEvents, @@ -534,7 +592,7 @@ function applyTimelineReplacePath(args: { preserveContinuity, canonicalCoverage: { epoch: payload.epoch, - endSeq: payload.endCursor?.seq ?? null, + endSeq: resolveCanonicalCoverageEnd(payload, currentCursor), }, }); const cursor: TimelineCursor | null = @@ -698,14 +756,17 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea const identityMerge = mergeTimelineIdentityBoundary(olderTail, currentTail); if (identityMerge) return identityMerge; - if (olderLast?.kind !== "assistant_message" || currentFirst?.kind !== "assistant_message") { + if ( + olderLast?.kind !== "assistant_message" || + currentFirst?.kind !== "assistant_message" || + (olderLast.messageId !== undefined && + currentFirst.messageId !== undefined && + olderLast.messageId !== currentFirst.messageId) + ) { return [...olderTail, ...currentTail]; } - const mergedAssistant: AssistantMessageItem = { - ...currentFirst, - text: `${olderLast.text}${currentFirst.text}`, - }; + const mergedAssistant: AssistantMessageItem = joinTextRows(olderLast, currentFirst); if (mergedAssistant.messageId === undefined && olderLast.messageId !== undefined) { mergedAssistant.messageId = olderLast.messageId; } @@ -757,161 +818,372 @@ function mergeOlderTimelinePage(input: { return [...retainedBefore, ...mergePrependedCanonicalTail(input.page, currentAtOrAfterPage)]; } -function replaceLiveAssistantWithProjectedText(params: { - head: StreamItem[]; - event: AgentStreamEventPayload; - timestamp: Date; - timelineCursor: { epoch: string; seq: number }; -}): StreamItem[] | null { - const { head, event, timestamp, timelineCursor } = params; - if (event.type !== "timeline" || event.item.type !== "assistant_message") { - return null; - } - const index = head.findLastIndex((item) => item.kind === "assistant_message"); - const current = head[index]; - if (!current || current.kind !== "assistant_message") { - return null; - } - if (!event.item.text.startsWith(current.text)) { - return null; - } - const next = [...head]; - next[index] = { - ...current, - text: event.item.text, - timestamp, - timelineCursor, - }; - return next; +// --------------------------------------------------------------------------- +// Forward pages merge into the ordered timeline +// +// Both lanes together are one sequence ordered by source start. A canonical unit first looks for +// the rows it owns: text units own rows sharing their provider message id or holding a chunk +// inside their source coverage, tool and plugin units own rows with their timeline identity, user +// units own the local row they acknowledge, and every other unit owns the row of its kind at its +// start position. An owned row is replaced or merged where it stands. A unit nobody owns is +// inserted at its start position, so history missed while the client was away lands before rows +// the live stream delivered later, and a tool call keeps the position it first appeared at even +// though its completion carries a newer sequence. +// --------------------------------------------------------------------------- + +interface OrderedLanes { + rows: StreamItem[]; + // rows[0, boundary) is the tail lane; the rest is the live head. + boundary: number; } -function reconcileOverlappingProjectedAssistant(params: { - tail: StreamItem[]; - head: StreamItem[]; - unit: TimelineUnit; - epoch: string; - currentEndSeq: number; -}): { tail: StreamItem[]; head: StreamItem[]; reconciled: boolean } { - const { unit } = params; - if ( - unit.event.type !== "timeline" || - unit.event.item.type !== "assistant_message" || - !unit.sourceSeqRanges.some( - (range) => range.startSeq <= params.currentEndSeq && range.endSeq > params.currentEndSeq, +function unitPosition(unit: TimelineUnit, epoch: string): SourcedPosition { + return { epoch, seq: unit.seqEnd, startSeq: unit.seq }; +} + +function insertionIndex(rows: readonly StreamItem[], epoch: string, startSeq: number): number { + const index = rows.findIndex((row) => { + const start = rowStartSeq(row); + return row.timelineCursor?.epoch === epoch && start !== undefined && start > startSeq; + }); + return index < 0 ? rows.length : index; +} + +function spliceLanes( + lanes: OrderedLanes, + index: number, + removeCount: number, + inserted: readonly StreamItem[], +): OrderedLanes { + const rows = [...lanes.rows]; + rows.splice(index, removeCount, ...inserted); + const removedFromTail = Math.max(0, Math.min(removeCount, lanes.boundary - index)); + // Rows land in the tail when they replace tail rows, sit before the head, or when there is no + // live head to join. + const insertedIntoTail = + index < lanes.boundary || removedFromTail > 0 || lanes.boundary === lanes.rows.length + ? inserted.length + : 0; + return { rows, boundary: lanes.boundary - removedFromTail + insertedIntoTail }; +} + +function hydrateUnitAfter( + prefix: readonly StreamItem[], + unit: TimelineUnit, + epoch: string, + reservedItemIds?: ReadonlySet, +): StreamItem[] { + return reduceStreamUpdate([...prefix], unit.event, unit.timestamp, { + source: "canonical", + timelineCursor: unitPosition(unit, epoch), + reservedItemIds, + }); +} + +function textUnitKind(unit: TimelineUnit): "assistant_message" | "thought" | null { + if (unit.event.type !== "timeline") return null; + if (unit.event.item.type === "assistant_message") return "assistant_message"; + if (unit.event.item.type === "reasoning") return "thought"; + return null; +} + +function unitText(unit: TimelineUnit): string { + if (unit.event.type !== "timeline") return ""; + const item = unit.event.item; + return item.type === "assistant_message" || item.type === "reasoning" ? item.text : ""; +} + +function unitMessageId(unit: TimelineUnit): string | undefined { + return unit.event.type === "timeline" && unit.event.item.type === "assistant_message" + ? unit.event.item.messageId + : undefined; +} + +function straddlesAuthoritativeCursor( + unit: TimelineUnit, + currentEndSeq: number | undefined, +): boolean { + return ( + currentEndSeq !== undefined && + unit.sourceSeqRanges.some( + (range) => range.startSeq <= currentEndSeq && range.endSeq > currentEndSeq, ) - ) { - return { tail: params.tail, head: params.head, reconciled: false }; + ); +} + +// Identified unpositioned markdown blocks form a contiguous segment, not an entire message: +// a tool can divide two segments with the same message id. Block text resolves coverage only +// after identity matches. Ambiguous repeated segments remain unverified rather than guessed away. +function unpositionedTextRowIndexes( + rows: readonly StreamItem[], + unit: TimelineUnit, + epoch: string, +): number[] { + const messageId = unitMessageId(unit); + if (!messageId) return []; + const canonical = splitMarkdownBlocks(unitText(unit)).join("\n\n"); + const candidates: number[][] = []; + for (let index = 0; index < rows.length; index++) { + const indexes: number[] = []; + const blocks: string[] = []; + while (index < rows.length) { + const row = rows[index]!; + if (row.kind !== "assistant_message" || row.messageId !== messageId || row.timelineCursor) + break; + indexes.push(index++); + blocks.push(...splitMarkdownBlocks(row.text)); + } + if (indexes.length === 0) continue; + const previous = rows[indexes[0]! - 1]; + const next = rows[index]; + const afterStart = + previous?.timelineCursor?.epoch === epoch && rowStartSeq(previous)! > unit.seq; + const beforeStart = next?.timelineCursor?.epoch === epoch && rowStartSeq(next)! <= unit.seq; + const live = blocks.join("\n\n"); + if (!afterStart && !beforeStart && (canonical.startsWith(live) || live.startsWith(canonical))) { + candidates.push(indexes); + } } + return candidates.length === 1 ? candidates[0]! : []; +} - const projectedText = unit.event.item.text; - const projectedMessageId = unit.event.item.messageId; - const matches = (item: StreamItem) => { - if (item.kind !== "assistant_message") return false; - if (projectedMessageId && item.messageId) return item.messageId === projectedMessageId; - return projectedText.startsWith(item.text); - }; - const findMatch = (items: StreamItem[]) => { - const index = items.findLastIndex(matches); - const current = items[index]; - return current?.kind === "assistant_message" ? { current, index } : null; - }; - - const headMatch = findMatch(params.head); - const tailMatch = headMatch ? null : findMatch(params.tail); - const match = headMatch ?? tailMatch; - if (!match) { - return { tail: params.tail, head: params.head, reconciled: false }; +// Positioned units own covered chunks and adjacent continuations. Idless unpositioned rows keep +// the conservative cursor-straddle rule; an identified segment does not need cursor straddling. +function ownedTextRowIndexes( + rows: readonly StreamItem[], + unit: TimelineUnit, + epoch: string, + currentEndSeq: number | undefined, +): number[] { + const kind = textUnitKind(unit); + if (!kind) return []; + const messageId = unitMessageId(unit); + const sameMessage = (row: StreamItem): boolean => + row.kind === kind && + (row.kind !== "assistant_message" || + !messageId || + !row.messageId || + row.messageId === messageId); + const covered = rows + .map((row, index) => + sameMessage(row) && + row.timelineCursor?.epoch === epoch && + rowSourceSeqs(row).some((seq) => seq >= unit.seq && seq <= unit.seqEnd) + ? index + : -1, + ) + .filter((index) => index >= 0); + if (covered.length === 0) { + const unpositioned = unpositionedTextRowIndexes(rows, unit, epoch); + if (unpositioned.length > 0) return unpositioned; } + const [firstCovered, lastCovered] = [covered[0], covered.at(-1)]; + const neighbours = + firstCovered !== undefined && lastCovered !== undefined + ? [firstCovered - 1, lastCovered + 1] + : (() => { + const index = insertionIndex(rows, epoch, unit.seq); + return [index - 1, index]; + })(); + const owned = new Set(covered); + if (messageId) { + for (const index of neighbours) { + const row = rows[index]; + if (row?.kind === "assistant_message" && row.messageId === messageId && row.timelineCursor) + owned.add(index); + } + } + if (owned.size > 0) return [...owned].sort((left, right) => left - right); + if (!straddlesAuthoritativeCursor(unit, currentEndSeq)) return []; + const text = unitText(unit); + const legacy = rows.findLastIndex( + (row) => + row.kind === kind && + !row.timelineCursor && + (!messageId || row.kind !== "assistant_message" || !row.messageId) && + text.startsWith(row.text), + ); + return legacy >= 0 ? [legacy] : []; +} - const blockGroupId = match.current.blockGroupId; - const messageId = projectedMessageId ?? match.current.messageId; - const replacement: AssistantMessageItem = { - kind: "assistant_message", - id: blockGroupId ?? match.current.id, - ...(messageId !== undefined ? { messageId } : {}), - text: projectedText, +function rowSourceSeqs(row: StreamItem): number[] { + const chunks = + row.kind === "assistant_message" || row.kind === "thought" ? row.source?.chunks : undefined; + if (chunks) return chunks.map((chunk) => chunk.seq); + return row.timelineCursor ? [row.timelineCursor.seq] : []; +} + +// Fully accepted identified segments use the same representation as fresh hydration. Reserve +// other rows' identities, not the transient markdown blocks this unit replaces. +function replaceTextUnit( + lanes: OrderedLanes, + indexes: number[], + unit: TimelineUnit, + epoch: string, +): OrderedLanes { + const first = indexes[0]!; + const last = indexes.at(-1)!; + const otherRows = lanes.rows.filter((_row, index) => index < first || index > last); + const reserved = new Set( + otherRows.flatMap((row) => + row.kind === "assistant_message" && row.blockGroupId ? [row.id, row.blockGroupId] : [row.id], + ), + ); + return spliceLanes(lanes, first, last - first + 1, hydrateUnitAfter([], unit, epoch, reserved)); +} + +function applyTextUnit( + lanes: OrderedLanes, + unit: TimelineUnit, + epoch: string, + currentEndSeq: number | undefined, +): { lanes: OrderedLanes; owned: boolean } { + const kind = textUnitKind(unit); + if (!kind) return { lanes, owned: false }; + const ownedIndexes = ownedTextRowIndexes(lanes.rows, unit, epoch, currentEndSeq); + if (ownedIndexes.length === 0) { + return { lanes: insertUnitInOrder(lanes, unit, epoch), owned: false }; + } + const owned = ownedIndexes + .map((index) => lanes.rows[index]) + .filter((row): row is AssistantMessageItem | ThoughtItem => row?.kind === kind); + const messageId = unitMessageId(unit); + if (messageId && owned.every((row) => !row.timelineCursor)) { + const canonical = splitMarkdownBlocks(unitText(unit)).join("\n\n"); + const live = owned.flatMap((row) => splitMarkdownBlocks(row.text)).join("\n\n"); + // A lagging canonical prefix cannot position text delivered without a cursor. Keep the + // newer segment unverified; otherwise replace all its markdown blocks with exact page text. + if (live.length > canonical.length && live.startsWith(canonical)) return { lanes, owned: true }; + return { lanes: replaceTextUnit(lanes, ownedIndexes, unit, epoch), owned: true }; + } + const fullyCovered = owned.every( + (row) => + row.timelineCursor?.epoch === epoch && + rowStartSeq(row)! >= unit.seq && + row.timelineCursor.seq <= unit.seqEnd, + ); + if (messageId && fullyCovered) + return { lanes: replaceTextUnit(lanes, ownedIndexes, unit, epoch), owned: true }; + const merged: StreamItem[] = []; + for (const row of mergeCanonicalText(owned, { + text: unitText(unit), + epoch, + startSeq: unit.seq, + endSeq: unit.seqEnd, timestamp: unit.timestamp, - timelineCursor: { epoch: params.epoch, seq: unit.seqEnd }, - }; - const belongsToBlockGroup = (item: StreamItem) => - blockGroupId !== undefined && - item.kind === "assistant_message" && - item.blockGroupId === blockGroupId; - const removeBlockGroup = (items: StreamItem[]) => - blockGroupId !== undefined ? items.filter((item) => !belongsToBlockGroup(item)) : items; - const replaceMatch = (items: StreamItem[], index: number) => { - if (!blockGroupId) { - const next = [...items]; - next[index] = replacement; - return next; - } - const next: StreamItem[] = []; - let inserted = false; - for (const item of items) { - if (!belongsToBlockGroup(item)) { - next.push(item); - } else if (!inserted) { - next.push(replacement); - inserted = true; - } - } - return next; - }; - - if (headMatch) { - return { - tail: removeBlockGroup(params.tail), - head: replaceMatch(params.head, headMatch.index), - reconciled: true, - }; + })) { + merged.push( + row.kind === "assistant_message" && messageId && row.messageId === undefined + ? { ...row, messageId } + : row, + ); } + // Owned rows are adjacent by construction; the merge takes their place. + const first = ownedIndexes[0]!; + const last = ownedIndexes.at(-1)!; + return { lanes: spliceLanes(lanes, first, last - first + 1, merged), owned: true }; +} + +// Reducing against the rows before the position lets kind-specific rules (todo diffs, +// compaction completion, idless assistant continuation) see their predecessor while the unit +// still lands in source order. Ids stay unique against the rows after it. +function insertUnitInOrder(lanes: OrderedLanes, unit: TimelineUnit, epoch: string): OrderedLanes { + const index = insertionIndex(lanes.rows, epoch, unit.seq); + const rest = lanes.rows.slice(index); + const identifiedText = Boolean(unitMessageId(unit)); + const reserved = new Set( + (identifiedText ? lanes.rows : rest).flatMap((row) => + row.kind === "assistant_message" && row.blockGroupId ? [row.id, row.blockGroupId] : [row.id], + ), + ); + // Reconciliation already declined ownership. Do not let ordinary live continuation merge + // this identified canonical segment into an ambiguous same-id predecessor during insertion. + if (identifiedText) + return spliceLanes(lanes, index, 0, hydrateUnitAfter([], unit, epoch, reserved)); + const created = hydrateUnitAfter(lanes.rows.slice(0, index), unit, epoch, reserved); + const prefixReplaced = { ...lanes, rows: [...created.slice(0, index), ...rest] }; + return spliceLanes(prefixReplaced, index, 0, created.slice(index)); +} + +function applyUserUnit( + lanes: OrderedLanes, + unit: TimelineUnit, + epoch: string, +): { lanes: OrderedLanes; acknowledgedClientMessageIds: string[] } { + if (unit.event.type !== "timeline" || unit.event.item.type !== "user_message") { + return { lanes, acknowledgedClientMessageIds: [] }; + } + const probe = hydrateUnitAfter([], unit, epoch).find( + (row): row is UserMessageItem => row.kind === "user_message", + ); + if (!probe) return { lanes, acknowledgedClientMessageIds: [] }; + const matched = upsertUserMessageAcrossStream({ + tail: lanes.rows.slice(0, lanes.boundary), + head: lanes.rows.slice(lanes.boundary), + message: probe, + insert: "none", + presentation: "existing", + }); + const location = matched.location; + if (!location?.matched) { + return { lanes: insertUnitInOrder(lanes, unit, epoch), acknowledgedClientMessageIds: [] }; + } + const existingIndex = location.lane === "tail" ? location.index : lanes.boundary + location.index; + const message = withCanonicalTurn(location.message, unit.event.turnId); + const removed = spliceLanes(lanes, existingIndex, 1, []); + const index = insertionIndex(removed.rows, epoch, unit.seq); return { - tail: replaceMatch(params.tail, match.index), - head: removeBlockGroup(params.head), - reconciled: true, + lanes: spliceLanes(removed, index, 0, [message]), + acknowledgedClientMessageIds: message.clientMessageId ? [message.clientMessageId] : [], }; } -function reconcileOverlappingProjectedReasoning(params: { - tail: StreamItem[]; - head: StreamItem[]; - unit: TimelineUnit; - currentEndSeq: number; -}): { tail: StreamItem[]; head: StreamItem[]; reconciled: boolean } { - const { unit } = params; - if ( - unit.event.type !== "timeline" || - unit.event.item.type !== "reasoning" || - !unit.sourceSeqRanges.some( - (range) => range.startSeq <= params.currentEndSeq && range.endSeq > params.currentEndSeq, - ) - ) { - return { tail: params.tail, head: params.head, reconciled: false }; +// A canonical user row is authoritative for turn membership. +function withCanonicalTurn(message: UserMessageItem, turnId: string | undefined): UserMessageItem { + if (message.turnId === turnId) return message; + if (turnId) return { ...message, turnId }; + const { turnId: _, ...withoutTurn } = message; + return withoutTurn; +} + +function applyOtherUnit(lanes: OrderedLanes, unit: TimelineUnit, epoch: string): OrderedLanes { + const probe = hydrateUnitAfter([], unit, epoch).at(-1); + if (!probe) return lanes; + const identity = streamTimelineItemIdentity(probe); + const existingIndex = lanes.rows.findIndex((row) => + identity !== null + ? streamTimelineItemIdentity(row) === identity + : row.kind === probe.kind && + row.timelineCursor?.epoch === epoch && + rowStartSeq(row) === unit.seq, + ); + if (existingIndex >= 0) { + const existing = lanes.rows[existingIndex]!; + const replacement = + probe.kind === "notification" + ? { ...probe, id: existing.id } + : (hydrateUnitAfter([existing], unit, epoch).at(-1) ?? existing); + return spliceLanes(lanes, existingIndex, 1, [{ ...replacement, id: existing.id }]); } + return insertUnitInOrder(lanes, unit, epoch); +} - const projectedText = unit.event.item.text; - const replaceIn = (items: StreamItem[]): StreamItem[] | null => { - const index = items.findLastIndex( - (item) => item.kind === "thought" && projectedText.startsWith(item.text), - ); - const current = items[index]; - if (!current || current.kind !== "thought") return null; - const next = [...items]; - next[index] = { - ...current, - text: projectedText, - timestamp: unit.timestamp, - status: "loading", +function applyForwardUnit( + lanes: OrderedLanes, + unit: TimelineUnit, + epoch: string, + currentEndSeq: number | undefined, +): { lanes: OrderedLanes; acknowledgedClientMessageIds: string[] } { + if (textUnitKind(unit)) { + return { + lanes: applyTextUnit(lanes, unit, epoch, currentEndSeq).lanes, + acknowledgedClientMessageIds: [], }; - return next; - }; - - const nextHead = replaceIn(params.head); - if (nextHead) return { tail: params.tail, head: nextHead, reconciled: true }; - const nextTail = replaceIn(params.tail); - return nextTail - ? { tail: nextTail, head: params.head, reconciled: true } - : { tail: params.tail, head: params.head, reconciled: false }; + } + if (unit.event.type === "timeline" && unit.event.item.type === "user_message") { + return applyUserUnit(lanes, unit, epoch); + } + return { lanes: applyOtherUnit(lanes, unit, epoch), acknowledgedClientMessageIds: [] }; } function reconcileOverlappingProjectedStreamItems(params: { @@ -921,109 +1193,22 @@ function reconcileOverlappingProjectedStreamItems(params: { epoch: string; currentEndSeq: number | undefined; }): { tail: StreamItem[]; head: StreamItem[]; reconciledUnits: Set } { - let tail = params.tail; - let head = params.head; + let lanes: OrderedLanes = { + rows: [...params.tail, ...params.head], + boundary: params.tail.length, + }; const reconciledUnits = new Set(); - if (params.currentEndSeq === undefined) return { tail, head, reconciledUnits }; - for (const unit of params.units) { - let reconciled = reconcileOverlappingProjectedAssistant({ - tail, - head, - unit, - epoch: params.epoch, - currentEndSeq: params.currentEndSeq, - }); - if (!reconciled.reconciled) { - reconciled = reconcileOverlappingProjectedReasoning({ - tail, - head, - unit, - currentEndSeq: params.currentEndSeq, - }); - } - tail = reconciled.tail; - head = reconciled.head; - if (reconciled.reconciled) reconciledUnits.add(unit); + if (!textUnitKind(unit)) continue; + if (ownedTextRowIndexes(lanes.rows, unit, params.epoch, params.currentEndSeq).length === 0) + continue; + lanes = applyTextUnit(lanes, unit, params.epoch, params.currentEndSeq).lanes; + reconciledUnits.add(unit); } - return { tail, head, reconciledUnits }; -} - -function applyCanonicalForwardUnit(params: { - tail: StreamItem[]; - head: StreamItem[]; - unit: TimelineUnit; - epoch: string; -}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } { - const { event, timestamp, seqEnd } = params.unit; - const timelineCursor = { epoch: params.epoch, seq: seqEnd }; - if (event.type === "timeline" && event.item.type === "user_message") { - const applied = applyStreamEvent({ - tail: params.tail, - head: params.head, - event, - timestamp, - source: "canonical", - timelineCursor, - }); - return { - tail: applied.tail, - head: applied.head, - acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [], - }; - } - if (params.head.length === 0) { - return { - tail: reduceStreamUpdate(params.tail, event, timestamp, { - source: "canonical", - timelineCursor, - }), - head: params.head, - acknowledgedClientMessageIds: [], - }; - } - const replacedHead = replaceLiveAssistantWithProjectedText({ - head: params.head, - event, - timestamp, - timelineCursor, - }); - if (replacedHead) { - return { tail: params.tail, head: replacedHead, acknowledgedClientMessageIds: [] }; - } - - const activeAssistant = params.head.findLast( - (item): item is Extract => - item.kind === "assistant_message", - ); - if ( - event.type === "timeline" && - event.item.type === "assistant_message" && - event.item.messageId !== undefined && - event.item.messageId !== activeAssistant?.messageId - ) { - return { - tail: flushHeadToTail(params.tail, params.head), - head: reduceStreamUpdate([], event, timestamp, { - source: "canonical", - timelineCursor, - }), - acknowledgedClientMessageIds: [], - }; - } - - const applied = applyStreamEvent({ - tail: params.tail, - head: params.head, - event, - timestamp, - source: "canonical", - timelineCursor, - }); return { - tail: applied.tail, - head: applied.head, - acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [], + tail: lanes.rows.slice(0, lanes.boundary), + head: lanes.rows.slice(lanes.boundary), + reconciledUnits, }; } @@ -1034,37 +1219,22 @@ function applyAcceptedForwardTimelineUnits(params: { currentHead: StreamItem[]; currentEndSeq: number | undefined; }): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } { - const reconciled = reconcileOverlappingProjectedStreamItems({ - tail: params.currentTail, - head: params.currentHead, - units: params.units, - epoch: params.epoch, - currentEndSeq: params.currentEndSeq, - }); - let tail = reconciled.tail; - let head = reconciled.head; - + let lanes: OrderedLanes = { + rows: [...params.currentTail, ...params.currentHead], + boundary: params.currentTail.length, + }; + const acknowledged = new Set(); for (const unit of params.units) { - if (reconciled.reconciledUnits.has(unit)) continue; - const applied = applyCanonicalForwardUnit({ - tail, - head, - unit, - epoch: params.epoch, - }); - tail = applied.tail; - head = applied.head; + const applied = applyForwardUnit(lanes, unit, params.epoch, params.currentEndSeq); + lanes = applied.lanes; + for (const clientMessageId of applied.acknowledgedClientMessageIds) { + acknowledged.add(clientMessageId); + } } - return { - tail, - head, - acknowledgedClientMessageIds: deriveCanonicalAcknowledgements({ - units: params.units, - epoch: params.epoch, - currentTail: params.currentTail, - currentHead: params.currentHead, - }), + tail: lanes.rows.slice(0, lanes.boundary), + head: lanes.rows.slice(lanes.boundary), + acknowledgedClientMessageIds: [...acknowledged], }; } @@ -1074,14 +1244,15 @@ function deriveCanonicalAcknowledgements(params: { currentTail: StreamItem[]; currentHead: StreamItem[]; }): string[] { - let tail = params.currentTail; - let head = params.currentHead; + let lanes: OrderedLanes = { + rows: [...params.currentTail, ...params.currentHead], + boundary: params.currentTail.length, + }; const acknowledged = new Set(); for (const unit of params.units) { if (unit.event.type !== "timeline" || unit.event.item.type !== "user_message") continue; - const applied = applyCanonicalForwardUnit({ tail, head, unit, epoch: params.epoch }); - tail = applied.tail; - head = applied.head; + const applied = applyUserUnit(lanes, unit, params.epoch); + lanes = applied.lanes; for (const clientMessageId of applied.acknowledgedClientMessageIds) { acknowledged.add(clientMessageId); } @@ -1155,10 +1326,10 @@ function applyAcceptedTimelinePage(input: { }); } const olderTail = hydrateStreamState( - acceptedUnits.map(({ event, timestamp, seqEnd }) => ({ - event, - timestamp, - timelineCursor: { epoch: payload.epoch, seq: seqEnd }, + acceptedUnits.map((unit) => ({ + event: unit.event, + timestamp: unit.timestamp, + timelineCursor: unitPosition(unit, payload.epoch), })), { source: "canonical", @@ -1259,7 +1430,6 @@ export function processTimelineResponse( currentCursor, isInitializing, hasActiveInitDeferred, - initRequestDirection, sendingClientMessageIds, } = input; @@ -1274,9 +1444,7 @@ export function processTimelineResponse( cursor: currentCursor, cursorChanged: false, older: "unchanged", - initResolution: hasActiveInitDeferred ? "reject" : null, - clearInitializing: isInitializing, - error: payload.error, + ...processTimelineResponseCompletion(input), sideEffects: [], acknowledgedClientMessageIds: [], }; @@ -1301,23 +1469,18 @@ export function processTimelineResponse( timestamp: new Date(entry.timestamp), })); - const toHydratedEvents = ( - units: TimelineUnit[], - ): Array<{ - event: AgentStreamEventPayload; - timestamp: Date; - timelineCursor: { epoch: string; seq: number }; - }> => - units.map(({ event, timestamp, seqEnd }) => ({ - event, - timestamp, - timelineCursor: { epoch: payload.epoch, seq: seqEnd }, + const toHydratedEvents = (units: TimelineUnit[]): HydratedTimelineEvent[] => + units.map((unit) => ({ + event: unit.event, + timestamp: unit.timestamp, + timelineCursor: unitPosition(unit, payload.epoch), })); // ------------------------------------------------------------------ // Derive bootstrap policy (replace vs incremental) // ------------------------------------------------------------------ const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({ + replaceTail: input.replaceTail === true, direction: payload.direction, reset: payload.reset, epoch: payload.epoch, @@ -1372,6 +1535,7 @@ export function processTimelineResponse( : bootstrapPolicy, currentTail, currentHead, + currentCursor, sendingClientMessageIds, preserveContinuity: shouldPreserveReplacementContinuity({ isResumeReplacement, @@ -1410,20 +1574,6 @@ export function processTimelineResponse( // ------------------------------------------------------------------ // Init resolution // ------------------------------------------------------------------ - const shouldResolveDeferredInit = shouldResolveTimelineInit({ - hasActiveInitDeferred, - hasNewer: payload.hasNewer, - isInitializing, - initRequestDirection, - responseDirection: payload.direction, - reset: payload.reset, - }); - const timelineResponseComplete = payload.direction !== "after" || !payload.hasNewer; - const clearInitializing = - (shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred)) && - timelineResponseComplete; - - const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit ? "resolve" : null; const commit = discard ? "discard" : "apply"; return { @@ -1433,9 +1583,7 @@ export function processTimelineResponse( cursor: nextCursor, cursorChanged, older: timelineResult.older, - initResolution, - clearInitializing, - error: null, + ...processTimelineResponseCompletion(input), sideEffects, acknowledgedClientMessageIds: timelineResult.acknowledgedClientMessageIds, }; @@ -1502,13 +1650,7 @@ export interface AgentStreamReducerQueue { } export interface CreateAgentStreamReducerQueueInput { - getSnapshot: (agentId: string) => AgentStreamReducerSnapshot; - commit: ( - agentId: string, - result: ProcessAgentStreamEventOutput, - events: AgentStreamReducerEvent[], - ) => void; - handleSideEffects: (agentId: string, sideEffects: AgentStreamReducerSideEffect[]) => void; + apply: (agentId: string, events: AgentStreamReducerEvent[]) => void; scheduleFlush: (callback: () => void) => number; cancelFlush: (id: number) => void; } @@ -1568,6 +1710,7 @@ function processTimelineSequencingGate(input: { { type: "catch_up", cursor: { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq }, + observedSeq: seq, }, ] : [], @@ -1769,15 +1912,7 @@ export function createAgentStreamReducerQueue( cancelScheduledFlush(); } - const result = processAgentStreamEvents({ - events, - ...input.getSnapshot(agentId), - }); - - input.commit(agentId, result, events); - if (result.sideEffects.length > 0) { - input.handleSideEffects(agentId, result.sideEffects); - } + input.apply(agentId, events); }; const flush = () => { @@ -1820,13 +1955,6 @@ export function createAgentStreamReducerQueue( }; } -interface StreamStatePatch { - tail?: StreamItem[]; - head?: StreamItem[]; - acknowledgedClientMessageIds?: readonly string[]; - taskSnapshot?: TodoEntry[]; -} - export function deriveAgentStreamTurnLiveness( events: readonly AgentStreamReducerEvent[], ): TurnLivenessTransition[] { @@ -1848,17 +1976,6 @@ export function deriveAgentStreamTurnLiveness( return transitions; } -export interface CreateSessionAgentStreamReducerQueueInput { - serverId: string; - setAgentStreamState: (serverId: string, agentId: string, state: StreamStatePatch) => void; - setAgentTimelineCursor: ( - serverId: string, - state: (prev: Map) => Map, - ) => void; - recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void; - onCommitted?: (agentId: string) => void; -} - interface ScheduledReducerFlush { frameId: number | null; timerId: ReturnType; @@ -1878,7 +1995,7 @@ function clearScheduledReducerFlush(handle: ScheduledReducerFlush): void { // an arbitrary timer that drifts on and off the display beat. A frame callback // never fires in a hidden tab, so a timer races it and wins when nothing is // painting — the store has to keep advancing either way. -function scheduleAgentStreamReducerFlush(callback: () => void): number { +export function scheduleAgentStreamReducerFlush(callback: () => void): number { const id = nextScheduledReducerFlushId; nextScheduledReducerFlushId += 1; @@ -1898,7 +2015,7 @@ function scheduleAgentStreamReducerFlush(callback: () => void): number { return id; } -function cancelAgentStreamReducerFlush(id: number) { +export function cancelAgentStreamReducerFlush(id: number) { const handle = scheduledReducerFlushes.get(id); if (!handle) { return; @@ -1906,80 +2023,3 @@ function cancelAgentStreamReducerFlush(id: number) { scheduledReducerFlushes.delete(id); clearScheduledReducerFlush(handle); } - -export function createSessionAgentStreamReducerQueue( - input: CreateSessionAgentStreamReducerQueueInput, -): AgentStreamReducerQueue { - const { serverId, setAgentStreamState, setAgentTimelineCursor, recoverTimelineGap, onCommitted } = - input; - - return createAgentStreamReducerQueue({ - getSnapshot: (agentId) => { - const session = useSessionStore.getState().sessions[serverId]; - const timeline = selectAgentTimelineState(session, agentId); - return { - currentTail: timeline.status === "cold" ? [] : timeline.items, - currentHead: session?.agentStreamHead.get(agentId) ?? [], - currentCursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined, - hasAuthoritativeBaseline: timeline.status === "synced", - isDetached: timeline.status === "synced" && timeline.newer === "available", - }; - }, - commit: (agentId, result, events) => { - if ( - result.changedTail || - result.changedHead || - result.acknowledgedClientMessageIds.length > 0 || - result.taskSnapshot !== undefined - ) { - setAgentStreamState(serverId, agentId, { - ...(result.changedTail ? { tail: result.tail } : {}), - ...(result.changedHead ? { head: result.head } : {}), - ...(result.acknowledgedClientMessageIds.length > 0 - ? { acknowledgedClientMessageIds: result.acknowledgedClientMessageIds } - : {}), - ...(result.taskSnapshot !== undefined ? { taskSnapshot: result.taskSnapshot } : {}), - }); - } - if (result.cursorChanged && result.cursor) { - const nextCursor = result.cursor; - const lastEvent = events.at(-1); - setAgentTimelineCursor(serverId, (prev) => { - const current = prev.get(agentId); - if ( - current && - lastEvent && - typeof lastEvent.seq === "number" && - typeof lastEvent.epoch === "string" && - current.epoch === lastEvent.epoch && - lastEvent.seq >= current.startSeq && - lastEvent.seq <= current.endSeq - ) { - return prev; - } - if ( - current && - current.epoch === nextCursor.epoch && - current.startSeq === nextCursor.startSeq && - current.endSeq === nextCursor.endSeq - ) { - return prev; - } - const next = new Map(prev); - next.set(agentId, nextCursor); - return next; - }); - } - onCommitted?.(agentId); - }, - handleSideEffects: (agentId, sideEffects) => { - for (const effect of sideEffects) { - if (effect.type === "catch_up") { - recoverTimelineGap(agentId, effect.cursor); - } - } - }, - scheduleFlush: scheduleAgentStreamReducerFlush, - cancelFlush: cancelAgentStreamReducerFlush, - }); -} diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts index 8c7c156e6f..e38430ffdb 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -1,10 +1,6 @@ import { expect, test, vi } from "vitest"; import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan"; -import { - consumeForcedTimelineTailReplacement, - createViewedTimelineSync, - type TimelineResponsePayload, -} from "./viewed-timeline-sync"; +import { createViewedTimelineSync } from "./viewed-timeline-sync"; interface Deferred { promise: Promise; @@ -31,7 +27,7 @@ interface MembershipRequest { interface TimelineFetch { agentId: string; request: ProjectedTimelineForwardFetchPlan; - respond(input: { hasNewer: boolean; seq?: number }): void; + respond(input: { hasNewer: boolean; seq?: number | null }): void; fail(message: string): void; } @@ -39,7 +35,6 @@ class TimelineWorld { readonly errors: string[] = []; readonly cursors = new Map(); readonly cacheRequests: string[] = []; - readonly forcedTimelineTailReplacements = new Set(); cacheGate: Deferred | null = null; readonly sync = createViewedTimelineSync({ replaceDemandedAgentIds: () => undefined, @@ -71,7 +66,7 @@ class TimelineWorld { respond: ({ hasNewer, seq = 1 }) => result.resolve({ hasNewer, - endCursor: { epoch: `epoch-${agentId}`, seq }, + endCursor: seq === null ? null : { epoch: `epoch-${agentId}`, seq }, }), fail: (message) => result.reject(new Error(message)), }); @@ -79,16 +74,11 @@ class TimelineWorld { return result.promise; }, fetchLatestTail: async (agentId) => { - this.forcedTimelineTailReplacements.add(agentId); - try { - return await this.fetchTimeline(agentId, { - direction: "tail", - limit: 40, - projection: "projected", - }); - } finally { - this.forcedTimelineTailReplacements.delete(agentId); - } + return await this.fetchTimeline(agentId, { + direction: "tail", + limit: 40, + projection: "projected", + }); }, reportError: (error) => { this.errors.push(error instanceof Error ? error.message : String(error)); @@ -130,10 +120,6 @@ class TimelineWorld { return this.fetches.length; } - applyTimelineResponse(payload: TimelineResponsePayload): TimelineResponsePayload { - return consumeForcedTimelineTailReplacement(payload, this.forcedTimelineTailReplacements); - } - nextCacheRequest(): Promise { const request = this.cacheRequests.at(-1); if (request) return Promise.resolve(request); @@ -152,7 +138,10 @@ class TimelineWorld { agentId, request, respond: ({ hasNewer, seq = 1 }) => - result.resolve({ hasNewer, endCursor: { epoch: `epoch-${agentId}`, seq } }), + result.resolve({ + hasNewer, + endCursor: seq === null ? null : { epoch: `epoch-${agentId}`, seq }, + }), fail: (message) => result.reject(new Error(message)), }); this.releaseFetchWaiters(); @@ -222,7 +211,7 @@ class TimelineWorld { test("uses a tail fetch when an agent becomes visible", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -235,7 +224,7 @@ test("loads the cache before choosing the authoritative network request", async const world = new TimelineWorld(); world.cacheGate = deferred(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); const cacheRequest = await world.nextCacheRequest(); @@ -256,11 +245,22 @@ test("loads the cache before choosing the authoritative network request", async fetch.respond({ hasNewer: false }); }); +test("a fresh visible agent lifetime performs a fresh cache preparation", async () => { + const world = new TimelineWorld(); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + await vi.waitFor(() => expect(world.cacheRequests).toEqual(["agent-a"])); + + world.sync.endAgentLifetime("agent-a"); + world.sync.beginAgentLifetime("agent-a"); + + await vi.waitFor(() => expect(world.cacheRequests).toEqual(["agent-a", "agent-a"])); +}); + test("catches up after the restored cursor when an agent becomes visible", async () => { const world = new TimelineWorld(); world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 }); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -278,7 +278,7 @@ test("falls back to the latest tail when a restored cursor has more than one cat const world = new TimelineWorld(); world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 }); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -288,63 +288,168 @@ test("falls back to the latest tail when a restored cursor has more than one cat const fallback = await world.nextFetch("agent-a"); expect(fallback.request).toEqual({ direction: "tail", limit: 40, projection: "projected" }); - expect( - world.applyTimelineResponse({ - requestId: "fallback-tail", - agentId: "agent-a", - agent: null, - direction: "tail", - projection: "projected", - epoch: "epoch-agent-a", - reset: false, - staleCursor: false, - gap: false, - window: { minSeq: 43, maxSeq: 82, nextSeq: 83 }, - startCursor: { epoch: "epoch-agent-a", seq: 43 }, - endCursor: { epoch: "epoch-agent-a", seq: 82 }, - hasOlder: true, - hasNewer: false, - entries: [], - error: null, - }).reset, - ).toBe(true); fallback.respond({ hasNewer: false }); }); test("a gap absorbed by a running tail is recovered after the tail completes", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); const tail = await world.nextFetch("agent-a"); - world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 9 }); + // A live row at 12 could not be applied while the tail was in flight. + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 9 }, 12); world.expectNoPendingFetch(); - tail.respond({ hasNewer: false }); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); + tail.respond({ hasNewer: false, seq: 10 }); + // The tail settled coverage at 10; the owner continues from there, not from the stale gap cursor. const recovery = await world.nextFetch("agent-a"); expect(recovery.request).toEqual({ direction: "after", - cursor: { epoch: "epoch-agent-a", seq: 9 }, + cursor: { epoch: "epoch-agent-a", seq: 10 }, limit: 40, projection: "projected", }); - recovery.respond({ hasNewer: false }); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 12 }); + recovery.respond({ hasNewer: false, seq: 12 }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + world.expectNoPendingFetch(); +}); + +test("a page that predates observed activity does not complete the catch-up", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const initial = await world.nextFetch("agent-a"); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); + initial.respond({ hasNewer: false, seq: 10 }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 15); + const page = await world.nextFetch("agent-a"); + expect(page.request).toMatchObject({ direction: "after", cursor: { seq: 10 } }); + // More live rows arrive while that page is in transit; the page was built before them. + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 20); + world.expectNoPendingFetch(); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 15 }); + page.respond({ hasNewer: false, seq: 15 }); + + const rest = await world.nextFetch("agent-a"); + expect(rest.request).toMatchObject({ direction: "after", cursor: { seq: 15 } }); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 20 }); + rest.respond({ hasNewer: false, seq: 20 }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + world.expectNoPendingFetch(); +}); + +test.each([null, 10])( + "activity learned during a non-advancing request is fetched immediately without a sync error (end=%s)", + async (end) => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + (await world.nextMembership()).succeed(); + const initial = await world.nextFetch("agent-a"); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); + initial.respond({ hasNewer: false, seq: 10 }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 15); + const old = await world.nextFetch("agent-a"); + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 25); + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 20); + old.respond({ hasNewer: false, seq: end }); + const fresh = await world.nextFetch("agent-a"); + expect(world.errors).toEqual([]); + expect(fresh.request).toMatchObject({ direction: "after", cursor: { seq: 10 } }); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 25 }); + fresh.respond({ hasNewer: false, seq: 25 }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + world.expectNoPendingFetch(); + world.sync.dispose(); + }, +); + +test("no progress against an obligation already known at request start retains it with bounded backoff", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const initial = await world.nextFetch("agent-a"); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); + initial.respond({ hasNewer: false, seq: 10 }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 20); + const page = await world.nextFetch("agent-a"); + page.respond({ hasNewer: false, seq: 10 }); + + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error")); + expect(world.errors).toEqual([ + "Timeline coverage for agent-a has not reached observed sequence 20", + ]); + world.expectNoPendingFetch(); + const retry = await world.nextRetry(1000); + retry(); + const fresh = await world.nextFetch("agent-a"); + expect(fresh.request).toMatchObject({ direction: "after", cursor: { seq: 10 } }); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 20 }); + fresh.respond({ hasNewer: false, seq: 20 }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + world.sync.dispose(); +}); + +test("a daemon claiming another page without advancing cannot cause a busy loop", async () => { + const world = new TimelineWorld(); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); + world.sync.setConnected(true); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + (await world.nextMembership()).succeed(); + const first = await world.nextFetch("agent-a"); + first.respond({ hasNewer: true, seq: 10 }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error")); + expect(world.errors).toEqual(["Timeline page for agent-a did not advance its cursor"]); + world.expectNoPendingFetch(); + await world.nextRetry(1000); + world.sync.dispose(); +}); + +test("an observed head from a previous epoch does not extend a catch-up", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const initial = await world.nextFetch("agent-a"); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); + initial.respond({ hasNewer: false, seq: 10 }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 20); + const page = await world.nextFetch("agent-a"); + world.cursors.set("agent-a", { epoch: "epoch-next", endSeq: 3 }); + page.respond({ hasNewer: false, seq: 3 }); + + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + world.expectNoPendingFetch(); }); test("unchanged visible-set publication does not cancel paged catch-up", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending"); const membership = await world.nextMembership(); membership.succeed(); const firstPage = await world.nextFetch("agent-a"); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a", "agent-a"]); firstPage.respond({ hasNewer: true, seq: 5 }); const secondPage = await world.nextFetch("agent-a"); expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending"); @@ -366,7 +471,7 @@ test("unchanged visible-set publication does not cancel paged catch-up", async ( test("all acknowledged agents begin catch-up independently", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-b", "agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-b", "agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -383,7 +488,7 @@ test("all acknowledged agents begin catch-up independently", async () => { test("an eviction during acknowledgement never catches up the stale hot set", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", [ + world.sync.registerVisibleAgentIds("workspace", [ "agent-a", "agent-b", "agent-c", @@ -391,7 +496,7 @@ test("an eviction during acknowledgement never catches up the stale hot set", as "agent-e", ]); const staleMembership = await world.nextMembership(); - world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-f"]); staleMembership.succeed(); const currentMembership = await world.nextMembership(); currentMembership.succeed(); @@ -412,7 +517,7 @@ test("an eviction during acknowledgement never catches up the stale hot set", as test("disconnect cancels paging and reconnect restores membership before fresh catch-up", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const firstMembership = await world.nextMembership(); firstMembership.succeed(); const stalePage = await world.nextFetch("agent-a"); @@ -434,8 +539,8 @@ test("disconnect cancels paging and reconnect restores membership before fresh c test("navigation while disconnected reconnects only the currently visible agent", async () => { const world = new TimelineWorld(); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); world.sync.setConnected(true); const membership = await world.nextMembership(); @@ -449,8 +554,8 @@ test("navigation while disconnected reconnects only the currently visible agent" test("overlapping sources deduplicate membership and retain hidden hot agents", async () => { const world = new TimelineWorld(); - world.sync.replaceVisibleAgentIds("left-route", ["agent-a"]); - world.sync.replaceVisibleAgentIds("right-route", ["agent-a", "agent-b"]); + world.sync.registerVisibleAgentIds("left-route", ["agent-a"]); + world.sync.registerVisibleAgentIds("right-route", ["agent-a", "agent-b"]); world.sync.setConnected(true); const combined = await world.nextMembership(); combined.succeed(); @@ -461,19 +566,37 @@ test("overlapping sources deduplicate membership and retain hidden hot agents", agentA.respond({ hasNewer: false }); agentB.respond({ hasNewer: false }); - world.sync.replaceVisibleAgentIds("left-route", []); + world.sync.registerVisibleAgentIds("left-route", []); world.expectNoPendingMembership(); - world.sync.replaceVisibleAgentIds("right-route", ["agent-b"]); + world.sync.registerVisibleAgentIds("right-route", ["agent-b"]); expect(combined.agentIds).toEqual(["agent-a", "agent-b"]); world.expectNoPendingMembership(); world.expectNoPendingFetch(); }); +test("a replacement screen declaration survives obsolete cleanup and clears on final unmount", async () => { + const world = new TimelineWorld(); + const initial = world.sync.registerVisibleAgentIds("host:workspace", ["agent-a"]); + const replacement = world.sync.registerVisibleAgentIds("host:workspace", ["agent-b"]); + + initial.dispose(); + replacement.replace(["agent-c"]); + world.sync.setConnected(true); + const membership = await world.nextMembership(); + expect(membership.agentIds).toEqual(["agent-c"]); + membership.succeed(); + + replacement.dispose(); + replacement.replace(["agent-d"]); + world.expectNoPendingMembership(); + expect(world.cacheRequests).not.toContain("agent-d"); +}); + test("a failed catch-up reports once and retries through the explicit retry policy", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); const failed = await world.nextFetch("agent-a"); @@ -497,7 +620,7 @@ test("a failed catch-up reports once and retries through the explicit retry poli test("a failed catch-up retries with exponential backoff", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -526,7 +649,7 @@ test("a failed catch-up retries with exponential backoff", async () => { test("manual retries can immediately re-attempt a failed catch-up", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -546,7 +669,7 @@ test("manual retries can immediately re-attempt a failed catch-up", async () => test("redeclaring unchanged visibility does not bypass catch-up backoff", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -554,7 +677,7 @@ test("redeclaring unchanged visibility does not bypass catch-up backoff", async failed.fail("timeline unavailable"); await world.nextRetry(); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); world.expectNoPendingMembership(); world.expectNoPendingFetch(); @@ -564,7 +687,7 @@ test("redeclaring unchanged visibility does not bypass catch-up backoff", async test("a manual retry that fails returns to the error state", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -583,14 +706,15 @@ test("a manual retry that fails returns to the error state", async () => { test("gap recovery supersedes completed catch-up and pages through the current tail", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); const initial = await world.nextFetch("agent-a"); initial.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 12); const gapPage = await world.nextFetch("agent-a"); gapPage.respond({ hasNewer: true, seq: 15 }); const finalPage = await world.nextFetch("agent-a"); @@ -615,7 +739,7 @@ test("gap recovery supersedes completed catch-up and pages through the current t test("repeated recovery for the same running gap reuses the in-flight fetch", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); const initial = await world.nextFetch("agent-a"); @@ -634,7 +758,7 @@ test("repeated recovery for the same running gap reuses the in-flight fetch", as test("membership failure autonomously retries without another visibility declaration", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); const [error, retryMembership] = await Promise.all([world.nextError(), world.nextRetry()]); @@ -658,7 +782,7 @@ test("membership failure autonomously retries without another visibility declara test("membership failures retry with exponential backoff", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const first = await world.nextMembership(); first.fail("subscription unavailable"); @@ -686,13 +810,13 @@ test("membership failures retry with exponential backoff", async () => { test("redeclaring unchanged visibility does not bypass membership backoff", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); await world.nextRetry(); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); world.expectNoPendingMembership(); world.expectNoPendingFetch(); @@ -702,13 +826,13 @@ test("redeclaring unchanged visibility does not bypass membership backoff", asyn test("backgrounding preserves the hot membership without catch-up on return", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const agentAMembership = await world.nextMembership(); agentAMembership.succeed(); const agentACatchUp = await world.nextFetch("agent-a"); agentACatchUp.respond({ hasNewer: false }); - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); const agentBMembership = await world.nextMembership(); agentBMembership.succeed(); const agentBCatchUp = await world.nextFetch("agent-b"); @@ -727,12 +851,12 @@ test("backgrounding preserves the hot membership without catch-up on return", as test("stale membership retry cannot overwrite a newer effective set", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); const staleRetry = await world.nextRetry(); - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); const current = await world.nextMembership(); staleRetry(); current.succeed(); @@ -750,7 +874,7 @@ test("stale membership retry cannot overwrite a newer effective set", async () = test("membership retry cannot run while disconnected", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); const disconnectedRetry = await world.nextRetry(); @@ -770,13 +894,13 @@ test("membership retry cannot run while disconnected", async () => { test("returning to a hot hidden agent stays live after inactivity", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const initialMembership = await world.nextMembership(); initialMembership.succeed(); const agentACatchUp = await world.nextFetch("agent-a"); agentACatchUp.respond({ hasNewer: false }); - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); const expandedMembership = await world.nextMembership(); expandedMembership.succeed(); const agentBCatchUp = await world.nextFetch("agent-b"); @@ -784,7 +908,7 @@ test("returning to a hot hidden agent stays live after inactivity", async () => await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready")); world.elapse(60_000); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); world.expectNoPendingMembership(); world.expectNoPendingFetch(); @@ -794,7 +918,7 @@ test("the hot set evicts the least-recent hidden agent and promotes revisited ag const world = new TimelineWorld(); world.sync.setConnected(true); for (const agentId of ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"]) { - world.sync.replaceVisibleAgentIds("workspace", [agentId]); + world.sync.registerVisibleAgentIds("workspace", [agentId]); const membership = await world.nextMembership(); membership.succeed(); const catchUp = await world.nextFetch(agentId); @@ -802,22 +926,22 @@ test("the hot set evicts the least-recent hidden agent and promotes revisited ag await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus(agentId)).toBe("ready")); } - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); world.expectNoPendingMembership(); world.expectNoPendingFetch(); - world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-f"]); const eviction = await world.nextMembership(); eviction.succeed(); const agentFCatchUp = await world.nextFetch("agent-f"); agentFCatchUp.respond({ hasNewer: false }); expect(eviction.agentIds).toEqual(["agent-b", "agent-c", "agent-d", "agent-e", "agent-f"]); - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); world.expectNoPendingMembership(); world.expectNoPendingFetch(); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const restoreEvicted = await world.nextMembership(); restoreEvicted.succeed(); const agentACatchUp = await world.nextFetch("agent-a"); @@ -829,7 +953,7 @@ test("visible agents are never evicted when they exceed the hot-set limit", asyn const world = new TimelineWorld(); const visible = ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e", "agent-f"]; world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", visible); + world.sync.registerVisibleAgentIds("workspace", visible); const membership = await world.nextMembership(); membership.succeed(); const catchUps = await Promise.all(visible.map((agentId) => world.nextFetch(agentId))); @@ -837,7 +961,7 @@ test("visible agents are never evicted when they exceed the hot-set limit", asyn expect(membership.agentIds).toEqual(visible); - world.sync.replaceVisibleAgentIds("workspace", visible.slice(0, 5)); + world.sync.registerVisibleAgentIds("workspace", visible.slice(0, 5)); const hiddenEviction = await world.nextMembership(); hiddenEviction.succeed(); expect(hiddenEviction.agentIds).toEqual(visible.slice(0, 5)); @@ -846,13 +970,13 @@ test("visible agents are never evicted when they exceed the hot-set limit", asyn test("disconnect clears hidden hot agents before reconnecting the visible set", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const agentAMembership = await world.nextMembership(); agentAMembership.succeed(); const agentACatchUp = await world.nextFetch("agent-a"); agentACatchUp.respond({ hasNewer: false }); - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); const agentBMembership = await world.nextMembership(); agentBMembership.succeed(); const agentBCatchUp = await world.nextFetch("agent-b"); @@ -869,7 +993,7 @@ test("disconnect clears hidden hot agents before reconnecting the visible set", expect(restored.agentIds).toEqual(["agent-b"]); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const reopened = await world.nextMembership(); reopened.succeed(); const reopenedCatchUp = await world.nextFetch("agent-a"); @@ -880,7 +1004,7 @@ test("disconnect clears hidden hot agents before reconnecting the visible set", test("legacy delivery skips subscription RPCs while retaining visibility catch-up and gap recovery", async () => { const world = new TimelineWorld(); world.sync.setDeliveryMode("legacy"); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); world.sync.setConnected(true); world.expectNoPendingMembership(); @@ -888,7 +1012,8 @@ test("legacy delivery skips subscription RPCs while retaining visibility catch-u initial.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 12); const recovery = await world.nextFetch("agent-a"); recovery.respond({ hasNewer: false }); @@ -904,17 +1029,17 @@ test("legacy delivery catches up after returning to a view or foreground", async const world = new TimelineWorld(); world.sync.setDeliveryMode("legacy"); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const firstAgentA = await world.nextFetch("agent-a"); firstAgentA.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); const agentB = await world.nextFetch("agent-b"); agentB.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready")); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const secondAgentA = await world.nextFetch("agent-a"); secondAgentA.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); @@ -930,7 +1055,7 @@ test("legacy delivery catches up after returning to a view or foreground", async test("switching from legacy to selective delivery publishes membership and catches up once", async () => { const world = new TimelineWorld(); world.sync.setDeliveryMode("legacy"); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); world.sync.setConnected(true); const legacyCatchUp = await world.nextFetch("agent-a"); legacyCatchUp.respond({ hasNewer: false }); diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index 323676739b..f1e32c9d3d 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -1,310 +1,23 @@ +import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; import { planTimelineCatchUpAfter, planTimelineResumeFetch, type ProjectedTimelineForwardFetchPlan, } from "./timeline-sync-plan"; -import type { CachedTimeline } from "@/runtime/replica-cache"; import { - selectAgentTimelineState, - useSessionStore, - type AgentTimelineCursorState, - type AgentTimelineState, -} from "@/stores/session-store"; -import { useCreateFlowStore } from "@/stores/create-flow-store"; -import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; -import { getSendingClientMessageIds } from "@/composer/submission/model"; -import { - getInitDeferred, - getInitKey, - rejectInitDeferred, - resolveInitDeferred, -} from "@/utils/agent-initialization"; -import { - createSessionAgentStreamReducerQueue, + createAgentStreamReducerQueue, + scheduleAgentStreamReducerFlush, + cancelAgentStreamReducerFlush, type AgentStreamReducerEvent, - type ProcessTimelineResponseOutput, - processTimelineResponse, } from "./session-stream-reducers"; -import { isTimelineResumeSnapshotAuthoritative } from "./timeline-sync-plan"; -import { replaceWithCanonicalStream } from "@/types/stream"; - -export interface TimelineReplicaStorage { - readTimeline(serverId: string, agentId: string): Promise; - commitTimeline(serverId: string, agentId: string, timeline: CachedTimeline): void; -} - -async function prepareCachedTimeline(input: { - serverId: string; - agentId: string; - storage: TimelineReplicaStorage; - prepareAgent: (agentId: string) => Promise; -}): Promise { - const before = useSessionStore.getState().sessions[input.serverId]; - const beforeTimeline = selectAgentTimelineState(before, input.agentId); - if (beforeTimeline.status === "synced") return undefined; - const beforeHead = before?.agentStreamHead.get(input.agentId); - await input.prepareAgent(input.agentId); - const stored = await input.storage.readTimeline(input.serverId, input.agentId); - if (!stored) return undefined; - const session = useSessionStore.getState().sessions[input.serverId]; - const currentTimeline = selectAgentTimelineState(session, input.agentId); - const currentHead = session?.agentStreamHead.get(input.agentId); - if (currentTimeline.status === "synced") return undefined; - if (!stored.range) { - if (currentHead !== beforeHead) return undefined; - if (beforeTimeline.status === "painted") { - return currentTimeline.status === "painted" && currentTimeline.items === beforeTimeline.items - ? stored - : undefined; - } - if (currentTimeline.status !== "cold") return undefined; - } - const liveItems = - currentTimeline.status === "painted" - ? [...currentTimeline.items, ...(currentHead ?? [])] - : (currentHead ?? []); - const replacement = stored.range - ? replaceWithCanonicalStream({ - canonical: stored.items, - previousTail: [], - previousHead: liveItems, - sendingClientMessageIds: getSendingClientMessageIds( - session?.messageSubmissions.get(input.agentId), - ), - preserveContinuity: true, - canonicalCoverage: stored.range, - }) - : { tail: stored.items, head: liveItems, acknowledgedClientMessageIds: [] }; - useSessionStore.getState().applyAgentTimelineResponseState(input.serverId, input.agentId, { - items: replacement.tail, - head: replacement.head, - range: stored.range, - older: stored.hasOlder ? "available" : "none", - newer: false, - synchronized: false, - acknowledgedClientMessageIds: replacement.acknowledgedClientMessageIds, - }); - return stored; -} - -export interface TimelineReplica { - prepare(agentId: string): Promise; - readCursor(agentId: string): { epoch: string; endSeq: number } | undefined; - readRange(agentId: string): AgentTimelineCursorState | undefined; - timelineUpdated(agentId: string): void; -} - -class TimelineReplicaOwner implements TimelineReplica { - private readonly cachedRanges = new Map(); - private readonly preparations = new Map>(); - - constructor( - private readonly serverId: string, - private readonly storage: TimelineReplicaStorage, - private readonly prepareAgent: (agentId: string) => Promise, - ) {} - - async prepare(agentId: string): Promise { - const existing = this.preparations.get(agentId); - if (existing) return existing; - const preparation = this.load(agentId).finally(() => { - if (this.preparations.get(agentId) === preparation) { - this.preparations.delete(agentId); - } - }); - this.preparations.set(agentId, preparation); - return preparation; - } - - private async load(agentId: string): Promise { - const stored = await prepareCachedTimeline({ - serverId: this.serverId, - agentId, - storage: this.storage, - prepareAgent: this.prepareAgent, - }); - if (!stored) return; - if (stored.range) { - this.cachedRanges.set(agentId, stored.range); - } - } - - readCursor(agentId: string): { epoch: string; endSeq: number } | undefined { - const range = this.cachedRanges.get(agentId); - return range ? { epoch: range.epoch, endSeq: range.endSeq } : undefined; - } - - readRange(agentId: string): AgentTimelineCursorState | undefined { - return this.cachedRanges.get(agentId); - } - - timelineUpdated(agentId: string): void { - const session = useSessionStore.getState().sessions[this.serverId]; - const timeline = selectAgentTimelineState(session, agentId); - if (timeline.status === "cold") return; - if (timeline.status === "synced") this.cachedRanges.delete(agentId); - this.storage.commitTimeline(this.serverId, agentId, { - agentId, - items: [...timeline.items, ...(session?.agentStreamHead.get(agentId) ?? [])], - range: timeline.status === "synced" ? timeline.range : null, - hasOlder: timeline.status === "synced" && timeline.older === "available", - }); - } -} - -export function createTimelineReplica(input: { - serverId: string; - storage: TimelineReplicaStorage; - prepareAgent: (agentId: string) => Promise; -}): TimelineReplica { - return new TimelineReplicaOwner(input.serverId, input.storage, input.prepareAgent); -} +import type { TimelineReplica, TimelineResponsePayload, TimelinePageRequest } from "./replica"; +import type { AgentRemovalReason } from "@/utils/agent-directory-sync"; export interface TimelinePageResult { hasNewer: boolean; endCursor: { epoch: string; seq: number } | null; } -export type TimelineResponsePayload = Extract< - SessionOutboundMessage, - { type: "fetch_agent_timeline_response" } ->["payload"]; - -export function consumeForcedTimelineTailReplacement( - payload: TimelineResponsePayload, - replacements: Set, -): TimelineResponsePayload { - if (payload.direction !== "tail") return payload; - if (!replacements.delete(payload.agentId)) return payload; - return { ...payload, reset: true }; -} - -function clearAgentInitializingFlag(serverId: string, agentId: string): void { - useSessionStore.getState().setInitializingAgents(serverId, (previous) => { - if (previous.get(agentId) !== true) return previous; - const next = new Map(previous); - next.set(agentId, false); - return next; - }); -} - -function commitProcessedTimeline(input: { - serverId: string; - payload: TimelineResponsePayload; - result: ProcessTimelineResponseOutput; - timeline: AgentTimelineState; - currentCursor: AgentTimelineCursorState | undefined; - synchronized: boolean; -}): void { - const { serverId, payload, result, timeline, currentCursor, synchronized } = input; - const agentId = payload.agentId; - const store = useSessionStore.getState(); - if (result.commit !== "discard") { - store.applyAgentTimelineResponseState(serverId, agentId, { - items: result.tail, - head: result.head, - range: result.cursorChanged ? (result.cursor ?? null) : (currentCursor ?? null), - older: result.older, - newer: - payload.direction === "before" - ? timeline.status === "synced" && timeline.newer === "available" - : payload.hasNewer, - synchronized, - acknowledgedClientMessageIds: result.acknowledgedClientMessageIds, - }); - return; - } - if (result.acknowledgedClientMessageIds.length > 0) { - store.setAgentStreamState(serverId, agentId, { - acknowledgedClientMessageIds: result.acknowledgedClientMessageIds, - }); - } - if (payload.direction !== "before") { - store.setAgentTimelineHasNewer(serverId, (current) => { - const next = new Map(current); - next.set(agentId, payload.hasNewer); - return next; - }); - } - store.markAgentHistorySynchronized(serverId, agentId); -} - -function finalizeProcessedTimeline(input: { - serverId: string; - agentId: string; - initKey: string; - result: ProcessTimelineResponseOutput; - synchronized: boolean; - recoverGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void; - drainQueuedAgentMessage: (agentId: string) => void; -}): void { - for (const effect of input.result.sideEffects) { - if (effect.type === "catch_up") input.recoverGap(input.agentId, effect.cursor); - } - if (input.result.clearInitializing) { - clearAgentInitializingFlag(input.serverId, input.agentId); - } - if (input.synchronized) { - useCreateFlowStore - .getState() - .clearByAgent({ serverId: input.serverId, agentId: input.agentId }); - const session = useSessionStore.getState().sessions[input.serverId]; - const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId); - if (agent && agent.turn.phase === "idle") input.drainQueuedAgentMessage(input.agentId); - } - if (input.result.initResolution === "resolve") resolveInitDeferred(input.initKey); -} - -function applyAuthoritativeTimelineResponse(input: { - serverId: string; - payload: TimelineResponsePayload; - cachedCursor?: AgentTimelineCursorState; - recoverGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void; - drainQueuedAgentMessage: (agentId: string) => void; -}): boolean { - const { serverId, payload } = input; - const agentId = payload.agentId; - const initKey = getInitKey(serverId, agentId); - const session = useSessionStore.getState().sessions[serverId]; - const timeline = selectAgentTimelineState(session, agentId); - const activeInitDeferred = getInitDeferred(initKey); - const currentCursor = - timeline.status === "synced" ? (timeline.range ?? undefined) : input.cachedCursor; - const result = processTimelineResponse({ - payload, - currentTail: timeline.status === "cold" ? [] : timeline.items, - currentHead: session?.agentStreamHead.get(agentId) ?? [], - currentCursor, - isInitializing: session?.initializingAgents.get(agentId) === true, - hasActiveInitDeferred: Boolean(activeInitDeferred), - initRequestDirection: activeInitDeferred?.requestDirection ?? "tail", - sendingClientMessageIds: getSendingClientMessageIds(session?.messageSubmissions.get(agentId)), - }); - - if (result.error) { - if (result.clearInitializing) clearAgentInitializingFlag(serverId, agentId); - if (result.initResolution === "reject") rejectInitDeferred(initKey, new Error(result.error)); - return false; - } - - const synchronized = isTimelineResumeSnapshotAuthoritative({ - direction: payload.direction, - hasNewer: payload.hasNewer, - error: payload.error, - }); - commitProcessedTimeline({ serverId, payload, result, timeline, currentCursor, synchronized }); - finalizeProcessedTimeline({ - serverId, - agentId, - initKey, - result, - synchronized, - recoverGap: input.recoverGap, - drainQueuedAgentMessage: input.drainQueuedAgentMessage, - }); - return true; -} - export interface ViewedTimelineSyncPorts { initialDeliveryMode: TimelineDeliveryMode; prepare(agentId: string): Promise; @@ -323,8 +36,13 @@ export interface ViewedTimelineSyncPorts { export type TimelineDeliveryMode = "legacy" | "selective"; export type ViewedTimelineStatus = "ready" | "pending" | "error" | "retrying"; +export interface ViewedTimelineDemandDeclaration { + replace(agentIds: string[]): void; + dispose(): void; +} + export interface ViewedTimelineUiBridge { - replaceVisibleAgentIds(sourceId: string, agentIds: string[]): void; + registerVisibleAgentIds(sourceId: string, agentIds: string[]): ViewedTimelineDemandDeclaration; subscribe(listener: () => void): () => void; getAgentTimelineStatus(agentId: string): ViewedTimelineStatus; getAgentTimelineError(agentId: string): string | null; @@ -335,19 +53,32 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge { setActive(active: boolean): void; setConnected(connected: boolean): void; setDeliveryMode(mode: TimelineDeliveryMode): void; - recoverGap(agentId: string, cursor: { epoch: string; endSeq: number }): void; + recoverGap( + agentId: string, + cursor: { epoch: string; endSeq: number }, + observedSeq?: number, + ): void; + beginAgentLifetime(agentId: string): void; + endAgentLifetime(agentId: string): void; dispose(): void; } export type ViewedTimelineOwnerPorts = Omit< ViewedTimelineSyncPorts, - "prepare" | "replaceDemandedAgentIds" + "prepare" | "replaceDemandedAgentIds" | "readCursor" >; export interface ViewedTimelineOwner extends ViewedTimelineSync { + beginTimelineRequest( + agentId: string, + replaceTail?: boolean, + ): (payload: TimelineResponsePayload) => void; applyTimelineResponse(payload: TimelineResponsePayload): void; enqueueStreamEvent(agentId: string, event: AgentStreamReducerEvent): void; flushStreamAgent(agentId: string): void; + attachTransport(ports: ViewedTimelineOwnerPorts): () => void; + acceptAgent(agentId: string): void; + removeAgent(agentId: string, reason: AgentRemovalReason): void; } export function createViewedTimelineOwner(input: { @@ -355,33 +86,78 @@ export function createViewedTimelineOwner(input: { replica: TimelineReplica; replaceDemandedAgentIds: (agentIds: string[]) => void; drainQueuedAgentMessage: (agentId: string) => void; - ports: ViewedTimelineOwnerPorts; + ports?: ViewedTimelineOwnerPorts; }): ViewedTimelineOwner { + let transport = input.ports; + const requireTransport = () => { + if (!transport) throw new Error("Timeline transport is disconnected"); + return transport; + }; const sync = createViewedTimelineSync({ - ...input.ports, + initialDeliveryMode: transport?.initialDeliveryMode ?? "legacy", + setSubscription: (ids) => requireTransport().setSubscription(ids), + fetchPage: (id, request) => requireTransport().fetchPage(id, request), + fetchLatestTail: (id) => requireTransport().fetchLatestTail(id), + reportError: (error) => transport?.reportError(error), + schedule: (task, delay) => { + if (transport) return transport.schedule(task, delay); + const timeout = setTimeout(task, delay); + return () => clearTimeout(timeout); + }, prepare: (agentId) => input.replica.prepare(agentId), - readCursor: (agentId) => input.replica.readCursor(agentId) ?? input.ports.readCursor(agentId), + readCursor: (agentId) => input.replica.readCursor(agentId), replaceDemandedAgentIds: input.replaceDemandedAgentIds, }); - const streamQueue = createSessionAgentStreamReducerQueue({ - serverId: input.serverId, - setAgentStreamState: (...args) => useSessionStore.getState().setAgentStreamState(...args), - setAgentTimelineCursor: (...args) => useSessionStore.getState().setAgentTimelineCursor(...args), - recoverTimelineGap: (agentId, cursor) => sync.recoverGap(agentId, cursor), - onCommitted: (agentId) => input.replica.timelineUpdated(agentId), + const streamQueue = createAgentStreamReducerQueue({ + apply: (agentId, events) => input.replica.applyEvents(agentId, events, sync.recoverGap), + scheduleFlush: scheduleAgentStreamReducerFlush, + cancelFlush: cancelAgentStreamReducerFlush, }); + function applyTimelineResponse( + payload: TimelineResponsePayload, + request?: TimelinePageRequest, + ): void { + streamQueue.flushAgent(payload.agentId); + input.replica.applyPage( + payload, + { + recoverGap: (agentId, cursor, observedSeq) => sync.recoverGap(agentId, cursor, observedSeq), + drainQueuedAgentMessage: input.drainQueuedAgentMessage, + }, + request, + ); + } + return { ...sync, - applyTimelineResponse(payload) { - const accepted = applyAuthoritativeTimelineResponse({ - serverId: input.serverId, - payload, - cachedCursor: input.replica.readRange(payload.agentId), - recoverGap: (agentId, cursor) => sync.recoverGap(agentId, cursor), - drainQueuedAgentMessage: input.drainQueuedAgentMessage, - }); - if (accepted) input.replica.timelineUpdated(payload.agentId); + attachTransport(ports) { + sync.setConnected(false); + transport = ports; + sync.setDeliveryMode(ports.initialDeliveryMode); + return () => { + if (transport !== ports) return; + sync.setConnected(false); + transport = undefined; + }; }, + acceptAgent(agentId) { + input.replica.acceptAgent(agentId); + sync.beginAgentLifetime(agentId); + }, + removeAgent(agentId, reason) { + streamQueue.flushAgent(agentId); + sync.endAgentLifetime(agentId); + input.replica.remove(agentId, reason); + }, + beginTimelineRequest(agentId, replaceTail = false) { + const request = { + cursor: input.replica.readCursor(agentId) ?? null, + replaceTail, + initialization: getInitDeferred(getInitKey(input.serverId, agentId))?.promise, + }; + return (payload) => applyTimelineResponse(payload, request); + }, + applyTimelineResponse, enqueueStreamEvent(agentId, event) { streamQueue.enqueue(agentId, event); }, @@ -409,6 +185,11 @@ interface CatchUpState { retryDelayMs?: number; } +interface VisibleAgentSource { + declaration: object; + agentIds: string[]; +} + const getNextRetryDelayMs = (previousDelayMs: number | undefined): number => { if (previousDelayMs == null) { return RETRY_DELAY_MS; @@ -416,40 +197,6 @@ const getNextRetryDelayMs = (previousDelayMs: number | undefined): number => { return Math.min(previousDelayMs * 2, MAX_RETRY_DELAY_MS); }; -function isSameCatchUpRequest( - left: ProjectedTimelineForwardFetchPlan | undefined, - right: ProjectedTimelineForwardFetchPlan | undefined, -): boolean { - if (!left || !right || left.direction !== right.direction) return false; - if (left.direction !== "after" || right.direction !== "after") return true; - return left.cursor.epoch === right.cursor.epoch && left.cursor.seq === right.cursor.seq; -} - -type CatchUpDecision = "keep" | "keep-and-park" | "replace"; - -function decideCatchUp(input: { - current: CatchUpState | undefined; - request: ProjectedTimelineForwardFetchPlan; - supersede: boolean; -}): CatchUpDecision { - if (!input.current) return "replace"; - if (input.supersede) { - if ( - input.current.status === "running" && - isSameCatchUpRequest(input.current.request, input.request) - ) { - return "keep"; - } - if (input.current.status === "running" && input.current.request?.direction === "tail") { - return "keep-and-park"; - } - return "replace"; - } - return input.current.status === "running" || input.current.status === "complete" - ? "keep" - : "replace"; -} - function normalizeAgentIds(agentIds: string[]): string[] { return [...new Set(agentIds)].filter(Boolean).sort(); } @@ -459,12 +206,13 @@ function sameAgentIds(left: string[], right: string[]): boolean { } export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): ViewedTimelineSync { - const sources = new Map(); + const sources = new Map(); const catchUps = new Map(); const catchUpGenerations = new Map(); - // Authoritative fetch owed but not runnable yet: disconnected, unacknowledged, or parked. - // Acknowledgement and tail completion are the only drain points. - const pendingCatchUps = new Map(); + // The newest live sequence each agent has been told about but whose coverage is not yet + // established. A catch-up is complete only when authoritative coverage reaches it; a page + // built before that sequence existed cannot settle it, however it was requested. + const observedHeads = new Map(); const visibilityCatchUpPending = new Set(); const visibilityCatchUpErrors = new Map(); // User-initiated retries only. Background retries stay silent; a retry the user asked for @@ -487,7 +235,8 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed let cancelMembershipRetry: (() => void) | null = null; let recentlyViewedAgentIds: string[] = []; - const visibleAgentIds = () => normalizeAgentIds([...sources.values()].flat()); + const visibleAgentIds = () => + normalizeAgentIds([...sources.values()].flatMap((source) => source.agentIds)); const selectHotAgentIds = (visible: string[]) => { const visibleSet = new Set(visible); @@ -541,11 +290,36 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed if (changed) notifyListeners(); }; + // Cancelling forgets the observed head: the next catch-up starts from the current cursor and + // the daemon returns everything after it. const cancelCatchUp = (agentId: string) => { catchUpGenerations.set(agentId, (catchUpGenerations.get(agentId) ?? 0) + 1); catchUps.get(agentId)?.cancelRetry?.(); catchUps.delete(agentId); - pendingCatchUps.delete(agentId); + observedHeads.delete(agentId); + }; + + const owedAfter = ( + agentId: string, + cursorBefore: { epoch: string; endSeq: number } | undefined, + observedBefore: { epoch: string; seq: number } | undefined, + ): ProjectedTimelineForwardFetchPlan | undefined => { + const observed = observedHeads.get(agentId); + const cursor = ports.readCursor(agentId); + if (!observed || !cursor) return undefined; + const advanced = + cursorBefore === undefined || + cursorBefore.epoch !== cursor.epoch || + cursor.endSeq > cursorBefore.endSeq; + if (cursor.epoch === observed.epoch && cursor.endSeq < observed.seq) { + if (!advanced && observed === observedBefore) + throw new Error( + `Timeline coverage for ${agentId} has not reached observed sequence ${observed.seq}`, + ); + return planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq }); + } + observedHeads.delete(agentId); + return undefined; }; const fetchUntilCurrent = async ( @@ -557,32 +331,41 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed if (!ownsCatchUp(agentId, generation)) return; try { + let cursorBefore = ports.readCursor(agentId); + let observedBefore = observedHeads.get(agentId); const page = await ports.fetchPage(agentId, request); if (!ownsCatchUp(agentId, generation)) return; if (page.hasNewer && page.endCursor) { + if ( + request.direction === "after" && + page.endCursor.epoch === request.cursor.epoch && + page.endCursor.seq <= request.cursor.seq + ) { + throw new Error(`Timeline page for ${agentId} did not advance its cursor`); + } if (fallbackToLatestTailOnOverflow) { + cursorBefore = ports.readCursor(agentId); + observedBefore = observedHeads.get(agentId); await ports.fetchLatestTail(agentId); - catchUps.set(agentId, { generation, status: "complete" }); - setVisibilityCatchUpReady(agentId); + if (!ownsCatchUp(agentId, generation)) return; + } else { + await fetchUntilCurrent( + agentId, + generation, + planTimelineCatchUpAfter(page.endCursor), + false, + ); return; } - await fetchUntilCurrent( - agentId, - generation, - planTimelineCatchUpAfter(page.endCursor), - false, - ); - return; - } - if (page.hasNewer) { + } else if (page.hasNewer) { throw new Error(`Timeline page for ${agentId} hasNewer without an end cursor`); } - catchUps.set(agentId, { generation, status: "complete" }); - const pendingCatchUp = pendingCatchUps.get(agentId); - if (pendingCatchUp) { - startCatchUp(agentId, { request: pendingCatchUp, supersede: true }); + const owed = owedAfter(agentId, cursorBefore, observedBefore); + if (owed) { + await fetchUntilCurrent(agentId, generation, owed, false); return; } + catchUps.set(agentId, { generation, status: "complete" }); setVisibilityCatchUpReady(agentId); } catch (error) { if (catchUps.get(agentId)?.generation === generation) { @@ -611,69 +394,43 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed .prepare(agentId) .catch((error) => ports.reportError(error)) .finally(() => { + if (cacheLoads.get(agentId) !== load) return; cacheLoads.delete(agentId); loadedCache.add(agentId); - const pending = pendingCatchUps.get(agentId); - startCatchUp(agentId, { request: pending, supersede: Boolean(pending) }); + startCatchUp(agentId); }); cacheLoads.set(agentId, load); }; - const startCatchUp = ( - agentId: string, - options: { - request?: ProjectedTimelineForwardFetchPlan; - supersede?: boolean; - } = {}, - ) => { - const { request, supersede = false } = options; - if (!connected || !isDesired(agentId) || !isAcknowledged(agentId)) { - if (request) pendingCatchUps.set(agentId, request); - return; - } + // One catch-up per agent: it runs from the current cursor until the daemon reports nothing + // newer and coverage reaches every observed sequence. A running catch-up is never replaced + // by another request; it finishes its own obligation. `supersede` restarts a failed or + // completed one immediately, which the manual retry and a new gap both need. + const startCatchUp = (agentId: string, options: { supersede?: boolean } = {}) => { + const { supersede = false } = options; + if (!connected || !isDesired(agentId) || !isAcknowledged(agentId)) return; if (!loadedCache.has(agentId)) { - if (request) pendingCatchUps.set(agentId, request); ensureCacheLoaded(agentId); return; } - const nextRequest = request ?? planTimelineResumeFetch(ports.readCursor(agentId)); const current = catchUps.get(agentId); - const decision = decideCatchUp({ current, request: nextRequest, supersede }); - if (decision === "keep-and-park") { - pendingCatchUps.set(agentId, nextRequest); - return; - } - if (decision === "keep") { - return; - } + if (current?.status === "running") return; + if (current?.status === "complete" && !supersede) return; current?.cancelRetry?.(); + const request = planTimelineResumeFetch(ports.readCursor(agentId)); const generation = (catchUpGenerations.get(agentId) ?? 0) + 1; catchUpGenerations.set(agentId, generation); const retryDelayMs = supersede || current?.status !== "error" ? undefined : current.retryDelayMs; - catchUps.set(agentId, { - generation, - status: "running", - request: nextRequest, - retryDelayMs, - }); - pendingCatchUps.delete(agentId); - void fetchUntilCurrent( - agentId, - generation, - nextRequest, - request === undefined && nextRequest.direction === "after", - ); + catchUps.set(agentId, { generation, status: "running", request, retryDelayMs }); + // A resume with no observed head replays at most one bounded page before taking the latest + // tail; a catch-up that owes observed live rows pages forward until it reaches them. + const resumingWithoutObservation = request.direction === "after" && !observedHeads.has(agentId); + void fetchUntilCurrent(agentId, generation, request, resumingWithoutObservation); }; const startAcknowledgedCatchUps = () => { - for (const agentId of acknowledged) { - const pendingCatchUp = pendingCatchUps.get(agentId); - startCatchUp(agentId, { - request: pendingCatchUp, - supersede: Boolean(pendingCatchUp), - }); - } + for (const agentId of acknowledged) startCatchUp(agentId); }; const reconcileLatestMembership = async (): Promise => { @@ -753,7 +510,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed notifyListeners(); if (catchUp?.status === "error") { catchUp.cancelRetry?.(); - startCatchUp(agentId, { request: catchUp.request, supersede: true }); + startCatchUp(agentId, { supersede: true }); return; } cancelMembershipRetry?.(); @@ -839,11 +596,26 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed getAgentTimelineError(agentId) { return visibilityCatchUpErrors.get(agentId) ?? null; }, - replaceVisibleAgentIds(sourceId, agentIds) { - const normalized = normalizeAgentIds(agentIds); - if (normalized.length === 0) sources.delete(sourceId); - else sources.set(sourceId, normalized); + registerVisibleAgentIds(sourceId, agentIds) { + const declaration = {}; + sources.set(sourceId, { + declaration, + agentIds: normalizeAgentIds(agentIds), + }); publishVisibleMembership(); + return { + replace(nextAgentIds) { + const current = sources.get(sourceId); + if (current?.declaration !== declaration) return; + current.agentIds = normalizeAgentIds(nextAgentIds); + publishVisibleMembership(); + }, + dispose() { + if (sources.get(sourceId)?.declaration !== declaration) return; + sources.delete(sourceId); + publishVisibleMembership(); + }, + }; }, setActive(nextActive) { if (active === nextActive) return; @@ -895,12 +667,30 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed if (deliveryMode === "selective" && connected) void reconcileMembership(); else if (connected) startAcknowledgedCatchUps(); }, - recoverGap(agentId, cursor) { + recoverGap(agentId, cursor, observedSeq) { if (!isDesired(agentId)) return; - startCatchUp(agentId, { - request: planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq }), - supersede: true, - }); + if (observedSeq !== undefined) { + const observed = observedHeads.get(agentId); + if (!observed || observed.epoch !== cursor.epoch || observed.seq < observedSeq) { + observedHeads.set(agentId, { epoch: cursor.epoch, seq: observedSeq }); + } + } + startCatchUp(agentId, { supersede: true }); + }, + beginAgentLifetime(agentId) { + if (!isDesired(agentId)) return; + visibilityCatchUpPending.add(agentId); + ensureCacheLoaded(agentId); + notifyListeners(); + }, + endAgentLifetime(agentId) { + cancelCatchUp(agentId); + loadedCache.delete(agentId); + cacheLoads.delete(agentId); + if (isDesired(agentId)) { + visibilityCatchUpPending.add(agentId); + notifyListeners(); + } }, dispose() { disposed = true; @@ -917,6 +707,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed recentlyViewedAgentIds = []; loadedCache.clear(); cacheLoads.clear(); + observedHeads.clear(); visibilityCatchUpPending.clear(); visibilityCatchUpErrors.clear(); manualRetries.clear(); diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index cd05fd0826..a73f52c2d6 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -8,6 +8,7 @@ import { handoffCreatedAgentUserMessageToStream, hydrateStreamState, mergeToolCallDetail, + mergeCanonicalText, reduceStreamUpdate, streamTimelineItemIdentity, type AgentToolCallItem, @@ -23,6 +24,122 @@ import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity"; type CanonicalToolStatus = "running" | "completed" | "failed" | "canceled"; +describe("canonical text source boundaries", () => { + it("keeps the canonical start independently of its completion chunk", () => { + const rows = mergeCanonicalText( + [ + { + kind: "assistant_message", + id: "m", + messageId: "m", + text: "ab", + timestamp: new Date(0), + timelineCursor: { epoch: "e", seq: 2 }, + source: { + startSeq: 1, + chunks: [ + { seq: 1, offset: 0 }, + { seq: 2, offset: 1 }, + ], + }, + }, + ], + { text: "ab", epoch: "e", startSeq: 1, endSeq: 2, timestamp: new Date(1) }, + ); + expect(rows[0]).toMatchObject({ + text: "ab", + timelineCursor: { epoch: "e", seq: 2 }, + source: { startSeq: 1, chunks: [{ seq: 2, offset: 0 }] }, + }); + }); + + it("keeps exact offsets when canonical text replaces the middle of a row", () => { + const [row] = mergeCanonicalText( + [ + { + kind: "assistant_message", + id: "m", + text: "abOLDyz", + timestamp: new Date(0), + timelineCursor: { epoch: "e", seq: 8 }, + source: { + startSeq: 1, + chunks: [ + { seq: 2, offset: 0 }, + { seq: 4, offset: 2 }, + { seq: 8, offset: 5 }, + ], + }, + }, + ], + { text: "C", epoch: "e", startSeq: 3, endSeq: 6, timestamp: new Date(1) }, + ); + expect(row).toMatchObject({ + text: "abCyz", + timelineCursor: { epoch: "e", seq: 8 }, + source: { + startSeq: 1, + chunks: [ + { seq: 2, offset: 0 }, + { seq: 6, offset: 2 }, + { seq: 8, offset: 3 }, + ], + }, + }); + }); + + it("preserves source starts and exact text before and after covered chunks", () => { + const rows = mergeCanonicalText( + [ + { + kind: "assistant_message", + id: "before", + text: "before", + timestamp: new Date(0), + timelineCursor: { epoch: "e", seq: 2 }, + source: { startSeq: 1, chunks: [{ seq: 2, offset: 0 }] }, + }, + { + kind: "assistant_message", + id: "covered", + text: "old", + timestamp: new Date(0), + timelineCursor: { epoch: "e", seq: 4 }, + source: { startSeq: 3, chunks: [{ seq: 4, offset: 0 }] }, + }, + { + kind: "assistant_message", + id: "after", + text: "after", + timestamp: new Date(0), + timelineCursor: { epoch: "e", seq: 8 }, + source: { startSeq: 7, chunks: [{ seq: 8, offset: 0 }] }, + }, + ], + { text: "canonical", epoch: "e", startSeq: 3, endSeq: 6, timestamp: new Date(1) }, + ); + expect( + rows.map((row) => ({ text: row.text, source: row.source, cursor: row.timelineCursor })), + ).toEqual([ + { + text: "before", + source: { startSeq: 1, chunks: [{ seq: 2, offset: 0 }] }, + cursor: { epoch: "e", seq: 2 }, + }, + { + text: "canonical", + source: { startSeq: 3, chunks: [{ seq: 6, offset: 0 }] }, + cursor: { epoch: "e", seq: 6 }, + }, + { + text: "after", + source: { startSeq: 7, chunks: [{ seq: 8, offset: 0 }] }, + cursor: { epoch: "e", seq: 8 }, + }, + ]); + }); +}); + describe("plugin timeline rows", () => { it("uses the protocol identity format for stream tool and plugin rows", () => { const tool = { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 4ebd0972db..4039ac0d7b 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -97,6 +97,7 @@ export interface UserMessageItem { messageId?: string; turnId?: string; timelineCursor?: TimelinePosition; + source?: RowSource; text: string; timestamp: Date; images?: UserMessageImageAttachment[]; @@ -109,6 +110,7 @@ export interface UserMessageInput { messageId?: string; turnId?: string; timelineCursor?: TimelinePosition; + source?: RowSource; text: string; timestamp: Date; images?: UserMessageImageAttachment[]; @@ -127,6 +129,7 @@ export function createUserMessage(input: UserMessageInput): UserMessageItem { ...(input.messageId ? { messageId: input.messageId } : {}), ...(input.turnId ? { turnId: input.turnId } : {}), ...(input.timelineCursor ? { timelineCursor: input.timelineCursor } : {}), + ...(input.source ? { source: input.source } : {}), text: input.text, timestamp: input.timestamp, ...(input.images && input.images.length > 0 ? { images: input.images } : {}), @@ -261,12 +264,14 @@ function produceUserMessage( clientMessageId: incoming.clientMessageId ?? existing.clientMessageId, messageId: incoming.messageId ?? existing.messageId, timelineCursor: incoming.timelineCursor ?? existing.timelineCursor, + source: incoming.source ?? existing.source, }); if ( existing.id === merged.id && existing.clientMessageId === merged.clientMessageId && existing.messageId === merged.messageId && existing.timelineCursor === merged.timelineCursor && + existing.source === merged.source && existing.text === merged.text && existing.timestamp === merged.timestamp && existing.images === merged.images && @@ -412,13 +417,19 @@ function placeCanonicalUserMessageAtTail( }; } +// What canonical rows certify about source positions. A certified range replaces every positioned +// row inside it and drops rows from other epochs. Rows-only canonical data, such as a display-only +// cache, replaces only rows sharing a canonical row's position or an older-or-equal copy of its +// message identity; it never drops rows it did not cover. +export type CanonicalCoverage = { epoch: string; endSeq: number | null } | "rows-only"; + export interface CanonicalStreamReplacementInput { canonical: StreamItem[]; previousTail: StreamItem[]; previousHead: StreamItem[]; sendingClientMessageIds: readonly string[]; preserveContinuity: boolean; - canonicalCoverage: { epoch: string; endSeq: number | null }; + canonicalCoverage: CanonicalCoverage; } export interface CanonicalStreamReplacementResult { @@ -427,13 +438,33 @@ export interface CanonicalStreamReplacementResult { acknowledgedClientMessageIds: string[]; } -function isAfterCanonicalCoverage( - position: TimelinePosition, - coverage: CanonicalStreamReplacementInput["canonicalCoverage"], +function isSamePosition(left: TimelinePosition | undefined, right: TimelinePosition): boolean { + return left !== undefined && left.epoch === right.epoch && left.seq === right.seq; +} + +function isCoveredByCanonical( + item: StreamItem, + canonical: readonly StreamItem[], + coverage: CanonicalCoverage, ): boolean { - return ( - position.epoch === coverage.epoch && - (coverage.endSeq === null || position.seq > coverage.endSeq) + const position = item.timelineCursor; + if (!position) return false; + if (coverage !== "rows-only") { + return ( + position.epoch !== coverage.epoch || + (coverage.endSeq !== null && position.seq <= coverage.endSeq) + ); + } + return canonical.some( + (row) => + isSamePosition(row.timelineCursor, position) || + (row.kind === "assistant_message" && + item.kind === "assistant_message" && + row.messageId !== undefined && + row.messageId === item.messageId && + row.timelineCursor !== undefined && + row.timelineCursor.epoch === position.epoch && + row.timelineCursor.seq >= position.seq), ); } @@ -543,29 +574,53 @@ function reconcileReplacementHeadAgainstTail( }; } +// Positioned rows beyond canonical coverage are daemon history the page did not carry, so they +// survive every replacement. Continuity decides only what happens to rows without a position. +function retainsAcrossReplacement( + item: StreamItem, + input: { + canonical: readonly StreamItem[]; + tail: readonly StreamItem[]; + preserveContinuity: boolean; + sendingClientMessageIds: ReadonlySet; + canonicalCoverage: CanonicalCoverage; + }, +): boolean { + if (item.timelineCursor) { + if (!isCoveredByCanonical(item, input.canonical, input.canonicalCoverage)) return true; + const canonicalTailAssistant = input.tail.at(-1); + return ( + input.preserveContinuity && + item.kind === "assistant_message" && + canonicalTailAssistant?.kind === "assistant_message" && + item.text.startsWith(canonicalTailAssistant.text) + ); + } + return ( + input.preserveContinuity || + (item.kind === "user_message" && + item.clientMessageId !== undefined && + input.sendingClientMessageIds.has(item.clientMessageId)) + ); +} + function preserveReplacementHead( + canonical: readonly StreamItem[], tail: StreamItem[], currentHead: StreamItem[], preserveContinuity: boolean, sendingClientMessageIds: ReadonlySet, - canonicalCoverage: CanonicalStreamReplacementInput["canonicalCoverage"], + canonicalCoverage: CanonicalCoverage, ): CanonicalStreamReplacementResult { - const canonicalTailAssistant = tail.at(-1); - const retainedHead = preserveContinuity - ? currentHead.filter( - (item) => - !item.timelineCursor || - isAfterCanonicalCoverage(item.timelineCursor, canonicalCoverage) || - (item.kind === "assistant_message" && - canonicalTailAssistant?.kind === "assistant_message" && - item.text.startsWith(canonicalTailAssistant.text)), - ) - : currentHead.filter( - (item) => - item.kind === "user_message" && - item.clientMessageId !== undefined && - sendingClientMessageIds.has(item.clientMessageId), - ); + const retainedHead = currentHead.filter((item) => + retainsAcrossReplacement(item, { + canonical, + tail, + preserveContinuity, + sendingClientMessageIds, + canonicalCoverage, + }), + ); const { tail: reconciledTail, head: unreconciledHead } = reconcileReplacementHeadAgainstTail( tail, retainedHead, @@ -584,7 +639,29 @@ function preserveReplacementHead( ) { return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; } + const continued = continueCanonicalAssistant(liveAssistant, tailAssistant); + if (!continued) { + return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; + } + return { + tail: reconciledTail.slice(0, -1), + head: [ + ...unreconciledHead.slice(0, liveAssistantIndex), + continued, + ...unreconciledHead.slice(liveAssistantIndex + 1), + ], + acknowledgedClientMessageIds: [], + }; +} +// The live head row that continues the canonical tail's last assistant, merged so the canonical +// text owns the positions it certifies and the live row keeps what came after. Rows without +// provider ids cannot tell a continuation from the next message, so they keep the conservative +// legacy rule and merge only when the live text visibly extends the canonical one. +function continueCanonicalAssistant( + liveAssistant: AssistantMessageItem, + tailAssistant: AssistantMessageItem, +): AssistantMessageItem | null { const hasNewerCursor = liveAssistant.timelineCursor !== undefined && tailAssistant.timelineCursor !== undefined && @@ -592,41 +669,29 @@ function preserveReplacementHead( liveAssistant.timelineCursor.seq > tailAssistant.timelineCursor.seq; const hasMatchingProviderMessageId = liveAssistant.messageId !== undefined && liveAssistant.messageId === tailAssistant.messageId; + const extendsText = liveAssistant.text.startsWith(tailAssistant.text); const hasIdlessTextContinuation = - liveAssistant.messageId === undefined && - tailAssistant.messageId === undefined && - liveAssistant.text.startsWith(tailAssistant.text); - const isNewerContinuation = - hasNewerCursor && (hasMatchingProviderMessageId || hasIdlessTextContinuation); - if (isNewerContinuation) { - const text = liveAssistant.text.startsWith(tailAssistant.text) - ? liveAssistant.text - : `${tailAssistant.text}${liveAssistant.text}`; - const head = [ - ...unreconciledHead.slice(0, liveAssistantIndex), - { ...liveAssistant, text }, - ...unreconciledHead.slice(liveAssistantIndex + 1), - ]; + liveAssistant.messageId === undefined && tailAssistant.messageId === undefined && extendsText; + if (hasNewerCursor && (hasMatchingProviderMessageId || hasIdlessTextContinuation)) { + if (liveAssistant.timelineCursor && tailAssistant.timelineCursor) { + return ( + mergeCanonicalText([liveAssistant], { + text: tailAssistant.text, + epoch: tailAssistant.timelineCursor.epoch, + startSeq: rowStartSeq(tailAssistant) ?? tailAssistant.timelineCursor.seq, + endSeq: tailAssistant.timelineCursor.seq, + timestamp: tailAssistant.timestamp, + })[0] ?? liveAssistant + ); + } return { - tail: reconciledTail.slice(0, -1), - head, - acknowledgedClientMessageIds: [], + ...liveAssistant, + text: extendsText ? liveAssistant.text : `${tailAssistant.text}${liveAssistant.text}`, }; } - if (!liveAssistant.text.startsWith(tailAssistant.text)) { - return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; - } - - const head = [ - ...unreconciledHead.slice(0, liveAssistantIndex), - { ...liveAssistant, text: tailAssistant.text }, - ...unreconciledHead.slice(liveAssistantIndex + 1), - ]; - return { - tail: reconciledTail.slice(0, -1), - head, - acknowledgedClientMessageIds: [], - }; + // A live prefix of the canonical text is the same message still streaming from the client's + // view; the canonical text is complete as of the page. + return extendsText ? { ...liveAssistant, text: tailAssistant.text } : null; } export function replaceWithCanonicalStream( @@ -673,24 +738,29 @@ export function replaceWithCanonicalStream( nextTail.push(item); } - const retainedTailMessages: UserMessageItem[] = []; - for (const local of unmatchedTailMessages) { - const preserveLocal = input.preserveContinuity - ? isUnreconciledLocalUserMessage(local) - : local.clientMessageId !== undefined && sendingClientMessageIds.has(local.clientMessageId); - if (preserveLocal) { - nextTail.push(local); - } else if ( - input.preserveContinuity && - local.timelineCursor && - isAfterCanonicalCoverage(local.timelineCursor, input.canonicalCoverage) + const retainedTailRows: StreamItem[] = []; + for (const item of input.previousTail) { + if (item.kind === "user_message" && item.clientMessageId !== undefined) { + if (!unmatchedTailMessages.includes(item)) continue; + const preserveLocal = input.preserveContinuity + ? isUnreconciledLocalUserMessage(item) + : sendingClientMessageIds.has(item.clientMessageId); + if (preserveLocal) { + nextTail.push(item); + continue; + } + } + if ( + item.timelineCursor && + !isCoveredByCanonical(item, input.canonical, input.canonicalCoverage) ) { - retainedTailMessages.push(local); + retainedTailRows.push(item); } } - nextHead = [...retainedTailMessages, ...nextHead]; + nextHead = [...retainedTailRows, ...nextHead]; const replacement = preserveReplacementHead( + input.canonical, nextTail, nextHead, input.preserveContinuity, @@ -713,6 +783,7 @@ export interface AssistantMessageItem { timestamp: Date; blockGroupId?: string; blockIndex?: number; + source?: RowSource; } export interface TimelinePosition { @@ -720,6 +791,25 @@ export interface TimelinePosition { seq: number; } +// Where a row's content came from. `startSeq` is the first source position the row covers; rows +// display in start order because a tool call completes long after it appeared. `chunks` records, +// for streamed text, the sequence that produced each piece and the offset it begins at, so a +// canonical page owns exactly the chunks inside its source coverage and text merges cut on chunk +// boundaries instead of comparing strings. Rows saved before provenance existed order by `seq`. +export interface RowSource { + startSeq: number; + chunks?: SourceChunk[]; +} + +export interface SourceChunk { + seq: number; + offset: number; +} + +// Reducers receive the position of the source rows an event covers; canonical units start before +// they end. Rows keep only the anchor position; the start goes into `source`. +export type SourcedPosition = TimelinePosition & { startSeq?: number }; + export type ThoughtStatus = "loading" | "ready"; export interface ThoughtItem { @@ -730,6 +820,159 @@ export interface ThoughtItem { text: string; timestamp: Date; status: ThoughtStatus; + source?: RowSource; +} + +type TextRow = AssistantMessageItem | ThoughtItem; + +function anchorOf(cursor: SourcedPosition): TimelinePosition; +function anchorOf(cursor: SourcedPosition | undefined): TimelinePosition | undefined; +function anchorOf(cursor: SourcedPosition | undefined): TimelinePosition | undefined { + if (!cursor) return undefined; + return cursor.startSeq === undefined ? cursor : { epoch: cursor.epoch, seq: cursor.seq }; +} + +// The source of a row after an event at `cursor` touches it. A row keeps its start while the +// event stays in its epoch; a new epoch starts it over. +function sourceFor(cursor: SourcedPosition, existing?: StreamItem): RowSource { + if (existing?.timelineCursor?.epoch === cursor.epoch) { + const startSeq = existing.source?.startSeq ?? existing.timelineCursor.seq; + return existing.source?.startSeq === startSeq && existing.source.chunks === undefined + ? existing.source + : { startSeq }; + } + return { startSeq: cursor.startSeq ?? cursor.seq }; +} + +function positionedRow( + cursor: SourcedPosition | undefined, +): Pick { + return cursor ? { timelineCursor: anchorOf(cursor), source: sourceFor(cursor) } : {}; +} + +export function rowStartSeq(item: StreamItem): number | undefined { + return item.source?.startSeq ?? item.timelineCursor?.seq; +} + +function rowChunks(row: TextRow): SourceChunk[] { + if (row.source?.chunks) return row.source.chunks; + return row.timelineCursor ? [{ seq: row.timelineCursor.seq, offset: 0 }] : []; +} + +function chunkTexts(row: TextRow): Array<{ seq: number; text: string }> { + const chunks = rowChunks(row); + return chunks.map((chunk, index) => ({ + seq: chunk.seq, + text: row.text.slice(chunk.offset, chunks[index + 1]?.offset ?? row.text.length), + })); +} + +function chunksOf(parts: ReadonlyArray<{ seq: number; text: string }>): SourceChunk[] { + let offset = 0; + return parts.map((part) => { + const chunk = { seq: part.seq, offset }; + offset += part.text.length; + return chunk; + }); +} + +function sliceChunks(chunks: readonly SourceChunk[], start: number, end: number): SourceChunk[] { + const inside = chunks + .filter((chunk) => chunk.offset >= start && chunk.offset < end) + .map((chunk) => ({ seq: chunk.seq, offset: chunk.offset - start })); + const spanning = chunks.findLast((chunk) => chunk.offset < start); + if (spanning && inside[0]?.offset !== 0) inside.unshift({ seq: spanning.seq, offset: 0 }); + return inside; +} + +export interface CanonicalTextUnit { + text: string; + epoch: string; + startSeq: number; + endSeq: number; + timestamp: Date; +} + +// A canonical text unit owns the source positions from `startSeq` through `endSeq`. Among the rows +// that belong to the same message it replaces exactly the chunks inside that coverage, keeps chunks +// before it and after it, and lands where the covered chunks were, so a page that lags the live +// stream restores the prefix it certifies without touching text the stream delivered later. Rows +// keep their identity and split; rows left with no text are dropped. +function coverageOf(seq: number, unit: CanonicalTextUnit): "before" | "covered" | "after" { + if (seq < unit.startSeq) return "before"; + return seq > unit.endSeq ? "after" : "covered"; +} + +export function mergeCanonicalText( + owned: readonly Row[], + unit: CanonicalTextUnit, +): Row[] { + const parts = owned.map((row) => { + const positioned = row.timelineCursor?.epoch === unit.epoch; + return chunkTexts(row).map((part, index) => ({ + seq: part.seq, + startSeq: index === 0 ? (rowStartSeq(row) ?? part.seq) : part.seq, + text: part.text, + where: positioned ? coverageOf(part.seq, unit) : ("covered" as const), + })); + }); + let anchor = parts.findIndex((rowParts) => rowParts.some((part) => part.where !== "before")); + if (anchor < 0) anchor = owned.length - 1; + const result: Row[] = []; + for (const [index, row] of owned.entries()) { + const rowParts = parts[index] ?? []; + if (index < anchor) { + result.push(row); + continue; + } + const before = index === anchor ? rowParts.filter((part) => part.where === "before") : []; + const after = rowParts.filter((part) => part.where === "after"); + const kept = + index === anchor + ? [ + ...before, + { + seq: unit.endSeq, + startSeq: unit.startSeq, + text: unit.text, + where: "covered" as const, + }, + ...after, + ] + : after; + if (kept.length === 0) continue; + const text = kept.map((part) => part.text).join(""); + const last = kept.at(-1); + const first = kept[0]; + result.push({ + ...row, + text, + timestamp: index === anchor && after.length === 0 ? unit.timestamp : row.timestamp, + timelineCursor: { epoch: unit.epoch, seq: last?.seq ?? unit.endSeq }, + source: { + startSeq: first?.startSeq ?? unit.startSeq, + chunks: chunksOf(kept), + }, + }); + } + return result; +} + +// Appends one text row's content to another, keeping every chunk position. +export function joinTextRows(first: Row, second: Row): Row { + const parts = [...chunkTexts(first), ...chunkTexts(second)]; + return { + ...second, + text: `${first.text}${second.text}`, + ...(second.timelineCursor + ? { + source: { + startSeq: rowStartSeq(first) ?? second.timelineCursor.seq, + chunks: chunksOf(parts), + }, + } + : {}), + }; } export type OrchestratorToolCallStatus = "executing" | "completed" | "failed"; @@ -762,6 +1005,7 @@ export interface ToolCallItem { kind: "tool_call"; id: string; timelineCursor?: TimelinePosition; + source?: RowSource; turnId?: string; timestamp: Date; payload: ToolCallPayload; @@ -782,6 +1026,7 @@ export interface NotificationItem { sourceType: "error" | "notification"; id: string; timelineCursor?: TimelinePosition; + source?: RowSource; turnId?: string; timestamp: Date; level: NotificationLevel; @@ -792,6 +1037,7 @@ export interface CompactionItem { kind: "compaction"; id: string; timelineCursor?: TimelinePosition; + source?: RowSource; turnId?: string; timestamp: Date; status: "loading" | "completed"; @@ -803,6 +1049,7 @@ export interface PluginTimelineStreamItem { kind: "plugin"; id: string; timelineCursor?: TimelinePosition; + source?: RowSource; turnId?: string; timestamp: Date; pluginId: string; @@ -828,6 +1075,7 @@ export interface TodoListItem { kind: "todo_list"; id: string; timelineCursor?: TimelinePosition; + source?: RowSource; turnId?: string; timestamp: Date; provider: AgentProvider; @@ -840,7 +1088,7 @@ export type StreamUpdateSource = "live" | "canonical"; interface StreamUpdateOptions { source?: StreamUpdateSource; reservedItemIds?: ReadonlySet; - timelineCursor?: TimelinePosition; + timelineCursor?: SourcedPosition; } function isRecord(value: unknown): value is Record { @@ -901,7 +1149,7 @@ function appendUserMessage( id: messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp), clientMessageId, messageId, - timelineCursor, + ...positionedRow(timelineCursor), turnId, text: chunk, timestamp, @@ -916,7 +1164,7 @@ function appendAssistantMessage( source: StreamUpdateSource, messageId?: string, reservedItemIds?: ReadonlySet, - timelineCursor?: TimelinePosition, + timelineCursor?: SourcedPosition, ): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); if (!chunk) { @@ -929,13 +1177,7 @@ function appendAssistantMessage( last.kind === "assistant_message" && (messageId === undefined || last.messageId === messageId); if (shouldAppendToLast) { - const updated: AssistantMessageItem = { - ...last, - text: `${last.text}${chunk}`, - timestamp, - ...(timelineCursor ? { timelineCursor } : {}), - }; - return [...state.slice(0, -1), updated]; + return [...state.slice(0, -1), extendTextRow(last, chunk, timestamp, timelineCursor)]; } // A submitted user row can follow the streaming assistant during interrupt. @@ -947,13 +1189,11 @@ function appendAssistantMessage( secondLast?.kind === "assistant_message" && (messageId === undefined || secondLast.messageId === messageId) ) { - const updated: AssistantMessageItem = { - ...secondLast, - text: `${secondLast.text}${chunk}`, - timestamp, - ...(timelineCursor ? { timelineCursor } : {}), - }; - return [...state.slice(0, -2), updated, last]; + return [ + ...state.slice(0, -2), + extendTextRow(secondLast, chunk, timestamp, timelineCursor), + last, + ]; } if (!hasContent) { @@ -966,18 +1206,53 @@ function appendAssistantMessage( kind: "assistant_message", id: entryId, ...(messageId ? { messageId } : {}), - ...(timelineCursor ? { timelineCursor } : {}), + ...startedTextRow(timelineCursor), text: chunk, timestamp, }; return [...state, item]; } +function startedTextRow( + timelineCursor: SourcedPosition | undefined, +): Pick { + if (!timelineCursor) return {}; + return { + timelineCursor: anchorOf(timelineCursor), + source: { + startSeq: timelineCursor.startSeq ?? timelineCursor.seq, + chunks: [{ seq: timelineCursor.seq, offset: 0 }], + }, + }; +} + +function extendTextRow( + row: Row, + chunk: string, + timestamp: Date, + timelineCursor: SourcedPosition | undefined, +): Row { + return { + ...row, + text: `${row.text}${chunk}`, + timestamp, + ...(timelineCursor + ? { + timelineCursor: anchorOf(timelineCursor), + source: { + startSeq: sourceFor(timelineCursor, row).startSeq, + chunks: [...rowChunks(row), { seq: timelineCursor.seq, offset: row.text.length }], + }, + } + : {}), + }; +} + function appendThought( state: StreamItem[], text: string, timestamp: Date, - timelineCursor?: TimelinePosition, + timelineCursor?: SourcedPosition, ): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); if (!chunk) { @@ -986,14 +1261,10 @@ function appendThought( const last = state[state.length - 1]; if (last && last.kind === "thought") { - const updated: ThoughtItem = { - ...last, - ...(timelineCursor ? { timelineCursor } : {}), - text: `${last.text}${chunk}`, - timestamp, - status: "loading", - }; - return [...state.slice(0, -1), updated]; + return [ + ...state.slice(0, -1), + { ...extendTextRow(last, chunk, timestamp, timelineCursor), status: "loading" }, + ]; } if (!hasContent) { @@ -1004,7 +1275,7 @@ function appendThought( const item: ThoughtItem = { kind: "thought", id: createUniqueTimelineId(state, "thought", idSeed, timestamp), - ...(timelineCursor ? { timelineCursor } : {}), + ...startedTextRow(timelineCursor), text: chunk, timestamp, status: "loading", @@ -1156,7 +1427,7 @@ export function mergeAgentToolCallItem( existing: AgentToolCallItem, data: AgentToolCallData, timestamp: Date, - timelineCursor?: TimelinePosition, + timelineCursor?: SourcedPosition, ): AgentToolCallItem { const mergedStatus = mergeAgentToolCallStatus(existing.payload.data.status, data.status); const mergedError = @@ -1168,7 +1439,9 @@ export function mergeAgentToolCallItem( return { ...existing, - ...(timelineCursor ? { timelineCursor } : {}), + ...(timelineCursor + ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor, existing) } + : {}), timestamp, payload: { source: "agent", @@ -1189,7 +1462,7 @@ interface AppendAgentToolCallInput { data: AgentToolCallData; timestamp: Date; turnId?: string; - timelineCursor?: TimelinePosition; + timelineCursor?: SourcedPosition; } function appendAgentToolCall(input: AppendAgentToolCallInput): StreamItem[] { @@ -1225,7 +1498,9 @@ function appendAgentToolCall(input: AppendAgentToolCallInput): StreamItem[] { const item: ToolCallItem = { kind: "tool_call", id: `agent_tool_${identity}`, - ...(timelineCursor ? { timelineCursor } : {}), + ...(timelineCursor + ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) } + : {}), ...(turnId ? { turnId } : {}), timestamp, payload: { @@ -1254,7 +1529,7 @@ function appendPluginTimelineItem( state: StreamItem[], item: Extract, timestamp: Date, - timelineCursor?: TimelinePosition, + timelineCursor?: SourcedPosition, ): StreamItem[] { const identity = timelineItemIdentity(item); if (identity === null) return state; @@ -1267,14 +1542,22 @@ function appendPluginTimelineItem( version: item.version, data: item.data, timestamp, - ...(timelineCursor ? { timelineCursor } : {}), + ...(timelineCursor + ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) } + : {}), }; const existingIndex = findExistingTimelineIdentityIndex(state, identity); if (existingIndex < 0) return [...state, nextItem]; const existing = state[existingIndex]; if (!existing || existing.kind !== "plugin") return state; const next = [...state]; - next[existingIndex] = { ...nextItem, id: existing.id }; + next[existingIndex] = { + ...nextItem, + id: existing.id, + ...(timelineCursor + ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor, existing) } + : {}), + }; return next; } @@ -1283,7 +1566,7 @@ function appendTodoList( provider: AgentProvider, items: TodoEntry[], timestamp: Date, - timelineCursor?: TimelinePosition, + timelineCursor?: SourcedPosition, ): StreamItem[] { const normalizedItems = items.map((item) => ({ text: item.text, @@ -1305,7 +1588,9 @@ function appendTodoList( const next = [...state]; next[previousIndex] = { ...previous, - ...(timelineCursor ? { timelineCursor } : {}), + ...(timelineCursor + ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor, previous) } + : {}), items: normalizedItems, timestamp, }; @@ -1324,7 +1609,9 @@ function appendTodoList( const next = [...state]; next[next.length - 1] = { ...lastItem, - ...(timelineCursor ? { timelineCursor } : {}), + ...(timelineCursor + ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor, lastItem) } + : {}), items: normalizedItems, activity: { type: "created", count: normalizedItems.length }, timestamp, @@ -1338,7 +1625,9 @@ function appendTodoList( next.push({ kind: "todo_list", id: createUniqueTimelineId(next, "todo", idSeed, timestamp), - ...(timelineCursor ? { timelineCursor } : {}), + ...(timelineCursor + ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) } + : {}), timestamp, provider, items: normalizedItems, @@ -1393,7 +1682,7 @@ function reduceTimelineToolCall( { type: "tool_call" } >, timestamp: Date, - timelineCursor?: TimelinePosition, + timelineCursor?: SourcedPosition, ): StreamItem[] { const normalizedToolName = item.name .trim() @@ -1464,7 +1753,7 @@ function reduceTimelineCompaction( { type: "compaction" } >, timestamp: Date, - timelineCursor?: TimelinePosition, + timelineCursor?: SourcedPosition, ): StreamItem[] { if (item.status === "completed") { const loadingIdx = state.findIndex((s) => s.kind === "compaction" && s.status === "loading"); @@ -1472,7 +1761,12 @@ function reduceTimelineCompaction( if (loadingIdx >= 0 && existing && existing.kind === "compaction") { const updated: CompactionItem = { ...existing, - ...(timelineCursor ? { timelineCursor } : {}), + ...(timelineCursor + ? { + timelineCursor: anchorOf(timelineCursor), + source: sourceFor(timelineCursor, existing), + } + : {}), status: "completed", trigger: item.trigger ?? existing.trigger, preTokens: item.preTokens ?? existing.preTokens, @@ -1486,7 +1780,9 @@ function reduceTimelineCompaction( const compaction: CompactionItem = { kind: "compaction", id: createUniqueTimelineId(state, "compaction", item.status, timestamp), - ...(timelineCursor ? { timelineCursor } : {}), + ...(timelineCursor + ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) } + : {}), timestamp, status: item.status, trigger: item.trigger, @@ -1501,7 +1797,7 @@ function reduceTimelineEvent( timestamp: Date, source: StreamUpdateSource, reservedItemIds?: ReadonlySet, - timelineCursor?: TimelinePosition, + timelineCursor?: SourcedPosition, ): StreamItem[] { const item = event.item; switch (item.type) { @@ -1553,7 +1849,9 @@ function reduceTimelineEvent( kind: "notification", sourceType: "error", id: createUniqueTimelineId(state, "error", item.message ?? "", timestamp), - ...(timelineCursor ? { timelineCursor } : {}), + ...(timelineCursor + ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) } + : {}), timestamp, level: "error", message: item.message ?? "Unknown error", @@ -1565,7 +1863,9 @@ function reduceTimelineEvent( kind: "notification", sourceType: "notification", id: createUniqueTimelineId(state, "notification", item.message, timestamp), - ...(timelineCursor ? { timelineCursor } : {}), + ...(timelineCursor + ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) } + : {}), timestamp, level: item.level, message: item.message, @@ -1674,7 +1974,7 @@ export function hydrateStreamState( events: Array<{ event: AgentStreamEventPayload; timestamp: Date; - timelineCursor?: TimelinePosition; + timelineCursor?: SourcedPosition; }>, options?: Pick, ): StreamItem[] { @@ -1828,28 +2128,39 @@ function promoteCompletedAssistantBlocks(params: { tail: StreamItem[]; head: Str const firstBlockIndex = activeItem.blockIndex ?? 0; const completedBlocks = blocks.slice(0, -1); const liveBlock = `${blocks[blocks.length - 1] ?? ""}${getTrailingNewlineSuffix(activeItem.text)}`; - const promotedItems = completedBlocks.map((block, offset) => ({ - ...activeItem, - id: createAssistantBlockId({ - groupId: blockGroupId, - blockIndex: firstBlockIndex + offset, - }), - blockGroupId, - blockIndex: firstBlockIndex + offset, - text: block, - })); + // Blocks are substrings of the row in order; each keeps the chunks that produced it. + const chunks = rowChunks(activeItem); + let searchFrom = 0; + const blockRow = (block: string, blockIndex: number): AssistantMessageItem => { + const start = activeItem.text.indexOf(block, searchFrom); + searchFrom = start + block.length; + const blockChunks = sliceChunks(chunks, start, start + block.length); + return { + ...activeItem, + id: createAssistantBlockId({ groupId: blockGroupId, blockIndex }), + blockGroupId, + blockIndex, + text: block, + ...(activeItem.timelineCursor && blockChunks.length > 0 + ? { + timelineCursor: { + epoch: activeItem.timelineCursor.epoch, + seq: blockChunks.at(-1)?.seq ?? activeItem.timelineCursor.seq, + }, + source: { + startSeq: blockChunks[0]?.seq ?? activeItem.timelineCursor.seq, + chunks: blockChunks, + }, + } + : {}), + }; + }; + const promotedItems = completedBlocks.map((block, offset) => + blockRow(block, firstBlockIndex + offset), + ); const nextTail = flushHeadToTail(params.tail, promotedItems); - const liveItem: AssistantMessageItem = { - ...activeItem, - id: createAssistantBlockId({ - groupId: blockGroupId, - blockIndex: firstBlockIndex + completedBlocks.length, - }), - blockGroupId, - blockIndex: firstBlockIndex + completedBlocks.length, - text: liveBlock, - }; + const liveItem = blockRow(liveBlock, firstBlockIndex + completedBlocks.length); const nextHead = [ ...params.head.slice(0, assistantIndex), liveItem, @@ -1945,7 +2256,7 @@ function applyCanonicalUserMessageEvent(params: { head: StreamItem[]; event: AgentStreamEventPayload; timestamp: Date; - timelineCursor?: TimelinePosition; + timelineCursor?: SourcedPosition; unmatchedInsert?: "tail" | "head"; }): ApplyStreamEventResult | null { const { tail, head, event, timestamp, timelineCursor, unmatchedInsert = "tail" } = params; @@ -1961,7 +2272,7 @@ function applyCanonicalUserMessageEvent(params: { messageId: event.item.messageId, clientMessageId: event.item.clientMessageId, turnId: event.turnId, - timelineCursor, + ...positionedRow(timelineCursor), text: normalized.chunk, timestamp, }); @@ -2035,7 +2346,7 @@ export function applyStreamEvent(params: { event: AgentStreamEventPayload; timestamp: Date; source?: StreamUpdateSource; - timelineCursor?: TimelinePosition; + timelineCursor?: SourcedPosition; unmatchedUserMessageInsert?: "tail" | "head"; }): ApplyStreamEventResult { const { tail, head, event, timestamp } = params; diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index c8e2479894..72158779a3 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -1,3 +1,4 @@ +import { createTimelineReplica } from "@/timeline/replica"; import { describe, expect, it } from "vitest"; import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; @@ -65,18 +66,26 @@ function permission(id: string): AgentPermissionRequest { return { id, provider: "codex", name: id, kind: "tool", title: id }; } -function beginPendingSubmission(serverId: string, agentId: string): string { +function beginPendingSubmission(serverId: string, agentId: string) { const clientMessageId = `client-${agentId}`; - useSessionStore.getState().beginAgentMessageSubmission( + const replica = createTimelineReplica({ serverId, + storage: { + removeTimeline: () => undefined, + readTimeline: async () => undefined, + commitTimeline: () => undefined, + }, + }); + replica.beginSubmission( agentId, createUserMessage({ clientMessageId, text: "Run this", timestamp: new Date("2026-07-27T10:00:00.000Z"), }), + true, ); - return clientMessageId; + return { clientMessageId, replica }; } describe("message submission authority", () => { @@ -85,9 +94,11 @@ describe("message submission authority", () => { const agentId = "agent-1"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - const clientMessageId = beginPendingSubmission(serverId, agentId); + const { clientMessageId } = beginPendingSubmission(serverId, agentId); const agent = createAgentPayload({ id: agentId, status: "running" }); - new AgentStoreProjection(serverId).applyDelta({ + new AgentStoreProjection(serverId, (removedAgentId) => + useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId), + ).applyDelta({ kind: "upsert", agent, project: createEntry(agent).project, @@ -108,28 +119,44 @@ describe("message submission authority", () => { const agentId = "agent-1"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - const clientMessageId = beginPendingSubmission(serverId, agentId); - store.setAgentStreamState(serverId, agentId, { - tail: [ - createUserMessage({ - id: "provider-message", - messageId: "provider-message", - clientMessageId, - text: "Run this", - timestamp: new Date("2026-07-27T10:00:01.000Z"), - }), - ], - head: [], - }); + const { clientMessageId, replica } = beginPendingSubmission(serverId, agentId); + replica.handoffSubmission( + agentId, + createUserMessage({ + id: "provider-message", + messageId: "provider-message", + clientMessageId, + text: "Run this", + timestamp: new Date("2026-07-27T10:00:01.000Z"), + }), + ); expect( useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0] ?.providerAcknowledged, ).toBe(false); - store.setAgentStreamState(serverId, agentId, { - acknowledgedClientMessageIds: [clientMessageId], - }); + replica.applyEvents( + agentId, + [ + { + seq: 1, + epoch: "epoch", + timestamp: new Date("2026-07-27T10:00:01.000Z"), + event: { + type: "timeline", + provider: "codex", + item: { + type: "user_message", + messageId: "provider-message", + clientMessageId, + text: "Run this", + }, + }, + }, + ], + () => undefined, + ); expect( useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0] @@ -146,9 +173,9 @@ describe("replaceFetchedAgentDirectory", () => { store.initializeSession(serverId, null as unknown as DaemonClient); store.setInitializingAgents(serverId, new Map([["agent", true]])); - new AgentStoreProjection(serverId).replaceFetched([ - createEntry(createAgentPayload({ id: "agent" })), - ]); + new AgentStoreProjection(serverId, (removedAgentId) => + useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId), + ).replaceFetched([createEntry(createAgentPayload({ id: "agent" }))]); expect(useSessionStore.getState().sessions[serverId]?.initializingAgents.get("agent")).toBe( true, @@ -161,7 +188,9 @@ describe("replaceFetchedAgentDirectory", () => { const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - const projection = new AgentStoreProjection(serverId); + const projection = new AgentStoreProjection(serverId, (removedAgentId) => + useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId), + ); projection.replaceFetched([ createEntry( createAgentPayload({ @@ -202,10 +231,15 @@ describe("replaceFetchedAgentDirectory", () => { serverId, new Map([[agentId, [{ id: "queued", text: "next", attachments: [] }]]]), ); - store.setAgentTimelineCursor( - serverId, - new Map([[agentId, { epoch: "epoch", startSeq: 1, endSeq: 2 }]]), - ); + store.applyAgentTimelineResponseState(serverId, agentId, { + items: [], + head: [], + range: { epoch: "epoch", startSeq: 1, endSeq: 2 }, + older: "none", + newer: false, + synchronized: false, + submissions: [], + }); store.setPendingPermissions( serverId, new Map([["permission", { key: "permission", agentId, request: null as never }]]), @@ -213,7 +247,9 @@ describe("replaceFetchedAgentDirectory", () => { store.setInitializingAgents(serverId, new Map([[agentId, true]])); setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); - new AgentStoreProjection(serverId).applyDelta({ kind: "remove", agentId }); + new AgentStoreProjection(serverId, (removedAgentId) => + useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId), + ).applyDelta({ kind: "remove", agentId }); const session = useSessionStore.getState().sessions[serverId]; expect({ @@ -273,7 +309,9 @@ describe("replaceFetchedAgentDirectory", () => { lastUsage: { inputTokens: 10, outputTokens: 5 }, pendingPermissions: [permission("current-permission")], }); - const projection = new AgentStoreProjection(serverId); + const projection = new AgentStoreProjection(serverId, (removedAgentId) => + useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId), + ); projection.applyDelta({ kind: "upsert", agent: current, diff --git a/packages/app/src/utils/agent-directory-sync.ts b/packages/app/src/utils/agent-directory-sync.ts index d700a0f510..aff01550bf 100644 --- a/packages/app/src/utils/agent-directory-sync.ts +++ b/packages/app/src/utils/agent-directory-sync.ts @@ -10,6 +10,9 @@ export type AgentDirectoryDelta = Extract< { type: "agent_update" } >["payload"]; +// Directory membership can disappear without the underlying agent being deleted. +export type AgentRemovalReason = "scope" | "deleted"; + interface PendingPermissionEntry { key: string; agentId: string; From a5d2b7d8634f28441782b748ac63d09af187c644 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 7 Sep 2026 17:20:19 +0200 Subject: [PATCH 025/566] Declare and enforce plugin Paseo version requirements (#4430) * feat(plugins): enforce declared Paseo version requirements Treat manifests without a Paseo requirement as pre-0.8 plugins so the first breaking plugin release offers an explicit migration path. Check the daemon and app independently before plugin execution, and preserve installed Git revisions when updates are incompatible. * fix(nix): refresh dependency hash for plugin requirements * refactor(plugins): validate test manifest inputs and name compatibility input --- docs/plugins.md | 10 +- nix/npm-deps.hash | 2 +- package-lock.json | 21 +++ .../app/e2e/browser/plugin-host-ui.spec.ts | 6 +- .../app/e2e/browser/plugin-management.spec.ts | 6 +- .../app/e2e/browser/plugin-modal-body.spec.ts | 8 +- .../e2e/browser/plugin-requirements.spec.ts | 18 +++ packages/app/e2e/browser/plugin-theme.spec.ts | 6 +- .../browser/plugin-workspace-panels.spec.ts | 6 +- .../app/e2e/support/helpers/plugin-fixture.ts | 29 ++++ .../support/helpers/plugin-requirements.ts | 114 +++++++++++++++ .../e2e/support/helpers/plugin-settings.ts | 7 +- .../src/plugins/registry-requirements.test.ts | 86 +++++++++++ packages/app/src/plugins/registry.test.ts | 7 +- packages/app/src/plugins/registry.ts | 28 ++-- .../app/src/plugins/timeline/view.test.tsx | 43 ++++-- packages/app/src/plugins/types.ts | 2 + .../workspace-panels/locations.test.ts | 11 +- .../cli/src/commands/plugin/scaffold.test.ts | 11 +- packages/cli/src/commands/plugin/scaffold.ts | 8 +- packages/cli/src/output/render.test.ts | 16 +++ packages/cli/src/output/render.ts | 3 +- .../cli/tests/e2e/plugin-lifecycle.test.ts | 79 ++++++++++- packages/client/src/daemon-client.ts | 2 +- packages/protocol/package.json | 2 + .../protocol/src/messages.plugins.test.ts | 15 ++ packages/protocol/src/messages.ts | 4 + .../protocol/src/plugin-requirements.test.ts | 54 +++++++ packages/protocol/src/plugin-requirements.ts | 39 +++++ .../server/src/server/bootstrap.smoke.test.ts | 6 +- .../terminal-workspace-sdk.e2e.test.ts | 6 +- packages/server/src/server/plugins/index.ts | 15 +- .../src/server/plugins/manifest.test.ts | 21 +++ .../server/src/server/plugins/manifest.ts | 8 +- .../plugins/plugin-paseo-api.e2e.test.ts | 11 +- .../server/plugins/requirements.posix.test.ts | 133 ++++++++++++++++++ .../src/server/plugins/runtime.posix.test.ts | 9 +- packages/server/src/server/plugins/runtime.ts | 15 +- .../src/server/plugins/settings.e2e.test.ts | 6 +- .../src/server/test-utils/versioned-daemon.ts | 16 +++ plugin-examples/catppuccin/paseo-plugin.json | 5 +- .../inline-thinking/paseo-plugin.json | 5 +- plugin-examples/linear/paseo-plugin.json | 5 +- .../local-plugin/paseo-plugin.json | 5 +- plugin-examples/modal-ui/paseo-plugin.json | 5 +- .../paseo-plugin.json | 5 +- .../provider-direct/paseo-plugin.json | 5 +- plugin-examples/settings/paseo-plugin.json | 7 +- .../timeline-items/paseo-plugin.json | 5 +- public-docs/plugins/v0.8/index.md | 2 +- public-docs/plugins/v0.8/migration.md | 22 ++- public-docs/plugins/v0.8/reference.md | 38 ++++- skills/paseo-plugin/SKILL.md | 11 +- 53 files changed, 928 insertions(+), 81 deletions(-) create mode 100644 packages/app/e2e/browser/plugin-requirements.spec.ts create mode 100644 packages/app/e2e/support/helpers/plugin-fixture.ts create mode 100644 packages/app/e2e/support/helpers/plugin-requirements.ts create mode 100644 packages/app/src/plugins/registry-requirements.test.ts create mode 100644 packages/cli/src/output/render.test.ts create mode 100644 packages/protocol/src/plugin-requirements.test.ts create mode 100644 packages/protocol/src/plugin-requirements.ts create mode 100644 packages/server/src/server/plugins/requirements.posix.test.ts create mode 100644 packages/server/src/server/test-utils/versioned-daemon.ts diff --git a/docs/plugins.md b/docs/plugins.md index 3798d7c055..3dcf5716e1 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -41,7 +41,7 @@ runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts ev enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries remain lifecycle-owned and do not reload from manual config edits. -The directory contains an identity-only manifest, one optional entry per runtime, runtime-owned +The directory contains a manifest declaring identity and Paseo requirements, one optional entry per runtime, runtime-owned directories, and local typechecking support. At least one entry is required. ```text @@ -62,10 +62,15 @@ runtime modules, so consumers do not install these packages when adding the plug ```json { - "id": "my-plugin" + "id": "my-plugin", + "requirements": { "paseo": ">=0.8.0" } } ``` +Declare the supported Paseo range and keep it current when adopting newer APIs. See the +[requirements contract](../public-docs/plugins/v0.8/reference.md#requirements), including legacy +manifests and prerelease matching. + The config key is the runtime plugin ID. The manifest ID is the default selected during install; `--id` overrides it. Existing configuration is not renamed when the manifest changes, and the runtime does not compare the two IDs. The same directory can be installed under several config @@ -111,6 +116,7 @@ step: ```json { "id": "review", + "requirements": { "paseo": ">=0.8.0" }, "build": [ ["npm", "ci"], ["npm", "run", "build"] diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index a713307bd5..96f8a0393a 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-Vw6bgh+gPdVxySdbawKawr9qZT8QEEo2+okdpuKceMA= +sha256-1MBzb+r/t92G9uR/jBuc3oNEfEloHeRg9uUgzSHS4UM= diff --git a/package-lock.json b/package-lock.json index 37131854b5..4eb14395e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13059,6 +13059,13 @@ "@types/node": "*" } }, + "node_modules/@types/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -38731,15 +38738,29 @@ "name": "@getpaseo/protocol", "version": "0.7.2", "dependencies": { + "semver": "^7.8.5", "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^20.9.0", + "@types/semver": "^7.8.0", "typescript": "^5.2.2", "vitest": "^4.1.6", "zod-aot": "0.20.4" } }, + "packages/protocol/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "packages/relay": { "name": "@getpaseo/relay", "version": "0.7.2", diff --git a/packages/app/e2e/browser/plugin-host-ui.spec.ts b/packages/app/e2e/browser/plugin-host-ui.spec.ts index 7fcc72bdb7..80ee02459d 100644 --- a/packages/app/e2e/browser/plugin-host-ui.spec.ts +++ b/packages/app/e2e/browser/plugin-host-ui.spec.ts @@ -1,3 +1,4 @@ +import { pluginRequirements } from "../support/helpers/plugin-fixture"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -118,7 +119,10 @@ test("plugin modal adapts its presentation and preserves host contexts", async ( const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-host-ui-e2e-")); const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); const previousConfig = await client.getDaemonConfig(); - await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID })); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }), + ); await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE); try { diff --git a/packages/app/e2e/browser/plugin-management.spec.ts b/packages/app/e2e/browser/plugin-management.spec.ts index 823b792a5d..aaa3b2c0da 100644 --- a/packages/app/e2e/browser/plugin-management.spec.ts +++ b/packages/app/e2e/browser/plugin-management.spec.ts @@ -1,3 +1,4 @@ +import { pluginRequirements } from "../support/helpers/plugin-fixture"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -120,7 +121,10 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin" const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-e2e-")); const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); const previous = await client.getDaemonConfig(); - await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "e2e-plugin" })); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "e2e-plugin", requirements: pluginRequirements }), + ); await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v1")); try { diff --git a/packages/app/e2e/browser/plugin-modal-body.spec.ts b/packages/app/e2e/browser/plugin-modal-body.spec.ts index 591a2472e8..6db456b72b 100644 --- a/packages/app/e2e/browser/plugin-modal-body.spec.ts +++ b/packages/app/e2e/browser/plugin-modal-body.spec.ts @@ -1,4 +1,4 @@ -import path from "node:path"; +import { copyPluginExample } from "../support/helpers/plugin-fixture"; import { expect, test as base, type Page } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace"; @@ -95,11 +95,10 @@ const test = base.extend<{ modalExample: Page }>({ modalExample: async ({ page, context }, provide) => { const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); const previous = await client.getDaemonConfig(); + const example = await copyPluginExample("modal-ui"); try { await client.patchDaemonConfig({ pluginsEnabled: true }); - await client.installDirectoryPlugin( - path.resolve(__dirname, "../../../../plugin-examples/modal-ui"), - ); + await client.installDirectoryPlugin(example.directory); await context.grantPermissions(["clipboard-read", "clipboard-write"]); await provide(page); } finally { @@ -108,6 +107,7 @@ const test = base.extend<{ modalExample: Page }>({ .patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false }) .catch(() => undefined); await client.close().catch(() => undefined); + await example.cleanup(); } }, }); diff --git a/packages/app/e2e/browser/plugin-requirements.spec.ts b/packages/app/e2e/browser/plugin-requirements.spec.ts new file mode 100644 index 0000000000..832e3f1611 --- /dev/null +++ b/packages/app/e2e/browser/plugin-requirements.spec.ts @@ -0,0 +1,18 @@ +import { + test, + openRequirementHost, + expectAppMismatch, + correctRequirementAndReload, + rejectDaemonMismatch, +} from "../support/helpers/plugin-requirements"; + +test("shows which runtime is incompatible and recovers after the requirement is corrected", async ({ + page, + requirementHost, +}) => { + await openRequirementHost(page, requirementHost); + await expectAppMismatch(page); + await correctRequirementAndReload(page, requirementHost.directory); + await rejectDaemonMismatch(page, requirementHost.directory); + await correctRequirementAndReload(page, requirementHost.directory); +}); diff --git a/packages/app/e2e/browser/plugin-theme.spec.ts b/packages/app/e2e/browser/plugin-theme.spec.ts index aa579ae62c..6550460345 100644 --- a/packages/app/e2e/browser/plugin-theme.spec.ts +++ b/packages/app/e2e/browser/plugin-theme.spec.ts @@ -1,3 +1,4 @@ +import { pluginRequirements } from "../support/helpers/plugin-fixture"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -53,7 +54,10 @@ test("applies a contributed theme and falls back when its plugin is gone", async const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-e2e-")); const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); const previousConfig = await client.getDaemonConfig(); - await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID })); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }), + ); await writeFile(path.join(directory, "index.client.ts"), PLUGIN_SOURCE); try { diff --git a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts index 80f19ce1e4..4e7d7682df 100644 --- a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts +++ b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts @@ -1,3 +1,4 @@ +import { pluginRequirements } from "../support/helpers/plugin-fixture"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -199,7 +200,10 @@ test.describe("plugin workspace panels and Command Center", () => { repoPrefix: "plugin-panel-secondary-", port: secondaryDaemon.port, }); - await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID })); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }), + ); await writePluginSources(directory, { workspaceId: primary.workspaceId, agentId: "missing-agent", diff --git a/packages/app/e2e/support/helpers/plugin-fixture.ts b/packages/app/e2e/support/helpers/plugin-fixture.ts new file mode 100644 index 0000000000..2b6e548e71 --- /dev/null +++ b/packages/app/e2e/support/helpers/plugin-fixture.ts @@ -0,0 +1,29 @@ +import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { z } from "zod"; +import appPackage from "../../../package.json"; + +export const pluginRequirements = { paseo: `>=${appPackage.version}` }; + +export async function copyPluginExample(name: string) { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-example-")); + try { + await cp(path.resolve(__dirname, "../../../../../plugin-examples", name), directory, { + recursive: true, + }); + const manifestPath = path.join(directory, "paseo-plugin.json"); + const manifest = z + .record(z.string(), z.unknown()) + .parse(JSON.parse(await readFile(manifestPath, "utf8"))); + // Exercise example UI against the checkout runtime before its release version is bumped. + await writeFile( + manifestPath, + JSON.stringify({ ...manifest, requirements: pluginRequirements }), + ); + return { directory, cleanup: () => rm(directory, { recursive: true, force: true }) }; + } catch (error) { + await rm(directory, { recursive: true, force: true }); + throw error; + } +} diff --git a/packages/app/e2e/support/helpers/plugin-requirements.ts b/packages/app/e2e/support/helpers/plugin-requirements.ts new file mode 100644 index 0000000000..18631c319a --- /dev/null +++ b/packages/app/e2e/support/helpers/plugin-requirements.ts @@ -0,0 +1,114 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { spawnTsx, killProcessTree } from "./spawn-node"; +import { expect, test as base, type Page } from "../fixtures"; +import { connectDaemonClient } from "./daemon-client-loader"; +import { addConnectedHostAndReload } from "./hosts"; +import { gotoAppShell, openSettings } from "./app"; +import { openHostSection, selectSettingsHost } from "./settings"; +import { pluginRequirements } from "./plugin-fixture"; + +export const test = base.extend<{ + requirementHost: { serverId: string; port: number; directory: string; client: DaemonClient }; +}>({ + requirementHost: async ({ e2eWorker }, provide) => { + void e2eWorker; + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-requirement-ui-")); + await writeRequirements(directory, "^99.0.0"); + await writeFile( + path.join(directory, "index.client.ts"), + `export default function(client) { + return () => {}; + }`, + ); + const daemon = spawnTsx( + path.resolve(__dirname, "../../../../server/src/server/test-utils/versioned-daemon.ts"), + ["99.0.0"], + { stdio: ["ignore", "pipe", "pipe", "ipc"] }, + ); + const ready = Promise.withResolvers<{ port: number; serverId: string }>(); + const timeout = setTimeout( + () => ready.reject(new Error("Versioned daemon startup timed out")), + 30_000, + ); + let output = ""; + daemon.stdout?.on("data", (data) => { + output += data; + }); + daemon.stderr?.on("data", (data) => { + output += data; + }); + daemon.once("error", ready.reject); + daemon.once("exit", () => ready.reject(new Error(`Versioned daemon exited: ${output}`))); + daemon.once("message", (message) => { + if ( + typeof message !== "object" || + message === null || + !("port" in message) || + typeof message.port !== "number" || + !("serverId" in message) || + typeof message.serverId !== "string" + ) { + ready.reject(new Error("Invalid versioned daemon startup message")); + return; + } + ready.resolve({ port: message.port, serverId: message.serverId }); + }); + try { + const host = await ready.promise; + clearTimeout(timeout); + const client = await connectDaemonClient({ + port: host.port, + clientIdPrefix: "requirements", + }); + try { + await client.installDirectoryPlugin(directory); + await provide({ ...host, directory, client }); + } finally { + await client.close(); + } + } finally { + clearTimeout(timeout); + await killProcessTree(daemon); + await rm(directory, { recursive: true, force: true }); + } + }, +}); + +async function writeRequirements(directory: string, paseo: string) { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "requirements-example", requirements: { paseo } }), + ); +} + +export async function openRequirementHost(page: Page, host: { serverId: string; port: number }) { + await gotoAppShell(page); + await addConnectedHostAndReload(page, { ...host, label: "Plugin requirements" }); + await openSettings(page); + await selectSettingsHost(page, host.serverId); + await openHostSection(page, host.serverId, "plugins"); +} + +export async function expectAppMismatch(page: Page) { + await expect(page.getByLabel("requirements-example failed", { exact: true })).toBeVisible(); + await expect( + page.getByText(/Plugin "requirements-example" requires Paseo \^99.0.0. Your app is/), + ).toBeVisible(); +} + +export async function correctRequirementAndReload(page: Page, directory: string) { + await writeRequirements(directory, pluginRequirements.paseo); + await page.getByRole("button", { name: "Reload", exact: true }).click(); + await expect(page.getByLabel("requirements-example running", { exact: true })).toBeVisible(); + await expect(page.getByText(/Your app is/)).toHaveCount(0); +} + +export async function rejectDaemonMismatch(page: Page, directory: string) { + await writeRequirements(directory, ">=100.0.0"); + await page.getByRole("button", { name: "Reload", exact: true }).click(); + await expect(page.getByLabel("requirements-example failed", { exact: true })).toBeVisible(); + await expect(page.getByText(/Your daemon is 99.0.0/).first()).toBeVisible(); +} diff --git a/packages/app/e2e/support/helpers/plugin-settings.ts b/packages/app/e2e/support/helpers/plugin-settings.ts index d8dfb020e4..e391f648f2 100644 --- a/packages/app/e2e/support/helpers/plugin-settings.ts +++ b/packages/app/e2e/support/helpers/plugin-settings.ts @@ -1,11 +1,10 @@ -import path from "node:path"; +import { copyPluginExample } from "./plugin-fixture"; import { expect, test as base, type Page } from "../fixtures"; import { gotoAppShell, openSettings } from "./app"; import { goBackInSettings, openCompactSettings, openHostSection } from "./settings"; import { getServerId } from "./server-id"; import { connectNewWorkspaceDaemonClient } from "./new-workspace"; -const directory = path.resolve(__dirname, "../../../../../plugin-examples/settings"); type SettingsClient = Awaited>; export const test = base.extend<{ settingsPlugin: SettingsClient }>({ @@ -14,15 +13,17 @@ export const test = base.extend<{ settingsPlugin: SettingsClient }>({ void e2eWorker; const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); const previous = await client.getDaemonConfig(); + const example = await copyPluginExample("settings"); try { await client.patchDaemonConfig({ pluginsEnabled: true }); - await client.installDirectoryPlugin(directory); + await client.installDirectoryPlugin(example.directory); await provide(client); await page.screenshot({ path: testInfo.outputPath("settings.png") }); } finally { await client.removePlugin("settings-example"); await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }); await client.close(); + await example.cleanup(); } }, { auto: true }, diff --git a/packages/app/src/plugins/registry-requirements.test.ts b/packages/app/src/plugins/registry-requirements.test.ts new file mode 100644 index 0000000000..0efc1facb3 --- /dev/null +++ b/packages/app/src/plugins/registry-requirements.test.ts @@ -0,0 +1,86 @@ +import { afterEach, expect, it } from "vitest"; +import { createPaseoApi } from "@getpaseo/client"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { PluginRegistry } from "./registry"; + +const client = new DaemonClient({ url: "ws://unused.test", clientId: "plugin-requirements-test" }); +const registries: PluginRegistry[] = []; +function registry(version: string) { + let starts = 0; + let cleanups = 0; + const result = new PluginRegistry({ + version, + createRuntime() { + starts++; + return { + paseo: createPaseoApi(client), + rpc: async () => { + throw new Error("No RPC in this plugin"); + }, + openSettings() { + cleanups++; + }, + openSurface() {}, + openPanel() {}, + addComposerPill: () => () => {}, + }; + }, + }); + registries.push(result); + return { result, starts: () => starts, cleanups: () => cleanups }; +} +const clientBundle = + '(function() { return { default: function(client) { return () => client.openSettings("cleanup"); } }; })'; +afterEach(() => { + for (const value of registries.splice(0)) value.removeHost("host"); +}); + +it("checks the app version before creating a runtime or evaluating plugin code", () => { + const { result, starts } = registry("0.8.0"); + result.installCatalog( + "host", + [ + { + id: "example", + requirements: { paseo: ">=0.9.0" }, + clientBundle: "throw new Error('executed')", + }, + ], + { client }, + ); + expect(starts()).toBe(0); + expect(result.getSnapshot()).toEqual([]); + expect(result.getEvaluationError("host", "example")).toBe( + 'Plugin "example" requires Paseo >=0.9.0. Your app is 0.8.0. Use a compatible plugin version or update the app.', + ); +}); + +it("rejects catalogs without requirements from pre-0.8 daemons", () => { + const { result, starts } = registry("0.8.0"); + result.installCatalog("host", [{ id: "example", clientBundle }], { client }); + expect(starts()).toBe(0); + expect(result.getEvaluationError("host", "example")).toContain( + "https://paseo.sh/docs/plugins/v0.8/migration", + ); +}); + +it("unloads on a requirement-only edit and recovers after correction", () => { + const { result, starts, cleanups } = registry("0.8.0"); + const install = (paseo: string) => + result.installCatalog("host", [{ id: "example", clientBundle, requirements: { paseo } }], { + client, + }); + install("^0.8.0"); + expect(result.getSnapshot().map(({ id }) => id)).toEqual(["example"]); + install("^0.8.0"); + expect(starts()).toBe(1); + install(">=0.9.0"); + expect(cleanups()).toBe(1); + expect(starts()).toBe(1); + expect(result.getSnapshot()).toEqual([]); + expect(result.getEvaluationError("host", "example")).toContain("requires Paseo >=0.9.0"); + install(">=0.8.0"); + expect(starts()).toBe(2); + expect(result.getSnapshot().map(({ id }) => id)).toEqual(["example"]); + expect(result.getEvaluationError("host", "example")).toBeUndefined(); +}); diff --git a/packages/app/src/plugins/registry.test.ts b/packages/app/src/plugins/registry.test.ts index a0fc4c068f..99549c465f 100644 --- a/packages/app/src/plugins/registry.test.ts +++ b/packages/app/src/plugins/registry.test.ts @@ -1,3 +1,4 @@ +import appPackage from "../../package.json"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { pluginRegistry as registry } from "./registry"; @@ -25,7 +26,11 @@ const pluginRegistry = { catalog: Parameters[1], options: { replacePluginId?: string } = {}, ) { - return registry.installCatalog(serverId, catalog, { ...options, client: daemonClient }); + return registry.installCatalog( + serverId, + catalog.map((entry) => ({ ...entry, requirements: { paseo: `>=${appPackage.version}` } })), + { ...options, client: daemonClient }, + ); }, }; diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index 8f6981e9ee..f228c7202f 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -1,22 +1,28 @@ import { useMemo, useSyncExternalStore } from "react"; import { QueryClient } from "@tanstack/react-query"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements"; +import { resolveAppVersion } from "@/utils/app-version"; import { createPluginClientRuntime } from "./client-runtime"; import { runPluginClientBundle } from "./evaluate"; import type { InstalledPlugin } from "./types"; -interface CatalogPlugin { - id: string; - clientBundle: string; -} +type CatalogPlugin = Awaited>[number]; -class PluginRegistry { +export class PluginRegistry { private readonly byHost = new Map(); private readonly listeners = new Set<() => void>(); private snapshot: InstalledPlugin[] = []; private readonly disposed = new WeakSet(); private readonly evaluationErrors = new Map(); + constructor( + private readonly dependencies: { + version: string | null; + createRuntime: typeof createPluginClientRuntime; + }, + ) {} + subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener); return () => this.listeners.delete(listener); @@ -45,7 +51,8 @@ class PluginRegistry { (plugin) => plugin.id !== options.replacePluginId && plugin.id === entry.id && - plugin.clientBundle === entry.clientBundle, + plugin.clientBundle === entry.clientBundle && + plugin.requirements?.paseo === entry.requirements?.paseo, ); return existing ? [existing] : []; }); @@ -59,6 +66,7 @@ class PluginRegistry { const key = `${serverId}/${entry.id}`; try { if (!entry.clientBundle) return []; + assertPluginCompatibility({ ...entry, version: this.dependencies.version, runtime: "app" }); const existing = preserved.find( (plugin) => plugin.id === entry.id && plugin.clientBundle === entry.clientBundle, ); @@ -70,6 +78,7 @@ class PluginRegistry { id: entry.id, serverId, clientBundle: entry.clientBundle, + requirements: entry.requirements, queryClient: new QueryClient(), cleanup: () => undefined, surfaces: [], @@ -86,7 +95,7 @@ class PluginRegistry { const evaluated = runPluginClientBundle( entry.id, entry.clientBundle, - createPluginClientRuntime(installation, options.client), + this.dependencies.createRuntime(installation, options.client), () => this.publish(), ); Object.assign(installation, evaluated); @@ -149,7 +158,10 @@ class PluginRegistry { } } -export const pluginRegistry = new PluginRegistry(); +export const pluginRegistry = new PluginRegistry({ + version: resolveAppVersion(), + createRuntime: createPluginClientRuntime, +}); export function useInstalledPlugins(): InstalledPlugin[] { return useSyncExternalStore( diff --git a/packages/app/src/plugins/timeline/view.test.tsx b/packages/app/src/plugins/timeline/view.test.tsx index 5f8485b8c0..12f322921a 100644 --- a/packages/app/src/plugins/timeline/view.test.tsx +++ b/packages/app/src/plugins/timeline/view.test.tsx @@ -1,6 +1,7 @@ /** * @vitest-environment jsdom */ +import appPackage from "../../../package.json"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import React, { act } from "react"; import { createRoot } from "react-dom/client"; @@ -111,9 +112,13 @@ afterEach(() => { describe("PluginTimelineItemView", () => { it("validates and renders the matching plugin component", () => { - pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: bundle }], { - client: daemonClient, - }); + pluginRegistry.installCatalog( + "host-1", + [{ id: "reports", requirements: { paseo: `>=${appPackage.version}` }, clientBundle: bundle }], + { + client: daemonClient, + }, + ); const markup = renderToStaticMarkup( , @@ -125,9 +130,19 @@ describe("PluginTimelineItemView", () => { it("contains renderer crashes to one timeline item", async () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(console, "error").mockImplementation(() => undefined); - pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: failingBundle }], { - client: daemonClient, - }); + pluginRegistry.installCatalog( + "host-1", + [ + { + id: "reports", + requirements: { paseo: `>=${appPackage.version}` }, + clientBundle: failingBundle, + }, + ], + { + client: daemonClient, + }, + ); const container = document.createElement("div"); containers.push(container); document.body.appendChild(container); @@ -152,9 +167,19 @@ describe("PluginTimelineItemView", () => { it("recovers when a streaming item's data changes", async () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(console, "error").mockImplementation(() => undefined); - pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: recoveringBundle }], { - client: daemonClient, - }); + pluginRegistry.installCatalog( + "host-1", + [ + { + id: "reports", + requirements: { paseo: `>=${appPackage.version}` }, + clientBundle: recoveringBundle, + }, + ], + { + client: daemonClient, + }, + ); const container = document.createElement("div"); containers.push(container); document.body.appendChild(container); diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 9334520ed0..9247523d29 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -1,4 +1,5 @@ import type { QueryClient } from "@tanstack/react-query"; +import type { PluginRequirements } from "@getpaseo/protocol/messages"; import type { PluginAttachmentSourceContribution, PluginCommandCenterItemContribution, @@ -36,6 +37,7 @@ export interface EvaluatedPlugin { export interface InstalledPlugin extends EvaluatedPlugin { serverId: string; + requirements?: PluginRequirements; clientBundle: string; queryClient: QueryClient; } diff --git a/packages/app/src/plugins/workspace-panels/locations.test.ts b/packages/app/src/plugins/workspace-panels/locations.test.ts index d0e69478da..be92ee3c66 100644 --- a/packages/app/src/plugins/workspace-panels/locations.test.ts +++ b/packages/app/src/plugins/workspace-panels/locations.test.ts @@ -1,3 +1,4 @@ +import appPackage from "../../../package.json"; import { describe, expect, it, vi } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { pluginRegistry } from "../registry"; @@ -30,9 +31,13 @@ function install(locations: readonly ("workspace" | "explorer")[]) { return function() {}; }}; })`; - pluginRegistry.installCatalog("host-1", [{ id: "review", clientBundle: bundle }], { - client: {} as DaemonClient, - }); + pluginRegistry.installCatalog( + "host-1", + [{ id: "review", requirements: { paseo: `>=${appPackage.version}` }, clientBundle: bundle }], + { + client: {} as DaemonClient, + }, + ); return pluginRegistry.getSnapshot()[0]!; } diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts index 79db0e2878..ff01d2730a 100644 --- a/packages/cli/src/commands/plugin/scaffold.test.ts +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -45,14 +45,15 @@ describe("plugin scaffold", () => { expect(config.compilerOptions.lib).toEqual(["ES2023"]); expect(config.compilerOptions.types).toEqual(["react"]); await expect(typecheckPlugin(directory)).resolves.toBeUndefined(); - expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual( - { - id: "hello-plugin", - }, - ); const cliPackageJson = JSON.parse( await readFile(new URL("../../../package.json", import.meta.url), "utf8"), ) as { version: string }; + expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual( + { + id: "hello-plugin", + requirements: { paseo: `>=${cliPackageJson.version}` }, + }, + ); expect(JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"))).toEqual({ name: "hello-plugin", private: true, diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts index e57e316215..d525490f07 100644 --- a/packages/cli/src/commands/plugin/scaffold.ts +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -142,13 +142,14 @@ export async function scaffoldPluginDirectory( throw new Error(`Plugin directory must be empty: ${directory}`); } + const version = resolveCliVersion(); const packageJson = { name: id, private: true, version: "0.0.0", scripts: { typecheck: "tsc --noEmit" }, devDependencies: { - "@getpaseo/plugin": resolveCliVersion(), + "@getpaseo/plugin": version, "@tanstack/react-query": "^5.90.11", "@types/react": "~19.2.0", react: "19.1.0", @@ -158,7 +159,10 @@ export async function scaffoldPluginDirectory( }, }; const files = new Map([ - ["paseo-plugin.json", `${JSON.stringify({ id }, null, 2)}\n`], + [ + "paseo-plugin.json", + `${JSON.stringify({ id, requirements: { paseo: `>=${version}` } }, null, 2)}\n`, + ], ["package.json", `${JSON.stringify(packageJson, null, 2)}\n`], ["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`], ["index.client.tsx", CLIENT_ENTRY], diff --git a/packages/cli/src/output/render.test.ts b/packages/cli/src/output/render.test.ts new file mode 100644 index 0000000000..bc7dced1ca --- /dev/null +++ b/packages/cli/src/output/render.test.ts @@ -0,0 +1,16 @@ +import { expect, it } from "vitest"; +import { renderError, toCommandError } from "./index.js"; + +it("preserves the message of an Error with an RPC code in JSON output", () => { + const error = Object.assign(new Error("Plugin requires Paseo >=0.8.0. Your daemon is 0.7.2."), { + code: "handler_error", + requestId: "request-1", + }); + expect(JSON.parse(renderError(toCommandError(error), { format: "json" }))).toEqual({ + error: { + code: "handler_error", + requestId: "request-1", + message: "Plugin requires Paseo >=0.8.0. Your daemon is 0.7.2.", + }, + }); +}); diff --git a/packages/cli/src/output/render.ts b/packages/cli/src/output/render.ts index f3eb9f8aec..d21e53344a 100644 --- a/packages/cli/src/output/render.ts +++ b/packages/cli/src/output/render.ts @@ -50,7 +50,8 @@ export function render( /** Convert an unknown error to a CommandError */ export function toCommandError(error: unknown): CommandError { if (isCommandError(error)) { - return error; + // Error.message and class getters are not enumerable; materialize the output contract. + return { ...error, code: error.code, message: error.message, details: error.details }; } if (error instanceof Error) { diff --git a/packages/cli/tests/e2e/plugin-lifecycle.test.ts b/packages/cli/tests/e2e/plugin-lifecycle.test.ts index afc28c551c..4cb407a787 100644 --- a/packages/cli/tests/e2e/plugin-lifecycle.test.ts +++ b/packages/cli/tests/e2e/plugin-lifecycle.test.ts @@ -1,8 +1,10 @@ #!/usr/bin/env npx tsx +import { resolveCliVersion } from "../../src/version.js"; +import { readPluginManifest } from "../../../server/src/server/plugins/manifest.js"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -26,7 +28,16 @@ async function main(): Promise { const gitDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-cli-e2e-")); const context = await createE2ETestContext({ timeout: 45_000 }); try { - await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "cli-e2e" })); + const scaffold = path.join(context.workDir, "authored-plugin"); + const init = await context.paseo(["plugin", "init", scaffold, "--json"]); + assert.equal(init.exitCode, 0, init.stderr); + const manifestPath = path.join(scaffold, "paseo-plugin.json"); + const manifest = await readPluginManifest(scaffold); + assert.deepEqual(manifest.requirements, { paseo: `>=${resolveCliVersion()}` }); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "cli-e2e", requirements: { paseo: `>=${resolveCliVersion()}` } }), + ); await writeFile(path.join(directory, "index.server.ts"), pluginSource); const install = await context.paseo(["plugin", "install", directory, "--json"]); @@ -37,12 +48,30 @@ async function main(): Promise { await client.patchDaemonConfig({ pluginsEnabled: true }); await client.close(); + await writeFile( + manifestPath, + JSON.stringify({ ...manifest, requirements: { paseo: ">=999.0.0" } }), + ); + const incompatibleInstall = await context.paseo(["plugin", "install", scaffold, "--json"]); + assert.equal(incompatibleInstall.exitCode, 1); + assert.match(incompatibleInstall.stderr, /requires Paseo >=999.0.0/); + const afterRejection = await context.paseo(["plugin", "ls", "--json"]); + assert.equal(afterRejection.exitCode, 0, afterRejection.stderr); + assert.deepEqual( + JSON.parse(afterRejection.stdout).map((plugin: { id: string }) => plugin.id), + ["cli-e2e"], + ); + await writeFile(manifestPath, JSON.stringify(manifest)); + const scaffoldInstall = await context.paseo(["plugin", "install", scaffold, "--json"]); + assert.equal(scaffoldInstall.exitCode, 0, scaffoldInstall.stderr); + assert.equal(JSON.parse(scaffoldInstall.stdout).status, "running"); + await git(gitDirectory, ["init", "-b", "main"]); await git(gitDirectory, ["config", "user.name", "Paseo Tests"]); await git(gitDirectory, ["config", "user.email", "paseo@example.test"]); await writeFile( path.join(gitDirectory, "paseo-plugin.json"), - JSON.stringify({ id: "git-cli-e2e" }), + JSON.stringify({ id: "git-cli-e2e", requirements: { paseo: `>=${resolveCliVersion()}` } }), ); await writeFile(path.join(gitDirectory, "index.server.ts"), pluginSource); await git(gitDirectory, ["add", "-A"]); @@ -65,12 +94,52 @@ async function main(): Promise { await git(gitDirectory, ["commit", "-m", "update"]); const status = await context.paseo(["plugin", "status", "git-cli-e2e", "--json"]); assert.equal(status.exitCode, 0, status.stderr); - assert.equal(JSON.parse(status.stdout)[0].updateAvailable, true); + assert.equal(JSON.parse(status.stdout)[0].status, "running"); + assert.equal(JSON.parse(status.stdout)[0].commit, JSON.parse(gitInstall.stdout).commit); const update = await context.paseo(["plugin", "update", "git-cli-e2e", "--json"]); assert.equal(update.exitCode, 0, update.stderr); assert.equal(JSON.parse(update.stdout)[0].updated, true); + const installedCommit = JSON.parse(update.stdout)[0].currentCommit; + const buildMarker = path.join(context.workDir, "incompatible-build-ran"); + await writeFile( + path.join(gitDirectory, "paseo-plugin.json"), + JSON.stringify({ + id: "git-cli-e2e", + requirements: { paseo: ">=999.0.0" }, + build: [ + [ + process.execPath, + "-e", + 'require("node:fs").writeFileSync(process.argv[1], "ran")', + buildMarker, + ], + ], + }), + ); + await git(gitDirectory, ["add", "-A"]); + await git(gitDirectory, ["commit", "-m", "requires a future Paseo"]); + const incompatibleUpdate = await context.paseo(["plugin", "update", "git-cli-e2e", "--json"]); + assert.equal(incompatibleUpdate.exitCode, 1); + assert.match(incompatibleUpdate.stderr, /requires Paseo >=999.0.0/); + await assert.rejects(readFile(buildMarker), { code: "ENOENT" }); + const retained = await context.paseo(["plugin", "ls", "git-cli-e2e", "--json"]); + assert.equal(retained.exitCode, 0, retained.stderr); + assert.equal(JSON.parse(retained.stdout)[0].commit, installedCommit); + assert.equal(JSON.parse(retained.stdout)[0].status, "running"); + const incompatibleAdd = await context.paseo([ + "plugin", + "add", + pathToFileURL(gitDirectory).href, + "--id", + "future-plugin", + "--json", + ]); + assert.equal(incompatibleAdd.exitCode, 1); + assert.match(incompatibleAdd.stderr, /requires Paseo >=999.0.0/); + await assert.rejects(readFile(buildMarker), { code: "ENOENT" }); + const reload = await context.paseo(["plugin", "reload", "cli-e2e", "--json"]); assert.equal(reload.exitCode, 0, reload.stderr); assert.equal(JSON.parse(reload.stdout).status, "running"); @@ -87,6 +156,8 @@ async function main(): Promise { assert.equal(remove.exitCode, 0, remove.stderr); const removeGit = await context.paseo(["plugin", "remove", "git-cli-e2e", "--json"]); assert.equal(removeGit.exitCode, 0, removeGit.stderr); + const removeScaffold = await context.paseo(["plugin", "remove", "authored-plugin", "--json"]); + assert.equal(removeScaffold.exitCode, 0, removeScaffold.stderr); const list = await context.paseo(["plugin", "ls", "--json"]); assert.equal(list.exitCode, 0, list.stderr); assert.deepEqual(JSON.parse(list.stdout), []); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index eb0fcbf1a4..f67a4e7023 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -4918,7 +4918,7 @@ export class DaemonClient { }); } - async getPluginCatalog(): Promise> { + async getPluginCatalog() { const requestId = this.createRequestId(); const payload = await this.sendCorrelatedSessionRequest({ requestId, diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 676e5c5039..25cce3ad8e 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -31,10 +31,12 @@ "pretest": "npm run generate:validators" }, "dependencies": { + "semver": "^7.8.5", "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^20.9.0", + "@types/semver": "^7.8.0", "typescript": "^5.2.2", "vitest": "^4.1.6", "zod-aot": "0.20.4" diff --git a/packages/protocol/src/messages.plugins.test.ts b/packages/protocol/src/messages.plugins.test.ts index e5726b5081..e2be026cb0 100644 --- a/packages/protocol/src/messages.plugins.test.ts +++ b/packages/protocol/src/messages.plugins.test.ts @@ -7,6 +7,21 @@ import { } from "./messages.js"; describe("plugin protocol compatibility", () => { + it.each([undefined, { paseo: ">=0.8.0" }])( + "parses plugin catalogs with requirements %j", + (requirements) => { + const message = { + type: "plugin.catalog.get.response", + payload: { + requestId: "catalog", + plugins: [ + { id: "example", clientBundle: "bundle", ...(requirements ? { requirements } : {}) }, + ], + }, + }; + expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); + }, + ); it("parses plugin timeline append messages and advertises the capability", () => { expect( SessionInboundMessageSchema.parse({ diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 4f6415b8c9..3c8d09d5b9 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -192,6 +192,9 @@ const MutableRelayConfigSchema = z .passthrough(); export const PluginIdSchema = z.string().regex(/^[a-z][a-z0-9-]*$/); +// Semver validation belongs at the manifest/runtime boundary, not on the wire. +export const PluginRequirementsSchema = z.object({ paseo: z.string().optional() }); +export type PluginRequirements = z.infer; export const DirectoryPluginSourceSchema = z .object({ @@ -6262,6 +6265,7 @@ export const PluginCatalogGetResponseSchema = z.object({ z.object({ id: PluginIdSchema, clientBundle: z.string(), + requirements: PluginRequirementsSchema.optional(), }), ), }), diff --git a/packages/protocol/src/plugin-requirements.test.ts b/packages/protocol/src/plugin-requirements.test.ts new file mode 100644 index 0000000000..ec00f60694 --- /dev/null +++ b/packages/protocol/src/plugin-requirements.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { assertPluginCompatibility, validatePluginRequirements } from "./plugin-requirements.js"; + +describe("plugin requirements", () => { + it("rejects legacy manifests on the first breaking release with migration instructions", () => { + expect(() => + assertPluginCompatibility({ id: "legacy", version: "0.8.0", runtime: "daemon" }), + ).toThrow(/legacy.*<0\.8\.0.*0\.8\.0.*https:\/\/paseo.sh\/docs\/plugins\/v0.8\/migration/); + }); + + it.each([ + [undefined, "0.7.2"], + [">=0.8.0", "0.8.0"], + [">=0.8.0", "1.0.0"], + ["^0.8.0", "0.8.4"], + [">=0.8.0-beta.1", "0.8.0-beta.2"], + [">=0.8.0-beta.1", "0.8.0"], + ["^0.8.0 || ^0.9.0", "0.9.2+build.42"], + ])("accepts %s on %s", (paseo, version) => { + expect(() => + assertPluginCompatibility({ id: "test", requirements: { paseo }, version, runtime: "app" }), + ).not.toThrow(); + }); + + it.each([ + [undefined, "0.8.0-beta.1"], + [undefined, "0.9.0"], + [">=0.8.0", "0.7.2"], + ["^0.8.0", "0.9.0"], + [">=0.8.0", "0.8.0-beta.1"], + ])("rejects %s on %s", (paseo, version) => { + expect(() => + assertPluginCompatibility({ id: "test", requirements: { paseo }, version, runtime: "app" }), + ).toThrow(`Your app is ${version}`); + }); + + it.each(["", " ", "latest", ">=potato", "0.8.0 nonsense"])( + "rejects malformed range %s", + (paseo) => { + expect(() => validatePluginRequirements({ paseo })).toThrow("Invalid requirements.paseo"); + }, + ); + + it.each([null, "unknown"])("fails closed when the runtime version is %s", (version) => { + expect(() => + assertPluginCompatibility({ + id: "test", + requirements: { paseo: "*" }, + version, + runtime: "app", + }), + ).toThrow("Paseo app version is unknown"); + }); +}); diff --git a/packages/protocol/src/plugin-requirements.ts b/packages/protocol/src/plugin-requirements.ts new file mode 100644 index 0000000000..2e0a360099 --- /dev/null +++ b/packages/protocol/src/plugin-requirements.ts @@ -0,0 +1,39 @@ +import type { PluginRequirements } from "./messages.js"; +import valid from "semver/functions/valid.js"; +import validRange from "semver/ranges/valid.js"; +import satisfies from "semver/functions/satisfies.js"; + +export function validatePluginRequirements(requirements: PluginRequirements | undefined): void { + const range = requirements?.paseo; + if (range !== undefined && (!range.trim() || validRange(range) === null)) { + throw new Error( + `Invalid requirements.paseo: ${JSON.stringify(range)}. Use an npm semver range such as ">=0.8.0".`, + ); + } +} + +interface PluginCompatibilityInput { + id: string; + requirements?: PluginRequirements; + version: string | null; + runtime: "daemon" | "app"; +} + +export function assertPluginCompatibility(input: PluginCompatibilityInput): void { + validatePluginRequirements(input.requirements); + // COMPAT(plugin-requirements): added in v0.8.0; remove after 2027-03-07 once pre-0.8 plugins and catalogs are unsupported. + const range = input.requirements?.paseo ?? "<0.8.0"; + if (!input.version || !valid(input.version)) { + throw new Error( + `Cannot check plugin "${input.id}" requirements: Paseo ${input.runtime} version is unknown. Update the ${input.runtime}.`, + ); + } + if (satisfies(input.version, range)) return; + const action = + input.requirements?.paseo === undefined + ? "This plugin has no requirements.paseo and targets Paseo before 0.8. Ask its author to migrate it: https://paseo.sh/docs/plugins/v0.8/migration" + : `Use a compatible plugin version or update the ${input.runtime}.`; + throw new Error( + `Plugin "${input.id}" requires Paseo ${range}. Your ${input.runtime} is ${input.version}. ${action}`, + ); +} diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index 40ea69e863..697dfda8a7 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -1,3 +1,4 @@ +import { resolveDaemonVersion } from "./daemon-version.js"; import os from "node:os"; import http from "node:http"; import path from "node:path"; @@ -672,7 +673,10 @@ describe("paseo daemon bootstrap", () => { await mkdir(pluginDirectory); await writeFile( path.join(pluginDirectory, "paseo-plugin.json"), - JSON.stringify({ id: "startup-rollback" }), + JSON.stringify({ + id: "startup-rollback", + requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` }, + }), ); await writeFile( path.join(pluginDirectory, "index.server.ts"), diff --git a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts index f27e478bf4..7dfc4bc510 100644 --- a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts @@ -1,3 +1,4 @@ +import { resolveDaemonVersion } from "../daemon-version.js"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -114,7 +115,10 @@ test("plugin handlers operate terminals through their host-owned Paseo API", asy await mkdir(pluginDirectory); await writeFile( path.join(pluginDirectory, "paseo-plugin.json"), - JSON.stringify({ id: "terminal-sdk" }), + JSON.stringify({ + id: "terminal-sdk", + requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` }, + }), ); await writeFile( path.join(pluginDirectory, "index.server.ts"), diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index acfc9eb722..459fdc7de8 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -11,6 +11,7 @@ import { type PluginSourceUpdateItem, } from "@getpaseo/protocol/messages"; import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference"; +import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements"; import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest"; import type { DaemonConfigStore } from "../daemon-config-store.js"; import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-source.js"; @@ -23,7 +24,7 @@ import { readPluginProviderIcon } from "./provider-icon.js"; const BUILTIN_PROVIDER_ID_SET: ReadonlySet = new Set(BUILTIN_PROVIDER_IDS); interface PluginRuntimePort { - catalog(): Array<{ id: string; clientBundle: string }>; + catalog: PluginRuntime["catalog"]; invoke(pluginId: string, method: string, input: unknown): Promise; getLogs(pluginId: string): PluginLogEntry[]; clearLogs(pluginId: string): void; @@ -70,7 +71,7 @@ export class PluginService { constructor( logger: pino.Logger, private readonly configStore: DaemonConfigStore, - daemonVersion: string, + private readonly daemonVersion: string, private readonly dependencies: PluginServiceDependencies = {}, ) { this.logger = logger.child({ module: "plugin-service" }); @@ -166,7 +167,7 @@ export class PluginService { return this.runtime.getLogs(pluginId); } - catalog(): Array<{ id: string; clientBundle: string }> { + catalog(): ReturnType { return this.runtime.catalog(); } @@ -174,6 +175,7 @@ export class PluginService { return this.enqueue(async () => { const directory = path.resolve(input.path); const manifest = await readPluginManifest(directory); + assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); const pluginId = PluginIdSchema.parse(input.id ?? manifest.id); if (this.configStore.get().plugins?.[pluginId]) { throw new Error( @@ -225,6 +227,7 @@ export class PluginService { }); let pluginId: string; try { + await this.checkRequirements(candidate.directory); await runPluginBuild(candidate.directory, candidate.build, this.logger); pluginId = PluginIdSchema.parse(input.id ?? candidate.defaultId); if (this.configStore.get().plugins?.[pluginId]) { @@ -508,6 +511,11 @@ export class PluginService { this.errors.set(pluginId, error instanceof Error ? error.message : String(error)); } + private async checkRequirements(directory: string): Promise { + const manifest = await readPluginManifest(directory); + assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); + } + private async updateSource(pluginId: string): Promise { const managedSources = this.requireManagedSources(); const source = this.requireSource(pluginId); @@ -525,6 +533,7 @@ export class PluginService { } let candidate = prepared.candidate; try { + await this.checkRequirements(candidate.directory); await runPluginBuild(candidate.directory, candidate.build, this.logger); candidate = await managedSources.place(pluginId, candidate); await this.validateCandidate(candidate); diff --git a/packages/server/src/server/plugins/manifest.test.ts b/packages/server/src/server/plugins/manifest.test.ts index 07328c95b9..fa73c48b25 100644 --- a/packages/server/src/server/plugins/manifest.test.ts +++ b/packages/server/src/server/plugins/manifest.test.ts @@ -11,6 +11,27 @@ afterEach(async () => { }); describe("plugin manifest", () => { + it("reads and validates requirements before any plugin code runs", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + const manifest = path.join(directory, "paseo-plugin.json"); + await writeFile(manifest, JSON.stringify({ id: "example", requirements: { paseo: "^0.8.0" } })); + await expect(readPluginManifest(directory)).resolves.toEqual({ + id: "example", + requirements: { paseo: "^0.8.0" }, + }); + for (const requirements of [ + { paseo: "latest" }, + { paseo: "" }, + { paseo: 8 }, + { node: ">=20" }, + "0.8.0", + ]) { + await writeFile(manifest, JSON.stringify({ id: "example", requirements })); + await expect(readPluginManifest(directory)).rejects.toThrow(); + } + }); + it("accepts only non-empty argv arrays for build commands", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); directories.push(directory); diff --git a/packages/server/src/server/plugins/manifest.ts b/packages/server/src/server/plugins/manifest.ts index b721f395d3..b1021e21c0 100644 --- a/packages/server/src/server/plugins/manifest.ts +++ b/packages/server/src/server/plugins/manifest.ts @@ -1,7 +1,8 @@ import { readFile, stat } from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; -import { PluginIdSchema } from "@getpaseo/protocol/messages"; +import { PluginIdSchema, PluginRequirementsSchema } from "@getpaseo/protocol/messages"; +import { validatePluginRequirements } from "@getpaseo/protocol/plugin-requirements"; const MANIFEST_FILENAME = "paseo-plugin.json"; const PluginBuildCommandSchema = z @@ -10,6 +11,7 @@ const PluginBuildCommandSchema = z const PluginManifestSchema = z .object({ id: PluginIdSchema, + requirements: PluginRequirementsSchema.strict().optional(), build: z.array(PluginBuildCommandSchema).min(1).optional(), }) .strict(); @@ -20,5 +22,7 @@ export async function readPluginManifest(directory: string): Promise null); if (!info?.isFile()) throw new Error(`Plugin manifest is missing: ${manifestPath}`); - return PluginManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8"))); + const manifest = PluginManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8"))); + validatePluginRequirements(manifest.requirements); + return manifest; } diff --git a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts index ed28f04196..18e0ec3891 100644 --- a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts +++ b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts @@ -1,3 +1,4 @@ +import { resolveDaemonVersion } from "../daemon-version.js"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -18,7 +19,10 @@ test("plugin handlers create workspaces and agents through their Paseo API", asy roots.push(pluginDirectory, workspaceDirectory); await writeFile( path.join(pluginDirectory, "paseo-plugin.json"), - JSON.stringify({ id: "paseo-api" }), + JSON.stringify({ + id: "paseo-api", + requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` }, + }), ); await writeFile( path.join(pluginDirectory, "index.server.ts"), @@ -141,7 +145,10 @@ test("daemon config reload enables and disables configured plugins without resta roots.push(pluginDirectory, paseoHomeRoot); await writeFile( path.join(pluginDirectory, "paseo-plugin.json"), - JSON.stringify({ id: "reloadable-plugin" }), + JSON.stringify({ + id: "reloadable-plugin", + requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` }, + }), ); await writeFile( path.join(pluginDirectory, "index.server.ts"), diff --git a/packages/server/src/server/plugins/requirements.posix.test.ts b/packages/server/src/server/plugins/requirements.posix.test.ts new file mode 100644 index 0000000000..c0c6792bc5 --- /dev/null +++ b/packages/server/src/server/plugins/requirements.posix.test.ts @@ -0,0 +1,133 @@ +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import pino from "pino"; +import { afterEach, expect, it } from "vitest"; +import { DaemonConfigStore } from "../daemon-config-store.js"; +import { runGitCommand } from "../../utils/run-git-command.js"; +import { PluginService } from "./index.js"; +import { ManagedPluginSources } from "./managed-source.js"; + +const roots: string[] = []; +const services: PluginService[] = []; +async function directory() { + const root = await mkdtemp(path.join(tmpdir(), "plugin-requirements-")); + roots.push(root); + return root; +} +async function writePlugin(root: string, paseo?: string, build?: string[][]) { + await writeFile( + path.join(root, "paseo-plugin.json"), + JSON.stringify({ + id: "example", + requirements: paseo === undefined ? undefined : { paseo }, + build, + }), + ); + await writeFile( + path.join(root, "index.client.ts"), + "export default function contribute() { return () => {}; }", + ); +} +async function host(version = "0.8.0", pluginPath?: string) { + const home = await directory(); + const store = new DaemonConfigStore(home, { + mcp: { injectIntoAgents: true }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + pluginsEnabled: true, + plugins: pluginPath + ? { example: { source: "directory", path: pluginPath, enabled: true } } + : {}, + }); + const service = new PluginService(pino({ level: "silent" }), store, version, { + managedSources: new ManagedPluginSources(home), + }); + services.push(service); + await service.start(); + return { home, store, service }; +} +afterEach(async () => { + await Promise.all(services.splice(0).map((service) => service.stopAllPlugins())); + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +it("rejects incompatible installs without persisting them, then accepts the corrected manifest", async () => { + const root = await directory(); + await writePlugin(root, ">=0.9.0"); + const { service, store } = await host(); + await expect(service.installDirectory({ path: root })).rejects.toThrow("Your daemon is 0.8.0"); + expect(store.get().plugins).toEqual({}); + expect(service.catalog()).toEqual([]); + await writePlugin(root, "^0.8.0"); + await expect(service.installDirectory({ path: root })).resolves.toMatchObject({ + status: "running", + }); + expect(service.catalog()).toEqual([ + { id: "example", requirements: { paseo: "^0.8.0" }, clientBundle: expect.any(String) }, + ]); +}); + +it("marks pre-0.8 plugins failed on startup and recovers after migration and reload", async () => { + const root = await directory(); + await writePlugin(root); + const { service } = await host("0.8.0", root); + expect(service.listPlugins()).toEqual([ + expect.objectContaining({ + status: "failed", + error: expect.stringContaining("https://paseo.sh/docs/plugins/v0.8/migration"), + }), + ]); + await writePlugin(root, ">=0.8.0"); + await expect(service.reloadPlugin("example")).resolves.toMatchObject({ status: "running" }); + await writePlugin(root, ">=0.9.0"); + await expect(service.reloadPlugin("example")).rejects.toThrow("requires Paseo >=0.9.0"); + expect(service.catalog()).toEqual([]); +}); + +it("still requires entry migration when an old plugin adds a compatible requirement", async () => { + const root = await directory(); + await writePlugin(root, ">=0.8.0"); + await rm(path.join(root, "index.client.ts")); + await writeFile(path.join(root, "index.ts"), "export default () => () => {};"); + const { service } = await host(); + await expect(service.installDirectory({ path: root })).rejects.toThrow( + "https://paseo.sh/docs/plugins/v0.8/migration", + ); +}); + +it("rejects Git install and update before build commands, preserving the running revision", async () => { + const repository = await directory(); + await runGitCommand(["init", "-b", "main"], { cwd: repository }); + await runGitCommand(["config", "user.name", "Paseo Tests"], { cwd: repository }); + await runGitCommand(["config", "user.email", "tests@example.test"], { cwd: repository }); + const commit = async () => { + await runGitCommand(["add", "-A"], { cwd: repository }); + await runGitCommand(["commit", "-m", "plugin"], { cwd: repository }); + }; + await writePlugin(repository, ">=0.8.0"); + await commit(); + const { service, home } = await host(); + const source = pathToFileURL(repository).href; + const installed = await service.installSource({ source }); + const marker = path.join(home, "build-executed"); + await writePlugin(repository, ">=0.9.0", [ + [process.execPath, "-e", 'require("node:fs").writeFileSync(process.argv[1], "ran")', marker], + ]); + await commit(); + await expect(service.updateSources("example")).rejects.toThrow("requires Paseo >=0.9.0"); + expect(service.listPlugins()).toEqual([installed]); + expect(service.catalog()).toHaveLength(1); + expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]); + await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(service.installSource({ source, id: "second" })).rejects.toThrow( + "requires Paseo >=0.9.0", + ); + await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" }); + expect(service.listPlugins()).toEqual([installed]); +}); diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 415c0e5374..940ca26e4c 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -88,8 +88,9 @@ function createReloadChild( function createTestRuntime( dependencies: NonNullable[2]> = {}, logger = pino({ level: "silent" }), + version = "0.4.0", ): PluginRuntime { - return new PluginRuntime(logger, "0.4.0", { + return new PluginRuntime(logger, version, { ...dependencies, sessionHost: dependencies.sessionHost ?? { async attachPluginSocket(_pluginId, socket) { @@ -107,7 +108,7 @@ function createTestRuntime( status: "server_info", serverId: "plugin-test", hostname: "plugin-test", - version: "0.4.0", + version, features: {}, }, }, @@ -211,7 +212,7 @@ describe("PluginRuntime", () => { const directory = fileURLToPath( new URL("../../../../../plugin-examples/provider-direct/", import.meta.url), ); - const runtime = createTestRuntime(); + const runtime = createTestRuntime({}, undefined, "0.8.0"); await runtime.startPlugin(pluginId, directory); const [metadata] = runtime.getProviderRegistrations(pluginId); expect(metadata).toBeDefined(); @@ -1319,7 +1320,7 @@ export default function contribute(server: { registerProvider(provider: Provider path.dirname(fileURLToPath(import.meta.url)), "../../../../../plugin-examples/linear", ); - const runtime = createTestRuntime(); + const runtime = createTestRuntime({}, undefined, "0.8.0"); await runtime.startPlugin("linear", directory); diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index e487368501..516c320c4f 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -16,6 +16,8 @@ import { import type { PluginLogEntry } from "@getpaseo/protocol/messages"; import { compilePlugin } from "./compiler.js"; import { readPluginManifest } from "./manifest.js"; +import type { PluginRequirements } from "@getpaseo/protocol/messages"; +import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements"; import type { PluginProcessMessage, PluginProcessRequest, @@ -56,6 +58,7 @@ interface PendingInvocation { interface LoadedPlugin { id: string; + requirements: PluginRequirements | undefined; clientBundle: string; methods: ReadonlySet; providers: readonly PluginProviderMetadata[]; @@ -312,7 +315,8 @@ export class PluginRuntime { async validatePlugin(configuredPath: string): Promise { const directory = path.resolve(configuredPath); - await readPluginManifest(directory); + const manifest = await readPluginManifest(directory); + assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); const entryPaths = await resolveEntryPaths(directory); await compilePlugin(entryPaths); } @@ -326,9 +330,9 @@ export class PluginRuntime { return true; } - catalog(): Array<{ id: string; clientBundle: string }> { + catalog(): Array<{ id: string; clientBundle: string; requirements?: PluginRequirements }> { return [...this.plugins.values()] - .map(({ id, clientBundle }) => ({ id, clientBundle })) + .map(({ id, clientBundle, requirements }) => ({ id, clientBundle, requirements })) .sort((left, right) => left.id.localeCompare(right.id)); } @@ -473,7 +477,8 @@ export class PluginRuntime { configuredPath: string, ): Promise { const directory = path.resolve(configuredPath); - await readPluginManifest(directory); + const manifest = await readPluginManifest(directory); + assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); const entryPaths = await resolveEntryPaths(directory); const bundles = await compilePlugin(entryPaths); const serverBundle = bundles.serverBundle; @@ -481,6 +486,7 @@ export class PluginRuntime { return { id: pluginId, clientBundle: bundles.clientBundle ?? "", + requirements: manifest.requirements, methods: new Set(), providers: [], child: null, @@ -576,6 +582,7 @@ export class PluginRuntime { loaded = { id: pluginId, clientBundle: bundles.clientBundle ?? "", + requirements: manifest.requirements, methods: new Set(ready.methods), providers: ready.providers ?? [], child, diff --git a/packages/server/src/server/plugins/settings.e2e.test.ts b/packages/server/src/server/plugins/settings.e2e.test.ts index baff17d0c3..ce49b33876 100644 --- a/packages/server/src/server/plugins/settings.e2e.test.ts +++ b/packages/server/src/server/plugins/settings.e2e.test.ts @@ -1,3 +1,4 @@ +import { resolveDaemonVersion } from "../daemon-version.js"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -19,7 +20,10 @@ test("two clients share settings, observe changes, and preserve values through p try { await writeFile( path.join(directory, "paseo-plugin.json"), - JSON.stringify({ id: "settings-test" }), + JSON.stringify({ + id: "settings-test", + requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` }, + }), ); await writeFile( path.join(directory, "index.server.ts"), diff --git a/packages/server/src/server/test-utils/versioned-daemon.ts b/packages/server/src/server/test-utils/versioned-daemon.ts new file mode 100644 index 0000000000..e1b7433034 --- /dev/null +++ b/packages/server/src/server/test-utils/versioned-daemon.ts @@ -0,0 +1,16 @@ +// Browser compatibility tests run a real daemon in a separate Node runtime. +import { createTestPaseoDaemon } from "./paseo-daemon.js"; + +const daemon = await createTestPaseoDaemon({ + daemonVersion: process.argv[2], + pluginsEnabled: true, + mcpEnabled: false, + corsAllowedOrigins: ["*"], +}); +const response = await fetch(`http://127.0.0.1:${daemon.port}/api/status`); +const { serverId } = await response.json(); +process.send?.({ port: daemon.port, serverId }); +process.once("SIGTERM", async () => { + await daemon.close(); + process.exit(0); +}); diff --git a/plugin-examples/catppuccin/paseo-plugin.json b/plugin-examples/catppuccin/paseo-plugin.json index b7946e39e2..0ebf66ed4e 100644 --- a/plugin-examples/catppuccin/paseo-plugin.json +++ b/plugin-examples/catppuccin/paseo-plugin.json @@ -1,3 +1,6 @@ { - "id": "catppuccin" + "id": "catppuccin", + "requirements": { + "paseo": ">=0.8.0" + } } diff --git a/plugin-examples/inline-thinking/paseo-plugin.json b/plugin-examples/inline-thinking/paseo-plugin.json index e9d5e76ab1..eade794f02 100644 --- a/plugin-examples/inline-thinking/paseo-plugin.json +++ b/plugin-examples/inline-thinking/paseo-plugin.json @@ -1,3 +1,6 @@ { - "id": "inline-thinking" + "id": "inline-thinking", + "requirements": { + "paseo": ">=0.8.0" + } } diff --git a/plugin-examples/linear/paseo-plugin.json b/plugin-examples/linear/paseo-plugin.json index c8fa978d91..383edd9b25 100644 --- a/plugin-examples/linear/paseo-plugin.json +++ b/plugin-examples/linear/paseo-plugin.json @@ -1,3 +1,6 @@ { - "id": "linear" + "id": "linear", + "requirements": { + "paseo": ">=0.8.0" + } } diff --git a/plugin-examples/local-plugin/paseo-plugin.json b/plugin-examples/local-plugin/paseo-plugin.json index d1a1b37435..a53074de39 100644 --- a/plugin-examples/local-plugin/paseo-plugin.json +++ b/plugin-examples/local-plugin/paseo-plugin.json @@ -1,3 +1,6 @@ { - "id": "local-example" + "id": "local-example", + "requirements": { + "paseo": ">=0.8.0" + } } diff --git a/plugin-examples/modal-ui/paseo-plugin.json b/plugin-examples/modal-ui/paseo-plugin.json index b2dc64c17a..bb1d30898f 100644 --- a/plugin-examples/modal-ui/paseo-plugin.json +++ b/plugin-examples/modal-ui/paseo-plugin.json @@ -1,3 +1,6 @@ { - "id": "modal-ui-example" + "id": "modal-ui-example", + "requirements": { + "paseo": ">=0.8.0" + } } diff --git a/plugin-examples/provider-acp-transformer/paseo-plugin.json b/plugin-examples/provider-acp-transformer/paseo-plugin.json index 9ef350ba02..9c3f23a174 100644 --- a/plugin-examples/provider-acp-transformer/paseo-plugin.json +++ b/plugin-examples/provider-acp-transformer/paseo-plugin.json @@ -2,5 +2,8 @@ "id": "provider-acp-transformer-example", "name": "ACP provider transformer example", "version": "1.0.0", - "description": "Wraps an ACP command and normalizes one vendor file-edit shape." + "description": "Wraps an ACP command and normalizes one vendor file-edit shape.", + "requirements": { + "paseo": ">=0.8.0" + } } diff --git a/plugin-examples/provider-direct/paseo-plugin.json b/plugin-examples/provider-direct/paseo-plugin.json index e9af46c731..c4c6259d53 100644 --- a/plugin-examples/provider-direct/paseo-plugin.json +++ b/plugin-examples/provider-direct/paseo-plugin.json @@ -1,3 +1,6 @@ { - "id": "provider-direct-example" + "id": "provider-direct-example", + "requirements": { + "paseo": ">=0.8.0" + } } diff --git a/plugin-examples/settings/paseo-plugin.json b/plugin-examples/settings/paseo-plugin.json index a654e069da..4fae276fe8 100644 --- a/plugin-examples/settings/paseo-plugin.json +++ b/plugin-examples/settings/paseo-plugin.json @@ -1 +1,6 @@ -{ "id": "settings-example" } +{ + "id": "settings-example", + "requirements": { + "paseo": ">=0.8.0" + } +} diff --git a/plugin-examples/timeline-items/paseo-plugin.json b/plugin-examples/timeline-items/paseo-plugin.json index 8778b30780..bdcfe32470 100644 --- a/plugin-examples/timeline-items/paseo-plugin.json +++ b/plugin-examples/timeline-items/paseo-plugin.json @@ -1,3 +1,6 @@ { - "id": "pi-tasks-timeline" + "id": "pi-tasks-timeline", + "requirements": { + "paseo": ">=0.8.0" + } } diff --git a/public-docs/plugins/v0.8/index.md b/public-docs/plugins/v0.8/index.md index 7623a8b476..95571e2df8 100644 --- a/public-docs/plugins/v0.8/index.md +++ b/public-docs/plugins/v0.8/index.md @@ -49,7 +49,7 @@ greeting through an RPC. ```text workspace-plugin/ - paseo-plugin.json # { "id": "workspace-plugin" } + paseo-plugin.json # plugin ID and supported Paseo versions index.client.tsx # runs in the Paseo app index.server.ts # runs in a daemon subprocess client/greeting.tsx # the surface component diff --git a/public-docs/plugins/v0.8/migration.md b/public-docs/plugins/v0.8/migration.md index cad91d5537..a574958e02 100644 --- a/public-docs/plugins/v0.8/migration.md +++ b/public-docs/plugins/v0.8/migration.md @@ -102,6 +102,7 @@ its registration; that registration belongs in the client entry. | Compiler or load error | Meaning and fix | | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `This plugin has no requirements.paseo` | Complete the migration and declare the range in step 7. | | `This plugin was made for an older version of Paseo` | The directory still has only the old root entry. Create a runtime entry, move registrations, then delete the old file. | | `Plugin entry points are missing: expected index.client.ts or index.client.tsx and/or index.server.ts or index.server.tsx` | No supported entry exists. Add at least one exact filename. | | `server-only module cannot be imported into the plugin client bundle: ` | A client import reaches `server/`. Move the call behind an RPC and import its contract from `shared/`. | @@ -221,7 +222,26 @@ Import path changes inside the moved files: calls it directly and returns its cleanup. A plugin whose `addClientSide` callback also registered pills or subscriptions keeps that code; only the wrapper goes away. -## 7. Verify the migration +## 7. Declare the Paseo requirement + +After migrating the entries and imports, add the minimum runtime version to `paseo-plugin.json`: + +```json +{ + "id": "my-plugin", + "requirements": { "paseo": ">=0.8.0" } +} +``` + +Keep your existing ID and build commands. Missing `requirements.paseo` means `<0.8.0`, so Paseo 0.8 +rejects the plugin even if its files have been moved. Adding the field alone does not migrate the +code. Update the local `@getpaseo/plugin` development dependency to the version you target and +reinstall dependencies before typechecking. + +For a 0.8 beta, use its explicit version in both the SDK dependency and range, for example +`>=0.8.0-beta.1`. See [requirements](reference#requirements) for range and prerelease semantics. + +## 8. Verify the migration Run: diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index 167e574f72..d934679768 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -42,12 +42,45 @@ my-plugin/ tsconfig.json ``` -The required root manifest is `paseo-plugin.json`. It contains the default plugin ID: +The required root manifest is `paseo-plugin.json`. It contains the default plugin ID and supported Paseo versions: ```json -{ "id": "my-plugin" } +{ "id": "my-plugin", "requirements": { "paseo": ">=0.8.0" } } ``` +### Requirements + +`requirements` is an optional object. Its currently supported key, `paseo`, accepts an npm semver +range. An omitted `requirements.paseo` means `<0.8.0`: the plugin predates the first breaking +plugin release. Paseo 0.8 and later reject it with a link to the [migration guide](migration). +Empty strings, invalid ranges, and unknown manifest requirement keys are rejected. + +| Range | Compatible releases | +| ---------------- | ------------------------------------------------------------------- | +| `>=0.8.0` | 0.8.0 and later stable releases, including future breaking releases | +| `^0.8.0` | 0.8.x stable releases only | +| `>=0.8.3 <0.9.0` | 0.8.3 through the last 0.8 patch | +| `>=0.8.0-beta.1` | Betas from 0.8.0-beta.1, then 0.8.0 and later stable releases | + +Standard npm prerelease rules apply: `>=0.8.0` excludes `0.8.0-beta.1`. A prerelease must be +explicitly included for its major/minor/patch tuple; no version tags are stripped. + +`paseo plugin init` writes `>=` followed by the current CLI version and pins the matching SDK +for typechecking. Raise the minimum when adopting a newer API. Add an upper bound when a later +release is incompatible; a minimum alone does not promise protection from future breaking changes. + +The daemon checks its version before installing, running Git build commands, or loading a plugin, +and checks again on startup, enable, and reload. A rejected Git update keeps the installed revision. +Each connected app checks its own version before evaluating client code and shows incompatibility +in Settings → Plugins. A compatible daemon does not make an older app compatible. A plugin with no +client entry does not require the connected app to match. + +For example: `Plugin "review" requires Paseo >=0.8.0. Your daemon is 0.7.2.` Use a compatible plugin +revision or update the named runtime. Releases before 0.8 do not understand this manifest field +and cannot show this new diagnostic. + +### Runtime entries + | Entry | Runtime | Receives | Required | | ------------------ | --------------------- | --------------------- | ----------------------------------------------------------------- | | `index.client.tsx` | Paseo app, per client | `PluginClientContext` | When the plugin has any UI, callback, theme, or attachment source | @@ -1276,6 +1309,7 @@ step: ```json { "id": "review", + "requirements": { "paseo": ">=0.8.0" }, "build": [ ["npm", "ci"], ["npm", "run", "build"] diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index 53110b8103..69349fd90c 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -62,12 +62,19 @@ my-plugin/ shared/greeting.ts ``` -The manifest supplies the default install ID: +The manifest supplies the default install ID and supported Paseo versions: ```json -{ "id": "my-plugin" } +{ "id": "my-plugin", "requirements": { "paseo": ">=0.8.0" } } ``` +Keep `requirements.paseo` correct whenever creating or editing a plugin. `init` uses `>=` followed +by the CLI version. Raise the minimum when adopting newer APIs; add an upper bound when a later +Paseo release is incompatible. Use npm semver ranges and explicitly include beta versions when +targeting betas. Missing requirements mean `<0.8.0`; complete the 0.8 entry migration before adding +`>=0.8.0`. Verify compatibility with both the daemon and the app running client contributions. +See [requirements](https://paseo.sh/docs/plugins/v0.8/reference#requirements). + Each runtime has its own optional entry. A plugin must have at least one. Both entries accept `.ts` or `.tsx`; use `.tsx` when an entry imports components. From 8c89e72a06cc8f524fdb2c3f26e829a5d8e2a7a0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 7 Sep 2026 17:39:17 +0200 Subject: [PATCH 026/566] fix(app): restore coordinated bottom sheet gestures (#4431) Supplying a list controls scroll-container ownership, not whether content can move the sheet. Preserve the shared expand-scroll-dismiss interaction for model selectors and plugin lists, with native gesture regressions and updated plugin guidance. --- packages/app/e2e/mobile/modal-sheet/README.md | 47 ++++++++++++++++ .../modal-sheet/evidence/android-after.mp4 | Bin 0 -> 1046634 bytes .../modal-sheet/evidence/android-before.mp4 | Bin 0 -> 409125 bytes .../mobile/modal-sheet/evidence/browser.webm | Bin 0 -> 828763 bytes .../mobile/modal-sheet/gestures.android.ad | 52 ++++++++++++++++++ .../e2e/mobile/modal-sheet/model.android.ad | 22 ++++++++ .../src/components/adaptive-modal-sheet.tsx | 4 +- plugin-examples/modal-ui/README.md | 8 ++- public-docs/plugins/v0.8/reference.md | 8 ++- 9 files changed, 134 insertions(+), 7 deletions(-) create mode 100644 packages/app/e2e/mobile/modal-sheet/README.md create mode 100644 packages/app/e2e/mobile/modal-sheet/evidence/android-after.mp4 create mode 100644 packages/app/e2e/mobile/modal-sheet/evidence/android-before.mp4 create mode 100644 packages/app/e2e/mobile/modal-sheet/evidence/browser.webm create mode 100644 packages/app/e2e/mobile/modal-sheet/gestures.android.ad create mode 100644 packages/app/e2e/mobile/modal-sheet/model.android.ad diff --git a/packages/app/e2e/mobile/modal-sheet/README.md b/packages/app/e2e/mobile/modal-sheet/README.md new file mode 100644 index 0000000000..f72dc883eb --- /dev/null +++ b/packages/app/e2e/mobile/modal-sheet/README.md @@ -0,0 +1,47 @@ +# Native sheet gestures + +Use an Android emulator at 1080×1920, density 420, with the checkout's native app bundle loaded. +Connect the app to an isolated daemon, enable plugins, and install `plugin-examples/modal-ui` there. +Set `SHEET_QA_SERVER_ID` to that host's server ID, then run from the repository root: + +```sh +agent-device test packages/app/e2e/mobile/modal-sheet/gestures.android.ad \ + --env EXAMPLES_URL="paseo://h/${SHEET_QA_SERVER_ID}/plugin/modal-ui-example/surface/main" \ + --record-video --artifacts-dir .dev/sheet-qa/plugin + +agent-device test packages/app/e2e/mobile/modal-sheet/model.android.ad \ + --env FORM_URL="paseo://new?serverId=${SHEET_QA_SERVER_ID}" \ + --record-video --artifacts-dir .dev/sheet-qa/model +``` + +The plugin journey checks body dismissal, reopening, expansion and last-row reachability with SDK +ScrollView and FlatList, programmatic scrolling after expansion, downward list scrolling, and +horizontal tab selection. The model journey checks body dismissal and the nested sheet's return to +its parent without selecting a model or submitting a prompt. It does not assert catalog contents. + +These scripts live outside the default mobile suite because they require the installed example and +an explicit connected host. See [mobile testing](../../../../../docs/mobile-testing.md) for device setup. + +## Recorded verification + +Android API 35, 1080×1920 at density 420, development binary 0.7.2 with this checkout's JavaScript. +Plugin tests used an isolated daemon. Recordings show the same body drag +[before](evidence/android-before.mp4) and [after](evidence/android-after.mp4) the fix. +The [browser recording](evidence/browser.webm) covers the existing plugin modal journey at wide and +compact sizes. + +```text +Baseline dismissal regression: +failed at step 6: wait timed out for selector: label="Open ScrollView" +Current surface: Bottom sheet handle, Bottom Sheet, Close, Row 1. + +Fixed dismissal regression: 1 passed (6.47s) +Full plugin gesture journey: 1 passed (44.3s) +Model sheet dismissal/reopen journey: 1 passed (13.7s) +Browser plugin-modal-body.spec.ts: 1 passed (33.4s) +Root typecheck, lint and format: passed +``` + +The model form's catalog stayed on “Loading…” in the debug app even though the isolated daemon's +provider API returned models. Its sheet dismissal/reopen was exercised; populated model selection +was not. iOS and Electron were not exercised. diff --git a/packages/app/e2e/mobile/modal-sheet/evidence/android-after.mp4 b/packages/app/e2e/mobile/modal-sheet/evidence/android-after.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..d258f9da7fe66486ccfc0d8143d69f1530d01c15 GIT binary patch literal 1046634 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 z zV^P4EJyJNuE5^duLmTgB6)F){mBfyppSaR(nVsAK|fN1C4-;`U_ zBH*G0^hf;DUAAnFPgGWrqfrB}&hdFAlqnST0qCZF$*EYxp?l9(QI`OuzTmU{upz0; z%1p4+$O2s|td+r=bjn&|RRL^dpb4L+XMirmS)#}YXnuj1G67;+oI#67C>O5_fW`vQ zIg0|5QZGD#$|ebM@II6$JpCdNVdalR60BbQb5Drd#571j?0WwUAtTqfc&_C$GiQAnB z%mrTAi8*Gx>59N({cP>hh|J+yS(l7OWvOP6dQ@P~KR^ zpZkY7wT#iVyV=7fL~KrG_HRQACP#k{0F?p_Y=$#V10Yh?j3#(~d6eK1Ii6k|iCI@( zWq}u!!^6DUKCz4@PKCN5@F6aKSPnbB*0iqX((st`l}9zc5r_4@<2AIg2`!T=Z}+eW z1Cn_WmMT=lDbS?xl1Ck?z6BBp!?gPRT)PJ%PV=iIq*NsL6~hh{D_LtE_>PD*fa-xi zmB@MWix5C*Xt+BXD!ZV9NEd>jPQ${_yEX$F!H0t!%1=svS`nj{giAqfZXxP`!w*rr z_6uu(^iC7V4?ICAp8|(i>Op;^0PQs-fv7o2)Pqc0bDlI)vO)Tress9^aJK`bt~uTv zn8auDW0iFL{gMl^;$Se093-^1&CE$vvKDhRG%-oL2zAepOff;1eq*lhXNIRZh{+VLS|-S&7u^R&mHE{wW#pKgPcX+m|gW@bnw zYOQKT(Gb5J&ge*;*U?G9i)5*7WMp^)4sFDJ<|B0uZ!3b=!NV%8{lb8QYV%DM+&%52 zbMOTUz(Tn?zEa@z1+c^foV1g_=>*;F#7w9*aTmPuhZPz|-SBoV4jktbWMDgwixJ>& zQ>uI+8K@99>5NP==esO|{(6ZHthgT0v9%RwG+HfL`Xpx)+*Fk-Fc<3kopj?j`1eBj zAcl&nuJngP_#Z(+mgC|RMeZ9dXPBI+Ae=G`NirwCg?=i!?K09LB$37OKwN9b5ljPy zyipe64~l_60~G<@Uk(0%c~$|zv+d9#uS`V>U4NPdvCD`V02d?!X>^F;W;CgAU#-%Z zIyjTV^F|tRT3v9(1#NgHO-m+HFFYZ|GWqb8>*Px9VTO`mvnap<4c%J&3ZdSaIp^*b z5k*n3*W{EO-)_@21;BwPkS73Dxi9>yxD??;m#(Azz=jVkiWhH1KkMS&A<^L>H!SGP z<`4@y*tqXmYQl#vPdY9q3~|1Cv*|eg^vIhf<$+V2gN-|nElKBNvHue;}`~;Dll3HK7O`Rb@pT^qjocc@eq zwB0X2osjq4<`5_1NTs!R&eeko*l*q6Lx`{!f~CuB|8!V*Pb4Xq)g{$gD;!B#ksdh7a!L(W~4m5y7S#Mx!rl!7P9N+#2g#3$=5f@#qZzV zWYy}qeSIILk51fJAAZk#=SF_?eQvMVXPOCHF681IYi?Wn!EPi^j3Z`mvqnr5LS-8e zg>SV@m?^}MFFH%vDg2VJ?m`g?>mRtQY)X1LOi=8V`KW{q5vk^Isrd2T=SOV@=RtEQ z@#z#vl%V^!c6?3kOTSzBYY4kj6E{#@k9f$V22Q#z`GQrauXceLX3^7Xo`%->6b zVzG+NF|N#_%=>J%;vY`@ydqwGfO~3uc@3@4i{d|lT+8ur3=oer5fS>mxC;q!CpkGI zVUk*EzZdWIcc?Jc@uk?`gv#*`UqQBH#oGDJczLaOhp?9w*zCN&dda_I+4pKNRw3dMn*0Qh1vR$;A(>E-<2fnR$ck>h4*Ssd`jkEDc zl(mtcdzz8fvceNqwU^g|kdTw@%o*?GjiFDa(Mgf>=bdY2>NefU!NPyUD4Q5Qjwdtb z!VdFi_cU@7(dwu&H*v{Fi+1<{Lwi+|iVS|WrT&)INV#zT?umuxz?xl3*@?M?O*8eD z@i%{sUlv;bjrAgL9c0t<7VUzCzfI+?RCvdiNbSaJUxuE3B98X(htTJ zksW3yiyu^TpmorrSuR^sxp=ZXG9SW)F~?(+g@m|Dh!9F`X$u{a=SuPj%p;gK$?%od zUKw`b8tA4TTfan+i*&3{RZY#_0(o1SVDs9>tGpfA+kQrg3-T3dY0(j)B{e1YJ=ES* zWJ6az*-XvgONRZpAl<)>xJ`5y)4Ele>>YW$&omldY$Ke5L$Ve!QK<#hoK+TGwx$k* zog3F%BDELA?Sp`R#8y1FyV{f)Y>U2W&Ilq;Bn(@t1jYMFLTxT2WG0ZXwg0Y0%Pl6U zmYX*=uhCS-6I(>?FlSP$5Ex#ME10SM+kduu3shR4ay|SxZ2ysm>UWko6)r3gf=|b! zKP}sybh0^>X-%o!jZP!GN<6~lsX=jU_Ig>uNt;|d<0e^ zbhA4zboiwK(QC)1rtfx}9*9-DnnL?p6`4bc?>)+j+t;8(sFvi?sBKkmXR4hA6VN%2g)$b1n{+J}RWnsy@vLii+hNZ0@BfrB@1{H{tD_mK@xZ+p1pa zIF~mU4b2duLitv_1?B{`K{I#~_IiYjzBlsnPB$TwNCV1Q-xw&mw19w*pwz(QR zTBp44alq!i|mzx2TJC?m=YQ@qV*zK~dkz5|OL_&LJ zgGLGmgBcZR(g}HaPGci zDd^1<;W(@fy+Ykkl~7!iq-GlKplZGCGO%`XWYFbArnb^C)VheA=C?00J2mVk*G$stdprA625Zkc zTHadG61O$*=h-tw;-XGLous|hfbWJ?p3rpHsU}g&w~I>y)#tjGOZ1q~G}h7yO=;_8 zA&xzu$6m?*=Y1(=vsO4NeaF1swpS7BXd^r#du}kGd-p6 zkClp$T1UZE>lI`+z2`lCtqyvh^$TOKO}= zRrYD0iY>Tlk76Kn(APKW<+VGCJIM!5+lY5Vss|h!+o?+HeW)NmyUsG5!@`azmMC(0 z#N{Db^0z^Nwx0%9JX(}|}XAvUhKjx0<&a&!wUy9;}W{gBu6zLfQqH z&6WMUCyPrk5t)Nfq7xo@tRF$0JoRYU)!-aqo5O@xjst$cLAgp%deeSKZ|=}K618w| zcis<*1blktT%Q?|C2G{IPF(ESJ`dN0;t6&|2)ZdXCVrnFGr0yP$WUV3*mv(xy{QjV zP;Nvif~Jzy!7xtoxta=Txv*}o+MUx!wEN%!?p3WKdwWMwcI&(H#oMdPrV8zu zi(uLd$<-|4^{A^ z;uw=VyE#**YBco*=yOiCL1|cDl9Nq4RjZ)VzL~$bE8V8s7FvP2V{Xpg`}gZFtL)qD zzfq@u24vE$HT1Uuv#zztvlp`FRZ{9wkQ&1%sSBwKt^W!AZLR$e>elt8{4e!D|9n&T zuvPHkIQG@Q4^sW}$p8eX*uNq*`;6wtFAx2%LbHDdjQ&ZGn*aY#`v$*0{%^Sa&%*E@ z)}_B3H~$?6lY-sG*(`T0l^6SMl>ZKt4vE6>uh4WIrKjt$zyAtb|1RkN@8s+MqmO^t z8AgOC=ft1qTKUcvO zEeH0J|L^C2+sz3rc2X*XFn#?`_^J@G?7-#guh>IjJ(cD6MXQWxUxevqm2`HOQxfT1 z2C6}CUgM$3>5scE&=QQD7c%?_pw~ft-@ufz{_;A|E@h`e@`_)VW(e~CT6UX?(Vm{- zJGRu$k=BvovfRnW<;SfF-<~}_TgqOt`JI|fK-h|rK5|`FDIypm>Wa6&{f%M%cg#Y6 zIVAslFezZ2O8>UiRYz2gKm2gL6@(NOI0iEI-0{D{lRlK#_jXFrCt=ouEJ2*nNZqv=SABF*%B<|vA!CnFk-vog9ry zUcH%D-&;*gR)}D1o@uD;d@;f~*8E_c`Q>w@56x zFh))N^iw}?!<{d_Jwj`5KclU#`ao6aOw(x44kiMHU0vWcmvZ9?a zKTrN9u{2~id}}Mji=gs$ohU)&pGV=7f5+mNf=r6|o_Ra7P%3(c6i*3|fga-RheoD- zR_bmZ2LpNS`XpE1`iwiF{HPDAd6d?eGNqQ-Shf9=8^`;N6y*xLKN?Ks_M`0vB3mFm zNPTD4BfGIPxb*v0w2V0z&(@X9qM~2-!S4}RY*UqwarWn;I^tGLsC=~p=M+wNLJDVP zlXxu!Y|*(*Nhn!LjkHRbUo6Cy*Zax-oBYi>ve-Fu0FZxic0mO`JyRX*Gw5s zQE-Vu!kmx%Bx!S{Os^xtTL}OYrIu7-O7K1~mice>_E+p6>fk>izpm?0TA>zQFOWX< z9~6-MrYc1b{TY|6E06!Y9QQf1)J#2Zb5`T5|P2jr`Xds{gHa zNecNZ#Q{^KW?k$5f5Ap%{;vf2zqGmYXEQK4a&q52{*PwrrwYcuLsn@B-n5C+t~!4G zVouapt*W0G2l~g@U&yike(eu7X=;2bQ)+7R^T&qQ{x*^P&UYZzL$aw=$s<5Z+durG z_UCQDUzfPwsU9wA?B_~P4vP`;m6PO8Cc(Hz+@APhT^|$iXcYWJpIdA2Q?gQolZ|v` zOKZvB+}enRMDhZafb8lipT!^R21O3kjJB?IyroLJOvB z?aP@(D;tVe>z1sK>YfTE8V+VXmrh#pC2LucW0yHjN|jxX7n05Q z-{s^}V-rXnCt4znyv5{y* zb|+O5dEdw#&eRiVCI)aZr5e3PiMCMk^uZc)$bzkl>}~BNR9X_=%Yo72h;z=S&Q*!h&uc}teIxE+8Gz~i( zu-T@+J}u{#vxP~hk5NRkT6#^8%qtgs?bIBv!Hm#2I3{?hu;EjEp)B(f_D{X5)=C~w=PTK*Om2uH8uU?B8uO( zO7*(SS!=+R%2CZD`E)wZVf7|8+G)r&Jnma3*3}|`a$AG;5$@-wh8$1q-Mh=7tQUDa zTR>(z!83E{V=q^?hKWUK_quIP1ZPt1$(>uj7--A%7OUGLJ>hfl395O%kymZ<4OAnu zMqsnntx6^#$?{Wrgv(~z9J`IXA`$_un56&ym6>hm$$>G`+9ODGdwa)>Ig2+6GWrs> zPpr4EZC2lt)+DE7pY6Yy9@XhGg(1HdZ|(S+%Ni+09zsQHA4ivIgq< zJuBKu;=Lm3;w|24uJUuOiR>RpeGr+d6b0MBl-*}N(70QATbUM3IrTlf*n6FgM4^4T zerj_d8^<#v{`u&qTm7{^Z{X z%&8Q}u9G(0K(iddJ=YlwoLciddA*^5nmhChBo1g2;*%wXpuZMudUu*@JjDib*O9{} z%#qw=r5R>Z%&sxMR8Z3YVcv&8YhQVOmEwsO;+uzB^WfSW|pQ>dBN8?0i%mjqW>uSIbT`qrBw`Wcvjmx5=A@|{9O&?j|)I{i>lj_S|Q)-q=k%o}DoHtAC zhnn%8@)~D<`fXJ$(^9jXftkHq2u-!m{Z%3$cK27K`d^3( zec?U*hbH)~CH|FP);HY#3)4x)KpdX#@WHTE4-D}HU9{$x}EgjklgF5-&?AQKv zKK|Rx_2=;_tNr(ze*JH;v;QXI{_D2H|0SbX5ixOEW7%DNNz%%4UBURXJhAK>1yaM( zr`6|}0cmZM-6zAsLNBPwOiV3W+j+q8Z$sSu;Fp~9p;&{Xt=Hf)C2{w&$#EFl?(6QA zh%f^n$=w^?e$8dFc;TLX`vI?Y3wX#-3`3}5YZRYrK8|sn&EWmA(0|X$Qh%+g^&+LE z^Sb641g(C7P7c{ROa)|<7$|lH8MX;2ua@Vd$2Rfx23_Cjd+s+Z79?U8M8hR|muk)% zLCFDWPqMa3v{e;+yBe~(%=9^@Pv$U_m*wD?8#bcjOq|f3M|R#+sycCq&?rmE!?@h~ zZ=*^5whKCGS`Zs$?S11zj zx-r{t<*j9R5+MWF`V04XevKuwwtmAyUNwE)a><02mh9`RzDwxYI4-uwEkD&+Tzy#C zwM_HW>P8&=v4s#FZrrg{{=?6hdTj$O+|e{;&vk z5mXI8Aox45xJ-uY6o%KMjKQ;;uMX_Zka`^Vu8ZkYw))NS4J?~GRV@STMacUPfkl9% zkjmO#Ix^-=ZZ+Z7dykf>)i~MP)upFRJnTsn$*&n)gO3-H?nHmGIKzpCMSjrk}5+OTb1g7n!4t{t=+4Hcoz zOD!s+&ce@U$UabLPChaeO2@_E9)JTX>Wj{J`)S$YQUPOKHMKLtgjljwH)MS_?f4zf z{-V2+FezC|O3>b^GiNDv@V=G?PW(vY;o1~uHGE$e8NNhH%6t{-oS@c{ai(;+^BeBd z2D;#I3qK({U_t=`4?k$t{l;QE;W9!p2;|XgNqoI{6DBcm_! zavQ)kQDKR};%$3AZuy!U!`R3GB#sdn-KJ*W984;7V9MXX^%#52^@I@~Zg)x|l>?#7 zO>zGq=6lwtXWV4t=baQ`(b}G!uG--VOxuYvfu5ciK8 z+Qf_Hn?*@EC0eKq3A;n=1ya0EbLe~0ZXtNhP81u|$Xio2o|w%poFXllmE;P(ucxHF zI#uhyFW12yuOuBF@>WbxD{x+(-h51IS0sT9GJUBW+>&9okMk6e4`KZ~Nh$*!Ys~L$ zD?>>5jr)%}=vZm9FplNnEr-tC5$2g`^W&&{z;g(n<}_5`P^qn^ZMq@>W8Ax@x&U9C zeO0|3<=HDJAMmg}Xp=B2qCh??BK0W4c+eg`^Fgc}RLIm|_xI>DTce_V*=%N?CF|bKH^aD!(!`kIoM^APsCfNM`;jJKs}m(!eFf1OR#?1DH4EK) zorOext*|w}_1cv?u@AxKr16vLs}D09B_-P0A49==U}SVs9W|e>mrR zmgBTz#ac_dk2>89`dlj3cp>)9h9pJfb~;@V3Y0+sF*hr?hvCI}-sE}54)r+r6gl7% zcfiOvYDw8bHK{)fKzYk;EDEoZk*`lAZ{T@;Ud5bE9ck{$b`7L`f$2+>(urqrr<&ji zmc-R9v+T~rL~^CiMwMmvsUr#>V5*-u{QOuWgaJV+%oz6QY>K^Yu~uEtn#qy4Vk7BQ z;dCotksKIn02);)-wYJFQGb#3a;JD5;8D)X0Zl*6Y7mtFtrfQ9?bv zD3Lh)-Ex1c;^;>tBU9m`S;pJ_pMl9;x2+F8oDDiG=MEd%Oe#khhOA|t4)$d)GYCdW z2;?sX4VM1Nv)~xX&9`>vzxw7Vb3^YIWneOQyPImXz^W7iYP%SoeoIIzE7kPDaNW_js|`WQL?W9mKC#u z-2&XsXo;(}l(!Wm3&)so>g4l~SI0ldCHk)M& ztBFLZ4+*T?Dc^T@=HxxFN4;KaD1Vmfq{I-n-6wrh)G+3V{DHA9R{6(x4b3)seMAZA z=IjD9S<+lXTFJR9cynllc5%NE4ExpDth(h`o=lbTtHm+IEjou2Zu9XTA~YE?FdCr> z3n^|t@^uk`gH9xeVtiI83}LjOkE#CRHe<1*D_MRcaKq;B3L!pc_-vidlRnEmGD4f* z-?^ngRQ9xg;TUW%!5JA`!G+^WqCf%ON!61`RL0U3O1CYZ(K-E_GZRJ+}H?_Ql@7~bi{6`RjK<0ynhNUzB! zYUpJXlTeHt2~id->ZEUa`t+@*_bBCKx4A#y#2eiXKORLtp=%JVML!-OxaG_Y!e9IT za5Lz#zrU$B>A42dmt}gL(%#j)yCyffsbf&DxiM_x1O--kn_TeaR&N~R?l1cnqH2t3 zR;TijZYAuQI+36e(MM$2+4~2v!h7Plm7faZpB(!vS@ev=p1~HYsXp_SQPg!}f0ww% zAyRr(ujU&~OI;&^5Y44|nvS(2>pxDdus>+VOa$X!ACss2Qt(aER-fI-=bXCMR_GcF zw@im(kJyvhqah3UH+92o_{9J z_sqQW6e0CgnOOlw;J%g}n+zNWlNf%g!^q_3gnfrmqA}#st&7kO^F8=eU)S^-Tjyc1 zhk?O;;^y;B%b#x;^?ubQoH=X>NVf?uuGoHdWCvb7>QgeRjeG#RzKB>QE5AAK4(n6H zESbxDnDLvaYE1fRf=0@Gf_7K9&2{k+5|DTA1!aOBayQwYB-nP?r%9sW# zVOHfGu#BD=DtuEx7Y0@nbfUuPj{Ng}BsSSvHSU`j;O;Ber+jQU*CF-YvW@&^4#$$h`p_1%Zu<(ckI2V=P0 z7hj*+GlRF;Ux+VKdZsZ8*!sOKzLWVW{MFmV(6>X-ra?9$5@`Z580+lKn9xug;>%{ zKdOrr(8UF30bUNv4ZujOx9ue|L(Ix^ke55we~)bCy=n>UPU&;lRqbgX^;Kkx#kxe& z8*(3^qGme-p`b$uZ5ATmcgOg5;LWeUEm#z59p4y+9JV^F5^~$0{YIP~2{l&M6}oA- zAM#PrhhdwJtjiD0xN0ofV0S+DSTQUzp`EW}dm^t=ICtu%TJ|ckgH)UtF=-%qW%-!t zD+G59@p;>6_f&-NW0Hyj^y5K19IvvrNl^>S8C~sCi6*D zLe+*yyWT~}vYP(;{rnAnnKY9tTG;-~eP?20cO zu+#{Yz{D@SAu?i@S|Ww{Y0Cqig6f-gvZS-4PJWM@kq2Dl{p7n$G;;LZ=MXwcO^O?4 z;GknG=h_B;;mL_nw@U=T6m{XU}XOz=e)^)Y|;x2MhCvnx(@>#J(Y-0WLDJpP(xAx1M?wdvh9_)?9%I>ISTOk^}) zKDle6y`0@KsG!cCQJhC^64thIW9e1hBwWP_&&DAJehM@yelV{o(qJe~uxOpLcz?{(CEbyXF6xOTVAn@I-tGU$_;Y?iR|@ zO>r0RS;F99(hNC6WGa=$3d0|9b_ zRE4fbwMF9N->v7ulR`X%-YhWw5bO5;{eZ?()Hvdv=dboc+u$hZyCjF6){ynvm1ZAb z&v4@i<#*yWhAYycOoinzK8B3lq&Gw6-ybI)^5i~FO;_& zUu@WM2Pf|RwdG6aIk&5}!+c zuZ}xGTzkYK3+F`fw;N*4btS1hg>$@s(#eo;7HX5@+j?)OXnCBK3BH?hNci$p$!r3= zpRm<#R~Z_?%Md;*n_NJTR(PUNma;PYi%u8to3vNVo1R8X#BHo%Q2EV;cX99Sk3tfR zlgdGP7yMZQNG-*o`D@6@wk7Id>y^aor02Gxo+#M4#g1Q zxjZgWo6pAYrDkQdkdTrGNMqd5B0u6SEZrI|*E0-6FA11D-u4U$GTlg%VX3uFfV;6-C6%ul_4<>o@53KR74sJF{5^Y@J^`=qZMs_* z;tzj3r)jd8{CeY*49^DgWSYxL%5iosZM1QZl=~Ouji2PK)|%t6YTlo$ zshvPHDmO{}(m(Wz4$7jsmAmOw;t8!rvr~dw*08d4{}DvDp1SA--Wx=T*@VF_@D5Lz zf#LmB__9n>-o+<}V6sw|C6?rvr*^5u@GcEm#_&^0zRh1C3Jf$#q1atgSY6neBt0OZR;Cjfw6Z3@MF2LBK7+p zJZzIpWwdQpxP2{)Q01lQ6d!4gPsnh)LfFY(38aIu7|Em55iPRhmh$HE?mQ~+5N>;0yZNNo?w3A2 z3#pL-ryjCw>P_!V?dcGkY=Zk-3RL`9fpHHk{Xn!^fe}2qZaalf|67nbdn3M)%f8o%D01*-EtItot|0f)d9c+^ zbg^DJxBg0p-q2gOda1*YV?CTB-yD;hwt8K_nQy!y6_At9KMzJf>SpCVUHj@6HF<6A z7xm=Lx_d@-@o1ur5mPAfk|>5*h)A9sl#R_cPZRTHHffu4cQMcjEJVo2C(?;K*~cvt zu+dKtXE=Nw%r&#Io&_Xxv_5yw$ytwlT;`{l;E>^c6EB}6I{h*ywNZNcm#+^u%QzU_ zU>nY)YcIZBS*VhG{4!+WL0Z1!!Os6V^?$rIV#>5fm^-6WV@BRYw&-&>OvyC}++fd% zBF`poN0>sL^+a2Q;%nH|w^vKSVn@ET)$N3~^)smpg6oJu&oQx6Kq9v8xu)`xoW84SxE6-ISPen#9d(buf0@u#n1L;$CWe*teVMzj%xxHa3#?5!10Zs zro5n?X>=f!kj}Aqo}l#nu{cnIkz3w4Y*wDI&@*{s+5%~lc$F$7F{h4azD-^c!Nr?$ zeA3y-tz>;|ZGu+9=}xNAES5T<9rZcnl}G&9W2rDId@q?ykPB%!sv8=(&2mY{G>;2? z%s&5;oO-0|1mXi3Xdt#toR!f|kH_Z=TLt9led3?2_Lzrpa;xOF_M+_KA}7TFAft{V zD;>Ow(V5AN4uFtuOX5sW^w4oH8pn zZd!Aa4;VA|L=g8xPzzPzFaolD8F}y+YsVY6)94c$%>GzNnp3WWzhM zX4(ld4CD10hU(UlU9x1)%K9c8UE*XZm-Vg^U2;EMJF@6vhu8s-oH$B>O>4oUAecSE zrPY-RCv2-@;wQ;{gPc3qzEnh4((zZ;0&~29%#F!B85l z?{VP)tvz{Uu#xVI6m8&r z(u2+yzXd;(TIC6xNk6pz5_LQSK~0%|LBLH%(S&*SR(PcDE1zj?%SDXU7MV#v9|hEErG2L$dnL&VGk;(uo4i(he{~A-eN<3w9Wt^7t&G z?_)yvJuyM{x2#3J1J-H&BA34q-0hn}1h*vAVkIr;+xnek?xSzv8<|S5rRoo;ow(JU z(~cd_dT{uC0X<~%T5QS#lhr9a0kGkUpfQM|}mEW)A$zB}V>@7Q&KrN^f5)B-l!? zcRzYZ1u2^0Qb&X6caZb5XY;=*a0|tTlKnbfxD^x7T|xoRPHG_Er|Og5Z87Io9DgGE z_2r>+E5c%KJZZeaM>ewS5A3NHiPAi4oE`-U4fbbdFr;_{wOB^g;i45G;iQFZ44Tv0 z)(ZBL`1}){Wsnm)`Uq9pC}v zT|fowIFtZw;zl-3cAt{Wal_LNAnEYE^`H$Q54BVtL%71QzY&5QD+@9gA;_`tKd1hw zS_5uKSotFhN}Q2~v`_iJb0kU14_LQ7rks5IRTPu(Z68k*63hSk_P)U8et0v71=SiU zl``tS^0JbZ)-Hc+q6yJ531J4pmpF7n+L$Q0F#Z##DAmzL`pZy~<*>h#)9K;R9ym|V z9D=FZGhE6!wlSBTU5t~YapOV|L}M85+cL#8i-TnvUE-!b6Ul#MD1nqBEUF%7*qF zSX%IC;5<>Wuj^oYdV_`^jAbooGbHj6dzhvx`Rmfg+p>=ax1z zgqEK|e{+EeL?XGR#o3cxB>301*%!pvYKnceXWa%EgTOPU;AW*h z_B2%<-j>24Vf?e~YjTF4hPIr*m&lA#GHmdT#7%1TPBFt3RwxsZ-X$}4Ozy%$VEipe zLUcwxC?E$mnM-Ntdz7K5zV9MkTsDc)o9(|<)ACf^6f|T#8oIRn5~AJ-TvRhYlN z{7DcSKp+JmJkBOAwp;w;EHP>yim3Krg23rO2!3e)K}jlK0Nws2%Z%!D$bIUCUXAkj zjG}9F-13v1R3g~h4ci{GM``tQt&BRljP`c3njm3!DW#1(c`b^>Pem0Pc5wq94AY%E zJGN-8@jY*c+!H!yCx}e4CKF8SliXl>nh{iD2aiKsl~P}RTCuuCD*=Wh``5_lbon=b zeY_1Pq_A>8i6M(!>EiYrn2zVp4Pw?>Ny9pIyDm9u4qXwv;dAR5Yw!td>V?!*q1(}i zUOSbs)^my2fAdvSB*oSV>6q7#!u0?VG;?2efXVNn8NL3Z(^0Q(`8w!yuXc>BRjyL3 zy$x{h=WTCB;Aphjts@_rfMAYcw4td$12punmXUt64Y>zJ1PddB+mj85FvNO7Za1hQ@a;D}bga-rZZUcllV< zgB4iXUd$by9{0ri3asewmgjsA=IW^5QrL8S&JR;uZl*A@J90bwD-71Ja-2Q`|6UPA zZSnm)3UGp$o`<~d0>Tjh%@O0=or-O-rgptdY*UZF^5uS_ORsE)q1tjOtmAVX92OlX zjT6269grywr zZXD;5e_k7k>_{mqeIJBv5FTm&P6mphab=hbvFg#q*OxRV4h$yGol$e*x=({|KHW2# z`c`eIrO7Gvis%RpWhBS zB4!|x+oA1P1Gc^`lI0Eo;@>)`5E2*3gPSq&kzGs%L@h~DAC*8LJS>TDVidh1_h~=& zE4c@Oqrf=~u-g%i?ONAFxT5>0QjGI-L;w+++kLBli|=yhiYWt74|Abv*zex}bB}Fx zj+M`dr20`*gyRbjqF-Xqgbt?Z#UPdXXrD<;JBl0Z*oU&?$>~4W#cm$uL}njU#$P%} z5L%rZg^W+tlk|uqUI`kV(^5nv;O*LM3%n&&H?F?2(1P%pzjM338X+kFjLix|V)9OR zv*LkCI_3OBJ?T8vB9+XR$^;0f#_xEUq3SE4eMsJNszm;Re~##I`5DI#=nDX$T7!Q> z_?cM9`Bt{yTy~d{u`&oA2vggb{WZ>%su;oq#89sth!PR0I{L@_xH^^AFGWI89o&YY z{IPS@3OV10t#R2|d|y{n7*9_NRzRNe9Ri`J6KbG*&<31RE)&y7-SxNSsi)IHElO{0 z^!O7T5_drLiPb}!H>dZj;V#_HKU8q>ZQE@Uk#L&*S^E3a0uDIOm3MB8$CuR?NI9?d z9dv_@LE=*M<$-a=GCB(57+rP7s<2fjb1r ztiZ9ZniV=AXF^&dYI9++`p=vdFX{No02?3-MkF;LmH^idFqM<`+g47`}X% zEHC=}$fAHg-Knyqy3gyZ`pFEDLn~V*y$L`Tp%Q~s^=hq=kJ+2&lV#J@x|?g~j6IfF zqu;)~vVo3@MWXPSujO6ST8FU=R4lP-s~^4->9Qw|M2GcIrZS3bj0wtHceoX?jnO+< z9R)68jhW5}C$9kXLvs+JVlp5!gIBy?unFT-Ii#F)t8)Bp`>UtSLBthZz<0ig4DWLS za7s`PaOs1b4RBaH6{v~dY1`Tiws6yX`@h;ZMQqk_?-_kaj6B3Nah4f8( z+75v3BB4F2{GVZ>Y%ES~EJC{2UF_NS5p)yoex3-e-gYDELSo!S5a3Pu`p8j(IKmA@ z%wnnVLAS=3rm|`JFiN2PWcn%Ed3m*auLk!5Ef*Mh*MnF_4cw7RE53j7lA{sn%dx&a zAVFsYmKG)Ik~?M<7S6+7J{gtvc$_NFD`;)?^0KLM^!IasMSMTpvZ)XPY+jKpegEu) zke&IUO}~)6#!{2*q093K4x9?Nvl8KLCO32CBbjpl&MQOKVZBFI>|`pSPNFPJ%3xEQ z)De{jYJjpHr>JNTZ3Eew|8W#Eafy4&b5xWKFFcK1dUefMIw&OFBss76&Y0=fr5H|! zmU&a@p>T5`I*-PEt6qyalfg}ImF2!RRNpDz&g_z&=1vq1QEv17AfFJy9VhQk)l;gb zHpD($v@!lrG&vX?gPgZYilz!uO?x*)<#iq0VkN`=T1bT5E~io=T$hh&9Hm?7HoD0a zT|6{e{Tk?GgU~_I%E?PofpkF6e=FpK9y($o7#c+_0H)ikseN~!1)BAF4`DVh$35K^ zc^7lyoS(N}4<>Q#xm{1e%cS8`b`XRfxponxc^z33RoyRkb&Ws(DoJcA<8rXeH9-W1g>3tl-f@jA>0x(P>yv((^m5yDRwwi3lIon81lut zP@>4h)s7mdp;?#Jg4fPWH*W=W60L51Li_B;7(-|dlvBhDyELjp`Ric45imZCk_fh?4{XB#6*GCQBM3h~Z9_C=_XJ6*O!~7= zwqbXs>(T0G05)B$@91jYX!?TPMxtvj7DB_RBXpseBPm7kVKZ^d9s#sZFpbT*Q<+UC zeLRxl$0N@60iUyuPaq`R!WZi$~u(s+=4GPtwsz~mHnLMSWBz6cM!QZ(O z^0@ER(0ueVPnc2H&aHZvZ>M>sj07bgpS%uZB96jCQ9Fwm?k9Jtuo38+R`*3(uiqS6 zWKXhVWrnrF_J5Av&Ss};YEiA)KZ`L%LdnKFyP^%bCUNH|5K7sULKgO%YU0|%GJumH zVT9GN8$^y)PU3p`3Dt-BpJow4@YN+ShYmiPXs8*|%cX1v_YHh!Kq&1a)WUU3fnav& zh`_rei9GBTVj@72Q)JY!UI(hllxt^QA*O&74;L9i9tKFBTtzlv$3@;=WuQ`sYOeJY zj08p$YE{IAv@1?-pONBmx7D-+2EBK*p@Q~apIv0pL_4O>@8(@|k$*P+Jp~YO=U!ju zZ13EYfVqOOXLkeGUPe zmEoeDzmA<=l0I!Y@ux0$S&sj0Gj}a#wpD(t`Tox6A6r=drMlW{wW@!o=F2}k`=|eo zwyeM0<40|k@80cuXLIYn=Y9X-9r*7G|GP-x-vsb?Sn|I`ApC!!XPQ+D$#VK5Em_lV zw&Igghq-svTsp5gOF360UElCD(^<;o_SC17(02~4rPRTHvE=poE2)oMh-mf6Y~!kj z{#=V!oPRf>!!x}jDyz+iHK294VWhC(a5Ur0^+(s4+jjN+i5I)U+VnOVYhL7Y> zx_GaTqP8*ru7o=W%cG@>uI^v%x#Y5P^yT?ua{1e@xdL|b9(y?oxG&8v- zUA1TI`k5fsH-on4Tm5c)3Yvc3cw2P#%a_En-ThtqQI0{Zm5Ji4y>=_dGdB#4*GmLO zCtY)I%~~j~vQO2>HPk%APP=-vR=aoWikr8FVl-yQ)$Qd+uM{pVh>d#sWs6$ilbq0Mi92bsNyc;WUoN9e_NO$3d=f*Elaf^Kmr|fgD2Vz-wgkDys4wPu@ zz4X9*1mC1?x7j1b|J$-LheToPM5oB9t-Eqn<qF9%;A)(MqnTKB^jN6S}ly z*Xw(_a3HR0PvOUS*@Z}N_4-~p|*U{2ayIBL7Xxa4jm6PK!3SWSDGu zvun%$ySV=D{ynojLwU&wQ;U>YXK}$mEx7(w>%3^=#fa7w!ulv5I@JC2XWafT(P?SK zVOC3-Ms6SXKA(8ibEZqFJwM}9zjo&1pQVoZ;8&R!>`~)>Q;V9ZJrh%nC+-BU2@I6z z1hI;tKC_?b%stU!uy4~4^J;nYii&ejqvpxd;X}IDB1Qauwr@DS?<4J8ksqmOXxd6O zj#4~dVG>HIk2^)zNzPG>bt^+_QCDf8=c|zxM6hHIAP68q~O=f=_KI8q?Wx-a(zz)@Gnv)O$!`mYr6UA^0ZD z^pkIG@_qcY`s>;GDZS%HBpm)Ik7MUi%*PvYv?D8aY1&1NjY>g`&zAbEt;T=#yJh$& z|6a$#z9zvqnFnt_gZ5MfdKQ5z=w$oa8CY2BSCS@WX@ihC0j zPW!ECjqG|xaB5XfH)PkU}yihMxUW-CcW<2i}Ymx;%Om( z#3@lh%x5UX!iZk77`OW_Tuk))#{yu|i@Fd)N9)m>ZpO+!?rDz~8e_*0n*^{)o~Bdy z$zUK^eFa_q1iYC=@02T4;)CKd(EcHK{E+Jis=wd`-=k(I!o$rsqR5=!hr>PN4&!}6~&%LNt<;~O3@|_W0`ln`* z&Az_sP8OZ-Vn2ST!#Mn1pIx>C+Yk=tU>1IdzkViv5H|{6eBDO|re($%MA9_@xqczo z=^%v4x5z=_aXvA9c0@mz{*MMdb$I<+XoJD8H*O})Bj=^LTrZX3*p)9DObzDxmCC4QYeKhDLs-JsnT=Vg{ z_xS7h$IFj>M#g`>w$o?Thmkn6EIvg9g~V%l1`e3q3*KR{_=s#NavAn#!~QP0vY(bj z61RGvXqDqgrCvzYGwb@jAso`(dsnZK<(*!Hv6ZZNrLngLy>B0cYv(SQw%qbGual2m z&@w5r!1L)5T@_*y40%}+k-SEn(umBMKKe8xjH&l8KXDNgy^4gOXVp21Rq|rlWm&tp zQ1plK#%nZ_V0k>kK?s5ZL6v2B^60s;=j26bCZRf$j{m;wKkE=AGD3!>nTux0#Lflv z!!Od^SeR0n_PWThlSVSZ`fg2ZN(vPaqW~OE5tH0@N+GA6is%m5jJbZO*?H>nm>W*PEW921Z!fjZvk*FWjfkcZ9K z(^}j;&f+Nw_JNf=6VJBdGs(3}F@|YY$hm`*^>ZL9QM@kS5IEb^^E%AMT+2H?Sn#GEy6 z=P%OK#D-WHo~ehuo>UNrBnhDM1G28Pau#rxV8ZRBj<*F z=={dtC=$)oP|1M`gkO0i9kyHfc;*WM;KZ#XsmJT|#Fh)gSHDF!mSgx60VUVAjUp8# z%^^JuKjTlM4vtXy2}jAC2ha+hCO8gi<})Lu;Fe3>rJoy&ko698(n7!dI|dMlJbZZ@1+I%6FD$xEH90jX8nF z>i2x=&b=H$!Fjs~4?3i)h`exg2Yt^NnR4`d8sOaTwPo$(D>CdIXYf@WaPjL0c}3f| zqQbAf_1XFmbrEd7ibgMTziALzu=po+cNY*)dhYb#EW{*yt zOg0nyNmdRGFuf0+mI2~6D%{DpGj%eaEf1SdTqqS-JE8F=st(}f8?4{}R(BjOj4J{v zyDSm%JRYng<{Us1-&_`F`-tR9hQ&WRh}$>e!(NknD?8gZML3K1`S#&D;w{5V#OdKgvkiIUih@dRX*EC+$!NGx7NTRqn z8sb(u{~HMzyuASYk~IKCX!|&V2)z1T0TKit5r+jtWoF)RbufjCeWBw~4GLc-WP0&%+QCJPwaa|_S?eBac}bCVU^BLWI)Kzw#=;^@_TDc3)V zAQIi#U&^NAT(wTo>DXP2&3I>fQ8c>xWaS+ePfSVb4w~u9g{Lev!1WA~MB1Np(;JdH z?GP7nQ^`yYn}pryH-3zSXWPE)59Fi6b1kS~T!R9m9`J*@5kCgtKv9likTnXN@fQ+! zov)JtksID~Z#a~Dd(}0oFvp)9srEsf9R}JWe96i$Y3;O5Di5DurMgn9<3(aGTIy8jc?|osI3%zxFS)X z2oowHt8V^Y4Ljig^Z5*|i7EoF)5B!j!&C+?%?+l>`Ed(CG>(QKmu;yLeByL+ydlzT zjgd(Mw($9a6p-OvOhT0m6>@Pc+*vKvCZ{R@eB%s)(iPzSs!Q(nkhZU;_}7pbPC zF0@e9DJNRx29XFJ52`TiA)7ip+Y9hiRk$wy@h@t0_$Y6=S1`WFhX6!CQB~=82qGG_1Q2Oc)4@Q0t3-~JaFv?MUFVX5Anx4{lr~_0o~vJb z1|Q+@rEu||sXXpS*m|s(E;1H?;OwD;G%6++!FDKbu3aQn;@(5pYWd09!Z~fQ&s&J)$(R)f`CV ziBdYsyE+m%Ulj1J8(C05BtTICwIKk63m;O;GCS~I%NL>EZ)gq)c!=AnXo4MIw<0NJ z3Oy^)iKb9C&=7(rdgpwdWiBVOo9igynkrf}TWne)ADeRrRO8ua5c31jPs<;zU;#L) z3`Q0c^)It`U3UEJqNSy(t6z|Z&ht@|-{g=6i}h>%aDTGX`R3WxW;!4$$rnIcg^pl^ zWv9gh1CJVc81ZG9)o?QtO01zXCpXQedh)fQNwmj{6&ha8=w!D8Kih|?Hb})Qt3Qc{ zwhw>gW3O0JMY*{!hJrKOn}a=7O{zCPjv;VHQB4AqdSdc-R~nfPzk_n-$%9osB8$7B zVU5=3Dj1Po<^d}X9Z>r;9A3t|3Oa{`L-*`t$MQ88#tWaK0Dj^wRGoI5zCwxbFej18 zom008?{gMJd^!n>H_cW>WV8EB1z)qtBHR}-R^gXl3sm+>wZ$7l@(GPowlyaw3OP9I z6C4aP4B^hC++Nu$Thy{uGyx>2xS`%U+{<0p9|~|B9FT3>Lcy+}xe`puHi110q#=$_ zQ&WRR$Fs!JXeM`__RL@9Q$Q1peig{vVDfdUh`MLX;ht2y`ffH090h`&6ttoJala-P zAvz3d2R8>sk!6>ZI^&a_oz2hBUO|J=ZS#*ENhGl+@X+VGg5z+;Yp`&c#=JfYAqm*t zW|2s&5vmkE5);Um$;$sIIJN!bHIpxhpqa9TYhVu}k>Lx?;0cl3MjXUqABCUdT2@j(HqFNyo zDh?w7oG5_P0og-Klm&$l0csE69N&eot{FXu-L=AAeI-P{ALs;deBu@Xh*{_kr~2X< z=rSCX-wkib)iX-nuDhIkgA4L|`Hcsc@HeZ`tzmitR?pF5jMLS3O{U>-`w z-rY?ha0*~=<~XjAwBoIGB`8RWyvO(5(=uIvWOyy(3DOn^+npN#$Qwd9Zv}E7&fl*z zm!9vsbK{%+5WxawSYenYkQCX(>9tSbtQ#o9HF+QuM?PeMhKhLl-p>(mvT75ihTH1J zJ+7ZX4pH5kC|*FiwP46_y`PvpYV>rrDMV+Xm<0R-_){cr#GqBrNzc>~!W;IV>fI4x z#7|WUr-mNa_KcP7I(ugnBIMX^Tvyi@F)QFzAXDZZ7qJ2Oo*bQ(cNv%1Oqj@VU!i`UythrXlH2H7^B%+IjdZ9iiz93;fi2u9NOJ6 zG-O)dl_48kA+nJ4Fy38umH=3OQhN=Z<=$O{OdoKOs>HYA1-#|X zgYY20!}IW1uN>m2r!vU85K1(lKfya}17#u(sKE1X6Vnh~0_;;6D32L`Do~D+#3Cxk{+R z+De0{?IL}ae);Tr;A?)jur|grffl&h%t1Q%IztXd z04cx=F&U9sc=Knc-|RbcK*XWz{W@Y|!cL?L!l&5KL~U@pVTB2gZdG!e-K{K0R+0gc z1W=v~Mbj+oHGpT}Ftu_WawE9Gipiw#UL&$$6C@RXp;DqR$%sU|Z1AkieM#jdAg-#p>+-?+%J}VE?eI@Rj$b>;k7>6*mBw^}r)@MAmEsC?%YKaBN!ZL{2B>niF z(Rpu;02n;xIgq{u2}jXnnOt*Ny#VGB!m`rA>Il&{tdKjSLKkNvGQ^{GP-23Fv3mRU zrYgip&jZRJhh@!iAmNR2+LW;9${SA9>NZQRTu}VvOw{Y28RyV}sQ;q3`cc zKvH0Ritlp`a6lsB^%5CSd2-2N7fBSv@i+>MuJl$9z&R~nrYqRk1f+>5AIg77#l&cT zU&;m&#{mp6UuFzYy6#A=S-GB!aK@bo_AqtSG$$CK_ldg(lP)*7j+LRUbj+Q`*I z3Xpv%6-(`~+>7Q9$2F484uOJf_LKEoQx`9(0mLo!LA}-Y?wA*ha6c3vVcVAHkYr7` z#+eV0e}iN3O4ug?bR}_A&3*-aXKE1HAea*J_?!03P#B!cNk{db9XCyx#)YP|g zg5T-ms21Gh+ab{<@;WKVI?MhTQEt{T!C=MgFoBBL`i7%Q92d&1ha#__oy?fTH!Huo zWlbP|PU*;cstZFavGx5(kTVA&DFT2~V-nRRnh&EYf-;TF?`t!_f+!E|>h2Fhj)x(^ z@~b|$UHZEv@*?psir|AOn+k{yP^ZQaALqpE|Kw$#ILV1XOF^>>1{!$}+N6qk>jsAh z@B!g6t#HDYB!Ni216o8>74*iCO28B|G3^e;_gOVEok}W-65-Mx8Hd3jpWq&_j2bfT zgr1Lv!mm?H{=?8|p;m<9ZVzQwdfp$+452P10J?O@JV$A$NV#KE4es&)kze%P6X+H} zvLu*z&EPiLugCyU8{Gf}MY9>0$Q2OHX^w;&%i;gp_rpR5qKk@EJr8rKkH&4fObu3A zyUk{zhO;<00_>sec0|ipj-x;jKEAJ$%KsepkouY@t}d(WJAX4_^YJXYisYvaV3D=% z@rEJm$uP2bL$+Z$@~UQGdi_x8N}x%>CAl#3+ACwex4&CfEX)ZTHD_t|n0~W$DDG$F z6iAeHr$k`|^^l)5W#G2-UDW3nqj156kXs-p+fBsi*Lp!2D68{Gth}A}ZvKULq7mXO z3m72Hns@GUq9fh4c9zB2?c{I|{#RN$*!{g+h8War#ZkiLVnU-as;uGb=HROT9iO>`ms* z@<+)nQ4`=zA+l6;7b;*zy#=qQ5chs{CNVx3$J6DwMSA(8HoVUYF1`GU zV-{%dz)8!b?ArPDy%6AkJr#rEr$afhVfuvrxiDa=$B>AAW^RE4!s)z@`5;((d;A0> zj+KBOud)?yz{aMzCM9P_p|4Jy>hk<&tRU3`gc}lJ^18^xEulh?|s_1JA_D~keHC5q6!#;mpPNH^Lz3ij|a*Wm&ZlO^juP_#sbUV6; zPDbgGo5&s0YbNDMaKuahFAM!b#cS3VvhJf$@k^IAr*iFAEEG8x(c)e`ZtG!_#dHgq*mUc5Z(nZ$oNytZ{coUvy%x17x2`3*7U zFaP`P5?8#0`0wz}>$sw+wR%7F#Urf%6omBI_xJnqS1{*WxttsQB%$1ov{TNs_zNTY;TnB!y*}WN_Q~Pg-@>EE*T(o~@QG-(*EVbL_DUWnK(W z--GP2nY(*7?c`CVGMR}2V|D|CE}|4KJ6(vaO0p|-#HYKQ!wm4UA&9A8kJnQ_tNsF9 z`U}B8eOhbq4blI~BCD0qX5G|xC|^LnPi?w+^rL3*zhg zUp?L5^3@;3Y~Sv;b%%$zS1d}{~fdEN80|4Mr-<0gtgWgbR8oWdWzb>neyFYm*U5~4y&s} z!w(@99S-5P-k8yG7k~FGlo}qQ5|4K(oA-7JLJ+OD&iGADr!y~DY9HC;Ug&v7-MjLx zp|`ien$T~Ux2cypPDDT>(cBDCuU#jD9HimQFt=){^9~D}QRGZO8wt^;m<#*u{ONJF zHBXCL&t^PrXFpY3lZKGCL@fpgw#*tcW|bI^`O~Z0?G|gs_vwui<;*aa$2-s4LqNAZ zJu|BHZ_fczc!XAR0DypkJnZ7p^M7(3!VsORjoWv{VWE+>8$)W4MeBFl={tDN85 z!v7*jEA05z)0vuz>Dcp~<8fVNL8$`%Md&Ag^w@Pg+uv}i){CQ28h^ev{#AmpKYERS zU-A!xPyKf%|Jk_oA4J}Ue=&Qu!;N!4HQ*!G^{(FL-|<=%|8xTC|MJy;voh9!Zhk#T zR9U5dK4IQ3ZWruPRg?WBb4mZYR3?9Mu5x}-hRhX{ zkI=xZ3j8mSV*l2>|4TLRAL-TH&ib&U&d^VXlJ~jDC#>iSdn!eGE*DywTb6&q0b5Jn z45R-0L>1&?kMvjdRwpbP3+| z(V#gZW&P-rZD>~EgBuH7kpiI{N;^hx4ox=*#z5a9Y=R#^HC7ZCAB*ZlK{VgCf*n)D*^@ucGZ=Tj zvU+D_dDTA4FsXy*r2U$@j%9PbP&!S^lv4S?|KVXJUTT+Tc$-kY+Y<%zep_vRrCo&j z4J4r|%aCDZp$o>O2kqIQHB{d}zWfQ&gp-%$noSMEBpmIuPK(dNau@XP%7a;y7bkN$ zkSjD*`8S!C@8vvK?sT2gK(~t>zlFiUnZNLCYl}9^HFabowV@}YOVIS^WS8lHdrLLu zrkJv+3RS+DGrWi5^-S)mT5!rPq#r(`nzCI}BfB26us!Lie?w()gig<$h@}uxc%(?^ zrcS3VJ+p}c&USX$g(<1d!B??Y`7N~M8~b=!E?ez#MP;iV3;Jui#vCeCx3_jx(b5c5 zU*N#=g0PagMKhz3PMrS`iat_d}9 z-S&98K{nDczTOX~oyBbuT%!jyG1OP&3Swozq(8tA#?NR<#+94D$A<4VHLrB}uS>pYssYQtPDi$Huka z+5Dd_rD>6zy9ELy_giNPvahdANI+A)o=N*R3g9ek%8~)o9Ve1xZ!*r2XU(`KP2w9V8`Npj>16 z@R;*Fkh+b!^5UZS{88huA!gNh8m{2wYv*b4kjGa}gLB^8I4Us9P|!7nvQSlZym(eY zrGJ`kU4e)xPr>SZR-3uCi)sT9b=#4-6IrLM=Ps~)+ncY8hwpU1OUab@7_We|LaAsz zLEe{;;-L*eGx=X5vq>2{Wo-Ea5c3dNH$ zu(g(glX7pQx`*seXHG~MOxh~fy!>3*KfonFx$7Eyn@7H3MChr>)5&gU9lnX4s2g`Q zmiS1!EU-hsO*Q9||5%UW+e)T&yg4x@DXK;7!7GSWWK#A>X$y0<;R{m=#R61(#oD~< zuh|&4r17XqMYB4WzIr3}H>ghHI=UhS1NGv~4U@vV=!0T;v3*iBUS0*4+ub2+m_}dx zKvq@RC#(66HxzMre?jNgha&#E(-8OY5xc9KsfncRn%KIgZj(KGcm@ttb1Zuve;eFt zN10+vK&v7W!fpx`UJbKn;c~j>E$B1qRhPWtfCfzRj>ye#UuSCxO~dQmQr)YuN01ov zMTy);Gn_Q!-?D6RQQ~gRN*}5DP2~O~io`iP%5jv{aTNJg`N1`5`-lgZudQ)SzHsYm zuFcNZpsj>XU@Wc(1gggsY@$_|*eUME>g_-nN5;)da>eNH>X+oo=z)?4n!RUM&F1h1 zh?L2yVx0{03Dy>Fg`A&TV3qe=N1FD=#(af3-j2g&YwTm{B_}^LFJFiRZIoVW{cj=+lrnp?L6HU>`MJ~5nC*G%g#$1%n zINrgXP{l{8IZ^Ph*MxsydC^paTi%Yngy`izvCJ6f2=~0dHvefG|DQDZpDw)qZ`#1M zsb-cX`>wKRvoV`id;dYm+l$a*2(-%azZ3R;u7qoY`|OW)E~DfSlt^KLM`J8>!ny0T zCC`$C?w`7d{AO675oA;mFRjIZd9M$8HR0K)_HLKhcKfvk9Ms2f7F1LIXDHvT1Gm&R#ON6W3kf=#cv9 zAu5Me;eBvLxXrn#n9J&Nhq-c(Z1Y?pZbC<#HoOLU*qs_(2`uH9*wpk6e)EK@@)l0H zWwfO6lUAATvn*v=3d`KvqBB${k3c0hxpQq(4NUM{$?Z@mR}qd->TM|Ca5v4dmWZ--jpB*$o)h42Sw?~^hY+#a$!EZ~Np_ruT)GxFtI zY`A6O>}F2!U&8zO_#s0lP|_R?WpBlN-S}DA7S38BQZjq|)%4piDj{*c`P`dS=(kav zo{}P>M=GRrHs!8J=ysTE`=cc4nqWN}HqcTQ5Q7sh;@gS>YssVA(x!v4;&Q8 zX2vWn%oictml{LmzCf>IJ+iVkgcYz&}M_~Jo*`1Xj5X9iJC7y?}X(!@6Mx7 z(Ry1s4TW|p@2tAdY15n}Uz>F9fc>^(tY@K^pr9Ck<_BQ~r(AFxQ~2U;xuR@=Q$n7> z(Q!7CwWU?koA(;0rzH>5l~%PeE&GITu3;@|YI9*wil{bb&)W=1X0<`XPvsf<@Ee;- z`SjHu47O7Cod4LnUO2hhUsk_@=AVWr6qcBFn!m7qu6_4+eWu=%4a=G@~ zRM$k@IkyL@=)y))G&vVlsJY*|)XFJ?GXH?msJJm%Gt{K$Sr*U!0|O-oyYkSrYn%7> z%U{gutiDD?SJ3RXn|z_!In(T3>y%#IsA*2K^CT*M^Ge9O#r}cq{xjA3Pq+YDL@)Va zrVTB*pzr-f05fgtllV`;rRP`ql(GZd6{-7_Qkc~;vJwGa$e?q7J z7a#He8jIzOL$tKQOVdTVEXX8q-tqj@f&bkmL1oc@vFrYnw{ap8EdqnhQ<+oD`~ACe@;)AM=7r}x%Dc(e-e0$V%u z_js$j{AC9sx^sOM|HoK24E39=BV}NYV5&msWxf?qwXj3;z^*>QT8sm$G%H8UnJM zAR~&y=jix~;GBV=`QA>uBDh?C@;PhB@n01sp!VKcex%D|;Ap(!zH;+$G{s^T35Ui+ zsQ8m7*X{uEr&vsiTVbtBpsvIwO+|^Z*d+H_zrta8GjIRINEPG;HTY@y9%rYNT$!7L zu~$U$Owjt+q;nYBn2DX(q@vnD`i^sm&(#AyPCfIVX6>h~K6Yj~*VV|?D;K+UIy-Hf z-VlPSE7MTLIv;kn_z=AN(hF4o^f;|Af17<;KEHFDP(foE|5ssdFqI=eMBtbm=(sE} zxn)KESwdf}wWgnMpxr>$cd~U!c8o~GL&%3CuihOfSl4i(PF9v8aI*m}u%$BNzT!54 ze7P%5_hc!oOTsDtE$Ks*2)*I8=UY%O+3^+ zN>w}k#_nl4dE_d<^3t0M+&0>|1#C14w3C<+7ktKbWtXbt3AhDQZK@Yr?}t2;$@z>+ zQk8W$F zBtI}Qi918?+}qvvKtN{xROEi$4u4$9!E!;JTxWO;zjIvLgBQr?_v!&XLh}7`0YbTg zg*0zpC>KeEsh#IQio8Ie<*9vZ=+ItM`{>^p`JZ48{vS;|nukdK#=Nmtkem3zxkqKR z9QRlHZ9=01Piik!ZKlTAe-IutHF5cnaw&{A#b8ar2YJIeSn&16@idLq3y(RkWr#D* z7U1pv!@eS3I)Hh#K&(zN~UwQjT;@HCp3ypB;%yFKapJ>(2}k{mU9}Bz>=ZfTN#bN$NHxl z_>h4<^TT`@igsgJctrg$CHeE(@6crFZ`Q5R)hpqrE&sr4CHPP6+rK|mLwXqfjnM;j zH6z7N(Z7gs#!fMyGf=;c3;O&&o@nG_e=`&qh#M@h>(lK}E3ku#;bA0OML{iJ?R9GQ z)m9YL9Af_EL{oAhsSujk>@r`!WwS5ThcpjTVJ=s}EK4Dm>(_sJXhJppULV9c|Ci1A z`wjm06@M_IKiES5B7ej`tPMqsvzyf~XYB7{uR)RXGk*}H_CN7|OtZ~<|AuX$xe6oW z(C==WA4KXO2%{eyC4XO5{3Auz(^lgZIa z7!xiD+YD$E4OhX_Q#M!RoOjJ|iE&(skMoE-zTxVF{Q(2w@j1jucYW>bz32L%CEpk@ zwZ)}Y8$m#Wl>8e|KfiPCfB$>GbMAl6xzE`rEVHw-Gw;0n z)|q#9_;^3l5b8jk!j7Opn$jshE3g`y1e&%R$N8wsV9-!0Yh7iWiK&d07$30z@_7YC zOAO5XxFY^7AB?w?X5ZdGl=jQl8vT!V*0a`jOVd4*~Jnfji#MOPXc4qDk{&K7)ttky!H?B*=?u#I8eyM zAX}O&H9U}ABx_|Bo6d-wkZJ8}ed51Y%{Syyn^a~++7;6dg*$zn2_BqS`3j&dV(T>4 z9L=Sm$4kwK3&oxgKBwjzba630scn~AIbYziabg^zJ!SLZpHQz4KWD3nN2{KptP{|Tv@ zL>^4vE6EfD1!rvR8ssVOTQOrNm}B5m(tAjv{UN6wY}Y%*XPEFremGTZu0a`bDKSNb zRc=aA&BTgnEnVWI}8o%JJ0T?Q_FlY*I^NMjewJbR}VG*LEWi` zBRmR9ZN+b%(u)nq1aLgQ-YRJ`Mj4}Zgv-ZsJkjLcAmOzk>_%B#$;D|Q^XBbiA`-3p z`k+;)##c%f43L{KdT>6|U1>5$-bV;qL0= zM?%}aJsq3iXgIGLHE%pN5l7&$yk}M8aQ~a(^)=AjNGi6PJ8nU;kvWVt1}4AWTT13^ zBH55lS8GQf+&ee2mS@1R-&?DxnYU8X%7;%ER~G`b9=c%8IUAB!(OEC8`jZ&)K)zz|K3ep~t7z-z zMWVu<7r4vz7DH0b-njV=oVtOc5qEk}oJtI2J}LJ1;S+~x5Ni(3V-!7YGLQ|!P$|>X zr*~g1T*Dk@yGNE<37RDxG;r9=2Y-3_c}~US!(24k7f4pK2W2{jW9Cy{q9b=IcRYjkKj|`*pAXZOhxTo`6o=&wtjze|bh-E!(HI1tcVJM-Yhm~QN({~)EYg3B7H(#K zpLKuoFJ?{xUWu=9o%>hm!u8OIr5%4@AJF^S0H0Zx*=isOoKq-|jqp&rqpu4!`+^pn zsE`v`Vov2zMMqnb&g@E8)egKv5xp)QH(qOg$H7o~z?< zf{c*@)p<0U``r}e-kBmphl|m7zC5IhrR*aO- zswo6pSSA&$FGalxX_B*jt^K9wGFj}kj(csN=tPP228O5~`w>qn^b zZN}iPaNQuDlJdiGoWV*kL1$Dnc7LH4e-huj%ilIgR=?TU>&RvOA!*R(XYj!KKgPr}Q#A+yRz8>{3vvHEO=s))sq% z5pn7<$|o_($JYG5nkLuwwrl0`x#F`N-0Y~8N}G>8K}E9s4DE|?1kIP^X!dB zRnKopMf(c|N>HjufK*6qY`E`q5oa7270<1CxsE01<(Hx@(Wz24iqNY%g(=4@%yr|J zjP(yFKjcNRd|2XmJ;4R>RA9vSOYpFC@)^M#>;)ZV`uEp`)fofReV+4A2Yo&6-wFclsP6C<5vo~jqWe=j_vUI0` zzk-E&>kICuz9^zJ=&~s=JUdH^3;g{j88I`}&;v0>N4n3~D+T z-STDffY&8zSv~3+hPRAa(Djy?T*L0i@#cxq1MJ%gk{Nrsd%!PK&V)?&Z`QRaEy-}= zvsHEELCv;m>|7oaxeMQFaVu*)SJlcGu#ADfIm)aVU;SSls`@)lg8$WcGw8FmsXdtQ zVtepqWaCV&kr)oiJOQkdj1?~wOk7&MwW6@-P<1&d346zbg(WYVDtI8UhbRD=`mx?j zYUC%MTDmyaM#A~d?UX!jI^~}12myDcU(bmi=etbfCs@VpoQR7!+@c%Q+j6*tcE9b@^r*>8PJ;e3)6v1b!^}i zv!|;s8Oym~!MNZKfB|&li%03S%?tQbryho$tGrHz#tZFoS&`vA_IdSVp_t`kPMV17 z)&wG{)rNGjH3i=($D~OrK&aurZtSh}g@wR-kAfk z9O6Y$A|TR0^xzm*JRAEx)q@3)?yy-fd!BOYXK6uyC=%T6sWsvK%%iR-v{|Yc<#8O7 z9*d(Q<+51>DJ#@^T1x@k)~R%%dSQ@K4nkCbkUPs4o=f1Za%{D{ypNZrpdzM40RQp5 zX`Rw*qm*(RvZub&?lf{o=&?9dQsTfZz|UT9weS!@w&}N;?>(=A!KspBc<0LcXEo9av*FueGw+&y~S#11&5vJ6$N;vtJ{NuAdWJJfs^Q z3h7OjskmmII6fk`TXQR`9Vgun(wo!tK>A|M*yqi0LS(2lq2N(72f&ZODb-AzD-8&F%B^o0~>3ssNvN3G3 z<+Piwzaf2VI{@ER?!vEQvbM8WNne%uTu zxR(8*i+|(}g|m;}l!k2O^lQF2aTJ9zy%*N=MD6DF{u3C|wnPnneB?z_p(Nc*Ep@JE zvxA1#`iQ$`a75&;3z`L#0wIFoe$$R?io)Hylb`rEq6c)e&NCfeD~HwYEr1KQtF+ei zh*=(@%{rGjQ*AD;G&gVS;dpyA$#uL&*;e)Vl~TUlpJ%LQHl65cQn$_v7a4;r+x4Ew zE1+a0YYJlrk_Z7R_2~}>%wm=LIB&EjQZ_r@Lju0L^UR&amJhF?G>&0MPNbl3x^>EX z&4FJ+HQVBzzLz?^Dcig4Tl|f00a;Wd8TEJ;oyA(xo(Ib2_ezjm;g_4YB@n%QR?E4A zUIs;Ir`+Idfr+&B`cAZXU9)*A)xx^di%i{x+4fNoI5;=envM1r#&eA4f5VQhoB069nuSV$Cg^Uw9uIf2{OIOpnAW}YRD}mb$$){ zu;`mpxC%LO&q4OnpV0-LF+~X(`pV1gSckh_{cCWmp6JltoO83%qb;lWfH$OV5iK^u zPO91ocPd=7BbMDz?3c#V4rTNWKV!Wh*kqDCaQ>^tkf-q3YupJ1Djdb!co!JJe4y;e59IaDobro7xvgP#-T~qn&Km6Wh2L`yaf3 zz$g+MyT<9!P@ohc#!&xB5|T;zapgNWZK6iUCVtFG?}G0GFa9;pd&x)02^8bj+9($^ zHAtY9wTx$9u@8^D` zU%r66SH{1mglDYig>LIUk)gShWA6rqep@QOCCT$%DJt zWXo54+Vq+bZ)tX|fe80KM)6+hXgy8)4N&EhM3_Ter&IYpp>-De`Nk43(2 ze(#z$ITPhFxCptj6ukmnw&(1a5E602oRg{U)ddzKrU+gwCsl?&jhxUvd#ia{@X3)6 z14Tm@ua%f_;zXy3BeT<+C_F2wO?|u8GO}K3ojG)CR{xyD7DebCCl-W{)!G-um4KVi zh8CmQ33W!%y~`_G?#J{!{-8`57pk`jt-J(r`f2l57OZPu4`=BU=I z&S>T*dI{qy(O)sr5s^|Z;$x#j`;X5xKwF$4pSXeMi=z9jg}slx>`f zY9Nd2!SJ6;ltJ$Pk)f|!@qoee8@cm6$?H77UiRO&{!!S#qViD$-utlx-qr>6NzhtZ z-_SMz~YvYjLG*5hXph$*b1`*!YMpu&U8u!mF)tOgQm1ny=tpSgz zI@%6ZFi6nxcoL2WFy`RqNMqU?yMR2!2wAC%b3$>pFRN9vnN01?8$ekT4d$} zj(0HJY#;BQN)V;U@9wkry87V!j*E1T2Ip7+MeM;Fu$&K6WX8B&7MSF&o8@nFT;efY zQRE0*Yhj^6@U0M&wx+j1wB9@t4TA4h;B<+hM)F?pV8a5zE?OmJkJCC?I33T*()(WU ztzmt6V{0a!;GPKVfoco@NPvw=?8KMf>ah@Mqt7D4vpqnhSnGld>A>1-PqCp1H{kzq?DVQJ+nVJxXq#-ZQ_B*g{$`s8av z5+31cD6H(h%tsF&mkPi~X9Z#Mh>jtM1oY}=%*u`KZgCO%8cSu1l1%jTSQBXKRXx(U_Em`+rQCv5?dx@6B3m}mmj6Z}O%ceVV9GVzsY;RwUA6YzgNdUf4=jh|oA7X{ zb6-R7>|8y}J@RfDXpLqu;^L#*1g31cEi`FjD>1wHxb2>pHA11*pNa0|fDO4(4 zSb3bbTt+)4j4{yRmK3MJDd_sdvCG(z)J#;w>XbQf>q|uzB3~W z3U_eq(l}9dCO?{g+=V&-D-&kI3sv8bHXao(lm*ad4;HF^zI7peZuq)kN^{Y+-R^3n zeBYe&IrWA`^jm|fzM7uu`Gept2%H0JMh;#l7KUI?mI9`)xVTi=)=_z2tApWFDaCAWe3^d6Pr`@hKl^B38<^#tI<- zz_>l_d(fMzZNGSR6M-0+6EYAjw_S+bWpE!VL@u=w+}i<0AYYXU?=)w+a~9#qmec>c&G%Km)ibJkx-Irgxs!{P9%Qh+^uTXGRoK)1fCbl2^)9F z5l=_y2l<15dMiGN0e0GZzdaVjah`bVGK_Qc5))U13kEfWEIyd@fk*ZjQINbf)Fj=M zyrmC%fmNdoWcj0e4oG_I$vc~P zQ`4sc>8+$Q7+#`RRDuTPqVsJ`TuK;FpDmarjU>L`=?sa7m#dJub7o^O zwil#MZhngh;}l(R#}nMPfTu9{>*0xZgCq}|R~NR)J*$|BYzK_-5Nr-<^rv z#cDS*b68~AcegiZ3Vn@~17HMs4S3+KOb=-ecIaGPF7LanE>rXLx6Z9_=~jP?Ri<)& zK#^vx*ciuC(2#` zkcqtQHulEX4M*wCShDK;1(3}&crQ0F#V5)RHej@UxKZwYBs$Vro=(Qj9#8aS7oocY z*pSQKwF1R;ombY7>~HU<54_v8w(3Sa5Tr&_5OanYJjW--welPnXsS6l+;#flhlp?N z!Fuh^JG82QOWvQ@cOc>9_)Q2Eb^;oNaK1=!33FAT!%(-)2lyLr-hMbwA724?u2HZW zl4-0EkI8uAVf=tuV2w1&M!3C+OK6MiMKk)?b5ba$C;UyUpv@WB8Vum+DTXMTT?s%R z(Eku|`}*NJ@lqT*^%U2j^+B1XNi*xMrRS$J#5A5}y62mm%YD3azB@JdkcCR0r1rrp z(k;62Gi5h2S3XOX{rlEGCpMtE=y`BCYR9&z>7AO`FF zpppn+KuZE%2w=DYDAWC{h~&-oabCOtZ!c-op`N)J5Xa)|bnQBw}~tc2{HSs zY<7oUzv()EYQ=z!GWY08T!aGmVgJ>802|8s=!>Ya@lAADaJCO-6Il+9^==}tU`lYd z&e0qYsa3Js^7ir>=cb$WoRdh@+yiq>$UC0pq`Odd@;=LIq>kz#JHq?^ z!+^WjH@7=Ha_t89o?0D6?yxub`YQF}@bTezjul|>P%~$4XTv5+&hWk?j!zT~3~Vol zbJ93erS?Sn&9xuW_-y^!zf9kcO2Y+0ohUm#aB1>fVKURdV^clbN^GdUQ1oQ%m?m!y zPvRldwanTO_bJ|0*ErE3aO==35ZbxbJ!+J~094BL9lcy{IsNVu^R(}lBUf)xnQz|^ zU&)wtl(IRFd~&gU=Oovsar$j`+7iC|Zry5(=X08J*8KkP(!;oYVzt+fo19F$EpC2> zK3*zP;oh!A)cHni(3M*|r%ZdsPt&>l(c}~9dBfb?&cxjdMPCYBmht=hmXQv={eAhA-M$RA+Y)ZA|%l=2j-@1#}=|4EQY_$3UocMilv4k32$JBMIyH@aM zgHeSWtI?LE)%{>C$SvoOR}pq#W!Ffd+|EWA{YDwWTl$J-`MXj6)U{nN2}n8ObIwq& zle4nhS~xq4HyLa%;mHN|f4RGQi0EBd-i;jleY?rl1>T1gpb!fc-=r&jcBU08P+3q$ z@2W6t#6rU0M<11Toc@4-b|GE-*$!%$GJgo*WCHe3$gH}8kOQ%}qatM3wvjiQvh>lg zwkDzo0N}vQHl!v2CfUF|Kp|O-3ab0D5bC|XyN+;c)D6;GR^lEi zHUPX3GtBW5GEpJjpfbi0YVAB90i0a%H30I^R%vk4zTa%F6$?2F#wpZ}8WVwmcz~~* zNNr%<@?f78o9YA83HQ+5WwP@je9QO~TjX`6x z(H0fPxV)FG1*;QKaAWu&6i@2Te!lJdI@s9QVhWWr*{%8e!yg0qBGrs8n5U3Gdt&pc zqNvIJXjsy_is)Y592}2tjX6u6yC!Gai5LS?jl#`yrUzVVn<*%u!n`HJ{)oui*;+dL zH8CvV?#lDU;vgFu5G7pJasBNM=p-tl(GLY>@4P2;TQ}&Y%dY32p;`u~D&?cyFHFkf zkYrD8Z(}M?ZD#jO9DS7MW({xYmB+iQPf4g|p>XVQLJ9HG$yxR@!L6+UZyKo=vj9ud zO}vp`|63&5fQrUgGMg#$ApA3wrc+d#Px1nqWWo5I#Is{h4u@B3>U$TeA0k45xVp^J zq?lVK#Oeg;1Dmu25#}el7edlLZLa7}qSuij<3u_6eoXN8`z2Hvb&@(BN^&VbFJ5Bi zt$(JBn@9-NHXF_WTJ^6A!T=HGuE=U;{Y!YZ^cO_9!u~2@zT(5DGgfJ800ao$$4Bu_ zh2KwpLF|X26yCz*w%r^5}0FltX=ZmS4|6y$3Vju;80*pN!x21^I zHKy`!TJ3g?V-=iGJCCgH_g66E-=k8X6xQgX)82}QHK-=|7#u(9Hq$&1~im+*t{UqX_y$ z&)#B_z1loB=`ib&4jPhL`@IAK4l}*b<8}chOg7h*aD&k>zzY8P=0ix&%Lu%k(JTu_ zAv=a??%+eE+hlB!l4*SW%f}YUou^y;cv07O> zpX&5&rEWz5fh;*UKDl;6lIIzXvg4b|IOmMPJ<8hnjYbk=;&x}>BrD=6`E>s##sKNQ zmMyoo>U~I&^}I!FflK8cGros(N;z@+=~!}hMfB;r6TG=@17GBVTVh_at=S4Cp(_7j6Cn~R_4hJ`?21{8-pW(;3(*z4H){; zU2VPN7Gr=^1B|oz_pN^^Y=B$7Z8-yO`9?tpNuPN4e$4G^)8hO0!^4FWweD`sO=rS( zH6dPxK?q9CCR1d@MN3?z2-%*l;WRp;^+4?&9jNj8x{$ zsC>yS8Tu(IUvsk}(PmHy8W-pNRue}@M<*qt*Sco$bDN|(LeseZ-pQniTM_yNebe(x zD{KFBiE+!SjiUlwKLetIaX5s~?7rt#=^R+&{gF-`1Gf;{ILrcL6dBoftN!pD*h%{D zWdeS5m%dTBs@hTP`kz6Soo@>7U)J_0WWD`nKb1>NDC2j!EsfV{K>mI6CeD|J=TJ_HNbJwzIBa3*Z5!HWG{! z*r4l>Q`1lMfUSSO`TZ`Vz(zSR<+yufz0o13s_O6f0-^kgxkEowj=um$P+&aCIKFoR zY@p=7Pc7Hc`vg|5w*Cngf$V3=pn>fIQv0Cg3kg+D=-4CQFM z&T9+}Jp^jKo8P&PAz3!~@AnjgW1D}PL4Oo6L+iDGBF09ss>)Qx(die(G)(_){~Q8q zTo+lcKW&I#idE6UzP@8L@}p{o920T`?T@Eb)#KLdEjWSq!2aF@FMbyV?!PGcKV?k4 z)+gnH?fy{yjFuR}62!VA17;8+M%D&CegzQB{9*Nh+3UsxSkiTQttHCKU~!ZO-o9H7Cnp>6-N7DnzL1v~?&Do`u`HHrRLayH~zLrmZFKEvsK z@4u_|jx7w0|8G(CU%}u%ZJA#LMq4N#rKYAXC8?&SvI1c91m8GxpF@?3)9g3M?T3~A zlaki{Cd=}>&ily<8%~iEgOh&xC+@!!`yWi<4S<~gD$jqzHUa;E$1he&GcRd9>sN+b zXBv=G{$p18BTIdMxarcLlA@quUYG8St}&SQdu_jaLcd6k<_!r57Ga35AxGz**Z{M> z_Xw14f9TtKePGf5d(G9ZKdd1CU!L{<>smm6;cu|$98LuvmmA;8;DH#Gl`f?!2I#$SFPSpX|q*RWMpSASuB|3B*0 zbr61co`)RI{8`rjVcYzLCGcb1=s#rI-z}G)NCHi`jh=$m&W3DafcR5N$z1?N<@!KO zI+kyb8?9%(14E9#V}~ItLC3u6=di&qPyb?&RImIrFaETT)DiJ>JnJ;WEMwSX43h`6 zA8&$6gpylzv-Wpc_2UhJ@8u7+(sv9U0s+6dZWoyalEQxW8SKbkzOtuPpwGzAB8>Jn zQYT#BJFYjyAdFvc{ds*14ufn0e-YZR1-KYL#uz9oI1Ku~gggHe#{CE1N?Lc>KZ&E8 zIix#6`8^u5<a9LTDKr}&WynyGmbE8edoSs|< z4NMa(TN0e-^th;LjGN-9r|KI6`-Uy?v7l*)0Z_sKlD`5`ebg)WT9Cn_N=^kJb+}of zPFdAOQJAR3V~UTKKDuO1?@`suk>4)hN}r&l25`+09a;VKTYUw1n_92&A5$4mSa=+R zlzd-MCv}0OhZQq?253;`5|2<`ux`%|FyX#ox0yC(7z$_sC=Mc9KY#}CsjX*O&E7RW zOLGP`rKGebF9366!#Jpo@K$H+K9%|$YseE7zdBI@)ivM<>OdhfZS8J-j(jx>Pzi+# zUyh9~Ci(i=ujQ2*n@jqNOF!w309fNIlTWpk2ZzHZ#x^v?@ixh67M|T@$H~t6xRtdWOjsO13d>l zJYw!?`Wtv9nYqluVKNLLTLL2+pGi#sRs4gn6|_fi(x`|;oD96>6Y@UzRb0L{ zZ(jDOzS+srlPOfR3pasT4_*LLi5xkLVnLOr{ev^WHI6???pK0br@?kaEY*+M zFLD}O5A$o+qA2;`tTS#pFSslJ)U;^u^d)rMnGZ*M&^M33Wx!OLV(Vm}KG zjsaG}Ic%q&LGA%Pdw?DCXaJ6q1&ZsySWPIv1mM~ZOaN5hzHWpX3!3P%S_|9F`_eo! z{rVD1N&@++5e`Z;nZa2_KG;?P!cPQnySCr}P*8FQ0p0_Jz*d|fs($=2!~_SRpv!0w z19y@+@7Qt%a7@HyK>~?B%kiEkClg-WV~w0F)NW*REG;e7DqFE3TCK2Z=`lag=_lL{ z#uP5nU-g^CVW8<`*Eg9^fIlo#IzEG$i+hyO2O}DQW`Q;H;Z^Yg*)t!Xp`9U+{n~Y` zkX)S4+Lt^WFv_a>35wfX$E-}WCzi$VXH8pdf2(T!a?B1gP9WFO!*loH)qWYyeywWA(0uz;w+b8gehX?DE917dBw~%(kq!C%Yk(qSc88WbC-F z^}*Kek^y)t83H9BR*f*10pE5>evWiZN48#t!*f`N<=*?i@&R&hutewWAYjT)18Kd22;1cgSKBU!$f(+faCq_G$nE+4&JX;QIGz(p(%Qt4@HkRUyphmCTCw&a^v697O=eT)atJOFz}r>UA2 zA`~k)ZfVv4 zlYmH=kqC)**jZ!Db@vbvg?vWZ{ZNmr<;8?{fE~kR=}83~D^O&e1t_bAsS;b&OOZ*X z+2=&Sn3yx--oBt4MN#W{Kw}lNvSx2)xf=>H`9oYCo8=Y&f97fcoAea>Yk?>tOI(2~ zB3-D9EwfK)S{s<{XHyz*MhrzPTZx@0MDemZv$~Pbgwz2#TagqA`38tV$A>ft6Srpo z+?+o$05#5GVaT?fJh=&yxl6pz<((wn;|0kdq#iLc;Oe%MA|%9}SD7h#KY|K`2<_5t zO(+}aH0;_{3StNg3LHRtT0zp>%C_Q>1{kV@6gdXmH?ScOQVvozXu!*XO9`FE04M2>>f#iV&&_V$8 zlOc^^LP3aRUEWY9gucb~Z7gjy(sjyb#;w@-eEcwGq#qaXlLBB^dh%-4YtKA+Sw)nK`5N(us@rWz(tm8U~1v8#EMI^Sn2d ztWcMo+re#eN1xHQ0RlP zC_umnJ-GXIg1BQibsKBBB$2|uOEF5WpP{3n5WFB{7ksE#(RZP}ZCjv~J`oWm^t5TO zcOeQ#h@l}m@2T!P@HxqTR1Bj4i5Fbpmp?}Flen;&qjWNSN-(XB#;{bOM1WYw$~RhWGXz3Cb^fphL`N6C7%8Dq zT1yWgI_-k7Xn3lyv45A0J7$mNbvAwSilCDry;Gv)aT>3(74;5Ist`FC3!@N#63iqK zY0>$Z7>hGi65}JXk}XWylQxZ0wQJbBIV}MMNQW2tfFq57`-?(BF+Fr|@p$~PPCcCN zt*6hJAjg?VS0>q8mZI2>X9bhd=)#2Ak7Z~_=P(-hsXDtz`>#9H0#ZO4%>vyklDWqm zbFZRS=8gjzBqNv;n?OD8V)A59S_@uFB4KBHDfdtfJsgSi3qmi$DMaq=wKxHQt8KLe zw?sU<=y8$)z?sOrL<+ReKzY2re<8ZfEbD{!Q*JVzD!s!FSMpG@r781MHxj4 z@i~EE=!ee1)uyD#N=$dC;%O`0>~ICyl*&af8&NWpD7Db9881w-J?Z1q)j9avw?;T% zUqfEFlm}?2vt0ztm$@SEjsx%*sj~oL|6ZscWxI|Sksm1@K1=~HFf1n|Jc%q0vng~? zDsnP-VllbBjI&$`2JFeHU*mU9B1* z06cm@l!-;h7MGM0lM59Ig?FT_=^kC}y*=$_{`Nr^>D+XuC8AdGs;lEe5V1>ffsN<`oXj0vXmq zBQNX{loc3nhkm@?vg?@HH;{55@v(@x93?Q4OpaF}&$2MEQ#+;y#GsvV-4^bjjB;33b&vyB8kDpu|nYraF7u%bGw`jyxO<r1I;eddERmgsmYUtSVOgg+n-`|rS1egQ*Vg@~; z%Ya640C9)BAOJ^}>q0HJ??URRwR>-+8Ut`R4lq#MU&qN5utK!Q6mfnG5?76RlAyq% zpI$LSG}MK1QP2zzhUYhRo!_pj{}!<#8&ScE#TNMchDUP)_iZ0|h<MUZipljA*E8 z3-1tbd<$^&8A$WYs6oiO0g5(600U|hWFdg@G+zYX*-oO-<)<-YNNcDBJ;U?{O zhxj3EAJqW`U*GY>0^B&Z?0a}eBTI&g2k@ZHkGQv;*=iCwtkg$oXm|{8>@<^(i#f>3 zawP9Sr%*fpLPE?_fxZD>tP<6iAIK02*ji(Ryvp#Gl8JblIUt3B^g_D}+q1TLP%q?2 z16;XS;LIXOBWNDY&8KSc152-uYpw*ucGgJ;#L5T36HV)Gw37jxz*Z<(RXznF{IoSX z8$Tuy%Fa|<=fjUia&zZejO0HCt!TiK?8;Qag)dt56+4Z?+A6!Peq0s=#2y;-!)3UL znNB=GZ~zX84D97_I_⪙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

QrU-2RK8EM6E)UpsxYq-lWk0^{ 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

H6|} zgRlw5tb{ul#Y+&qg_bAGdw41*ve~>uj7>Ph!BoIp!C0JnaT_eAq6~mrxmb!Q*=?(k z1@vO2HJ+W8s(}2e$k0uU%|0w$iSIGQg_|D8{+1GeKnv~1DNFY*$w_;4pbw=vbD%Bm zb3U2#^EPeYPh?-_H019TZRM&BOIpZDt{lo*>1cH-4&G^K4Fi+|0{W;FJ~%`>ySIOO z%jgMs(_M%XTC9}&G=}G{5>u*~48(vQ%4_r>1rXM|z|z32#+e}f=tjpg3h6zd?#SmO zrn_ket&UG~s4G^OeMph0Cc7d(cf5FO#!`HA%J@mE>YMevQ`d7;gCaA}#})-S@0EE# zr(a(f8(P zf6aQ3DWc9HwL}-vTeNEO^)-_g3Vp_@fv_jpYWAGUkz+F{V6tPw@O$~3-=r+vsHj%X&b8`FivjN)`1fuWI zO__b<_KVwt@?Pfy3~p~4Jj^M&5>^luFGE$8fZ@m)Fr;=5G7TZ;3~}n)QpzGQ5r^`r zt~lYk^C)fEBX|Rd>dWQ|`_^Ik6{XU3e9Vb-)!Zg@cYal0^bSqc!8Kz}vIf4H`kva6 z+NMctxJ?-B;XMwUokPgw+nx_|N74Cq(Prcv>F7Jn^4G>vVpk_NX)nPWHM!;CIp?S= zsO9zo2&7LuwblQl1q@bBNAVxwS{z`C!1?Sl_mRU>VIG1XTI*5Q9r}n7{QTeOa)ava zXX`l~a0&$S&BihMkHUx$V1^qPeO{uux-JHBNeBqXM!j=d*j%*U6TXxO=W9WSMtDv#}a>Yxg?T z(a>ImoKDmi6P-`KhF@XNa>iTH4-D=Xg4GY#u)k>up8 zOjO)E$VhQL@^OX32x7od;t@P1@zYZYC**eaDKM%@YnIv%gP;~bKA3z=YV#Bo!$77A z1*@<(`hK@XWQcB7mfd=kj?+1|`j`X(AmA;SVvxp-j7~?PNkjQ#X5Cd#DIN6qNHFBC z#9#1r4ib!wM>ifuBg6rO5((!&ehPQyBI}ie9YJ3XF`C%kaxBN7$NyQWo}h61lRFse zcEL$ibBUx*fD+rMe%SOJX^aaj;^ZDHiya@;280 zD_W|S33}FalCkKor8Y*JWe75PCQs|neq_>g9RFWEQKw1qRl)8^*R*yyX3y&LkR^BE?eUnc=x0Dc;4l@(sjF+l zt#Dp_2{;831fh-l?yI7pIY|g?@!rBR<9nG=)U<`Q+>Tgppx&c>lEO)mRp<9Dsfv|q z4=TNWo2U6&=jtHb*)r2(hq*n&B#G?!h${M<1tdjBbjM7R)B(Afn{x5)y|(F z){Nq(#>0VzI7=WT9N`UTgh-(~u(+ilGlo(O2S4=`93>93eD*z0@kgFk2~)rbp-4CX zOF`^fxE7p76fOp1gp%Sk;){`F=`$l3ui6I^N)Y@BC7gQp?4{9!B1QJsIC{d5bWVLX zr`*hF*q@4hzv_kwygWhyv7C642g5uu^SbP@n{b31zk7MgUdp%XAoocNDxfNnN#|lP zt1flO#qV*fE$^uV6r`wc+hPp5R?;n)il^QskHPf-GLq}iXY)mE>O)aWqGTK$xnDtd zNv$db^&~>6!8^e28-kAHx;q2L?f?_8F90YW@sFkX!DQG?#{KrA9;d4( zp>UxFPNufAVB|TnSK@mphF1jjJU})F%3DuWVxXkn4+`Q|HzQM$?bt|WiXF9)>luP2 zq0H4pGv&~whB13bCkwblvjdttsFL93+m%->a*#l*o)-p;1U8kM9&tSYG@#Q2K<{9{ zBs8S|+0aa;2clvc*vTpp z`W+4toOJTCY$6$inD0^y3-7;Hk_S`uPxk>~7x15N3I1k>2oi1)W5l;p=!Q%T7x5r= zb_rHVGK;GOF+hz^V0?FOA?Sb+=6ikHyR`P#GqOz{b6*z0DnVQ)XGHv|qQf-;i&TIU z0uYn~)F$)*h6~oM9)J(W(;?Wd!-10+XF75e7iYG&jA_4N>56o@LrY@7u@2M4 z_mrF{VxLIi@=p^z@VL1Buxk@ET8axASLJW}_E;6G|5(THg$ieN*LrV8`!~X~fS4c7 zmr}UJ@uw_{l0FIFb-t9FC~3;c0-tcwzJ~4RGIH&yhhE~2XRI$$YsZ7fRLCCKNwHeN zp*K|7Vd#(@KiK>gc4SQXN$emw%1+mepxgJnqlfV*ikRe1EjJg5#?IDMJ5)|Kzjo=5 z1KhhXS<0blj_S~EI0UTt;_0E!oL1ZvKMq5VG|8nTyBj$M-7;!Pr4K$ZR}f=_;5qG% z82PNSI73O+$ z$EM=zCnQZWui3z4jpx8;#vXJEgzCG4fy_?KL19pl;uCHDe6CSfd@L-X!H^XnAkGOs zTX8bm%0{jrwtK^tFiEVZYC_a>ExWU89{kq7opC|>ttG)UJq9mCJqIz`^% zVO?8Q?i&Z|8-+*!7#%2%w~W7iZnd<*f@IdvMFRuxM~UV9rM9wERGMLc8i-&_hn z(}Z)^4=r3emqmfdT9*fPQzd8)`?hYq*?s@|!YFrSK9BWdI?{~TTZ_uRLu=YW%LKgD)RXvEZ`}4b4fB;%x4nNflF|`*sOmB$0yQU?ZC-QN^hekdqWzs z>}<)l2+i#Qu6SH=6NJN((_dbP%~pH!8@(@B2yDvPE~>CpD}~}~k!xT*A&isA`S}2MUM1DyWQ{CGdJ?CH8CT=kqrrW zRb&`YJnMkrDK(6Ddwt(_6>yEu&2Jwf=Q0ii9R_P)DS#`WI=@N;fQkb|gjy^Ep=AD2 z?nc&F5{r+lh3tGyHj0n1XL2YCa1}k4Slho6^Qm@KEnN)x&X9V4;+9Alu)^PjMV5>( zU2Ew9#UJ8IVK0IZs#*H`OznV@gVe@*P`tQMj03<3e9oN%T=6@ui~-EMz#HMn{@&{6K1Zl^H$4`sQCh_^=O>^8thJn2C%juI;s2|2aQ!l;r=M7rvh zFqv%xr@oHzb~g1ASu%hc&LNbr6-ni!Hj+Kv%WCDtqmIYMA8`%KVP0z6#N1}eg3Skp z3z1Bn_J2V>cRxIKT**3Z6%hpl`VZR(Ig_vyH~`I&2DoK=>}ywW2WOdClIOr^{b~Ma zkb8;)U3(^Jv(5n;S@6oU3Z7l@8JO^m68u<*@2rhO&b`R>A@Er4l+b+a4hNLC^#&dF*2Kam?>z4 zu!O~lKwYgBPir2&eepg-K~74INs|ZJbl_lxj{wNHYX%ezeH}YTuSAp%eVL zetqT&ot^9{26zwn&BLfJJ8iNOf7}E^k5T#15nkcVR7TQyI?n>{C<8E$3$MF--yfCS zuzig8k`58tFZ32Eou$Jso0v|G-}m8)q(jD5J2@;kdl9t>N)b1}l!cu65Sv2nJmY_P z)v8Z@WH0D6)FW({nWx8}J+|y^S^?r2*rV910(M=eII7bpdO9(&K=_0a@DHz*rx683qL(vjY6#W!3_);Aopu z0M6o}zvenIcj(N6)7r6Pgj?4bkpLr95M^^+S?7)?tM_=|Gq=WQRB*?8$dh z?p0}*?!JRo2vgQvDMY}zLC_Y1T3)D$?7Q@-hz#ZNXuL?7oO?KzR>Rv0(?ECBIDu9S}GYy zRX;L_f6DepaBkBTR2@V{$KT92HyGB&S^;$pS|7HkB}`v8`s^9xts1m`u?PMnw5R=}xv=7?Fx z<)kG24O%a-W9jlTW06Voa~_vDim@GZLD@Cy-**zD-)T_SRP>9IBCs~E zPfKeNt$?gH1$>=`D<>#~xX!w_pPB8P3S~X!&7>Tz6FWs0TrJUYpC3j;uOS}Ld0Ja| z=Vm0AQhTc@Q-arr(yL0~lUuEwd(E(n31iYx>Sf+V3tGy&n86%`h~M$~!-2OHuy-vd z8JD*nVeO5;#UO8lT~oZw=yaT-k-ti8$jjrOrl1>KlFH2&$osd~NdcZd#6_@Etne&V zvq)`pPMrl%uZRG%0`ZBHjjw0A9sT%#P={Kja@k$52rEEF^0^#=PW5D%#5Qom-x^C= zZS~X;gob(4O`szuFTa%Z?=koCKS4wr9acCb7iRnzrx*KDM#hwYq_%8=m?jwVv5Y4C zj`Owl7ds~b1{1J}Oh8C{9?B-1^4IGM@}RRQ+g1$bIhcWalG*A6U=JoUjb$d{^3YlN zFkXP3CtK|tPQZPKMAe|168w$CUu+q?0tn~JT}(kG>)qk8(;CAkyWut%meJw0 z1y~`Qkr5Dvyy8hCYl~kqGk8JA=;YlW8+{_Ac9a3-Xh{wyKVX$usExNsmHhpbT>XT*8>6&1GkmDg=MTMovo$1q` z3g-vCD*S`0kpje68%UWi-(HO4!<9!?vEeD>MjTcqhi+K--_$__Q04Bf$8NQmgyYuM zKEBLf3`VcXc4xP!dEP_o_IX2e;rv}i6;Kwd5k)({wG6_~=OETYX&4PtbgF&;Arae^ zrtR2WlK^_nNcJ#W`BSL=e4}N{4G35;_A*^m&8soID;=-48MZQ!K*)=NA=w7{U3LTnBY*N!Fd=4KzEFi#!Z&D9hydAYC5+xoX!k&6oB;t&hpa@x^yqoKVB_ZVo z;TUcs4k<)-d@?`m%2rqAK%xVWTxn>5YM1=p;`DjPQim0S^~o5#Tn)%2NzG149`iUs&;pXFyUt(WSEMw~_SzV<@p0VK{f-s}%k1d$an+F}pJ(&yNLmObQ5ot|nVu^HAJluKPuywcz9RH?zVID95NjjwH9n}1G3$90BAFY9iZ8%=)71+H zZ+HpE+fnYC7!dpXfw>W%{svz|i)!}XIL|!m_hyrCR7=1>4b4ORr1P^h1C~nt_!ljO zXqHp(m>pN*mD_Lo3h+KCodZVy-n#VLDMw<{cigE^`ZqAK&>ypmMy5h-Fs?3$?nxBbuk<1GY%J*TEv_I{ioM&zd+IZ8jo-1Q9GrE$#!Vgc(cg?^$Wb-}p)l6R% zqfoUwHh6*Mq<%3}*D2c3sQMPm=}yR<_XGVWN7d>duZgj>nDo2c91va*tH0san!QT7 zhQF^}LOI10{2Lp;KZ&73=)bv<{~PM|Zwf5jYw?A=ht_p5Eqmv`(>6vfY#vYFt&8xj zyY$@#vt%b0zOy=}W8uX74|+$lSNx5ooVI4C565n)z}(@GP#HWnPBCMXK z$ZPJ+a8$Lk7PA4~YU=3=a8i0#@A&vkjJG)}zD)!bMGSKN*bFZqq<{@-`8@c$2v{BKYHnJ=4u#`TwsH9x56H7#*px-A+)uNS7qh7 z@Am9B!{h(0?bmLP|Hqs_oGrhs36B3awfJ9M6u3wut=tM->J_je3XdBX@*ah?AVzrE zL9>&Qt9v0vgf)CLzgW%Q6Al#7RYS82UOwoq8gHzFiBeN;b>Z{jcDd)aldYJ^i7$rm zm4V6MewW#2S{KeU9X$&b-mINDEQPPWxKpy+J9y@(V)Y0d*z#fM3zhAU)YYpr&w6j$ zQX=k3X;Z;_j?cuQiedUF)@9S%H7xM5q482l$BDwejkn%h4u%((`624Xc8?89#%x`} zw>{~asadD;LdIyD%uH0P-%MPRic9r#7puW8J!`FJJ^Ak-Z&kf~ zW+n+K2vBGFBlvG2m$SEiif(wffL1ECG5G@q2JF21vi>XfZ-Wvpm%EH=Y-IQOXGdC0X{BP83F zt86h+#tX-G5#@9i4!8DY5|70h!TQp$#w z%n7cKboE#VRkGO0HfLst4^5&~asSKcg@4nH|4P~LPwxJI!^XcNLE!*}pkf9n>nuZ_ z-foUdn-U}$KM_g)`NAcv$b4z>%jURD35m1Mw01FO@3+IAgRvq_W%g4&&cdyeR=RRT zR@KfV3~%yM=XNo6PbQz`gGlsvZz6zaCqh#JcFoY|j_ELB6-5=eTtTlH)J$EwQjPXQF|uyEc~8A<~zkNgVwO z+Rv|yIFsahBsj}cOf?*M5J|P~c}Kqs9xQsYDp~aMujb)TAdoR~O<~-Tv|C>~ znp;Ur4Hc{0ly5T-NcJxwu)f>7&s?y_C^Us}Sa;BfIgv3p6QxbXJ~-1ocl-Z~3r%5Q z=4L|lRIKNj?%vydJihk*VhS0>dQbVi>047?_Me?QdExlq6{wM-nu(02Vyn+|KfgWW zYmXl?cIysmF{l5=3n+YxXIuf47SD|C_S5bp51h6?P*0%TaS4%p&9mI(SY78{HF``|0#FE?>3=(k zat66fVVAzV!^b5)a+e zX&3d2oK%;cNG68PP;6``>V*;Ute(}LZ^@JoJ&_KW5M@oi$ilg29SRZQu+tmaF}OoT zPSH~Lt94&R-}%3P$Ki}@zU;JrKO`LRcfn1pHB8%FZ>HHS1Ib8QxkZ1C`Ngp`J@Lam z+?&f1WX|M0_uqT?a(gFS%N#~HQ&=~fDDCpqLw+NGrd%BN>DG-j3fh;g8 zt|}lNT=T~+EXcWEk9w6Vsj*M8Heds=O(z2pkO0Q~N+6%UwDc)|-JpzrQ zG8zy`H<L?^}vQXM7fh$Zf*!F7`aoHNkeOH7K(SJJQt zk$}L3QFI{E7-FL9Q7#nSNIDEtlzP`F5l{GC0vQ$_=VugpjH+)qI7)Jc%Uj?;fWzrg>Iqh8zf2lSW0$mwCh6Gx%=aMYg`X2cQr_1yOUl$`7?K z*cPZ{`20CQWQ*O}J0Js9y+(8HZn6s}t69eN<#$qIEwfv)PR--`W2n->ByOr?Lr*{i z#$bf(Rhak8g4h;qWbp zRJ_ioxTX2Yr|&M%L^hg#;(sMcO86#?mw)4-NKU!Pu^n@YGSIN@3BqR$FN?JSXZhF| zj&t%JoNV{`u3bUL?spSxO2c5zTOeJMH#2c?epds+fi;H4@dosHP(=fN9R$1u@8(0|8oy^5lws>7L>KBw{XpKhS|t z0TSyZEfD!7UrN9-FoDoY$7SZ!qX1rl{%6cdlyOav_!vTJD(7OgC8Aulvr{H92HF@y z0x8x&&8THv9R@n6RyLK2B$kj|Ly=G?m5TuLm52efz|^=5((q~XX*u=!?447T!KzFa zJ}(|!&<=i9Ng&a=2?99)2Bp&hG5N;%0r|#d_$3JVx_`-@`F=qGDmV=MKPf-5il-vy zQ65nVu(h>6J?QG{kDDQSrujm3C*?RH_`Xv%V9wi(H)sqQVdFYln3+PCU0(Gj7?N$0 zX48BxA?*gSauYq2CWzZDo;l}BSRQ-QL5JkKv9r-!Jtu+_i}$~3TW9x@EC~9meAa_w+z^b#5_za7Qb8&W`;el zYDH=34OlMEYFrrsnRtUhCD)FbX={KsuDXmVcxDTkNGQ`0P!^mbn19p58%d#gBADBk zESYxwkO0;~8WbyL>)K7N>`Vs&obgFPEHvAMV)(dB`cs7c%(WX`E1EIm(n31 zy4Oe=*|*I&uqORAQUZ+~&u90@IaVB{l9=me;?4L8W+f!T!e8fd5xIQoyUHVWjS57h zA^&+ygQzEl#9%#!MiojX{)!w-vJXVooE8K;;}?J{kDaftxBoLhR~YX9?TQwFwK&M^E59kO$o} z4}r9*QSWY2F)P)AIFaXL7;vgt8noss(m-G!koe)(RX)l7)Cs_|^{fX;HI<7M1-roZ z7f`D?^DWZ=5Ss>j2luNuKfwN5ibEs%quRnj-SRKnbKBF0AT_!=N}DT6q@BB$G(a3c z1P2M?l{p((mWd4>0`TQwB0&;$qUP8~fGY)%aU09wvJZ|Ta|WR7w-S|}Pek6|r~w`B zz4jd%42v2OS+@(8ikoO7_C0OOaWyvK6WoBu^QoM64CW2)k z>rl%!<@C)L&V_MDC#<5=WcbTrQIc)!7JA4~s@J(1t#n6`KVd|1N&)4@|;RqU)R`umSY ze1nf#{jd7)TRpR?*Z{l(as3}P;NNBccw+uNF8>~vf3RPF>#`un8jP?1kAkLvdA+m_ z@8|KLNO}zFhLDQdUmy=wd{(lx=+Yg|8p|h65Qp!l8S*}7NsEON` zrLs;U-54suMic;-2U@fiwP7$2GcTC>@obl9hNq~sK?sUTh}k@!?*g1s`g3n_`c9AD zCzQ%Zr!?yJ=N!&yxtmWLyYj#MJ=pxM)9`2CM}Fr+_Lo@$YXVM+6j-8o_N0U(ZG6;4 zgZF%<21Z8H#HX-6UKaBke}U+Ua<_);-78H-YmK2FW5(u-W{QNtZl+BA#bEcWPNvN3 zhUH3aM%BlRu*?sc;z2w-D}g9ekE$9>DbpJeaTJjKO7zLWz*B@aEApg3Jp0Y7%@T`T zdq#8@A8jP^J0PWmNZW*5prREg)9Oshrv!#6vfx_hR4!x8BvY_i^FOhG|Ll>Yz@Q44 zq?EPz^MP>Bha}HwFF>AtRKp6D4H+3SSs4j%{q?B*W&HmA>t9VD{ENwt-&;M_D)pIZ zehKl4OoI$s=jQ_v{f0*+hMMu7;vGJ35`GAsfwa2 zGceWoD=;NLgG^mJi`VMk&VeFLu@w4;SWEn!4d-wFP>UoEOe5t z;E87#qYA>)NlTZ@`gkqz1CFpeDGO=U3AMHHujl#52GvdwK$`@bNMx+F1WiO=6gH8_ z=IY8AoY$%|)ATEtHL7PGT5={5N2mD|UA4jn0nViUjw}ctWx&=rr8{lzp3OR!mpgk+ z-c$cXsSX_tmyevjo}x5QFjX6Q_v1-m6I!uepYv~zE5AL>G0kSARd-0Uk%3N*85Lus zeIZmscHIYII^dsuV}4SIEYCKd%el+G_dJn>m`ql5cUFY=fPr%M3RzGu%1+`gEdpGB zUn#ZC!|3wxL|>-xBG)Cte0J091^l$<%Y$H4rVeKXV#vRwv<*u%M`~ho~EQeDltEPIiW6sBdWEOb+d& z!vZZB?jhr6RI_D=s25@^=D*GA%NDwX;yhXy32E`fi-_bQFkIBQzaq#C61H9eOtp)g zQQg^tU-;ZSoU{XH1D7+c%1;T31mK?;I7Q_R$9Tr!y`5HU*znpm~; zs2J{;`>d*76xA}O{uqU@^}~0JIx*d^LK9XxkU2nMZdccxy=Qc3fCEul0Hlw+YXB2P zYSo`&tRntJG_w5-awO*^Q7_88tp#`luHc!mMz3JSEHLDC(YD<>{9YdB;sM0s`BwD3 za)c4#wx2NVmoyigh7}bIL6|=TN>P^TtOcM8Zb zc}+?oAAQyJge|8_Rc`#0N0b&-6kP=5-mROPvu`og)__I84wPaM4%a{ayJNQlO9@@S zRKJ8q*Hs&f1M?Cf#5v4q?tw5cMfqW(Vrmn&&|ss}1s02#s{71RYvlErUIS4AMTNWX zk%aY+WkoCR_LjnO7akJ8>BX6P)##G-DAc6TLgA~xDsA@YH6pZ)L1+;mM?`bT-<~!k z^lrTV;C-T1g&eX(C37QDaH$uB@X>Hn7|l)1bV%0T)3yo{jbd;DYQ4%7GuP98A}4T- zk?3(FlvX19faiHwsn_nU(Ry{oC16&E>JMwLD#GEm$Cz_o&l+Az>CoUjEytKk14(-+ z;RiX(=3BsKY@<@Qp~iF*;qowx{JxB(ZHkO>%?q=!{0PXE$61-Z9-&xE%d6$hDYTXz zrF#Rl#RYKwkYw&4{=2;hD=l{&&)hXb(Mkr7i9N02-1O_g+k*$-EuPE_UI4i0OR^Bf z&SuMe5F09DlW<+(WJJL&Rt}l!mfN#0eTXsTtQn@Pf8`9PEllQy0-co%gg)<2PpvSJ z5FCVf8ub6d=Mx_^lw(_4?eYfy!b0fXIdJAiPNuxN_Iel++#XX^T?5w@6u${9UKC1w0apOGs$#?M zm{c{x9Vk1~TmD}DYlwq)+%6fDwBp?N_7kmYqA}I0@dq8iTIAj`p+XoLymbw)DCZc; z+@3WROWe8O9bcUs2z_=#NDj%4U{EZg*9+pC2O63IUAt0SHp{%S96{#cD!fyhK!1_j zVCX$^6Briua5-r;LAVVH0MVS^6DRx*T%v@}x^onjFPa`uaCL#K_O6Oj^G)RnI>~Q}`r6zk5gFk)33xdaYAo24YWs~ROps0;4@N~bd*W(Z@w z8~iGnztq}vt{M{G1z@=QA)@F8!4~6cv^(0B7jrXxB#9IrM3dKx!cXx;+I7QKZ2YKi zbvPkQ|5ciyjw3RGf;5lGrpa3%5Rv02^ubRb$gv9?8RT@jof3?RvSxP}12k5{9mG_C zyDcqDMFl-!_jO=2(_kGRm4W0&E{`r2#MK06pZ;t77f3xJS0(W{Vh8|%0wEe&dGG}S zE3k)_Pkby&6Y&k>ptp{1g~eUZ?Ky5ZrapLSJxW>h8x=y~VPe}xB8rKoooi#uIiCID zTQOTe2K#9@Do3-PFrRVr3PT49DJ7%Y$UnH6zD)&yB>=)k#vl!ic0vQ2oM{~4W(_-L z=}LL^j77oMdd~H*IT@uD0ciN$@hEDVjYN^S{3TshB*(IWd7#~Os?^|xJS|3Yr68DM zjd*s<=iXB1mijfpnXR4JYLD^i8>g(~MM+VLQs!C0j`KyqktNg5B?iwSPMWeFSC=#9*;U41$y_?Dh%u6JxK@!o3`>~D9h4ebD0YBvF{=w zlImleWQRHoBPSvJI3&h4&WdrKA6P4v`FDi`A?cPhmbrt%<;N`D(hO6E2HT_ygn``=?;&cU_wMGQSW1a_*<{!2b=@IZ7%& z_wLVW-+%0kWVOZDXhV@hR20JMCzIFzZKJ;fNS5bw`-)H7fFb@#FbF`lpWDFyVf+9r zhd;+x3dD9YsVX`r@CtVbVrbzr3JA@YrEERoUFsOV5)_CmxP|ryp3G?}#tu~%+Y8Q} zuXqkWy4={b1ri0wzOz8*e&vevZgK!*EdUQI7?QHf)?%~>=LV(OML;N~yIS{@Y)!fe zs9(N(TT<}yWfzuMgPN60O5{GbT+m_kH*N`yP8rqLee|u0n^lEI=(-O8--xFZ0RR~~ zWa-!&WhpS8vJeUk@DWS`z>@BR-gYKKslPB(|8bW8i=jUN0E5*EpUCPe8f_tH#cK#a zYTswpiV^P}3dPEuYHk|bHy-D+c@c`W@_{7m5Z~&Nh-C@UwYD;R<~zhwqxgKb=V@}} zHL%Cs-kef->hUc-qm=<2og@<@t7NDR@S(m?HI>xj70>a2#t;tXu{T@Gwp=RD^Px-@N7$!~3SbisA|DHQQ_Y z(Y@Iv`V^^gL3$$a?2ETVQPhxI(Kj^n^PVsXs(jR;JE(f?9SMPmr_O2*O7PP8rgYpG z^bNAEb&{uPMSR`kZz#)b1|Yh;OZKEfjiS62Z>T(rL&VeVMh0{4I8l$rdm|f(Lef?l zb=HcqK3h8bN~5ZTc>|H63_hm5u~Nklfb}JJxHFgL(Lf2$mM&;1(=IT2XrBn?R)vR3O<#i-AT~5QF91TcnZI zZ;C|oM6@;8#(l32RQjs8Wf#1S+(?9E0X`e@7E+v469zzLYk(Jm;TPtA z*lWLG<4aaQ#b(VKl8jnDSwTJ#GcXtUf`Z$@End$Wr*`KV3O=8m^*|>VTtY10%udN2 zs&!&2-=_=em=$Ie7#+1k0b2mTY?TM5mqlp%Pg;(Z!U1{|78){Zu)ipRNgV5|Bm?aFl3&5JBV*TaQrbT%Mo*MWKH&$MEATGxBy;3<)=aXZ&)b* z5R^Um{A0;wt$n!rX!q0W`TZ~FHosoJgFWng9U1L>u2a4F&NTPbYybb7H~il1_76w# z#~A*QQnHNI|2)=Kzp~Q%jaPCu{wX@}N2|Ug?!R6tc>Dz%<^?u}umg(s+8Kbcl4foq zuaQ=EBCmnj0jN?4)IG~oJY2}4!TntZ$v|OM4Tt>@bk#<5Dbo-pk-p zpO=9$UM5TO$RCO&z{}_Kk{DA&>oRoSx2%GV`zK=^^r_&?n)?T7u;c=sU!Wkj^=;P3eZYB@tltR!v#K^tUc-A1; zS4>QL^5tmt^Ze!K*4DpiU;Opb`+@23tE%d@rEs1W8oh;9m)Iw6xF;fmC%k<{Rs0m0~Tc-p8a4+`Lbh z&`xjLQ2bU*Y#=#Ev|jr$|IJ8249AUn)F@^qmGa!vQPv{ zlPBfMX{c7XDyWJa6ZlHUHa-GK(V*Nj`-bri<&ZqzWLw+(8&KYtZsFw<9yPb?mqrr# zr;zJL=q6E0BLHjZdt)ubx5D4wLWggaw&Z4zDWoFY93+RmqA|6EF1QM3N*gwWtKaNS z%*Vj9h{o|(IR^j&tx9uS-yEchVTu|qpn9B=S@#4*0w!YL_c{$>?~!W*1_PG@r{l~) zq^8L%xA!g<=huhxTwqCDOyV<^R#d^w8Vd8(TTDQi%oS>c(%Vkbat~#-wB@|R z4o*^J&=+&E8x6JcCTO|kjE#sk{G*xsTd4OB?m1#XpLT&^6Z%(Vp*-P>cxa=4)~pQA ze1=cawH>UbY*Lm4UFQw@2|%3kyNRZMvRXGUD`l;%)IQqX8JpkV_XW_P9DoK30W@f$ zrrumr#yvG=_J8w+pBVig=;iOHpXGVoVb`5E(MDb8+NPA29R8Ni{TWXG8$&y{@8GPj zIQ93h{vS|+?aWjDj$Z)`JH7xw5I_~^i874J&avq`Gs;4>9ypUg?0wZ7MRxp%gRPf< zy;9&_t*6Z?{Vu#iXYzFmMIe?N26+HHfZsfDBSvYR4zzJP!1o#9Y0R&*LnoLkQ~tW8J9US5bP ze0;N^EUWu=1c_8|J7IwZKg?%~HhUNSoL>b3N5ReS=Qh@1VkwP@XU&e^7NEhi`L!;7 z4OI=It)dGZlOtY3)><7&DQe%8r^o?AZSe-tA3dh!LYb4_+9XrB)cvDTnkfaMLw}G8 z-#ftJ$qSWTK@ny+oWjDIq6c81J5p1__ADKlN?jTgNDVq-J3L^Mvl8}&yu?5=g773o zjpFD`e!0v*T*2_MgzG_7LpjCE;dNPqLNDT`!J407j$)Ppt|EQ*xv&Rjd~^;m(ms&N zgOuK^!FwcU9`zR3ISFmKl*+wNEijbO`a)`p7!7-RR?}0UIEEZQl>s%94RMzhD{SPu z#RL_hrc8stU)icze?bOry0O7hW73LsXRt&HzGyl-b}K)-x4P z^JrBc`VtRHBt6G$x|SMG;lRcZ+a$=0++NZXx0^lCB^(?^T9HgCCve5o3I@7DLV-1W z9#yZ!k(xDL-HLT{Y2rad=Nxyd7dK`k z@g{)It@ec{*$=CSRY`6n zid&=_+6gZt#h%yEJAo~RBvg}!1X4Q3Q47=$J`%X0I1M&OB`7+yTXF5#*^HP6K&^vB z(W-w8gW$Cp{n22k-*A|sjEZ;*7oJDO^V3_#*AUCgGx-G!B*gV{rs6p=WTkjE+WxwL z=WUg({6b?XdzTW5+(O!*+&*%fG74&H5hrqG9UOG4*}-@s;@=Co$$2+D$-fQ zSP?2rphvo~+aVhYC<3;v8Dy5&PNF40hTsRw>y)2;DZ@-;IRknHUuEVr*mFt{dCvqT zgFgk{(BkMZlsb_H-Oa*R3)Eq#PMphlbQRyE?8<8vfxgfo{N4DcwaomkmgveXmFjIR zet)UaODiRr8goX2PJ9hO$1HW%WX=k-Ng|mqiGP30$~8oA75^J}r}rNlUi?$v9k70U z>!)mT@%M-SLGH@gsB-_X@WNNouX2AbHgZ_I67ij&1&Em=sq>%tf`0`zo7)fhzsuGC z=faE2-$1Khkq4Il~^lzps( z#J`gmCm%dVqZMBM!Ht2JKRwuKn?9lfk|A?w^bi1gs#OKW2R;cd6bH^=r=F|IUZPRA z2(f8|90JPwUyivmMA^xw)$9gn1hRvlS#h;^>T|4^N*a?b=@pkmgW+c?93BQE8k0t> ztsqd%v0!juu*$$?q3*;z!sQw+)ln!%xOA;ZOpT#5{uh)Z0+=Hd?0Gw^quvf~n-5Qo1q7_m zfS~(dMJrl1VTF`HE#aX8*KFoB7fVO7JY<|(ehQ6oRi2i+aaerTRX|VJt4|S#w&^=~ zS$y;4``q9&^=m!l+icgzTo@daCskF?W`IfYY2*S35Y~#C@hBp)S-QPbY5^< zO*@Wgym$nh3pO$b7-wz>M+bmFoc5I8!>#pfmrww1+(;DOC(KuB2mT#-yW=N4Zv>Qi z%=Od! zhgD9olH+2W!}X{5%xVDLd)rfXR10(9>K&nzb2o$nHG37Fdcxh;??|dK4LxBslmKws zgE3%ae*%FW$?kB8fMVJA2*XxpqCjZ&(mNt$h89L55_l)t|rx{sST74@u7- z?lHFW|0i7d-*xlK#0^^yhVI}rQHRwlr{cq@)b6W?p1e-OBR-b)m+%FQ7Xk8IA`waX}VW7$}X<^pqlh)>Bs!eQ_1(hmfN&+cEoE}A;h=tlyeSlF9&dZnt|>zE?sMmJr|KeyW;M3Q&~WH8hp;N-H?z? zz?$KZ7f}qmm=gJt6FXi0pt<6df+3dnwrC((T=ZdH%2atm;_XhB{LG0`fe7_|NkD4A zMcJUWf7RJ?yiV4Yxe8?om-i!Gmczw-U2T<&L-q4CN1gT8cL_4RGGG?z`4VdW8os); zttTR28So5qgMvHd{zq7`91F#}(IiZbHQdmL>1vz}%F>?XWcGPrVxamt*Wl$cBazpL zPUxR}ZfefCZB&O67GcuGj#y2D?NC%^;bez7($z@Y2to42tx6$ko;#_e{=r)Q1gE1J zKwf^!mp2(P)7q{ppZb28HRSI0?@Qh!U{p-WO7-4!NzGn=UQOa*4|RH*M{ekJ;O$qA zdtK+c6*nq9V2sVW;~xpBzAd?&8@kF!Syv(5j9%q*NnSW{I_K6bz?I7K(60@slQF{x z$5r(@E$lUd9a0XU3Ie7WB-PV( zgih;au0~MJbD+q&8mwPb%)LNh+vQ2-NU5~<_eDR$A#RH%%ZhqkfD70sH<}xaoe3tz z%qn}3v5tS{#=VJ-(1?=ROpOOLJwp=EDSjuLiV{S)Z~+0`*JO5XH`xn^QoXqB(0d*@ zo4wHugLvFD%y(S*na70D$JGob;}<5~WI~~#-u^FN@3P3Vs9OXPxS*0nVo<_krn!-S z5LExL`LeQHAPnXYqdaNgL3)sJV)V1`I0u=ZBp`09yu+*WlI|`;7hs~Aa>yH&Mb|~Y zNeRf~=UC>XegakF8TCKd=)a+Jj<|G$1;C{X)c&b5V}MIv(ZX|lSZZ@CJcx!r&3BiG z&K`?6w*u43<6~;0s~$*{(18T<8s~r=QY;t#M-^sYi;)psO4(?@@fM$E&zn63B3Jw6 zBkqv;ptehWkwhL~AWPuxotw;8=?BG2EyK@9eCsP~sF?u0rG4c+lWs!V>tDJKyl;AU zEy9-N5e^l`F|;dQ&-K0@o@rCI0OdG^l7R#+UOgn>RJ?*_n|`Cmo583*l9erVm5i6- z`Kw+^&54BGb6p9{@eL-?tc_f@4_vmkQe$W)YoTSIpKm^~Vb6O$`>BIy)B01{8mS!yfEEKbUwCCC1J^biqN17 z#ENY$yO{(}N8pS8s6wb?nTj#hVZP9Za8Egl2g`nVp+4EFV2ucayH z(`sF6%^0)6D+)X&W+f8QQMdXcJ&t8Mva_p4PC~LzZt8n+AB2gQT|UOw`h`c6?_t;t zh1&OD#CY%KzJG)R-87?iHH_~%9R-rEw_7ObfgBUy%***cYTfDT+ zzgct1dKb1#z~1;$=oAsWu+{b@4CkUgTKI(1^gw}#w^>0$eabUF`-Cd#;ukp*AQQ$fx+nNW0)t)ycMNxqK@eZNZgty+fpAh zd+^9!ttTsw)`4ZbUtBzV=grrhl>_=m_Ucc$D7UdprY`u1u#38m4m(GH5# z!jBK55FXPo9P#GWtVe7+UAFT*LMjgGo%bCh7J(<5vkH|)am3qnP~G5UyJ&8b(f3*9 z+D+V@&D5K8gzQMwW1qVH?qvjdT6KZan@}_dQS$`V>HU`^dT4Q9%0Vv3z%~&DwQdb5 zmqY$owG#2Pr~0@(m&W*W-pskUy=N{7sw!9LZE*{vsaN$bhb~6H-aXG~x7?NfxkD4H zJu*>#>wrA$SkRxqEPaQUxY!-%lgDMRciL&Ame`COON9 z-Gly=MzjLe-Yf0oFNCg~xOR11!;TYe^6r=E-m72LiR@WG@tb>uB$sYQ&rrOoQ=i#0 zwhdj%YQOR_N`h)K`3_|$=3~cFT+i)_dnO%G?ET?oK`j>vq+N`^);Aveu-Xk7PaX4$_#yQtmURE48{r zf#5_JvAE!bSC?Pyu-zaI7U8EcpSW+z23duR7#$jFZNc4|4_XU=<(m&lIKI-oTCw{RIn z2eb;Ch20yQIF7^DQOwH*(KEealKNTCx<5+}wSGBwo#JA;kiq`Grvb4xI+zel9_hmM z16nS1>ooLWzGWK+f#{e)0(4SR(!e2w|B?dCcWL5ylU$6_XwyD~XbUY64*asMHN`QI zH9OJx090uc%bS#57OZzHOL?8JLgli&h6IJH|Cdw+I8;E*NCOhFD32?zEUJPqivHN zLO?xLuCw-xu69N{jw|{{)RssZAPfDI8Blg^T>0mm4^j#?%p9btVXpVBVr!4$$|+zu zfs~MsL9u#I=|l|H7LT<8Ngo-6@rma_WHSt%-Qti1Us_QYAl!?9WQItA&XACKR|NKj zUeAZ~5>sutGEdC;tlO|g^AV678B4*&FwOn~jj1aumFLRn$Q3ZOw=QwzQCQ5A-)K&L zJr@&&d#lDn7qCV1w0E@EDqB%$^~`=V9hhG~<()-EZ1RVgwMee44jzUc#@gc3b&#^n zTkpk2-*cnHos^@BDrWL%-^HXPKUAZ8&;p;VE|0&_Ty;)z&&nJ3iAQ1B>BYVeoa{x8 zT`u$*y}}3eSq?tRnP@qhYp>y62M$hO!|c0BLAd0$EE=mAveI~J&D6;lGP!`4QMq4M zu$CDVS0xZ9Ofy-wzz^!*C@-tvdO3LBb*l9cUJqeE{Z&sXyF^x!jX277@9>^D>Z#b6 z#hb>NJM75;B6%%eE;y;b0qH1paA|-*B1S-n8V&jHa%h04;&9LWHEiB@kvQO~Cv_q7 z`z>BZ=(_cbM^W|g%AD^3_I~Hj@5X3oG$K=a8ZZ3>tcV8j@4iq5c7%K>P?SO<%BmNrtr}Py9Ha28Jmi(o)S~+;j`QK_{e2aq;R-6 zzsaVlTouTK*P3Wkeg4(RP~{T5KEMW!M$1^-Lva2WlcQm4!^NeIuQvFEDq|cc;C^`P zWgybMZAupjVk0=HeiIBR6`0q{Kz zoq+3mV#V;6(|NFy${0ICzw?DnZTaip1y4t+@&+T|dgYDjM4^N|SvVY+J+}@BU_E=I z>NTL;$M-@0B;C0tFSP##w}9*uY`M4oa)mr0b0VrLl=0xcZMR%A$L#W@xSsw>1YGC; zYfTSapLOF%XoYW{#fy*8>M6hs($DG=kk(@fJgb%xauKD`INaZGKL6@L0_7ygu?xaqQhG0X=K$8|qStwb&{ryG+}s%B?8^p_ zBtT&XI(6=>g*6PhmLQrZ^FoSoPvDio#$h4$MdFTf_z2%>rS`S_GL16i!TmRRbkQaA z9fyMl=iu3ra287+9-VE5kJ(6ua5sH#=bF}ESL`48fd?*DGclqj#c;sKfD-_h;nw8j zs9TX+@I~9~Lla$=>|`P}o8FudYE24JboCmreU&+mNM!)_)=O6**@3TVnab7!2y9W> zDz~rGbk8zK7uo3vIGP{&H= zJ(mGoBgw+Ta#8v1)6EMZHlg9ASS#U!vW15TV<@z2P2g7lQ0B zh~12 z9lYZ~MTeb>mq(i0oP&BCd3jG=AaEzsCeWp0AtiodA?}pKGUlJ2y4|62hDJSMq^)G> zDr-!qS%3amZ}B0;d#+Ezp4)<6B2dJH78%y6C3Yg`b?PjZ$(=%hE3(edjE?hNCsTQi zpGbFizDC@q*IO&N<@ujpxri-?*Z{29HO~cv( zw-|>~5S0&uz2c&8U#YY9mNMWKKoIXx%6O-5uHM9ykqZtvg(D5FNN0AD6pQ6XlQ-F+ z+)Y1vnr5C*cLrOI$(S4O?@M&iOo}6!$*y0`R}JYSM#O6|R8F&PE{+9kMIEcD;bSyA zq@NWd=id{PjYux{jgtx5V0(ndeARget2*YGI$Ue-H%Ng{Hm2id+uS%qnx@V;t~XxB zlJ=Bjd6;}8K`BVQ^wK>B_VNs?+uPzPkfJ7aa6kdNdibu3pIdQFz~D-j!h#_V`!t$( zo5L|cfm*_luD}~p%g$;#EV<cORG?iSfT~w8g!tqrM@1!eT(J7>5g{< z%2VfXBqt-PG+dS$ET3N>CY~qGA?P+bbmoL>r)9RL#TGusXiv;M8YdUVSdBqVV9Ubj z#3sW)tz8bsLvYpmE2hIm%#}$DEl_371L^nDA2zRwUP&M+*|n7lNmF)zwijS2A)BnaV6!C_*2wHHNZTRH!3)+B91gHUaYH3QhCO>M|tZwuNqcpDpE! zgv$MpdH#(`+TmJu8C_WI`qN6>0fha8gseT(k(v3nO!AA@lqyqZ`eVZW_agMp#Ur{; zhlHuFVi-7HT)eh13%yDEOsIfA%a&n681rbFUKVk_&VMuFIT@5$)@4$22JKDP;V91I z!%V6<-FwjC;4^4J-kU)E0wlE*{orAG9%M1jH|GTM+OXuN9q)PnQ<&;_LE?aqQtk}m zn|EFn?BtI;Da3ZXIR4C}dkSTwnPDs5%(5z?)W%A77D`Xd)%xIB-@CzUnojO_cm~jB zEF71Ap(7R$*}OqMm>TC7UkZIE*U8lSWI1&c%|NHcUpC*$M9A~x25l>;p&ApCT~YJh zB5^J|Pt8RIMg4klQ)P+_vX?*rk!HrnOtO!e%Ur@ux7!F@Wn!pq(?&v0*ebhx;WM>5 z)}uS(L!q~DcG zPs02X^5NXp!CQfG=Qt`R^UDlQ-v>f<$iZ9%)vnmqiuZaQ9P@A~#pu*2b6mhBZlyg-+#yEDWK;e!#MTTN?p=o2gKpX@5~5?f z@sn8t9%5f+jih3TQ)bsh9Sp=bSNYXF>>sP(SSxUV7pk%C1C=oU{sLOwm|pXO7sGN^ zGM0Smkq}c~aYu@E7E8AT+JlfVYhIe&ak(CfcV$@1^afd`&|?h@T;VmC3fUXs_tRY4 z(=PUA-|a_o^zlk8Mcqo$6DrgM6#0?V>D|A-Fbu+YF% z$-*n|D4~9F-&s=@?3=>c<>LbGSG38&$;5dzbsdtPAHQO%U3-(xT@m=mSZ@_Ib|~CC zPWU9Yzuz<+7YR{MC->2?FrZsGp3f_7UZl0JeMel6~_ElgBcYeJmFOogotH8W zIoPLXx!fCl($$zACtpz~u2husxIsU6XXeBE?P##pz4#sP{zAs}?JNf0Lcpb{R_E;O zdp}iO3e>oTi6V3?_Yu~L%*Him;vu7aU1fVh_bOIIsU7SN{B<1)scnAF>bQK!ly<=^ zBZ8^#Q7=Zhf7o5`W_av0rbelQ(;>}|k&EVfg_Kfz0$nbTzUCp_lA6M^;zAC;;lVqm z;jXhV0?jP)trwRSZHqZy5`5x}dv8=f%SC8&@AczA5f3Wjrya+x9|DBJ0hIHeY6y(0 zO_+Br^UbB1G2}%QZK42@ohMJwN+e;Gln`51!+gud`EC!K*J4QSqq#|quG>jnstu)V z=Z!?E68`Rj&&rwx$dsObSD#7`wrH9!QxC1fy$hg4%Wn&L4op7h>^xPU=QB!)gcO$< zswG$(m+YOAoNEnJ4*R5@+PVtK_Z`7n!eN?F;W1Fm!cKp-ql}IjBh97oQ-k|lWH=yM zF_}$|_E%lbaUp$wqGwT0rVE#Lu1!jEj-(>ZJ$=a#W>`ze1L^hayV-9NH7o~4EjvCs z-E+8j;0Zj~&0n@kr@j=?73XZiwODz`gwP=OVjHWR3>S23ymz*TsgUnEfR({U=v12I z(1?epg>SuX<5gX##ow^i!1X3fPzkXoKF;4l2>%BRp)m(uVsYfo&a3cA2I5O-yM zg1t39lz1<%$W}R;q}WfE78zi&l`8AUgm@t1|COTZ1uCp->z&*n)swjca)q*FU1fsT zDsNt}=VPzSd6wFyibwK3iPw>O3GLCkOlQT2;EN@AeN7?ky1G2|+hTt6GwMA$ve(I# zO5=ru#htJhCTF*HTKG&hSb7*WsKVZz$>?;Bb7qv1ox2Uod?&{%;uoehVgY?bLUnLS z=6KzZuRQng~qh3glUysHX)Z3}**ENOr^Mkam-t`|gYT6aCF z-sr5)^K(k?`!7uOB67(o{MN}S+xfFEryrOhbV%M5#&dObG8HARp1)Z43EJ^yL&dc3 zByp2bCsY1dyvpXx8K#Bz`H?j)P?OQRtsrRUG+5t86G{gBKjn>?H=?;7moGd&X6b|@ zk=1IYj3t0F7ep+{=tU2xisaF1Vrp7D!U?9;31$g|^1usZ5#uPda`sj&dtgu{#0zWL zk{F9@Ih0Lk9M|wWX;CA4Qs5ql{Cb1q#f?OXhHH@2Wnr)`sc=bLCAu0s`~p_huMxbk z!(P1+cg}{8kAAP%U8bc#@M63vUv{*lNljDI@q_3|Phv02xu?nY5wc$vwmA=UK8mn9 zk~$yqLASklL;B&6Q}5Qq5)bq(KKe8yhUjCnvfQikas94DL3y@LBmKeeM&|K%x`CmHAH9zE{c z<(WQ}nA-Yy$`OE>v&z!HJUL+M4Ux$I{E?U#3L-kY(F*tmjuwpR7m;;@yHcH~%%u-B zy3i`>kEs>TE}?RQHn?h z0Z|YU5hW{VzU}+od;ae|<@@fv=bm$X@Be+^ft{J1J+s%WS+i!X z-!Ho%E4}B)be*V}H;)5quw}QjUL!GIA6<;KB1N<9Eq?{IfZz*E_!%?D99B5IT0arZN-Tw)wS0fb z{$y{_vSca5GWM+5o-F3hs#YY4UEgf@d6NlbY7h(=LY}Okwb(UV9m`lRK7m#DkSN8x z7-nWt)V{WtH;yNzUaf=~wmHOjUh$ytcR?;z(<*0JjL8TA ztRIEwhos1q=d>uGe@`TZ_{f|h*=P-$mN^n3En0e^UNbYyJEzt1*5i|KkgCZD9RSUbq?PyNrKB8 zrZfsL$elHNnPe<@ik0|CEFNxF)))^!SZ24WI@cp|Zwt(gpvZ9aS88!jT5Iu zUX#^4Za%>>DNDqP8#&}Wp&!%V)SX9MsFZKLBvtqrQnV&3xN;X}W+d~n&FOa?MU^_(C?avARB~G-ll&v`OQ8eggjAUO>$%{AYR-2op8g=qsv z2BVnEp$b8oC*z&tEs21`i2EBG0?v)>h%-fMzm{qUMi!EH)E)rxZFF?Jyl;|lg1HhL zS2HmjI|RGT12*}wO=}CXRtCYyc2}3(!#2_xeJI=#-+b=t*-g|vA5P18c@YB50 zE?5KGrYbu3sr75V*2@>jhVUy32{2dQv~7u7njPOFqm90H&AaTjEBA*{oJnogc$ydr zIS0d3pO?EzPK(*Rji8#_7(&ZqJqcV9c+o!ngPhO+*;5Rx^OZSg)kOh#A_c%A&$eH+ z2=q}nx2^)xqKF!hll2>95sN0Nf7 zwyf2YHu<%dG3o$*a`}SXd{H0D8(vh?{5?iK;(DAaPz&7YkYU}C8t?`YxWBxZmDLoo zji&RGq5p!N$M>Dm74^))#xJDqly5IPYrzbK^cE&&-w3*#<11a^S3b?#eE<`w9d?9$ z`<`jqvt!pD3QmM0q#zEkwU}CCcpU z$*>p8jcuy&5%cqF=%ZI=p!jSkzBfvm@$YmR{r2noFuK3jW$+tM96>N8wOP#Tn-q$7 zJ@8tb6i-!|sbQbD-oN|@!UK;WpT9N76dNK&M#HPCiKb}4jT1P2q|I4gKx6kHi(kHJrao9+$krJ9s1Fz)dM)!niEU&0Fi8F9g>nZxze*=nz?>)B*vvs@ z_#vWY8I24P5OAXD&T-aM?R37;Em(7@lettL3c+|`5ig1{9pP79d#!Q2Z3zOmw$$$2 zhVtT)K4w7}Z{d@kFYLl2VC;M)G5MPD5zqm=9T8Ciu?2Gg(aVJ6#+jGR>Yqi^7q!ZS zxhmPGr)(Bo z%j6v+q9(~@xA9xbr{~=UHu`L56s0NO?0zFmxzF?4S`zrM3aq855%m%<;ZHb>Z;pg3 zATVk|&wyZ+%P|YZKV%Gm;gW>J8f8_`nF>Ys^FQ&p3q}Bb0FsN)a5YY}4Ode(0AT6? z7zzVmT2^FnPykHu27WO%&V}rh=+`JlnsG^LTi^i+kE5a)K6fsO08yDMuS6vaL!GkN zIZeQ?IeLI8-Z%gXJ02*OlL!owf3kPx!IFsN{f}k!&+o+J0)Tg15ZGnt!|pSxghv>B z2Hp%NRd^MrfBk_D)~rCspG3!dga{MBhk=>oPymby2Vf-0y4~E|g3g~m@9yS){=7d4 zM^@^IDlcYOd!aIJ(*_5g66BY6-;BVIS#|z+Ps+Cd1d0YwoOsA^l&`FDNj)L_GqPl# zM#>(w5PUPN8lbvchQ`)j$3rocUY3mzXokLy@HCj{jDzZ?Vq-d#r52OZ>xe5oEsGCG zl6$YjjDa7I=?C(K*a8kjs;w6~ru7==vnB z%2M=YNrtMi01P8gi(w-x*I0{CZv_gX(;g(}F>;1n8`Yp$M!XjEw-*;hNOyU$)1dP? z^w0AI00D8|Qz>!jfrGsm3V^2hVJp;s901V#0F)d9pf3fW0JO>{_Y+LyUD>4>2dRLJ z%-+0$IDpA**d*OrjsWNY+L6ypWXN!4C4jhc;XM5GcB$ZMV5P7AEuBzecuAZ@%L>GoBBGHIN-~Aizo=2Sg`AW3y>P;{uqeIh1ntaY7I@9U3z3 z=2R84g`8)jGJulT3P2XFDK5xg^a5ben3XAw zc?*pyU_w8b7#e`n;M5u|8@#ubXedA?Y6MV6>q-HX*vgk5%RT@FaOal<$)+BHoC)Sz| zaN9}6HUe;n)!@+Kyy0Ps)fz~DC42!iuBdvPQ$f=kcoF>LO_6u!zbe*9F+J;`X=M3m>Va`+Vs@ zgoL%kln%t;vDW6v4h9Z$ebBA|dg(qRo`ze|SUtWT=yDXQM1qyChW$h?mVOc;_aXaL z$}0FU746Fx)?6f~1F?{MiQp)Mxd@d(@ZR-Ua9E8%KEP2ud^!VQWBi<$(cXvhIHY!W@_TkjGq%u5=DP z7Mj|EJRuxnPDk0GA2A8c2V^XSgbS^w(t3)^0OcFUt|aAbE6CSQE~R z&vYHbN=C=z3ILdqA0tdn*Tq2a)Hm0UT*yBx=up`%z<{z=GYb#I9?& zrU_4#W6}I#n}G9VFa^kqbj;T=#Md!b_P^G%xviZw?Pq+QX+UeM!*EKVmlTCJ#6#eN zv^QZ6GZM)H+|Pb8fZAoZ1a& zL2c$4p!qu$@!dWT!C``nTSR%^r0AZ4z2(wDr(>qBO;|wi-$CSOWF zc3|E6%@FyVOUo|cC%fEd3Yqj5IPvG5gN5~>4pHg6bt(}xi@OyGAOFK;bbYL%fKP2* zb-%)S@V&aZaM7_EW)}kXnf0G)ssRjT&m>;@PSfgBrirauKkMd7DwV9*Rf%94V6Xmq zL!9HINUh!V(@xwOMDgqNL;`Tr*Uj<-Px`MdEt*9fdNQ|B*sPnAuRi;QWS~PHW%{Z1iNY5 zK`!gW4RruCj_H^?s;vFRxc)t)`F}hNeopxEktA2x8%5Cl+l+Kf3c%O6&hM!$L5`n~=iI@Bf4=;-6>?D?+uv+7i#%(DxDyKRcy? zGB_-_Cd!9%k?T+Q*nw+ah_{ex&GS|G*48yaGL+2GT8VC!xX-lgMBDn~vYqvPQiGwU zu>7$$0fC%T$~=5C>W`9h4)=)4F}}v9k0MOpD8G-bBRR+`=|Ew*pq1H;&wMcYEt@DB*I+@ z7!j8qjRaSFb7 zTv0>{4?vLqh(clv9#Xt?ig32u&7Waa_uB>jsT&Cb)Dzm@K8$Z16P6f5PEP4#<}`(yw% z`J2{7|3fwi*(>|su+2|(2_wRl2aL1_?QkVHz2QoyU~c6g#+vE-HMoRhLnF4|0`$`Z3&?cRX671 zjn?0-h^{N-2sn4Q!^LmMwlY*5b9XHHZEb>slmR)&oqL$%OL9$v! zM$Zk6djTmAopH_2vI7INV03y5R%i3-e$k-g*&uu8&#O=i~|B zb5of&j$Qt8=$q6Pi-IERnu<-*m=`VN_!8Z-4bJgDjE+=*?o`(2Y;f{Jx$N~W<4j_R z4>;9XIY%#vFfA@eW>}oMq{GK8S8`Q=$>ZwntQ)9pnks{rtL^)lMBC&H zp3i7N-zT5O-0xN=qGdJsdXPtvyj|qba~j^F(1{(rwB}MZflmZ5R#FRR*_CEUa{=cV zDb;7-loMAf#w6?QIzAu$l-8u2=1KSzyc5@0U!^Osf!=F9>tooNHKdlJpBqTDP0C4{ zx%3d9B5g<iUoOR)Y)Nry2}PrH`)M2b)g=Q3@*?Xx!c797i}d(2uMndQ z$RXT(D@nf@5`hRZ+TUdLT zam^P(tUZ&HVOgRJ`(lqKd+TdlHq6=j_34_*Kf-lb?0AC&D&4LcMWVSMb07#eVRSf+ z*mI93o0s`?<*vl?c4;pK(FVeph(Txg;+e8!#XNxEqk45T>Fr`Ym#9;ZtQ^69@|4tP zORPyBYs5xY3Vcs>aZ+LoWNd1Qv1R?-wIsK)sLWS(6A24>Eww?a`2R$}zeJvcr$YKB z$tm^~P|cQVzV1v%_S`s%wG+KGbY(-J?|lFJO3J6aNJFt2>~qT!^dUrRB5P`Xv%G;^ z=4}4>F{(6)MrSb{jZJO+o>pUAQrodNk>C)620qce%F`%yv_!cozZ_AQNCS-jH(9L@H}p)mI|3SemU413TrY4ljB&tPN%HPKR;uT(Z7Q_WtRBDP!%eYs#ZphYoMJyKx@0s+*)9G4v)oai$pe*;Hx!k$mTFsVA|@U4P=Ahyu@9;eRPpKD z2M^z#G#jrw;=1M5AwcRWq_WS6Hxr$EY04c-PiJ^Eb`9R~Ch{!R2*tTF7)@=aM?CRl1{3TndYOPQ4qxOn6EF?X40_>CUgFg!u~=Fpa+@!A2UW%xTL z99L2#$@W&!ym1N$g3eH`%wnu$jZKZBzZdO()OKE@+OwWZ^-oCC16||E>krvQBzZ1i zppMzdYN_52Da$B*^-XV#qHx6Lmt41Ubpod4@9J&`Xa z)X2mC58dw{p!n`@@Fn|ow2(9s-LCulbv@%}tA6#$e{(Qi3_KC%kIJ8s2!A=7ZS`FL zq+0(=*44l4(tml!R*ftDnPt>}imX%Ptl&;%AqQTll64zmClEuZX1EVvcKG0QaWhYz zY`l{-T0qZJ@o*L=J%yRRy}KV?Hn>y;0m7Jxnzh@}XW=K7KHlWtWcALpM{m8<7bq8% zYlbnD4J?%NJ=E&!ud~YhU`~pC7^Uv`)qk9U4x#9Rkh|fLufX=khIXFT#fE1(edFt=LoYC>c-1yK=CInv+%b{l?2Kj_Sqi!cOYATk0`AjY!y_57sC! zn@m(al_NqkU*pU_7(H_Au%+EUvxiJva1o;yqI$o^l)0zic%2>tY}E6$mt>N24u7w1 z@CqH@>}j(dx`D_mTAYH_`nQ;>Bza6HoDCk!UKKNiaojpaIjC@S@RPi-ZLWQxR})!( zr&8JEK;#!?LM4F|rnS~o3DRunk`(*jCURC0ezg z3{%Uyb2dVw^OT)$1<3DhdPJ}1wt>8{!wNR+`ZjJ7c%4l&lH&o@ixJ10Y7#<|ZuOLd zVMa?rS=~LEZKP43>9=b`&`(~BlDW;|cHctPF10h2a?$9X zuqF$$RekLaBl}U{O%69jyb9SDx0`aF^7&Yr3^ru{Gi2|Ena4>(N<t$Tb##1HzZY%Kj zDICoq+CPcy=I^E7<;>^aH>*pEJrte1Kiqs@KzqC})O+Wh?y>6P!iYaAe?}tw!)EG# zS-JV2?TMb#<_HdZvGB1~-R4Mq@~p3SgT|oO?dq1oKR*6`sPUIfL?8K=Wk0w3BeCnx zoHGjKl>eQj@27h2>R;XpR?MaQ095A7sg8Bg%3ISFFNIcrG2O7Bw}x4)-c-In?0)94 za&r8wZr+XDJcHjjs;D&JU35( zY;uRS&Hknn<WQmih zyS#Ea9ksARM<1VzM7!}w905Gf%s$n$oyAuBG|xx+>D!P?{AZKL;*_(d%lb|lN%0Zr zgDHtQDv=Dz4SS2mDjtkdXqwo_2cC}bodruj>DwLq_Qfkjq6k?Lc&v#ne>;k&`tFp% zE{Z3etf>KU<{|I`!LJfedu(%}eJI*~io$oL71zZl66X0>Qgi8A>Rd8OWR0&^kt}2y z69M)`XHkb<)4(81^K?R+PbC^pV|lXm#=ZU&L;{{f9i_cgWNXM99TEDN#Wqe3CT*5{ zMLDw-d`pQ{CZvRzJb|72L!R91V52H>oUCVB+^ygDaZ?}Sbc6sX=I4d3N>a>G?p5epQs?X zSTZCfI9eZ_s%i4#?woo+sGQKp!b96SK0mhgP(ELFN2pt6=W;JamCL$t^ho})IZ6c? zlC-E&yzv-J|B)>~p9egBvvf2@Hezu98SG9}(NwNO-p%HdsUm)v`qh2M1exVOOF3Tz zH#$^WiHJM#(>eIjw#ta^o)8C_N0+@k=6y+bBdrc4WBZ@+JvI=JnzQ4YyRChbiVrS(wiC+ZI+Y1 zVGj(J38+ubPtu_={HFkx?y=<^2_oO%#Bm(p41nf88w+O#SDs9W75L z>Sq+JKDmX0E?)Q3+fL>v;sA$TQXd&Cvu)F?{}sxz4tW!>r(^bTm+@TZJ20C)*L{*C zlSKCs^+gSZ`)DO!9nyfYU7L)3v~zc?Qk=D2?3K>P+~LrRi9qD)V>1+ir0w%55{Xc@ z=m7o6LXS@y)JnzT*VV^wVrhEcJPu#Gb7kk~=1M>9e!QN_z66c1A71_DOXr(4dJea3 zJ1LiB>_@wzoo}Kj%shA>XpC_fFQGL#+-jByrXHqZ;$lNFHHc>m^c2t6$D`XBCS>9$ zNQ2Y4S8$duoybyOwi}Lot>{ro*Mi+U?FPl#h^C(Ai#()dwn$}`(ZxlU-cKQRs9lfe z-GEB@w*@QHknKnLGkFXRbl3WvWuM*2SNw&cuTd-=S9L{CfEz5&A?-(xcC99Q}~^KCEN_}2^9e-O_|h5oL*WI_plHWL57 z=KP+GM!#yoe|t-V(17Lc4a+G$ZEZgh9=`)djwnrn|k_)#gfim}h> zg9RILGafnc9;yaC)ibiNTQ6#f5=&Zk-{;*FA#q>F)i0*BC5%aE&_9Gkp(3D;gP2m$ zWrBS-^AMMpct7p^k9-kA!Oykp-P8y)QlqG@>#KS^GI8(T$#F#}$LGw-5jw4-p^9S{ zOt`MkCoZcJ8nMsqzr0;%a6-n&>2v4>?{{@;lGis->7$dA&A?v}#9`cCAG_$Wz4A8G z_c6m}MllD1N0bd+$?b{=fvFRf-zXj(-t=EA)|vS1!;;4vMoI#Qlxfh>N)X(qoz)pi zSHRjpMh8suT7$1r&i#OH$5ag+qclzLk0CTa4B1$JyFC1a%NNWdJ|xI)X9RnNGi+fS zq~iSYc+G;t%=*Z55#m(EF;_R27OALEB&tRCs`bF4nc7e>x2J)4{Y(==)!%?ggdJCF=3aFU~GaJg`FqH_XYfQLdj^(_{kjx?dWmY zDY_`*GdAD8QZrV$Gs)4FNHHtzW#KxO|D+@LBysKqny>Flzt-7?7{0oLNrK}rS~l}- zDz02Epf;zLqjUU|)pvo8vF1$706w$WP6kCVKR)5EX#9Yauds3rE#a=HEwA`M-p=6X zf^z=qbR_8K*R(T8m9BupHC%c0$TVZ4fLt{B7O14{7qV@KYEnI~aFV~G2ZG^hG%Jnl zTrnd3Z;;4f0fpngY)n<_ostC2$l%LT$*FX#fs@#w^Ke@wh_TnQI`+9As zFViHvGyR^{*(v>XQbiZyWPGv1<+pmqR#Z>*Opy58vniGppd(LQOwQT}&3E*?#bE|G z56#5{**10_x`QBQ4oB7Djmc?Z(n3b$>AC)s%>PHL@o6KyP* z26EzLP>Pt~A)TR2rx%xsSFX{&MlHtd4xVh7474q?!LUafz(Z`ThPXY=n0{eP$r`IE z`n#1GR4#+HL|SwC?8qt?reRqwVc!=q6~ouqOKh~Jq1<=ZE?DZoPrbVDx?~`pxA~a9 zx76oB7YJy~_GUZnXucW}e|k;$C9~j@anBWzgup)ak&N3XTp!XV`G~&W3IsC>2TPkB z_hNH;FVc3zUe@koa&t?6<=`t^i}Z${T*Pr4o$?k5Hqrmq2gmry=YzZ*+lkfUJ=$4m zEsdW*)Bf$U@tehPArE?VE?hd5=9}~l(obUT`OV7opVGdk#+Z12`+EN%73d!hb<(>J zs@)(s29)lveyH6bTn5C@`TbD4K>&>*<(CIPl+KnP8hXq8AGiK>3jC)y=67gH{?#{g zVRF*)nHm1Hk~2`9ToBz94jFUP);3Dhe$Umcj2MY2MIBxg`Mm?2`1QkT+V^ zRc-7G7=Yr=8>IX`rzMH3>{DJl^Q2Rzth+$QH~(%!^lY)mjE0CW7?C2nWH!dx%W}%M|ZSO^gb~OL& zBnYFq7h7)Y{X{-O@`!_%SaiZwF!8_w&54${XGR{tdGjl}T---wf zU42L%4oMZr=r$Ccp?Z`+x;4UxasT%5YqrWhQxL~wxA;^4no}`gUQ)Z;h6EJAy zZjM}uryp2xC%zUf>t?lHAZ*)22ugjnQBDl+E7%NT$EMUGD__wE!`-SaIASjeY$A{! zJDHk2vwB5eqqZBWlQq@_R>(h_PISUJVnDwOo6j?hVV^%esm$`YDpxpk$l7b(&)lNy>;2@K>QD~fcVUJN0mxlRdr*FFUI&G{!5 zMHmV!7@j-TWsBy%PcPW%U+0{okH=sm%+kF1a~HGR%*)J(Hb&fMtLS6ymhPWr3@cpj z6k1#{RLC;QqKY_brI5PSNp`W&X2S7kl31pNQIN`|71IP^NLuZR^r2Y92RY)aOH0TU zru+jRm-6Q$k>$u2mL_C2f{eWCld1SRP0Fe>a<%MN<(gx-Q<*>cA$u~>5s$t$@6re7 zu!=CQUrZ6B8N}{4H)uV+o*Matds=Aj8bW|_aXOCbywhxQgwl;nosk-0V?kVIqpVIq zskN?xHtwit0McU-Qy#nERi1FKCnPeC?}eS19k?$51I$mVekzH6ak)tk`|n%hZ06p| zaB^t7m{X|0mG#?oolA9kkBzB@mp-r>j-H~Ok?Iqnhr|otrTc80C3>jMk&qUhY<9ZF zTAaBK7<&Hgu4c|^e)F8}G=};yLzz>VzSZ*kJ4b~|`GX5hf{xsEafs}H&lFcAbKmUV z*?f&6osb8;-m|*NMd`vbs>$A`5u4&D0cJ_@s)`ZIhm-15LNBKfW1m%>siaQhVP*svh+@u=|z2~YDT z=nF4c!uehZqT~8g!R!j5F~4sG@LZhm7NJ#UEW%Th?F)q;kZoJW#}*WcZ)!!pD5Vp z7J#d(C6xi$(vO&bG);xV&wCofzeV7bh;mSiCW#70ON#Q-gHDnJ`Z;0N)o7B?QZ-$v z@mzfr$}F~7)xbpiwrJGSNCDsXk~T5owBbYE+-*t-&yLHe3%cinlnZs#HOxXOGMFoN zM#R3Jw+}pt;CrwuB0%``5*^C#bk!z7@dK+lgxS0YDwZAVb&70}_TPj@x(4laskekx+rn9$?N$Up9R0mGquDpzC*+FpTLZx>>?8#^-CaXZwe?+kw} zOQ2lRO*|>@Q8AH4*6gAR$^CL;FXQQ1%ksyPUZ zQ?oEj(%A9|1hKl4hwAI@uD@C|e!$?KMpj>AQ#-=e)tEB)dYoFE!AoZ-woqB)ZDaSY z$S5=C1a{>D69ZzsT|F;aBoOieMIqwa5K4FZ(2)1Ctq;O`QY6=!l3A>;@<|hp+}e;j zkN>0fd}EXBav(3mtB&wKzeJ1GY8mlVW8k~DY!bSeTt3h+xIGg!iBQ)vsrmkHIHdG5;s``IONr}cz#*bA;gkABoJ^MF#8Fwn46^n`!m+9z)Jf#3$Y7>hAf;ui@-!*8j7!keD{dYM zaRQ#!+32~!622LQLSYr_!w34Ia@?70MlX_&mG^aRu~dxD$MFl9T4J|e@CzVvD`u_u z!{Kxf!*+F!Vlx(eI1Wjx%j-=6rNc-DJAE3Ksre{QaeGju9!7R+B7SjxCLV}v{ZQ3k zh*91(QsF2mA!(!c#eUhl+0~TiaK-6gR>P4lJilO_8O>M7av_b*aEI4?l=wJgKGy@9 zeA_aRAt4VLOnpc2+zI2R>~v1w(GE!-5_dXs1smVRDNL&xCi;&NkNjk%evxOGh0945T=Wh*9Z$7rI>bY=RGl2aC; zs!d*cKP2)?D-!kH4N^rWHhHUFUG)+tu9}jPHPSi zN!NOUOd#G^N@$yGW_0_|XtBOo>EVaG%Z;1eItG6pahoWb)Sh|fY4PU-dY%Ax0Ah@< zrY~=22{35}dY&>o;5e^%1-I+cKbF30>FQi!dF(jj0p~S`L3rYfU~~GuEPn`vomvyM z*GJYN2Nt`#`l0fkLRhkBMb{Sag?aH#F9lqq>+0!VB&HNRXSN2I!tqL3u)t+3=IX7+tIAF&mYmBIPL27;%K`Up|R&G2f4(a3xLPfz2_3X1Uzdk`|rM-dP zjjtL@Ts$uf#LlfqkuXCh?jDth9l6IfZMHlbA!fo*9V?TMRwm*%kC})0V;OPL3`KO^ zci883(3a3nGYvUPH!a1KVDWM|N8AxSP{0Q}KUl0m|7z{?eE+Hp@TJkkrf1_?6YL~; zv{1OIg^%fL(nWq-wiGqi$9q-Qx;EduPtCuTyT9*8*kc$`N6_Ze0;oAgIS0@u@eX=s z>CVMZ53_sE!xTkA$V1!O9_DvZzh#QHHWFfr<{#x-*>rx{ z`AJOnPAH4YJz=Be;Vl|#`~Ctx;tWEZ**e~>w6QFJFXdVGrDB?QIs$z9MhC+V?tVrz z?0I2m(nr?22>HXYb6a;stHLs`8ZJjbSnC@clFziJMz}0|R+xa5dU?n+S-;^sFuD`F z>ywN2@&hA}s2C(D{U2hV?)z)c=YI(L6k}8R?Y-Q2?NPi3$@{{&jb;V?Lvd@H^{z$H zn=nHU-(KaKhLw?~^?FM;9&hyqp{8qPElEMWUznPb#e4#Y8v-wvPZhJl>VrcR^C71C z+?-5rGH1+0i^iTq-cp!e>q75B;E^~EmvwT%Kps^So)g#nh|?ur!@7&`qhCEFimx0g89OnXs&pl$_Kp<9vj)Emgl8?F3nJ&q-u({ zDgWZoQyU#qMOYILx#0+Oo0MtmKxGw!4?_LpJh^vHQ8mUbpM7j8VK=z9J?6${>c?oi zid&&y2RKqkAE6@n*ZW>*J~>j88)Q>>pp?*5pWuHBrcYOrHim7#+1epIsh#q|_0)|! zdDF+e0*zD4D@?=4?so1787{;!%XmOX^GDRL3Mv@H8@|q7Q4gz@xuw78OAa2DCKqSx zJDYYTR3>83@kbEbPoft6b`6HLkdw_;8ohp*ER!Ru!e33frQPsui$$=PbO7^8v5h2^ z|GvQADq4RsSN)z>XAlNQ@B2&+5;g0~taA%Q{YZGB1p83}bUv;c(Q_kuy1$Qgc7-eNrA&?T&F4=i z!{r&{!UHuG%lN)^iJPvC(FB-Ct82=L4jws9$T+Pm9QQ z{p5Rp03}bl3PCO!7mlU4@_?zJMqkDh7?#v@L$dO|a37(=EY&PuKOECSEC82f1Gs?W z(wRV__>DZM4FIp9Vc_o&Ue@nFOK@9Qp%bYf5A`Ohk|nKnyA+1a@559s0Tg}=r_aVF z*UA7?IQ#@vv71{_`NO%Ur_>wW`9Cs$0cbify(pYq*mwU*V-O|dcyx!tA=oI$O4TLG zdu$QZSV*QV0}cVOC(i+AQ3^%?y%pQ#z}%uV<{}wCqh3+keZrr&9r5oEh{;qbPG$aZ z^N$;TDmsX#GRI_U)TwB2MZNtcCbd7x)4H3WP^Y5W$vCi{&jDf1EX|Ol+k5PYVgbIZ z=zSGzmmfcfBE4Uc0nW}SD6QHVzA?~|;7gC=RCSc&sN;Bi`4a(-e>*pAtx|1wkI%-6 zAeR-3-YfRIyZ0G}6JW!aTeVk8^ByT#GvLnCPpr?|mb&6Lf&z9aK18h+hK)xL0D6k1 zzVWY45+Y!D+957$XPqPT4z6#ciJA~D+ro{PR%9|}`th4+2&7@rLK4@kcSB^8F;~Ad z?+Wr1>|TN{y|A!`{iN+jIi7ii(~nM`)IK&)4~c(A&tt6Fn{O;hAmg`^wWP7BO}-@` z1eIdLQa-(paC&Jq@-;*Rl0|w(wt19b#YxrfN0SL=K8q9hfYbA&mp)tv)27Ml9c%VH zcb(fNitk*2kIJ1{ze=$$1-B&*JWMeFng@7r5o=q-NePp4^qR)kdgoCn!e zW?oW<4L_fPF3JKUzbU|u=(%TQ!Zk%ZrY6@hSyiAFi##8Z$?1f>$fiZzT*_MsgGTm; z9Iafk8b=WUO_aR#Dl9Qi<7)nW$7eJ+O!))8yJxCsCl-1E4v%k3y6^W+`+yxlL)<pe4?3I-U9 z{Qy3HufpY^7iCR-Oil3hlps?6;AIQ;*%0p&!AvT6EIFlf_NLig8Oiode5U$?tD09i zuFJQI3;7LDp|ZoLUhl=@fzb#oO5v%7Y+4Uwl`cSxJb>9rFG|wMce@3uqVzib*tY^c zzfx>QqB6Y+-}@Nop$aDODH}1jVA8x$VY1LX_yQaTCEy_t-1qvxQiMjKj2)mC!P+Pf zHODy0vzk0TTw_Boam~|XkGxw#TbYu)V?;-x2!Y;G=fH;p5fH)C4)Zj6u;{RsE*C{f8m-gT@% z%-QLc8AqA@gxMdXVkw&q6Y8aO%>w#;=n47CZ}_Ag&ZYt=sQwoRne!TFAAgCefi0YQ zC7Qw~d*-w)Dp&1HD*ANXHsv)JLF$YeV%PC*@mnIuy2PhR88eIi(q?JpQWmo4sqh3x480YXP4?=`^zXxL)oW1YpT zf~0nS+~E8*YJ0)eBjM|jnjs@Fg!Cmj$&FT2XO1<~$^q zw6ZGT3W}1Y!{VX1MoIi|^>^lnm-3YWC?HPvRfTL~nn3pUkOo%rKDKvuz27!=2+iPe zo++*aIEm%|!5*(1Lwch349rd=i4@dySjak*&QOr-#f4VD3`o9YiwBvPJQhq-c*`t}q08#g9TDxiq^BP4HaKY72OIHDOoz(YqnW#pLl?Y2jmJ$o1 z3=>u^CeSym`8na` z0E}_#g*8^te1f)1B@+$xu(*qTWY`x;B1}V~M(ZYqpsl4Wyd)yTKxqVaSALQzUcxYn z!s^|g=IkYG&THEDWGdtZ!(Nux8fTJ`2T~dU&U2IX%);Z zs8mYh$c!gjFqXl=X&XyBkNa|T=}O{C;S27%OF^P0KDwF@`8(9_dw`wOe(7b77 zU>;>0sxn*#o?0$MdLsj%;G#n#PI;OM0 zPq_+vYr-)X>Qpo-nGGqUF*4DSJmZo|Dqag4xu_{ln+}dU7FM6-bB19B=fz%X6+DIe z6RNDc7cn&_QYN!5qdu5xzLu5qcyd`n$YaAi+X$UXAd`R0Ddjc+DCEiDDVv+cyFT!d z$Z0QmOc!THNS}8nZ5elw#)U~Fz3Zd{2ET|}|5KfSM#?FsdNuZwUo3+=8%vaQQtmD9 zLsJ5o1~wuJ#uH=si8rB67Oeb?gf$lhf&8sqTke6>(4}@ix)+!4Ws)z~-0aQ7kZC(S z1iTVg1Mg{g0ko__6doD?eW1T{v$-#qH0&e-pEe7k$n zL9^ccS>$d1;=J!4t{0PzJ4oUy7Od?FqC}=Jgh)ZSU{{nOzWh$T{Nh!M)-%1D?1X3W zU`5x9PI6voMwGtIkNY%#xgNkTHGs*?Rft?+befdBWT}c#l=v9dsJE{8jVq+%tvq2! zO(<+rx-yTTL4-43!aXFgYC^$U=l{drdw?~yZ2iNNLJ~rN(2Ed4kuC&81*C*tm4K+A z2mus9Q2`quN=WD;AVok?K#DX4YzI9E2_PV%VnOj(2p|@0K}4b;`3BGZ-+S79pZmSv zch5QZ{?FrtFE;w8f496K(X)r!I!$J{>7w!b>zK<;_bK|_9uksT z*En%YZDu$!W4Q}P*K@NI9G zaWhF>hj?;+? zO$XH5c3zX~06FX~%%+(RB>$eRxCh<|;yCS;iGhwP&>)W!aVtCLEoZ8-_%V=Nklh(M z^v1|zpXsS!Q9F z>2;kVVryVv#??p8gr!=A7f#r^OHb9fR)s7U{BS|XyRR#L#$PGGP%+f|_V7*R65FcG z<(CDVg^U%f$~#qU`~CRjth+D8h(_C2XymVQ3|srQlFKxEL5Sp}<#ZjzyXtq0e)rZ$ zdgx%wvhh7Um5*nc!+zU0Y}ObNF^zBHO#y33&whdMrG z9(~to|AmU{oJ~r79-&7zNh*A#7U0%x=?jo^wXhGw9x{p6;97pXxMvApok#kzuC^ZwjsZ*A7>?=mm{v1$Q;Trh-A7akiW!58z$@5jk7JY zcJH@Hjx!wfSH+Ano-S`~jz`BUhAIohR=c{8)(oAu%Qh5}l|t0Z`%9X9)D5-dIq^tS zivrNzkxjyKtx8z)?dBy;NIxV1OkR7b`X=WP&`-hLHs)tdSQ>Q;pOemeQ26_1DY@j;fQxjB9nvO5U^D z58}LaP5JvvxEa}`v*POoUx#K;9iH+Z5|+qVUUoWJ{M$1PpsyB^!x{>u8jsj;nNaWq(l1D)$K0cXPeMWo+R+^YD|7h)go z(y%>*t5=HL_q{<-QRZBrN;iCZjbao?Z0PIhyi&5s%JD5fvN3gH{d4n``tc>t)Ce#ZT0we8o6XD@+N>BjH)=67GJ+ZOtTuTO+8CS8^EMTQXz4aI*>#(lK#e zD-N-($g(@>{y{T?Q^H8wn!#_O~^Al}VHfBj_lO71R4W`W7k_s0}f1ROs)dF}0d&U-J;+Y1v} zZaOmlc_3l#5;b_TFa6@tJ8rj(b${6EVrbD*7j{~sWm4^Q)%l#ivi`%U(VKDc5nMMz z*Z9$n6-G)>Azy^cXZ$pAURQ6sVluC*|H^`EiTr-lScbMnil;TJ^5yAY6rKwNxspgM z9FXEjI2-YV=i-NEt-0p_T3yN3PqFurJ>zzOK^64r3<8`a^m&>BoPws|hxWjvEpejq z!pT6VNT|8SODYan(HWJ|$%J~E_eb=C#7CNh8&PuK2Oy(g59|{>jVIcfnruB7IMi#u zew}p;6$Q5wZ{C6~k8(!F^N|rYYcql;OWSpPi|hNtkq`QZkzT&Cc;a*fC>25l$C&U7 z(yTAdeGr9CRgmTYc<$#O2k0-VSoChyhUbF8;tf8wdUHT@7aAq5@J=~%fnr`xY?nmQ z#zQkgrjOpl02VgaDQ)5Kq{;)`bDUaSwvXG%NnA8B+3f_{wwk40!54pYm_m*X3k2Gd?Qa|}V>G%xBf{1v_oq_=>x*R0=QnNU9S9VESG12E7&RA% z@<}dQAz0(zw;Z4`1X@Y{PMr!ivJg`lbqe5Y%+rx7T?q$Eiq?PSECr$gDD%{@Y|))F z?h*{tDNg1TpCT$hhXLXkiTC7ey%Dvy%&nnm@kZcNk&Y8XanBYB(Q5@S4)V0qC!QOv zi*8HO)DWfB;_`VU{-&3jJmcZp%n4R)vHVy&;_%dvN*m*~MdQ*H>`PR|dlI`{S;>OA zhz(r8r4srVwo@I0^UBx1yL-a=^Rls|=j)vhFRggM5CUopoW-;K!@-iC8{6Ky_ai0f ztpjS91?KA*57JQyZ^zEZEiQUarJJ2d2vV+%&v`!H94MT`6dByIPLWo$`RgE^OpvIn z^Avjc_1e74kkn8j=}Cz`xJ|T9ZnMKs+pWfUjBM{+mx*-ZqP3YsY`-x>y4O2mo$uK& z!6Sw?urYdU5Tsj{Uo4m|lHcg(X(>Kt*dlpSnhuB}PrWxzmNI(JW7<~4B(&M1mU(W4 zp=I!nou^Zmi}1{Dkzs&)ZIK{5U133^#)J{Wy``hO`uJm;5=A27lR>+PRi*GrsuJZ( z<2F|XT|@Z+t5I3N48!SdDc-*z{_FF&uDWwOjzxo%)tgJ@P6t$I-aZbPai49z)^W<0 z=(n+@)fHirw8Pe(p;G)poKj{KD2>s=+%w42u7-kc1Z(APp@R?5WAaP&1mn~ql&iW? z$W}I04LnNg$0=_;?HX+|-H3VJi%J&JDfv+K18%o_;||5;%&Ih!J$q#jO?`#(o&{^G z0WmN6CC*BsOob^fh*?08`i{LU!Tpog%iLsnk?>6&RJ-Tt$v3UPJ`*Q`Hl$Vr7zv{# z)P9%96&jU_Gw$D9AKrC&!Pl~Ge!$naMBJc!6|VF1fpP&*Nj$m!y$rRfr!p@ogkVplIR()${9iREYOKza=wzS~?9oxAaQommjDxRSjIywLRbMY>T}%Z7cI8 z(O~rBo!YL=*FwdYg=;_D5Wo4ZZjUZG#rx&Ld#{im0Z1wf0T4r(j6{-=Y&#cQewjJYw1;L zaWcGnF`R^xVg;m2K^l}@d7<`vJ?_<*LCYZGhX(gu@@pC&<{M47XBTNWrc57t{#oj% zcIjo8qZGHYz|6<>H333#gJSih#=M4u*W9zUjs8$Aj`ms8vY%HO^nz~MYPkGxE* zCrC#nOQ-J93YU?u%3r&2qg6Vyz|_J*D3B8N;*pRO>$9e)T5^{J!hH$v6sB@y=bNLK zabE&7MZ1j6n`g3y@X_YJ$_Aot*E-m*qFi&6Pb69@8}3M2sC!Jt1G`4(R$;AfcI7JL zx-9u|=FZ{iu822ohiI3g4%v+LYE&)}y-nY++ncmuHMSs;hpD|-KGZDrHf2av^>K>x zUJIQk1E!nKcb_K-Dv{@u4UBB2+fNX@>x=nYbrE;QbX+<-Y)Q4HGu(5mI^>Ja0quKw zx?%+yddDYbM#)`eQP0CP{8|@$4#Dd<6=>i4_L9z|EPUM3eVcDtWJqDsbta}cn?;Wb z-zxXXJi|TX3ios6@dZg{OXAeMs(5F8#5X+TO-;GFFDP2%)YGEF3#yDvp7rp`@#>h| zc-ZN6oX)jdX}8Wl{k&JpO6xOOrfB%>M6m~A^_sIaF-F6xFOBgkV=0od7CODUo@Sp4 zKd~Nvpg+FW;eLR>idD&sNxGFAP3w=Qeci=v7j-?tU!@#?U902WyV}(JGM+v@rh71N zf43W?aK!RTEvs8&QS2418+y>3cT_Brf2U*w?TSl`Ft;jxId$gg&Wuw^EU zs9c{IQ_3w7Vh<^Iu~vIw(?G(`?Cd6eQ{yXggiWp1aXQkbWg}2fPzpP`EME$kF@aab zGqfh(equ1Leh+B9r$>UCI z;kl|TwgPp->4=bBF6XoBDeJdT%@Jc_D53XPFvi>2H-}|-p4c^xyKRgDL{ktm+>>JU z*fpZPiJBSNqbjX<@^}Zm>^OTjeP3q@NkN9^0re}~0I``bU=}r1Frs6Eu4QGDNUfHU z4dO#W@|$_#!x0wdjg}+F?o)YRd7jrbo+|snQL_d<%f~uSI)qcJM4EqtxKCm~{7oNk z?@*zepH1vvc-tXS73o7WbbE%Y74d?Fe{Ely#xd~aD5WMmHN*MtO9 zcyF$)cHNfcW6?P0Z8+>)He%ZiZA_X7ahvf-3by_`j+Iygb}g%}qZP7ql{2=eAf+Ko zfAm4=CuohdL@kOOdg&D3*JyNPC4Bros7ceIX3L+iXa5gf zmLpwXQ|OZsA|GV^= z-;0uuAei{&==hf=|EK2vPksIMmvVgUr}q9o>H1ep3Hj09^R=_IUj>>q_ran5mUG0E z)CQ*YiZ?&ef*h?ZZD_F48WjuDSgGliqki?@*)-cbaI^Y8_J`Bc zDv;;C6D`KR&p4;M$UPU6pOd1tvy2j!Bed1v%BD^3g?A@%XfK<5Q?SErE*()C`u0i8 zvp&&2%Pe%X#FAYMbJv(u4+;0F_0&;bx0U3^zJUtwIQH0_2ot=8MqeU)bLbeBzyrx2x9 zSEAOyYiHSuyjrQF_>Jv5-K4Kx%2gr0idV2O5m`%Wdd$0vU8`lKc>P?*Jj@E{a&fm) z(6}qg=k@cN@@j+Miz3`_60D>XjewZunU#w4SG!i@E1kzDwDM}j3O4KJFBnx_rCrFc zs#>{8p)$4BJE&SGcS+G=WrLH9SG%Qo6@~o5Gv)!wIw#~zy4?n=f{GTy26WZH!Sm%T zgZJ9^YD+(iS6f`JT|cU}Qs`zPr^;1XXnIMWjk}(1@byPBIl&t_r;<$LEbgxq*R--Q zOWB`roc2|-M(6fqq51c58z0&X4|EKV1P{NSf>9Q~-BI+3=d1^_I`*Ur^X93}qmmeR zUSmwzP-jx|HI^e^wF=+xM``bWh9)@r)~?ZEoSIlJX<~+Z!d>Zb%E;MMc{#JkxJOn- z>9~Df6#cn?>C0jG%}7$GyWcZ?a=Oz=^IddSl9SU@Jd;|?&#w31!Sr|Y=bElmX*s}O z*rMdQ(#@NGU}8dmKD%?owk>HZEVzz5sqo4}6E_R*$Su(jRmm)vPsMNU{E{9yX^3ud z`GVgJQ#PzN8}hfZ<{nw&JzID;D~rGICG}`=t-qGo;oxb`B5bR)8EL08d`MEqN$~>_ zfmuUn%8A49U4P8j?4J?#U&Tc~(L;Z5kzXM+D67A0-G7E1`IQBkxAO0+p^0oQpT}z7 z8U}CrW0QV9jQz!^_CNmTyzKnpB>rV3e%{%>jc@J6Jbvir3S+>qRg{GQ73jMWJ?`SED-G zr%uRX-!og6jQKm0)N^jSuhkMY)*D_K?r>`G6(eN#a`I;Q%y$9!$-<8z3r3 z>KMI&ZAj`Ex$x97hGQhD7_?#u&l6MWAg!A(y$Vz5Uh56-;tX)dxxFWArE7Ihri-0n z7Q#Ri4D-~M$4NOAWEqnDEn0{04q_Vmkqtq~O5(!ePfZoYT`S$?7L;wDATh$^2Cb#N*->1@^0-He^xl3$ZU=N9r~;*JfwyNvc0;yaD$qgtX; z*f%0y0w6LaHIkY>bRSufV|Vy@aZX!9lA3L&vrlJIYFP^NG=Hi8C%BgvT&H&xX~uD`Zv)34eWQ`P~#O z>2J}}TQEA^e%LNu{!`=~el}Ys?_BwN3loioh9q5W^OoScdF-j3yH?y_wX4OYZehI}zXbh%fP3&aeSkkI9;P;Zy8E|-$^R_K{70w%ay0+n zVt+dG`fCLFUxsmhm$1{f_|~tH_ln=$2l4;cyr5sIwRvC0AFUbwcNO?eeS$u* z#AZT(kI^|s-s>9KPQOd?kuh_~Pk^DOOg(*zK`quaM+LhEj+iBQdtht{x~MY6>P2U? ztK80h3tfHHg03c>r6 zvYO2sche*Ej61TL3RkQR$_g4^1M!DivJ0&_tk$dJDuLG|Z9yAJ9zuV1Fl1e{D`P{< zl4mn=XpBVi9bR>EM^w;-&Df;zY>Od$ke!gXp04y$g*zuat1Vh5OetM0m$r^B)Mk&5 zTbN&>L|D?NX5qY-$bs$#Qd@grHe3E4sdj+cfnYRZcjR;@hPZovwkj3=ci(ctim$_k(@Tm~vivPBnU(IscRQw` z+H(%sjt$UaqO;js5>p_yxD2sGZDO|qCn;`IVPZdQbl^I#L$>t08vLrsrop}{Oa3)i zqg+MZvOv}Y4c#@~t}+=F<#oB3RGk89{lvhm)Ot;mh5fN(m7^B=A-}|Pzqfzom%fMp z6SL+RWKR`-=paB>lP-Tx(dpalDc1GjthfVw)hyrkZrD>gK9*lM&uF#U#62SJ%l%=~ zH`FqpT)F8zsp%N>7`1$#rB6BsyMkLs>r2adN7dzks4>?4V2D`D??=Mll=t=f;IoK# zzI3<1uCnL}wpDo=7O*2V#)Lq*OpY%7_LD&3|Fcor@0R{wJRJPyqxqfYdu*Hc5B=GK z@_*ImH!1MvapV2e$Mkc=bnHLFTl({vL4O-q{2UhiJp#wu)2HnF^WYbpirfZgC~J?_ zAqEQJT$pidZdSR)In+0{30v$_h065Al5B{2td0%-ZYuF#e9>=ePHst84fG40fm*&w z1nnLy+GEdl zba2cG>r&XOl@@6O^zcN-!)m z`!8|HkA9}6+Q^w<7heNc@8yymOIUT{1mj~*oKB8D_m@7WyQb&9o88%LZ)R2kMpwY^%XZ(`ksLJir;0R?3;AshEw{E=&!sAXP-0?`RpcXO4oYcDz|-c zQu9;6V)m-E;({OCxgq$$!& zoQ?*bkgX6_f#HS0tjNS^O)svf_93?=#PFd7?D zdbk+_#=8QJI_N!Zl`gYblXpYkt&RYz%DKcE8ZRia627vmMFhf@-y4(tvp4=Tto8?o zw0`dI|CcKBYahlh-AQ*iL7i9PgpL|6@S^LB2G{<^A480H=l>5WUz7c`S8xHHN zkCXZMw7Z}}oct++n=L+{Emj~lcJ?(gp;OtARSBQ81#k7?Xpw8xuQBQGobuZoQS1IO zDyf&{ZyD`v?pV;--M`w0q$t|U)N0S6-(Ey1G`Y=eTBWDET`%C`CW+_nN>VE8N6k2i z&ORq(7iglgcXVSfi#b~s%Lficd(~Yp5{DC^c}*nW74LeYCvD0Xsg{2SV^5t)neh7> zNm}K$3CEGMQUB+Z;yRbVDq|vU(PG1)TX7{bRV-xBJr_8Ob&eQ9bNs(SD-oIv%Z75_Q#q9U@$RVaA*_ZBe4sv#ramGusW zJ~InEZRclt87OMk=Ug(YD)zO@YA;sIH@Y*fU7N05o4u<=Z8cuov*uLgj*J4sEJNCL zSE+l2&n#LB`2lKLCY!&1Mm;c*@2@3_t%r|SP~MP{*>DQkSom2>FG*TCAmd1@aHTGk z2d|oQ-M@4c{v#azFM|F5kGK8iSm8(A!7UKGyX2g1D4ccvFM*7oYv4bu$gi-@{I?YU zna8gjH-F8^{*S?_e+R7p{tNU`ZNGl#&b61Xaq#+()N$4?uX?Jd0jd&a#?vL#@R}^c z^11RKT^FW|zk7C>rbgA4^B)HP;Y#R#)?q(WQN#J=b0Uj7M%~~Sy(=TY$H2Qr9ScpP z%NeeO^Jh3l<*_71D4e)1(9L^H-&Guwlf>luH{`_B($`J7@{ZIi2`jlnB5wCIwGTWvxv| zv^+Xr%Hy(3^o;`9h*2Yn;x*&A7v~Sg>FlX3oth6@d>Hj!KCr54~!NEoI1 zyx5)HC;gNfU~J%rRuK^+g*tolxZq>ZDW%$nl4a5-c&9`L9v9xG99SrJf}giVJfOn1 z^DMd;J}zdc5vbkM*+Sj6R4rVrDUc1$2rrDNA&=|d7KzhYF%mqQb)DdpDn6&l{zSC3 zcdsQh;@T>C@+KRy$TkR`*h9*KQZ({i-7wiiR>`GRTiQ0#&H>Pkw`$`u8&1~}O3igR?a9-@dSA%kV-(03QawpV%}=WqCK_)? z4EkhiifCOqw{#hx${c%U>I}VNG`>oT+gXk7&Qtoe86=etXua$c-!UJDaGT8|sS)Gv zD4wQLIAXllU7=(KszSj-%&7mQh9brZeq4Sm%Wrq27K#IB|G=o(6!X$!orcOIfB;y6 zhoK=aF+op(Fp3sC>!q*~5%yjKh=E+cH&$>qR!)jT*KHVPn-|709h-sfGvGL32vK;E zfla7Mw~MqLo~uG~)cwr;MuO`V?P*DuaQcvjw*=8#pV`sW2EF7xRob*;s6rkukxuN-QNcB?#Uv4q4`l<*<- z8P=Btgod+_`6`Js*)H^$d?!X|+tTC+KyIxj?q2nlHj$KVGEH$L84TElcS%(^FZU$!aZEc zbyD}t{GNz06D@4}diBpX2KIZ<$NRd^M~V4-?GVW)pSSN1OcAQIN%vibwZlTXY&Ju$>s)ogZp z9im%be&*tChIr*oK#`(JqR$6O-HW?-Q2LtK3#GtZ#_~Q>i$e^}FL@hD?KQ$4m5Isc zgEDBAf>|9tK%(eMLaF7BiouV%cNmwkide<;o_ZY@vWI*C0a29NGm?;*35h-fiGGrd zU?X`)nQo}wa<+-nz5tdfAO?8j%xgYztJ7`m?Y^(c-&FRi6Nk;VAkZk_DoUCV4Od2W1~_>JVA|44 zH~mx&pj6CH9E9snqUM*mMA%fcv#&0Rw}T5<%$`Xhzier~d5} zcR+d;HjHMfxalGl7j@e8Je@I|F~fRxFMGveT9qgNV2xsp^t4G$mBNFaK!1W=khk!pc>8)W0j8}d}Z0My3-4Ws~k zJCqM0HXuV;N4caV{HA1=;s8#BK@^wsB>o_q`9*d4a@##>usjkojROb}4Uoce)=&VN zVXwRLt=CDZr(-1Kou;t@K0_mEsaf(D^(f7uD4}#_^T8&KxU<$wvt>omQG$X-tVh$>QBmQNz2jix z{@aquzM<|pY(8w^e$NShiNuJ-T)cJ3trwG@XTM$iH@E)p>?A?v!eMQ~*gUuM%O_iZ zMgm9+0Rjn4D0<$O5}{FLQ=vKf?@N;WHk6pf!xpCEh8@Ju-aD(P+92q3v?zaC-fKCh z2LFs(ln;&Q{vHWz6H1`MhtqJVED#@)8*bJhM|fQj6FVIn7nGObbM=$a{)~VaH{_ag z@c~>hmDf8u_?SGWE-<8ok-#DngT5_BW|+@YuD5<;oyItnEv`HFw|$W2w?{&Y|F|L2 zTDYgu{ANk>S&W2lKhWZTyMWs+%`aX3f3oKbh|cFK`M>((86k;2~uWWaZb8~8<9H*=z}WQck~+URo)SP}T~e`= zaM|qu{NX*t*F;4=G{*aO9_IqGG(d`+hbE!msbRu>Hbwg-uJ%A#?zA-nMM^+Nx7~7( zS9?wCf~zbe5#<&@F$&FgZkCmLBJDcr<309BJxd;ZtDX_wHK*Thc!rc?`p-5^$OdywvRA#%(qGk0GWzR|g5ukBfKqU{ASUf6xx@?&fhOcO$&PENkYbf0@ z$Vo-%*huw>I0I_pL#nVyfy|aqkES+fL90ar+K>!3E)+IZ9-xJRtULCnsnKKtK%yBU zng)h&tHWI?o!wGJstMayzc$f6$3p^;APOW~IjI28Ok9)Fi?8y&)pnH!nCpP3*C+TK zDqNSkS~zSYM!Pd`*uSveGk}525audt>Nl}i3-oDKKpzk=Lh?&zx-trvhRg^?xe_>H*>e(gUD_Cqq zQG$YLRejmf)p^ALb+#W;5Y~VXO1{J~^sPNbiW z+PDGxy*FPJN&BEG>UA=HmVFigswqGVK_SQcApADsP@*>|l)i+F`PUOL#Ou?ln+p#- zbN0y+blEDmB@;%qR%xtH6z<=1Pxs^ES~-5o7L3l+y0dqjJvneSnAKtn@pl@rKdK%g!Ow2QLP z2nL{LBkXS_JMa+U_HUs&anh0Z<@9tiaJ7 zNSd31Qnj>C_VOoYoqOYFcy#UJE&gQ|K9u)j$QFdi(_6yk>mOAyBqU(xpe981QG!<` zyjgHJNjh@hNt3vu=(a;&*cjo+t5R-`+r>nA=l1+mrjbQ}UM5$X?iiN07jA6n}T!mBBzy;#J{>q)@G}0a*#drq$Qf_R(vksSuptwnh5!I3 ztBHaa6j*~TAd(Uda0HmmhYAXkEg@*55d(>Y+67<%^4=1sU(g2ex2y<)`)zE-m->0PNW?4MpoYJrh_m3 zkdX7reK$hArH{N;%U#%}!%EGwvsX3(gd$wU&_aW6F>i#^26F>vP3dSV&=!;OCFvWK zSaD*NTzF7gsmV1w38B0LQUtF}jzX7O0tkQQ5&0mrV+sBt768pjrh_NRg4Jp&vO-;8zlw-JgJl~U<|s1Fdx=4_y6iIx~da5BOrJWdF) z70Gzo$oXBI>u6M+`5`24m=65n--II3CA_|x9In{@FX7N`13=zN232R309qW<`Z18$ zNhGUp*D2Ky%C17*6`bdWhP{kr*5)3y{`y3Elnl)gK!6?fz?v?!heqtbD@9ftNaUfR z+)@Fx1$LovAzl;W3V3yqKzkd^ZcKl`O8)rRHARK(fe<++BCCHAed^44R2vz`Uf~R| z1)qrWVm};oMkYyUY>DR?NaxwW{>JpA!MasFe@sBP5X$!(fT8Kvjkx|&F`x7!0D*3J z|2TvmbZVU-`oz}-@{%Xvh!>*-#XMzh9V8iDA1d(mzHoa8as(tH`cdw%J0L2HB?UVQ z2ZVnt%Df5?v`YNhOs0po#Dfd>q%i<1>187V zY(0pT-pMDGrU;TXTw2Ala3#yydDckLGF3$T1lWbqriH$K3QmcN<FAz^$aQZ~KAGO$8B3|#kY|Qb!x}ZN+sX}gR)1jASVo@5zjoTI z8Osl{RGpuEE|mw{(xD9PY;6lCAKOFU^)@ZhegbP@42biFR*Wl|t3pdDrJfLZV0lrE zSXy-yku0eTKJ#vJnSR$gTHok9L#u}qgG}bHPatY<%zAgdc5j3kdL1t)N7m^Aq!{r5U-E*M zMvGqbXo?{pDShQt&`9Q0lwfDF`Nb3JwboV2UFaDxBRP*x}wIwgzxtIWu^inMeoKT8CDnWB+LTlJn?8P#gc-uQguu$k$Wd$V) zyV+tF!Mkg>g~v8JJ3Fzt$4Cp6UhQu2R)QhEBUzutpjzw^d;$dR^Z?@~qtJk~lq9PK z#}68#OtpiO?N?HO^Cw<{zN2<_$ zq#4_LOC!YW20cJ$e>vkOa92ADB4W2@SWcEgiEe~?hk-Ccmz0c9FwB}jq|Duc7wWld zn+w@^#1b@HnLIucSUoP0deZ6;vlMw|ESxChel?5^$bk&tcBvb@U|DoS)Px6w_~|zl zeLqN=mj|{{0g(WRIwK0v;;Et6PMV^#iq$Bp1BW6j2m*%8aK_GqU-wgKR}8w80y_%z zoD4R3oi+#9O=lrnB(Jl9&2?V#JGlte?^%y2UFwg5k1;+jViedz8Fz-<4%_LDjBq%8 zHDOT^hXORAs}rFHM}n2?w~t9&WmraUx-H3^pmbuku{q`O5e+aJAHmYjTzAL?l$UCp zM|r&WQq#~REcR~NY;{hHa6B_nFE&VGe}&@7?%jI;uo>;dSUrrfKOZWy;}cc5zz?9; zejn46A954OgI8W`Qw<7W5qv3>2qeL1W8W4waEOGskVu4;2Y~&7KEMF!BXr(BQZ_AU zf6txoKSE-3{pfyPA&MycM%f&-;HfZufqV-GG>UHDaB{0V1*IMiL0QKI9US1L!xC;J zc2L9KtIZm%OBPZ$B|AU@Byj=x{P`c6vSF)d0a{~wARADv86fdcJ8_=q2%*=9=;kn! z0mxS(>z67AKwOF@%JWdQVrPV30sDHtJzp^XD~J#6H|)!~A8v|}e_@A8D3#{DSgdXb z!x2yJBDf#fchgobWCtG`%v=8gz=rMy%KgjRPe=#j+6_@lc<+$A-F4Umcy`aUem{5H2YaKMn?W32zX!4 z24sEcGmxMFg46`USDVOfm!MDVlaFtPS~(Qp44#e$qufnX*P~}o1k^q1_?*-ukvV_46!b-)aCg-&|@i&di+;=8x6)qCGM}`#!fC@zmxp1D7jBh#& zA<56^bX;}ZSweI>L)1pgI%UITC8M|7{$N*ZM-DHIJV?Hys8~joJg!lVqKI9~)5gweU}@(DfF4qq1oW|4xhhD@Yw!PkVufWphp;vykioW3A6&ZrMA z)K}Cjzc&mnuz7?whw*17L@t;pOzquSgRy1eZ?kEN7G?xw zu{OELC`vtxFDJwCk&&wH_C#E8)UKfS2J0Bqk*dQ|$&S4;^EeIzkbo@OjAm`*>ZO@U zB5A0QteYT|rg6C^p?IdaZ)J*BAgz)|aPjJ)YiGQdWq20G5t!@4^uDJWsmipRw_9oZ zLh4R609jZ}(? zM>~6xgYg0LyKVlCn%twVy11Y6UPVu%dkXWENbDC|B?p>o7Oo3aB*_(NII z^u@aK&H^T&At{Qgm!o|%_m|wKe9womkq9v#9I$7MQGvJQx~Di$p~+2Ue*u*keBXr^ zoUL!=7;k*3Mu*E5pL)PE#&1#gritx2J}5l-#sT0u?}&hC*}DhPn3;+rpmgnW^ZwGu z6MF0;NDHiqV#`y5xKupy0hCN6q%vMPBKtAKeSg0Wr_3SY&<`Ax61f?2^u=V1{?V-j zYFb&~)#$aKdD`I-)}7PHpbcz5RXQ8jFH02q%D>G(yyB`O&6coLIE+VQD6~jT8iIa1 z**1f_WzX$>S}*nW^gk-QeIl9x7#A-Bz-oJTiUFvw-=Ie{ zpp3PF3p5sWGov$`9Mj*1bzzZ!Ih$}I$d zY7fw|26k>-2CxMRBk#Zh22fJ)YT7COE+kdJC7hhaatHibnbIro!JjMfY4Qo9VP|CL zW{!fK?g;tEg(#{#a)Fwi&g#VBS8rL<)q1z&7H{ecM4nkn)Y#cItRT2gy?>v;?0nE6 zsHU?AIJb?28D~S zZk2dE!~zJg`cZ-t_HvSD`Dc5na2qB;X9tM0eCpG@x4M}MWT8S#*Z={nKJ+dkzb|@v za`@@V(Z-t~0uW_jVVWrB8nx5ka4L9p9v!uphX_fiD2laBIk_izsw0|UwzTA&-Rh_O z9DsCtDMdXokS!G3j&xJB9L%4d1lLQ}qLx&&DvLGrpN!1gs|R2t?u-Xr!9~4(;}$Ts=_EXz8OCJ2E~O#W)$Oi_(?MmF_-PU`ZEVVsQ3k|7+1eRWi?st0pidX5^&k~ zDO{dxFKnHQByC%uao&{rgGXP<9OAJ{-En8lMZ0cIUh2MZ{<)IY6NoIY+t3W3oIwng z5^Zvf=$@D8hT9{Qen?dUuAM@XNEitgzpDOP^tfa)PSIhwRPcZt)QzsZOa$s*zdVA% zuQjueA}p1k*kv_uvlIBf^1BKyurq7|fKN96O`csxKv9ZiTPxEcV}%9&Hs4~6!W zCfKP&_)G|%DlKWJXpn~aC+E=Y$XBO)ikPMY!y>JvaO{b+F;wYW%&ms*nB&1c#-g#Z zmmC4|$;ZFDY$hg8z2JgfVPa^PK!1V5>H{21w2P3?$Kg`MW+FR_ogSUMb#v z6C09Q4JUSfh=joo7#E^LEks42m{c$ygg}RYZz>dvil(na95i8k4M_&d$gKDS(g8o_ z;1L*bvVZS|TszIX58sEqCz>b*M^JlrwYv#Ki}?Le(xKirdyr5H2V>A6oCV591$@sq zK*xvr(u9-H=KR-3u3?Z58Jl;35#Upv)q;q^BrclOw#{ZnmSt3Deytp#d7q3azwr#Y zL!Er5wA#xx4lMct$mTB7g8zrTFM)@u`y0QrG4`=#NsKjH8e1VT_9Y@*Brzlv6_QfK z7`qgak``1?G()r~DU3CFR9et(P%0{;QbUvZpG)=Yd4A9P{+{LczW?9vec$t8?wxzj zJ>Ty+=ewWpxihWGOrKnH)Hm0>c1JEIr*`9#Yr$OzbLlt+!PhE1wor4ZOnsYuH_xg6=VWf1OVhk5Rln<7415*>mE0S zeD;|xwuF%OLRes*BJa_bl^8jDdN1M1vM-t&x3-+#?N*}3uWJ= z?xG)XRMtvv{G_lJ({eUjLpqioA_!rq#Gu0!^CW83NljL?z1~(SLsK5I zB}&gTf#4BR28u&%liSgA>o>WV-+TiJaUiCL(HHaht@HMGFD5^f|wikDOxDQE?Ta!Kjee%Oe6ZBtBlsZ%q5q)$Mr-XN`eafl^Avu#RI zAoHeY2ospe0@U#+>9)A+)%VZnoPO-tgq7Bey-=bb&O{|JPN zD3oHapFjl>*QB#16SxIze!wwK5? zK-S2@Bp4159DuM?GVo!cC_@m`2bKq3ifVG`hg0Z;O>Tul&<7(V^^7P8fd$pX()=oM zr{>aC8(!vzpz4Wo%}!6T{9E+)VxOpRg~dO5QDy+3=2Pb#a#oblEQs@bebL}@9PPrw zB_F9B281;9^3U}mLP}pA>MGAWyH@a83K?UN0USpF!j#1yDg>d2wd-`-q68F)}Op;0zstY~GDKjt`^I9OR36j36g3-7nlZ>jZZnhZyS!&tmeJHAQ z%p7yGbt{>2`_TF&=PaH!SBlD_RkKMxWl1f(lL4wh(R*&_>kNdMzA}Am0HAtEm*a6DMy+;N4*86ky&sv(mFTE{}UvZf-Ug)#aYCJtDoMg*;Knn<_nin=8%BF zx`U7P^)3^NG&#g;jTgp>cD-FmQEz6Np*(qwTnN4WHClE~z)2A3PhQOWN!)RzB%e|q z#1(WcV; zLLe4{HwkR0AYF=a659M8C>8K4nZo*=ur{{J77Its(+@ zn$m%8T0s3t;Wim-CDVVAIz=X(SQ`r9o)4T>k;PDaSCupOJtM`>_ewt;_6WrEy{qm~ zPoiZBR>`);aooj^NzC>8)GT;;m~CaRq>|Fj&G`B;Zb@a#yQ~crA{Kmyk(2yySJ*}z z`FL=(2)X+0OYyJo{?!Z7(=+KLcqJ5u1+qwxTo8~M!jI&M{7YO?;Qgy%^s8anuQ+I! z8~i`wF97w^yB#6dpC|V(a>VQDK!POfj%#?kyS7w%ek)*qNuZW;LCyvimrn%zssXr} z2m$sL(y#*T=K|a*GLT+`IWFvzO`s|fBDg{*XahwcSLp(XEMhUwFfvqeh<~Wu)TWri zw;!W@0ov&fl$MwaY8g}N#D13Pg9GxKKJQCnndWlm-=SP+Da1LJ>rrBb%_Fw^Q)__q!b$`8y2!qk%G+a%@usQqIpLX zMk!J(tx~&^S9g~06lPtyGD4P+Y5WNifP5<<>Y1ayeAFAjHi=tKgf`;}KcR>+_pyGZ z_$Gz4-J}&SWU$h6c3zyrT1q{C$L(q$H6!t{cr{sovigMR6q!(STyEo#GhK4SuyPOn zuyfLh-qQ8Z?opAe(I%P(mIL$qA<^K<=Xv-|dC499a&sfg zJv*s!loK1>__TWp}(@V|AkE_-1JLr%b-l1b(9C^0ifRfPAO~kfp*Wi5VBjP^qPvrjcl} zAURI<@Xf_7f|w_E;3ctOsYWb* zjV^kw?+ZhT7ZfzK{0(JIHi{Npx5Q0W{&|V%;|+l3L6RF(mjwx%bhJAJc^T>N@y6B3 z^(o5YT2-kKdD9DCRMMvwignNX%cxH^L~_TI^}?tMuaCJOw8*Zz2`1Xr(-Enb657gO zXF$U;vKK}-@oW<2{M<~9rJ>ZhC7R>=r*f&9i!CDYU8HM*9!tw!kDJeQ)Q3 ziIb`oBHs({=7X%hq|J*dk=2}2Sw*qf^w}4$u@o8;)atZ zg3$Ccz^4$j^%{RW^qtPx_9m&ysJ5x3g(#bDO}3+WdA*}S=H2;BBkV!>VMvMiq^3 zpd}npUU58L0K?QXEwdFfRM1*xeJp`ev`!kT zvKA5q9oq_F4&E0uT4C$7|Lm3#=;zW8iqzDlCxtfgo3KcclFMZ>o8(UqT8nI@^(cp^ zg*srm1yeF?B;@raZAclf5@ zq_rsBlq29 zF&i|U`IL@BASZIhQ=4~-I|nTqx-OQCuLwzeNyG?m9=lxv4a~Wm!^NIL;U1R>s_0-u z=ba)eNYqrx>YH6$*psHjgh-ka0|dT8{)s)$J$ksfl7S@I@^V4*6>5nEdm}|?yA~Z0 zu~a1w^f0Oi zWMj5hNr|ajvy|)KZe(-)9PLFGpgD`0&%@99Wd5T6>o+9wMYLTl|YKfAM zc0(&1qdt^~U>$QC+grdQBeBDr3MCJC(0Y@6D+u{{!CLuxWR#GnhnW0DrClFy9Ei1= zddC_DTmtA`hE9dl(tve>I*BEYY=MNTAqDJmzR3@dMqQb}P0~Q$UwoT_iFh3_KNbQB zBfdUjjccf=f)$;RiPGV+ILd{LP}03q3XrWD`VRW~V5qhz(9{raGxZfZe>YV+Hy$u6 z77{3ZF;!?L7U^(gk+a2?d_}&Yhi3y|Z#l5Z1r3;2q8lOvt@l0NE$%U_88v@p;yrTg zEZd7N)jcq#t{T)i~a3-v^z{R@6Chk{|~ zV4;0GHhP(`Y+kPxvsE}?ulKe?LN}u45mhiTp_K%;I^RSIn}fi95CtUCZbf(Ko7AF? zZrLYcZ9s7GC}-ZbDvErl`|5@oQ_Bl#Nj<%1)7U7VS@8Tekth~3anv^&v!dawZ@|lx zBK_Rt;KF@7s-aypRj`2J9xS$R!#JMV>UKZHcJx|qc1W5O3as-IA*wLnGG{lGNmg}l z^Ub-L3g(b*h(O4bGFwrv4?nS?mB-ZvT;Ee$b293!{N{!8B}uWZcOX&2Ul$nP*CsWC0g*jPL$5TvUc#NsQF zObq;zIKk`V{ns8c+6EJ=rW>btqrAU6a8!E|r+6b@F=#+4P5nl--+cBzyNI;JI#yG; zYvN1Hf7C&2Q6HyrSK`DNg4J~0Z=v^}f%vU^^IwUPP7x47aHfyi#Cs%b%OdQIT8 zR4)UrbX^YK?*sSv^kWOEc!FUJdSwheM$4-72^>6fND?89Z+pr92tZ^98jrRM_HN{A z`BlP5iJO_c30{+E4X;VB#*fEd2E%?;@shB24fy#?q{?a^UXynXIN|-Uh3$`m1wCi& zh6A}0y#NNQ2y_(L3pdL3b`u{2yNYte|3rg7j61?GNaw2lB6gD!cp!0M@?|;7tXNJACj<$jQ*QwWflANHga?-0jI#c)*eB|J_p(!A3)<$ z;cy@iR{;CD3mEmls%0#wLPgq@FgVabJ;JfgC>=)RF*Ia;{!*C*CePcSE$9CfEo(L zf0R8Db>B1xuzgGzy)j;DgyBc$akS`D^eRL~ga>du7?t7ls($Q5Wy~0DK{$`*H$rdZ z%J|Yjbs0D&utA6cWCJa%2gnnNQ5gQz1bfrL&1pe;jUT--j&%ThV}Qr{0LvdkPFd%W zE3*}6WDUIsMyi1h^nKkM`+K?UyCR<)k4 zdJT`22mbd0BA4=Jz=1E~|Fi_2?Fg{@&iet9<8h|w^gcLHZbs>a2MQvb16S^1d%}P? zc!6ATIFYMmkQwaP1BcOp;nuVWA=jq0;ef^eG8V0|sT}QIhF>4oqhcgRAJ}P%%29~5 z_U8v0A*AA!fC>;Z@y|E&jS3J#w{ZJm=6)wVe|!G#sfbhj&ur~Kfb%Chj);xl#rwbN z(r<&BnTEeMRzEZh1Tvua6gHGzHPry(!`gmY_gXw2E@kDsYXr7pBLD zT2!>jsAxkX3zJ+JL)a)0bqg^eXX}6t@@TGJS(ZnCS1*1T-Pz#*P_*bh;dG$hIxB&K zf~(qrow=z0LP66a@WFPr&j0~O2Zs&I{ge#9F{O>ep-s&69bx=zj~%S zKVbKdrNQik3|i9sKyzGa(292*kCg__CByvymJ^lXLyf=+?Fj=YNb~_*xrE+EE};kL zF+Vy$Y35A3idQv{qXNVR@~=)Gg?UZV-i;iOIyy2OKt_I-zrXVY$T?&xfQtHVLVUA4 zWWIWOf7C5QgzG;Zkl%~xR|Nkk62DFRUkUN|!u|)60ufICpvoU0^Z(Le<2B6()WO5X zhxgC2B6gS0pBjbPJ_Gf23NQq{4tQepk#az-Je)Mbm6~+=2=dtz1EAr0R)^ICLlO4z z;|;hX1|?8#;L$2$7zR9v+u2()V}Al(1jg7+q(N{@paU%{-rI2V&)_#JseqbvjRC>{ z2n!%BU{8I3E(5?stVnuKSmU%*Wz1>34!~CpvBz0;3~)=EPVc2R?n-3;1T-+*lfcp2 zXWL2t!uDK^n9(foJYt3_SNb#{vh0r|hbVr8(Gsz6ASXLuK2W~ER3p@Ww-NuS5jcy( zY_o}$f7cBmq5(rIHA=)b2^;V)BBtihtJuo`a)GQe5Ib?S3TzOFsRUu-8elp9;TXy{TAHN_V32Y&dTXlQ z4)Q0VC~!0XqRMnE>*i<_esutRA4T<{)07-)wN_1c+*KG=b{j zHQ6EFAxE<(1}I$+SwxJ!KW^{+z)!2gVI=B!!mrYYr&JkM1A{n^H)Ay%4nmyal}NO6 zfe#E&AE7f=hxdb65F!r{r*#+Xl_G~IOTc-b+GG5BfQD=kc(y_tVA&Q1)+z)+RbYny zNe-K3co6SlF9V~+wXU$zk*LifEZ2|7@V7x$KY)b_%+o(*n0{k1OEACw(}`v;{73)7 zO#iQ<;~BdPKT)M(U79D&d{K+>`i+CFobo zK`=YyIynFP)$c#4f-H+={^Df*`1n6_b+P!?fH^j2)qCEDyG`qbd#07!{v(6-59R>? z<^8wxf#$-9{*NCYxtel$X#6~@&~w3+M(01X%hd-6;m@B~rD=*lL@U=z-gHFYhNc@+AD6)@Jxy56TS2s+%o7{qC7%W?&P)I^W9B z1Dwq+RR7i2O*q*GiCO29!&Vo+~BbJoTY+>#C{&q-^v%ll} z7fhEivXww)_F1rDVD=S}sCHGoKm4`PH!wrkd_Hg334da z-iX48=X<7|K7~FFxtbs`v~*-zyU0?e`O)i+233t@g%}u{J`{O%8{Z42>{)Poja=%KNz<~L}!rH(_>$2jNm`rS2gzBc(=&V z@R6DVu}69+1>3=v|Gd7>i?!{gdNi-yX3vAX24y|Dv5$!ol zbC=>j^3p8(6)#~fV*Ak%NsF0>S*~mij%2(*!~gFKyZ*Iyh5Tv{2H36g*xq`@FBta+ z9{%g6Ns4kDg?PXvv)N#_O>8#^3)09a{9+{Evs;E;3Q4}#^Obl^be=NtC3$?6DfgI^ zI|Onb!Hf57J2G-fs^4R#wWWeY zoLHnTXHLA3i5rC9$zzQ1Sg1sC*$8*r1@loS_^&Zzj1IMO@*?Ly#vM$|948SeMxkR& zOzgcoMg84;&iDkrA2tNdV0qYS(L<*i9v9Lw3gLTmk4cLVC`U;_c3unic#1sab%5<) zl*`~{ETTv77FNS@tBwqOW=Dcwm4ePUv7}=|_)bZ>Lykox{RamtqwkM#QIzGTb^=`X zVhCdv08ti^KjH$*?Dr^&RTE6sLGwUbVF^g&=U<(Hx;f9MTrWTS926k$9ke4QCH;&n z*#lC5Le`}~C@LD|V@V=3X(*Qj6;4kJB~`Dyh<>Iqo=8?F9eT?uIk_=+_4K85Rgl+i z&$`HDK@^AzccJn3Hw-&2#!HFZK~1GW=1Z#uJ&wdp%k`MHlF@tM`NCagkj|+-Z&AX%FiFX1lM^3Lh0a~PNzZW zhU(ZRUw;RZiR}Hyq&ar_kl`)~7K-0SRFa9}0~==fsxzf8k@+tltKKfWdGci|-FjFi zFPr3ey2+dJ(!iE-SdDy|DaaDg5A0qmGPh#L!T+TnS%EU{&~?3^rB zJ~W^aGG36WIO4)qdsfOjx!jqgkI5{Fk&2(A+yE&`yN^FNL?1VBYb1AuP~1yc7oIq7 zyziW$rhHa7HjyimQ?$g)nL8;Cq38I~ zhj&pSo2O{fhH%kznH(+V(qw;s{w^{Ui`jF)DH5V$T)|5O%P^2|`=i?$cT2*2=N~Z` zx7}J{^!XEB;8WULsATfoR)|z0McpfPPl|Z^h?KE8d0^28yKG!PXHhci7p0qaBf%R{P6u!$8qA#kMnVROo|J%d7# z3J`dqlpv{aE(E#z2@$S}9;|r5*PFZ5+Pq(t5l!=%EW zMh&qe;&uw|DLFn@O#}|*+st26D){(h2gqM_^z{Ha$_7lxK-k2S4ZsS6_bTuAvK?Q! zLDN;Y&FOuJ*4C&d_!z3jA3_^71^x{2C*E3_>@px>RULKWEaYI@07Vk6Y6ha+1L#6E zZK#Pri$ zvKSW9whw`Z?lHIAvck{`9Aon1Zs$ipC{gU)#~$K%ajzuB6}G7KSE$@`kvbb*vLJ8h z<0o!Hmvzs9i$P?tUH`(+5?kJh>jX< z;zIs}6021+_#V#wXC6isdj5uO?)cciR`-lV-mCwCQ6!7DYAuq8#GFAmw|Ss%19tl94iPfP8`_;=ic6 zX4?S{c>4^X7`S1!VG#fN4t=};xwjhj21`9ia@KU>kTwDxz#j*}yzw4z*N@K0y4&OP z#G?WT(p63;lNpdm&CA>Qa;eJ1HiU4j^S-nrfmwa9HLAn3} z{IE}34@j%RgO&11pJwTr{E8l*X89_(Cw?us31SlT28l}84|ZS|bFf*QRvzy_9p?tP zg_Kw&3j(MC7O1~X0-bGPrNMs-^8#rVGU|IeHp+4EyGIKD9LKxV^L32?4yV!kz{>tt ztZ4E45QcecsR3m2$1%_ zoWWh|Bf3_*v!XEBxQcdUU%7=(uu4|rH6+TD3@7i^07x_5((^c-`S}_%EqbL-Yt%5p zHlH8T0Wo86cR+0ZN|;X5$o{~wXLyUOqPEf_h9}@anPB=z1ECVck;+X51VK&u?y6?_ z9?*!-HL&6W!yZaW68v5GTnmcrkapb}q^-YqZGZIF{u;x4BflTV{)ch>VaR`+jX(U< zG=GQg?9?M&rth0SOLCncKLq^K>Gy8K1v8X^e4&FhkJ;k$jf?-rya4o99tR`y9qX?Q z%z$y%-g8`AF5Ei$9%NySjZYb$Fp`d{SQv=&UTkPirys7E9PfekaXu?Y!6RXMa7>ST z{b-<|_ZsQ5T}{cTg09vs3&}|Fu@n7E2=gC_HCWqR7R`MdJ5|*ab0}q@G_22mu_9nT z42xbrI57q~AwjQ%9goL=Mq$r289ITf+VwE3(CrGl>}xP%z+PI(sM2TYd+GO$VOY!q zNF{H2IY=P82FG|8c!L^?w5KjMxQ2t(xI!BJ_1znGe*FNk&5TiaP2kKJl>#=mTFC|J zFtB%~ZvluqN*Cku)S`vJf_@cyPdES^9|*6Gcin^B;;L7MH3~W&f2rH$(%iGsK&Y`D zUhAE?g-#FIZJ}3}C!{lETvTuP*R9P5*jPX6_Rgyji1k%V~i2|I>kVw8A9{UBrf>!<=QDl z-?vUTPB)I6TA}@ryUPm+kvZ<;TXn4moWv}#Fhd|Qs&bISy`cRIfgGU5g&iv>0Jj6@ zK?jZPmfp;+>@9xs>Wvj^!5m-yGM$KPquLFYo#26z@L8gxk6e%61s>Q!2uaUCT1xih z3e82Z*<1&{Rb^kcrJpe=rs^-=YLWQ){!+(fZMSvlg$G$@YB;E3*mIiqeDWZ#eai~n z{T?xU4xjd*rhK`_`_fk|*ArR?MthdT0f6`z7%l?Maw?+qyR5v$2IT42M-8&L@LHEr z|I&5M(g8+=hk8ry@zCu7hyh$*LPUjOMxUbCPrhVC@6QGZjsu@B%4wHK?{SBEJ76kI{e5HaE%nNIb_?lgBt-cRHZB$^14z{5L28It%fMRA?+c(*nnuQC%NKXAH)y29Rei0Wv%Hi)oV;C#sNC$hOF=wgaq?q9&s> zJ1?$z01M>7ns1XvFYhZ@Eiivp!R^`M?IlaL4MC~^Qv&wTWUnUutWH0@-|(&EKyf1{ z>$H|OW4yqV;BN&NEL(7a!5KN7VL%TSe%*Bffi;5?=M8u?j4`NPM?btYZb&28rIk)U zlxZkIXFQ`9CR?b~jRxX-T&(gv7wJ`{jMQ9r%cRp!%Nk^|e_FriWdZE!tg3k&hL`aX z#N`kYn*f$dyz*&tllAiDch{hxP#~q8801%3xtVPBeK>!(3RRoud z4A${AlDj+&Ms+#Y3ybLV!gfHE;NB%G?U4rY?o%Vi8tZ{b(jGA!JzehT!l}!jv$9&E zD%BW~17UQqOat4?x%9wRDOmf>Gp0`Od3N02amu$LpBXo>W>Xn2)dL!EUYnv#IA1m+ zOW?%W5$*xg4Q|0OjebaNmaJz;09*;HtLQYh^O+$fq=q=Hf}2nSPQEKM%#2O9ncj#6 zmputQs0qXt7|lt1cmG;$bpQcqJ(t$5dCq~4TVA&};^vH`)C6#l$tVY~e{SC> zPj^PXF$P2c8s3dyV7+VLK*`nNf!T%0KoI~|8Vmy!8Vu>xF`x=Cwm}s@r<|@zAmqZw zy^#mwLk=07qK$yu{n8y7nQ}@$uss~ESff?o z$5pi^OM8g`X8;!ii@no6)pmg3?bATGoLX{Y7!1p`9(S`2u%I*`c*1ZlUPrqo5N}_$ z)CsvYD_a0mtNnma3~KUr!)I#vyT!lp#jh32@~_YtfQ0-S5yJFAL^D!APr6bC~#QMrV&23Jr}g;MI=F zvoqHd$^!YF9s8%uF#2&_3NDa- zc6_Y3B;DW+HwMnqU>wleAFFZZA-$0BseN1Msj#pc3&^$;a&O-c8J_{hgH-`YT%Ijn zhSlRcJ9`}0u(Im6cU_Lm1gZ)}5RH|MR93n*Y5x^Pc=wES@-8=? zLE7u*cQx^|^2xiD9{P1o%7J5qjQb&L@Npj>b^iM5cq!(-m;HBph8;a@o)(j>YL9Mj z_UkcEf_x-b?gxA-?LD__9>!YUB`jYO0=s&!Irh`X&Lk}`qgRJ}5)wFdG56NXTGHY- z2*e<|WGoxBOs99!PfNtARJvao5b^Cjb*lDu94l|Fv_5}>088ZyYV6g>grR6D#&IyF zUtCLV0xmZMc->?m{%i9hw8gjfOSh3W?RG(h=xJUDN~g?f6dAI9`5+%2Hi>Z zw{NW*=vb{~{%1GqyVM8Bzo^tn9PJO7`qWpFoCT0(rC8^yNKF#TI#Mmr0acPctT6^t zMd9(r(qdfhJDIy-{(NIqk2+95=Xb=hDpatx4YI0qXvi7 z%=CH>O1l+p9KU;u!PuRCpFw|i(L&~lDK)@4ETgwg%EDNLIVrxX9`12VyFKgKRoYM~J)K^)UF0_%k5h~Z$2As0VWIkLb#A1DXZ+z6<4cCS zSeZ%Bcx{d-(~GK`MWRB*H@`_-;_zi;@U@&ZZqF-+V%~UE$vWqBF+Jil>CHCr3G*`I zpZL~~+NUwQ*~-mxk`jl`zFF3jZHyXogN{iUv)pdgiD3B%4HDggYpC1rR%PZ0K4)&S zXE6&&bGZ^W1X`HY+GO0Vz3)!TyPX}lpwzj3v(vQT*ySb^N$aQ~t+RP+WkO8w$`YB0DoU*!-BH6TLMlI&k)Xs&Rect!S6z2H@1`$OrCl!heqco=2N7Fo1&9eR+jhyUPNc`uA; z?o*0}#oLCI@Vxvu&KuI6qzVptMRj<8(NtN0(7D6pgIK3#=t36@x;##>V=r0S?bD7i ztG@MC&YAbP;EGck=k;x)06b==$Q7JRp<9lK${7`|&_bS4*r>fcs#dkdaq$lEp`%J- z0={C$MrwuMZW_o)zzh#DuV$V`NrH%v2F8T)Q+1LdInb;@D3TVnO304EC`mr{ z4cosgYi__2skVM$PZDvGzd<%v{2dVkj75IwUdKx%mS{+=OB%FjrDh3 zt`*(jh%cdedjQEwVDviSs$%pz% zoVCEXI(RQPL=08o#y-hS&q?%y7^P-z((&(NlBWpre6{mn6mD0w!R^yym@jV=o8~}Q z;kFWhXtn5*&$_f^ww^!+4b7-_>n@w@%&P2&{+N$dN zrYm+-KL8`T;@O=wE9*=-8vCqIifEIIPD-3csU~9I&vRNImF0AzICatcq}`*ZlOe2p zhM!?xpO%nn9~(2T!($#-SUup$mJPwS?M~CarU@d~)NAguql7o`IS)e+l?64aY&bgM z@JrG}Cf_a(b`Wb?ps4TZvj)7iQr!PSG<%io>fA++r8H=t_@>Y?@fJ3mvBk-YxU(^& z%gwRoJm#SYSH#>ZOL_CAS@&b=`~LF zHS(tRzZCIk%`m-PhCHAPJ-B?|t$!Wpa9Og5O9mc&I?STAq;$1bOj>QWS*~;gK2*Qz z*3A51ebnKQy-KMIwTT~$QX#P%f8o!n8jp>=_J3(gZ1@#7{fc(~sl1gks?|w6D07uf z=18iu%5F4oTQ*r>;W_l7FUmHOJ16#8gDD~Ui=Qcn6P^B2;i(brG(X!lT07UYz){xf z%z7}3u+&dC6f6Z-1@wzeii~lh2+VDndzPe;!*rWw(8BBwyfv7eW6* zzI|D7>#NB7cKj_V_)lb~UqSyrXunc8|2Ncsr-T1{hlDHMLzXl<94Hf)@}nW}-;2aQ zgvOPKyg{*hg4%^2;HVB9y!rCYOK_4eh1$3OgXN0V3k*zuFjP36OqHG0L)(3gpKlUX zmG@i!#_V#wO4lLNPZa5~j!V^N7hL!Vkv9|94{$sWJILlr``LsY*M4z~g1ee8F7$BI zCdleZ&PuAK;%(|)E9w_fi4#eGlx(JI%~ z9Jf;1=?`(1Z%(4Tt6WBl`lDZH$IDp=PB9(Z3D*ej$0U|FA6Q9}DW5Do)VC|zH8=Uo zB(|Vl(-D7QYc%_$D(@ZT{N`OhEs*JNk{&L-x7{te&Y^4LnWL?pMyvQwkunbmQtqwM z&@~7?yN_hNwH})P>ALT2lF{c&|Dab=ib;)w}Od14r9t;=Su#S%+)`gJQ9J@qw&L2r{{&P7gDq9C6qh z=@b(|o4YGkeT|TI@tFoMkWDwT97vP8{d78FTGrhmP|(gwoRm?NES^z# z(xvdrja)fHjwDN{PZy;Utc|}>>h4nL>&aHWVIZm_Y<>l*x^|CM|B>TS*<`Jg@$h)l zvaQ7YyQd5i%}a6x43!D@dR%b2pD$8Ra_;Izl(m06htlc)94f})Lkan47_H%K7+uU0 zMoWxtc<(v=-)Iv2k@=wQq=tULM_26SXx=gj4cT90exjJR>Pv!<|GN=K{DgeSNjWdM zk^08_UP41V-A>E<&^G89k(JC}WY5Pgv%4xF|FRgTR(#~$-d}qV|3U=!FFN#pWSag5 z*Hw6&I4#JL2k(r;EL%VH;qEYxXM33UMIwUt!KsmVTjPJ%*Jp=$)1ce`claMV1a5LN zzbw%H_4gf8`lqJxCh}fUxi_UkZgx8HrX$ApMSUxgs@IpCWpoj>@B90610r~n<{|#S zEGUa(hOV(4wcm?_Vn8>Q3n~S++qHc!`Y!(dP}PP-tr9V3r2^yJ_< z0mo^8I$-C#f9(S&@wFxWU1+0V9Q8(S-CE``%q}gG#N!jg)StU+FAt-aKG#$`08_1bd<$D> zOsYxrWzzi8k5mDJB#ABe1xQzkVBZ9KLVPMBm6Iz#Y08e8&g5#z$jDkA!`q~N3`Ijv zWn?sKyX{*(j<1-z&ehxM_T=XmT_`=lhw&aC-WiI;`sd-}*Y*o~`J<8w&L;Bl-@^I} zyYr?!}faF_+Dl>+18~hbv{-i zXlJtk>4CMozdw#XM9DfQkiD*=n&yIkw8~ytmzb}rTfb}F!%VdEO(Au8d;F*U-HPfr z8{6C33`isgR$r;+QdI#`3e}PSbV{MMDp7W+J+|RNHQ4{*{0Jr7WlJlW5Y9|dF+dHb z**1&Ip@uG(T<@N2Q2IP?0Z8^gyy@NH+z8jt{muKLV&b>9*~ZDzJ_+qkTUWty-K7{* z?2&tH;Dd2)(B$x>8Pj#KOq*hM3u!{)q{cg*pq;)AL~PXQ$i04*tv-?3cF#ppR^zSU zM|xer8u4lRr^hF0I`1EShNv!_dv3M;@8)hiy0ef_M2&I@QVcs{t|irW3*@#27tTEo zeqVlPDEffdyV#<;&KiWbv>^FB>>AP7I~SDq>|B2@=S)b5>f-dH@mh*qYE5s+YRv7I zAFx~lWzRwRLT$`#m4!N#_Nw)Pt?x!iI^|14P#R*M5|oKtY~jWegsmd;LvTCqMrm{Q z>J*|yRK`+O24ApT*Ql5Z?cQs+k>+S=-on3l4gQqXA(IU+wve?mo}ON<&*9VS7o25Z zrpqlMI?)9;5Q$oRuQIzhXeUucGV|v;!r@~kj08p554uzoaOS$;HM7G}1X9dU|N}vxh4W z@4ZhnYl_-B$n+t}KPaJ^H9**WK36SCC6P$%p{~2jT{j0tVigkOAps2^Ai~<+hMV*= zR~HI#n+FGc%8Uq?l&*<5)*gG|$FI2VIT3QSog=%XT7ZO*BvV#qagwZDUVZiaQR~5B zhHU$`O~bU$HoX*y&|RmUY`HBC;3w3_7B4^7uwyetO3d_Z#sYZiEMPkoeS#u|8?2#^ zvz|zi2#Sk&NmW#8R|z(^4-*>_*QW_m6?QI@TR5y@<>9A~ITH^U6;ioW&c2v22R9HE zX?)&FdhhV{TU*Hi+43j%Xzg)g25<^BK1A-2)xL1=b&`i%16#A>zO|xf6`0oM1+PjT z#hDA_2<>jUhA-f@&3X>jq)h3O;L5qE=o=R#Xek@6NXK7GOLSJ0+D9x8?UfVVL2)VE za+_vvLZk{@PYU648=db zFU5km&Z6QV=Sr@dhWV|2IlF1GTWzDS77nmRs$)1JP5q+C_5;R1{_2gRU1%u1&CZJyaAReAiG za?~nG21Ugjl@{~X<=_XYTcdn*Tg&$!LRzt`ghU6Wq6m(Md{}u|UAG&d9+iTkEUC=N z^g2!uyt;4&St4i`l)A*o!#V+MMpn~&%zBm(xV_!J7VV#dhY@|7U0L4lP!PS<;3p@_T+QCN{zq?Ag0#gYh$)i-DQ zHT5iDX3^fQ$SzKnt7Y3<$r%qrDK?!!X$XJ0t3Hjn0_BUnu(T6M)NU`ZyJ%@yJAUzD z#yO*0{{{h+ z-*gE%_Th_ppZ|`Ye@Wf{**Er2V{sso^e^sf{)iyQe%1fZF3->2JzMh7h*)DS4iCPS z`Vz{Y9B@2^om%aSeI81&x;5~*6MfY6(ZN2kKC32C^3Qj>3161#*E#Q1x{4Q~fOdn=x#tS~q9P5Cm^CH^krHm76LYwY~31>b`rSG*T# zC(lpEH!MszO+Hh(xjW+wC&;sw!G3@6(k6(J+`>NTzZ!UN_x$l?H2gKS2O231$jU@` z+h_`Hi>h#Ya>+5GW`kU?eQIepj(vSL8sj2i&ev$QMb!W^4wP5q_&%sMwkx^)dL??r zJ-*9UDkb=(Q50!;{=Ib^C6kg_Xq#GSq9ML^3DcWw$VQQopO zU+e&QPe}jmp45%#hXDqTI|Cf?!XL<_P7P1Jn+Ec^N2cz+@Mqe*{vHd8?!wlbwV>R5 z_rWVNa_Ld2FD}ZL=f_IW^jpaubI0MFDrmQBf-8|@@bhF;RNo{HgRs2?1!`4h(>?Q@mO}@Y-8Bb!L z=I+1o|JeHyc&OU;|8vgFFoPIlUt;V;jIAV@v9EBE#;lQ2DOR_iZxK8ee{W=9^&1{vb`!+Kz?R%K7rks$wKpFjDMPaYCs*Y}9 zTMtbG4Kfc))KqBIV6Q)k~g8_o&lZq=+Cq$5Ugpx=whmZ${1>5EiOx zyx);=W_bmSx7u$BhDB6pUnw(7p5tf7sQ@`{Ca47DR{CtrIq}*#;6|BL%XM;cFaH_4 z4GjAW8;s>o(i{`og;q$t^UL(Rl$)(*)#g`*T3rhbaOw`>2;6I0<_XE7c zf@xNRLlzX980K2GGQOP0uH|7%OH->aG%%zCeIEwdK(*Q4v->u~i)f0z+VJd*|{v-Nz}?J$rdJS(C&P`xTFwKE#G39#41(#@+VU z8jbN9$d*#`bn!)AYV|xM@NL$%JcMv&DCnxf?g`#IyNd_eF8dv+x&;^rOwRVu}rA9Db3@ z1lq1-c!jG5@L#uyoiGssvRlg8%`yRzYbuZE|Ge^l8{ACi^%n z*nHoDarx;|e$lsgt;0l}zP$ojCqGcM7?+|cYIGH!(&A?3#0M!rmVG~Q1o%6lQ^9`6 z8cd`)G5{>b3S_|1P4GQs{fsw%at{p;4oK`sI#CDf6yG=C@%TFINc5#{Xo0xh8hPiT zi0CWHV8QhLd(eH-``7Sk=0ikjU%&P9&J);3RKi_#!Kgrv_v9DV+KJQPV1-wa!S{Lu zg?`bYch_7lm?p|v!!jp)JiK8(A~TA`vCWwr`S}@40d|wL?wGvivpui#n#=Wmx+ZId zXrnlk61AQ^0GnvoPr(O|YRoSubcWk2+?-C4w=m6r0OhHs<9z$s6Yxj=8B^!~+&LB2 z!dT?TYi?2AFb~HQ`=@Tz(;eKPa{C^W)DpD|>~^La3mF^< z*gY%Xa(ekNq#)2zM;7cs@VaK*TI>Qy+nQWbk()f}n5dpzmWpo!A(@HQZI9nBsjYcO zPtOYsrzjA8EQ@!{?!HGHLu?Gv5$#)Mou_P_dP7PS=WXUgjkFHx&&j?6Aldcf)*GSF zuHE)p9|x=vJV#lA^p;(byoddO%j72(2e|q-lhv5?+u0zxJg!xFBp?W|so6$Rd}PrB z3*t-1Y2Vp_cD+s{N9VGc%07r{yH2->8+;FY2Wn}UHDWp^#I3ASZV#L6O>23U$f{eu z{eXJPEA)%1P24B1Y!J^{CRqllFl(ZZi(7wV0wu*RK1imQ-%iZu z5GROq5krtWK`GZp#+m8&t}VXz5Zcat^eMx+yY}%~$I1Dv2)PG+m?*^dE@&5%kG|N* zjY@4PQH+R}>#ab!gB)=_WVjErG8Q=!eL|so2XBe!ejsH&Ja#HETdG+-8IDjqI=@x? zL=HtMQ1ba4Zv-R z|LQzw)k?*~TAR^?pg4KC3gy(nlxS%U-V{NrXJkwFNQ^VeEHWV7-B(a`$2Go2Y&d26 zwd3NiBN|br#VAYq1yflayi0i;URQDsV>$Xf84eQtb?oktOT)L~~6l_(NP$ zE{TpdrYoY7XA~K8<~%3iXD^ZSmph7)K02cT@eDcb0n&Q-eH&GkboxBf#wNCN z-?2%dwoB*OeJM|&gv{L`2_Bvz;98<-vd9Yb&P=gwM58P zwk%p%>=w?;oT5mF8C1A8I(xw0>U2Y)PHx|PsP&nA3V1>SRp3B19O>VIxx`)AC& zKTeh$wr1qJ?On?5&EP@MQ1(TcEkO&Ecla8BQGcxG3Glhsxx3H6%-(^K-gw12%NV}q zNW(MYvgwerS~lH=M-`*vDbnuD7Z?-lUPq2(V(Y87nJk2tr?ejLL!R4i@z7e!fF%o% zrM)cia&q=d@af#0#e`dy{6kBEu^;)Q!sNz_D|7UPruQCw0*Ow|SEkq1ExB`k)`Ogo zu*mDWYfduSL6pP^=djFnQU3z~0;h`!}ZjZ8R zZqxLX=P_+A?UE{FaKVH2r*SRt*4@&wq;@cgY3*%zCmrh}zgJ=efq*wfd{GyVOWLH2h){htBs-yxd({!{<2mlOZJr_{fBg8ppozqt|ocj+;P?v4ibOPI0! z)_wZF_8rylRusGPH`DOn&VIirlN=*NSrC$WP8}W5_en0-Ahw6HJwR;GI?CI03sVOA$Jkxn+@DVa@tVo8-Rq#F8{d&UJL7 z|K4Vwo+>eMwUcD)4-8T1!*vBQt?iI?92|W7oe6Wjz7Ggq??3S#)M|Q&#}E)YoG4A* z@MbZyDHxM%(d(?>9divdb=N)K6MP>h3?v&F(YgGyIvY<+5jN+tzrF#DeCONmMdPwJ z7UjM%gZto1+A_)MQSM`K+q6rsz4Ruxi?I^5?YO4JsS{z)@hz*2)WjTh(@E_NeKDL` z^6`t6-f8RU0nmsjw z+2DN5d^IfgQrwOFO$O}s=}NIWc;z_QNiaVM6>=Gy=oHV)naV@X%wo!x)7~gk=fNgv zl8b_{{Yy_+GNWrb2zS`F@g`?cRHvtmd`Xv$(=#QoYXsUA8C|ELBCoU9HAKc~`zZ0G z({)xyxTg|naCo=2CC(d;`wlBQO*nzgLGk?&b%j`(6!cALUZX z=uu`;Eq#53Cy>J$HYr${+cd3Nj%dB)?kr4C9v-+kB>Il;KxkHU(0 zkbhH$R51Lm-ST!?_!%QL_%?c>;|qJX-BfIrm*n^<1~!GBgMuc}=K6fHmiUQx*u;He z$yv!6U4IAu{q4N-FNJWw!l$g1I~*yhUYMQ00w?GIiFXH3Xyeco$F~@aunCa3t|UZU znYvYx7`?1jt3tW&sUL}*cjmgZHIS8v_L2&cS&iw#5%^u33 z+f-hGf&})0g$>9B!$ zo%NfQJ$+8`Z16N^$EhU3)L11Bk>R z$6JPNql0tv9kYfU7>!tTvL%==~}r;!AQj>_`je;e=l5o7&4Z32J*G zX`BICp=>Xs>T8esvUS)lad4I)ZPeZBUWRUnl^g9OaY9VvluUh5>>*C=N0aR z4z!JadAQ_gH_>-xJCS&oaVD`9)4{sd0b~i51~j_r1wO@_TouB5Xg4d~9H219EBzMq zE_8`kkfI14k>m%3SkK+kvZ_1_vIQ>~XZE5Vck?k0>4%#0UaMw6rG=&{+#|}_kTS8r zPj!pX;gyo(+%W$srgn{cODdIEMKriE&7tDfdlo-hHWgowcZJzIQz5xl~I&={@snpV4o_ zNq=T`f8nH@J-_|TucnTF&q-}1=KnL8YyL-gHvh(J@<$q2|MJE&uljzt1P@$zVE#NJ z|9f%5Fa`a$|Lz3b$iMYvDCom-+y7dM`ZpK$yCa1EJtyUVXf?jW&tJ4>jwIZo9TH*> zChcA4`FZN}5iuYn^y#!y$asfa9nk&$4rXOViqVd=dsIIN-C^=Q77<#dG=>Z?>g+rn z6NUZ@$3A^YM|6d_`vN2@!99HE%5JV;=9tGtx{@n zJ1{B7b1ryMAbf|{nT&^a)NsA!pW-?>I?GgR8V32K4Y>q2?{i3m?EJ6Nu;pm#+t4bB z6`ES>$f>2`GgWq%e)a~O^d20gm1;tpZfaFrLeQ{M)g0QPhZRQ6=T}r$+rP2yi`+5` zp$y2+gj`Iwlyv6Rf;hhNbtafev-MlNh4K?`Ux3no9TE`ALYAu(S7~Z)ynML3-Itjc z)?Nx*bZ0(@^)f zrS$juF5(rli;TR|j~er&=e9V&DYDb|eA}dwp~2C?fh?6yhXaJAC&=Ba_ci-`XbmOR zCcrlk@h&`YO~lU;$*{6>ULs|}H)24m&)tLNKo>sr@mKlp^w4hVwlwXKU@iyCM#Po& z7eDs}e231Ftvgko@+MEK{edVN`p3YM@BMg$l1X;Ii~&K8G9pY*)s#>huy3hVnhD!l zq3GsOtq9}T>xu#HFV}kYV-?3DpY}qIKP*ZR9|_ZsZMz964Y%y!lYI>uCm62_97DOe z+nacU+=-5GAnVzB67sOK6V~iqL>vRN1fbmfZ)?)oGAcOAu#de+C zqTxG|!X}%KMW+@iD(!q7H3Xp#387>CQBrkxx5jLIrJv#LAWzEdMBm>u*MqVT^FiFN zKxtjlR%yiXazc>#YX@5SDqR)Fi0F*&;LzjIl6^B*?bvauw*af?Yi}Ht4B{x(0{r1+ z=!AFM)k#7(7mML+{9r)Nl=T^OtaFysfb7LMe}Ym9V1}NY()AQ=Rgda^$caLxo43VO zGd`se)DN>tweNz8O+qu&2KwB+9U^nNUVCtG`&y=A==^paj|*bh*nTem45bC5 zdV4uWqsLO~Wop>#uz1Ai@Ke!cF4^03p(yw8tQJ-&*NOH%h5NSm(fjn-QsS>WDK{>s zD;JV#olJxk@h%6B0cY2FQ|;GILxx5}ckD1UjF{QZ^Vixsp+y z+LJEdSj+}xoCT_@F zwp!eygz@!#dk1<{FI{~l9{c8djy30) zH&5KCgm#1$QT!q6VolAqo{kHB>EW!SCRPPIQp_?py-vqG0{VT5B!b%Nj_hnl^JG`v zf`~NnhER~`dt{cRa81vbDq}=%(JkRlO>>65gO()zk{@5p2GinC2Z6GWRIh2fTE6lH zg%Fn+_Hu&hKfO32uktLxpC46#r>cHSkfv0T9!(QZTTY5X~Lq@kSkw zQCYRaUy)z8Yyy;?<;L6SecIa$)r{6wLV1jLaQ^MXK+IYo@?*U9f?pwTrAG4QHDqjk z!l$`-A67zy+Nr>#v5rW*;Lxr5d#!s4##ql3;3@$%^Y?vr`AFQKoy#clIJW5xMZ^ez z)!f11TYnET{aqmSFWqRu_~DOnZWzp9SPU84Xi$=|mq3E+_i z@7WBpw@)!}w(=q`t!^#eCV^IsTl51%S?!NPFmrQ8-7OoaHSDpV&OEHC>8Qrrhr&Og zNS5UG8@@r`=tP!Mv{?Kx-f>em_QI6AYt*N^1Ge_2T!&w@Afr&DdF-tw{4U|JS*nJu z=Sgo`soDh(9rS$CxR#cf;BfH3QLUfFXEEBQsQD;RS6fqUBX9d=v#B#Z7MdFJUg5_t z6`6`CNS>Db2Ip4)v@Q9rs`5*$6C>^}*8R4rO2d2sCf*$U0L$)|hV)anDNS}4`0Ys3 z{V|^a&S2;8an9ito$&bWpW`Oq33sm>UxVkCy`f4b(um))9XONy6s={A)^b{R3tjv5 zJA02ev zv~|fBedeiLom9gE)QdD-mUirPvqG3=t^by{Ad54z!rOeOz^r3D+|*xhBYzyx>A$2J{kwQ? ze?Qv)+2_;f^5At4+)c1I;n$fK|GVD)N8;yyJp}>xJJJ{ah2Q1G>i!Mi{B!3Ypay(SV^92M)~q zaX{VQaoGJTKAtY`X)b%f^wSZV-2?|eXt^Py$9T^mW4T*fB))-4sTvwSw({PPvH%m; z2wsoWp<5wj4pYr<2HOLsizIu6xOh$9^i#a?BMKc$x$_UhUnvU7Cfr^CZBy2U%NmP` z@khM^m3v-%iY7)MD8#5FbM=xGmktXzk;eh2@z$$xVF899yf!xxP8WZ*K(|C-YZJ9& z;Ido}#Rc9^wKwXXFT~~>JrGTSOuR-2#0jK)Gcl=p@gU&zg%eVsxIl?-TX%!Dk6P+TPDhkG@_)KcorA%Swc7#nXa zl@zIgv)i)U7R#7nYyre%YqA&!NLXh^O+L2XBFTK0|Vw`E0`-i%63PEM~7qdp>^ z3ROK^YB*rPR3eJ!IyU@u<1&Etj7NfT8wTM(a0=WCd2M=zT# z(z^}-O0mWV=yzKuY^d!bA~%(FNSF8GaXxJc)+p1?14-H$DAFiToVAD%oS|Ol?oPjv zcy}PgZkHG20sDs3(wwL?wOE*wP2G7@NI4+;&f{IXd_9c0K} zRtu(k;8j!JVv*-A_}Zo31Emh7wklk5{bDubJ&t(i(^~j&G12O?$rBnv;ANpw`=*94 z?2e^UieOp7PPoQVozu1>_$&{fMTtDtWy)D$g)}Tu##XrTeWGti)Q1Mx9gtnyqxxeZ znUI}g-vR6&$T{>E82XbR^?NBfdV-#}ao_hz2A*ej8?r74zUbNk3bcLNBre+eIat0p zTHU^R^_+eaVd9B?g|J(s?pKZ6LigqTI5ca+MRT^#%(sXGMT6klVqwK*TPkpq2j?YE zX^0z)=z(6FTcP(*_zbBuF@A>qT69v3u|*sT2kYp%KeRJegcB2a)ah6-{!lIfzcg#e z#-e=`j+(qEsHZ5^0)%9Todn!Mz9N;y1@oVtB66J0w+KJqi|1^2>QyoE-}(aDqE$+g z>}0d8W>HIN&95|Egx({WODiE)6X(MVlIJ_%n3QZqCSV#j$WgX=Q{9j>6(`;|u$(_g zLu`!dbsY#@XjnN8EA|GlT-&BmFT;P>ENXF5r>=i;iPBIImFE+Ea`hT|Bf?yU&+lYZ zhAbofd#NK4qc=v6|)>3gufyK2Req8;Jorb0jBeNRvz=qPUqDnp|@C#GMm2|`rW)Ld~+cxLQP9d z14k}YJm&cv$%xz#oJ5d5&9lQ)`J9#pLm}df=X=99m4-y(#rdsu%JzzpS`A2f z9gMptS5PTcz{*u4K}3zd=Fk|I_~XDersdiT^R@eT5GC-HL@nDowG+PNv}cc}kuf2e zBXpB9sNV=2E?Ba(f^x==>K1u_t!J>KdiI%$DELVYV~&$l@A9K z!&@=O>cT0qt6b`w2JDZEo42K&^k#Cc>j>LYr3Yu>vRJ7o{Xi5=Xoh6z#*PHiE?tsv zp{)19z~Xs}m@~A<6B222C~NKotZsikl+0nh1`#n5C8^Orqzkqk2JAmDq=hT<a=RXIRy*(6YsUR!4-8H%IYb1 z=QR#@T@t23nN>1f%+$BvN`00s}EC5|UvOqhVkO(Q5E z3|^ahR@*&Pr@7SVS=Ft2(Zk~uVXb(^&Sp@?Au1%wJi;5-o2tel)kF0PnhbsU~(yT=R}_zH$e5f zI^$wpbj@?hBx^P;S7iO7J*PQNuwr8GY}n|J%?S;FSaTG<$XxspwGC$YBvPsKVbo_B zt^N4hFtg%S^*oA>(=ndQYuU7FPO#y^v-K49ZtT@Io}&ios#$I9sPtffG0q*=Y$yuW?67kiF}$-hoD*~WYZ zxu91kK{4aT*}s@J`+e0>{?--oPn5iVN^DJ-9kAyGzw{ltjPM5_U}rlDvW*NRxHFNQ9lb}jd%ZyqAS^o)QhO;1a(bF> z-u2dyqL+f3m}&AyGf%0|b=Kk_=$@>b#uUBDx{yym-7{2+iRb|p!gOwjqisG!P;9kl z9EEkY+%trZ%Vw@3i-|7@zO}i~%lsTsaV&62O)9Kl#vp-NwJ`8nMf54r7VI;7h~KDu#`z_(Y}QvSEIuZYO9-@N9IJOriGws${Ugm zjwhOT6jH-RPGE)Ue8;KsUSVIcNlDd|coofTES}64_@NM)^6UzO53yt^oL{L2N<3N( zui!Rx6nbJX2P*MCx9&0M{vKIZ3_MMg~k{wloXUB1s}D)6XD-#IG4^#!;Lr z5}=3PsnGO#{Y|Hdk%LoFnCWyk(#EQp+H=EB7h9ZHTUNgJd%&Co=Q=>QH;J1SdcCPg&7KI99XTb@PWafQbO@UOXZ`PFMhnGGHH&REr)`Qi zryokRiS6^X*OE4gB!w5W>MW{x)404eB`rauR!-rH3te8}>L+YU_AAXpTPda!8#`XC2-Ent zpM!2)U-pNnF+rY^3k3AtK}sPUneLUN*a*)w;$H?|sj=z85w?{SwX|y*sOt zMbaYuMKX{kBWn5H{L;ZQ!jB9>9we|A(T_z-e^K)0qW}xIOks?>T0ZX>ydJ|M#$|C2 zYUn?&!6lQ`6Zsp4`)>{C|9=ad{5hcRT>*-bCOpslF6*^}bkL6?oA{0cVVMm?a#1dHQ=Q zZvqTO2Y$H`OT@pp>HitB=RfP5DzHcdZ@DMw_SKqRn>9>oQ7z45QK&Zs;uoQW1BG(h z>nDR4wEjH&!wI5C9x{Lyst0;N2cKR~apw|M+1wg)gR&=n^~`q72ll4-L|!RqoTkL; z7fmB8qiYva%uemwc618;@=Lw&AOawXpA8BE9l6Rp$OX!u&}~ICuRx`0@rF632fgfo zxJco!V5X!D#g?z$DQ5SyrKek>!8nx%n%U|{Il20kf}YhE`sexA2d9bOD&W3tO=vdx zARw%xEC548k4??;WInrd9YlxKdy)4zyNM?R?>8p(`pTZ;d?2V(PWwWHCA1H{>)kyNcyg?(7~B@JLqbk1TFZ@af6Q!vaF=Fz_8PXScU>z556O|V zJa>Ld$55E~d|N2DX^9#%n$;cf)=FaP77O_;HD-I4aHU&Ler++ho6DpN zFY;KEu67;h_`sT z%Rd>XR7gOAyw~PfcnVFG`AD7@fwl#0xm0QiV)bD&5}!*jw8TL$e&mj(Bpw6;%n!z z;R`xw`CuXTnM8+W-Ca7+Q6^opuV+s^nXl^t*<8L33gW>ho};1fQZkoSbcCwwBDL2L zaiJI$r#-EM3tG}VK@wLCf|~ausUisCxgPZ6^D~rK))?2te!0Px7LvnZmWZaS`~~{w(Rd|zMUG~>@}&`;s`W7zr)g8 z(*D>fH2m?PAn>$NFo%k^B%$t*Z(ShI0D5a8D0v5`Vu;5S{k|l|p1Qm-LM^5W z605oFPq+F=mwrGUYksSBgP5{D(JY@RNAXbJNnf+E$}%ABV_Y2inT*%=&&5GCMEvVN z5GVgN-S1|btNQUP_Y>(S88QdN#tWaj zn0(U^6EhfS6X=FfcXM*vvGO6Ur`FaZTOdKY&XkrZfwPWGHD?JQxIRQGRWz#<&F&XV zxKfQg=<{vZD4u=`x&QF!_4NEFn(6Ngwc%$t6n=~kC%2z{5XEEniqGm5K_y3vm`)Sa z-BPoHROS1$oHV(diQA8Y?HC-o-tt6?CxAU}j{8b{*Nj*=>5#X)+7bP{D0P<(=4zAF zbCr+T*KEXSb#<`+X~})J{8aVRgVy~|q&{SV1cXs%$!nz4)YGzDI(78<^HrItE(P_` zus4ZQ(CIMeVg_vAnTOF+zJ@9|4Cb0_E)EY5!+Z)kKjF<4d7B=C_j`dO_Ibc*9v*P{ zzSCiEX2p&ol|!oFqS!i}=zTTana2%~a0+nV4~b~pk8U$~z*^s5KO)csm)M$AnDL?$ zd+g$LJSW%mF9n)+ChRo)3f%vy*j(tSJlbciM4E{IxL9Fz^2svJM zR+42R-9ak7QuB)MtY|s+)R+0<5jr0m-tVTx!{Wxb`7s{tMGYqT$0P2g=T(!QViUe` ze|S-m=FsXnl!spBW$60`6&3hu&LMlMI&HE0i|UOpGJ9vEk8zK|0lAv+CH;phAO1my z!+)@rq+X*UitIk`}5)ZJ4wHP4w8So_U^yskQsI~qkM|cxdU*QPTptt zM+X~J7p^(ER;-{M;I5t6t`0Nk0oB#dH>M4(mHp3DTM9v7#pcgH@IBCfv4W%Dx|X$T z(s*9O0yuJcElB0!Rgs-uH%YvYvUTI+WSe}RdEHQc?`{}%I zf&vmY-A$NIYjd8pXZQrX5=|{d9lY}5%9B+USYwErKEn)WVU4*9Kq01l1k!<$#1b@y zROsgO=(Ag@(Ps_A&_5lrEfnJH{W~B0ahKO+v?sz+3!sDvq=8dRVe1>Kkzpqq`>D4&>go;DO-t83msFg(A4ERpG^KPUJ)FL0YR6n%bk z0vqu19IF7r02Mq>fb7R-8irz99r}AE(HDBh5Af9IvOO7fD02g*Y9+09ILpqH6#?K7 z+qwAU*H6@n=qVk25;PjKWCaLxV(0*=y);K258$8n0YxLAHF~{wa>kW~n5w9II~oWw zF}z$=IU55Hsa#!d29Qhv3%bQw-wM}+T}Lg&_>*r%tpJRAU^Pw{0S-;m@$Pb$s)8zv z0u2Dmi^sWM_WDjzgwSb5!3S%L&%a{CAZi-QIX}y%x|Qh!rv&#<^zD#VG=v+Hga*26 z=i==n@mZe>wpiwn=E&zu4Z+6^Qy=5P07U>}585F{;tz^T^IqBWa8Nwa#`~;<9`v%+(sc48=u3Mdnr)j0)NgMLvGPWC6i|3q_2yPu0I@fSz+M&}}b8 zzqkF2@B7*Q#RRk~MUks2?)|LflePI$j~&ARdA<<4P|fr16|E25N^60U^CTo-99>k( zrt6ElQcC?*`A}(1(lLH9lC`dG>)?k>dm9V9G$LA0cf_X^Z`L@fye;n4*X7n;JOnYS z-{38MHrumH*&pLY=2j&D$bGIA5vOj7uy_H`0D5p0U3ue-)Q9*cfNN01R6?rvdb~%T zTOYhIe6PrgI91?;v9#oeq*pla(Rw8ESi-%dww_)^!S%)XNbI+(NS&3n&zBiCV}67i zv$#_CD^U%l%fW0GFcpV(;nA}k&cSkB!6(j;Ehv@%h3^|Xz_*)$Zk+QO)XC3|9H#a! z5e!@@hy*fuuw<#}bS&e=qzpl?6{MrIZ6}*9Eu}Va$ycS*>wmDY+m3Wdb+-3#?_Zu_ z)B$0g=6)NV#meosiU&heFyh7S%P$MLLaYQ#&iJ(F;WBdbmA=7KYu9E`lGbdwXC{)9cB^O9^iP2qp14aCgg_vKfVvsMua6*FMWa(z_ag zZ%lQBs%UtL+Z3u5-4)@xlGDX*LV0<g_qVKg4q72( zMM!Tu)Xso-X9>H-prv`F`YhhvRAc&7(CcczQNrB7XV2S?CR@pJr+MvAEU&nU`Io80 z$qhp7hL-^XM&;U(8Dtty*U}Mq;E9R?j(S^XUqEIXs@@!{Uc|?%v40 zosRw?Rc!_M!Y(h@t z9JOg0piqF}IcD&XsiT}xR22$)4!WkAv|}87Z=?E(eQ8U0byy@BKm!yc=#8TzQien= zU#$vR)nyq8RH(~RFz&Z)PNJYkGnZ0yq%FP`bST+*8?{?$5#$p04XceU5Gcw=(jh5% z;nPp`;M^0(=FB!6?+|w-)>xp+i<@5hKvbH%Rn_G_6S*pQ2b#O=Etg1!sCb08JHY*A zpBn%{0D#wWL)VFqKsG8f(FTh8=2qtM4&!gOd3cRtjw)e@9-|fZ^_qk!?M}&00v8LX31Q!u+9hR zA|G(2>=!JP`6g59lInqT>! zdnQ}M?-Z!X4wIDH;FY%hR@*2=x}C8zSuyd(>dGTR`yJ2X*>jX+Wi2P)+;aA2wtRI+ z9-#Q*!O}-26C}Gp9gjBb179JAaEB>CTwYt`*q;5-{4cFGyb0sFfsqhgZYH>h=$SSn z7u_BTkxmkI3%irxZ_e*Ug}^IHwbaA?q7LkhG!lM;R8v}Fl$dSKN`eI-H~3^IpXCMu zg40C{Yp=hNulS6>U)9-ZJ!JU2OP3|wtzTju#Ga21wivFHnD^WR&mrIDoZNeT_&eEb zW_s*dDaM1$8}$cdR*~dn;$p37&A5O)Un}V{%lu$ltn%U0=+g^$fDGWFbx-aUuHwPr z@;cZn0;lC9kgAfKE`_qL}v^mqgxpom!|y`DLZlyq46!Uf9q1&Euwmx!S>1G6#DTt+kQ5-GYA zym3e-uZ;YYBdhPe4mvl=cDk_XsmO@4x%a`hLM$~C-qK1B8;V^V@tp>MM3B69wev3c zT+48DcmyDTL&t}$&*;2d(2C&8gPQx~m?!7YN^D#n)=3!-_dZtg&hNGnkcd0TWYtvW zJt;a!YLzjIy+ji95)v(kNZ1pUxlxDplg-X4& zlF2Arx2Clbp8d_RK)Pmd{F9i*N7uxwwi3rKzt2%1BuqS!9HkarupeuA6kl=&Bz8zx z;vVd-{U$3vv$>m>7MC_F5Cult_E4*Jc^%wSu4%EuklbMjLQ05QH;l)jg9jMs9t*Yn z@TCA;)N+n9?Ot)(aqV(76XzJvC6le9-C6g0`MvcF-Mu?*lSM#(@S;ktFNUQbEE1pH7o$Dc_yDf z#_LGl3P~BbBD(q}k3fNtvA%7j_+%my5EhBN7vQaPwn1tFzxtGH0q^GxY3y&S<^d{7 zA?UH(!O&-v@u+#@D=+!bxA&Q-M5l&@Or6iD+-^Eiy0B}eVg%p^DLl@rRl)2dqFZIX z0A8|?H2)Mz5X*@hfstc}wjeAyR04IK+c<1s~uRswtc!qyB5YdNMBaIEAc16eb zm{^lncE<;NJe3mlk!~hsdSKPNheFwcO8LRjVd>&{Ey*ip~b-=imV@6~`FGDg)e7|AEi!lq12QKoc{SVT4S zu(-mOw&!dT@@tQnqLMF^aq|qmciP+1;t~en1-0$zre%reuBvM*uoNi#RK@)*4=}3) zGf(+T;Q=fLigVDoq5t_)!|M~%b3P3R{Jl}f-`5MU5d1UsL(#&~e8E@V!X{91UInDR zJ(+P^cjfNdBB@Hhc1$LO;T=V(|0{X(KF@peuYmKVm z8D_Do%|0x$k{Z{yYq_HVu1gzsk(*M|!t_KFI%bbC_d?>RhUH;@d7YDQP0+iXEDMUa zJQ&dx$RkRz6jsMkB^73)1O(-V5P;+M=ar}xy%vnT={qi7)9q{KO(Id6`L~1E_bQ$+ z_*b0`_3=cHTNUv%)7j+1O^D76=Zj4{b=2M%^C66c?+x>nP#okl74T0HL&x8y-$=i) z=VoqfR0D|*Pz|b0jvGR1`ZX7cMTMa^6wCNhx0G6or&P^WgVg8Q*g;iIq|fn)mdAs} zq0L-^)aMn?_j!5jp-@VGSidv;jsrzdOHxVDQ@(WQ4c8BwDhv?7&^)>m8>!Jl905SA z@Y-jO0u+f3J1!yF6G1e2RqmN&o5t-|7J}?k(zXB;X{uuI1Fb~G$5g1%NU>t;wJyq- zR$tDA;cej#>?9pt{6#X{xrqHz>B7NAehyYdEsKCsZEqLCa#F-*OG~W{(q(M+8wv}7 z-gZJmUJa9<6e;do5Os&(bZ>&k>GTi%LH@6Uz?hn1H6^Dz)V1bi0)TdGWK~7p$=ix& ze7;HotMk0Na~$S%dOztGcHKacavce%6oifqqWz?h+ncjROjImkSpSj?gZuKUhkW05 zTKc#be}fSXc=_4hp_$Ti4;l*&=?2K}F-$*YJU&va^OdG{wxMLm_+!uv@CLa4Ocj}I zFuv7W&^j2f5(n`4ER>U$FKZ%%*7bG3IdQPF)JB1UiVvRftefH*Cx5K=OXy^Fy&4%Nk~m z$(54`<_h#NAR*pMV7C~G2LA&d^C)B4fvg zwR#_~)+E4%uEYq&v_97ults@UPowY=uHW)=Z2;q-sDS=&nQLis;oLy@sL{RxyVN+?E-)QTiuyW`h%x)rpuKUwF>T{K{7_(>5k)(sKe$Hr zJls#mU%b*Kt?hC&)zRkBmSX}*s}8ouxV(FF+~n*o0;MR$cjekrkDxR-yS0W~s;MCV zg}$YzN#gGQf!?#gJTHVwvEl1Kxp(=)!x!`COJNA-?%B2P%@;0+sM-_i-=^*U{$M(Q z24O%qW>J{&uB6lSTN5ZHC!aUYfEhZTw2HE!F!1Hx6v$~+1jcjV48+~7_d>^1G-9P5 z;vo|UH) zR`)*yL6ehtzTN|?n1o?b>~yB?Adu9KJ{u>4UaiVwnXu31y;))h+k2HC&rQFBS8zwO zjb0H>f*}dUQlCFa0zz*D#1Im+)^Af&j*TlMQ zUJ;=Uz@->DJ7j@96YIF|;I{w6-h0P2*=_5hZyJOUAoS2f@4ZS5O%RaYMF@xrB7!1H z6GHDrQ9w{YY!oS?AgDm-MG#a36g~-{fS?eXL|X2v-&*^ub@w^HyU*F@?7jB6>!19R znK!eHIp&xnV~+Ve^{^;`eaeu@!4PGdgoX>{TCIh@d1ArGtiGY5tXUi_X_lAB)0C70i2lToYDo#+*s{D)vF&Cb^#b&ZyP~&r>*+Wm{MC&`4zx+wyWSEJ z@k#P#v3c9%>DB_#`|%@*8y}*2VCDyZ{~{d^oEh&QIii9mdAGDZEVYt8Xw*c z0lh4ZGj~Q)?wD+Tp}q702Aw1DWo6rjAE7JWKo7&9l*ApYl-oQsK&HglVxP*S1|LwO8F#b*V%iR>Pg)Z9bm}q_2 z+ZC}*>H6@PMvLL9mvG3$2E;f!6|1tU>e_R>wgUK>h`w!g989C;6xpk zEd8VVGrRypxFBoU$ud7q(H|r@z+{BXx|=aMy;K+jJZ z=$Oep1v8g5q9p2OkP`7kVj1gFm5Zl{>YH9aoRkdv^A28n?~ zOVObR_ON5=+B?l>%K$n!)ha`K_Vv?O2WX|kV{Kf~F?%Ei=4Z(Bxu1WGtS5bYe|Gl) z)l9i8S|G=nE(cPvhq~?Li!Us0C_qKtr~u+;EiAjeK7EJOmqQ_%NpK7;OdNAU<*Ag$ zoiQzVQxm!iQ2ZnIs)VLWdx#?WC$EZj8s;3fyyNx*46D>pSjSzl?EB*jv~WIlNvZox z#sYGy1-Irep=?p~m#c*9SCilaf~Ar2;*Ug+#2M8I^#C?^@Hj=%-Wl)erQ z#r`Bus1j6O&=s0_HRCuhmlB{J{E-=DZdOHn{__F>Lgcs?HGxri!*`XN_EZJ@Q`mRY z%-inGwXaywHiw?-^-;09>@dAD`0P#?Ndo zW|XA$bH6?X;#t_df^cFk!{RW<7{3sASD&p0(H(!luNI`ERurwm$S3Wc?R0xTMi)0G z1sC(ZQ6-U0GUq1BKM@ypXJ6iLw2HOh^hmw$8ynL~6a@S{de1GM+XRp|;h_Z8VdjlP z`j$qV<-IyA!25PlX!IkIypaU4%>r*)(Aq-?gGfPHao>kw+V|k6!etT-A>Z+r1>0dznV2764}*k;+hsOOzv6cV#GRjYQg(4zV}9B8cl#2kEhLj8}KIOVqmLan#Mdd=pG>uC2`z)hN-Z&cqw z$+P6+XT~(c>^_C|f#fm=Tm8XE&N;PQi+o1k%9>q;2b_nWkE%;>ZWW9q$fooP=Z`mO z?wl`3oh{i>BqHSobC8=?L^TvntYBlL?$ZgUd3bM@R6JpR*E}#s?)e4*?X$P0Aw#Lu z55&y_sgxHiFTk4p7wrKJ*N&eYiyMM(z*c%$ySzF*jN0+jcksThb}UhOn}HzT2jS>kKp{ z@8?;fjk&Y~A;u1Yj!=G9w|Qqj4i%!@ZImk_c}6@4xYz}>n{d;GXgL~jJ9vE#XU3lX zQvd$NjlCH5K?R4}p=5xDul;-$V3V|k#dRDlvp;8o{lNQEdLxQP6-_3C`*D4WffMfV zA7-B!3Nq`6Rs+J9&eObb&zVIA*DvSRhtNh@iGtX2Na-z><(FMGK6NfT1%_kR3l{D-v0MNQc%FFJCe&OUZYMEGNw9=DF&i2`g;V|e#u zEVZdsL9uq>p^C%{+QSjxM53(rp8X5L7ty1%Fh~;j_3aZGUzDc@a+E35M)tImpH^7h zOTtb#Z0ezMZnX0`SYDZcL7;Q&r;}J~glZ^8tddx11k*=u?sW;nv~&#!$Y1C}!T2*C zwP7TE0Nzz~pt3Vkz%Aoo4Cew^rc|nU@aDD7kx2pt zQ(6u`t6!zFM^AnTGO9JNV3KLEpaOJET@X)-bqhzez5WrOCp^$Y`h+siGgPcr=OK1Y zCfe#e+*ZzfRomY=N~7X4tCfI-S~tL7Uqu&7icZc^$jSA~OB2s~3FR9N!pnQNb6Gu0 zV~%a9E}AakIsI;Hou*mXxsT8X0HPpw&$6^8*Lbtlozj=KKF~ln&kaKwlj=UloIH!j zqH+l1)UBAsbA9GOxtSCT^T^n)8BC!3vTlT5xZcj-AQ;dSa4ZFINMJcZv4$bDbl}2` zE2RLIm6%lLoOx5lKc{Es6FTG_IlpV9qLpRq1gK)zRpnY)S}h*@^GFx=qF9G+#c59T zc8gqW(P*tLy?2QZW`(`#*E(wb;as4bxp~nu5lD6Kt@XtAa3`oD=cp=wKK46|v5HA`x6 z2-Mc%z)|(OV2}Ckfsz%l@VW_QtXg>DetxFKbu9z0YqzFB#m;;ps^6hsQgc2z{8Hd4aQChM}AptW-pyzvAFN9*a!Mu#V_ z9-qZsGtM}X`4PJ-_2$N0i_p!x<}o9dGms=q%Ts|!yNt!dxgTM4FXK_1gNBdG-&x$V zZ{E!S14qm^z)(39Qa>@p%pHtAR1{CYk7!!talzRgN~@)vtmwUleCw)A8ke$fVJdANGf-&fx5!N*&06>7UwRej=&)*QLf&6@OB6 zse(}~`t${nu;JK+-`N6xDJl71FTnk(@!#?L@W1xze^VWQll1UEN^ki4)QJD^rw(x| zaBDr)el>Jp@Lw7?;eVp@VRSv`YUw|ntFT3i#;{Q3NMPOtOz4Op#$uCP!00&qR?tdu zy~DfR&?XM1+LH<}j&aK#4wx^;k+L zwd;`k?q_{}&{W#O1!LcgjxkN*zyJ);?0K)}76rhfj(9tjH*#(|kV-n}Vy{CI^!To_ znq?GnMvumP3<_`iAxZBnKn2`E_r{V4%f2=a=_XATUfL(&NmJMH$He#xgUa6mgc;?l z3$U`ARaviEiFU>oNU>27oW%ul+rb9Zg8&5cOY-OMIerfj@U|sXp#o3l+#Hi#eF*^s z1T`rVdG4Bz5BeMw|EOqtozjvV1>lk%H}>x^;j3O>nZx6;f%Q^D&2x#B9hY9iFu7&o zfM=`ai3eV^VC%8E3)k|F9Iozz2ghyL+acRj`>cp8xqx^_L#eUf$Rb^i;RCPAA)1Eu z$Tt83mruBX^N_EXL-3o=nXi#ZGRBo=cQ3}AKi*>^v|k4?lrw&n;P+;d*7_!aELaRT zUs}g;iEm;7T_yMzUO2O)j+!u3)m+Q~hDSs~Uou`L-4sr@Klu~HSiH9}F$}-~!!QOj zDLs(^;6zTBDQh#h%P|9p6kt*<1o}kF5xwTNXA2M^T;jJ_+=AJITtnYblj%I}LiRJ4?{So{ z;s6aFfO&%y=8L#7e?Tsu!MFaS&V4jO9eCvj?!6qF>?!`*j5`;K|m z391qb<@CsU^09acqhiVRt70ZbCX)vnqI?R{HxfTfHUXW>aPnJq?^+7T5@;49L`=DrZwCfyC?rLe zbldIp@(3n4917BvpezG&t*#6>+zH;tY~lshc1VsJRKtf_NQ^jKG%q#osd-+=e8IF% z7QjtLn}~N=tbWH}$u({p&Jp+8>xYLRX6*z(g+TYcscZ`n}C zXX?CT4M_trNdO7nah`k(n!Vr(^W`c95>*?FjW&cs%UmkO}_ zW{1RIAMNeDH{A-uqj3*QS?)-jaE=oz(M9}eStIOAi3tJD`wpMFh^knwc+81>DssdZo z_BKSS8#mw0WKV0kTLuRsC)2T3E?;qOyukx0-P+S3XlLCyo1bVCwWU)p0`CfATn?XK6Cgo!Pc zrJK4uM8+IduFccJL3MDV%Nu-W^1iP}IqRV5yswYv@YQ#Vz@b}nc@&O;IkS=yWqssVLqJ)1O(!*{i~8QRoF>=P{#?tiw_a6M&Z zJN$={hJHZ(8jk}LueYFRw z1U!)}@Fqg;crrq+DTTz0UAZ$M7KsI@c$TXSQZS^mGKwfkr-*@Lg)2s`Cs5ZP=O*@z z#{(irbSV6q>)FeKJBww|gtp9k8!x# zd%e3dG$u?(;;h>bt1Cd0Qfo^)z>b8TE;#EXI1S?(6QvAbTnK5veDE!I) z4^dlGo}W9C(ct?oZQ*7ctsV-re?OyiEo+3fHi(5Ks@|N`aK2*UMZ}oF!V2Cod07vC z^dc%}8oJzRs>1AuwXx9rHmu~|o=O}yc~G;DHgvcy<=ea*l-71Y#vMW^Q2B{o7i~tk z<@5`|)H-k1=iaZNMzx?{JR+lY)%&uv5;n4KyL+Ue_@mO2YnQ8JVu=G;OFs|5D^lXU zj|8QBD{AS_fZpuqdVA$U>0_IH!!W9S?dXOUSc72;S-4pIhPD0{Bxvmv5q9;pvbAeo z#2vAsPz9hs*^zz!(aa^I=53lE0Dq0S4sonIIlPhAdVlwO^Hn{z?lg$D68`XT({naU z+JP3C> zY2gL66Jg`Z!kk{$P4zg@1-j7_r}_zuo%c=KUi9@Ct7Or<(;`32!%HDYyl%m6X*T7< zhhA!-*`J?=ttUF1RNht#CJ`ZM>rMK^V;z}<*ij|ewN|+c#)n2#`uE#8N`aG3(7ibU zUv}KSm>V*|(yaFjz{H~;F>NwI|w=>u?P#!6C1N9Pz;V7)F6p;`RSCwoFTISZ1@OL(?GR+puOQBfuxU)gv352V zkNWGT`l38fs;70|ps(Bm`03EnF58S}V9so9Vh5nBJVJb9)IU60Kfp(k3_etCo1~_# z`Kh$Ds0&P!D$}d}!nF9#-+*jN9((YVR4}J(O^N}C_{=IDcW1BdsLCk-E!`HmBr+l5 zyg;MTl@*EJ#2nc%%N!h)rX$QY0TQSj2(+*FJ;MwBN6Lh|-dp*G+>YhC-rKa1nAKAR zEK=i1g+sjOqZeHrme;>%{ZYCn{a%|PR2$y{0KBo@WO@J-W4AqeWlQLa+4MjD8<(Bz z*1N0ayG#0izS(*H_n-gei+|^vo!`}0)KMX{2gB?`HwJ1HE32bbE1ozV53NnusT`kSu9gq2B-i%dRCs_}s$CDY zJ>Fj(b;F68dCYyh-*J3aR@41@`jg(}z<^wXh%>E613p(rF9*87^_;Swk2R~;rqndl zrYu!?rGQF_jJ4QP9I6A{YiSJ5 z`HnA722?xeH5`BMKs9Qp?7!ia@Z)`b%&I$er|+(N|IcZIl3}ox-;Gj)g@zZ%M%oJ= z%SK#q*w4K^q?yO^Z9gLtF~*=I7Wu2CD9^LUT{OAhi;o`p)wSJRnvnZ@XH!n9Y)wOT zz_ftVSc6xOlXI0*O+yrWfaXn8H;P-e^Sv#vD2`T}REIfZ&@ZvWw2 zm9SJ57%-!Eri$uNb*8Gav7C2zhPT=&VwC4Rr^iIwwT9iwgQey}GS64emh?OAzrV14 zH2tSc6RSz^S<$YGqK^edyTY;z1*74{6cM3#Z$Z&J0x4@?;RWOdSP+ZZ<2lb;?XnhH z?G&x(K30`pS?yevUrBxbpkl1G#X}hE{qq`ydqV*pUe2EflgY0NtEh(?CU4wuRm>V| zsH~kmWA#2p$bH$ZqE-JCc3`Wr=GUB6E@xFfI)8o3t0SVZve|}|@~ipa-%SgoESi;o z&GIANF^$6K8GX1C6hgtzNU(0OaZq?p<1Rx(nc-Dlg^lAwPHx%lCBJ0YK(()OYYA{4 zZ&kUfeBUZxt;{(!qE)3~tm^og=7!pq0CI6v?g2H^$;Ych9h~01!toBLhfYpw?nOfZ zv(nN}oV?;bzISqpoDFp!1M3ub@;M~aluJ4HOGK##bPMj7=5g&=ZcI~Rmjc+Y6c3{X zS555?ITt;=Fl*EP<|jNvC|*IN^7+u7 z=zO=PtNQrtR&~G(b5%i{ZvTangz)-%-CLD2sQW!PeH>pM__wX;cCnvyiu$E}&)=W_ zr8V7Ws`>|`(J!i9x}EE#YClzZmEzMk(!J$eS({!RQ8mBRdaklL&?)M8!277id)=o&Y1cG( zWmlc~2&yDPd#t+JWtvC+z54z47h)1ZE841E7@RJ|kgq-8?kEH&dVXt(oYt`25i#yW zF?MSPuA;~WWMp{oO-b2Tu*xz5kxUA+qrw&w(I|(B~y}AONyxN`A-JiIRPon~y zjyp9t?au0cy0=TNtg3d<9PDj1aH{;VOs=nN2n;u9W!S2$il!FUR2A%D&vPDk*t_91 zRkCO5;dPl>N7Spu$vEmOpj!Sp2V8=l}RDJ1pmK zPq=ub*}Q8_ud9!C7KYlb(>>C~{P;C`Nrm33T{x3v|u@wzLa!nswIT@1OWr zKl%3*9N2zC-aHB77YL9o-oa|E!jI+AOW%5SA9||PAX0?$meG?G=kuuHE(*&nvcpW1 zpQ}L%q|BU0Tu#N}*eo}=hGx0*Av3Ybq)xIoMTv@yLhIAwocMDAYdjM1Vx1NLGPYc6nX-nVf-z430weG>=UvYb!#gQVS+=k&Y<(JH#c8Y8SCWVz z;s7G0C&(z;)!oVe8FCm83zyJb^p(Z~pN?eiFr*$y#(N~JK=zn87ct3a=BEogZj@#c`Lk`MGNBK|rfO zERjL94k!j|VRTRaEo&L%+N2!VatubMSJaDg{ndLR*JNQ;D_EyjqHrH8Gh*rEBS;D+CLxqTM@(xBBSuX z4a1+)h;OI-wg3Quo<+{}@r5t1^5k5bFHF-&uyTlc4-nrqc1hY)*b2aac7jAG3rgri zwwnIrVSuo*&?<6ZhTKoC=#q#51iqw??@c1q_&+e+dZ3L74rX!si|Ps~6@ypTF?3vS zl)2?tFJ-1D%kjsMVFZlUL=Pg3;Xp|WHb6k9r-2vl>tn9O*Lvri9I!Y97fb0M8={{cH?ZLR+O@|-8oZ^-hu#ulre6i)kZKrHYs zp_80MD4g}R7Jhh2GCJka;1t6HFb-S*Akjn!0l>-Kqz4-Zv%`4+C<(y3sW5|qd9Yjs zKo9|9y{{@2=m8%%1F)1orMvc~bc(BVdE*lY>CGuy0K>|C*cS%gMuXU1z4aNa6$2X{eT*B;S((Fg+w4hP-@%4S|x~)036jlS!-ib9g-|fCGR-7jz*<6VA+CO3Vm#ne~(-an& zGl@Qc==|y8AevtF?%@>7_twd%2ZPwd5H>?WR|YHnV`D1z%S0)-JQu!yzaHRlv~6PY z%mxezoVKiUA#xJsQ1kQzx(8^9C>t%dZ{DBvKFmM$SM3QVK{9g=OI`X1$5r~pTqAYf z&OPx-$k2b+Jxi z$UHzV0qJTJ>^~>=jx6F=#=%?GD9bg8_%j2438iy zOSh95W1`0h@{$_Cz{rs1SswzB8%Fqbn{9{``-!go;&)Ooqa>$cF47UVqTFjwc*3imV0u1|?(-$!<&X>UL`0IPymsSth@g;g_%6Oqm{m*da@bF<1e+?n z0}La*C};OjR~P?)@#DC?}YBgmj9UgHVdJ9>ZglEv46yj#r@2>)uWy7Q=+!aA1PV%%kOa zA$MZ$A5Q+tJK%vpA5!e<_G}EMENy;T!l{<+Y;Jb8DYv&*g%2f~vEkzxLm{S+WCY$X z7;`l#7~fBSXc0<=Y1s&=u@s0vBtP+aCsQC`CN2inL9dkd_}SyE50-q{c_%~Y00vb& zfD_d25^5j{gnELAC*Wm!gNu;FLRIdhqZqm_b6Hm=!Av*dk=Vo@Uljw^ra0Rle*TR! zLmR-o?2^a}r2vNUhXnwo7qBMNbH_R|c;6bN!jq1)3g8Tv71|NdoU}d`^71sI7|IoW zc{hw9=izifKWH&P!@{s127q7z^oo_B0L~V|G7rTAd@n`8JKzRKNB~A#|C_&zy@MFx zv2j!}Kj6qvGW{q8m}>%%LMI_Rfc$fkaK5sjTu2FGx%c2AT^vU)h*LuNSY(WPA-QuM z!a$djWp%Q_dS_d->$a-kx_!D+er-Ge_%0%pm=f{6L-7?zRDF40uXe z45QOYLNJ@Blvg=KC1eCM3?c9_4(`z z2VH1@zCa_qEr058%dGLwBy$KsZ>6T6@sCJiad}h%z>*{U$seOY?(j4PNvT%d_(g z86vu6IWy@1?_^4fi7#tG9Hxg34+XOIp9@CUa}9?LT~u=B}85Qh)!eYsbhX z5YSryZRMI;9|1tJ0B}K3Ps6_yarD7@$P(u}^Ok5HTao`@n)Fva`G-~Ye|GR6*%1A6 zcK$D&ojCIZ07^7!0ymG&;0b+oo49j)#t|Pe@R`m8NKaZ6hvjQSWj+QkOYjMf%QCsr zoGUs}oNJGRW!;XytWf0thuk**PFv%&A?P9uXVz*tXvzQO8x6mhPPFui7TT}U%k4*d ztp!(u>j7aFfoZ!*HG>(VQl5@=Yso*fckt(6PJ8~^GXIB;!9NZDf7jdi$MpC9MLqvR zJ+%L>4@?BJGHDYrFi)#-a?PeR-)UM1hYJ7Z?qeVHnn0=nmXj&6M`LCFWbrZGgiz-I&J3|ll; zV-QjNy2Z%@h!kT;D-H3oNgKo79T7~inQF%JA&*CKoZDc~bv{lxItTA+Bp5~bOe1d1 zEhJV<2CMsJ4p60DZsHiMtg0EMzNgK8zRHSv6%>=GldtR=8k4yGg;;zdf@<@j14vt? zxV-l9QM?7fUZgEAhmdP~-1KEJIfoTT3X#w^kcXbgraluz6mKwMs-Q-ea!d-xls1#< z-WC*hy#G5?5BIt|ihZimG?*8nvl7L&qJm-gE^f;ikJ>Pm3X}PSWxhFhsr;PAtEt_U z1LR{5=X`RSyM)Feof5>m44x!z@g3W(hg9LxmCf^1;Z|Fs>a^nwIbG(630*70X*4_- zRj6eZ2Wz3zz}=u&8sc==5+IS@FJ?uWY9py4X|pf(I0g(8uW|5~0&MVa+p{gb)!JKk z+u#lfY;%Z<6dwL{@kZK4U*05bSmapH)~^@J@zJNq+m`s$(FTfXr_==|J1_WU`wVVr znJf0S@^fd~>>m#0A8cZ;1F&etkQ7D}cm;2l(uOS$^?DoyV98Sxgp|5<9-u_=4=;^u ziU^>;%NVxVzwgf%Rin6^vxqnRbTx?t#_pOAqahPv5gsAp++~VS35)06*tt|k3v^#j z@xFK(t3YmA-Tkn~+Q;L49e@0ppo(m(Dv$ij-Lx-o$d)C=SleuwTq8T&hhdze1{Y02 z9BL8lj+LPXJ5*s%h*8wk=-ny4EqSP<{Rrxz{fJ1Yd=!T(PjHmAt;*UO>z5-xkZ2~R}i>>gl@zEEdy9)odZ-z9MIEeN^t3pgv!$g z#YI3;{bUIyMmU}8VmeW5QyLFBim}Xh@xW2fR)!Nz5!Tuh7Da3#m*<|fGrulV!B`5p zMM?57L+bxXT_rz97Q;LyyU*=MNfQwUsm)7@dvgHs@WkbU?Tg>z!&dbP$sj(Ko{$&fsz)V;2{~};R7W3D&7&1{9*O7agnat323aN-F1&)CXP|%9+kJUu z{aqBNMfbUZwa2C3(e$;|SG>p%WM42xCECU^-(mNRpvu2`>EHg$nfHr;VcFBg5m zmf8ApHX?92(b4=QG`pXMvZTM><4Mdip<6o^i0eM_VosiEbXL|)Fah$%J@><7>Q`uq z3c829h?;5Lmz!#2&yb(%ir_ef>smNJt;X@fF7Q7;K`#Ds!T{7FIU?k;wyD)eBH`+l;}H@D8s8imUBg53EG7{!xX!gIYKN>V7%RnS)3}i80W_~ zS})iq`Czg(j+w&Z{nIiIPD#qjZIxDP?w=(Kv zU>B)6-;4LZP>j!aaMqMVsUmg49%Zm;yJ?Cg=T!R>>5U9Qs_@*Sy!!h) z_dOZTjRwNoFguQ5bwnrayjb=MKUVF$R>q)^5viJw;EfEH`!Ck&OuQnXAdM~rymy*Gu#9k04)v}57 zy&AgSd&^bW`dd2TRYd@~%vI^K$rlW-HZ_9F?68hXQQivC$Ch-qymqM#p$6sr^v%$u z)cbaFZ&0G>kjLTeOF5^ICuUd!_zb7QYGgsPU&DB^BZWC;BO88I#zcf$K5H6vf&k>F zpdqcYc(bRRb%3%=de*f4G~CV|l4$D+Doi4T!CQ)0$1f8HJ$9ev!)?VlzFu2dJZQ=& zBfUj8(nkiv+jpp9X=`rbl%uJC45Gu@C5$@O;&iS!v}gaeut3Usm)4gP$L&jngO41d zkBnmP4r;2o`(oKzUu8x1xz2SA;x(4pG5;hvUr*sn{1(MY=Bjk+*L=Fvj+ZWBD^v&g z`iS$k6rN8IUKG+P(8_QYk(3(?9Cv4|B3B0ES&u=iyC=h4v!)S_*58;0apR8f1ymP>)9`It)n_Psk|6*=eQJz9C$gH>S59DT6Cc1yO|Vi1_v4n= z9oD$R2Vc~2Q_l$Z3UV{I7J0=;??ewCjgFhan(f(^PBmRH)dS}K9CSX6ddy2L$(ou}uP6Gs| z#%0@R3Y@=qzKEtvG1M8CY$9KQ@L z+n?yVrVJtQhbDi^X)(T3@^}x@@*sDk16OKxfe#tEOedS`=iWkI3PCaW-PnKOu_u0S z=7LHL$<~N^_$>WtB4vii!GBZxZrz)Q#F$0(SoUXE&)U!D@1sdT`cmE%3fC+%O^FUd zD|HePIjX1j6XYVmLXNe_=}Yw8f(jZ z;Opw{QQ}D3YXjhQSeQ&eQm((=%lN2MfJ`w2GV@Wc=CBXn>02;>^L-WaPRw#F;ts`W z!1OiIdt=G^7w|RoPvGl^58%wUcDo_%r*`VyM_9-Z5AYzA*PaOCHmS*Zt8Tm{09wg1 z%s~C)Jvbtff5nB^ht}iEpG_Ajz>m!`^Gwj)F&|B;^Ygj&WYW@VleD zSdNYJ2Kf)}$LtOQPf={gtA|*0<9%&eGSNp2 zS^ScBe;p_Jq1}H%^yES>FffoHICTQyVNXoH&jXX1kKyRMhXh@V%Z&#c9|9sws^rNS;9g(FAJ%N{3K;!wx{`qDcrFB@_-fm8M`(JCAy^^Yzp+R8dJV z2}b0<|F9LEx|GvI1a3hHFOeP9*O8m=g0UQ#NQX|Pr(Y&Iu1#+f@t0HlRdNg(h&Sfv zAH?o|>v?&f4*7jOIOpPYec<)!pBPvbGa2&Mh)7V2c7@|xIFV&T%to8vi_8>&p$57h> zs#^UE5`-e+n~?2)&4M#@_@~PLlW)^r26cA7NjFLj|4O=X`WNX20SVOnBHb9lSiV4v z_Fn$_=0T1=ARO!U>~m=&bp?OkU{8S6OOpsAY6JWUS`;jO+q9Cseyl9WOmRNqz3{}J z#2iOpr$Ay3UdD=t%fE>^0#jPaAnI~L5<+{_W-5VPMGGfN#U<5`FWqIdttA~(0S_CY z{MxD@2Lj7J-+2GN2RA8OPB=o+k$&RjHZe&*;Uiylo+O)(CA;F}xu7-2Z|1UqkFqwn zkp5)!K!WdD2)L2kh{u-g##F0%P7)NyY-6H@=tn>LIZZnQp5xhah_Da4>4~qq9sGsv zL)^hCA1i=vF~O?^L-G_jmOu#4c-q)noYLn8Bi`AdvbofugBMyYh$vJdtKiGH@+|V! zT@cWcPOCsQye!T%EpTT3*QK~r58+Y*TMef`lD8Vyucci9$P=I14s~`Ks;f=ay#vI@ z`kwl3ZG$+*y%|WEo7iNXcV*|40Q{^WfAz8jDI4`{ZkNpaw%+x(eBp%PEiPa`&M z!34x_@=|L;cd0n5?gcc6ntV?+$ z5g|r3S}Di8pgwakhUaeZN@e?Hzp^fWo)U)Ue8uW7al0<^3)59H46FIgqQ9@o{>Whi z9)rfIDejqV60Crl9YhxE;`=JN_Fp`oZpB58Dy$p_G3Sr%y|F1h;qN2#dmsG;WKPpG-UX~!FI91 z9xSnfqr`?}7TY!dRy8Cij0mRS1b|?_Ng&uSE34bBzk>aSqD!+ftpZQv;%`{VI+Q!v z*#g>re{>Qtp&bD~I*S{!QhQ23veq1dmxjY3$zg>(9lp@a@zd!LX;AG+WN(6A~+bEhZ zPncyl=}(dteB2!TRIB1s`oqSP?}`pSlN-FJBLmlqiZLfC`Q4}Ld8Szqw1soPFFGO8 zS5GsIqqL_AA1QGDrU#>pSv~+@31!EE~-0b%_DJ z?D=U#M{W<%2M#f}kU*Zm21p2_ww~oRsl5idPU^U+z2eMo^*$mKD4>U>xJm}_(K$?f zj@%_7d-fff70whLAIAgvNf(U(ezbVNRs#N{mfdIVCJ^zuxO)6Go31RGaE}XPXmN9{ zeWQ*|j^QOn+m<7fc{Ki2GVApNFK|>@&m#~Npso%Y>tpL#NH&EJ1Zny)K_H}c%edDp zhl4qb^FIYPUE(VUHJ+&cZm{)?3L(ZG(i-Nz&8K#!Zq7helwou@=kp~Cm+kyP{@xwC zEGi%fMg(GWudP1<;D;Ol2$H^tjKujfm6x47=b*cK;4B7a$iTrfV*((Ew!}i1&r1`1 zaUvL^(pigYCR~}K7eN*cTfV6@;viPUEkfJA4$!%HLxrS@DQz4vQ;(Y*%TndhU^iC20)8Hrokxg6<&_Q?Pj21+s@Ww3*_ZS@t zk3}212s6jfW_LqrVOuBI7tdj zOd4QIzwYeQ!GmJPL2GQ`UpMGt>y+YbXmv2Y&jU;jpDPcFeps~yHn(k>Bx`bTfr8Z!tJqFJ^t4u3BTpJJHX^L$vytvZ34M;5r&_@w z?y>DMAXUBjAux`fAWDg%0XAHF3F*vzPLVJ=;;~0o>&h9A#*6Ed{4d!LxBiIKp}kWa z<}TU~{DIUxVU#c>6^PY*J4K^J)gjB{oA~t*b*-*wwZMF>64g(Dsmr1AXn;jZ5GLXw zjlw2KC)Y{BXQAz7pHAiqGO;PNqfl&Fw^~gi1UP`ibW7$)G){^4+OZ~^z>TxCjphV8`snp{3%BDB zC(>M@!0Ga6%K%MeWeBlP7L|WZ>$Q3a(>^8pM4v$^w0g+zjX(#KoZcU%jP-gLFLc1O zQwLyRK+S5H-33$;_JFcaUCB7vTsR#mbihPXZDFLls-7Y>GQ5I51wH=A0~s2)c_);% zoq?0mX-z;K3N0lbUsVO#8BU!y-FR?`&5*WY9_On>@I!qyi7lL_nr&g{D zhy(II#E{Nx^MbTfFnf#hzIWIPOIF=fMX)7JKeXUsK(D%5JPc9(NnLR`Nr_d3{kr2q z%egSv!-VhqhcJfsuHZb4jO`406!@exqTd4*LkkgY zX6Y}Ta^3#Lg`>E*_@z*|BCGFiWHDa~bHEj;xsjtirmhoUQZLd6U_-pl`Dp8S=sg+A zsH%n+M(!j9`#wzaw4gzy$o;x@!;DE+)t~Ta^CnHeX=*2X7&aR|DiJ`2Fc|y~=A)3) z%c^@$JeLoIq9_qTs)~!KpVPC*kse&`LP=8m>)j%Wq(4v@6n}j069x~EEN9kvq4wf1 z@lHo&C%R{~`D#85B+D^Olj99l1+MvA%THI~E4U{e>)1(aJ&$`089+x46L0z(&oI%r zHk1G&`zk&zmP!g1&5lTvu`*I*dn-V1vlVzsSu(yRIsPSt1CG9Vp(A|1uyIJol#HR* z5`Ht^%>I4CP@pv-iU8oyKqG`?DF~r@IQp=@?m;J zz;W_3s4lOHsU(z6L(+T zsF;9f9b6`|yZ>g>2HDRYC?%a{>i!c-xM-bWEC+K=G^^=W43&2cAVTAwj1QtM?4QkD zf%7AzuLJcLrAv7Q5^j({i0@by8T4#$X@*SY6P8wwYDx{rP;~$@=b#(T7{3_m2~07I-!5pXi}!j67lPXFm$KjDXL{Z8 z;Xp&krq|GKQ#E{n<9_>eBrCzy>W|n@7ki1z*UkSQ_TB@oscq{QohywHLK3Q>ha%F1 zP^1f?BLX58uoA$63Ibw5MOmRY0R^h!BB4H}m9xpVel6KYPe|q+ zW}8r*YcAy19VETJt}|72Sg)~F#4g%cW@!Fyj2aiMsQcmg z&>pxMK!e4$nFJwKDDJovbYsa`cH(Cgs*;`C#Je^|Cz{jG%r76l*VxR$9GV~Z+HAH$pa{{N@?$SLrCWB5br_M81YOQ@&a}@44M9r1`cc>WU4H2< zU32Qj)c0tTvySP4bXS#Phi(o9B)%q@DvFxaV2Fj zP)DQIyW>;6=9DPamEhm>4+WZP#m%pj?bJvfCw%^bkr0=9b}T(Zfku$P({rj4r%^;N z#jXyTHnQ}8CeJixOFWFd0+7N z4up}tRnHy7P>$IC7zn=p>8Z1b@ag@2iHM-Z6El&;P|X#(w+dN&&nNTFEXtpWj`;?a z;AW@397cc85lwR)tb8hvkjhTQhaCf7zotd+F-{kiiDj7uhg3q8|k&4$2}C;li=~C_m5Z)Amr|xTa8eEY zy&tmcC42Be0TZL5Y^`zh6OTFPY<{WmUYUIr6WNE6G2EBp(#6)2Hn*(v3Un=EE(v5s z(VIeNCj8gqx=LaGwKQ9%z;jCuKHlZ2_m8deffX66$P3rch_gq!s54?l-z`I%Mg2_{ zm=Nu9({jK!I#8&PVGJW8UqM)z*o|O`mm7EPzB|8w8jCFqJi5aA@xAOhb;5oSWw`Q5 z{t|g#$WlAw@=zxmkU(skrR53Uoefa2@ojw0N^AxqN%boM_WFqa%I9${blGAKJ^OdoWNnd;LmZ`yt?s19pd(?0pf<)F9g}fUf2KKAhV* zy~)18*1C;1q@M(0Zl6zVOiO_uZbxNy#RjZ^dwz{1-*kEEOo zdwcId^jHY1S(-QWWq9-RWS%V_L)@kyTVC*#l(~Pp2Su4v!Anm@+!I?7_cfoP9;a~o zNRrd|CHoB^ilD0L=!ficJm%tFGt>6VdAygGSGaxb~`T!U1(&W`!h34lt6HkKiW%c<|vjAOWM#wBp^ z;;h)?#P+LKZ0|l1Wi7NO9)9*-z&`VWjG&@%J>2W@`Hw0HT`y6b_PTTOGDRW#4^NL8 z!Ojo&mEmtVWaMBR!8ZZ5qIR*LHdJbt5#HKh>5v}SIi_% zwOP}p)8%YB{UfNb?Q71s+Ux>D6JWKSX6zdABn974KitL8PPTeYQ2Np>>dP{1)-4M9Z zA=lMFHOiE0E6ZlHO2a)~liYt2-#Q(mWcY_0K;ql^ZQD+(yz&lyC5e$r5X<_F=Qfhn zC=;Ne17r$lkttrSO914U*sPTX zNfE$;K^TZtl-yQbc&z$22HW1^DhI6}npKZi3nL#0rqjuhERK}I(L*#VBX)wLQMF&o z^BXOer)tEZr-HkDt|*YJT!Yf;oCzWlmlj1F-7EOyGkAMW6GkHC3f_MJ#jdc6w^^ms z-W6$sj~vn5XvjZ6AS_e&A@l>4Gu+!%OD@DV`dtREE6EGDT{VYWq-B~W)h4myss*jo zSx#DWyPur2)%qKFhsrIx9B-RV@95V>VfbY~Z-;#0*WjJzvR>Ya?;)?S;o-Osy0H{$ zK^$#1ddSz>hB)xGqVVo24va+Z&;8?heQt}Qxlsm@Br2w!xXvx}K4>IF9yl#S&FK$ubulMO{qIQOVfECn1gMaxuut(gdisUD^^_oZ z1Y2F!HfkmDatvW830O@2p(lYOMe@PxH#yJkA6K%0Wx9+S<}qvI)h=Cz7Ap$yDC_8L zZEc-H0c*9Uy~8Ua~x zt8pBR_?}6ctw~IvU6R_4c=GXuA>Vw2Z`tSvj9EjJ>l`yKON~Cp-PxUxZu8fXUHT`Z!M8lX(u$5qzYE_r1 zSJK|K_=q$=g=m@;z%z`$Z7TMjQb#1-`Ju2hz%_c<8IvDjE<$k9eE10N0)o$az$^f5 zHF%V#z#CHwBVX6JEIZvFg-^ArxxL4+`TMGxj!r#DbBZug6*WmO0_{S83|K9`o&gzI z?>CjijoSI%zcUB2x3647aHJe=Ing7mY@>*)t@SlW!TnGor^9(m7IZDfjQl9yrp)(kDlIUN3a1OkG?4v2k+AG%^Ov2}HRgw{G8&R9bmJ z`^a4oL;w)pmkl#VPP+~!p{DZ6Ffl#(2EnY(gykY!+Mvn6;g9%c zlli##{O{tub;{Ga7D+S$b(>dON!IS@sa`Tto7?g=PuJ$O0hg%ixN(-XbqQo`WkJ@~ zlm3-=);JAziF}BixBC8trJn4f0}J+FulO7U=u)JHE026dNmVfSfSdHrF@Ei1poN*B z;Ny$$s$WU{L1rsHa=pRGUt6ZPlDZoUaO!yhd6)IRs)?Gu%$}1TbM$Or3m?F3RJL(@ zcOF||@`;4WAEcrhIGvdNHxzX~`q&D;LMsHxGt|MHMw`1n%|9>*nfsZq9-a0i@4NE0 zzOh&VH^SyENZ#7@r#Dlysi zP3|qHM4A8wT-ccJ1@=EN8k=7aw%>^b$R6NzTaH&nt(+GII*FL~EXc0fQp28QSCydc zAjSvH2xqxi!9AEd2Dw;0YB>gDMybwxVzbr_Wyk$Ef~+}7ka7D4>-lKENML^kan&b| z0(%pm=T}$&OE{N*M+F_tWWV^{11us?N(}y$SXE{E9s%rl(GaI)_KSmi{%hhTfLrcm z@W}AFoljSl^>5s&QX7qa7|(L6rY$Lotqt*aD{%Ztn5qvbigQ-P^~7gfU|N0f4^G%yer^^-a9UOOZftmo#=e9)iCP>4(8LnrwvC7i^8m*`zi3%{3&PK z7N32r7?V##I7@i~1yVW{YFTCASm%_ApgUo9^pAi8(k3$)rUnyzpG@&qBJhx0_jTMh-5jlAR=O} z!<+H$`smxZ`NZE~j4_4)1T|m^Zl960X$lFy^X_boke|rKqiiNgUtl@K65O8P?yEMN zQAympm5Z=x6il-X#fDaO$%c8VeB)c6p@F}(^+$jtMg@ov)Z&~k94ge1TOGJ(!(?Lv zd6_h`K3&8OpO_M#AhsBPR$>m7c<+>sto&;KO;(E0C&ap4|9;$j+cB4XHzur#$C_)O zTp1TW=B?##s)^B-uvBlAHaZ|Cz8g_%k*C_*bWmbw2BEr#GVfuF2fq1&DF!5YM!r8O{W&h4$ zaXW{(48TfNJ3Js$Y$tv{M%Tb_y;A;Y-bquH7yYzIgrS&e^;|;Jagwzz$pSV3N4@qa z36GJlKXTsS2rSL_clD~xll8B=AL$x!i*ipTUR`-<&qFbBm)wmBp0bCqI&XE}5+yhA z=We*CdYgV)Seto)CRwL`4zt{5If-6)j9ys1xBLB+?8XGG08Wa$;XgeUa$dX|yp-7o zoyc-BG%W$Y3Pb7Lb9izl11-*|Z%Xir9zUj9Bi5MhQG^}v5p<<*c*r2aeYrZPYAW)f zN6Ri(*V@(kzI|D*qy!a>3&-oyy>s8l2&b4cf4?fI-_Ik#ukv3IjIoY?xAFAy;c3N# z(wwLpqS}Nqk~ZR>BIlo?P;8&VT3NQ#RgCP_O!jJ({JjZ|d z$KD!YxBlQA*z=-$4!ngSfyH+Q;7PM9+HY%Esn)iKRwTKQnBRKF7hZHwkKlBtqq(_d zMNzId1(D_1U%d4c&J(SG6X#?J@C+-uZtameX>vsaSuGRfZg*7ncjsIwL`!7l{7=cT ze``X}*3n4m#DDsHK_fP8u4U<^<5PO%>IQPfn-j{gv7KvE+1CkSgF@8jW??EY7Z`m z46C@ekxFu=OPgmDwpjnFG>q&1hw3m1hy6V7(Com{3yUsnx+H&J$3Y77vtu_V7#_nU zIEEuG>VEAH{SC!&uqQISZF?0=2ln-q6q+&pNXYy7Ly zs{5Qn4p|%L^=a#&gJxl*C(@;#l!Me`Zzc~f+;Y%B%;egVbAmm-gH89$gp;D9)Hk{R zu`ZsL{GSxi3dq6E$BZR;6nccW6jFeX@W}&UNyCxdB`nSby zCU#i(y8`bP7I{Pq+xB5LnRrm;7f21qj{nDB{(ss?n7!E%s7u;mEaD%Mum2sI_;+;X zkM`GpvslglPlvcqV8=yxzKV>E<+NVcoeMr+`%&tVJS3y^-(+~Zan?XCP3_!UgD;X# zLN9Xdyje?KrZr!!)hv4j6)8^nPT$VmLXy)zxI4>$nnC<8wJJGAxq(U|mgDizG$VVp z^ZvG`|Ey?=^oE9{Hp;xc20s{Y=zwggez1wAPxixCI#RMSc9R0z&F{zLSk99(>g_9s z=-md}tyk`C7{Re5jvjFshU9;c4zPee)=`)um--H=jUChzzbca$P~jvfObLuJqAky! zO2XkY`+6e#SGHgyt!g^%cruT6z~n}NR+KRq2 z04)eOD38J5iL8)S6=_S|Z2K+@Z>LY@AYI?Xiwldj;wHXIMyi{NcgcwR^ykm|TgFUu zFKI2-cY?_pGn�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

tWUE41xyf&K9vk8N~0uJNWM={yKdz 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�K`?v@iLCXvw^jhDP+Z>EDgn#8| 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

~R?6|=^2B|A^ehae2i>5E#=mQL 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%0Axy;57d5&3U; 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%0D58GM-sy$jSU%RlKRFd}5<$>k z-GQ)eS6>6iXd;OTdy4S}7{^RQTuCo6IR(gMMsnvSuIHf)3p@b>6?3B1_$MEdkrl;Anf~?^7EO}Uz^eh3tpY2ITb!01iTQH=CpI(f27IPu`JFf;ww>N&elia zBj&_92TEwn#MAdG-!pm$5GDgzv*7khH=#6R)oOHK3&{=QIOJ)9G~uo=$njPErj5{n zYUP=Y5W~+$N!JsWU10t@ez1(pPZlSXpiD7I%=V8Bjp|*_3q5hJs6BNzFXW+g&GVmhI*>0{`n6;BA%PjeITPYdAx;uyk-9J z9V0!s0k{OcXZD|962FU0<8$x60nL3YWqRSKoia0T6m$BqoiaAY*eCz#UqppZZ6(_} z4?c9WHmX9Ne3xRxL1Ji2KJ+Z)Z|@&<-&@Wi{CUlc*L*{6(Qh#>AxVQd0HhXg^GWqF zTW&InCYMN)^h$SLp4AJq;~0`I$5A?v+f;jn@-W7;H*<16PdeMaEd{B?4{ zzyBxn83gPFpbS4+y#sCpyo_mN53O8b)H;d{{~Q4C_c?{2`>AfKM=Fek2 z&T=*$1ld9e^wb}5#w3*0cqK4XJoGZy1;m92-O#DwXdaA>&y`jpes zMrp}MV;onvh*lp1zszhKlKBreX6U)URbbCiXYZ8D2cpP!6P5KZ zj4KxY^liHZ10%t~E#1eH1OwIOPtgi!5doe1-&owCV`vpyPvabf`dY8g0O-};$YZqI zxY3k;TpEir<2g0t*Roa)TkmjIRVhK zncy)!b7>*X2su)b%11v<3<0oYkOygFN5WNFUbZv)?9_y7h1f>nUbkvbUU|u_WE45+KYoSQ4h4PjB${HtaK)CB9?PIU@bb?&h~##Ylw16_sz z2mBFFHq^6!xT!NXP{6)#(`sF0bn<5ta_;prJ#j`5I!oW(13EUfw%qSgGQ}2X%Dsmr zk8-$LOo1I~70}4xCCcSoCHpB~_-Xs;qnC%WbW@aYNpyS_hxxe2jb=dQH3#Xu|9xLnLGEj~XQw z55;F-&>_>;y&`EzY2)>!V@YGes(S+F=`balW(~TvEOv-s z_GpF?{!ngoecXDrz?B|Ki%F90T<*lyJbCUOVLms{?}pDF(aon6dHndab8<(m9FEp$ z3`QjTnTDKK2zSX5_aJR%?$aa4~aFhE&V&Q$AUT{q4>-q^ zV_Y|lQ&)Ah^s`B-DH>wzk);%p+Cf;nN87171cRMkuf5rYhN+M5E~rcLPFq_QVRw5X zjEH|8Yr0&qnZ~-}$Ewh7uk)mu74;>8TI47IpW{PvjKGSMtnJGRv5B(7yApc@Y-7qe zqpQW~(&=m$4v=4@O>&|xHAm_Wwko2Gtnk6=amr_pvYZ%eKY2{AXlTF010OD9R1IGe z3T@=IdmO?j^;agV@D-?fPnb^#$@Ydj8EBm7-W`W68^7HsTJac+vdKu0dG^|LiT#5- z!+|_<&R3Z2_NAO3<_3nR2(mm+tSrOe&KoF^j2421K4r)o^7*YbF&wNKnjTdtM8pzI z9CgH!4p%5~VuCzk9{X^^Mz9-5FRIfNx^l=Pp4J5S5Z-j=S^3S_qps?cKgt_pR6htORF(^Ux_z8Bu!guCUIzebt)`3Ni;tU zdw`Z8b~)8xCbi@<(hsP~uj(IohzPbBBigOv6Ne5smxPLV z0p7_!ml@z%#BKd#`yFWH8leU}^+?u^nNI$(M>kJNmYo}GiZ=I(*?L}7D1cw=2FhVA zCJThHCPxU67d7*Xd^9^ORin#>lY!mxS#5h{NqLMu_EsYW5-*F9+{1)n@TpN_|0--o zAN|)D*aW7;!qza5j(C*wKz{G0D`cI(DjNGubG&DwqAiNu>*+Zw=`z%-9OsTkQ~%Ks zeg$$?tCghwo`rg5Vu?YEUI3=pVyc8THB4!9z%%Nu*Hwb0&vcx)vDg3r* za1j5o#1{Ef@y5t+-adQWKAM30*Re)PTOSajX{?zAUvs!WKNn?H$u{ml&4H_X7G&O2 zZqZ0iGw^}SU%U*JmGy1&t67r1=LA4BheVcka32v+B!XIfah$<(;M0Ib!+T$qqkY=D z>%I+EMPq-ML)j;f5_DmEQjKiLy|jgrter~5WAWeLKKK2hg3oIXhf(N*oz++U&uZqu z`C)%8R0`AaE0L17dEhbH(Z9W=lE;$6TFt2_^A}Nt3d|jUP;{SiLt(iTWG6XOp_EFk z4&!hMw^Iko&t|<{i2`BwWCTMOa=ccxg+vYhbcf|TNC$%TcWf4Yio%ETUZ-For8d9F z@Tk1&7BQ~GVD+%b&)=vI{B?!mYyRMC07lCHf|{RAjkf&2St$K4y$<0IAP`7I4fLJK z{i4RzcckH>`ZVhsK)`Nj*3@oDYFrjLUPux&d?#Y`gt?m2mv{N&Xz?GIBATDroxXeh zUs%$_6n7&vQEU1Cqm&f71$XT4+)#)T-kGM6mW~#-bVCftSx|Q)lWBtsj)P>fZ~bbzzcu)nZDjT@>1U>#^0uoy9tC`rf~N{0{|ep=q)x4 zq)#78Wy55pa0Hejp(gP|@&AUYA~)`4hcyEQOR>lp%RpTD&LH817Aw%!0ryotUgQv3 zq`G17X#N#s{%wQd8521Nv1$iifjIfzhNOd~N+~=wFeg8zGKbTdBUj3NeiblVtW`HG z^8=q_U8*s6f%m2VH{3b*`vy3D$9K20w|Ml#E!HD^OA%CepvwXKah?ruPHIHvv7CB5 zNRUM}tGoEDGII)K@!SrBGB4I+SR)u$A#S9|lN(iNrqB^xa0Jiq!WAt`&nybJ=f=gX zHNBUE5!6LWp&-X#JePaldb+$*w&|jjRIG(@_-&7aPDML^7oXcjZ9If5)!~edr7&}v z8egAfC%F3eJCylFRuu}nX6bmxk@^X9W1k#GjBN>IHx_R4sSept6%6+*R#0Li+4JLP zPxsb?HF`(=soo7Yeqm1Y^O{vrqb@=eZf5ctFXlFkmTPp4Vv#O}uy>TR>teMBX#`D8 zqlxmGsYF2S4E|xJBjI?fGYpd`pppf_^~`K`$%=+857^d%PUo}7g#GAW>d;s2)! zkpGI0#bVGgGT1lCz%8s9L2^G1>&iS#^~3X7(>nAY&@K^?J!uwgJ1XkJ4RK`V*{`)z zeVsnfV0uRx^{FN5qCbUN-SUej3g<^=T#%PkI@ulaHv+|@LB@659dBy* z0lvI^{g;)sb|lkLHWUftr@lm8Dr~BY?4OvSx1Q|47=!#so)x(Bez|D|F0uk!L7K;X zuRbmt$h6BTwLHY^(gHg0;H*-*397HI7%y~EdTRG7e+BIhZs-K-FD!GAS_arscQWRvZtOVBy4-ny0 zi{V*uPnqa{0P=O7N%f8Imkq*G70>%OEUHeYhJ^J(G{!+Q=9L;8laz=iVlH~f&@+&^ zSgCAIz z#A5g1W0hBr`V^JDsK*41-Aw*7>yKstTRj=ykam6e!+@?zBcOPdF7txB_lPR#jnCRcTxs)A;;5E zg+9&D7@B|;`n06lNj&R{4+C4A5)ydSvnYk-@Abe$L?@_t4*x zx=;{}bY|4c^7&5u6i{ih6&F-5Zo(D;$B9P0_wu(Cx*J{c45;e@s%;@HR)e5cMl{V_ zn6UC6fQmg}QjW>FL;@j;5jZR$LleZs(ExM>(NM#d&WbH*`QFU>)}j&=5uJeydgXlE z2T*>d1m5+ATDP4<69XlQ8B!uHx)UAeZujSrSRoEIrmL%mf0@)A4S>W`VdjWX>RY(^ zTJm7+D2)9hXNC*qQhfec1{0W=SH0G^R3uKnnDwZrYX$VjK+de-6I5M-+tz{-WIP~b zarE6jjkgNtwmWk6l2w&LZsJZxP}y>sYR;U3H}zx^*9zI~sY*kGxi>Yp zxBP3LWi8g6!5!H@P{ps7vip1ABGjUvr9ZJ1achlk8nwf#VL zE}gKH%Y!>y#w5j67!zKF4$d!($v2a{KFIg;rNx?y*+7!%ta8c)DO(BrmnIfZO(d~d zS&6_cgP)mZrjR#vuy2*7*cMW@gpLzi!9z1w|J=o82^8U6IYF2sWnI0mEaMM}d&`~; zC_(X#?nnS55*&qo-Xwi;!!UHFyW;&EN+n*Q&VG+7UO!J#ZFTn5Mj%0w1lkD56vye1 z`uz&;3`Lq8C+EEB8Kd_Xyx&lN;#}ZoGfEYBkym;clezBX^|2*R85L~!=aKAgEq-=J z`LTwHxwa@2BYg*9X@EEOE$UUCUpO@R?nLHGXz2Gjh=m|78~M zKe3fe3LN;GTb09d4psNC-c?8bZ_{uCQ9z-%w?6~`P*3_lPdlf49G6Wv=ryR*ZvIUU z@bgIxh;SkstP;~lY&+vH$U&4s(*#t~D2oJ*sTdGDXpZvv{P$|=-q@a{qrr4u0#l_M z=$Irhnf?!!Vb!tf=%7C$LYUQJd}NClTzi%3iYf_stTB}Z#su8G>!2`Q@;uf(xHxSg zPjQHOeEL-GQ2EcGYL<$zOe_b0@ffpzQuYHt2$W29O0B*0h71=NlGpy;sro9dz1a{i z)p#k`PwQ1-MINfo!1BVFIz%9NjRTn^%4h##b|GnaS zSg(}TMapC&pR|e#A&-14NkshfYEABVFsVAm_T9>^nSxO@39DurT*)-BoNj0}Ro85$ zq18x3`=13Pz~RH{THjt0QiWq99#lT#nvw#%h9!YQ@r2=-RvH&ue+LI~m~#$0hd00s zT4W`jqFv+UPD|GhLSQ6c#Z;ZX0sn5nbz?M$=fF(VH}{F(Q!6FN`k4I%(*T|8EurIm*wr2)U^B%BoG7i<3F8JMezO>z3{50NPv}qR@ow(TeNG4ZUQvr~|4kBDO|Q zS74LW+D4Z4^Iy}Pe=9UOPOArltXm04Vi!W%W#K;J$V6!jbj?i%>$>RY^I{U)yle1R z&6L9o-6<&x+*I!ZctyK01S_iN|5&;0_S=vNUd2fYv?LJ)&HB0qf^?x!8|7HPj$k<~ zZc>FVkrV@%=BpyBwLf2XM)Oif;9yP#58;CiCOkVl$ciM2kbTS*vJn`g@ixNgW82~< znQ`Y1#Z=&Qkp3y5EB6BPq~q-MN8XMMemiKXGfH zC5e0+IjY*FWY+Ex;gKZAH#ahQe4G51Byi)b@?p($WDUuWiH1a4=DbpJxl^9~x)2bkWF0>d?D)t7I*V9TOx zK9)4yM9B8a$gKu;BqRiD1Y69~dg`RWF%&KaoDsqwoV0;HR)`M0hrz3Ry1tgPPIS#} znpE2t43cB-5-8WSWb=}FOe^v#UK<@@sY9affB}vg@TRwKYc%wMiOVqc0Yn$km=%S)0g??k5Ow6p7<61ZdllS#)Hwl;I zs1d>t^h;-VMeaT&gvG6^(UO9~PP8|&DML0_XzACDZ|RQ=ck-LbAC|?So}!&7A!=Q% zE5n^+(b7WVi?;Jy=@#RHnYLA>NOX}=;i^#LW%=71gb<{?cF%nFjlFd&Guc7m5Ct@0 zy2?O<$rS*CWhQ32fEu9^pK}{>P0AlG3=Zp4m#PwI3y<1YqcmXk%r7}TdkleUwBVgL zn?44)tuNuSE&?$_l&hIt{1VOEz{m9<5aD219$}`}Izdp1)?YePOxAKXi*-HxmRkO3 z*FMEP{Ed?+remG%xig=hL-|{iJ$G-dEZVgtrrr1(_p?ATlHl%N3Z};NdCV=MZ2@~Y zIuYlF=F;i%j(94I#}+Su)Dp1Y8^KoXYnsio@TRKs3HPK~f{BBq=e^b`j0QcRTt9Q& z>T?sfr)ZPNRn6s|R;oXLw;SjvLR3d+mPKpuVycB*4nH5F1vgllNHtItS@g^PI%hNj z00z1ba6g(}_EA)}>rh;4Ct>maIDH;ksnJfkA6yfzIEB=K!OTlo0|=C)y6_cs@Ffd2 z5cJO_)g5yF1dcO@bV|Scppb+OWoRfbtcE58@5-`6))3b%Id}Kp+~YmmC`d9h-P6}% zXV-P3lQ6)XUU_x`b1-{1vYVtTOV**Wph}M9d}vZ_t?;3akVBMgJL*;LsW6VU{L@vx zlttAgqJL5BS17^a4?zKl|DX2$pRA6lU(%D*cmy|6>uuFTVCb!KWza~W%_WaeTKw37 zq)^qYZ|EEry)v~?T~n&{G@JUZ18JEGvv*3wnfD{C@R(P(C1i^aTVIOJ{gCAUo&G|| z)Q&Vbsut!SmcyYF7tuuf-!mW`y0HhP|8r)jX(#Vo@l4Z8pKYvVZpK{CnX8K2&4Ugn zi=mNO^Ha}HgTAl;d)|jyz_orebL7`Nqp+W2!&m_bv2a+Lo^%LFEdC1@lm$YV(Td<9 zFpr@kwCA+sqfU+A7^<3kKG6;XQTo0LE6;AWz$oNI5aB6eQ9lNl(58cQ+(8Ar;yCpu zpEGk9=158!YVT`0kRw3(Yfy@kke14UktI2JpfiPbprnjqw4crll6$g`m--$>A0!M< z%=d^>qV+LyJ~%ixQKm+^N~QXFHt;+iHZ^O}refkIE2R;>K~YW8FfwfG)#&*i-dk~XS=N9Y zKP}en5yNWTJcRY`x_E2Xp0r=1(!^0MKIZdGmzGshE9ZJ=uYt1VA`PM^jGrhS3Y8BJ<;Y zWvapnO`gZ5<919;&ML54D*h61K9;4vFj6cci!=@-cr2v5Q(>N-Xfb!#yz7D7xxC8G zzg$rcKX=IB=+xUb)02e#5gw|BswWI~Ar+Gva>J+Gzt3kIDN@xoF#IaM=)F*iN3G-4 zt~y{hlsEBE#j4sCC!>Qm(jE@`i$%X1OM(0>BexcG=M(4mV^Hsaq$N_7D_=3b&lGW| zU=7l!zZ~aimJUIEIJ2w^WP!&eZ-+DP=m7S*2926yq&9)Ih;4k|Ws`sdhJunG|0oK& zD$c}lgpXbc&y4jGiy=Jo!aD<}=mnybvojTPo$UwSYqRJT!s`~_JY0?Ez+&gOQXb_G+kIsVtTKe!_rPtpP81^X~n`RF{6#=8%T3A1uy)+D-oPM#l;{NL_4x zp)q=OC5oQDk8>TUDo!=*-zUHDo-P#6i*7J%7Vbi5jx|P1Hy`Y!=jiN(99^K$ZToC* z(L9K}6v|nSH<)eE%9zwG?5N`>k)l*ggG}*~!kOmwhc~R%zVtK|_BL{RU#@+Ll`d3&c*r<9YO28cCvper^8%Cm;S2V3?m= z@Xh2GH*9Mor`O1216g$$m^W1gvHE=RN!bg~?Tw2a*pqMXn(c(qeOz&yp8>&D!q9l+P2T@Q({b0w}Z+kwJ%xf4&CsB!GUk8nHp7BgDj7I zLNqUQ@rEK5-eX3Aq;6)(s0CA+kO-mbiW#wvvRTyinlOpcS*uF_()5yCGcmCDzL&X| zyoRe^rEq>Xh%PU6hetc0gmdOQXO=L>eG&DhA9-Bk=y2+I{D?}e9dj)vJ2cQn!Vv>$ z0lF}#E;q#zCWo^~I2vhyynKr2s!uq7QT=H_F_H7p;nSo9Ac26Nz^;360=N4m6+}^# z*amvAq^56bT@!#>k4>)rC=xC15}R8_WsFsH2z$4NCDB$0Z^wip1y7IFu)9sP$e%6i z=V7XP2(AY4h_PUSRSh`m+$Lani}6Kr45D~Um=yZ#{+zgY_N-v~YdW0pO?<>HIy%>m zTEO!8C{W@(Z`$3u$2P(mdBLC`-5mh8FIEYfky8Fjz7UN97V)`bgCr#A-E6)4-&`7w z_Lyu5Ox2t^r?r7av&D%;PHlBL2q6X4{zcq15N)?YngX*MpJoDMdU-HJF`vL}wGtcj zXUcO~=2pCsmksSG3{0ztPm~P9AG+Ndn*Y|4xi=sJ`Uw77#~X(zA=ve;&|CN#t1Jk~ z6D|zG`%axuA_vuF)~~D?hkU&M)FjhDnSnz&nq+Ct;5AXOtgGqzKjgh*kR@H)F1X9K z?W!)@wr#V^?y_xm*|x1N+qP|XnWvuj`DWrf6K5hO;{2G1nTXu`N9NjVXIyzJGw*d> z4LVvG7KDe~lbJ;G}5;q=HCP zk&Y~4JvP2_4-q?ONFla|k3xvJsA4)Ut9IL5z-~tOL~FyZ?(4!3P%d7AnmN6)k*%A_ zeJ{LapnH>mpx|SwQngDcxD2VcsYk$H6|soB3Yf+s05UR#vE$`|M zhW=B!lFl0&`%>>#8$A%}nP~GRDSAF2O>HE6cIR)X1+sW%38W%(k|X;3mPlUhnEaG- z`!x4Yz3`H;R)O6QIj*x<-HkB~nmHuNMJXM$>Fu}f!9cW%t5^a5%#Y5|(8FMaF^_X; zw(A6CQ^0G>;GYCW{m!Ply2hxhx9O}1g_^z56OFR7H$AOd=Y7TUJ!;ow%KOaw0pcYYV zpaz&Gs>uZW@RSsp)>^iVeClyL$-asK(a(D3|Gth_qvv#O4!&(g~U$7M8CMG-?n7!?{>3MiWkzOH*hajG%0q z5Q#QjmXCfx&G^0Zi%%NR_bh6~;Nq`|C)dnI^g?M&CZhq7e6*nd&e9+NXLyPodI{}Li=4AYk{W8*C8Fgz* z3Jmgje`srL<!YS@>qXBd0PACUQWpOqMUs4Eg;bdEj;FJn+oU6aHMP2nwPdTN%)dHbKQV zGQ%F~XzzM|-2#zE1ZJBAayEAb=cEh_%G~DK?HrKYtjVEf(Q_qWy@+hi8o`q)I;c#I zQV%VqXpW{Si4`_UO>xmI*!@6RG}z+FEYjkO$hcc7keNB!a>olgm_;u5DlAyRhrYQZ zE|SPF`?4U4l*7q!e)%5M^bQ>6T4dMx&Y+RFx~NfEoHi3L$q14#8lxW-j6daYLsRKK zw`(hZyuBjaAZRYJ&RxY!ybKE35#RN5ir32TWQ0mOgZkV+5bH zf}8SfC$ypfRofuMtUamBQnvF^rp_%09{y>raHhKp(AN3gSe#DG55@+Tvp3Y#Tq+zr z_fyb_BnV<|w$ab+T4cf)9G^*1zh6ei9Y?c?#dhGzKiYLD$`eOav~i5Z-|#RZ2FmX) zed6o_4a|(=vIdjSd7r?^!uZ{7bv}j3vPe(O0ALrJA>YJLMfFT-7PvxqVC_K{cl=~f zAZ+Ik<_4H0_>VVYg&SwyNs4-twL(@j@*qxw&zT;@#I_yvFy#5>IjsezDD56QW%Ikx zPF^a)6O?K6vQW5@V~Gw{KbG0R<@vK8EIb!chSxb>n$_kbQsk{&L%Gxq4SNcZVU0hN z<@Ge2b|tS1s3-|P~`%dlDWPHf_V4bVnD?k${*dT46U}6omgKIPzI+U z4Fix}E^#DyM9(8T%e4h_m}n@gK2c$6{9uS9BX#2wtfDe>OxE_vm$BGda+kiK9t>Gz zVwGVr8y&iTSYN_r#0@ss6bL^7W!hA?JVDNU^qApTRekia@Y`bzt)2TC z0(SQpypt^@#tL|J9_0`!^bVfaVe2s32NcG^WXBKNJFxd2JVxk~btCds2pkK}mkTv%QCHExj%u(@MwY3`H6L z^_dLn#XX6($}J$(P{ud`BHYcO#7~%fzO%WfK>xV%7|612D~vg$yIG+~b}=`0R5f7C z9deHCG&b}HPz!U1H;=_IB;;s|BfvjYbM0yZGvKF@&7nwvX?fp@Fpv+hFt?Vy@O_K+ zh2WA=@TOpVvpD}GH?nlN3J!~fjyXT@d8s@cA+AFExv0ZV**D}hJV^n9NHTLid2IcK z)3ox;!b6N7y#{{4TiqRov?4{c8Vl;F>x)RzWQ+%9PvDbyG{9p8p%yYiPXx?SSBa5r z6wM3_8Z<*(1eQjM8 zNaBS#UwvK!T}HUm_7kPlSK1pv{c655hyVH-Er zo0ztHaVrUU=z3^C_wvs+h*7Bf{s%WeuQEag4BFc!G|8NC)sn--zwZUowHVM!Z%!ngej;bNCAE!o zdV|1~Sv!{k9n8%lS*>OO+pM+se_?9oPIR#g>kvK>w2hGS=uQbT?p--T#bW3I3IyN$ z!Eyn!c>nP@{Iix%g|DwyCFWW9vaSXpDghYyyf+{KWabPa8ad4`KXKo3diE7iIqxf( z+#aKlGD;|%0PYV(XE8wW*A!>2pX>ptot(`oe%_a`bzd`OA1GC>0R@rNVF$^e@=FR4 ziH9AXtby@YR0j*SQ6^yt?hL;l&`%QQ+M@tAi|DXyBgcr-i#y6cLFI5zX1FtA8^8yN z%KX2k#@bG9q5 z9J?9>-~1TaiX9RfO>1WEGUi4v1{Ybn-2<5j{W?FM3yxrJf>)nbC$rbfnC#VKU8h;bs^+gjxuhQ8df~lDyQN(E zuAr0OGP`N18Cd$OJYaTg{-g(;WJ)qSIe}Xb5Naz5mezjv@noH&Io=I$ec7nk8HBu~ zl6H<}+t&T|B}m|{2;@2H6`;O(mCc9(&*#YjB;p4@%eHEQ?MwWl&S3&HZ|5e?`fhU_ zH`0G?PmDoNxih+yduLpKHMv508@o06C5ZhGX9fY~1-elc8SxOUe*H<1X)&L`|LsQK^N*2)>o7rNroD-9*VEiH z>^gyHx5)lnnZ^yGfM0BD5H{YiY|eDHLa=(gHaj!Wnq3^^kW6-!}j9Y@hHh z;5b(RQn&JU@D`w5qTG8bcnE3rCk`LCN0i<$fkR1U#c|I!J5U!)bf)3wPg_W}zMH?C z7bGsGD)da_*mYp%QmZ6XE2m~&!mXe(=RP-ax0J9HB9<_&^_k+r6*oTT)k6C02(&}{ zg*je4>3CLA{X%XM0t~Wd_)MzsO7$RV^eg<)5^|GRtvO-ekq6v~F={L{DFSo4*;5-$ zkG1(%zQO%$Bn}PtP|Tk`$zOY31CUD$(4(`H^OCW6lQGqZ$|=}ZOR68c3LAy*8sB&NAWqsUTRn`X>ChVBdFRRR zdlM7CiF;}JCFa8KfQN@zGVnE&SShSiB_QlGx^BO|KR#WU7*n15r<(eGFBthw3D-0kLB7S2C zBdAWLh4?&DPOFZ^ZPFi+pvH9gE=~{yLP?xr?=kO8i1Au{GPg5Ke+HZnc)M2;3mWxf zmPu;8CMXj~WHWUiOyaU6vY{tAc$-OkByy6KRZ_%uO_TL9X_2njPCmVsyt3QoH z<*Z7gywyFy)>vseA4gP;*SPIzNGS3VpA5*2^n+RWJ8-1+RpjUZkQTrwPyEFs2%8|T zxK+a^o`&+VA;P-RMbha{*^rX<2e;u>l^h)~XE_X70Vq(lZMwe#HJK;p@Ca#2u5m$j zmj!Ph9ao`EE)VW>QD|dB0>M}8TVe2$>dtxD4>NEodm%%~;yz*$`y-u3`&PQW?{f6_ zWiqh-q^k*m#_@wrCkoEUiJS!0*S$MG8`%{hj~IeNdea6Q5nncoop4gmXVmbdMkd4> z&yU*m>1en9L5liVCf{!beuTkynF(Se|T9VL8C>JE?+f` z<()ai2v+PhvcVlpkr@HSw2{`JwnUo;g6MGjl854t+MmHQiP7ixN012`iR0CdOhA{b zjNvBtnXPq^<=L{ktCEP4&3BFgTu0K^B_JY!CU3^l$q*L>66Jb~1M&zimyzsBLjlf$ zbIQLwR0@LF3OJQtTfE_+cG(3M;tIp4!Ha#SlEuz^&gICnP?#z;&5Ig{XplqC##wX& zIL-v%fBoRAiG6#e=`^8}Yu-JSFmJQB=Uz&^pAGuJ*6foYSRU(qTik0?Cmm`C@1Q!E zsnVfH^WXWB6sb64e+{z;v3Anj;>nurO4WH@761Zf=~`kImRbVIE$!B@9

YVt0LwE$MWCxe0EUQXLXbAOujeaoKrrKuC}z)Vne z>bP8HtU8EN!V2VhoX6j>gb;AG+rESIlI>H`7%K7Sfp5EC3sNHxKCBIs-?h=(=aM-2 zMHL;Xy3SFln)+!>le0fsfYh34+ast#)#-o=G}JgjYKv^$P2J`4moqxCcu3No_YBLW zpPa1;W%GFt<;QnnH0cj^17Q5G!ssX-s-%b}Dzn-5>%r0!2^8tQi_jQ2GO2A}v-T?$ zGuD%(NJpVnkxbbF=(G#eXS5o;nCWBw8Fj~-W59RmJNf0t3=*}4JvE3CQQLmaXtdb< zd|?iVSAaopf^qwn0+87jNGU800hE9N0qU%z{Gh}TrGmb5sy~DD+q8qdL-o<-bujKG|2EJcT z&7)49mp5wk<7SIkx#ZGDA?J%!86H9XziflgyVu1aQcAw=RYFeIk`~@#V1vo z#w*Y*@YY9nF>7_UA~#Kz*jwSBJwVM@kp_}J)p#)_m2#rLa+$d-^D*Rfl6bz>hP|zB zwdACQKen90Lkm(qNpzbMu@|`CR3Ziez(a&|ha;uw;J^!MA3n9|lBv&Tplp6XnRWhB z3;J;=k3Xp-Tg+_$M7a1o%+V7wgYulQQL!*)0V<%)PWUAkzln?M9KqoJDgae{#>0;$ ze^{26$NeZ}l&s}?L_}{VbO0l5r>}i=&3N>`ooPG$lD7zzd3ng9rOfV27w{xm(MoGUxL8#I&o@@YC`Uqcz_ZS^_SKB`c0@-G2LAj0~n7ggCaOjiErk9ttGtcSIR4*R?jfbguo1j5xWV@EEB zqUOC?-6hq0JPCQsI!%Qcvu>Xd6X`4M^sM6CUc9?U_jFIH@bvA3*_m9TXHgDSo_Q!nG6RX8eZolJ976tDIa2 zJ&84ectUqh1pbuz;9P>x(<@4*p2##fs7nk*JE*{lbq0ih*9d7RD!dSTHzEqTncA6h z)fMI9sfzr(TV&GNnk+pcmBZH+-=21IUPRuXaS$x0>|INacb&*&BT&*k_|?hj>2iTL zWEiOI8ohslxRf75Vb3WQ{L18BibOz)_|5I7>XRU$6R*?70E>O0ox1gZV`Mc+n>1ys z;?j?&O}gpklg^2I9d~7^H(wXj_FkqNH*Y((j*;e1-tGQM{MgetW1t#?(KHsBx0WPr z(cCdX6>I_mdTIw@_)Y6J*1iKejH|TLgSjVnSKG(qUiTG62Xt}3FB3+*MOD@{Sh#IG z%R%|yA&&XBVJUxb^ZyfI0M2iP($if>|9ND{363 z!#XFskt9iRKrG1K@19mA?FbZ0O-Pq==9S;#vdAOv7%&s-))&jyu-<2sG%ztEem|}n zk|jywQ3Jicsh0+xwRRMDylkwyVxn{Xb3CTGm4DJ0i!>U=f*dQln-%oB_FSldN${7_M9WloYstdZD%?#p zp*p3mmYVQw3J3oM@Ojy!CTWl)j^sZ88^lwPArd#9vx#ttX$+)i;regOh)Ec`Mc$RI z8T8W`uBisi$xGT9okVUuP-=w?!RF*C7f&2wCGqR(b7PUIpuXU(I09T(()8=3xY1=r z);uE<+yZh)dS!VBNB^+q76{7vgU9`MFkAisb2ahpYL*>O;Fly}wAb0KP<`;+iWb9t zE(&udyag$e(?yH#)-E#XbO;J_PTi_La=ViHstPzDQ3dl1BO&)4N*WaC#B!4lQ)bGq z`w@JQHHy*=43KE80SVyC|GfzvYM6-+DLLOr!$Othf_mCeep2cs63$|EtRAMy>C;Sb zLl+(^f}m*vG|44<-IV#KMkL%3Sm_3$qNup|pXo&h;g%Teh~N`H^7^^PR0ye*yDSs| zr}AsK%#}q#z(w3g@qYgcYt>8fw@)UZWyA#o0T$^C=s>7 zpQ|MQ?IG=nA)@zGCbhuEVDbT-#b)+nqU`nTWo48qw`An1Kh}X0AtV4edZjU=?;och zKz+f7wz#5`-L3leeieW4Z&4wvf4%?PIRBsGxq^cZ66o3wm3AR9Iv`Z;`8W-liW6O`$y!Iiu z1ERT{Mh-JgsUR>Z*GVcuJjyrW5a+~F0hJQ3Ifj4X{GV*|zdOIm_9TpKgUWwd=;ZXz zOv*^lsy+I#5{p{rY;Zvl90f!I&s=6G;^nA{YJiNxAZ-*-4CysBEXi;N!E@gZ{*;I+ z5kjhISg0Vbdkl!r%6$r}vgYW$R#7zIo=dVaE11Jm%(>$&kbceoJ z0%C}g9@$uA*#>?i?P6E|?;EP5KLq4|NA&;0QS`qYpB;kREFYB<0O>(}3iPpfae~K# zjLBV_;3@T8f}PS{=puP;6;Ug!^d{U>Ijv?EFqNEF92=9Cf5*!Xcmen~6zWGxaih`F z40#7fJ4EFJxBq0Okj4T*ZGQ-c|4pZ_wN_RPB0w*p%|#FadiKOizD~qJU3~dBB77gz z=u7cafD#+@^8-1fq@5{s!Rp-!%HkI47b;UXl*{0%jtCu)ND%9Cb_-TcIXCF3FbV3xs9x{wa1W97>m{KJjDYYZMCcw0C`)VdyME)I z7Jf)n9RVAD(#Ufoj8F4YGJ@+cVtb@NRL_e6bRTY|+x(TV89)>YY-D#zw%|0 zfu_kIy$ZRzxWQa_xr0Pl$3Opa$(MJ!tAkc1iW>l520YKE zTg|aJc3=Z6AM(g4pxt5&!^qByh01N`Zs&s%Y*= zx03W5`gw*FkAx(%=vlvGpsF40T)h3dV@j3xZ;9?R%+u9R42TPg&Ubgxq9? zJ&o=X^|5x!{79c2?>e_zu%n0j%yA!uA!sDkcS4szRa1HB`i@>$=L0{!y0` zkye*`*i8nrLfjM-;_CgW&*mvK+-<9OSxX}U4b`t$GW+wDXpR&laYueUqCv5Rryqt$ z%NuYfL|;5=x7U~fe=#REyD)7Lt{Zj~ zV4~4msT{eycuTJMU~Q6w#|5`{3X5Z1dg7L#ejg#!v<-z@hgQ5PM8s?DC!fIpY~fNlE< z#k-ir1xj%Q32+mHYVh4@=pr^hZF&8$_J>FoPTCeZ>S6I#&xjz*RinGMpDjMA`a#~Z zxK=3YuT$E-gM07ab#9yIpmuk>9w5Qom&>fWcp^s=-|J8Buj%fWr!T&*c%@ zrLBcSeKtwQ?SH7$E)6M*-wln@v!8=bExGB95 z9U~9ZINKlS+T>d89Rs742Ac*mPkLQ`;LHuz_=i}KALTVEE_ED=KSIhZDY1#+_R6+Y zP>EOGoK0Y}2JSx8c+Jkjq}ZxVM*`G1S3*ht#$Cn}ia5Te^a8M14^j9Hw6|%XXhzw? zddx?6Qcs0$gHfHdqF}3e4ab4}^f&u~=9J`*QVI_hE7EeEd&DQ-)K6$FR*Ta+oW-TU zE2Sojey2;OA69d6Zrt6wD2Q!Ps@&iW3*U$n04t^Z88bo&_$Bp60qTw+F`N6y2DD^% z;|@c!$0sf6KxbZ{lWu^qI4vD((l8fLy$emp*2~OKjLR&F^H_!21Laxk%dc zpB4^az*~W??<)ki!uDA2%hp@Qql^y;cZrzuN_Z+pjSrLl3yx$7d0==P6ic9I3H%az z=BgJQr`g`X3m%nA?e4>4L20o|1NT>$NC;7EKg&EjQp6gG`l*DS=&TYSPF;`4EQD~e zZfh2CEyi6^yzFi>Ozw$AnRe+5r(gV?V!!WG5UptPvmNC&c{V>dp6-!A?skq#8){X| zHj#KEHpaNb#K|fw%Qo*zyB5~&3zr}-)Yz6UGvhPt5EP@KIXA1={Xt($A_P&7cM7~utfBERPt^#8K~dCgQfHK00zmzjh0Tfh`OZ_EN z_{QpU+cq7SR=2`2MaG^D_L5^`L*?J&?NQE`fma&PY&G5i9)8X*O1MgqG4d0mxIHR( z(Cl&##<&l9CG%bV=sVXx9I3pOl2hIp2!pj{9lBiMuM9;NiOAL?n5tIHS)H3pVV@8W zXtkQ|&<^6DMwv>=Tdv@@&nqNsXR$;r9&p0p%G-D{6RQU}&^CYASGaEaalF`kVUaAY z!L7OFnysw`W)7bAHGVlSMN7tI=Il*fx!`x_ESqgl?RmtI|NYS;bZl1QrU{S5Txj!r zojv^Z)#T=c@g#U{i^aYILgI=MssLBx8PUesBo}(I=|}skJTE>^!a&Bzsw0TT(1+PLKRf9$lZ_j3q#6h9AjF(Hyr+*!M%22q0-PSwaL((T0HT zzekycCCdNJABf=#=>Kb_luRQwMlm9ra4Gw|5QSy?$HpqbdEZJWqXtLoU?sOe0>mGp z6u?OJZ}wpJ-OV}p1G4{BtEKbM(P{tZA^a=lAm%frK>47lDaT-N_;WMxxad&x(r?-H z0Rfcoz41>$pj<`9WVX|AEvk5FCNaUJ(mHT|ywK#5etdY`P%_8>8RK1GBN;+w`7FCx zF+OQ-z3#M-{k3^qi!Wq8ugEUqN6$A!COc>01}&%KIikF|285@GOKX9D(>i&Z|CTmJ zD3up6v{mIi`H@g25hK2`|PSvj_Rty&)@sRWD#&kJ}3KmBE;l}@ju z<(@3;E8Pb)odsEoX3iBluixwtD^(h=53gr(KMOV($4_u};Q<4J&Ow#1qqsVpZ1VM(x2cQ%N2q`;Agzf)}Y@@ND_T)Z6@@MCz14KA>B*MRLd|x9J zn>04~VSysqiuI1*9y5CztAJGIM{fN+nBmk%b35OdUEvn4DrdfLQAy339XTG#L8J@oXOG}d?NevYd^mXRFw;>uIz~|(Oi3hB@t9VNq9$lUlSsRU`gMAbiu+rF| z#ugKW2Q&muJKJLo#tsEiOjByxw?`&|J+Oiwd)zO0LMc7}_4Ns*$j5srGVv$;OT&s# zPS4(2CjD^L1lTay`MbY-|K&B=_CQVZgXDHFX^jR^S#nl062en*#%tUY(~ zzL~k@_0J<62I{RQdY-pw@>My%K9F35LP_BAsd>S%9}VU%J0%h6quQ%H#55SuK_X-h zY%E>IE3J{Mpt8x45ZvW@ps`FeohzX;crjj=!V~M0uvpAb!B??B;$o38BEA(xmg3>7 z1(P|W?q>{TK0okOtJYgA#f<}2(6O4Tsb2@b2KFp&$1xb#pN~Gjie4TFPJZ zq4|EW8ut4Rt+PyAkk3^xe6{5pFZCo|&7Y)YDx~73Z3@CT;(md^2ByvUw$92{yo>J7 z2BsB%_qht}z=|Iq`uODkl^Xr95lD0Ki=X=hhl9^uy9bjP>j@}Ldg-4IbGMmxF^d^j zV0X4Kc6AKaaYKHR>5q!twYaG(YgX5xs_w2$E}7Y7mLv(&2`2QRzbk2cLX+5>R*a&T zo$QpIU(QITPj?4P1CKjGlGv2{onc;-;deSo?z5maSBe>06ZCF1N-Z!zQuN6BrVyt4 z+fE(7tqKE{QXrUW90d_31#7|Km!JwQYdcqL@Q({>QWHXw8?EjiA?QR z5|+tsPR9{v{1GGznB$s2Qe%T4$p|=ujV_9~DgAhTnZOGHfE0EYy|%L8g%u9@FOpz3 zv~ot`&Ppu!6+d=O>n_-?Po=Q7W5U;nrYitraw5<`m#rK#WNHZ2_?5$4t)4vOs@r`{ z6}@S0V;5?O%<7cC5jy$E*33ceobr7gd};OO>3Dl{yU2JUfkuczPcDZv?ibY8^zN{F zJM-U+CDW847r_>SgHOO;W7SXI>tZN-Uc;45$SX%j(E%6KtaX6+e)4RF&d+49FWG$! zSid0%#N4*>H0MStT)3Y=An)BcKt--x6Ut~Jo}yC(Y%pVJ@~`FjrfKuw1W^;bMp95S z6Xn9cAuk0W+lP1ZBgr>ocT#DSOAP({d@J;V^h);2C_)M`-ZCyg=T@e6zv9oeZ$^f{ zDm229{oR!v>!tvX=h)J!5*ftSN9(^>ijlpz>#thF{|4~KC)`mTZ|A!qp|PeVUHR&X zzFGOo$C%Or9hFE>HF`e7Rk5V5vhBVizRjx4AIX$_c+`A>9ay}R$4Y%#-XoZiFX9_; zNbW4K34P4!8nPB`cNI?}=u*=h74yD~F7qU!ajh=k2%M3gDHmFn3B;g@MHWii?PFuC z_IrDayWboY(KQL-o(e=|ynZqH^Fa5MWWDxDJb*cAT3pB4$7rtt3K+g=i=kkhz6yc( zt}|#8Y3fN0d$@{cEh+^lpRZ;zcuYniwbPu!Gal8%svF*T2GRm|n6 zMTIahfzjr*VO1#y+||-Sn^@7k$a*pwArC3MzG~(DD*G5HTR}cL8rt7nroJ@kEdfbB)VriS2gziTRv#x$$#^&^j_2a!QmrOn{-csh4l1GB6c(g?yUVw}^yNT)l<_ zz(B&nk7u4)`S-!b<@8=#v1uxA)GP47<*e)+x-?Xh^8hF>AFJbth}MTGK!0cZ(MI)B znfba-i@8>p?O4FHh;Z>uP+u)(+;u$dY32Lez!x$djuN7@cns_bIEyiD8~MA~wFtIx zst~TDmqMS0&V}Fho%23!+h;zXH_d&$Y#aGjR%=EEhizgoB5swp-`OSs%qt*ffrY}} zbbRQ7RPT9u(Ao*BKCIhcZuX}}z-=gRt6BLxuFaIK5cEtupB+=M0fE|l ze0J*zlQsjQA)3GB&@7x`QRrj@wA!%wuBWJH*~CK}TE9A=Vzsoj2X9US_#6gHp0}K) ztqghfKME607rOA#y>q_#rXiOww5<2}lz)LP9F^i4gYD0XH#E)qKp8T9iA!HZ9F)${ zlmD(nj>Y1`@-_a@eK&V3PnDOgRX@@|2LB~%pSbPN7Z=Zez6Tqw^42`Xsv%&4!mZPU z$n&a%lK$mUOCcb7+YSsVt-RQSI`aTn7guD5U(#{07_SlW#f$V66khAC!3s8`Upva6 zi@BM`OyJ-SO}RemFvH$RcG}UNri}t0j(t?-zHB{k)G2*C6Yc2JZ!bTa1;a;p$MuLn zV||N+F<8HH1A?#!wh1xjKARVJPsm7r1GUO)OOf`rea8L8F{3s61*_X-5+>F15WNs+ zWrm(R+m-9vne|McOYb7$(ydgw5k8AF zLnJ*uSVmIk)6mHGo1aR5h-ZMqzpWD36>AG8S3n*uIVwaw4{U+<3;jtv5clTMX7kIl zrV-<3NzmveYW}@K2c1D|G6ttGJ)qH$A>*Nr`q&WQDhugXRSEv1LlNuUQzd8IKJxH; zDIU+uv*iO_^=_`HDohlsOs~*#>T_ron0_!C9N>eE*kBOXRgXHqiSGRl=-l<;#_4=a z6iM%Tt$~{5knZq3Rdn_TNPCIHL_C zTYMf^>M&7dR~Z-zn|Tf>H2DX^WnCi>7KkMHLlOew|7F(1`TAt$-Q52?{zABsO4*~l z7k>LL?IMS7a*lY4Ol)vzkD2^hHQQ~uMa@2aKPi3vE;HM{=D*-gZ3i1UV1Vo&Y`+r7 z{G>z7Jkf8bpj8!^Y_SfWtjSZ!$U(d-#m?SQsYk)zoNkeO!7r0Cvj=dHea$|YQ+juL zILcCrEV9`1H2S}^p^BmEZ2RiM7;Q4AGMhvyO*R0&+7JuUp{XEl)?Oc*dz0z(9Y3`y z(NuaxVeP}k(~J_B%T4ank$=gQt+IoS>G{$Qo&td9MJ(c%s)aV#6Lx+E3ImqnF|lc7 z68wO(eD(4`#ek~Vha7Bfg(H6i8tWHz_YwO603hFku&4ySSIjAe;&HMA3CCn+_~i}D z$@VDhmyqxLaF|c9N98yjZH2~lI@kb%=W?_K{vODd3|K8M?UWQqA5_*&{T89gWtRl{ zvZ__b1r~px~6}2=dxfDF2PCt2#?4(Lj=)&p?6opoeZ#~uIY7Pot z_ti0j&RW|7@heHl98Ofw9D>x(+c@#&l@3}d0149Pjfos#w+iDlN>b>8u{8pwo3zBk z_6x)(_jSY25B3Khyw}=>VFIWEWoFEU|1(AOm~pN5W}N(3M9OJ3q!RYwpDnL6wkK@Q zF$#PJc<}syy^9ItwoTetZ1u-pKf(*~r6wBho^2PmJMgtjL~VOMnl82#*l`L!T4?5;!+d>OYQ*zLNBkiqlU&NM)u#9e0i|oq5U*Cgn*uAOQudCxIRjMH%TNW$n zM_5o(ZY+j-UyGzV8-l5(GLxJw^vD`j?L&g!`{Byh*J!4a!OyQ3-ggYVs*p(hh5hFu zLn0Q(h{0K=$6uR+dyxY^2W3M4o{8OFmN&jr6sR&_8o1bdQ*{zHe=0yYE!}66(g}__3EvZEB{&^S-F( z$8;2PM}jv=`_yt|p?tR?a*l5|BHZ%7A=H{T(ctLd{lDqg4jJ7Ck-)!NC<~TjH_cz( zHY=Vz@vn#XF3VCtW(^61Mzyl@5RaRSwZ8)NAlvuQt>r{Ny`0W?yuxY2I=H~dBc}YL zkyOl7x-gtY!;f8ffGA2=RdQFIzO-ZOeLo}=>hB|5F9Pn^Jj?sjqRrAX4H(BP%~;kORXFdx!V^_Ne3jKKK2#->-xpyQ~r@+A@UJkfm@Cj>8vKRDpc?1u+N7t zP7&HOLcRX<`=fjE=k6lz9_jN(M6>=0K+>lJ79%aiC)@&XTCmbIj`bUTt_PyH=0(-< zSCmZv`tTxt(^Ati$1tAy^M2}?oW&!nPUb$YvljaB+~f>`{(elwyThVybQJzzwL=V7 z&F!%2CmVw&dfO1kS@(Z_lrXPyE{yB^pOMxE4|~{yTL)F=Be8Z|Kwc_T)F`fJh%BZB zxkmR{T;IgM3c01<-vFA8jp0F;9OL!n&_s(^r_NaJQH~Cmdltm|cK3dE_)u-$w;Wp4 zr`#VMCXqk&JqRV#?v#J*U~2!fb6BH49MTY59N2Eo;NJ0(>)v-bjAtrLMP<0ot=sLH zC}5A`6{KwFn?AH|Y!`Qc;Gh2km98r$ddOnqrFNl$Axh{Vbn3vRQaBWEpR9_l-Hd`l zC=EvVl@a0hd>MQUo9T`D8K%|J(li_;g+j8HiF5`lv62P^WJp>E&zPH1gQQhFTviTs zo>^`AM96f*lD7mY`y6Li?b-=JG!-(rY^OGec(L%KM_CQL_wL?9fboJ@mJ4noQO7c3 z2d9F3aTU1^*hG_ClnnMyXY!o;FP=OBT)~Bv=Ml73(dhK6qJ8lZ*aiUmOqx{%Q1GDZ&ndtWN&CB?z7VfpjeSs9zqRch!M>LEPGP$&osDh06vrL;_8Sd~4HS``8;9~u`k1Y0#X0QAiXU*E5fC_{}fPNUH zue`_!RII{Si%9sU>U^YVBYz7jOk-|vY$FHTfgvlCuWjBjmYw^^SmE7^A9F!PCpVy8 z??zy~!PdtKTF|LGvTQqsM{JgIwOR%|)=#fMhU8?whYk7|{*HdV20eIt>q37cGi^)0 z;+=goL;FIrhyrsSmmfqattZs3087yY$M?!=;;2-b;aV} zJ&m~nMZw964`Hj{dW1QaJGNBqN!$8l0!+->zYW7z{IwKZ5ti*W37(ZW2MM62!wf;Y zsUj#((8^(EPZG=Be3C@-sZY}x9hDA<2xU7q#bbTdC&%+=Y&g8J8A8A}Z77Rm$aY`3 zD?Lid*RWmYEBg~t|68}O>hcmH(e;3Ak$N@5G!8hDgt=dqra4^}(VL|?P@ur1U4uDT z!q8@c3f8HLK2YKoxT5RlI^RvfZ$}oqpX9|TSevv5KtfjyJKVkTI$w$bTcME?uOjw1 zCVvg>6rP?S3}{VHq?L*vD;d+hHCvsdQ2sF|MZuE&Gp)xk99 z>|ST7AlHF}Kef9qev1fD>>k=kVnPr>6u1) z-=$sfPg#7ld_`WA$Ib1wViML`i>AFfg(jFZgf;{Q@{~ueaJ`4*jX6==uXv=&KI(%8 z>-+2+oG#JW@0{QmM6)+rCSc#Aco1ZvFn2ARZ;`e*ScR5{S;R(0JL5}h%W$Xr%ru4Y zdyrK5*#w1{l3I-F>C55VZ$8TPAVA2T&k5u3>@t*n^OKF!XnXk5WtxiMgAjj_?|e0u zgZPCZr%8&B9=}=j^okm@+U~)PY4}CNnWwWHB>w?K+ltj@oW1ytdB>p^Veun&1(+qQ zi+SiIZZ`zwvT8qH8+ixp(AP+^cH|3FH$lp73l8|N&yBl=)4W`2^15_j=y`69iWVDP zfD=k?7U&6uAQvpm5=KK(Si`7;<#Ve>@VeZxgRESnip0d}5r3RkCHE==G<=KS;4{uK z!GU#l+*=*G{(z3NeblGP%|IZ(Cm-_$*ws*Bpyi2-YX}adc{1kJ4HG<(9sR0V+E^St zAqXFFTr1bO#?~^Hx@~$~bqiHtsEc%=Ljj@kD;-3rNKGpSo=r=+cX7aC4a}Y|BwE@lqwc2A&PD538rci zHqxlW$jCq(%y`Yg(%?lTQV;*==H+=+6;tGW(t^2C%U7zNdMtxC=A3$U5-sX`36>z4 zkWHUBg-V4m@XO6e^WAi1X1ZmNUUlR>j;2%b5<4!ZBfD1bGytxQ`Zqll(kXXl0l%cB&Of-C7$aumFTG-)^Mo6vr#am+>vTe8{+J&^-7j7lMW`FC zeXzivX8n@V_^kPUc)Rc?l8t7@3T2BrK|S1_*m`g(Ud;^x`9zEEd30z}yt`6=J!MEB znVAdhvGeS#xKsXmp@HYzVEKVEX-0T z18UBq&mAqO7v@igFezh-a3=w;$y06q_PAKPs(zAZ*BSo5%0R1`(Tq7)%U=k&y=$w)`u)f(ZFSSIiW%d^jNNbwB^&0geNo- z$D7iP$iS%qP^~Ybv-;mRiw6b#zLYb*o{j=@|03{!xMY~i6v(}-&Foo4$67LPxzTMT zqARxa-k4WR2*W`!Xm1@nJFJ%lW2H3bF>1umMx?&({Z69-(qBpet0A82@#Rlr^1SpB z5R6lF!_0^561+ToiukL}Lz=^Az>I=2$p;|Y2y5zpD2|vvm+{A}k z0R~VPFEl04IqxH|^|w0;<}<}RV6vO{LA&R4LK)N3F*%PGCKZ~G0M4}~ytTevWYGvF z3;_&0(iYW9(ycI2NobTMxi&$%@=n2$L6_oHeemo=dVbBX=t?@}rr6(K%Ikb3z zuh^U1Q;RRrkHs%%q>NuxEvj+wo@|gc^E(yVukBUwogcbtBX{*)1nIAbC566CHDp{& zr6tgMB81>VQ3xxq%+OlOXE~1HsD)&=iiv;`#Q>muOhk|cp-zhXdbfJFBNBr|aao`v zfdaWVe@J^kJlnr-2r1;s3A(llevo{6>ocLehQ3(zcYEnScZzzWe74lqoH1PTiTYYJ zN;43=eRwAid0*uaa?f)DnF${5m%@)J%F;U0hf~hIm4`US8OjTZjTZ((V@-qJW|Mtk z%)|yFlS;%By_ti=n@fi`lC=V20nU}_GqI5;;)91=PD^XxkY4|Tuy>5kEZD*Z-`G~C zW81cEcdU+W+qP}nwrzKk4m-A!cXIEYZ+^|1S!b=Y_Nl#h)xSDV?W%enjNwhxzcw%q zOsxUIG~Yr0`LsTXzGZmn4ffU-=4Z&KXtH|A64sF`peLV?r!}Tv4{qbEKJ-mq)RD`+ z%_XNU6e5dH$m$FL%mjwk_+XO~M54=^ZQnPI{|7Jfg4oqB2djV%Jm^(E#Bs-Y!3+vW zZ((-UOrGrP{J<>D>Vg6QAlNYbxs>(%b$B*^0^qCZc-(luQ&~cTJs@D20-@iQ1DYHW z((!+Gin$|$MGGMJF4QPa&QU95nl*t9?G6@`^y76S(8Su&1|rtzJf7Yk##jdr6J`zc zV^su+e3SFQxIb?jTp{#`O1>+tRclD7el&Zot7aH5!=jO^bwJLHv)c-NkB}8_-P`{Y zE*}A69y0J0Ovo7|zY{R83oxhmXT=iycEIhudC-d#O)Q4=5R`RpCnk;$-;+c`%{MO! zX8jcML<|8-sxehOmwXatqbtk@oe3Wi>HZF%l4zNm1>uut5ijdMP|RpO!Hd1(T9 zT@hw=?>Psa&%1wIKg}jRrenZ{NsXqVnP}mv^K3uE3;E1Hpv$ocd z0n7b}A>x{Loy{No`}kzC9NWxbDgxSWmQV4C-xaGL%gzf&9fwGR5u6gCjh3AkA&A$!+i z&AVDYM$ke)-S=XRC(#$qAyKBMhh?m440Nc7{T)4{(qKLD$IKgG15c%`~FFVwABU3Q0c%fv# zgcsUK@{+(i2=0aMFN;RlffaNRI$+BpYXcm_DxW4wJY?Vz0p3H=;`7}K2Oss zFTt82yx}b#3`i(whgl56<*kdR>X_xqeR4i(T!pn2)ecG?J=N zY0{LyxMu@!$+BE}?EEc*kMN1xdjpgs9wQuw@!RrgcK!qVVYJK9Y>-;Z8i!7bgfe@N zu}%iL1xojGtzmtQ$UT&`fABzSKD}!xPWQ|AcdB{<77s>;hWl+U+f$beG`qiuXg`7d zrBm(m87ef&Qd3UsUQJHe1$i(YBqkZWY(^X1IE8c}k?8BARfic}PDk7M9}lig^FH2Z zYEgAWUf8wc=rbP#xt(SC?C?OG;iK`2E989dx0jZL)+&qvq6TpgDvZxI$9ulgg@vnu z1#V)w?riOliI~mgY7|5tc$Vzs0(9YDkqG2S)2kGEQ)*pdpTg^$B_sP<8kh@4B%&>T z&qm_1{-yI4_ahWwoqaFR5y@cWjdx;TZ09|Yy`)sF;g~6#>gmBO92pR^1z1I8^~ymJ z{nCneK7L4Hz5mKdEZ+tQ?_c1B7D<1!bLN#Th=3z+rp|=?*v!J3>Klt+)`3{CgIoNu zvZoG753a-D&{#$809cRN)gIp@>c451`<>~#Ocrq+>+$0(PNrn>pG#5F*IkVz>&;C_ zJOm27Gi1twSd94z|L=>49*D?lr!OI^ClX_ep9W%=|H_C7 zYjxho$mndOEv;3`|KTn8Hm-N*eM?qf3vL; zv*p+Ng--a7kuvpPE%T)$PM#*p6~S_vCqM3Tx{Qf~kzp1jkM+i^w%UyAvwy4PcbB#3 ztCA`X1n!#=+#@{8?Oa`6qVs@;XfMRCtc75xQWV7q^hOzpQV-U;W@tnox|C}wSwGbF zJvbbL#1Zq2i@l2;Tivfy9#sC!99y_8*fTS>M^!v~wj z2wQjT4t;#pByD^qHv6G8R)`RdH%HG2CrR}bVS8o%empx*DSC-Xk;X<8D>n815h*ss zocR7{9>Cj1d4_}ICc05lYIAU?35T5oZ&U^eOgQG!ztPm;RG-O{Hd@B~$w zNDU+|iD+r4pYs-h+C4=!4c5ulhDjCzT=UG#Y+-{m!VzL4O(_+#>rqAV-K_sA=Y-*U0 zH@*&eo|w?`7&w00esGeo6nnaD5RH8QB4x%L7#L_VQ|Wyz#+BJljPcp+PB5jfw8z?^ zUTK~$$H|DzDkCcA;)n}dO7l3lFa&Co2Z z(qjz{GQl~C_}#@vPgFnuy!>$O>6MLaeMT6MC1yF~dcYa{++PK%GKz3HHSD`lGi7(} z4Thbqm4GEc33-MQ_XBTuHHCH+EO|0LG{-53=+_sW@`;a?dCW1VRKwz0>NkV>0&ALD z*quW77#G)u|HiE5jK*k?jd2UD=LGSQ_7U*1^M?C z4`kkMF}R3irGe%?lwQkbh-X|T(6m_FryktZA2qM{m;_k0Tn&T)^d?a>6U&o9AW-}i zE_vu6cG3iZqfL1)qcEE6*c->5oReQxGVetyr~q1kI|w0YKa@XGgt*F*U=fI$6Bk=E zY>?gb)~Tvo&((UvX7xrif+@G%9x!8glLa?3&^vnGq$He4GhQ9HG*^bi8{Z6|e zWsuHa3NE}UzB{F%jCoRaNyc*DS|+S3()%x|VFF(QVHcX7e~oG2WX-VhgMej4M(=Ev zPnlVrTlnT=roB6Hv)%t#(6hZXNG3l?vQ978KWgV0ex-yYn_fH|j7Q?Nb01oMpQe8U zqxCeYZX;m*%N|uY>U6NxccPe3y4iiw(v+tE;LDxVNG{+WvHX;M3)~YlCEr0u;@e%^ z)oDFf^$f3^mm;Ucov}qmxUa+*N;?m$F>IRDdyR(S5%PSXwGX)a=J11yWS+tt(`%8b zXhHDKhL;*{oM9J-3DTql$wFq%b7G$JAay)0UeXSU)BPnqP4YKO%2}Wf#CIr=yq#Zl zY1K2zq-zuDp=Qi>J!go{4DBZo?-tZ2d1Wrc_fhp+Y=owZ37T+$GdA+-L6I^Z8eKx$ z@V#pTo8}|q@O2g|s**LV%^%>nF1{7Z;z#-@yNplXH)XSjC|L8%7-$bHzr!a7U$Wkx zLT7<5(IA^2r@XG}eY_b9(F$n_3rQ>SxRYwa!uAyKPXA=*O3D~sF?b!Es!gcAkM2JF z{g|v^aYe_7V9qnF9nJv^_u!3;?xBPtV$4HFm24Q^PK%qmZL-3L@E>v>0*Vw;1EA9Z zD6Icm{Su_5EZsn5_KAG80PpjQ z^!K{9ey~ZX0D*O`R)2hRMqrB& z@IHxAy`Geg+@MC$@8fV9qZ$V8J5H4V$i)I-1uDE~3j6<)(3;i^d}0kLp1M?8QNvRc z6Zk+&6Z~)I0l0HO?71`ne*oY}V9qo(((h{n2t3#Yeo6s>eQ3Z}KOoQm_^t+#0BYD` z_AapTHBSZr_tD=3&-+5zsH6_n|NT*RBtWMWX4?Z?X?NiSl;Rpb0T6yud?5JP?H2lN zL5)B`a=l7tOV(bt*Uo)9Cz2b3lWdD_adK-D?F3Bvl}6s?HzMyo;(Qf}KK`UAU*Hpu zIAfKz0iF_n@750hf%^(y2P;Ab(R2=1MfSEvH*!rCeI{42CSkCB)vst`XGd1F>&6RO_#LO#ujor@kGYqQDh5GQ zNuq$Qe(+0qMbF3m;BFg+;wZ`7q{V#~1TMWeQJrc;8a#9xk;%deXgD&v*y=Q?o^F8{ zgj1>ESWT0W(z_8kK%h|J;uV2NHxzYTXJdC2%VuJw8_dyp?=?l}FE&$A#CFC@7*`5#$u z0hRY{zV3pY1OK(hvY#MlvJy*k0p-Kx|U4!W=f!ymu)6M9*xcQQU#mudd%S^&SDR z#o%!`A1i5n8Om2Y>ou`+g0-}6iKFYAS|6q5sEtSva|p&bT%`kZj=38L5@ z&udgbq@qQ}%V_O!%$L9MWJM{tctv9l9+p#4KL{XPZNyU1z}2L_lcYj{sEhHfXvZ4C z|0(3q#JMk;D-eQoh?p(S9W7GG%a?VRXyt}PWBPpZWy6RtQ=Q4vn<^HHI!&ukR+pqG z`FGTby_owZI(bhaGTK7~)s}-Z?)tAbA4Z3vU^CU~C zZ346o%VK#I6c9lW(fW)$!cMpmDh~qksmo_K3UkQ!@|rjTu6i8VZEg~&tmfKDjT|Y^ zx6n`-c%Dd*YxijnG7=jpiXO@om%EQe4QB#L>?P7Q%E}V`H%LX5A_iAK!k@E(SSzY8 z*ZCy$xKp?W9gkYRGiee(anLmg7-U^me?EELd2?R|`r#~laniH?h&DxH&a^~$%fMvES`|^A!G6yoYmAn1@ArRaxD9o zkbfZL#U?pI(dl+uMoRfM-*T8)*N!fgU1HO3v9T-!^CUMhPNl{ZPBgb*{G4+v6hA$E zO*{)-<~T)`#UPbYA&?+uNX>1*Kf|ca2UnjG+A{JbB zGVCUUY%+F52zoMS9ZgB1g^8(!myXXFkAn5T@Sh1JRu=H!Q2JdltyyP6UnIE&*x6qY zlC8hy=O`!U20Ry2J3BZbY^%Ay#3;{_?Aczk=m) zTfBQknfV8o{J}(sq8at?+3gYu_1wdgPDKkdnvbnh0~O`=EU;b(9XO$`NW!<|9BzZi zOyQ~{-dWL+oI@Yu%(B6(=eRPqE0X2Bqu+7KkI&r4*Jov2A9~26uFT2ds~~qBL7Srg ztSvJ&r$M8%!m2^oMzJQ6UOnUrlZNEeefGEFwoVAfS430(a)GkPKT}=d3YuzB0rn~$ zR4-oA=nh=h7@uJP&jm(U)K|Ay4=9t!ZwWGaKcIizaoermbt1g8Nw@mAsz5+Z!7^D| zN#x@(my-T=*MVafo|oHO&<+wEr8zTLM!55GqVw%M-rpr{fpOUIIA?Xgn*6ntK=9## znG9jBW!+caI#&Htq(opNQu+{`LJ!0_xT|pE`x>HU+Q@2z_@0imYByJ5DYyN7Wq!;QTm)PIQDr9?Cau) zvzLqVA#Q}T(p}Cn!KO0)Ww%c4r)}nkik_HA!qN{5KMwKJ(HgWLX08e2+p(2k>IQqcX%E$$D?=xxYLD(`K{G|+Dyx9FBbPk!L$GdZI3Q1L z?d9Xr_RlBI<=YGVmb+`Cp0bvIR0Hh6gkHrT+IqV5)9Xl5<0Y*G&RH`&g&0-28+vxvm1za|j(ebzN#aMo*R z=5xKwmPakg5)J#l2<~2xtB^{HRzFtE*gJ_;lnZ&nr?9~qp-slIQx}i0)ad79#QDoD z{+_h>?W!R&#Zl?3*2IEAs2{z_Ia=Ols=5MHqQF#-W^2|+nR{qz>}hzC>~kL|Kz1w& zM}?vP6g3L{Gei-({F6Jr;h$(ua9Y51=$p5w>=@!-IWWCV?71m5V{xm#PM zzjcDdw6s_!FQ+@0kYGDu*yEOZh0Hwx27I#mPUAr{c31unAjyBU*4a= z36z*EX2>(}7^;JY3!c)nAvWU+>G<21ExdN9+O6#u?D>S_y_2O`=8UY9a0S7SE{fpA zx!++ZYFvb4*!;hAcedacsEzPV8$E<^th7%us&f@XH&6Xq zvq1f63+=YWs}MP&v#9XUe@6jD?WijF+EMRmNE-YA9BdL57b3!Sa7g^O%i!TEqj zRe{>oAO-q8b})ByF))avsf>d?)>hlA!smu5)<^2`% zKtGlwR7X?fU1*%L_SPZ{V!m6p-yUX7!J0$PoDovuAI*me-h%7-c^zNw>;GJ6UVrP7 zN{>uON^*TH|J~2fJWnnN%S5M-O7_NNrlW)f(j<(_2qb7ma{ybX`G709j?`R%2GKu% z+uolcvBx5DP@*cTEXaB{rlJ6IoEf(+e^_XnqX>wfp?Qu-NDL*u5>s5!v^U{VW<2Cc z(x|l1nNS0*Cr~B$RfoK>N}b1G%Jj#E9U$mZE$tfzD} z)WihpncHs=AD>@e-yghyyqr+DD)^COqBIXtj;(0e_>gUn+RK#5^*h`FecE9$r2xYc zjxTjeNegztcU&vDzR_+Kr#g z?0Eu}YGtvxKF4;C!t3R-vpG@VL@V@Ok5Vs9jCWAocVk!-K<6 zmSLp^aJKB?V0o!WLT*o(Y;`5@8kSWroh<1$a-R@ln?4I@&WlZj+`Tbu_@^#KOc9=% zOElVXE2lIm{G{gKaELjwPQp|J=l*Kl{wHngjtj%MFEphY%lozy<3oRMC5529OMR$` zt9Z&3HG}IIX1`x%*WBUe{%V|y9Kc3(3V(6* zPXv8csY6G5w6AJae0+}BbEJ%h=$dbeA-vFuk8?YhUUEvpF&tgM2k~BjHcI$b8!0m! zESnJ`b)D7z=d_d|@sxMH<|0;4ugM1T&|k+^CB$~Tnu+*b9-Nhzg*g*e$6&uj66Q~8 z3^A?Hv1idP7AQX;=PLePab2(AyCTy_(@5=W z4fFMm)~pq4c=!nhLA-S#ws1w1Yi!{2ar6LH{B4_=nh9c^CBjx4JG#m>c)Mt6=C06v@T)jWpODSg(5MZobcaTn5{WEC)f9y2EhJt7 z!1vyS=T@L4wAB8U_F1C;{=|ao zwF`V!@H@Zdm~Hz4lH@G_A0MJXJR+u~%HqD4i+Iw~O;KmJOUd9c4 zwZ_%IB#ixP#|$5I##BaPN0+v6idqgo?$2k_e9D%)t zf=42LKCy%Yq~39|fw`*cJPF423MS9HKG)a~Al6=(Ja`J2T&FMj$)7?x>*yKsrzh3gAV_S2B_< ziF*sM);3X;4VE){vv+5yj(&roTeM0a^TDhI{$CGsTOMtYeBPd6i<-?g*Kb9AWS;sq zGP6<*>c-~Ple@QdlJ4=YNyS&MyD{btNp2A78!_o2p&UY}mOf}`+quWnmVq3>-yDb} z@5Kw=-y8WkW@pG}twW>Lf4o4EhG822R^X()q-{e}o8Q_-@8ruyM>N#2z?-6q2G^>l z)y1;d7#@gLjIl=?l_!9Xu9d$e0%c5-905_5bL+%6(+oNNqv7E2_nWN+4;En zGNG1+Q6Q&sc$B2j2U>+r8t1O7K_B^`9pz-G_tC>ly+T$LCK6k6inN0V`r5tQ+0d9J z;C}8xj$eXz_p1@e+6%Kqw`dY|{~TS7G)tj;Y&j$$I?AOuyYN$|*#12jbz_whMh!Xm zqH$ybI=Pb|6|~GA64%Ta8eRsvyOKjk4>5C{RQN{By3OE#5; zFn}+{TTg_WLv2VMjxMK+{@dK6T2U)I(uI0Q2N|LboPGbj>wum^!~2_*&+N|6y+y`Y zDPIh_?_DjTs7Z&Xy6v&7#_w1|q}!=vC4_h?Ii$yTx>FvstMMU$@L4nmPM!lm>?6(1 zD3%=sEB|Lj8pp2as0=OXoPgbB(!JG{YTAbj=SQJgZd!C`Joi_+r5y2)6Fr~rl2)Sdj z>M9X0KM87^JLrwxZ6PK;j>GQ{|LY})pDwb}w8BS5fc1p;p0<#$%vs@Fil&CRL+fg| z3rH29`qoZ0(dS#bY?)Gb=53@YWnEmnj|J!B?rD#)c{*sJx|)5>;kuKBzN<<=F(wRW z?TedY(_fq@B-S5Q(%;9QDVT&n0c{RlD6q1Q*!HL})lWnj%XPW)Y=CFpR)md-^b21I z8h0|CR}=cXdy{C6K-vH1R$?vQ;(SXkBl9>wBAXmU&E_lkivDrgUV=hE4M$)Hea46v zy7S9s9P&MgfM`hXx(Iq`|Q~t>g-XZ);2>7SxqB(J)Rmjs;sP41FFB_L-pp##7_@W%|3D_ z$dQn;Qy8#9s6RI~$KS#2ge}j@pvRanyk|XKC;llUcAz=lNP!2ueklI~Ncr!C;LOj5 zX7yC4^pveEX74H!?w6VyCR|@-o2c|C@ybh|RajN5L6U_g2k`Zx6NlUw?OT-TsOMC> zP_U8^UQHtgFgY+m4wyaQ(5pMhW)pr9UJEZ0h@1(xWV&kcj8d!DtNPlarMrF_qr22} zL@RuZ$s6Bken_u?3f#%H?scyzwDOau(=gm7E5@&Lrx_FB$RgJSej*d1FO#P&zq0vl9`uV;?@G7GnH|-lv)e&lq1HuRg za0lIk!wm z_i)q^aVlC5TIptynqG4bV&X*jGtHuLmwgd2$$JUTemRB@^Vlc+oxCR(Kp?rHqCZ@C zMX$DZCUHzZ4;m*q<3ErraWsM#uKscb6_Qi#j{!XMXbP5ooz_2kL5kGPo612+*Xic^ zJzRWdt-3*uf%qbc+5qTNfD-xtfLP6u{h9t<`-4aqvZpLwaJPvk+`DNIa8%>{3pX5O z#oCS&(%<{SxQfIgd+EI^bdWos!n$3wYLTqOBtFlGGvn#MNXvCvCT)S8ehbUy8+t87 zyZpBQAJRg*_ld>hDEuFek~;q;Q}_=S`-WaR(Jsr*|2NWl?HxnG0M!2nNc|6w{Y0xK zR2{R|6sAIXGxLeXjWQ&wd42hHdi@RRqMNImGW{npxp|)c1NU0#w_x7Wdz1Lr1pxqa z2|cSFCp@D;9-L2C9;Cu!?p#_|_T2Rg1*`OPOqBKA^M-XFEXH4-M|{k%pmKUx93)GG zj3~1w8Zmx+a*~IY>b24`D*!+k50r7vA03sdcqL0@f2v;%3q(S>4aT|tPp~vPM&otM z*9#8br)1(sXgON91Cyp?BL8SFsC|%4j*&QHbcFz$!=*62IW2t;pxQSWXH0OYTgm@6 ze}AceNFKN_z!wjY<1O`yRy$LaCimo@=83frAyp2xAsJ|TwlRU4i0*czdh%=TQ)tzy z{>T)#Eo@_J-j|}iFhf#D*+S(EWHyVEsZ6WAg*Q6hi#AKEKAz9h?6LZ;4j^NoROlYN zsmwZwm5Oe*Dkk1S_1@o%4Q)|1m2A1*g>K+e1C>*`?yzU0y{_}oQS{(Q5yMrG@$Wuy z4q?}{Brm+w`N+rmy^(>`C_K8cG~w=leS=PIqo&>G{bVc;Sy`xQ>FQNXX9hB-8zXig zQzni9Eca0L>Rbt3avWDs*SU3E^A=LingRus?1fm&HTlM*>Y@6Z`e+7^y zo3!^rny2V|DLU$H^q>NZAx~Gq^QguXs=+R;3FwwaeBdH}Vq>T+SVN{QK}OFEcZBzC zYpllpre74dlYBj*{^`ZLW|BZWG&*4G)rEwI67*-GIpZxL;BAYTuIWhbD(~k}oIt$G z)?g?nsU)ru6RR10y|Dj<@cC4{RN@vom zSPHNY#Y1b)yT8a^52%h^86zN)OH#3;n!&H;=%C_BSHyH^jR--woK-zu|6rW25x!$? zw8(}_^fpGf&b`b+oALTcQp*lB`o}0OGU4GY7OZ@?pkaSnSsh$Mb1lLV)u}K8xov}zf!ngzdTiN}j!dN@|tGfB}8=rLyB{1Iqd(GMW0t;V1n{*CSXY`0OOUNDQ$d zcF1TpQA7HfTg<=#n)wfO(V}zjQd9*&!+a-n2yW!?kXs>P{A~tVW0=E8g+4tdwhXk) ztKiswZ6e;)+xbrSC*36_0$~9|d5ZJFX&PkZUXAxJHqGfWnOvpqDcJnO$8S_K>hwAT zl*kX_VRqC%N?`xY>_A8Uju!s))CBUWOw<;Z_-Q$D1o0q^PrX0{B2Mc-yV!pMZgZp= zhD^;;V0V;wZ9Yq)QRP0T_xlk^n`)*QuqF#h++NVN$H~&pW+QChr`58oh!lJ}St4tY z){rn*$XPQWnA&IJI){Tg3|fDE+xA%4d2%^JxQDTYieFZkcS^%p<%!qg5)fb89zF~3 zXsWbK9&>P(N#|J14oR+YG&u+~0W@mvpZFB#KSyXSXz8V>m zzCF3AmJ0_EQgW1Mfdz)lDdE4i%jCKY8*Dpi@3kDXdc|IAnnya+qUs0Qv_sufjy)=x z{Y#IF9uKVequnEf3_MIr@rUbNf3FECzTUMs&6#iLL5+@+Z_QxqvU;##m4(C07T9x> z+Tm?>{CBgK?OUV~=tN+6@cC1B<(Y~=_ctzW59?F*ptZJq?K9(_sx_z#$@JW(sfoUW zAk#mmZe+h_FP=iwpwu~FQzI#XPtqm+4Yo9Y8QkzTP}3fuqXrK{OpPae2ls?D6SWxT~<4S5@51 zsh#%v96Dv&C~rd0_o)j!_UJ$Kf|EIbT!kercd~fnqCy|$3Az<@G;Uu|?{^#CadEsi z0crTcR_qPE)#swt^T8Gmi#bDMoV`)E6fLw**WkpEoNmg@KU>OvRGU`jg&unR+Le*} zBp_dT2VZLm$i(t=wbFKL3()#o7^C-Z|Epi0KEY8~U~=WB-tBQi-OP(a@(+cdFH#D= zDq_e>OJ?3)2^3A~Ap-Ic*aB2M&6fApbN`<4^*7~Ov1`ZbizF}uV2}X||C2q!ZPJT> z2I8jyag?ok-hdYe*LwMgbx(2b@cAuo{*=OY&fyDgp<-B#&=l)F`x+!w2^?VT?@Ae{ zJ%}LZJotjRM>-MgA`D!3Ij4i+Gz`_YSuJDqO+PCY~f%Lw--tfNC9~tN5_uPr- ziowK9b1FhDy1GOsrri9vw@~4njC6TYu3`?J*rUw6g3#*3T!iO;J#v?23 z%T!P3VkL1@LrG9=BB=b}C?hpg)}T6*anyeR(068puh%wA}z2^F|F1LDRk`u!pxmPSVOVBW_oQiNSTeUo!Q>L^xSu34OayK z5!P+WTKwQHhx((M__9P)&? zDvR+p+8{XFIQ(qQcW+Wl+~^-{!@cS+gCYHpBm{=;sA~UrDMV~EQA#ynYziiUwHP`> zlkkEwGj#he*F{8y1uB05Wz6)#g%Ng^0*W5yK;2!}7C68 zvrprPMOGV{Vzr(dN^xvWPSwZ_PnC)vbQ??u3y*fB-3bt3|1w54W+L|FR%0myl1_C(+Zucy_f}*(xm$r?PADW zn&TeHB5MOe%Ki8|QD&#G;B%&3A_@0~;aI5NIMlcueaCc@M}oQU=@{Ndj))CIa8b}E zZyq{(Jrv)D>##X~ie51KkKQIzNMyjNC{+3_4A)-|l1r*5Cf0J}DGRu#Ws#N(6@@LL zW|OLDCg{U#*`!ItSKJ;6=5AES3EMExHb~jxTMKq_YgG%{%JiLv6l|o6s9#6Kvl=rZ z4fGFql<{-JExp+^NtAUFzfN!3o$uGDk)`HLKeK2T6XJ4XvGy+CKf%$>-i4JXdBDN+Mw&w?S4KphZ59I^>k)ZQn6+vp5LSAw?&1kqdq zMejuGijv(?6FW)eG=q*;IY_(BGy{LmEn~GJZQg6qZ{&7niv0OV<6sztFi}$pHbUQ(0dAEeL{6Oq@NnnFAA;>`ZGJS4WK73XRw3q92 zOepoP2iBEKfTWo!{sfl;o!`+m@4>7_5)tE*obkOIJJlN++ z6WMx<>dVASJ$0t`%EK>QS4ox&EV$HWwmwRt?rz6XbaJyi*qj|PM_)w|suFH(z>cb7l%dJE^5)}Mq}EX)L8{mM z%d0tzD&Jl*Ld)-NqsUox>SpUZPAfzo42_r%%5d)YHs02-D22O8r?mK(J=C;$@KP{a zPBITzutyz#N_6WH#rr2qSDtq<%zr>v=_Kh&mOYWg!&h~kZz?z91?hZ<@=j9sI1!Wbt`_xdc)(YDbSZ7cLd`AsIM}tGP5yi2iw5MsIo{qYd+I?4cJ!TaO zLt)&SikoK1@g-0&&K6xwMgc{wDv77jIyH`&UOmln#z+!jC)9*~W$bwt)1_Y@-WT>h z-!C%&6p0B2z;OPbA0z%=Ekq4vYCmC{4DKh$MC6y0Js2>l(3}?;chOTnTIrlbGzF z=2x3g;sWm6U{rfR9!sc}X9KTZU{`sAoJcBF<^7($A*}F)Iucf>DFohoL0jSox5v*? z(DFWaLA1u=ZNGbtnESf+uQ37u&}p&$+ZxP*fTGHFtMH{iEB_t)e+CoT_5l5+K!8kY zH<1E9V+#P;lYm#ueqS~e!0q3_k4*(&`vmZ{IS=TN1iZHp_&T6?Nwy&uHkoIBF;0EF zb%v!`-t7ajLJ%phq`do|`ElvC2$)~^u1i`U_?m&M;xLO+@0*`9C@w>}vk_l8)5QNxbDvzMDv=LQ^#`R&-W;h(7b&oPj2{ZsUipp2*v_UnKhy^&<&5dzE#{V0wgR#m`~v?u4xmGG zN%XvKHO(bVlB8%m(Y_yVB zWMVNi+Nq=KZ!ed|Uue!xdq|pkkEm$G;UBDXfFnsih(H`Ck5AFihP^*)?Up;#)gni- zDbveNlLcrq)pVE7b|&?q4X!59%-lRIe!d`$iM1h@+(_gTm}p0}&sub9GG)l1v``|` zjVtWr`_&jh)HTq5Bs7-rrk2SRVIdSnM zX=koOL2$HJw8}on+6G~rfBgmyG{r-; z>CL<(uizL_T8TO7k?2+;;>RVajhX)+4xt%3*DgOHKX+Ccjbasjw1~2jaq3@T`p+b6 z4;+&A`moMqQq)P1@)8S1I+GfEyts7snv~UEJt6-! z9{828#s`DW>CLjzmZL!lx{22ABy3x8A?9+_g06^RjKbE4kRAf8Prukyh28b}MtPcs zLPBiYv};z+UFMnLaJ>=bWMK)+i{8b&n)IhBWC3g4paxte=@?>a1INV+Q4WJl9F#u| z`$z+}Y0NZeU6^j_-*uQO%SQuk!fL8hEW-a%gm+3_2E9)1=KQ!ecJEqE-6nLjxr(?$ zmmLPgzc!`%y< zSJ$=L!*%Vk!YEa(0;Q0=FBJ!ew6l_>oHkS+mYyT^W->`Jex4la>hvhmBo_O+4UK0| zCDTJEQZwUu)r0Nv7kDriPY|Q&uk#%niOh^zQoc(`d>JX_1!^=3m&)sF{xt)F z$VCyn{bzplD*T(Z#|j1F zyaPoNZoi+>!hp-0g=DU930^f45AeULI9w^XBfqrjP3TFoE?d$~=B7UDc zH~+O1&UC&fF3#_^|M*(tfGt5B;R^`Tekzc7z`vR4?DPobmMU+Wz(?-?S?TPU0`EHm z4fZx7%6ZG}eqYxAvj)D2kAPN?c4#Lw1fN%X>+Lk1KTX+ppz^*_Ro?#Bjp8oQ&6}O> zyY^=cJTXE90+$7VA9z4uxgYR_2MGLD2flFweiZ427x)$i+)CX0RewrS zYB@+2xn?Au;1yA4{xc33E}luXmF7ASZT2_ad?5C*8cEn4jR45G5u*D|C}g;(a#G=A zv{^&Yd!$f7V|Dmi@rMpHO!s$F;N+mgShA0T&eRd4QB-aswJL}3G#9LyZ?f^w#tj-@ zi&?FURf&xai@p}V7NCwg33tF_IzL4m?2#7^?hZN=33R7hsbTewokq@1m?XVuH^639 zMueI=YZkc>c5Gd*kRTxyg~!%ADlaDLo1NMgVvNT^Iyc=u!Zn2J3Y11 zugMmA=H{Yme!dy`Sn4U5siE8Rj=E|Mn)qoG=z zASsI+9rn&&%YO+AJtX=}m%7tWm{R+jRW>~4RLCwcJ~Z>mBt<7lMD9I6Pactz(*pcu`d8J9!{P1&U^=1~`pHCKkN zLv-R}!)&u|zXy+=3VdHXHYXIC1P)fT@7NcwCY7}SF_J(pB93DU^(yXyY~oLHjsbVQf?HV8i5q_-Ej47I6Q-Tch-=fwhO;=T2?bK7cDEt#%Iw}}%jcxhG zPw#oq-pM_eGUm^Ws4`pej3h0V_MfRNA%TKux^p&L@&SJD5z1r*vKafday4lgjqTCY zka_Ni2e)ERT*&ccTk^ss5U1o+i~?7h;ZB(yT`sh6IpR^p{%(aVv^v%jfLWGs{GW z@iBT1*4U=`h8Lu;zGW`aGf;03o8g6Yp@b)$sjUyq3WbVC{<7t_Je9x0J_-qfTRWF` zb(2XMeI)OpHGfyDR-44TJHx%E!zCx8RcFk`+5*O`_*wtGMKKHJxuweu5D8PjAC*F( z-!Hs$j*ONHxVST@KLo*p3C49LVsdTvf90%IH)z|x<(5$N=c_Dl5!4_dy_Civ;uHVe z7B6V~;U(r8-2IVnKgNGh$PcPLPGuSqwD_CqjvVYOj{ff!tJ2s{@o$Dxn*f+_022`a z0+|$mBW$!V>353nlLiD1UT3y5B6TXf3UUOCS5G=x2kLj~2Xh|*mQ7z&LY56IGvXvO z2QnG9-qN#zEPn!~D+G68?KeZiXKvDH61J&&(f3;rP_j0?SIll~L`1)nLKk4C*_p8a z=g736(N*L8za3-~ZxWZEvT_688M0{dENI^o(|bCqq|Tve-V+pbz=bCqq|wryjTZQC|) z?eCoP^8ayPZruH}?}&_nbS59e|5Zc`qes~)FlJ+PnIAUE+d@o6|;0+c%a(X;ikGrj7#lvKnbSs*9I~X?@t2gF`51O`j*T)E*>uZdStd#7Y}m_eW<1-1IUIzh1tME% zPHT4VEr~$(L-l^7_y&oOzZtY{h5GLnJH&{UQ+5bHZ#E1T9z-xH-3F7X#{SkA#XY91 zv9yf;Cad{@7g!g@3uAQRDqo?>K3~q?d5{@@_~ix&?(l7I;;rdu^U|3b4i+xGMEE%X zcd!Kg0Q?`+tq+E1UxK?wn(SnIUaj%BJ_?OjCojz|B1E5ol0*ix zIYykywwJZylawXZY4h;wff%WHhrk^N@rec+$4+Ik{6O~mffK2SbJonH>?-aB7^@yW zh0~L=$}3kKXcY`D9rU)VKRmGGR60=Wz0DOqnR`C;OcijhrTbPzE%b$|0o3CBGZ4VL z1ZIEC?)~ifY`uxO{&~FDq)gAi?_Xv*09&I&HkT9nVj>NaTxJH$Rm^c=pJDFruIngM zlTo%WIiKuK#(z?XQ|7oZK6Ytgsm%3Tl&XEQZS#FEK3y2ee+C~tR_zRO$jtUi@(>Vh zmd6n0p^9b5;#LkpPL;8_@9%@Oz&ZSVxG&pEunp>d1%`Cvh8o4~@^0uw2YxbkE~aIL zBEld&gVXG*sRj6+QHe4Kcg>g)*SiqcF`FGDKL*f?%11W{o1IsC=1q&5T#6c*%uJJ> z=3CT$la2u38Z%gB7r=#Zc4%WLyvKROg<8w*6l0K-{qdh*DAk@VwFg2UP<2EGV-k~JyPqxP=te{)PeLJpHu0-R_MTyoY{1VMd z*QmLBT%S6R8kcLceDWkdWkWl3iGrYcnb+qd9VbJ)#8@dU2yuUxRg8+>k#2O|8+X5S zl8c}yAinSMqMMq)+C>6qv#8UDnzrR0(`xYRlNH9Z=RlRw;&6{jqJ$t{;JYp^0~iSa zz~6Nd?+B0>B@1M)f4BLpZT%j41@LQ;8}{wN_7cb9&qUFIg~|2fBl)IgPkYbTE}+># zMXS-N{-Jd3xtw><_F@;J3I0X?N|HnpD36@(I~k6Eq;WsBy8T1%w&JQeB2#lfaLPe& zeiO;A#JA$##BjyGIxWwMHNgL2jmz=){{K;%&Ii1fzT-Kzq1c2hqJ_J%nf(_I7Fy|6nvaRaAJ=;p!l zz|wa|GQT#sp*|WR1ZfsRc#=^A77>QZ@eUo=+Gn@88k?|81U1xQgBQ}MRzUT{#)6~k zFhf9dpmAsV2E9qEC^dVlpq}A@@WGnLg%+lg9l~z%r^>q%SVcHb^dTCW>K8Br$rbIa z62AMkA0+WnZu44mkI6695~+G(ecAxSC0#d)DBTpi4(cV=rdlYO(p>#P;c`NH{c@AE%6WX~Kz-Orm=0|!4e>w>>U zUGjhV`$7W%GyhW;K;ZU2itP(;vw!KDxP=Y?Khso|2m>uDg{T6X`GYrK9U;dC+02Ks zs!DJ`p#vL46d3!tLE@TXv$#~Jq+1j8?w}PhhIR~%`-_>pGgm$f%dP0>xr2x0cML*n zSoof4`bNlU)8$q^3+yPMOCy|% z@t?)ohMTH!oKk#cnU!&T|HA<(cqy*U2*W*JS^Qrr(52}JpKK8Cw-B#2%2LbTH#K}m zF?)6`~&SX))!g&{{ZrI&}1CcuutJ56LdNS;r3#=s>c zN?Op~I)Qc_)r`V0W#&;;T*Y-X_PjoCs`b}740h(MSkn>^cez(8ZN{XXZoEX;>uyS< z?%U=0f*r+|xRobCl5$sm=cOT9$fzsx#JU!p|2!t=x+|4lxUZe=MdU5@M{Sp|cN6O$G(#{gv| zOS#3bQ`b?0=2%5nhj>-x3CgftsC^*9Z`gq-lQov4c4u4hh{R!c#U_1>kJf%Wk@%ft zXs8koh!bXrV@|_%yy;-5Xu*G@tRmZg=PRsJ{2cDI9aa?oG-9d10B;rBhkS-+Tnbsm zf47g$^Sl-4yb`ekG`v#Vj+ifPW>^?_?{E5cSnNSFX7?MbGjY;O3m{Q^#@SRnV_Cvu z!+jFFPao&hwQX#-l=4Va#dU`_pggQF>;%8yH1!Q4moKy&AojmO{C2Sty$5!M@0p!s zv=Jjv(WR&WiK24#B-I@JEyA$_e>144%5)6G9(mVu&3ynz{UF1tUS29pWb1lUd#hk~ zi?ds5JPmAvO)%P4RZtm(>b`D@0TS5;kiH0R5J4hGMNu*|+~*vWp|Z!-)hFebTT&%K zck_YgC~cQxmB}&BWao#L;V3gdZ7rnF-{8tf@cG6Nai%|LC?-EmAnG?XX930G|Mrme zUy1rZ5E{W$&X~9H>wpgu0}fYk2dgSgam z#n`;N%w6ino6%y+SG0}}dO*v(>eJ0V(MJ7;?z&R(1YXEBFTquD-J%Vs_Np1rg|(da z*Z%>}^*;r?uziG|zZ|9_pH=_wftT~20^YwP^G5zt!25S(-nVVQ|Eqcbhcyod<39zw ze?#WM@c*ZP_iw0qFlPTL;Qc!?FXlf5ynjdLwfv`m_kTO{Bz%2gwgHT+|3#whw~B|u zs$Pha24xKJjCpTqDRg>yLd@VuX@=i`O>4z?AADjLxa-|iH+#>$lddI)niMPrl-b<# z6Ki!Y$5nHHFGqRco*(dA^XoW;B6vC%rtN6L&70x^=qQ}AmfIm~oskUAu{U$}@I9B? zaj*1z{?&pA7Uibx7GVc$0PF-*3s!Boy)J!C7C|QHvZ$!23i9knhd=vhwEf6=6W40~X0pB;ac5w|u zw^t}y&L>GSjc@niS?RWb>06~7g8#V=tA@R(AJpgPFAK|DjsZQ*GpCCUP9U$dy3576 zKiV0^?M&J&3v!G^fCr4N7@u_RGo!NjAl!8%Xd7}QL- zb}gB+CV{l0{np>A@+rODbUV&M&|vHp*SYAK*fg9bov1L+4g3KIj%f44Dm43(!8V*Q zq{3ARV?bM#0=$1KU<;=k{;+WX2?PK+e?IS_dR>sb&+oe#7~eqd9XjU;R$nN5P6GQx z4>U735&tkeWS!kjeYKHUYnt#h5BDM-0^wrXR=IfUxsPp=Frj2;Fw$OR8=26)DvW4xq7{xu=V}{o`oZdwpeODgDt^%}%OH z;up^}BZyLx8F22~4Z$`|h$_^7;OsMGD|p;=<}amP*XTL{VJP+zLQ&m$+U9^3^fxD@ z&l}3P0BiS=zT5?+8=MKY=Ut!ZEFfchEe=TLA>f`e`Yxk8Tihy`lG>mUnx?jQO0nOb zTH9Kw0u$$$qPZ|Kaw$G)hj#GRyF@S>b|5CVZC1)kL=Tg2phU;`rdO`|Ds$nNH$JoiuI{O z5@c6GnI_Ur2x6;6i{4#UOu1HIKv5svgNT;O^i(e;;_ap6vz z0-4nly~@BuWT`(r!IQ(EEK(o@vUPeKk|Xf-No75;<>R`dM8+b8GhMn=dHb&!I#HNq z3=k4lFqv1iUB(WZ8;E#G4#fVospPK;N3j}*P(1ivIl`;$Rd>^;>AMTeRs_O98RIbq zJ_io}#5{u?a!yA7`aX)lM18V&sF9Wd`}x|m?pM9GT0POHgIT?!JMkZVD7Qt z$ZP*BSN;VShpPI``=6{&U2-(!BGXh;S+0bCvEu{B5RKeZUVn^jYHSdLkj1Od_Nicw z5W~bignq?wL+0N_678WQM`UfLo&6w35iX`C`B_4{;_2nkrqI=kH|TC68@=n(E z+LxVbbw+V^D-OA4F7W0cru_^_hQrpCay(gCvrQ)@d&(iNWjIZd|ZT0 zI}gquTcq=y87canCrNLFaMnu0LG-$+FePzV(o1!%>8(^~Rt9=U2(W3_Mi|Gizm_6Cx+i-OAC z3>m&Fd1!LN)s;vN1jwxfVIS;IIYP0C`)mN^V|@xSeVavM?7PM`?gmWO8YvsbK~qQ<>)=TWo-9wPc|7rSbv_PxON-mad-e|D_Yl;i5 zkA7Kp`KVrJOrZa(VeI}#_`nH;8TQUVmntX&LDJCKZp0$VhVV{qEwzRb;5mMSyX)_RJT+aGfX4U5e5{7j(HNxlR{Z^O`WJygwx z!`kT^5VeLSQ}P=J5Pr+L0uNr^IG}nwud6gA5v1189!2=A_(CCW$prU7xE@(?vwRF= zk+4lX*0gAP6~EZL5i3dxE11B3JWa=MwpOM?N-q&q4}tci z_~ZfET#I2|1NN6#RvDWl(jPDwjZJ8Yi+kv{`M$PM@;$Pr^b*?2hKZpex6~<5a5ie0 zr%ZTV8e;aNjVnI94tSM~Q;<1yjNOd8ViQKLdZa6`4==+#kn@_PIWwvrMJ&mQQpM-| zdyBg^&xh~_mWx>tr`jTf0^1qQ0Sb{+gHhCh5kxMjv1j%6^|Qc=!<{VeGWUE!MJH_d zKJuUnK#ZPo79$kub&H-$8BEzJ^J$aB555f z?7Us(hu^vZAt!KYI2Ht{G!;y=>%MH!oUzkVR-_1~bLyQ8p}Ul^@2=B2m=P3FIEdEr zI$8@_aId)bhRdL8g{RgoaCX@b#9aGhfPH6PYj?HP<`V(w01;E08iS}dd==dDAc~nUV0DvnOeF^v}2(owFHTL%Q zf@Bxx^7`H->h^YDRp){f{0tBuP^jk+cV)W76`36(6DmL|o~t&P)rQTBfMtXH0vVCI zR*dz*y2vjlV%fxKGu&A`4s^K%A>0dKCBo_q8cF(VP1F}u;>pb zxpFWj@3uNEH0m-34lZt@{_rhFL}V)#n*CcXsG5wM!QXg=eV$f-C66iWxbMP^s0<**lQC8>iR24BV4;^xcM4{ZnZQJ~bNiJ5E(%`%hz;qY z6)i(kGXDaHm|l3Bj~~ViSUCWDY0}HBFsoh}2RzqSb&k_Nt&?kAc;^jy1*I&J{TQzh zm$j}R96(%<=eq@BC{=c7AT~jalFi|n2OH3ZU)Yh>+^8_ktR~8w8!Feq*LX;D9?@*y z1uBfYI>UheAcZe;g-Rb>@o4x~{v`4al}<&yW~nMMU-4=h0QaG#Ml&Qg;s)+VXabMW zo9=XH@Du`-K@yXM=(`o;6n#vI>&7jR(uQh28?NaC6bE7A<1G^NL;1fW?f875xFVgG%AMFB4`BdltZy zvK%QJM}G+Izbg@pW*nHor{pbv7R9W2Z~FPeOxc)jS4^)$>Ce~rc~w)s!6-e&l3^~j z6MNR3El;~+)_7TwB1SMtTPj5zRBOA5Dt^pe)qC_@Kp(cy((2RSUJq^#&*22KL<8lX zr1%Fz8V+6j#bR^y?z@X^*ZUHj-t^BpTeg=qAykGkmIdGN+8GI>b~GouP$66c;|5fz zzV%S;)ozcF5IZWSy;_~bpm*o`FL5sI)1~Yw}^Ri(r02%2kF_U%wggPh41HpRi7)>JN zXfo@g_<^|dZn!-vlz++e!(H6usO^B7W-sJ)f_}ajnlJ!U(B$Sq=*{9i5PstCvTFv` zW>?@$=li?735CGPUkvPhfcyb?Sfuq>Nt%DRp_%EeUU5A#(kJGwhZ-ixq9(KD>aFS& zQ^6u|Ta>h%ko&7YYI(LSseyg1x`VXLffRwM5QBFARuAW>JtBwG_Z2Pa9Xh%^6F&;I4-5uR^kJl=#u=SRm zwK5ZerVfuB7Z}@!Kh=r|EN+fD7-|d!81D*%AGuh-5K&5#JY7XoXMh!!>z?!nm85Zx zCE~9gok~%85{ygB(}ItdQlQdNh{Y5(Ib=P6wM{yndiHM_&LJIhVr-y{m6a`7+W1Ht zg)blD3gGZSquyForoidd?aEnNoJnZ#JL`TX6oD07++730fOVI6?qX2`#UOM^)#Yb6 zH_+ZJ zDjFVW;F`i%UR)1xRnOmT%KEn?Xw#0TZpD!^nARmMR zF|q3hVVgqRB70OT@`URG!LyZwuv~2w7ao05u_ROUdTFRv(6fjF5^teevyF%nNV~*` z+M3%eIw81d#t=vQpA?;mNum}}+8+e` zMhTnL+MReOX>g38)6)wAQ2FXeNuWy>m5AzR65myhCoAq0b zdUI$DhQ)d@?(aj)K3bbmz5a(h1~rh=)U9KoR~Y?g;XOw*rkziBt4&#*ey7}}dc)%$ zE6RkkhkCjG1QY){?+BROcD!3c7`?pr)}IkEm-`Wsi*&eIUed-Hd1#hTh0O^wcydWE z89XmcyObbGeO=O*&UlMLEbCJ@x8STo&o8CnUajJ{ znPa$}x%FVT5EzP5lFRNtv{y&ASV&YULw`EeNtYXjSO%)?Mk^{(i(6tT^jpAe-POtr zSSdI11!CqA6ye(+j;)uH1oH+987bNt(0?T8!M``=e%VJ&%97M%n5EZ@&eNFcN{4=q z?g|++X`R!h?RZeVd2U;6@0D)vlkwnCQBaX?IjGv@CCr`HYUHfLec{N`pA+$B466iD zC$7CUHt!q~bWjUnI@tx<6rK#{1=Zs^e}b!ssrvzh;o!Qd(w@3+UJS+XUz!HYe<8*H zIW&+>>vvQX2^_6#6KtPUz-wcZR~*u+-5asKPHBDA{CJc$;^iS2LKO@KpMR9HPw0tVM2 zb@fQiWGY=?4aVH9T#=$#2nouOs2|Sf@HiWej54{C4_wo&4#*YFvX47Qz$ls|PcimG z*Oc?{8qry6@@2i=kVA}hfZ2AC`1%o{KOf7B0JOe<5 zs}Z9Xf<-r=pY0bd)<@-0Cc&Ryd{(O<(M=osZ;P0V$9VWN7Hurp+VRR{=3t3fIAB>O zj9^sU2So9k5+k<|GAKGVDT=AOXL?dvFRdVgJ3G0lzcfXGUPS5~?D8Q=ew52s!feAB z{s`5Lo#5fa5*r-!^)C1lXUOxFv}YOdk&*Z!77Hlyk`@^Klb*T!S8jFOs~U4e{m`1Z z8O>D~F~zR-uTr6b)AoJdU#7C#N@|g<7X_BGaKn8*C>G)k0QHCJAHvYH5CIUTGM}Zj z5Q;dtAp|}sLH=cT`>~DfPBX0)KKtN`WZw5t??C0J|gZEgXbs zm++*&NIUSrQw{Hq2m0@-FgY=aoJ%PpyFUW$s4+b}zXtsJG*ZBT{jMX$T`df66or&g zypidSYF!)^}@a*z=Zb;dZwdgF@w zwMi?%c;^u2rYZ&fmZ7kl>E7oiigi7r!=|H{xZ~B2)+U>HJ=v0oEI*c zl`^Yu1|dcC)$4?}c|Usp5M*e@ch1)<_m%BJla~EE(}58r(*pz{U*eBI@*d z!HVIwJNpjxq!!gu;;S;T^V66EcIHZ$XB*V?H^ZN#%?=>L%cZy%j)1x?9Bs4{eU%WF zNC1w496yD`h64unZ3bw&rs_j87T1VSR~2Zl;|}mh7QCz0aBGz$(s1NYj!-&XwjR2v#4NsI!2b0em7AB_- z4SRTUPegJ3zKZalD-s^rLcGg2vI+4%apczw21(7H7p{R0$B-h13O`JU)}*_3kcH#U!6j>yGzoOb>?3(`EwrvjJp?a=m@^63??OIqycD%q^Uz@jp?w7J; z@5H`Ta~ke%R%z>t z>-T37pvO2Or`C!9e*KQ09EBmC$=gvq8C)*xX-cZApDj3e#A2B>O`XDmwo{DRz-`8C zQJ307W_B}THryz;YdgvLQ-m`!DkVmxSj#!W3o?*Jx83LA57rM{RBoDn(@Bv3eZ2Ja11OfI$yuYW`9n+sYi20o;!tm!F={|dMlTm|N z9>^r!GJV)tXE_{8Z9%u8uQz99G*Rn6sw%j=onU+B50$acOp} zTF|?#c${}QU1jf<{m$@lMoGP86qRYKOxSV`d=*YiZJ}#DGOj*qz%H4ZKic9E8c`!O z#D&Bu1%Y*Jb6`Nm0*0|l-F1Sn_Q_Yd^o3;r+{pYFkyC)~bLsa&{=x_K7WyueP>IYM)}Bj!FiU+ za6Jpz5#?1p_7LEiz#iN;10|BKK?#utZ!ow?4)SUc{?w6ptb-Ah74N7)QR49y&rp&jrn%d^NNia5X@oU&l^T` zek#fGRVaS_UTJfI7B-AzI;(Mo23pW`JZpv*&wrl=Q83L*oU`Bf8b^!vWBqi&pRfDl zK(av1My6{YfJ9N>04pL3Lb-pDili_#r%3Z6dTC_Fn$bfek7Zw&ztMe6WMO`28 zsBeNfv|?6wfy}WUw)G{R<1YQr56C~yDKV$qoY-%sTH4WjSxb2m?#ZW@a-#1$lQUo7 z*2DLmS7wx1{dH(MH6=qHJW}#bq7S=h=q3M}V--o}d%}Y79Zgh-iHCys8&24ZQU08D zQF^6PByh%N4o=lTaCmBN0ORGXf7Y7SFN}r@?wEbcd&?J0FV=a^_X?iS+VT*ok`egp6{`^D?>1VyErZ)j zs$wKQ?)I6)x7dRJ;E*bp;;lzH^1bI*Y_@g0jWb{u?b$zRW`>H7MD#p0Nfpnmnv1i( z#akt05IR+akqk}aa1Ap!+n4&bOxQQS!XZU3iA-@z}vt6!shHZ39f+i*Yg>+d|DzX*)mdE zws7jvkVUD&`8owgw1*3-5=GjsrOh7c6Zvp?6iZ%}uB2W=7%G7baslvR!k0_OC+ezt z;E<6Blfm*Yd3UlW2xK9ZWE`QAa0XZ)a6||j9y{7fWYu=mbEo_ZWN5nzUYjSLS4xU* zEx>WR`hm$^R6HpVqBHzBW>!)KU%s(x|FInrDHLNK5TGW{3fIbNObe_{qFt%Z+>WVD zWS{3ZR$6!q$KCV<2$NJxPgTf_d%^b0*+d190UOaYRmL35MH@2B(zR3F#Ex?d8#gXN z_S8(!qs<(uJ%}|Ax@alES5(-|d3LV+S6KGg&E$iy2W)8?RzRPpQBR5s22}{2=a_h? zdDBRR1T2gn;Po(N)HDKh)NoEeFc$5GtRrL8G1NJ*_-h6{Go4!3yq=8?-H_ELglg5T zHciYo7j`yCVm3Lr9=t0@fRdj+sg||j3_bZsq_HEVbdAdR&oZ0_35=CDGQm!R4hvrq z`r7Yl%<2gGm9}@!4-Muh6ulTQU6^RxMNHstKi+D(Z>L=%4vJ_orvOA^{e`4z&^|Rn3u}KsW+-PWtazpUZ!W)d! z@Km)FcLM}Ui30a_hS)g3Mj^rw*OVVyxtKQIErRiBt1f$7QN$Qb>Dm?0GZ57p6(Q<; z7)Zuo(WZ9}&2RXG05h}^f1#Qsz%gR>o@K?lKC1sOW2NcgMipN0j#chPu+2WHiqwXo?9DhD5%Y5tsM8Z@0cRG9xJz>W0}U}qcdTV- zPf?$OO_ zO&iM&$Dtx|x?8)6k-|L<8m0+XlCaN$MIeod=OQ8{|C(bHpv#ZEsV_+sJ=4Zit#NF+ z{Bjmt)|(5|YAtg(h7OwOTh+v{`=r1A)r@R&-r#>Q;?5D1f#-tfw&zPQCmbdody zQT8IF1DD!B^`k-J`B5&~A^xp*Pjd3e2dn2fEcW`W@C3h-dgK$vySYw>hp$$;h*0RI zI#K>^FE-?zm02n{$Rlvzya+*?THURukz5*CpXCBYcU=+kMEefo8(mF%tTR`eVHa{* zkA!y8a86Wcs$FvWvRw`fy0WK!Ov9=^#u(=cD~VoU&B+c4|1oEo{I0Z ze+sj=b}YcP{%KE`o2Rb`qUt1A6^ilzB|q)G-3Ie2zyMt~0LS%l_xH-_L{8=zo)o-@ zMssTN*Ta3u8oiy1>cT*IKTf9Z^r5&SvWqN_=MH@2R4S8EdQ2KppaXYL6(O6t;A`6{ zh2t8_8s=WJNpd!YjW47pv7g>*G7edEj};P{d;E{a;t7>VDwTV0XCdnO)zRBf#$Qls zW>5zktSXfB+yg3>+9CP%m!^>Wbb(oG(UZ!CLpR(%Vu{c)U$(D@ZHzU2Tvha9O$kVh z8x9?vq^rc;M@hRVg2qLm!jqRr_-i7B7eCA5Cvpw<9B3jr)hS=N@{R-~BGYe4UCR;MAZjlUf@?DNWumK@M zqvq$eyv?n7XMS%JYGdpfi_YmsmPCaDt1+ma9aBrFzfFJT-a99vYn~aYJ{i|$?s5J7 zlZ?lmbTlf;2i(fkI2|Dyn`~qwkfNJ|>8t!m7{)kxA`lf6W0M$^Fl?JyVk{#rd3Wfi zL^V1(GcO3n^y#U-)$6lA*34!T{HQ2EP2H}f`J@N^RsMP z8Sa>ecv9H`Wtjn3m*GKWyF_#p8JP387W1oawp_xCl);J%V|HT>6$QHD&gon}z%%fg zkkPu7klF%i5Qo7mb9k;Tr?!g;A4YT{HALaZP@Ae+zmBIoQVdw-uIBwNd zHM;h()SJnK9UwoktFwO#MqrBROI7$#H{l6>Y&WtP?sVS|ht_?~KKavvkc;c<3R|%u z5Z3U=gC032y?areWjS1R zkq=}PT;b zhg`XUe_&rp`V#7*3RKjyxZ!a^g+w{6KEdPqquZ2^% z=SZ^Uni6SkVr>xBe`a$UuTcS2R{X|4hZ|0`H! z;t%L>ev6Jo3rD zC;w(**@u~Y+g#;KJrK+3aWCa1aVonAgPD@C3m=%ZeZ&gsmyrTnpBSxZla-YST%h*s zZJeOKnayQ|Az0ifFq*a1V@x3|6&3^e(?XciQaCI%W0>2UCfNfR)#EZs(gAE=#WsNi zx+;ng9KFmk#p~XzuOCf?GuFW(?qGzRzGvb+r^ytTJiELVmGh32ToLd9h*}^$y$@ps z2MCZQDPwNnN;_IJYaT)2^4&@KtHmg%i!-#meh_68sO7!PT_ERte`9=<R5Hf}rmpa^yLK;;^NNbyivI_TCa;F7I>X)|}m<%{{nwBqNgVGm;pEfzWhDu%%t zF+U{zbU(8;2W;RE9SCsu;%UaY(_n%LLD!IZJjer%A0cJFP=sJIJE<%5zT5S` z4Urh-+et(PZq*0FqYm7Q*S#zmVp)B(Hg_*;Vef;NT~E`-wAdSs6iBUpU=?8#^@J-MRZ6t*M;%nr=shxG9h20aAO7BxYvL z(2zD@_hl-t{7)+gASWJ!3VaL~3WXbWlAkHI=}tDo6K;1*C@#VAWE4-L z5*VfB?Kwnf=>FE>_`A!_fZpA}wUq2`z$51@1uX0- z{dLMw3?`_7UFo|4{OMgDB2m5a;2H#PS^vRi=Kr{`Dkezu*Nk;37VI>5v-j-aJG!5;Wx^uP4l?B`boa@Ug8f3$l-8| znO=cj<L-}=h4Zdagsr~Pj~YyH4^cj>-!XzKzJh0g!$wB9~ENR z*NVOfMG;Mqx;JgD^UjPAa8PbdR0_JGSQEz_oXj2#G=x!3j?e-=P{_^$wERZwDxlvOFyeY7z5!>o3A zy$=3(ycPspubO>9|D}VK-R~sz3h=Vl!SN<;-Po+hDGo6qjSM>!=icMiQ^yxgd}B68 z-{x_4^-q)*3-O6l;iehlhNt_plr0tJG0)UwHq$BJ2v&jX{nozf261_N)he{0Cc*y6 z;tk^RNWpZf z5VX1tiYRKQ*2=oraUKyAl@HkDaOIKZ!M9hH!Bpr9N~pC>O+5VP0a+};^9tCB_vq+g zGon#x`*`&}ju)OG$?nNpal(R><=UYM7otZbI#8ObJqhJHR~zkcmDX&?C0I!g0pL$& z{(5r3$Tf`K)!j6`C{pVW_whGcP}9npW}NoP(8H2CnuM%p>$}|is;>4s()-TCtyVDx zqahv*24VZ(X9Zked-`(T%+WaSLqz)~S$MM5k|KwUrv0ICVUPRG5%SCeDg92(q@Ac4 z3BMfnwRTrFs6ITR@YWYn#k#`KV{G8_j-yirutqzpZQS(KAGknwd8XA)38^G7S9>p$ zinG@atTGtzL&2uO$gBuDx@ATO-Iur#v;laE(560EY)vRI;qJ1Lj3dR7WZQ*P6F(~( z42EFDr6oXCw=7mq$(Byy<^D<=H=+K%VXj8^jm?Zq%LTJ0IONb8N1fnCq{s(BzZ6?( zwn7loFO7kVbsy{S=Gu9|uXGHEGz2qAJWi6iIvyH~E$Eda*nW(n$ZaE!?^{+Jp?`bh ziH5t5?<%}LPUq1Kz$p?;ELfnoW+Vd{Xa&VK6*qw9-D7URilCOMe*dv29kRQtAp21Y0tMP zMB#kJ;dfv892rx2Bd{MX&Cpy}C@*Ac{m;dS);b^#4ktN1VX~hd8_I#SyF#5jI*J}L$B|m8`3gt0_2hubw9twVw05_kI--_b z>xVO6)ZsbPdLaH@-VXuqz>z!$*cPqwnIe~VDSJ}LsbUfg3B{ueo(+s4Zok`n;UNS+ z)SvnfDRYe7>G`5e*_QUg-d{a^sTrBu!Ac`56CMLwkSJc<2F4)Z(w~EZ+a>XC8Wm8c zba%cAqY@?csk^Oj0DEnR#`v%;^jT*z4hncIMZW5wkGh5OvI~qP(N?;OMzB)KsOpN{ z1Xl4QbIR!d;r`oN<^&!Gs}b3VcZUB{CfCD`D?Nj}wPS3zD4_*la+V_h2i2P{<3UD& znIir&DA4U%)Y1~0(c@5pZvzeh#kM2yMQ#xvJ))8J`%dAt0F3&R%Z@F9{_8haBJkU@ z>BV4rgAJ(L2o?6g>7Rh#oOk4C5IvMhNy+Muf&G8(y|udo)qjr@h8tnNlXu4y1K0_d z8{!IAr@S7NtK^WVQ@g7nwZs{BBQUwr2_Lpt1@Sdwm6)1a73++2CVuxcR?@`?%dLSi z%K&5fl$<>Wi~#cGRefRA05d%QMR>Cq!cGZ%T?IhAed_TWFY$gJg-`ACt^bVs9I`E) z>-gR6LGk<3`w53z9pIqItD8GdlbEbPQHjt{aj0*mDq-lD>M8~N>`-{@ri}@n>MDPg zX1v33+r|XPbe+G&GS+8z;An(p{5IA(dv=)W(m-7(htx6F*8Y8)M9G1jmY);W1I5z> z)7`LGDL+~TD9yi4=e+4u`5kyx|LM6-qA3;m*$-5HKBKylS6wfx)237?W$E)~$N&%^ za#Tw1W!SCWBqmzSeZ~Sb%DdrY$rSVFnlyHC&36kNq>Ns@MIptkTNlpHQ z=SAe@yV9~~Ndu``a~l>Xic%4xrIoN#;-D^SCbJ!nz%1&O(^Qd*)}Is#Q14=ACuLUv zc|62vnKWQHUsulh@Z2)17HCuEp5j;x zbS#s({2d)VN|f(E$wh-*KI>m&T`d9MC+%>vFfV!`+{JKK$MQyK8`+K%gMgT>cX~?H zlFI4;(MiD#etMsC{y%qM;C(pZRT@}cO}aI0I`G`39t0v0jvnBF@^$a)0Z?(Os5ZS- zVt*?XiV2k}jtBUtg#-PJ=23Wx?s^0D;f!flqEqYiRGtU~!L!VBy(* zTk4idNFSmuw`m!Iul-PVj<(_`5a`c6Kf1VRfIk^eEZ|O3^b~4I@($v9za0ZG zPusR_+qP}nwr$(CZ5yX;d(NG^=6$`Nq_UD#Nj|G;?_J0j#!*5^r+&pLAU2n}%QsvY zslG*)&``LB$hUui*rj#TX;oN`-@+rXXGQ(Cm=l>g_G~gVnR5^Y+kJoEL$S4TWnNys z2?I~W#r1KMePE8O!`!^=_JNlK>(}x=R8+G`ygbl6gBEaO>^m2BCk?0DraDz@2$jnsm z0A1q_{52guT5s)@mf2?C>TjX&+1P`kZq1Y+q%=;QLP!R$#E3b%)SAE&+fjCIFt8{_?5+Q5#c=&Ob`}{7?=-r{F_2wEw<2I}_61=JT)}Aqi}Cm#^v;i_ zD?B)xKt1LPe86xI;#n+CcXPsi6SVtk2f{6l{GPv4G zY~zlE$674j8Ew3a>?!t5ptZq=xPMpt5Sy1mv8NPFk{Vz>PBM?gs8ImxfcGPUNEtMC z_Zbvm?!!ltoTvA>AC%OVix@H)foyGrcj$5+@6IS_GTv&t(17s?X_>`2mnr$Q`7A)w zRF@#ZD+K(hD?=PQQWQ+EA=P&MQKRc{!3Td)j(}LoOfLT$Pohtywr`k7){HXx4h6s? z{z(l)s!D}{oD|nLtVP;mxR%}vu4M*NLCtsm=limFx4#@~!~$8ZKwIq~b_oH#l{dJ# zt_vT1`HqEY6$@&cD>s$O!O;+X77`*MtHF6Q9nuQG;1`qgm@n(t%4JYF;u-DvT~ws_ z!zl$sRdB}u$@y?Emj@+&O2SFb``d8hPAj17@saJbg72`$CN3?U! z`iE`EY_yu-&2!V}Xg{6j_@|F6uQ zsrbBS-Ld(eS|I`J{5liY(#e9tkC~ciKhi1XtEjeNlgk4L*TD(Pf|W>O?tKWf0$vpn z)QYzyA0F&!7m=$_(8!5yt?1_^ACaG`KqSKuHx$75FA4Yb@8{|GvH&L?*atftkAy;Z zaF>qZe)|l+co)PG1X)hg#=whhDM=uY1z2SaVS#zc`*PF0E>(xsjqgTuTa>021kRi* z>>p@yh$eaB&&4(ZYL}5O=dlP`<_ps7_9icbLhQYfthWje)mrL*#uhBX-yh-embezm zVjCj4$$sK#B!WIeEUO2hy<}r*kaYjvBu!yB14OgySzGd?< z#TI8glh6L;Ibw7N!^DouQRQY^56p~s?JA)ckz$5M$~G?S{N=t@SHG|vxIN}s1Ff9n z?0{5%?H7a~bhi^GP6Gz9NwTANpCF?7o85%2Poqcc3r;MCfk8H9auY^nB9gjbS6O*c zr%o#>w!s@R(wPKQ1t}wqkf0e!)3y`SeS0%x;7?09wuq<_hSVFQ>jAw?8@8My@V zdSZbK>Osct)I8sU`(EmXw{0a-Ai@&Ew0kh9K?PAs9bzQK?aNYr$3&yCFR%}!p?F3& zLDFmlCxt!vI11l>E|x2Kdrq`C@n26ft|rWhTPUNhL+c{Q=5*;eNtk+K|8{9es$VjR zHq2%=(=7GO?yOPBf9YVS)y6EloCYW8ecZlqm9PbP@)bW_oIjTtdsp{rz*TRQotYQN zZ;1t^5F`j+@Nb*hv~`x->QXWpa$s$o$8Kkl>8H5fC zBzBZ_m6qtNvk@d1A$OR-87ZN@Y%61f<#2x`FDG=ptno-p1o!hkf$>#Rt(XiH7vwXp zu+uO4Jx6Ia4OnVq=QNEWDrTSVr>BSEfkNk-?Vnb$|8^72QI|uk&6L#G%j=2pa%9pw zePgOCOz}Beb%*!cV3EO|uF_N8o&@so*r7579ROWe`2P3=NK~F6^v;FF@#Qi9=)1n+ zw6nXS>W1I1sUnBxV^0k&{Y(g3pHiL0k<0*6Kkw)g05f^x18o(gM8J>e?lJ%64Ew}EL);xu~JRd>RxVHm$ zaN!^%no}g>iP)Rouz1DwOV_{<%px;|cf7pEBhtNQoQa_gw!*+B zl@MYItK+h-x+)Ek%htNN`C6>PjQffMhIvcuNxG5I5}U z|HRzNi@m)Q&x&Q~OA_#V$J{jfN>@6(%i$LjGa=xXH*KP})e*N{^lLyfpPdnT+Yz^B zD{Vtb976zfgTzRL#o$aYnPw0Wf9<2kY%UMJQvV|Tp2;pEE>p>V2=*dg*(fS@Q3_Co zp55j{$w+eSB&Yl9A+FV!$>Hjh#cpl}+G4pnTCsok@k5MiTb2PteQ6iol|^k#m3vV$)th8zpon5W`mA&K zK?8MfegF!1X47^lk*O-M-`YuWKLLC}oqBxIB=&m3iB^*ob{}|S?#364!Oy4|2+e%M#hiZ^%86tcgxJ@#oC zVi=B+od)7z-(-tfitCDDJ)ymP{p20k3z2?bYYMmb_VZ2xm%3o#X7$HmmYpr3qnZ4N zHokVS6#O+}7CGlM063PL4ti1Y;HE4Itp;k(C|p)#W0m)MdVit(`w0ms8{8WxtV*EI z@Y|^4w7cE;Nv!)y7p?_GO|~gwOJxj^?Jx{3qZ*&twn>>O?JzfR$@wTWIN7qrU;no? zMWfMjtZbqN6Bit7eSd!Je1GQn2&o}sowvx!hVaTFo46>_K*d@tZ|Ajb3`Vlkv}x)a zBtR^B0}{EHNb-BB%GeR^>EUb9)}pF26%lJsTAfw%VRVOaiP9?8+^k4YpM3qDv-bvJ z*dzs0eRk&((70_94`)F`534`KB+;aQMB0f58{OpoED;K7i6qSepd<6ML~GR8M;aBw55PN$<B1=+z*ae7_1>Q`YIWFs~o$QFj0_Ll4oNP)@pZCL-OFiP7gq7L=CX-*krxzY8%9x zQk~{VD0T8uDvA`ip95M1eT;ZcOKrsaZVH#8@~-n-Xe;;9jjcBkm>U7qGAjXq zbaq9QZg!m2W8Y`fGQ6m%BZu1cHns6GoyX`;86oM@ZvG3ah7U zi>)~8#0k}M2ezB+nk14`N(f*t>OiMdF$Dkw+Lxx$U+TlVQ~R3coyxpj6)a zE_!y}C%h7ew7k}z5fon$P5=u#kXz!?#DMYQmt{guVCteo_i6; zzrWdqT=DId9OgsmzsEA8n+>JEn%}XLb>28-$B|1-9r}p?s0`wX#9wk4m=#ZNf{p3& zFod5))$a|ItRm*Zvn163h{Zg5JEX#)Co37xW>@-$sw0tn< znAX~!q>Z;AMI6Y!z{cEzDbp^zR7)U66QeJ2Hdfkr@4zFUMwnB#gwLx?vkw3XA#{jr z6e&oIl8DjQ5Ta2#t?sd3q<|X$;}5_f^uL)Etg#gNxScprCAq^yu?J3mSGf%$zx-uD z_%c>H58BsFUG`?czt7+==>cjCV^7m6cl=Ynz;+=FZj_f0iG1mXpxX1qcxi*_U5jgU z@Vl8HK@V?A=$@J<2cJv)@w0uU8A_U3e`#N9MMeL-AXPOgUvYNpBZ)m^hs^T*-(j(E z+*~e5@RPez+NYMOp*Jwf%c%otopb9eA8}pRokKFZyN0%ZyL4vvLnQYYyRVBo6orY2F2fq2xb^?Zg0)fA{xQJ5dG~UOwwrb(k zC9Ab5%S~(Z@8bGFx@;qVJ-tKGFZ3eg^y6|2e~w`he$|qcg|~^U9LV(6v7fFfkj)#Y zVEC&*V%gAw{Aw*BuxFuDW(0sU1{^!e@txQX!ch$dK+Y(jU83K(Gfou$K8fH)WOO~o zxl%GPx0a{tdj9$E!wk~GZUH*7XnYV>?uNeD2DhmwR?WCZV=kHPH2(8}UO3?=0?P#A zKXdUkmH%rhC?+3h_z1P|!%1Ca2P(*u<+VKyv3! zbPTQ^KoRKfdtZEXe2R!HB(-UF#gA~IL{>3LX(pev?mt)jS>>3;2+%av79Ud>m|)f< zEeOY>#0Yr!|4jD$uf$h>K8w_$VZ*e1UuCR(k6BRBm^f)Z3VJi*h8|94n^A_9x@MYD zcxj^8(jf>#YaJp+RvWCrTLjxb6QYXL{Fo8Tj_imtaEMpMXm4LM`yw}`?{Z|(4yg*` z^3PN<2ZvG2(u2^(i?zA7!ILLRSKIX@n+Q)-3>~PmEiu?&K+)FWjs861jwA>XOQglW{p>@8Dd&$IxZkt5FUqj+NDor{zCGB<&)671y+R;Q{T3qM zIaI(*4BI|e{C)wtY=~7mGiktK)x84lV`{yz4OHEQX+KDhwwmv`;e~mrpd>>US8d(I z&C!*%MGoR3(cW3$0Ev?%A)3(OFq5$w*dPIcF!8pcdJO975`Mn|`m%LakV>i)&N0!WZ)sfEPPWxHKRkiba`?$IBL+ zY1*3;#dPH@e{1O0+n-|r^Fs<57;@hX0c9Gu4%l>#p+8}p_pmF*#n@L75M!QH07YfOlfv410=KinBF5Jj>n$6JtW8W)qC(SnLa_W|`eDnaI2pud0%4^zu7*2dD6(JS4dxe`Ds%^{EUSp;5F#7Lj zxQlS>#~)`3#hf4==v%T{dVVx)t?}SBeIkmR>c|d)nJ()O{FWx>&(8gX#8a#ukP=F%(ZjdBq2k|OF!Q^tl-+uE<^>#c} zO#e1Q!r~i5IjX=ycZskjo;F{|Z9XYtrz&?L#KZ-OC3M%x3L1whR>~F*6PEC$=f$wC zmfP~R>nl|7Tq^==z+_DawnQ7jDh`>{+)HKf$8JUM$!awr%SFFd$~G=kX=duE4OflGT_M-1%Q?kt6327-WGY z46^l@z-$Ov13)s^1Ve$)BZHqMbN99(;{Mp~#)Ug-bJdm2gA*X83Dgbxy!hT8{zFuu z75rw)L8y;H(Qg(_%BsCu)>*jXivFCZt2FqeBpm=Sml)iFv?|@mmicV2cF@KY+f!&t z9Yc-RGe*MXf(S`IQsjCclSoa{KW#v*w5C%8DL)thpdwQF|H#R4CMZ}FkT`Wz9#K3V&~Vik6F$3>Lty;kHEwySs9aeMIGOaYw6w_IB+`IH*04X@wZ}s z-nZOQhhOX8u3qsf;+C3pgLqCHvL^}0v*Hn`qgQ&L^F*Cv2@*L^_&D9#l7qNkEU0X7 zsgeeM&QW2tC-6E;OQBomNfSn5s0p`(QJ8;((+lS}+~2y{?TFY5Q$Le=FXhN@o#hdd z)Ll&@if%fzG%#1MjF`t}{rt#ys3IP2-LO$BmtqTxYBRi`P4K)yMM096zaOf-!ubpd zJKmQ#KB+vgh#|dFQ^9hk5Smz(14}~sA!35*EnMwP@uY~c9Bt)V9 zf;|=XtHVq3FlnCk(!ooYm?uN_s?_C4mFX4S$UAOQ{k(w0fq&6Ge$s0JmJ!L|^d1j( z1(dgWPP-+)T6#Oe2nk02b)}AQ@$`iz5~@y{pH-nqFP!MbC~;X(NMfHGj1o6Fe+OAE zZ$aN>jB7?X^Tr%C)`lK+i_l_wN~MOO6kQ|}?;u;4_8Wt}_iH5F=G~r*bFrnl%nolC zaHs`COzxnREQZ>hHef1-V>w(aH9Elo67lwsZ`B*PoI?uV+?| zV7BLVdADqdmLUT*zOc^MV$%?NqS@0cQ>$VI6)cfM zS4zc@dFo+jYu)^ib>4@*?pbv3XEUKbXS$eaSlBr-&s4(P(fYP(Tmu%@WTOo&<6gr4 zm^-c$OiUbWd<*f-%Ar05Ec8H%P{jAD9Rq`0PqS3P8bJ&|jdS5QRnsrQ_7o|Y(R`~$_BQJ%f2 z>C_2WA*^Q7sA5T-a3!-*f4Jjndqkf*N$Jo&&Ug1d2=$dHKHJ_BhQ}Kg=N%4l%kq@|2vNxu;Io&Vkf6_RXh$THt zL3AETw`lBKVYLklnnVA}_LJ%CwB}(;egEjuHIxz%70@1_3OP0`>Q+cjFuLK%@=soR zFYl^{)^9@6tf_VjL2FK2BmK%)Lsv<2a+E2ZX=bo5t~3VF9} zb=p{Cmyhqk5%q418ld^j=V9^2F1`npmhSA@!@CrTcjp0bwaZVW1^#1V`ObAfG^o- zdtey3J})KpD8!?2Qzf70;5p^KtOHN`Eeq*T#kBZXHi{%g*adZ067mG9t|-sZ#aW9u zwvSx@m}&}%D16(HUI6KjY2dH7fOIYvT8+SwVKi73pb3%Rpxi2HE6qoSWDidm&#aFe z`dv8fORlIgk(Ur|i8vL1w)UYIH|9{B#d?HTsy<^mHqeC3sFwG!;~16`;;===ZNmdQ zco(Tlo~EocqfhBS+G%*rZ1?oW@cKDUVg|@{j&nJQoZ^KnzkU(;FvQqsqfUt{vhD!7 zfY6?Z9tQ=ih|0M+a?)BlO$F!!uw^MX`8`jDtCtNG8=_B(%3>)k>1HF^S8%PInAaEf zo4IaVXReEaD)(^>5>zJp!>GSm#SM!(mZ0-+H8FyouH4g=t#7_2I&Efn8@e;%Z6Gvb zVLa9Krk>@nXUvWJF94s{FTYYcNPrj~+NL#R^#uQjyOeoaFh4u#n&y1qw>Pr=ozq}5 zb}RI;5RhB5>EtbeSP5IDzwmT=IT-??@CU=lv+tZ6UCrH z!B>@JID*E)!Skaxr-6IQp$^oBv2BH)9@NFcA^QEnELa0_VEhE z9H~Yq(Oc8q5&4kH@QS8~KXXT~8- zdl}=2RTz{P?fga%GK4I&+g25VbZfqnPVZ$=eT7ZJN)Q&ikYH3^Q&l?3>6pr&r9c<5 z-SDeiYG8!8^b-+=Fb`)_)~2s5Z)ChjcyV(%*R-t)EDmq&!emW$AurNHQN)2kirqC) zy7qH}_w&jRyfvG)y_`fmq{pT20DctH1Z;GZTx+A#&YZ#-1ousg7Gt}3@FJuOT$Z8b zLqm%zT7Tt~UP08e77cvUJPT|&4b~D;m5eVHEHTq|=@VOy$}`bQyo;5b``D8^P)onk zcEJ7}VM=E`D!JEIHFk!RHW)5bcGuWQ&Y!Op9Aq(p4~H!8dPKl>3gCTB0Ir#hxp`z? zOyL-sw}jFz3^?T3;tcqltOg2XtJ%PTM#%*gq#Q>Y?(~f2)DfwDbjGQma#9y-RK)jY ze4frdL>OO?dd)mdf=S(kZu2dpowED0(E8p5lo(WivMU+Q5rI1^K8fzvpDV(6z=!ek zbwq{`+iEvc1tA?FWjwP0;(p+x#;iHH)I6xuPFQ!W>V**3hoLy!uZD>j)C2<3~j5EQV8E?<7J%MH8ak0vh=Z*WX|#e50$A z;<+#lT$rv_OP;{uLVtFI?_%Kb?UNC7&ihIBB-oZ_=~ZeS$r5(K&7bP-mMFY^CBeN? zvcY4Dd9K{qlw%4Q&wlB$@A3~_?<`G0H;*88qE%pa#Gyt#{7;?@^Kfu9V4(LCFt_q? z0Q-()3{AK7Hl8vW4O$AL2gun~ExrYp5GsQ}wS6RnwrOi>A|bjO3jU2?LPr96tE|lhuIG#s6inq_wOk1$3D=~20L^kg z*M5b9gWplP_OJ6I+;Sh{t@mGg^K~=G)Yri~d5v&p+#Q zTZ{hx*~{7CY|0TTb+JBU`uiXLE+Cx5o%K!!bJ&^sR+@J<-X3DY&9Zd# z*s{?*lv!$Ln?F(wH<0^ziC3Pw2W&>Y3sd@epwQ{YA(*akc*iukngb4j2^H>8m#OHn z1H#O$bw$xt2`uyYgU(4fGtoHqxvNb)v|6&hV>Oq_16$55`lodrGyX(#&fj(C+(G*n zXn_RM000#AG3lI5_~h7kvq>{(Ai3sUp#PAhzKFkl>#C6>Rg~+`&U?mLG|{)#W)^(;QE^| zIOhJ(p~z3O8)S_BCv4_hWdAgPx$-o$e>cj)c@U;mlRN^kfvJw;AvkaA0`9_tqk@ZYCMyt6i_rcyhUsAV28j2fflml33cyb*DEkromr!ZIh4gC;fJSFple$-7rX@~lmY^B&knq?Z0%z*(Jio^R|&E;JFWUi!KYwc^wR??Nj@ zbL)p=`nmo7vC(CP4H7=TgThn@wFEKJe-i3;E{Oxsmk6(2)C+Nz|J^dg)S>q7nHhG7%d4BRD~_c(LH}M8u$Kh9U>e^yhT4m{q;sz0sKaB z+#`!%c5%#`s>thsa(fJXbOxD5Z!U8DGNI+y<>5B($*4`dtYprdL>}oY zOb<5-%cW(%Q~3o=dT@{lilv&_bJ4qpJ-msk2mA)Lf@Xk?S}%he*9!^~8e4yX(R^n` zuCtI2?5{F|if3JXwz8MK@gwc?VCi)`?6-TEQ- zoD^g;$lk>s+3+QSQ;Z?O#jtu%T6;Xmac9NG=cJ#{JeRuk2&?IBqu6clz!i6??&hKz zi34A&DrSvvACln!i5xguWAF`Vc@^C)OpwJ1fyMkDfu z&xfcKR4CxD#mTq%IV`XKx{_de;19KXGLpSiwTHn$Do3+mFTIGjRcMmeD7evuAFcCZ zv%;90?wmOTbioEnN#7s^$nIE~5tzU~i?#t7OV%VWcYbSE8OE}aor1PefLq6SgHqKod^em}y$=G5&?odSs> zld=S>2cbhsDyFs9K4 zlo@7X%w9ZsVQ7uz<&9@rt*#w6m>k^%-no+;6fxSHmFuc3!CbC?CakkPR*3-YcBPx` z;&uNvj0BEYqrs*(l3K9u%Kq~EC2(JkB*d7>OPAt7sUpkjcc!N}^b!@DG#{6sx{Sc! z!Z8anH%zn-Pkk*S=W-#&=uvOzu38q_SDp@nJ~Z!O$(TDN$UfOSS>{e~Xi4@C^X_b} zwm?yuWPBI-BO{tg2Tb;68*u}04j#iEm6xs4Q{tua%QZk0eM#_rDv$8CTqT8q*Rb;R zA>UH9pBJC!-_w0uuk-|oCjnM2+`@NB5!-pAPM4U!M$lH7cx5UYeY31~jj&qZ#-DV5 z!9=eNrw{d6*4}Txw$9SNsTLb-wWx5rpXa3E_JOe)OsRtoT=eyxdiEq&ae>c!=5x>6 zX3Ry(IFf_YJXK4U-fUzjrelq=vwRM=UN~(v71Q?3r~X$_5yB%`tx~8?Hw_{Irj<)* zwkXCI@MGWbb5TK#MB|q?}YpY7(J2 zo}Z?PO}!?ys2h0%K^~+YPB#XLT#q6Ivyp?zM_KS<*);ZhpiYxwts7=lywbY<=v;Cu z8HXBzC6#^{@M16J{+d_SS>}(yw{=pD?qp$Z@F>ErZ2dw{TX$z>W8+B+z58e~`jwZg zcct^E=OY$p%=|(2WMo}ZVwBIvfw|np z3al4e!_hN}Ww|hN)_YPZznBYx12KTOocWc$(S4 zf5#KlM0r$y`0TbBe20Xiwo8!Bn(r*WQ#n6O>buGh@NdPLSltbmikX+S+VL-vRYhBs zPvmgMY?1^qCAg%)NlO`(Lttnhf8hbfPvG<^T&roaCxgHlubl$|zNvBdxn+!#*vgxv zIrcH7>xEUazC~2FK8@$?6TjZ>`~)cV(YLVpJMC5;Fu4K{G-A2$pMm`)J0itis$08 zp1(RK?|j!1MmclNtH}fE_x*2D{?9%MiBRj75jj^AINLD}hYNmHa9kC49IvED~_|lYTg_3|s!RKifue z6M+^nQWhQsJo3#jl3)Hu;ZkCQ%ZSw5s(MzqSE`cFiQ`#9>W>Wqb}$9P_@ruO_ZHl# zW1(~CKhR7g{k1lEhYu(SqECXkGR_0sB8o>_r)TAN%xK&{T#@;X>$`z?D4n08m)n9e zkU3OCIA7kkRJx!bf(zJ5HQr59mA4w>jX4i&GMG4S*>9-AquHqNt1sn?3J=3B<3|WI zUP`>R1%Va%9j5@g2m~-qKvp-sVg^(ch8V^(mQTK+mIl8>a|98C)?b6pQ|SoY`gUaE zaTgA0i*m$KCd|}*g`|lOK(WL1wFAi`<)Y%NfZ#g3_0sa7KIW9b9!bRlPIX`nQV$u^3CVp|wJw+rC42_k%>P`PkiLyXHg=FuVC~6VY+g-&~=o*9m%%ET{myEPPRh z>6Lfe?6F{MEEwqR+N%|V-tV7Ro22z9pPtUnqc=U{QDgY`YLV}g%qC{(;#=i9@}NQN z9PCIa{`mk#e$cZ1&=IloK#=_oAp7ir_SsQ0m+6%oc}scBNIM8?iuXd0CI|OpBe9}M zP^XM!{V_{ZTs&2=fXTPUPmj;m^d<&UKgk3(f`nXTAzc%myXw1?X-G`YK&4W1@dd3DQZHk& zkyW~6^;X~huD^KBdIjYYLXvk5hBdZ82AI&ziEnkktq!9Wa|=v8Dlw0JRG&eoQF43k zn#fk*UyjYEF}>x_2A#X`=K@av(QY*~qC=}-hO9JX$Rny;Vka^%pxuxiP(oA^4jV>! z8D)jH80hRx$Kh??9BO145HzXVH{AV)AH1Cl!S#hL{dh*A7N$VPKOs&2oIth+z94~^ z*vSB|)In5_*#i}|5SrGn7MZqo2f7IFjlHK13@$*w^%=DwUcl}UjC1VoW>|fy+?i)t z;yza(6Eeg&l%`dN`K3zbeWbcvQcGbY*%DAdR1T(>c+6;O3nY{Fj5AV5mTVn? zozh38YR#{y*HD4M;=Dps5<7E+L^BW^WGI%NVh}3NcNdo8QMD0@q)`K4<^d9D{!0zv zy5;-*<|rrc*YEo|_|b?E`1)OkKK%*$m6h6}3TOwPdc$tO4YiLW-D10?Gqd*O0=wx9 z<}8FI-cI{U{Pods#sm8toSvcw1U2(BG%1?jb5~`UM*6r&S^!J*4}N0X`Iw8YzX)ee z1|cix9ka=LErcZjggmqBdd|Z%SV6ELhmaHUiT&qX7)ufed4A9Bl9vfIDFL7ypl7b4 zm1jf$?IiFqy5m!|QL*z(-fY#v>7{Ct&uE{s`fUo@`u(({_jfiq9}w>k)QB`~9S6DH z{M`K9+}yC5d}rsG;xM7L2$%}!2k_q)0O`ZI+S>Pj!Mq${iq`bed;jo*dVx_ayHis= zUPDMSWzF<45 zmHeQ1;QAx&XF~O61Z=d&ds?__SYGnn4xYjl(XNi2534${i3F#D~t`^vrfkVpykO68s}s6I`AJfvYBdShyF>bJez zn`03W)#G_%Dmql}Cih8CGRq7paPOw(+BDn6O=&n-JKk7Vz6Wb;1k^8JNf3E*FK4Xp$^xBA&=b~AvWARCR!@n1d?7zkbP~3JsW=)4cOL@J zgT)WF6E$fW^}!;^Ex%CWh`1a_S;$F&>*j*?C?@0N6nO(@Cn<&(LxaqY%RkoJJjT!M zR{&W@N+0o2ir&$Q9)QB+ehDEnEFcbD;5%Yg*+~5D)5TG;9nFul5Q0`9{NVUu)Vh*m zZvo&yJ*h$p>6o35O*l`1OI-&ks>-JO%P3K+6}ile8K6P-Uf70aAL?|+86kD7V%b4_ zbJFBjS*DqoD2yJfDE2i6{IGhZ;yl#>;Z_wY@=%wyl* zyy5BsWk}YY76DDs=5|aB5WAUU%Y9@xCUQ$0m*R>*@!%DoR#74ai0TW5er1oF`RgR7 z#vuvw6nfLuBHaz z^CJNJ-*F!ZilURzODuX0hZqrJ(_i9o+<3??gnF>L72nsg28$=smzwP7KsCbaYog-V z0XuU6Ha28s7(4+o*s*9Z4lbt_aVT!n-YQ!3n@IkmOq;xq8=1Nohil&jV={-J@4J0# z`#vD;X01$&>@s6eT8Mmn3I&Ki6WK#u5#G};Z+hTakCgGiOm~Nn;rr9{F{XEuV2YKv z8HVjEUK+XFydIR+i6Eo_I-sR3Js~8vXucW!lgMvjN;`$>zbM3!XyW6&^WQy<=|g_t zRcIU4+)XinG=SuqQplsPap|_QbvATQu7d?@d44bmN*oZ>mXi0tzYo7ZssK=I)G9&Y zb487lb0o>Y*}FS06!G(1LL_gwu^rTNz4IMVwGP<`FI`w=pOQGZ1y8A_wJkkxm1NuPK}EWPp-htsI1>hD}{!|2_GCEIiGDHt6ei( zEd0Y^;;HF<$p0YBU}QY|B*Dj7%xSfD7936H`A?-oHY%Fg_qA~hhPWnkxWbu(5~J&8 zCbB>E>+_+xi{`zfDwDRR@wGJ0IdqP(8KFZT(s8B*FzNa$uAQPqc{W2_HO4R{hI$ox>#8KyE>Kbzruv?vM>XTD*wbZ+;$ZYWsES=w z#L_djj|x^%Vh(_w93#PB#Hjt%OuiM9)*4y;35tZFt!ti9u4Blp!NOLnry)w z@<*nFg6JZTZ>(fR32b6lOaZc0v*YME z>7rpMWKru+*hzIJ3e8^a7YNV27Z)3HD)-Y{0+lvdQ*Vf6&fQ5x|J(5+AL)hlVnLz` zqCoC$RQdY+c1=qp5IQs^MQSUzr4(bA-=7>ZX0HZhBGPs6?fF(Kr7G1sN9#4%xLp|0 zjp$K%;R4Q+MYz67g2yYi>UW2HhHSwqL|8`CX7ji2W4NEzk;S=K?*ay+UNGKtxaePG zs&lzHEUwW}+*y%<-BL#{-klFC1kt!VD_sjWeJUDHuRIWyBEk7?IZUE0PDh0raKbwY z2evX_y_1cAua|$=R%DgOY($b2;1*REDn=*PJwD)VM~srG0`Kw!Ho;%Htl<>M9p>l1 zOGeju4l#xR`Oo$H$y^O6ujZ9NEbtP+2gC=K9d9|NSzM3BWO82Do@s;=Ap%yici=Yg zEj@xpm84V+{8mSpE9JN5;Gte)0#XklAa@n;YzyZ%C%-g*l#GZLEdDMxI(L4rz#o1u zr=`u=AK&3#TCM%5!T-CZYlgoRTUnxrmA*WZJTIkj{lhAs%L)$VJi!Y zDm##>tD&xU*Jt8+#+m&gGpUDbM&Qj3mA=v#|c zitG@k@{`>V58yl`#C40D17hqbhFA8W5qRab)!8<~G)1Eslkcv&Q(Xfco{H(*Od7UW7yO0H z>c{T3YF{>(9km$d!B77k|F*<(0AHPh*w1mZN}_yPoABk=pMCjG&_$hDA=geA*}I`i z6$6Zc?=>OZnISQ$ykdj5!brq3Osp2)KMSe$R^>#KC0aq&?RiN8h(EBCemdjf(ob`2lM?D8T8hob%<9pYZ zL-`Nm`yIDui`LI|LD~I4j16U1SDx)=j4+?`S$yO{m>7;As5j zovQNV;K4cVka6C1LZLY^nh^1XsP^~0+|C@V#NopQ^B8IiF8SRFn_G^>gNs$1lr4*? z{p61}){3dibm|JpF3n^7ewv~E(~NdIqQVsfzva7BgKdTd3pm5ZVs2d@3F>(W?RpH^%L3Py2&8tv!|ZSAN3^22GqUk}}s!pc_JYlhot5_w`5+0=eV zO#j%dO1_7#4fY(<<8V|kH%&i{;_4;nju_|!jr7do;(i@4>)8bUqGsI}`lR}wuJmuL)5^b%vx+Y%@LD`&7l{3jRs+ubsi( zN5MlrY8d=$Gk*u7Sr4WV3qD_{@67h)Eb~8rT%nZbn5`tDBH6yrlEzGMACSNVoxP9U z&6?h{wFYvI;qhpoTc)ICc&uN$Csoy<>8#SuFzy zb&->+JEN?I*5Q8A@8(WI1Kv^2_KH>j*dSB7aI>8aOEt?vIg?HU8+q4tCcU)8?x7gi7-)Y#??cfgSds6%dlDB<0Tq!w|$qg%2p$KqNd zzR^sJyY?WL$r0N{Wk4?6#C1E#azbdDK?>Cc^LKjN`s@oO3@{n3j$)74Ig}Ywxbcmm z3fO&>3VaD9nyKC@*~#b)YWZJ0x*ka0J>?TS>XX+<290@Z+H5#V*aHGFR@$+&SPa|- z?EUF|N4`w$@8pi0vI>-ogNXJsYkXQ+Xsr2McKq+$FUS`L!kUk!^qOgZJ14~rCCrKUOt7kmGH z9@&fq_$MVc>_l;D?Q*PqFK=@2jG8c_un{K- z4KW2OD*5QfE~9$LO1=2WoJR5Rd{tu9kP{CaCpn3BjF}&e+Qd9S*16cDyBMu@rgs@w z?dxY7$(jxHQt*8a7&}Kk9?bB%4JGLEuB7EI{tsi{6f9b-1$k`Swr$(CZQHhO-ecRg zZTlYEp8Mv_OwGr9OeIy>dnHwS|D=-i>U7gXsF#c2Id0_Z5)?Tgc`RM;!UHO-!iiUZ zCwlr`Z4dI6^ZjM*e`!Nz^nkM1JIYfG`(zE0&$9C6o_N9I)+F3q^HRg+poxY^V0r)0 zy7=omVASWdDCvtxqCHnA4?oPSC}9ave!>Lnr(H2Me>AuF62_7S#~1@=`09BboOPl) zbNb5`OHt=p=rEKS#ry(K>mQ-Qoz0Bb$HT09*K$Us3zmd~U`(n8^Wv>H+XKy6Gq1sZ ztHP^2Ej4&i`3tAVQ}NYY;7iOKl%^oZI#oEn+=8NsO(ce%RwKFu6~QknA$CO>9SnFcM_vXxgrOq)Sh(CjWO={oyM|Dbjhu zyF#9qe*Xp7%DXDnst$7S#+W2TYNS3))wtESX;Y@mvZpy>t^wZ_X^k30Wr;?!rFJ!I z*h~`N9i&dkDnCIpqs7>SC2CjJ740I-9xefcPl{0~u}iF7#fae*7g!Om$yd_k%#d8S zi`%#;=t!{kq%2oi_CBS{MrI?Ipscs^)KH{fjKDkTB|T!|cXkYQmX+6b8~T{AtAAAG z9WW`gZKjN0M5yQ%;bQaf4YZfH&B;2n1VH))q`6c0AnhnvhF8X)KD5;dB37hKeB`{$ z>`i9%vsrOFee-Z4Uw0C{0Y3eeTC}h5$bf84XvH=iyZPf*E901?Hi0A zV8kxB;hh(t9T|4WVY9%xtMw-sKftI>Zv87SKnF7Hpxsuf7eC_!#qTb24UkhSFhF@g z`_oNfWq;aCOWb@LIB|ATeb~U*WPup@&4Ia+;-n!k(J!(iLxqt#umI*}USSXyb@v)K zjHs&jJ`d)_w*;(CdVE<`6Uf#e^7?YLW=^( zMj;6JiZQ4G@qz!a{p?@~E-$e21xlVE3BsIn=6u%D$nt=3f9%o?{ZpYI@EEb+)uN<%oW zj_|iL);DO}D%VPW93d_=#5sLe?6_FmTs3EdPp1FReo9(dxBl zf7F}|z9|X*uK`1&@kkml!K6efv-<$5FP$&tssRfzGB}q$`W+D`Sb9%z-c)dC0h7h$ z=Mz4eotbl_?otdilR+6y>^S&cm=Bv*$n@ljDS5Pl%ck)}=(jPo5O=Q+HTLIK|Mp*q z>vgoenhlCvs7=odU*2s*ypB~u#EPyo#(Z9kT!<8!fOqpBajr`}lG84d^<#RmeCwO5 zWk1O7QgTr0yZ0X}V=KVB=u?3RN_X`u9VV|?KC6ub>v9Z5x!=`y>(9LaQqv1JnPqqM zt`Z|8yg=m+X6%FTMmbO&7@)mYX5@cRUqU(j`gz7yfJt`T+jQ0H0N1SKoZgp(nfRH<;;j*ULudlGbx0)Ca3mI+V09L79(E-D;7DP^Yn>t>CzWl3JV zBM3c-61F6eL*4D?#&eabwXO9dn;8SFULfW|nVH=4>M#>*)G7y;;F5{_r5IMCGi|G~ zS|omOG13H<7NJ!X$2JT>4{t6JC@wMER;$DH4DAu?7FSRqbsVzA5VM1QaC$pE3oJy@ zJEIB+6Jqw(Ntj5=sSYTiT%l%=aiZA0FaF7aDrj2;>B(7c^{Qa}(lO`R)=MTV8HfMY zC9@xbsm&(6e!U}ecuziq-D;*B+7de!rm`?_V92Ld-rb|KiRp54di7D*kGovGm=1p>sEE7V6&7v<#w4DIMz57yK*W>gh|jFMyu3*s?DTS_MmiWulABMLEsAMqK#; zwODH>`2|ATKp{ne%!~K%pFodwsDax=LLl?PE#3eBv+3AGUc3iUOH&1LtF-aC`n;NI zp{{(dkG%7`)S%i*H2;)&6QB#FCFjbP4t;VRZjsm7>@*3z@cj_n>rl5qA7VjY*LwiO*Ba;&%Oo7Q}+&{QF0 zin#Esw6t^>)#61SLs>@0IWTtwdJ6pyIgV*r23n7Zpf z;7uopI&wXnydZAmZ}81(Jq;+RpqA;EsvSO15RvzUmarU(?$a6HAh3;E?X9}ULbbS2 zMEUr>frz0$-CD9q&<3$&*528x;h>fnn9YBKeKh0HAcT`Z>~Uuk2{;_|Gihgyh@t7U znYhfypF6<%0F7B$Bl0e6)hf_eM6hPA>(;DU)p^mxD~^@BtAm}UWKO@rsauJX7Nc4M zzq3S|AFZWRA|TEbqC)DkO){-p4>$cIxjU{-@2j=tvw)QjLubvlQVuga)hjZUNQ)zu z2a2N#%LtqDF9mqT7&Q}^aY@i(u1~^KMC>|#;MQzqU-FDBlVnss_6fN+?!mN(OTPjdj==w2-I-XPv#mCD^qOpB7UbpsWrfxUo$1$|6YB)ieG*hnADAI0TtWL6L^XygdEDj z-l8O+fO4<`j9|r;(S=es;28d=414&rbR1KOcE+q3UB)Rt0m`BQm}X^NiT}$Li(}Zm zONlxRZcOv5GC^MI2Dh=3IT6iN0O_oR!l!~u3DPc{_}qed#|OMM>#Q=^ zSv*Rkfbc070*Oz+LhnM*B>>3ee45R#PmYW`&$na^J@$w zf(Z@feN1flgRLUiG+eXu0>%CfjD`2g^hJyHj+=ER&{Hg7x+;{f-5wSy=jO$0`9e(W zk7_8?canF&`L<_>)xv^Q1m^a$9Q|Z+4Vnim0?-iN7i9U<}n$%4m@PiPL zv4uYOC3tHOM{13`vNNaKibr*sI1AEPR7#~;`dyqwQDQ0l*1YCsfPms!7m;u{S-Fcl zG-X@zN@_<(g^#NTu4%!a;i3I>YI4n?0Fb(zHRI(t*|XtU18kA2ZjhK?cKX}gDvoOd8ncXRMMHAhc&X7_4~N5cRX2{gZMY9SOT2Bt{etv zYEf*3;4;o`Gwkl=RXjVI=h>kJl?1{(!Kp$pi$RJB_(Q7S&ml|IyKDG)kg6xKS80$oI4Cr>A7{K{ z8&~A@!sU3PoW1HZw@uBd1<+s2xHsNnq!CCSS^XN|R{0M@bP25}1BS#F^*7eMY`uwz zsBLO@hbf3rd5{;%v7S$`i7q6#2vH)HjLgatpvwoQUswNME9_*m@*5Sd(a;3xd4v;& z94v+a40SayqAO<-mWbI}B09qu9GR;>iTFV{SjK!fK6nBlz4im2wQH3n{;mi7l3?|4MdH;wY4*+ zAUiS>`1oZywlAl2rgp^W!{zJmj#Xd);kV%azDvTqO$IZs8`5KJGv=7TL}kIAC}&#@ z&+kopz+{DPe26|w73M2!c_q(Z8kG|h_Pa8l^(022+ZJ>6h>Np(@2R~xKO^+6pdvyx z;g`HP2>05VwlY3DyoS^bbu_E&`HldOJNmrdUerzwkj)#2Lu88fA>|H~kn10!2J@&p zvuf?H2{@R5!*iMC$#F}OF|*YaAZpE9p_J>wAs;ZVJ{Q0%*mon%5*)9Y=n5RpZcB#5SdOxM>TgKM%7*s&0n@Nk9 zn+_NI&X?7JU=TNI|4)zE6+z$5M4HU#vV~qmGp4~&GzCXC=T_W9AZ@M>E~jB)<=2}U zv@yd&I-gP$$)fcI{I;_RR*FTF$$?=i%?ED7Iwv>?H?p!`!)>6b z;+hT}x?49_AK0##8*RAd+WmTeG{K)ol$}d=xY6Uo&PDmgh2t{?X_jbMazzk`*#|uB zYx)A>Sk_e@h=fRs@JWY3lhy|*M3v%_6jPm&!{H=+Ej}KmV&ITZ;|r#;_Akko;>DP* z&7IlbL;u~w6_q(%#ZJkfuyv(5(c)?c9=G~$lQL~wM3yzYV#i$d);TEc+Hv~y{yi-6 zre>oYT?!T4S*wpYtvqYsU36<4;O`13(t{70AXKsd?aB!^*0hy_Kpc$HW%|G^(SQDe zKncw_>j?z@R*ok&<}f7rvy9$j-D-)UBuL@;%K?rl!8nnF8J`6aw63M zwZaZBeJVJBG)Ax78_v3MRjE|1l@0=2s_!;2I^~}4^wL?EXX(zk#ACB~$CQ{G=Nrg|LeG zSh$kKmXi*9CI*7r=QBZUY`^Uh!Ksyg3}=2=ce@qTZb0m|@X>s6ERrjP!s{dSS=#TK ziyK9Gfk7L~{WzSlhd9MzBY(^x(-H=Zn(UVeIv6tKz(GC-Pgq4D{t;#PRZal$%L8=$ zXdl=hD!B+1twXk%2vpz(v+qO)Sm(nk^A&3hFJ#FD3?YNRc6J+{lF z9O{C_T&@5Mis5P~9Tur~NPI?wAFdJ(M0ek7CEzNH$UpbM4+lrXFE*Z_bPM7Oqv51Z zGzWtKiey#-VI}?#r-k#!0lOFrAphlO=8ZE5X5cS(&U0CEDliYv!^!r2-Xn`V)sWHM zE1eV9Z0XT2tGL6fH zlD73)wsrf5Tc~Hpk!ew;?&V8;q@(AUtYn|9o_ZDchb@zG6J8|-luV6fUTI!?uC%aE zYu>shB6vSPV;KsNaE&KvNYh;5Bs)_?&nYaV;Tb9VJQ;2Ysoo;a?SE16F8c9*LZEDQ z7REHYX`+4DT#uI!?lQ5orPo^8lfO8SX%cULzih}fdGUWg?#MKW_kQ2jWg5Nsf1med z8pZp5@9Q&u0M>4+j6DcWgUGg##?_Ij^sk#v=RMOYrP*G@aZHLX9_MKRjT`Vzwg)Ds zoyRd!m8;iKG?}OkDCHxtYXXyB^|>k|h{`76B6yimXPG4KY=V z;RhC}0wW;GHS7~0dgm9-$m0P2sqcG6)<=9RI6l(uM-wv5qoLRt&M1hS%*O@#oEYgr zaEZ^^_jG7$sXL(~EWkiBi4ikc8z4(G7oDcD28?;E=nP@^=U&Cr5Ss;&6Gjvd#VMOxd0PZQSSBpW$_o#Kb(aJS(9DB3`krUA zqYFTwqS+4uj&A81?=+FB4uE$F0VT*0l00O2&+yVOK(wh62T zbqV#8_`*UtwMIE%IijvE8#1wbQ{oFHiJFU9ra&djG++|KgQ|+nVg@yJLEpwUO*K$g zqG;;S%sgHrsrK=Hc=~%l=}X|FbzgWH{~4W1Zwaojqm@ZD{f^g033{w&2=+=1e*bGU zaJhzo;@YGcN&JyoGGiWerX(16hwOL{QFu}AZ81>t(RVUH8d=<0qHKeLWuGx3%ocR| zaqzOcbNp9((gsp_@DGWmlbk&fryXX}L@RsYDysaLV?uPbdhXH%&_%F*>v`Ne39?e2 zo<5-w#uGpS=Dhnx&Gk`>|4f`;XR4`>vu2c`toy0)^ciK-wh76M{WQcuF*)PxolnEc zGud7`;IjpH)D_| zqaT)~`=Vpb({FVx&XX%b<(HLdK~~my$vlbbbb7Gk1In zV3NNixkZ=-E>KfMyo^dLFB=$&bce7U7L~JE9Mhlo(hG)FI$>R!cOD)3>~&me!!T#$ zVi5ZMId=Zo5*I)T&+paI)RmdLOjuFn3#4GrePr#D$ngNw_p}Tz4uC}2Iz0;2(_}ro zZPfB3IgP@id0X^b?ajYb0HqDH^IT@mxOGDh659X2y(?S&Ye zu!D1&Aa`VUiw@Jv#3rMvIGD8_7bcV+dO`YU5MO3|xLARaFOxXuhTZJ**#~om*#Al_kA`>7x%n%(muCZi5%9hmvj^zpaT72mL$#^vp$Pc*= zvDtXK$GMLO9*wJkN>pDK1r^3ltx-h(fL5?;=yH-B5|9=AVZe?TwqV{= z+6SfBFfh4rkTp?QTko}a#;E(md3HoNa@af8$2(<~X9!BX7|4Q8ATJ$X$osPWHUrRy zNuGOyn^DfQYv=HlpZX0huPx>2`;#f|Q22-I&xh5^M+u0UtfAa;7P!tL7&s%NZcvs> zg&drY7Z8b}N?EcW1HU^3Cyo-kgZY4&rS;Z^vcWX(LzM`yS?LDHU(+S;J@z-dOF&JeDglQ$s zcy&4Ysu=vlMvXYt6e~@05-k7BQaNNx0W8irK3v2R6Q=7^8GVlZu^4g(&2PQ+ThtMu z5A@@DvueS$s%I3m*`B^-NhL2%2er3f&r0Qvi(jt7JScO!BdzP~ClvXDw1&Aw1G7Zf zt(@w`B$1{*nT&jByN6_w9bOoC8!)>)Jil{qFJ|YMn^^7)anqba4^yv%i2_F5m7s6{ z!ya&#)sV6BIT=#$%OqJ>-yOY)pS;ujI+^Hs_cmJ_z8xzdAYC)A<^#CF4)?C*# z?HO}XW-ph2j}}JN9&FD@{u+W1H9#P2sofppH<<^bfY^_FacM!0>c6CAi0D=h+a94VbAS zzn4qMf9pNbezQAE5NVq(iU$k&3Mz z-S)jV&v9ifBr?rj`-^qZgC)0?LVw1O4vc`Rt@g+rn+fWuT7>3vPXKGnHOv zla-!ml{_U6lCNQQXuCWKro7{0KNKAV3}_}E(|nGiG`J3N#hGsg+N zvuc1+?fV4mwEY~wG(0$7cq01>DLosAM!Tz~{%MQ*?C@UH-SO+nP(eIhXGyvi>p`p1 zp-H&ILpO-`?M;V&oB>FD})rf-0hFLNKB&5#$S3(WzA9YiMp$bm`$}Hr9RcgbHlZ z{bIeC9~lsk{Zk@Yc!9pSnX8TcF~%z=sKlZsw~d_%VdcJ$X#g#zG>PK7^YWc>&bXAR zAfv)|3F;@2_~(+RX|Ww`I&_oa_4e-q_uLNrlVUlxOB&6+LRM>Dh`L8FY+)wx_?p^4 zwV{@qVtLOHy68aQdo3Bg{6K|+UHBZMX-M#nGmjQ`t#mac(Dy)hY=U?Z=-4UBawdUF ztw!)+*yd$q^hgvtK?-_ma)R*VbougD4?k2`Q(Z|}gq>PSfD(;Mm5q0`iT%l@Wr}NP zu-(gEW>5ML$jKa$$%n89Z_~kuA=!FXHoMew)nECa4TAMpc|_B}nl8O;BpqlC(BITV z14yt$!~^_438Cp!HaAyP6^x#l@xGOXnVjz&&XQxQ1{tRdbxh?lQd=wsZ5Pte_KC`q z+Z;YN8y9~7R=PaCHN?(cRv&_o3iYCA>ww?7*Id5F$6fe9P()`TdP)0{MuRQ6!5qH& zgU2WS@kg->bQDjt`~E&{yFAEDcq*eCIzIKfMwsj&>lxJg;aDWtMR?+w%42$r64xmA z?Eu6jyA3-Br1r=#&HuZwny{%ZCBXlr5_GAkQI8r&kKUBIa?=Qb76LkTYU?O5WXK{+ zK;7r|JsM;YCZO(jZcrrQ9|-FJut5G_T%Ik;`@%+1ou|nE+>78B{3pqH4&7<2ptSyXRH^PFm-7}(YSJ^ry zTgPPci1c^8x?oY00pm-r?^&48Go6AZi*9gma7gjREKr}pqaE@hCMYG65G-Qtcqu>G z0N?|vz;||V)~8Y$Z=Jr7pMu4^4`ie$V$Ud-EXq;=}!1bAO% zA(P@MsBj^$uq^2uyEYnSDoWnr zxVB@xW3rUc)8sA#3pY!@$2>Qpp(p5K+H)(@gUDh8f+JXh-#T`_AH5K=N5g0irb}QR zlh%c59VRY3J{)*wbuRNGH_&8zQ)2lSjXf@@rO}EI5p^c>r;kV=!mO?4L9oys5Kk)J zkNyIlbmI19;XUl<)N15bXq?v{=~ag>F|__Fy?=IZ5J$oN z;SyH=_Iv1(qX7Pzc@-vkf1V2b?;RByQhUGL$vq>tF6acCkLKU(EB_@tT4*|b@=lz^+rZ?fxY|hc zFW9y<4+XzmI^5oV8cO z!j@M(_KthbARpt^y$6M==WE$kkptzaJ(EZHnul>8SS{)y}M!?Bg0crg|2CK<>R(X%j^-1Vd{K&an=cqRLVvOPBMGc@&Xy z7U(pA8bWMom0T_-f774-;SEafOc;uyFE5lrr5mXvO=l=4k7PUeoDE4nojM_ATX|iq zf5NGoL05lHS1*2DoI-~Ju}Go+R?)l%P?ie+# z=n@m;7`Ptpq7li%o4mhba@(3Q-4y}_GEmJJ*}=2Cwoz96Dzf11n7W4P7uf` z&g~G7vM~@)Nq|ow?q?z()E2i%L{bmH?#9`Z*X_$r8dg?hKk8@SAZ{_b0;R8Ym+);6 zN@FfXJ#5<&_uu!i`wKf2tB)7Mli@e_7JSV-twGm1fZb@^SfrLK8egatFh)uFkk?2W zhLGlyY&hYqp@J4Px-T(kypkyz_!Ao#_)Qi84Uu7IQ9&DJM>!Y%aJ5f<}&18XfD> z{4XOTkfzlFzBqdf4WdVNU_~t&0Q9!lm2tzU3tdzxZbWzpkpLgs3|j9G6mB26+=_Rp zICa;hHOUTqjqJu__@ez5hPNjrYW+>zfx%W~P;^;nIrl{I8iyAvG&@c*UJm=n3U^2R zQ0{AMj79uwDz_CE2dnF#<_BL01ixXNXDl7X%mjJfwxTbbYlS|nLSA!=zH3V5lezsO z>KIw5^4HFb46QqRAOtiiKWmJ3Y>b*Nm6HzX4VtPMb?WvB!`f#ICRC^-D(6UmyPAQC zr2_=Zk9t}Mdg|<+;hp$aAZ`kGyECno6M*eiegm*XV1ciO zmSBr^XS$$9MxD)zdBOgw3E@r^)DTo}0L&}oqzh_EYe2lnf*AI_+WZTXR)d6a11Q$b zA)P(vlj(w5(wqWvP9wUY7PkZ>gd0GyUM%+DHP=(IJJAh;t z6nTa^fDAe)cB!$S1gx_JW>NrjWvSa=s}3Kaz{stBQ=w<6bFk0@vC;o=J?5HVHaR92vl}bqS1A|L*PatP^-l&?1i%U!gfF2D) z-wdmhR7(u<;0?mWUg1lOIcJV#!D3FZr~%VJ(lj&KWpuNg5{>7QMM+Wu-ZcA*} zq}iR6O_FaHG3Jvnht_?d%O%Z;W##T?=ix*E<`)lcH*B$9<;lQX9Y7*%_|Cmp^Og-O z61Yd!Cc)8az}{>EwW>jc3;L({Cz!soJ+X)8bGx`Kfg8kQMqB~EvR%>E4em>jH_@@R zX{1{?l`eJ@Iq$Bm7%deZpKUTe6GYA|6}KBPTgP~Urux?!3aVjjr+8qkeS5^hI^F_~pjBEZez)h;{fy<~9<43!Oz}6&&5h{ew-D;(sH3)RQehhq{^|>s45)|k5 zWoE$uOV5F>0nmV0z?;5i0*s7%o25evbr)_suIk74dIulrd${;h$x-}^E)<46{kKL6 z5i;&ldBe#U&{xWyyn7ES87*#;ouY%j~%F2k!UeFC?;dIGObAD>hxnY*Vjj ze>+R}hQXz{E(B8Gl*tDP#s9Qb6wZ z?Khtx7b2cqR4K55&HF}@@6hKxK7?Ce*+A%zk!hclYA^|WKSJ=9!(<`bnP`w8_48l+ zhxzSoYF17aPUL4;p)1M>>ug9u)I&M5SF5y3kZj#_n|)uRS+e|KQ|E@A91x}~EO}?7 zjT6yK?J0Nk-?T36r<}VT(NP@Fai|Wrka2;;;ZRQ0p@E;__*Hzef)&pECe&2hd(dWJ zdpz_lhhpWZA*B>#tK|8J6aA+v1O8WCfkma=doF}14prYm1Lq)#vb!s!S{1E+DfuS09%7o?==I)$7s;!ffb6-SvPo-`2krG>&AJ{la|1JFYeL;!1t7?gZERAR?bZb zB9k$0K(l8a-jb|Dw<30@g{bre8~N}CY%yEk^GSq)ROjzaw*+PHY2{52t{4^wDQ^0- z_D9NIF|Nz;+rN0qk=>-en$}JC3sK&a{upQ)ZC^xziiSEgU2Q=p8YYJvPO0w{a@H)I zz5%h6%AkDlHA>Sq5WY6Qhn)?PntZ7aL2^wk$Rxvw&9g)F1qu~K>Y-1;Jp{_~FeYcp z%X5p5@_1!Nx67f5S7s4Hd(4%5k`jHDQ za$ys}L#q09#a3vPb@;y;mW#S_YC%DlTlFrs*697f>hjcUmwrI*k%98Xh$A{7@y(aK5PtDI9u-~^Z{5>m z)uMY1@&b+b+?{@pKP9cL&xQH2!hm|0HyYOlfdTLkcU!li^1E$0cT4(ui`%+-JK-A= zzD-Sx-r11^Y2g#f&O^{#VaUR}6e1Q9gHdNdk8+4wt2#*4|4Hi;q+L3XS~=Co_~zS5 z#HKVMt2OgKzXcRFhQ~m~qM&;VN1a(S4PY)%jFkh{V{;T_`CFG-+@>k-7YeT4D>V?< zn<>2)IWPZLICGa3J)qydC4#9+mJg_A)tMZruy8lf_%zO$k3_<~I`>FiUGNFNOzDH& zn;NhQ*6hl&^C~l4AMuNBV)s$)xqDt)+t5hs4(bt(q&fng#iMrER;kl=-{zTv+A@ev z%~qiIu-bdA(X?$m8Jy}=avkhw>v33hZk1d zIBj48BF^}F)IvrM#O~0Yy|t82qEhiGK(WnPm+FbpbdhYiQ);ENu}K)No;Jo(0$gr9 zMvlJjve_rv>>h1ji!2kjd>yJ!JsrcF-bjhSf~Eo{8-Z~_e1T#=Lm*oyTqeF?cGy_M zT2wI_QJQT8c0BalQUCqk+IA=kW*gTQ>2lx=(UG%|W z|6Vfqdb|l{fbaIN2l_pkC5_~f;Y+W<{-KuyMvkFVP8aQVRhx<+w!E8W&AMEC01w_D z<|8GXmNCP#h3J*TaBkSh|9 z1EerQC3YTF_arj1S?qOn_26Rm-snS&p5-bP)onhxe(J^bSud*WX^m}*J!Ja3t?Q9t!!vDUy1Ku3{gY`(jB0ZlwfuPD#nkYLo zP7#sRnY#clYpH~4p9Id@o3LWxRT@#)-pug#<>l9+T8@~w7r2ZE0yqHUGhF0L$x-;u zhYefZ5{f-FFod31FO|Tkq0bd1-d>om;Eq2CGhlR-%zl?!qRzQ`h>>??X*I{(|Mcpi zQl5&pzo~77J!s}czQo8eKzD+FFMRuY^-65rSL6h-ZBBzrS`*05gsMeXh+7i(e49xt zNcE6DrXCX)zM1^EeC5A$@N?40r4F-29hymY!&O>JN<2AG;0-=qPhxqGVH(GD_3gqy zh*3hrx_t}|J?5beB~Q}vaJ0YCMPWe6omc*-5a|WkBH7jEBcEk>2rzvPs6!dlQgo)g ztVFGEbk+qz%GvuG6K7QaGaY$(ggv!-ZNc>Tq%6^LYH8rEBEEsY?XXEyfJ4ejAQI*3 z!bicdnvL08hYmt&_0$d*gNT9(U+0PrF&pE8-lLsrwAk?>X1WAD75B+4gC>#X%jGjL zT&PIm19jrkzgkR+xV6Bgj<6(0lEb5`IhM7;Eo1s^GyvbE$^%0*hOgmh0Zy;*JTT?J zkCVDPP>Hh~ocZu^?h;VC8Ts%u&+AquM+?$LaL8$(bgzxe>%C}#xZ=zK2(Cq|aPkYE zALs8u|Jm7vPk28#DqMP}tymkI0yqCqoqi7`V(z;az|ky^{kp$3bL6Pc=v*5?QHo!# zYr%+J6dwf=wb}s|l{t)_n@iz#;2g3lmm!xjN_QjzKA)dgMTV{#8Ch%s!?%8p9v41u zSTvDY$$MNpOP1-{W$E<%TQ$F!#$a{9>T`sbLVu&16xtu56$!F}V)G0O$SIE_oQl>Hg;J zE>9t+R=t6)us)e~PZ6H=LtQf<-5nbR_{dZFJw_Ts)Fzs?g#y*SaXfYs3g8!XfE_W? zXe0c-4!K^8ObDuPp;fL2O*$A}?kxc~0vxOKSj#ByKS|0ks9L4-2ms_kfW2}803h!% z0xJ5wK7R;)rpTGuoyx`hJx(R3X5 z*+Nb&24B#+)kYedy%%yNB|(b=mIlrBTI%R-MU}97mDD#kYU=LP)Lp5mJCl<)C#LRB zja?m@y4%&Y|Eq%!UXEG}B*vkZrCSNvJ zd&1s;(I>#NF?cze)`DzIRhemKl~!-lAFda%yvxbtbH|&S9BVbE?pBkEc8*a)G6&4X zp%uf!qrdA^PBjLH@X62gM>yY*h2hMl=>( zhKw_q)3=YMY&T5Kyh{Z}(YF>=M0^B~@M*y_rYTwY+C>^dX{elqX(h-({lu~~Wf&KN zU2zGsoeH>m@Xi6pn;HgIk$Vm3Z4TjY?tegm$u$86XbJTQ?{sVc)$KuuyU`2Q$!z6T zb-HQPp$5T+*>(KegjY=8C&`Nwh8JAWc3^EW8&1WIf$+%~Ut%dc7T~u&6$1$?4{=4< znQp|Jg(*Q9M}B4FEbtwhzH)Ll);k>81v?c#Z|bWuPpnng>$3DA-rMf@8A`%4NENST zYv2QdU-1|E_4OhK{vQLTYG@rn<@@W}(F~@w_RRqRfW9buW+m8qBxKWA4-H9(n}Q;A zgCgOGKsW>d#D63>1QGzeOKC{{`{~i-#8dG%5a~BP1T)}`e~b1+#ft&ZlE`*3-ZE3E ziG^?iYYUg)b=_6FSJ)OwR@(Aye<4y6tGyML>Ck3mVv-5Wh`_kFjRr#SOr72gtK6MJ zq-K@m;r68a-kU1(QN6$_QtBNMXh?| z&ooS>%!%y662G@|3_f9lz)UNZ7okFhd`bGXjAS~x8xJ;BTlFi(kNo{Y-XH%Q1MqTCuYs7G3OoiDOek=otb zxBmz+6h*0Rt1dXRv=x03=FpOoU8lJ|F8SJH4}Iob30XX$xS_Q4UoS*0lhcf&rp(qo zbkbdxWo|eKj&k7_@ruBF-HBLtN4wsV_@zzUHWXcxM6I>fC8AEGf|j;}h$9uW3=}0~ z0^H607UP9PG0T5TaHgex-@ATUHqP|8eo5(>wUY{3+=j@LRm#~;8G9_!sUsPfgSWK` z4&ME-RZH>D0i^c61C`c7Ihgm?EC-+|5%ZmTsv#;ymA?Pdf=0W5&9HBv+Uq9V+zcr% zdhcC;;b@s}tT<+Bp`}Y>n_L>t&SiIDnRpbJCE6cNUHe`WLz`|viPN8cgjO)uaZ!QL zuNkCfDR@(ScWpYy&=miHog3pSlU_tS?^iiHJSzXhc>5W$CG><~+=|#!Q%8c+4u`lN zm;sZC*vYCPga}uMd?c4AC~s?s>>%6Ga89vn2Au)W1K5csnKYUm%&Z(@ zJ5Ts*s)?;JTebbas?~gv#B3lO2Y@0m0PugN;$Kho#M*W8DQwaz_aEmW_shKlUcg^T ziSTE1s!d&INiyxAdD5SL0ie@gIY~VI6Rki$$}-l@08TaWf@Ej~kVW(L6qLWM;o>T( zmrb=NxXL;iz?_7)K7sDqg&?41HiLVs9#e3U-0_}yBMerd9SCuu( z3t_5-F4W{vuR_J;X($Tk;iGU&DO?6W|K*-}?opv|@;^KWi^|ea6wbhh?*Vp*6Fl+U zyz>~V?K`XVO-Z?|oy{?Z_n^tSxP=lkofTL^=6o4e)3mF%Y~t0Gqf6zAH~4FmsA{ z-6L2*!T>Y_?GiR|r(@xPL2{2dHr+~#_@&H?QrJiri%x%8x;zAt(JA`IE}pj%zNPeH z>u($D5H%Te4vlra!qjzbzkX|6@HDcO@r&+9?1<2a7AyrB(~v2zbGD_*MSMbS}{<#jm)HL zr98c02XSdKs~YMGlN?aU-c!I+#yOLN7R%PJm}-_G0ka@GE$;=_cW86@)xq1|+TMXi zet@XtS{R%Sn+1Bdkio$jvjtG0LH416+1%&PLm45$Xnyb>EkT0IeoSpS9xqN{hq~p?888`* zuH2el&X--T(}a+@-I7_3WE>PQbd|!p+ap@b&RfALrd^-?+y0_``yo7MKkcPXWu*1> zVnvJ{LrWvJEgWB@>EGCwR5qLOt~mi}nb44vaM9sL$wntD&3-rUTLAJjcYI`c-;)?N zoX-od-qWvv&s1Xup?rcdPCs^@hCT04rl4~Q7^tDxXW#S^q$%I5$+w7yqWKwC z7m#RiMs&sI0gdAV!0Avg^UfR{yn+S_T_)O*-n+dR2VRgjE>HgqgK!ft30v?3z+HdyD_xsK=6X!1QM0|7 zLnU$i#NotYPzT;7B-93J3DNIC#r>&#lA=~1zv2AeCP<_IK@^>|9jha@*N|ca;@}V6 zRSZlRP2^mn(L~An)7hBk)jZzI_2E;_>q7-L5FbJf4fnS_{{p@;2PVv!B@Ss?`<&ZbL%mS@&3j!|Z-Vh`T6C6@+ z39R$cup?OyjwpM`D)sR~$|=7F&?lQ6Je>h?QARI^{I`InUx236t^6Zq=$%zt3T!7W zvE+!6nKPa^d(RA3e!Sa6UYX~ou(MhK^}W1xpb1xp{}o6R{l_Lf&$_E%Pm}l*p9ntR zqIRV8BLSnAw(9UBoGoO|e5K*Bt}0Z(=AV6eVWC}%`PE~-JxK(+APMbH*@-^<@Yjq? zL)XpN2eiWV#`L1Lb^Yrq7)hfK$6Lxr85su!?GTxY^PYk!ugbiz3 zHcWw+VxFGT;5V{7?O?7hQC&+hW;$W{C!q;f7W4LR{gCh$YP$h_>3SB1xNm)(b698XXBwoPJbDLFH&Gtv8!n zQnhe`2=|I3iCj!S1b-%{DG| zdEtt!IvilVHCb3sxJfzMWv14^l_|z9Ci6^EMk1yuih={^$bJD!n60LwS~#H6EI^?U zQ2@LFzybfCvjq-~&c&Za{$zC&-NCP~g&+LZB41z2kc6beHy{`|-U?KE&hr%$XnB!7 zfz7!|w*_^wg3}5%2K5#V`XpSHA{)|+$t|fTVjmy;;_F(|^Rj`}AczON=9oQrVNu?{ z4WWS~%Pr`F7@|56I%0(f2NBvu%BsyyUP$X$_;|$D#WJMxczxGBozFyR%p#5ULQmN0 z&Qcm{{6)<%oT)Wm`TUZw1C&K4n_q9c7S*99w)&cLOB6C@7*5n0?-z;|TG;#*(4gx_ z#rqv}&n7E^L=^HR3qw4?pR@%wGC}-Z>A=${u+J3iY5vD+8-wp1i?`m-!VxHck;%jv zVrq#xlHHhQwPhjM46ZXA)`5U5qPi$Tit)MkRRHgLUE)|nMt5LWvw&oFO)vI+dUxa~ z+)C2;e$;401=5(#YHW9ic$YGQxK8RGECh*(u-040=`(?)s#`c5UTH}|A1rY!MHWnB zOfs(NK*V)jBtmR0IYxGrji+$LkJN&wgCFpL4WY>#b5i@)Sz;zYpt!%7_8HTUv_l~X z!MfL;eI4FI&H~pQ;%hqZm)y3NZ5X>^i+^18G#Ca8a&Iz~PEiyOY-WuA^pb{7*8Wmy zFY!4m4lXS<&G|g955YY3Sz8(=+zyywV@o~Nuuk-T!*<=8;eyfYMy=DRbvwN8=hu$~ zD4C$@1So2l!wGsV#qSgfZUw-H1L|@9F%rzTXCRFAFMrIPp6O=ms7O^+Ky1r#Z5W?H zrdyzV3kpJK24Qu#(ONg+&c<47{~dc7TPI@{&1W3-BXUz|m49M$XUf19c&dYa|CGJ;qMH56M!3`76}Nd<_&u zE&0bn-U-rUH7A^P@_VLf(v8E#_u#KXKk|vJ%+cy*UavDHhY@>wZXJTOE*bhexAA88 z3Ak+^u4k|1Lf2ZU>+?B3PlI5zQ?ozI8St>k|6QxH!2e_pKX?supeP>UCm2V8&QJ6m zEDRR6tza02c7Qw+*h8pjU#?Ks$Z9p_k^`nJjLiBB{qJ9`LiW zZ>$dQq^(4S{fKPs1D5fjVV3teJURk|Ju$GOKwM4DhUNb{vBL8@MdZ|2$%I@@PVA(g`^ZjW$x%#SS`t*DM7k@+0Pi4kW2ibN-UAoKbRE2;Q$gMqhDP z>wt$ZIbM6Bf>Mz3)y{$BpkodQ@9#v0vGuImpTsiUAlT`oEpGGHKZzooZ_1H*V!^2B1i#+lkkhwQW|Hs@p|woLMR>TM>+G3Q2Q9ymhz;;eR<(lm7n z`^s8sJEm#m?Ejjw(s4@B%-a1rX074m*nFu+n4a5mlBAgj)KU_WH8Ax%UYnlGKvgH^ywsV>%Sa-2)=cX^^T8`N7SVOHHi;&5`yPEKd)CdCl23v#8RTlu zh`k{LtLSJOgR$wr;8B&m%eRuLt8@_A0K z=ZUmVob!~IJmn=Xy3hHpVt&K_GXQ=CaD(*^{1^Z%ncRC|W5i7eGJl`Y%moDIxsXv8 z>4J&X4U&?8y}V?wMUpE_Ff*wuz7Cf1vXN5ki@(y9g^s#<>Q;iIx#9A{MpCrvL8;!< z?*NkM%ie|IYa0O_(GWA4E3{nsZx9Ln^7F2T!qCZa?J(`UMym15=izrDz*cQ&`}e*? zG?~3Be?gF8!i7Jg0R56CZ6BQMUh+hlnT_>7ay=LkUALhYTxp}NB-D0I&b!bZr0El! zQpj=&0LhvqIHr)}>Y9 z(u=`#R~%QqoEo>L-*s;c(2kIHx09R!DuVin8Nr(?F3R?y3wpUProlwiqDHN>ISY7Z z{6b+{@8{`|HJp8-Tzsip(TdkS^B_qbf1_pUa|g`$oc@0R9V$>LXcvG005JaB$gx&< zas>XDfG$B`4sp~~hDbO|)1}xs+=hN4`(aZI0YUey{tS>rN6Tg!7T=Chkq~eWTzHG{ zN;o?~&VYb{To)js93F{O*L4YsQ20Vj`uJ5t*G?N7BH60POSKu4M#9_3OMX-{kp&Iw$7!6R?VrjS~eDz z>vvVhsYpOM%77a*grDj)ry5{S2lU2KrA4xX_x zFm~R-3_{(!XTXuCLOWf#vt}lDaMBEq3UXdYQT9GT)v50BvRE=JChERV_Na@qt#svd z5d?E9=JU*YOFBu3^_7mfgro4Yy_ZI+)m>UOI z#t(b}Y(PWJdFn><{+>L);KwT#0<5`%k;BqW%G2-2*`r?!e-Z6O0p7bbth3dYQX?RLq z4^?bGp$JdQWM8{c{#1iNxMaU&5FH-rGX8NyXl2`13qc{;cOn9OxqSlz9tk0e-MMYh@N$Fe$7Ye~a{iBY#vS)TCvM%gR?!5uLBzeUmi zDRY!aQf9KG-s8^sD-QHih^=6PKfLQ7vm~LG||9YCUw+&p`gSAx|H4b|+lB!MEi_iy?a*^A>6KU{{15gBHGE z7mFNJWliOCBypE<(Sg4O_N4n{H~+MVnBLXz9CjZ&->j)C69-o=mc~J5mN|Rqm~;kB z6cjeeb#^Pt{&FPPQ%Pi;T4L>SN}SHk49!8q=nqI~`yXfnVble#re$n6l`rQ)EpL$37?)HlClrFf*y_NeaFQ^Tl zs1M}(c8WW4^|d1P%6!K8F`e-{YOo=(%;tzRY!4O9oG{2*YoCFC!N1WNWM+y{CHnh{ zZ{^X1oaG5CJ{fi&O?Li2I?Bv23MxHK59YzYekw8LXBm|ZE;TRxN4*^5SH~m5;6Lr< znDV%`%7p)#?0ZZFqf3>9uI5Z(4D?1?iDu4FeONF0(kAOMr+?#`&rTBn0JL-n_}Axt zk;@#K&)4b9;uAe;CKwCB{bD3{5y-2T5EdgpKdAtA5$-L=D_ySnm7lH4=OlnodsL>- z-_X{(S$!M#Cp-yZ$m)tn3T?tQR1wu!S1_$!%if=IK2xXWbJ4g^SfY4oUj&BwnUgeA zfi zXw2kh44*0~@ccn0Yu4C&TUo=+qd9vKm5gLw=t2Fv(w1Z8>-Vv%;xpd>j+O!ybMInV z0YnsP2GXmF_d%UoPX8E1j*c-eEXe28RI0wljD`tR^B3Fc?g>_(2K)A1PGEgYz}*

d!v=?a62zAO=+bp3&+*vi- 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 z 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�a(WzLPag%9Shk+^ybMLRl-(&5W4$dh_iCpe2joXVg-ZfPEIGOzCQH#U2( zixZSwOylj-$Yb;0eqx&vMG`K?(OC@u(#%6yWmdCHIYC&<W7bJRNd}z6InnB`iDsdfp zSFWKk3A;N-AmI&3b%npKrepF-Lq}M0Rr=Lg!X6m@&XBl)K&aGRk2lbX)|9=yoI*&9 zXqMXT@2diK=&}?JmRlA&?J*Mp5TEn_bvvXodVEa)Ffe^jK zc)f-KAqb)d7L?scrmjaT(Pf67?W~Ds#J*)eiba*dL0|DTFalgGJZ~rjy(8UoTwq(w z@5g%k@U`KNYh50b^bAFm?_ud6)?_0~;Rm8NJt{YcLYX^yfz(k875_Q2H7U_zE_L;b zwbji->34Y99TG?id5pG*3I#KN8L5a)E;N_o6cn&Ki?3HD5A7t$Wg16gFRyXEfcuHa zbmvi48KWM*C2t2Z{5emAI`td$bpx=Pa_`k4_m-GlhVDEKlrgmYS3n++{S~>itxv2c zfsSdjlPmODkZf7Y#t(ANR(ZX%pRBTZ?dB(~j$WGkxP&j&^j+h((N9NC@bvLvwR0xX zRCAX%xZ0wcfPEP9?KxLUZ^RknzT)ttee(KnU~5#<;Bx0@4#D;Ig?UNN8CkN#foyN9 zN+cz3^vnyTt=Ea5GD1X>N=KVoWv%Zl#gH&^aL$22XypxF19#mQ{peL?{9KAhU97H9U_ZNajnuT!g5Y9N6J9z$-fS5BpOUi`$b} zx=Mx1`ZEt)#?X8+WXoP3y;Ji5rUJkzy(o9bzboxJ)se4NKliOeSAFmp4t$q=#(E5# z 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 `y_;#9b}(n2{#)2k^9>#je=P;!z>z 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

*t%BnG=%X=9rGm=y;YS8XoJJFB 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 literal 0 HcmV?d00001 diff --git a/packages/app/e2e/mobile/modal-sheet/gestures.android.ad b/packages/app/e2e/mobile/modal-sheet/gestures.android.ad new file mode 100644 index 0000000000..083e766bcf --- /dev/null +++ b/packages/app/e2e/mobile/modal-sheet/gestures.android.ad @@ -0,0 +1,52 @@ +# Install plugin-examples/modal-ui on the connected host and set EXAMPLES_URL to +# paseo://h//plugin/modal-ui-example/surface/main. +# Gesture coordinates target a 1080x1920 Android emulator at density 420. +context platform=android timeout=120000 retries=0 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" "${EXAMPLES_URL}" +wait "label=\"Open ScrollView\"" 20000 +press "label=\"Open ScrollView\"" +wait "label=\"Row 1\"" 10000 +gesture pan 540 980 0 820 500 +wait "label=\"Open ScrollView\"" 3000 + +# The caller-owned ScrollView expands with a body drag and reaches its final row. +press "label=\"Open ScrollView\"" +wait "label=\"Row 1\"" 10000 +gesture pan 540 980 0 -600 500 +wait "label=\"Row 10\"" 3000 +scroll bottom +wait "label=\"Row 100\"" 3000 +press "role=\"button\" label=\"Row 100\"" +press "label=\"Close\"" +wait "text=\"Selected: Row 100\"" 3000 + +# FlatList has the same body dismissal, then supports refs after expansion. +press "label=\"Open FlatList\"" +wait "label=\"Row 1\"" 10000 +gesture pan 540 1100 0 700 500 +wait "label=\"Open FlatList\"" 3000 +press "label=\"Open FlatList\"" +wait "label=\"Row 1\"" 10000 +gesture pan 540 1140 0 -750 500 +wait "label=\"Row 9\"" 3000 +press "label=\"Jump to last row\"" +wait "label=\"Row 100\"" 3000 +# Dragging down while scrolled moves the list, leaving the sheet open. +gesture pan 540 650 0 900 500 +wait "label=\"Row 88\"" 3000 +is visible "label=\"Close\"" +press "label=\"Close\"" +wait "label=\"Open Horizontal\"" 3000 + +# Horizontal tabs still receive gestures inside the host-owned vertical body. +press "label=\"Open Horizontal\"" +wait "label=\"Overview\"" 10000 +gesture pan 960 980 -880 0 500 +wait stable 300 5000 +press "role=\"button\" label=\"System logs\"" +wait stable 300 5000 +press "label=\"Close\"" +wait "text=\"Selected: System logs\"" 3000 +close diff --git a/packages/app/e2e/mobile/modal-sheet/model.android.ad b/packages/app/e2e/mobile/modal-sheet/model.android.ad new file mode 100644 index 0000000000..8a2d534b7b --- /dev/null +++ b/packages/app/e2e/mobile/modal-sheet/model.android.ad @@ -0,0 +1,22 @@ +# FORM_URL is paseo://new?serverId=. +# Gesture coordinates target a 1080x1920 Android emulator at density 420. +context platform=android timeout=60000 retries=0 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" "${FORM_URL}" +wait "id=\"combined-model-selector\"" 20000 +press "id=\"combined-model-selector\"" +wait "id=\"agent-controls-model\"" 10000 +gesture pan 540 790 0 960 500 +wait "id=\"combined-model-selector\"" 3000 + +# Reopen, dismiss the nested model browser, then dismiss its restored parent. +press "id=\"combined-model-selector\"" +wait "id=\"agent-controls-model\"" 10000 +press "id=\"agent-controls-model\"" +wait stable 300 5000 +gesture pan 540 790 0 960 500 +wait "id=\"agent-controls-model\"" 3000 +gesture pan 540 790 0 960 500 +wait "id=\"combined-model-selector\"" 3000 +close diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 99c3b5117f..31f3f2f9c8 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -449,6 +449,7 @@ export interface AdaptiveModalSheetProps { desktopMaxWidth?: number; /** Bound an author-owned list without changing content-sized first-party dialogs. */ desktopHeight?: DimensionValue; + /** Whether the host supplies the scroll container. Caller-owned lists still share sheet gestures. */ scrollable?: boolean; presentation?: "push" | "replace"; /** Full body viewport below the header, including space beyond the content. */ @@ -646,9 +647,6 @@ export function AdaptiveModalSheet({ onDismiss={handleDismiss} backdropComponent={renderBackdrop} enablePanDownToClose - // A custom scroll owner must also own body gestures. Gorhom otherwise - // locks even imperative list offsets until the sheet reaches its top snap. - enableContentPanningGesture={scrollable} backgroundComponent={SheetBackground} handleIndicatorStyle={handleIndicatorStyle} keyboardBehavior="extend" diff --git a/plugin-examples/modal-ui/README.md b/plugin-examples/modal-ui/README.md index 96cbbd8b78..0f1e429d18 100644 --- a/plugin-examples/modal-ui/README.md +++ b/plugin-examples/modal-ui/README.md @@ -13,11 +13,15 @@ cd packages/app npx playwright test e2e/browser/plugin-modal-body.spec.ts --project=browser --workers=1 ``` -On Android, open each example and swipe through its content. In FlatList, use **Jump to last row** -before expanding the sheet; row 100 should be visible. Drag the handle to expand the sheet. In Form, +On Android, open each example and drag up on its content to expand the sheet, then scroll the list. +In FlatList, expand before using **Jump to last row**; row 100 should be visible. At the top of either +list, drag down on a row to dismiss the sheet. In Form, press **Copy text**, long-press the input, and choose **Paste**. The input should contain “Copied from Paseo”. With the system keyboard enabled, focusing the input should keep it visible. +Run the [native sheet regression](../../packages/app/e2e/mobile/modal-sheet/README.md) to check body +dismissal, list scrolling and horizontal tabs together. + These captures show Android copy/paste and custom padding on browser and wide native layouts. Android API 35 and Chromium were exercised; iOS and Electron were not tested. diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index d934679768..a4529d013a 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -366,8 +366,7 @@ intact. The host reserves bottom safe-area space on compact native layouts; sett to zero removes the decorative inset, not that space. Keyboard clearance is handled separately. With `scrollable={false}`, the body fills the available sheet height, and the centered dialog uses -85% of the available height. Use `flex: 1, minHeight: 0` on your list. In this mode the body scrolls -at every sheet height; drag the handle to resize or dismiss the sheet. Default scrolling dialogs stay +85% of the available height. Use `flex: 1, minHeight: 0` on your list. Default scrolling dialogs stay content-sized on wide layouts. Presentation follows window size, including narrow desktop windows and wide tablets. @@ -387,6 +386,11 @@ Use one vertical scroll owner: either the default modal body, or your own list w `scrollable={false}`. A fixed-height vertical list nested inside the default scrolling body can compete with the sheet for gestures on Android. Horizontal scrolling can coexist with the host's vertical body. +Both the default body and SDK lists share native sheet gestures: drag up to expand before scrolling; +drag down at the top of the list to collapse or dismiss. `scrollable={false}` removes the host's scroll +container without changing these gestures. Expand the sheet before using list methods such as +`scrollToEnd`; the sheet locks list offsets below its largest height. + ```tsx import { FlatList, Modal } from "@getpaseo/plugin/react-native"; import { Text } from "react-native"; From 9c8d775895872932e1a8ef0e5c2dda182f2e8228 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 7 Sep 2026 19:17:42 +0200 Subject: [PATCH 027/566] Let plugins customize agent creation and observe lifecycle events (#4435) * feat(plugins): expose agent and workspace lifecycle hooks * test(plugins): verify lifecycle examples with version requirements * fix(plugins): publish archive hooks for closed agents * test(plugins): remove diagnostic catch from lifecycle test * refactor(plugins): define hook handlers in lifecycle module --- docs/plugins.md | 11 + packages/plugin/src/contracts.ts | 3 +- packages/plugin/src/index.ts | 11 + packages/plugin/src/lifecycle.ts | 88 +++++ packages/plugin/src/server.ts | 11 + .../server/src/server/agent/agent-manager.ts | 85 ++++- packages/server/src/server/bootstrap.ts | 10 + .../plugins/agent-configuration.e2e.test.ts | 56 +++ packages/server/src/server/plugins/index.ts | 14 + .../plugins/lifecycle-archive.e2e.test.ts | 56 +++ .../src/server/plugins/lifecycle.e2e.test.ts | 262 ++++++++++++++ .../server/plugins/lifecycle.real.e2e.test.ts | 341 ++++++++++++++++++ .../server/plugins/lifecycle/handlers.test.ts | 100 +++++ .../src/server/plugins/lifecycle/index.ts | 273 ++++++++++++++ .../server/plugins/plugin-process-protocol.ts | 24 +- .../src/server/plugins/plugin-process.ts | 52 ++- packages/server/src/server/plugins/runtime.ts | 66 ++++ packages/server/src/server/session.test.ts | 4 + packages/server/src/server/session.ts | 8 + .../workspace-provisioning-service.ts | 5 + plugin-examples/agent-configuration/README.md | 26 ++ .../agent-configuration/index.server.ts | 30 ++ .../agent-configuration/paseo-plugin.json | 6 + plugin-examples/lifecycle-actions/README.md | 32 ++ .../lifecycle-actions/index.server.ts | 95 +++++ .../lifecycle-actions/paseo-plugin.json | 6 + .../lifecycle-actions/server/inspect.test.ts | 20 + .../lifecycle-actions/server/inspect.ts | 28 ++ plugin-examples/lifecycle-logger/README.md | 31 ++ .../lifecycle-logger/index.server.ts | 53 +++ .../lifecycle-logger/paseo-plugin.json | 6 + .../lifecycle-logger/server/log.ts | 14 + public-docs/plugins/v0.8/reference.md | 321 +++++++++++++++++ skills/paseo-plugin/SKILL.md | 40 +- 34 files changed, 2163 insertions(+), 25 deletions(-) create mode 100644 packages/plugin/src/lifecycle.ts create mode 100644 packages/server/src/server/plugins/agent-configuration.e2e.test.ts create mode 100644 packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts create mode 100644 packages/server/src/server/plugins/lifecycle.e2e.test.ts create mode 100644 packages/server/src/server/plugins/lifecycle.real.e2e.test.ts create mode 100644 packages/server/src/server/plugins/lifecycle/handlers.test.ts create mode 100644 packages/server/src/server/plugins/lifecycle/index.ts create mode 100644 plugin-examples/agent-configuration/README.md create mode 100644 plugin-examples/agent-configuration/index.server.ts create mode 100644 plugin-examples/agent-configuration/paseo-plugin.json create mode 100644 plugin-examples/lifecycle-actions/README.md create mode 100644 plugin-examples/lifecycle-actions/index.server.ts create mode 100644 plugin-examples/lifecycle-actions/paseo-plugin.json create mode 100644 plugin-examples/lifecycle-actions/server/inspect.test.ts create mode 100644 plugin-examples/lifecycle-actions/server/inspect.ts create mode 100644 plugin-examples/lifecycle-logger/README.md create mode 100644 plugin-examples/lifecycle-logger/index.server.ts create mode 100644 plugin-examples/lifecycle-logger/paseo-plugin.json create mode 100644 plugin-examples/lifecycle-logger/server/log.ts diff --git a/docs/plugins.md b/docs/plugins.md index 3dcf5716e1..76214a6dca 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -262,6 +262,17 @@ optional client-owned agent and workspace navigation; its absence is the compati older clients. Other navigation remains limited to registered global surfaces and workspace panels. Plugins do not receive Expo Router or workspace-layout store access. +## Lifecycle hooks + +Server entries register lifecycle observers with `server.on()` and request transforms with +`server.before()`. The [public reference](../public-docs/plugins/v0.8/reference.md#lifecycle-hooks) +owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all +eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks. + +Emit from the operation owner, not a client subscription. Provider history replay must not trigger +live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or +answer a permission through its own daemon session. Awaiting it there deadlocks that command. + ## Contribute a provider Register a provider from `index.server.ts`. The provider connection is callback-based and owns all diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index 628d8ee355..3e06485f19 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -1,3 +1,4 @@ +import type { PluginLifecycleRegistration } from "./lifecycle.js"; import type { ComponentType } from "react"; import type { PaseoApi } from "@getpaseo/client"; import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types"; @@ -319,7 +320,7 @@ export interface PluginHandlerContext { paseo: PaseoApi; } -export interface PluginServerContext { +export interface PluginServerContext extends PluginLifecycleRegistration { registerSettings( definition: import("./settings.js").SettingsDefinition, ): void; diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 2f0229acc8..272524de39 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -66,3 +66,14 @@ export type { export { usePaseo } from "./paseo-context.js"; export { useAgent, useWorkspace } from "./client-state.js"; export { useRpc } from "./rpc-context.js"; + +export type { + PluginBeforeRequests, + PluginHookAgent, + PluginHookContext, + PluginHookWorkspace, + PluginLifecycleEvents, + PluginLifecycleRegistration, + PluginSessionOpenRequest, + PluginTurnOutcome, +} from "./lifecycle.js"; diff --git a/packages/plugin/src/lifecycle.ts b/packages/plugin/src/lifecycle.ts new file mode 100644 index 0000000000..9115145cde --- /dev/null +++ b/packages/plugin/src/lifecycle.ts @@ -0,0 +1,88 @@ +import type { + AgentPermissionRequest, + AgentPermissionResponse, + AgentTimelineItem, + AgentSessionConfig, +} from "@getpaseo/protocol/agent-types"; +import type { PaseoApi } from "@getpaseo/client"; +import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages"; + +export interface PluginHookContext { + paseo: PaseoApi; + signal: AbortSignal; +} + +export interface PluginHookWorkspace { + id: string; + projectId: string; + cwd: string; + name: string | null; + archivedAt: string | null; +} + +export interface PluginHookAgent { + id: string; + workspaceId: string | null; + parentAgentId: string | null; + provider: string; + cwd: string; + title: string | null; +} + +export interface PluginSessionOpenRequest { + agentId: string; + workspaceId: string | null; + provider: string; + cwd: string; + reason: "create" | "resume" | "refresh" | "import"; + purpose: "interactive" | "history"; + env: Record; +} + +export type PluginTurnOutcome = + | { kind: "completed" } + | { kind: "failed"; error: { message: string; code?: string } } + | { kind: "canceled"; reason: string }; + +export interface PluginLifecycleEvents { + "agent.turn_started": { agent: PluginHookAgent; turnId: string | null }; + "agent.turn_ended": { + agent: PluginHookAgent; + turnId: string | null; + outcome: PluginTurnOutcome; + timeline: readonly AgentTimelineItem[]; + }; + "agent.permission_requested": { agent: PluginHookAgent; request: AgentPermissionRequest }; + "agent.permission_resolved": { + agent: PluginHookAgent; + requestId: string; + resolution: AgentPermissionResponse; + }; + "agent.archived": { agent: PluginHookAgent; archivedAt: string }; + "agent.created": { agent: PluginHookAgent }; + "workspace.created": { workspace: PluginHookWorkspace }; + "workspace.archived": { workspace: PluginHookWorkspace }; +} + +export interface PluginBeforeRequests { + "agent.create": { config: AgentSessionConfig; env?: Record }; + "agent.session_open": PluginSessionOpenRequest; + "workspace.create": Omit; +} + +export interface PluginLifecycleRegistration { + on( + name: Name, + handler: ( + event: PluginLifecycleEvents[Name], + context: PluginHookContext, + ) => void | Promise, + ): () => void; + before( + name: Name, + handler: ( + input: { request: PluginBeforeRequests[Name] }, + context: PluginHookContext, + ) => PluginBeforeRequests[Name] | void | Promise, + ): () => void; +} diff --git a/packages/plugin/src/server.ts b/packages/plugin/src/server.ts index 8aa3325a83..69c8cc6c4c 100644 --- a/packages/plugin/src/server.ts +++ b/packages/plugin/src/server.ts @@ -1 +1,12 @@ export type { PluginHandlerContext } from "./contracts.js"; + +export type { + PluginBeforeRequests, + PluginHookAgent, + PluginHookContext, + PluginHookWorkspace, + PluginLifecycleEvents, + PluginLifecycleRegistration, + PluginSessionOpenRequest, + PluginTurnOutcome, +} from "./lifecycle.js"; diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 4d3c1447f7..d16f0cf4cf 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1,3 +1,6 @@ +import type { PluginLifecycle } from "../plugins/lifecycle/index.js"; +import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js"; +import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server"; import { randomUUID } from "node:crypto"; import { basename, resolve } from "node:path"; import { stat } from "node:fs/promises"; @@ -286,6 +289,7 @@ export interface CreateAgentOptions { } export interface AgentManagerOptions { + pluginLifecycle?: PluginLifecycle; clients?: ProviderClientMap; providerDefinitions?: ProviderEnabledMap; idFactory?: () => string; @@ -685,6 +689,7 @@ function detachedAgentLabelPatch(labels: Record): AgentLabelPatc } export class AgentManager { + private readonly pluginLifecycle: PluginLifecycle | undefined; private readonly clients = new Map(); private readonly providerEnabled = new Map(); private readonly providerDefinitions = new Map(); @@ -725,6 +730,7 @@ export class AgentManager { private acceptingAgentRegistrations = true; constructor(options: AgentManagerOptions) { + this.pluginLifecycle = options.pluginLifecycle; this.idFactory = options?.idFactory ?? (() => randomUUID()); this.registry = options?.registry; this.durableTimelineStore = options?.durableTimelineStore; @@ -1192,6 +1198,14 @@ export class AgentManager { ): Promise { this.assertAcceptingAgentRegistrations(); const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent"); + if (this.pluginLifecycle && !config.internal) { + const request = await this.pluginLifecycle.before("agent.create", { + config, + env: options.env, + }); + config = { ...request.config, internal: config.internal }; + options = { ...options, env: request.env }; + } await this.deleteAgentState(resolvedAgentId); const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( config, @@ -1209,18 +1223,25 @@ export class AgentManager { storedConfig.cwd, paseoToolPolicy, options?.env, + { reason: "create", purpose: "interactive", workspaceId: options.workspaceId ?? null }, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const createOptions = this.buildCreateSessionOptions(options); const session = await client.createSession(providerLaunchConfig, launchContext, createOptions); await this.requireExternalMcpSupport(session, storedConfig); - return this.registerSession(session, storedConfig, resolvedAgentId, { + const agent = await this.registerSession(session, storedConfig, resolvedAgentId, { labels: options.labels, initialTitle: options.initialTitle, workspaceId: options.workspaceId, owner: options.owner, historyPrimed: true, }); + if (!agent.internal) { + this.pluginLifecycle?.emit("agent.created", { + agent: describeHookAgent({ ...agent, title: agent.config.title }), + }); + } + return agent; } private buildCreateSessionOptions(options?: { @@ -1295,6 +1316,12 @@ export class AgentManager { client, storedConfig.cwd, paseoToolPolicy, + undefined, + { + reason: "resume", + purpose: resumeOptions?.purpose ?? "interactive", + workspaceId: options?.workspaceId ?? null, + }, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const session = await client.resumeSession( @@ -1349,6 +1376,8 @@ export class AgentManager { client, storedConfig.cwd, paseoToolPolicy, + undefined, + { reason: "import", purpose: "interactive", workspaceId: input.workspaceId }, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const imported = await client.importSession( @@ -1442,6 +1471,8 @@ export class AgentManager { client, storedConfig.cwd, paseoToolPolicy, + undefined, + { reason: "refresh", purpose: "interactive", workspaceId: existing.workspaceId }, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); if ( @@ -1725,11 +1756,11 @@ export class AgentManager { } private async markRecordArchived(record: StoredAgentRecord): Promise { - const registry = this.requireRegistry(); const archivedAt = new Date().toISOString(); - const archivedRecord = buildArchivedAgentRecord(record, { archivedAt, updatedAt: archivedAt }); - - await registry.upsert(archivedRecord); + const archivedRecord = await this.persistArchivedRecord(record, { + archivedAt, + updatedAt: archivedAt, + }); await this.syncNativeArchiveState(record.provider, record.persistence, "archive"); @@ -1744,6 +1775,21 @@ export class AgentManager { return archivedRecord; } + private async persistArchivedRecord( + record: StoredAgentRecord, + options: { archivedAt: string; updatedAt?: string }, + ): Promise { + const archivedRecord = buildArchivedAgentRecord(record, options); + await this.requireRegistry().upsert(archivedRecord); + if (!record.archivedAt && !record.internal) { + this.pluginLifecycle?.emit("agent.archived", { + agent: describeHookAgent(archivedRecord), + archivedAt: archivedRecord.archivedAt, + }); + } + return archivedRecord; + } + private async fireAgentArchived(agentId: string): Promise { const callback = this.onAgentArchived; if (!callback) { @@ -2015,8 +2061,7 @@ export class AgentManager { throw new Error(`Agent not found: ${agentId}`); } - const nextRecord = buildArchivedAgentRecord(record, { archivedAt }); - await registry.upsert(nextRecord); + const nextRecord = await this.persistArchivedRecord(record, { archivedAt }); await this.syncNativeArchiveState(record.provider, record.persistence, "archive"); @@ -4757,6 +4802,14 @@ export class AgentManager { "agent.manager.dispatch_stream", ); this.dispatch({ type: "agent_stream", agentId, event, ...metadata }); + if (this.pluginLifecycle && agent && !agent.internal && event.type !== "timeline") { + publishAgentStream( + this.pluginLifecycle, + describeHookAgent({ ...agent, title: agent.config.title }), + event, + this.timelineStore.getItems(agentId), + ); + } } private dispatch(event: AgentManagerEvent): void { @@ -4941,7 +4994,25 @@ export class AgentManager { cwd: string, paseoToolPolicy: ProviderPaseoToolsPolicy | undefined, env?: Record, + opening?: { + reason: PluginSessionOpenRequest["reason"]; + purpose: PluginSessionOpenRequest["purpose"]; + workspaceId?: string | null; + }, ): Promise { + if (this.pluginLifecycle) { + const request: PluginSessionOpenRequest = { + agentId, + provider: client.provider, + cwd, + workspaceId: opening?.workspaceId ?? null, + reason: opening?.reason ?? "resume", + purpose: opening?.purpose ?? "interactive", + env: { ...env }, + }; + const transformed = await this.pluginLifecycle.before("agent.session_open", request); + env = transformed.env; + } const context: AgentLaunchContext = { agentId, env: { diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 42121b6404..81aee8aa7f 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1,3 +1,4 @@ +import { describeHookWorkspace } from "./plugins/lifecycle/index.js"; import express from "express"; import { createServer as createHTTPServer, type IncomingMessage, type ServerResponse } from "http"; import { constants, existsSync, unlinkSync } from "fs"; @@ -877,7 +878,15 @@ export async function createPaseoDaemon( forgeOverrides: { github }, }, }); + workspaceRegistry.subscribeToMutations((mutation) => { + if (mutation.kind === "archive" && mutation.workspace) { + pluginRuntime.emit("workspace.archived", { + workspace: describeHookWorkspace(mutation.workspace), + }); + } + }); const workspaceProvisioning = createWorkspaceProvisioningService({ + lifecycle: pluginRuntime, serverId, projectRegistry, workspaceRegistry, @@ -911,6 +920,7 @@ export async function createPaseoDaemon( }); const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState(); const agentManager = new AgentManager({ + pluginLifecycle: pluginRuntime, clients: initialAgentManagerState.clients, providerDefinitions: initialAgentManagerState.providerDefinitions, registry: agentStorage, diff --git a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts new file mode 100644 index 0000000000..adb0c6d2da --- /dev/null +++ b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts @@ -0,0 +1,56 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestAgentClient } from "../test-utils/fake-agent-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +test("the configuration example adds MCP servers and overrides Codex options while preserving other configuration", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-config-example-")); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.8.0", + agentClients: { codex: createTestAgentClient("codex", { supportsMcpServers: true }) }, + mcpEnabled: false, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + try { + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "configuration-example" } }); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin( + fileURLToPath(new URL("../../../../../plugin-examples/agent-configuration", import.meta.url)), + ); + const agent = await client.createAgent({ + provider: "codex", + cwd: directory, + systemPrompt: "Keep the user's instructions.", + providerOptions: { + sandbox_mode: "read-only", + approval_policy: "never", + web_search: "disabled", + }, + mcpServers: { existing: { type: "http", url: "https://existing.example.com/mcp" } }, + }); + expect(daemon.daemon.agentManager.getAgent(agent.id)?.config).toMatchObject({ + provider: "codex", + cwd: directory, + systemPrompt: "Keep the user's instructions.", + providerOptions: { + sandbox_mode: "workspace-write", + approval_policy: "on-request", + web_search: "disabled", + }, + mcpServers: { + existing: { type: "http", url: "https://existing.example.com/mcp" }, + company: { type: "http", url: "https://tools.example.com/mcp" }, + }, + }); + await client.archiveAgent(agent.id); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 459fdc7de8..894b1663c4 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -1,3 +1,4 @@ +import type { PluginLifecycle } from "./lifecycle/index.js"; import path from "node:path"; import { stat, rm } from "node:fs/promises"; import type pino from "pino"; @@ -24,6 +25,8 @@ import { readPluginProviderIcon } from "./provider-icon.js"; const BUILTIN_PROVIDER_ID_SET: ReadonlySet = new Set(BUILTIN_PROVIDER_IDS); interface PluginRuntimePort { + emit?: PluginLifecycle["emit"]; + before?: PluginLifecycle["before"]; catalog: PluginRuntime["catalog"]; invoke(pluginId: string, method: string, input: unknown): Promise; getLogs(pluginId: string): PluginLogEntry[]; @@ -91,6 +94,17 @@ export class PluginService { }); } + readonly emit: PluginLifecycle["emit"] = (name, event) => { + this.runtime.emit?.(name, event); + }; + + readonly before: PluginLifecycle["before"] = async (name, request) => { + if (this.runtime.before) { + return this.runtime.before(name, request); + } + return request; + }; + subscribeSettings(listener: (pluginId: string, settingsId: string) => void): () => void { this.settingsListeners.add(listener); return () => this.settingsListeners.delete(listener); diff --git a/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts b/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts new file mode 100644 index 0000000000..06ced0f347 --- /dev/null +++ b/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts @@ -0,0 +1,56 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +test("workspace archive publishes agent archive hooks for both live and closed agents", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-archive-hooks-")); + const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + try { + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "archive-hooks" } }); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin( + fileURLToPath(new URL("../../../../../plugin-examples/lifecycle-logger", import.meta.url)), + ); + const created = await client.createWorkspace({ + source: { kind: "directory", path: directory }, + }); + expect(created.error).toBeNull(); + const workspace = created.workspace!; + const live = await client.createAgent({ + provider: "claude", + cwd: directory, + workspaceId: workspace.id, + }); + const closed = await client.createAgent({ + provider: "claude", + cwd: directory, + workspaceId: workspace.id, + }); + await daemon.daemon.agentManager.closeAgent(closed.id); + expect(daemon.daemon.agentManager.getAgent(closed.id)).toBeNull(); + await client.archiveWorkspace(workspace.id); + await expect + .poll(async () => { + const logs = await client.getPluginLogs("lifecycle-logger"); + return logs + .filter((entry) => { + return entry.message.startsWith('{"hook":"agent.archived"'); + }) + .map((entry) => { + return JSON.parse(entry.message).data.agent.id; + }) + .sort(); + }) + .toEqual([live.id, closed.id].sort()); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/lifecycle.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.e2e.test.ts new file mode 100644 index 0000000000..f21d1b86e5 --- /dev/null +++ b/packages/server/src/server/plugins/lifecycle.e2e.test.ts @@ -0,0 +1,262 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +test("a plugin transforms workspace creation and observes its committed lifecycle", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-")); + const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "lifecycle", requirements: { paseo: ">=0.8.0" } }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +export default function contribute(server) { + server.before("workspace.create", ({ request }) => { + return { ...request, title: "Created through a hook" }; + }); + server.on("workspace.created", (event) => { + console.log(JSON.stringify({ hook: "workspace.created", event })); + }); + server.on("workspace.archived", (event) => { + console.log(JSON.stringify({ hook: "workspace.archived", event })); + }); + return () => {}; +} +`, + ); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "lifecycle" } }); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + const created = await client.createWorkspace({ + source: { kind: "directory", path: directory }, + }); + expect(created.workspace?.name).toBe("Created through a hook"); + const workspaceId = created.workspace!.id; + await client.archiveWorkspace(workspaceId); + await expect + .poll(async () => { + const logs = await client.getPluginLogs("lifecycle"); + return logs + .filter((entry) => { + return entry.message.startsWith('{"hook":'); + }) + .map((entry) => { + return JSON.parse(entry.message); + }); + }) + .toMatchObject([ + { hook: "workspace.created", event: { workspace: { id: workspaceId, cwd: directory } } }, + { hook: "workspace.archived", event: { workspace: { id: workspaceId } } }, + ]); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); + +test("plugins observe turns, answer permissions, and observe archive without blocking the agent", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-turn-hooks-")); + const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "turn-hooks", requirements: { paseo: ">=0.8.0" } }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +export default function contribute(server) { + server.on("agent.turn_started", (event) => { + console.log(JSON.stringify({ hook: "agent.turn_started", event })); + }); + server.on("agent.turn_ended", (event) => { + console.log(JSON.stringify({ hook: "agent.turn_ended", event })); + }); + server.on("agent.permission_requested", async (event, context) => { + console.log(JSON.stringify({ hook: "agent.permission_requested", event })); + await context.paseo.agents.ref(event.agent.id).respondToPermission({ + requestId: event.request.id, + response: { behavior: "deny", message: "Declined by plugin" }, + }); + }); + server.on("agent.permission_resolved", (event) => { + console.log(JSON.stringify({ hook: "agent.permission_resolved", event })); + }); + server.on("agent.archived", (event) => { + console.log(JSON.stringify({ hook: "agent.archived", event })); + }); + return () => {}; +} +`, + ); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "turn-hooks" } }); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + const agent = await client.createAgent({ + provider: "claude", + cwd: directory, + title: "Turn hooks", + modeId: "default", + }); + await client.sendMessage(agent.id, "Run rm -f permission.txt"); + await expect + .poll( + async () => { + const logs = await client.getPluginLogs("turn-hooks"); + return logs + .filter((entry) => { + return entry.message.startsWith('{"hook":'); + }) + .map((entry) => { + return JSON.parse(entry.message); + }) + .sort((left, right) => { + return left.hook.localeCompare(right.hook); + }); + }, + { timeout: 10_000 }, + ) + .toMatchObject([ + { + hook: "agent.permission_requested", + event: { request: { input: { command: "rm -f permission.txt" } } }, + }, + { hook: "agent.permission_resolved", event: { resolution: { behavior: "deny" } } }, + { + hook: "agent.turn_ended", + event: { outcome: { kind: "completed" }, timeline: expect.any(Array) }, + }, + { hook: "agent.turn_started", event: { agent: { id: agent.id } } }, + ]); + await client.archiveAgent(agent.id); + await expect + .poll(async () => { + const logs = await client.getPluginLogs("turn-hooks"); + return logs.filter((entry) => { + return entry.message.startsWith('{"hook":"agent.archived"'); + }).length; + }) + .toBe(1); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); + +test("agent creation hooks change the provider and environment before the session opens", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-agent-hooks-")); + const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "agent-hooks", requirements: { paseo: ">=0.8.0" } }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +export default function contribute(server) { + server.before("agent.create", ({ request }) => { + return { ...request, config: { ...request.config, provider: "codex" }, env: { ...request.env, HOOK_CREATE: "created" } }; + }); + server.before("agent.session_open", ({ request }) => { + console.log(JSON.stringify({ hook: "agent.session_open", request })); + return { ...request, env: { ...request.env, HOOK_OPEN: "opened" } }; + }); + server.on("agent.created", (event) => { + console.log(JSON.stringify({ hook: "agent.created", event })); + }); + return () => {}; +} +`, + ); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "agent-hooks" } }); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + const agent = await client.createAgent({ + provider: "claude", + cwd: directory, + title: "Hook test", + }); + expect(agent.provider).toBe("codex"); + await expect + .poll(async () => { + const logs = await client.getPluginLogs("agent-hooks"); + return logs + .filter((entry) => { + return entry.message.startsWith('{"hook":'); + }) + .map((entry) => { + return JSON.parse(entry.message); + }); + }) + .toMatchObject([ + { + hook: "agent.session_open", + request: { agentId: agent.id, env: { HOOK_CREATE: "created" } }, + }, + { hook: "agent.created", event: { agent: { id: agent.id, provider: "codex" } } }, + ]); + await client.archiveAgent(agent.id); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); + +test("invalid output from an untyped plugin rejects creation before later callbacks run", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-invalid-hook-")); + const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "invalid-hook", requirements: { paseo: ">=0.8.0" } }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +export default function contribute(server) { + server.before("workspace.create", () => { + return { source: { kind: "worktree", branchName: 42 } }; + }); + server.before("workspace.create", ({ request }) => { + console.log("Unexpected later callback"); + return request; + }); + return () => {}; +} +`, + ); + await client.connect(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + const result = await client.createWorkspace({ source: { kind: "directory", path: directory } }); + expect(result.workspace).toBeNull(); + expect(result.error).toContain("Plugin invalid-hook before workspace.create failed"); + const logs = await client.getPluginLogs("invalid-hook"); + expect( + logs.some((entry) => { + return entry.message.includes("Unexpected later callback"); + }), + ).toBe(false); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts new file mode 100644 index 0000000000..7dad32752e --- /dev/null +++ b/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts @@ -0,0 +1,341 @@ +import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, readlink, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import pino from "pino"; +import { expect, test } from "vitest"; +import type { PluginBeforeRequests, PluginLifecycleEvents } from "@getpaseo/plugin/server"; +import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; +import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +type RecordedHook = + | { + [Name in keyof PluginLifecycleEvents]: { hook: Name; data: PluginLifecycleEvents[Name] }; + }[keyof PluginLifecycleEvents] + | { + [Name in keyof PluginBeforeRequests]: { + hook: `before ${Name}`; + data: { request: PluginBeforeRequests[Name] }; + }; + }[keyof PluginBeforeRequests]; + +const repoRoot = fileURLToPath(new URL("../../../../../", import.meta.url)); +const evidenceDirectory = path.join(repoRoot, ".dev", "lifecycle-proof"); + +async function readHooks(client: DaemonClient): Promise { + const logs = await client.getPluginLogs("lifecycle-logger"); + const events: RecordedHook[] = []; + for (const entry of logs) { + if (entry.message.startsWith('{"hook":')) { + events.push(JSON.parse(entry.message) as RecordedHook); + } + } + return events; +} + +async function endedTurns(client: DaemonClient, agentId: string) { + const events = await readHooks(client); + return events.filter((event): event is Extract => { + return event.hook === "agent.turn_ended" && event.data.agent.id === agentId; + }); +} + +async function waitForTurns(client: DaemonClient, agentId: string, count: number): Promise { + await expect + .poll( + async () => { + return (await endedTurns(client, agentId)).length; + }, + { timeout: 120_000, interval: 250 }, + ) + .toBe(count); +} + +async function waitForPermission(client: DaemonClient, agentId: string, command: string) { + let requestId = ""; + await expect + .poll( + async () => { + const events = await readHooks(client); + for (const event of events) { + if ( + event.hook === "agent.permission_requested" && + event.data.agent.id === agentId && + event.data.request.input?.command === command + ) { + requestId = event.data.request.id; + } + } + return requestId; + }, + { timeout: 120_000, interval: 250 }, + ) + .not.toBe(""); + return requestId; +} + +test.skipIf(process.platform !== "linux")( + "all lifecycle hooks and the example actions work with a real Claude agent on an isolated daemon", + async () => { + await mkdir(evidenceDirectory, { recursive: true }); + const fixture = await mkdtemp(path.join(tmpdir(), "paseo-real-hooks-")); + const project = path.join(fixture, "project"); + await mkdir(path.join(project, ".claude"), { recursive: true }); + await writeFile( + path.join(project, ".claude", "settings.json"), + JSON.stringify({ permissions: { ask: ["Bash"] } }), + ); + await writeFile(path.join(project, "canary.txt"), "preserve me\n"); + execFileSync("git", ["init", "-b", "main"], { cwd: project, stdio: "pipe" }); + execFileSync("git", ["add", "."], { cwd: project }); + execFileSync( + "git", + [ + "-c", + "user.name=Lifecycle Test", + "-c", + "user.email=lifecycle@example.invalid", + "commit", + "-m", + "Fixture", + ], + { cwd: project, stdio: "pipe" }, + ); + const destination = pino.destination({ + dest: path.join(evidenceDirectory, "daemon.log"), + sync: true, + }); + const logger = pino({ level: "info" }, destination); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.8.0", + logger, + agentClients: { + claude: new ClaudeAgentClient({ logger }), + codex: new CodexAppServerAgentClient(logger), + }, + mcpEnabled: false, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.8.0", + }); + const proof: Record = { + port: daemon.port, + paseoHome: daemon.paseoHome, + startedAt: new Date().toISOString(), + }; + try { + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "real-hooks" } }); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(path.join(repoRoot, "plugin-examples/lifecycle-logger")); + await client.installDirectoryPlugin(path.join(repoRoot, "plugin-examples/lifecycle-actions")); + const created = await client.createWorkspace({ + title: "Isolated lifecycle example", + source: { kind: "directory", path: project }, + }); + expect(created.error).toBeNull(); + const workspace = created.workspace!; + expect(workspace.workspaceKind).toBe("worktree"); + expect(workspace.workspaceDirectory).not.toBe(project); + proof.workspace = workspace; + const agent = await client.createAgent({ + provider: "codex", + cwd: workspace.workspaceDirectory, + workspaceId: workspace.id, + title: "Real lifecycle example", + }); + expect(agent.provider).toBe("claude"); + proof.agent = { id: agent.id, requestedProvider: "codex", actualProvider: agent.provider }; + + await client.sendMessage( + agent.id, + "This is a hook integration test. Reply exactly: out of credits. If the next user message is Try again., reply exactly RETRY_OK instead. Do not use tools.", + ); + await waitForTurns(client, agent.id, 2); + const retryTurns = await endedTurns(client, agent.id); + expect(assistantText(retryTurns[1].data.timeline)).toBe("RETRY_OK"); + proof.followup = "RETRY_OK"; + + await client.sendMessage( + agent.id, + "Call the Bash tool with exactly git status. Do not combine it with other commands. Then reply STATUS_OK.", + ); + await waitForTurns(client, agent.id, 3); + const approvalId = await waitForPermission(client, agent.id, "git status"); + expect(await readHooks(client)).toContainEqual( + expect.objectContaining({ + hook: "agent.permission_resolved", + data: expect.objectContaining({ + requestId: approvalId, + resolution: expect.objectContaining({ behavior: "allow" }), + }), + }), + ); + proof.approvedRequestId = approvalId; + + await client.sendMessage( + agent.id, + "Call the Bash tool with exactly rm -rf canary.txt. If declined, do not try any other command or tool. Reply DECLINED and stop.", + ); + await waitForTurns(client, agent.id, 4); + const deniedId = await waitForPermission(client, agent.id, "rm -rf canary.txt"); + expect(await readHooks(client)).toContainEqual( + expect.objectContaining({ + hook: "agent.permission_resolved", + data: expect.objectContaining({ + requestId: deniedId, + resolution: expect.objectContaining({ behavior: "deny" }), + }), + }), + ); + expect(await readFile(path.join(workspace.workspaceDirectory, "canary.txt"), "utf8")).toBe( + "preserve me\n", + ); + proof.deniedRequestId = deniedId; + proof.canaryPreserved = true; + + const envCommand = 'printf \'%s|%s\' "$PASEO_HOOK_CREATE_EXAMPLE" "$PASEO_HOOK_OPEN_EXAMPLE"'; + await client.sendMessage( + agent.id, + `Call Bash with exactly this command: ${envCommand}. Reply with its exact output only.`, + ); + const envPermission = await waitForPermission(client, agent.id, envCommand); + await client.respondToPermission(agent.id, envPermission, { behavior: "allow" }); + await waitForTurns(client, agent.id, 5); + const envTurns = await endedTurns(client, agent.id); + expect(assistantText(envTurns[4].data.timeline)).toBe("created|opened"); + proof.environmentOutput = "created|opened"; + + await client.refreshAgent(agent.id); + await daemon.daemon.agentManager.closeAgent(agent.id); + await client.sendMessage(agent.id, "Reply exactly RESUMED. Do not use tools."); + await waitForTurns(client, agent.id, 6); + + await client.sendMessage( + agent.id, + "Write a detailed 2000-word essay about binary trees. Do not use tools.", + ); + await expect + .poll( + async () => { + return (await readHooks(client)).filter((event) => { + return event.hook === "agent.turn_started" && event.data.agent.id === agent.id; + }).length; + }, + { timeout: 30_000 }, + ) + .toBe(7); + await client.cancelAgent(agent.id); + await waitForTurns(client, agent.id, 7); + expect((await endedTurns(client, agent.id))[6].data.outcome.kind).toBe("canceled"); + await client.archiveAgent(agent.id); + + const failed = await client.createAgent({ + provider: "claude", + model: "haiku", + cwd: workspace.workspaceDirectory, + workspaceId: workspace.id, + title: "Expected runtime crash", + }); + await client.sendMessage(failed.id, "Reply READY. Do not use tools."); + await waitForTurns(client, failed.id, 1); + const rows = execFileSync("ps", ["-eo", "pid=,ppid=,args="], { encoding: "utf8" }).split( + "\n", + ); + const providerPids: number[] = []; + for (const row of rows) { + const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(row); + if (match && Number(match[2]) === process.pid && match[3].includes("claude")) { + const cwd = await readlink(`/proc/${match[1]}/cwd`); + if (cwd === workspace.workspaceDirectory) { + providerPids.push(Number(match[1])); + } + } + } + expect(providerPids).toHaveLength(1); + const providerPid = providerPids[0]; + await client.sendMessage( + failed.id, + "Write a detailed 2000-word essay about binary trees. Do not use tools.", + ); + process.kill(providerPid, "SIGKILL"); + await waitForTurns(client, failed.id, 2); + expect((await endedTurns(client, failed.id))[1].data.outcome.kind).toBe("failed"); + proof.crashedProviderPid = providerPid; + await client.archiveAgent(failed.id); + await client.archiveWorkspace(workspace.id); + await expect + .poll(async () => { + const names = new Set( + (await readHooks(client)).map((event) => { + return event.hook; + }), + ); + return [...names].sort(); + }) + .toEqual([ + "agent.archived", + "agent.created", + "agent.permission_requested", + "agent.permission_resolved", + "agent.turn_ended", + "agent.turn_started", + "before agent.create", + "before agent.session_open", + "before workspace.create", + "workspace.archived", + "workspace.created", + ]); + const openReasons = (await readHooks(client)) + .filter((event) => { + return event.hook === "before agent.session_open"; + }) + .map((event) => { + return event.data.request.reason; + }); + expect(openReasons).toContain("create"); + expect(openReasons).toContain("refresh"); + expect(openReasons).toContain("resume"); + proof.passed = true; + } finally { + proof.finishedAt = new Date().toISOString(); + await writeFile(path.join(evidenceDirectory, "proof.json"), JSON.stringify(proof, null, 2)); + const logs = await client.getPluginLogs("lifecycle-logger").catch(() => { + return []; + }); + await writeFile( + path.join(evidenceDirectory, "hooks.jsonl"), + logs + .filter((entry) => { + return entry.message.startsWith('{"hook":'); + }) + .map((entry) => { + return entry.message; + }) + .join("\n") + "\n", + ); + await client.close(); + await daemon.close(); + destination.end(); + await rm(fixture, { recursive: true, force: true }); + } + }, + 600_000, +); + +function assistantText(timeline: readonly AgentTimelineItem[]): string { + let text = ""; + for (const item of timeline) { + if (item.type === "user_message") { + text = ""; + } else if (item.type === "assistant_message") { + text += item.text; + } + } + return text; +} diff --git a/packages/server/src/server/plugins/lifecycle/handlers.test.ts b/packages/server/src/server/plugins/lifecycle/handlers.test.ts new file mode 100644 index 0000000000..5e30535953 --- /dev/null +++ b/packages/server/src/server/plugins/lifecycle/handlers.test.ts @@ -0,0 +1,100 @@ +import { expect, test } from "vitest"; +import { createPaseoApi } from "@getpaseo/client"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { PluginHookHandlers } from "./index.js"; + +const paseo = createPaseoApi( + new DaemonClient({ url: "ws://127.0.0.1:1/ws", clientId: "lifecycle-unit" }), +); + +test("removing an old registration twice preserves a newer registration for the same hook", async () => { + const hooks = new PluginHookHandlers(() => {}); + const remove = hooks.before("workspace.create", ({ request }) => { + return { ...request, title: "old" }; + }); + remove(); + hooks.before("workspace.create", ({ request }) => { + return { ...request, title: "new" }; + }); + remove(); + const output = await hooks.invoke( + "operation", + "before", + "workspace.create", + { + source: { kind: "directory", path: "/project" }, + }, + paseo, + ); + expect(output).toEqual({ source: { kind: "directory", path: "/project" }, title: "new" }); +}); + +test("before hooks compose returned requests and preserve the original input", async () => { + const hooks = new PluginHookHandlers(() => {}); + hooks.before("workspace.create", ({ request }) => { + return { ...request, title: "first" }; + }); + hooks.before("workspace.create", () => { + return; + }); + hooks.before("workspace.create", ({ request }) => { + return { ...request, title: request.title + ":second" }; + }); + const input = { source: { kind: "directory", path: "/project" } }; + expect(await hooks.invoke("operation", "before", "workspace.create", input, paseo)).toEqual({ + source: { kind: "directory", path: "/project" }, + title: "first:second", + }); + expect(input).toEqual({ source: { kind: "directory", path: "/project" } }); +}); + +test("teardown aborts an active callback and removes its registrations", async () => { + const hooks = new PluginHookHandlers(() => {}); + hooks.before("workspace.create", async (_input, context) => { + await new Promise((_resolve, reject) => { + context.signal.addEventListener( + "abort", + () => { + reject(new Error("Hook aborted")); + }, + { once: true }, + ); + }); + }); + const invocation = hooks.invoke( + "operation", + "before", + "workspace.create", + { + source: { kind: "directory", path: "/project" }, + }, + paseo, + ); + hooks.close(); + await expect(invocation).rejects.toThrow("Hook aborted"); + expect(hooks.catalog()).toEqual({ events: [], before: [] }); +}); + +test("session-open hooks reject changes to session identity instead of silently ignoring them", async () => { + const hooks = new PluginHookHandlers(() => {}); + hooks.before("agent.session_open", ({ request }) => { + return { ...request, provider: "another-provider" }; + }); + await expect( + hooks.invoke( + "operation", + "before", + "agent.session_open", + { + agentId: "agent", + workspaceId: "workspace", + provider: "claude", + cwd: "/project", + reason: "resume", + purpose: "interactive", + env: {}, + }, + paseo, + ), + ).rejects.toThrow("agent.session_open hooks can only change env"); +}); diff --git a/packages/server/src/server/plugins/lifecycle/index.ts b/packages/server/src/server/plugins/lifecycle/index.ts new file mode 100644 index 0000000000..766ccc0bce --- /dev/null +++ b/packages/server/src/server/plugins/lifecycle/index.ts @@ -0,0 +1,273 @@ +import type { AgentStreamEvent, AgentTimelineItem } from "../../agent/agent-sdk-types.js"; +import { z } from "zod"; +import { CreateAgentRequestMessageSchema } from "@getpaseo/protocol/messages"; +import type { + PluginHookAgent, + PluginHookContext, + PluginLifecycleRegistration, +} from "@getpaseo/plugin/server"; +import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import type { + PluginBeforeRequests, + PluginHookWorkspace, + PluginLifecycleEvents, +} from "@getpaseo/plugin/server"; +import { WorkspaceCreateRequestSchema } from "@getpaseo/protocol/messages"; +import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; + +export const lifecycleEventNames = [ + "agent.created", + "agent.turn_started", + "agent.turn_ended", + "agent.permission_requested", + "agent.permission_resolved", + "agent.archived", + "workspace.created", + "workspace.archived", +] as const; +export const beforeHookNames = ["agent.create", "agent.session_open", "workspace.create"] as const; + +const beforeSchemas = { + "agent.create": CreateAgentRequestMessageSchema.pick({ config: true, env: true }).strict(), + "agent.session_open": z + .object({ + agentId: z.string(), + workspaceId: z.string().nullable(), + provider: z.string(), + cwd: z.string(), + reason: z.enum(["create", "resume", "refresh", "import"]), + purpose: z.enum(["interactive", "history"]), + env: z.record(z.string(), z.string()), + }) + .strict(), + "workspace.create": WorkspaceCreateRequestSchema.omit({ type: true, requestId: true }).strict(), +}; + +export interface PluginLifecycle { + emit( + name: Name, + event: PluginLifecycleEvents[Name], + ): void; + before( + name: Name, + request: PluginBeforeRequests[Name], + ): Promise; +} + +export function validateBeforeRequest( + name: Name, + value: unknown, +): PluginBeforeRequests[Name] { + return beforeSchemas[name].parse(value) as PluginBeforeRequests[Name]; +} + +export function describeHookWorkspace(workspace: PersistedWorkspaceRecord): PluginHookWorkspace { + return { + id: workspace.workspaceId, + projectId: workspace.projectId, + cwd: workspace.cwd, + name: workspace.title, + archivedAt: workspace.archivedAt, + }; +} + +export function describeHookAgent(agent: { + id: string; + workspaceId?: string; + provider: string; + cwd: string; + title?: string | null; + labels: Record; +}): PluginHookAgent { + return { + id: agent.id, + workspaceId: agent.workspaceId ?? null, + parentAgentId: agent.labels[PARENT_AGENT_ID_LABEL] ?? null, + provider: agent.provider, + cwd: agent.cwd, + title: agent.title ?? null, + }; +} + +export function publishAgentStream( + lifecycle: PluginLifecycle, + agent: PluginHookAgent, + event: AgentStreamEvent, + timeline: readonly AgentTimelineItem[], +): void { + if (event.type === "turn_started") { + lifecycle.emit("agent.turn_started", { agent, turnId: event.turnId ?? null }); + } else if (event.type === "turn_completed") { + lifecycle.emit("agent.turn_ended", { + agent, + turnId: event.turnId ?? null, + timeline, + outcome: { kind: "completed" }, + }); + } else if (event.type === "turn_failed") { + lifecycle.emit("agent.turn_ended", { + agent, + turnId: event.turnId ?? null, + timeline, + outcome: { kind: "failed", error: { message: event.error, code: event.code } }, + }); + } else if (event.type === "turn_canceled") { + lifecycle.emit("agent.turn_ended", { + agent, + turnId: event.turnId ?? null, + timeline, + outcome: { kind: "canceled", reason: event.reason }, + }); + } else if (event.type === "permission_requested") { + lifecycle.emit("agent.permission_requested", { agent, request: event.request }); + } else if (event.type === "permission_resolved") { + lifecycle.emit("agent.permission_resolved", { + agent, + requestId: event.requestId, + resolution: event.resolution, + }); + } +} + +export function validateBeforeResult( + name: Name, + input: PluginBeforeRequests[Name], + output: unknown, +): PluginBeforeRequests[Name] { + const result = validateBeforeRequest(name, output); + if (name === "agent.session_open") { + const previous = beforeSchemas["agent.session_open"].parse(input); + const next = beforeSchemas["agent.session_open"].parse(result); + if ( + previous.agentId !== next.agentId || + previous.workspaceId !== next.workspaceId || + previous.provider !== next.provider || + previous.cwd !== next.cwd || + previous.reason !== next.reason || + previous.purpose !== next.purpose + ) { + throw new Error("agent.session_open hooks can only change env"); + } + } + if (name === "agent.create") { + const previous = beforeSchemas["agent.create"].parse(input); + const next = beforeSchemas["agent.create"].parse(result); + if (previous.config.cwd !== next.config.cwd) { + throw new Error("agent.create hooks cannot change the workspace directory"); + } + } + return result; +} + +type Handler = (input: unknown, context: PluginHookContext) => unknown; + +export class PluginHookHandlers implements PluginLifecycleRegistration { + private readonly events = new Map>(); + private readonly transforms = new Map>(); + private readonly active = new Map(); + + private readonly changed: () => void; + + constructor(changed: () => void) { + this.changed = changed; + } + + readonly on: PluginLifecycleRegistration["on"] = (name, handler) => { + if (!lifecycleEventNames.includes(name)) { + throw new Error(`Unknown lifecycle event: ${name}`); + } + return this.register(this.events, name, handler as Handler); + }; + + readonly before: PluginLifecycleRegistration["before"] = (name, handler) => { + if (!beforeHookNames.includes(name)) { + throw new Error(`Unknown before hook: ${name}`); + } + return this.register(this.transforms, name, handler as Handler); + }; + + catalog(): { events: string[]; before: string[] } { + return { events: [...this.events.keys()], before: [...this.transforms.keys()] }; + } + + async invoke( + id: string, + kind: "event" | "before", + name: string, + input: unknown, + paseo: PluginHookContext["paseo"], + ): Promise { + const controller = new AbortController(); + this.active.set(id, controller); + try { + if (kind === "before") { + if ( + !beforeHookNames.includes( + name as keyof import("@getpaseo/plugin/server").PluginBeforeRequests, + ) + ) { + throw new Error(`Unknown before hook: ${name}`); + } + const hookName = name as keyof import("@getpaseo/plugin/server").PluginBeforeRequests; + let request = validateBeforeRequest(hookName, input); + for (const handler of this.transforms.get(name) ?? []) { + controller.signal.throwIfAborted(); + const result = await handler( + { request: structuredClone(request) }, + { paseo, signal: controller.signal }, + ); + if (result !== undefined) { + request = validateBeforeResult(hookName, request, result); + } + } + return request; + } + for (const handler of this.events.get(name) ?? []) { + controller.signal.throwIfAborted(); + try { + await handler(structuredClone(input), { paseo, signal: controller.signal }); + } catch (error) { + console.error(`Lifecycle hook ${name} failed`, error); + } + } + return null; + } finally { + this.active.delete(id); + } + } + + cancel(id: string): void { + this.active.get(id)?.abort(); + } + + close(): void { + for (const controller of this.active.values()) { + controller.abort(); + } + this.events.clear(); + this.transforms.clear(); + } + + private register( + registry: Map>, + name: string, + handler: Handler, + ): () => void { + let handlers = registry.get(name); + if (!handlers) { + handlers = new Set(); + registry.set(name, handlers); + } + handlers.add(handler); + this.changed(); + return () => { + if (!handlers.delete(handler)) { + return; + } + if (handlers.size === 0) { + registry.delete(name); + } + this.changed(); + }; + } +} diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index 41462b999e..80d925652a 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -29,6 +29,8 @@ export type PluginProcessRequest = providerId: string; options: ProviderCatalogOptions; } + | { type: "hook"; requestId: string; kind: "event" | "before"; name: string; input: unknown } + | { type: "hook.cancel"; requestId: string } | { type: "invoke"; requestId: string; method: string; input: unknown } | { type: "provider.connect"; @@ -49,7 +51,13 @@ export type PluginProcessRequest = export type PluginProcessMessage = | { type: "settings.changed"; settingsId: string } - | { type: "ready"; methods: string[]; providers: PluginProviderMetadata[] } + | { type: "hooks.changed"; hooks: { events: string[]; before: string[] } } + | { + type: "ready"; + methods: string[]; + providers: PluginProviderMetadata[]; + hooks?: { events: string[]; before: string[] }; + } | { type: "result"; requestId: string; output: unknown } | { type: "error"; requestId: string; error: string } | { type: "fatal"; error: string } @@ -72,6 +80,8 @@ export type PluginProcessMessage = | { type: "paseo_frame"; data: string | Uint8Array; isBinary: boolean } | { type: "paseo_close" }; +const hooksSchema = z.object({ events: z.array(z.string()), before: z.array(z.string()) }).strict(); + const providerMetadataSchema = z .object({ id: z.string().min(1), @@ -121,6 +131,16 @@ export const PluginProcessRequestSchema: z.ZodType = z.dis ]), }) .strict(), + z + .object({ + type: z.literal("hook"), + requestId: z.string(), + kind: z.enum(["event", "before"]), + name: z.string(), + input: z.unknown(), + }) + .strict(), + z.object({ type: z.literal("hook.cancel"), requestId: z.string() }).strict(), z .object({ type: z.literal("invoke"), @@ -156,11 +176,13 @@ export const PluginProcessMessageSchema: z.ZodType = z.dis "type", [ z.object({ type: z.literal("settings.changed"), settingsId: z.string() }).strict(), + z.object({ type: z.literal("hooks.changed"), hooks: hooksSchema }).strict(), z .object({ type: z.literal("ready"), methods: z.array(z.string()), providers: z.array(providerMetadataSchema), + hooks: hooksSchema.optional(), }) .strict(), z diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 473fcc7e0b..41fe8fcff0 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -1,3 +1,4 @@ +import { PluginHookHandlers } from "./lifecycle/index.js"; import { PluginProcessRequestSchema, type PluginProcessMessage, @@ -48,6 +49,9 @@ interface RegisteredRpc { handler: RpcHandler; } +const hooks = new PluginHookHandlers(() => { + send({ type: "hooks.changed", hooks: hooks.catalog() }); +}); const handlers = new Map(); const providers = new Map(); const providerConnections = new Map< @@ -239,7 +243,13 @@ function evaluateBundle(bundle: string): void { if (typeof setup !== "function") { throw new Error("Plugin server bundle must default export a function"); } - const contributedCleanup = setup({ handle: register, registerProvider, registerSettings }); + const contributedCleanup = setup({ + handle: register, + registerProvider, + registerSettings, + on: hooks.on, + before: hooks.before, + }); if (typeof contributedCleanup !== "function") { throw new Error("Plugin contribution must return a cleanup function"); } @@ -274,6 +284,7 @@ async function initialize(message: Extract left.id.localeCompare(right.id)) .map(providerMetadata), @@ -283,6 +294,7 @@ async function initialize(message: Extract { if (stopping) return; stopping = true; + hooks.close(); for (const pending of pendingProviderConnections.values()) pending.tombstoned = true; const currentCleanup = cleanup; cleanup = null; @@ -397,6 +409,10 @@ process.on("message", (rawMessage: unknown) => { return; } if (message.type === "paseo_frame" || message.type === "paseo_close") return; + if (isHookMessage(message)) { + handleHookMessage(message); + return; + } const registered = handlers.get(message.method); if (!registered) { send({ @@ -418,3 +434,37 @@ process.on("message", (rawMessage: unknown) => { (error) => send({ type: "error", requestId: message.requestId, error: describeError(error) }), ); }); + +function handleHookMessage( + message: Extract, +): void { + if (message.type === "hook.cancel") { + hooks.cancel(message.requestId); + return; + } + if (message.type === "hook") { + if (!paseo) { + send({ + type: "error", + requestId: message.requestId, + error: "Plugin Paseo API is unavailable", + }); + return; + } + void hooks.invoke(message.requestId, message.kind, message.name, message.input, paseo).then( + (output) => { + return send({ type: "result", requestId: message.requestId, output }); + }, + (error) => { + return send({ type: "error", requestId: message.requestId, error: describeError(error) }); + }, + ); + return; + } +} + +function isHookMessage( + message: PluginProcessRequest, +): message is Extract { + return message.type === "hook" || message.type === "hook.cancel"; +} diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 516c320c4f..8902793fcf 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -1,3 +1,5 @@ +import type { PluginBeforeRequests, PluginLifecycleEvents } from "@getpaseo/plugin/server"; +import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.js"; import { fork } from "node:child_process"; import { stat } from "node:fs/promises"; import path from "node:path"; @@ -61,6 +63,7 @@ interface LoadedPlugin { requirements: PluginRequirements | undefined; clientBundle: string; methods: ReadonlySet; + hooks: { events: string[]; before: string[] }; providers: readonly PluginProviderMetadata[]; child: PluginChild | null; outputCapture: PluginOutputCapture | null; @@ -423,6 +426,60 @@ export class PluginRuntime { return this.request(loaded, { type: "invoke", requestId: randomUUID(), method, input }); } + emit( + name: Name, + event: PluginLifecycleEvents[Name], + ): void { + for (const loaded of this.plugins.values()) { + if (!loaded.hooks.events.includes(name)) { + continue; + } + void this.request(loaded, { + type: "hook", + requestId: randomUUID(), + kind: "event", + name, + input: event, + }).catch((error) => { + this.appendLog( + loaded.id, + "stderr", + `Lifecycle hook ${name} failed: ${describeError(error)}`, + ); + }); + } + } + + async before( + name: Name, + input: PluginBeforeRequests[Name], + ): Promise { + let request = validateBeforeRequest(name, input); + const plugins = [...this.plugins.values()].sort((left, right) => { + return left.id.localeCompare(right.id); + }); + for (const loaded of plugins) { + if (!loaded.hooks.before.includes(name)) { + continue; + } + try { + const output = await this.request(loaded, { + type: "hook", + requestId: randomUUID(), + kind: "before", + name, + input: request, + }); + request = validateBeforeResult(name, request, output); + } catch (error) { + throw new Error(`Plugin ${loaded.id} before ${name} failed: ${describeError(error)}`, { + cause: error, + }); + } + } + return request; + } + async getProviderCatalogCacheKey( pluginId: string, providerId: string, @@ -452,6 +509,9 @@ export class PluginRuntime { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { loaded.pending.delete(requestId); + if (message.type === "hook") { + void send(child, { type: "hook.cancel", requestId }).catch(() => {}); + } reject(new Error(`Plugin RPC timed out: ${pluginId}.${message.type}`)); }, REQUEST_TIMEOUT_MS); loaded.pending.set(requestId, { resolve, reject, timeout }); @@ -488,6 +548,7 @@ export class PluginRuntime { clientBundle: bundles.clientBundle ?? "", requirements: manifest.requirements, methods: new Set(), + hooks: { events: [], before: [] }, providers: [], child: null, outputCapture: null, @@ -584,6 +645,7 @@ export class PluginRuntime { clientBundle: bundles.clientBundle ?? "", requirements: manifest.requirements, methods: new Set(ready.methods), + hooks: ready.hooks ?? { events: [], before: [] }, providers: ready.providers ?? [], child, outputCapture, @@ -601,6 +663,10 @@ export class PluginRuntime { } private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void { + if (message.type === "hooks.changed") { + loaded.hooks = message.hooks; + return; + } if (message.type === "settings.changed") { this.dependencies.onSettingsChanged?.(loaded.id, message.settingsId); return; diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index ea16a7f56f..04406b21f0 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -489,6 +489,10 @@ test("routes plugin requests and releases its owned catalog subscription on clea status: "running" as const, }; const pluginRuntime: NonNullable = { + before: async (_name, request) => { + return request; + }, + emit: () => {}, listPlugins: () => [plugin], getLogs: () => [ { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index bc59f68ee5..6a6d9ba030 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -479,6 +479,8 @@ export interface SessionOptions { workspaceAutoName: WorkspaceAutoName; daemonConfigStore: DaemonConfigStore; pluginRuntime?: { + before: import("./plugins/lifecycle/index.js").PluginLifecycle["before"]; + emit: import("./plugins/lifecycle/index.js").PluginLifecycle["emit"]; listPlugins(): import("@getpaseo/protocol/messages").PluginListItem[]; getLogs(pluginId: string): import("@getpaseo/protocol/messages").PluginLogEntry[]; installDirectory(input: { @@ -859,6 +861,7 @@ export class Session { }); this.workspaceAutoName = workspaceAutoName; this.workspaceProvisioning = createWorkspaceProvisioningService({ + lifecycle: this.pluginRuntime, serverId, workspaceRegistry: this.workspaceRegistry, projectRegistry: this.projectRegistry, @@ -6049,6 +6052,11 @@ export class Session { request: Extract, ): Promise { try { + if (this.pluginRuntime) { + const { type, requestId, ...input } = request; + const transformed = await this.pluginRuntime.before("workspace.create", input); + request = { ...transformed, type, requestId }; + } if (request.source.kind === "directory") { await this.handleWorkspaceCreateLocal(request); return; diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index eb9905016d..a89e044883 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -1,3 +1,5 @@ +import type { PluginLifecycle } from "../../plugins/lifecycle/index.js"; +import { describeHookWorkspace } from "../../plugins/lifecycle/index.js"; import { basename, resolve } from "node:path"; import type { Logger } from "pino"; import { @@ -92,6 +94,7 @@ export function createWorkspaceProvisioningService(deps: { projectRegistry: ProjectRegistry; workspaceGitService: Pick; logger: Logger; + lifecycle?: PluginLifecycle; }): WorkspaceProvisioningService { const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps; @@ -216,6 +219,7 @@ export function createWorkspaceProvisioningService(deps: { updatedAt: timestamp, }); await workspaceRegistry.upsert(workspace, context); + deps.lifecycle?.emit("workspace.created", { workspace: describeHookWorkspace(workspace) }); return workspace; } @@ -251,6 +255,7 @@ export function createWorkspaceProvisioningService(deps: { await workspaceRegistry.upsert(workspace, { expectsInitialAgent: input.expectsInitialAgent, }); + deps.lifecycle?.emit("workspace.created", { workspace: describeHookWorkspace(workspace) }); return workspace; } diff --git a/plugin-examples/agent-configuration/README.md b/plugin-examples/agent-configuration/README.md new file mode 100644 index 0000000000..0d283483e2 --- /dev/null +++ b/plugin-examples/agent-configuration/README.md @@ -0,0 +1,26 @@ +# Agent configuration + +Changes new Codex agents through `server.before("agent.create", ...)`: + +- Sets `config.providerOptions.sandbox_mode` to `workspace-write`. +- Sets `config.providerOptions.approval_policy` to `on-request`. +- Adds the `company` server to `config.mcpServers`. +- Preserves other configuration, provider options, MCP servers, and environment overrides. + +Replace `https://tools.example.com/mcp` in `index.server.ts` with your MCP server URL before using +this example. The URL is a placeholder. The `company` entry replaces an existing server with that name. + +Install on a daemon with plugins enabled: + +```bash +paseo plugin install /absolute/path/to/plugin-examples/agent-configuration +``` + +The callback receives the request after earlier plugins have transformed it. The nested spreads +preserve their settings; the explicit values replace only the named fields. Paseo validates and saves +the resulting configuration before starting the agent. Existing agents are unchanged. + +See the [lifecycle reference](../../public-docs/plugins/v0.8/reference.md#lifecycle-hooks) for ordering +and callback shapes. [Lifecycle actions](../lifecycle-actions) demonstrates provider switching and +environment injection. Its provider-switch callback clears provider options when changing Codex to +Claude, so use these examples separately when trying the Codex-specific settings. diff --git a/plugin-examples/agent-configuration/index.server.ts b/plugin-examples/agent-configuration/index.server.ts new file mode 100644 index 0000000000..6d74bae0f8 --- /dev/null +++ b/plugin-examples/agent-configuration/index.server.ts @@ -0,0 +1,30 @@ +import type { PluginServerContext } from "@getpaseo/plugin"; + +export default function contribute(server: PluginServerContext) { + server.before("agent.create", ({ request }) => { + if (request.config.provider !== "codex") { + return request; + } + + return { + ...request, + config: { + ...request.config, + providerOptions: { + ...request.config.providerOptions, + sandbox_mode: "workspace-write", + approval_policy: "on-request", + }, + mcpServers: { + ...request.config.mcpServers, + company: { + type: "http", + url: "https://tools.example.com/mcp", + }, + }, + }, + }; + }); + + return () => {}; +} diff --git a/plugin-examples/agent-configuration/paseo-plugin.json b/plugin-examples/agent-configuration/paseo-plugin.json new file mode 100644 index 0000000000..1a049eda62 --- /dev/null +++ b/plugin-examples/agent-configuration/paseo-plugin.json @@ -0,0 +1,6 @@ +{ + "id": "agent-configuration", + "requirements": { + "paseo": ">=0.8.0" + } +} diff --git a/plugin-examples/lifecycle-actions/README.md b/plugin-examples/lifecycle-actions/README.md new file mode 100644 index 0000000000..0ff23d4cc2 --- /dev/null +++ b/plugin-examples/lifecycle-actions/README.md @@ -0,0 +1,32 @@ +# Lifecycle actions + +Small callbacks using the existing SDK: + +- Send `Try again.` when the latest output contains `out of credits`. +- Decline a shell request matching `rm -rf`; approve exactly `git status`; leave other requests alone. +- Inject `PASEO_HOOK_CREATE_EXAMPLE=created` on creation and `PASEO_HOOK_OPEN_EXAMPLE=opened` on each session opening. +- Change requested Codex agents to Claude Haiku before creation. +- Create a worktree when a directory creation request is titled `Isolated lifecycle example`. + +This is a demonstration plugin: installing it enables all these behaviors on its host. Install it +alongside `lifecycle-logger` on a test daemon with plugins enabled: + +```bash +paseo plugin install /absolute/path/to/plugin-examples/lifecycle-actions +paseo plugin install /absolute/path/to/plugin-examples/lifecycle-logger +paseo plugin logs lifecycle-logger +``` + +Copy the callbacks you need into your own plugin. The follow-up example sends a new message; it does +not replay attachments or undo previous tool effects. If the provider keeps returning the matching +phrase, it keeps sending follow-ups. Add limits or delay in your plugin when your workflow needs them. +The command regex illustrates a policy; it does not parse shell syntax or cover every deletion command. + +`server/inspect.ts` joins provider text chunks and excludes earlier turns and the current user prompt +from the phrase match. The failing-first regression test records the split text observed with a real +Claude agent. + +See [agent configuration](../agent-configuration) for a focused example that injects MCP servers and +changes Codex sandbox and approval options while preserving other settings. + +See the [lifecycle reference](../../public-docs/plugins/v0.8/reference.md#lifecycle-hooks) for the API. diff --git a/plugin-examples/lifecycle-actions/index.server.ts b/plugin-examples/lifecycle-actions/index.server.ts new file mode 100644 index 0000000000..9ca88734b8 --- /dev/null +++ b/plugin-examples/lifecycle-actions/index.server.ts @@ -0,0 +1,95 @@ +import type { PluginServerContext } from "@getpaseo/plugin"; +import { latestOutputText, shellCommand } from "./server/inspect"; + +export default function contribute(server: PluginServerContext) { + server.before("workspace.create", ({ request }) => { + if (request.title === "Isolated lifecycle example" && request.source.kind === "directory") { + return { + ...request, + source: { + kind: "worktree", + cwd: request.source.path, + action: "branch-off", + branchName: "lifecycle-example", + }, + }; + } + return request; + }); + + server.on("agent.turn_ended", async (event, context) => { + if (event.outcome.kind === "canceled") { + return; + } + const text = latestOutputText(event.timeline); + if (/out of credits/i.test(text)) { + const agent = context.paseo.agents.ref(event.agent.id); + console.log("Sending a follow-up after out of credits", event.agent.id); + await agent.send("Try again."); + } + }); + + server.on("agent.permission_requested", async (event, context) => { + const command = shellCommand(event.request); + if (command === null) { + return; + } + + const agent = context.paseo.agents.ref(event.agent.id); + if (/\brm\s+-rf\b/.test(command)) { + await agent.respondToPermission({ + requestId: event.request.id, + response: { + behavior: "deny", + message: "Recursive deletion is blocked by the example plugin.", + }, + }); + return; + } + + if (command.trim() === "git status") { + await agent.respondToPermission({ + requestId: event.request.id, + response: { behavior: "allow" }, + }); + } + }); + + server.before("agent.create", ({ request }) => { + return { + ...request, + env: { + ...request.env, + PASEO_HOOK_CREATE_EXAMPLE: "created", + }, + }; + }); + + server.before("agent.create", ({ request }) => { + if (request.config.provider === "codex") { + return { + ...request, + config: { + ...request.config, + provider: "claude", + model: "haiku", + modeId: "default", + providerOptions: undefined, + }, + }; + } + return request; + }); + + server.before("agent.session_open", ({ request }) => { + return { + ...request, + env: { + ...request.env, + PASEO_HOOK_OPEN_EXAMPLE: "opened", + }, + }; + }); + + return () => {}; +} diff --git a/plugin-examples/lifecycle-actions/paseo-plugin.json b/plugin-examples/lifecycle-actions/paseo-plugin.json new file mode 100644 index 0000000000..fe578fae6d --- /dev/null +++ b/plugin-examples/lifecycle-actions/paseo-plugin.json @@ -0,0 +1,6 @@ +{ + "id": "lifecycle-actions", + "requirements": { + "paseo": ">=0.8.0" + } +} diff --git a/plugin-examples/lifecycle-actions/server/inspect.test.ts b/plugin-examples/lifecycle-actions/server/inspect.test.ts new file mode 100644 index 0000000000..5a70cc4476 --- /dev/null +++ b/plugin-examples/lifecycle-actions/server/inspect.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "vitest"; +import { latestOutputText } from "./inspect"; + +test("matches provider text split across timeline chunks without matching old turns or user prompts", () => { + const output = latestOutputText([ + { type: "user_message", text: "An older request" }, + { type: "assistant_message", text: "old out of credits" }, + { type: "user_message", text: "The user mentions out of credits" }, + { type: "assistant_message", text: "out", messageId: "reply" }, + { type: "assistant_message", text: " of credits", messageId: "reply" }, + ]); + expect(output).toBe("out of credits"); + expect( + latestOutputText([ + { type: "assistant_message", text: "out of credits" }, + { type: "user_message", text: "Try again." }, + { type: "assistant_message", text: "Done" }, + ]), + ).toBe("Done"); +}); diff --git a/plugin-examples/lifecycle-actions/server/inspect.ts b/plugin-examples/lifecycle-actions/server/inspect.ts new file mode 100644 index 0000000000..8df057c2dc --- /dev/null +++ b/plugin-examples/lifecycle-actions/server/inspect.ts @@ -0,0 +1,28 @@ +import type { AgentPermissionRequest, AgentTimelineItem } from "@getpaseo/protocol/agent-types"; + +export function latestOutputText(timeline: readonly AgentTimelineItem[]): string { + let output = ""; + for (const item of timeline) { + if (item.type === "user_message") { + output = ""; + } else if ("text" in item && typeof item.text === "string") { + output += item.text; + } else { + output += JSON.stringify(item) + "\n"; + } + } + return output; +} + +export function shellCommand(request: AgentPermissionRequest): string | null { + if (!request.input) { + return null; + } + if (typeof request.input.command === "string") { + return request.input.command; + } + if (typeof request.input.cmd === "string") { + return request.input.cmd; + } + return null; +} diff --git a/plugin-examples/lifecycle-logger/README.md b/plugin-examples/lifecycle-logger/README.md new file mode 100644 index 0000000000..de943c4d43 --- /dev/null +++ b/plugin-examples/lifecycle-logger/README.md @@ -0,0 +1,31 @@ +# Lifecycle logger + +Registers all eleven server lifecycle hooks and logs their data as JSON. It observes agent creation, +session opening, turns, permissions, archive, and workspace creation/archive. Before hooks return +the request unchanged. Environment values are redacted; prompts and timeline content are logged. + +Install it on a daemon where plugins are enabled: + +```bash +paseo plugin install /absolute/path/to/plugin-examples/lifecycle-logger +paseo plugin logs lifecycle-logger +``` + +The same entries appear in the target daemon's `daemon.log`. The plugin log viewer retains a bounded +tail; use the daemon log for longer captures. Log lines are capped at 16 KiB, so a large timeline can +be truncated. + +See the [lifecycle reference](../../public-docs/plugins/v0.8/reference.md#lifecycle-hooks) for callback +shapes and delivery rules. The real-provider test in +`packages/server/src/server/plugins/lifecycle.real.e2e.test.ts` runs both examples on an isolated daemon +and saves evidence under `.dev/lifecycle-proof/`. + +Run it from `packages/server` on Linux with Claude configured: + +```bash +npx vitest run src/server/plugins/lifecycle.real.e2e.test.ts --bail=1 +``` + +The test uses real provider calls and a temporary daemon home. Its failure check uses Linux `/proc` +to identify and terminate only its own Claude subprocess. The credits check asks the real agent to +emit the matching phrase; it does not exhaust an account's credits. diff --git a/plugin-examples/lifecycle-logger/index.server.ts b/plugin-examples/lifecycle-logger/index.server.ts new file mode 100644 index 0000000000..92f5ca85b5 --- /dev/null +++ b/plugin-examples/lifecycle-logger/index.server.ts @@ -0,0 +1,53 @@ +import type { PluginServerContext } from "@getpaseo/plugin"; +import { logHook } from "./server/log"; + +export default function contribute(server: PluginServerContext) { + server.before("workspace.create", ({ request }) => { + logHook("before workspace.create", { request }); + return request; + }); + + server.on("workspace.created", (event) => { + logHook("workspace.created", event); + }); + + server.on("workspace.archived", (event) => { + logHook("workspace.archived", event); + }); + + server.before("agent.create", ({ request }) => { + logHook("before agent.create", { request }); + return request; + }); + + server.before("agent.session_open", ({ request }) => { + logHook("before agent.session_open", { request }); + return request; + }); + + server.on("agent.created", (event) => { + logHook("agent.created", event); + }); + + server.on("agent.turn_started", (event) => { + logHook("agent.turn_started", event); + }); + + server.on("agent.turn_ended", (event) => { + logHook("agent.turn_ended", event); + }); + + server.on("agent.permission_requested", (event) => { + logHook("agent.permission_requested", event); + }); + + server.on("agent.permission_resolved", (event) => { + logHook("agent.permission_resolved", event); + }); + + server.on("agent.archived", (event) => { + logHook("agent.archived", event); + }); + + return () => {}; +} diff --git a/plugin-examples/lifecycle-logger/paseo-plugin.json b/plugin-examples/lifecycle-logger/paseo-plugin.json new file mode 100644 index 0000000000..8385fbf14c --- /dev/null +++ b/plugin-examples/lifecycle-logger/paseo-plugin.json @@ -0,0 +1,6 @@ +{ + "id": "lifecycle-logger", + "requirements": { + "paseo": ">=0.8.0" + } +} diff --git a/plugin-examples/lifecycle-logger/server/log.ts b/plugin-examples/lifecycle-logger/server/log.ts new file mode 100644 index 0000000000..e6557700c0 --- /dev/null +++ b/plugin-examples/lifecycle-logger/server/log.ts @@ -0,0 +1,14 @@ +export function logHook(hook: string, data: unknown): void { + const entry = { hook, timestamp: new Date().toISOString(), data }; + const line = JSON.stringify(entry, (key, value: unknown) => { + if (key === "env" && value !== null && typeof value === "object") { + const redacted: Record = {}; + for (const name of Object.keys(value)) { + redacted[name] = "[redacted]"; + } + return redacted; + } + return value; + }); + console.log(line); +} diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index a4529d013a..eeb703ae82 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -230,6 +230,327 @@ export default function contribute(client: PluginClientContext) { Cleanup can be async. Release timers, watchers, sockets, and other resources created by the plugin. Paseo also removes registrations, unmounts surfaces, rejects pending RPCs, closes the plugin's daemon session, and stops its subprocess on reload, disable, removal, disconnect, or daemon shutdown. +## Lifecycle hooks + +In `index.server.ts`: + +```ts +import type { PluginServerContext } from "@getpaseo/plugin"; + +export default function contribute(server: PluginServerContext) { + server.on("agent.turn_ended", (event) => { + console.log(event.agent.id, event.outcome); + }); + + return () => {}; +} +``` + +| Register | Callback receives | Return | +| ------------------------------- | ---------------------------------- | ------------------------------------------------------------ | +| `server.on(name, callback)` | `(event, { paseo, signal })` | `void` or `Promise` | +| `server.before(name, callback)` | `({ request }, { paseo, signal })` | Modified request, or `undefined` to keep it; async supported | + +Hooks run on the daemon while the plugin is enabled, even with no app connected. + +### Change configuration and inject an MCP server + +Add this callback inside `contribute(server)`. Replace the placeholder URL with your MCP endpoint. + +```ts +server.before("agent.create", ({ request }) => { + if (request.config.provider !== "codex") { + return request; + } + + return { + ...request, + config: { + ...request.config, + providerOptions: { + ...request.config.providerOptions, + sandbox_mode: "workspace-write", + approval_policy: "on-request", + }, + mcpServers: { + ...request.config.mcpServers, + company: { + type: "http", + url: "https://tools.example.com/mcp", + }, + }, + }, + }; +}); +``` + +| Input | Result | +| ------------------------------------------- | ----------------------------- | +| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | +| `providerOptions.web_search: "disabled"` | Preserved by the spread | +| Existing `mcpServers.search` | Preserved by the spread | +| Existing `mcpServers.company` | Replaced with the entry above | + +The selected provider validates `providerOptions` and must support the configured MCP servers. +Explicit Codex sandbox and approval options override its mode presets. + +### Inject environment variables on every session opening + +```ts +server.before("agent.session_open", ({ request }) => { + return { + ...request, + env: { + ...request.env, + COMPANY_ENV: "development", + }, + }; +}); +``` + +This runs on create, resume, refresh, and import. To inject only on creation, set `env` in an +`agent.create` callback instead. + +### Choose workspace isolation + +```ts +server.before("workspace.create", ({ request }) => { + if (request.source.kind !== "directory") { + return request; + } + + return { + ...request, + source: { + kind: "worktree", + cwd: request.source.path, + action: "branch-off", + }, + }; +}); +``` + +**Result:** an explicit directory creation request becomes a worktree request. Existing workspaces +and directory lookup/import operations are unaffected. + +### Send a follow-up when a turn ends + +Copy [server/inspect.ts](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts) +into your plugin. `latestOutputText` joins text chunks after the latest user message. + +```ts +import type { PluginServerContext } from "@getpaseo/plugin"; +import { latestOutputText } from "./server/inspect"; + +export default function contribute(server: PluginServerContext) { + server.on("agent.turn_ended", async (event, context) => { + if (event.outcome.kind === "canceled") { + return; + } + + const text = latestOutputText(event.timeline); + if (/out of credits/i.test(text)) { + await context.paseo.agents.ref(event.agent.id).send("Try again."); + } + }); + + return () => {}; +} +``` + +```text +Turn ends: "out of credits" + → plugin sends "Try again." + → a new turn starts +``` + +This sends a new message with the existing SDK. Persistent matches keep sending follow-ups; +add limits or delays in your plugin when needed. Attachments and tool effects are not replayed. + +### Answer a permission request + +Using `shellCommand` from the same [helper file](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts): + +```ts +import type { PluginServerContext } from "@getpaseo/plugin"; +import { shellCommand } from "./server/inspect"; + +export default function contribute(server: PluginServerContext) { + server.on("agent.permission_requested", async (event, context) => { + const command = shellCommand(event.request); + if (command === null) { + return; + } + + const agent = context.paseo.agents.ref(event.agent.id); + if (/\brm\s+-rf\b/.test(command)) { + await agent.respondToPermission({ + requestId: event.request.id, + response: { behavior: "deny", message: "Recursive deletion is blocked." }, + }); + return; + } + + if (command.trim() === "git status") { + await agent.respondToPermission({ + requestId: event.request.id, + response: { behavior: "allow" }, + }); + } + }); + + return () => {}; +} +``` + +| Request | Result | +| ------------------------ | ------------------------- | +| `rm -rf build` | Declined | +| `git status` | Approved | +| Other command or request | Left pending for the user | +| Already-resolved request | SDK response fails | + +The regex is an example policy, not a shell parser. Permission requests can also be questions, +plans, and mode changes; requesting permission does not end the turn. + +### Events + +| Name | Event fields | Trigger | +| ---------------------------- | ---------------------------------------- | -------------------------------------------------- | +| `agent.created` | `agent` | Ordinary creation finishes; excludes import/resume | +| `agent.turn_started` | `agent`, `turnId` | Live turn starts | +| `agent.turn_ended` | `agent`, `turnId`, `outcome`, `timeline` | Live turn completes, fails, or is canceled | +| `agent.permission_requested` | `agent`, `request` | Permission or question becomes pending | +| `agent.permission_resolved` | `agent`, `requestId`, `resolution` | Pending request is answered or cleared | +| `agent.archived` | `agent`, `archivedAt` | Archive state is saved | +| `workspace.created` | `workspace` | Record created; directory available | +| `workspace.archived` | `workspace` | Archive state is saved | + +Agent events exclude internal utility agents. Archive events can precede runtime/worktree cleanup; +`workspace.created` is not a setup barrier before agent startup. + +**Shared payload shapes** (`@getpaseo/plugin/server`): + +```ts +interface PluginHookAgent { + id: string; + workspaceId: string | null; + parentAgentId: string | null; + provider: string; + cwd: string; + title: string | null; +} + +interface PluginHookWorkspace { + id: string; + projectId: string; + cwd: string; + name: string | null; + archivedAt: string | null; +} + +type PluginTurnOutcome = + | { kind: "completed" } + | { kind: "failed"; error: { message: string; code?: string } } + | { kind: "canceled"; reason: string }; +``` + +| Field | Shape / meaning | +| ------------ | ----------------------------------------------------------------------------------------------------- | +| `turnId` | Provider-reported `string` or `null`; can repeat after a session reopens | +| `timeline` | `readonly AgentTimelineItem[]`; complete snapshot including earlier conversation; text may span items | +| `request` | SDK `AgentPermissionRequest`; `kind` is `tool`, `plan`, `question`, `mode`, or `other` | +| `resolution` | SDK `AgentPermissionResponse` | +| `archivedAt` | Timestamp string | + +### Before hooks + +| Name | Request fields | Editable | +| -------------------- | ----------------------------------------------------------------------- | --------------------------------------- | +| `agent.create` | `config`, optional `env` | Public agent config except `cwd`; `env` | +| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, `reason`, `purpose`, `env` | Only `env` | +| `workspace.create` | `source`, optional `title`, `firstAgentContext` | Entire explicit creation request | + +**`agent.create.config`** uses `AgentSessionConfig`: + +| Fields | Constraint | +| --------------------------------------------- | -------------------------------------------------------------------------- | +| `provider`, `model` | Separate fields; changing provider may require changing model/mode/options | +| `modeId`, `thinkingOptionId`, `featureValues` | Provider-specific selections | +| `title`, `systemPrompt` | Agent configuration | +| `providerOptions` | Provider-specific validated options | +| `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals | +| `cwd` | Cannot change | +| `internal` | Daemon-owned; cannot change through this hook | + +**`agent.session_open` request example:** + +```json +{ + "agentId": "agent-123", + "workspaceId": "workspace-456", + "provider": "codex", + "cwd": "/projects/shop", + "reason": "resume", + "purpose": "interactive", + "env": { "COMPANY_ENV": "development" } +} +``` + +| Field | Values | +| ------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `workspaceId` | String or `null` | +| `reason` | `create`, `resume`, `refresh`, `import` | +| `purpose` | `interactive`, `history` | +| `env` | Launch override map; excludes the daemon's inherited environment. Replace the map to add, replace, or remove overrides. | + +### Ordering and returned values + +```text +Creation request + → agent.create hooks (plugin-ID order; registration order within each plugin) + → resolve defaults and validate provider configuration + → derive launch configuration with Paseo runtime tools and daemon prompt + → agent.session_open hooks (same ordering; env only) + → set PASEO_AGENT_ID and PASEO_AGENT_CWD + → open provider session and save agent configuration +``` + +| Callback returns | Next callback receives | +| ------------------------------------------------------- | --------------------------------------------------- | +| `{ ...request, env: { ...request.env, REGION: "eu" } }` | Prior request with `REGION` added/replaced | +| `{ ...request, env: { REGION: "eu" } }` | Prior request with the entire override map replaced | +| `undefined` | Unchanged request | +| Throws or returns invalid data | Operation fails; later callbacks do not run | + +No automatic deep merge. Later callbacks can overwrite earlier values. Agent configuration is +saved; environment overrides are not persisted with it. + +### Context and cleanup + +| Contract | Behavior | +| ---------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `context.paseo` | Existing SDK connected to this daemon | +| `context.signal` | Aborted on invocation timeout or plugin stop; pass to external requests | +| Input data | Detached snapshot; change state through returned requests or SDK commands | +| Registration result | Idempotent remover, e.g. `const remove = server.on(...); remove();` | +| Reload, disable, removal, shutdown | Remaining registrations removed | +| Unknown hook name | Registration fails | +| Hook timeout | 30 seconds; aborts the signal. A before hook fails the pending operation; an event handler logs an error. | +| Event-handler error | Logged against plugin; original operation continues | +| Event delivery | Live, best effort; no replay, persistence, or automatic retry | +| Event concurrency | Different events may overlap; callback completion order is not guaranteed | + +### Complete examples + +| Plugin | Includes | +| ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | +| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | All eleven hooks; JSON logs with environment values redacted | +| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | Follow-ups, permissions, environment, provider switching, worktree selection | +| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP injection and Codex sandbox/approval options | + +Read logger output with `paseo plugin logs lifecycle-logger` or the host's `daemon.log`. + ## Surfaces and sidebar items Register a component, then point a sidebar item at its surface ID: diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index 69349fd90c..cc2c4eddc7 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -1,6 +1,6 @@ --- name: paseo-plugin -description: Build and manage trusted local Paseo plugins. Use when the user asks to create, edit, install, reload, enable, disable, remove, or troubleshoot a Paseo plugin; add a native surface, sidebar item, or workspace panel; add Command Center items or slash commands; add composer pills or attachment sources; transform, render, or append agent timeline items; contribute a theme; use Paseo from plugin code; or add plugin RPCs. +description: Build and manage trusted local Paseo plugins. Use when the user asks to create, edit, install, reload, enable, disable, remove, or troubleshoot a Paseo plugin; add lifecycle hooks; transform agent configuration, environment, MCP servers, or workspace creation; automate permissions or turn follow-ups; add a native surface, sidebar item, or workspace panel; add Command Center items or slash commands; add composer pills or attachment sources; transform, render, or append agent timeline items; contribute a theme; use Paseo from plugin code; or add plugin RPCs. --- # Paseo plugins @@ -18,25 +18,35 @@ Fetch [https://paseo.sh/llms.txt](https://paseo.sh/llms.txt) first. Select and f Use the deployed docs when they disagree with this skill. Do not send the user away to read them instead of completing the work. -When working in the Paseo repository, also read `docs/plugins.md` and the relevant example under `plugin-examples/`. +In the Paseo repository, use `public-docs/plugins/v0.8/reference.md` for the checkout's API, including +unreleased changes. Use `docs/plugins.md` for maintainer guidance. Complete contracts belong in the +public docs; this skill indexes the references and examples. ## What a plugin can contribute Pick the contribution that matches the request. Each row names the registration, when it fits, and where the full contract lives. Most plugins combine several: a slash command that calls an RPC, which appends a timeline row, which a renderer draws. -| Contribution | Registration | Use it when | Reference | -| -------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| Sidebar surface | `addSurface` + `addSidebarItem` | A full screen of plugin UI reachable from the sidebar | reference.md → Surfaces and sidebar items; `plugin-examples/local-plugin` | -| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels | -| Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items | -| Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Client slash commands | -| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills | -| Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` | -| Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon | -| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` | -| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` | -| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior | -| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK | +| Contribution | Registration | Use it when | Reference | +| ------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Sidebar surface | `addSurface` + `addSidebarItem` | A full screen of plugin UI reachable from the sidebar | reference.md → Surfaces and sidebar items; `plugin-examples/local-plugin` | +| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels | +| Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items | +| Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Client slash commands | +| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills | +| Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` | +| Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon | +| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` | +| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` | +| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior | +| Lifecycle events | `server.on` | Observe agent/workspace lifecycle, inspect ended turns, and answer permission requests | [Lifecycle hooks](https://paseo.sh/docs/plugins/v0.8/reference.md#lifecycle-hooks) | +| Creation and launch hooks | `server.before` | Change agent config, provider options, MCP servers, environment, or workspace isolation before the operation | [Before hooks](https://paseo.sh/docs/plugins/v0.8/reference.md#before-hooks) | +| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK | + +| Lifecycle task | Example | +| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Log all eleven hooks | [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | +| Follow-ups, permissions, environment, provider switching, worktrees | [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | +| Inject MCP servers and change Codex sandbox/approval options | [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | ## Create the project From c46aee4e6a2b8b077bd8a0074b468d48eccdcb59 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 7 Sep 2026 19:18:54 +0200 Subject: [PATCH 028/566] Revert #4421 and narrow the cache restore fix (#4436) * Revert "Restore cached conversations before reconnecting (#4421)" This reverts commit 1c51b52d079977aea4c9c2b20243d30be112f5f1. * fix(app): restore provider loading on native * fix(app): preserve native navigators across appearance changes Reapply the isolated appearance boundaries and native menu transform origin from #4421 so appearance hydration preserves native navigator and gesture host lifetimes. * fix(app): retain cached baselines during live updates Read the timeline concurrently with agent preparation and reconcile display-only cache rows beneath the live head using the existing reducer. Merge cached directory entries beneath live state while preserving the existing checkpoint race guard. --- docs/architecture.md | 23 +- docs/data-model.md | 18 +- docs/timeline-sync.md | 110 +- .../browser/agent-message-submission.spec.ts | 5 +- .../e2e/browser/composer-autocomplete.spec.ts | 24 +- .../new-workspace-launch-memory.spec.ts | 13 +- .../app/e2e/browser/sidebar-model-b.spec.ts | 5 +- .../support/helpers/agent-timeline-gate.ts | 33 +- .../support/helpers/timeline-pagination.ts | 9 +- .../e2e/support/helpers/timeline-resume.ts | 8 - .../src/app/h/[serverId]/agent/[agentId].tsx | 6 + .../src/composer/draft/create-flow.test.ts | 19 +- .../app/src/composer/draft/create-flow.ts | 25 +- packages/app/src/composer/index.tsx | 5 +- .../src/composer/submission/writer.test.ts | 16 +- .../app/src/composer/submission/writer.ts | 77 +- packages/app/src/contexts/session-context.tsx | 60 +- .../hooks/use-agent-initialization.test.ts | 20 +- .../directory-sync/agent-replica.test.ts | 14 - .../runtime/directory-sync/agent-replica.ts | 70 +- .../src/runtime/directory-sync/index.test.ts | 869 +++---- .../app/src/runtime/directory-sync/index.ts | 252 +- .../directory-sync/internal/agent-store.ts | 20 +- .../directory-sync/workspace-replica.test.ts | 7 +- .../directory-sync/workspace-replica.ts | 9 + packages/app/src/runtime/host-runtime.test.ts | 349 +-- packages/app/src/runtime/host-runtime.ts | 102 +- .../src/runtime/replica-cache/index.test.ts | 550 +---- .../app/src/runtime/replica-cache/index.ts | 871 ++++--- .../runtime/replica-cache/row-store-sqlite.ts | 17 +- .../replica-cache/row-store.contract.ts | 21 +- .../src/runtime/replica-cache/row-store.ts | 2 +- .../replica-cache/row-store.web.test.ts | 45 +- .../runtime/replica-cache/row-store.web.ts | 65 +- .../screens/workspace/workspace-screen.tsx | 33 +- packages/app/src/stores/session-store.test.ts | 218 +- packages/app/src/stores/session-store.ts | 615 ++++- .../legacy-unverifiable-timeline.json | 62 - .../mixed-unpositioned-message.json | 44 - .../fetch-agent-timeline-once.test.ts | 30 + .../src/timeline/fetch-agent-timeline-once.ts | 31 + .../app/src/timeline/replica.contract.test.ts | 2171 ----------------- packages/app/src/timeline/replica.test.ts | 812 ++---- packages/app/src/timeline/replica.ts | 752 ------ .../timeline/session-stream-reducers.test.ts | 856 +------ .../src/timeline/session-stream-reducers.ts | 922 ++++--- .../src/timeline/viewed-timeline-sync.test.ts | 327 +-- .../app/src/timeline/viewed-timeline-sync.ts | 641 +++-- packages/app/src/types/stream.test.ts | 117 - packages/app/src/types/stream.ts | 587 ++--- .../src/utils/agent-directory-sync.test.ts | 100 +- .../app/src/utils/agent-directory-sync.ts | 3 - 52 files changed, 3422 insertions(+), 8638 deletions(-) delete mode 100644 packages/app/src/timeline/__fixtures__/legacy-unverifiable-timeline.json delete mode 100644 packages/app/src/timeline/__fixtures__/mixed-unpositioned-message.json create mode 100644 packages/app/src/timeline/fetch-agent-timeline-once.test.ts create mode 100644 packages/app/src/timeline/fetch-agent-timeline-once.ts delete mode 100644 packages/app/src/timeline/replica.contract.test.ts delete mode 100644 packages/app/src/timeline/replica.ts diff --git a/docs/architecture.md b/docs/architecture.md index e83e3d0539..134e8f3532 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,14 +113,21 @@ Cross-platform React Native app that connects to one or more daemons. - Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete. - Voice features: dictation (STT) and voice agent (realtime) -Consumers request data from host-lived owners without choosing memory, disk or network. Cached rows -satisfy the same readiness projection as network rows, including offline. Directory restoration -merges under live/local changes; network refresh waits for that baseline. Only completed catch-up -advances the directory checkpoint because a live sequence can follow an offline gap. The owner -retains surface demand across reconnects; React does not track connection generations. Storage -commit and scoped-read guarantees belong to [data-model.md](data-model.md#replica-row-store); -timeline display, resume and lifetime contracts belong to -[timeline-sync.md](timeline-sync.md#client-replica-lifetime). +Consumers request directory or timeline data without choosing memory, cache, or network. The owner +publishes an accepted cache hit and then reconciles it over the existing network path. A miss or an +invalid row uses that same path. Offline demand still publishes an accepted cache hit and defers +network reconciliation. Cache loading is demand-driven: opening a chat reads its agent row and +focused timeline row, plus the workspace and project rows needed by the route; opening a directory +reads directory rows for that host and establishes its live subscription when connected. Accepted +cached rows satisfy the same consumer-readiness projection as network rows. Host registry startup and +host connection do not create directory demand or install replicas. The directory owner retains +declared surface demand and re-establishes network reconciliation after reconnect; React does not +track connection generations. Late cache reads cannot replace state already advanced by live or +authoritative network data. Owners explicitly persist accepted commits; directory rows and their +checkpoint share one storage transaction. See +[data-model.md](data-model.md#replica-row-store) +for the storage shape and [timeline-sync.md](timeline-sync.md#client-replica-lifetime) for timeline +resume behavior. The three directory entity types have independent monotonic sequences and share one daemon generation. The daemon retains only the latest projection per entity and bounded tombstones, not an diff --git a/docs/data-model.md b/docs/data-model.md index 7e05a6d1de..5bd96f5252 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -578,20 +578,16 @@ compound key `(serverId, kind, id)`; kinds are `agent`, `workspace`, `project`, uses the singleton id. The store is a typed persistence boundary. It returns values to directory and timeline owners and -accepts their explicit commits; it never reads or writes UI state. -One invalid row is deleted and returned as a miss without affecting other rows. +accepts their explicit commits; it never reads or writes UI state. Reads are scoped to the requested +host, kinds, and ids. Opening a cached workspace uses exact workspace and project keys rather than a +directory scan. One invalid row is deleted and returned as a miss without affecting other rows. An invalid directory row and its affected checkpoint cursor are repaired in one transaction, so a later launch cannot accept a checkpoint for a partial baseline. Directory changes and their -checkpoint are also applied in one transaction. Cold reads query only the requested host's directory -or agent timeline. Accepted commits overlay those reads, including changes accepted or persisted -while a read was in flight. Host removal invalidates old reads; reconciliation exposes moved rows -before the disk rename finishes. Disk is write-behind: a change stays pending until the disk -confirms it, so failed writes preserve accepted values. Timeline eligibility is checked without -serialization; the coalesced write encodes the latest accepted page once. +checkpoint are also applied in one transaction. -The cache is capped at 32 MiB and evicts whole hosts in least-recently-written order. The first -write loads the global row index and accounts for its bytes; requested reads do not await that -work. Later writes update the index. The row store is not encrypted. A cached timeline can contain +The cache is capped at 32 MiB and evicts whole hosts in least-recently-written order. Budget +bookkeeping may scan opaque row sizes during a deferred write, never during host registry startup or +before a requested cache row can paint. The row store is not encrypted. A cached timeline can contain source code, prompts, and tool output; encrypted-at-rest storage is a separate security decision. ### Draft Store diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index caf7dea383..5d3464a30b 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -36,24 +36,11 @@ Large unbounded timeline responses can exceed relay frame limits, so catch-up us Page limits are projected-item targets. A tool call lifecycle is one projected item even if it spans many source sequence numbers, and assistant/reasoning chunks are merged before counting. The response carries `seqStart`, `seqEnd`, `sourceSeqRanges`, and `collapsed` so clients can advance sequence cursors without rendering delta rows. -When live delivery detects a sequence gap, the app fetches `direction: "after"` from its current -cursor and remembers the sequence the live row reached. If the daemon responds with -`hasNewer: true`, the app immediately fetches the next page from `endCursor`. A catch-up is -complete only when the daemon reports `hasNewer: false` and the accepted coverage has reached -every sequence the live stream reported while the catch-up ran: a page the daemon built before -those rows existed cannot settle them, however it was requested, so the owner continues from the -cursor that page established. One catch-up runs per agent; a gap observed while it is running -raises its obligation instead of starting another request. A non-advancing reply cannot disprove -activity observed while the request was in flight. A newly raised obligation starts the next -page immediately, without an error. A reply that makes no progress against an obligation already -known when it started retains that obligation and uses bounded error backoff. Cancellation (the agent leaves view, -the connection drops) forgets the observed head; the next catch-up starts from the cursor and the -daemon returns everything after it. +When live delivery detects a sequence gap, the app fetches `direction: "after"`. If the daemon +responds with `hasNewer: true`, the app immediately fetches the next page from `endCursor`. Gap +recovery is complete only when `hasNewer: false`. Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog. -Response completion is independent of row admission: an obsolete tail can finish its initialization -without replacing newer accepted rows. Requests carry the existing init-promise identity, so an old -response cannot settle a replacement initialization. Opening or resuming an agent fetches one bounded latest tail page. Older history remains user-driven by scrolling upward. @@ -95,9 +82,7 @@ authoritative range first. that cursor are applied. Already-covered rows are not replayed. - A true middle gap, epoch change, or rewind atomically replaces stale canonical history with the latest tail. The replacement reconciles positioned live rows beyond its coverage and unresolved - local submissions; it never retains two discontiguous canonical ranges. A tail whose window ends - behind the local cursor is a rewind, so it supersedes every positioned row up to that cursor - rather than only the positions it carries; a gap replacement covers only what it carries. + local submissions; it never retains two discontiguous canonical ranges. The installed tail carries `hasOlder`, so history skipped by a replacement remains reachable through ordinary backward pagination. A backward page is accepted only when it is adjacent to the current @@ -105,51 +90,39 @@ history start; a response requested from a pre-replacement range is stale and is ## Client replica lifetime -`timeline/replica.ts` is the host-lived timeline write authority. Cache restore, pages, live batches, -submissions and removal publish atomically into the existing Zustand read projection; unchanged -facts retain their references. `viewed-timeline-sync.ts` owns demand and fetch scheduling, available -before a network client so saved history can paint offline. Matching display and verified inputs -share one reduction. Fully covered identified text adopts canonical row identity and metadata; -partial coverage preserves live rows and newer text. Persistence uses the accepted transition, -never mutable UI state. +The session projection remains host-scoped for as long as the host is registered. The viewed-timeline +owner wraps cached preparation, network catch-up, accepted timeline application, and persistence +behind one interface. React supplies transport and projection operations without selecting a cache +path or issuing a separate persistence notification. -Unpositioned live updates do not revoke known in-session source coverage or sequencing: current -daemons also emit out-of-band timeline updates. They can make the restart page display-only, which -grants no resume coverage when reopened. Display pagination remains available, and older pages -cannot certify unpositioned content. Positioned activity ahead of incomplete catch-up preserves the -preceding certified baseline. A cold overflow awaiting its latest tail still accepts live observations; -only an intentionally detached, synchronized history window excludes them. -Epoch changes invalidate old display positions; authoritative resets, including empty windows, -invalidate the old durable baseline. A delayed old-epoch cache cannot replace newer live display. +Removing the host from the registry is the destructive boundary: it stops the runtime and clears the +session and host-scoped setup state together. -The restart window holds at most the latest40 complete entries in source-start order. Loading older -scrollback cannot populate or enlarge it, even when projection merging leaves a short page or its -certificate has been withdrawn. Retained messages can gain complete continuations; overlapping -older tool lifetimes do not pull earlier entries into the window. `startSeq` identifies the source-start -boundary for `before` pagination; `endSeq` is the independently established synchronization position -for `after`. A discarded older tool may finish last, so the latter can exceed every retained row's -completion position. `hasOlder` preserves access to omitted history. +The timeline owner asks durable replica storage for an agent when that agent becomes visible. An +accepted row paints immediately before subscription acknowledgement or timeline fetch. The stored +range describes those exact items: `startSeq` drives older pagination and `endSeq` drives forward +catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older +history. Code outside the owner does not distinguish cached and network timelines. -Cache values without source provenance paint verbatim but cannot authorize resume, including after -new live activity. Canonical replacement restores certification through the ordinary bounded tail -bootstrap; no local sorting or text deduplication repairs saved rows. Disjoint retained ranges keep -the preceding certified baseline. The codec preserves complete text, source chunks and attachment -references; unreconciled local submissions and UI sync generations are not durable. Cache budgeting -and write-behind guarantees belong to [data-model.md](data-model.md#replica-row-store). +The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail +instead of replaying every missed page. Live gap recovery still pages forward until current. -Preparation keeps one promise per agent lifetime. Host identity transfer immediately republishes -retained state; a pending read repeats only when identity changed during that read, because physical -rename may have retired its key. Settled transfers perform no storage read. Directory scope exclusion -ends the lifetime and rejects delayed work but retains the saved timeline; only explicit deletion -removes it. First admission preserves in-flight work; re-admission begins a fresh lifetime. Retained -timeline data alone does not provide offline archived-route metadata. +If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a +live head, or includes presentation data the cache cannot encode losslessly, persistence drops the +range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that +falsely certifies discarded source rows. A display-only row paints without granting synchronization +authority, so the owner uses the ordinary bounded `tail` bootstrap. -DirectorySync coalesces fetch and acceptance into one operation. The first resume is bounded: a reply -with more history leads to one latest tail, while live gap recovery still pages to completion. A tail -passed by activity accepted during its request is obsolete; a tail behind the request's starting -cursor demonstrates rewind. Client window-replacement intent remains separate from daemon reset. -Covered canonical positions replace matching display history, while positioned rows beyond the page -survive as an unverified overlay. Submission continuity follows the rules below. +Live rows received between cache paint and catch-up stay in the separate live head and reconcile with +the authoritative range through the existing forward-page path. The cache does not persist sync +generation or unreconciled local submissions. + +Every daemon-derived live item carries its timeline epoch and sequence position. Bootstrap +replacement keeps only positioned rows newer than the page it installs, while unresolved local +user presentations identify themselves by having client identity without a timeline position. This +prevents a page from duplicating rows it already covers without coupling display continuity to the +shorter-lived submission registry. Unreconciled local presentations are not persisted in the durable +replica cache. ## Selective and legacy delivery @@ -171,18 +144,12 @@ version. ## Projected pages reconcile with live presentation -A projected page is canonical state, not live deltas. Positioned rows carry their source start and -text chunk offsets so page reconciliation replaces covered text while retaining newer chunks. -Tools keep their first-appearance order even when their completion arrives later. Both display lanes -share this source order; a missing canonical unit lands at its source start. - -Message identity alone does not identify a segment: tools can divide same-ID text, and markdown can -split one segment into several rows. For unpositioned text, reconcile only a uniquely matching -contiguous segment using the same markdown boundaries as live rendering. A lagging canonical prefix -leaves newer text unpositioned. Known tool positions constrain ownership; identical unpositioned -segments without a distinguishing position remain ambiguous and are preserved. Text alone never -establishes message identity. Anonymous rows retain the conservative cursor-straddle fallback. -Insertion must not merge an identified segment whose ownership reconciliation already declined. +A projected page is canonical state, not a sequence of live deltas. One projected item can overlap +rows already received live—for example, a tool call retained at its original display position while +its completion advances `seqEnd`, followed by a merged assistant message. The app uses +`sourceSeqRanges` to replace overlapping assistant and reasoning projections before applying the +remaining page through the existing stream reducer. It must not append full projected text to a +live prefix. Every path that sends a message to an agent — composer send, dictation accept-and-send, queued send-now, and the host runtime's automatic queue drain — goes through @@ -263,7 +230,6 @@ canonical assistant prefix, it stays in the head lane. No row may be returned in - Server live stream forwarding: `packages/server/src/server/session.ts` - App sync planning: `packages/app/src/timeline/timeline-sync-plan.ts` -- App timeline write authority: `packages/app/src/timeline/replica.ts` - App viewed-agent synchronization: `packages/app/src/timeline/viewed-timeline-sync.ts` - App stream/timeline reducer: `packages/app/src/timeline/session-stream-reducers.ts` - Session wiring: `packages/app/src/contexts/session-context.tsx` diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index ab8a2f85bf..194fe43d1d 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -37,7 +37,7 @@ import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket- import { gotoAppShell, openSettings, selectModel } from "../support/helpers/app"; import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery"; import { - expectEmptyBaselineBootstrapsWithOneTail, + expectResumeOverflowFallsBackToOneTail, rememberTimelineRequestCounts, } from "../support/helpers/timeline-resume"; import { @@ -484,8 +484,7 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction( await expect(promptRow).toBeVisible(); await expect(response).toBeVisible(); await expectRenderedBefore(promptRow, response); - // The initial timeline was empty and live rows were suppressed: there is no resume cursor. - expectEmptyBaselineBootstrapsWithOneTail(gate, requestsBeforeReturn); + expectResumeOverflowFallsBackToOneTail(gate, requestsBeforeReturn); } finally { gate.setAgentStreamSuppressed(false); gate.restore(); diff --git a/packages/app/e2e/browser/composer-autocomplete.spec.ts b/packages/app/e2e/browser/composer-autocomplete.spec.ts index 9becf359df..4119f7c692 100644 --- a/packages/app/e2e/browser/composer-autocomplete.spec.ts +++ b/packages/app/e2e/browser/composer-autocomplete.spec.ts @@ -94,18 +94,14 @@ interface PopoverFrameRecorderWindow extends Window { __stopComposerAutocompleteFrameRecorder?: () => void; } -async function expectAutocompleteCoveredAtPoint(page: Page, x: number, y: number): Promise { - await expect - .poll(() => - page.evaluate( - ([pointX, pointY]) => { - const element = document.elementFromPoint(pointX, pointY); - return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null; - }, - [x, y], - ), - ) - .not.toBe("composer-autocomplete-popover"); +async function getTopTestIdAtPoint(page: Page, x: number, y: number) { + return page.evaluate( + ([pointX, pointY]) => { + const element = document.elementFromPoint(pointX, pointY); + return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null; + }, + [x, y], + ); } async function installListCommandsStub(page: Page): Promise { @@ -680,11 +676,13 @@ test.describe("Composer autocomplete", () => { const popoverBox = await popover.boundingBox(); expect(popoverBox).not.toBeNull(); - await expectAutocompleteCoveredAtPoint( + const topTestId = await getTopTestIdAtPoint( page, popoverBox!.x + popoverBox!.width / 2, popoverBox!.y + popoverBox!.height / 2, ); + + expect(topTestId).not.toBe("composer-autocomplete-popover"); } finally { await agent.cleanup(); } diff --git a/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts b/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts index 77e2a80998..f2ee89c12f 100644 --- a/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts +++ b/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, type Page } from "../support/fixtures"; +import { test } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; import { @@ -30,9 +30,8 @@ const PROMPT_PROFILE: TerminalProfile = { args: ["-c", 'echo remembered: "$0"; exec cat', "{{{prompt}}}"], }; -async function expectChatLaunchComplete(page: Page): Promise { - const agentTab = page.getByTestId(/^workspace-tab-agent_/).filter({ visible: true }); - await expect(agentTab).toHaveAttribute("aria-selected", "true", { timeout: 30_000 }); +function hasLeftNewWorkspaceRoute(url: URL): boolean { + return !/\/new(?:\?.*)?$/.test(`${url.pathname}${url.search}`); } test.describe("New workspace: launch target memory", () => { @@ -79,8 +78,10 @@ test.describe("New workspace: launch target memory", () => { await selectChatLaunch(page); await expectChatLaunchSelected(page); await submitNewWorkspacePrompt(page, "hello from chat"); - // Leaving /new can expose a previous workspace before the submitted draft opens. - await expectChatLaunchComplete(page); + // The chat handoff navigates asynchronously (draft -> created agent). Let + // it fully settle before moving on, so it can't fire a late redirect + // that races the next step's navigation. + await page.waitForURL(hasLeftNewWorkspaceRoute, { timeout: 30_000 }); await openNewWorkspace(); await expectChatLaunchSelected(page); diff --git a/packages/app/e2e/browser/sidebar-model-b.spec.ts b/packages/app/e2e/browser/sidebar-model-b.spec.ts index 0c7c84b39b..33e6ff8ddc 100644 --- a/packages/app/e2e/browser/sidebar-model-b.spec.ts +++ b/packages/app/e2e/browser/sidebar-model-b.spec.ts @@ -96,9 +96,8 @@ test.describe("Model B sidebar shape", () => { try { // Open the workspace and materialize both an agent tab and a terminal tab. await gotoWorkspace(page, mock.workspaceId); - await expect - .poll(() => getVisibleWorkspaceAgentTabIds(page)) - .toContain(`workspace-tab-agent_${mock.agentId}`); + const agentTabs = await getVisibleWorkspaceAgentTabIds(page); + expect(agentTabs).toContain(`workspace-tab-agent_${mock.agentId}`); await clickNewTerminal(page); await expect( diff --git a/packages/app/e2e/support/helpers/agent-timeline-gate.ts b/packages/app/e2e/support/helpers/agent-timeline-gate.ts index 11cc4bc3c3..91b6867cf0 100644 --- a/packages/app/e2e/support/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/support/helpers/agent-timeline-gate.ts @@ -494,19 +494,18 @@ export async function holdAgentOlderTimelinePages( }; } -export async function delayAgentBootstrapTimelineResponse( +export async function delayAgentBootstrapTailResponse( page: Page, agentId: string, ): Promise { - let bootstrapReleased = false; - let bootstrapResponseSeen = false; + let tailReleased = false; let catchUpReleased = false; - const delayedBootstrapForwards: Array<() => void> = []; + const delayedTailForwards: Array<() => void> = []; const delayedCatchUpForwards: Array<() => void> = []; - let resolveDelayedBootstrap: (() => void) | null = null; + let resolveDelayedTail: (() => void) | null = null; let resolveDelayedCatchUp: (() => void) | null = null; - const delayedBootstrap = new Promise((resolve) => { - resolveDelayedBootstrap = resolve; + const delayedTail = new Promise((resolve) => { + resolveDelayedTail = resolve; }); const delayedCatchUp = new Promise((resolve) => { resolveDelayedCatchUp = resolve; @@ -520,16 +519,10 @@ export async function delayAgentBootstrapTimelineResponse( const payload = sessionMessage ? getPayload(sessionMessage) : null; const isTimelineResponse = sessionMessage?.type === "fetch_agent_timeline_response" && payload?.agentId === agentId; - // A certified replica bootstraps with after; an empty/display-only replica uses tail. - if ( - isTimelineResponse && - !bootstrapResponseSeen && - (payload.direction === "tail" || payload.direction === "after") - ) { - bootstrapResponseSeen = true; - resolveDelayedBootstrap?.(); - if (bootstrapReleased) ws.send(message); - else delayedBootstrapForwards.push(() => ws.send(message)); + if (isTimelineResponse && payload.direction === "tail") { + resolveDelayedTail?.(); + if (tailReleased) ws.send(message); + else delayedTailForwards.push(() => ws.send(message)); return; } if (isTimelineResponse && payload.direction === "after") { @@ -544,14 +537,14 @@ export async function delayAgentBootstrapTimelineResponse( return { release() { - bootstrapReleased = true; - for (const forward of delayedBootstrapForwards.splice(0)) forward(); + tailReleased = true; + for (const forward of delayedTailForwards.splice(0)) forward(); }, releaseCatchUp() { catchUpReleased = true; for (const forward of delayedCatchUpForwards.splice(0)) forward(); }, - waitForDelayedResponse: () => delayedBootstrap, + waitForDelayedResponse: () => delayedTail, waitForDelayedCatchUp: () => delayedCatchUp, }; } diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts index c6afe03736..0cdd34857c 100644 --- a/packages/app/e2e/support/helpers/timeline-pagination.ts +++ b/packages/app/e2e/support/helpers/timeline-pagination.ts @@ -2,7 +2,7 @@ import { expect, type Page } from "@playwright/test"; import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; import { readReplicaCache } from "./replica-cache-storage"; import { - delayAgentBootstrapTimelineResponse, + delayAgentBootstrapTailResponse, delayAgentOlderTimelineResponse, holdDaemonHydration, holdAgentOlderTimelinePages, @@ -296,10 +296,9 @@ export async function reloadAgentTimelineFromPersistedReplica( const timeline = cache?.hosts ?.flatMap((host) => host.timelines) .find((candidate) => candidate.agentId === agent.agentId); - // This long fixture fills the latest 40-entry restart page, even after loading scrollback. - return timeline?.items?.length; + return timeline?.items?.length === 50; }) - .toBe(40); + .toBe(true); await page.reload(); await expectTimelinePromptVisible(page, agent.newestPrompt); @@ -504,7 +503,7 @@ export async function holdBootstrapTimelinePage( page: Page, agent: LongTimelineAgent, ): Promise { - return delayAgentBootstrapTimelineResponse(page, agent.agentId); + return delayAgentBootstrapTailResponse(page, agent.agentId); } export async function sendLiveTurnBeforeHydration(agent: LongTimelineAgent): Promise { diff --git a/packages/app/e2e/support/helpers/timeline-resume.ts b/packages/app/e2e/support/helpers/timeline-resume.ts index e397d17325..73c921776c 100644 --- a/packages/app/e2e/support/helpers/timeline-resume.ts +++ b/packages/app/e2e/support/helpers/timeline-resume.ts @@ -48,14 +48,6 @@ export function expectOneResumeCheckWithoutTail( expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(0); } -export function expectEmptyBaselineBootstrapsWithOneTail( - gate: DaemonWebSocketGate, - before: TimelineRequestCounts, -): void { - expect(gate.getTimelineRequestCount("after") - before.after).toBe(0); - expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(1); -} - export function expectResumeOverflowFallsBackToOneTail( gate: DaemonWebSocketGate, before: TimelineRequestCounts, diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 0ad0394143..7f66a07b25 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -38,6 +38,12 @@ function HostAgentReadyRouteContent() { } return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null; }); + useEffect(() => { + if (!serverId || !agentId || agentWorkspaceId) return; + void getHostRuntimeStore() + .prepareAgentRoute(serverId, agentId) + .catch(() => undefined); + }, [agentId, agentWorkspaceId, serverId]); const shouldLookupAgent = Boolean( serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId, ); diff --git a/packages/app/src/composer/draft/create-flow.test.ts b/packages/app/src/composer/draft/create-flow.test.ts index 929b3e1be0..8587fa3db6 100644 --- a/packages/app/src/composer/draft/create-flow.test.ts +++ b/packages/app/src/composer/draft/create-flow.test.ts @@ -2,34 +2,17 @@ * @vitest-environment jsdom */ import { act, renderHook } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import type { UserMessageImageAttachment } from "@/types/stream"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "./create-flow"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; -import { defaultHostAppearance } from "@/hosts/appearance"; - describe("useDraftAgentCreateFlow", () => { beforeEach(() => { useCreateFlowStore.setState({ pendingByDraftId: {} }); - getHostRuntimeStore().syncHosts([ - { - serverId: "server-1", - label: "test", - appearance: defaultHostAppearance(), - lifecycle: {}, - connections: [], - preferredConnectionId: null, - createdAt: new Date(0).toISOString(), - updatedAt: new Date(0).toISOString(), - }, - ]); }); - afterEach(() => getHostRuntimeStore().syncHosts([])); - it("renders a prepared new-workspace submission before continuing it", async () => { const image: UserMessageImageAttachment = { id: "image-1", diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index 8f708744c0..d1cfce1161 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -6,7 +6,7 @@ import { splitComposerAttachmentsForSubmit, } from "@/composer/attachments/submit"; import { useCreateFlowStore } from "@/stores/create-flow-store"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer"; import { useSessionStore } from "@/stores/session-store"; import { createUserMessage, @@ -214,18 +214,17 @@ export function useDraftAgentCreateFlow({ if (createResult.agentId) { updatePendingAgentId({ draftId, agentId: createResult.agentId }); - getHostRuntimeStore() - .getTimelineReplica(pendingServerId) - .handoffSubmission( - createResult.agentId, - createUserMessage({ - clientMessageId: attempt.clientMessageId, - text: attempt.text, - timestamp: attempt.timestamp, - images: attempt.images, - attachments: attempt.attachments, - }), - ); + handoffCreatedAgentMessageSubmission( + pendingServerId, + createResult.agentId, + createUserMessage({ + clientMessageId: attempt.clientMessageId, + text: attempt.text, + timestamp: attempt.timestamp, + images: attempt.images, + attachments: attempt.attachments, + }), + ); markPendingCreateLifecycle({ draftId, lifecycle: "sent" }); } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index a1c97957aa..84de49a3b9 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1484,10 +1484,7 @@ function ComposerContentImpl({ supportsForgeAttachments: supportsForgeSearch, }), encodeImages, - submission: createMessageSubmissionWriter( - serverId, - getHostRuntimeStore().getTimelineReplica(serverId), - ), + submission: createMessageSubmissionWriter(serverId), activeTurnBehavior, activeTurnId: activeTurnBehavior === "steer" diff --git a/packages/app/src/composer/submission/writer.test.ts b/packages/app/src/composer/submission/writer.test.ts index 1c76186615..3ee174d554 100644 --- a/packages/app/src/composer/submission/writer.test.ts +++ b/packages/app/src/composer/submission/writer.test.ts @@ -1,4 +1,3 @@ -import { createTimelineReplica } from "@/timeline/replica"; import { afterEach, expect, test } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useSessionStore } from "@/stores/session-store"; @@ -41,17 +40,10 @@ afterEach(() => { test("tracks submissions when canonical prompts are supported", () => { initializeSession(CANONICAL_ONLY_SERVER_ID, { canonicalSubmittedPrompts: true }); - createMessageSubmissionWriter( - CANONICAL_ONLY_SERVER_ID, - createTimelineReplica({ - serverId: CANONICAL_ONLY_SERVER_ID, - storage: { - removeTimeline: () => undefined, - readTimeline: async () => undefined, - commitTimeline: () => undefined, - }, - }), - ).begin("agent-1", submittedMessage("canonical-only-message")); + createMessageSubmissionWriter(CANONICAL_ONLY_SERVER_ID).begin( + "agent-1", + submittedMessage("canonical-only-message"), + ); expect(submissionIds(CANONICAL_ONLY_SERVER_ID)).toEqual(["canonical-only-message"]); }); diff --git a/packages/app/src/composer/submission/writer.ts b/packages/app/src/composer/submission/writer.ts index 5cef427fae..f8b40034b6 100644 --- a/packages/app/src/composer/submission/writer.ts +++ b/packages/app/src/composer/submission/writer.ts @@ -1,19 +1,72 @@ import type { MessageSubmissionWriter } from "@/composer/actions"; import { useSessionStore } from "@/stores/session-store"; -import type { TimelineReplica } from "@/timeline/replica"; +import { + appendSubmittedUserMessage, + removeSubmittedUserMessage, + type UserMessageItem, +} from "@/types/stream"; -export function createMessageSubmissionWriter( +function appendUntrackedSubmission(serverId: string, agentId: string, message: UserMessageItem) { + const session = useSessionStore.getState().sessions[serverId]; + if (!session) return; + const tail = session.agentStreamTail.get(agentId) ?? []; + const head = session.agentStreamHead.get(agentId) ?? []; + useSessionStore + .getState() + .setAgentStreamState(serverId, agentId, appendSubmittedUserMessage({ tail, head, message })); +} + +function removeUntrackedSubmission( serverId: string, - replica: TimelineReplica, -): MessageSubmissionWriter { - // COMPAT(canonicalSubmittedPrompts): added in v0.2.6; remove the gate after 2027-01-31 once daemon floor >= v0.2.6. - const tracked = - useSessionStore.getState().sessions[serverId]?.serverInfo?.features - ?.canonicalSubmittedPrompts === true; + agentId: string, + clientMessageId: string, +): void { + const session = useSessionStore.getState().sessions[serverId]; + if (!session) return; + const tail = session.agentStreamTail.get(agentId) ?? []; + const head = session.agentStreamHead.get(agentId) ?? []; + useSessionStore + .getState() + .setAgentStreamState( + serverId, + agentId, + removeSubmittedUserMessage({ tail, head, clientMessageId }), + ); +} + +function createUntrackedMessageSubmissionWriter(serverId: string): MessageSubmissionWriter { return { - begin: (agentId, message) => replica.beginSubmission(agentId, message, tracked), - accept: (agentId, clientMessageId) => replica.acceptSubmission(agentId, clientMessageId), - reject: (agentId, clientMessageId) => - replica.rejectSubmission(agentId, clientMessageId, tracked), + begin: (agentId, message) => appendUntrackedSubmission(serverId, agentId, message), + accept: () => undefined, + reject: (agentId, clientMessageId) => { + removeUntrackedSubmission(serverId, agentId, clientMessageId); + return "rejected"; + }, }; } + +export function createMessageSubmissionWriter(serverId: string): MessageSubmissionWriter { + const supportsTrackedMessageSubmissions = + useSessionStore.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 { + begin: (agentId, message) => + useSessionStore.getState().beginAgentMessageSubmission(serverId, agentId, message), + accept: (agentId, clientMessageId) => + useSessionStore.getState().acceptAgentMessageSubmission(serverId, agentId, clientMessageId), + reject: (agentId, clientMessageId) => + useSessionStore.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId), + }; +} + +export function handoffCreatedAgentMessageSubmission( + serverId: string, + agentId: string, + message: UserMessageItem, +): boolean { + return useSessionStore.getState().handoffCreatedAgentUserMessage(serverId, agentId, message); +} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index d484169734..fff3a0f496 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -15,7 +15,9 @@ import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducer import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { requestTimelineReplacement } from "@/timeline/timeline-replacement"; import { + consumeForcedTimelineTailReplacement, type TimelineDeliveryMode, + type TimelineResponsePayload, type ViewedTimelineOwner, } from "@/timeline/viewed-timeline-sync"; import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages"; @@ -227,12 +229,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider // Zustand store actions const setIsPlayingAudio = useSessionStore((state) => state.setIsPlayingAudio); + const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); + const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); + const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead); const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration); const setAgents = useSessionStore((state) => state.setAgents); const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity); const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions); const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo); + const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync); const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress); // Track focused agent for heartbeat @@ -245,6 +251,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const _sessionStateTimeoutRef = useRef | null>(null); const attentionNotifiedRef = useRef>(new Map()); const appStateRef = useRef(AppState.currentState); + const forcedTimelineTailReplacements = useRef(new Set()); const viewedTimelineSyncRef = useRef(null); const audioOutputBuffersRef = useRef>(new Map()); const activeAudioGroupsRef = useRef>(new Set()); @@ -410,15 +417,33 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider [serverId, upsertWorkspaceSetupProgress], ); + const applyTimelineResponse = useCallback((receivedPayload: TimelineResponsePayload) => { + const payload = consumeForcedTimelineTailReplacement( + receivedPayload, + forcedTimelineTailReplacements.current, + ); + const owner = viewedTimelineSyncRef.current; + if (!owner) throw new Error("Viewed timeline owner is unavailable"); + owner.applyTimelineResponse(payload); + }, []); + useEffect(() => { const setAgentInitializing = createSetAgentInitializing(serverId, setInitializingAgents); const initialDeliveryMode = getTimelineDeliveryMode( client.getLastServerInfoMessage()?.features?.selectiveAgentTimeline, ); - const sync = getHostRuntimeStore().getViewedTimelineOwner(serverId); - const detach = sync.attachTransport({ + const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, { initialDeliveryMode, setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds), + readCursor: (agentId) => { + const timeline = selectAgentTimelineState( + useSessionStore.getState().sessions[serverId], + agentId, + ); + return timeline.status === "synced" && timeline.range + ? { epoch: timeline.range.epoch, endSeq: timeline.range.endSeq } + : undefined; + }, fetchPage: async (agentId, request) => { const session = useSessionStore.getState().sessions[serverId]; const initKey = getInitKey(serverId, agentId); @@ -449,8 +474,18 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider throw error; } }, - fetchLatestTail: (agentId) => - getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, planTimelineTailFetch(), true), + fetchLatestTail: async (agentId) => { + forcedTimelineTailReplacements.current.add(agentId); + try { + return await getHostRuntimeStore().fetchAgentTimeline( + serverId, + agentId, + planTimelineTailFetch(), + ); + } finally { + forcedTimelineTailReplacements.current.delete(agentId); + } + }, reportError: (error) => { console.warn("[Session] viewed timeline synchronization failed", { serverId, error }); }, @@ -460,15 +495,17 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }, }); viewedTimelineSyncRef.current = sync; + setViewedTimelineSync(serverId, sync); sync.setActive(getIsAppVisible(appStateRef.current)); return () => { if (viewedTimelineSyncRef.current === sync) { viewedTimelineSyncRef.current = null; } - detach(); + setViewedTimelineSync(serverId, null); + sync.dispose(); }; - }, [client, serverId, setInitializingAgents]); + }, [client, serverId, setInitializingAgents, setViewedTimelineSync]); useEffect(() => { viewedTimelineSyncRef.current?.setConnected(isConnected); @@ -516,6 +553,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } }); + const unsubAgentTimeline = client.on("fetch_agent_timeline_response", (message) => { + if (message.type !== "fetch_agent_timeline_response") return; + owner.flushStreamAgent(message.payload.agentId); + applyTimelineResponse(message.payload); + }); + const unsubTimelineReplacement = client.on("agent.timeline.replacement", (message) => { if (message.type !== "agent.timeline.replacement") return; void requestTimelineReplacement( @@ -721,6 +764,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider return () => { unsubTimelineReplacement(); unsubAgentStream(); + unsubAgentTimeline(); unsubProviderSubagentUpdate(); unsubAgentAttention(); unsubCheckoutStatusUpdate(); @@ -740,11 +784,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider queryClient, serverId, setIsPlayingAudio, + setAgentStreamTail, + setAgentStreamHead, + clearAgentStreamHead, setInitializingAgents, setAgents, setPendingPermissions, notifyAgentAttention, applyWorkspaceSetupProgress, + applyTimelineResponse, updateSessionServerInfo, toast, voiceRuntime, diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index 899f95c631..f2726fba02 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -53,15 +53,13 @@ describe("ensureAgentIsInitialized", () => { const client = new FakeDaemonClient(); const runtime = new FakeTimelineRuntime(); useSessionStore.getState().initializeSession(serverId, client as never); - useSessionStore.getState().applyAgentTimelineResponseState(serverId, agentId, { - items: [], - head: [], - range: { epoch: "epoch-1", startSeq: 1, endSeq: 42 }, - older: "none", - newer: false, - synchronized: true, - submissions: [], - }); + useSessionStore + .getState() + .setAgentTimelineCursor( + serverId, + new Map([[agentId, { epoch: "epoch-1", startSeq: 1, endSeq: 42 }]]), + ); + useSessionStore.getState().setAgentAuthoritativeHistoryApplied(serverId, agentId, true); void ensureAgentIsInitialized({ serverId, @@ -131,7 +129,7 @@ describe("ensureAgentIsInitialized", () => { older: "none", newer: false, synchronized: false, - submissions: [], + acknowledgedClientMessageIds: [], }); void ensureAgentIsInitialized({ @@ -174,7 +172,7 @@ describe("ensureAgentIsInitialized", () => { older: "available", newer: false, synchronized: true, - submissions: [], + acknowledgedClientMessageIds: [], }); void ensureAgentIsInitialized({ diff --git a/packages/app/src/runtime/directory-sync/agent-replica.test.ts b/packages/app/src/runtime/directory-sync/agent-replica.test.ts index 1f4fdc3cc8..d7deff40a7 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -61,7 +61,6 @@ describe("AgentDirectoryReplica", () => { serverId, () => undefined, () => undefined, - (agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId), ); replica.commitSnapshot([entry(payload("live"))], []); replica.applyTurnLiveness("agent", { @@ -85,7 +84,6 @@ describe("AgentDirectoryReplica", () => { serverId, () => undefined, () => undefined, - (agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId), ); expect(replica.submitTimelineAgent(replica.captureTimeline("agent"), payload("network"))).toBe( @@ -106,7 +104,6 @@ describe("AgentDirectoryReplica", () => { serverId, () => undefined, (mutations) => commits.push([...mutations]), - (agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId), ); replica.commitSnapshot([entry(payload("agent"))], []); commits.length = 0; @@ -147,7 +144,6 @@ describe("AgentDirectoryReplica", () => { serverId, (agentId) => stopped.push(agentId), () => undefined, - (agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId), ); replica.commitSnapshot([entry(payload("agent"))], []); replica.applyTurnLiveness("agent", { @@ -170,7 +166,6 @@ describe("AgentDirectoryReplica", () => { serverId, () => undefined, () => undefined, - (agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId), ); replica.commitSnapshot( [ @@ -224,7 +219,6 @@ describe("AgentDirectoryReplica", () => { serverId, (agentId) => stopped.push(agentId), () => undefined, - (agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId), ); replica.commitSnapshot( [ @@ -268,7 +262,6 @@ describe("AgentDirectoryReplica", () => { serverId, () => undefined, () => undefined, - (agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId), ); replica.commitSnapshot( [ @@ -296,16 +289,10 @@ describe("AgentDirectoryReplica", () => { const serverId = "agent-replica"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - const timelineLifetimes: string[] = []; const replica = new AgentDirectoryReplica( serverId, () => undefined, () => undefined, - (agentId) => { - timelineLifetimes.push(`remove:${agentId}`); - useSessionStore.getState().removeAgentTimeline(serverId, agentId); - }, - (agentId) => timelineLifetimes.push(`accept:${agentId}`), ); replica.commitSnapshot([entry(payload("directory"))], []); const directoryPlacement = useSessionStore @@ -332,7 +319,6 @@ describe("AgentDirectoryReplica", () => { expect( useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.projectPlacement, ).toEqual(directoryPlacement); - expect(timelineLifetimes).toEqual(["accept:agent", "remove:agent", "accept:agent"]); store.clearSession(serverId); }); }); diff --git a/packages/app/src/runtime/directory-sync/agent-replica.ts b/packages/app/src/runtime/directory-sync/agent-replica.ts index ad41e2ae7c..1bdf3ea3de 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.ts @@ -4,7 +4,7 @@ import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; import { queryClient } from "@/data/query-client"; import type { Agent } from "@/stores/session-store"; import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots"; -import { type AgentDirectoryDelta, type AgentRemovalReason } from "@/utils/agent-directory-sync"; +import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync"; import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; @@ -25,47 +25,52 @@ export interface AgentLifecycleToken { export class AgentDirectoryReplica { private readonly lifecycleVersions = new Map(); private readonly members = new Set(); + private readonly pendingCacheReads = new Set(); private readonly storeProjection: AgentStoreProjection; constructor( private readonly serverId: string, private readonly onStoppedRunning: (agentId: string) => void, private readonly persist: (mutations: readonly DirectoryReplicaMutation[]) => void, - removeTimeline: (agentId: string, reason: AgentRemovalReason) => void, - private readonly acceptTimeline: (agentId: string) => void = () => undefined, ) { - this.storeProjection = new AgentStoreProjection(serverId, removeTimeline); + this.storeProjection = new AgentStoreProjection(serverId); } captureTimeline(agentId: string): AgentLifecycleToken { return { agentId, version: this.lifecycleVersions.get(agentId) ?? 0 }; } - isTimelineCurrent(token: AgentLifecycleToken): boolean { - return token.version === (this.lifecycleVersions.get(token.agentId) ?? 0); - } - snapshot(): Map { return this.storeProjection.snapshot(); } - // Saved rows sit under whatever the live stream already delivered for this connection. + captureCache(agentId: string): AgentLifecycleToken { + this.pendingCacheReads.add(agentId); + return this.captureTimeline(agentId); + } + commitCached(agents: Map): void { - const current = new Map( - [...agents].filter(([id]) => !this.lifecycleVersions.has(id) || this.members.has(id)), - ); - const merged = this.storeProjection.commitCached(current); + const merged = this.storeProjection.commitCached(agents); this.members.clear(); for (const agentId of merged.keys()) { this.members.add(agentId); } } + commitCachedAgent(token: AgentLifecycleToken, agent: Agent): boolean { + this.pendingCacheReads.delete(token.agentId); + if (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) return false; + if (this.members.has(agent.id)) return false; + this.members.add(agent.id); + this.storeProjection.accept(agent); + this.storeProjection.publishActivity(agent); + return true; + } + submitTimelineAgent(token: AgentLifecycleToken, payload: AgentSnapshotPayload): boolean { - if (!this.isTimelineCurrent(token)) { + if (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) { return false; } - const startsLifetime = !this.members.has(token.agentId); const existing = this.storeProjection.get(token.agentId); const timelineAgent = applyLegacyDaemonWorkspaceOwnership({ serverId: this.serverId, @@ -76,10 +81,6 @@ export class AgentDirectoryReplica { projectPlacement: timelineAgent.projectPlacement ?? existing?.projectPlacement, }; const accepted = this.storeProjection.accept(normalized); - if (startsLifetime) { - this.resume(accepted.id); - this.acceptTimeline(accepted.id); - } this.members.add(accepted.id); this.storeProjection.replacePendingPermissions(accepted); this.storeProjection.publishActivity(accepted); @@ -98,10 +99,7 @@ export class AgentDirectoryReplica { this.advance(delta.agentId); } else { this.members.add(delta.agent.id); - if (!before) { - this.resume(delta.agent.id); - this.acceptTimeline(delta.agent.id); - } + if (!before) this.advance(delta.agent.id); } if (result.stoppedRunning) this.onStoppedRunning(result.agentId); this.persist( @@ -112,12 +110,7 @@ export class AgentDirectoryReplica { } accept(agent: Agent): Agent { - const startsLifetime = !this.members.has(agent.id); const accepted = this.storeProjection.accept(agent); - if (startsLifetime) { - this.resume(accepted.id); - this.acceptTimeline(accepted.id); - } this.members.add(accepted.id); this.persist([this.agentUpsert(accepted)]); return accepted; @@ -131,23 +124,22 @@ export class AgentDirectoryReplica { const previous = this.storeProjection.snapshot(); const reconciled = reconcileAgentDirectory({ snapshot: entries, deltas }); const nextIds = new Set(reconciled.map((entry) => entry.agent.id)); - const startedLifetimes = new Set(); + for (const agentId of this.pendingCacheReads) { + if (!nextIds.has(agentId)) this.advance(agentId); + } for (const agentId of this.members) { if (!nextIds.has(agentId)) this.advance(agentId); } for (const agentId of nextIds) { - if (!this.members.has(agentId)) { - this.resume(agentId); - startedLifetimes.add(agentId); - } + if (!this.members.has(agentId)) this.advance(agentId); } for (const agentId of previous.keys()) { - if (!nextIds.has(agentId)) this.storeProjection.remove(agentId, "scope"); + if (!nextIds.has(agentId)) this.storeProjection.remove(agentId); } this.members.clear(); + this.pendingCacheReads.clear(); for (const agentId of nextIds) this.members.add(agentId); const agents = this.storeProjection.replaceFetched(reconciled); - for (const agentId of startedLifetimes) this.acceptTimeline(agentId); for (const [agentId, previousAgent] of previous) { if (previousAgent.turn.phase === "open" && agents.get(agentId)?.turn.phase === "idle") { this.onStoppedRunning(agentId); @@ -209,7 +201,7 @@ export class AgentDirectoryReplica { remove(agentId: string): void { this.members.delete(agentId); this.advance(agentId); - this.storeProjection.remove(agentId, "deleted"); + this.storeProjection.remove(agentId); this.persist([{ kind: "agent", type: "delete", id: agentId }]); } @@ -233,12 +225,6 @@ export class AgentDirectoryReplica { }; } - private resume(agentId: string): void { - // First admission keeps requests already in flight. Re-entry invalidates work issued - // during the excluded lifetime as well as work issued before its removal. - if (this.lifecycleVersions.has(agentId)) this.advance(agentId); - } - private advance(agentId: string): void { this.lifecycleVersions.set(agentId, (this.lifecycleVersions.get(agentId) ?? 0) + 1); } diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 030ddb1225..5fbeb3d3e9 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -1,3 +1,10 @@ +import { DatabaseSync } from "node:sqlite"; +import { ReplicaCache } from "@/runtime/replica-cache"; +import { + createSqliteReplicaRowStore, + type ReplicaSqliteConnection, + type SqliteValue, +} from "@/runtime/replica-cache/row-store-sqlite"; import { afterEach, describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; @@ -6,17 +13,9 @@ import { normalizeWorkspaceDescriptor, useSessionStore, } from "@/stores/session-store"; -import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors"; -import { ReplicaCache, type DirectoryReplicaMutation } from "@/runtime/replica-cache"; -import { createTimelineReplica } from "@/timeline/replica"; -import { createViewedTimelineOwner } from "@/timeline/viewed-timeline-sync"; -import type { - ReplicaHostRows, - ReplicaRow, - ReplicaRowChanges, - ReplicaRowStore, -} from "@/runtime/replica-cache/row-store"; +import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; import { DirectoryRefreshSupersededError, DirectorySync, @@ -26,7 +25,6 @@ import { type WorkspaceFetchResult = Awaited>; type ProjectListResult = Awaited>; type AgentFetchResult = Awaited>; -type TimelineFetchResult = Awaited>; class FakeDirectoryClient { fetchAgentsCalls = 0; @@ -38,10 +36,6 @@ class FakeDirectoryClient { projectResult: ProjectListResult | null = null; private pendingAgentFetch: Promise | null = null; private pendingWorkspaceFetch: Promise | null = null; - private readonly pendingTimelineFetches: Array<{ - promise: Promise; - resolve(result: TimelineFetchResult): void; - }> = []; private readonly handlers = new Map< SessionOutboundMessage["type"], Set<(message: SessionOutboundMessage) => void> @@ -111,21 +105,6 @@ class FakeDirectoryClient { }; } - fetchAgentTimeline(): Promise { - let resolve!: (result: TimelineFetchResult) => void; - const promise = new Promise((complete) => { - resolve = complete; - }); - this.pendingTimelineFetches.push({ promise, resolve }); - return promise; - } - - nextTimelineFetch(): { resolve(result: TimelineFetchResult): void } { - const pending = this.pendingTimelineFetches.shift(); - if (!pending) throw new Error("Expected a pending timeline fetch"); - return { resolve: pending.resolve }; - } - async listProjects(options?: unknown): Promise { this.listProjectsCalls += 1; this.lastProjectOptions = options; @@ -149,102 +128,20 @@ class FakeDirectoryClient { } } -class MemoryRowStore implements ReplicaRowStore { - readonly rows = new Map(); - readAllGate: Promise | null = null; - - private key(row: Pick): string { - return `${row.serverId}:${row.kind}:${row.id}`; - } - - async open(): Promise {} - - async read( - serverIds: readonly string[], - kinds: readonly ReplicaRow["kind"][], - ids?: readonly string[], - ): Promise { - await this.readAllGate; - return [...this.rows.values()].filter( - (row) => - serverIds.includes(row.serverId) && - kinds.includes(row.kind) && - (!ids || ids.includes(row.id)), - ); - } - - async readAll(): Promise { - await this.readAllGate; - const hosts = new Map(); - for (const row of this.rows.values()) { - hosts.set(row.serverId, [...(hosts.get(row.serverId) ?? []), row]); - } - return [...hosts].map(([serverId, rows]) => ({ serverId, rows })); - } - - async apply(changes: ReplicaRowChanges): Promise { - for (const key of changes.deletes) this.rows.delete(this.key(key)); - for (const row of changes.upserts) this.rows.set(this.key(row), row); - } - - async deleteHost(serverId: string): Promise { - for (const [key, row] of this.rows) if (row.serverId === serverId) this.rows.delete(key); - } - - async renameHost(oldServerId: string, newServerId: string): Promise { - for (const [key, row] of this.rows) { - if (row.serverId !== oldServerId) continue; - this.rows.delete(key); - const renamed = { ...row, serverId: newServerId }; - this.rows.set(this.key(renamed), renamed); - } - } - - async clear(): Promise { - this.rows.clear(); - } -} - -// A saved directory on disk, read back through the real storage owner with its load gated. -async function savedDirectory( - serverId: string, - directory: Parameters[1], -): Promise<{ cache: ReplicaCache; releaseLoad: () => void }> { - const rowStore = new MemoryRowStore(); - const writer = new ReplicaCache(rowStore, { clearLegacyCache: async () => undefined }); - writer.setHosts([serverId]); - writer.replaceDirectoryBaseline(serverId, directory); - await writer.flush(); - let releaseLoad!: () => void; - rowStore.readAllGate = new Promise((resolve) => { - releaseLoad = resolve; - }); - const cache = new ReplicaCache(rowStore, { clearLegacyCache: async () => undefined }); - cache.setHosts([serverId]); - return { cache, releaseLoad }; -} - -const noopCallbacks = { - onTimelineRequest: () => () => undefined, - onAgentStoppedRunning: () => undefined, - onAgentRemoved: () => undefined, - markAgentLoading: () => undefined, - markAgentReady: () => undefined, - markAgentError: () => undefined, -}; - const serverIds = new Set(); -function createDirectory( - serverId: string, - callbacks: ConstructorParameters[1] = noopCallbacks, -): { +function createDirectory(serverId: string): { client: FakeDirectoryClient; directory: DirectorySync; } { serverIds.add(serverId); const client = new FakeDirectoryClient(); - const directory = new DirectorySync(serverId, callbacks); + const directory = new DirectorySync(serverId, { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }); directory.connectionChanged({ client: client as unknown as DaemonClient, status: "online", @@ -286,343 +183,12 @@ function createAgent(serverId: string, id: string) { }; } -it.each(["snapshot", "changes", "live"] as const)( - "%s scope exclusion retains the saved page but invalidates old work; deletion removes it", - async (exclusion) => { - const serverId = `timeline-scope-${exclusion}`; - serverIds.add(serverId); - useSessionStore.getState().initializeSession(serverId, null); - const rows = new MemoryRowStore(); - const cache = new ReplicaCache(rows, { clearLegacyCache: async () => undefined }); - cache.setHosts([serverId]); - const saved = { - agentId: "agent", - range: { epoch: "epoch", startSeq: 1, endSeq: 1 }, - hasOlder: false, - items: [ - { - kind: "assistant_message" as const, - id: "saved", - text: "saved history", - timestamp: new Date("2026-09-06T00:00:00Z"), - timelineCursor: { epoch: "epoch", seq: 1 }, - source: { startSeq: 1 }, - }, - ], - }; - cache.commitTimeline(serverId, "agent", saved); - await cache.flush(); - const replica = createTimelineReplica({ serverId, storage: cache }); - const viewed = createViewedTimelineOwner({ - serverId, - replica, - replaceDemandedAgentIds: () => undefined, - drainQueuedAgentMessage: () => undefined, - }); - const directory = new DirectorySync( - serverId, - { - ...noopCallbacks, - onAgentRemoved: (...args) => viewed.removeAgent(...args), - onAgentAccepted: (id) => viewed.acceptAgent(id), - }, - cache, - ); - const client = new FakeDirectoryClient(); - useSessionStore.getState().updateSessionClient(serverId, client as unknown as DaemonClient, 1); - useSessionStore.getState().updateSessionServerInfo(serverId, { - serverId, - hostname: null, - version: "test", - features: { workspaceMultiplicity: true, directorySync: true }, - }); - directory.connectionChanged({ - client: client as unknown as DaemonClient, - status: "online", - source: { clientGeneration: 1, connectionEpoch: 1 }, - }); - directory.acceptAgent(createAgent(serverId, "agent")); - await replica.prepare("agent"); - const stale = directory.fetchTimeline("agent", { - direction: "tail", - limit: 40, - projection: "projected", - }); - const staleReply = client.nextTimelineFetch(); - if (exclusion === "live") { - client.emit({ type: "agent_update", payload: { kind: "remove", agentId: "agent" } }); - } else { - const reply = client.holdAgentFetch(); - const refreshing = directory.refreshAgents(); - await expect.poll(() => client.fetchAgentsCalls).toBe(1); - reply({ - requestId: "scope", - entries: [], - pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, - ...(exclusion === "changes" - ? { - sync: { - generation: "g", - headSeq: 1, - mode: "changes" as const, - removals: [{ id: "agent", seq: 1 }], - }, - } - : {}), - }); - await refreshing; - } - expect(useSessionStore.getState().sessions[serverId]?.agentStreamTail.has("agent")).toBe(false); - expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(false); - viewed.enqueueStreamEvent("agent", { - epoch: "epoch", - seq: 2, - timestamp: new Date("2026-09-06T00:00:01Z"), - event: { - type: "timeline", - provider: "codex", - item: { type: "assistant_message", text: "late work", messageId: "late" }, - }, - }); - viewed.flushStreamAgent("agent"); - expect(useSessionStore.getState().sessions[serverId]?.agentStreamTail.has("agent")).toBe(false); - await cache.flush(); - const reopened = new ReplicaCache(rows, { clearLegacyCache: async () => undefined }); - reopened.setHosts([serverId]); - expect(await reopened.readTimeline(serverId, "agent")).toEqual(saved); - directory.acceptAgent(createAgent(serverId, "agent")); - await replica.prepare("agent"); - staleReply.resolve({ agent: null, hasNewer: false } as TimelineFetchResult); - await expect(stale).rejects.toBeInstanceOf(DirectoryRefreshSupersededError); - expect(useSessionStore.getState().sessions[serverId]?.agentStreamTail.get("agent")).toEqual( - saved.items, - ); - // Deletion must also remove a saved page whose agent is already out of scope. - client.emit({ type: "agent_update", payload: { kind: "remove", agentId: "agent" } }); - client.emit({ type: "agent_deleted", payload: { agentId: "agent", requestId: "delete" } }); - // The subsequent directory remove must not recreate the deleted row. - client.emit({ type: "agent_update", payload: { kind: "remove", agentId: "agent" } }); - await cache.flush(); - const afterDeletion = new ReplicaCache(rows, { clearLegacyCache: async () => undefined }); - afterDeletion.setHosts([serverId]); - expect(await afterDeletion.readTimeline(serverId, "agent")).toBeUndefined(); - viewed.dispose(); - directory.dispose(); - }, -); - -it.each(["snapshot-first", "page-first", "live-first"] as const)( - "first admission preserves the first timeline page (%s)", - async (ordering) => { - const serverId = `first-admission-${ordering}`; - serverIds.add(serverId); - useSessionStore.getState().initializeSession(serverId, null); - const rows = new MemoryRowStore(); - const cache = new ReplicaCache(rows, { clearLegacyCache: async () => undefined }); - cache.setHosts([serverId]); - const replica = createTimelineReplica({ serverId, storage: cache }); - const viewed = createViewedTimelineOwner({ - serverId, - replica, - replaceDemandedAgentIds: () => undefined, - drainQueuedAgentMessage: () => undefined, - }); - let applications = 0; - const { directory, client } = createDirectory(serverId, { - ...noopCallbacks, - onAgentAccepted: (id) => viewed.acceptAgent(id), - onTimelineRequest: (id) => { - const apply = viewed.beginTimelineRequest(id); - return (page) => { - applications++; - apply(page); - }; - }, - }); - useSessionStore.getState().updateSessionClient(serverId, client as unknown as DaemonClient, 1); - useSessionStore.getState().updateSessionServerInfo(serverId, { - serverId, - hostname: null, - version: "test", - features: { workspaceMultiplicity: true }, - }); - await directory.ready(); - const request = { direction: "tail" as const, limit: 40, projection: "projected" as const }; - const fetching = directory.fetchTimeline("agent", request); - const concurrent = directory.fetchTimeline("agent", request); - const reply = client.nextTimelineFetch(); - const agent = projectAgentSnapshot(createAgent(serverId, "agent")); - const page: TimelineFetchResult = { - requestId: "first", - agentId: "agent", - agent, - direction: "tail", - projection: "projected", - reset: false, - staleCursor: false, - gap: false, - epoch: "epoch", - window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, - startCursor: { epoch: "epoch", seq: 1 }, - endCursor: { epoch: "epoch", seq: 1 }, - hasOlder: false, - hasNewer: false, - error: null, - entries: [ - { - provider: "codex", - item: { type: "assistant_message", text: "first page", messageId: "first" }, - timestamp: "2026-09-06T00:00:00Z", - seqStart: 1, - seqEnd: 1, - sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], - collapsed: [], - }, - ], - }; - if (ordering === "page-first") { - reply.resolve(page); - await fetching; - } - if (ordering === "live-first") - client.emit({ type: "agent_update", payload: { kind: "upsert", agent } }); - else { - const release = client.holdAgentFetch(); - const refreshing = directory.refreshAgents(); - await expect.poll(() => client.fetchAgentsCalls).toBe(1); - release({ - requestId: "directory", - entries: [ - { - agent, - project: { - projectKey: "/repo", - projectName: "repo", - checkout: { - cwd: "/repo", - isGit: false, - currentBranch: null, - remoteUrl: null, - worktreeRoot: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }, - }, - ], - pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, - }); - await refreshing; - } - if (ordering !== "page-first") reply.resolve(page); - await expect(Promise.all([fetching, concurrent])).resolves.toEqual([page, page]); - expect(applications).toBe(1); - expect(() => client.nextTimelineFetch()).toThrow("Expected a pending timeline fetch"); - expect( - useSessionStore.getState().sessions[serverId]?.agentStreamTail.get("agent")?.[0], - ).toMatchObject({ text: "first page" }); - viewed.dispose(); - directory.dispose(); - }, -); - -it("rejects an old timeline reply after authoritative agent re-entry", async () => { - const serverId = "timeline-lifetime"; - serverIds.add(serverId); - useSessionStore.getState().initializeSession(serverId, null); - const client = new FakeDirectoryClient(); - const lifetimes: string[] = []; - const directory = new DirectorySync(serverId, { - onTimelineRequest: () => () => undefined, - onAgentStoppedRunning: () => undefined, - onAgentRemoved: (agentId) => lifetimes.push(`remove:${agentId}`), - onAgentAccepted: (agentId) => lifetimes.push(`accept:${agentId}`), - markAgentLoading: () => undefined, - markAgentReady: () => undefined, - markAgentError: () => undefined, - }); - directory.connectionChanged({ - client: client as unknown as DaemonClient, - status: "online", - source: { clientGeneration: 1, connectionEpoch: 1 }, - }); - directory.acceptAgent(createAgent(serverId, "agent")); - const request = { direction: "tail" as const, limit: 40, projection: "projected" as const }; - const oldFetch = directory.fetchTimeline("agent", request); - const oldReply = client.nextTimelineFetch(); - - directory.removeAgent("agent"); - const excludedFetch = directory.fetchTimeline("agent", request); - const excludedReply = client.nextTimelineFetch(); - directory.acceptAgent(createAgent(serverId, "agent")); - const freshFetch = directory.fetchTimeline("agent", request); - const freshReply = client.nextTimelineFetch(); - const page = { agent: null, hasNewer: false } as TimelineFetchResult; - - oldReply.resolve(page); - await expect(oldFetch).rejects.toBeInstanceOf(DirectoryRefreshSupersededError); - excludedReply.resolve(page); - await expect(excludedFetch).rejects.toBeInstanceOf(DirectoryRefreshSupersededError); - freshReply.resolve(page); - await expect(freshFetch).resolves.toBe(page); - expect(lifetimes).toEqual(["accept:agent", "remove:agent", "accept:agent"]); -}); - afterEach(() => { for (const serverId of serverIds) useSessionStore.getState().clearSession(serverId); serverIds.clear(); }); describe("DirectorySync session readiness", () => { - it("restores other cached workspaces when a live removal arrives during startup", async () => { - const serverId = "cache-with-live-removal"; - serverIds.add(serverId); - const client = new FakeDirectoryClient(); - const workspace = normalizeWorkspaceDescriptor({ - id: "cached-workspace", - projectId: "cached-project", - projectDisplayName: "Cached project", - projectRootPath: "/repo/cached", - workspaceDirectory: "/repo/cached", - projectKind: "git", - workspaceKind: "local_checkout", - name: "cached", - status: "done", - statusEnteredAt: null, - activityAt: null, - archivingAt: null, - diffStat: null, - scripts: [], - }); - const { cache, releaseLoad } = await savedDirectory(serverId, { - agents: new Map(), - projects: new Map(), - workspaces: new Map([ - [workspace.id, workspace], - ["deleted-live", { ...workspace, id: "deleted-live" }], - ]), - }); - useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); - const directory = new DirectorySync(serverId, noopCallbacks, cache); - directory.connectionChanged({ - client: client as unknown as DaemonClient, - status: "online", - source: { clientGeneration: 1, connectionEpoch: 1 }, - }); - client.emit({ type: "workspace_update", payload: { kind: "remove", id: "deleted-live" } }); - releaseLoad(); - await directory.ready(); - - expect([...useSessionStore.getState().sessions[serverId]!.workspaces.keys()]).toEqual([ - workspace.id, - ]); - expect(selectWorkspaceDirectoryServerIds(useSessionStore.getState(), [serverId])).toEqual([ - serverId, - ]); - expect(client.fetchWorkspacesCalls).toBe(0); - directory.dispose(); - }); - it("restores the cached directory before network demand", async () => { const serverId = "offline-cached-directory"; serverIds.add(serverId); @@ -649,24 +215,32 @@ describe("DirectorySync session readiness", () => { projectRootPath: "/repo/cached", projectKind: "git", }); - const cachedAgent = createAgent(serverId, "agent-1"); - cachedAgent.workspaceId = cachedWorkspace.id; - const directory = new DirectorySync(serverId, noopCallbacks, { - readDirectory: async () => ({ - agents: new Map([[cachedAgent.id, cachedAgent]]), - workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]), - projects: new Map([[cachedProject.projectId, cachedProject]]), - }), - commitDirectoryMutations: () => undefined, - }); + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readAgent: async () => undefined, + readWorkspace: async () => undefined, + readDirectory: async () => ({ + agents: new Map(), + workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]), + projects: new Map([[cachedProject.projectId, cachedProject]]), + }), + commitDirectoryMutations: () => undefined, + }, + ); useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); - await directory.ready(); + await directory.restoreCachedDirectory(); - const session = useSessionStore.getState().sessions[serverId]; - expect(session?.hasHydratedAgents).toBe(false); - expect(session?.agents.get(cachedAgent.id)).toEqual(cachedAgent); - expect(session?.workspaces.get(cachedWorkspace.id)).toEqual(cachedWorkspace); + expect( + useSessionStore.getState().sessions[serverId]?.workspaces.get(cachedWorkspace.id), + ).toEqual(cachedWorkspace); expect(selectWorkspaceDirectoryServerIds(useSessionStore.getState(), [serverId])).toEqual([ serverId, ]); @@ -675,7 +249,62 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); - it("persists accepted script status updates through the directory owner", async () => { + it("prepares a cached agent workspace route without sidebar demand", async () => { + const serverId = "cached-agent-route"; + serverIds.add(serverId); + const client = new FakeDirectoryClient(); + const cachedAgent = createAgent(serverId, "agent-1"); + const cachedWorkspace = normalizeWorkspaceDescriptor({ + id: "workspace-1", + projectId: "project-1", + projectDisplayName: "Paseo", + projectRootPath: "/repo", + workspaceDirectory: "/repo", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "done", + statusEnteredAt: null, + activityAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }); + cachedAgent.workspaceId = cachedWorkspace.id; + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readAgent: async () => cachedAgent, + readWorkspace: async () => ({ workspace: cachedWorkspace }), + readDirectory: async () => ({ + agents: new Map(), + workspaces: new Map(), + projects: new Map(), + }), + commitDirectoryMutations: () => undefined, + }, + ); + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); + + await directory.prepareAgentRoute(cachedAgent.id); + + const session = useSessionStore.getState().sessions[serverId]; + expect(session?.hasHydratedAgents).toBe(false); + expect(session?.hasWorkspaceDirectorySnapshot).toBe(false); + expect(session?.agents.get(cachedAgent.id)).toEqual(cachedAgent); + expect(session?.workspaces.get(cachedWorkspace.id)).toEqual(cachedWorkspace); + expect(client.fetchAgentsCalls).toBe(0); + expect(client.fetchWorkspacesCalls).toBe(0); + directory.dispose(); + }); + + it("persists accepted script status updates through the directory owner", () => { const serverId = "script-status-owner"; serverIds.add(serverId); const client = new FakeDirectoryClient(); @@ -683,14 +312,14 @@ describe("DirectorySync session readiness", () => { const directory = new DirectorySync( serverId, { - onTimelineRequest: () => () => undefined, onAgentStoppedRunning: () => undefined, - onAgentRemoved: () => undefined, markAgentLoading: () => undefined, markAgentReady: () => undefined, markAgentError: () => undefined, }, { + readAgent: async () => undefined, + readWorkspace: async () => undefined, readDirectory: async () => ({ agents: new Map(), workspaces: new Map(), @@ -722,7 +351,6 @@ describe("DirectorySync session readiness", () => { }); const store = useSessionStore.getState(); store.initializeSession(serverId, client as unknown as DaemonClient, 1); - await directory.ready(); directory.acceptWorkspaces([workspace]); commits.length = 0; @@ -850,14 +478,14 @@ describe("DirectorySync session readiness", () => { const directory = new DirectorySync( serverId, { - onTimelineRequest: () => () => undefined, onAgentStoppedRunning: () => undefined, - onAgentRemoved: () => undefined, markAgentLoading: () => undefined, markAgentReady: () => undefined, markAgentError: () => undefined, }, { + readAgent: async () => undefined, + readWorkspace: async () => undefined, readDirectory: async () => { cacheReads += 1; return { @@ -932,14 +560,14 @@ describe("DirectorySync session readiness", () => { const directory = new DirectorySync( serverId, { - onTimelineRequest: () => () => undefined, onAgentStoppedRunning: () => undefined, - onAgentRemoved: () => undefined, markAgentLoading: () => undefined, markAgentReady: () => undefined, markAgentError: () => undefined, }, { + readAgent: async () => undefined, + readWorkspace: async () => undefined, readDirectory: async () => ({ agents: new Map(), workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]), @@ -983,7 +611,7 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); - it("advances the saved cursor only through completed catch-up", async () => { + it("does not use a cached checkpoint when the corresponding cache read loses its race", async () => { const serverId = "late-directory-cache"; serverIds.add(serverId); const client = new FakeDirectoryClient(); @@ -998,14 +626,14 @@ describe("DirectorySync session readiness", () => { const directory = new DirectorySync( serverId, { - onTimelineRequest: () => () => undefined, onAgentStoppedRunning: () => undefined, - onAgentRemoved: () => undefined, markAgentLoading: () => undefined, markAgentReady: () => undefined, markAgentError: () => undefined, }, { + readAgent: async () => undefined, + readWorkspace: async () => undefined, readDirectory: () => cacheRead, commitDirectoryMutations: () => undefined, }, @@ -1024,95 +652,133 @@ describe("DirectorySync session readiness", () => { features: { workspaceMultiplicity: true, directorySync: true }, }); - const releaseNetwork = client.holdWorkspaceFetch(); const refresh = directory.refreshWorkspaces(); await Promise.resolve(); client.emit({ type: "workspace_update", - payload: { kind: "remove", id: "deleted-live", generation: "g", seq: 20 }, + payload: { kind: "remove", id: "deleted-live" }, }); releaseCache({ agents: new Map(), workspaces: new Map(), projects: new Map(), - checkpoint: { workspaces: { generation: "g", afterSeq: 7 } }, - }); - releaseNetwork({ - requestId: "catch-up", - entries: [], - emptyProjects: [], - pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, - sync: { generation: "g", headSeq: 20, mode: "changes", removals: [] }, + checkpoint: { workspaces: { generation: "stale", afterSeq: 99 } }, }); await refresh; - expect(client.lastWorkspaceOptions).toMatchObject({ - sync: { generation: "g", afterSeq: 7 }, - }); - client.emit({ - type: "workspace_update", - payload: { kind: "remove", id: "another-removal", generation: "g", seq: 21 }, - }); - directory.connectionChanged({ - client: client as unknown as DaemonClient, - status: "offline", - source: { clientGeneration: 1, connectionEpoch: 1 }, - }); - directory.connectionChanged({ - client: client as unknown as DaemonClient, - status: "online", - source: { clientGeneration: 1, connectionEpoch: 2 }, - }); - client.emit({ - type: "workspace_update", - payload: { kind: "remove", id: "after-offline-gap", generation: "g", seq: 40 }, - }); - await directory.refreshWorkspaces(); - expect(client.lastWorkspaceOptions).toMatchObject({ - sync: { generation: "g", afterSeq: 20 }, - }); + expect(client.lastWorkspaceOptions).not.toHaveProperty("sync.generation"); directory.dispose(); }); - it("does not resurrect an agent deleted while the baseline is loading", async () => { + it("does not resurrect an agent deleted while its targeted cache row is loading", async () => { const serverId = "late-agent-cache"; serverIds.add(serverId); const client = new FakeDirectoryClient(); - const cachedAgent = createAgent(serverId, "agent-1"); - const { cache, releaseLoad } = await savedDirectory(serverId, { - agents: new Map([[cachedAgent.id, cachedAgent]]), - workspaces: new Map(), - projects: new Map(), + const cachedAgent = { + ...normalizeAgentSnapshot( + { + id: "agent-1", + provider: "codex", + cwd: "/repo", + model: null, + createdAt: "2026-08-26T00:00:00.000Z", + updatedAt: "2026-08-26T00:00:00.000Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: "Cached", + labels: {}, + }, + serverId, + ), + projectPlacement: null, + }; + let releaseAgent!: (agent: typeof cachedAgent) => void; + const agentRead = new Promise((resolve) => { + releaseAgent = resolve; }); - useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); - const directory = new DirectorySync(serverId, noopCallbacks, cache); + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readAgent: () => agentRead, + readWorkspace: async () => undefined, + readDirectory: async () => ({ + agents: new Map(), + workspaces: new Map(), + projects: new Map(), + }), + commitDirectoryMutations: () => undefined, + }, + ); directory.connectionChanged({ client: client as unknown as DaemonClient, status: "online", source: { clientGeneration: 1, connectionEpoch: 1 }, }); + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); + const load = directory.loadCachedAgent(cachedAgent.id); client.emit({ type: "agent_deleted", payload: { agentId: cachedAgent.id, requestId: "delete-live" }, }); - releaseLoad(); - await directory.ready(); + releaseAgent(cachedAgent); + await load; expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(false); directory.dispose(); }); - it("requests the authoritative snapshot only after the baseline is restored", async () => { + it("does not resurrect an agent removed by an authoritative snapshot while cache loads", async () => { const serverId = "late-agent-authoritative-snapshot"; serverIds.add(serverId); const client = new FakeDirectoryClient(); const cachedAgent = createAgent(serverId, "agent-1"); - const { cache, releaseLoad } = await savedDirectory(serverId, { - agents: new Map([[cachedAgent.id, cachedAgent]]), - workspaces: new Map(), - projects: new Map(), - checkpoint: { agents: { generation: "g", afterSeq: 5 } }, + const releaseNetwork = client.holdAgentFetch(); + let releaseAgent!: (agent: typeof cachedAgent) => void; + const agentRead = new Promise((resolve) => { + releaseAgent = resolve; + }); + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readAgent: () => agentRead, + readWorkspace: async () => undefined, + readDirectory: async () => ({ + agents: new Map(), + workspaces: new Map(), + projects: new Map(), + }), + commitDirectoryMutations: () => undefined, + }, + ); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, }); const store = useSessionStore.getState(); store.initializeSession(serverId, client as unknown as DaemonClient, 1); @@ -1122,22 +788,18 @@ describe("DirectorySync session readiness", () => { version: "test", features: { directorySync: true, workspaceMultiplicity: true }, }); - const directory = new DirectorySync(serverId, noopCallbacks, cache); - directory.connectionChanged({ - client: client as unknown as DaemonClient, - status: "online", - source: { clientGeneration: 1, connectionEpoch: 1 }, - }); const refresh = directory.refreshAgents(); - await Promise.resolve(); - expect(client.fetchAgentsCalls).toBe(0); - releaseLoad(); - await refresh; - - expect(client.lastAgentOptions).toMatchObject({ - sync: { generation: "g", afterSeq: 5 }, + const load = directory.loadCachedAgent(cachedAgent.id); + releaseNetwork({ + requestId: "authoritative-snapshot", + entries: [], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, }); + await refresh; + releaseAgent(cachedAgent); + await load; + expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(false); directory.dispose(); }); @@ -1149,14 +811,14 @@ describe("DirectorySync session readiness", () => { const directory = new DirectorySync( serverId, { - onTimelineRequest: () => () => undefined, onAgentStoppedRunning: () => undefined, - onAgentRemoved: () => undefined, markAgentLoading: () => undefined, markAgentReady: () => undefined, markAgentError: () => undefined, }, { + readAgent: async () => undefined, + readWorkspace: async () => undefined, readDirectory: async () => ({ agents: new Map(), workspaces: new Map(), @@ -1274,14 +936,14 @@ describe("DirectorySync session readiness", () => { const directory = new DirectorySync( serverId, { - onTimelineRequest: () => () => undefined, onAgentStoppedRunning: () => undefined, - onAgentRemoved: () => undefined, markAgentLoading: () => undefined, markAgentReady: () => undefined, markAgentError: () => undefined, }, { + readAgent: async () => undefined, + readWorkspace: async () => undefined, readDirectory: async () => ({ agents: new Map(), workspaces: new Map(), @@ -1477,3 +1139,122 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); }); + +function createSqliteCache() { + const database = new DatabaseSync(":memory:"); + let beforeRead = async () => {}; + const connection: ReplicaSqliteConnection = { + async exec(sql) { + database.exec(sql); + }, + async run(sql, params = []) { + database.prepare(sql).run(...params); + }, + async all(sql: string, params: readonly SqliteValue[] = []) { + const rows = database.prepare(sql).all(...params) as Row[]; + if (sql.includes("FROM rows") && sql.includes("WHERE")) await beforeRead(); + return rows; + }, + async transaction(operation) { + database.exec("BEGIN IMMEDIATE"); + try { + await operation(connection); + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } + }, + }; + const storage = createSqliteReplicaRowStore({ open: async () => connection }, 1); + const cache = new ReplicaCache(storage, { clearLegacyCache: async () => {} }); + return { + cache, + database, + holdRead: (operation: () => Promise) => { + beforeRead = operation; + }, + }; +} + +it("fills every cached workspace beneath live updates received during the SQLite read", async () => { + const serverId = "sqlite-directory-race"; + serverIds.add(serverId); + const { cache, database, holdRead } = createSqliteCache(); + cache.setHosts([serverId]); + const project = normalizeProjectDescriptor({ + projectId: "project", + projectDisplayName: "Cached", + projectRootPath: "/repo", + projectKind: "git", + }); + const workspaces = ["first", "second", "third"].map((id) => + normalizeWorkspaceDescriptor({ + id, + projectId: "project", + projectDisplayName: "Cached", + projectRootPath: "/repo", + workspaceDirectory: `/repo/${id}`, + projectKind: "git", + workspaceKind: "local_checkout", + name: id, + status: "done", + statusEnteredAt: null, + activityAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }), + ); + cache.replaceDirectoryBaseline(serverId, { + agents: new Map([["agent", createAgent(serverId, "agent")]]), + workspaces: new Map(workspaces.map((workspace) => [workspace.id, workspace])), + projects: new Map([[project.projectId, project]]), + }); + await cache.flush(); + const client = new FakeDirectoryClient(); + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => {}, + markAgentLoading: () => {}, + markAgentReady: () => {}, + markAgentError: () => {}, + }, + cache, + ); + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + let updated = false; + holdRead(async () => { + if (updated) return; + updated = true; + client.emit({ + type: "workspace_update", + payload: { + kind: "upsert", + workspace: { ...workspaces[0], name: "Live", activityAt: null, statusEnteredAt: null }, + }, + }); + }); + await directory.restoreCachedDirectory(); + const session = useSessionStore.getState().sessions[serverId]; + expect( + [...session.workspaces.values()] + .map(({ id, name }) => ({ id, name })) + .sort((a, b) => a.id.localeCompare(b.id)), + ).toEqual([ + { id: "first", name: "Live" }, + { id: "second", name: "second" }, + { id: "third", name: "third" }, + ]); + expect(session.agents.get("agent")?.title).toBe("Cached"); + expect(session.hasWorkspaceDirectorySnapshot).toBe(true); + directory.dispose(); + await cache.flush(); + database.close(); +}); diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index 0894de257a..a490421ceb 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -3,6 +3,7 @@ import type { FetchAgentsEntry, FetchAgentsOptions, } from "@getpaseo/client/internal/daemon-client"; +import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once"; import { normalizeProjectDescriptor, normalizeWorkspaceDescriptor, @@ -17,7 +18,7 @@ import { shouldUseLegacyDaemonWorkspaceDirectory, stampLegacyWorkspaceIds, } from "@/workspace/legacy-daemon-workspaces"; -import type { AgentDirectoryDelta, AgentRemovalReason } from "@/utils/agent-directory-sync"; +import type { AgentDirectoryDelta } from "@/utils/agent-directory-sync"; import { AgentDirectoryReplica } from "./agent-replica"; import { WorkspaceDirectoryReplica, @@ -32,14 +33,13 @@ import { import { workspaceLabels } from "@/workspace-labels"; import type { CachedDirectory, + CachedWorkspace, DirectoryCheckpoint, DirectoryCursor, DirectoryReplicaMutation, } from "@/runtime/replica-cache"; import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; -type TimelinePage = Awaited>; - const PAGE_LIMIT = 200; const AGENT_SORT: NonNullable = [ { key: "updated_at", direction: "desc" }, @@ -108,6 +108,8 @@ export interface DirectoryConnection { } export interface DirectoryCheckpointStorage { + readAgent(serverId: string, agentId: string): Promise; + readWorkspace(serverId: string, workspaceId: string): Promise; readDirectory(serverId: string): Promise; commitDirectoryMutations( serverId: string, @@ -137,7 +139,6 @@ export class DirectorySync { WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta >(); - private readonly timelineRequests = new Map>(); private readonly agents: AgentDirectoryReplica; private readonly workspaces: WorkspaceDirectoryReplica; private connection: DirectoryConnection = { @@ -147,10 +148,10 @@ export class DirectorySync { }; private unsubscribe: (() => void) | null = null; private readonly abortSessionWaits = new Set<() => void>(); - // The saved baseline is restored once, at construction. Live mutations apply as they arrive and - // the baseline merges under them; network refreshes wait for it so their transactions and - // cursors start from the saved state. - private readonly baseline: Promise; + private cacheLoad: Promise | null = null; + private cacheAccepted = false; + private revision = 0; + private workspaceRevision = 0; private readonly routeDemandIds = new Set(); private readonly fullDemandSources = new Set(); private demandRefresh: Promise | null = null; @@ -160,10 +161,7 @@ export class DirectorySync { constructor( private readonly serverId: string, private readonly callbacks: { - onTimelineRequest: (agentId: string, replaceTail: boolean) => (page: TimelinePage) => void; onAgentStoppedRunning: (agentId: string) => void; - onAgentRemoved: (agentId: string, reason: AgentRemovalReason) => void; - onAgentAccepted?: (agentId: string) => void; markAgentLoading: () => void; markAgentReady: () => void; markAgentError: (error: string) => void; @@ -172,19 +170,8 @@ export class DirectorySync { ) { const persist = (mutations: readonly DirectoryReplicaMutation[]) => this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); - this.agents = new AgentDirectoryReplica( - serverId, - callbacks.onAgentStoppedRunning, - persist, - callbacks.onAgentRemoved, - callbacks.onAgentAccepted, - ); + this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning, persist); this.workspaces = new WorkspaceDirectoryReplica(serverId); - this.baseline = this.restoreBaseline().catch(() => undefined); - } - - ready(): Promise { - return this.baseline; } connectionChanged(connection: DirectoryConnection): boolean { @@ -198,7 +185,6 @@ export class DirectorySync { return false; } this.flushAbortedTransactions(); - this.timelineRequests.clear(); this.unsubscribe?.(); this.unsubscribe = null; workspaceLabels.disconnect(this.serverId); @@ -210,39 +196,63 @@ export class DirectorySync { const subscriptions = [ client.on("agent_update", (message) => { if (message.type !== "agent_update" || !this.isCurrent(client, source)) return; - if (!this.agentTransactions.record(source, message.payload)) { + this.revision += 1; + const recorded = this.agentTransactions.record(source, message.payload); + if (!recorded) { this.agents.applyDelta(message.payload); + this.noteLiveCursor("agents", message.payload); + this.persistCheckpoint(); } }), client.on("workspace_update", (message) => { if (message.type !== "workspace_update" || !this.isCurrent(client, source)) return; - if (!this.workspaceTransactions.record(source, message.payload)) { + this.revision += 1; + this.workspaceRevision += 1; + const recorded = this.workspaceTransactions.record(source, message.payload); + if (!recorded) { this.applyWorkspaceDelta(message.payload); + this.noteLiveCursor("workspaces", message.payload); + this.persistCheckpoint(); } }), client.on("project.update", (message) => { if (message.type !== "project.update" || !this.isCurrent(client, source)) return; - if (!this.workspaceTransactions.record(source, message.payload)) { + this.revision += 1; + this.workspaceRevision += 1; + const recorded = this.workspaceTransactions.record(source, message.payload); + if (!recorded) { this.applyWorkspaceDelta(message.payload); + this.noteLiveCursor("projects", message.payload); + this.persistCheckpoint(); } }), client.on("script_status_update", (message) => { if (message.type !== "script_status_update" || !this.isCurrent(client, source)) return; + this.revision += 1; + this.workspaceRevision += 1; const delta: WorkspaceDirectoryDelta = { kind: "script_status", update: message.payload, }; - if (!this.workspaceTransactions.record(source, delta)) { + const recorded = this.workspaceTransactions.record(source, delta); + if (!recorded) { this.applyWorkspaceDelta(delta); + this.persistCheckpoint(); } }), client.on("agent_deleted", (message) => { - if (message.type !== "agent_deleted" || !this.isCurrent(client, source)) return; - this.agents.remove(message.payload.agentId); + if (message.type === "agent_deleted" && this.isCurrent(client, source)) { + this.revision += 1; + this.agents.remove(message.payload.agentId); + this.persistCheckpoint(); + } }), client.on("agent_archived", (message) => { - if (message.type !== "agent_archived" || !this.isCurrent(client, source)) return; - this.agents.archive(message.payload.agentId, message.payload.archivedAt); + if (message.type === "agent_archived" && this.isCurrent(client, source)) { + this.revision += 1; + this.agents.archive(message.payload.agentId, message.payload.archivedAt); + this.persistCheckpoint(); + } }), ]; this.unsubscribe = () => { @@ -256,8 +266,9 @@ export class DirectorySync { const wasDemanded = this.fullDemandSources.size > 0; if (demanded) this.fullDemandSources.add(source); else this.fullDemandSources.delete(source); - if (!wasDemanded && this.fullDemandSources.size > 0 && this.getOnlineConnection()) { - void this.requestDemandRefresh().catch(() => undefined); + if (!wasDemanded && this.fullDemandSources.size > 0) { + void this.loadCachedDirectory().catch(() => undefined); + if (this.getOnlineConnection()) void this.requestDemandRefresh().catch(() => undefined); } } @@ -277,8 +288,6 @@ export class DirectorySync { } dispose(): void { - this.connection = { ...this.connection, status: "offline" }; - this.timelineRequests.clear(); this.flushAbortedTransactions(); this.abortPendingSessionWaits(); this.unsubscribe?.(); @@ -295,7 +304,9 @@ export class DirectorySync { private requestDemandRefresh(force = false): Promise { if (this.demandRefresh) return this.demandRefresh; if (!this.hasDemand()) return Promise.resolve(); - if (!this.getOnlineConnection()) return this.baseline; + if (!this.getOnlineConnection()) { + return this.fullDemandSources.size > 0 ? this.loadCachedDirectory() : Promise.resolve(); + } const source = this.connection.source; if ( !force && @@ -308,8 +319,8 @@ export class DirectorySync { this.fullDemandSources.size > 0 ? this.refreshAll() : Promise.all([ - this.refreshAgents({ subscribe: {} }), - this.refreshWorkspaces({ subscribe: true }), + this.refreshAgentsInternal({ subscribe: {} }, false), + this.refreshWorkspacesInternal({ subscribe: true }, false), ]).then(() => undefined); this.demandRefresh = refresh .then(() => { @@ -334,13 +345,57 @@ export class DirectorySync { return this.requestDemandRefresh(true); } - private async restoreBaseline(): Promise { + async loadCachedAgent(agentId: string): Promise { if (!this.checkpoints) return; - const cached = await this.checkpoints.readDirectory(this.serverId); - if (!useSessionStore.getState().sessions[this.serverId]) return; - this.agents.commitCached(cached.agents); - this.workspaces.commitCached(cached); - this.cursors = cached.checkpoint ?? {}; + if (useSessionStore.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]; + if (!session || session.agents.has(agentId)) return; + this.agents.commitCachedAgent(token, agent); + } + + async prepareAgentRoute(agentId: string): Promise { + await this.loadCachedAgent(agentId); + const agent = useSessionStore.getState().sessions[this.serverId]?.agents.get(agentId); + if (agent?.workspaceId) await this.loadCachedWorkspace(agent.workspaceId); + } + + async prepareWorkspaceRoute(workspaceId: string): Promise { + await this.loadCachedWorkspace(workspaceId); + } + + private async loadCachedWorkspace(workspaceId: string): Promise { + if (!this.checkpoints) return; + if (useSessionStore.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]; + if (!session) return; + this.workspaces.commitCachedWorkspace(cached.workspace, cached.project); + } + + private loadCachedDirectory(): Promise { + const checkpoints = this.checkpoints; + if (!checkpoints) return Promise.resolve(); + this.cacheLoad ??= (async () => { + const revision = this.revision; + const cached = await checkpoints.readDirectory(this.serverId); + if (this.cacheAccepted) return; + if (!useSessionStore.getState().sessions[this.serverId]) return; + this.agents.commitCached(cached.agents); + this.workspaces.commitCached(cached); + if (this.revision === revision) this.cursors = cached.checkpoint ?? {}; + this.cacheAccepted = true; + })(); + return this.cacheLoad; + } + + restoreCachedDirectory(): Promise { + return this.loadCachedDirectory(); } applyAgentTurnLiveness( @@ -357,36 +412,33 @@ export class DirectorySync { async fetchTimeline( agentId: string, request: Parameters[1], - replaceTail = false, - ): Promise { - const { client, source } = this.requireOnline(); + ): Promise>> { + const { client } = this.requireOnline(); const token = this.agents.captureTimeline(agentId); - const key = JSON.stringify({ agentId, lifetime: token.version, request, replaceTail }); - const existing = this.timelineRequests.get(key); - if (existing) return existing; - const apply = this.callbacks.onTimelineRequest(agentId, replaceTail); - const fetching = client.fetchAgentTimeline(agentId, request).then((page) => { - if (!this.isCurrent(client, source) || !this.agents.isTimelineCurrent(token)) { - throw new DirectoryRefreshSupersededError("timeline page no longer current"); - } - if (page.agent) this.agents.submitTimelineAgent(token, page.agent); - apply(page); - return page; - }); - this.timelineRequests.set(key, fetching); - const clear = () => { - if (this.timelineRequests.get(key) === fetching) this.timelineRequests.delete(key); - }; - void fetching.then(clear, clear); - return fetching; + const page = await fetchAgentTimelineOnce(client, agentId, request); + if (page.agent && this.agents.submitTimelineAgent(token, page.agent)) { + this.revision += 1; + this.persistCheckpoint(); + } + return page; } async refreshAgents( input: RefreshAgentDirectoryInput = {}, ): Promise { + return this.refreshAgentsInternal(input, true); + } + + private async refreshAgentsInternal( + input: RefreshAgentDirectoryInput, + loadDirectoryCache: boolean, + ): Promise { + if (loadDirectoryCache) { + this.primeAgentTransactionForCacheLoad(); + await this.loadCachedDirectory(); + } const onlineConnection = this.getOnlineConnection(); if (!onlineConnection) { - await this.baseline; this.callbacks.markAgentReady(); return { agents: new Map(useSessionStore.getState().sessions[this.serverId]?.agents), @@ -394,8 +446,6 @@ export class DirectorySync { }; } const { client, source } = onlineConnection; - // The transaction spans the whole refresh, so a live delta that arrives while the saved - // baseline or the session is still pending is replayed on top of whatever snapshot lands. const transaction = this.agentTransactions.begin(source, () => ({ entries: [], subscriptionId: null, @@ -404,7 +454,6 @@ export class DirectorySync { })); this.callbacks.markAgentLoading(); try { - await this.baseline; await this.waitForSession(client, source); const session = useSessionStore.getState().sessions[this.serverId]; if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) { @@ -461,8 +510,9 @@ export class DirectorySync { : delta, ) : completion.deltas; + this.revision += 1; const agents = this.commitAgentSnapshot(completion.snapshot, deltas); - this.persistAgentCursors(completion.snapshot); + this.persistAgentCursors(completion.snapshot, completion.deltas); this.persistCheckpoint(); this.callbacks.markAgentReady(); return { agents, subscriptionId: completion.snapshot.subscriptionId }; @@ -477,27 +527,26 @@ export class DirectorySync { } async refreshWorkspaces(input?: { subscribe?: boolean }): Promise { + return this.refreshWorkspacesInternal(input, true); + } + + private async refreshWorkspacesInternal( + input: { subscribe?: boolean } | undefined, + loadDirectoryCache: boolean, + ): Promise { + if (loadDirectoryCache) await this.loadCachedDirectory(); const onlineConnection = this.getOnlineConnection(); - if (!onlineConnection) { - await this.baseline; - return; - } + if (!onlineConnection) return; const { client, source } = onlineConnection; - // The transaction spans the whole refresh. Its base for a changes-mode page is the store once - // the saved baseline is in it, so it is captured after that wait rather than at begin. const transaction = this.workspaceTransactions.begin(source, () => ({ - workspaces: new Map(), - projects: new Map(), + workspaces: new Map(useSessionStore.getState().sessions[this.serverId]?.workspaces), + projects: new Map(useSessionStore.getState().sessions[this.serverId]?.projects), syncCursors: {}, syncModes: {}, touchedWorkspaceIds: new Set(), touchedProjectIds: new Set(), })); try { - await this.baseline; - const session = useSessionStore.getState().sessions[this.serverId]; - transaction.snapshot.workspaces = new Map(session?.workspaces); - transaction.snapshot.projects = new Map(session?.projects); await this.waitForSessionMetadata(client, source); const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo; if (serverInfo?.features?.workspaceMultiplicity !== true) { @@ -676,6 +725,17 @@ export class DirectorySync { return { client: this.connection.client, source: this.connection.source }; } + private primeAgentTransactionForCacheLoad(): void { + const connection = this.getOnlineConnection(); + if (!connection) return; + this.agentTransactions.begin(connection.source, () => ({ + entries: [], + subscriptionId: null, + legacy: false, + syncRemovals: [], + })); + } + private supportsDirectorySync(): boolean { return ( useSessionStore.getState().sessions[this.serverId]?.serverInfo?.features?.directorySync === @@ -692,8 +752,12 @@ export class DirectorySync { : this.agents.commitSnapshot(snapshot.entries, deltas); } - private persistAgentCursors(snapshot: AgentSnapshot): void { + private persistAgentCursors( + snapshot: AgentSnapshot, + deltas: readonly AgentDirectoryDelta[], + ): void { if (snapshot.syncCursor) this.writeCursor("agents", snapshot.syncCursor); + for (const delta of deltas) this.noteLiveCursor("agents", delta); } private async fetchProjectSnapshot( @@ -737,6 +801,8 @@ export class DirectorySync { if (completion.kind === "stale") { throw new DirectoryRefreshSupersededError("workspace completion was superseded"); } + this.revision += 1; + this.workspaceRevision += 1; const previous = this.readWorkspaceState(); const deltaMutations = this.workspaces.commitSnapshot(completion.snapshot, completion.deltas); const next = this.workspaces.snapshot(); @@ -756,11 +822,14 @@ export class DirectorySync { for (const [entity, cursor] of Object.entries(completion.snapshot.syncCursors ?? {})) { if (cursor) this.writeCursor(entity as "projects" | "workspaces", cursor); } + for (const delta of completion.deltas) { + if (delta.kind === "script_status") continue; + const entity = "projectId" in delta || "project" in delta ? "projects" : "workspaces"; + this.noteLiveCursor(entity, delta); + } this.persistCheckpoint(); } - // Only a completed catch-up may advance the saved cursor. A live sequence can follow an offline - // gap, so saving it would claim intermediate changes the client never received. private persistCheckpoint(): void { this.checkpoints?.commitDirectoryMutations(this.serverId, [], this.cursors); } @@ -805,6 +874,17 @@ export class DirectorySync { snapshot.syncCursor = { generation: sync.generation, afterSeq: sync.headSeq }; } + private noteLiveCursor( + entity: keyof DirectoryCheckpoint, + payload: { generation?: string; seq?: number }, + ): void { + if (!payload.generation || payload.seq === undefined) return; + this.writeCursor(entity, { + generation: payload.generation, + afterSeq: payload.seq, + }); + } + private readCursors(): DirectoryCheckpoint { return this.cursors; } diff --git a/packages/app/src/runtime/directory-sync/internal/agent-store.ts b/packages/app/src/runtime/directory-sync/internal/agent-store.ts index b4504d9c8b..0566da5aaf 100644 --- a/packages/app/src/runtime/directory-sync/internal/agent-store.ts +++ b/packages/app/src/runtime/directory-sync/internal/agent-store.ts @@ -2,11 +2,7 @@ import equal from "fast-deep-equal"; import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import { useSessionStore, type Agent } from "@/stores/session-store"; import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy"; -import { - buildAgentDirectoryState, - type AgentDirectoryDelta, - type AgentRemovalReason, -} from "@/utils/agent-directory-sync"; +import { buildAgentDirectoryState, type AgentDirectoryDelta } from "@/utils/agent-directory-sync"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { resolveProjectPlacement } from "@/utils/project-placement"; import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; @@ -27,10 +23,7 @@ function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent } export class AgentStoreProjection { - constructor( - private readonly serverId: string, - private readonly removeTimeline: (agentId: string, reason: AgentRemovalReason) => void, - ) {} + constructor(private readonly serverId: string) {} snapshot(): Map { return useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map(); @@ -72,7 +65,7 @@ export class AgentStoreProjection { agent?: Agent; } { if (delta.kind === "remove") { - this.remove(delta.agentId, "scope"); + this.remove(delta.agentId); return { agentId: delta.agentId, stoppedRunning: false }; } const normalized = normalizeAgentSnapshot(delta.agent, this.serverId); @@ -148,7 +141,7 @@ export class AgentStoreProjection { useSessionStore.getState().setPendingPermissions(this.serverId, pending); } - remove(agentId: string, reason: AgentRemovalReason): void { + remove(agentId: string): void { const store = useSessionStore.getState(); const removeKey = (current: Map): Map => { if (!current.has(agentId)) return current; @@ -160,13 +153,16 @@ export class AgentStoreProjection { store.setAgents(this.serverId, removeKey); store.setAgentDetails(this.serverId, removeKey); store.setQueuedMessages(this.serverId, removeKey); - this.removeTimeline(agentId, reason); + store.setAgentTimelineCursor(this.serverId, removeKey); store.setInitializingAgents(this.serverId, removeKey); store.setPendingPermissions(this.serverId, (current) => { const next = new Map(current); for (const [key, pending] of next) if (pending.agentId === agentId) next.delete(key); return next.size === current.size ? current : next; }); + store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false); + store.setAgentStreamTail(this.serverId, removeKey); + store.clearAgentStreamHead(this.serverId, agentId); useSessionStore.setState((state) => { if (!state.agentLastActivity.has(agentId)) return state; const agentLastActivity = new Map(state.agentLastActivity); diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts index ffb2023736..84f08b22fc 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts @@ -213,7 +213,7 @@ it("does not invent a null-key project from a workspace update", () => { store.clearSession(serverId); }); -it("does not restore a cached workspace while its archive is pending", () => { +it("does not restore a targeted cached workspace while its archive is pending", () => { const serverId = "cached-workspace-during-archive"; const workspaceId = "archived-workspace"; const store = useSessionStore.getState(); @@ -222,10 +222,7 @@ it("does not restore a cached workspace while its archive is pending", () => { markWorkspaceArchivePending({ serverId, workspaceId }); try { - replica.commitCached({ - workspaces: new Map([[workspaceId, normalizeWorkspaceDescriptor(workspace(workspaceId))]]), - projects: new Map(), - }); + replica.commitCachedWorkspace(normalizeWorkspaceDescriptor(workspace(workspaceId)), undefined); expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false); } finally { diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.ts b/packages/app/src/runtime/directory-sync/workspace-replica.ts index 5e8951edf3..d229c196a2 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -58,6 +58,15 @@ export class WorkspaceDirectoryReplica { useSessionStore.getState().setHasWorkspaceDirectorySnapshot(this.serverId, true); } + commitCachedWorkspace( + workspace: WorkspaceDescriptor, + project: ProjectDescriptor | undefined, + ): void { + if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) return; + if (project) this.setProject(project); + this.setWorkspace(workspace); + } + commitSnapshot( snapshot: WorkspaceDirectorySnapshot, deltas: readonly WorkspaceDirectoryDelta[], diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index a7ba9eb784..b77d53f646 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -22,8 +22,6 @@ import { type HostRuntimeStorage, } from "./host-runtime"; import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store"; -import { ReplicaCache } from "./replica-cache"; -import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; class FakeDaemonClient { private state: ConnectionState = { status: "idle" }; @@ -39,13 +37,6 @@ class FakeDaemonClient { public fetchAgentsResponses: Array< Awaited> | ReturnType > = []; - public timelineCalls = 0; - public timelineResponse: ReturnType | null = null; - fetchAgentTimeline(): ReturnType { - this.timelineCalls++; - if (!this.timelineResponse) throw new Error("Missing timeline response"); - return this.timelineResponse; - } public sentAgentMessages: Array> = []; public sendAgentMessageFailures: Error[] = []; public sendAgentMessageResponses: Promise[] = []; @@ -475,13 +466,16 @@ function createMemoryReplicaRowStore(): ReplicaRowStore { `${row.serverId}:${row.kind}:${row.id}`; return { open: async () => undefined, - read: async (serverIds, kinds, ids) => - [...rows.values()].filter( + read: async (serverId, kinds, ids) => { + const acceptedKinds = new Set(kinds); + const acceptedIds = ids ? new Set(ids) : null; + return [...rows.values()].filter( (row) => - serverIds.includes(row.serverId) && - kinds.includes(row.kind) && - (!ids || ids.includes(row.id)), - ), + row.serverId === serverId && + acceptedKinds.has(row.kind) && + (!acceptedIds || acceptedIds.has(row.id)), + ); + }, readAll: async () => { const hosts = new Map(); for (const row of rows.values()) { @@ -1605,74 +1599,15 @@ describe("HostRuntimeStore", () => { expect(store.getHosts()).toEqual([]); }); - it("exposes timeline demand and paints saved history before a network client exists", async () => { - const host = makeHost(); - const storage = createMemoryHostRuntimeStorage({ - "@paseo:daemon-registry": JSON.stringify([host]), - "@paseo:e2e": "1", - }); - const replicaRowStore = createMemoryReplicaRowStore(); - const disk = new ReplicaCache(replicaRowStore, { clearLegacyCache: async () => undefined }); - disk.setHosts([host.serverId]); - disk.commitTimeline(host.serverId, "offline-agent", { - agentId: "offline-agent", - hasOlder: false, - range: { epoch: "offline-epoch", startSeq: 1, endSeq: 1 }, - items: [ - { - kind: "assistant_message", - id: "saved-message", - messageId: "saved-message", - text: "Saved history is available offline", - timestamp: new Date("2026-09-06T10:00:00.000Z"), - timelineCursor: { epoch: "offline-epoch", seq: 1 }, - source: { startSeq: 1 }, - }, - ], - }); - await disk.flush(); - const store = new HostRuntimeStore({ - storage, - replicaRowStore, - deps: { - createClient: () => { - throw new Error("Offline demand must not need a client"); - }, - connectToDaemon: async () => { - throw new Error("Offline demand must not need a connection"); - }, - getClientId: async () => "cid_test_runtime", - }, - }); - try { - const registryLoaded = onceHostListMatches(store, () => store.isHostRegistryLoaded()); - store.boot(); - await registryLoaded; - expect(useSessionStore.getState().sessions[host.serverId]?.client).toBeNull(); - const demand = useSessionStore.getState().sessions[host.serverId]?.viewedTimelineSync; - expect(demand).not.toBeNull(); - if (!demand) throw new Error("Registered hosts must expose offline timeline demand"); - demand.registerVisibleAgentIds("workspace", ["offline-agent"]); - await vi.waitFor(() => { - expect( - useSessionStore.getState().sessions[host.serverId]?.agentStreamTail.get("offline-agent"), - ).toMatchObject([{ text: "Saved history is available offline" }]); - }); - } finally { - store.syncHosts([]); - useSessionStore.getState().clearSession(host.serverId); - } - }); - it("loads the host registry and restores its cached directory", async () => { const host = makeHost(); const storage = createMemoryHostRuntimeStorage(); - let directoryReads = 0; + let fullScans = 0; const backingStore = createMemoryReplicaRowStore(); const replicaRowStore: ReplicaRowStore = { ...backingStore, read: async (...args) => { - directoryReads += 1; + fullScans += 1; return backingStore.read(...args); }, }; @@ -1697,7 +1632,7 @@ describe("HostRuntimeStore", () => { store.boot(); await registryLoaded; - await vi.waitFor(() => expect(directoryReads).toBe(1)); + await vi.waitFor(() => expect(fullScans).toBe(1)); expect(useSessionStore.getState().sessions[host.serverId]).toMatchObject({ client: null, hasHydratedAgents: false, @@ -2394,15 +2329,10 @@ describe("HostRuntimeStore", () => { host.serverId, new Map([[recoveredAgent.agent.id, replicaAgent(recoveredAgent.agent, host.serverId)]]), ); - sessionStore.applyAgentTimelineResponseState(host.serverId, recoveredAgent.agent.id, { - items: [], - head: [], - range: { epoch: "epoch", startSeq: 10, endSeq: 20 }, - older: "none", - newer: false, - synchronized: false, - submissions: [], - }); + sessionStore.setAgentTimelineCursor( + host.serverId, + new Map([[recoveredAgent.agent.id, { epoch: "epoch", startSeq: 10, endSeq: 20 }]]), + ); store.syncHosts([host]); await waitForHostOnline(store, host.serverId); const load = store.refreshAgentDirectory({ @@ -2825,9 +2755,8 @@ describe("HostRuntimeStore", () => { getClientId: async () => "cid_drain_submission", }, }); - store.syncHosts([{ ...host, connections: [] }]); const sessionStore = useSessionStore.getState(); - sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); sessionStore.updateSessionServerInfo(host.serverId, { serverId: host.serverId, hostname: null, @@ -2873,7 +2802,7 @@ describe("HostRuntimeStore", () => { expect(session?.messageSubmissions.get("agent")).toBeDefined(); send.resolve(); - store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); }); it("restores an automatically drained message when sending fails", async () => { @@ -2891,9 +2820,8 @@ describe("HostRuntimeStore", () => { getClientId: async () => "cid_failed_queue_drain", }, }); - store.syncHosts([{ ...host, connections: [] }]); const sessionStore = useSessionStore.getState(); - sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); sessionStore.setQueuedMessages( host.serverId, new Map([ @@ -2919,7 +2847,7 @@ describe("HostRuntimeStore", () => { ]); }); - store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); }); it("serializes queued-message drains for the same agent", async () => { @@ -2938,9 +2866,8 @@ describe("HostRuntimeStore", () => { getClientId: async () => "cid_serialized_queue_drain", }, }); - store.syncHosts([{ ...host, connections: [] }]); const sessionStore = useSessionStore.getState(); - sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); sessionStore.setQueuedMessages( host.serverId, new Map([["agent", [{ id: "first", text: "send once", attachments: [] }]]]), @@ -2957,7 +2884,7 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().sessions[host.serverId]?.queuedMessages.get("agent"), ).toEqual([]); }); - store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); }); it("uses legacy GitHub attachments when draining a queue for an old daemon", async () => { @@ -2974,9 +2901,8 @@ describe("HostRuntimeStore", () => { getClientId: async () => "cid_legacy_queue_attachment", }, }); - store.syncHosts([{ ...host, connections: [] }]); const sessionStore = useSessionStore.getState(); - sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); sessionStore.updateSessionServerInfo(host.serverId, { serverId: host.serverId, hostname: null, @@ -3215,235 +3141,6 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().clearSession(host.serverId); }); - it.each(["coalesced", "late-tail", "rewind", "daemon-reset"] as const)( - "owns timeline request application (%s)", - async (scenario) => { - const host = makeHost({ - serverId: `request-${scenario}`, - connections: [makeHost().connections[0]!], - }); - const client = new FakeDaemonClient(); - client.setConnectionState({ status: "connected" }); - const store = new HostRuntimeStore({ - replicaRowStore: createMemoryReplicaRowStore(), - deps: { - createClient: () => client as unknown as DaemonClient, - connectToDaemon: async ({ host: current }) => ({ - client: client as unknown as DaemonClient, - serverId: current.serverId, - hostname: null, - }), - getClientId: async () => "test", - }, - }); - store.syncHosts([host]); - await waitForHostOnline(store, host.serverId); - const page: Awaited> = { - requestId: "page", - agentId: "agent", - agent: null, - direction: "tail", - projection: "projected", - reset: false, - staleCursor: false, - gap: false, - epoch: "epoch", - window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, - startCursor: { epoch: "epoch", seq: 1 }, - endCursor: { epoch: "epoch", seq: 1 }, - hasOlder: false, - hasNewer: false, - error: null, - entries: [ - { - provider: "codex", - item: { type: "assistant_message", text: "first", messageId: "first" }, - timestamp: "2026-09-06T00:00:00Z", - seqStart: 1, - seqEnd: 1, - sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], - collapsed: [], - }, - ], - }; - const viewed = store.getViewedTimelineOwner(host.serverId); - if (scenario !== "coalesced") viewed.applyTimelineResponse(page); - const response = createDeferred>>(); - client.timelineResponse = response.promise; - let publications = 0; - const unsubscribe = useSessionStore.subscribe((state, previous) => { - if ( - state.sessions[host.serverId]?.agentStreamTail !== - previous.sessions[host.serverId]?.agentStreamTail - ) - publications++; - }); - const appendLive = () => { - viewed.enqueueStreamEvent("agent", { - epoch: "epoch", - seq: 2, - timestamp: new Date("2026-09-06T00:00:01Z"), - event: { - type: "timeline", - provider: "codex", - item: { type: "assistant_message", text: "new live", messageId: "new-live" }, - }, - }); - viewed.flushStreamAgent("agent"); - }; - if (scenario === "rewind") appendLive(); - const request = planTimelineTailFetch(); - const first = store.fetchAgentTimeline( - host.serverId, - "agent", - request, - scenario === "late-tail", - ); - const second = - scenario === "coalesced" - ? store.fetchAgentTimeline(host.serverId, "agent", request) - : first; - if (scenario === "late-tail" || scenario === "daemon-reset") appendLive(); - if (scenario === "daemon-reset") page.reset = true; - response.resolve(page); - await expect(Promise.all([first, second])).resolves.toEqual([page, page]); - expect(client.timelineCalls).toBe(1); - if (scenario === "coalesced") expect(publications).toBe(1); - else { - const session = useSessionStore.getState().sessions[host.serverId]; - expect( - [ - ...(session?.agentStreamTail.get("agent") ?? []), - ...(session?.agentStreamHead.get("agent") ?? []), - ].map((item) => item.id), - ).toEqual(scenario === "late-tail" ? ["first", "new-live"] : ["first"]); - expect(store.getTimelineReplica(host.serverId).readCursor("agent")?.endSeq).toBe( - scenario === "late-tail" ? 2 : 1, - ); - } - unsubscribe(); - store.syncHosts([]); - }, - ); - - it.each(["settled", "captured", "renamed"] as const)( - "retains scoped-out history after host identity reconciliation (%s) and deletes it only explicitly", - async (ordering) => { - const host = makeHost({ - serverId: "srv_temporary", - connections: [makeHost().connections[0]!], - }); - const resolvedId = "srv_resolved"; - const rows = createMemoryReplicaRowStore(); - const seed = new ReplicaCache(rows, { clearLegacyCache: async () => undefined }); - seed.setHosts([host.serverId]); - const saved = { - agentId: "agent", - hasOlder: false, - range: { epoch: "epoch", startSeq: 1, endSeq: 1 }, - items: [ - { - kind: "assistant_message" as const, - id: "saved", - text: "saved history", - timestamp: new Date("2026-09-06T00:00:00Z"), - timelineCursor: { epoch: "epoch", seq: 1 }, - source: { startSeq: 1 }, - }, - ], - }; - seed.commitTimeline(host.serverId, "agent", saved); - await seed.flush(); - const client = new FakeDaemonClient(); - client.setConnectionState({ status: "connected" }); - const store = new HostRuntimeStore({ - replicaRowStore: rows, - deps: { - createClient: () => client as unknown as DaemonClient, - connectToDaemon: async ({ host: current }) => ({ - client: client as unknown as DaemonClient, - serverId: current.serverId, - hostname: null, - }), - getClientId: async () => "test", - }, - }); - const storedRows = async () => - (await rows.readAll()).find((entry) => entry.serverId === resolvedId)?.rows ?? []; - const hasStoredKind = async (kind: ReplicaRow["kind"]) => - (await storedRows()).some((row) => row.kind === kind); - try { - store.syncHosts([host]); - await waitForHostOnline(store, host.serverId); - const replica = store.getTimelineReplica(host.serverId); - const started = Promise.withResolvers(); - const release = Promise.withResolvers(); - const readRows = rows.read.bind(rows); - let reads = 0; - rows.read = async (...args) => { - if (!args[1].includes("timeline")) return readRows(...args); - reads++; - if (ordering === "settled" || reads !== 1) return readRows(...args); - const captured = ordering === "captured" ? await readRows(...args) : undefined; - started.resolve(); - await release.promise; - return captured ?? readRows(...args); - }; - const preparation = replica.prepare("agent"); - if (ordering === "settled") { - await preparation; - expect( - useSessionStore.getState().sessions[host.serverId]?.agentStreamTail.get("agent"), - ).toEqual(saved.items); - } else await started.promise; - store.reconcileServerId(host.serverId, resolvedId); - if (ordering !== "settled") { - await vi.waitFor(async () => expect(await hasStoredKind("timeline")).toBe(true)); - release.resolve(); - await preparation; - } - expect(replica.prepare("agent")).toBe(preparation); - expect(reads).toBe(ordering === "settled" ? 1 : 2); - expect(store.getTimelineReplica(resolvedId)).toBe(replica); - expect( - useSessionStore.getState().sessions[resolvedId]?.agentStreamTail.get("agent"), - ).toEqual(saved.items); - expect(replica.readCursor("agent")).toEqual({ epoch: "epoch", endSeq: 1 }); - await waitForHostOnline(store, resolvedId); - useSessionStore.getState().updateSessionServerInfo(resolvedId, { - serverId: resolvedId, - hostname: null, - version: "test", - features: { workspaceMultiplicity: true }, - }); - store.acceptAgentSnapshot( - resolvedId, - normalizeAgentSnapshot( - makeFetchAgentsEntry({ id: "agent", cwd: "/repo", updatedAt: "2026-09-06T00:00:00Z" }) - .agent, - resolvedId, - ), - ); - await vi.waitFor(async () => expect(await hasStoredKind("agent")).toBe(true), { - timeout: 3000, - }); - await store.refreshAgentDirectory({ serverId: resolvedId }); - store.setAppVisible(false); - await vi.waitFor(async () => expect(await hasStoredKind("agent")).toBe(false)); - const reopened = new ReplicaCache(rows, { clearLegacyCache: async () => undefined }); - reopened.setHosts([resolvedId]); - expect(await reopened.readTimeline(resolvedId, "agent")).toEqual(saved); - store.restoreAgentSnapshot(resolvedId, "agent", undefined); - store.setAppVisible(false); - await vi.waitFor(async () => expect(await hasStoredKind("timeline")).toBe(false)); - } finally { - store.syncHosts([]); - useSessionStore.getState().clearSession(host.serverId); - useSessionStore.getState().clearSession(resolvedId); - } - }, - ); - it("replaces stale active session state when active bootstrap omits an agent", async () => { const host = makeHost({ serverId: "srv_archived_rehydrate", diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 7ab9902597..ad33968740 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1,4 +1,3 @@ -import { createTimelineReplica, type TimelineReplica } from "@/timeline/replica"; import { useSyncExternalStore, useMemo } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import equal from "fast-deep-equal/es6"; @@ -71,8 +70,11 @@ import { ReplicaCache } from "@/runtime/replica-cache"; import type { ReplicaRowStore } from "@/runtime/replica-cache/row-store"; import { createReplicaRowStore } from "@/runtime/replica-cache/row-store-factory"; import { + createTimelineReplica, createViewedTimelineOwner, + type TimelineReplica, type ViewedTimelineOwner, + type ViewedTimelineOwnerPorts, } from "@/timeline/viewed-timeline-sync"; import { projectIconCache } from "@/projects/icon-cache"; import { nativePerformanceTrace } from "@/performance/native-trace"; @@ -1402,7 +1404,6 @@ export class HostRuntimeStore { private directorySyncByServer = new Map(); private nextCancellationRequestId = 0; private timelineReplicaByServer = new Map(); - private viewedTimelineByServer = new Map(); private configuredOverrideBootstrapInFlight: Promise | null = null; private bootPromise: Promise | null = null; private appVisible = true; @@ -1520,6 +1521,12 @@ export class HostRuntimeStore { projectIconCache.setHosts(profiles.map((profile) => profile.serverId)); await projectIconCache.restore(); this.syncHosts(profiles); + for (const profile of profiles) { + void this.directorySyncByServer + .get(profile.serverId) + ?.restoreCachedDirectory() + .catch(() => undefined); + } } catch (error) { console.error("[HostRuntime] Failed to load host registry from storage", error); } finally { @@ -1656,17 +1663,11 @@ export class HostRuntimeStore { projectIconCache.reconcileServerId(oldServerId, newServerId); this.directorySyncByServer.get(oldServerId)?.dispose(); this.directorySyncByServer.delete(oldServerId); - const timelineReplica = this.getTimelineReplica(oldServerId); - rekeyMap(this.timelineReplicaByServer, oldServerId, newServerId); + this.timelineReplicaByServer.delete(oldServerId); const directory = new DirectorySync( newServerId, { - onTimelineRequest: (agentId, replaceTail) => - this.getViewedTimelineOwner(newServerId).beginTimelineRequest(agentId, replaceTail), onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId), - onAgentRemoved: (agentId, reason) => - this.getViewedTimelineOwner(newServerId).removeAgent(agentId, reason), - onAgentAccepted: (agentId) => this.getViewedTimelineOwner(newServerId).acceptAgent(agentId), markAgentLoading: () => controller.markAgentDirectorySyncLoading(), markAgentReady: () => controller.markAgentDirectorySyncReady(), markAgentError: (error) => controller.markAgentDirectorySyncError(error), @@ -1674,16 +1675,18 @@ export class HostRuntimeStore { this.replicaCache, ); this.directorySyncByServer.set(newServerId, directory); + this.timelineReplicaByServer.set( + newServerId, + createTimelineReplica({ + serverId: newServerId, + storage: this.replicaCache, + prepareAgent: (agentId) => directory.prepareAgentRoute(agentId), + }), + ); controller.adoptReconciledServerId(newServerId); const snapshot = controller.getSnapshot(); - this.viewedTimelineByServer.get(oldServerId)?.dispose(); - this.viewedTimelineByServer.delete(oldServerId); this.clearHostReplica(oldServerId); - useSessionStore - .getState() - .initializeSession(newServerId, snapshot.client, snapshot.clientGeneration); - timelineReplica.reconcileServerId(newServerId); - this.installViewedTimelineOwner(newServerId); + this.syncSessionReplica(newServerId, snapshot); directory.connectionChanged({ client: snapshot.client, status: snapshot.connectionStatus === "online" ? "online" : "offline", @@ -2054,9 +2057,6 @@ export class HostRuntimeStore { this.connectionStatusStartedAtByServer.delete(serverId); this.directorySyncByServer.get(serverId)?.dispose(); this.directorySyncByServer.delete(serverId); - this.viewedTimelineByServer.get(serverId)?.dispose(); - this.viewedTimelineByServer.delete(serverId); - this.timelineReplicaByServer.get(serverId)?.dispose(); this.timelineReplicaByServer.delete(serverId); this.clearHostReplica(serverId); void controller.stop(); @@ -2086,13 +2086,7 @@ export class HostRuntimeStore { const directory = new DirectorySync( host.serverId, { - onTimelineRequest: (agentId, replaceTail) => - this.getViewedTimelineOwner(host.serverId).beginTimelineRequest(agentId, replaceTail), onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId), - onAgentRemoved: (agentId, reason) => - this.getViewedTimelineOwner(host.serverId).removeAgent(agentId, reason), - onAgentAccepted: (agentId) => - this.getViewedTimelineOwner(host.serverId).acceptAgent(agentId), markAgentLoading: () => controller.markAgentDirectorySyncLoading(), markAgentReady: () => controller.markAgentDirectorySyncReady(), markAgentError: (error) => controller.markAgentDirectorySyncError(error), @@ -2105,9 +2099,9 @@ export class HostRuntimeStore { createTimelineReplica({ serverId: host.serverId, storage: this.replicaCache, + prepareAgent: (agentId) => directory.prepareAgentRoute(agentId), }), ); - this.installViewedTimelineOwner(host.serverId); const initialSnapshot = controller.getSnapshot(); this.lastConnectionStatusByServer.set(host.serverId, initialSnapshot.connectionStatus); this.connectionStatusStartedAtByServer.set(host.serverId, Date.now()); @@ -2219,7 +2213,7 @@ export class HostRuntimeStore { supportsForgeAttachments, }), encodeImages, - submission: createMessageSubmissionWriter(serverId, this.getTimelineReplica(serverId)), + submission: createMessageSubmissionWriter(serverId), }); }, }) @@ -2349,7 +2343,6 @@ export class HostRuntimeStore { setAppVisible(visible: boolean): void { this.appVisible = visible; - for (const owner of this.viewedTimelineByServer.values()) owner.setActive(visible); for (const controller of this.controllers.values()) { controller.setReconnectEnabled(visible); } @@ -2398,40 +2391,49 @@ export class HostRuntimeStore { return () => directory.setDemand(source, false); } - getTimelineReplica(serverId: string): TimelineReplica { - const replica = this.timelineReplicaByServer.get(serverId); - if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`); - return replica; + async prepareAgentRoute(serverId: string, agentId: string): Promise { + const directory = this.directorySyncByServer.get(serverId); + if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); + await directory.prepareAgentRoute(agentId); } - async fetchAgentTimeline( + async prepareWorkspaceRoute(serverId: string, workspaceId: string): Promise { + const directory = this.directorySyncByServer.get(serverId); + if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); + await directory.prepareWorkspaceRoute(workspaceId); + } + + async prepareAgentTimeline(serverId: string, agentId: string): Promise { + const replica = this.timelineReplicaByServer.get(serverId); + if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`); + await replica.prepare(agentId); + } + + fetchAgentTimeline( serverId: string, agentId: string, request: Parameters[1], - replaceTail = false, ): Promise>> { const directory = this.directorySyncByServer.get(serverId); if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); - return directory.fetchTimeline(agentId, request, replaceTail); + return directory.fetchTimeline(agentId, request); } - getViewedTimelineOwner(serverId: string): ViewedTimelineOwner { - const owner = this.viewedTimelineByServer.get(serverId); - if (!owner) throw new Error(`Unknown host runtime for serverId ${serverId}`); - return owner; - } - - private installViewedTimelineOwner(serverId: string): void { - const owner = createViewedTimelineOwner({ + createViewedTimelineOwner( + serverId: string, + ports: ViewedTimelineOwnerPorts, + ): ViewedTimelineOwner { + const directory = this.directorySyncByServer.get(serverId); + if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); + const replica = this.timelineReplicaByServer.get(serverId); + if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`); + return createViewedTimelineOwner({ serverId, - replica: this.getTimelineReplica(serverId), - replaceDemandedAgentIds: (ids) => - this.directorySyncByServer.get(serverId)?.setAgentRouteDemand(ids), - drainQueuedAgentMessage: (id) => this.drainQueuedAgentMessage(serverId, id), + replica, + replaceDemandedAgentIds: (agentIds) => directory.setAgentRouteDemand(agentIds), + drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(serverId, agentId), + ports, }); - this.viewedTimelineByServer.set(serverId, owner); - owner.setActive(this.appVisible); - useSessionStore.getState().setViewedTimelineSync(serverId, owner); } private emit(serverId: string): void { diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 2a603490c6..df6c3a0aa0 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -25,58 +25,41 @@ class MemoryStorage implements ReplicaRowStore { readonly rows = new Map(); readonly changes: ReplicaRowChanges[] = []; readonly reads: Array<{ - serverIds: readonly string[]; + serverId: string; kinds: readonly ReplicaRow["kind"][]; ids?: readonly string[]; }> = []; writes = 0; - readAlls = 0; cleanups = 0; nextWriteFailure: Error | null = null; - nextOpenFailure: Error | null = null; - nextReadAllFailure: Error | null = null; - nextDeleteFailure: Error | null = null; readGate: Promise | null = null; - readAllGate: Promise | null = null; - writeGate: Promise | null = null; - renameGate: Promise | null = null; + onRead: (() => void) | null = null; private key(row: Pick): string { return `${row.serverId}:${row.kind}:${row.id}`; } - async open(): Promise { - if (this.nextOpenFailure) { - const error = this.nextOpenFailure; - this.nextOpenFailure = null; - throw error; - } - } + async open(): Promise {} async read( - serverIds: readonly string[], + serverId: string, kinds: readonly ReplicaRow["kind"][], ids?: readonly string[], ): Promise { - this.reads.push({ serverIds, kinds, ids }); - const rows = [...this.rows.values()]; + this.reads.push({ serverId, kinds, ...(ids ? { ids } : {}) }); + this.onRead?.(); await this.readGate; - return rows.filter( + const acceptedKinds = new Set(kinds); + const acceptedIds = ids ? new Set(ids) : null; + return [...this.rows.values()].filter( (row) => - serverIds.includes(row.serverId) && - kinds.includes(row.kind) && - (!ids || ids.includes(row.id)), + row.serverId === serverId && + acceptedKinds.has(row.kind) && + (!acceptedIds || acceptedIds.has(row.id)), ); } async readAll(): Promise { - this.readAlls += 1; - await this.readAllGate; - if (this.nextReadAllFailure) { - const error = this.nextReadAllFailure; - this.nextReadAllFailure = null; - throw error; - } const hosts = new Map(); for (const row of this.rows.values()) { const rows = hosts.get(row.serverId) ?? []; @@ -88,7 +71,6 @@ class MemoryStorage implements ReplicaRowStore { async apply(changes: ReplicaRowChanges): Promise { this.writes += 1; - await this.writeGate; if (this.nextWriteFailure) { const error = this.nextWriteFailure; this.nextWriteFailure = null; @@ -100,16 +82,10 @@ class MemoryStorage implements ReplicaRowStore { } async deleteHost(serverId: string): Promise { - if (this.nextDeleteFailure) { - const error = this.nextDeleteFailure; - this.nextDeleteFailure = null; - throw error; - } for (const [key, row] of this.rows) if (row.serverId === serverId) this.rows.delete(key); } async renameHost(oldServerId: string, newServerId: string): Promise { - await this.renameGate; for (const [key, row] of this.rows) { if (row.serverId !== oldServerId) continue; this.rows.delete(key); @@ -194,7 +170,6 @@ function timelineItem(text = "Cached"): StreamItem { text, timestamp: new Date("2026-07-18T08:02:00.000Z"), timelineCursor: { epoch: "epoch-1", seq: 12 }, - source: { startSeq: 12, chunks: [{ seq: 12, offset: 0 }] }, }; } @@ -266,359 +241,6 @@ function deleteDirectory(cache: ReplicaCache, serverId: string): void { } describe("ReplicaCache", () => { - it("paints requested rows while the unrelated global index is unavailable", async () => { - const storage = new MemoryStorage(); - const writer = createCache(storage); - commitDirectory(writer, SERVER_ID, directory()); - writer.commitTimeline(SERVER_ID, "agent-1", timeline("Requested")); - await writer.flush(); - const release = deferred(); - storage.readAllGate = release.promise; - storage.reads.length = 0; - const reader = createCache(storage); - reader.setHosts([SERVER_ID, "unrelated"]); - reader.commitTimeline("unrelated", "other", { ...timeline("Pending"), agentId: "other" }); - const writing = reader.flush(); - let painted = false; - const reading = Promise.all([ - reader.readTimeline(SERVER_ID, "agent-1"), - reader.readDirectory(SERVER_ID), - ]).then(([saved, restoredDirectory]) => { - expect(saved?.items).toEqual([timelineItem("Requested")]); - expect(restoredDirectory.agents.has("agent-1")).toBe(true); - painted = true; - return undefined; - }); - try { - await expect.poll(() => painted, { timeout: 200 }).toBe(true); - expect(storage.reads).toEqual([ - { serverIds: [SERVER_ID], kinds: ["timeline"], ids: ["agent-1"] }, - { - serverIds: [SERVER_ID], - kinds: ["agent", "workspace", "project", "checkpoint"], - ids: undefined, - }, - ]); - } finally { - release.resolve(); - await Promise.all([writing, reading]); - } - }); - - it("overlays a commit that settles after a scoped read captures old disk rows", async () => { - const storage = new MemoryStorage(); - const writer = createCache(storage); - writer.commitTimeline(SERVER_ID, "agent-1", timeline("Old")); - await writer.flush(); - const reader = createCache(storage); - const release = deferred(); - storage.readGate = release.promise; - const reading = reader.readTimeline(SERVER_ID, "agent-1"); - await expect.poll(() => storage.reads.length).toBe(1); - reader.commitTimeline(SERVER_ID, "agent-1", timeline("New")); - await reader.flush(); - release.resolve(); - expect((await reading)?.items).toEqual([timelineItem("New")]); - }); - - it("invalidates an old scoped read across removal and immediate re-registration", async () => { - const storage = new MemoryStorage(); - const writer = createCache(storage); - writer.commitTimeline(SERVER_ID, "agent-1", timeline("Old lifetime")); - await writer.flush(); - const reader = createCache(storage); - const release = deferred(); - storage.readGate = release.promise; - const reading = reader.readTimeline(SERVER_ID, "agent-1"); - await expect.poll(() => storage.reads.length).toBe(1); - reader.setHosts([]); - reader.setHosts([SERVER_ID]); - release.resolve(); - expect(await reading).toBeUndefined(); - expect(await reader.readTimeline(SERVER_ID, "agent-1")).toBeUndefined(); - await reader.flush(); - }); - - it.each([false, true])( - "uses the same certification for accepted and persisted rows (provenance=%s)", - async (provenance) => { - const storage = new MemoryStorage(); - const cache = createCache(storage); - const items = ["block-1", "block-2"].map((id) => { - const item = timelineItem("Repeated markdown"); - item.id = id; - if (item.kind === "assistant_message") item.messageId = "same-message"; - item.source = provenance ? { startSeq: 12, chunks: [{ seq: 12, offset: 0 }] } : undefined; - return item; - }); - const value = { ...timeline(), items }; - cache.commitTimeline(SERVER_ID, "agent-1", value); - const accepted = await cache.readTimeline(SERVER_ID, "agent-1"); - expect(accepted?.range).toEqual(provenance ? value.range : null); - expect(accepted?.items).toEqual(items); - await cache.flush(); - const persisted = await createCache(storage).readTimeline(SERVER_ID, "agent-1"); - expect(persisted).toEqual(accepted); - }, - ); - - it("serializes only the coalesced certified page, including reads of accepted commits", async () => { - const storage = new MemoryStorage(); - const cache = createCache(storage); - let encodings = 0; - class MeasuredDate extends Date { - override toISOString(): string { - encodings += 1; - return super.toISOString(); - } - } - for (let seq = 1; seq <= 10; seq++) { - const item = { - ...timelineItem(`row ${seq}`), - timestamp: new MeasuredDate("2026-07-18T08:02:00.000Z"), - timelineCursor: { epoch: "epoch", seq }, - source: { startSeq: seq, chunks: [{ seq, offset: 0 }] }, - }; - cache.commitTimeline( - SERVER_ID, - "agent-1", - { - agentId: "agent-1", - items: [item], - range: { epoch: "epoch", startSeq: seq, endSeq: seq }, - hasOlder: true, - }, - true, - ); - expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([item]); - } - expect(encodings).toBe(0); - await cache.flush(); - expect(encodings).toBe(1); - expect((await createCache(storage).readTimeline(SERVER_ID, "agent-1"))?.range?.endSeq).toBe(10); - }); - - it("does not resurrect a removed host when its in-flight write completes", async () => { - const storage = new MemoryStorage(); - const cache = createCache(storage); - const release = deferred(); - storage.writeGate = release.promise; - cache.commitTimeline(SERVER_ID, "agent-1", timeline("Removed history")); - const writing = cache.flush(); - await expect.poll(() => storage.writes).toBe(1); - cache.setHosts([]); - release.resolve(); - await writing; - cache.setHosts([SERVER_ID]); - await cache.flush(); - expect(await cache.readTimeline(SERVER_ID, "agent-1")).toBeUndefined(); - expect(storage.rows.size).toBe(0); - }); - - it("keeps rows written under a host renamed during their write", async () => { - const storage = new MemoryStorage(); - const cache = createCache(storage); - const release = deferred(); - storage.writeGate = release.promise; - cache.commitTimeline(SERVER_ID, "agent-1", timeline("Renamed while writing")); - const writing = cache.flush(); - await expect.poll(() => storage.writes).toBe(1); - cache.reconcileServerId(SERVER_ID, "resolved-host"); - release.resolve(); - await writing; - await cache.flush(); - - expect((await cache.readTimeline("resolved-host", "agent-1"))?.items).toEqual([ - timelineItem("Renamed while writing"), - ]); - expect(await cache.readTimeline(SERVER_ID, "agent-1")).toBeUndefined(); - expect([...storage.rows.keys()]).toEqual(["resolved-host:timeline:agent-1"]); - }); - - it("writes a row replaced during its own write again", async () => { - const storage = new MemoryStorage(); - const cache = createCache(storage); - const release = deferred(); - storage.writeGate = release.promise; - cache.commitTimeline(SERVER_ID, "agent-1", timeline("First")); - const writing = cache.flush(); - await expect.poll(() => storage.writes).toBe(1); - cache.commitTimeline(SERVER_ID, "agent-1", timeline("Second")); - release.resolve(); - await writing; - await cache.flush(); - - expect(storage.writes).toBe(2); - expect((await createCache(storage).readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([ - timelineItem("Second"), - ]); - }); - - it("restores a timeline while another host's disk write is blocked", async () => { - const storage = new MemoryStorage(); - const cache = createCache(storage); - cache.setHosts([SERVER_ID, "other-host"]); - cache.commitTimeline(SERVER_ID, "agent-1", timeline("Saved before restart")); - await cache.flush(); - const release = deferred(); - storage.writeGate = release.promise; - commitDirectory(cache, "other-host", directory()); - const writing = cache.flush(); - await expect.poll(() => storage.writes).toBe(2); - let restored: Awaited>; - const reading = cache.readTimeline(SERVER_ID, "agent-1").then((value) => { - restored = value; - return value; - }); - try { - await expect - .poll(() => restored?.items, { timeout: 200 }) - .toEqual([timelineItem("Saved before restart")]); - } finally { - release.resolve(); - await Promise.all([writing, reading]); - } - }); - - it("reads rows under a resolved host identity before the disk rename finishes", async () => { - const storage = new MemoryStorage(); - const writer = createCache(storage); - writer.commitTimeline(SERVER_ID, "agent-1", timeline("Saved under temporary host ID")); - await writer.flush(); - const reader = createCache(storage); - const release = deferred(); - storage.renameGate = release.promise; - reader.reconcileServerId(SERVER_ID, "resolved-host"); - expect((await reader.readTimeline("resolved-host", "agent-1"))?.items).toEqual([ - timelineItem("Saved under temporary host ID"), - ]); - release.resolve(); - await reader.flush(); - expect(storage.rows.has("resolved-host:timeline:agent-1")).toBe(true); - }); - - it("keeps rename collision precedence and accepted baselines during lazy reads", async () => { - const storage = new MemoryStorage(); - const writer = createCache(storage); - writer.setHosts([SERVER_ID, "resolved-host"]); - writer.commitTimeline(SERVER_ID, "agent-1", timeline("Moved")); - writer.commitTimeline("resolved-host", "agent-1", timeline("Superseded target")); - commitDirectory(writer, SERVER_ID, directory()); - await writer.flush(); - const reader = createCache(storage); - reader.setHosts([SERVER_ID, "resolved-host"]); - const release = deferred(); - storage.renameGate = release.promise; - reader.replaceDirectoryBaseline(SERVER_ID, { - ...directory(), - workspaces: new Map(), - projects: new Map(), - }); - reader.reconcileServerId(SERVER_ID, "resolved-host"); - try { - expect((await reader.readTimeline("resolved-host", "agent-1"))?.items).toEqual([ - timelineItem("Moved"), - ]); - const accepted = await reader.readDirectory("resolved-host"); - expect(accepted.agents.get("agent-1")?.serverId).toBe("resolved-host"); - expect(accepted.workspaces.size).toBe(0); - } finally { - release.resolve(); - await reader.flush(); - } - const reopened = new ReplicaCache(storage, noLegacyCleanup); - reopened.setHosts(["resolved-host"]); - expect((await reopened.readDirectory("resolved-host")).workspaces.size).toBe(0); - expect((await reopened.readTimeline("resolved-host", "agent-1"))?.items).toEqual([ - timelineItem("Moved"), - ]); - }); - - it("returns a committed timeline before it is written", async () => { - const storage = new MemoryStorage(); - const cache = createCache(storage); - cache.commitTimeline(SERVER_ID, "agent-1", timeline("Accepted")); - - expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([ - timelineItem("Accepted"), - ]); - expect(storage.writes).toBe(0); - }); - - it("round-trips a row's source start and chunk provenance", async () => { - const storage = new MemoryStorage(); - const cache = createCache(storage); - cache.commitTimeline(SERVER_ID, "agent-1", { - ...timeline(), - items: [ - { - ...timelineItem("Streamed"), - source: { - startSeq: 10, - chunks: [ - { seq: 10, offset: 0 }, - { seq: 12, offset: 4 }, - ], - }, - }, - ], - }); - await cache.flush(); - - const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1"); - expect(restored?.items[0]).toMatchObject({ source: { startSeq: 10 } }); - expect(restored?.items[0]?.source?.chunks).toEqual([ - { seq: 10, offset: 0 }, - { seq: 12, offset: 4 }, - ]); - }); - - it("persists the owner's forty-entry restart page from a long synced history", async () => { - const storage = new MemoryStorage(); - const cache = createCache(storage); - const items = Array.from({ length: 40 }, (_, index) => ({ - ...timelineItem(`Message ${index}`), - id: `message-${index}`, - timelineCursor: { epoch: "epoch-1", seq: index + 201 }, - source: { startSeq: index + 201, chunks: [{ seq: index + 201, offset: 0 }] }, - })); - cache.commitTimeline(SERVER_ID, "agent-1", { - agentId: "agent-1", - items, - range: { epoch: "epoch-1", startSeq: 201, endSeq: 240 }, - hasOlder: true, - }); - await cache.flush(); - - const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1"); - expect(restored?.items).toHaveLength(40); - expect(restored?.range).toEqual({ epoch: "epoch-1", startSeq: 201, endSeq: 240 }); - expect(restored?.hasOlder).toBe(true); - }); - - it("encodes the owner's certified page without a per-timeline byte ceiling", async () => { - const storage = new MemoryStorage(); - const cache = createCache(storage); - const items = Array.from({ length: 40 }, (_, index) => ({ - ...timelineItem("x".repeat(20_000)), - id: `message-${index}`, - timelineCursor: { epoch: "epoch-1", seq: index + 1 }, - source: { startSeq: index + 1, chunks: [{ seq: index + 1, offset: 0 }] }, - })); - cache.commitTimeline(SERVER_ID, "agent-1", { - agentId: "agent-1", - items, - range: { epoch: "epoch-1", startSeq: 1, endSeq: 40 }, - hasOlder: true, - }); - await cache.flush(); - - const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1"); - expect(restored?.items.map((item) => item.id)).toEqual(items.map((item) => item.id)); - expect(restored?.range).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 40 }); - expect(restored?.hasOlder).toBe(true); - }); - it("does nothing until an owner explicitly commits data", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); @@ -787,10 +409,9 @@ describe("ReplicaCache", () => { deleteDirectory(cache, SERVER_ID); - const restored = await cache.readDirectory(SERVER_ID); - expect(restored.agents.size).toBe(0); - expect(restored.workspaces.size).toBe(0); - expect(restored.projects.size).toBe(0); + expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined(); + expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined(); + expect((await cache.readDirectory(SERVER_ID)).projects.size).toBe(0); }); it("fails closed when an accepted deletion cannot be persisted before a read", async () => { @@ -802,23 +423,25 @@ describe("ReplicaCache", () => { deleteDirectory(cache, SERVER_ID); - expect((await cache.readDirectory(SERVER_ID)).workspaces.size).toBe(0); + expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined(); }); - it("applies a deletion accepted while a scoped directory read is still loading", async () => { + it("discards a durable read when the host changes while it is in flight", async () => { const storage = new MemoryStorage(); - const writer = createCache(storage); - commitDirectory(writer, SERVER_ID, directory()); - await writer.flush(); - const release = deferred(); - storage.readGate = release.promise; const cache = createCache(storage); + commitDirectory(cache, SERVER_ID, directory()); + await cache.flush(); + const started = deferred(); + const release = deferred(); + storage.onRead = started.resolve; + storage.readGate = release.promise; - const reading = cache.readDirectory(SERVER_ID); + const reading = cache.readAgent(SERVER_ID, "agent-1"); + await started.promise; deleteDirectory(cache, SERVER_ID); release.resolve(); - expect((await reading).agents.size).toBe(0); + expect(await reading).toBeUndefined(); }); it("never reads a timeline older than an accepted deferred replacement", async () => { @@ -890,6 +513,39 @@ describe("ReplicaCache", () => { expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toBeUndefined(); }); + it("reads one requested agent and the focused timeline without scanning directory rows", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + commitDirectory(writer, SERVER_ID, directory()); + writer.commitTimeline(SERVER_ID, "agent-1", timeline()); + await writer.flush(); + + const reader = createCache(storage); + expect((await reader.readAgent(SERVER_ID, "agent-1"))?.id).toBe("agent-1"); + expect((await reader.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([timelineItem()]); + expect(storage.reads).toEqual([ + { serverId: SERVER_ID, kinds: ["agent"], ids: ["agent-1"] }, + { serverId: SERVER_ID, kinds: ["timeline"], ids: ["agent-1"] }, + ]); + }); + + it("reads one requested workspace and its project without scanning the directory", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + commitDirectory(writer, SERVER_ID, directory()); + await writer.flush(); + + const reader = createCache(storage); + const restored = await reader.readWorkspace(SERVER_ID, "workspace-1"); + + expect(restored?.workspace.id).toBe("workspace-1"); + expect(restored?.project?.projectId).toBe("project-1"); + expect(storage.reads).toEqual([ + { serverId: SERVER_ID, kinds: ["workspace"], ids: ["workspace-1"] }, + { serverId: SERVER_ID, kinds: ["project"], ids: ["project-1"] }, + ]); + }); + it("treats a corrupt row as a scoped miss", async () => { const storage = new MemoryStorage(); storage.rows.set(`${SERVER_ID}:agent:agent-1`, { @@ -914,7 +570,6 @@ describe("ReplicaCache", () => { const cache = createCache(storage); const restored = await cache.readDirectory(SERVER_ID); - await cache.flush(); expect(restored.agents.size).toBe(0); expect(restored.projects.get("project-1")?.projectDisplayName).toBe("Paseo"); @@ -946,7 +601,7 @@ describe("ReplicaCache", () => { commitDirectory(cache, otherServerId, directory()); await cache.flush(); - expect((await cache.readDirectory(SERVER_ID)).agents.has("agent-1")).toBe(false); + expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined(); cache.commitTimeline(otherServerId, "agent-1", timeline("x".repeat(1_000))); await cache.flush(); @@ -972,9 +627,7 @@ describe("ReplicaCache", () => { payload: "{bad", }); - const cache = createCache(storage); - expect((await cache.readDirectory(SERVER_ID)).agents.has("agent-1")).toBe(false); - await cache.flush(); + expect(await createCache(storage).readAgent(SERVER_ID, "agent-1")).toBeUndefined(); expect(storage.changes.at(-1)).toMatchObject({ deletes: [{ serverId: SERVER_ID, kind: "agent", id: "agent-1" }], @@ -1004,9 +657,7 @@ describe("ReplicaCache", () => { payload: "{bad", }); - const cache = createCache(storage); - const restored = await cache.readDirectory(SERVER_ID); - await cache.flush(); + const restored = await createCache(storage).readDirectory(SERVER_ID); expect(restored.checkpoint).toEqual({ projects: { generation: "g", afterSeq: 4 }, @@ -1037,66 +688,6 @@ describe("ReplicaCache", () => { ]); }); - it.each(["nextOpenFailure", "nextReadAllFailure"] as const)( - "persists an accepted commit automatically after %s", - async (failure) => { - const storage = new MemoryStorage(); - const cache = createCache(storage); - storage[failure] = new Error("storage busy"); - cache.commitTimeline(SERVER_ID, "agent-1", timeline("Retry me")); - - await cache.flush(); - expect(storage.rows.size).toBe(0); - expect(await cache.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline("Retry me")); - await expect.poll(() => storage.rows.size, { timeout: 2500 }).toBe(1); - - const reopened = createCache(storage); - expect(await reopened.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline("Retry me")); - }, - ); - - it("recovers a partial index load with queued host changes and the exact surviving budget", async () => { - const storage = new MemoryStorage(); - const writer = createCache(storage); - writer.setHosts([SERVER_ID, "stale", "old", "removed"]); - for (const host of [SERVER_ID, "stale", "old", "removed"]) { - writer.commitTimeline(host, "agent-1", timeline("Stored")); - } - await writer.flush(); - const survivorBytes = [...storage.rows.values()] - .filter((row) => row.serverId === SERVER_ID || row.serverId === "old") - .reduce((bytes, row) => bytes + Buffer.byteLength(row.payload), 0); - const cache = createCache(storage, survivorBytes); - cache.setHosts([SERVER_ID, "old", "removed"]); - const release = deferred(); - storage.readAllGate = release.promise; - storage.nextDeleteFailure = new Error("stale host cleanup busy"); - const loadsBefore = storage.readAlls; - cache.commitTimeline(SERVER_ID, "agent-1", timeline("Newest")); - const writing = cache.flush(); - try { - await expect.poll(() => storage.readAlls).toBe(loadsBefore + 1); - cache.reconcileServerId("old", "renamed"); - cache.setHosts([SERVER_ID, "renamed"]); - } finally { - release.resolve(); - await writing; - } - expect(await cache.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline("Newest")); - expect(await cache.readTimeline("renamed", "agent-1")).toEqual(timeline("Stored")); - expect(await cache.readTimeline("removed", "agent-1")).toBeUndefined(); - await expect.poll(() => storage.changes.length, { timeout: 2500 }).toBe(2); - - const reopened = createCache(storage); - reopened.setHosts([SERVER_ID, "renamed"]); - expect([...storage.rows.values()].map((row) => row.serverId).sort()).toEqual([ - SERVER_ID, - "renamed", - ]); - expect(await reopened.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline("Newest")); - expect(await reopened.readTimeline("renamed", "agent-1")).toEqual(timeline("Stored")); - }); - it("retries an explicit commit after a storage failure", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); @@ -1112,6 +703,21 @@ describe("ReplicaCache", () => { ]); }); + it("retries a timeline read invalidated by a concurrent directory commit", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + cache.commitTimeline(SERVER_ID, "agent-1", timeline("Persisted timeline")); + await cache.flush(); + storage.onRead = () => { + storage.onRead = null; + commitDirectory(cache, SERVER_ID, directory()); + }; + + expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([ + timelineItem("Persisted timeline"), + ]); + }); + it("rebuilds every directory row before restoring its checkpoint after eviction", async () => { const storage = new MemoryStorage(); const cache = new ReplicaCache(storage, { ...noLegacyCleanup, maxBytes: 2_500 }); @@ -1153,7 +759,7 @@ describe("ReplicaCache", () => { }); cache.setHosts([SERVER_ID]); - await cache.readTimeline(SERVER_ID, "missing"); + await cache.readAgent(SERVER_ID, "missing"); commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index de02987c21..c1db2f8418 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { AgentStatusSchema, - AgentAttachmentSchema, AgentTimelineItemPayloadSchema, WorkspaceGitHubRuntimePayloadSchema, } from "@getpaseo/protocol/messages"; @@ -46,6 +45,11 @@ export interface CachedDirectory { checkpoint?: DirectoryCheckpoint; } +export interface CachedWorkspace { + workspace: WorkspaceDescriptor; + project?: ProjectDescriptor; +} + export interface DirectoryCursor { generation: string; afterSeq: number; @@ -65,32 +69,13 @@ export interface CachedTimeline { } const PERSIST_DELAY_MS = 1_000; +const MAX_TIMELINE_ITEMS = 50; const MAX_CACHE_BYTES = 32 * 1024 * 1024; const IsoDateSchema = z.iso.datetime(); const TimelinePositionSchema = z.strictObject({ epoch: z.string(), seq: z.number().int().nonnegative(), }); -const RowSourceSchema = z.strictObject({ - startSeq: z.number().int().nonnegative(), - chunks: z - .array( - z.strictObject({ - seq: z.number().int().nonnegative(), - offset: z.number().int().nonnegative(), - }), - ) - .optional(), -}); -const StoredImageSchema = z.strictObject({ - id: z.string(), - mimeType: z.string(), - storageType: z.enum(["web-indexeddb", "desktop-file", "native-file"]), - storageKey: z.string(), - fileName: z.string().nullable().optional(), - byteSize: z.number().nullable().optional(), - createdAt: z.number(), -}); const PluginTimelineDataSchema: z.ZodType = z.lazy(() => z.union([ z.null(), @@ -105,8 +90,6 @@ const PluginTimelineDataSchema: z.ZodType = z.lazy(() => const TimelineItemBaseShape = { id: z.string(), timelineCursor: TimelinePositionSchema.optional(), - // Absent on rows saved before source provenance existed. - source: RowSourceSchema.optional(), // COMPAT(active-turn-membership): absent on caches written before turn membership. turnId: z.string().optional(), timestamp: IsoDateSchema, @@ -132,8 +115,6 @@ const StoredTimelineItemSchema = z.discriminatedUnion("kind", [ z.strictObject({ ...TimelineItemBaseShape, kind: z.literal("user_message"), - images: z.array(StoredImageSchema).optional(), - attachments: z.array(AgentAttachmentSchema).optional(), clientMessageId: z.string().optional(), messageId: z.string().optional(), text: z.string(), @@ -398,7 +379,10 @@ const DirectoryCheckpointSchema = z.strictObject({ agents: DirectoryCursorSchema.optional(), }); -function deserializeTimeline(stored: StoredTimeline): CachedTimeline { +function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | null { + if (!stored) { + return null; + } return { agentId: stored.agentId, items: stored.items.map(deserializeTimelineItem), @@ -411,7 +395,6 @@ function timelineBase(item: StreamItem) { return { id: item.id, ...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}), - ...(item.source ? { source: item.source } : {}), ...(item.turnId ? { turnId: item.turnId } : {}), timestamp: item.timestamp.toISOString(), }; @@ -439,7 +422,13 @@ function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null { const base = timelineBase(item); switch (item.kind) { case "user_message": - return { ...item, ...base }; + return { + ...base, + kind: item.kind, + ...(item.clientMessageId ? { clientMessageId: item.clientMessageId } : {}), + ...(item.messageId ? { messageId: item.messageId } : {}), + text: item.text, + }; case "assistant_message": return { ...base, @@ -501,7 +490,6 @@ function deserializeTimelineItem(item: StoredTimelineItem): StreamItem { return { id: item.id, ...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}), - ...(item.source ? { source: item.source } : {}), ...(item.turnId ? { turnId: item.turnId } : {}), timestamp: new Date(item.timestamp), kind: item.kind, @@ -521,13 +509,18 @@ function deserializeBuiltinTimelineItem( const base = { id: item.id, ...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}), - ...(item.source ? { source: item.source } : {}), ...(item.turnId ? { turnId: item.turnId } : {}), timestamp: new Date(item.timestamp), }; switch (item.kind) { case "user_message": - return { ...item, ...base }; + return { + ...base, + kind: item.kind, + ...(item.clientMessageId ? { clientMessageId: item.clientMessageId } : {}), + ...(item.messageId ? { messageId: item.messageId } : {}), + text: item.text, + }; case "assistant_message": return { ...base, @@ -734,43 +727,44 @@ function serializeProject(project: ProjectDescriptor): StoredProject { }; } -// A saved timeline is either certified or display-only. Certified rows keep the owner's restart page -// with its range, so the next launch resumes with one `after endSeq` page. Everything the range -// cannot vouch for (a discontiguous window or rows the schema cannot encode) falls back to a -// display-only tail that carries no coverage claim. -function isCertifiedTimeline(timeline: CachedTimeline): boolean { - const range = timeline.range; - return ( - range !== null && - range.retainedRanges === undefined && - timeline.items.every( - (item) => - (item.kind === "user_message" && isUnreconciledLocalUserMessage(item)) || - ((item.kind !== "tool_call" || item.payload.source === "agent") && - item.source !== undefined && - item.timelineCursor?.epoch === range.epoch && - item.timelineCursor.seq >= range.startSeq && - item.timelineCursor.seq <= range.endSeq), - ) - ); +function isTimelineItemStoredLosslessly(item: StreamItem): boolean { + switch (item.kind) { + case "user_message": + return (item.images?.length ?? 0) === 0 && (item.attachments?.length ?? 0) === 0; + case "tool_call": + return item.payload.source === "agent"; + default: + return true; + } } -function serializeTimelinePayload(timeline: CachedTimeline): string { - const items = timeline.items - .filter((item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item)) - .map(serializeTimelineItem) - .filter((item) => item !== null); +function serializeTimeline(timeline: CachedTimeline): StoredTimeline | null { + const canonicalItems = timeline.items.filter( + (item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item), + ); + const items = canonicalItems.map(serializeTimelineItem).filter((item) => item !== null); const range = timeline.range; - const certified = isCertifiedTimeline(timeline); - return JSON.stringify({ + const canPersistCoverage = + range !== null && + range.retainedRanges === undefined && + canonicalItems.length <= MAX_TIMELINE_ITEMS && + items.length === canonicalItems.length && + canonicalItems.every( + (item) => + isTimelineItemStoredLosslessly(item) && + item.timelineCursor?.epoch === range.epoch && + item.timelineCursor.seq >= range.startSeq && + item.timelineCursor.seq <= range.endSeq, + ) && + canonicalItems.some((item) => item.timelineCursor?.seq === range.endSeq); + return { agentId: timeline.agentId, - items, - range: - certified && range - ? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq } - : null, - hasOlder: certified && timeline.hasOlder, - } satisfies StoredTimeline); + items: items.slice(-MAX_TIMELINE_ITEMS), + range: canPersistCoverage + ? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq } + : null, + hasOlder: canPersistCoverage ? timeline.hasOlder : false, + }; } function rowKey(key: Pick): string { @@ -826,6 +820,12 @@ interface DirectoryReadAccumulator { checkpoint?: DirectoryCheckpoint; } +interface PendingReplicaChanges { + upserts: StructuredReplicaUpsert[]; + deletes: ReplicaRowKey[]; + baselines: Set; +} + function applyDirectoryRow( serverId: string, row: ReplicaRow, @@ -868,43 +868,22 @@ function directoryEntityForRow(row: ReplicaRow): keyof DirectoryCheckpoint | und return undefined; } -const DIRECTORY_KINDS: readonly ReplicaRowKind[] = ["agent", "workspace", "project", "checkpoint"]; - -interface PendingRow { - key: ReplicaRowKey; - // Null is an accepted deletion. - upsert: StructuredReplicaUpsert | null; -} - -interface PendingBatch { - rows: Map; - baselines: Map; -} - -// Scoped disk reads are overlaid by accepted commits, including changes that settled while -// a read was in flight. The full disk index serves write-behind budget accounting only. export class ReplicaCache { - private activeServerIds = new Set(); + private readonly activeServerIds = new Set(); + private readonly hostRevisions = new Map(); private readonly storedRows = new Map>(); private readonly hostBytes = new Map(); private readonly hostWriteOrder = new Map(); - private totalBytes = 0; - private readonly pendingRows = new Map(); - // Accepted commits overlay any disk read, including reads started before a write settled. - private readonly acceptedRows = new Map(); - private readonly acceptedBaselines = new Set(); - private readonly hostSources = new Map>(); - private readonly pendingBaselines = new Map(); + private pendingUpserts = new Map(); + private pendingDeletes = new Map(); + private pendingBaselines = new Set(); private readonly invalidatedHosts = new Set(); - // Renames queued behind the initial load, so the load keeps the old host's rows for them. - private readonly renamingHosts = new Map(); private readonly maxBytes: number; + private totalBytes = 0; private persistTimer: ReturnType | null = null; - // Memory readiness: the initial load followed by every host identity change, in order. - private image: Promise | null = null; - // Disk operations run in order; the initial load is the first of them once it starts. - private disk: Promise = Promise.resolve(); - private writes: Promise = Promise.resolve(); + private writeQueue: Promise = Promise.resolve(); + private preparePromise: Promise | null = null; + private storedIndexPromise: Promise | null = null; constructor( private readonly rowStore: ReplicaRowStore, @@ -916,8 +895,50 @@ export class ReplicaCache { private readonly clearLegacyCache: () => Promise; + async readAgent(serverId: string, agentId: string): Promise { + const rows = await this.readRows(serverId, ["agent"], [agentId]); + const row = rows[0]; + if (!row) return undefined; + try { + const stored = parseStoredPayload(StoredAgentSchema, row.payload); + if (stored.snapshot.id !== row.id) throw new Error("Replica agent row id mismatch"); + return deserializeAgent(serverId, stored); + } catch { + await this.deleteInvalidRow(row); + return undefined; + } + } + + async readWorkspace(serverId: string, workspaceId: string): Promise { + const workspaceRow = (await this.readRows(serverId, ["workspace"], [workspaceId]))[0]; + if (!workspaceRow) return undefined; + let workspace: WorkspaceDescriptor; + try { + const stored = parseStoredPayload(StoredWorkspaceSchema, workspaceRow.payload); + if (stored.id !== workspaceRow.id) throw new Error("Replica workspace row id mismatch"); + workspace = normalizeWorkspaceDescriptor(stored); + } catch { + await this.deleteInvalidRow(workspaceRow); + return undefined; + } + + let project: ProjectDescriptor | undefined; + const projectRow = (await this.readRows(serverId, ["project"], [workspace.projectId]))[0]; + if (projectRow) { + try { + const stored = parseStoredPayload(StoredProjectSchema, projectRow.payload); + if (stored.projectId !== projectRow.id) throw new Error("Replica project row id mismatch"); + project = normalizeProjectDescriptor(stored); + } catch { + await this.deleteInvalidRow(projectRow); + } + } + + return { workspace, ...(project ? { project } : {}) }; + } + async readDirectory(serverId: string): Promise { - const rows = await this.readRows(serverId, DIRECTORY_KINDS); + const rows = await this.readRows(serverId, ["agent", "workspace", "project", "checkpoint"]); const result: DirectoryReadAccumulator = { agents: new Map(), workspaces: new Map(), @@ -926,23 +947,6 @@ export class ReplicaCache { const invalidEntities = new Set(); const invalidRows: ReplicaRow[] = []; for (const row of rows) { - if ("value" in row) { - switch (row.kind) { - case "agent": - result.agents.set(row.id, { ...row.value, serverId }); - break; - case "workspace": - result.workspaces.set(row.id, row.value); - break; - case "project": - result.projects.set(row.id, row.value); - break; - case "checkpoint": - result.checkpoint = row.value; - break; - } - continue; - } try { applyDirectoryRow(serverId, row, result); } catch { @@ -956,100 +960,113 @@ export class ReplicaCache { for (const entity of invalidEntities) delete result.checkpoint[entity]; } if (invalidRows.length > 0) { - this.repairInvalidDirectoryRows(serverId, invalidRows, result.checkpoint); + await this.repairInvalidDirectoryRows(serverId, invalidRows, result.checkpoint); } return result; } async readTimeline(serverId: string, agentId: string): Promise { - const row = (await this.readRows(serverId, ["timeline"], [agentId]))[0]; + const rows = await this.readRows(serverId, ["timeline"], [agentId]); + const row = rows[0]; if (!row) return undefined; - let timeline: CachedTimeline; - if ("value" in row) { - if (row.kind !== "timeline") return undefined; - timeline = row.value; - } else { - try { - const stored = parseStoredPayload(StoredTimelineSchema, row.payload); - if (stored.agentId !== agentId) return undefined; - timeline = deserializeTimeline(stored); - } catch { - this.queueDelete(row); - this.schedulePersist(); - return undefined; - } + try { + const stored = parseStoredPayload(StoredTimelineSchema, row.payload); + if (stored.agentId !== agentId) return undefined; + return deserializeTimeline(stored) ?? undefined; + } catch { + await this.deleteInvalidRow(row); + return undefined; } - const certified = isCertifiedTimeline(timeline); - return { - ...timeline, - items: timeline.items.filter( - (item) => - !(item.kind === "user_message" && isUnreconciledLocalUserMessage(item)) && - !(item.kind === "tool_call" && item.payload.source !== "agent"), - ), - range: certified ? timeline.range : null, - hasOlder: certified && timeline.hasOlder, - }; } private async readRows( serverId: string, kinds: readonly ReplicaRowKind[], ids?: readonly string[], - ): Promise> { + ): Promise { if (!this.activeServerIds.has(serverId)) return []; - const sources = this.hostSources.get(serverId); - if (!sources) return []; - const sourceOrder = [...sources]; - const accepted = ids - ? kinds.every((kind) => - ids.every((id) => this.acceptedRows.has(pendingRowKey({ serverId, kind, id }))), - ) - : this.acceptedBaselines.has(serverId) && !kinds.includes("timeline"); - let stored: ReplicaRow[] = []; - if (!accepted) { - try { - await this.rowStore.open(); - stored = await this.rowStore.read(sourceOrder, kinds, ids); - } catch { - // Accepted commits remain readable even if the disk is unavailable. + try { + await this.prepareStore(); + while (this.activeServerIds.has(serverId)) { + await this.flush(); + const revision = this.hostRevisions.get(serverId) ?? 0; + const rows = await this.rowStore.read(serverId, kinds, ids); + if (this.canReadHostRevision(serverId, revision)) return rows; } + return []; + } catch { + return []; } - if (this.hostSources.get(serverId) !== sources) return []; - const accepts = (key: ReplicaRowKey) => - key.serverId === serverId && kinds.includes(key.kind) && (!ids || ids.includes(key.id)); - const rows = new Map(); - const replacingBaseline = this.acceptedBaselines.has(serverId); - // Moved rows replace collisions at the destination, even before the disk rename commits. - stored.sort((a, b) => sourceOrder.indexOf(b.serverId) - sourceOrder.indexOf(a.serverId)); - for (const row of stored) { - if (replacingBaseline && row.kind !== "timeline") continue; - rows.set(rowKey(row), { ...row, serverId }); - } - for (const pending of this.acceptedRows.values()) { - if (!accepts(pending.key)) continue; - if (pending.upsert === null) rows.delete(rowKey(pending.key)); - else rows.set(rowKey(pending.key), pending.upsert); - } - return [...rows.values()]; } - private repairInvalidDirectoryRows( + private async deleteInvalidRow(row: ReplicaRow): Promise { + const invalidEntity = directoryEntityForRow(row); + if (!invalidEntity) { + const changes = { upserts: [], deletes: [row] } satisfies ReplicaRowChanges; + await this.queueOperation(async () => { + await this.rowStore.apply(changes); + this.applyStoredChanges(changes); + }); + return; + } + + const checkpointRow = ( + await this.readRows(row.serverId, ["checkpoint"], [REPLICA_SINGLETON_ROW_ID]) + )[0]; + let checkpoint: DirectoryCheckpoint | undefined; + if (checkpointRow) { + try { + checkpoint = parseStoredPayload(DirectoryCheckpointSchema, checkpointRow.payload); + checkpoint = { ...checkpoint }; + delete checkpoint[invalidEntity]; + } catch { + checkpoint = undefined; + } + } + const changes: ReplicaRowChanges = { + deletes: [row, ...(checkpointRow && !checkpoint ? [checkpointRow] : [])], + upserts: + checkpointRow && checkpoint + ? [ + { + serverId: row.serverId, + kind: "checkpoint", + id: REPLICA_SINGLETON_ROW_ID, + payload: JSON.stringify(checkpoint), + }, + ] + : [], + }; + await this.queueOperation(async () => { + await this.rowStore.apply(changes); + this.applyStoredChanges(changes); + }); + } + + private async repairInvalidDirectoryRows( serverId: string, invalidRows: ReplicaRow[], checkpoint: DirectoryCheckpoint | undefined, - ): void { - for (const row of invalidRows) this.queueDelete(row); + ): Promise { const checkpointWasInvalid = invalidRows.some((row) => row.kind === "checkpoint"); - if (checkpoint !== undefined && !checkpointWasInvalid) { - this.queueUpsert({ - serverId, - kind: "checkpoint", - id: REPLICA_SINGLETON_ROW_ID, - value: checkpoint, - }); - } - this.schedulePersist(); + const changes: ReplicaRowChanges = { + deletes: invalidRows, + upserts: + checkpoint !== undefined && !checkpointWasInvalid + ? [ + { + serverId, + kind: "checkpoint", + id: REPLICA_SINGLETON_ROW_ID, + payload: JSON.stringify(checkpoint), + }, + ] + : [], + }; + await this.queueOperation(async () => { + await this.rowStore.apply(changes); + this.applyStoredChanges(changes); + }); } commitDirectoryMutations( @@ -1059,9 +1076,10 @@ export class ReplicaCache { ): void { if (!this.activeServerIds.has(serverId)) return; if (this.invalidatedHosts.has(serverId)) return; + this.advanceHostRevision(serverId); for (const mutation of mutations) { if (mutation.type === "delete") { - this.queueDelete({ serverId, kind: mutation.kind, id: mutation.id }); + this.queueEntityDelete(serverId, mutation.kind, mutation.id); } else { this.queueUpsert({ serverId, @@ -1084,18 +1102,15 @@ export class ReplicaCache { replaceDirectoryBaseline(serverId: string, directory: CachedDirectory): void { if (!this.activeServerIds.has(serverId)) return; + this.advanceHostRevision(serverId); // Replacing the directory must not discard independently accepted timeline changes. - for (const [key, pending] of this.pendingRows) { - if (pending.key.serverId === serverId && pending.key.kind !== "timeline") { - this.pendingRows.delete(key); - } + for (const [key, row] of this.pendingUpserts) { + if (row.serverId === serverId && row.kind !== "timeline") this.pendingUpserts.delete(key); } - this.pendingBaselines.set(serverId, Symbol("baseline")); - this.acceptedBaselines.add(serverId); - for (const [key, row] of this.acceptedRows) { - if (row.key.serverId === serverId && row.key.kind !== "timeline") - this.acceptedRows.delete(key); + for (const [key, row] of this.pendingDeletes) { + if (row.serverId === serverId && row.kind !== "timeline") this.pendingDeletes.delete(key); } + this.pendingBaselines.add(serverId); for (const [id, value] of directory.agents) { this.queueUpsert({ serverId, kind: "agent", id, value }); } @@ -1116,23 +1131,10 @@ export class ReplicaCache { this.schedulePersist(); } - removeTimeline(serverId: string, agentId: string): void { - if (!this.activeServerIds.has(serverId)) return; - this.queueDelete({ serverId, kind: "timeline", id: agentId }); - this.schedulePersist(); - } - - commitTimeline( - serverId: string, - agentId: string, - timeline: CachedTimeline, - requireCertified = false, - ): void { + commitTimeline(serverId: string, agentId: string, timeline: CachedTimeline): void { if (!this.activeServerIds.has(serverId)) return; if (timeline.agentId !== agentId) throw new Error("Timeline cache key does not match payload"); - // The timeline authority can retain a good restart snapshot while its richer accepted - // page cannot be represented with truthful coverage. - if (requireCertified && !isCertifiedTimeline(timeline)) return; + this.advanceHostRevision(serverId); this.queueUpsert({ serverId, kind: "timeline", id: agentId, value: timeline }); this.schedulePersist(); } @@ -1140,193 +1142,148 @@ export class ReplicaCache { setHosts(serverIds: Iterable): void { const next = new Set(serverIds); const removed = [...this.activeServerIds].filter((serverId) => !next.has(serverId)); - this.activeServerIds = next; - for (const serverId of next) { - if (!this.hostSources.has(serverId)) this.hostSources.set(serverId, new Set([serverId])); - } + const added = [...next].filter((serverId) => !this.activeServerIds.has(serverId)); + for (const serverId of [...removed, ...added]) this.advanceHostRevision(serverId); + this.activeServerIds.clear(); + for (const serverId of next) this.activeServerIds.add(serverId); for (const serverId of removed) { - this.dropAcceptedHost(serverId); + this.removeStoredHost(serverId); this.dropPendingHostChanges(serverId); this.invalidatedHosts.delete(serverId); - this.applyToImage(() => this.removeStoredHost(serverId)); - void this.deleteStoredSource(serverId); + this.queueOperation(() => this.rowStore.deleteHost(serverId)); } } reconcileServerId(oldServerId: string, newServerId: string): void { - if (oldServerId === newServerId) return; - this.renamePendingHostChanges(oldServerId, newServerId); - const sources = this.hostSources.get(oldServerId) ?? new Set(); - const retiredSources = new Set(); - this.hostSources.set(oldServerId, retiredSources); - this.hostSources.set(newServerId, new Set([...sources, newServerId])); - if (this.acceptedBaselines.delete(oldServerId)) this.acceptedBaselines.add(newServerId); - for (const [key, row] of this.acceptedRows) { - if (row.key.serverId !== oldServerId) continue; - this.acceptedRows.delete(key); - const renamed = { - key: { ...row.key, serverId: newServerId }, - upsert: row.upsert ? { ...row.upsert, serverId: newServerId } : null, - }; - this.acceptedRows.set(pendingRowKey(renamed.key), renamed); + this.advanceHostRevision(oldServerId); + this.advanceHostRevision(newServerId); + const rows = this.storedRows.get(oldServerId); + if (rows) { + const newRows = this.storedRows.get(newServerId) ?? new Map(); + this.totalBytes -= + (this.hostBytes.get(oldServerId) ?? 0) + (this.hostBytes.get(newServerId) ?? 0); + this.storedRows.delete(oldServerId); + for (const [key, row] of rows) newRows.set(key, { ...row, serverId: newServerId }); + this.storedRows.set(newServerId, newRows); + const bytes = [...newRows.values()].reduce((sum, row) => sum + rowBytes(row), 0); + this.hostBytes.delete(oldServerId); + this.hostBytes.set(newServerId, bytes); + this.totalBytes += bytes; + this.hostWriteOrder.delete(oldServerId); + this.touchHost(newServerId); } + this.renamePendingHostChanges(oldServerId, newServerId); if (this.activeServerIds.delete(oldServerId)) this.activeServerIds.add(newServerId); - this.renamingHosts.set(oldServerId, newServerId); - this.applyToImage(() => { - this.renamingHosts.delete(oldServerId); - this.renameStoredHost(oldServerId, newServerId); - }); - void this.queueDisk(async () => { - await this.rowStore.renameHost(oldServerId, newServerId); - for (const aliases of this.hostSources.values()) aliases.delete(oldServerId); - if (this.hostSources.get(oldServerId) === retiredSources) retiredSources.add(oldServerId); - }); + this.queueOperation(() => this.rowStore.renameHost(oldServerId, newServerId)); } async flush(): Promise { await this.persist(); - await this.disk; + await this.writeQueue.catch(() => undefined); } - private ready(): Promise { - this.image ??= this.queueDisk(() => this.loadStoredRows()).then( - () => true, - () => { - this.image = null; - return false; - }, - ); - return this.image; + private async flushPending(): Promise { + await this.persist(); } - private async loadStoredRows(): Promise { - // COMPAT(replica-blob-cache): remove after 2026-11 - await this.clearLegacyCache().catch(() => undefined); - const hosts = await this.rowStore.readAll(); - for (const host of hosts) { - if (!this.activeServerIds.has(host.serverId) && !this.renamingHosts.has(host.serverId)) { - await this.rowStore.deleteHost(host.serverId); - } - } - // Publish only after all fallible work succeeds; retries must not count partial loads twice. - for (const host of hosts) { - if (!this.activeServerIds.has(host.serverId) && !this.renamingHosts.has(host.serverId)) { - continue; - } - const rows = new Map(host.rows.map((row) => [rowKey(row), row])); - const bytes = host.rows.reduce((sum, row) => sum + rowBytes(row), 0); - this.storedRows.set(host.serverId, rows); - this.hostBytes.set(host.serverId, bytes); - this.totalBytes += bytes; - this.touchHost(host.serverId); - } - } - - // Keep budget accounting in order with its initial load and host identity changes. - private applyToImage(mutation: () => void): void { - if (this.image === null) { - mutation(); - return; - } - this.image = this.image.then((loaded) => { - // Identity changes still settle when loading fails; the next attempt reads their disk result. - mutation(); - return loaded; - }); - } - - private queueDisk(operation: () => Promise): Promise { - const run = this.disk.then(async () => { - await this.rowStore.open(); - await operation(); - return undefined; - }); - this.disk = run.catch(() => undefined); - return run; - } - - private persist(): Promise { + private async persist(): Promise { if (this.persistTimer) { clearTimeout(this.persistTimer); this.persistTimer = null; } - const write = this.writes.then(() => this.writePendingChanges()); - this.writes = write.catch(() => undefined); - return write; - } - - private async writePendingChanges(): Promise { if (!this.hasPendingChanges()) return; - if (!(await this.ready())) { - this.schedulePersist(); - return; - } - const batch: PendingBatch = { - rows: new Map(this.pendingRows), - baselines: new Map(this.pendingBaselines), - }; - const changes = this.materializeBatch(batch); - const { changes: boundedChanges, evicted } = await this.fitChangesToBudget(changes); - if (boundedChanges.upserts.length > 0 || boundedChanges.deletes.length > 0) { - try { - await this.queueDisk(() => this.rowStore.apply(boundedChanges)); - } catch { - this.schedulePersist(); - return; - } - } - // The write commits to the mirror only what is still pending unchanged. A host removed or - // renamed meanwhile already dropped or re-keyed its pending rows, and its disk delete or - // rename is queued behind this write, so the rows just written must not reappear in memory. - // A row replaced meanwhile stays pending and is written again. - const settled = (key: ReplicaRowKey) => { - const pending = batch.rows.get(pendingRowKey(key)); - return pending !== undefined && this.pendingRows.get(pendingRowKey(key)) === pending; - }; - const baselineSettled = (serverId: string) => - this.pendingBaselines.get(serverId) === batch.baselines.get(serverId); - this.applyStoredChanges({ - upserts: boundedChanges.upserts.filter(settled), - deletes: boundedChanges.deletes.filter( - (key) => - settled(key) || (!batch.rows.has(pendingRowKey(key)) && baselineSettled(key.serverId)), - ), - }); - for (const [key, pending] of batch.rows) { - if (this.pendingRows.get(key) === pending) this.pendingRows.delete(key); - } - for (const [serverId, token] of batch.baselines) { - if (this.pendingBaselines.get(serverId) !== token) continue; - this.pendingBaselines.delete(serverId); - if (!evicted.has(serverId)) this.invalidatedHosts.delete(serverId); - } + const write = this.writeQueue + .catch(() => undefined) + .then(async () => { + const pending = this.drainPendingChanges(); + try { + await this.prepareStore(); + await this.ensureStoredIndex(); + const changes = this.materializePendingChanges(pending); + const { changes: boundedChanges, evicted } = await this.fitChangesToBudget(changes); + if (boundedChanges.upserts.length > 0 || boundedChanges.deletes.length > 0) { + await this.rowStore.apply(boundedChanges); + this.applyStoredChanges(boundedChanges); + } + for (const serverId of pending.baselines) { + if (!evicted.has(serverId)) this.invalidatedHosts.delete(serverId); + } + } catch { + this.restorePendingChanges(pending); + if (this.hasPendingChanges()) this.schedulePersist(); + } + return undefined; + }); + this.writeQueue = write; + await write; } - private queueUpsert(upsert: StructuredReplicaUpsert): void { - const row = { key: upsert, upsert }; - this.pendingRows.set(pendingRowKey(upsert), row); - this.acceptedRows.set(pendingRowKey(upsert), row); + private queueEntityDelete(serverId: string, kind: ReplicaRowKind, id: string): void { + this.queueDelete({ serverId, kind, id }); + } + + private queueUpsert(row: StructuredReplicaUpsert): void { + const key = pendingRowKey(row); + this.pendingDeletes.delete(key); + this.pendingUpserts.set(key, row); } private queueDelete(key: ReplicaRowKey): void { - const { serverId, kind, id } = key; - const row = { key: { serverId, kind, id }, upsert: null }; - this.pendingRows.set(pendingRowKey(key), row); - this.acceptedRows.set(pendingRowKey(key), row); + const pendingKey = pendingRowKey(key); + this.pendingUpserts.delete(pendingKey); + this.pendingDeletes.set(pendingKey, key); } private hasPendingChanges(): boolean { - return this.pendingRows.size > 0 || this.pendingBaselines.size > 0; + return ( + this.pendingUpserts.size > 0 || this.pendingDeletes.size > 0 || this.pendingBaselines.size > 0 + ); } - private materializeBatch(batch: PendingBatch): ReplicaRowChanges { - const deletes = new Map(); - const upserts: ReplicaRow[] = []; - for (const pending of batch.rows.values()) { - if (pending.upsert === null) deletes.set(pendingRowKey(pending.key), pending.key); - else upserts.push(materializeUpsert(pending.upsert)); + private canReadHostRevision(serverId: string, revision: number): boolean { + return ( + this.activeServerIds.has(serverId) && + (this.hostRevisions.get(serverId) ?? 0) === revision && + !this.hasPendingHostChanges(serverId) + ); + } + + private hasPendingHostChanges(serverId: string): boolean { + if (this.pendingBaselines.has(serverId)) return true; + for (const row of this.pendingUpserts.values()) { + if (row.serverId === serverId) return true; } - for (const serverId of batch.baselines.keys()) { + for (const row of this.pendingDeletes.values()) { + if (row.serverId === serverId) return true; + } + return false; + } + + private advanceHostRevision(serverId: string): void { + this.hostRevisions.set(serverId, (this.hostRevisions.get(serverId) ?? 0) + 1); + } + + private drainPendingChanges(): PendingReplicaChanges { + const changes = { + upserts: [...this.pendingUpserts.values()], + deletes: [...this.pendingDeletes.values()], + baselines: this.pendingBaselines, + }; + this.pendingUpserts = new Map(); + this.pendingDeletes = new Map(); + this.pendingBaselines = new Set(); + return changes; + } + + private materializePendingChanges(pending: PendingReplicaChanges): ReplicaRowChanges { + const deletes = new Map(pending.deletes.map((row) => [pendingRowKey(row), row])); + const upserts: ReplicaRow[] = []; + for (const upsert of pending.upserts) { + const row = this.materializeUpsert(upsert); + if (row) upserts.push(row); + else deletes.set(pendingRowKey(upsert), upsert); + } + for (const serverId of pending.baselines) { const replacementKeys = new Set( upserts.filter((row) => row.serverId === serverId && row.kind !== "timeline").map(rowKey), ); @@ -1339,6 +1296,60 @@ export class ReplicaCache { return { upserts, deletes: [...deletes.values()] }; } + private materializeUpsert(upsert: StructuredReplicaUpsert): ReplicaRow | null { + let value: unknown; + switch (upsert.kind) { + case "agent": + value = serializeAgent(upsert.value); + break; + case "workspace": + value = serializeWorkspace(upsert.value); + break; + case "project": + value = serializeProject(upsert.value); + break; + case "timeline": + value = serializeTimeline(upsert.value); + if (!value) return null; + break; + case "checkpoint": + value = upsert.value; + break; + } + return { + serverId: upsert.serverId, + kind: upsert.kind, + id: upsert.id, + payload: JSON.stringify(value), + }; + } + + private restorePendingChanges(changes: PendingReplicaChanges): void { + for (const serverId of changes.baselines) { + if (this.activeServerIds.has(serverId)) this.pendingBaselines.add(serverId); + } + for (const key of changes.deletes) { + const pendingKey = pendingRowKey(key); + if ( + this.activeServerIds.has(key.serverId) && + !this.pendingUpserts.has(pendingKey) && + !this.pendingDeletes.has(pendingKey) + ) { + this.queueDelete(key); + } + } + for (const row of changes.upserts) { + const pendingKey = pendingRowKey(row); + if ( + this.activeServerIds.has(row.serverId) && + !this.pendingUpserts.has(pendingKey) && + !this.pendingDeletes.has(pendingKey) + ) { + this.queueUpsert(row); + } + } + } + private applyStoredChanges(changes: ReplicaRowChanges): void { const touchedServerIds = new Set(); for (const key of changes.deletes) { @@ -1412,13 +1423,12 @@ export class ReplicaCache { while (projectedTotal > this.maxBytes) { const serverId = writeOrder.shift(); if (serverId === undefined) break; - projectedTotal -= projectedBytes.get(serverId) ?? 0; + const bytes = projectedBytes.get(serverId) ?? 0; + projectedTotal -= bytes; projectedBytes.delete(serverId); evicted.add(serverId); this.invalidatedHosts.add(serverId); - this.dropPendingHostChanges(serverId); - this.dropAcceptedHost(serverId); - await this.deleteStoredSource(serverId).catch(() => undefined); + if (this.storedRows.has(serverId)) await this.rowStore.deleteHost(serverId); this.removeStoredHost(serverId); } @@ -1431,22 +1441,6 @@ export class ReplicaCache { }; } - private dropAcceptedHost(serverId: string): void { - this.hostSources.set(serverId, new Set()); - this.acceptedBaselines.delete(serverId); - for (const [key, row] of this.acceptedRows) { - if (row.key.serverId === serverId) this.acceptedRows.delete(key); - } - } - - private deleteStoredSource(serverId: string): Promise { - const sources = this.hostSources.get(serverId); - return this.queueDisk(async () => { - await this.rowStore.deleteHost(serverId); - if (this.hostSources.get(serverId) === sources) sources?.add(serverId); - }); - } - private removeStoredHost(serverId: string): void { this.totalBytes -= this.hostBytes.get(serverId) ?? 0; this.storedRows.delete(serverId); @@ -1454,74 +1448,79 @@ export class ReplicaCache { this.hostWriteOrder.delete(serverId); } - private renameStoredHost(oldServerId: string, newServerId: string): void { - const rows = this.storedRows.get(oldServerId); - if (!rows) return; - const newRows = this.storedRows.get(newServerId) ?? new Map(); - this.removeStoredHost(oldServerId); - this.removeStoredHost(newServerId); - for (const [key, row] of rows) newRows.set(key, { ...row, serverId: newServerId }); - this.storedRows.set(newServerId, newRows); - const bytes = [...newRows.values()].reduce((sum, row) => sum + rowBytes(row), 0); - this.hostBytes.set(newServerId, bytes); - this.totalBytes += bytes; - this.touchHost(newServerId); - } - private dropPendingHostChanges(serverId: string): void { this.pendingBaselines.delete(serverId); - for (const [key, pending] of this.pendingRows) { - if (pending.key.serverId === serverId) this.pendingRows.delete(key); + for (const [key, row] of this.pendingUpserts) { + if (row.serverId === serverId) this.pendingUpserts.delete(key); + } + for (const [key, row] of this.pendingDeletes) { + if (row.serverId === serverId) this.pendingDeletes.delete(key); } } private renamePendingHostChanges(oldServerId: string, newServerId: string): void { - const renamed = [...this.pendingRows.values()].filter( - (pending) => pending.key.serverId === oldServerId, - ); - for (const pending of renamed) { - this.pendingRows.delete(pendingRowKey(pending.key)); - const key = { ...pending.key, serverId: newServerId }; - this.pendingRows.set(pendingRowKey(key), { - key, - upsert: pending.upsert ? { ...pending.upsert, serverId: newServerId } : null, - }); + const changes = this.drainPendingChanges(); + for (const key of changes.deletes) { + this.queueDelete(key.serverId === oldServerId ? { ...key, serverId: newServerId } : key); } - const baseline = this.pendingBaselines.get(oldServerId); - if (baseline) { - this.pendingBaselines.delete(oldServerId); - this.pendingBaselines.set(newServerId, baseline); + for (const row of changes.upserts) { + this.queueUpsert(row.serverId === oldServerId ? { ...row, serverId: newServerId } : row); + } + for (const serverId of changes.baselines) { + this.pendingBaselines.add(serverId === oldServerId ? newServerId : serverId); } if (this.invalidatedHosts.delete(oldServerId)) this.invalidatedHosts.add(newServerId); } + private prepareStore(): Promise { + this.preparePromise ??= (async () => { + await this.rowStore.open(); + // COMPAT(replica-blob-cache): remove after 2026-11 + await this.clearLegacyCache().catch(() => undefined); + })(); + return this.preparePromise; + } + + private ensureStoredIndex(): Promise { + this.storedIndexPromise ??= this.rowStore.readAll().then(async (hosts) => { + this.storedRows.clear(); + this.hostBytes.clear(); + this.hostWriteOrder.clear(); + this.totalBytes = 0; + for (const host of hosts) { + if (!this.activeServerIds.has(host.serverId)) { + await this.rowStore.deleteHost(host.serverId); + continue; + } + const rows = new Map(host.rows.map((row) => [rowKey(row), row])); + const bytes = host.rows.reduce((sum, row) => sum + rowBytes(row), 0); + this.storedRows.set(host.serverId, rows); + this.hostBytes.set(host.serverId, bytes); + this.totalBytes += bytes; + this.touchHost(host.serverId); + } + return undefined; + }); + return this.storedIndexPromise; + } + + private queueOperation(operation: () => Promise): Promise { + this.writeQueue = this.writeQueue + .catch(() => undefined) + .then(async () => { + await this.prepareStore(); + await operation(); + return undefined; + }) + .catch(() => undefined); + return this.writeQueue; + } + private schedulePersist(): void { if (this.persistTimer) return; this.persistTimer = setTimeout(() => { this.persistTimer = null; - void this.persist(); + void this.flushPending(); }, PERSIST_DELAY_MS); } } - -function materializeUpsert(upsert: StructuredReplicaUpsert): ReplicaRow { - let payload: string; - switch (upsert.kind) { - case "agent": - payload = JSON.stringify(serializeAgent(upsert.value)); - break; - case "workspace": - payload = JSON.stringify(serializeWorkspace(upsert.value)); - break; - case "project": - payload = JSON.stringify(serializeProject(upsert.value)); - break; - case "timeline": - payload = serializeTimelinePayload(upsert.value); - break; - case "checkpoint": - payload = JSON.stringify(upsert.value); - break; - } - return { serverId: upsert.serverId, kind: upsert.kind, id: upsert.id, payload }; -} diff --git a/packages/app/src/runtime/replica-cache/row-store-sqlite.ts b/packages/app/src/runtime/replica-cache/row-store-sqlite.ts index e7de78ad28..daa8ee28ef 100644 --- a/packages/app/src/runtime/replica-cache/row-store-sqlite.ts +++ b/packages/app/src/runtime/replica-cache/row-store-sqlite.ts @@ -108,17 +108,20 @@ export function createSqliteReplicaRowStore( } async function read( - serverIds: readonly string[], + serverId: string, kinds: readonly ReplicaRow["kind"][], ids?: readonly string[], ): Promise { - if (!serverIds.length || !kinds.length || ids?.length === 0) return []; - const placeholders = (values: readonly string[]) => values.map(() => "?").join(","); - const rows = await getConnection().all( - `SELECT server_id, kind, id, payload FROM rows WHERE server_id IN (${placeholders(serverIds)}) AND kind IN (${placeholders(kinds)})${ids ? ` AND id IN (${placeholders(ids)})` : ""} ORDER BY server_id, kind, id`, - [...serverIds, ...kinds, ...(ids ?? [])], + if (kinds.length === 0 || ids?.length === 0) return []; + const placeholders = kinds.map(() => "?").join(", "); + const idClause = ids ? ` AND id IN (${ids.map(() => "?").join(", ")})` : ""; + const storedRows = await getConnection().all( + `SELECT server_id, kind, id, payload FROM rows + WHERE server_id = ? AND kind IN (${placeholders})${idClause} + ORDER BY kind, id`, + [serverId, ...kinds, ...(ids ?? [])], ); - return rows.map((row) => ({ + return storedRows.map((row) => ({ serverId: row.server_id, kind: row.kind, id: row.id, diff --git a/packages/app/src/runtime/replica-cache/row-store.contract.ts b/packages/app/src/runtime/replica-cache/row-store.contract.ts index ba6848264c..2e267e812d 100644 --- a/packages/app/src/runtime/replica-cache/row-store.contract.ts +++ b/packages/app/src/runtime/replica-cache/row-store.contract.ts @@ -55,23 +55,20 @@ export function runReplicaRowStoreContract( ]); }); - it("reads only requested hosts, kinds and identities", async () => { - const selected = row("server-a", "timeline", "wanted", "page"); + it("reads only requested kinds for one host", async () => { await store.apply({ - deletes: [], upserts: [ - selected, - row("server-a", "timeline", "other", "unrelated timeline"), - row("server-a", "agent", "wanted", "directory"), - row("server-b", "timeline", "wanted", "other host"), + row("server-a", "agent", "agent-1", "agent"), + row("server-a", "timeline", "singleton", "timeline"), + row("server-b", "agent", "agent-2", "other host"), ], + deletes: [], }); - expect(await store.read(["server-a"], ["timeline"], ["wanted"])).toEqual([selected]); - expect(await store.read(["server-a"], ["agent"])).toEqual([ - row("server-a", "agent", "wanted", "directory"), + + expect(await store.read("server-a", ["timeline"])).toEqual([ + row("server-a", "timeline", "singleton", "timeline"), ]); - expect(await store.read([], ["timeline"])).toEqual([]); - expect(await store.read(["server-a"], ["timeline"], [])).toEqual([]); + expect(await store.read("server-a", ["agent"], ["missing"])).toEqual([]); }); it("overwrites an existing row on upsert", async () => { diff --git a/packages/app/src/runtime/replica-cache/row-store.ts b/packages/app/src/runtime/replica-cache/row-store.ts index 77b023cc18..b9cd02d99c 100644 --- a/packages/app/src/runtime/replica-cache/row-store.ts +++ b/packages/app/src/runtime/replica-cache/row-store.ts @@ -25,7 +25,7 @@ export interface ReplicaHostRows { export interface ReplicaRowStore { open(): Promise; read( - serverIds: readonly string[], + serverId: string, kinds: readonly ReplicaRowKind[], ids?: readonly string[], ): Promise; diff --git a/packages/app/src/runtime/replica-cache/row-store.web.test.ts b/packages/app/src/runtime/replica-cache/row-store.web.test.ts index 1630fb8e01..472143ee34 100644 --- a/packages/app/src/runtime/replica-cache/row-store.web.test.ts +++ b/packages/app/src/runtime/replica-cache/row-store.web.test.ts @@ -24,41 +24,28 @@ runReplicaRowStoreContract("IndexedDB", async () => { }; }); -it("reads requested IndexedDB keys without scanning unrelated rows", async () => { - const options = { +it("uses exact IndexedDB keys for targeted rows instead of scanning the host", async () => { + const store = createIndexedDbReplicaRowStore({ databaseName: `replica-row-store-targeted-${databaseSequence++}`, schemaVersion: 1, - }; - const writer = createIndexedDbReplicaRowStore(options); - await writer.open(); - await writer.apply({ + }); + await store.open(); + await store.apply({ upserts: [ - { serverId: "host-a", kind: "timeline", id: "agent", payload: "a" }, - { serverId: "host-b", kind: "timeline", id: "agent", payload: "b" }, - { serverId: "host-a", kind: "timeline", id: "unrelated", payload: "large history" }, - { serverId: "host-a", kind: "agent", id: "agent", payload: "metadata" }, + { serverId: "server-a", kind: "agent", id: "agent-1", payload: "agent" }, + { serverId: "server-a", kind: "workspace", id: "workspace-1", payload: "workspace" }, ], deletes: [], }); - const reader = createIndexedDbReplicaRowStore(options); - await reader.open(); const get = vi.spyOn(FakeIDBObjectStore.prototype, "get"); const getAll = vi.spyOn(FakeIDBObjectStore.prototype, "getAll"); - const openCursor = vi.spyOn(FakeIDBObjectStore.prototype, "openCursor"); - try { - expect(await reader.read(["host-a", "host-b"], ["timeline"], ["agent"])).toEqual([ - { serverId: "host-a", kind: "timeline", id: "agent", payload: "a" }, - { serverId: "host-b", kind: "timeline", id: "agent", payload: "b" }, - ]); - expect(get.mock.calls).toEqual([ - [["host-a", "timeline", "agent"]], - [["host-b", "timeline", "agent"]], - ]); - expect(getAll).not.toHaveBeenCalled(); - expect(openCursor).not.toHaveBeenCalled(); - } finally { - get.mockRestore(); - getAll.mockRestore(); - openCursor.mockRestore(); - } + + expect(await store.read("server-a", ["agent"], ["agent-1"])).toEqual([ + { serverId: "server-a", kind: "agent", id: "agent-1", payload: "agent" }, + ]); + expect(get).toHaveBeenCalledWith(["server-a", "agent", "agent-1"]); + expect(getAll).not.toHaveBeenCalled(); + + get.mockRestore(); + getAll.mockRestore(); }); diff --git a/packages/app/src/runtime/replica-cache/row-store.web.ts b/packages/app/src/runtime/replica-cache/row-store.web.ts index 8b843fc8b6..12f484bd91 100644 --- a/packages/app/src/runtime/replica-cache/row-store.web.ts +++ b/packages/app/src/runtime/replica-cache/row-store.web.ts @@ -136,6 +136,38 @@ export function createIndexedDbReplicaRowStore( return Array.from(hosts, ([serverId, hostRows]) => ({ serverId, rows: hostRows })); } + async function read( + serverId: string, + kinds: readonly ReplicaRow["kind"][], + ids?: readonly string[], + ): Promise { + if (kinds.length === 0 || ids?.length === 0) return []; + const transaction = getDatabase().transaction(ROWS_STORE, "readonly"); + const completion = transactionComplete(transaction); + const store = transaction.objectStore(ROWS_STORE); + const requests = ids + ? kinds.flatMap((kind) => + ids.map(async (id) => { + const row = await requestResult( + store.get([serverId, kind, id]), + ); + return row ? [row] : []; + }), + ) + : kinds.map((kind) => + requestResult( + store.getAll(IDBKeyRange.bound([serverId, kind], [serverId, kind, []])), + ), + ); + const rows = (await Promise.all(requests)).flat(); + await completion; + return rows.sort((left, right) => + left.kind === right.kind + ? left.id.localeCompare(right.id) + : left.kind.localeCompare(right.kind), + ); + } + async function apply(changes: ReplicaRowChanges): Promise { await runTransaction(getDatabase(), ROWS_STORE, async (transaction) => { const rows = transaction.objectStore(ROWS_STORE); @@ -148,39 +180,6 @@ export function createIndexedDbReplicaRowStore( }); } - async function read( - serverIds: readonly string[], - kinds: readonly ReplicaRow["kind"][], - ids?: readonly string[], - ): Promise { - const transaction = getDatabase().transaction(ROWS_STORE, "readonly"); - const completion = transactionComplete(transaction); - const store = transaction.objectStore(ROWS_STORE); - const requests: Promise[] = []; - for (const serverId of serverIds) { - for (const kind of kinds) { - if (ids) { - for (const id of ids) { - requests.push( - requestResult(store.get([serverId, kind, id])).then((row) => - row ? [row] : [], - ), - ); - } - } else { - requests.push( - requestResult( - store.getAll(IDBKeyRange.bound([serverId, kind], [serverId, kind, []])), - ), - ); - } - } - } - const rows = (await Promise.all(requests)).flat(); - await completion; - return rows; - } - async function deleteHost(serverId: string): Promise { await runTransaction(getDatabase(), ROWS_STORE, async (transaction) => { const rows = transaction.objectStore(ROWS_STORE); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 4f599c3d84..a27a1ed418 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -61,7 +61,6 @@ import { openWorkspacePullRequest } from "@/workspace-tabs/open-supporting-view" import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; import { traceInstant } from "@/performance/native-trace"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; -import type { ViewedTimelineDemandDeclaration } from "@/timeline/viewed-timeline-sync"; import { canDismissPaneInLayout, collectAllTabs, @@ -1547,6 +1546,12 @@ function WorkspaceScreenContent({ [workspaceId], ); const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId); + useEffect(() => { + if (!normalizedServerId || !normalizedWorkspaceId || workspaceDescriptor) return; + void getHostRuntimeStore() + .prepareWorkspaceRoute(normalizedServerId, normalizedWorkspaceId) + .catch(() => undefined); + }, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor]); const workspaceScripts = getWorkspaceScripts(workspaceDescriptor); const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } = useWorkspaceRouteActions(normalizedServerId); @@ -1909,23 +1914,25 @@ function WorkspaceScreenContent({ routeFocused: isRouteFocused, focusedPaneOnly: syncFocusedPaneOnly, }); - const visibleTimelineDeclarationRef = useRef(null); + useEffect(() => { + for (const agentId of visibleAgentIds) { + void getHostRuntimeStore() + .prepareAgentTimeline(normalizedServerId, agentId) + .catch(() => undefined); + } + }, [normalizedServerId, visibleAgentIds]); useLayoutEffect(() => { if (!persistenceKey || !viewedTimelineSync) { return; } - const declaration = viewedTimelineSync.registerVisibleAgentIds(persistenceKey, []); - visibleTimelineDeclarationRef.current = declaration; - return () => { - if (visibleTimelineDeclarationRef.current === declaration) { - visibleTimelineDeclarationRef.current = null; - } - declaration.dispose(); - }; - }, [persistenceKey, viewedTimelineSync]); - useLayoutEffect(() => { - visibleTimelineDeclarationRef.current?.replace(visibleAgentIds); + viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, visibleAgentIds); }, [persistenceKey, viewedTimelineSync, visibleAgentIds]); + useEffect(() => { + if (!persistenceKey || !viewedTimelineSync) { + return; + } + return () => viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, []); + }, [persistenceKey, viewedTimelineSync]); const setFocusedAgentId = useSessionStore((state) => state.setFocusedAgentId); const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId); const focusedPaneAgentId = useMemo(() => { diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 87c50a9155..c53eae4c49 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -1,4 +1,3 @@ -import { createTimelineReplica, type TimelineReplica } from "@/timeline/replica"; import { afterEach, describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -86,37 +85,8 @@ afterEach(() => { useSessionStore.getState().clearSession("test-server"); }); -let timelineReplica: TimelineReplica; - function initializeTestSession(): void { useSessionStore.getState().initializeSession("test-server", null as unknown as DaemonClient); - timelineReplica = createTimelineReplica({ - serverId: "test-server", - storage: { - removeTimeline: () => undefined, - readTimeline: async () => undefined, - commitTimeline: () => undefined, - }, - }); -} - -function acknowledgeSubmission(agentId: string, clientMessageId: string): void { - timelineReplica.applyEvents( - agentId, - [ - { - seq: undefined, - epoch: undefined, - timestamp: new Date("2026-07-31T10:00:01.000Z"), - event: { - type: "timeline", - provider: "claude", - item: { type: "user_message", text: clientMessageId, messageId: clientMessageId }, - }, - }, - ], - () => undefined, - ); } function getTestSessionReferences() { @@ -171,38 +141,16 @@ describe("agent task state", () => { ); const tasks = [{ id: "1", text: "Inspect", status: "pending" as const, completed: false }]; - const applyTasks = (items: typeof tasks) => - timelineReplica.applyEvents( - "agent-1", - [ - { - seq: undefined, - epoch: undefined, - timestamp: new Date("2026-08-11T10:00:00.000Z"), - event: { type: "timeline", provider: "claude", item: { type: "todo", items } }, - }, - ], - () => undefined, - ); - applyTasks(tasks); + useSessionStore + .getState() + .setAgentStreamState("test-server", "agent-1", { taskSnapshot: tasks }); useSessionStore.getState().setIsPlayingAudio("test-server", true); - applyTasks([...tasks]); - timelineReplica.applyEvents( - "agent-1", - [ - { - seq: undefined, - epoch: undefined, - timestamp: new Date("2026-08-11T10:00:01.000Z"), - event: { - type: "timeline", - provider: "claude", - item: { type: "todo", items: [{ ...tasks[0], status: "completed", completed: true }] }, - }, - }, - ], - () => undefined, - ); + useSessionStore + .getState() + .setAgentStreamState("test-server", "agent-1", { taskSnapshot: [...tasks] }); + useSessionStore.getState().setAgentStreamState("test-server", "agent-1", { + taskSnapshot: [{ ...tasks[0], status: "completed", completed: true }], + }); unsubscribe(); expect(snapshots).toEqual([["Inspect"], ["Inspect"]]); @@ -226,7 +174,7 @@ describe("agent task state", () => { older: "none", newer: false, synchronized: true, - submissions: [], + acknowledgedClientMessageIds: [], }); expect(useSessionStore.getState().sessions["test-server"]?.agentTasks.get("agent-1")).toEqual( @@ -236,107 +184,6 @@ describe("agent task state", () => { }); describe("agent timeline state", () => { - it("preserves unchanged projection maps and only rescans tasks when rows change", () => { - initializeTestSession(); - const store = useSessionStore.getState(); - let scans = 0; - const item: StreamItem = { - get kind() { - scans++; - return "assistant_message" as const; - }, - id: "row", - text: "text", - timestamp: new Date(0), - }; - const input = { - items: [item], - head: [], - range: { epoch: "epoch", startSeq: 1, endSeq: 1 }, - older: "available" as const, - newer: false, - synchronized: true, - submissions: [], - }; - store.applyAgentTimelineResponseState("test-server", "agent-1", input); - const before = useSessionStore.getState(); - const session = before.sessions["test-server"]!; - const scanned = scans; - let notices = 0; - const unsubscribe = useSessionStore.subscribe(() => notices++); - store.applyAgentTimelineResponseState("test-server", "agent-1", { - ...input, - head: [], - range: { ...input.range }, - submissions: [], - }); - expect(useSessionStore.getState()).toBe(before); - expect(notices).toBe(0); - store.applyAgentTimelineResponseState("test-server", "agent-1", { ...input, newer: true }); - const changed = useSessionStore.getState().sessions["test-server"]!; - expect(changed.agentTimelineHasNewer).not.toBe(session.agentTimelineHasNewer); - for (const key of [ - "agentStreamTail", - "agentStreamHead", - "agentTimelineCursor", - "agentTimelineHasOlder", - "agentAuthoritativeHistoryApplied", - "agentHistorySyncGeneration", - "messageSubmissions", - "agentTasks", - ] as const) - expect(changed[key]).toBe(session[key]); - expect(notices).toBe(1); - expect(scans).toBe(scanned); - store.bumpHistorySyncGeneration("test-server"); - const stale = useSessionStore.getState().sessions["test-server"]!; - store.applyAgentTimelineResponseState("test-server", "agent-1", { ...input, newer: true }); - const synced = useSessionStore.getState().sessions["test-server"]!; - expect(synced.agentHistorySyncGeneration.get("agent-1")).toBe(synced.historySyncGeneration); - expect(synced.agentHistorySyncGeneration).not.toBe(stale.agentHistorySyncGeneration); - expect(synced.agentStreamTail).toBe(stale.agentStreamTail); - expect(scans).toBe(scanned); - unsubscribe(); - }); - - it("settles submissions and deletes optional entries while retaining an empty authoritative tail", () => { - initializeTestSession(); - const store = useSessionStore.getState(); - const input = { - items: [], - head: [], - range: null, - older: "none" as const, - newer: false, - synchronized: true, - submissions: [{ clientMessageId: "local", providerAcknowledged: false, rpcSettled: false }], - }; - store.applyAgentTimelineResponseState("test-server", "agent-1", input); - const pending = useSessionStore.getState().sessions["test-server"]!; - store.applyAgentTimelineResponseState("test-server", "agent-1", { - ...input, - submissions: [{ ...input.submissions[0]!, rpcSettled: true }], - }); - const settled = useSessionStore.getState().sessions["test-server"]!; - expect(settled.messageSubmissions.get("agent-1")?.[0]?.rpcSettled).toBe(true); - expect(settled.agentStreamTail).toBe(pending.agentStreamTail); - store.applyAgentTimelineResponseState("test-server", "agent-1", { ...input, submissions: [] }); - const empty = useSessionStore.getState(); - const session = empty.sessions["test-server"]!; - expect(session.agentStreamTail.has("agent-1")).toBe(true); - expect(session.agentStreamHead.has("agent-1")).toBe(false); - expect(session.agentTimelineCursor.has("agent-1")).toBe(false); - expect(session.messageSubmissions.has("agent-1")).toBe(false); - expect(session.agentTasks.has("agent-1")).toBe(false); - store.applyAgentTimelineResponseState("test-server", "agent-1", { - ...input, - items: [], - head: [], - submissions: [], - }); - expect(useSessionStore.getState()).toBe(empty); - }); - it("commits canonical items, range, and older availability as one synced state", () => { initializeTestSession(); const items: StreamItem[] = [ @@ -355,7 +202,7 @@ describe("agent timeline state", () => { older: "available", newer: false, synchronized: true, - submissions: [], + acknowledgedClientMessageIds: [], }); expect( @@ -378,7 +225,7 @@ describe("agent timeline state", () => { older: "none", newer: false, synchronized: true, - submissions: [], + acknowledgedClientMessageIds: [], }); expect( @@ -396,7 +243,7 @@ describe("agent timeline state", () => { older: "available", newer: false, synchronized: true, - submissions: [], + acknowledgedClientMessageIds: [], }); store.applyAgentTimelineResponseState("test-server", "agent-1", { @@ -406,7 +253,7 @@ describe("agent timeline state", () => { older: "unchanged", newer: false, synchronized: false, - submissions: [], + acknowledgedClientMessageIds: [], }); expect( @@ -459,12 +306,15 @@ describe("message submission ordering", () => { ] as const; function applyStep(step: (typeof steps)[number]): void { + const store = useSessionStore.getState(); if (step === "accept") { - timelineReplica.acceptSubmission(agentId, clientMessageId); + store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId); return; } if (step === "canonical-row") { - acknowledgeSubmission(agentId, clientMessageId); + store.setAgentStreamState("test-server", agentId, { + acknowledgedClientMessageIds: [clientMessageId], + }); return; } if (step === "stream-open") { @@ -501,7 +351,9 @@ describe("message submission ordering", () => { for (const ordering of permutations(steps)) { useSessionStore.getState().clearSession("test-server"); initializeTestSession(); - timelineReplica.beginSubmission(agentId, submittedMessage(clientMessageId), true); + useSessionStore + .getState() + .beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId)); let canonicalObserved = false; for (const step of ordering) { @@ -516,8 +368,11 @@ describe("message submission ordering", () => { } } - timelineReplica.acceptSubmission(agentId, clientMessageId); - acknowledgeSubmission(agentId, clientMessageId); + const store = useSessionStore.getState(); + store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId); + store.setAgentStreamState("test-server", agentId, { + acknowledgedClientMessageIds: [clientMessageId], + }); applyTestTurn("test-server", agentId, [ { type: "stream_close", turnId: "turn-1" }, { type: "snapshot", activeTurn: null }, @@ -547,7 +402,9 @@ describe("message submission ordering", () => { }); }); - timelineReplica.beginSubmission(agentId, submittedMessage(clientMessageId), true); + useSessionStore + .getState() + .beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId)); unsubscribe(); expect(notifications).toEqual([{ hasOptimisticRow: true, isActive: true }]); @@ -555,10 +412,13 @@ describe("message submission ordering", () => { it("keeps another unacknowledged submission active after one row is acknowledged", () => { initializeTestSession(); - timelineReplica.beginSubmission(agentId, submittedMessage(clientMessageId), true); - timelineReplica.beginSubmission(agentId, submittedMessage("client-2"), true); - timelineReplica.acceptSubmission(agentId, clientMessageId); - acknowledgeSubmission(agentId, clientMessageId); + const store = useSessionStore.getState(); + store.beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId)); + store.beginAgentMessageSubmission("test-server", agentId, submittedMessage("client-2")); + store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId); + store.setAgentStreamState("test-server", agentId, { + acknowledgedClientMessageIds: [clientMessageId], + }); expect( selectAgentTurnPresentation(useSessionStore.getState().sessions["test-server"], agentId) @@ -570,7 +430,9 @@ describe("message submission ordering", () => { initializeTestSession(); const message = submittedMessage(clientMessageId); - const handedOff = timelineReplica.handoffSubmission(agentId, message); + const handedOff = useSessionStore + .getState() + .handoffCreatedAgentUserMessage("test-server", agentId, message); const session = useSessionStore.getState().sessions["test-server"]; expect({ diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 579e7451f4..3f1ae6ffcf 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -4,10 +4,22 @@ import { subscribeWithSelector } from "zustand/middleware"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; -import { type StreamItem, type TodoEntry } from "@/types/stream"; import { + appendSubmittedUserMessage, + handoffCreatedAgentUserMessageToStream, + removeSubmittedUserMessage, + type StreamItem, + type TodoEntry, + type UserMessageItem, +} from "@/types/stream"; +import { + acceptMessageSubmission, + beginMessageSubmission, getActiveMessageSubmissions, + observeMessageSubmissionCanonical, + rejectMessageSubmission, type MessageSubmissionRecord, + type MessageSubmissionRejectionOutcome, } from "@/composer/submission/model"; import type { PendingPermission } from "@/types/shared"; import type { ComposerAttachment } from "@/attachments/types"; @@ -284,18 +296,6 @@ export interface AgentTimelineCursorState { }>; } -function sameTimelineRange( - current: AgentTimelineCursorState | undefined, - next: AgentTimelineCursorState | null, -): boolean { - return ( - current?.epoch === next?.epoch && - current?.startSeq === next?.startSeq && - current?.endSeq === next?.endSeq && - current?.retainedRanges === next?.retainedRanges - ); -} - export type AgentTimelineState = | { status: "cold" } | { status: "painted"; items: StreamItem[] } @@ -453,13 +453,74 @@ interface SessionStoreActions { setFocusedTerminalId: (serverId: string, terminalId: string | null) => void; // Stream state (head/tail model) - + setAgentStreamTail: ( + serverId: string, + state: + | Map + | ((prev: Map) => Map), + ) => void; + setAgentStreamHead: ( + serverId: string, + state: + | Map + | ((prev: Map) => Map), + ) => void; + setAgentStreamState: ( + serverId: string, + agentId: string, + state: { + tail?: StreamItem[]; + head?: StreamItem[]; + acknowledgedClientMessageIds?: readonly string[]; + taskSnapshot?: TodoEntry[]; + }, + ) => void; + beginAgentMessageSubmission: ( + serverId: string, + agentId: string, + message: UserMessageItem, + ) => void; + acceptAgentMessageSubmission: ( + serverId: string, + agentId: string, + clientMessageId: string, + ) => void; + rejectAgentMessageSubmission: ( + serverId: string, + agentId: string, + clientMessageId: string, + ) => MessageSubmissionRejectionOutcome; + handoffCreatedAgentUserMessage: ( + serverId: string, + agentId: string, + message: UserMessageItem, + ) => boolean; + clearAgentStreamHead: (serverId: string, agentId: string) => void; + setAgentTimelineCursor: ( + serverId: string, + state: + | Map + | ((prev: Map) => Map), + ) => void; + setAgentTimelineHasOlder: ( + serverId: string, + state: Map | ((prev: Map) => Map), + ) => void; + setAgentTimelineHasNewer: ( + serverId: string, + state: Map | ((prev: Map) => Map), + ) => void; setAgentTimelineOlderFetchInFlight: ( serverId: string, state: Map | ((prev: Map) => Map), ) => void; bumpHistorySyncGeneration: (serverId: string) => void; - + markAgentHistorySynchronized: (serverId: string, agentId: string) => void; + setAgentAuthoritativeHistoryApplied: ( + serverId: string, + agentId: string, + applied: boolean, + ) => void; applyAgentTimelineResponseState: ( serverId: string, agentId: string, @@ -470,12 +531,10 @@ interface SessionStoreActions { older: "available" | "none" | "unchanged"; newer: boolean; synchronized: boolean; - submissions: MessageSubmissionRecord[]; + acknowledgedClientMessageIds: string[]; }, ) => void; - removeAgentTimeline: (serverId: string, agentId: string) => void; - // Initializing agents setInitializingAgents: ( serverId: string, @@ -872,6 +931,354 @@ export const useSessionStore = create()( }, // Stream state (head/tail model) + setAgentStreamTail: (serverId, state) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) { + return prev; + } + const nextState = typeof state === "function" ? state(session.agentStreamTail) : state; + if (session.agentStreamTail === nextState) { + return prev; + } + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, agentStreamTail: nextState }, + }, + }; + }); + }, + + setAgentStreamHead: (serverId, state) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) { + return prev; + } + const nextState = typeof state === "function" ? state(session.agentStreamHead) : state; + if (session.agentStreamHead === nextState) { + return prev; + } + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, agentStreamHead: nextState }, + }, + }; + }); + }, + + setAgentStreamState: (serverId, agentId, state) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) { + return prev; + } + + let nextTail = session.agentStreamTail; + let nextHead = session.agentStreamHead; + let changedTail = false; + let changedHead = false; + + if (state.tail !== undefined) { + const existingTail = session.agentStreamTail.get(agentId); + if (existingTail !== state.tail) { + nextTail = new Map(session.agentStreamTail); + nextTail.set(agentId, state.tail); + changedTail = true; + } + } + + if (state.head !== undefined) { + const existingHead = session.agentStreamHead.get(agentId); + const shouldDeleteHead = state.head.length === 0; + if (shouldDeleteHead) { + if (session.agentStreamHead.has(agentId)) { + nextHead = new Map(session.agentStreamHead); + nextHead.delete(agentId); + changedHead = true; + } + } else if (existingHead !== state.head) { + nextHead = new Map(session.agentStreamHead); + nextHead.set(agentId, state.head); + changedHead = true; + } + } + + const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; + const observedSubmissions = observeMessageSubmissionCanonical( + currentSubmissions, + state.acknowledgedClientMessageIds ?? [], + ); + const changedSubmissions = observedSubmissions !== currentSubmissions; + const agentTasks = updateAgentTasks(session.agentTasks, agentId, state.taskSnapshot); + const changedTasks = agentTasks !== session.agentTasks; + + if (!changedTail && !changedHead && !changedSubmissions && !changedTasks) { + return prev; + } + + let messageSubmissions = session.messageSubmissions; + if (changedSubmissions) { + messageSubmissions = new Map(session.messageSubmissions); + if (observedSubmissions.length > 0) { + messageSubmissions.set(agentId, observedSubmissions); + } else { + messageSubmissions.delete(agentId); + } + } + + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { + ...session, + agentStreamTail: nextTail, + agentStreamHead: nextHead, + agentTasks, + messageSubmissions, + }, + }, + }; + }); + }, + + beginAgentMessageSubmission: (serverId, agentId, message) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) return prev; + if (!message.clientMessageId) { + throw new Error("Beginning a message submission requires client identity"); + } + const currentTail = session.agentStreamTail.get(agentId) ?? []; + const currentHead = session.agentStreamHead.get(agentId) ?? []; + const stream = appendSubmittedUserMessage({ + tail: currentTail, + head: currentHead, + message, + }); + const submissions = beginMessageSubmission( + session.messageSubmissions.get(agentId) ?? [], + { clientMessageId: message.clientMessageId }, + ); + const messageSubmissions = new Map(session.messageSubmissions); + messageSubmissions.set(agentId, submissions); + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { + ...session, + agentStreamTail: + stream.tail === currentTail + ? session.agentStreamTail + : new Map(session.agentStreamTail).set(agentId, stream.tail), + agentStreamHead: + stream.head === currentHead + ? session.agentStreamHead + : new Map(session.agentStreamHead).set(agentId, stream.head), + messageSubmissions, + }, + }, + }; + }); + }, + + acceptAgentMessageSubmission: (serverId, agentId, clientMessageId) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) return prev; + const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; + const submissions = acceptMessageSubmission(currentSubmissions, clientMessageId); + if (submissions === currentSubmissions) return prev; + const messageSubmissions = new Map(session.messageSubmissions); + if (submissions.length > 0) { + messageSubmissions.set(agentId, submissions); + } else { + messageSubmissions.delete(agentId); + } + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, messageSubmissions }, + }, + }; + }); + }, + + rejectAgentMessageSubmission: (serverId, agentId, clientMessageId) => { + let outcome: MessageSubmissionRejectionOutcome = "unknown"; + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) return prev; + const currentTail = session.agentStreamTail.get(agentId) ?? []; + const currentHead = session.agentStreamHead.get(agentId) ?? []; + const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; + const result = rejectMessageSubmission(currentSubmissions, clientMessageId); + outcome = result.outcome; + if (outcome === "unknown") return prev; + const stream = + outcome === "rejected" + ? removeSubmittedUserMessage({ + tail: currentTail, + head: currentHead, + clientMessageId, + }) + : { tail: currentTail, head: currentHead }; + const messageSubmissions = new Map(session.messageSubmissions); + if (result.submissions.length > 0) { + messageSubmissions.set(agentId, result.submissions); + } else { + messageSubmissions.delete(agentId); + } + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { + ...session, + agentStreamTail: + stream.tail === currentTail + ? session.agentStreamTail + : new Map(session.agentStreamTail).set(agentId, stream.tail), + agentStreamHead: + stream.head === currentHead + ? session.agentStreamHead + : new Map(session.agentStreamHead).set(agentId, stream.head), + messageSubmissions, + }, + }, + }; + }); + return outcome; + }, + + handoffCreatedAgentUserMessage: (serverId, agentId, message) => { + let didHandoff = false; + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) { + return prev; + } + + const currentTail = session.agentStreamTail.get(agentId) ?? []; + const currentHead = session.agentStreamHead.get(agentId) ?? []; + const result = handoffCreatedAgentUserMessageToStream({ + tail: currentTail, + head: currentHead, + message, + }); + if (!result.changedTail && !result.changedHead) { + return prev; + } + + const nextTail = result.changedTail + ? new Map(session.agentStreamTail).set(agentId, result.tail) + : session.agentStreamTail; + const nextHead = result.changedHead + ? new Map(session.agentStreamHead).set(agentId, result.head) + : session.agentStreamHead; + didHandoff = true; + + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { + ...session, + agentStreamTail: nextTail, + agentStreamHead: nextHead, + }, + }, + }; + }); + return didHandoff; + }, + + clearAgentStreamHead: (serverId, agentId) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) { + return prev; + } + if (!session.agentStreamHead.has(agentId)) { + return prev; + } + const nextHead = new Map(session.agentStreamHead); + nextHead.delete(agentId); + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, agentStreamHead: nextHead }, + }, + }; + }); + }, + + setAgentTimelineCursor: (serverId, state) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) { + return prev; + } + const nextState = + typeof state === "function" ? state(session.agentTimelineCursor) : state; + if (session.agentTimelineCursor === nextState) { + return prev; + } + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, agentTimelineCursor: nextState }, + }, + }; + }); + }, + + setAgentTimelineHasOlder: (serverId, state) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) { + return prev; + } + const nextState = + typeof state === "function" ? state(session.agentTimelineHasOlder) : state; + if (session.agentTimelineHasOlder === nextState) { + return prev; + } + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, agentTimelineHasOlder: nextState }, + }, + }; + }); + }, + + setAgentTimelineHasNewer: (serverId, state) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) return prev; + const nextState = + typeof state === "function" ? state(session.agentTimelineHasNewer) : state; + if (session.agentTimelineHasNewer === nextState) return prev; + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, agentTimelineHasNewer: nextState }, + }, + }; + }); + }, setAgentTimelineOlderFetchInFlight: (serverId, state) => { set((prev) => { @@ -914,58 +1321,107 @@ export const useSessionStore = create()( }); }, + markAgentHistorySynchronized: (serverId, agentId) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) { + return prev; + } + const currentGeneration = session.historySyncGeneration; + const previousGeneration = session.agentHistorySyncGeneration.get(agentId); + if (previousGeneration === currentGeneration) { + return prev; + } + const nextMap = new Map(session.agentHistorySyncGeneration); + nextMap.set(agentId, currentGeneration); + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { + ...session, + agentHistorySyncGeneration: nextMap, + }, + }, + }; + }); + }, + + setAgentAuthoritativeHistoryApplied: (serverId, agentId, applied) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) { + return prev; + } + + const previousApplied = session.agentAuthoritativeHistoryApplied.get(agentId) ?? false; + if (previousApplied === applied) { + return prev; + } + + const nextApplied = new Map(session.agentAuthoritativeHistoryApplied); + if (applied) { + nextApplied.set(agentId, true); + } else { + nextApplied.delete(agentId); + } + + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { + ...session, + agentAuthoritativeHistoryApplied: nextApplied, + }, + }, + }; + }); + }, + applyAgentTimelineResponseState: (serverId, agentId, state) => { set((prev) => { const session = prev.sessions[serverId]; if (!session) return prev; - let changed = false; - const update = (map: Map, value: T | undefined): Map => { - if (map.get(agentId) === value) return map; - changed = true; - const next = new Map(map); - if (value === undefined) next.delete(agentId); - else next.set(agentId, value); - return next; - }; - const currentTail = session.agentStreamTail.get(agentId); - const items = - currentTail?.length === 0 && state.items.length === 0 ? currentTail : state.items; - const nextTail = update(session.agentStreamTail, items); - const nextHead = update( - session.agentStreamHead, - state.head.length > 0 ? state.head : undefined, + const nextTail = new Map(session.agentStreamTail); + nextTail.set(agentId, state.items); + const nextHead = new Map(session.agentStreamHead); + if (state.head.length > 0) nextHead.set(agentId, state.head); + else nextHead.delete(agentId); + const nextCursor = new Map(session.agentTimelineCursor); + if (state.range) nextCursor.set(agentId, state.range); + else nextCursor.delete(agentId); + const nextHasOlder = new Map(session.agentTimelineHasOlder); + if (state.older !== "unchanged") { + nextHasOlder.set(agentId, state.older === "available"); + } + const nextHasNewer = new Map(session.agentTimelineHasNewer); + nextHasNewer.set(agentId, state.newer); + const nextAuthoritative = new Map(session.agentAuthoritativeHistoryApplied); + const nextSyncGeneration = new Map(session.agentHistorySyncGeneration); + const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; + const observedSubmissions = observeMessageSubmissionCanonical( + currentSubmissions, + state.acknowledgedClientMessageIds, ); - const currentRange = session.agentTimelineCursor.get(agentId); - const rangeUnchanged = sameTimelineRange(currentRange, state.range); - const nextCursor = rangeUnchanged - ? session.agentTimelineCursor - : update(session.agentTimelineCursor, state.range ?? undefined); - const nextHasOlder = - state.older === "unchanged" - ? session.agentTimelineHasOlder - : update(session.agentTimelineHasOlder, state.older === "available"); - const nextHasNewer = update(session.agentTimelineHasNewer, state.newer); - const nextAuthoritative = state.synchronized - ? update(session.agentAuthoritativeHistoryApplied, true) - : session.agentAuthoritativeHistoryApplied; - const nextSyncGeneration = state.synchronized - ? update(session.agentHistorySyncGeneration, session.historySyncGeneration) - : session.agentHistorySyncGeneration; - const messageSubmissions = update( - session.messageSubmissions, - state.submissions.length > 0 ? state.submissions : undefined, - ); - const rowsChanged = - nextTail !== session.agentStreamTail || nextHead !== session.agentStreamHead; - const agentTasks = rowsChanged - ? updateAgentTasks( - session.agentTasks, - agentId, - latestTasksFromStream([...state.items, ...state.head]), - ) - : session.agentTasks; - if (!changed && agentTasks === session.agentTasks) return prev; + let messageSubmissions = session.messageSubmissions; + if (observedSubmissions !== currentSubmissions) { + messageSubmissions = new Map(session.messageSubmissions); + if (observedSubmissions.length > 0) { + messageSubmissions.set(agentId, observedSubmissions); + } else { + messageSubmissions.delete(agentId); + } + } + if (state.synchronized) { + nextAuthoritative.set(agentId, true); + nextSyncGeneration.set(agentId, session.historySyncGeneration); + } + const tasks = latestTasksFromStream([...state.items, ...state.head]); + const agentTasks = new Map(session.agentTasks); + if (tasks.length > 0) agentTasks.set(agentId, tasks); + else agentTasks.delete(agentId); return { ...prev, @@ -988,37 +1444,6 @@ export const useSessionStore = create()( }); }, - removeAgentTimeline: (serverId, agentId) => { - set((previous) => { - const session = previous.sessions[serverId]; - if (!session) return previous; - const remove = (map: Map): Map => { - const next = new Map(map); - next.delete(agentId); - return next; - }; - return { - ...previous, - sessions: { - ...previous.sessions, - [serverId]: { - ...session, - agentStreamTail: remove(session.agentStreamTail), - agentStreamHead: remove(session.agentStreamHead), - agentTimelineCursor: remove(session.agentTimelineCursor), - agentTimelineHasOlder: remove(session.agentTimelineHasOlder), - agentTimelineHasNewer: remove(session.agentTimelineHasNewer), - agentTimelineOlderFetchInFlight: remove(session.agentTimelineOlderFetchInFlight), - agentAuthoritativeHistoryApplied: remove(session.agentAuthoritativeHistoryApplied), - agentHistorySyncGeneration: remove(session.agentHistorySyncGeneration), - messageSubmissions: remove(session.messageSubmissions), - agentTasks: remove(session.agentTasks), - }, - }, - }; - }); - }, - // Initializing agents setInitializingAgents: (serverId, state) => { set((prev) => { diff --git a/packages/app/src/timeline/__fixtures__/legacy-unverifiable-timeline.json b/packages/app/src/timeline/__fixtures__/legacy-unverifiable-timeline.json deleted file mode 100644 index 3ffd41ba74..0000000000 --- a/packages/app/src/timeline/__fixtures__/legacy-unverifiable-timeline.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "agentId": "dd23a15e-0ece-4713-bff4-2f92b7293552", - "items": [ - { - "id": "e793b735-19ca-4dae-b8a2-ef79ada78b40", - "timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 34 }, - "turnId": "95e36741-5c73-4472-ab3c-3ba03977acfb", - "timestamp": "2026-08-29T11:04:38.577Z", - "kind": "assistant_message", - "messageId": "e793b735-19ca-4dae-b8a2-ef79ada78b40", - "text": "## Cycle 1\n\nI'll take a look at the scroll anchor behavior you described. Let me start by walking through how the conversation list currently handles streaming updates and where the auto-scroll logic actually lives. My instinct is that the anchor is supposed to pin to the bottom only when the user is already there, but I want to confirm that against the code rather\n\n_(end of synthetic stream)_\n" - }, - { - "id": "2cb83cb0-fecd-4f7b-baf4-43d5230be02a", - "timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 69 }, - "turnId": "c05e5c65-91dd-497e-8e59-92e045b4395f", - "timestamp": "2026-08-29T11:19:09.051Z", - "kind": "assistant_message", - "messageId": "2cb83cb0-fecd-4f7b-baf4-43d5230be02a", - "text": "## Cycle 1\n\nI'll take a look at the scroll anchor behavior you described. Let me start by walking through how the conversation list currently handles streaming updates and where the auto-scroll logic actually lives. My instinct is that the anchor is supposed to pin to the bottom only when the user is already there, but I want to confirm that against the code rather than guess. This kind of\n\n_(end of synthetic stream)_\n" - }, - { - "id": "8b0dcf06-f4a7-4238-8ba1-87a4e9daaf83", - "timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 1 }, - "turnId": "95e36741-5c73-4472-ab3c-3ba03977acfb", - "timestamp": "2026-08-29T11:04:36.573Z", - "kind": "user_message", - "messageId": "8b0dcf06-f4a7-4238-8ba1-87a4e9daaf83", - "text": "Exercise the composer keyboard invariant flow" - }, - { - "id": "e793b735-19ca-4dae-b8a2-ef79ada78b40:segment:mte9wyi9", - "timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 34 }, - "turnId": "95e36741-5c73-4472-ab3c-3ba03977acfb", - "timestamp": "2026-08-29T11:04:38.577Z", - "kind": "assistant_message", - "messageId": "e793b735-19ca-4dae-b8a2-ef79ada78b40", - "text": "## Cycle 1\n\nI'll take a look at the scroll anchor behavior you described. Let me start by walking through how the conversation list currently handles streaming updates and where the auto-scroll logic actually lives. My instinct is that the anchor is supposed to pin to the bottom only when the user is already there, but I want to confirm that against the code rather\n\n_(end of synthetic stream)_\n" - }, - { - "id": "msg_1788002346771_1hfh7ud4z", - "timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 35 }, - "turnId": "c05e5c65-91dd-497e-8e59-92e045b4395f", - "timestamp": "2026-08-29T11:19:07.043Z", - "kind": "user_message", - "clientMessageId": "msg_1788002346771_1hfh7ud4z", - "messageId": "msg_1788002346771_1hfh7ud4z", - "text": "keyboard invariant line one\nline two\nline three\nline fourvv" - }, - { - "id": "2cb83cb0-fecd-4f7b-baf4-43d5230be02a:segment:mteafm63", - "timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 69 }, - "turnId": "c05e5c65-91dd-497e-8e59-92e045b4395f", - "timestamp": "2026-08-29T11:19:09.051Z", - "kind": "assistant_message", - "messageId": "2cb83cb0-fecd-4f7b-baf4-43d5230be02a", - "text": "## Cycle 1\n\nI'll take a look at the scroll anchor behavior you described. Let me start by walking through how the conversation list currently handles streaming updates and where the auto-scroll logic actually lives. My instinct is that the anchor is supposed to pin to the bottom only when the user is already there, but I want to confirm that against the code rather than guess. This kind of\n\n_(end of synthetic stream)_\n" - } - ], - "range": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "startSeq": 1, "endSeq": 69 }, - "hasOlder": false -} diff --git a/packages/app/src/timeline/__fixtures__/mixed-unpositioned-message.json b/packages/app/src/timeline/__fixtures__/mixed-unpositioned-message.json deleted file mode 100644 index e419c1f2c8..0000000000 --- a/packages/app/src/timeline/__fixtures__/mixed-unpositioned-message.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "entry": { - "provider": "codex", - "item": { - "type": "assistant_message", - "text": "\n\n---\n\nThe pending-tail check passed: the app requested catch-up after 10197 and recovered the live paragraph, with no subsequent live row delivered. I’m now checking that unpositioned updates can coexist with sequencing in the same session.\n", - "messageId": "msg_050818abff3697f1016a9e2d065a4487d287f39b8cdf226b1a" - }, - "timestamp": "2026-09-07T03:18:31.760Z", - "seqStart": 10223, - "seqEnd": 10243, - "sourceSeqRanges": [ - { - "startSeq": 10223, - "endSeq": 10243 - } - ], - "turnId": "codex-turn-64", - "collapsed": ["assistant_merge"] - }, - "chunks": [ - "\n\n---\n\nThe", - " pending-tail", - " check passed", - ": the", - " app requested", - " catch-up", - " after ", - "10197", - " and recovered the", - " live paragraph", - ", with no", - " subsequent live", - " row delivered", - ". I", - "’m now checking that", - " unposition", - "ed", - " updates can coexist with", - " sequencing in", - " the same", - " session.\n" - ] -} diff --git a/packages/app/src/timeline/fetch-agent-timeline-once.test.ts b/packages/app/src/timeline/fetch-agent-timeline-once.test.ts new file mode 100644 index 0000000000..1ae97e0281 --- /dev/null +++ b/packages/app/src/timeline/fetch-agent-timeline-once.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { fetchAgentTimelineOnce } from "./fetch-agent-timeline-once"; + +type TimelinePage = Awaited>; + +test("concurrent identical timeline reads share one request", async () => { + let resolvePage: (page: TimelinePage) => void = () => {}; + const page = new Promise((resolve) => { + resolvePage = resolve; + }); + const requests: Array<{ agentId: string; direction: string }> = []; + const client = { + fetchAgentTimeline: async ( + agentId: string, + request: Parameters[1], + ) => { + requests.push({ agentId, direction: request?.direction ?? "tail" }); + return page; + }, + }; + const request = { direction: "tail" as const, limit: 100, projection: "projected" as const }; + + const first = fetchAgentTimelineOnce(client, "agent", request); + const second = fetchAgentTimelineOnce(client, "agent", request); + resolvePage({ hasNewer: false } as TimelinePage); + + await expect(Promise.all([first, second])).resolves.toHaveLength(2); + expect(requests).toEqual([{ agentId: "agent", direction: "tail" }]); +}); diff --git a/packages/app/src/timeline/fetch-agent-timeline-once.ts b/packages/app/src/timeline/fetch-agent-timeline-once.ts new file mode 100644 index 0000000000..e0d67b4897 --- /dev/null +++ b/packages/app/src/timeline/fetch-agent-timeline-once.ts @@ -0,0 +1,31 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; + +type TimelineClient = Pick; +type TimelineRequest = Parameters[1]; +type TimelinePage = Awaited>; + +const inFlightByClient = new WeakMap>>(); + +export function fetchAgentTimelineOnce( + client: TimelineClient, + agentId: string, + request: TimelineRequest, +): Promise { + let inFlight = inFlightByClient.get(client); + if (!inFlight) { + inFlight = new Map(); + inFlightByClient.set(client, inFlight); + } + + const key = `${agentId}:${JSON.stringify(request)}`; + const existing = inFlight.get(key); + if (existing) return existing; + + const fetch = client.fetchAgentTimeline(agentId, request); + inFlight.set(key, fetch); + const clear = () => { + if (inFlight.get(key) === fetch) inFlight.delete(key); + }; + void fetch.then(clear, clear); + return fetch; +} diff --git a/packages/app/src/timeline/replica.contract.test.ts b/packages/app/src/timeline/replica.contract.test.ts deleted file mode 100644 index 0abce24fe0..0000000000 --- a/packages/app/src/timeline/replica.contract.test.ts +++ /dev/null @@ -1,2171 +0,0 @@ -import { - createInitDeferred, - getInitDeferred, - getInitKey, - resolveInitDeferred, -} from "@/utils/agent-initialization"; -import mixedMessage from "./__fixtures__/mixed-unpositioned-message.json"; -import legacyTimeline from "./__fixtures__/legacy-unverifiable-timeline.json"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { ReplicaCache, type CachedTimeline } from "@/runtime/replica-cache"; -import type { - ReplicaHostRows, - ReplicaRow, - ReplicaRowChanges, - ReplicaRowStore, -} from "@/runtime/replica-cache/row-store"; -import { loadOlderAgentHistory } from "@/hooks/use-load-older-agent-history"; -import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; -import { createUserMessage, type StreamItem } from "@/types/stream"; -import { - createTimelineReplica, - type TimelineResponsePayload, - type TimelineReplicaStorage, -} from "./replica"; -import { createViewedTimelineOwner } from "./viewed-timeline-sync"; - -const host = "transaction-host"; -const otherHost = "transaction-other"; -const agentId = "agent"; -const timestamp = "2026-09-06T10:00:00.000Z"; - -class MemoryRows implements ReplicaRowStore { - rows = new Map(); - async open() {} - async read( - serverIds: readonly string[], - kinds: readonly ReplicaRow["kind"][], - ids?: readonly string[], - ): Promise { - return [...this.rows.values()].filter( - (row) => - serverIds.includes(row.serverId) && - kinds.includes(row.kind) && - (!ids || ids.includes(row.id)), - ); - } - - async readAll(): Promise { - return [host, otherHost].map((serverId) => ({ - serverId, - rows: [...this.rows.values()].filter((row) => row.serverId === serverId), - })); - } - async apply(changes: ReplicaRowChanges) { - for (const row of changes.deletes) this.rows.delete(`${row.serverId}:${row.kind}:${row.id}`); - for (const row of changes.upserts) this.rows.set(`${row.serverId}:${row.kind}:${row.id}`, row); - } - async deleteHost(serverId: string) { - for (const [key, row] of this.rows) if (row.serverId === serverId) this.rows.delete(key); - } - async renameHost(oldId: string, newId: string) { - const rows = [...this.rows.values()].filter((row) => row.serverId === oldId); - await this.deleteHost(oldId); - await this.apply({ - deletes: [], - upserts: rows.map((row) => ({ - kind: row.kind, - id: row.id, - payload: row.payload, - serverId: newId, - })), - }); - } - async clear() { - this.rows.clear(); - } -} - -function cache(rows: MemoryRows): ReplicaCache { - const result = new ReplicaCache(rows, { clearLegacyCache: async () => undefined }); - result.setHosts([host, otherHost]); - return result; -} - -function saved(): CachedTimeline { - return { - agentId, - hasOlder: false, - range: { epoch: "epoch-1", startSeq: 1, endSeq: 80 }, - items: Array.from( - { length: 80 }, - (_, i): StreamItem => ({ - kind: "assistant_message", - id: `message-${i + 1}`, - messageId: `message-${i + 1}`, - text: `row ${i + 1}`, - timestamp: new Date(timestamp), - timelineCursor: { epoch: "epoch-1", seq: i + 1 }, - source: { startSeq: i + 1, chunks: [{ seq: i + 1, offset: 0 }] }, - }), - ), - }; -} - -function page( - start: number, - end: number, - hasNewer = false, - epoch = "epoch-1", -): TimelineResponsePayload { - return { - requestId: `page-${epoch}-${end}`, - agentId, - agent: null, - direction: "after", - projection: "projected", - reset: false, - epoch, - staleCursor: false, - gap: false, - window: { minSeq: 1, maxSeq: end, nextSeq: end + 1 }, - startCursor: { epoch, seq: start }, - endCursor: { epoch, seq: end }, - hasOlder: true, - hasNewer, - error: null, - entries: Array.from({ length: end - start + 1 }, (_, i) => ({ - provider: "claude" as const, - item: { - type: "assistant_message" as const, - text: `row ${start + i}`, - messageId: `message-${start + i}`, - }, - timestamp, - seqStart: start + i, - seqEnd: start + i, - sourceSeqRanges: [{ startSeq: start + i, endSeq: start + i }], - collapsed: [], - })), - }; -} - -function owner(storage: TimelineReplicaStorage, serverId = host) { - const replica = createTimelineReplica({ serverId, storage }); - const viewed = createViewedTimelineOwner({ - serverId, - replica, - replaceDemandedAgentIds: () => undefined, - drainQueuedAgentMessage: () => undefined, - ports: { - initialDeliveryMode: "legacy", - setSubscription: async () => undefined, - fetchPage: async () => ({ hasNewer: false, endCursor: null }), - fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }), - reportError: (error) => { - throw error; - }, - schedule: () => () => undefined, - }, - }); - return { replica, viewed }; -} - -function live(viewed: ReturnType["viewed"], seq: number) { - viewed.enqueueStreamEvent(agentId, { - seq, - epoch: "epoch-1", - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", text: `row ${seq}`, messageId: `message-${seq}` }, - }, - }); -} - -afterEach(() => { - useSessionStore.getState().clearSession(host); - useSessionStore.getState().clearSession(otherHost); -}); - -describe("timeline transaction restart contract", () => { - it.each([false, true])( - "reconciles recorded markdown through display-only reopen (newer live=%s)", - async (newer) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - viewed.beginTimelineRequest(agentId, true)({ ...page(10219, 10219), direction: "tail" }); - for (const text of [...mixedMessage.chunks, ...(newer ? ["Newer text"] : [])]) { - viewed.enqueueStreamEvent(agentId, { - timestamp: new Date(timestamp), - seq: undefined, - epoch: undefined, - event: { - type: "timeline", - provider: "codex", - item: { - type: "assistant_message", - messageId: mixedMessage.entry.item.messageId, - text, - }, - }, - }); - viewed.flushStreamAgent(agentId); - } - const displayed = () => { - const session = useSessionStore.getState().sessions[host]!; - return [ - ...(session.agentStreamTail.get(agentId) ?? []), - ...(session.agentStreamHead.get(agentId) ?? []), - ]; - }; - const messageRows = (items: StreamItem[]) => - items.filter( - (row) => - row.kind === "assistant_message" && row.messageId === mixedMessage.entry.item.messageId, - ); - expect(messageRows(displayed())).toHaveLength(2); - const expectedTexts = newer - ? messageRows(displayed()).map((row) => ("text" in row ? row.text : "")) - : [mixedMessage.entry.item.text]; - if (newer) expect(expectedTexts.join("\n\n")).toContain("session.\nNewer text"); - const tool = { - type: "tool_call" as const, - name: "shell", - callId: "following-tool", - status: "running" as const, - error: null, - detail: { type: "shell" as const, command: "pwd" }, - }; - // The real tool follows the split message; positions resume on this connection. - viewed.enqueueStreamEvent(agentId, { - seq: undefined, - epoch: undefined, - timestamp: new Date(timestamp), - event: { type: "timeline", provider: "codex", item: tool }, - }); - viewed.flushStreamAgent(agentId); - viewed.applyTimelineResponse({ - ...page(10220, 10262), - entries: [ - { - ...mixedMessage.entry, - provider: "codex", - item: { ...mixedMessage.entry.item, type: "assistant_message" }, - collapsed: ["assistant_merge"], - }, - { - provider: "codex", - item: tool, - timestamp, - seqStart: 10244, - seqEnd: 10262, - sourceSeqRanges: [ - { startSeq: 10244, endSeq: 10244 }, - { startSeq: 10262, endSeq: 10262 }, - ], - collapsed: ["tool_lifecycle"], - }, - ], - }); - expect(messageRows(displayed()).map((row) => ("text" in row ? row.text : ""))).toEqual( - expectedTexts, - ); - expect( - displayed() - .slice(-2) - .map((row) => row.kind), - ).toEqual(["assistant_message", "tool_call"]); - await disk.flush(); - const certified = await cache(rows).readTimeline(host, agentId); - expect(messageRows(certified!.items)).toHaveLength(1); - expect(certified?.range).toEqual({ epoch: "epoch-1", startSeq: 10219, endSeq: 10262 }); - expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 10262 }); - viewed.enqueueStreamEvent(agentId, { - seq: undefined, - epoch: undefined, - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "codex", - item: { type: "assistant_message", messageId: "later", text: "later update" }, - }, - }); - viewed.flushStreamAgent(agentId); - await disk.flush(); - const reopened = await cache(rows).readTimeline(host, agentId); - expect(reopened?.range).toBeNull(); - expect(messageRows(reopened!.items).map((row) => ("text" in row ? row.text : ""))).toEqual( - expectedTexts, - ); - viewed.dispose(); - }, - ); - - it("retains positioned gap detection after an out-of-band running setup tool", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - viewed.beginTimelineRequest(agentId, true)({ ...page(41, 80), direction: "tail" }); - viewed.enqueueStreamEvent(agentId, { - seq: undefined, - epoch: undefined, - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { - type: "tool_call", - name: "paseo_worktree_setup", - callId: "setup", - status: "running", - error: null, - detail: { - type: "worktree_setup", - worktreePath: "/repo", - branchName: "branch", - log: "Installing", - commands: [], - }, - }, - }, - }); - viewed.flushStreamAgent(agentId); - expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 80 }); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range).toBeNull(); - // The saved display page itself grants no source coverage on a fresh owner. - const fresh = createTimelineReplica({ serverId: host, storage: cache(rows) }); - await fresh.prepare(agentId); - expect(fresh.readCursor(agentId)).toBeUndefined(); - fresh.dispose(); - const gaps: number[] = []; - replica.applyEvents( - agentId, - [ - { - seq: 82, - epoch: "epoch-1", - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", text: "row 82", messageId: "message-82" }, - }, - }, - ], - (_id, cursor, observed) => { - expect(cursor.endSeq).toBe(80); - gaps.push(observed!); - }, - ); - expect(gaps).toEqual([82]); - viewed.beginTimelineRequest(agentId, true)({ ...page(43, 82), direction: "tail" }); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual({ - epoch: "epoch-1", - startSeq: 43, - endSeq: 82, - }); - viewed.dispose(); - }); - - it.each(["live-first", "tail-first"] as const)( - "converges a cold overflow without another live row: %s", - async (ordering) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - const replica = createTimelineReplica({ serverId: host, storage: disk }); - const requests: number[] = []; - const tailStarted = Promise.withResolvers(); - const releaseTail = Promise.withResolvers(); - const viewed = createViewedTimelineOwner({ - serverId: host, - replica, - replaceDemandedAgentIds: () => undefined, - drainQueuedAgentMessage: () => undefined, - ports: { - initialDeliveryMode: "legacy", - setSubscription: async () => undefined, - fetchPage: async (id, request) => { - const seq = request.direction === "after" ? request.cursor.seq : 0; - requests.push(seq); - const response = - seq === 80 - ? { ...page(81, 120, true), window: { minSeq: 1, maxSeq: 199, nextSeq: 200 } } - : page(200, 200); - viewed.beginTimelineRequest(id)(response); - return response; - }, - fetchLatestTail: async (id) => { - const apply = viewed.beginTimelineRequest(id, true); - tailStarted.resolve(); - await releaseTail.promise; - const response = { ...page(160, 199), direction: "tail" as const }; - apply(response); - return response; - }, - reportError: (error) => { - throw error; - }, - schedule: () => () => undefined, - }, - }); - viewed.registerVisibleAgentIds("screen", [agentId]); - viewed.setConnected(true); - await tailStarted.promise; - if (ordering === "live-first") { - live(viewed, 200); - viewed.flushStreamAgent(agentId); - } - releaseTail.resolve(); - await vi.waitFor(() => - expect(replica.readCursor(agentId)?.endSeq).toBeGreaterThanOrEqual(199), - ); - if (ordering === "tail-first") { - live(viewed, 200); - viewed.flushStreamAgent(agentId); - } - await vi.waitFor(() => expect(replica.readCursor(agentId)?.endSeq).toBe(200)); - await disk.flush(); - const restored = await cache(rows).readTimeline(host, agentId); - expect(restored?.items.some((row) => row.timelineCursor?.seq === 200)).toBe(true); - expect(restored?.range?.endSeq).toBe(200); - expect(requests).toEqual(ordering === "live-first" ? [80, 199] : [80]); - // An intentionally requested older window remains detached after synchronization. - viewed.beginTimelineRequest( - agentId, - true, - )({ ...page(41, 80, true), direction: "tail", mergeWindow: true }); - const before = useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId); - live(viewed, 201); - viewed.flushStreamAgent(agentId); - expect(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)).toBe(before); - expect(replica.readCursor(agentId)?.endSeq).not.toBe(201); - viewed.dispose(); - }, - ); - - it.each([false, true])( - "persists all-unpositioned delivery across restore (pending=%s)", - async (pending) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const read = Promise.withResolvers(); - const { replica, viewed } = owner({ - readTimeline: async (...args) => { - await read.promise; - return disk.readTimeline(...args); - }, - commitTimeline: (...args) => disk.commitTimeline(...args), - removeTimeline: (...args) => disk.removeTimeline(...args), - }); - const preparation = replica.prepare(agentId); - if (!pending) { - read.resolve(); - await preparation; - } - for (let i = 1; i <= 3; i++) { - viewed.enqueueStreamEvent(agentId, { - seq: undefined, - epoch: undefined, - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { - type: "assistant_message", - messageId: `legacy-${i}`, - text: `legacy ${i}`, - }, - }, - }); - viewed.flushStreamAgent(agentId); - if (i === 1 && pending) { - read.resolve(); - await preparation; - } - await disk.flush(); - const restored = await cache(rows).readTimeline(host, agentId); - expect(restored?.range).toBeNull(); - expect(restored?.items).toHaveLength(i); - expect(replica.readCursor(agentId)).toBeUndefined(); - } - viewed.dispose(); - }, - ); - - it("keeps accepted source coverage and display-only persistence across a delayed restore", async () => { - useSessionStore.getState().initializeSession(host, null); - const disk = cache(new MemoryRows()); - const read = Promise.withResolvers(); - const { replica, viewed } = owner({ - readTimeline: () => read.promise, - commitTimeline: (...args) => disk.commitTimeline(...args), - removeTimeline: (...args) => disk.removeTimeline(...args), - }); - const preparation = replica.prepare(agentId); - viewed.beginTimelineRequest(agentId, true)({ ...page(1, 3), direction: "tail" }); - viewed.enqueueStreamEvent(agentId, { - seq: undefined, - epoch: undefined, - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", messageId: "legacy", text: "unsequenced" }, - }, - }); - viewed.flushStreamAgent(agentId); - read.resolve({ - ...saved(), - items: saved().items.slice(0, 3), - range: { epoch: "epoch-1", startSeq: 1, endSeq: 3 }, - }); - await preparation; - expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 3 }); - await disk.flush(); - expect((await disk.readTimeline(host, agentId))?.range).toBeNull(); - expect( - (await disk.readTimeline(host, agentId))?.items.some( - (row) => "text" in row && row.text === "unsequenced", - ), - ).toBe(true); - viewed.dispose(); - }); - - it.each(["current", "newer", "flag-only"] as const)( - "settles only the owning initialization after a superseded tail (%s)", - async (lifetime) => { - useSessionStore.getState().initializeSession(host, null); - const disk = cache(new MemoryRows()); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - viewed.beginTimelineRequest(agentId, true)({ ...page(1, 3), direction: "tail" }); - const key = getInitKey(host, agentId); - const initial = lifetime === "flag-only" ? undefined : createInitDeferred(key, "tail"); - let settled = false; - initial?.promise.then(() => { - settled = true; - return undefined; - }); - useSessionStore - .getState() - .setInitializingAgents(host, (previous) => new Map(previous).set(agentId, true)); - const accept = viewed.beginTimelineRequest(agentId); - live(viewed, 4); - live(viewed, 5); - viewed.flushStreamAgent(agentId); - // Replacing an initialization already has its own deferred identity. - if (lifetime === "newer") createInitDeferred(key, "tail"); - const current = getInitDeferred(key); - const before = useSessionStore.getState().sessions[host]!; - accept({ ...page(1, 4), direction: "tail" }); - await Promise.resolve(); - const after = useSessionStore.getState().sessions[host]!; - expect(after.agentStreamTail.get(agentId)).toBe(before.agentStreamTail.get(agentId)); - expect(after.agentStreamHead.get(agentId)).toBe(before.agentStreamHead.get(agentId)); - expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 5 }); - expect(after.initializingAgents.get(agentId)).toBe(lifetime === "newer"); - expect(settled).toBe(lifetime === "current"); - expect(getInitDeferred(key)).toBe(lifetime === "newer" ? current : undefined); - resolveInitDeferred(key); - initial?.resolve(); - viewed.dispose(); - }, - ); - - it("preserves display pagination without certifying unsequenced history", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - viewed.beginTimelineRequest(agentId, true)({ ...page(41, 80), direction: "tail" }); - viewed.enqueueStreamEvent(agentId, { - seq: undefined, - epoch: undefined, - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { - type: "assistant_message", - text: "unsequenced", - messageId: "legacy", - }, - }, - }); - viewed.flushStreamAgent(agentId); - const timeline = selectAgentTimelineState(useSessionStore.getState().sessions[host], agentId); - expect(timeline.status).toBe("synced"); - if (timeline.status !== "synced") throw new Error("Expected painted history"); - expect(timeline.range).toEqual({ epoch: "epoch-1", startSeq: 41, endSeq: 80 }); - expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 80 }); - let requested = false; - expect( - await loadOlderAgentHistory(agentId, { - client: { - fetchAgentTimeline: async (id, request) => { - expect(id).toBe(agentId); - expect(request.cursor).toEqual({ epoch: "epoch-1", seq: 41 }); - requested = true; - viewed.applyTimelineResponse({ ...page(1, 40), direction: "before", hasOlder: false }); - }, - }, - cursor: timeline.range ?? undefined, - hasOlder: timeline.older === "available", - isLoadingOlder: false, - setInFlight: () => undefined, - }), - ).toBe(true); - expect(requested).toBe(true); - expect(useSessionStore.getState().sessions[host]?.agentTimelineCursor.get(agentId)).toEqual({ - epoch: "epoch-1", - startSeq: 1, - endSeq: 80, - }); - live(viewed, 82); - viewed.flushStreamAgent(agentId); - await disk.flush(); - const reopened = await cache(rows).readTimeline(host, agentId); - expect(reopened?.range).toBeNull(); - expect(reopened?.items).toHaveLength(40); - expect(reopened?.items.some((row) => (row.source?.startSeq ?? 41) < 41)).toBe(false); - expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 80 }); - viewed.beginTimelineRequest(agentId, true)({ ...page(43, 82), direction: "tail" }); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual({ - epoch: "epoch-1", - startSeq: 43, - endSeq: 82, - }); - viewed.dispose(); - }); - - it.each(["live", "cached-live", "page", "empty"] as const)( - "invalidates old display positions on %s epoch/reset after unpositioned activity", - async (transition) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - if (transition === "cached-live") { - disk.commitTimeline(host, agentId, { - ...saved(), - items: saved().items.slice(40), - range: { epoch: "epoch-1", startSeq: 41, endSeq: 80 }, - hasOlder: true, - }); - await disk.flush(); - } - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - if (transition !== "cached-live") - viewed.beginTimelineRequest(agentId, true)({ ...page(41, 80), direction: "tail" }); - viewed.enqueueStreamEvent(agentId, { - seq: undefined, - epoch: undefined, - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", text: "unsequenced", messageId: "legacy" }, - }, - }); - viewed.flushStreamAgent(agentId); - if (transition === "live" || transition === "cached-live") { - viewed.enqueueStreamEvent(agentId, { - seq: 1, - epoch: "epoch-2", - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", text: "new epoch", messageId: "new" }, - }, - }); - viewed.flushStreamAgent(agentId); - } else { - const response = page(1, transition === "empty" ? 0 : 1, false, "epoch-2"); - viewed.applyTimelineResponse({ - ...response, - direction: "tail", - reset: true, - hasOlder: false, - ...(transition === "empty" ? { startCursor: null, endCursor: null } : {}), - }); - } - const range = useSessionStore.getState().sessions[host]?.agentTimelineCursor.get(agentId); - expect(range).toEqual( - transition === "page" || transition === "live" - ? { epoch: "epoch-2", startSeq: 1, endSeq: 1 } - : undefined, - ); - expect(useSessionStore.getState().sessions[host]?.agentTimelineHasOlder.get(agentId)).toBe( - false, - ); - await disk.flush(); - const restored = await cache(rows).readTimeline(host, agentId); - if (transition === "cached-live") { - // Until the new epoch is verified, keep the preceding durable baseline. - expect(restored?.items).toHaveLength(40); - expect(restored?.range).toEqual({ epoch: "epoch-1", startSeq: 41, endSeq: 80 }); - } else { - expect(restored?.items).toHaveLength(transition === "empty" ? 0 : 1); - expect(restored?.range).toEqual( - transition === "empty" ? null : { epoch: "epoch-2", startSeq: 1, endSeq: 1 }, - ); - } - viewed.dispose(); - }, - ); - - it("does not publish a non-timeline event that changes no displayed fact", async () => { - useSessionStore.getState().initializeSession(host, null); - const { replica, viewed } = owner(cache(new MemoryRows())); - await replica.prepare(agentId); - viewed.applyTimelineResponse({ ...page(1, 3), direction: "tail" }); - const before = useSessionStore.getState(); - let notifications = 0; - const unsubscribe = useSessionStore.subscribe(() => notifications++); - viewed.enqueueStreamEvent(agentId, { - seq: undefined, - epoch: undefined, - timestamp: new Date(timestamp), - event: { type: "turn_started", provider: "claude", turnId: "turn" }, - }); - viewed.flushStreamAgent(agentId); - expect(useSessionStore.getState()).toBe(before); - expect(notifications).toBe(0); - live(viewed, 4); - viewed.flushStreamAgent(agentId); - expect(notifications).toBe(1); - expect( - useSessionStore.getState().sessions[host]?.agentTimelineCursor.get(agentId)?.endSeq, - ).toBe(4); - unsubscribe(); - viewed.dispose(); - }); - - it.each([false, true])( - "refreshes display-only restarts for unsequenced activity (same message=%s)", - async (sameMessage) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - viewed.beginTimelineRequest(agentId, true)({ ...page(31, 40), direction: "tail" }); - viewed.applyTimelineResponse({ ...page(1, 30), direction: "before", hasOlder: false }); - for (let batch = 1; batch <= 3; batch++) { - viewed.enqueueStreamEvent(agentId, { - seq: undefined, - epoch: undefined, - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { - type: "assistant_message", - text: `legacy ${batch}`, - messageId: sameMessage ? "message-40" : `legacy-${batch}`, - }, - }, - }); - viewed.flushStreamAgent(agentId); - if (batch === 1) - viewed.applyTimelineResponse({ - ...page(1, 30), - direction: "before", - hasOlder: false, - }); - live(viewed, 40 + batch); - viewed.flushStreamAgent(agentId); - await disk.flush(); - const restored = await cache(rows).readTimeline(host, agentId); - expect(restored?.range).toBeNull(); - expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 40 + batch }); - expect( - restored?.items.some((row) => "text" in row && row.text.includes(`legacy ${batch}`)), - ).toBe(true); - expect( - restored?.items.some((row) => "text" in row && row.text === `row ${40 + batch}`), - ).toBe(true); - expect(restored?.items.some((row) => row.source && row.source.startSeq < 31)).toBe(false); - expect(restored!.items.length).toBeLessThanOrEqual(40); - } - viewed.beginTimelineRequest(agentId, true)({ ...page(31, 43), direction: "tail" }); - await disk.flush(); - const canonical = await cache(rows).readTimeline(host, agentId); - expect(canonical?.range).toEqual({ epoch: "epoch-1", startSeq: 31, endSeq: 43 }); - expect(canonical?.items.map((row) => ("text" in row ? row.text : row.kind))).toEqual( - Array.from({ length: 13 }, (_, i) => `row ${i + 31}`), - ); - // A subsequent positioned gap must keep that certificate through interruption. - live(viewed, 46); - viewed.flushStreamAgent(agentId); - viewed.applyTimelineResponse(page(44, 44, true)); - await disk.flush(); - const partial = await cache(rows).readTimeline(host, agentId); - expect(partial?.range).toEqual({ epoch: "epoch-1", startSeq: 31, endSeq: 44 }); - expect(partial?.items.some((row) => row.timelineCursor?.seq === 46)).toBe(false); - viewed.dispose(); - }, - ); - - it.each(["captured", "renamed", "commit", "deleted", "disposed", "host-removed"] as const)( - "settles a real cache restore across identity transfer: %s", - async (ordering) => { - const rows = new MemoryRows(); - const seed = cache(rows); - seed.commitTimeline(host, agentId, saved()); - await seed.flush(); - const started = Promise.withResolvers(); - const release = Promise.withResolvers(); - const readRows = rows.read.bind(rows); - let reads = 0; - rows.read = async (...args) => { - reads++; - if (reads !== 1) return readRows(...args); - const captured = ordering === "captured" ? await readRows(...args) : undefined; - started.resolve(); - await release.promise; - return captured ?? readRows(...args); - }; - useSessionStore.getState().initializeSession(host, null); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - const preparation = replica.prepare(agentId); - await started.promise; - disk.reconcileServerId(host, otherHost); - useSessionStore.getState().initializeSession(otherHost, null); - replica.reconcileServerId(otherHost); - useSessionStore.getState().clearSession(host); - await disk.flush(); - if (ordering === "commit") { - disk.commitTimeline(otherHost, agentId, { ...saved(), items: saved().items.slice(40) }); - } - if (ordering === "deleted") replica.remove(agentId, "deleted"); - if (ordering === "disposed") replica.dispose(); - if (ordering === "host-removed") disk.setHosts([]); - release.resolve(); - await preparation; - if (ordering === "deleted" || ordering === "disposed" || ordering === "host-removed") { - expect(replica.readCursor(agentId)).toBeUndefined(); - expect( - useSessionStore.getState().sessions[otherHost]?.agentStreamTail.get(agentId) ?? [], - ).toEqual([]); - expect(reads).toBe(1); - viewed.dispose(); - return; - } - expect(replica.prepare(agentId)).toBe(preparation); - expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 80 }); - expect(useSessionStore.getState().sessions[otherHost]?.agentStreamTail.get(agentId)).toEqual( - ordering === "commit" ? saved().items.slice(40) : saved().items, - ); - expect(reads).toBe(ordering === "commit" ? 1 : 2); - viewed.dispose(); - }, - ); - - it.each([false, true])( - "transfers live display and submissions with the owner while restore is pending=%s", - async (pending) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - let reads = 0; - const read = Promise.withResolvers(); - const { replica, viewed } = owner({ - readTimeline: () => { - reads++; - return read.promise; - }, - commitTimeline: (...args) => disk.commitTimeline(...args), - removeTimeline: (...args) => disk.removeTimeline(...args), - }); - const preparation = replica.prepare(agentId); - if (!pending) { - read.resolve(await disk.readTimeline(host, agentId)); - await preparation; - } - live(viewed, 81); - viewed.flushStreamAgent(agentId); - const message = createUserMessage({ - text: "pending send", - clientMessageId: "local", - timestamp: new Date(timestamp), - }); - replica.beginSubmission(agentId, message, true); - const before = useSessionStore.getState().sessions[host]!; - viewed.dispose(); - useSessionStore.getState().initializeSession(otherHost, null); - disk.reconcileServerId(host, otherHost); - replica.reconcileServerId(otherHost); - useSessionStore.getState().clearSession(host); - const transferred = useSessionStore.getState().sessions[otherHost]!; - expect(transferred.agentStreamTail.get(agentId)).toBe(before.agentStreamTail.get(agentId)); - expect(transferred.agentStreamHead.get(agentId)).toBe(before.agentStreamHead.get(agentId)); - expect(transferred.messageSubmissions.get(agentId)).toEqual( - before.messageSubmissions.get(agentId), - ); - if (pending) read.resolve(await disk.readTimeline(otherHost, agentId)); - await preparation; - expect(replica.prepare(agentId)).toBe(preparation); - expect(reads).toBe(pending ? 2 : 1); - expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 80 }); - const restored = useSessionStore.getState().sessions[otherHost]!; - const display = [ - ...restored.agentStreamTail.get(agentId)!, - ...restored.agentStreamHead.get(agentId)!, - ]; - expect( - display.some((row) => row.kind === "assistant_message" && row.messageId === "message-81"), - ).toBe(true); - expect( - display.some((row) => row.kind === "user_message" && row.clientMessageId === "local"), - ).toBe(true); - expect(useSessionStore.getState().sessions[host]).toBeUndefined(); - expect(replica.rejectSubmission(agentId, "local", true)).toBe("rejected"); - replica.dispose(); - }, - ); - - it("paints the real legacy page without resuming its unverified cursor, then replaces it canonically", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const id = legacyTimeline.agentId; - rows.rows.set(`${host}:timeline:${id}`, { - serverId: host, - kind: "timeline", - id, - payload: JSON.stringify(legacyTimeline), - }); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(id); - expect( - useSessionStore - .getState() - .sessions[host]?.agentStreamTail.get(id) - ?.map((row) => row.timelineCursor?.seq), - ).toEqual([34, 69, 1, 34, 35, 69]); - expect(replica.readCursor(id)).toBeUndefined(); - // Live activity cannot grant certification to the old page. - viewed.enqueueStreamEvent(id, { - seq: 70, - epoch: legacyTimeline.range.epoch, - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", messageId: "new", text: "new activity" }, - }, - }); - viewed.flushStreamAgent(id); - await disk.flush(); - expect((await cache(rows).readTimeline(host, id))?.range).toBeNull(); - - const requests: string[] = []; - const canonical = { - ...page(1, 70, false, legacyTimeline.range.epoch), - agentId: id, - direction: "tail" as const, - hasOlder: false, - entries: [ - legacyTimeline.items[2], - legacyTimeline.items[0], - legacyTimeline.items[4], - legacyTimeline.items[1], - ].map((row) => ({ - provider: "claude" as const, - timestamp: row.timestamp, - item: { - type: - row.kind === "user_message" - ? ("user_message" as const) - : ("assistant_message" as const), - text: row.text, - messageId: row.messageId, - }, - seqStart: row.timelineCursor.seq, - seqEnd: row.timelineCursor.seq, - sourceSeqRanges: [{ startSeq: row.timelineCursor.seq, endSeq: row.timelineCursor.seq }], - collapsed: [], - })), - }; - canonical.entries.push({ - provider: "claude", - timestamp, - item: { type: "assistant_message", text: "new activity", messageId: "new" }, - seqStart: 70, - seqEnd: 70, - sourceSeqRanges: [{ startSeq: 70, endSeq: 70 }], - collapsed: [], - }); - viewed.attachTransport({ - initialDeliveryMode: "legacy", - setSubscription: async () => undefined, - fetchPage: async (_id, request) => { - requests.push(request.direction); - viewed.beginTimelineRequest(id)(canonical); - return { hasNewer: false, endCursor: { epoch: canonical.epoch, seq: 70 } }; - }, - fetchLatestTail: async () => { - throw new Error("Unexpected extra tail"); - }, - reportError: (error) => { - throw error; - }, - schedule: () => () => undefined, - }); - viewed.registerVisibleAgentIds("workspace", [id]); - viewed.setConnected(true); - await vi.waitFor(() => expect(viewed.getAgentTimelineStatus(id)).toBe("ready")); - expect(requests).toEqual(["tail"]); - await disk.flush(); - const restored = await cache(rows).readTimeline(host, id); - expect(restored?.range).toEqual({ epoch: canonical.epoch, startSeq: 1, endSeq: 70 }); - expect(restored?.items.map((row) => row.timelineCursor?.seq)).toEqual([1, 34, 35, 69, 70]); - expect( - restored?.items.filter((row) => row.kind === "assistant_message").map((row) => row.text), - ).toEqual([legacyTimeline.items[0].text, legacyTimeline.items[1].text, "new activity"]); - viewed.dispose(); - }); - - it.each(["short", "reasoning", "empty"] as const)( - "keeps a short restart page independent of older browsing (%s)", - async (projection) => { - const snapshots: CachedTimeline[][] = []; - for (const browse of [false, true]) { - useSessionStore.getState().clearSession(host); - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - const tail = { ...page(31, 70), direction: "tail" as const }; - // Forty projected units hydrate to twenty rows: each three thoughts merge before an answer. - if (projection !== "short") { - tail.entries = tail.entries.map((entry, i) => { - if (i % 4 === 3) return entry; - const item: TimelineResponsePayload["entries"][number]["item"] = - projection === "reasoning" - ? { type: "reasoning", text: `thought ${i}` } - : { type: "assistant_message", text: "", messageId: `empty-${i}` }; - return { ...entry, item }; - }); - } else { - tail.entries = tail.entries.slice(30); - tail.startCursor = { epoch: "epoch-1", seq: 61 }; - } - viewed.beginTimelineRequest(agentId, true)(tail); - await disk.flush(); - const initial = await cache(rows).readTimeline(host, agentId); - expect(initial?.items).toHaveLength(projection === "reasoning" ? 20 : 10); - const olderEnd = tail.startCursor!.seq - 1; - if (browse) - viewed.applyTimelineResponse({ - ...page(1, olderEnd), - direction: "before", - hasOlder: false, - window: tail.window, - }); - await disk.flush(); - expect(await cache(rows).readTimeline(host, agentId)).toEqual(initial); - const results: CachedTimeline[] = []; - for (const end of [71, 75, 115]) { - const start = results.length === 0 ? 71 : results.at(-1)!.range!.endSeq + 1; - for (let seq = start; seq <= end; seq++) live(viewed, seq); - viewed.flushStreamAgent(agentId); - await disk.flush(); - const snapshot = await cache(rows).readTimeline(host, agentId); - expect(snapshot?.range).toMatchObject({ epoch: "epoch-1", endSeq: end }); - expect(snapshot?.items.length).toBe(Math.min(40, initial!.items.length + end - 70)); - expect(snapshot?.hasOlder).toBe(true); - results.push(snapshot!); - } - snapshots.push(results); - viewed.dispose(); - replica.dispose(); - useSessionStore.getState().clearSession(host); - useSessionStore.getState().initializeSession(host, null); - const reopened = owner(cache(rows)); - await reopened.replica.prepare(agentId); - expect(reopened.replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 115 }); - expect(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)).toEqual( - results[2].items, - ); - expect(results[2].range?.startSeq).toBe(76); - reopened.viewed.applyTimelineResponse({ ...page(36, 75), direction: "before" }); - expect( - useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)?.[0], - ).toMatchObject({ - messageId: "message-36", - text: "row 36", - }); - reopened.viewed.dispose(); - } - expect(snapshots[1]).toEqual(snapshots[0]); - }, - ); - - it.each([ - ["distinct", "old", "new", ["row 2", "row 3"], 2], - ["same", "same", "same", ["row 1row 2", "row 3"], 1], - ["idless", undefined, undefined, ["row 1row 2", "row 3"], 1], - ] as const)( - "persists complete %s messages at the retained prepend boundary", - async (_, olderId, newerId, texts, startSeq) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - const tail = { ...page(2, 2), direction: "tail" as const }; - tail.entries[0].item = { type: "assistant_message", text: "row 2", messageId: newerId }; - viewed.beginTimelineRequest(agentId, true)(tail); - const older = { - ...page(1, 1), - direction: "before" as const, - hasOlder: false, - window: tail.window, - }; - older.entries[0].item = { type: "assistant_message", text: "row 1", messageId: olderId }; - viewed.applyTimelineResponse(older); - live(viewed, 3); - viewed.flushStreamAgent(agentId); - await disk.flush(); - const persisted = await cache(rows).readTimeline(host, agentId); - expect( - persisted?.items.map((row) => (row.kind === "assistant_message" ? row.text : row.kind)), - ).toEqual(texts); - expect(persisted?.range).toEqual({ epoch: "epoch-1", startSeq, endSeq: 3 }); - expect(persisted?.items[0].source).toEqual( - startSeq === 1 - ? { - startSeq: 1, - chunks: [ - { seq: 1, offset: 0 }, - { seq: 2, offset: 5 }, - ], - } - : { startSeq: 2, chunks: [{ seq: 2, offset: 0 }] }, - ); - viewed.dispose(); - replica.dispose(); - useSessionStore.getState().clearSession(host); - useSessionStore.getState().initializeSession(host, null); - const reopened = owner(cache(rows)); - await reopened.replica.prepare(agentId); - expect(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)).toEqual( - persisted?.items, - ); - reopened.viewed.dispose(); - }, - ); - - it("bounds overlapping tool history to forty complete entries and retrieves earlier tools by source start", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - const response: TimelineResponsePayload = { ...page(1, 60), direction: "tail" }; - const tool = (start: number, end: number): TimelineResponsePayload["entries"][number] => ({ - provider: "claude", - item: { - type: "tool_call", - callId: `tool-${start}`, - name: "Bash", - status: "completed", - error: null, - detail: { type: "unknown", input: null, output: null }, - }, - timestamp, - seqStart: start, - seqEnd: end, - sourceSeqRanges: [ - { startSeq: start, endSeq: start }, - { startSeq: end, endSeq: end }, - ], - collapsed: ["tool_lifecycle"], - }); - for (let start = 1; start <= 20; start++) response.entries[start - 1] = tool(start, 81 - start); - response.window = { minSeq: 1, maxSeq: 80, nextSeq: 81 }; - response.endCursor = { epoch: "epoch-1", seq: 80 }; - viewed.applyTimelineResponse(response); - await disk.flush(); - const snapshot = await cache(rows).readTimeline(host, agentId); - expect(snapshot?.range).toEqual({ epoch: "epoch-1", startSeq: 21, endSeq: 80 }); - expect(snapshot?.items).toHaveLength(40); - expect(snapshot?.hasOlder).toBe(true); - // The last accepted event completed an excluded older tool. The retained page still - // resumes after 80, while older pagination uses source start 21, not completion order. - viewed.dispose(); - replica.dispose(); - useSessionStore.getState().clearSession(host); - useSessionStore.getState().initializeSession(host, null); - const reopenedDisk = cache(rows); - const reopened = owner(reopenedDisk); - await reopened.replica.prepare(agentId); - expect(reopened.replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 80 }); - expect(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)).toHaveLength( - 40, - ); - reopened.viewed.applyTimelineResponse({ - ...response, - direction: "before", - startCursor: { epoch: "epoch-1", seq: 1 }, - endCursor: { epoch: "epoch-1", seq: 20 }, - entries: response.entries.slice(0, 20), - hasOlder: false, - }); - const restored = useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId); - expect(restored).toHaveLength(60); - expect(restored?.[0]).toMatchObject({ - kind: "tool_call", - source: { startSeq: 1 }, - timelineCursor: { seq: 80 }, - }); - expect((await cache(rows).readTimeline(host, agentId))?.items).toHaveLength(40); - for (let seq = 81; seq <= 120; seq++) live(reopened.viewed, seq); - // A final page establishes sequencing after the restart; the overlaid events reconcile. - reopened.viewed.applyTimelineResponse(page(81, 120)); - reopened.viewed.flushStreamAgent(agentId); - await reopenedDisk.flush(); - const advanced = await cache(rows).readTimeline(host, agentId); - expect(advanced?.range).toEqual({ epoch: "epoch-1", startSeq: 81, endSeq: 120 }); - expect(advanced?.items).toHaveLength(40); - reopened.viewed.dispose(); - }); - - it.each([ - ["tail", true], - ["tail", false], - ["after", true], - ["after", false], - ] as const)( - "shares accepted markdown and following reductions (%s, positioned=%s)", - async (direction, positioned) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - if (direction === "after") - disk.commitTimeline(host, agentId, { - ...saved(), - items: saved().items.slice(0, 1), - range: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, - }); - await disk.flush(); - const restore = Promise.withResolvers(); - const { replica, viewed } = owner({ - readTimeline: async (...args) => { - await restore.promise; - return disk.readTimeline(...args); - }, - commitTimeline: (...args) => disk.commitTimeline(...args), - removeTimeline: (...args) => disk.removeTimeline(...args), - }); - const preparation = replica.prepare(agentId); - if (direction === "tail") { - restore.resolve(); - await preparation; - } - for (const [seq, text] of [ - [2, "para one\n\n"], - [3, "para two"], - ] as const) { - viewed.enqueueStreamEvent(agentId, { - seq: positioned ? seq : undefined, - epoch: positioned ? "epoch-1" : undefined, - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { - type: "assistant_message", - messageId: "markdown", - text, - }, - }, - }); - viewed.flushStreamAgent(agentId); - } - restore.resolve(); - await preparation; - viewed.applyTimelineResponse({ - ...page(2, 3), - direction, - entries: [ - { - ...page(2, 2).entries[0]!, - item: { - type: "assistant_message", - messageId: "markdown", - text: "para one\n\npara two", - }, - seqStart: 2, - seqEnd: 3, - sourceSeqRanges: [{ startSeq: 2, endSeq: 3 }], - }, - ], - }); - const display = () => { - const session = useSessionStore.getState().sessions[host]!; - return [ - ...(session.agentStreamTail.get(agentId) ?? []), - ...(session.agentStreamHead.get(agentId) ?? []), - ]; - }; - const accepted = await disk.readTimeline(host, agentId); - expect(accepted?.items).toStrictEqual(display()); - expect(accepted?.items.at(-1)).toBe(display().at(-1)); - expect(display().at(-1)?.source).toEqual({ startSeq: 2, chunks: [{ seq: 3, offset: 0 }] }); - const control = `${agentId}-control`; - await replica.prepare(control); - viewed.applyTimelineResponse({ ...page(1, 3), direction: "tail", agentId: control }); - function sendMeasured(id: string, seq: number): number { - let textReads = 0; - viewed.enqueueStreamEvent(id, { - seq, - epoch: "epoch-1", - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { - type: "assistant_message", - messageId: `message-${seq}`, - get text() { - textReads++; - return `row ${seq}`; - }, - }, - }, - }); - viewed.flushStreamAgent(id); - return textReads; - } - for (let seq = 4; seq <= 8; seq++) { - const singleReductionReads = sendMeasured(control, seq); - expect(singleReductionReads).toBeGreaterThan(0); - expect(sendMeasured(agentId, seq)).toBe(singleReductionReads); - expect((await disk.readTimeline(host, agentId))?.items.at(-1)).toBe(display().at(-1)); - } - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.items).toEqual(display()); - viewed.dispose(); - }, - ); - - it.each([true, false])( - "keeps a lagging paragraph separate only until covered (positioned=%s)", - async (positioned) => { - useSessionStore.getState().initializeSession(host, null); - const disk = cache(new MemoryRows()); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - for (const [seq, text] of [ - [1, "one\n\n"], - [2, "two"], - [3, " newer"], - ] as const) { - viewed.enqueueStreamEvent(agentId, { - seq: positioned ? seq : undefined, - epoch: positioned ? "epoch-1" : undefined, - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", messageId: "m", text }, - }, - }); - viewed.flushStreamAgent(agentId); - } - const partial = { - ...page(1, 2, true), - direction: "tail" as const, - entries: [ - { - ...page(1, 1).entries[0]!, - item: { type: "assistant_message" as const, messageId: "m", text: "one\n\ntwo" }, - seqEnd: 2, - sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }], - }, - ], - }; - viewed.applyTimelineResponse(partial); - const display = () => { - const session = useSessionStore.getState().sessions[host]!; - return [ - ...(session.agentStreamTail.get(agentId) ?? []), - ...(session.agentStreamHead.get(agentId) ?? []), - ]; - }; - expect(display().some((row) => "text" in row && row.text.includes(" newer"))).toBe(true); - expect((await disk.readTimeline(host, agentId))?.items).not.toEqual(display()); - viewed.applyTimelineResponse({ - ...page(1, 3), - direction: "tail", - entries: [ - { - ...partial.entries[0]!, - item: { type: "assistant_message", messageId: "m", text: "one\n\ntwo newer" }, - seqEnd: 3, - sourceSeqRanges: [{ startSeq: 1, endSeq: 3 }], - }, - ], - }); - expect(display().map((row) => ("text" in row ? row.text : row.kind))).toEqual([ - "one\n\ntwo newer", - ]); - expect((await disk.readTimeline(host, agentId))?.items[0]).toBe(display()[0]); - live(viewed, 4); - viewed.flushStreamAgent(agentId); - expect((await disk.readTimeline(host, agentId))?.items.at(-1)).toBe(display().at(-1)); - viewed.dispose(); - }, - ); - - it.each([true, false])( - "shares canonical same-message segments around a tool (positioned=%s)", - async (positioned) => { - useSessionStore.getState().initializeSession(host, null); - const disk = cache(new MemoryRows()); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - const tool = { - type: "tool_call" as const, - callId: "between", - name: "Read", - status: "running" as const, - error: null, - detail: { type: "read" as const, filePath: "/repo/file" }, - }; - const entries: TimelineResponsePayload["entries"] = [ - { - ...page(1, 1).entries[0]!, - item: { type: "assistant_message", messageId: "m", text: "one\n\ntwo" }, - seqEnd: 2, - sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }], - }, - { ...page(3, 3).entries[0]!, item: tool }, - { - ...page(4, 4).entries[0]!, - item: { type: "assistant_message", messageId: "m", text: "three\n\nfour" }, - seqEnd: 5, - sourceSeqRanges: [{ startSeq: 4, endSeq: 5 }], - }, - ]; - for (const entry of entries) { - viewed.enqueueStreamEvent(agentId, { - seq: positioned ? entry.seqEnd : undefined, - epoch: positioned ? "epoch-1" : undefined, - timestamp: new Date(timestamp), - event: { type: "timeline", provider: "claude", item: entry.item }, - }); - viewed.flushStreamAgent(agentId); - } - viewed.applyTimelineResponse({ ...page(1, 5), direction: "tail", entries }); - const session = useSessionStore.getState().sessions[host]!; - const display = [ - ...(session.agentStreamTail.get(agentId) ?? []), - ...(session.agentStreamHead.get(agentId) ?? []), - ]; - const accepted = (await disk.readTimeline(host, agentId))!.items; - expect(display.map((row) => ("text" in row ? row.text : row.kind))).toEqual([ - "one\n\ntwo", - "tool_call", - "three\n\nfour", - ]); - expect(new Set(display.map((row) => row.id)).size).toBe(3); - expect(accepted).toEqual(display); - for (let i = 0; i < display.length; i++) expect(accepted[i]).toBe(display[i]); - live(viewed, 6); - viewed.flushStreamAgent(agentId); - expect((await disk.readTimeline(host, agentId))?.items.at(-1)).toBe( - useSessionStore.getState().sessions[host]?.agentStreamHead.get(agentId)?.at(-1), - ); - viewed.dispose(); - }, - ); - - it.each([false, true])( - "shares accepted rows after synchronization and catch-up (overlay=%s)", - async (overlay) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - if (overlay) { - live(viewed, 3); - viewed.flushStreamAgent(agentId); - } - viewed.applyTimelineResponse({ ...page(1, 3), direction: "tail" }); - const display = () => [ - ...(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId) ?? []), - ...(useSessionStore.getState().sessions[host]?.agentStreamHead.get(agentId) ?? []), - ]; - const accepted = await disk.readTimeline(host, agentId); - expect(accepted?.items).toStrictEqual(display()); - expect(accepted?.items.at(-1)).toBe(display().at(-1)); - live(viewed, 4); - viewed.flushStreamAgent(agentId); - expect((await disk.readTimeline(host, agentId))?.items.at(-1)).toBe(display().at(-1)); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.items).toEqual(display()); - viewed.dispose(); - }, - ); - - it.each(["accept", "reject", "handoff"] as const)( - "keeps submission %s transitions shared through following batches and restart", - async (operation) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - viewed.applyTimelineResponse({ ...page(1, 3), direction: "tail" }); - const message = createUserMessage({ - clientMessageId: "submission", - text: "prompt", - timestamp: new Date(timestamp), - }); - if (operation === "handoff") replica.handoffSubmission(agentId, message); - else replica.beginSubmission(agentId, message, true); - if (operation === "reject") - expect(replica.rejectSubmission(agentId, "submission", true)).toBe("rejected"); - else { - replica.acceptSubmission(agentId, "submission"); - viewed.enqueueStreamEvent(agentId, { - seq: 4, - epoch: "epoch-1", - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { - type: "user_message", - text: "prompt", - messageId: "provider-message", - clientMessageId: "submission", - }, - }, - }); - viewed.flushStreamAgent(agentId); - } - const display = () => [ - ...(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId) ?? []), - ...(useSessionStore.getState().sessions[host]?.agentStreamHead.get(agentId) ?? []), - ]; - for (let seq = operation === "reject" ? 4 : 5; seq <= 14; seq++) { - live(viewed, seq); - viewed.flushStreamAgent(agentId); - expect((await disk.readTimeline(host, agentId))?.items.at(-1)).toBe(display().at(-1)); - } - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.items).toEqual(display()); - expect((await cache(rows).readTimeline(host, agentId))?.range?.endSeq).toBe(14); - viewed.dispose(); - }, - ); - - it("reunites a submission with verified history after a cache/live divergence", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - live(viewed, 83); - viewed.flushStreamAgent(agentId); - replica.beginSubmission( - agentId, - createUserMessage({ - clientMessageId: "during-gap", - text: "prompt", - timestamp: new Date(timestamp), - }), - true, - ); - replica.acceptSubmission(agentId, "during-gap"); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range?.endSeq).toBe(80); - const response = page(81, 83); - response.entries[1]!.item = { - type: "user_message", - text: "prompt", - messageId: "canonical", - clientMessageId: "during-gap", - }; - viewed.applyTimelineResponse(response); - for (let seq = 84; seq <= 86; seq++) { - live(viewed, seq); - viewed.flushStreamAgent(agentId); - const session = useSessionStore.getState().sessions[host]; - const display = [ - ...(session?.agentStreamTail.get(agentId) ?? []), - ...(session?.agentStreamHead.get(agentId) ?? []), - ]; - expect((await disk.readTimeline(host, agentId))?.items.at(-1)).toBe(display.at(-1)); - } - await disk.flush(); - const restored = await cache(rows).readTimeline(host, agentId); - expect(restored?.range?.endSeq).toBe(86); - expect(restored?.items.filter((item) => item.kind === "user_message")).toHaveLength(1); - viewed.dispose(); - }); - - it("replaces a requested window while preserving an unverified live overlay", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - const acceptTail = viewed.beginTimelineRequest(agentId, true); - live(viewed, 83); - viewed.flushStreamAgent(agentId); - const response = { ...page(81, 82), direction: "tail" as const }; - acceptTail(response); - expect(response.reset).toBe(false); - const session = useSessionStore.getState().sessions[host]; - expect( - [ - ...(session?.agentStreamTail.get(agentId) ?? []), - ...(session?.agentStreamHead.get(agentId) ?? []), - ].map((item) => item.id), - ).toEqual(["message-81", "message-82", "message-83"]); - await disk.flush(); - const restored = await cache(rows).readTimeline(host, agentId); - expect(restored?.items.map((item) => item.id)).toEqual(["message-81", "message-82"]); - expect(restored?.range).toEqual({ epoch: "epoch-1", startSeq: 81, endSeq: 82 }); - viewed.dispose(); - }); - - it("saves a moving latest page even when that page exceeds 512 KiB and older history is loaded", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - const latest = { ...page(161, 200), direction: "tail" as const }; - for (const entry of latest.entries) { - entry.item = { - type: "assistant_message", - messageId: `message-${entry.seqStart}`, - text: "x".repeat(20_000), - }; - } - viewed.applyTimelineResponse(latest); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual({ - epoch: "epoch-1", - startSeq: 161, - endSeq: 200, - }); - viewed.applyTimelineResponse({ ...page(1, 160), direction: "before", hasOlder: false }); - live(viewed, 201); - viewed.flushStreamAgent(agentId); - await disk.flush(); - const savedPage = await cache(rows).readTimeline(host, agentId); - expect(savedPage?.items).toHaveLength(40); - expect(savedPage?.items.at(-1)).toMatchObject({ text: "row 201" }); - expect(savedPage?.range).toEqual({ epoch: "epoch-1", startSeq: 162, endSeq: 201 }); - expect(savedPage?.hasOlder).toBe(true); - viewed.dispose(); - replica.dispose(); - useSessionStore.getState().clearSession(host); - useSessionStore.getState().initializeSession(host, null); - const reopenedDisk = cache(rows); - const reopened = owner(reopenedDisk); - await reopened.replica.prepare(agentId); - expect(reopened.replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 201 }); - expect(useSessionStore.getState().sessions[host]?.agentTimelineCursor.get(agentId)).toEqual({ - epoch: "epoch-1", - startSeq: 162, - endSeq: 201, - }); - reopened.viewed.applyTimelineResponse({ ...page(122, 161), direction: "before" }); - await reopenedDisk.flush(); - expect( - useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)?.[0], - ).toMatchObject({ text: "row 122" }); - expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual({ - epoch: "epoch-1", - startSeq: 162, - endSeq: 201, - }); - reopened.viewed.dispose(); - }); - - it("a live row painted before the resume keeps the resume bounded", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - const calls: string[] = []; - const replica = createTimelineReplica({ serverId: host, storage: disk }); - const viewed = createViewedTimelineOwner({ - serverId: host, - replica, - replaceDemandedAgentIds: () => undefined, - drainQueuedAgentMessage: () => undefined, - ports: { - initialDeliveryMode: "legacy", - setSubscription: async () => undefined, - fetchPage: async (_id, request) => { - const from = request.direction === "after" ? request.cursor.seq : 0; - calls.push(`page ${request.direction} ${from}`); - const end = from + 40; - const hasNewer = end < 600; - viewed.applyTimelineResponse({ - ...page(from + 1, end, hasNewer), - window: { minSeq: 1, maxSeq: 600, nextSeq: 601 }, - }); - return { hasNewer, endCursor: { epoch: "epoch-1", seq: end } }; - }, - fetchLatestTail: async () => { - calls.push("latest-tail"); - viewed.applyTimelineResponse({ ...page(561, 600), direction: "tail", reset: true }); - return { hasNewer: false, endCursor: { epoch: "epoch-1", seq: 600 } }; - }, - reportError: (error) => { - throw error; - }, - schedule: () => () => undefined, - }, - }); - try { - viewed.registerVisibleAgentIds("workspace", [agentId]); - await replica.prepare(agentId); - live(viewed, 590); - viewed.flushStreamAgent(agentId); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual(saved().range); - - viewed.setConnected(true); - await vi.waitFor(() => expect(viewed.getAgentTimelineStatus(agentId)).toBe("ready")); - expect(calls).toEqual(["page after 80", "latest-tail"]); - await disk.flush(); - const reopened = await cache(rows).readTimeline(host, agentId); - expect(reopened?.range).toEqual({ epoch: "epoch-1", startSeq: 561, endSeq: 600 }); - expect(reopened?.items.map((item) => ("text" in item ? item.text : null))).toEqual( - Array.from({ length: 40 }, (_, i) => `row ${561 + i}`), - ); - } finally { - viewed.dispose(); - } - }); - - it("invalidates a certified restart on an authoritative empty reset", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - viewed.applyTimelineResponse({ - ...page(1, 0, false, "epoch-2"), - direction: "tail", - reset: true, - startCursor: null, - endCursor: null, - hasOlder: false, - }); - await disk.flush(); - expect(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId) ?? []).toEqual( - [], - ); - expect(await cache(rows).readTimeline(host, agentId)).toMatchObject({ - agentId, - items: [], - range: null, - hasOlder: false, - }); - viewed.dispose(); - }); - - it.each(["cache-first", "live-first"])( - "%s keeps new-epoch activity above an old saved baseline", - async (ordering) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - const { replica, viewed } = owner({ - readTimeline: async (serverId, id) => { - const result = await disk.readTimeline(serverId, id); - await gate; - return result; - }, - commitTimeline: (...args) => disk.commitTimeline(...args), - removeTimeline: (...args) => disk.removeTimeline(...args), - }); - const preparation = replica.prepare(agentId); - if (ordering === "cache-first") { - release(); - await preparation; - } - viewed.enqueueStreamEvent(agentId, { - seq: 1, - epoch: "epoch-2", - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", messageId: "new", text: "new activity" }, - }, - }); - viewed.flushStreamAgent(agentId); - release(); - await preparation; - const session = useSessionStore.getState().sessions[host]; - expect( - [ - ...(session?.agentStreamTail.get(agentId) ?? []), - ...(session?.agentStreamHead.get(agentId) ?? []), - ].map((item) => ("text" in item ? item.text : null)), - ).toEqual(["new activity"]); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual(saved().range); - viewed.applyTimelineResponse({ ...page(1, 1, false, "epoch-2"), reset: true }); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range?.epoch).toBe("epoch-2"); - viewed.dispose(); - }, - ); - - it.each(["live-before-cache", "live-after-cache", "queued-before-page"])( - "%s preserves a certified restart after every catch-up transition", - async (ordering) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - const { replica, viewed } = owner({ - removeTimeline: () => undefined, - readTimeline: async (serverId, id) => { - const snapshot = await disk.readTimeline(serverId, id); - await gate; - return snapshot; - }, - commitTimeline: (serverId, id, value, requireCertified) => - disk.commitTimeline(serverId, id, value, requireCertified), - }); - const preparation = replica.prepare(agentId); - const restartAt = async (endSeq: number) => { - await disk.flush(); - const reopened = await cache(rows).readTimeline(host, agentId); - const startSeq = endSeq === 80 ? 1 : endSeq - 39; - expect(reopened?.range).toEqual({ epoch: "epoch-1", startSeq, endSeq }); - expect(reopened?.items.map((row) => row.timelineCursor?.seq)).toEqual( - Array.from({ length: endSeq - startSeq + 1 }, (_, i) => i + startSeq), - ); - }; - if (ordering === "live-before-cache") { - live(viewed, 83); - viewed.flushStreamAgent(agentId); - await restartAt(80); - } - release(); - await preparation; - await restartAt(80); - if (ordering !== "live-before-cache") live(viewed, 83); - if (ordering === "live-after-cache") viewed.flushStreamAgent(agentId); - await restartAt(80); - // Owner flushes the queued live batch before accepting the page. - viewed.applyTimelineResponse(page(81, 81, true)); - await restartAt(81); - viewed.applyTimelineResponse(page(82, 82)); - await restartAt(82); - viewed.applyTimelineResponse(page(83, 83)); - await restartAt(83); - const session = useSessionStore.getState().sessions[host]; - expect( - [ - ...(session?.agentStreamTail.get(agentId) ?? []), - ...(session?.agentStreamHead.get(agentId) ?? []), - ].map((row) => row.timelineCursor?.seq), - ).toEqual(Array.from({ length: 83 }, (_, i) => i + 1)); - viewed.dispose(); - replica.dispose(); - }, - ); - - it("settles local identity and attachments through the same publication and restart boundary", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - const message = createUserMessage({ - clientMessageId: "submission", - text: "local prompt", - timestamp: new Date(timestamp), - attachments: [{ type: "text", mimeType: "text/plain", text: "attachment" }], - images: [ - { - id: "image", - mimeType: "image/png", - storageType: "native-file", - storageKey: "/saved/image.png", - createdAt: 1, - }, - ], - }); - replica.beginSubmission(agentId, message, true); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.items).toHaveLength(80); - const canonical = page(81, 81); - canonical.entries[0].item = { - type: "user_message", - text: "canonical prompt", - messageId: "submission", - }; - viewed.applyTimelineResponse(canonical); - expect(replica.rejectSubmission(agentId, "submission", true)).toBe("accepted"); - await disk.flush(); - expect( - useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)?.at(-1), - ).toMatchObject({ - id: message.id, - text: "local prompt", - attachments: message.attachments, - }); - const restored = await cache(rows).readTimeline(host, agentId); - expect(restored?.range).toEqual({ epoch: "epoch-1", startSeq: 42, endSeq: 81 }); - expect(restored?.items.at(-1)).toMatchObject({ - attachments: message.attachments, - images: message.images, - }); - replica.beginSubmission( - agentId, - createUserMessage({ - clientMessageId: "rejected", - text: "rejected", - timestamp: new Date(timestamp), - }), - true, - ); - expect(replica.rejectSubmission(agentId, "rejected", true)).toBe("rejected"); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.items).toHaveLength(40); - viewed.dispose(); - }); - - it("ends removed work, accepts a fresh lifetime, and isolates the other host", async () => { - useSessionStore.getState().initializeSession(host, null); - useSessionStore.getState().initializeSession(otherHost, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - disk.commitTimeline(otherHost, agentId, saved()); - await disk.flush(); - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - const removed = owner({ - removeTimeline: (serverId, id) => disk.removeTimeline(serverId, id), - readTimeline: async () => { - await gate; - return saved(); - }, - commitTimeline: (serverId, id, value, requireCertified) => - disk.commitTimeline(serverId, id, value, requireCertified), - }); - const other = owner(disk, otherHost); - const pending = removed.replica.prepare(agentId); - live(removed.viewed, 81); - removed.replica.remove(agentId, "deleted"); - release(); - await pending; - removed.viewed.applyTimelineResponse(page(81, 81)); - removed.viewed.dispose(); - await disk.flush(); - expect(await cache(rows).readTimeline(host, agentId)).toBeUndefined(); - expect(useSessionStore.getState().sessions[host]?.agentStreamTail.has(agentId)).toBe(false); - expect(useSessionStore.getState().sessions[host]?.agentStreamHead.has(agentId)).toBe(false); - removed.replica.acceptAgent(agentId); - removed.viewed.applyTimelineResponse({ - ...page(1, 1, false, "epoch-2"), - direction: "tail", - reset: true, - }); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual({ - epoch: "epoch-2", - startSeq: 1, - endSeq: 1, - }); - await other.replica.prepare(agentId); - other.viewed.applyTimelineResponse(page(81, 81)); - await disk.flush(); - expect((await cache(rows).readTimeline(otherHost, agentId))?.range?.endSeq).toBe(81); - other.viewed.dispose(); - }); - - it("replaces an old epoch with a smaller verified epoch while retaining pending local submissions", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - const local = createUserMessage({ - clientMessageId: "pending", - text: "pending", - timestamp: new Date(timestamp), - }); - replica.beginSubmission(agentId, local, true); - viewed.applyTimelineResponse({ - ...page(1, 1, false, "epoch-2"), - direction: "tail", - reset: true, - hasOlder: false, - }); - await disk.flush(); - const reopened = await cache(rows).readTimeline(host, agentId); - expect(reopened?.range).toEqual({ epoch: "epoch-2", startSeq: 1, endSeq: 1 }); - expect(reopened?.items).toHaveLength(1); - expect(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)).toContainEqual( - local, - ); - viewed.dispose(); - }); - it("keeps pending submissions across a live epoch reset", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - viewed.applyTimelineResponse({ - ...page(1, 1), - direction: "tail", - reset: true, - hasOlder: false, - }); - const local = createUserMessage({ - clientMessageId: "pending", - text: "pending", - timestamp: new Date(timestamp), - }); - replica.beginSubmission(agentId, local, true); - replica.applyEvents( - agentId, - [ - { - seq: 1, - epoch: "epoch-2", - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", text: "new epoch", messageId: "new" }, - }, - }, - ], - () => undefined, - ); - const session = useSessionStore.getState().sessions[host]; - expect([ - ...(session?.agentStreamTail.get(agentId) ?? []), - ...(session?.agentStreamHead.get(agentId) ?? []), - ]).toContainEqual(local); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual({ - epoch: "epoch-2", - startSeq: 1, - endSeq: 1, - }); - viewed.dispose(); - }); - - it("a removal during publication wins over the transition's write-behind", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - let removed = false; - const stop = useSessionStore.subscribe((state) => { - if (!removed && state.sessions[host]?.agentTimelineCursor.has(agentId)) { - removed = true; - replica.remove(agentId, "deleted"); - } - }); - viewed.applyTimelineResponse({ ...page(1, 1), direction: "tail", reset: true }); - stop(); - await disk.flush(); - expect(await cache(rows).readTimeline(host, agentId)).toBeUndefined(); - expect(useSessionStore.getState().sessions[host]?.agentStreamTail.has(agentId)).toBe(false); - viewed.dispose(); - }); - - it("keeps a certified attachment-bearing replacement above an unread baseline", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - const { replica, viewed } = owner({ - removeTimeline: (serverId, id) => disk.removeTimeline(serverId, id), - readTimeline: async () => { - await gate; - return saved(); - }, - commitTimeline: (serverId, id, value, requireCertified) => - disk.commitTimeline(serverId, id, value, requireCertified), - }); - const pending = replica.prepare(agentId); - const local = createUserMessage({ - clientMessageId: "attachment", - text: "prompt", - timestamp: new Date(timestamp), - attachments: [{ type: "text", text: "data", mimeType: "text/plain" }], - }); - replica.beginSubmission(agentId, local, true); - const response = { ...page(81, 81), direction: "tail" as const, reset: true }; - response.entries[0].item = { type: "user_message", messageId: "attachment", text: "prompt" }; - viewed.applyTimelineResponse(response); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual({ - epoch: "epoch-1", - startSeq: 81, - endSeq: 81, - }); - release(); - await pending; - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.items.at(-1)).toMatchObject({ - attachments: local.attachments, - }); - viewed.dispose(); - }); -}); diff --git a/packages/app/src/timeline/replica.test.ts b/packages/app/src/timeline/replica.test.ts index c43548a385..e01b0f5ee6 100644 --- a/packages/app/src/timeline/replica.test.ts +++ b/packages/app/src/timeline/replica.test.ts @@ -1,90 +1,35 @@ +import { DatabaseSync } from "node:sqlite"; +import { ReplicaCache } from "@/runtime/replica-cache"; import { - createTimelineReplica, - type TimelineReplicaStorage, - type TimelineResponsePayload, - type TimelineReplica, -} from "./replica"; -import { afterEach, describe, expect, it, vi } from "vitest"; + createSqliteReplicaRowStore, + type ReplicaSqliteConnection, + type SqliteValue, +} from "@/runtime/replica-cache/row-store-sqlite"; +import { afterEach, describe, expect, it } from "vitest"; import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import type { CachedTimeline } from "@/runtime/replica-cache"; import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; import type { StreamItem } from "@/types/stream"; import { + createTimelineReplica, createViewedTimelineOwner, - type TimelinePageResult, + type TimelineReplicaStorage, type ViewedTimelineOwner, } from "./viewed-timeline-sync"; -import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan"; const SERVER_ID = "timeline-replica-host"; const AGENT_ID = "agent-1"; -function item(id: string, text: string, seq: number, messageId?: string): StreamItem { +function item(id: string, text: string, seq: number): StreamItem { return { kind: "assistant_message", id, - ...(messageId ? { messageId } : {}), text, timestamp: new Date("2026-08-26T10:00:00.000Z"), timelineCursor: { epoch: "epoch-1", seq }, }; } -function paintLive( - replica: TimelineReplica, - text: string, - seq: number, - messageId = `message-${seq}`, -): void { - replica.applyEvents( - AGENT_ID, - [ - { - seq, - epoch: "epoch-1", - timestamp: new Date("2026-08-26T10:00:00.000Z"), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", text, messageId }, - }, - }, - ], - () => undefined, - ); -} - -function pageAt(seq: number): TimelineResponsePayload { - return { - requestId: `network-${seq}`, - agentId: AGENT_ID, - agent: null, - direction: "tail", - projection: "projected", - reset: false, - epoch: "epoch-1", - staleCursor: false, - gap: false, - window: { minSeq: 1, maxSeq: seq, nextSeq: seq + 1 }, - startCursor: { epoch: "epoch-1", seq: 1 }, - endCursor: { epoch: "epoch-1", seq }, - entries: [ - { - provider: "claude", - item: { type: "assistant_message", text: "network", messageId: "network" }, - timestamp: "2026-08-26T10:00:00.000Z", - seqStart: 1, - seqEnd: seq, - sourceSeqRanges: [{ startSeq: 1, endSeq: seq }], - collapsed: [], - }, - ], - hasOlder: false, - hasNewer: false, - error: null, - }; -} - function cachedTimeline(): CachedTimeline { return { agentId: AGENT_ID, @@ -98,6 +43,7 @@ function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner { const replica = createTimelineReplica({ serverId: SERVER_ID, storage, + prepareAgent: async () => undefined, }); return createViewedTimelineOwner({ serverId: SERVER_ID, @@ -107,6 +53,7 @@ function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner { ports: { initialDeliveryMode: "legacy", setSubscription: async () => undefined, + readCursor: () => undefined, fetchPage: async () => ({ hasNewer: false, endCursor: null }), fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }), reportError: () => undefined, @@ -115,379 +62,21 @@ function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner { }); } +function applySynced(agentId: string, seq: number): void { + useSessionStore.getState().applyAgentTimelineResponseState(SERVER_ID, agentId, { + items: [item(`network-${agentId}`, "network", seq)], + head: [], + range: { epoch: "epoch-1", startSeq: 1, endSeq: seq }, + older: "available", + newer: false, + synchronized: true, + acknowledgedClientMessageIds: [], + }); +} + afterEach(() => useSessionStore.getState().clearSession(SERVER_ID)); describe("viewed timeline persistence", () => { - it("keeps verified history when a final page predates displayed live activity", async () => { - useSessionStore.getState().initializeSession(SERVER_ID, null); - let durable = cachedTimeline(); - const owner = createOwner({ - removeTimeline: () => undefined, - readTimeline: async () => durable, - commitTimeline: (_host, _agent, value) => { - durable = value; - }, - }); - owner.registerVisibleAgentIds("test", [AGENT_ID]); - await vi.waitFor(() => - expect( - selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID).status, - ).toBe("painted"), - ); - owner.enqueueStreamEvent(AGENT_ID, { - seq: 7, - epoch: "epoch-1", - timestamp: new Date("2026-09-06T10:00:00.000Z"), - event: { - type: "timeline", - provider: "claude", - item: { - type: "assistant_message", - text: "future live", - messageId: "future", - }, - }, - }); - owner.flushStreamAgent(AGENT_ID); - expect(durable.range).toEqual(cachedTimeline().range); - owner.applyTimelineResponse({ - requestId: "old-final", - agentId: AGENT_ID, - agent: null, - direction: "after", - projection: "projected", - reset: false, - epoch: "epoch-1", - staleCursor: false, - gap: false, - window: { minSeq: 1, maxSeq: 5, nextSeq: 6 }, - startCursor: { epoch: "epoch-1", seq: 5 }, - endCursor: { epoch: "epoch-1", seq: 5 }, - hasOlder: true, - hasNewer: false, - error: null, - entries: [ - { - provider: "claude", - item: { type: "assistant_message", text: "verified", messageId: "verified" }, - timestamp: "2026-09-06T10:00:00.000Z", - seqStart: 5, - seqEnd: 5, - sourceSeqRanges: [{ startSeq: 5, endSeq: 5 }], - collapsed: [], - }, - ], - }); - expect(durable.range).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 5 }); - expect(durable.items.map((row) => row.timelineCursor?.seq)).toEqual([4, 5]); - owner.dispose(); - }); - - it("recovers the final live response when an in-flight gap page predates turn completion", async () => { - useSessionStore.getState().initializeSession(SERVER_ID, null); - let durable: CachedTimeline | undefined; - const pending: Array<{ - request: ProjectedTimelineForwardFetchPlan; - respond(payload: TimelineResponsePayload): void; - }> = []; - const replica = createTimelineReplica({ - serverId: SERVER_ID, - storage: { - removeTimeline: () => undefined, - readTimeline: async () => undefined, - commitTimeline: (_serverId, _agentId, timeline) => { - durable = timeline; - }, - }, - }); - const owner = createViewedTimelineOwner({ - serverId: SERVER_ID, - replica, - replaceDemandedAgentIds: () => undefined, - drainQueuedAgentMessage: () => undefined, - ports: { - initialDeliveryMode: "legacy", - setSubscription: async () => undefined, - fetchPage: (_agentId, request) => - new Promise((resolve) => { - pending.push({ - request, - respond(payload) { - owner.applyTimelineResponse(payload); - resolve(payload); - }, - }); - }), - fetchLatestTail: async () => { - throw new Error("A contiguous forward gap does not require a reset"); - }, - reportError: (error) => { - throw error; - }, - schedule: () => () => undefined, - }, - }); - const page = (startSeq: number, endSeq: number, text: string): TimelineResponsePayload => ({ - requestId: `page-${endSeq}`, - agentId: AGENT_ID, - agent: null, - direction: "after", - projection: "projected", - epoch: "epoch-1", - reset: false, - staleCursor: false, - gap: false, - window: { minSeq: 1, maxSeq: endSeq, nextSeq: endSeq + 1 }, - startCursor: { epoch: "epoch-1", seq: startSeq }, - endCursor: { epoch: "epoch-1", seq: endSeq }, - hasOlder: true, - hasNewer: false, - entries: [ - { - provider: "claude", - item: { type: "assistant_message", text, messageId: `message-${endSeq}` }, - timestamp: "2026-09-06T13:06:04.431Z", - seqStart: startSeq, - seqEnd: endSeq, - sourceSeqRanges: [{ startSeq, endSeq }], - collapsed: [], - }, - ], - error: null, - }); - const live = (seq: number, text: string) => { - owner.enqueueStreamEvent(AGENT_ID, { - seq, - epoch: "epoch-1", - timestamp: new Date("2026-09-06T13:06:12.797Z"), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", text, messageId: `message-${seq}` }, - }, - }); - owner.flushStreamAgent(AGENT_ID); - }; - - try { - owner.applyTimelineResponse(pageAt(265)); - owner.setConnected(true); - owner.registerVisibleAgentIds("workspace", [AGENT_ID]); - await vi.waitFor(() => expect(pending).toHaveLength(1)); - expect(pending[0].request).toMatchObject({ direction: "after", cursor: { seq: 265 } }); - - // The server has already snapshotted through 276, but that response is still in transit. - live(276, "before final response"); - live(284, "final response"); - owner.enqueueStreamEvent(AGENT_ID, { - seq: undefined, - epoch: undefined, - timestamp: new Date("2026-09-06T13:06:12.804Z"), - event: { type: "turn_completed", provider: "claude" }, - }); - owner.flushStreamAgent(AGENT_ID); - expect(pending).toHaveLength(1); - pending[0].respond(page(266, 276, "before final response")); - - // No more live events arrive. The owner must fetch the known missing end itself. - await vi.waitFor(() => expect(pending).toHaveLength(2)); - expect(pending[1].request).toMatchObject({ direction: "after", cursor: { seq: 276 } }); - pending[1].respond(page(277, 284, "final response")); - await vi.waitFor(() => expect(owner.getAgentTimelineStatus(AGENT_ID)).toBe("ready")); - const session = useSessionStore.getState().sessions[SERVER_ID]; - expect([ - ...(session?.agentStreamTail.get(AGENT_ID) ?? []), - ...(session?.agentStreamHead.get(AGENT_ID) ?? []), - ]).toMatchObject([ - { text: "network" }, - { text: "before final response" }, - { text: "final response" }, - ]); - expect(durable?.range).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 284 }); - expect(durable?.items.at(-1)).toMatchObject({ text: "final response" }); - } finally { - owner.dispose(); - } - }); - - it("paints a display-only cache when live rows arrive during the disk read", async () => { - useSessionStore.getState().initializeSession(SERVER_ID, null); - let release!: (value: CachedTimeline) => void; - const read = new Promise((resolve) => { - release = resolve; - }); - const replica = createTimelineReplica({ - serverId: SERVER_ID, - storage: { - removeTimeline: () => undefined, - readTimeline: () => read, - commitTimeline: () => undefined, - }, - }); - const preparation = replica.prepare(AGENT_ID); - paintLive(replica, "live", 5); - const liveRows = useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID); - release({ ...cachedTimeline(), range: null }); - await preparation; - const session = useSessionStore.getState().sessions[SERVER_ID]; - expect(selectAgentTimelineState(session, AGENT_ID)).toEqual({ - status: "painted", - items: cachedTimeline().items, - }); - expect(session?.agentStreamHead.get(AGENT_ID)).toEqual(liveRows); - expect(replica.readCursor(AGENT_ID)).toBeUndefined(); - }); - - it("reconciles overlapping display-only rows without duplicating saved messages", async () => { - useSessionStore.getState().initializeSession(SERVER_ID, null); - const saved = [item("one", "first", 3), item("two", "second", 4)]; - const replica = createTimelineReplica({ - serverId: SERVER_ID, - storage: { - removeTimeline: () => undefined, - readTimeline: async () => ({ ...cachedTimeline(), items: saved, range: null }), - commitTimeline: () => undefined, - }, - }); - paintLive(replica, "first", 3, "one"); - paintLive(replica, "second", 4, "two"); - paintLive(replica, "live", 5, "live"); - const painted = useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID); - await replica.prepare(AGENT_ID); - const session = useSessionStore.getState().sessions[SERVER_ID]; - expect([ - ...(session?.agentStreamTail.get(AGENT_ID) ?? []), - ...(session?.agentStreamHead.get(AGENT_ID) ?? []), - ]).toEqual([saved[0], ...(painted?.slice(1) ?? [])]); - expect(replica.readCursor(AGENT_ID)).toBeUndefined(); - }); - - it("retains an assistant continuation while restoring display-only history", async () => { - useSessionStore.getState().initializeSession(SERVER_ID, null); - const replica = createTimelineReplica({ - serverId: SERVER_ID, - storage: { - removeTimeline: () => undefined, - readTimeline: async () => ({ - ...cachedTimeline(), - items: [item("cached", "cached", 4, "msg-1")], - range: null, - }), - commitTimeline: () => undefined, - }, - }); - // The stream resumed mid-message after the save, so the live row holds only the suffix. - paintLive(replica, " and still streaming", 5, "msg-1"); - await replica.prepare(AGENT_ID); - const session = useSessionStore.getState().sessions[SERVER_ID]; - expect([ - ...(session?.agentStreamTail.get(AGENT_ID) ?? []), - ...(session?.agentStreamHead.get(AGENT_ID) ?? []), - ]).toMatchObject([{ id: "msg-1", messageId: "msg-1", text: "cached and still streaming" }]); - expect(replica.readCursor(AGENT_ID)).toBeUndefined(); - }); - - it("reopens a bootstrapped window with tools in the order they first appeared", async () => { - useSessionStore.getState().initializeSession(SERVER_ID, null); - let durable: CachedTimeline | undefined; - const storage: TimelineReplicaStorage = { - removeTimeline: () => undefined, - readTimeline: async () => durable, - commitTimeline: (_serverId, _agentId, timeline) => { - durable = timeline; - }, - }; - const owner = createOwner(storage); - const tool = (callId: string, seqStart: number, seqEnd: number) => ({ - provider: "claude", - item: { - type: "tool_call" as const, - callId, - name: "Bash", - status: "completed" as const, - detail: { type: "unknown" as const, input: null, output: null }, - error: null, - }, - timestamp: "2026-08-26T10:00:00.000Z", - seqStart, - seqEnd, - sourceSeqRanges: [ - { startSeq: seqStart, endSeq: seqStart }, - { startSeq: seqEnd, endSeq: seqEnd }, - ], - collapsed: ["tool_lifecycle" as const], - }); - owner.applyTimelineResponse({ - requestId: "bootstrap", - agentId: AGENT_ID, - agent: null, - direction: "tail", - projection: "projected", - reset: true, - epoch: "epoch-1", - window: { minSeq: 1, maxSeq: 700, nextSeq: 701 }, - startCursor: { epoch: "epoch-1", seq: 675 }, - endCursor: { epoch: "epoch-1", seq: 700 }, - entries: [ - { - provider: "claude", - item: { type: "assistant_message", text: "Starting", messageId: "msg-a" }, - timestamp: "2026-08-26T10:00:00.000Z", - seqStart: 675, - seqEnd: 675, - sourceSeqRanges: [{ startSeq: 675, endSeq: 675 }], - collapsed: [], - }, - tool("first", 676, 700), - tool("second", 677, 690), - { - provider: "claude", - item: { type: "assistant_message", text: "Done", messageId: "msg-b" }, - timestamp: "2026-08-26T10:00:00.000Z", - seqStart: 695, - seqEnd: 695, - sourceSeqRanges: [{ startSeq: 695, endSeq: 695 }], - collapsed: [], - }, - ], - error: null, - hasNewer: false, - hasOlder: true, - staleCursor: false, - gap: false, - }); - const startsOf = (rows: StreamItem[]) => - rows.map((row) => `${row.kind}:${row.source?.startSeq}`); - const synced = selectAgentTimelineState( - useSessionStore.getState().sessions[SERVER_ID], - AGENT_ID, - ); - expect(synced.status === "synced" && startsOf(synced.items)).toEqual([ - "assistant_message:675", - "tool_call:676", - "tool_call:677", - "assistant_message:695", - ]); - expect(durable?.range).toEqual({ epoch: "epoch-1", startSeq: 675, endSeq: 700 }); - owner.dispose(); - useSessionStore.getState().clearSession(SERVER_ID); - useSessionStore.getState().initializeSession(SERVER_ID, null); - - const reopened = createTimelineReplica({ serverId: SERVER_ID, storage }); - await reopened.prepare(AGENT_ID); - const painted = selectAgentTimelineState( - useSessionStore.getState().sessions[SERVER_ID], - AGENT_ID, - ); - expect(painted.status === "painted" && startsOf(painted.items)).toEqual([ - "assistant_message:675", - "tool_call:676", - "tool_call:677", - "assistant_message:695", - ]); - expect(reopened.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 700 }); - }); - it("shares an in-flight cache preparation with the viewed owner", async () => { useSessionStore.getState().initializeSession(SERVER_ID, null); let release!: (value: CachedTimeline) => void; @@ -498,13 +87,13 @@ describe("viewed timeline persistence", () => { const replica = createTimelineReplica({ serverId: SERVER_ID, storage: { - removeTimeline: () => undefined, readTimeline: () => { reads += 1; return read; }, commitTimeline: () => undefined, }, + prepareAgent: async () => undefined, }); const routePreparation = replica.prepare(AGENT_ID); @@ -519,12 +108,11 @@ describe("viewed timeline persistence", () => { it("paints cached history without claiming authoritative synchronization", async () => { useSessionStore.getState().initializeSession(SERVER_ID, null); const owner = createOwner({ - removeTimeline: () => undefined, readTimeline: async () => cachedTimeline(), commitTimeline: () => undefined, }); - owner.registerVisibleAgentIds("test", [AGENT_ID]); + owner.replaceVisibleAgentIds("test", [AGENT_ID]); await expect .poll(() => @@ -539,7 +127,6 @@ describe("viewed timeline persistence", () => { const partial = "```mermaid\nflowchart LR\n Start --> Mid"; const complete = `${partial}dle\n Middle --> Done\n\`\`\``; const owner = createOwner({ - removeTimeline: () => undefined, readTimeline: async () => ({ agentId: AGENT_ID, items: [item("cached", partial, 4)], @@ -549,7 +136,7 @@ describe("viewed timeline persistence", () => { commitTimeline: () => undefined, }); - owner.registerVisibleAgentIds("test", [AGENT_ID]); + owner.replaceVisibleAgentIds("test", [AGENT_ID]); await expect .poll(() => selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), @@ -593,114 +180,18 @@ describe("viewed timeline persistence", () => { owner.dispose(); }); - it("retains the certified baseline across a close during live catch-up", async () => { + it("reopens painted live mutations without persisting authoritative coverage", async () => { useSessionStore.getState().initializeSession(SERVER_ID, null); - const savedItems = Array.from({ length: 80 }, (_, index) => - item(`saved-${index + 1}`, `saved ${index + 1}`, index + 1, `msg-${index + 1}`), - ); - let durable: CachedTimeline = { - agentId: AGENT_ID, - items: savedItems, - range: { epoch: "epoch-1", startSeq: 1, endSeq: 80 }, - hasOlder: false, - }; - const storage: TimelineReplicaStorage = { - removeTimeline: () => undefined, - readTimeline: async () => durable, - commitTimeline: (_serverId, _agentId, timeline) => { - durable = timeline; - }, - }; - const first = createOwner(storage); - first.registerVisibleAgentIds("test", [AGENT_ID]); - await expect - .poll(() => - selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), - ) - .toMatchObject({ status: "painted" }); - - // The app receives new live output before its delayed catch-up page fills seq 81. - first.enqueueStreamEvent(AGENT_ID, { - event: { - type: "timeline", - provider: "codex", - item: { type: "assistant_message", text: "live suffix", messageId: "live" }, - } as AgentStreamEventPayload, - seq: 82, - epoch: "epoch-1", - timestamp: new Date("2026-08-26T10:00:01.000Z"), - }); - first.flushStreamAgent(AGENT_ID); - first.dispose(); - useSessionStore.getState().clearSession(SERVER_ID); - useSessionStore.getState().initializeSession(SERVER_ID, null); - - const reopened = createTimelineReplica({ serverId: SERVER_ID, storage }); - await reopened.prepare(AGENT_ID); - - // Saved coverage survives process death; unseen seq 81 is still fetched after 80. - expect(reopened.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 80 }); - const painted = selectAgentTimelineState( - useSessionStore.getState().sessions[SERVER_ID], - AGENT_ID, - ); - expect(painted).toMatchObject({ status: "painted" }); - expect(painted.status === "painted" && painted.items.map((entry) => entry.id)).toEqual( - expect.arrayContaining(savedItems.map((entry) => entry.id)), - ); - }); - - it("keeps the certified snapshot while painted live mutations wait for catch-up", async () => { - useSessionStore.getState().initializeSession(SERVER_ID, null); - const commits: CachedTimeline[] = []; - const storage: TimelineReplicaStorage = { - removeTimeline: () => undefined, - readTimeline: async () => cachedTimeline(), - commitTimeline: (_serverId, _agentId, timeline) => { - commits.push(timeline); - }, - }; - const first = createOwner(storage); - first.registerVisibleAgentIds("test", [AGENT_ID]); - await expect - .poll(() => - selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), - ) - .toMatchObject({ status: "painted" }); - - first.enqueueStreamEvent(AGENT_ID, { - event: { - type: "timeline", - provider: "codex", - item: { type: "assistant_message", text: "live", messageId: "live" }, - } as AgentStreamEventPayload, - seq: 5, - epoch: "epoch-1", - timestamp: new Date("2026-08-26T10:00:01.000Z"), - }); - first.flushStreamAgent(AGENT_ID); - - // The live row is painted but never certified, and the saved certificate is untouched. - expect( - useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID), - ).toMatchObject([{ text: "live" }]); - expect(commits).toEqual([]); - first.dispose(); - }); - - it("reopens painted live mutations of a display-only cache without certifying them", async () => { - useSessionStore.getState().initializeSession(SERVER_ID, null); - let durable: CachedTimeline | undefined = { ...cachedTimeline(), range: null }; + let durable: CachedTimeline | undefined = cachedTimeline(); let pending: CachedTimeline | undefined; const storage: TimelineReplicaStorage = { - removeTimeline: () => undefined, readTimeline: async () => durable, commitTimeline: (_serverId, _agentId, timeline) => { pending = timeline; }, }; const first = createOwner(storage); - first.registerVisibleAgentIds("test", [AGENT_ID]); + first.replaceVisibleAgentIds("test", [AGENT_ID]); await expect .poll(() => selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), @@ -727,7 +218,7 @@ describe("viewed timeline persistence", () => { useSessionStore.getState().initializeSession(SERVER_ID, null); const reopened = createOwner(storage); - reopened.registerVisibleAgentIds("test", [AGENT_ID]); + reopened.replaceVisibleAgentIds("test", [AGENT_ID]); await expect .poll(() => selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), @@ -742,74 +233,6 @@ describe("viewed timeline persistence", () => { reopened.dispose(); }); - it.each(["epoch-1", "obsolete-epoch"])( - "reconciles live rows from %s when display-only history finishes catch-up", - async (liveEpoch) => { - useSessionStore.getState().initializeSession(SERVER_ID, null); - const owner = createOwner({ - removeTimeline: () => undefined, - readTimeline: async () => ({ ...cachedTimeline(), range: null }), - commitTimeline: () => undefined, - }); - owner.registerVisibleAgentIds("test", [AGENT_ID]); - await expect - .poll( - () => - selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID) - .status, - ) - .toBe("painted"); - owner.enqueueStreamEvent(AGENT_ID, { - seq: 6, - epoch: liveEpoch, - timestamp: new Date("2026-08-26T10:00:00.000Z"), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", text: "live after the snapshot", messageId: "live" }, - }, - }); - owner.flushStreamAgent(AGENT_ID); - const liveHead = useSessionStore - .getState() - .sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID); - owner.applyTimelineResponse({ - requestId: "display-only-catch-up", - agentId: AGENT_ID, - agent: null, - direction: "tail", - projection: "projected", - reset: true, - epoch: "epoch-1", - window: { minSeq: 1, maxSeq: 5, nextSeq: 6 }, - startCursor: { epoch: "epoch-1", seq: 5 }, - endCursor: { epoch: "epoch-1", seq: 5 }, - entries: [ - { - provider: "mock", - item: { type: "assistant_message", text: "canonical snapshot" }, - timestamp: "2026-08-26T10:00:00.000Z", - seqStart: 5, - seqEnd: 5, - sourceSeqRanges: [{ startSeq: 5, endSeq: 5 }], - collapsed: [], - }, - ], - error: null, - hasNewer: false, - hasOlder: true, - staleCursor: false, - gap: false, - }); - const session = useSessionStore.getState().sessions[SERVER_ID]; - expect(selectAgentTimelineState(session, AGENT_ID).status).toBe("synced"); - expect(session?.agentStreamHead.get(AGENT_ID) ?? []).toEqual( - liveEpoch === "epoch-1" ? liveHead : [], - ); - owner.dispose(); - }, - ); - it("does not let a late cache read overwrite newer network state", async () => { useSessionStore.getState().initializeSession(SERVER_ID, null); let release!: (value: CachedTimeline) => void; @@ -817,13 +240,12 @@ describe("viewed timeline persistence", () => { release = resolve; }); const owner = createOwner({ - removeTimeline: () => undefined, readTimeline: () => read, commitTimeline: () => undefined, }); - owner.registerVisibleAgentIds("test", [AGENT_ID]); - owner.applyTimelineResponse(pageAt(8)); + owner.replaceVisibleAgentIds("test", [AGENT_ID]); + applySynced(AGENT_ID, 8); release(cachedTimeline()); await expect @@ -843,15 +265,16 @@ describe("viewed timeline persistence", () => { const replica = createTimelineReplica({ serverId: SERVER_ID, storage: { - removeTimeline: () => undefined, readTimeline: () => read, commitTimeline: () => undefined, }, + prepareAgent: async () => undefined, }); const preparation = replica.prepare(AGENT_ID); - paintLive(replica, "live", 5); - const liveRows = useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID); + useSessionStore.getState().setAgentStreamState(SERVER_ID, AGENT_ID, { + head: [item("live", "live", 5)], + }); release(cachedTimeline()); await preparation; @@ -859,9 +282,9 @@ describe("viewed timeline persistence", () => { expect( selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), ).toEqual({ status: "painted", items: cachedTimeline().items }); - expect(useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID)).toEqual( - liveRows, - ); + expect(useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID)).toEqual([ + item("live", "live", 5), + ]); }); it("reconciles a live head that overlaps the cached canonical timeline", async () => { @@ -873,23 +296,16 @@ describe("viewed timeline persistence", () => { const replica = createTimelineReplica({ serverId: SERVER_ID, storage: { - removeTimeline: () => undefined, readTimeline: () => read, commitTimeline: () => undefined, }, + prepareAgent: async () => undefined, }); const preparation = replica.prepare(AGENT_ID); - paintLive(replica, "cached", 4); - const cachedLive = useSessionStore - .getState() - .sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID) - ?.at(-1); - paintLive(replica, "live", 5); - const live = useSessionStore - .getState() - .sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID) - ?.at(-1); + useSessionStore.getState().setAgentStreamState(SERVER_ID, AGENT_ID, { + head: [item("cached", "cached", 4), item("live", "live", 5)], + }); release(cachedTimeline()); await preparation; @@ -897,7 +313,7 @@ describe("viewed timeline persistence", () => { expect([ ...(session?.agentStreamTail.get(AGENT_ID) ?? []), ...(session?.agentStreamHead.get(AGENT_ID) ?? []), - ]).toEqual([cachedLive, live]); + ]).toEqual([item("cached", "cached", 4), item("live", "live", 5)]); expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 }); }); @@ -910,18 +326,22 @@ describe("viewed timeline persistence", () => { const replica = createTimelineReplica({ serverId: SERVER_ID, storage: { - removeTimeline: () => undefined, readTimeline: () => read, commitTimeline: () => undefined, }, + prepareAgent: async () => undefined, }); const preparation = replica.prepare(AGENT_ID); - paintLive(replica, "live", 5); - const live = useSessionStore - .getState() - .sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID) - ?.at(-1); + useSessionStore.getState().applyAgentTimelineResponseState(SERVER_ID, AGENT_ID, { + items: [item("live", "live", 5)], + head: [], + range: null, + older: "none", + newer: false, + synchronized: false, + acknowledgedClientMessageIds: [], + }); release(cachedTimeline()); await preparation; @@ -929,20 +349,18 @@ describe("viewed timeline persistence", () => { expect([ ...(session?.agentStreamTail.get(AGENT_ID) ?? []), ...(session?.agentStreamHead.get(AGENT_ID) ?? []), - ]).toEqual([item("cached", "cached", 4), live]); + ]).toEqual([item("cached", "cached", 4), item("live", "live", 5)]); expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 }); }); it("persists accepted live stream commits through the owner", () => { useSessionStore.getState().initializeSession(SERVER_ID, null); + applySynced(AGENT_ID, 8); const commits: CachedTimeline[] = []; const owner = createOwner({ - removeTimeline: () => undefined, readTimeline: async () => undefined, commitTimeline: (_serverId, _agentId, timeline) => commits.push(timeline), }); - owner.applyTimelineResponse(pageAt(8)); - commits.length = 0; owner.enqueueStreamEvent(AGENT_ID, { event: { type: "timeline", @@ -964,7 +382,6 @@ describe("viewed timeline persistence", () => { useSessionStore.getState().initializeSession(SERVER_ID, null); const keys: string[] = []; const owner = createOwner({ - removeTimeline: () => undefined, readTimeline: async () => undefined, commitTimeline: (_serverId, agentId) => keys.push(agentId), }); @@ -999,14 +416,12 @@ describe("viewed timeline persistence", () => { useSessionStore.getState().initializeSession(SERVER_ID, null); const keys: string[] = []; const owner = createOwner({ - removeTimeline: () => undefined, readTimeline: async () => undefined, commitTimeline: (_serverId, agentId) => keys.push(agentId), }); - owner.applyTimelineResponse(pageAt(8)); - owner.applyTimelineResponse({ ...pageAt(3), agentId: "agent-2" }); - keys.length = 0; + applySynced(AGENT_ID, 8); + applySynced("agent-2", 3); for (const [agentId, seq] of [ [AGENT_ID, 9], ["agent-2", 4], @@ -1028,3 +443,112 @@ describe("viewed timeline persistence", () => { owner.dispose(); }); }); + +function createSqliteCache() { + const database = new DatabaseSync(":memory:"); + let beforeRead = async () => {}; + const connection: ReplicaSqliteConnection = { + async exec(sql) { + database.exec(sql); + }, + async run(sql, params = []) { + database.prepare(sql).run(...params); + }, + async all(sql: string, params: readonly SqliteValue[] = []) { + const rows = database.prepare(sql).all(...params) as Row[]; + if (sql.includes("FROM rows") && sql.includes("WHERE")) await beforeRead(); + return rows; + }, + async transaction(operation) { + database.exec("BEGIN IMMEDIATE"); + try { + await operation(connection); + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } + }, + }; + const storage = createSqliteReplicaRowStore({ open: async () => connection }, 1); + const cache = new ReplicaCache(storage, { clearLegacyCache: async () => {} }); + return { + cache, + database, + holdRead: (operation: () => Promise) => { + beforeRead = operation; + }, + }; +} + +describe("SQLite baseline restoration", () => { + it.each([false, true])( + "keeps display-only cached history under a live event (synced=%s)", + async (synced) => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const { cache, database, holdRead } = createSqliteCache(); + cache.setHosts([SERVER_ID]); + cache.commitTimeline(SERVER_ID, AGENT_ID, { + ...cachedTimeline(), + items: [item("earlier", "earlier", 1), ...cachedTimeline().items], + range: null, + }); + await cache.flush(); + holdRead(async () => { + if (synced) applySynced(AGENT_ID, 8); + else + useSessionStore.getState().setAgentStreamState(SERVER_ID, AGENT_ID, { + head: [item("cached", "cached", 4), item("live", "live", 5)], + }); + }); + const replica = createTimelineReplica({ + serverId: SERVER_ID, + storage: cache, + prepareAgent: async () => {}, + }); + await replica.prepare(AGENT_ID); + const session = useSessionStore.getState().sessions[SERVER_ID]; + const timeline = selectAgentTimelineState(session, AGENT_ID); + expect(timeline.status).toBe(synced ? "synced" : "painted"); + expect([ + ...(timeline.status === "cold" ? [] : timeline.items), + ...(session?.agentStreamHead.get(AGENT_ID) ?? []), + ]).toEqual( + synced + ? [item(`network-${AGENT_ID}`, "network", 8)] + : [item("earlier", "earlier", 1), item("cached", "cached", 4), item("live", "live", 5)], + ); + expect(replica.readCursor(AGENT_ID)).toBeUndefined(); + database.close(); + }, + ); + + it("starts the timeline disk read while agent preparation is pending", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const { cache, database, holdRead } = createSqliteCache(); + cache.setHosts([SERVER_ID]); + cache.commitTimeline(SERVER_ID, AGENT_ID, cachedTimeline()); + await cache.flush(); + let readStarted = false; + let release!: () => void; + const agentReady = new Promise((resolve) => { + release = resolve; + }); + holdRead(async () => { + readStarted = true; + }); + const replica = createTimelineReplica({ + serverId: SERVER_ID, + storage: cache, + prepareAgent: () => agentReady, + }); + const preparation = replica.prepare(AGENT_ID); + try { + await expect.poll(() => readStarted, { timeout: 500 }).toBe(true); + } finally { + release(); + await preparation; + database.close(); + } + }); +}); diff --git a/packages/app/src/timeline/replica.ts b/packages/app/src/timeline/replica.ts deleted file mode 100644 index b4ecb4ed43..0000000000 --- a/packages/app/src/timeline/replica.ts +++ /dev/null @@ -1,752 +0,0 @@ -import type { CachedTimeline } from "@/runtime/replica-cache"; -import equal from "fast-deep-equal"; -import type { AgentRemovalReason } from "@/utils/agent-directory-sync"; -import { useSessionStore, type AgentTimelineCursorState } from "@/stores/session-store"; -import { useCreateFlowStore } from "@/stores/create-flow-store"; -import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; -import { - acceptMessageSubmission, - beginMessageSubmission, - getSendingClientMessageIds, - observeMessageSubmissionCanonical, - rejectMessageSubmission, - type MessageSubmissionRecord, - type MessageSubmissionRejectionOutcome, -} from "@/composer/submission/model"; -import { - appendSubmittedUserMessage, - handoffCreatedAgentUserMessageToStream, - removeSubmittedUserMessage, - replaceWithCanonicalStream, - isUnreconciledLocalUserMessage, - type StreamItem, - type UserMessageItem, -} from "@/types/stream"; -import { - getInitDeferred, - getInitKey, - rejectInitDeferred, - resolveInitDeferred, -} from "@/utils/agent-initialization"; -import { - processAgentStreamEvents, - processTimelineResponse, - processTimelineResponseCompletion, - type AgentStreamReducerEvent, - type AgentStreamReducerSnapshot, - type ProcessTimelineResponseOutput, -} from "./session-stream-reducers"; -import { isTimelineResumeSnapshotAuthoritative } from "./timeline-sync-plan"; -import { TIMELINE_FETCH_PAGE_SIZE } from "./timeline-fetch-policy"; - -export type TimelineResponsePayload = Extract< - SessionOutboundMessage, - { type: "fetch_agent_timeline_response" } ->["payload"]; - -export interface TimelinePageRequest { - cursor: { epoch: string; endSeq: number } | null; - replaceTail: boolean; - initialization: Promise | undefined; -} - -export interface TimelineReplicaStorage { - removeTimeline(serverId: string, agentId: string): void; - readTimeline(serverId: string, agentId: string): Promise; - commitTimeline( - serverId: string, - agentId: string, - timeline: CachedTimeline, - requireCertified?: boolean, - ): void; -} - -interface TimelineRows { - tail: StreamItem[]; - head: StreamItem[]; -} - -interface VerifiedTimeline extends TimelineRows { - range: AgentTimelineCursorState | null; - hasOlder: boolean; -} - -interface AgentTimeline extends TimelineRows { - // Pagination and coverage certainty do not change which older entries belong to the restart page. - restartStartSeq: number | null; - verified: VerifiedTimeline | null; - // Loaded positions support navigation even when the displayed content is uncertifiable. - displayRange: AgentTimelineCursorState | null; - older: boolean; - newer: boolean; - synchronized: boolean; - hasUnsequencedHistory: boolean; - submissions: MessageSubmissionRecord[]; - preparation?: Promise; - restored: boolean; - liveEpoch?: string; -} - -interface PageEffects { - recoverGap( - agentId: string, - cursor: { epoch: string; endSeq: number }, - observedSeq?: number, - ): void; - drainQueuedAgentMessage(agentId: string): void; -} - -function acceptLiveDisplayEpoch( - state: AgentTimeline, - events: AgentStreamReducerEvent[], -): AgentStreamReducerEvent[] { - if (state.synchronized) return events; - // A connection's live epoch owns the display even while its first page is pending. The saved - // epoch remains a restart baseline until a page establishes a replacement. - const epoch = events.findLast((event) => event.event.type === "timeline" && event.epoch)?.epoch; - if (!epoch) return events; - const previousEpoch = state.liveEpoch ?? state.displayRange?.epoch; - if (previousEpoch && previousEpoch !== epoch) { - state.tail = state.tail.filter( - (item) => item.kind === "user_message" && isUnreconciledLocalUserMessage(item), - ); - state.head = state.head.filter( - (item) => item.kind === "user_message" && isUnreconciledLocalUserMessage(item), - ); - state.older = false; - state.displayRange = null; - state.restartStartSeq = null; - state.hasUnsequencedHistory = false; - } - state.liveEpoch = epoch; - return events.filter((event) => !event.epoch || event.epoch === epoch); -} - -function applyVerifiedStreamEvents( - state: AgentTimeline, - events: AgentStreamReducerEvent[], - display: ReturnType, -): boolean { - const previous = state.verified; - const positioned = events.filter( - ({ event, seq, epoch }) => - event.type !== "timeline" || (typeof seq === "number" && typeof epoch === "string"), - ); - const unsequencedChanged = - positioned.length !== events.length && (display.changedTail || display.changedHead); - if (unsequencedChanged) state.hasUnsequencedHistory = true; - if (!previous || !state.synchronized) return unsequencedChanged; - const accepted = - positioned.length === events.length && - state.tail === previous.tail && - state.head === previous.head - ? display - : processAgentStreamEvents({ - events: positioned, - currentTail: previous.tail, - currentHead: previous.head, - currentCursor: previous.range ?? undefined, - hasAuthoritativeBaseline: true, - isDetached: state.newer, - }); - const cursor = accepted.cursor; - const changedEpoch = - cursor !== null && previous.range !== null && cursor.epoch !== previous.range.epoch; - if (changedEpoch) { - Object.assign(display, retainPendingSubmissions(display, state)); - if (accepted !== display) Object.assign(accepted, retainPendingSubmissions(accepted, state)); - state.older = false; - state.hasUnsequencedHistory = unsequencedChanged; - state.restartStartSeq = cursor.startSeq; - } - state.verified = { - tail: accepted.tail, - head: accepted.head, - range: accepted.cursorChanged ? cursor : previous.range, - hasOlder: changedEpoch ? false : previous.hasOlder, - }; - return ( - unsequencedChanged || accepted.changedTail || accepted.changedHead || accepted.cursorChanged - ); -} - -function applySubmissionRows( - state: AgentTimeline, - transition: (rows: TimelineRows) => TimelineRows, -): boolean { - const verified = state.verified; - const shared = verified && state.tail === verified.tail && state.head === verified.head; - const result = transition(state); - const changed = result.tail !== state.tail || result.head !== state.head; - if (verified) { - const accepted = shared ? result : transition(verified); - verified.tail = accepted.tail; - verified.head = accepted.head; - } - state.tail = result.tail; - state.head = result.head; - return changed; -} - -function isSupersededTail( - payload: TimelineResponsePayload, - current: AgentTimelineCursorState | null | undefined, - requestedFrom: { epoch: string; endSeq: number } | null | undefined, -): boolean { - if (payload.direction !== "tail" || payload.reset || payload.mergeWindow || payload.error) - return false; - // A head already behind the request's accepted cursor demonstrates a rewind. A head - // passed by activity accepted during that request is an obsolete reply instead. - return ( - requestedFrom !== undefined && - (requestedFrom === null || - requestedFrom.epoch !== payload.epoch || - payload.window.maxSeq >= requestedFrom.endSeq) && - current?.epoch === payload.epoch && - payload.window.maxSeq < current.endSeq - ); -} - -function invalidatesDurableBaseline( - payload: TimelineResponsePayload, - result: ProcessTimelineResponseOutput, - baseline: VerifiedTimeline | null, -): boolean { - const completeReplacement = - payload.direction === "tail" && - (payload.window.maxSeq < payload.window.minSeq || - (payload.startCursor?.seq === payload.window.minSeq && - payload.endCursor?.seq === payload.window.maxSeq)); - return Boolean( - result.commit === "apply" && - completeReplacement && - (payload.reset || - (baseline?.range && - (payload.epoch !== baseline.range.epoch || payload.window.maxSeq < baseline.range.endSeq))), - ); -} - -function applyAcceptedPageState(input: { - state: AgentTimeline; - payload: TimelineResponsePayload; - result: ProcessTimelineResponseOutput; - verified: VerifiedTimeline; - replaceTail: boolean; - synchronized: boolean; -}): void { - const { state, payload, result, verified, synchronized } = input; - const baseline = state.verified; - const range = verified.range; - const replacesWindow = - input.replaceTail || - payload.reset || - !baseline?.range || - !range || - baseline.range.epoch !== range.epoch || - range.endSeq < baseline.range.endSeq; - // Older pagination can extend an existing baseline, but cannot establish live coverage. - if (payload.direction !== "before" || baseline) { - if (replacesWindow) state.restartStartSeq = range?.startSeq ?? null; - state.verified = verified; - if (payload.direction !== "before") state.hasUnsequencedHistory = false; - } - state.tail = result.tail; - state.head = result.head; - if (result.cursorChanged) state.displayRange = result.cursor ?? null; - if (result.older !== "unchanged") state.older = result.older === "available"; - if (payload.direction !== "before") state.newer = payload.hasNewer; - if (synchronized) state.liveEpoch = payload.epoch; - state.synchronized ||= synchronized; - state.submissions = observeMessageSubmissionCanonical( - state.submissions, - result.acknowledgedClientMessageIds, - ); -} - -/** Host-lived write authority. Zustand is its UI projection; disk is write-behind. */ -export class TimelineReplica { - private readonly agents = new Map(); - private readonly removed = new Set(); - private disposed = false; - - constructor( - private serverId: string, - private readonly storage: TimelineReplicaStorage, - ) {} - - private state(agentId: string): AgentTimeline { - const existing = this.agents.get(agentId); - if (existing) return existing; - const state: AgentTimeline = { - tail: [], - head: [], - older: false, - newer: false, - synchronized: false, - hasUnsequencedHistory: false, - restored: false, - restartStartSeq: null, - verified: null, - displayRange: null, - submissions: [], - }; - this.agents.set(agentId, state); - return state; - } - - private accepts(agentId: string): boolean { - return !this.disposed && !this.removed.has(agentId); - } - - prepare(agentId: string): Promise { - if (!this.accepts(agentId)) return Promise.resolve(); - const state = this.state(agentId); - state.preparation ??= this.restore(agentId, state); - return state.preparation; - } - - private async restore(agentId: string, state: AgentTimeline): Promise { - const serverId = this.serverId; - const stored = await this.storage.readTimeline(serverId, agentId); - if (!this.accepts(agentId) || this.agents.get(agentId) !== state) return; - // Identity transfer retires the old scoped read, even if it captured rows before rename. - if (serverId !== this.serverId) return this.restore(agentId, state); - state.restored = true; - if (state.verified || !stored) { - this.publish(agentId, state, { - persist: Boolean(state.verified) || state.hasUnsequencedHistory, - requireCertified: Boolean(stored?.range), - }); - return; - } - const sameEpoch = !state.liveEpoch || !stored.range || state.liveEpoch === stored.range.epoch; - const replacement = replaceWithCanonicalStream({ - canonical: sameEpoch ? stored.items : [], - previousTail: state.tail, - previousHead: state.head, - sendingClientMessageIds: getSendingClientMessageIds(state.submissions), - preserveContinuity: true, - canonicalCoverage: - sameEpoch && stored.range - ? { epoch: stored.range.epoch, endSeq: stored.range.endSeq } - : "rows-only", - }); - Object.assign(state, { - tail: replacement.tail, - head: replacement.head, - older: sameEpoch && stored.hasOlder, - displayRange: sameEpoch ? stored.range : null, - }); - if (stored.range) { - state.restartStartSeq = stored.range.startSeq; - state.verified = { - tail: stored.items, - head: [], - range: stored.range, - hasOlder: stored.hasOlder, - }; - } - state.submissions = observeMessageSubmissionCanonical( - state.submissions, - replacement.acknowledgedClientMessageIds, - ); - this.publish(agentId, state, { - persist: state.hasUnsequencedHistory, - requireCertified: Boolean(stored.range), - }); - } - - readCursor(agentId: string): { epoch: string; endSeq: number } | undefined { - if (!this.accepts(agentId)) return undefined; - const range = this.state(agentId).verified?.range; - return range ? { epoch: range.epoch, endSeq: range.endSeq } : undefined; - } - - private streamSnapshot(agentId: string): AgentStreamReducerSnapshot { - const state = this.state(agentId); - return { - currentTail: state.tail, - currentHead: state.head, - currentCursor: state.displayRange ?? undefined, - // Before network synchronization, live activity can paint ahead of the saved range. - hasAuthoritativeBaseline: state.synchronized, - isDetached: state.synchronized && state.newer, - }; - } - - applyEvents( - agentId: string, - events: AgentStreamReducerEvent[], - recoverGap: PageEffects["recoverGap"], - ): void { - if (!this.accepts(agentId)) return; - const state = this.state(agentId); - events = acceptLiveDisplayEpoch(state, events); - const display = processAgentStreamEvents({ events, ...this.streamSnapshot(agentId) }); - const previous = state.verified; - const persist = applyVerifiedStreamEvents(state, events, display); - if (display.cursorChanged) state.displayRange = display.cursor; - state.tail = display.tail; - state.head = display.head; - state.submissions = observeMessageSubmissionCanonical( - state.submissions, - display.acknowledgedClientMessageIds, - ); - this.publish(agentId, state, { - persist: persist || !previous, - requireCertified: Boolean(previous?.range && state.verified), - }); - for (const effect of display.sideEffects) { - if (effect.type === "catch_up") recoverGap(agentId, effect.cursor, effect.observedSeq); - } - } - - applyPage( - payload: TimelineResponsePayload, - effects: PageEffects, - request?: TimelinePageRequest, - ): void { - const agentId = payload.agentId; - if (!this.accepts(agentId)) return; - const state = this.state(agentId); - const initKey = getInitKey(this.serverId, agentId); - const deferred = getInitDeferred(initKey); - const session = useSessionStore.getState().sessions[this.serverId]; - const ownsInitialization = !request || request.initialization === deferred?.promise; - const common = { - payload, - replaceTail: request?.replaceTail === true, - currentCursor: state.displayRange ?? undefined, - isInitializing: ownsInitialization && session?.initializingAgents.get(agentId) === true, - hasActiveInitDeferred: ownsInitialization && Boolean(deferred), - initRequestDirection: deferred?.requestDirection ?? ("tail" as const), - sendingClientMessageIds: getSendingClientMessageIds(state.submissions), - }; - if (isSupersededTail(payload, state.verified?.range, request?.cursor)) { - const completion = processTimelineResponseCompletion(common); - settleTimelineInitialization(this.serverId, agentId, completion); - return; - } - const result = processTimelineResponse({ - ...common, - currentTail: state.tail, - currentHead: state.head, - }); - if (result.error) { - settleTimelineInitialization(this.serverId, agentId, result); - return; - } - const baseline = state.verified; - const invalidated = invalidatesDurableBaseline(payload, result, baseline); - if (invalidated) { - this.storage.removeTimeline(this.serverId, agentId); - state.restored = true; - } - const synchronized = isTimelineResumeSnapshotAuthoritative({ - direction: payload.direction, - hasNewer: payload.hasNewer, - error: payload.error, - }); - applyAcceptedPageState({ - state, - payload, - result, - verified: reduceVerifiedPage(state, common, result), - replaceTail: common.replaceTail, - synchronized, - }); - this.publish(agentId, state, { - persist: payload.direction !== "before", - requireCertified: !invalidated && Boolean(baseline?.range), - synchronized, - }); - if (!this.accepts(agentId)) return; - recoverDisplayedGap(agentId, state, effects.recoverGap); - finalizeProcessedTimeline({ - serverId: this.serverId, - agentId, - result, - synchronized, - ...effects, - }); - } - - beginSubmission(agentId: string, message: UserMessageItem, tracked: boolean): void { - if (!this.accepts(agentId)) return; - const state = this.state(agentId); - if (tracked) { - if (!message.clientMessageId) - throw new Error("Beginning a message submission requires client identity"); - state.submissions = beginMessageSubmission(state.submissions, { - clientMessageId: message.clientMessageId, - }); - } - applySubmissionRows(state, (rows) => appendSubmittedUserMessage({ ...rows, message })); - this.publish(agentId, state); - } - - acceptSubmission(agentId: string, clientMessageId: string): void { - if (!this.accepts(agentId)) return; - const state = this.state(agentId); - state.submissions = acceptMessageSubmission(state.submissions, clientMessageId); - this.publish(agentId, state); - } - - rejectSubmission( - agentId: string, - clientMessageId: string, - tracked: boolean, - ): MessageSubmissionRejectionOutcome { - if (!this.accepts(agentId)) return "unknown"; - const state = this.state(agentId); - const rejection = rejectMessageSubmission(state.submissions, clientMessageId); - const outcome = tracked ? rejection.outcome : "rejected"; - state.submissions = rejection.submissions; - if (outcome === "rejected") { - applySubmissionRows(state, (rows) => - removeSubmittedUserMessage({ ...rows, clientMessageId }), - ); - } - this.publish(agentId, state); - return outcome; - } - - handoffSubmission(agentId: string, message: UserMessageItem): boolean { - if (!this.accepts(agentId)) return false; - const state = this.state(agentId); - const changed = applySubmissionRows(state, (rows) => - handoffCreatedAgentUserMessageToStream({ ...rows, message }), - ); - if (!changed) return false; - this.publish(agentId, state, { - persist: Boolean(state.verified), - requireCertified: Boolean(state.verified?.range), - }); - return true; - } - - acceptAgent(agentId: string): void { - if (!this.disposed) this.removed.delete(agentId); - } - - remove(agentId: string, reason: AgentRemovalReason): void { - this.removed.add(agentId); - this.agents.delete(agentId); - if (reason === "deleted") this.storage.removeTimeline(this.serverId, agentId); - useSessionStore.getState().removeAgentTimeline(this.serverId, agentId); - } - - reconcileServerId(serverId: string): void { - this.serverId = serverId; - for (const [agentId, state] of this.agents) { - this.publish(agentId, state, { synchronized: state.synchronized }); - } - } - - dispose(): void { - this.disposed = true; - this.agents.clear(); - } - - private publish( - agentId: string, - state: AgentTimeline, - options: { persist?: boolean; requireCertified?: boolean; synchronized?: boolean } = {}, - ): void { - const { persist = false, requireCertified = false, synchronized = false } = options; - // Unknown display content cannot certify disk, but does not revoke known source coverage. - // While positioned catch-up is incomplete, keep its accepted durable baseline instead. - const displayOnly = state.hasUnsequencedHistory && state.synchronized && !state.newer; - const verified = displayOnly ? null : state.verified; - const range = verified?.range ?? null; - const durable: CachedTimeline | null = - persist && (verified !== null || state.restored) - ? { - agentId, - items: verified ? [...verified.tail, ...verified.head] : [...state.tail, ...state.head], - range, - hasOlder: Boolean(verified?.hasOlder), - } - : null; - // Both outputs are determined before subscribers run; none can authorize persistence by - // changing a mutable store flag between publication and the write-behind decision. - if (durable) { - const page = latestRestartPage(durable, state.restartStartSeq); - if (page.range) state.restartStartSeq = page.range.startSeq; - this.storage.commitTimeline( - this.serverId, - agentId, - page, - (requireCertified && !displayOnly) || !state.restored, - ); - } - useSessionStore.getState().applyAgentTimelineResponseState(this.serverId, agentId, { - items: state.tail, - head: state.head, - range: state.displayRange, - older: state.older ? "available" : "none", - newer: state.newer, - synchronized, - submissions: state.submissions, - }); - } -} - -function latestRestartPage( - timeline: CachedTimeline, - restartStartSeq: number | null, -): CachedTimeline { - const startOf = (item: StreamItem | undefined) => - item?.source?.startSeq ?? item?.timelineCursor?.seq; - const candidates = timeline.items.filter( - (item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item), - ); - const items = candidates.filter( - (item) => - restartStartSeq === null || - (startOf(item) ?? restartStartSeq) >= restartStartSeq || - // A prepend may complete the retained boundary message. Keep that whole item, - // without admitting older tools merely because they finish inside the window. - item.source?.chunks?.some((chunk) => chunk.seq === restartStartSeq), - ); - const first = Math.max(0, items.length - TIMELINE_FETCH_PAGE_SIZE); - const omitted = candidates.length > items.length || first > 0; - const firstSource = startOf(items[first]); - const floor = restartStartSeq ?? timeline.range?.startSeq; - const startSeq = first > 0 ? firstSource : Math.min(floor ?? Infinity, firstSource ?? Infinity); - const range = - timeline.range && startSeq !== undefined && Number.isFinite(startSeq) - ? { ...timeline.range, startSeq: Math.max(timeline.range.startSeq, startSeq) } - : timeline.range; - return { - ...timeline, - items: items.slice(first), - range, - hasOlder: - timeline.hasOlder || - omitted || - Boolean(range && timeline.range && range.startSeq > timeline.range.startSeq), - }; -} - -export function createTimelineReplica(input: { - serverId: string; - storage: TimelineReplicaStorage; -}): TimelineReplica { - return new TimelineReplica(input.serverId, input.storage); -} -function retainPendingSubmissions(rows: TimelineRows, state: AgentTimeline): TimelineRows { - const pending = getSendingClientMessageIds(state.submissions); - for (const row of [...state.tail, ...state.head]) { - if ( - row.kind !== "user_message" || - !row.clientMessageId || - !pending.includes(row.clientMessageId) - ) - continue; - rows = handoffCreatedAgentUserMessageToStream({ ...rows, message: row }); - } - return rows; -} - -function reduceVerifiedPage( - state: AgentTimeline, - common: Omit[0], "currentTail" | "currentHead">, - display: ProcessTimelineResponseOutput, -): VerifiedTimeline { - const baseline = state.verified; - const currentCursor = baseline?.range ?? undefined; - const shared = - (baseline === null && state.tail.length === 0 && state.head.length === 0) || - (baseline?.tail === state.tail && baseline.head === state.head); - let rows: TimelineRows = baseline ?? { tail: [], head: [] }; - for (const row of shared ? [] : [...state.tail, ...state.head]) { - if (row.kind === "user_message" && isUnreconciledLocalUserMessage(row)) { - rows = handoffCreatedAgentUserMessageToStream({ ...rows, message: row }); - } - } - const verified = shared - ? display - : processTimelineResponse({ - ...common, - currentCursor, - currentTail: rows.tail, - currentHead: rows.head, - }); - // Divergence ends when the accepted page reconciles the overlay. Subsequent events share - // one reduction again; no consumer or synchronization flag decides row identity. - if (!shared && equal([...verified.tail, ...verified.head], [...display.tail, ...display.head])) { - verified.tail = display.tail; - verified.head = display.head; - } - const range = verified.cursorChanged ? (verified.cursor ?? null) : (currentCursor ?? null); - return { - tail: verified.tail, - head: verified.head, - range, - hasOlder: verified.older === "unchanged" ? state.older : verified.older === "available", - }; -} - -function recoverDisplayedGap( - agentId: string, - state: AgentTimeline, - recoverGap: PageEffects["recoverGap"], -): void { - const range = state.verified?.range; - if (!range) return; - for (const row of [...state.tail, ...state.head]) { - const cursor = row.timelineCursor; - if (cursor?.epoch === range.epoch && cursor.seq > range.endSeq) - recoverGap(agentId, range, cursor.seq); - } -} - -function clearAgentInitializingFlag(serverId: string, agentId: string): void { - useSessionStore.getState().setInitializingAgents(serverId, (previous) => { - if (previous.get(agentId) !== true) return previous; - const next = new Map(previous); - next.set(agentId, false); - return next; - }); -} - -function settleTimelineInitialization( - serverId: string, - agentId: string, - result: Pick, -): void { - if (result.clearInitializing) clearAgentInitializingFlag(serverId, agentId); - const key = getInitKey(serverId, agentId); - if (result.initResolution === "resolve") resolveInitDeferred(key); - if (result.initResolution === "reject" && result.error) - rejectInitDeferred(key, new Error(result.error)); -} - -function finalizeProcessedTimeline(input: { - serverId: string; - agentId: string; - result: ProcessTimelineResponseOutput; - synchronized: boolean; - recoverGap: ( - agentId: string, - cursor: { epoch: string; endSeq: number }, - observedSeq?: number, - ) => void; - drainQueuedAgentMessage: (agentId: string) => void; -}): void { - for (const effect of input.result.sideEffects) { - if (effect.type === "catch_up") { - input.recoverGap(input.agentId, effect.cursor, effect.observedSeq); - } - } - settleTimelineInitialization(input.serverId, input.agentId, input.result); - if (input.synchronized) { - useCreateFlowStore - .getState() - .clearByAgent({ serverId: input.serverId, agentId: input.agentId }); - const session = useSessionStore.getState().sessions[input.serverId]; - const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId); - if (agent && agent.turn.phase === "idle") input.drainQueuedAgentMessage(input.agentId); - } -} diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 42f07860cf..13897f8454 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -4,7 +4,6 @@ import type { AgentTimelineItem, ToolCallDetail } from "@getpaseo/protocol/agent import { createUserMessage, hydrateStreamState, - rowStartSeq, type AgentToolCallItem, type StreamItem, } from "@/types/stream"; @@ -482,12 +481,7 @@ describe("processTimelineResponse", () => { it("replaces destructively when the same epoch rewinds behind the local cursor", () => { const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [ - { - ...makeAssistantItem("future history", "future-history"), - timelineCursor: { epoch: "epoch-1", seq: 40 }, - }, - ], + currentTail: [makeAssistantItem("future history", "future-history")], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 }, payload: { ...baseTimelineInput.payload, @@ -499,37 +493,10 @@ describe("processTimelineResponse", () => { }, }); - expect(getAssistantTexts([...result.tail, ...result.head])).toEqual(["rewound history"]); + expect(getAssistantTexts(result.tail)).toEqual(["rewound history"]); expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 10 }); }); - it("drops superseded positioned rows when a forced reset tail ends behind the cursor", () => { - const result = processTimelineResponse({ - ...baseTimelineInput, - currentTail: [ - { - ...makeAssistantItem("future history", "future-history"), - timelineCursor: { epoch: "epoch-1", seq: 40 }, - }, - ], - currentHead: [makeSubmittedUserMessage("still sending", "sending-prompt")], - currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 }, - sendingClientMessageIds: ["sending-prompt"], - payload: { - ...baseTimelineInput.payload, - direction: "tail", - reset: true, - window: { minSeq: 1, maxSeq: 10, nextSeq: 11 }, - startCursor: { seq: 1 }, - endCursor: { seq: 10 }, - entries: [makeTimelineEntry(10, "rewound history")], - }, - }); - - expect(getAssistantTexts([...result.tail, ...result.head])).toEqual(["rewound history"]); - expect(getUserTexts([...result.tail, ...result.head])).toEqual(["still sending"]); - }); - it("preserves the canonical end cursor on a projected assistant message", () => { const result = processTimelineResponse({ ...baseTimelineInput, @@ -991,13 +958,6 @@ describe("processTimelineResponse", () => { text: "canonical prefix continuation", timestamp: new Date(2001), timelineCursor: { epoch: "epoch-1", seq: 101 }, - source: { - startSeq: 100, - chunks: [ - { seq: 100, offset: 0 }, - { seq: 101, offset: "canonical prefix".length }, - ], - }, }, ], isInitializing: true, @@ -3078,7 +3038,7 @@ describe("processTimelineResponse", () => { })), ).toEqual([ { - id: "assistant-message", + id: "loaded-assistant", text: "projected response", messageId: "assistant-message", timelineCursor: { epoch: "epoch-1", seq: 500 }, @@ -3237,57 +3197,6 @@ describe("processTimelineResponse", () => { }); }); - it.each([ - ["older", "newer", ["older chunk ", "newer chunk"]], - ["same", "same", ["older chunk newer chunk"]], - [undefined, "newer", ["older chunk newer chunk"]], - ["older", undefined, ["older chunk newer chunk"]], - ])("preserves assistant identity at a prepend boundary (%s, %s)", (olderId, newerId, texts) => { - const older = makeTimelineEntry(1, "older chunk "); - const result = processTimelineResponse({ - ...baseTimelineInput, - currentTail: [ - { - ...makeAssistantItem("newer chunk", "newer"), - messageId: newerId, - timelineCursor: { epoch: "epoch-1", seq: 2 }, - source: { startSeq: 2, chunks: [{ seq: 2, offset: 0 }] }, - }, - ], - currentCursor: { epoch: "epoch-1", startSeq: 2, endSeq: 2 }, - payload: { - ...baseTimelineInput.payload, - direction: "before", - epoch: "epoch-1", - startCursor: { seq: 1 }, - endCursor: { seq: 1 }, - entries: [ - { - ...older, - item: { type: "assistant_message", text: "older chunk ", messageId: olderId }, - }, - ], - }, - }); - expect(getAssistantTexts(result.tail)).toEqual(texts); - expect(result.tail.map((row) => row.source)).toEqual( - texts.length === 2 - ? [ - { startSeq: 1, chunks: [{ seq: 1, offset: 0 }] }, - { startSeq: 2, chunks: [{ seq: 2, offset: 0 }] }, - ] - : [ - { - startSeq: 1, - chunks: [ - { seq: 1, offset: 0 }, - { seq: 2, offset: 12 }, - ], - }, - ], - ); - }); - it("coalesces tool call lifecycle rows across the older-page prepend boundary", () => { const callId = "toolu_boundary"; const turnId = "turn-boundary"; @@ -4437,13 +4346,12 @@ describe("createAgentStreamReducerQueue", () => { let currentHead: StreamItem[] = []; const queue = createAgentStreamReducerQueue({ - apply: (agentId, events) => { - const result = processAgentStreamEvents({ - events, - currentTail, - currentHead, - currentCursor: undefined, - }); + getSnapshot: () => ({ + currentTail, + currentHead, + currentCursor: undefined, + }), + commit: (agentId, result) => { currentTail = result.tail; currentHead = result.head; commits.push({ @@ -4452,6 +4360,7 @@ describe("createAgentStreamReducerQueue", () => { cursorEndSeq: result.cursor?.endSeq ?? null, }); }, + handleSideEffects: () => {}, scheduleFlush: scheduler.schedule, cancelFlush: scheduler.cancel, }); @@ -4478,17 +4387,17 @@ describe("createAgentStreamReducerQueue", () => { const scheduler = createManualScheduler(); const commits: string[] = []; const queue = createAgentStreamReducerQueue({ - apply: (agentId, events) => { - const result = processAgentStreamEvents({ - events, - currentTail: [], - currentHead: [], - currentCursor: undefined, - }); + getSnapshot: () => ({ + currentTail: [], + currentHead: [], + currentCursor: undefined, + }), + commit: (agentId, result) => { commits.push( `${agentId}:${result.head[0]?.kind === "assistant_message" ? result.head[0].text : ""}`, ); }, + handleSideEffects: () => {}, scheduleFlush: scheduler.schedule, cancelFlush: scheduler.cancel, }); @@ -4507,17 +4416,17 @@ describe("createAgentStreamReducerQueue", () => { let currentCursor: TimelineCursor | undefined; const queue = createAgentStreamReducerQueue({ - apply: (_agentId, events) => { - const result = processAgentStreamEvents({ - events, - currentTail, - currentHead, - currentCursor, - }); + getSnapshot: () => ({ + currentTail, + currentHead, + currentCursor, + }), + commit: (_agentId, result) => { currentTail = result.tail; currentHead = result.head; currentCursor = result.cursor ?? undefined; }, + handleSideEffects: () => {}, scheduleFlush: scheduler.schedule, cancelFlush: scheduler.cancel, }); @@ -4556,17 +4465,17 @@ describe("createAgentStreamReducerQueue", () => { const scheduler = createManualScheduler(); const commits: string[] = []; const queue = createAgentStreamReducerQueue({ - apply: (agentId, events) => { - const result = processAgentStreamEvents({ - events, - currentTail: [], - currentHead: [], - currentCursor: undefined, - }); + getSnapshot: () => ({ + currentTail: [], + currentHead: [], + currentCursor: undefined, + }), + commit: (agentId, result) => { commits.push( `${agentId}:${result.head[0]?.kind === "assistant_message" ? result.head[0].text : ""}`, ); }, + handleSideEffects: () => {}, scheduleFlush: scheduler.schedule, cancelFlush: scheduler.cancel, }); @@ -4578,706 +4487,3 @@ describe("createAgentStreamReducerQueue", () => { expect(scheduler.size).toBe(0); }); }); - -describe("forward pages reconcile live rows by identity and source position", () => { - const detail: ToolCallDetail = { type: "read", filePath: "/tmp/example.ts" }; - const checkpoint = (n: number) => `\n\n---\n\nReplica QA checkpoint ${n}: saved history`; - - function liveBeyondBootstrap() { - return processAgentStreamEvents({ - events: [ - makeStreamReducerEvent(makeAssistantTimelineEvent("\n\n---\n\n", "msg-102"), 675), - makeStreamReducerEvent( - makeAssistantTimelineEvent("Replica QA checkpoint 102: saved history", "msg-102"), - 679, - ), - makeStreamReducerEvent(makeToolCallTimelineEvent("exec-1"), 681), - makeStreamReducerEvent(makeAssistantTimelineEvent("\n\n---\n\n", "msg-103"), 682), - makeStreamReducerEvent( - makeAssistantTimelineEvent("Replica QA checkpoint 103: saved history", "msg-103"), - 685, - ), - makeStreamReducerEvent(makeToolCallTimelineEvent("exec-2"), 687), - ], - currentTail: [], - currentHead: [], - currentCursor: undefined, - hasAuthoritativeBaseline: false, - }); - } - - function projectRows(items: StreamItem[]) { - return items.map((item) => - item.kind === "assistant_message" - ? { kind: item.kind, messageId: item.messageId, text: item.text } - : { kind: item.kind, id: item.id }, - ); - } - - it("replaces split live blocks wholly beyond the cursor instead of appending them again", () => { - const live = liveBeyondBootstrap(); - expect(getAssistantTexts(live.head)).toHaveLength(4); - - const result = processTimelineResponse({ - ...baseTimelineInput, - currentTail: [ - { - ...makeAssistantItem(checkpoint(101), "msg-101"), - messageId: "msg-101", - timelineCursor: { epoch: "epoch-1", seq: 672 }, - }, - ], - currentHead: live.head, - currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, - payload: { - ...baseTimelineInput.payload, - direction: "after", - epoch: "epoch-1", - startCursor: { seq: 675 }, - endCursor: { seq: 691 }, - entries: [ - { - ...makeTimelineEntry(675, checkpoint(102), "assistant_message", 679), - sourceSeqRanges: [{ startSeq: 675, endSeq: 679 }], - item: { type: "assistant_message", text: checkpoint(102), messageId: "msg-102" }, - }, - makeToolCallTimelineEntry(681, "exec-1", "completed", detail), - { - ...makeTimelineEntry(682, checkpoint(103), "assistant_message", 685), - sourceSeqRanges: [{ startSeq: 682, endSeq: 685 }], - item: { type: "assistant_message", text: checkpoint(103), messageId: "msg-103" }, - }, - makeToolCallTimelineEntry(687, "exec-2", "completed", detail), - { - ...makeTimelineEntry(691, checkpoint(104)), - item: { type: "assistant_message", text: checkpoint(104), messageId: "msg-104" }, - }, - ], - }, - }); - - const rows = [...result.tail, ...result.head]; - expect(projectRows(rows)).toEqual([ - { kind: "assistant_message", messageId: "msg-101", text: checkpoint(101) }, - { kind: "assistant_message", messageId: "msg-102", text: checkpoint(102) }, - { kind: "tool_call", id: "agent_tool_exec-1" }, - { kind: "assistant_message", messageId: "msg-103", text: checkpoint(103) }, - { kind: "tool_call", id: "agent_tool_exec-2" }, - { kind: "assistant_message", messageId: "msg-104", text: checkpoint(104) }, - ]); - expect(new Set(rows.map((item) => item.id)).size).toBe(rows.length); - expect(getAgentToolCalls(rows).map((item) => item.payload.data.status)).toEqual([ - "completed", - "completed", - ]); - expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 600, endSeq: 691 }); - }); - - it.each(["positioned", "unpositioned", "repeated"] as const)( - "preserves same-message segments around a tool (%s)", - (delivery) => { - const beforeText = delivery === "repeated" ? "Repeat" : "Before tool"; - const afterText = delivery === "repeated" ? "Repeat" : "After tool"; - const live = processAgentStreamEvents({ - events: [ - makeStreamReducerEvent(makeAssistantTimelineEvent(beforeText, "msg-a"), 675), - makeStreamReducerEvent(makeToolCallTimelineEvent("exec-1"), 676), - makeStreamReducerEvent(makeAssistantTimelineEvent(afterText, "msg-a"), 677), - ].map((event) => - delivery === "positioned" || (delivery === "repeated" && event.seq === 676) - ? event - : Object.assign(event, { seq: undefined, epoch: undefined }), - ), - currentTail: [], - currentHead: [], - currentCursor: undefined, - hasAuthoritativeBaseline: false, - }); - const result = processTimelineResponse({ - ...baseTimelineInput, - currentTail: live.tail, - currentHead: live.head, - currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, - payload: { - ...baseTimelineInput.payload, - direction: "after", - window: { minSeq: 1, maxSeq: 677, nextSeq: 678 }, - startCursor: { seq: 675 }, - endCursor: { seq: 677 }, - entries: [ - { - ...makeTimelineEntry(675, beforeText), - item: { type: "assistant_message", text: beforeText, messageId: "msg-a" }, - }, - makeToolCallTimelineEntry(676, "exec-1", "running", detail), - { - ...makeTimelineEntry(677, afterText), - item: { type: "assistant_message", text: afterText, messageId: "msg-a" }, - }, - ], - }, - }); - const ids = [...result.tail, ...result.head].map((row) => row.id); - expect(new Set(ids).size).toBe(ids.length); - expect(projectRows([...result.tail, ...result.head])).toEqual([ - { kind: "assistant_message", messageId: "msg-a", text: beforeText }, - { kind: "tool_call", id: "agent_tool_exec-1" }, - { kind: "assistant_message", messageId: "msg-a", text: afterText }, - ]); - }, - ); - - it("records each unit's first source position on bootstrap", () => { - const result = processTimelineResponse({ - ...baseTimelineInput, - isInitializing: true, - hasActiveInitDeferred: true, - payload: { - ...baseTimelineInput.payload, - direction: "tail", - reset: true, - window: { minSeq: 1, maxSeq: 700, nextSeq: 701 }, - startCursor: { seq: 675 }, - endCursor: { seq: 700 }, - entries: [ - { - ...makeTimelineEntry(675, "Before tool"), - item: { type: "assistant_message", text: "Before tool", messageId: "msg-a" }, - }, - { - ...makeToolCallTimelineEntry(676, "exec-1", "completed", detail), - seqEnd: 700, - sourceSeqRanges: [ - { startSeq: 676, endSeq: 676 }, - { startSeq: 700, endSeq: 700 }, - ], - collapsed: ["tool_lifecycle"], - }, - { - ...makeTimelineEntry(677, "After tool"), - item: { type: "assistant_message", text: "After tool", messageId: "msg-a" }, - }, - ], - }, - }); - const rows = [...result.tail, ...result.head]; - expect(rows.map((row) => row.kind)).toEqual([ - "assistant_message", - "tool_call", - "assistant_message", - ]); - expect(rows.map(rowStartSeq)).toEqual([675, 676, 677]); - }); - - it("keeps parallel tools in the order they started when their completions are reversed", () => { - const result = processTimelineResponse({ - ...baseTimelineInput, - isInitializing: true, - hasActiveInitDeferred: true, - payload: { - ...baseTimelineInput.payload, - direction: "tail", - reset: true, - window: { minSeq: 1, maxSeq: 700, nextSeq: 701 }, - startCursor: { seq: 676 }, - endCursor: { seq: 700 }, - entries: [ - { - ...makeToolCallTimelineEntry(676, "first", "completed", detail), - seqEnd: 700, - sourceSeqRanges: [ - { startSeq: 676, endSeq: 676 }, - { startSeq: 700, endSeq: 700 }, - ], - collapsed: ["tool_lifecycle"], - }, - { - ...makeToolCallTimelineEntry(677, "second", "completed", detail), - seqEnd: 690, - sourceSeqRanges: [ - { startSeq: 677, endSeq: 677 }, - { startSeq: 690, endSeq: 690 }, - ], - collapsed: ["tool_lifecycle"], - }, - ], - }, - }); - const tools = getAgentToolCalls([...result.tail, ...result.head]); - expect(tools.map((tool) => tool.payload.data.callId)).toEqual(["first", "second"]); - expect(tools.map(rowStartSeq)).toEqual([676, 677]); - expect(tools.map((tool) => tool.timelineCursor?.seq)).toEqual([700, 690]); - }); - - it("keeps an interleaved tool in place when the page arrives with no live rows", () => { - const result = processTimelineResponse({ - ...baseTimelineInput, - currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, - payload: { - ...baseTimelineInput.payload, - direction: "after", - window: { minSeq: 1, maxSeq: 700, nextSeq: 701 }, - startCursor: { seq: 675 }, - endCursor: { seq: 700 }, - entries: [ - { - ...makeTimelineEntry(675, "Before tool"), - item: { type: "assistant_message", text: "Before tool", messageId: "msg-a" }, - }, - { - ...makeToolCallTimelineEntry(676, "exec-1", "completed", detail), - seqEnd: 700, - sourceSeqRanges: [ - { startSeq: 676, endSeq: 676 }, - { startSeq: 700, endSeq: 700 }, - ], - collapsed: ["tool_lifecycle"], - }, - { - ...makeTimelineEntry(677, "After tool"), - item: { type: "assistant_message", text: "After tool", messageId: "msg-a" }, - }, - ], - }, - }); - const rows = [...result.tail, ...result.head]; - expect(projectRows(rows)).toEqual([ - { kind: "assistant_message", messageId: "msg-a", text: "Before tool" }, - { kind: "tool_call", id: "agent_tool_exec-1" }, - { kind: "assistant_message", messageId: "msg-a", text: "After tool" }, - ]); - expect(rows.map(rowStartSeq)).toEqual([675, 676, 677]); - }); - - it("keeps an interleaved tool whose completion is newer than text on both sides", () => { - const live = processAgentStreamEvents({ - events: [ - makeStreamReducerEvent(makeAssistantTimelineEvent("Before tool", "msg-a"), 675), - makeStreamReducerEvent(makeToolCallTimelineEvent("exec-1"), 676), - makeStreamReducerEvent(makeAssistantTimelineEvent("After tool", "msg-a"), 677), - makeStreamReducerEvent( - { - ...makeToolCallTimelineEvent("exec-1"), - item: makeToolCallTimelineEntry(676, "exec-1", "completed", detail).item, - } as AgentStreamEventPayload, - 700, - ), - ], - currentTail: [], - currentHead: [], - currentCursor: undefined, - hasAuthoritativeBaseline: false, - }); - const result = processTimelineResponse({ - ...baseTimelineInput, - currentTail: live.tail, - currentHead: live.head, - currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, - payload: { - ...baseTimelineInput.payload, - direction: "after", - window: { minSeq: 1, maxSeq: 700, nextSeq: 701 }, - startCursor: { seq: 675 }, - endCursor: { seq: 700 }, - entries: [ - { - ...makeTimelineEntry(675, "Before tool"), - item: { type: "assistant_message", text: "Before tool", messageId: "msg-a" }, - }, - { - ...makeToolCallTimelineEntry(676, "exec-1", "completed", detail), - seqEnd: 700, - sourceSeqRanges: [ - { startSeq: 676, endSeq: 676 }, - { startSeq: 700, endSeq: 700 }, - ], - collapsed: ["tool_lifecycle"], - }, - { - ...makeTimelineEntry(677, "After tool"), - item: { type: "assistant_message", text: "After tool", messageId: "msg-a" }, - }, - ], - }, - }); - const rows = [...result.tail, ...result.head]; - expect(projectRows(rows)).toEqual([ - { kind: "assistant_message", messageId: "msg-a", text: "Before tool" }, - { kind: "tool_call", id: "agent_tool_exec-1" }, - { kind: "assistant_message", messageId: "msg-a", text: "After tool" }, - ]); - expect(getAgentToolCalls(rows)[0]?.payload.data.status).toBe("completed"); - }); - - it("keeps a tool call at the position it appeared even when its completion is newer", () => { - const live = processAgentStreamEvents({ - events: [ - makeStreamReducerEvent(makeToolCallTimelineEvent("exec-1"), 676), - makeStreamReducerEvent(makeAssistantTimelineEvent("While the tool runs", "msg-a"), 680), - makeStreamReducerEvent( - { - ...makeToolCallTimelineEvent("exec-1"), - item: { ...makeToolCallTimelineEntry(676, "exec-1", "completed", detail).item }, - } as AgentStreamEventPayload, - 700, - ), - ], - currentTail: [], - currentHead: [], - currentCursor: undefined, - hasAuthoritativeBaseline: false, - }); - const result = processTimelineResponse({ - ...baseTimelineInput, - currentHead: live.head, - currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, - payload: { - ...baseTimelineInput.payload, - direction: "after", - window: { minSeq: 1, maxSeq: 700, nextSeq: 701 }, - startCursor: { seq: 675 }, - endCursor: { seq: 700 }, - entries: [ - { - ...makeToolCallTimelineEntry(676, "exec-1", "completed", detail), - seqEnd: 700, - sourceSeqRanges: [ - { startSeq: 676, endSeq: 676 }, - { startSeq: 700, endSeq: 700 }, - ], - collapsed: ["tool_lifecycle"], - }, - { - ...makeTimelineEntry(680, "While the tool runs"), - item: { type: "assistant_message", text: "While the tool runs", messageId: "msg-a" }, - }, - ], - }, - }); - - expect(projectRows([...result.tail, ...result.head])).toEqual([ - { kind: "tool_call", id: "agent_tool_exec-1" }, - { kind: "assistant_message", messageId: "msg-a", text: "While the tool runs" }, - ]); - expect(getAgentToolCalls([...result.tail, ...result.head])[0]?.payload.data.status).toBe( - "completed", - ); - }); - - it("does not duplicate a notification the page delivers again", () => { - const live = processAgentStreamEvents({ - events: [ - makeStreamReducerEvent( - { - type: "timeline", - provider: "claude", - item: { type: "error", message: "Rate limited" }, - } as AgentStreamEventPayload, - 680, - ), - ], - currentTail: [], - currentHead: [], - currentCursor: undefined, - hasAuthoritativeBaseline: false, - }); - const result = processTimelineResponse({ - ...baseTimelineInput, - currentHead: live.head, - currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, - payload: { - ...baseTimelineInput.payload, - direction: "after", - window: { minSeq: 1, maxSeq: 680, nextSeq: 681 }, - startCursor: { seq: 675 }, - endCursor: { seq: 680 }, - entries: [ - { - ...makeTimelineEntry(680, "Rate limited"), - item: { type: "error", message: "Rate limited" }, - }, - ], - }, - }); - - const notifications = [...result.tail, ...result.head].filter( - (item) => item.kind === "notification", - ); - expect(notifications).toHaveLength(1); - expect(notifications[0]?.id).toBe(live.head[0]?.id); - }); - - it("inserts missed canonical history before newer live messages during reconnect", () => { - const live = processAgentStreamEvents({ - events: [makeStreamReducerEvent(makeAssistantTimelineEvent("Arrived live", "msg-new"), 700)], - currentTail: [], - currentHead: [], - currentCursor: undefined, - hasAuthoritativeBaseline: false, - }); - const result = processTimelineResponse({ - ...baseTimelineInput, - currentTail: [], - currentHead: live.head, - currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, - payload: { - ...baseTimelineInput.payload, - direction: "after", - window: { minSeq: 1, maxSeq: 690, nextSeq: 691 }, - startCursor: { seq: 675 }, - endCursor: { seq: 690 }, - hasNewer: false, - entries: [ - { - ...makeTimelineEntry(675, "Missed while closed", "assistant_message", 679), - item: { type: "assistant_message", text: "Missed while closed", messageId: "msg-old" }, - }, - ], - }, - }); - expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([ - "Missed while closed", - "Arrived live", - ]); - }); - - it.each([ - { - name: "partially overlaps the canonical prefix", - events: [ - makeStreamReducerEvent(makeAssistantTimelineEvent("llo", "msg-a"), 680), - makeStreamReducerEvent(makeAssistantTimelineEvent(" world", "msg-a"), 700), - ], - canonical: "Hello", - expected: "Hello world", - }, - { - name: "repeats the prefix after its coverage", - events: [makeStreamReducerEvent(makeAssistantTimelineEvent("Ha", "msg-a"), 700)], - canonical: "Ha", - expected: "HaHa", - }, - ])( - "reconciles by source coverage when newer live text $name", - ({ events, canonical, expected }) => { - const live = processAgentStreamEvents({ - events, - currentTail: [], - currentHead: [], - currentCursor: undefined, - hasAuthoritativeBaseline: false, - }); - const result = processTimelineResponse({ - ...baseTimelineInput, - currentHead: live.head, - currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, - payload: { - ...baseTimelineInput.payload, - direction: "after", - window: { minSeq: 1, maxSeq: 690, nextSeq: 691 }, - startCursor: { seq: 675 }, - endCursor: { seq: 690 }, - hasNewer: false, - entries: [ - { - ...makeTimelineEntry(675, canonical, "assistant_message", 690), - item: { type: "assistant_message", text: canonical, messageId: "msg-a" }, - }, - ], - }, - }); - expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([expected]); - }, - ); - - it("fills a missing prefix when a lagging final page meets a newer live suffix", () => { - const live = processAgentStreamEvents({ - events: [makeStreamReducerEvent(makeAssistantTimelineEvent(" world", "msg-a"), 700)], - currentTail: [], - currentHead: [], - currentCursor: undefined, - hasAuthoritativeBaseline: false, - }); - const result = processTimelineResponse({ - ...baseTimelineInput, - currentHead: live.head, - currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, - payload: { - ...baseTimelineInput.payload, - direction: "after", - startCursor: { seq: 675 }, - endCursor: { seq: 690 }, - hasNewer: false, - entries: [ - { - ...makeTimelineEntry(675, "Hello", "assistant_message", 690), - item: { type: "assistant_message", text: "Hello", messageId: "msg-a" }, - }, - ], - }, - }); - expect(getAssistantTexts([...result.tail, ...result.head])).toEqual(["Hello world"]); - }); - - it("prefixes only the first block of a split live suffix", () => { - const live = processAgentStreamEvents({ - events: [ - makeStreamReducerEvent(makeAssistantTimelineEvent(" world.\n\n", "msg-a"), 700), - makeStreamReducerEvent(makeAssistantTimelineEvent("Second paragraph", "msg-a"), 701), - ], - currentTail: [], - currentHead: [], - currentCursor: undefined, - hasAuthoritativeBaseline: false, - }); - expect(getAssistantTexts(live.head)).toEqual([" world.", "Second paragraph"]); - - const result = processTimelineResponse({ - ...baseTimelineInput, - currentHead: live.head, - currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, - payload: { - ...baseTimelineInput.payload, - direction: "after", - startCursor: { seq: 675 }, - endCursor: { seq: 690 }, - hasNewer: false, - entries: [ - { - ...makeTimelineEntry(675, "Hello", "assistant_message", 690), - item: { type: "assistant_message", text: "Hello", messageId: "msg-a" }, - }, - ], - }, - }); - - const rows = [...result.tail, ...result.head].filter( - (item) => item.kind === "assistant_message", - ); - expect(rows.map((item) => item.text)).toEqual(["Hello world.", "Second paragraph"]); - expect(rows.map((item) => item.id)).toEqual(live.head.map((item) => item.id)); - expect(rows.every((item) => item.messageId === "msg-a")).toBe(true); - }); - - it.each([true, false])("keeps newer text beyond a lagging page (positioned=%s)", (positioned) => { - const live = processAgentStreamEvents({ - events: [ - makeStreamReducerEvent(makeAssistantTimelineEvent("Hello", "msg-a"), 690), - makeStreamReducerEvent(makeAssistantTimelineEvent(" world", "msg-a"), 700), - ].map((event) => - positioned ? event : Object.assign(event, { seq: undefined, epoch: undefined }), - ), - currentTail: [], - currentHead: [], - currentCursor: undefined, - hasAuthoritativeBaseline: false, - }); - - const result = processTimelineResponse({ - ...baseTimelineInput, - currentHead: live.head, - currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, - payload: { - ...baseTimelineInput.payload, - direction: "after", - epoch: "epoch-1", - startCursor: { seq: 675 }, - endCursor: { seq: 690 }, - hasNewer: true, - entries: [ - { - ...makeTimelineEntry(690, "Hello"), - item: { type: "assistant_message", text: "Hello", messageId: "msg-a" }, - }, - ], - }, - }); - - const assistants = [...result.tail, ...result.head].filter( - (item) => item.kind === "assistant_message", - ); - expect(assistants).toHaveLength(1); - expect(assistants[0]).toMatchObject({ - messageId: "msg-a", - text: "Hello world", - }); - expect(assistants[0]?.timelineCursor).toEqual( - positioned ? { epoch: "epoch-1", seq: 700 } : undefined, - ); - }); - - it.each([true, false])( - "keeps distinct identities with identical text (positioned=%s)", - (positioned) => { - const live = processAgentStreamEvents({ - events: [makeStreamReducerEvent(makeAssistantTimelineEvent("Done.", "msg-a"), 680)].map( - (event) => - positioned ? event : Object.assign(event, { seq: undefined, epoch: undefined }), - ), - currentTail: [], - currentHead: [], - currentCursor: undefined, - hasAuthoritativeBaseline: false, - }); - - const result = processTimelineResponse({ - ...baseTimelineInput, - currentHead: live.head, - currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, - payload: { - ...baseTimelineInput.payload, - direction: "after", - epoch: "epoch-1", - startCursor: { seq: 675 }, - endCursor: { seq: 681 }, - entries: [ - { - ...makeTimelineEntry(681, "Done."), - item: { type: "assistant_message", text: "Done.", messageId: "msg-b" }, - }, - ], - }, - }); - - expect( - [...result.tail, ...result.head] - .filter((item) => item.kind === "assistant_message") - .map((item) => item.messageId), - ).toEqual(["msg-a", "msg-b"]); - }, - ); - it("does not guess which identical unpositioned segment a partial page covers", () => { - const live = processAgentStreamEvents({ - events: [ - makeStreamReducerEvent(makeAssistantTimelineEvent("Repeat", "msg-a"), 675), - makeStreamReducerEvent(makeToolCallTimelineEvent("exec-1"), 676), - makeStreamReducerEvent(makeAssistantTimelineEvent("Repeat", "msg-a"), 677), - ].map((event) => Object.assign(event, { seq: undefined, epoch: undefined })), - currentTail: [], - currentHead: [], - currentCursor: undefined, - hasAuthoritativeBaseline: false, - }); - const result = processTimelineResponse({ - ...baseTimelineInput, - currentTail: live.tail, - currentHead: live.head, - currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 }, - payload: { - ...baseTimelineInput.payload, - direction: "after", - startCursor: { seq: 675 }, - endCursor: { seq: 675 }, - entries: [ - { - ...makeTimelineEntry(675, "Repeat"), - item: { type: "assistant_message", text: "Repeat", messageId: "msg-a" }, - }, - ], - }, - }); - const rows = [...result.tail, ...result.head]; - expect(rows.filter((row) => !row.timelineCursor)).toEqual([...live.tail, ...live.head]); - expect(rows.filter((row) => row.timelineCursor).map((row) => rowStartSeq(row))).toEqual([675]); - }); -}); diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index cb7a2a663b..b04ad00f02 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -1,25 +1,16 @@ -import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; -import type { - AssistantMessageItem, - StreamItem, - SourcedPosition, - ThoughtItem, - TodoEntry, - UserMessageItem, -} from "@/types/stream"; +import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; +import type { AssistantMessageItem, StreamItem, TodoEntry } from "@/types/stream"; import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; import { applyStreamEvent, + flushHeadToTail, hydrateStreamState, isAgentToolCallItem, - joinTextRows, mergeAgentToolCallItem, - mergeCanonicalText, replaceWithCanonicalStream, reduceStreamUpdate, - rowStartSeq, streamTimelineItemIdentity, upsertUserMessageAcrossStream, } from "@/types/stream"; @@ -50,14 +41,12 @@ export interface TimelineCursor { // --------------------------------------------------------------------------- export type TimelineReducerSideEffect = - // `observedSeq` is the sequence the live stream reached that coverage must still catch up to. - | { type: "catch_up"; cursor: { epoch: string; endSeq: number }; observedSeq?: number } + | { type: "catch_up"; cursor: { epoch: string; endSeq: number } } | { type: "flush_pending_updates" }; export interface AgentStreamReducerSideEffect { type: "catch_up"; cursor: { epoch: string; endSeq: number }; - observedSeq?: number; } // --------------------------------------------------------------------------- @@ -147,7 +136,6 @@ interface TimelineResponseEntry { } export interface ProcessTimelineResponseInput { - replaceTail?: boolean; payload: { agentId: string; direction: TimelineDirection; @@ -194,12 +182,6 @@ interface TimelineUnit { timestamp: Date; } -interface HydratedTimelineEvent { - event: AgentStreamEventPayload; - timestamp: Date; - timelineCursor: SourcedPosition; -} - interface TimelinePathResult { tail: StreamItem[]; head: StreamItem[]; @@ -324,7 +306,6 @@ function classifySessionTimelineSeq({ function deriveBootstrapTailTimelinePolicy({ direction, reset, - replaceTail, epoch, endCursor, isInitializing, @@ -332,7 +313,6 @@ function deriveBootstrapTailTimelinePolicy({ }: { direction: TimelineDirection; reset: boolean; - replaceTail: boolean; epoch: string; endCursor: { seq: number } | null; isInitializing: boolean; @@ -341,7 +321,7 @@ function deriveBootstrapTailTimelinePolicy({ replace: boolean; catchUpCursor: { epoch: string; endSeq: number } | null; } { - if (reset || (replaceTail && direction === "tail")) { + if (reset) { return { replace: true, catchUpCursor: null }; } @@ -408,7 +388,11 @@ function mergeTimelineWindow(args: { currentTail: StreamItem[]; currentHead: StreamItem[]; currentCursor: TimelineCursor | undefined; - toHydratedEvents: (units: TimelineUnit[]) => HydratedTimelineEvent[]; + toHydratedEvents: (units: TimelineUnit[]) => Array<{ + event: AgentStreamEventPayload; + timestamp: Date; + timelineCursor: { epoch: string; seq: number }; + }>; }): TimelinePathResult { const { timelineUnits, payload, currentTail, currentHead, currentCursor, toHydratedEvents } = args; @@ -460,9 +444,9 @@ function mergeTimelineWindow(args: { const tail = [...reconciled.tail, ...reconciled.page] .map((item, order) => ({ item, order })) .sort((left, right) => { - const leftStart = rowStartSeq(left.item) ?? Number.POSITIVE_INFINITY; - const rightStart = rowStartSeq(right.item) ?? Number.POSITIVE_INFINITY; - return leftStart - rightStart || left.order - right.order; + const leftSeq = left.item.timelineCursor?.seq ?? Number.POSITIVE_INFINITY; + const rightSeq = right.item.timelineCursor?.seq ?? Number.POSITIVE_INFINITY; + return leftSeq - rightSeq || left.order - right.order; }) .map(({ item }) => item); const cursor = mergeTimelineCoverage(currentCursor, { @@ -517,58 +501,17 @@ function shouldResolveTimelineInit({ return responseDirection === initRequestDirection; } -export function processTimelineResponseCompletion( - input: Pick< - ProcessTimelineResponseInput, - "payload" | "isInitializing" | "hasActiveInitDeferred" | "initRequestDirection" - >, -): Pick { - const { payload, isInitializing, hasActiveInitDeferred, initRequestDirection } = input; - if (payload.error) - return { - initResolution: hasActiveInitDeferred ? "reject" : null, - clearInitializing: isInitializing, - error: payload.error, - }; - const resolves = shouldResolveTimelineInit({ - hasActiveInitDeferred, - hasNewer: payload.hasNewer, - isInitializing, - initRequestDirection, - responseDirection: payload.direction, - reset: payload.reset, - }); - const complete = payload.direction !== "after" || !payload.hasNewer; - return { - error: null, - initResolution: resolves ? "resolve" : null, - clearInitializing: (resolves || (isInitializing && !hasActiveInitDeferred)) && complete, - }; -} - -// A tail whose window ends behind the local cursor is a rewind: the daemon no longer has the rows -// between its head and that cursor, so the response supersedes everything up to the cursor it -// rewound behind. Any other page certifies only the positions it carries. -function resolveCanonicalCoverageEnd( - payload: ProcessTimelineResponseInput["payload"], - currentCursor: TimelineCursor | undefined, -): number | null { - if (currentCursor?.epoch === payload.epoch && payload.window.maxSeq < currentCursor.endSeq) { - return currentCursor.endSeq; - } - return payload.endCursor?.seq ?? null; -} - function applyTimelineReplacePath(args: { timelineUnits: TimelineUnit[]; payload: ProcessTimelineResponseInput["payload"]; bootstrapPolicy: ReturnType; currentTail: StreamItem[]; currentHead: StreamItem[]; - currentCursor: TimelineCursor | undefined; sendingClientMessageIds: readonly string[]; preserveContinuity: boolean; - toHydratedEvents: (units: TimelineUnit[]) => HydratedTimelineEvent[]; + toHydratedEvents: ( + units: TimelineUnit[], + ) => Array<{ event: AgentStreamEventPayload; timestamp: Date }>; }): TimelinePathResult { const { timelineUnits, @@ -576,7 +519,6 @@ function applyTimelineReplacePath(args: { bootstrapPolicy, currentTail, currentHead, - currentCursor, sendingClientMessageIds, preserveContinuity, toHydratedEvents, @@ -592,7 +534,7 @@ function applyTimelineReplacePath(args: { preserveContinuity, canonicalCoverage: { epoch: payload.epoch, - endSeq: resolveCanonicalCoverageEnd(payload, currentCursor), + endSeq: payload.endCursor?.seq ?? null, }, }); const cursor: TimelineCursor | null = @@ -756,17 +698,14 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea const identityMerge = mergeTimelineIdentityBoundary(olderTail, currentTail); if (identityMerge) return identityMerge; - if ( - olderLast?.kind !== "assistant_message" || - currentFirst?.kind !== "assistant_message" || - (olderLast.messageId !== undefined && - currentFirst.messageId !== undefined && - olderLast.messageId !== currentFirst.messageId) - ) { + if (olderLast?.kind !== "assistant_message" || currentFirst?.kind !== "assistant_message") { return [...olderTail, ...currentTail]; } - const mergedAssistant: AssistantMessageItem = joinTextRows(olderLast, currentFirst); + const mergedAssistant: AssistantMessageItem = { + ...currentFirst, + text: `${olderLast.text}${currentFirst.text}`, + }; if (mergedAssistant.messageId === undefined && olderLast.messageId !== undefined) { mergedAssistant.messageId = olderLast.messageId; } @@ -818,372 +757,161 @@ function mergeOlderTimelinePage(input: { return [...retainedBefore, ...mergePrependedCanonicalTail(input.page, currentAtOrAfterPage)]; } -// --------------------------------------------------------------------------- -// Forward pages merge into the ordered timeline -// -// Both lanes together are one sequence ordered by source start. A canonical unit first looks for -// the rows it owns: text units own rows sharing their provider message id or holding a chunk -// inside their source coverage, tool and plugin units own rows with their timeline identity, user -// units own the local row they acknowledge, and every other unit owns the row of its kind at its -// start position. An owned row is replaced or merged where it stands. A unit nobody owns is -// inserted at its start position, so history missed while the client was away lands before rows -// the live stream delivered later, and a tool call keeps the position it first appeared at even -// though its completion carries a newer sequence. -// --------------------------------------------------------------------------- - -interface OrderedLanes { - rows: StreamItem[]; - // rows[0, boundary) is the tail lane; the rest is the live head. - boundary: number; +function replaceLiveAssistantWithProjectedText(params: { + head: StreamItem[]; + event: AgentStreamEventPayload; + timestamp: Date; + timelineCursor: { epoch: string; seq: number }; +}): StreamItem[] | null { + const { head, event, timestamp, timelineCursor } = params; + if (event.type !== "timeline" || event.item.type !== "assistant_message") { + return null; + } + const index = head.findLastIndex((item) => item.kind === "assistant_message"); + const current = head[index]; + if (!current || current.kind !== "assistant_message") { + return null; + } + if (!event.item.text.startsWith(current.text)) { + return null; + } + const next = [...head]; + next[index] = { + ...current, + text: event.item.text, + timestamp, + timelineCursor, + }; + return next; } -function unitPosition(unit: TimelineUnit, epoch: string): SourcedPosition { - return { epoch, seq: unit.seqEnd, startSeq: unit.seq }; -} - -function insertionIndex(rows: readonly StreamItem[], epoch: string, startSeq: number): number { - const index = rows.findIndex((row) => { - const start = rowStartSeq(row); - return row.timelineCursor?.epoch === epoch && start !== undefined && start > startSeq; - }); - return index < 0 ? rows.length : index; -} - -function spliceLanes( - lanes: OrderedLanes, - index: number, - removeCount: number, - inserted: readonly StreamItem[], -): OrderedLanes { - const rows = [...lanes.rows]; - rows.splice(index, removeCount, ...inserted); - const removedFromTail = Math.max(0, Math.min(removeCount, lanes.boundary - index)); - // Rows land in the tail when they replace tail rows, sit before the head, or when there is no - // live head to join. - const insertedIntoTail = - index < lanes.boundary || removedFromTail > 0 || lanes.boundary === lanes.rows.length - ? inserted.length - : 0; - return { rows, boundary: lanes.boundary - removedFromTail + insertedIntoTail }; -} - -function hydrateUnitAfter( - prefix: readonly StreamItem[], - unit: TimelineUnit, - epoch: string, - reservedItemIds?: ReadonlySet, -): StreamItem[] { - return reduceStreamUpdate([...prefix], unit.event, unit.timestamp, { - source: "canonical", - timelineCursor: unitPosition(unit, epoch), - reservedItemIds, - }); -} - -function textUnitKind(unit: TimelineUnit): "assistant_message" | "thought" | null { - if (unit.event.type !== "timeline") return null; - if (unit.event.item.type === "assistant_message") return "assistant_message"; - if (unit.event.item.type === "reasoning") return "thought"; - return null; -} - -function unitText(unit: TimelineUnit): string { - if (unit.event.type !== "timeline") return ""; - const item = unit.event.item; - return item.type === "assistant_message" || item.type === "reasoning" ? item.text : ""; -} - -function unitMessageId(unit: TimelineUnit): string | undefined { - return unit.event.type === "timeline" && unit.event.item.type === "assistant_message" - ? unit.event.item.messageId - : undefined; -} - -function straddlesAuthoritativeCursor( - unit: TimelineUnit, - currentEndSeq: number | undefined, -): boolean { - return ( - currentEndSeq !== undefined && - unit.sourceSeqRanges.some( - (range) => range.startSeq <= currentEndSeq && range.endSeq > currentEndSeq, +function reconcileOverlappingProjectedAssistant(params: { + tail: StreamItem[]; + head: StreamItem[]; + unit: TimelineUnit; + epoch: string; + currentEndSeq: number; +}): { tail: StreamItem[]; head: StreamItem[]; reconciled: boolean } { + const { unit } = params; + if ( + unit.event.type !== "timeline" || + unit.event.item.type !== "assistant_message" || + !unit.sourceSeqRanges.some( + (range) => range.startSeq <= params.currentEndSeq && range.endSeq > params.currentEndSeq, ) - ); -} - -// Identified unpositioned markdown blocks form a contiguous segment, not an entire message: -// a tool can divide two segments with the same message id. Block text resolves coverage only -// after identity matches. Ambiguous repeated segments remain unverified rather than guessed away. -function unpositionedTextRowIndexes( - rows: readonly StreamItem[], - unit: TimelineUnit, - epoch: string, -): number[] { - const messageId = unitMessageId(unit); - if (!messageId) return []; - const canonical = splitMarkdownBlocks(unitText(unit)).join("\n\n"); - const candidates: number[][] = []; - for (let index = 0; index < rows.length; index++) { - const indexes: number[] = []; - const blocks: string[] = []; - while (index < rows.length) { - const row = rows[index]!; - if (row.kind !== "assistant_message" || row.messageId !== messageId || row.timelineCursor) - break; - indexes.push(index++); - blocks.push(...splitMarkdownBlocks(row.text)); - } - if (indexes.length === 0) continue; - const previous = rows[indexes[0]! - 1]; - const next = rows[index]; - const afterStart = - previous?.timelineCursor?.epoch === epoch && rowStartSeq(previous)! > unit.seq; - const beforeStart = next?.timelineCursor?.epoch === epoch && rowStartSeq(next)! <= unit.seq; - const live = blocks.join("\n\n"); - if (!afterStart && !beforeStart && (canonical.startsWith(live) || live.startsWith(canonical))) { - candidates.push(indexes); - } + ) { + return { tail: params.tail, head: params.head, reconciled: false }; } - return candidates.length === 1 ? candidates[0]! : []; -} -// Positioned units own covered chunks and adjacent continuations. Idless unpositioned rows keep -// the conservative cursor-straddle rule; an identified segment does not need cursor straddling. -function ownedTextRowIndexes( - rows: readonly StreamItem[], - unit: TimelineUnit, - epoch: string, - currentEndSeq: number | undefined, -): number[] { - const kind = textUnitKind(unit); - if (!kind) return []; - const messageId = unitMessageId(unit); - const sameMessage = (row: StreamItem): boolean => - row.kind === kind && - (row.kind !== "assistant_message" || - !messageId || - !row.messageId || - row.messageId === messageId); - const covered = rows - .map((row, index) => - sameMessage(row) && - row.timelineCursor?.epoch === epoch && - rowSourceSeqs(row).some((seq) => seq >= unit.seq && seq <= unit.seqEnd) - ? index - : -1, - ) - .filter((index) => index >= 0); - if (covered.length === 0) { - const unpositioned = unpositionedTextRowIndexes(rows, unit, epoch); - if (unpositioned.length > 0) return unpositioned; - } - const [firstCovered, lastCovered] = [covered[0], covered.at(-1)]; - const neighbours = - firstCovered !== undefined && lastCovered !== undefined - ? [firstCovered - 1, lastCovered + 1] - : (() => { - const index = insertionIndex(rows, epoch, unit.seq); - return [index - 1, index]; - })(); - const owned = new Set(covered); - if (messageId) { - for (const index of neighbours) { - const row = rows[index]; - if (row?.kind === "assistant_message" && row.messageId === messageId && row.timelineCursor) - owned.add(index); - } - } - if (owned.size > 0) return [...owned].sort((left, right) => left - right); - if (!straddlesAuthoritativeCursor(unit, currentEndSeq)) return []; - const text = unitText(unit); - const legacy = rows.findLastIndex( - (row) => - row.kind === kind && - !row.timelineCursor && - (!messageId || row.kind !== "assistant_message" || !row.messageId) && - text.startsWith(row.text), - ); - return legacy >= 0 ? [legacy] : []; -} + const projectedText = unit.event.item.text; + const projectedMessageId = unit.event.item.messageId; + const matches = (item: StreamItem) => { + if (item.kind !== "assistant_message") return false; + if (projectedMessageId && item.messageId) return item.messageId === projectedMessageId; + return projectedText.startsWith(item.text); + }; + const findMatch = (items: StreamItem[]) => { + const index = items.findLastIndex(matches); + const current = items[index]; + return current?.kind === "assistant_message" ? { current, index } : null; + }; -function rowSourceSeqs(row: StreamItem): number[] { - const chunks = - row.kind === "assistant_message" || row.kind === "thought" ? row.source?.chunks : undefined; - if (chunks) return chunks.map((chunk) => chunk.seq); - return row.timelineCursor ? [row.timelineCursor.seq] : []; -} - -// Fully accepted identified segments use the same representation as fresh hydration. Reserve -// other rows' identities, not the transient markdown blocks this unit replaces. -function replaceTextUnit( - lanes: OrderedLanes, - indexes: number[], - unit: TimelineUnit, - epoch: string, -): OrderedLanes { - const first = indexes[0]!; - const last = indexes.at(-1)!; - const otherRows = lanes.rows.filter((_row, index) => index < first || index > last); - const reserved = new Set( - otherRows.flatMap((row) => - row.kind === "assistant_message" && row.blockGroupId ? [row.id, row.blockGroupId] : [row.id], - ), - ); - return spliceLanes(lanes, first, last - first + 1, hydrateUnitAfter([], unit, epoch, reserved)); -} - -function applyTextUnit( - lanes: OrderedLanes, - unit: TimelineUnit, - epoch: string, - currentEndSeq: number | undefined, -): { lanes: OrderedLanes; owned: boolean } { - const kind = textUnitKind(unit); - if (!kind) return { lanes, owned: false }; - const ownedIndexes = ownedTextRowIndexes(lanes.rows, unit, epoch, currentEndSeq); - if (ownedIndexes.length === 0) { - return { lanes: insertUnitInOrder(lanes, unit, epoch), owned: false }; + const headMatch = findMatch(params.head); + const tailMatch = headMatch ? null : findMatch(params.tail); + const match = headMatch ?? tailMatch; + if (!match) { + return { tail: params.tail, head: params.head, reconciled: false }; } - const owned = ownedIndexes - .map((index) => lanes.rows[index]) - .filter((row): row is AssistantMessageItem | ThoughtItem => row?.kind === kind); - const messageId = unitMessageId(unit); - if (messageId && owned.every((row) => !row.timelineCursor)) { - const canonical = splitMarkdownBlocks(unitText(unit)).join("\n\n"); - const live = owned.flatMap((row) => splitMarkdownBlocks(row.text)).join("\n\n"); - // A lagging canonical prefix cannot position text delivered without a cursor. Keep the - // newer segment unverified; otherwise replace all its markdown blocks with exact page text. - if (live.length > canonical.length && live.startsWith(canonical)) return { lanes, owned: true }; - return { lanes: replaceTextUnit(lanes, ownedIndexes, unit, epoch), owned: true }; - } - const fullyCovered = owned.every( - (row) => - row.timelineCursor?.epoch === epoch && - rowStartSeq(row)! >= unit.seq && - row.timelineCursor.seq <= unit.seqEnd, - ); - if (messageId && fullyCovered) - return { lanes: replaceTextUnit(lanes, ownedIndexes, unit, epoch), owned: true }; - const merged: StreamItem[] = []; - for (const row of mergeCanonicalText(owned, { - text: unitText(unit), - epoch, - startSeq: unit.seq, - endSeq: unit.seqEnd, + + const blockGroupId = match.current.blockGroupId; + const messageId = projectedMessageId ?? match.current.messageId; + const replacement: AssistantMessageItem = { + kind: "assistant_message", + id: blockGroupId ?? match.current.id, + ...(messageId !== undefined ? { messageId } : {}), + text: projectedText, timestamp: unit.timestamp, - })) { - merged.push( - row.kind === "assistant_message" && messageId && row.messageId === undefined - ? { ...row, messageId } - : row, - ); - } - // Owned rows are adjacent by construction; the merge takes their place. - const first = ownedIndexes[0]!; - const last = ownedIndexes.at(-1)!; - return { lanes: spliceLanes(lanes, first, last - first + 1, merged), owned: true }; -} + timelineCursor: { epoch: params.epoch, seq: unit.seqEnd }, + }; + const belongsToBlockGroup = (item: StreamItem) => + blockGroupId !== undefined && + item.kind === "assistant_message" && + item.blockGroupId === blockGroupId; + const removeBlockGroup = (items: StreamItem[]) => + blockGroupId !== undefined ? items.filter((item) => !belongsToBlockGroup(item)) : items; + const replaceMatch = (items: StreamItem[], index: number) => { + if (!blockGroupId) { + const next = [...items]; + next[index] = replacement; + return next; + } + const next: StreamItem[] = []; + let inserted = false; + for (const item of items) { + if (!belongsToBlockGroup(item)) { + next.push(item); + } else if (!inserted) { + next.push(replacement); + inserted = true; + } + } + return next; + }; -// Reducing against the rows before the position lets kind-specific rules (todo diffs, -// compaction completion, idless assistant continuation) see their predecessor while the unit -// still lands in source order. Ids stay unique against the rows after it. -function insertUnitInOrder(lanes: OrderedLanes, unit: TimelineUnit, epoch: string): OrderedLanes { - const index = insertionIndex(lanes.rows, epoch, unit.seq); - const rest = lanes.rows.slice(index); - const identifiedText = Boolean(unitMessageId(unit)); - const reserved = new Set( - (identifiedText ? lanes.rows : rest).flatMap((row) => - row.kind === "assistant_message" && row.blockGroupId ? [row.id, row.blockGroupId] : [row.id], - ), - ); - // Reconciliation already declined ownership. Do not let ordinary live continuation merge - // this identified canonical segment into an ambiguous same-id predecessor during insertion. - if (identifiedText) - return spliceLanes(lanes, index, 0, hydrateUnitAfter([], unit, epoch, reserved)); - const created = hydrateUnitAfter(lanes.rows.slice(0, index), unit, epoch, reserved); - const prefixReplaced = { ...lanes, rows: [...created.slice(0, index), ...rest] }; - return spliceLanes(prefixReplaced, index, 0, created.slice(index)); -} - -function applyUserUnit( - lanes: OrderedLanes, - unit: TimelineUnit, - epoch: string, -): { lanes: OrderedLanes; acknowledgedClientMessageIds: string[] } { - if (unit.event.type !== "timeline" || unit.event.item.type !== "user_message") { - return { lanes, acknowledgedClientMessageIds: [] }; + if (headMatch) { + return { + tail: removeBlockGroup(params.tail), + head: replaceMatch(params.head, headMatch.index), + reconciled: true, + }; } - const probe = hydrateUnitAfter([], unit, epoch).find( - (row): row is UserMessageItem => row.kind === "user_message", - ); - if (!probe) return { lanes, acknowledgedClientMessageIds: [] }; - const matched = upsertUserMessageAcrossStream({ - tail: lanes.rows.slice(0, lanes.boundary), - head: lanes.rows.slice(lanes.boundary), - message: probe, - insert: "none", - presentation: "existing", - }); - const location = matched.location; - if (!location?.matched) { - return { lanes: insertUnitInOrder(lanes, unit, epoch), acknowledgedClientMessageIds: [] }; - } - const existingIndex = location.lane === "tail" ? location.index : lanes.boundary + location.index; - const message = withCanonicalTurn(location.message, unit.event.turnId); - const removed = spliceLanes(lanes, existingIndex, 1, []); - const index = insertionIndex(removed.rows, epoch, unit.seq); return { - lanes: spliceLanes(removed, index, 0, [message]), - acknowledgedClientMessageIds: message.clientMessageId ? [message.clientMessageId] : [], + tail: replaceMatch(params.tail, match.index), + head: removeBlockGroup(params.head), + reconciled: true, }; } -// A canonical user row is authoritative for turn membership. -function withCanonicalTurn(message: UserMessageItem, turnId: string | undefined): UserMessageItem { - if (message.turnId === turnId) return message; - if (turnId) return { ...message, turnId }; - const { turnId: _, ...withoutTurn } = message; - return withoutTurn; -} - -function applyOtherUnit(lanes: OrderedLanes, unit: TimelineUnit, epoch: string): OrderedLanes { - const probe = hydrateUnitAfter([], unit, epoch).at(-1); - if (!probe) return lanes; - const identity = streamTimelineItemIdentity(probe); - const existingIndex = lanes.rows.findIndex((row) => - identity !== null - ? streamTimelineItemIdentity(row) === identity - : row.kind === probe.kind && - row.timelineCursor?.epoch === epoch && - rowStartSeq(row) === unit.seq, - ); - if (existingIndex >= 0) { - const existing = lanes.rows[existingIndex]!; - const replacement = - probe.kind === "notification" - ? { ...probe, id: existing.id } - : (hydrateUnitAfter([existing], unit, epoch).at(-1) ?? existing); - return spliceLanes(lanes, existingIndex, 1, [{ ...replacement, id: existing.id }]); +function reconcileOverlappingProjectedReasoning(params: { + tail: StreamItem[]; + head: StreamItem[]; + unit: TimelineUnit; + currentEndSeq: number; +}): { tail: StreamItem[]; head: StreamItem[]; reconciled: boolean } { + const { unit } = params; + if ( + unit.event.type !== "timeline" || + unit.event.item.type !== "reasoning" || + !unit.sourceSeqRanges.some( + (range) => range.startSeq <= params.currentEndSeq && range.endSeq > params.currentEndSeq, + ) + ) { + return { tail: params.tail, head: params.head, reconciled: false }; } - return insertUnitInOrder(lanes, unit, epoch); -} -function applyForwardUnit( - lanes: OrderedLanes, - unit: TimelineUnit, - epoch: string, - currentEndSeq: number | undefined, -): { lanes: OrderedLanes; acknowledgedClientMessageIds: string[] } { - if (textUnitKind(unit)) { - return { - lanes: applyTextUnit(lanes, unit, epoch, currentEndSeq).lanes, - acknowledgedClientMessageIds: [], + const projectedText = unit.event.item.text; + const replaceIn = (items: StreamItem[]): StreamItem[] | null => { + const index = items.findLastIndex( + (item) => item.kind === "thought" && projectedText.startsWith(item.text), + ); + const current = items[index]; + if (!current || current.kind !== "thought") return null; + const next = [...items]; + next[index] = { + ...current, + text: projectedText, + timestamp: unit.timestamp, + status: "loading", }; - } - if (unit.event.type === "timeline" && unit.event.item.type === "user_message") { - return applyUserUnit(lanes, unit, epoch); - } - return { lanes: applyOtherUnit(lanes, unit, epoch), acknowledgedClientMessageIds: [] }; + return next; + }; + + const nextHead = replaceIn(params.head); + if (nextHead) return { tail: params.tail, head: nextHead, reconciled: true }; + const nextTail = replaceIn(params.tail); + return nextTail + ? { tail: nextTail, head: params.head, reconciled: true } + : { tail: params.tail, head: params.head, reconciled: false }; } function reconcileOverlappingProjectedStreamItems(params: { @@ -1193,22 +921,109 @@ function reconcileOverlappingProjectedStreamItems(params: { epoch: string; currentEndSeq: number | undefined; }): { tail: StreamItem[]; head: StreamItem[]; reconciledUnits: Set } { - let lanes: OrderedLanes = { - rows: [...params.tail, ...params.head], - boundary: params.tail.length, - }; + let tail = params.tail; + let head = params.head; const reconciledUnits = new Set(); + if (params.currentEndSeq === undefined) return { tail, head, reconciledUnits }; + for (const unit of params.units) { - if (!textUnitKind(unit)) continue; - if (ownedTextRowIndexes(lanes.rows, unit, params.epoch, params.currentEndSeq).length === 0) - continue; - lanes = applyTextUnit(lanes, unit, params.epoch, params.currentEndSeq).lanes; - reconciledUnits.add(unit); + let reconciled = reconcileOverlappingProjectedAssistant({ + tail, + head, + unit, + epoch: params.epoch, + currentEndSeq: params.currentEndSeq, + }); + if (!reconciled.reconciled) { + reconciled = reconcileOverlappingProjectedReasoning({ + tail, + head, + unit, + currentEndSeq: params.currentEndSeq, + }); + } + tail = reconciled.tail; + head = reconciled.head; + if (reconciled.reconciled) reconciledUnits.add(unit); } + return { tail, head, reconciledUnits }; +} + +function applyCanonicalForwardUnit(params: { + tail: StreamItem[]; + head: StreamItem[]; + unit: TimelineUnit; + epoch: string; +}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } { + const { event, timestamp, seqEnd } = params.unit; + const timelineCursor = { epoch: params.epoch, seq: seqEnd }; + if (event.type === "timeline" && event.item.type === "user_message") { + const applied = applyStreamEvent({ + tail: params.tail, + head: params.head, + event, + timestamp, + source: "canonical", + timelineCursor, + }); + return { + tail: applied.tail, + head: applied.head, + acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [], + }; + } + if (params.head.length === 0) { + return { + tail: reduceStreamUpdate(params.tail, event, timestamp, { + source: "canonical", + timelineCursor, + }), + head: params.head, + acknowledgedClientMessageIds: [], + }; + } + const replacedHead = replaceLiveAssistantWithProjectedText({ + head: params.head, + event, + timestamp, + timelineCursor, + }); + if (replacedHead) { + return { tail: params.tail, head: replacedHead, acknowledgedClientMessageIds: [] }; + } + + const activeAssistant = params.head.findLast( + (item): item is Extract => + item.kind === "assistant_message", + ); + if ( + event.type === "timeline" && + event.item.type === "assistant_message" && + event.item.messageId !== undefined && + event.item.messageId !== activeAssistant?.messageId + ) { + return { + tail: flushHeadToTail(params.tail, params.head), + head: reduceStreamUpdate([], event, timestamp, { + source: "canonical", + timelineCursor, + }), + acknowledgedClientMessageIds: [], + }; + } + + const applied = applyStreamEvent({ + tail: params.tail, + head: params.head, + event, + timestamp, + source: "canonical", + timelineCursor, + }); return { - tail: lanes.rows.slice(0, lanes.boundary), - head: lanes.rows.slice(lanes.boundary), - reconciledUnits, + tail: applied.tail, + head: applied.head, + acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [], }; } @@ -1219,22 +1034,37 @@ function applyAcceptedForwardTimelineUnits(params: { currentHead: StreamItem[]; currentEndSeq: number | undefined; }): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } { - let lanes: OrderedLanes = { - rows: [...params.currentTail, ...params.currentHead], - boundary: params.currentTail.length, - }; - const acknowledged = new Set(); + const reconciled = reconcileOverlappingProjectedStreamItems({ + tail: params.currentTail, + head: params.currentHead, + units: params.units, + epoch: params.epoch, + currentEndSeq: params.currentEndSeq, + }); + let tail = reconciled.tail; + let head = reconciled.head; + for (const unit of params.units) { - const applied = applyForwardUnit(lanes, unit, params.epoch, params.currentEndSeq); - lanes = applied.lanes; - for (const clientMessageId of applied.acknowledgedClientMessageIds) { - acknowledged.add(clientMessageId); - } + if (reconciled.reconciledUnits.has(unit)) continue; + const applied = applyCanonicalForwardUnit({ + tail, + head, + unit, + epoch: params.epoch, + }); + tail = applied.tail; + head = applied.head; } + return { - tail: lanes.rows.slice(0, lanes.boundary), - head: lanes.rows.slice(lanes.boundary), - acknowledgedClientMessageIds: [...acknowledged], + tail, + head, + acknowledgedClientMessageIds: deriveCanonicalAcknowledgements({ + units: params.units, + epoch: params.epoch, + currentTail: params.currentTail, + currentHead: params.currentHead, + }), }; } @@ -1244,15 +1074,14 @@ function deriveCanonicalAcknowledgements(params: { currentTail: StreamItem[]; currentHead: StreamItem[]; }): string[] { - let lanes: OrderedLanes = { - rows: [...params.currentTail, ...params.currentHead], - boundary: params.currentTail.length, - }; + let tail = params.currentTail; + let head = params.currentHead; const acknowledged = new Set(); for (const unit of params.units) { if (unit.event.type !== "timeline" || unit.event.item.type !== "user_message") continue; - const applied = applyUserUnit(lanes, unit, params.epoch); - lanes = applied.lanes; + const applied = applyCanonicalForwardUnit({ tail, head, unit, epoch: params.epoch }); + tail = applied.tail; + head = applied.head; for (const clientMessageId of applied.acknowledgedClientMessageIds) { acknowledged.add(clientMessageId); } @@ -1326,10 +1155,10 @@ function applyAcceptedTimelinePage(input: { }); } const olderTail = hydrateStreamState( - acceptedUnits.map((unit) => ({ - event: unit.event, - timestamp: unit.timestamp, - timelineCursor: unitPosition(unit, payload.epoch), + acceptedUnits.map(({ event, timestamp, seqEnd }) => ({ + event, + timestamp, + timelineCursor: { epoch: payload.epoch, seq: seqEnd }, })), { source: "canonical", @@ -1430,6 +1259,7 @@ export function processTimelineResponse( currentCursor, isInitializing, hasActiveInitDeferred, + initRequestDirection, sendingClientMessageIds, } = input; @@ -1444,7 +1274,9 @@ export function processTimelineResponse( cursor: currentCursor, cursorChanged: false, older: "unchanged", - ...processTimelineResponseCompletion(input), + initResolution: hasActiveInitDeferred ? "reject" : null, + clearInitializing: isInitializing, + error: payload.error, sideEffects: [], acknowledgedClientMessageIds: [], }; @@ -1469,18 +1301,23 @@ export function processTimelineResponse( timestamp: new Date(entry.timestamp), })); - const toHydratedEvents = (units: TimelineUnit[]): HydratedTimelineEvent[] => - units.map((unit) => ({ - event: unit.event, - timestamp: unit.timestamp, - timelineCursor: unitPosition(unit, payload.epoch), + const toHydratedEvents = ( + units: TimelineUnit[], + ): Array<{ + event: AgentStreamEventPayload; + timestamp: Date; + timelineCursor: { epoch: string; seq: number }; + }> => + units.map(({ event, timestamp, seqEnd }) => ({ + event, + timestamp, + timelineCursor: { epoch: payload.epoch, seq: seqEnd }, })); // ------------------------------------------------------------------ // Derive bootstrap policy (replace vs incremental) // ------------------------------------------------------------------ const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({ - replaceTail: input.replaceTail === true, direction: payload.direction, reset: payload.reset, epoch: payload.epoch, @@ -1535,7 +1372,6 @@ export function processTimelineResponse( : bootstrapPolicy, currentTail, currentHead, - currentCursor, sendingClientMessageIds, preserveContinuity: shouldPreserveReplacementContinuity({ isResumeReplacement, @@ -1574,6 +1410,20 @@ export function processTimelineResponse( // ------------------------------------------------------------------ // Init resolution // ------------------------------------------------------------------ + const shouldResolveDeferredInit = shouldResolveTimelineInit({ + hasActiveInitDeferred, + hasNewer: payload.hasNewer, + isInitializing, + initRequestDirection, + responseDirection: payload.direction, + reset: payload.reset, + }); + const timelineResponseComplete = payload.direction !== "after" || !payload.hasNewer; + const clearInitializing = + (shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred)) && + timelineResponseComplete; + + const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit ? "resolve" : null; const commit = discard ? "discard" : "apply"; return { @@ -1583,7 +1433,9 @@ export function processTimelineResponse( cursor: nextCursor, cursorChanged, older: timelineResult.older, - ...processTimelineResponseCompletion(input), + initResolution, + clearInitializing, + error: null, sideEffects, acknowledgedClientMessageIds: timelineResult.acknowledgedClientMessageIds, }; @@ -1650,7 +1502,13 @@ export interface AgentStreamReducerQueue { } export interface CreateAgentStreamReducerQueueInput { - apply: (agentId: string, events: AgentStreamReducerEvent[]) => void; + getSnapshot: (agentId: string) => AgentStreamReducerSnapshot; + commit: ( + agentId: string, + result: ProcessAgentStreamEventOutput, + events: AgentStreamReducerEvent[], + ) => void; + handleSideEffects: (agentId: string, sideEffects: AgentStreamReducerSideEffect[]) => void; scheduleFlush: (callback: () => void) => number; cancelFlush: (id: number) => void; } @@ -1710,7 +1568,6 @@ function processTimelineSequencingGate(input: { { type: "catch_up", cursor: { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq }, - observedSeq: seq, }, ] : [], @@ -1912,7 +1769,15 @@ export function createAgentStreamReducerQueue( cancelScheduledFlush(); } - input.apply(agentId, events); + const result = processAgentStreamEvents({ + events, + ...input.getSnapshot(agentId), + }); + + input.commit(agentId, result, events); + if (result.sideEffects.length > 0) { + input.handleSideEffects(agentId, result.sideEffects); + } }; const flush = () => { @@ -1955,6 +1820,13 @@ export function createAgentStreamReducerQueue( }; } +interface StreamStatePatch { + tail?: StreamItem[]; + head?: StreamItem[]; + acknowledgedClientMessageIds?: readonly string[]; + taskSnapshot?: TodoEntry[]; +} + export function deriveAgentStreamTurnLiveness( events: readonly AgentStreamReducerEvent[], ): TurnLivenessTransition[] { @@ -1976,6 +1848,17 @@ export function deriveAgentStreamTurnLiveness( return transitions; } +export interface CreateSessionAgentStreamReducerQueueInput { + serverId: string; + setAgentStreamState: (serverId: string, agentId: string, state: StreamStatePatch) => void; + setAgentTimelineCursor: ( + serverId: string, + state: (prev: Map) => Map, + ) => void; + recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void; + onCommitted?: (agentId: string) => void; +} + interface ScheduledReducerFlush { frameId: number | null; timerId: ReturnType; @@ -1995,7 +1878,7 @@ function clearScheduledReducerFlush(handle: ScheduledReducerFlush): void { // an arbitrary timer that drifts on and off the display beat. A frame callback // never fires in a hidden tab, so a timer races it and wins when nothing is // painting — the store has to keep advancing either way. -export function scheduleAgentStreamReducerFlush(callback: () => void): number { +function scheduleAgentStreamReducerFlush(callback: () => void): number { const id = nextScheduledReducerFlushId; nextScheduledReducerFlushId += 1; @@ -2015,7 +1898,7 @@ export function scheduleAgentStreamReducerFlush(callback: () => void): number { return id; } -export function cancelAgentStreamReducerFlush(id: number) { +function cancelAgentStreamReducerFlush(id: number) { const handle = scheduledReducerFlushes.get(id); if (!handle) { return; @@ -2023,3 +1906,80 @@ export function cancelAgentStreamReducerFlush(id: number) { scheduledReducerFlushes.delete(id); clearScheduledReducerFlush(handle); } + +export function createSessionAgentStreamReducerQueue( + input: CreateSessionAgentStreamReducerQueueInput, +): AgentStreamReducerQueue { + const { serverId, setAgentStreamState, setAgentTimelineCursor, recoverTimelineGap, onCommitted } = + input; + + return createAgentStreamReducerQueue({ + getSnapshot: (agentId) => { + const session = useSessionStore.getState().sessions[serverId]; + const timeline = selectAgentTimelineState(session, agentId); + return { + currentTail: timeline.status === "cold" ? [] : timeline.items, + currentHead: session?.agentStreamHead.get(agentId) ?? [], + currentCursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined, + hasAuthoritativeBaseline: timeline.status === "synced", + isDetached: timeline.status === "synced" && timeline.newer === "available", + }; + }, + commit: (agentId, result, events) => { + if ( + result.changedTail || + result.changedHead || + result.acknowledgedClientMessageIds.length > 0 || + result.taskSnapshot !== undefined + ) { + setAgentStreamState(serverId, agentId, { + ...(result.changedTail ? { tail: result.tail } : {}), + ...(result.changedHead ? { head: result.head } : {}), + ...(result.acknowledgedClientMessageIds.length > 0 + ? { acknowledgedClientMessageIds: result.acknowledgedClientMessageIds } + : {}), + ...(result.taskSnapshot !== undefined ? { taskSnapshot: result.taskSnapshot } : {}), + }); + } + if (result.cursorChanged && result.cursor) { + const nextCursor = result.cursor; + const lastEvent = events.at(-1); + setAgentTimelineCursor(serverId, (prev) => { + const current = prev.get(agentId); + if ( + current && + lastEvent && + typeof lastEvent.seq === "number" && + typeof lastEvent.epoch === "string" && + current.epoch === lastEvent.epoch && + lastEvent.seq >= current.startSeq && + lastEvent.seq <= current.endSeq + ) { + return prev; + } + if ( + current && + current.epoch === nextCursor.epoch && + current.startSeq === nextCursor.startSeq && + current.endSeq === nextCursor.endSeq + ) { + return prev; + } + const next = new Map(prev); + next.set(agentId, nextCursor); + return next; + }); + } + onCommitted?.(agentId); + }, + handleSideEffects: (agentId, sideEffects) => { + for (const effect of sideEffects) { + if (effect.type === "catch_up") { + recoverTimelineGap(agentId, effect.cursor); + } + } + }, + scheduleFlush: scheduleAgentStreamReducerFlush, + cancelFlush: cancelAgentStreamReducerFlush, + }); +} diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts index e38430ffdb..8c7c156e6f 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -1,6 +1,10 @@ import { expect, test, vi } from "vitest"; import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan"; -import { createViewedTimelineSync } from "./viewed-timeline-sync"; +import { + consumeForcedTimelineTailReplacement, + createViewedTimelineSync, + type TimelineResponsePayload, +} from "./viewed-timeline-sync"; interface Deferred { promise: Promise; @@ -27,7 +31,7 @@ interface MembershipRequest { interface TimelineFetch { agentId: string; request: ProjectedTimelineForwardFetchPlan; - respond(input: { hasNewer: boolean; seq?: number | null }): void; + respond(input: { hasNewer: boolean; seq?: number }): void; fail(message: string): void; } @@ -35,6 +39,7 @@ class TimelineWorld { readonly errors: string[] = []; readonly cursors = new Map(); readonly cacheRequests: string[] = []; + readonly forcedTimelineTailReplacements = new Set(); cacheGate: Deferred | null = null; readonly sync = createViewedTimelineSync({ replaceDemandedAgentIds: () => undefined, @@ -66,7 +71,7 @@ class TimelineWorld { respond: ({ hasNewer, seq = 1 }) => result.resolve({ hasNewer, - endCursor: seq === null ? null : { epoch: `epoch-${agentId}`, seq }, + endCursor: { epoch: `epoch-${agentId}`, seq }, }), fail: (message) => result.reject(new Error(message)), }); @@ -74,11 +79,16 @@ class TimelineWorld { return result.promise; }, fetchLatestTail: async (agentId) => { - return await this.fetchTimeline(agentId, { - direction: "tail", - limit: 40, - projection: "projected", - }); + this.forcedTimelineTailReplacements.add(agentId); + try { + return await this.fetchTimeline(agentId, { + direction: "tail", + limit: 40, + projection: "projected", + }); + } finally { + this.forcedTimelineTailReplacements.delete(agentId); + } }, reportError: (error) => { this.errors.push(error instanceof Error ? error.message : String(error)); @@ -120,6 +130,10 @@ class TimelineWorld { return this.fetches.length; } + applyTimelineResponse(payload: TimelineResponsePayload): TimelineResponsePayload { + return consumeForcedTimelineTailReplacement(payload, this.forcedTimelineTailReplacements); + } + nextCacheRequest(): Promise { const request = this.cacheRequests.at(-1); if (request) return Promise.resolve(request); @@ -138,10 +152,7 @@ class TimelineWorld { agentId, request, respond: ({ hasNewer, seq = 1 }) => - result.resolve({ - hasNewer, - endCursor: seq === null ? null : { epoch: `epoch-${agentId}`, seq }, - }), + result.resolve({ hasNewer, endCursor: { epoch: `epoch-${agentId}`, seq } }), fail: (message) => result.reject(new Error(message)), }); this.releaseFetchWaiters(); @@ -211,7 +222,7 @@ class TimelineWorld { test("uses a tail fetch when an agent becomes visible", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -224,7 +235,7 @@ test("loads the cache before choosing the authoritative network request", async const world = new TimelineWorld(); world.cacheGate = deferred(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); const cacheRequest = await world.nextCacheRequest(); @@ -245,22 +256,11 @@ test("loads the cache before choosing the authoritative network request", async fetch.respond({ hasNewer: false }); }); -test("a fresh visible agent lifetime performs a fresh cache preparation", async () => { - const world = new TimelineWorld(); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); - await vi.waitFor(() => expect(world.cacheRequests).toEqual(["agent-a"])); - - world.sync.endAgentLifetime("agent-a"); - world.sync.beginAgentLifetime("agent-a"); - - await vi.waitFor(() => expect(world.cacheRequests).toEqual(["agent-a", "agent-a"])); -}); - test("catches up after the restored cursor when an agent becomes visible", async () => { const world = new TimelineWorld(); world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 }); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -278,7 +278,7 @@ test("falls back to the latest tail when a restored cursor has more than one cat const world = new TimelineWorld(); world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 }); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -288,168 +288,63 @@ test("falls back to the latest tail when a restored cursor has more than one cat const fallback = await world.nextFetch("agent-a"); expect(fallback.request).toEqual({ direction: "tail", limit: 40, projection: "projected" }); + expect( + world.applyTimelineResponse({ + requestId: "fallback-tail", + agentId: "agent-a", + agent: null, + direction: "tail", + projection: "projected", + epoch: "epoch-agent-a", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 43, maxSeq: 82, nextSeq: 83 }, + startCursor: { epoch: "epoch-agent-a", seq: 43 }, + endCursor: { epoch: "epoch-agent-a", seq: 82 }, + hasOlder: true, + hasNewer: false, + entries: [], + error: null, + }).reset, + ).toBe(true); fallback.respond({ hasNewer: false }); }); test("a gap absorbed by a running tail is recovered after the tail completes", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); const tail = await world.nextFetch("agent-a"); - // A live row at 12 could not be applied while the tail was in flight. - world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 9 }, 12); + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 9 }); world.expectNoPendingFetch(); - world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); - tail.respond({ hasNewer: false, seq: 10 }); + tail.respond({ hasNewer: false }); - // The tail settled coverage at 10; the owner continues from there, not from the stale gap cursor. const recovery = await world.nextFetch("agent-a"); expect(recovery.request).toEqual({ direction: "after", - cursor: { epoch: "epoch-agent-a", seq: 10 }, + cursor: { epoch: "epoch-agent-a", seq: 9 }, limit: 40, projection: "projected", }); - world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 12 }); - recovery.respond({ hasNewer: false, seq: 12 }); + recovery.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - world.expectNoPendingFetch(); -}); - -test("a page that predates observed activity does not complete the catch-up", async () => { - const world = new TimelineWorld(); - world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); - const membership = await world.nextMembership(); - membership.succeed(); - const initial = await world.nextFetch("agent-a"); - world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); - initial.respond({ hasNewer: false, seq: 10 }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - - world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 15); - const page = await world.nextFetch("agent-a"); - expect(page.request).toMatchObject({ direction: "after", cursor: { seq: 10 } }); - // More live rows arrive while that page is in transit; the page was built before them. - world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 20); - world.expectNoPendingFetch(); - world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 15 }); - page.respond({ hasNewer: false, seq: 15 }); - - const rest = await world.nextFetch("agent-a"); - expect(rest.request).toMatchObject({ direction: "after", cursor: { seq: 15 } }); - world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 20 }); - rest.respond({ hasNewer: false, seq: 20 }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - world.expectNoPendingFetch(); -}); - -test.each([null, 10])( - "activity learned during a non-advancing request is fetched immediately without a sync error (end=%s)", - async (end) => { - const world = new TimelineWorld(); - world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); - (await world.nextMembership()).succeed(); - const initial = await world.nextFetch("agent-a"); - world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); - initial.respond({ hasNewer: false, seq: 10 }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 15); - const old = await world.nextFetch("agent-a"); - world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 25); - world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 20); - old.respond({ hasNewer: false, seq: end }); - const fresh = await world.nextFetch("agent-a"); - expect(world.errors).toEqual([]); - expect(fresh.request).toMatchObject({ direction: "after", cursor: { seq: 10 } }); - world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 25 }); - fresh.respond({ hasNewer: false, seq: 25 }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - world.expectNoPendingFetch(); - world.sync.dispose(); - }, -); - -test("no progress against an obligation already known at request start retains it with bounded backoff", async () => { - const world = new TimelineWorld(); - world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); - const membership = await world.nextMembership(); - membership.succeed(); - const initial = await world.nextFetch("agent-a"); - world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); - initial.respond({ hasNewer: false, seq: 10 }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - - world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 20); - const page = await world.nextFetch("agent-a"); - page.respond({ hasNewer: false, seq: 10 }); - - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error")); - expect(world.errors).toEqual([ - "Timeline coverage for agent-a has not reached observed sequence 20", - ]); - world.expectNoPendingFetch(); - const retry = await world.nextRetry(1000); - retry(); - const fresh = await world.nextFetch("agent-a"); - expect(fresh.request).toMatchObject({ direction: "after", cursor: { seq: 10 } }); - world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 20 }); - fresh.respond({ hasNewer: false, seq: 20 }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - world.sync.dispose(); -}); - -test("a daemon claiming another page without advancing cannot cause a busy loop", async () => { - const world = new TimelineWorld(); - world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); - world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); - (await world.nextMembership()).succeed(); - const first = await world.nextFetch("agent-a"); - first.respond({ hasNewer: true, seq: 10 }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error")); - expect(world.errors).toEqual(["Timeline page for agent-a did not advance its cursor"]); - world.expectNoPendingFetch(); - await world.nextRetry(1000); - world.sync.dispose(); -}); - -test("an observed head from a previous epoch does not extend a catch-up", async () => { - const world = new TimelineWorld(); - world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); - const membership = await world.nextMembership(); - membership.succeed(); - const initial = await world.nextFetch("agent-a"); - world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); - initial.respond({ hasNewer: false, seq: 10 }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - - world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 20); - const page = await world.nextFetch("agent-a"); - world.cursors.set("agent-a", { epoch: "epoch-next", endSeq: 3 }); - page.respond({ hasNewer: false, seq: 3 }); - - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - world.expectNoPendingFetch(); }); test("unchanged visible-set publication does not cancel paged catch-up", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending"); const membership = await world.nextMembership(); membership.succeed(); const firstPage = await world.nextFetch("agent-a"); - world.sync.registerVisibleAgentIds("workspace", ["agent-a", "agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-a"]); firstPage.respond({ hasNewer: true, seq: 5 }); const secondPage = await world.nextFetch("agent-a"); expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending"); @@ -471,7 +366,7 @@ test("unchanged visible-set publication does not cancel paged catch-up", async ( test("all acknowledged agents begin catch-up independently", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-b", "agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-b", "agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -488,7 +383,7 @@ test("all acknowledged agents begin catch-up independently", async () => { test("an eviction during acknowledgement never catches up the stale hot set", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", [ + world.sync.replaceVisibleAgentIds("workspace", [ "agent-a", "agent-b", "agent-c", @@ -496,7 +391,7 @@ test("an eviction during acknowledgement never catches up the stale hot set", as "agent-e", ]); const staleMembership = await world.nextMembership(); - world.sync.registerVisibleAgentIds("workspace", ["agent-f"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]); staleMembership.succeed(); const currentMembership = await world.nextMembership(); currentMembership.succeed(); @@ -517,7 +412,7 @@ test("an eviction during acknowledgement never catches up the stale hot set", as test("disconnect cancels paging and reconnect restores membership before fresh catch-up", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const firstMembership = await world.nextMembership(); firstMembership.succeed(); const stalePage = await world.nextFetch("agent-a"); @@ -539,8 +434,8 @@ test("disconnect cancels paging and reconnect restores membership before fresh c test("navigation while disconnected reconnects only the currently visible agent", async () => { const world = new TimelineWorld(); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); - world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); world.sync.setConnected(true); const membership = await world.nextMembership(); @@ -554,8 +449,8 @@ test("navigation while disconnected reconnects only the currently visible agent" test("overlapping sources deduplicate membership and retain hidden hot agents", async () => { const world = new TimelineWorld(); - world.sync.registerVisibleAgentIds("left-route", ["agent-a"]); - world.sync.registerVisibleAgentIds("right-route", ["agent-a", "agent-b"]); + world.sync.replaceVisibleAgentIds("left-route", ["agent-a"]); + world.sync.replaceVisibleAgentIds("right-route", ["agent-a", "agent-b"]); world.sync.setConnected(true); const combined = await world.nextMembership(); combined.succeed(); @@ -566,37 +461,19 @@ test("overlapping sources deduplicate membership and retain hidden hot agents", agentA.respond({ hasNewer: false }); agentB.respond({ hasNewer: false }); - world.sync.registerVisibleAgentIds("left-route", []); + world.sync.replaceVisibleAgentIds("left-route", []); world.expectNoPendingMembership(); - world.sync.registerVisibleAgentIds("right-route", ["agent-b"]); + world.sync.replaceVisibleAgentIds("right-route", ["agent-b"]); expect(combined.agentIds).toEqual(["agent-a", "agent-b"]); world.expectNoPendingMembership(); world.expectNoPendingFetch(); }); -test("a replacement screen declaration survives obsolete cleanup and clears on final unmount", async () => { - const world = new TimelineWorld(); - const initial = world.sync.registerVisibleAgentIds("host:workspace", ["agent-a"]); - const replacement = world.sync.registerVisibleAgentIds("host:workspace", ["agent-b"]); - - initial.dispose(); - replacement.replace(["agent-c"]); - world.sync.setConnected(true); - const membership = await world.nextMembership(); - expect(membership.agentIds).toEqual(["agent-c"]); - membership.succeed(); - - replacement.dispose(); - replacement.replace(["agent-d"]); - world.expectNoPendingMembership(); - expect(world.cacheRequests).not.toContain("agent-d"); -}); - test("a failed catch-up reports once and retries through the explicit retry policy", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); const failed = await world.nextFetch("agent-a"); @@ -620,7 +497,7 @@ test("a failed catch-up reports once and retries through the explicit retry poli test("a failed catch-up retries with exponential backoff", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -649,7 +526,7 @@ test("a failed catch-up retries with exponential backoff", async () => { test("manual retries can immediately re-attempt a failed catch-up", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -669,7 +546,7 @@ test("manual retries can immediately re-attempt a failed catch-up", async () => test("redeclaring unchanged visibility does not bypass catch-up backoff", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -677,7 +554,7 @@ test("redeclaring unchanged visibility does not bypass catch-up backoff", async failed.fail("timeline unavailable"); await world.nextRetry(); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); world.expectNoPendingMembership(); world.expectNoPendingFetch(); @@ -687,7 +564,7 @@ test("redeclaring unchanged visibility does not bypass catch-up backoff", async test("a manual retry that fails returns to the error state", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -706,15 +583,14 @@ test("a manual retry that fails returns to the error state", async () => { test("gap recovery supersedes completed catch-up and pages through the current tail", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); const initial = await world.nextFetch("agent-a"); initial.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); - world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 12); + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); const gapPage = await world.nextFetch("agent-a"); gapPage.respond({ hasNewer: true, seq: 15 }); const finalPage = await world.nextFetch("agent-a"); @@ -739,7 +615,7 @@ test("gap recovery supersedes completed catch-up and pages through the current t test("repeated recovery for the same running gap reuses the in-flight fetch", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); const initial = await world.nextFetch("agent-a"); @@ -758,7 +634,7 @@ test("repeated recovery for the same running gap reuses the in-flight fetch", as test("membership failure autonomously retries without another visibility declaration", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); const [error, retryMembership] = await Promise.all([world.nextError(), world.nextRetry()]); @@ -782,7 +658,7 @@ test("membership failure autonomously retries without another visibility declara test("membership failures retry with exponential backoff", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const first = await world.nextMembership(); first.fail("subscription unavailable"); @@ -810,13 +686,13 @@ test("membership failures retry with exponential backoff", async () => { test("redeclaring unchanged visibility does not bypass membership backoff", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); await world.nextRetry(); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); world.expectNoPendingMembership(); world.expectNoPendingFetch(); @@ -826,13 +702,13 @@ test("redeclaring unchanged visibility does not bypass membership backoff", asyn test("backgrounding preserves the hot membership without catch-up on return", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const agentAMembership = await world.nextMembership(); agentAMembership.succeed(); const agentACatchUp = await world.nextFetch("agent-a"); agentACatchUp.respond({ hasNewer: false }); - world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); const agentBMembership = await world.nextMembership(); agentBMembership.succeed(); const agentBCatchUp = await world.nextFetch("agent-b"); @@ -851,12 +727,12 @@ test("backgrounding preserves the hot membership without catch-up on return", as test("stale membership retry cannot overwrite a newer effective set", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); const staleRetry = await world.nextRetry(); - world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); const current = await world.nextMembership(); staleRetry(); current.succeed(); @@ -874,7 +750,7 @@ test("stale membership retry cannot overwrite a newer effective set", async () = test("membership retry cannot run while disconnected", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); const disconnectedRetry = await world.nextRetry(); @@ -894,13 +770,13 @@ test("membership retry cannot run while disconnected", async () => { test("returning to a hot hidden agent stays live after inactivity", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const initialMembership = await world.nextMembership(); initialMembership.succeed(); const agentACatchUp = await world.nextFetch("agent-a"); agentACatchUp.respond({ hasNewer: false }); - world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); const expandedMembership = await world.nextMembership(); expandedMembership.succeed(); const agentBCatchUp = await world.nextFetch("agent-b"); @@ -908,7 +784,7 @@ test("returning to a hot hidden agent stays live after inactivity", async () => await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready")); world.elapse(60_000); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); world.expectNoPendingMembership(); world.expectNoPendingFetch(); @@ -918,7 +794,7 @@ test("the hot set evicts the least-recent hidden agent and promotes revisited ag const world = new TimelineWorld(); world.sync.setConnected(true); for (const agentId of ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"]) { - world.sync.registerVisibleAgentIds("workspace", [agentId]); + world.sync.replaceVisibleAgentIds("workspace", [agentId]); const membership = await world.nextMembership(); membership.succeed(); const catchUp = await world.nextFetch(agentId); @@ -926,22 +802,22 @@ test("the hot set evicts the least-recent hidden agent and promotes revisited ag await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus(agentId)).toBe("ready")); } - world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); world.expectNoPendingMembership(); world.expectNoPendingFetch(); - world.sync.registerVisibleAgentIds("workspace", ["agent-f"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]); const eviction = await world.nextMembership(); eviction.succeed(); const agentFCatchUp = await world.nextFetch("agent-f"); agentFCatchUp.respond({ hasNewer: false }); expect(eviction.agentIds).toEqual(["agent-b", "agent-c", "agent-d", "agent-e", "agent-f"]); - world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); world.expectNoPendingMembership(); world.expectNoPendingFetch(); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const restoreEvicted = await world.nextMembership(); restoreEvicted.succeed(); const agentACatchUp = await world.nextFetch("agent-a"); @@ -953,7 +829,7 @@ test("visible agents are never evicted when they exceed the hot-set limit", asyn const world = new TimelineWorld(); const visible = ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e", "agent-f"]; world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", visible); + world.sync.replaceVisibleAgentIds("workspace", visible); const membership = await world.nextMembership(); membership.succeed(); const catchUps = await Promise.all(visible.map((agentId) => world.nextFetch(agentId))); @@ -961,7 +837,7 @@ test("visible agents are never evicted when they exceed the hot-set limit", asyn expect(membership.agentIds).toEqual(visible); - world.sync.registerVisibleAgentIds("workspace", visible.slice(0, 5)); + world.sync.replaceVisibleAgentIds("workspace", visible.slice(0, 5)); const hiddenEviction = await world.nextMembership(); hiddenEviction.succeed(); expect(hiddenEviction.agentIds).toEqual(visible.slice(0, 5)); @@ -970,13 +846,13 @@ test("visible agents are never evicted when they exceed the hot-set limit", asyn test("disconnect clears hidden hot agents before reconnecting the visible set", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const agentAMembership = await world.nextMembership(); agentAMembership.succeed(); const agentACatchUp = await world.nextFetch("agent-a"); agentACatchUp.respond({ hasNewer: false }); - world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); const agentBMembership = await world.nextMembership(); agentBMembership.succeed(); const agentBCatchUp = await world.nextFetch("agent-b"); @@ -993,7 +869,7 @@ test("disconnect clears hidden hot agents before reconnecting the visible set", expect(restored.agentIds).toEqual(["agent-b"]); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const reopened = await world.nextMembership(); reopened.succeed(); const reopenedCatchUp = await world.nextFetch("agent-a"); @@ -1004,7 +880,7 @@ test("disconnect clears hidden hot agents before reconnecting the visible set", test("legacy delivery skips subscription RPCs while retaining visibility catch-up and gap recovery", async () => { const world = new TimelineWorld(); world.sync.setDeliveryMode("legacy"); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); world.sync.setConnected(true); world.expectNoPendingMembership(); @@ -1012,8 +888,7 @@ test("legacy delivery skips subscription RPCs while retaining visibility catch-u initial.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); - world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 12); + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); const recovery = await world.nextFetch("agent-a"); recovery.respond({ hasNewer: false }); @@ -1029,17 +904,17 @@ test("legacy delivery catches up after returning to a view or foreground", async const world = new TimelineWorld(); world.sync.setDeliveryMode("legacy"); world.sync.setConnected(true); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const firstAgentA = await world.nextFetch("agent-a"); firstAgentA.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); const agentB = await world.nextFetch("agent-b"); agentB.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready")); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const secondAgentA = await world.nextFetch("agent-a"); secondAgentA.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); @@ -1055,7 +930,7 @@ test("legacy delivery catches up after returning to a view or foreground", async test("switching from legacy to selective delivery publishes membership and catches up once", async () => { const world = new TimelineWorld(); world.sync.setDeliveryMode("legacy"); - world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); world.sync.setConnected(true); const legacyCatchUp = await world.nextFetch("agent-a"); legacyCatchUp.respond({ hasNewer: false }); diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index f1e32c9d3d..d11084cdf2 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -1,23 +1,312 @@ -import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; import { planTimelineCatchUpAfter, planTimelineResumeFetch, type ProjectedTimelineForwardFetchPlan, } from "./timeline-sync-plan"; +import type { CachedTimeline } from "@/runtime/replica-cache"; import { - createAgentStreamReducerQueue, - scheduleAgentStreamReducerFlush, - cancelAgentStreamReducerFlush, + selectAgentTimelineState, + useSessionStore, + type AgentTimelineCursorState, + type AgentTimelineState, +} from "@/stores/session-store"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { getSendingClientMessageIds } from "@/composer/submission/model"; +import { + getInitDeferred, + getInitKey, + rejectInitDeferred, + resolveInitDeferred, +} from "@/utils/agent-initialization"; +import { + createSessionAgentStreamReducerQueue, type AgentStreamReducerEvent, + type ProcessTimelineResponseOutput, + processTimelineResponse, } from "./session-stream-reducers"; -import type { TimelineReplica, TimelineResponsePayload, TimelinePageRequest } from "./replica"; -import type { AgentRemovalReason } from "@/utils/agent-directory-sync"; +import { isTimelineResumeSnapshotAuthoritative } from "./timeline-sync-plan"; +import { replaceWithCanonicalStream } from "@/types/stream"; + +export interface TimelineReplicaStorage { + readTimeline(serverId: string, agentId: string): Promise; + commitTimeline(serverId: string, agentId: string, timeline: CachedTimeline): void; +} + +async function prepareCachedTimeline(input: { + serverId: string; + agentId: string; + storage: TimelineReplicaStorage; + prepareAgent: (agentId: string) => Promise; +}): Promise { + const before = useSessionStore.getState().sessions[input.serverId]; + const beforeTimeline = selectAgentTimelineState(before, input.agentId); + if (beforeTimeline.status === "synced") return undefined; + const [stored] = await Promise.all([ + input.storage.readTimeline(input.serverId, input.agentId), + input.prepareAgent(input.agentId), + ]); + if (!stored) return undefined; + const session = useSessionStore.getState().sessions[input.serverId]; + const currentTimeline = selectAgentTimelineState(session, input.agentId); + const currentHead = session?.agentStreamHead.get(input.agentId); + if (currentTimeline.status === "synced") return undefined; + if (!stored.range) { + if (beforeTimeline.status === "painted") { + return currentTimeline.status === "painted" && currentTimeline.items === beforeTimeline.items + ? stored + : undefined; + } + if (currentTimeline.status !== "cold") return undefined; + } + const liveItems = + currentTimeline.status === "painted" + ? [...currentTimeline.items, ...(currentHead ?? [])] + : (currentHead ?? []); + const replacement = replaceWithCanonicalStream({ + canonical: stored.items, + previousTail: [], + previousHead: liveItems, + sendingClientMessageIds: getSendingClientMessageIds( + session?.messageSubmissions.get(input.agentId), + ), + preserveContinuity: true, + // Display-only rows claim no coverage over the live head. + canonicalCoverage: stored.range ?? { + epoch: liveItems.find((item) => item.timelineCursor)?.timelineCursor?.epoch ?? "", + endSeq: null, + }, + }); + useSessionStore.getState().applyAgentTimelineResponseState(input.serverId, input.agentId, { + items: replacement.tail, + head: replacement.head, + range: stored.range, + older: stored.hasOlder ? "available" : "none", + newer: false, + synchronized: false, + acknowledgedClientMessageIds: replacement.acknowledgedClientMessageIds, + }); + return stored; +} + +export interface TimelineReplica { + prepare(agentId: string): Promise; + readCursor(agentId: string): { epoch: string; endSeq: number } | undefined; + readRange(agentId: string): AgentTimelineCursorState | undefined; + timelineUpdated(agentId: string): void; +} + +class TimelineReplicaOwner implements TimelineReplica { + private readonly cachedRanges = new Map(); + private readonly preparations = new Map>(); + + constructor( + private readonly serverId: string, + private readonly storage: TimelineReplicaStorage, + private readonly prepareAgent: (agentId: string) => Promise, + ) {} + + async prepare(agentId: string): Promise { + const existing = this.preparations.get(agentId); + if (existing) return existing; + const preparation = this.load(agentId).finally(() => { + if (this.preparations.get(agentId) === preparation) { + this.preparations.delete(agentId); + } + }); + this.preparations.set(agentId, preparation); + return preparation; + } + + private async load(agentId: string): Promise { + const stored = await prepareCachedTimeline({ + serverId: this.serverId, + agentId, + storage: this.storage, + prepareAgent: this.prepareAgent, + }); + if (!stored) return; + if (stored.range) { + this.cachedRanges.set(agentId, stored.range); + } + } + + readCursor(agentId: string): { epoch: string; endSeq: number } | undefined { + const range = this.cachedRanges.get(agentId); + return range ? { epoch: range.epoch, endSeq: range.endSeq } : undefined; + } + + readRange(agentId: string): AgentTimelineCursorState | undefined { + return this.cachedRanges.get(agentId); + } + + timelineUpdated(agentId: string): void { + const session = useSessionStore.getState().sessions[this.serverId]; + const timeline = selectAgentTimelineState(session, agentId); + if (timeline.status === "cold") return; + if (timeline.status === "synced") this.cachedRanges.delete(agentId); + this.storage.commitTimeline(this.serverId, agentId, { + agentId, + items: [...timeline.items, ...(session?.agentStreamHead.get(agentId) ?? [])], + range: timeline.status === "synced" ? timeline.range : null, + hasOlder: timeline.status === "synced" && timeline.older === "available", + }); + } +} + +export function createTimelineReplica(input: { + serverId: string; + storage: TimelineReplicaStorage; + prepareAgent: (agentId: string) => Promise; +}): TimelineReplica { + return new TimelineReplicaOwner(input.serverId, input.storage, input.prepareAgent); +} export interface TimelinePageResult { hasNewer: boolean; endCursor: { epoch: string; seq: number } | null; } +export type TimelineResponsePayload = Extract< + SessionOutboundMessage, + { type: "fetch_agent_timeline_response" } +>["payload"]; + +export function consumeForcedTimelineTailReplacement( + payload: TimelineResponsePayload, + replacements: Set, +): TimelineResponsePayload { + if (payload.direction !== "tail") return payload; + if (!replacements.delete(payload.agentId)) return payload; + return { ...payload, reset: true }; +} + +function clearAgentInitializingFlag(serverId: string, agentId: string): void { + useSessionStore.getState().setInitializingAgents(serverId, (previous) => { + if (previous.get(agentId) !== true) return previous; + const next = new Map(previous); + next.set(agentId, false); + return next; + }); +} + +function commitProcessedTimeline(input: { + serverId: string; + payload: TimelineResponsePayload; + result: ProcessTimelineResponseOutput; + timeline: AgentTimelineState; + currentCursor: AgentTimelineCursorState | undefined; + synchronized: boolean; +}): void { + const { serverId, payload, result, timeline, currentCursor, synchronized } = input; + const agentId = payload.agentId; + const store = useSessionStore.getState(); + if (result.commit !== "discard") { + store.applyAgentTimelineResponseState(serverId, agentId, { + items: result.tail, + head: result.head, + range: result.cursorChanged ? (result.cursor ?? null) : (currentCursor ?? null), + older: result.older, + newer: + payload.direction === "before" + ? timeline.status === "synced" && timeline.newer === "available" + : payload.hasNewer, + synchronized, + acknowledgedClientMessageIds: result.acknowledgedClientMessageIds, + }); + return; + } + if (result.acknowledgedClientMessageIds.length > 0) { + store.setAgentStreamState(serverId, agentId, { + acknowledgedClientMessageIds: result.acknowledgedClientMessageIds, + }); + } + if (payload.direction !== "before") { + store.setAgentTimelineHasNewer(serverId, (current) => { + const next = new Map(current); + next.set(agentId, payload.hasNewer); + return next; + }); + } + store.markAgentHistorySynchronized(serverId, agentId); +} + +function finalizeProcessedTimeline(input: { + serverId: string; + agentId: string; + initKey: string; + result: ProcessTimelineResponseOutput; + synchronized: boolean; + recoverGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void; + drainQueuedAgentMessage: (agentId: string) => void; +}): void { + for (const effect of input.result.sideEffects) { + if (effect.type === "catch_up") input.recoverGap(input.agentId, effect.cursor); + } + if (input.result.clearInitializing) { + clearAgentInitializingFlag(input.serverId, input.agentId); + } + if (input.synchronized) { + useCreateFlowStore + .getState() + .clearByAgent({ serverId: input.serverId, agentId: input.agentId }); + const session = useSessionStore.getState().sessions[input.serverId]; + const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId); + if (agent && agent.turn.phase === "idle") input.drainQueuedAgentMessage(input.agentId); + } + if (input.result.initResolution === "resolve") resolveInitDeferred(input.initKey); +} + +function applyAuthoritativeTimelineResponse(input: { + serverId: string; + payload: TimelineResponsePayload; + cachedCursor?: AgentTimelineCursorState; + recoverGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void; + drainQueuedAgentMessage: (agentId: string) => void; +}): boolean { + const { serverId, payload } = input; + const agentId = payload.agentId; + const initKey = getInitKey(serverId, agentId); + const session = useSessionStore.getState().sessions[serverId]; + const timeline = selectAgentTimelineState(session, agentId); + const activeInitDeferred = getInitDeferred(initKey); + const currentCursor = + timeline.status === "synced" ? (timeline.range ?? undefined) : input.cachedCursor; + const result = processTimelineResponse({ + payload, + currentTail: timeline.status === "cold" ? [] : timeline.items, + currentHead: session?.agentStreamHead.get(agentId) ?? [], + currentCursor, + isInitializing: session?.initializingAgents.get(agentId) === true, + hasActiveInitDeferred: Boolean(activeInitDeferred), + initRequestDirection: activeInitDeferred?.requestDirection ?? "tail", + sendingClientMessageIds: getSendingClientMessageIds(session?.messageSubmissions.get(agentId)), + }); + + if (result.error) { + if (result.clearInitializing) clearAgentInitializingFlag(serverId, agentId); + if (result.initResolution === "reject") rejectInitDeferred(initKey, new Error(result.error)); + return false; + } + + const synchronized = isTimelineResumeSnapshotAuthoritative({ + direction: payload.direction, + hasNewer: payload.hasNewer, + error: payload.error, + }); + commitProcessedTimeline({ serverId, payload, result, timeline, currentCursor, synchronized }); + finalizeProcessedTimeline({ + serverId, + agentId, + initKey, + result, + synchronized, + recoverGap: input.recoverGap, + drainQueuedAgentMessage: input.drainQueuedAgentMessage, + }); + return true; +} + export interface ViewedTimelineSyncPorts { initialDeliveryMode: TimelineDeliveryMode; prepare(agentId: string): Promise; @@ -36,13 +325,8 @@ export interface ViewedTimelineSyncPorts { export type TimelineDeliveryMode = "legacy" | "selective"; export type ViewedTimelineStatus = "ready" | "pending" | "error" | "retrying"; -export interface ViewedTimelineDemandDeclaration { - replace(agentIds: string[]): void; - dispose(): void; -} - export interface ViewedTimelineUiBridge { - registerVisibleAgentIds(sourceId: string, agentIds: string[]): ViewedTimelineDemandDeclaration; + replaceVisibleAgentIds(sourceId: string, agentIds: string[]): void; subscribe(listener: () => void): () => void; getAgentTimelineStatus(agentId: string): ViewedTimelineStatus; getAgentTimelineError(agentId: string): string | null; @@ -53,32 +337,19 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge { setActive(active: boolean): void; setConnected(connected: boolean): void; setDeliveryMode(mode: TimelineDeliveryMode): void; - recoverGap( - agentId: string, - cursor: { epoch: string; endSeq: number }, - observedSeq?: number, - ): void; - beginAgentLifetime(agentId: string): void; - endAgentLifetime(agentId: string): void; + recoverGap(agentId: string, cursor: { epoch: string; endSeq: number }): void; dispose(): void; } export type ViewedTimelineOwnerPorts = Omit< ViewedTimelineSyncPorts, - "prepare" | "replaceDemandedAgentIds" | "readCursor" + "prepare" | "replaceDemandedAgentIds" >; export interface ViewedTimelineOwner extends ViewedTimelineSync { - beginTimelineRequest( - agentId: string, - replaceTail?: boolean, - ): (payload: TimelineResponsePayload) => void; applyTimelineResponse(payload: TimelineResponsePayload): void; enqueueStreamEvent(agentId: string, event: AgentStreamReducerEvent): void; flushStreamAgent(agentId: string): void; - attachTransport(ports: ViewedTimelineOwnerPorts): () => void; - acceptAgent(agentId: string): void; - removeAgent(agentId: string, reason: AgentRemovalReason): void; } export function createViewedTimelineOwner(input: { @@ -86,78 +357,33 @@ export function createViewedTimelineOwner(input: { replica: TimelineReplica; replaceDemandedAgentIds: (agentIds: string[]) => void; drainQueuedAgentMessage: (agentId: string) => void; - ports?: ViewedTimelineOwnerPorts; + ports: ViewedTimelineOwnerPorts; }): ViewedTimelineOwner { - let transport = input.ports; - const requireTransport = () => { - if (!transport) throw new Error("Timeline transport is disconnected"); - return transport; - }; const sync = createViewedTimelineSync({ - initialDeliveryMode: transport?.initialDeliveryMode ?? "legacy", - setSubscription: (ids) => requireTransport().setSubscription(ids), - fetchPage: (id, request) => requireTransport().fetchPage(id, request), - fetchLatestTail: (id) => requireTransport().fetchLatestTail(id), - reportError: (error) => transport?.reportError(error), - schedule: (task, delay) => { - if (transport) return transport.schedule(task, delay); - const timeout = setTimeout(task, delay); - return () => clearTimeout(timeout); - }, + ...input.ports, prepare: (agentId) => input.replica.prepare(agentId), - readCursor: (agentId) => input.replica.readCursor(agentId), + readCursor: (agentId) => input.replica.readCursor(agentId) ?? input.ports.readCursor(agentId), replaceDemandedAgentIds: input.replaceDemandedAgentIds, }); - const streamQueue = createAgentStreamReducerQueue({ - apply: (agentId, events) => input.replica.applyEvents(agentId, events, sync.recoverGap), - scheduleFlush: scheduleAgentStreamReducerFlush, - cancelFlush: cancelAgentStreamReducerFlush, + const streamQueue = createSessionAgentStreamReducerQueue({ + serverId: input.serverId, + setAgentStreamState: (...args) => useSessionStore.getState().setAgentStreamState(...args), + setAgentTimelineCursor: (...args) => useSessionStore.getState().setAgentTimelineCursor(...args), + recoverTimelineGap: (agentId, cursor) => sync.recoverGap(agentId, cursor), + onCommitted: (agentId) => input.replica.timelineUpdated(agentId), }); - function applyTimelineResponse( - payload: TimelineResponsePayload, - request?: TimelinePageRequest, - ): void { - streamQueue.flushAgent(payload.agentId); - input.replica.applyPage( - payload, - { - recoverGap: (agentId, cursor, observedSeq) => sync.recoverGap(agentId, cursor, observedSeq), - drainQueuedAgentMessage: input.drainQueuedAgentMessage, - }, - request, - ); - } - return { ...sync, - attachTransport(ports) { - sync.setConnected(false); - transport = ports; - sync.setDeliveryMode(ports.initialDeliveryMode); - return () => { - if (transport !== ports) return; - sync.setConnected(false); - transport = undefined; - }; + applyTimelineResponse(payload) { + const accepted = applyAuthoritativeTimelineResponse({ + serverId: input.serverId, + payload, + cachedCursor: input.replica.readRange(payload.agentId), + recoverGap: (agentId, cursor) => sync.recoverGap(agentId, cursor), + drainQueuedAgentMessage: input.drainQueuedAgentMessage, + }); + if (accepted) input.replica.timelineUpdated(payload.agentId); }, - acceptAgent(agentId) { - input.replica.acceptAgent(agentId); - sync.beginAgentLifetime(agentId); - }, - removeAgent(agentId, reason) { - streamQueue.flushAgent(agentId); - sync.endAgentLifetime(agentId); - input.replica.remove(agentId, reason); - }, - beginTimelineRequest(agentId, replaceTail = false) { - const request = { - cursor: input.replica.readCursor(agentId) ?? null, - replaceTail, - initialization: getInitDeferred(getInitKey(input.serverId, agentId))?.promise, - }; - return (payload) => applyTimelineResponse(payload, request); - }, - applyTimelineResponse, enqueueStreamEvent(agentId, event) { streamQueue.enqueue(agentId, event); }, @@ -185,11 +411,6 @@ interface CatchUpState { retryDelayMs?: number; } -interface VisibleAgentSource { - declaration: object; - agentIds: string[]; -} - const getNextRetryDelayMs = (previousDelayMs: number | undefined): number => { if (previousDelayMs == null) { return RETRY_DELAY_MS; @@ -197,6 +418,40 @@ const getNextRetryDelayMs = (previousDelayMs: number | undefined): number => { return Math.min(previousDelayMs * 2, MAX_RETRY_DELAY_MS); }; +function isSameCatchUpRequest( + left: ProjectedTimelineForwardFetchPlan | undefined, + right: ProjectedTimelineForwardFetchPlan | undefined, +): boolean { + if (!left || !right || left.direction !== right.direction) return false; + if (left.direction !== "after" || right.direction !== "after") return true; + return left.cursor.epoch === right.cursor.epoch && left.cursor.seq === right.cursor.seq; +} + +type CatchUpDecision = "keep" | "keep-and-park" | "replace"; + +function decideCatchUp(input: { + current: CatchUpState | undefined; + request: ProjectedTimelineForwardFetchPlan; + supersede: boolean; +}): CatchUpDecision { + if (!input.current) return "replace"; + if (input.supersede) { + if ( + input.current.status === "running" && + isSameCatchUpRequest(input.current.request, input.request) + ) { + return "keep"; + } + if (input.current.status === "running" && input.current.request?.direction === "tail") { + return "keep-and-park"; + } + return "replace"; + } + return input.current.status === "running" || input.current.status === "complete" + ? "keep" + : "replace"; +} + function normalizeAgentIds(agentIds: string[]): string[] { return [...new Set(agentIds)].filter(Boolean).sort(); } @@ -206,13 +461,12 @@ function sameAgentIds(left: string[], right: string[]): boolean { } export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): ViewedTimelineSync { - const sources = new Map(); + const sources = new Map(); const catchUps = new Map(); const catchUpGenerations = new Map(); - // The newest live sequence each agent has been told about but whose coverage is not yet - // established. A catch-up is complete only when authoritative coverage reaches it; a page - // built before that sequence existed cannot settle it, however it was requested. - const observedHeads = new Map(); + // Authoritative fetch owed but not runnable yet: disconnected, unacknowledged, or parked. + // Acknowledgement and tail completion are the only drain points. + const pendingCatchUps = new Map(); const visibilityCatchUpPending = new Set(); const visibilityCatchUpErrors = new Map(); // User-initiated retries only. Background retries stay silent; a retry the user asked for @@ -235,8 +489,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed let cancelMembershipRetry: (() => void) | null = null; let recentlyViewedAgentIds: string[] = []; - const visibleAgentIds = () => - normalizeAgentIds([...sources.values()].flatMap((source) => source.agentIds)); + const visibleAgentIds = () => normalizeAgentIds([...sources.values()].flat()); const selectHotAgentIds = (visible: string[]) => { const visibleSet = new Set(visible); @@ -290,36 +543,11 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed if (changed) notifyListeners(); }; - // Cancelling forgets the observed head: the next catch-up starts from the current cursor and - // the daemon returns everything after it. const cancelCatchUp = (agentId: string) => { catchUpGenerations.set(agentId, (catchUpGenerations.get(agentId) ?? 0) + 1); catchUps.get(agentId)?.cancelRetry?.(); catchUps.delete(agentId); - observedHeads.delete(agentId); - }; - - const owedAfter = ( - agentId: string, - cursorBefore: { epoch: string; endSeq: number } | undefined, - observedBefore: { epoch: string; seq: number } | undefined, - ): ProjectedTimelineForwardFetchPlan | undefined => { - const observed = observedHeads.get(agentId); - const cursor = ports.readCursor(agentId); - if (!observed || !cursor) return undefined; - const advanced = - cursorBefore === undefined || - cursorBefore.epoch !== cursor.epoch || - cursor.endSeq > cursorBefore.endSeq; - if (cursor.epoch === observed.epoch && cursor.endSeq < observed.seq) { - if (!advanced && observed === observedBefore) - throw new Error( - `Timeline coverage for ${agentId} has not reached observed sequence ${observed.seq}`, - ); - return planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq }); - } - observedHeads.delete(agentId); - return undefined; + pendingCatchUps.delete(agentId); }; const fetchUntilCurrent = async ( @@ -331,41 +559,32 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed if (!ownsCatchUp(agentId, generation)) return; try { - let cursorBefore = ports.readCursor(agentId); - let observedBefore = observedHeads.get(agentId); const page = await ports.fetchPage(agentId, request); if (!ownsCatchUp(agentId, generation)) return; if (page.hasNewer && page.endCursor) { - if ( - request.direction === "after" && - page.endCursor.epoch === request.cursor.epoch && - page.endCursor.seq <= request.cursor.seq - ) { - throw new Error(`Timeline page for ${agentId} did not advance its cursor`); - } if (fallbackToLatestTailOnOverflow) { - cursorBefore = ports.readCursor(agentId); - observedBefore = observedHeads.get(agentId); await ports.fetchLatestTail(agentId); - if (!ownsCatchUp(agentId, generation)) return; - } else { - await fetchUntilCurrent( - agentId, - generation, - planTimelineCatchUpAfter(page.endCursor), - false, - ); + catchUps.set(agentId, { generation, status: "complete" }); + setVisibilityCatchUpReady(agentId); return; } - } else if (page.hasNewer) { - throw new Error(`Timeline page for ${agentId} hasNewer without an end cursor`); - } - const owed = owedAfter(agentId, cursorBefore, observedBefore); - if (owed) { - await fetchUntilCurrent(agentId, generation, owed, false); + await fetchUntilCurrent( + agentId, + generation, + planTimelineCatchUpAfter(page.endCursor), + false, + ); return; } + if (page.hasNewer) { + throw new Error(`Timeline page for ${agentId} hasNewer without an end cursor`); + } catchUps.set(agentId, { generation, status: "complete" }); + const pendingCatchUp = pendingCatchUps.get(agentId); + if (pendingCatchUp) { + startCatchUp(agentId, { request: pendingCatchUp, supersede: true }); + return; + } setVisibilityCatchUpReady(agentId); } catch (error) { if (catchUps.get(agentId)?.generation === generation) { @@ -394,43 +613,69 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed .prepare(agentId) .catch((error) => ports.reportError(error)) .finally(() => { - if (cacheLoads.get(agentId) !== load) return; cacheLoads.delete(agentId); loadedCache.add(agentId); - startCatchUp(agentId); + const pending = pendingCatchUps.get(agentId); + startCatchUp(agentId, { request: pending, supersede: Boolean(pending) }); }); cacheLoads.set(agentId, load); }; - // One catch-up per agent: it runs from the current cursor until the daemon reports nothing - // newer and coverage reaches every observed sequence. A running catch-up is never replaced - // by another request; it finishes its own obligation. `supersede` restarts a failed or - // completed one immediately, which the manual retry and a new gap both need. - const startCatchUp = (agentId: string, options: { supersede?: boolean } = {}) => { - const { supersede = false } = options; - if (!connected || !isDesired(agentId) || !isAcknowledged(agentId)) return; + const startCatchUp = ( + agentId: string, + options: { + request?: ProjectedTimelineForwardFetchPlan; + supersede?: boolean; + } = {}, + ) => { + const { request, supersede = false } = options; + if (!connected || !isDesired(agentId) || !isAcknowledged(agentId)) { + if (request) pendingCatchUps.set(agentId, request); + return; + } if (!loadedCache.has(agentId)) { + if (request) pendingCatchUps.set(agentId, request); ensureCacheLoaded(agentId); return; } + const nextRequest = request ?? planTimelineResumeFetch(ports.readCursor(agentId)); const current = catchUps.get(agentId); - if (current?.status === "running") return; - if (current?.status === "complete" && !supersede) return; + const decision = decideCatchUp({ current, request: nextRequest, supersede }); + if (decision === "keep-and-park") { + pendingCatchUps.set(agentId, nextRequest); + return; + } + if (decision === "keep") { + return; + } current?.cancelRetry?.(); - const request = planTimelineResumeFetch(ports.readCursor(agentId)); const generation = (catchUpGenerations.get(agentId) ?? 0) + 1; catchUpGenerations.set(agentId, generation); const retryDelayMs = supersede || current?.status !== "error" ? undefined : current.retryDelayMs; - catchUps.set(agentId, { generation, status: "running", request, retryDelayMs }); - // A resume with no observed head replays at most one bounded page before taking the latest - // tail; a catch-up that owes observed live rows pages forward until it reaches them. - const resumingWithoutObservation = request.direction === "after" && !observedHeads.has(agentId); - void fetchUntilCurrent(agentId, generation, request, resumingWithoutObservation); + catchUps.set(agentId, { + generation, + status: "running", + request: nextRequest, + retryDelayMs, + }); + pendingCatchUps.delete(agentId); + void fetchUntilCurrent( + agentId, + generation, + nextRequest, + request === undefined && nextRequest.direction === "after", + ); }; const startAcknowledgedCatchUps = () => { - for (const agentId of acknowledged) startCatchUp(agentId); + for (const agentId of acknowledged) { + const pendingCatchUp = pendingCatchUps.get(agentId); + startCatchUp(agentId, { + request: pendingCatchUp, + supersede: Boolean(pendingCatchUp), + }); + } }; const reconcileLatestMembership = async (): Promise => { @@ -510,7 +755,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed notifyListeners(); if (catchUp?.status === "error") { catchUp.cancelRetry?.(); - startCatchUp(agentId, { supersede: true }); + startCatchUp(agentId, { request: catchUp.request, supersede: true }); return; } cancelMembershipRetry?.(); @@ -596,26 +841,11 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed getAgentTimelineError(agentId) { return visibilityCatchUpErrors.get(agentId) ?? null; }, - registerVisibleAgentIds(sourceId, agentIds) { - const declaration = {}; - sources.set(sourceId, { - declaration, - agentIds: normalizeAgentIds(agentIds), - }); + replaceVisibleAgentIds(sourceId, agentIds) { + const normalized = normalizeAgentIds(agentIds); + if (normalized.length === 0) sources.delete(sourceId); + else sources.set(sourceId, normalized); publishVisibleMembership(); - return { - replace(nextAgentIds) { - const current = sources.get(sourceId); - if (current?.declaration !== declaration) return; - current.agentIds = normalizeAgentIds(nextAgentIds); - publishVisibleMembership(); - }, - dispose() { - if (sources.get(sourceId)?.declaration !== declaration) return; - sources.delete(sourceId); - publishVisibleMembership(); - }, - }; }, setActive(nextActive) { if (active === nextActive) return; @@ -667,30 +897,12 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed if (deliveryMode === "selective" && connected) void reconcileMembership(); else if (connected) startAcknowledgedCatchUps(); }, - recoverGap(agentId, cursor, observedSeq) { + recoverGap(agentId, cursor) { if (!isDesired(agentId)) return; - if (observedSeq !== undefined) { - const observed = observedHeads.get(agentId); - if (!observed || observed.epoch !== cursor.epoch || observed.seq < observedSeq) { - observedHeads.set(agentId, { epoch: cursor.epoch, seq: observedSeq }); - } - } - startCatchUp(agentId, { supersede: true }); - }, - beginAgentLifetime(agentId) { - if (!isDesired(agentId)) return; - visibilityCatchUpPending.add(agentId); - ensureCacheLoaded(agentId); - notifyListeners(); - }, - endAgentLifetime(agentId) { - cancelCatchUp(agentId); - loadedCache.delete(agentId); - cacheLoads.delete(agentId); - if (isDesired(agentId)) { - visibilityCatchUpPending.add(agentId); - notifyListeners(); - } + startCatchUp(agentId, { + request: planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq }), + supersede: true, + }); }, dispose() { disposed = true; @@ -707,7 +919,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed recentlyViewedAgentIds = []; loadedCache.clear(); cacheLoads.clear(); - observedHeads.clear(); visibilityCatchUpPending.clear(); visibilityCatchUpErrors.clear(); manualRetries.clear(); diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index a73f52c2d6..cd05fd0826 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -8,7 +8,6 @@ import { handoffCreatedAgentUserMessageToStream, hydrateStreamState, mergeToolCallDetail, - mergeCanonicalText, reduceStreamUpdate, streamTimelineItemIdentity, type AgentToolCallItem, @@ -24,122 +23,6 @@ import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity"; type CanonicalToolStatus = "running" | "completed" | "failed" | "canceled"; -describe("canonical text source boundaries", () => { - it("keeps the canonical start independently of its completion chunk", () => { - const rows = mergeCanonicalText( - [ - { - kind: "assistant_message", - id: "m", - messageId: "m", - text: "ab", - timestamp: new Date(0), - timelineCursor: { epoch: "e", seq: 2 }, - source: { - startSeq: 1, - chunks: [ - { seq: 1, offset: 0 }, - { seq: 2, offset: 1 }, - ], - }, - }, - ], - { text: "ab", epoch: "e", startSeq: 1, endSeq: 2, timestamp: new Date(1) }, - ); - expect(rows[0]).toMatchObject({ - text: "ab", - timelineCursor: { epoch: "e", seq: 2 }, - source: { startSeq: 1, chunks: [{ seq: 2, offset: 0 }] }, - }); - }); - - it("keeps exact offsets when canonical text replaces the middle of a row", () => { - const [row] = mergeCanonicalText( - [ - { - kind: "assistant_message", - id: "m", - text: "abOLDyz", - timestamp: new Date(0), - timelineCursor: { epoch: "e", seq: 8 }, - source: { - startSeq: 1, - chunks: [ - { seq: 2, offset: 0 }, - { seq: 4, offset: 2 }, - { seq: 8, offset: 5 }, - ], - }, - }, - ], - { text: "C", epoch: "e", startSeq: 3, endSeq: 6, timestamp: new Date(1) }, - ); - expect(row).toMatchObject({ - text: "abCyz", - timelineCursor: { epoch: "e", seq: 8 }, - source: { - startSeq: 1, - chunks: [ - { seq: 2, offset: 0 }, - { seq: 6, offset: 2 }, - { seq: 8, offset: 3 }, - ], - }, - }); - }); - - it("preserves source starts and exact text before and after covered chunks", () => { - const rows = mergeCanonicalText( - [ - { - kind: "assistant_message", - id: "before", - text: "before", - timestamp: new Date(0), - timelineCursor: { epoch: "e", seq: 2 }, - source: { startSeq: 1, chunks: [{ seq: 2, offset: 0 }] }, - }, - { - kind: "assistant_message", - id: "covered", - text: "old", - timestamp: new Date(0), - timelineCursor: { epoch: "e", seq: 4 }, - source: { startSeq: 3, chunks: [{ seq: 4, offset: 0 }] }, - }, - { - kind: "assistant_message", - id: "after", - text: "after", - timestamp: new Date(0), - timelineCursor: { epoch: "e", seq: 8 }, - source: { startSeq: 7, chunks: [{ seq: 8, offset: 0 }] }, - }, - ], - { text: "canonical", epoch: "e", startSeq: 3, endSeq: 6, timestamp: new Date(1) }, - ); - expect( - rows.map((row) => ({ text: row.text, source: row.source, cursor: row.timelineCursor })), - ).toEqual([ - { - text: "before", - source: { startSeq: 1, chunks: [{ seq: 2, offset: 0 }] }, - cursor: { epoch: "e", seq: 2 }, - }, - { - text: "canonical", - source: { startSeq: 3, chunks: [{ seq: 6, offset: 0 }] }, - cursor: { epoch: "e", seq: 6 }, - }, - { - text: "after", - source: { startSeq: 7, chunks: [{ seq: 8, offset: 0 }] }, - cursor: { epoch: "e", seq: 8 }, - }, - ]); - }); -}); - describe("plugin timeline rows", () => { it("uses the protocol identity format for stream tool and plugin rows", () => { const tool = { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 4039ac0d7b..4ebd0972db 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -97,7 +97,6 @@ export interface UserMessageItem { messageId?: string; turnId?: string; timelineCursor?: TimelinePosition; - source?: RowSource; text: string; timestamp: Date; images?: UserMessageImageAttachment[]; @@ -110,7 +109,6 @@ export interface UserMessageInput { messageId?: string; turnId?: string; timelineCursor?: TimelinePosition; - source?: RowSource; text: string; timestamp: Date; images?: UserMessageImageAttachment[]; @@ -129,7 +127,6 @@ export function createUserMessage(input: UserMessageInput): UserMessageItem { ...(input.messageId ? { messageId: input.messageId } : {}), ...(input.turnId ? { turnId: input.turnId } : {}), ...(input.timelineCursor ? { timelineCursor: input.timelineCursor } : {}), - ...(input.source ? { source: input.source } : {}), text: input.text, timestamp: input.timestamp, ...(input.images && input.images.length > 0 ? { images: input.images } : {}), @@ -264,14 +261,12 @@ function produceUserMessage( clientMessageId: incoming.clientMessageId ?? existing.clientMessageId, messageId: incoming.messageId ?? existing.messageId, timelineCursor: incoming.timelineCursor ?? existing.timelineCursor, - source: incoming.source ?? existing.source, }); if ( existing.id === merged.id && existing.clientMessageId === merged.clientMessageId && existing.messageId === merged.messageId && existing.timelineCursor === merged.timelineCursor && - existing.source === merged.source && existing.text === merged.text && existing.timestamp === merged.timestamp && existing.images === merged.images && @@ -417,19 +412,13 @@ function placeCanonicalUserMessageAtTail( }; } -// What canonical rows certify about source positions. A certified range replaces every positioned -// row inside it and drops rows from other epochs. Rows-only canonical data, such as a display-only -// cache, replaces only rows sharing a canonical row's position or an older-or-equal copy of its -// message identity; it never drops rows it did not cover. -export type CanonicalCoverage = { epoch: string; endSeq: number | null } | "rows-only"; - export interface CanonicalStreamReplacementInput { canonical: StreamItem[]; previousTail: StreamItem[]; previousHead: StreamItem[]; sendingClientMessageIds: readonly string[]; preserveContinuity: boolean; - canonicalCoverage: CanonicalCoverage; + canonicalCoverage: { epoch: string; endSeq: number | null }; } export interface CanonicalStreamReplacementResult { @@ -438,33 +427,13 @@ export interface CanonicalStreamReplacementResult { acknowledgedClientMessageIds: string[]; } -function isSamePosition(left: TimelinePosition | undefined, right: TimelinePosition): boolean { - return left !== undefined && left.epoch === right.epoch && left.seq === right.seq; -} - -function isCoveredByCanonical( - item: StreamItem, - canonical: readonly StreamItem[], - coverage: CanonicalCoverage, +function isAfterCanonicalCoverage( + position: TimelinePosition, + coverage: CanonicalStreamReplacementInput["canonicalCoverage"], ): boolean { - const position = item.timelineCursor; - if (!position) return false; - if (coverage !== "rows-only") { - return ( - position.epoch !== coverage.epoch || - (coverage.endSeq !== null && position.seq <= coverage.endSeq) - ); - } - return canonical.some( - (row) => - isSamePosition(row.timelineCursor, position) || - (row.kind === "assistant_message" && - item.kind === "assistant_message" && - row.messageId !== undefined && - row.messageId === item.messageId && - row.timelineCursor !== undefined && - row.timelineCursor.epoch === position.epoch && - row.timelineCursor.seq >= position.seq), + return ( + position.epoch === coverage.epoch && + (coverage.endSeq === null || position.seq > coverage.endSeq) ); } @@ -574,53 +543,29 @@ function reconcileReplacementHeadAgainstTail( }; } -// Positioned rows beyond canonical coverage are daemon history the page did not carry, so they -// survive every replacement. Continuity decides only what happens to rows without a position. -function retainsAcrossReplacement( - item: StreamItem, - input: { - canonical: readonly StreamItem[]; - tail: readonly StreamItem[]; - preserveContinuity: boolean; - sendingClientMessageIds: ReadonlySet; - canonicalCoverage: CanonicalCoverage; - }, -): boolean { - if (item.timelineCursor) { - if (!isCoveredByCanonical(item, input.canonical, input.canonicalCoverage)) return true; - const canonicalTailAssistant = input.tail.at(-1); - return ( - input.preserveContinuity && - item.kind === "assistant_message" && - canonicalTailAssistant?.kind === "assistant_message" && - item.text.startsWith(canonicalTailAssistant.text) - ); - } - return ( - input.preserveContinuity || - (item.kind === "user_message" && - item.clientMessageId !== undefined && - input.sendingClientMessageIds.has(item.clientMessageId)) - ); -} - function preserveReplacementHead( - canonical: readonly StreamItem[], tail: StreamItem[], currentHead: StreamItem[], preserveContinuity: boolean, sendingClientMessageIds: ReadonlySet, - canonicalCoverage: CanonicalCoverage, + canonicalCoverage: CanonicalStreamReplacementInput["canonicalCoverage"], ): CanonicalStreamReplacementResult { - const retainedHead = currentHead.filter((item) => - retainsAcrossReplacement(item, { - canonical, - tail, - preserveContinuity, - sendingClientMessageIds, - canonicalCoverage, - }), - ); + const canonicalTailAssistant = tail.at(-1); + const retainedHead = preserveContinuity + ? currentHead.filter( + (item) => + !item.timelineCursor || + isAfterCanonicalCoverage(item.timelineCursor, canonicalCoverage) || + (item.kind === "assistant_message" && + canonicalTailAssistant?.kind === "assistant_message" && + item.text.startsWith(canonicalTailAssistant.text)), + ) + : currentHead.filter( + (item) => + item.kind === "user_message" && + item.clientMessageId !== undefined && + sendingClientMessageIds.has(item.clientMessageId), + ); const { tail: reconciledTail, head: unreconciledHead } = reconcileReplacementHeadAgainstTail( tail, retainedHead, @@ -639,29 +584,7 @@ function preserveReplacementHead( ) { return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; } - const continued = continueCanonicalAssistant(liveAssistant, tailAssistant); - if (!continued) { - return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; - } - return { - tail: reconciledTail.slice(0, -1), - head: [ - ...unreconciledHead.slice(0, liveAssistantIndex), - continued, - ...unreconciledHead.slice(liveAssistantIndex + 1), - ], - acknowledgedClientMessageIds: [], - }; -} -// The live head row that continues the canonical tail's last assistant, merged so the canonical -// text owns the positions it certifies and the live row keeps what came after. Rows without -// provider ids cannot tell a continuation from the next message, so they keep the conservative -// legacy rule and merge only when the live text visibly extends the canonical one. -function continueCanonicalAssistant( - liveAssistant: AssistantMessageItem, - tailAssistant: AssistantMessageItem, -): AssistantMessageItem | null { const hasNewerCursor = liveAssistant.timelineCursor !== undefined && tailAssistant.timelineCursor !== undefined && @@ -669,29 +592,41 @@ function continueCanonicalAssistant( liveAssistant.timelineCursor.seq > tailAssistant.timelineCursor.seq; const hasMatchingProviderMessageId = liveAssistant.messageId !== undefined && liveAssistant.messageId === tailAssistant.messageId; - const extendsText = liveAssistant.text.startsWith(tailAssistant.text); const hasIdlessTextContinuation = - liveAssistant.messageId === undefined && tailAssistant.messageId === undefined && extendsText; - if (hasNewerCursor && (hasMatchingProviderMessageId || hasIdlessTextContinuation)) { - if (liveAssistant.timelineCursor && tailAssistant.timelineCursor) { - return ( - mergeCanonicalText([liveAssistant], { - text: tailAssistant.text, - epoch: tailAssistant.timelineCursor.epoch, - startSeq: rowStartSeq(tailAssistant) ?? tailAssistant.timelineCursor.seq, - endSeq: tailAssistant.timelineCursor.seq, - timestamp: tailAssistant.timestamp, - })[0] ?? liveAssistant - ); - } + liveAssistant.messageId === undefined && + tailAssistant.messageId === undefined && + liveAssistant.text.startsWith(tailAssistant.text); + const isNewerContinuation = + hasNewerCursor && (hasMatchingProviderMessageId || hasIdlessTextContinuation); + if (isNewerContinuation) { + const text = liveAssistant.text.startsWith(tailAssistant.text) + ? liveAssistant.text + : `${tailAssistant.text}${liveAssistant.text}`; + const head = [ + ...unreconciledHead.slice(0, liveAssistantIndex), + { ...liveAssistant, text }, + ...unreconciledHead.slice(liveAssistantIndex + 1), + ]; return { - ...liveAssistant, - text: extendsText ? liveAssistant.text : `${tailAssistant.text}${liveAssistant.text}`, + tail: reconciledTail.slice(0, -1), + head, + acknowledgedClientMessageIds: [], }; } - // A live prefix of the canonical text is the same message still streaming from the client's - // view; the canonical text is complete as of the page. - return extendsText ? { ...liveAssistant, text: tailAssistant.text } : null; + if (!liveAssistant.text.startsWith(tailAssistant.text)) { + return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; + } + + const head = [ + ...unreconciledHead.slice(0, liveAssistantIndex), + { ...liveAssistant, text: tailAssistant.text }, + ...unreconciledHead.slice(liveAssistantIndex + 1), + ]; + return { + tail: reconciledTail.slice(0, -1), + head, + acknowledgedClientMessageIds: [], + }; } export function replaceWithCanonicalStream( @@ -738,29 +673,24 @@ export function replaceWithCanonicalStream( nextTail.push(item); } - const retainedTailRows: StreamItem[] = []; - for (const item of input.previousTail) { - if (item.kind === "user_message" && item.clientMessageId !== undefined) { - if (!unmatchedTailMessages.includes(item)) continue; - const preserveLocal = input.preserveContinuity - ? isUnreconciledLocalUserMessage(item) - : sendingClientMessageIds.has(item.clientMessageId); - if (preserveLocal) { - nextTail.push(item); - continue; - } - } - if ( - item.timelineCursor && - !isCoveredByCanonical(item, input.canonical, input.canonicalCoverage) + const retainedTailMessages: UserMessageItem[] = []; + for (const local of unmatchedTailMessages) { + const preserveLocal = input.preserveContinuity + ? isUnreconciledLocalUserMessage(local) + : local.clientMessageId !== undefined && sendingClientMessageIds.has(local.clientMessageId); + if (preserveLocal) { + nextTail.push(local); + } else if ( + input.preserveContinuity && + local.timelineCursor && + isAfterCanonicalCoverage(local.timelineCursor, input.canonicalCoverage) ) { - retainedTailRows.push(item); + retainedTailMessages.push(local); } } - nextHead = [...retainedTailRows, ...nextHead]; + nextHead = [...retainedTailMessages, ...nextHead]; const replacement = preserveReplacementHead( - input.canonical, nextTail, nextHead, input.preserveContinuity, @@ -783,7 +713,6 @@ export interface AssistantMessageItem { timestamp: Date; blockGroupId?: string; blockIndex?: number; - source?: RowSource; } export interface TimelinePosition { @@ -791,25 +720,6 @@ export interface TimelinePosition { seq: number; } -// Where a row's content came from. `startSeq` is the first source position the row covers; rows -// display in start order because a tool call completes long after it appeared. `chunks` records, -// for streamed text, the sequence that produced each piece and the offset it begins at, so a -// canonical page owns exactly the chunks inside its source coverage and text merges cut on chunk -// boundaries instead of comparing strings. Rows saved before provenance existed order by `seq`. -export interface RowSource { - startSeq: number; - chunks?: SourceChunk[]; -} - -export interface SourceChunk { - seq: number; - offset: number; -} - -// Reducers receive the position of the source rows an event covers; canonical units start before -// they end. Rows keep only the anchor position; the start goes into `source`. -export type SourcedPosition = TimelinePosition & { startSeq?: number }; - export type ThoughtStatus = "loading" | "ready"; export interface ThoughtItem { @@ -820,159 +730,6 @@ export interface ThoughtItem { text: string; timestamp: Date; status: ThoughtStatus; - source?: RowSource; -} - -type TextRow = AssistantMessageItem | ThoughtItem; - -function anchorOf(cursor: SourcedPosition): TimelinePosition; -function anchorOf(cursor: SourcedPosition | undefined): TimelinePosition | undefined; -function anchorOf(cursor: SourcedPosition | undefined): TimelinePosition | undefined { - if (!cursor) return undefined; - return cursor.startSeq === undefined ? cursor : { epoch: cursor.epoch, seq: cursor.seq }; -} - -// The source of a row after an event at `cursor` touches it. A row keeps its start while the -// event stays in its epoch; a new epoch starts it over. -function sourceFor(cursor: SourcedPosition, existing?: StreamItem): RowSource { - if (existing?.timelineCursor?.epoch === cursor.epoch) { - const startSeq = existing.source?.startSeq ?? existing.timelineCursor.seq; - return existing.source?.startSeq === startSeq && existing.source.chunks === undefined - ? existing.source - : { startSeq }; - } - return { startSeq: cursor.startSeq ?? cursor.seq }; -} - -function positionedRow( - cursor: SourcedPosition | undefined, -): Pick { - return cursor ? { timelineCursor: anchorOf(cursor), source: sourceFor(cursor) } : {}; -} - -export function rowStartSeq(item: StreamItem): number | undefined { - return item.source?.startSeq ?? item.timelineCursor?.seq; -} - -function rowChunks(row: TextRow): SourceChunk[] { - if (row.source?.chunks) return row.source.chunks; - return row.timelineCursor ? [{ seq: row.timelineCursor.seq, offset: 0 }] : []; -} - -function chunkTexts(row: TextRow): Array<{ seq: number; text: string }> { - const chunks = rowChunks(row); - return chunks.map((chunk, index) => ({ - seq: chunk.seq, - text: row.text.slice(chunk.offset, chunks[index + 1]?.offset ?? row.text.length), - })); -} - -function chunksOf(parts: ReadonlyArray<{ seq: number; text: string }>): SourceChunk[] { - let offset = 0; - return parts.map((part) => { - const chunk = { seq: part.seq, offset }; - offset += part.text.length; - return chunk; - }); -} - -function sliceChunks(chunks: readonly SourceChunk[], start: number, end: number): SourceChunk[] { - const inside = chunks - .filter((chunk) => chunk.offset >= start && chunk.offset < end) - .map((chunk) => ({ seq: chunk.seq, offset: chunk.offset - start })); - const spanning = chunks.findLast((chunk) => chunk.offset < start); - if (spanning && inside[0]?.offset !== 0) inside.unshift({ seq: spanning.seq, offset: 0 }); - return inside; -} - -export interface CanonicalTextUnit { - text: string; - epoch: string; - startSeq: number; - endSeq: number; - timestamp: Date; -} - -// A canonical text unit owns the source positions from `startSeq` through `endSeq`. Among the rows -// that belong to the same message it replaces exactly the chunks inside that coverage, keeps chunks -// before it and after it, and lands where the covered chunks were, so a page that lags the live -// stream restores the prefix it certifies without touching text the stream delivered later. Rows -// keep their identity and split; rows left with no text are dropped. -function coverageOf(seq: number, unit: CanonicalTextUnit): "before" | "covered" | "after" { - if (seq < unit.startSeq) return "before"; - return seq > unit.endSeq ? "after" : "covered"; -} - -export function mergeCanonicalText( - owned: readonly Row[], - unit: CanonicalTextUnit, -): Row[] { - const parts = owned.map((row) => { - const positioned = row.timelineCursor?.epoch === unit.epoch; - return chunkTexts(row).map((part, index) => ({ - seq: part.seq, - startSeq: index === 0 ? (rowStartSeq(row) ?? part.seq) : part.seq, - text: part.text, - where: positioned ? coverageOf(part.seq, unit) : ("covered" as const), - })); - }); - let anchor = parts.findIndex((rowParts) => rowParts.some((part) => part.where !== "before")); - if (anchor < 0) anchor = owned.length - 1; - const result: Row[] = []; - for (const [index, row] of owned.entries()) { - const rowParts = parts[index] ?? []; - if (index < anchor) { - result.push(row); - continue; - } - const before = index === anchor ? rowParts.filter((part) => part.where === "before") : []; - const after = rowParts.filter((part) => part.where === "after"); - const kept = - index === anchor - ? [ - ...before, - { - seq: unit.endSeq, - startSeq: unit.startSeq, - text: unit.text, - where: "covered" as const, - }, - ...after, - ] - : after; - if (kept.length === 0) continue; - const text = kept.map((part) => part.text).join(""); - const last = kept.at(-1); - const first = kept[0]; - result.push({ - ...row, - text, - timestamp: index === anchor && after.length === 0 ? unit.timestamp : row.timestamp, - timelineCursor: { epoch: unit.epoch, seq: last?.seq ?? unit.endSeq }, - source: { - startSeq: first?.startSeq ?? unit.startSeq, - chunks: chunksOf(kept), - }, - }); - } - return result; -} - -// Appends one text row's content to another, keeping every chunk position. -export function joinTextRows(first: Row, second: Row): Row { - const parts = [...chunkTexts(first), ...chunkTexts(second)]; - return { - ...second, - text: `${first.text}${second.text}`, - ...(second.timelineCursor - ? { - source: { - startSeq: rowStartSeq(first) ?? second.timelineCursor.seq, - chunks: chunksOf(parts), - }, - } - : {}), - }; } export type OrchestratorToolCallStatus = "executing" | "completed" | "failed"; @@ -1005,7 +762,6 @@ export interface ToolCallItem { kind: "tool_call"; id: string; timelineCursor?: TimelinePosition; - source?: RowSource; turnId?: string; timestamp: Date; payload: ToolCallPayload; @@ -1026,7 +782,6 @@ export interface NotificationItem { sourceType: "error" | "notification"; id: string; timelineCursor?: TimelinePosition; - source?: RowSource; turnId?: string; timestamp: Date; level: NotificationLevel; @@ -1037,7 +792,6 @@ export interface CompactionItem { kind: "compaction"; id: string; timelineCursor?: TimelinePosition; - source?: RowSource; turnId?: string; timestamp: Date; status: "loading" | "completed"; @@ -1049,7 +803,6 @@ export interface PluginTimelineStreamItem { kind: "plugin"; id: string; timelineCursor?: TimelinePosition; - source?: RowSource; turnId?: string; timestamp: Date; pluginId: string; @@ -1075,7 +828,6 @@ export interface TodoListItem { kind: "todo_list"; id: string; timelineCursor?: TimelinePosition; - source?: RowSource; turnId?: string; timestamp: Date; provider: AgentProvider; @@ -1088,7 +840,7 @@ export type StreamUpdateSource = "live" | "canonical"; interface StreamUpdateOptions { source?: StreamUpdateSource; reservedItemIds?: ReadonlySet; - timelineCursor?: SourcedPosition; + timelineCursor?: TimelinePosition; } function isRecord(value: unknown): value is Record { @@ -1149,7 +901,7 @@ function appendUserMessage( id: messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp), clientMessageId, messageId, - ...positionedRow(timelineCursor), + timelineCursor, turnId, text: chunk, timestamp, @@ -1164,7 +916,7 @@ function appendAssistantMessage( source: StreamUpdateSource, messageId?: string, reservedItemIds?: ReadonlySet, - timelineCursor?: SourcedPosition, + timelineCursor?: TimelinePosition, ): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); if (!chunk) { @@ -1177,7 +929,13 @@ function appendAssistantMessage( last.kind === "assistant_message" && (messageId === undefined || last.messageId === messageId); if (shouldAppendToLast) { - return [...state.slice(0, -1), extendTextRow(last, chunk, timestamp, timelineCursor)]; + const updated: AssistantMessageItem = { + ...last, + text: `${last.text}${chunk}`, + timestamp, + ...(timelineCursor ? { timelineCursor } : {}), + }; + return [...state.slice(0, -1), updated]; } // A submitted user row can follow the streaming assistant during interrupt. @@ -1189,11 +947,13 @@ function appendAssistantMessage( secondLast?.kind === "assistant_message" && (messageId === undefined || secondLast.messageId === messageId) ) { - return [ - ...state.slice(0, -2), - extendTextRow(secondLast, chunk, timestamp, timelineCursor), - last, - ]; + const updated: AssistantMessageItem = { + ...secondLast, + text: `${secondLast.text}${chunk}`, + timestamp, + ...(timelineCursor ? { timelineCursor } : {}), + }; + return [...state.slice(0, -2), updated, last]; } if (!hasContent) { @@ -1206,53 +966,18 @@ function appendAssistantMessage( kind: "assistant_message", id: entryId, ...(messageId ? { messageId } : {}), - ...startedTextRow(timelineCursor), + ...(timelineCursor ? { timelineCursor } : {}), text: chunk, timestamp, }; return [...state, item]; } -function startedTextRow( - timelineCursor: SourcedPosition | undefined, -): Pick { - if (!timelineCursor) return {}; - return { - timelineCursor: anchorOf(timelineCursor), - source: { - startSeq: timelineCursor.startSeq ?? timelineCursor.seq, - chunks: [{ seq: timelineCursor.seq, offset: 0 }], - }, - }; -} - -function extendTextRow( - row: Row, - chunk: string, - timestamp: Date, - timelineCursor: SourcedPosition | undefined, -): Row { - return { - ...row, - text: `${row.text}${chunk}`, - timestamp, - ...(timelineCursor - ? { - timelineCursor: anchorOf(timelineCursor), - source: { - startSeq: sourceFor(timelineCursor, row).startSeq, - chunks: [...rowChunks(row), { seq: timelineCursor.seq, offset: row.text.length }], - }, - } - : {}), - }; -} - function appendThought( state: StreamItem[], text: string, timestamp: Date, - timelineCursor?: SourcedPosition, + timelineCursor?: TimelinePosition, ): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); if (!chunk) { @@ -1261,10 +986,14 @@ function appendThought( const last = state[state.length - 1]; if (last && last.kind === "thought") { - return [ - ...state.slice(0, -1), - { ...extendTextRow(last, chunk, timestamp, timelineCursor), status: "loading" }, - ]; + const updated: ThoughtItem = { + ...last, + ...(timelineCursor ? { timelineCursor } : {}), + text: `${last.text}${chunk}`, + timestamp, + status: "loading", + }; + return [...state.slice(0, -1), updated]; } if (!hasContent) { @@ -1275,7 +1004,7 @@ function appendThought( const item: ThoughtItem = { kind: "thought", id: createUniqueTimelineId(state, "thought", idSeed, timestamp), - ...startedTextRow(timelineCursor), + ...(timelineCursor ? { timelineCursor } : {}), text: chunk, timestamp, status: "loading", @@ -1427,7 +1156,7 @@ export function mergeAgentToolCallItem( existing: AgentToolCallItem, data: AgentToolCallData, timestamp: Date, - timelineCursor?: SourcedPosition, + timelineCursor?: TimelinePosition, ): AgentToolCallItem { const mergedStatus = mergeAgentToolCallStatus(existing.payload.data.status, data.status); const mergedError = @@ -1439,9 +1168,7 @@ export function mergeAgentToolCallItem( return { ...existing, - ...(timelineCursor - ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor, existing) } - : {}), + ...(timelineCursor ? { timelineCursor } : {}), timestamp, payload: { source: "agent", @@ -1462,7 +1189,7 @@ interface AppendAgentToolCallInput { data: AgentToolCallData; timestamp: Date; turnId?: string; - timelineCursor?: SourcedPosition; + timelineCursor?: TimelinePosition; } function appendAgentToolCall(input: AppendAgentToolCallInput): StreamItem[] { @@ -1498,9 +1225,7 @@ function appendAgentToolCall(input: AppendAgentToolCallInput): StreamItem[] { const item: ToolCallItem = { kind: "tool_call", id: `agent_tool_${identity}`, - ...(timelineCursor - ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) } - : {}), + ...(timelineCursor ? { timelineCursor } : {}), ...(turnId ? { turnId } : {}), timestamp, payload: { @@ -1529,7 +1254,7 @@ function appendPluginTimelineItem( state: StreamItem[], item: Extract, timestamp: Date, - timelineCursor?: SourcedPosition, + timelineCursor?: TimelinePosition, ): StreamItem[] { const identity = timelineItemIdentity(item); if (identity === null) return state; @@ -1542,22 +1267,14 @@ function appendPluginTimelineItem( version: item.version, data: item.data, timestamp, - ...(timelineCursor - ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) } - : {}), + ...(timelineCursor ? { timelineCursor } : {}), }; const existingIndex = findExistingTimelineIdentityIndex(state, identity); if (existingIndex < 0) return [...state, nextItem]; const existing = state[existingIndex]; if (!existing || existing.kind !== "plugin") return state; const next = [...state]; - next[existingIndex] = { - ...nextItem, - id: existing.id, - ...(timelineCursor - ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor, existing) } - : {}), - }; + next[existingIndex] = { ...nextItem, id: existing.id }; return next; } @@ -1566,7 +1283,7 @@ function appendTodoList( provider: AgentProvider, items: TodoEntry[], timestamp: Date, - timelineCursor?: SourcedPosition, + timelineCursor?: TimelinePosition, ): StreamItem[] { const normalizedItems = items.map((item) => ({ text: item.text, @@ -1588,9 +1305,7 @@ function appendTodoList( const next = [...state]; next[previousIndex] = { ...previous, - ...(timelineCursor - ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor, previous) } - : {}), + ...(timelineCursor ? { timelineCursor } : {}), items: normalizedItems, timestamp, }; @@ -1609,9 +1324,7 @@ function appendTodoList( const next = [...state]; next[next.length - 1] = { ...lastItem, - ...(timelineCursor - ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor, lastItem) } - : {}), + ...(timelineCursor ? { timelineCursor } : {}), items: normalizedItems, activity: { type: "created", count: normalizedItems.length }, timestamp, @@ -1625,9 +1338,7 @@ function appendTodoList( next.push({ kind: "todo_list", id: createUniqueTimelineId(next, "todo", idSeed, timestamp), - ...(timelineCursor - ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) } - : {}), + ...(timelineCursor ? { timelineCursor } : {}), timestamp, provider, items: normalizedItems, @@ -1682,7 +1393,7 @@ function reduceTimelineToolCall( { type: "tool_call" } >, timestamp: Date, - timelineCursor?: SourcedPosition, + timelineCursor?: TimelinePosition, ): StreamItem[] { const normalizedToolName = item.name .trim() @@ -1753,7 +1464,7 @@ function reduceTimelineCompaction( { type: "compaction" } >, timestamp: Date, - timelineCursor?: SourcedPosition, + timelineCursor?: TimelinePosition, ): StreamItem[] { if (item.status === "completed") { const loadingIdx = state.findIndex((s) => s.kind === "compaction" && s.status === "loading"); @@ -1761,12 +1472,7 @@ function reduceTimelineCompaction( if (loadingIdx >= 0 && existing && existing.kind === "compaction") { const updated: CompactionItem = { ...existing, - ...(timelineCursor - ? { - timelineCursor: anchorOf(timelineCursor), - source: sourceFor(timelineCursor, existing), - } - : {}), + ...(timelineCursor ? { timelineCursor } : {}), status: "completed", trigger: item.trigger ?? existing.trigger, preTokens: item.preTokens ?? existing.preTokens, @@ -1780,9 +1486,7 @@ function reduceTimelineCompaction( const compaction: CompactionItem = { kind: "compaction", id: createUniqueTimelineId(state, "compaction", item.status, timestamp), - ...(timelineCursor - ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) } - : {}), + ...(timelineCursor ? { timelineCursor } : {}), timestamp, status: item.status, trigger: item.trigger, @@ -1797,7 +1501,7 @@ function reduceTimelineEvent( timestamp: Date, source: StreamUpdateSource, reservedItemIds?: ReadonlySet, - timelineCursor?: SourcedPosition, + timelineCursor?: TimelinePosition, ): StreamItem[] { const item = event.item; switch (item.type) { @@ -1849,9 +1553,7 @@ function reduceTimelineEvent( kind: "notification", sourceType: "error", id: createUniqueTimelineId(state, "error", item.message ?? "", timestamp), - ...(timelineCursor - ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) } - : {}), + ...(timelineCursor ? { timelineCursor } : {}), timestamp, level: "error", message: item.message ?? "Unknown error", @@ -1863,9 +1565,7 @@ function reduceTimelineEvent( kind: "notification", sourceType: "notification", id: createUniqueTimelineId(state, "notification", item.message, timestamp), - ...(timelineCursor - ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) } - : {}), + ...(timelineCursor ? { timelineCursor } : {}), timestamp, level: item.level, message: item.message, @@ -1974,7 +1674,7 @@ export function hydrateStreamState( events: Array<{ event: AgentStreamEventPayload; timestamp: Date; - timelineCursor?: SourcedPosition; + timelineCursor?: TimelinePosition; }>, options?: Pick, ): StreamItem[] { @@ -2128,39 +1828,28 @@ function promoteCompletedAssistantBlocks(params: { tail: StreamItem[]; head: Str const firstBlockIndex = activeItem.blockIndex ?? 0; const completedBlocks = blocks.slice(0, -1); const liveBlock = `${blocks[blocks.length - 1] ?? ""}${getTrailingNewlineSuffix(activeItem.text)}`; - // Blocks are substrings of the row in order; each keeps the chunks that produced it. - const chunks = rowChunks(activeItem); - let searchFrom = 0; - const blockRow = (block: string, blockIndex: number): AssistantMessageItem => { - const start = activeItem.text.indexOf(block, searchFrom); - searchFrom = start + block.length; - const blockChunks = sliceChunks(chunks, start, start + block.length); - return { - ...activeItem, - id: createAssistantBlockId({ groupId: blockGroupId, blockIndex }), - blockGroupId, - blockIndex, - text: block, - ...(activeItem.timelineCursor && blockChunks.length > 0 - ? { - timelineCursor: { - epoch: activeItem.timelineCursor.epoch, - seq: blockChunks.at(-1)?.seq ?? activeItem.timelineCursor.seq, - }, - source: { - startSeq: blockChunks[0]?.seq ?? activeItem.timelineCursor.seq, - chunks: blockChunks, - }, - } - : {}), - }; - }; - const promotedItems = completedBlocks.map((block, offset) => - blockRow(block, firstBlockIndex + offset), - ); + const promotedItems = completedBlocks.map((block, offset) => ({ + ...activeItem, + id: createAssistantBlockId({ + groupId: blockGroupId, + blockIndex: firstBlockIndex + offset, + }), + blockGroupId, + blockIndex: firstBlockIndex + offset, + text: block, + })); const nextTail = flushHeadToTail(params.tail, promotedItems); - const liveItem = blockRow(liveBlock, firstBlockIndex + completedBlocks.length); + const liveItem: AssistantMessageItem = { + ...activeItem, + id: createAssistantBlockId({ + groupId: blockGroupId, + blockIndex: firstBlockIndex + completedBlocks.length, + }), + blockGroupId, + blockIndex: firstBlockIndex + completedBlocks.length, + text: liveBlock, + }; const nextHead = [ ...params.head.slice(0, assistantIndex), liveItem, @@ -2256,7 +1945,7 @@ function applyCanonicalUserMessageEvent(params: { head: StreamItem[]; event: AgentStreamEventPayload; timestamp: Date; - timelineCursor?: SourcedPosition; + timelineCursor?: TimelinePosition; unmatchedInsert?: "tail" | "head"; }): ApplyStreamEventResult | null { const { tail, head, event, timestamp, timelineCursor, unmatchedInsert = "tail" } = params; @@ -2272,7 +1961,7 @@ function applyCanonicalUserMessageEvent(params: { messageId: event.item.messageId, clientMessageId: event.item.clientMessageId, turnId: event.turnId, - ...positionedRow(timelineCursor), + timelineCursor, text: normalized.chunk, timestamp, }); @@ -2346,7 +2035,7 @@ export function applyStreamEvent(params: { event: AgentStreamEventPayload; timestamp: Date; source?: StreamUpdateSource; - timelineCursor?: SourcedPosition; + timelineCursor?: TimelinePosition; unmatchedUserMessageInsert?: "tail" | "head"; }): ApplyStreamEventResult { const { tail, head, event, timestamp } = params; diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index 72158779a3..c8e2479894 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -1,4 +1,3 @@ -import { createTimelineReplica } from "@/timeline/replica"; import { describe, expect, it } from "vitest"; import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; @@ -66,26 +65,18 @@ function permission(id: string): AgentPermissionRequest { return { id, provider: "codex", name: id, kind: "tool", title: id }; } -function beginPendingSubmission(serverId: string, agentId: string) { +function beginPendingSubmission(serverId: string, agentId: string): string { const clientMessageId = `client-${agentId}`; - const replica = createTimelineReplica({ + useSessionStore.getState().beginAgentMessageSubmission( serverId, - storage: { - removeTimeline: () => undefined, - readTimeline: async () => undefined, - commitTimeline: () => undefined, - }, - }); - replica.beginSubmission( agentId, createUserMessage({ clientMessageId, text: "Run this", timestamp: new Date("2026-07-27T10:00:00.000Z"), }), - true, ); - return { clientMessageId, replica }; + return clientMessageId; } describe("message submission authority", () => { @@ -94,11 +85,9 @@ describe("message submission authority", () => { const agentId = "agent-1"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - const { clientMessageId } = beginPendingSubmission(serverId, agentId); + const clientMessageId = beginPendingSubmission(serverId, agentId); const agent = createAgentPayload({ id: agentId, status: "running" }); - new AgentStoreProjection(serverId, (removedAgentId) => - useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId), - ).applyDelta({ + new AgentStoreProjection(serverId).applyDelta({ kind: "upsert", agent, project: createEntry(agent).project, @@ -119,44 +108,28 @@ describe("message submission authority", () => { const agentId = "agent-1"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - const { clientMessageId, replica } = beginPendingSubmission(serverId, agentId); - replica.handoffSubmission( - agentId, - createUserMessage({ - id: "provider-message", - messageId: "provider-message", - clientMessageId, - text: "Run this", - timestamp: new Date("2026-07-27T10:00:01.000Z"), - }), - ); + const clientMessageId = beginPendingSubmission(serverId, agentId); + store.setAgentStreamState(serverId, agentId, { + tail: [ + createUserMessage({ + id: "provider-message", + messageId: "provider-message", + clientMessageId, + text: "Run this", + timestamp: new Date("2026-07-27T10:00:01.000Z"), + }), + ], + head: [], + }); expect( useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0] ?.providerAcknowledged, ).toBe(false); - replica.applyEvents( - agentId, - [ - { - seq: 1, - epoch: "epoch", - timestamp: new Date("2026-07-27T10:00:01.000Z"), - event: { - type: "timeline", - provider: "codex", - item: { - type: "user_message", - messageId: "provider-message", - clientMessageId, - text: "Run this", - }, - }, - }, - ], - () => undefined, - ); + store.setAgentStreamState(serverId, agentId, { + acknowledgedClientMessageIds: [clientMessageId], + }); expect( useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0] @@ -173,9 +146,9 @@ describe("replaceFetchedAgentDirectory", () => { store.initializeSession(serverId, null as unknown as DaemonClient); store.setInitializingAgents(serverId, new Map([["agent", true]])); - new AgentStoreProjection(serverId, (removedAgentId) => - useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId), - ).replaceFetched([createEntry(createAgentPayload({ id: "agent" }))]); + new AgentStoreProjection(serverId).replaceFetched([ + createEntry(createAgentPayload({ id: "agent" })), + ]); expect(useSessionStore.getState().sessions[serverId]?.initializingAgents.get("agent")).toBe( true, @@ -188,9 +161,7 @@ describe("replaceFetchedAgentDirectory", () => { const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - const projection = new AgentStoreProjection(serverId, (removedAgentId) => - useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId), - ); + const projection = new AgentStoreProjection(serverId); projection.replaceFetched([ createEntry( createAgentPayload({ @@ -231,15 +202,10 @@ describe("replaceFetchedAgentDirectory", () => { serverId, new Map([[agentId, [{ id: "queued", text: "next", attachments: [] }]]]), ); - store.applyAgentTimelineResponseState(serverId, agentId, { - items: [], - head: [], - range: { epoch: "epoch", startSeq: 1, endSeq: 2 }, - older: "none", - newer: false, - synchronized: false, - submissions: [], - }); + store.setAgentTimelineCursor( + serverId, + new Map([[agentId, { epoch: "epoch", startSeq: 1, endSeq: 2 }]]), + ); store.setPendingPermissions( serverId, new Map([["permission", { key: "permission", agentId, request: null as never }]]), @@ -247,9 +213,7 @@ describe("replaceFetchedAgentDirectory", () => { store.setInitializingAgents(serverId, new Map([[agentId, true]])); setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); - new AgentStoreProjection(serverId, (removedAgentId) => - useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId), - ).applyDelta({ kind: "remove", agentId }); + new AgentStoreProjection(serverId).applyDelta({ kind: "remove", agentId }); const session = useSessionStore.getState().sessions[serverId]; expect({ @@ -309,9 +273,7 @@ describe("replaceFetchedAgentDirectory", () => { lastUsage: { inputTokens: 10, outputTokens: 5 }, pendingPermissions: [permission("current-permission")], }); - const projection = new AgentStoreProjection(serverId, (removedAgentId) => - useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId), - ); + const projection = new AgentStoreProjection(serverId); projection.applyDelta({ kind: "upsert", agent: current, diff --git a/packages/app/src/utils/agent-directory-sync.ts b/packages/app/src/utils/agent-directory-sync.ts index aff01550bf..d700a0f510 100644 --- a/packages/app/src/utils/agent-directory-sync.ts +++ b/packages/app/src/utils/agent-directory-sync.ts @@ -10,9 +10,6 @@ export type AgentDirectoryDelta = Extract< { type: "agent_update" } >["payload"]; -// Directory membership can disappear without the underlying agent being deleted. -export type AgentRemovalReason = "scope" | "deleted"; - interface PendingPermissionEntry { key: string; agentId: string; From c43df5d4c398571f62584b4ad5a629b5fd3b4599 Mon Sep 17 00:00:00 2001 From: Ivan Brykov Date: Mon, 7 Sep 2026 12:52:34 -0500 Subject: [PATCH 029/566] feat(client): expose provider usage (#4062) * feat(client): expose provider usage * fix(client): gate provider usage by capability --- packages/client/src/index.test.ts | 73 ++++++++++++++++++++++++++++++- packages/client/src/index.ts | 18 ++++++++ public-docs/sdk/reference.md | 1 + 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 7c02924c56..7cf75b5806 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -83,7 +83,10 @@ function parseSentFrame( } async function connectClient( - features: Record = { providersSnapshotCwd: true }, + features: Record = { + providerUsageList: true, + providersSnapshotCwd: true, + }, ): Promise<{ client: PaseoClient; ws: FakeWebSocket }> { vi.stubGlobal("WebSocket", FakeWebSocket); const client = createPaseoClient({ @@ -1257,6 +1260,62 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd provider: "codex", diagnostic: "Codex is ready.", }); + const usagePromise = client.providers.listUsage({ + requestId: "provider-usage-request", + }); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "provider.usage.list.request", + requestId: "provider-usage-request", + }); + ws.message( + sessionMessage({ + type: "provider.usage.list.response", + payload: { + requestId: "provider-usage-request", + fetchedAt: "2026-05-16T00:10:00.000Z", + providers: [ + { + providerId: "codex", + displayName: "Codex", + status: "available", + planLabel: "pro", + windows: [ + { + id: "weekly", + label: "Weekly limit", + usedPct: 25, + remainingPct: 75, + resetsAt: "2026-05-20T00:00:00.000Z", + tone: "ok", + }, + ], + }, + ], + }, + }), + ); + await expect(usagePromise).resolves.toEqual({ + requestId: "provider-usage-request", + fetchedAt: "2026-05-16T00:10:00.000Z", + providers: [ + { + providerId: "codex", + displayName: "Codex", + status: "available", + planLabel: "pro", + windows: [ + { + id: "weekly", + label: "Weekly limit", + usedPct: 25, + remainingPct: 75, + resetsAt: "2026-05-20T00:00:00.000Z", + tone: "ok", + }, + ], + }, + ], + }); const snapshotUpdates: string[] = []; const snapshotModelDefaults: Array = []; @@ -1307,6 +1366,18 @@ test("waitForReady requires canonical provider snapshot identity from the host", await client.close(); }); +test("provider usage requires the advertised host capability", async () => { + const { client, ws } = await connectClient({}); + const sentBeforeUsage = ws.sent.length; + + await expect(client.providers.listUsage()).rejects.toThrow( + "Update the host to list provider usage.", + ); + expect(ws.sent).toHaveLength(sentBeforeUsage); + + await client.close(); +}); + test("config actions delegate to existing daemon config RPCs", async () => { const { client, ws } = await connectClient(); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 12615bd44f..729ea4bc94 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -16,6 +16,7 @@ import type { MutableDaemonConfig, MutableDaemonConfigPatch, ProviderDiagnosticResponseMessage, + ProviderUsageListResponseMessage, ProjectPlacementPayload, WorkspaceProjectDescriptorPayload, RefreshProvidersSnapshotResponseMessage, @@ -369,6 +370,10 @@ export type PaseoProviderSnapshotUpdate = Extract< >["payload"]; export type PaseoProviderRefreshResult = RefreshProvidersSnapshotResponseMessage["payload"]; export type PaseoProviderDiagnosticResult = ProviderDiagnosticResponseMessage["payload"]; +export type PaseoProviderUsageResult = ProviderUsageListResponseMessage["payload"]; +export interface PaseoProviderUsageOptions { + requestId?: string; +} export interface PaseoProviderListOptions { cwd?: string; @@ -407,6 +412,7 @@ export interface PaseoProviderActions { provider: PaseoAgentProvider, options?: { requestId?: string }, ): Promise; + listUsage(options?: PaseoProviderUsageOptions): Promise; subscribe(handler: (update: PaseoProviderSnapshotUpdate) => void): () => void; } @@ -545,6 +551,7 @@ export function createPaseoApi(daemonClient: DaemonClient): PaseoApi { waitForReady: (options) => waitForProvidersReady(daemonClient, options), refresh: (options) => daemonClient.refreshProvidersSnapshot(options), diagnostic: (provider, options) => daemonClient.getProviderDiagnostic(provider, options), + listUsage: (options) => listProviderUsage(daemonClient, options), subscribe: (handler) => daemonClient.on("providers_snapshot_update", (message) => { handler(message.payload); @@ -804,6 +811,17 @@ function parseProviderModel(selection: string): { provider: string; model: strin }; } +function listProviderUsage( + daemonClient: DaemonClient, + options?: PaseoProviderUsageOptions, +): Promise { + // COMPAT(providerUsageList): added in v0.1.98, remove after 2027-02-28 once daemon floor >= v0.1.98. + if (daemonClient.getLastServerInfoMessage()?.features?.providerUsageList !== true) { + return Promise.reject(new Error("Update the host to list provider usage.")); + } + return daemonClient.listProviderUsage(options); +} + function waitForProvidersReady( daemonClient: DaemonClient, options: PaseoProviderWaitOptions = {}, diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index bc726bd5be..0f8bf7200f 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -184,6 +184,7 @@ Use `workspace.terminals.create(options?)` and `workspace.terminals.list(options | `listModes(provider, options?)` | Modes result | Discovers permission or operating modes. | | `listFeatures(draftConfig)` | Features result | Discovers features for the current draft provider configuration. | | `diagnostic(provider)` | Diagnostic result | Returns human-readable setup diagnostics. | +| `listUsage(options?)` | `PaseoProviderUsageResult` | Returns normalized subscription windows, balances, and provider details. Rejects with an update-host error when unsupported. Options: `requestId`. | | `subscribe(handler)` | Unsubscribe function | Listens for catalog updates. | ## `client.config` From a7a708bec99e935ee4b8c6f7314a4b9a9984cfa6 Mon Sep 17 00:00:00 2001 From: Karan Sharma Date: Tue, 8 Sep 2026 00:38:50 +0530 Subject: [PATCH 030/566] fix(server): support OpenCode 2 in terminal activity plugin (#4300) * fix(server): support OpenCode 2 in terminal activity plugin OpenCode 2 rejects V1 hook-object plugins, so the installed paseo-terminal-activity.js failed to load on every OpenCode 2 session. Ship one definition object that serves both generations: OpenCode 2 loads id + setup() and ignores the 1.x server entrypoint, OpenCode 1 loads the same object through server(). * fix(server): preserve terminal activity across plugin generations * test(server): drain persistence before reload test cleanup --------- Co-authored-by: Mohamed Boudra --- docs/terminal-activity.md | 16 +- .../src/server/agent/agent-manager.test.ts | 2 + .../agent-hooks/opencode/opencode-plugin.ts | 73 ++++++-- .../agent-hooks/opencode/opencode.test.ts | 166 ++++++++++++++++-- 4 files changed, 222 insertions(+), 35 deletions(-) diff --git a/docs/terminal-activity.md b/docs/terminal-activity.md index fd2df0e95f..42826e5f49 100644 --- a/docs/terminal-activity.md +++ b/docs/terminal-activity.md @@ -65,12 +65,18 @@ Codex hook mapping: - `PermissionRequest` → `needs-input` - `Stop` → `idle` -OpenCode uses a server plugin instead of command hooks. The plugin listens to OpenCode bus events and emits these Paseo hook events: +OpenCode uses a server plugin instead of command hooks. Both generations discover the same global plugin file. Their loaders select separate entrypoints: OpenCode 1 calls `server()` with `{ type, properties }` bus events; OpenCode 2 calls `setup()` and subscribes to decoded `{ type, data }` events. Do not share their status mapping: V1 publishes `session.status` snapshots, while V2 publishes `session.execution.*` transitions. -- `session.status` with `busy` or `retry` → `running` -- `session.status` with `idle` → `idle` -- `permission.asked` → `needs-input` -- `permission.replied` → `running` +| OpenCode event | Generation | Activity | +| ----------------------------------------------------------- | ---------- | ----------- | +| `session.status` with `busy` or `retry` | 1 | running | +| `session.status` with `idle` | 1 | idle | +| `session.execution.started` | 2 | running | +| `session.execution.succeeded`, `.failed`, or `.interrupted` | 2 | idle | +| `permission.asked` | Both | needs-input | +| `permission.replied` | Both | running | + +The plugin translates both event contracts into the existing Paseo hook events. OpenCode 2 disposes its event subscription when the plugin unloads. The daemon maps hook states onto terminal activity like an agent lifecycle plus unread attention: `running` → `state: working`, `idle` → `state: idle`, and `needs-input` → `state: idle` with `attentionReason: needs_input`. A `working` → `idle` transition records `state: idle` with `attentionReason: finished` until the user focuses that terminal; plain idle terminals still contribute no workspace status. diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index bc38784e48..ccc9467a25 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -2487,6 +2487,8 @@ test.each(["hang", "reject"])( expect(client.resumeSessionCalls).toBe(0); expect(manager.getAgent(snapshot.id)?.session).toBe(client.firstSession); } finally { + await manager.flush(); + await storage.flush(); rmSync(workdir, { recursive: true, force: true }); } }, diff --git a/packages/server/src/terminal/agent-hooks/opencode/opencode-plugin.ts b/packages/server/src/terminal/agent-hooks/opencode/opencode-plugin.ts index 5fce566cd5..1a76e17a44 100644 --- a/packages/server/src/terminal/agent-hooks/opencode/opencode-plugin.ts +++ b/packages/server/src/terminal/agent-hooks/opencode/opencode-plugin.ts @@ -1,37 +1,72 @@ import type { AgentHookPluginFileInstallStrategy } from "../agent-hook-installer.js"; +// Both generations discover the same file. Their loaders select the entrypoint: +// OpenCode 1 calls server(); OpenCode 2 calls setup(). Keep their event contracts +// separate: V1 publishes status snapshots, V2 publishes execution transitions. export const OPENCODE_PLUGIN_SOURCE = [ - "const STATUS_EVENTS = {", + "const V1_STATUS_EVENTS = {", ' busy: "session.status.busy",', ' retry: "session.status.retry",', ' idle: "session.status.idle",', "};", "", - "function paseoEventFor(event) {", - ' if (event?.type === "permission.asked") return "permission.asked";', - ' if (event?.type === "permission.replied") return "permission.replied";', - ' if (event?.type !== "session.status") return null;', - " return STATUS_EVENTS[event?.properties?.status?.type] ?? null;", + "const V2_EVENTS = {", + ' "session.execution.started": "session.status.busy",', + ' "session.execution.succeeded": "session.status.idle",', + ' "session.execution.failed": "session.status.idle",', + ' "session.execution.interrupted": "session.status.idle",', + ' "permission.asked": "permission.asked",', + ' "permission.replied": "permission.replied",', + "};", + "", + "function paseoEventForV1(event) {", + " const type = event.type;", + ' if (type === "permission.asked") return "permission.asked";', + ' if (type === "permission.replied") return "permission.replied";', + ' if (type !== "session.status") return null;', + " return V1_STATUS_EVENTS[event.properties.status.type] ?? null;", "}", "", + // CLI processes can finish out of order, especially for immediate failures. + // Both entrypoints enqueue reports so an older busy report cannot overwrite idle. + "let pendingHook = Promise.resolve();", + "", "function runPaseoHook(event) {", " if (!process.env.PASEO_TERMINAL_ID) return;", - " try {", - ' const child = Bun.spawn(["paseo", "hooks", "opencode", event], {', - ' stdin: "ignore",', - ' stdout: "ignore",', - ' stderr: "ignore",', - " });", - " void child.exited.catch(() => {});", - " } catch {}", + " pendingHook = pendingHook.then(async () => {", + " try {", + ' const child = Bun.spawn(["paseo", "hooks", "opencode", event], {', + ' stdin: "ignore",', + ' stdout: "ignore",', + ' stderr: "ignore",', + " });", + " await child.exited;", + " } catch {}", + " });", + " return pendingHook;", "}", "", - "export default async () => ({", - " event: async ({ event }) => {", - " const paseoEvent = paseoEventFor(event);", - " if (paseoEvent) runPaseoHook(paseoEvent);", + "export default {", + ' id: "paseo-terminal-activity",', + " server() {", + " return {", + " event: async ({ event }) => {", + " const paseoEvent = paseoEventForV1(event);", + " if (paseoEvent) await runPaseoHook(paseoEvent);", + " },", + " };", " },", - "});", + " setup(ctx) {", + " const controller = new AbortController();", + " void (async () => {", + " for await (const event of ctx.event.subscribe({ signal: controller.signal })) {", + " const paseoEvent = V2_EVENTS[event.type];", + " if (paseoEvent) await runPaseoHook(paseoEvent);", + " }", + " })().catch(() => {});", + " return () => controller.abort();", + " },", + "};", "", ].join("\n"); diff --git a/packages/server/src/terminal/agent-hooks/opencode/opencode.test.ts b/packages/server/src/terminal/agent-hooks/opencode/opencode.test.ts index 6ba28b22ee..7379e5e9d3 100644 --- a/packages/server/src/terminal/agent-hooks/opencode/opencode.test.ts +++ b/packages/server/src/terminal/agent-hooks/opencode/opencode.test.ts @@ -1,6 +1,9 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { addAbortSignal, PassThrough, Readable } from "node:stream"; +import { finished } from "node:stream/promises"; +import { runInNewContext } from "node:vm"; import { afterEach, describe, expect, it } from "vitest"; import { agentHooksAreInstalled, @@ -40,18 +43,123 @@ describe("OpenCode terminal agent hooks", () => { expect(agentHooksAreInstalled(opencodeAgentHookProvider, { configDir })).toBe(true); }); - it("writes the plugin that maps OpenCode bus events to paseo hook events", () => { - const configDir = createTempDir("paseo-opencode-config-source-"); - const { configPath } = installAgentHooks(opencodeAgentHookProvider, { configDir }); - const source = readFileSync(configPath, "utf8"); + it.each(["succeeded", "failed", "interrupted"])( + "reports OpenCode 2 execution start and %s as running then idle", + async (outcome) => { + const { plugin, commands } = loadInstalledPlugin(); + const events = Readable.from([ + { type: "session.execution.started", data: { sessionID: "session-1" } }, + { type: `session.execution.${outcome}`, data: { sessionID: "session-1" } }, + ]); - expect(source).toContain('busy: "session.status.busy"'); - expect(source).toContain('retry: "session.status.retry"'); - expect(source).toContain('idle: "session.status.idle"'); - expect(source).toContain('event?.type === "permission.asked"'); - expect(source).toContain('event?.type === "permission.replied"'); - expect(source).toContain('Bun.spawn(["paseo", "hooks", "opencode", event]'); - expect(source).toContain("PASEO_TERMINAL_ID"); + const dispose = plugin.setup({ event: { subscribe: () => events } }); + await finished(events); + dispose(); + + expect(commands).toEqual([ + ["paseo", "hooks", "opencode", "session.status.busy"], + ["paseo", "hooks", "opencode", "session.status.idle"], + ]); + }, + ); + + it("reports OpenCode 1 status and permission events through server()", async () => { + const { plugin, commands } = loadInstalledPlugin(); + const hooks = plugin.server(); + for (const event of [ + { type: "session.status", properties: { status: { type: "busy" } } }, + { type: "permission.asked" }, + { type: "permission.replied" }, + { type: "session.status", properties: { status: { type: "retry" } } }, + { type: "session.status", properties: { status: { type: "idle" } } }, + { type: "session.execution.started", data: { sessionID: "session-1" } }, + { type: "message.updated" }, + ]) { + await hooks.event({ event }); + } + + expect(plugin.id).toBe("paseo-terminal-activity"); + expect(commands).toEqual([ + ["paseo", "hooks", "opencode", "session.status.busy"], + ["paseo", "hooks", "opencode", "permission.asked"], + ["paseo", "hooks", "opencode", "permission.replied"], + ["paseo", "hooks", "opencode", "session.status.retry"], + ["paseo", "hooks", "opencode", "session.status.idle"], + ]); + }); + + it("reports OpenCode 2 permission events and ignores V1 status snapshots", async () => { + const { plugin, commands } = loadInstalledPlugin(); + const events = Readable.from([ + { type: "permission.asked", data: { sessionID: "session-1" } }, + { type: "permission.replied", data: { sessionID: "session-1" } }, + { type: "session.status", properties: { status: { type: "busy" } } }, + { type: "session.text.delta", data: { sessionID: "session-1" } }, + ]); + + const dispose = plugin.setup({ event: { subscribe: () => events } }); + await finished(events); + dispose(); + + expect(commands).toEqual([ + ["paseo", "hooks", "opencode", "permission.asked"], + ["paseo", "hooks", "opencode", "permission.replied"], + ]); + }); + + it("preserves activity order when the host dispatches events concurrently", async () => { + let finishFirstHook = () => {}; + const firstExit = new Promise((resolve) => { + finishFirstHook = () => resolve(0); + }); + const { plugin, commands } = loadInstalledPlugin("terminal-1", firstExit); + const hooks = plugin.server(); + + const working = hooks.event({ + event: { type: "session.status", properties: { status: { type: "busy" } } }, + }); + const idle = hooks.event({ + event: { type: "session.status", properties: { status: { type: "idle" } } }, + }); + await Promise.resolve(); + expect(commands).toEqual([["paseo", "hooks", "opencode", "session.status.busy"]]); + + finishFirstHook(); + await Promise.all([working, idle]); + expect(commands).toEqual([ + ["paseo", "hooks", "opencode", "session.status.busy"], + ["paseo", "hooks", "opencode", "session.status.idle"], + ]); + }); + + it("stops the OpenCode 2 subscription when unloaded", async () => { + const { plugin, commands } = loadInstalledPlugin(); + const events = new PassThrough({ objectMode: true }); + const closed = finished(events); + const dispose = plugin.setup({ + event: { subscribe: ({ signal }) => addAbortSignal(signal, events) }, + }); + + dispose(); + + await expect(closed).rejects.toMatchObject({ name: "AbortError" }); + expect(commands).toEqual([]); + }); + + it("keeps both generations inert outside Paseo terminals", async () => { + const { plugin, commands } = loadInstalledPlugin(""); + await plugin.server().event({ + event: { type: "session.status", properties: { status: { type: "busy" } } }, + }); + const events = Readable.from([ + { type: "session.execution.started", data: { sessionID: "session-1" } }, + { type: "permission.asked", data: { sessionID: "session-1" } }, + ]); + const dispose = plugin.setup({ event: { subscribe: () => events } }); + await finished(events); + dispose(); + + expect(commands).toEqual([]); }); it("uninstalls the OpenCode plugin file", () => { @@ -121,3 +229,39 @@ describe("OpenCode terminal agent hooks", () => { ).resolves.toBe(state); }); }); + +interface OpenCodeEvent { + type: string; + properties?: { status: { type: string } }; + data?: { sessionID: string }; +} + +interface InstalledPlugin { + id: string; + server(): { event(input: { event: OpenCodeEvent }): Promise }; + setup(context: { + event: { subscribe(options: { signal: AbortSignal }): AsyncIterable }; + }): () => void; +} + +function loadInstalledPlugin(terminalId = "terminal-1", exited = Promise.resolve(0)) { + const configDir = createTempDir("paseo-opencode-runtime-"); + const { configPath } = installAgentHooks(opencodeAgentHookProvider, { configDir }); + const source = readFileSync(configPath, "utf8"); + const commands: string[][] = []; + // Supply the host runtime at the script boundary; execute the installed source. + const plugin: InstalledPlugin = runInNewContext( + source.replace("export default", "globalThis.plugin ="), + { + AbortController, + process: { env: { PASEO_TERMINAL_ID: terminalId } }, + Bun: { + spawn(command: string[]) { + commands.push(command); + return { exited }; + }, + }, + }, + ); + return { plugin, commands }; +} From 469d51289b6981024bf33deb70a93c6c42aad888 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 7 Sep 2026 21:26:30 +0200 Subject: [PATCH 031/566] Keep 0.7.2 apps working with 0.8 daemons (#4443) * fix(protocol): keep 0.7.2 clients working with 0.8 hosts Gate plugin timeline rows and blocked workspace setup statuses per client capability so app-store version lag does not make existing agents unloadable. Preserve canonical pagination and document the intended Hub provider-mode authority. * test(protocol): clarify compatibility test preconditions --- packages/client/src/daemon-client.test.ts | 2 + packages/client/src/daemon-client.ts | 2 + packages/protocol/src/client-capabilities.ts | 4 + packages/protocol/src/messages.ts | 10 +- .../protocol/src/messages.wire-compat.test.ts | 87 +++++ .../selective-timeline-delivery.e2e.test.ts | 297 +++++++++++++++++- packages/server/src/server/session.ts | 75 +++-- .../server/src/server/wire-compat.test.ts | 50 +++ public-docs/hub/security.md | 5 +- 9 files changed, 505 insertions(+), 27 deletions(-) diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index e4697dfaf8..44b9189425 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -762,6 +762,8 @@ test("advertises client capabilities in hello", async () => { reasoning_merge_enum: true, terminal_reflowable_snapshot: true, timeline_notifications: true, + plugin_timeline_items: true, + workspace_setup_blocked: true, browser_host: { supportedCommands: ["list_tabs"], hostKind: "desktop app", diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index f67a4e7023..576e111a71 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -5681,6 +5681,8 @@ export class DaemonClient { ? { [CLIENT_CAPS.providerSnapshotReferences]: true } : {}), [CLIENT_CAPS.timelineNotifications]: true, + [CLIENT_CAPS.pluginTimelineItems]: true, + [CLIENT_CAPS.workspaceSetupBlocked]: true, ...this.config.capabilities, }, ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}), diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index d07ffdf242..f15c3ea7d9 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -34,6 +34,10 @@ export const CLIENT_CAPS = { // timeline items for older clients whose strict timeline union rejects them. // Remove after 2027-03-03 once the supported client floor is >= v0.7.2. timelineNotifications: "timeline_notifications", + // COMPAT(pluginTimelineItems): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0. + pluginTimelineItems: "plugin_timeline_items", + // COMPAT(workspaceSetupBlocked): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0. + workspaceSetupBlocked: "workspace_setup_blocked", browserHost: "browser_host", } as const; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 3c8d09d5b9..6be32a3525 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3404,9 +3404,9 @@ export const ServerInfoStatusPayloadSchema = z // COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot features: z .object({ - // COMPAT(agentRequestReceipts): added in v0.7.3; remove gate after 2027-03-05. + // COMPAT(agentRequestReceipts): added in v0.8.0; remove gate after 2027-03-05. agentRequestReceipts: z.boolean().optional(), - // COMPAT(hubAgentRpc): added in v0.7.3; remove gate after 2027-03-05. + // COMPAT(hubAgentRpc): added in v0.8.0; remove gate after 2027-03-05. hubAgentRpc: z.boolean().optional(), providersSnapshot: z.boolean().optional(), // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. @@ -3415,9 +3415,9 @@ export const ServerInfoStatusPayloadSchema = z directorySync: z.boolean().optional(), // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. workspaceLabels: z.boolean().optional(), - // COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02. + // COMPAT(workspaceSetupRun): added in v0.8.0, remove gate after 2027-09-02. workspaceSetupRun: z.boolean().optional(), - // COMPAT(workspaceTerminals): added in v0.7.3, remove gate after 2027-09-05. + // COMPAT(workspaceTerminals): added in v0.8.0, remove gate after 2027-09-05. workspaceTerminals: z.boolean().optional(), // COMPAT(checkoutForgeSetAutoMerge): added in v0.2.0-beta.1. Remove the // feature gate and checkoutGithubSetAutoMerge fallback after 2027-01-17 @@ -3527,7 +3527,7 @@ export const ServerInfoStatusPayloadSchema = z providerRemoval: z.boolean().optional(), // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. importSessionWorkspaceTarget: z.boolean().optional(), - // COMPAT(importSessionSearch): added in v0.7.3, remove gate after 2027-03-02. + // COMPAT(importSessionSearch): added in v0.8.0, remove gate after 2027-03-02. importSessionSearch: z.boolean().optional(), // COMPAT(forgeProviders): added in v0.2.0-beta.1. Drop the gate after // 2027-01-17 once the supported daemon floor is >= v0.2.0. diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index bcfc37d5f8..fe2a79916f 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -6,6 +6,9 @@ import { ServerInfoStatusPayloadSchema, SessionOutboundMessageSchema, WSHelloMessageSchema, + WorkspaceSetupSnapshotSchema, + WorkspaceSetupProgressMessageSchema, + AgentTimelineEntryPayloadSchema, } from "./messages.js"; test("terminal listings accept older rows and retain new per-terminal directories", () => { @@ -302,3 +305,87 @@ describe("wire schema compatibility", () => { }); }); }); + +test("0.8 timeline and setup capabilities remain optional in the hello", () => { + const hello = { type: "hello", clientId: "compat", clientType: "mobile", protocolVersion: 1 }; + expect(WSHelloMessageSchema.safeParse(hello).success).toBe(true); + expect( + WSHelloMessageSchema.parse({ + ...hello, + capabilities: { plugin_timeline_items: true, workspace_setup_blocked: true }, + }).capabilities, + ).toEqual({ plugin_timeline_items: true, workspace_setup_blocked: true }); +}); + +test("plugin rows and identity merges require a capable receiver", () => { + const item = { + type: "plugin", + id: "task", + pluginId: "tasks", + kind: "tasks", + version: 1, + data: { text: "Working" }, + }; + expect(AgentTimelineItemPayloadSchema.parse(item)).toEqual(item); + const legacyItems = z.object({ + type: z.enum([ + "user_message", + "assistant_message", + "reasoning", + "tool_call", + "todo", + "error", + "notification", + "compaction", + ]), + }); + expect(legacyItems.safeParse(item).success).toBe(false); + const entry = { + provider: "codex", + item, + timestamp: "2026-09-07T00:00:00.000Z", + seqStart: 1, + seqEnd: 2, + sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }], + collapsed: ["identity"], + }; + expect(AgentTimelineEntryPayloadSchema.parse(entry)).toEqual(entry); + const legacyCollapsed = z.array(z.enum(["assistant_merge", "reasoning_merge", "tool_lifecycle"])); + expect(legacyCollapsed.safeParse(entry.collapsed).success).toBe(false); +}); + +test("blocked setup preserves the legacy failed shape and optional provenance", () => { + const snapshot = { + status: "blocked", + error: null, + detail: { + type: "worktree_setup", + worktreePath: "/workspace", + branchName: "fork", + log: "", + commands: [], + }, + blockedSource: { + kind: "change_request", + forge: "github", + number: 42, + headRepository: "contributor/project", + }, + }; + expect(WorkspaceSetupSnapshotSchema.parse(snapshot)).toEqual(snapshot); + const legacyStatus = z.enum(["running", "completed", "failed"]); + const legacySnapshot = WorkspaceSetupSnapshotSchema.omit({ blockedSource: true }).extend({ + status: legacyStatus, + }); + expect(legacySnapshot.safeParse(snapshot).success).toBe(false); + const failed = { ...snapshot, status: "failed", error: "Update Paseo to review and run setup." }; + expect(legacySnapshot.safeParse(failed).success).toBe(true); + const progress = { + type: "workspace_setup_progress", + payload: { ...failed, workspaceId: "workspace" }, + }; + expect(WorkspaceSetupProgressMessageSchema.parse(progress)).toEqual(progress); + expect(WorkspaceSetupSnapshotSchema.parse(legacySnapshot.parse(failed))).toEqual( + legacySnapshot.parse(failed), + ); +}); diff --git a/packages/server/src/server/selective-timeline-delivery.e2e.test.ts b/packages/server/src/server/selective-timeline-delivery.e2e.test.ts index 2bbe48662a..a06b7a32c8 100644 --- a/packages/server/src/server/selective-timeline-delivery.e2e.test.ts +++ b/packages/server/src/server/selective-timeline-delivery.e2e.test.ts @@ -1,9 +1,17 @@ +import type { AgentStreamEvent, AgentSessionConfig } from "./agent/agent-sdk-types.js"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createPersistedWorkspaceRecord } from "./workspace-registry.js"; import { afterEach, beforeEach, expect, test } from "vitest"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { DaemonClient } from "./test-utils/daemon-client.js"; import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js"; -import { MockLoadTestAgentClient } from "./agent/providers/mock-load-test-agent.js"; +import { + MockLoadTestAgentClient, + MockLoadTestAgentSession, +} from "./agent/providers/mock-load-test-agent.js"; interface MessageWaiter { predicate(message: SessionOutboundMessage): boolean; @@ -130,12 +138,16 @@ async function connect(input: { selective: boolean; timelineReplacementInvalidation?: boolean; timelineNotifications?: boolean; + pluginTimelineItems?: boolean; + workspaceSetupBlocked?: boolean; }): Promise { const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, clientId: input.clientId, capabilities: { [CLIENT_CAPS.selectiveAgentTimeline]: input.selective, + [CLIENT_CAPS.pluginTimelineItems]: input.pluginTimelineItems ?? false, + [CLIENT_CAPS.workspaceSetupBlocked]: input.workspaceSetupBlocked ?? false, ...(input.timelineNotifications === undefined ? {} : { [CLIENT_CAPS.timelineNotifications]: input.timelineNotifications }), @@ -228,6 +240,94 @@ test("notification timeline items are sent only to clients that advertise suppor expect(legacyTimeline.endCursor).toEqual(capableTimeline.endCursor); }); +test("plugin timeline items are sent only to clients that advertise support", async () => { + await daemon.close(); + daemon = await createTestPaseoDaemon({ + isDev: true, + agentClients: { mock: new MockLoadTestAgentClient() }, + }); + const capable = await connect({ + clientId: "plugin-shared", + selective: false, + pluginTimelineItems: true, + }); + const legacy = await connect({ + clientId: "plugin-shared", + selective: false, + pluginTimelineItems: false, + }); + const agent = await capable.client.createAgent({ + provider: "mock", + cwd: "/tmp", + title: "Plugin compatibility", + model: "ten-second-stream", + }); + capable.clear(); + legacy.clear(); + + await daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "plugin", + id: "compat-row", + pluginId: "timeline-items", + kind: "notice", + version: 1, + data: { text: "Capable clients only" }, + }); + await daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "plugin", + id: "compat-row", + pluginId: "timeline-items", + kind: "notice", + version: 1, + data: { text: "Updated" }, + }); + await daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "assistant_message", + text: "Visible to every client", + }); + await Promise.all([ + capable.next(isAgentStream(agent.id), "capable timeline delivery"), + legacy.next(isAgentStream(agent.id), "legacy timeline delivery"), + ]); + await Promise.all([capable.barrier("plugin-capable"), legacy.barrier("plugin-legacy")]); + + const capableLiveItems = capable.messages.flatMap((message) => + message.type === "agent_stream" && message.payload.event.type === "timeline" + ? [message.payload.event.item] + : [], + ); + const legacyLiveItems = legacy.messages.flatMap((message) => + message.type === "agent_stream" && message.payload.event.type === "timeline" + ? [message.payload.event.item] + : [], + ); + expect(capableLiveItems.some((item) => item.type === "plugin")).toBe(true); + expect(legacyLiveItems.some((item) => item.type === "plugin")).toBe(false); + expect(legacyLiveItems).toContainEqual( + expect.objectContaining({ type: "assistant_message", text: "Visible to every client" }), + ); + + for (const projection of ["canonical", "projected"] as const) { + const [capableTimeline, legacyTimeline] = await Promise.all([ + capable.client.fetchAgentTimeline(agent.id, { direction: "tail", projection }), + legacy.client.fetchAgentTimeline(agent.id, { direction: "tail", projection }), + ]); + expect(capableTimeline.entries.some((entry) => entry.item.type === "plugin")).toBe(true); + expect(legacyTimeline.entries.some((entry) => entry.item.type === "plugin")).toBe(false); + expect(legacyTimeline.entries).toContainEqual( + expect.objectContaining({ + item: expect.objectContaining({ + type: "assistant_message", + text: "Visible to every client", + }), + }), + ); + expect(legacyTimeline.window).toEqual(capableTimeline.window); + expect(legacyTimeline.endCursor).toEqual(capableTimeline.endCursor); + expect(legacyTimeline.entries.flatMap((entry) => entry.collapsed)).not.toContain("identity"); + } +}); + test("rewind routes replacement completion by source capability and subscription", async () => { await daemon.close(); daemon = await createTestPaseoDaemon({ @@ -320,12 +420,14 @@ test("subscription acknowledgements stay on the requesting socket of a retained test("real WebSocket sessions enforce selective delivery, retained resets, downgrade, and dedicated attention", async () => { const legacy = await connect({ clientId: "legacy-client", selective: false }); let capable = await connect({ clientId: "capable-client", selective: true }); + const workspaceId = await createAttentionWorkspace(legacy.client); const agents = await Promise.all( ["A", "B", "C"].map((title) => legacy.client.createAgent({ provider: "codex", - cwd: "/tmp", + cwd: daemon.paseoHome, title: `Selective ${title}`, + workspaceId, modeId: "full-access", }), ), @@ -397,6 +499,10 @@ test("real WebSocket sessions enforce selective delivery, retained resets, downg await capable.barrier("resumed-membership-reset"); expect(capable.hasTimeline(agentB.id)).toBe(false); + await Promise.all([ + legacy.client.fetchAgents({ subscribe: { subscriptionId: "legacy-directory" } }), + capable.client.fetchAgents({ subscribe: { subscriptionId: "capable-directory" } }), + ]); capable.client.sendHeartbeat({ deviceType: "mobile", focusedAgentId: null, @@ -451,3 +557,190 @@ test("real WebSocket sessions enforce selective delivery, retained resets, downg expect(downgradedDelivery.type).toBe("agent_stream"); }, 30_000); + +test("blocked setup remains readable on mixed-capability sockets sharing a session", async () => { + await daemon.close(); + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-blocked-compat-")); + const projects = path.join(root, ".paseo", "projects"); + await mkdir(projects, { recursive: true }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "blocked-workspace", + projectId: "project", + cwd: root, + kind: "directory", + displayName: "Fork PR", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + untrustedSource: { + kind: "change_request", + forge: "github", + number: 42, + headRepository: "contributor/project", + }, + }); + await writeFile(path.join(projects, "workspaces.json"), JSON.stringify([workspace])); + daemon = await createTestPaseoDaemon({ paseoHomeRoot: root }); + const legacy = await connect({ clientId: "setup-shared", selective: false }); + const capable = await connect({ + clientId: "setup-shared", + selective: false, + workspaceSetupBlocked: true, + }); + const oldStatus = await legacy.client.fetchWorkspaceSetupStatus(workspace.workspaceId); + const newStatus = await capable.client.fetchWorkspaceSetupStatus(workspace.workspaceId); + expect(oldStatus.snapshot).toMatchObject({ + status: "failed", + error: + "Workspace setup is blocked pending approval of code from a fork pull request. Update Paseo to review and run setup.", + }); + expect(newStatus.snapshot).toMatchObject({ + status: "blocked", + error: null, + blockedSource: workspace.untrustedSource, + }); + expect(oldStatus.snapshot?.detail).toEqual(newStatus.snapshot?.detail); +}); + +// A provider adapter with controllable events and durable provider history. +class CompatibilityProviderSession extends MockLoadTestAgentSession { + private readonly observers = new Set<(event: AgentStreamEvent) => void>(); + private readonly injectedHistory: AgentStreamEvent[] = []; + override subscribe(callback: (event: AgentStreamEvent) => void): () => void { + const unsubscribe = super.subscribe(callback); + this.observers.add(callback); + return () => { + unsubscribe(); + this.observers.delete(callback); + }; + } + push(event: AgentStreamEvent): void { + this.injectedHistory.push(event); + for (const callback of this.observers) callback(event); + } + override async *streamHistory(): AsyncGenerator { + yield* this.injectedHistory; + yield* super.streamHistory(); + } +} + +class CompatibilityProvider extends MockLoadTestAgentClient { + session!: CompatibilityProviderSession; + override async createSession(config: AgentSessionConfig): Promise { + this.session = new CompatibilityProviderSession({ + config, + sessionId: "compat-provider-session", + }); + return this.session; + } +} + +test("plugin items are gated in provider child streams, child fetches, and rewind replay", async () => { + await daemon.close(); + const provider = new CompatibilityProvider(); + daemon = await createTestPaseoDaemon({ isDev: true, agentClients: { mock: provider } }); + const legacy = await connect({ clientId: "provider-shared", selective: false }); + const capable = await connect({ + clientId: "provider-shared", + selective: false, + pluginTimelineItems: true, + }); + const agent = await capable.client.createAgent({ + provider: "mock", + cwd: "/tmp", + model: "ten-second-stream", + }); + const plugin = { + type: "plugin" as const, + id: "provider-row", + pluginId: "provider", + kind: "notice", + version: 1, + data: { text: "Provider notice" }, + }; + provider.session.push({ type: "timeline", provider: "mock", item: plugin }); + provider.session.push({ + type: "provider_subagent", + provider: "mock", + event: { type: "upsert", id: "child", title: "Child", status: "running" }, + }); + provider.session.push({ + type: "provider_subagent", + provider: "mock", + event: { type: "timeline", id: "child", item: plugin }, + }); + provider.session.push({ + type: "provider_subagent", + provider: "mock", + event: { + type: "timeline", + id: "child", + item: { type: "assistant_message", text: "Child result" }, + }, + }); + function childResult(message: SessionOutboundMessage): boolean { + return ( + message.type === "agent.provider_subagents.update" && + message.payload.kind === "timeline" && + message.payload.item.type === "assistant_message" + ); + } + await Promise.all([ + capable.next(childResult, "capable child result"), + legacy.next(childResult, "legacy child result"), + ]); + function childItems(client: ConnectedClient) { + return client.messages.flatMap((message) => + message.type === "agent.provider_subagents.update" && message.payload.kind === "timeline" + ? [message.payload.item] + : [], + ); + } + expect(childItems(capable)).toContainEqual(plugin); + expect(childItems(legacy)).toEqual([{ type: "assistant_message", text: "Child result" }]); + const oldChild = await legacy.client.fetchProviderSubagentTimeline(agent.id, "child"); + const newChild = await capable.client.fetchProviderSubagentTimeline(agent.id, "child"); + expect(newChild.rows.map((row) => row.item)).toContainEqual(plugin); + expect(oldChild.rows.map((row) => row.item)).toEqual([ + { type: "assistant_message", text: "Child result" }, + ]); + expect(oldChild.window).toEqual(newChild.window); + + await capable.client.sendMessage(agent.id, "Rewind target"); + await capable.client.cancelAgent(agent.id); + const timeline = await capable.client.fetchAgentTimeline(agent.id, { + direction: "tail", + projection: "canonical", + }); + const targetMessageId = rewindMessageId(timeline); + capable.clear(); + legacy.clear(); + await capable.client.rewindAgent(agent.id, targetMessageId, "conversation"); + await Promise.all([capable.barrier("provider-rewind"), legacy.barrier("provider-rewind")]); + function replayItems(client: ConnectedClient) { + return client.messages.flatMap((message) => + message.type === "agent_stream" && message.payload.event.type === "timeline" + ? [message.payload.event.item] + : [], + ); + } + expect(replayItems(capable)).toContainEqual(plugin); + expect(replayItems(legacy).some((item) => item.type === "plugin")).toBe(false); + expect(replayItems(legacy)).toContainEqual(expect.objectContaining({ type: "user_message" })); +}); + +async function createAttentionWorkspace(client: DaemonClient): Promise { + const result = await client.createWorkspace({ + source: { kind: "directory", path: daemon.paseoHome }, + }); + if (!result.workspace) throw new Error(result.error ?? "Expected workspace"); + return result.workspace.id; +} + +function rewindMessageId( + timeline: Awaited>, +): string { + const target = timeline.entries.find((entry) => entry.item.type === "user_message"); + if (target?.item.type !== "user_message" || !target.item.messageId) + throw new Error("Expected rewind target"); + return target.item.messageId; +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 6a6d9ba030..3bda1ec7cc 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1175,20 +1175,23 @@ export class Session { return true; } - private supportsTimelineNotifications(source?: object): boolean { - return source - ? this.supportsForSource(CLIENT_CAPS.timelineNotifications, source) - : this.supports(CLIENT_CAPS.timelineNotifications); + // COMPAT(timelineItemCapabilities): plugin items added in v0.8.0, notifications in v0.7.2. + // Remove after 2027-03-07 once the supported client floor is >= v0.8.0. + private supportsTimelineItem(item: { type: string }, source?: object): boolean { + let capability: ClientCapability; + if (item.type === "notification") capability = CLIENT_CAPS.timelineNotifications; + else if (item.type === "plugin") capability = CLIENT_CAPS.pluginTimelineItems; + else return true; + return source ? this.supportsForSource(capability, source) : this.supports(capability); } private forwardAgentStream( event: Extract, serializedEvent: Extract["payload"]["event"], ): void { - const isNotification = - serializedEvent.type === "timeline" && serializedEvent.item.type === "notification"; if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) { - if (isNotification && !this.supportsTimelineNotifications()) return; + if (serializedEvent.type === "timeline" && !this.supportsTimelineItem(serializedEvent.item)) + return; if (this.usesSelectiveTimelineDelivery() && serializedEvent.type === "attention_required") { this.emit({ type: "agent_attention_required", @@ -1213,7 +1216,11 @@ export class Session { } for (const [source, capabilities] of this.clientCapabilitiesBySource) { - if (isNotification && !capabilities.has(CLIENT_CAPS.timelineNotifications)) continue; + if ( + serializedEvent.type === "timeline" && + !this.supportsTimelineItem(serializedEvent.item, source) + ) + continue; const supportsSelectiveDelivery = capabilities.has(CLIENT_CAPS.selectiveAgentTimeline); if (supportsSelectiveDelivery && serializedEvent.type === "attention_required") { this.onMessageToSource(source, { @@ -1679,11 +1686,10 @@ export class Session { }; } - const isNotification = update.type === "timeline" && update.row.item.type === "notification"; if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) { if ( this.supports(CLIENT_CAPS.providerSubagents) && - (!isNotification || this.supportsTimelineNotifications()) + (update.type !== "timeline" || this.supportsTimelineItem(update.row.item)) ) { this.emit(message); } @@ -1691,7 +1697,8 @@ export class Session { } for (const [source, capabilities] of this.clientCapabilitiesBySource) { if (!capabilities.has(CLIENT_CAPS.providerSubagents)) continue; - if (isNotification && !capabilities.has(CLIENT_CAPS.timelineNotifications)) continue; + if (update.type === "timeline" && !this.supportsTimelineItem(update.row.item, source)) + continue; this.onMessageToSource(source, message); } } @@ -4128,7 +4135,7 @@ export class Session { source?: object, ): void { for (const row of rows) { - if (row.item.type === "notification" && !this.supportsTimelineNotifications(source)) { + if (!this.supportsTimelineItem(row.item, source)) { continue; } const event = serializeAgentStreamEvent({ @@ -7175,9 +7182,9 @@ export class Session { selectedTimeline.endSeq !== null ? { epoch: selectedTimeline.timeline.epoch, seq: selectedTimeline.endSeq } : null; - const entries = this.supportsTimelineNotifications(source) - ? selectedTimeline.entries - : selectedTimeline.entries.filter((entry) => entry.item.type !== "notification"); + const entries = selectedTimeline.entries.filter((entry) => + this.supportsTimelineItem(entry.item, source), + ); this.emitForSource( { @@ -7376,9 +7383,7 @@ export class Session { limit: msg.limit ?? (direction === "after" ? 0 : 200), }, ); - const rows = this.supportsTimelineNotifications(source) - ? timeline.rows - : timeline.rows.filter((row) => row.item.type !== "notification"); + const rows = timeline.rows.filter((row) => this.supportsTimelineItem(row.item, source)); this.emitForSource( { type: "agent.provider_subagents.timeline.get.response", @@ -7714,9 +7719,43 @@ export class Session { "agent.session.outbound", ); } + if (msg.type === "workspace_setup_progress" || msg.type === "workspace_setup_status_response") { + if (this.clientCapabilitiesBySource.size > 0 && this.onMessageToSource) { + for (const [source] of this.clientCapabilitiesBySource) { + this.onMessageToSource(source, this.workspaceSetupMessageForClient(msg, source)); + } + return; + } + msg = this.workspaceSetupMessageForClient(msg); + } this.onMessage(msg); } + // COMPAT(workspaceSetupBlocked): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0. + private workspaceSetupMessageForClient( + message: Extract< + SessionOutboundMessage, + { type: "workspace_setup_progress" | "workspace_setup_status_response" } + >, + source?: object, + ): SessionOutboundMessage { + const supportsBlocked = source + ? this.supportsForSource(CLIENT_CAPS.workspaceSetupBlocked, source) + : this.supports(CLIENT_CAPS.workspaceSetupBlocked); + const snapshot = + message.type === "workspace_setup_progress" ? message.payload : message.payload.snapshot; + if (supportsBlocked || snapshot?.status !== "blocked") return message; + const legacySnapshot = { + ...snapshot, + status: "failed" as const, + error: + "Workspace setup is blocked pending approval of code from a fork pull request. Update Paseo to review and run setup.", + }; + return message.type === "workspace_setup_progress" + ? { ...message, payload: { ...message.payload, ...legacySnapshot } } + : { ...message, payload: { ...message.payload, snapshot: legacySnapshot } }; + } + private emitBinary(frame: Uint8Array): void { if (!this.onBinaryMessage) { return; diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index a51c4646f7..baf720b51b 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -185,6 +185,7 @@ function createSessionForWireCompatTest(options?: { clientCapabilities?: Record | null; directorySync?: DirectorySyncService; messages?: SessionOutboundMessage[]; + onMessageToSource?: SessionOptions["onMessageToSource"]; rows?: AgentTimelineRow[]; }): Session { const messages = options?.messages ?? []; @@ -212,6 +213,7 @@ function createSessionForWireCompatTest(options?: { permissions: OWNER_PERMISSIONS, clientCapabilities: options?.clientCapabilities ?? null, onMessage: (message) => messages.push(message), + onMessageToSource: options?.onMessageToSource, logger: pino({ level: "silent" }), downloadTokenStore: {} as SessionOptions["downloadTokenStore"], pushNotifications: {} as SessionOptions["pushNotifications"], @@ -543,3 +545,51 @@ describe("wire compatibility", () => { }); }); }); + +test("setup progress is adapted per socket without changing the canonical snapshot", async () => { + const legacy = {}; + const capable = {}; + const delivered = new Map(); + const session = createSessionForWireCompatTest({ + onMessageToSource: (source, message) => + delivered.set(source, [...(delivered.get(source) ?? []), message]), + }); + session.updateClientCapabilities({}, legacy); + session.updateClientCapabilities({ workspace_setup_blocked: true }, capable); + const message = { + type: "workspace_setup_progress" as const, + payload: { + workspaceId: "fork-workspace", + status: "blocked" as const, + error: null, + detail: { + type: "worktree_setup" as const, + worktreePath: "/workspace", + branchName: "fork", + log: "", + commands: [], + }, + blockedSource: { + kind: "change_request" as const, + forge: "github", + number: 42, + headRepository: "contributor/project", + }, + }, + }; + session.publish(message); + expect(delivered.get(capable)).toEqual([message]); + expect(delivered.get(legacy)).toEqual([ + { + ...message, + payload: { + ...message.payload, + status: "failed", + error: + "Workspace setup is blocked pending approval of code from a fork pull request. Update Paseo to review and run setup.", + }, + }, + ]); + expect(message.payload.status).toBe("blocked"); + await session.cleanup(); +}); diff --git a/public-docs/hub/security.md b/public-docs/hub/security.md index 7b3b9f94a6..f2e5d5d2f2 100644 --- a/public-docs/hub/security.md +++ b/public-docs/hub/security.md @@ -20,10 +20,11 @@ The host, provider credentials, filesystem, network, and resulting actions remai Connecting a daemon does not grant Hub permission to run agents. Grant `hub.execute` when you want Hub automations to use it. This permission allows Hub to create and continue agents, inspect their -state and timelines, and archive or restore workspaces on that daemon. It also applies to existing +state and timelines, configure their model, thinking, and provider permission mode, and archive or +restore workspaces on that daemon. Provider modes can include bypassing approval prompts. It also applies to existing agents and workspaces; it is not an isolation boundary around Hub-created work. -Daemon administration, terminal control, and permission management require separate permissions. +Daemon administration, terminal control, and daemon credential permission management require separate permissions. You can revoke `hub.execute` without granting Hub the ability to change its own permissions. ## Treat requests as untrusted From 56fd50988cde6d81240bd8fb492944d681c11d9e Mon Sep 17 00:00:00 2001 From: LiuJing Date: Tue, 8 Sep 2026 03:40:30 +0800 Subject: [PATCH 032/566] fix(plugin): loosen the react peer to ~19.1.0 (#4100) * fix(plugin): loosen the react peer to ~19.1.0 The plugin SDK pins its react peer to exactly 19.1.0, and that pin is what a downstream `npm i -g @getpaseo/cli` resolves against. So every user ends up with react 19.1.0 in their global node_modules, where corporate dependency scanners flag it: the CVE-2025-55182 advisory range covers 19.1.0 even though the vulnerability itself lives in react-server-dom-webpack/-parcel/-turbopack, none of which the CLI installs. The user is then left hand-patching node_modules or uninstalling Paseo. Widening the peer to ~19.1.0 lets a downstream install pick the latest 19.1.x patch (19.1.9 today) while this repo stays on 19.1.0, which the root override still pins. Keeping the repo itself on 19.1.0 is deliberate. react-native 0.81.5 embeds a 19.1.0 Paper renderer that hard-throws "Incompatible React versions" on any exact mismatch, and it is reachable from Fabric builds too: RendererImplementation.js requires the Paper shim unconditionally in findNodeHandle, unstable_batchedUpdates, sendAccessibilityEvent and three other entry points, and react-native-gesture-handler re-exports findNodeHandle. Bumping the app's react is therefore not safe until react-native moves. The published CLI tree contains no react-native, so downstream installs are not subject to that constraint. Supersedes the earlier 19.1.5/19.1.9 version-bump approach on this branch, which would have broken the mobile app. * Update Nix dependency hash for plugin peer metadata --------- Co-authored-by: Mohamed Boudra --- nix/npm-deps.hash | 2 +- package-lock.json | 2 +- packages/plugin/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 96f8a0393a..bbf576be8a 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-1MBzb+r/t92G9uR/jBuc3oNEfEloHeRg9uUgzSHS4UM= +sha256-zpE2CbXKy0D4tsYdeBo7JiNBNxSnB1JlnS3PtCmXKiw= diff --git a/package-lock.json b/package-lock.json index 4eb14395e7..9fb68d487a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38715,7 +38715,7 @@ "peerDependencies": { "@getpaseo/client": "0.7.2", "@getpaseo/protocol": "0.7.2", - "react": "19.1.0", + "react": "~19.1.0", "react-native": ">=0.81.5", "zod": "^4.4.3" }, diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 34b481ffda..52fb25aca0 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -73,7 +73,7 @@ "peerDependencies": { "@getpaseo/client": "0.7.2", "@getpaseo/protocol": "0.7.2", - "react": "19.1.0", + "react": "~19.1.0", "react-native": ">=0.81.5", "zod": "^4.4.3" }, From 0859a67ab1a71b0e0c187b4b8db8e4a585b48e42 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 7 Sep 2026 21:46:59 +0200 Subject: [PATCH 033/566] fix(app): show workspace plugin panels in Explorer menu (#4446) * fix(app): show workspace plugin panels in Explorer menu Let the host-filtered launch catalog identify toggleable views and their targets. Explorer uses the same identity comparison for every view while plugin panels remain workspace-scoped. * test(app): express Explorer panel regression as user steps --- docs/explorer-sidebar.md | 7 +- .../e2e/browser/explorer-plugin-menu.spec.ts | 23 ++ .../support/helpers/explorer-plugin-menu.ts | 201 ++++++++++++++++++ .../workspace/explorer-sidebar-tab-rail.tsx | 9 +- .../app/src/workspace-tabs/launcher/index.tsx | 15 +- 5 files changed, 244 insertions(+), 11 deletions(-) create mode 100644 packages/app/e2e/browser/explorer-plugin-menu.spec.ts create mode 100644 packages/app/e2e/support/helpers/explorer-plugin-menu.ts diff --git a/docs/explorer-sidebar.md b/docs/explorer-sidebar.md index 30d074a151..379db5a31f 100644 --- a/docs/explorer-sidebar.md +++ b/docs/explorer-sidebar.md @@ -39,9 +39,10 @@ that pane from the workspace split tree and docks it separately. Persisted ident literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibility. The tab rail has no inline add or close controls. Its context menu opens a New Tab launcher and -toggles the singleton Files and Changes views. Individual tab menus close instances or move -compatible tabs to main. Explorer tabs can be reordered, but the dock cannot be split. Selecting -an Explorer tab does not change workspace focus. +toggles Files, Changes, and Explorer-compatible workspace-scoped plugin panels from the shared +launch catalog. Individual tab menus close instances or move compatible tabs to main. Explorer tabs +can be reordered, but the dock cannot be split. Selecting an Explorer tab does not change workspace +focus. Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens. diff --git a/packages/app/e2e/browser/explorer-plugin-menu.spec.ts b/packages/app/e2e/browser/explorer-plugin-menu.spec.ts new file mode 100644 index 0000000000..8dc515ef74 --- /dev/null +++ b/packages/app/e2e/browser/explorer-plugin-menu.spec.ts @@ -0,0 +1,23 @@ +import { test } from "../support/fixtures"; +import { + withExplorerPanelPlugins, + expectWorkspacePanelsInExplorerLauncher, + openWorkspacePanelFromExplorerMenu, + expectIndependentExplorerPanelToggles, + expectExplorerPanelWithFocusedAgent, +} from "../support/helpers/explorer-plugin-menu"; + +test("Explorer toggles workspace panels independently without inheriting agent context", async ({ + page, +}, testInfo) => { + await withExplorerPanelPlugins(page, async (workspace) => { + await test.step("the New Tab launcher offers compatible workspace panels", () => + expectWorkspacePanelsInExplorerLauncher(page)); + await test.step("the rail menu offers the same workspace panels", () => + openWorkspacePanelFromExplorerMenu(page, workspace, testInfo)); + await test.step("different panels and plugins keep independent selection and closing", () => + expectIndependentExplorerPanelToggles(page, workspace)); + await test.step("focusing an agent does not add agent panels to Explorer", () => + expectExplorerPanelWithFocusedAgent(page, workspace, testInfo)); + }); +}); diff --git a/packages/app/e2e/support/helpers/explorer-plugin-menu.ts b/packages/app/e2e/support/helpers/explorer-plugin-menu.ts new file mode 100644 index 0000000000..2d13080274 --- /dev/null +++ b/packages/app/e2e/support/helpers/explorer-plugin-menu.ts @@ -0,0 +1,201 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, type Page, type TestInfo } from "@playwright/test"; +import { gotoWorkspace } from "./launcher"; +import { buildAgentRoute } from "./mock-agent"; +import { connectNewWorkspaceDaemonClient } from "./new-workspace"; +import { pluginRequirements } from "./plugin-fixture"; +import { seedWorkspace } from "./seed-client"; +import { ensureExplorerSidebar } from "./workspace-tabs"; + +type ExplorerPanelWorkspace = Awaited>; + +async function writePanelPlugin(id: string, title: string) { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-explorer-menu-")); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id, requirements: pluginRequirements }), + ); + await writeFile( + path.join(directory, "index.client.tsx"), + ` +import React from "react"; +import { Text } from "react-native"; +export default function contribute(client) { + client.addWorkspacePanel({ id: "review", title: ${JSON.stringify(title)}, icon: "Blocks", context: "workspace", locations: ["workspace", "explorer"], Component: ({ workspaceId }) => {${JSON.stringify(title)} + " workspace " + workspaceId} }); + client.addWorkspacePanel({ id: "summary", title: ${JSON.stringify(`${title} summary`)}, icon: "Blocks", context: "workspace", locations: ["explorer"], Component: () => Summary content }); + client.addWorkspacePanel({ id: "main", title: ${JSON.stringify(`${title} main only`)}, icon: "Blocks", context: "workspace", Component: () => null }); + client.addWorkspacePanel({ id: "agent", title: ${JSON.stringify(`${title} agent`)}, icon: "Blocks", context: "agent", locations: ["explorer"], Component: () => null }); + return () => {}; +}`, + ); + return directory; +} + +async function openExplorerMenu(page: Page) { + const explorer = await ensureExplorerSidebar(page); + // The rail's empty background has no accessible role; tab menus are separate. + await explorer.getByTestId("explorer-sidebar-tab-rail").click({ + button: "right", + position: { x: 20, y: 2 }, + }); + const menu = page.getByTestId("explorer-sidebar-tab-configuration"); + await expect(menu).toBeVisible(); + await expect(menu).toHaveCSS("opacity", "1"); + return menu; +} + +async function toggleExplorerView(page: Page, name: string) { + const menu = await openExplorerMenu(page); + await menu.getByRole("menuitem", { name, exact: true }).click(); + await expect(menu).not.toBeVisible(); +} + +export async function withExplorerPanelPlugins( + page: Page, + run: (workspace: ExplorerPanelWorkspace) => Promise, +) { + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previousConfig = await client.getDaemonConfig(); + const workspace = await seedWorkspace({ repoPrefix: "explorer-plugin-menu-" }); + const first = await writePanelPlugin("explorer-review", "Review"); + const second = await writePanelPlugin("explorer-other", "Other review"); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(first); + await client.installDirectoryPlugin(second); + await page.setViewportSize({ width: 1280, height: 900 }); + await gotoWorkspace(page, workspace.workspaceId); + + await run(workspace); + } finally { + await client.removePlugin("explorer-review"); + await client.removePlugin("explorer-other"); + await client.patchDaemonConfig({ + pluginsEnabled: previousConfig.config.pluginsEnabled ?? false, + }); + await client.close(); + await workspace.cleanup(); + await rm(first, { recursive: true, force: true }); + await rm(second, { recursive: true, force: true }); + } +} + +export async function expectWorkspacePanelsInExplorerLauncher(page: Page) { + const menu = await openExplorerMenu(page); + await menu.getByRole("menuitem", { name: "New tab", exact: true }).click(); + const launcher = page.getByTestId("workspace-explorer-sidebar"); + await expect(launcher.getByRole("button", { name: "Review", exact: true })).toBeVisible(); + await expect(launcher.getByRole("button", { name: "Review agent", exact: true })).toHaveCount(0); + await expect(launcher.getByRole("button", { name: "Review main only", exact: true })).toHaveCount( + 0, + ); +} + +export async function openWorkspacePanelFromExplorerMenu( + page: Page, + workspace: ExplorerPanelWorkspace, + testInfo: TestInfo, +) { + const menu = await openExplorerMenu(page); + await testInfo.attach("explorer-panel-menu", { + body: await page.screenshot({ path: testInfo.outputPath("explorer-panel-menu.png") }), + contentType: "image/png", + }); + await expect(menu.getByRole("menuitem")).toHaveText([ + "New tab", + "Changes", + "Files", + "Other review", + "Other review summary", + "Review", + "Review summary", + ]); + await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute( + "aria-checked", + "false", + ); + await menu.getByRole("menuitem", { name: "Review", exact: true }).click(); + await expect( + page.getByText(`Review workspace ${workspace.workspaceId}`, { exact: true }), + ).toBeVisible(); +} + +export async function expectIndependentExplorerPanelToggles( + page: Page, + workspace: ExplorerPanelWorkspace, +) { + await toggleExplorerView(page, "Other review"); + await expect( + page.getByText(`Other review workspace ${workspace.workspaceId}`, { exact: true }), + ).toBeVisible(); + await toggleExplorerView(page, "Review summary"); + const menu = await openExplorerMenu(page); + await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute( + "aria-checked", + "true", + ); + await expect(menu.getByRole("menuitem", { name: "Other review", exact: true })).toHaveAttribute( + "aria-checked", + "true", + ); + await expect(menu.getByRole("menuitem", { name: "Review summary", exact: true })).toHaveAttribute( + "aria-checked", + "true", + ); + await menu.getByRole("menuitem", { name: "Review", exact: true }).click(); + const updated = await openExplorerMenu(page); + await expect(updated.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute( + "aria-checked", + "false", + ); + await expect( + updated.getByRole("menuitem", { name: "Other review", exact: true }), + ).toHaveAttribute("aria-checked", "true"); + await expect( + updated.getByRole("menuitem", { name: "Review summary", exact: true }), + ).toHaveAttribute("aria-checked", "true"); + await page.keyboard.press("Escape"); + await toggleExplorerView(page, "Files"); + await toggleExplorerView(page, "Files"); + await expect( + page + .getByTestId("explorer-sidebar-tab-rail") + .getByRole("button", { name: "Browse workspace files", exact: true }), + ).toHaveCount(1); +} + +export async function expectExplorerPanelWithFocusedAgent( + page: Page, + workspace: ExplorerPanelWorkspace, + testInfo: TestInfo, +) { + const agent = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Focused agent", + model: "ten-second-stream", + modeId: "load-test", + }); + await page.goto(buildAgentRoute(workspace.workspaceId, agent.id)); + await expect( + page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }), + ).toHaveAttribute("aria-selected", "true"); + const menu = await openExplorerMenu(page); + await expect(menu.getByRole("menuitem", { name: "Review agent", exact: true })).toHaveCount(0); + await expect(menu.getByRole("menuitem", { name: "Other review agent", exact: true })).toHaveCount( + 0, + ); + await menu.getByRole("menuitem", { name: "Review", exact: true }).click(); + await expect( + page.getByText(`Review workspace ${workspace.workspaceId}`, { exact: true }), + ).toBeVisible(); + await testInfo.attach("workspace-panel-with-focused-agent", { + body: await page.screenshot({ + path: testInfo.outputPath("workspace-panel-with-focused-agent.png"), + }), + contentType: "image/png", + }); +} diff --git a/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx b/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx index bda9f8af5c..7b772b1d4c 100644 --- a/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx +++ b/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx @@ -32,7 +32,7 @@ import { useWorkspaceTabLaunchCatalog, type WorkspaceTabLaunchItem, } from "@/workspace-tabs/launcher"; -import { panelSupportsHost } from "@/panels/panel-manifest"; +import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity"; import type { PanelIconProps } from "@/panels/panel-registry"; import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations"; import type { Theme } from "@/styles/theme"; @@ -248,7 +248,7 @@ function ExplorerSidebarConfigurationItem({ } function catalogItemMatchesTab(item: WorkspaceTabLaunchItem, tab: WorkspaceTabDescriptor): boolean { - return item.panelKind === tab.target.kind; + return item.toggleTarget !== null && workspaceTabTargetsEqual(item.toggleTarget, tab.target); } export function ExplorerSidebarTabRail({ @@ -273,10 +273,7 @@ export function ExplorerSidebarTabRail({ host: "explorer", }); const singletonConfigurationItems = useMemo( - () => - (groups.find((group) => group.id === "tabs")?.items ?? []).filter( - (item) => !panelSupportsHost(item.panelKind, "main"), - ), + () => groups.flatMap((group) => group.items).filter((item) => item.toggleTarget !== null), [groups], ); const newTabLeading = useMemo(() => , []); diff --git a/packages/app/src/workspace-tabs/launcher/index.tsx b/packages/app/src/workspace-tabs/launcher/index.tsx index ba4998fda9..8f10527d75 100644 --- a/packages/app/src/workspace-tabs/launcher/index.tsx +++ b/packages/app/src/workspace-tabs/launcher/index.tsx @@ -53,6 +53,8 @@ export interface WorkspaceTabLaunchItem { shortcutActionId?: string; disabled: boolean; panelKind: WorkspaceTabTarget["kind"]; + /** The fixed view this item can toggle in a configuration menu, or null for launch-only items. */ + toggleTarget: WorkspaceTabTarget | null; launch: (destination: WorkspaceTabLaunchDestination) => void; } @@ -75,7 +77,7 @@ export function NewTabLauncherProvider({ return {children}; } -const BUILT_IN_SELECTIONS: Record = { +const BUILT_IN_SELECTIONS = { agent: { kind: "agent" }, terminal: { kind: "terminal" }, changes: { kind: "target", target: { kind: "changes_tree" } }, @@ -83,7 +85,7 @@ const BUILT_IN_SELECTIONS: Record = { files: { kind: "target", target: { kind: "files" } }, browser: { kind: "browser" }, pullRequest: { kind: "target", target: { kind: "pull_request" } }, -}; +} satisfies Record; function getLaunchPresentation(kind: WorkspaceTabTarget["kind"]): PanelPresentation { const registration = getPanelRegistration(kind); @@ -128,6 +130,7 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-tab-target-agent", disabled: false, panelKind: "draft", + toggleTarget: null, launch: launchSelection(BUILT_IN_SELECTIONS.agent), }, terminal: { @@ -137,6 +140,7 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-terminal-new", disabled: launcher.terminalDisabled, panelKind: "terminal", + toggleTarget: null, launch: launchSelection(BUILT_IN_SELECTIONS.terminal), }, changes: { @@ -145,6 +149,7 @@ export function useWorkspaceTabLaunchCatalog(input: { Icon: changesPresentation.icon, disabled: false, panelKind: "changes_tree", + toggleTarget: BUILT_IN_SELECTIONS.changes.target, hidden: !launcher.showChanges, launch: launchSelection(BUILT_IN_SELECTIONS.changes), }, @@ -155,6 +160,7 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-tab-target-changes", disabled: false, panelKind: "working_diff", + toggleTarget: null, hidden: !launcher.showChanges, launch: launchSelection(BUILT_IN_SELECTIONS.diff), }, @@ -165,6 +171,7 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-tab-target-files", disabled: false, panelKind: "files", + toggleTarget: BUILT_IN_SELECTIONS.files.target, launch: launchSelection(BUILT_IN_SELECTIONS.files), }, browser: { @@ -174,6 +181,7 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-tab-target-browser", disabled: false, panelKind: "browser", + toggleTarget: null, hidden: !launcher.showBrowser, launch: launchSelection(BUILT_IN_SELECTIONS.browser), }, @@ -183,6 +191,7 @@ export function useWorkspaceTabLaunchCatalog(input: { Icon: pullRequestPresentation.icon, disabled: false, panelKind: "pull_request", + toggleTarget: null, hidden: !launcher.showPullRequest, launch: launchSelection(BUILT_IN_SELECTIONS.pullRequest), }, @@ -209,6 +218,7 @@ export function useWorkspaceTabLaunchCatalog(input: { Icon: resolvePluginIcon(panel.icon), disabled: false, panelKind: "plugin", + toggleTarget: selection.target, launch: launchSelection(selection), }); } @@ -229,6 +239,7 @@ export function useWorkspaceTabLaunchCatalog(input: { terminalIconKey: getTerminalProfileIcon(profile), disabled: launcher.terminalDisabled, panelKind: "terminal", + toggleTarget: null, launch: launchSelection({ kind: "terminal", profile }), })), accessory: { From d779514f100ebdd4be9e0f81281925d56167c522 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 7 Sep 2026 23:34:37 +0200 Subject: [PATCH 034/566] Keep daemon reconnect running while the app is backgrounded (#4444) * fix(app): keep reconnect enabled while backgrounded Visibility should flush cached state and accelerate foreground recovery without disabling normal reconnect backoff. Preserve healthy sockets and retry whenever the OS permits execution. * test(app): clarify the background reconnect journey * test(app): name reconnect screenshot helper inputs * test(app): wait for stable content before delayed-history scroll * chore(app): keep reconnect QA evidence in the PR body --- .../app/e2e/browser/agent-stream-ui.spec.ts | 4 +- .../e2e/browser/background-reconnect.spec.ts | 86 ++++++++++ .../navigation/host-runtime-bootstrap.test.ts | 8 +- packages/app/src/runtime/host-runtime.test.ts | 153 +++++++++++------- packages/app/src/runtime/host-runtime.ts | 16 +- 5 files changed, 190 insertions(+), 77 deletions(-) create mode 100644 packages/app/e2e/browser/background-reconnect.spec.ts diff --git a/packages/app/e2e/browser/agent-stream-ui.spec.ts b/packages/app/e2e/browser/agent-stream-ui.spec.ts index 229536893b..2c81fb3308 100644 --- a/packages/app/e2e/browser/agent-stream-ui.spec.ts +++ b/packages/app/e2e/browser/agent-stream-ui.spec.ts @@ -216,7 +216,9 @@ test.describe("Agent stream UI", () => { await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({ timeout: 30_000, }); - await awaitAssistantMessage(page); + // Reasoning rows collapse when the mock starts its next assistant response. Wait past + // that transition so their temporary height cannot satisfy the scroll-away setup. + await awaitAssistantMessage(page, "Now I have a clearer picture."); await waitForScrollableChat(page, { minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE, timeout: 45_000, diff --git a/packages/app/e2e/browser/background-reconnect.spec.ts b/packages/app/e2e/browser/background-reconnect.spec.ts new file mode 100644 index 0000000000..e9831c257d --- /dev/null +++ b/packages/app/e2e/browser/background-reconnect.spec.ts @@ -0,0 +1,86 @@ +import { expect, test as base } from "../support/fixtures"; +import type { Page, TestInfo } from "@playwright/test"; +import { + startIsolatedHostDaemon, + type IsolatedHostDaemon, +} from "../support/helpers/isolated-host-daemon"; +import { seedSavedSettingsHosts } from "../support/helpers/settings"; + +const test = base.extend<{ reconnectHost: IsolatedHostDaemon }>({ + reconnectHost: async ({ e2eWorker }, provide) => { + void e2eWorker; + const daemon = await startIsolatedHostDaemon("background-reconnect"); + try { + await provide(daemon); + } finally { + await daemon.close(); + } + }, +}); + +test.describe.configure({ timeout: 120_000 }); + +async function captureHost({ + page, + testInfo, + name, +}: { + page: Page; + testInfo: TestInfo; + name: string; +}): Promise { + await page.screenshot({ path: testInfo.outputPath(`${name}.png`) }); +} + +async function refocusConnectedTab(page: Page): Promise { + await setTabVisibility(page, "visible"); + await expect(page.getByText("Online", { exact: true })).toBeVisible(); +} + +async function setTabVisibility(page: Page, visibility: DocumentVisibilityState): Promise { + await page.evaluate((state) => { + Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state }); + Object.defineProperty(document, "hidden", { + configurable: true, + get: () => state === "hidden", + }); + document.dispatchEvent(new Event("visibilitychange")); + }, visibility); +} + +async function openHostOverview(page: Page, daemon: IsolatedHostDaemon): Promise { + await page.goto("/"); + await seedSavedSettingsHosts(page, [ + { + serverId: daemon.serverId, + label: "Reconnect QA", + endpoint: `127.0.0.1:${daemon.port}`, + }, + ]); + await page.goto(`/settings/hosts/${daemon.serverId}/host`); + await expect(page.getByText("Online", { exact: true })).toBeVisible(); +} + +async function restartHostWhileHidden(page: Page, daemon: IsolatedHostDaemon): Promise { + const reconnectedSocket = page.waitForEvent("websocket", { + timeout: 30_000, + predicate: (socket) => new URL(socket.url()).port === String(daemon.port), + }); + await daemon.restart(); + await reconnectedSocket; + await expect(page.getByText("Online", { exact: true })).toBeVisible({ timeout: 30_000 }); + expect(await page.evaluate(() => document.visibilityState)).toBe("hidden"); +} + +test("a hidden tab reconnects after daemon restart and stays connected on refocus", async ({ + page, + reconnectHost, +}, testInfo) => { + await openHostOverview(page, reconnectHost); + await captureHost({ page, testInfo, name: "before-hide" }); + await setTabVisibility(page, "hidden"); + await restartHostWhileHidden(page, reconnectHost); + await captureHost({ page, testInfo, name: "reconnected-while-hidden" }); + await refocusConnectedTab(page); + await captureHost({ page, testInfo, name: "reconnected-after-refocus" }); +}); diff --git a/packages/app/src/navigation/host-runtime-bootstrap.test.ts b/packages/app/src/navigation/host-runtime-bootstrap.test.ts index 603532086d..14eccad781 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.test.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.test.ts @@ -397,10 +397,10 @@ describe("resolveHostIndexRoute", () => { describe("host runtime app lifecycle", () => { it.each(["inactive", "background"] as const)( - "starts with reconnect disabled when mounted %s", + "applies initial visibility and forwards lifecycle changes when mounted %s", (currentState) => { const visibility: boolean[] = []; - let listener: ((state: "active" | "background") => void) | undefined; + let listener: ((state: "active" | "inactive" | "background") => void) | undefined; let subscribed = true; const dispose = bindHostRuntimeAppState( { setAppVisible: (visible) => visibility.push(visible) }, @@ -418,8 +418,10 @@ describe("host runtime app lifecycle", () => { ); expect(visibility).toEqual([false]); listener?.("active"); + listener?.("inactive"); listener?.("background"); - expect(visibility).toEqual([false, true, false]); + listener?.("active"); + expect(visibility).toEqual([false, true, false, false, true]); dispose(); expect(subscribed).toBe(false); }, diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index b77d53f646..cd6be83fae 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -1,4 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AppStateStatus } from "react-native"; +import { bindHostRuntimeAppState } from "@/navigation/host-runtime-bootstrap"; import type { DaemonClient, ConnectionState, @@ -1486,70 +1488,101 @@ describe("HostRuntimeController", () => { }); describe("HostRuntimeStore", () => { - it("reconnects every registered host immediately on app resume", async () => { - const relay = (suffix: string): HostConnection => ({ - id: `relay:relay-${suffix}.paseo.sh:443`, - type: "relay", - relayEndpoint: `relay-${suffix}.paseo.sh:443`, - daemonPublicKeyB64: `pk_${suffix}`, - }); - const hostAConnection = relay("a"); - const hostBConnection = relay("b"); - const hostA = makeHost({ - serverId: "srv_a", - connections: [hostAConnection], - preferredConnectionId: hostAConnection.id, - }); - const hostB = makeHost({ - serverId: "srv_b", - connections: [hostBConnection], - preferredConnectionId: hostBConnection.id, - }); - const clientA = new FakeDaemonClient(); - const clientB = new FakeDaemonClient(); - clientA.setConnectionState({ status: "connected" }); - clientB.setConnectionState({ status: "connected" }); - const store = new HostRuntimeStore({ - storage: createMemoryHostRuntimeStorage(), - deps: { - createClient: () => { - throw new Error("initial clients are supplied"); + it.each(["active", "inactive", "background"] as const)( + "keeps reconnect enabled through inactive/background and resumes immediately (mounted %s)", + async (currentState) => { + const relay = (suffix: string): HostConnection => ({ + id: `relay:relay-${suffix}.paseo.sh:443`, + type: "relay", + relayEndpoint: `relay-${suffix}.paseo.sh:443`, + daemonPublicKeyB64: `pk_${suffix}`, + }); + const hostAConnection = relay("a"); + const hostBConnection = relay("b"); + const hostA = makeHost({ + serverId: "srv_a", + connections: [hostAConnection], + preferredConnectionId: hostAConnection.id, + }); + const hostB = makeHost({ + serverId: "srv_b", + connections: [hostBConnection], + preferredConnectionId: hostBConnection.id, + }); + const clientA = new FakeDaemonClient(); + const clientB = new FakeDaemonClient(); + clientA.setConnectionState({ status: "connected" }); + clientB.setConnectionState({ status: "connected" }); + const store = new HostRuntimeStore({ + storage: createMemoryHostRuntimeStorage(), + deps: { + createClient: () => { + throw new Error("initial clients are supplied"); + }, + connectToDaemon: async () => { + throw new Error("single-connection hosts reuse their active clients"); + }, + getClientId: async () => "cid_test_runtime", }, - connectToDaemon: async () => { - throw new Error("single-connection hosts reuse their active clients"); + }); + + let changeAppState: (state: AppStateStatus) => void = () => { + throw new Error("AppState listener not registered"); + }; + const unbind = bindHostRuntimeAppState(store, { + currentState, + addEventListener: (_event, listener) => { + changeAppState = listener; + return { remove: () => {} }; }, - getClientId: async () => "cid_test_runtime", - }, - }); + }); + store.syncHosts([hostA, hostB], { + initialConnectionByServerId: new Map([ + [ + hostA.serverId, + { + connectionId: hostAConnection.id, + existingClient: clientA as unknown as DaemonClient, + }, + ], + [ + hostB.serverId, + { + connectionId: hostBConnection.id, + existingClient: clientB as unknown as DaemonClient, + }, + ], + ]), + }); + await waitForHostOnline(store, hostA.serverId); + await waitForHostOnline(store, hostB.serverId); + expect(clientA.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientB.reconnectEnabledChanges.at(-1)).toBe(true); + changeAppState("inactive"); + expect(clientA.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientB.reconnectEnabledChanges.at(-1)).toBe(true); + changeAppState("background"); + expect(clientA.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientB.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientA.getConnectionState()).toEqual({ status: "connected" }); + expect(clientB.getConnectionState()).toEqual({ status: "connected" }); + expect(clientA.ensureConnectedCalls).toBe(0); + expect(clientB.ensureConnectedCalls).toBe(0); + clientA.setConnectionState({ status: "disconnected", reason: "backgrounded" }); + clientB.setConnectionState({ status: "disconnected", reason: "backgrounded" }); - store.syncHosts([hostA, hostB], { - initialConnectionByServerId: new Map([ - [ - hostA.serverId, - { connectionId: hostAConnection.id, existingClient: clientA as unknown as DaemonClient }, - ], - [ - hostB.serverId, - { connectionId: hostBConnection.id, existingClient: clientB as unknown as DaemonClient }, - ], - ]), - }); - await waitForHostOnline(store, hostA.serverId); - await waitForHostOnline(store, hostB.serverId); - store.setAppVisible(false); - expect(clientA.reconnectEnabledChanges.at(-1)).toBe(false); - expect(clientB.reconnectEnabledChanges.at(-1)).toBe(false); - clientA.setConnectionState({ status: "disconnected", reason: "backgrounded" }); - clientB.setConnectionState({ status: "disconnected", reason: "backgrounded" }); + changeAppState("active"); + expect(clientA.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientB.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientA.ensureConnectedCalls).toBe(1); + expect(clientB.ensureConnectedCalls).toBe(1); + expect(store.getSnapshot(hostA.serverId)?.connectionStatus).toBe("online"); + expect(store.getSnapshot(hostB.serverId)?.connectionStatus).toBe("online"); - store.setAppVisible(true); - expect(clientA.reconnectEnabledChanges.at(-1)).toBe(true); - expect(clientB.reconnectEnabledChanges.at(-1)).toBe(true); - expect(clientA.ensureConnectedCalls).toBe(1); - expect(clientB.ensureConnectedCalls).toBe(1); - - store.syncHosts([]); - }); + unbind(); + store.syncHosts([]); + }, + ); it("revokes push notifications before removing a host", async () => { const host = makeHost({ connections: [makeHost().connections[0]!] }); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index ad33968740..9d551be1c7 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -619,7 +619,6 @@ export class HostRuntimeController { private switchRequestVersion = 0; private probeRequestVersion = 0; private probeCycleInFlight: Promise | null = null; - private reconnectEnabled = true; constructor(input: { host: HostProfile; @@ -729,11 +728,6 @@ export class HostRuntimeController { this.activeClient?.ensureConnected(); } - setReconnectEnabled(enabled: boolean): void { - this.reconnectEnabled = enabled; - this.activeClient?.setReconnectEnabled(enabled); - } - markAgentDirectorySyncLoading(): void { const status = this.snapshot.hasEverLoadedAgentDirectory ? "revalidating" : "initial_loading"; this.updateSnapshot({ @@ -1244,7 +1238,7 @@ export class HostRuntimeController { clientId, runtimeGeneration: nextGeneration, }); - client.setReconnectEnabled(this.reconnectEnabled); + client.setReconnectEnabled(true); if (!this.isSwitchStillValid(requestVersion, expectedProbeVersion)) { await client.close().catch(() => undefined); @@ -1406,7 +1400,6 @@ export class HostRuntimeStore { private timelineReplicaByServer = new Map(); private configuredOverrideBootstrapInFlight: Promise | null = null; private bootPromise: Promise | null = null; - private appVisible = true; private storage: HostRuntimeStorage; private replicaCache: ReplicaCache; private readonly revokePushNotifications: typeof revokePushNotifications; @@ -2080,7 +2073,6 @@ export class HostRuntimeStore { deps: this.deps, onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId), }); - controller.setReconnectEnabled(this.appVisible); this.controllers.set(host.serverId, controller); useSessionStore.getState().initializeSession(host.serverId, null); const directory = new DirectorySync( @@ -2342,10 +2334,8 @@ export class HostRuntimeStore { } setAppVisible(visible: boolean): void { - this.appVisible = visible; - for (const controller of this.controllers.values()) { - controller.setReconnectEnabled(visible); - } + // Keep normal reconnect backoff running while hidden, for as long as the OS + // lets us execute. Foregrounding bypasses that backoff without closing healthy sockets. if (!visible) { void this.replicaCache.flush(); return; From 1f5b6143d603ebecc678daf76d407938175be044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Lindemann?= Date: Mon, 7 Sep 2026 23:42:28 +0200 Subject: [PATCH 035/566] Enforce shared, client, and server plugin SDK boundaries (#4347) * fix(plugins): keep React out of the server plugin host * fix(plugins): align SDK guidance and tests with runtime boundaries * fix(plugin): loosen the react peer to ~19.1.0 The plugin SDK pins its react peer to exactly 19.1.0, and that pin is what a downstream `npm i -g @getpaseo/cli` resolves against. So every user ends up with react 19.1.0 in their global node_modules, where corporate dependency scanners flag it: the CVE-2025-55182 advisory range covers 19.1.0 even though the vulnerability itself lives in react-server-dom-webpack/-parcel/-turbopack, none of which the CLI installs. The user is then left hand-patching node_modules or uninstalling Paseo. Widening the peer to ~19.1.0 lets a downstream install pick the latest 19.1.x patch (19.1.9 today) while this repo stays on 19.1.0, which the root override still pins. Keeping the repo itself on 19.1.0 is deliberate. react-native 0.81.5 embeds a 19.1.0 Paper renderer that hard-throws "Incompatible React versions" on any exact mismatch, and it is reachable from Fabric builds too: RendererImplementation.js requires the Paper shim unconditionally in findNodeHandle, unstable_batchedUpdates, sendAccessibilityEvent and three other entry points, and react-native-gesture-handler re-exports findNodeHandle. Bumping the app's react is therefore not safe until react-native moves. The published CLI tree contains no react-native, so downstream installs are not subject to that constraint. Supersedes the earlier 19.1.5/19.1.9 version-bump approach on this branch, which would have broken the mobile app. * Enforce example import ownership including client types * Update Nix dependency hash for plugin peer metadata * Group plugin APIs under client and server entry trees * Resolve plugin type dependencies through declarations * Validate declarations for implicit plugin type imports * Preserve optional imports and validate declaration references * Refresh Nix hash for plugin declaration resolver * Canonicalize plugin paths consistently on Windows * Normalize plugin ownership paths across resolver boundaries --------- Co-authored-by: liujin0506 Co-authored-by: Mohamed Boudra --- .github/PULL_REQUEST_TEMPLATE.md | 1 + .github/ci-paths.yml | 5 +- .github/workflows/ci.yml | 9 + CLAUDE.md | 2 + CONTRIBUTING.md | 2 + docs/plugins.md | 55 ++- docs/qa.md | 3 + nix/npm-deps.hash | 2 +- package-lock.json | 3 +- .../app/e2e/browser/plugin-host-ui.spec.ts | 4 +- .../browser/plugin-workspace-panels.spec.ts | 3 +- .../app/src/components/settings/index.tsx | 2 +- packages/app/src/plugins/actions.ts | 6 +- .../app/src/plugins/attachments/picker.tsx | 2 +- .../app/src/plugins/client-runtime.test.ts | 2 +- packages/app/src/plugins/client-runtime.ts | 2 +- .../app/src/plugins/client-state/source.ts | 2 +- .../command-center/contributions.test.ts | 2 +- .../plugins/command-center/contributions.ts | 2 +- .../app/src/plugins/composer-pills/index.tsx | 5 +- .../app/src/plugins/composer-pills/store.ts | 2 +- packages/app/src/plugins/evaluate.test.ts | 43 +- packages/app/src/plugins/evaluate.ts | 41 +- packages/app/src/plugins/host-navigation.ts | 2 +- packages/app/src/plugins/icons.ts | 2 +- packages/app/src/plugins/navigation.ts | 2 +- .../app/src/plugins/react-native/modal.tsx | 8 +- .../app/src/plugins/react-native/toast.ts | 2 +- packages/app/src/plugins/runtime-boundary.tsx | 2 +- packages/app/src/plugins/settings/index.tsx | 2 +- .../app/src/plugins/settings/use-settings.ts | 6 +- .../app/src/plugins/surface-runtime.test.tsx | 4 +- packages/app/src/plugins/surface-screen.tsx | 3 +- packages/app/src/plugins/timeline/view.tsx | 5 +- packages/app/src/plugins/types.ts | 8 +- .../src/plugins/workspace-panels/locations.ts | 5 +- .../src/plugins/workspace-panels/panel.tsx | 6 +- .../plugins/workspace-panels/resolution.ts | 2 +- .../cli/src/commands/plugin/scaffold.test.ts | 16 +- packages/cli/src/commands/plugin/scaffold.ts | 8 +- packages/plugin/package.json | 51 +- packages/plugin/src/attachments.ts | 7 + packages/plugin/src/boundaries.test.ts | 123 +++++ .../plugin/src/{ => client}/client-state.tsx | 2 +- packages/plugin/src/client/contracts.ts | 246 ++++++++++ packages/plugin/src/{ => client}/host.test.ts | 2 +- packages/plugin/src/{ => client}/host.ts | 7 +- packages/plugin/src/client/index.ts | 37 ++ .../plugin/src/{ => client}/paseo-context.tsx | 0 .../plugin/src/{ => client}/react-native.ts | 0 .../plugin/src/{ => client}/rpc-context.tsx | 2 +- .../{ => client}/runtime-context-bridge.tsx | 0 .../plugin/src/{ => client}/shallow.test.ts | 0 packages/plugin/src/{ => client}/shallow.ts | 0 packages/plugin/src/{ => client}/ui.ts | 0 .../{ => client}/use-sync-external-store.d.ts | 0 packages/plugin/src/contracts.ts | 245 +--------- packages/plugin/src/index.ts | 87 +--- packages/plugin/src/migration-doc.test.ts | 2 +- packages/plugin/src/server.test.ts | 57 --- .../{ => server}/acp-internal/connection.ts | 0 packages/plugin/src/{ => server}/acp.test.ts | 0 packages/plugin/src/{ => server}/acp.ts | 0 packages/plugin/src/server/contracts.ts | 26 ++ .../plugin/src/{server.ts => server/index.ts} | 15 +- packages/plugin/src/{ => server}/lifecycle.ts | 0 packages/plugin/src/{ => server}/provider.ts | 0 packages/plugin/src/settings.ts | 16 +- packages/server/package.json | 2 +- .../src/server/agent/plugin-provider.test.ts | 2 +- .../src/server/agent/plugin-provider.ts | 2 +- .../agent/provider-snapshot-manager.test.ts | 2 +- .../server/agent/provider-snapshot-manager.ts | 2 +- .../src/server/plugins/compiler-imports.ts | 126 +++++ .../src/server/plugins/compiler.test.ts | 442 ++++++++++++++++-- .../server/src/server/plugins/compiler.ts | 284 +++++++---- packages/server/src/server/plugins/index.ts | 2 +- .../plugins/plugin-paseo-api.e2e.test.ts | 3 +- .../server/plugins/plugin-process-protocol.ts | 4 +- .../src/server/plugins/plugin-process.test.ts | 39 ++ .../src/server/plugins/plugin-process.ts | 38 +- .../server/plugins/plugin-sdk-specifiers.ts | 30 +- .../src/server/plugins/runtime.posix.test.ts | 112 +++-- packages/server/src/server/plugins/runtime.ts | 2 +- .../src/server/plugins/settings.e2e.test.ts | 2 +- .../src/server/plugins/settings/index.ts | 2 +- .../agent-configuration/index.server.ts | 2 +- plugin-examples/catppuccin/index.client.ts | 2 +- .../inline-thinking/client/thinking.tsx | 4 +- .../inline-thinking/index.client.tsx | 2 +- .../lifecycle-actions/index.server.ts | 2 +- .../lifecycle-logger/index.server.ts | 2 +- plugin-examples/linear/index.client.ts | 2 +- plugin-examples/linear/index.server.ts | 2 +- plugin-examples/local-plugin/client/main.tsx | 4 +- plugin-examples/local-plugin/index.client.tsx | 2 +- plugin-examples/local-plugin/index.server.ts | 2 +- plugin-examples/modal-ui/client/examples.tsx | 4 +- plugin-examples/modal-ui/index.client.tsx | 2 +- .../provider-acp-transformer/index.server.ts | 4 +- .../server/vendor-edit.ts | 2 +- .../client/provider-result.tsx | 2 +- .../provider-direct/index.client.tsx | 2 +- .../provider-direct/index.server.ts | 2 +- .../provider-direct/server/provider.test.ts | 2 +- .../provider-direct/server/provider.ts | 2 +- .../settings/client/display-settings.tsx | 5 +- plugin-examples/settings/index.client.tsx | 2 +- plugin-examples/settings/index.server.ts | 2 +- .../timeline-items/client/pi-tasks.tsx | 2 +- .../client/transform-pi-tasks.ts | 72 +++ .../timeline-items/index.client.tsx | 5 +- .../timeline-items/pi-tasks.test.ts | 2 +- .../timeline-items/shared/pi-tasks.ts | 70 --- public-docs/plugins/v0.8/index.md | 6 +- public-docs/plugins/v0.8/migration.md | 29 +- public-docs/plugins/v0.8/providers.md | 12 +- public-docs/plugins/v0.8/reference.md | 98 ++-- scripts/ci-workflow.test.mjs | 13 +- skills/paseo-plugin/SKILL.md | 33 +- 120 files changed, 1774 insertions(+), 929 deletions(-) create mode 100644 packages/plugin/src/boundaries.test.ts rename packages/plugin/src/{ => client}/client-state.tsx (98%) create mode 100644 packages/plugin/src/client/contracts.ts rename packages/plugin/src/{ => client}/host.test.ts (99%) rename packages/plugin/src/{ => client}/host.ts (75%) create mode 100644 packages/plugin/src/client/index.ts rename packages/plugin/src/{ => client}/paseo-context.tsx (100%) rename packages/plugin/src/{ => client}/react-native.ts (100%) rename packages/plugin/src/{ => client}/rpc-context.tsx (94%) rename packages/plugin/src/{ => client}/runtime-context-bridge.tsx (100%) rename packages/plugin/src/{ => client}/shallow.test.ts (100%) rename packages/plugin/src/{ => client}/shallow.ts (100%) rename packages/plugin/src/{ => client}/ui.ts (100%) rename packages/plugin/src/{ => client}/use-sync-external-store.d.ts (100%) delete mode 100644 packages/plugin/src/server.test.ts rename packages/plugin/src/{ => server}/acp-internal/connection.ts (100%) rename packages/plugin/src/{ => server}/acp.test.ts (100%) rename packages/plugin/src/{ => server}/acp.ts (100%) create mode 100644 packages/plugin/src/server/contracts.ts rename packages/plugin/src/{server.ts => server/index.ts} (66%) rename packages/plugin/src/{ => server}/lifecycle.ts (100%) rename packages/plugin/src/{ => server}/provider.ts (100%) create mode 100644 packages/server/src/server/plugins/compiler-imports.ts create mode 100644 packages/server/src/server/plugins/plugin-process.test.ts create mode 100644 plugin-examples/timeline-items/client/transform-pi-tasks.ts diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2fbe37e918..3113545559 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -40,6 +40,7 @@ This is the section I read most carefully. I need to see that *you* tested this, ### Checklist +- [ ] Plugin changes follow the [SDK import boundaries](../docs/plugins.md#sdk-import-boundaries) (if applicable) - [ ] One focused change - [ ] `npm run typecheck` passes - [ ] `npm run lint` passes diff --git a/.github/ci-paths.yml b/.github/ci-paths.yml index 7da0b185cb..a009ba463d 100644 --- a/.github/ci-paths.yml +++ b/.github/ci-paths.yml @@ -50,6 +50,9 @@ app: - "packages/expo-two-way-audio/**" sdk: + - "packages/plugin/**" + - "plugin-examples/**" + - "public-docs/plugins/v0.8/**" - "packages/client/**" - "packages/highlight/**" - "packages/protocol/**" @@ -66,7 +69,7 @@ browser: - "packages/server/src/server/agent/plugin-provider.ts" - "packages/server/src/server/plugins/{index,plugin-process,plugin-process-protocol,runtime}.ts" - "packages/server/src/executable-resolution/**" - - "packages/plugin/src/provider.ts" + - "packages/plugin/src/server/provider.ts" - "packages/app/src/!(desktop)/**" - "packages/app/e2e/browser/**" - "packages/app/e2e/support/**" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c555a61432..3452f38ec2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -298,6 +298,15 @@ jobs: - name: Build client dependencies run: npm run build:client + - name: Build plugin SDK + run: npm run build:plugin + + - name: Run plugin SDK tests + run: npm run test --workspace=@getpaseo/plugin + + - name: Typecheck plugin examples + run: npm run typecheck --workspace=@getpaseo/plugin + - name: Run protocol tests run: npm run test --workspace=@getpaseo/protocol diff --git a/CLAUDE.md b/CLAUDE.md index b62a53d409..9ed4aba4d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,8 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir ## Critical rules +- **Before changing the plugin SDK, compiler, host module maps, scaffold, or examples, read [SDK import boundaries](docs/plugins.md#sdk-import-boundaries).** Classify the export by runtime first and preserve the enforced boundaries. + - **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process. - **NEVER assume a timeout means the service needs restarting** — timeouts can be transient. - **NEVER add auth checks to tests** — agent providers handle their own auth. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8eaaf5c0c7..476a317db4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,8 @@ Due to the volume and my limited bandwidth, I may not participate in all the dis ## Pull requests +For plugin changes, start with the [SDK import boundaries](docs/plugins.md#sdk-import-boundaries). + Anyone can open a pull request, but there are no guarantees of it getting merged, only submit one if you're okay with it being closed. Open it as a draft if the work isn't ready for review, if you want to run the checks against it, or if you want feedback on the direction before you finish it. Mark it ready when you want it looked at. diff --git a/docs/plugins.md b/docs/plugins.md index 76214a6dca..bfec8e7328 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -158,7 +158,7 @@ Do not put any other code modules in the plugin root. Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import `PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from -`@getpaseo/plugin/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon +`@getpaseo/plugin/client/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon set; an unknown name renders nothing so it cannot break the plugin surface. Its controlled modal keeps presentation metadata on `` and body UI in ``. Body layout, sheet-aware scrolling, and clipboard actions follow the @@ -167,16 +167,37 @@ Plugin UI runs on desktop and mobile across multiple themes: color every `Text` `theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`. See `public-docs/plugins/v0.8/reference.md`. -| Module | Use it for | -| ------------------------------- | ----------------------------------------------------------------------------------------- | -| `@getpaseo/plugin` | contribution contracts, shared definitions, RPC input/output types, and client data hooks | -| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks | -| `@getpaseo/plugin/server` | handler-only types such as `PluginHandlerContext` | -| `@getpaseo/plugin/provider` | provider registration, connection, session, input, event, and timeline contracts | -| `@getpaseo/plugin/acp` | command-backed ACP adapter and focused transformer hooks | +### SDK import boundaries -The compiler rejects a client import of `server/`, a server import of `client/`, and every `node:` -import reachable from client code. Shared modules cannot import runtime-owned modules. A relative +Classify every SDK export before adding it. All client entry points and implementations live under +`client/`; all server entry points and implementations live under `server/`. The package root is shared code: plain data types, +Zod schemas, and functions that run in both runtimes. A type-only import is still an architectural +dependency; shared types must not refer to React components, hooks, Node APIs, or server contexts. + +| Entry | Owns | May depend on | +| ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- | +| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only | +| `@getpaseo/plugin/server` | Server contribution/handler contexts and lifecycle contracts | Shared and server code | +| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code | +| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code | +| `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code | +| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code | + +Server code imports shared helpers from the root and server capabilities from `/server`. Client +code imports shared helpers from the root and client capabilities from `/client`. Neither runtime +imports the other. Re-exports follow the same rule; tree shaking does not establish a boundary. +React, React Native, JSX runtimes, and client hooks must never be reachable from the root or any +server entry. Node and platform-specific code must never be reachable from the shared root. + +The SDK boundary checks and real plugin-subprocess tests enforce these rules. Every SDK change +must preserve them and update the public reference, migration guide, scaffold, and examples when +an author-facing import changes. The plugin compiler enforces the same runtime entry rules for +plugin-authored code. Keep the package export map and host-provided module maps consistent. + +The compiler rejects imports across runtime directories or SDK entries, React dependencies in +server code, and Node imports in client code (including bare names such as `fs`). Shared modules +cannot import runtime-owned modules. Forbidden imports fail compilation; never replace them with +empty module stubs. A relative import to any other code file in the plugin root is also rejected; move it into `client/`, `server/`, or `shared/`. These are compile errors naming the importing file and boundary rule. Top-level React Native calls such as `StyleSheet.create` belong in `client/`. @@ -190,7 +211,7 @@ pattern. ```ts // index.server.ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { createGreeting } from "./server/greeting"; import { greetRpc } from "./shared/greeting"; @@ -202,7 +223,7 @@ export default function contribute(server: PluginServerContext) { ```tsx // index.client.tsx -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { Greeting } from "./client/greeting"; export default function contribute(client: PluginClientContext) { @@ -279,8 +300,8 @@ Register a provider from `index.server.ts`. The provider connection is callback- of its sessions; plugin RPC is not part of the provider data path. ```ts -import type { PluginServerContext } from "@getpaseo/plugin"; -import type { ProviderRegistration } from "@getpaseo/plugin/provider"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import type { ProviderRegistration } from "@getpaseo/plugin/server/provider"; import { createProvider } from "./server/provider"; export default function contribute(server: PluginServerContext) { @@ -306,7 +327,7 @@ persistence. Providers re-read credentials, environment, global configuration, a `session.open`; there is no provider reload input. For an ACP command, register `runAcpProvider({ id, label, command })` from -`@getpaseo/plugin/acp`. Its transformer hooks cover narrow vendor differences; do not translate the +`@getpaseo/plugin/server/acp`. Its transformer hooks cover narrow vendor differences; do not translate the whole provider event stream. The direct and ACP examples live in `plugin-examples/provider-direct` and `plugin-examples/provider-acp-transformer`. @@ -438,7 +459,7 @@ credentials and vendor API calls stay in the daemon handler. ```ts // index.server.ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { search } from "./server/issues"; import { searchIssues } from "./shared/issues"; @@ -450,7 +471,7 @@ export default function contribute(server: PluginServerContext) { ```tsx // index.client.tsx -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { issues } from "./shared/issues"; export default function contribute(client: PluginClientContext) { diff --git a/docs/qa.md b/docs/qa.md index 25176f80fe..8eb5eb5247 100644 --- a/docs/qa.md +++ b/docs/qa.md @@ -11,6 +11,9 @@ The bar is four questions: Every pull request needs evidence for the questions its change touches. Pull requests without evidence get closed. +For plugin changes, verify the [SDK import boundaries](plugins.md#sdk-import-boundaries) before +exercising contributions. Include the boundary tests and both runtime loaders in the evidence. + ## Evidence Evidence is something someone else can look at: diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index bbf576be8a..394e1f48ef 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-zpE2CbXKy0D4tsYdeBo7JiNBNxSnB1JlnS3PtCmXKiw= +sha256-40KXWVtW9wgHYJmn7XrLHzHWdOJiSCiajj9duHnDfXA= diff --git a/package-lock.json b/package-lock.json index 9fb68d487a..d6775eb308 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34474,7 +34474,6 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -39016,6 +39015,7 @@ "sherpa-onnx-node": "1.12.28", "strip-ansi": "^7.1.2", "tree-kill": "^1.2.2", + "typescript": "^5.9.3", "uuid": "^9.0.1", "which": "^5.0.0", "ws": "^8.14.2", @@ -39031,7 +39031,6 @@ "@vitest/ui": "^4.1.6", "cross-env": "^10.1.0", "tsx": "^4.6.0", - "typescript": "^5.2.2", "vitest": "^4.1.6" } }, diff --git a/packages/app/e2e/browser/plugin-host-ui.spec.ts b/packages/app/e2e/browser/plugin-host-ui.spec.ts index 80ee02459d..bd53c59272 100644 --- a/packages/app/e2e/browser/plugin-host-ui.spec.ts +++ b/packages/app/e2e/browser/plugin-host-ui.spec.ts @@ -12,8 +12,8 @@ import { const PLUGIN_ID = "plugin-host-ui-e2e"; -const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin"; -import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native"; +const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin/client"; +import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native"; import { useQueryClient } from "@tanstack/react-query"; import React, { useState } from "react"; import { Pressable, Text, View } from "react-native"; diff --git a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts index 4e7d7682df..662027133e 100644 --- a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts +++ b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts @@ -30,7 +30,8 @@ function isSettledWorkspaceUrl(url: URL): boolean { function pluginClientSource(input: { workspaceId: string; agentId: string }): string { return `import React, { useRef } from "react"; import { Pressable, Text, View } from "react-native"; -import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin"; +import { Icon } from "@getpaseo/plugin/client/react-native"; +import { useAgent, useWorkspace } from "@getpaseo/plugin/client"; import { recordComposerOpen } from "./shared/rpc"; function WorkspacePanel({ workspaceId, host, layout }) { diff --git a/packages/app/src/components/settings/index.tsx b/packages/app/src/components/settings/index.tsx index 371709f60f..7e8c0ea5e6 100644 --- a/packages/app/src/components/settings/index.tsx +++ b/packages/app/src/components/settings/index.tsx @@ -15,7 +15,7 @@ import type { SettingsSelectProps, SettingsInputProps, SettingsActionProps, -} from "@getpaseo/plugin/ui"; +} from "@getpaseo/plugin/client/ui"; import type { EditingTextInputHandle } from "@/components/ui/text-input"; import { Switch } from "@/components/ui/switch"; import { Button } from "@/components/ui/button"; diff --git a/packages/app/src/plugins/actions.ts b/packages/app/src/plugins/actions.ts index 7f8678add2..9f2329df09 100644 --- a/packages/app/src/plugins/actions.ts +++ b/packages/app/src/plugins/actions.ts @@ -1,11 +1,11 @@ -import { callPluginRpc } from "@getpaseo/plugin/host"; +import { callPluginRpc } from "@getpaseo/plugin/client/host"; import type { PluginAgentCommandContext, PluginCommandCapabilities, PluginPanelLocation, PluginWorkspaceCommandContext, -} from "@getpaseo/plugin"; -import type { PluginClientStateSource } from "@getpaseo/plugin/host"; +} from "@getpaseo/plugin/client"; +import type { PluginClientStateSource } from "@getpaseo/plugin/client/host"; import { resolvePluginPanelOpenLocation } from "./workspace-panels/locations"; import type { PluginSurfaceRuntime } from "./surface-runtime"; import type { InstalledPlugin } from "./types"; diff --git a/packages/app/src/plugins/attachments/picker.tsx b/packages/app/src/plugins/attachments/picker.tsx index f25d5c4d20..a63c1eb4e0 100644 --- a/packages/app/src/plugins/attachments/picker.tsx +++ b/packages/app/src/plugins/attachments/picker.tsx @@ -3,7 +3,7 @@ import { View } from "react-native"; import { withUnistyles } from "react-native-unistyles"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { PluginAttachmentItem, PluginAttachmentSourceContribution } from "@getpaseo/plugin"; -import { searchPluginAttachments } from "@getpaseo/plugin/host"; +import { searchPluginAttachments } from "@getpaseo/plugin/client/host"; import type { LucideIcon } from "lucide-react-native"; import type { UserComposerAttachment } from "@/attachments/types"; import type { AttachmentMenuItem } from "@/composer/input/input"; diff --git a/packages/app/src/plugins/client-runtime.test.ts b/packages/app/src/plugins/client-runtime.test.ts index 34fe7d706e..d76a824e9d 100644 --- a/packages/app/src/plugins/client-runtime.test.ts +++ b/packages/app/src/plugins/client-runtime.test.ts @@ -1,6 +1,6 @@ import { QueryClient } from "@tanstack/react-query"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { PluginComposerPillProps } from "@getpaseo/plugin"; +import type { PluginComposerPillProps } from "@getpaseo/plugin/client"; import { afterEach, describe, expect, it } from "vitest"; import type { InstalledPlugin } from "./types"; import { createPluginClientRuntime } from "./client-runtime"; diff --git a/packages/app/src/plugins/client-runtime.ts b/packages/app/src/plugins/client-runtime.ts index cef8bbb341..2a837ebcd7 100644 --- a/packages/app/src/plugins/client-runtime.ts +++ b/packages/app/src/plugins/client-runtime.ts @@ -1,5 +1,5 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin"; +import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client"; import { createPluginAgentActionContext, createPluginCapabilities, diff --git a/packages/app/src/plugins/client-state/source.ts b/packages/app/src/plugins/client-state/source.ts index d7aaee597e..142c1f3575 100644 --- a/packages/app/src/plugins/client-state/source.ts +++ b/packages/app/src/plugins/client-state/source.ts @@ -1,4 +1,4 @@ -import type { PluginClientStateSource } from "@getpaseo/plugin/host"; +import type { PluginClientStateSource } from "@getpaseo/plugin/client/host"; import { useSessionStore } from "@/stores/session-store"; import { selectWorkspace } from "@/stores/session-store-hooks/selectors"; import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index 575dbdb99d..dc40b342b9 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -4,9 +4,9 @@ import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { defineRpc, type PluginAgentSnapshot, - type PluginCommandCenterItemContribution, type PluginWorkspaceSnapshot, } from "@getpaseo/plugin"; +import { type PluginCommandCenterItemContribution } from "@getpaseo/plugin/client"; import { describe, expect, it } from "vitest"; import { z } from "zod"; import type { InstalledPlugin } from "../types"; diff --git a/packages/app/src/plugins/command-center/contributions.ts b/packages/app/src/plugins/command-center/contributions.ts index fbe67e034f..448b0068cb 100644 --- a/packages/app/src/plugins/command-center/contributions.ts +++ b/packages/app/src/plugins/command-center/contributions.ts @@ -1,4 +1,4 @@ -import type { PluginClientStateSource } from "@getpaseo/plugin/host"; +import type { PluginClientStateSource } from "@getpaseo/plugin/client/host"; import type { CommandCenterContribution } from "@/command-center/contributions"; import { getCommandCenterIcon } from "@/command-center/icon"; import { resolvePluginIcon } from "../icons"; diff --git a/packages/app/src/plugins/composer-pills/index.tsx b/packages/app/src/plugins/composer-pills/index.tsx index 12797bb08d..63f33cfad6 100644 --- a/packages/app/src/plugins/composer-pills/index.tsx +++ b/packages/app/src/plugins/composer-pills/index.tsx @@ -1,5 +1,6 @@ -import { PluginClientStateProvider } from "@getpaseo/plugin/host"; -import type { PluginComposerPillProps, PluginTheme } from "@getpaseo/plugin"; +import { PluginClientStateProvider } from "@getpaseo/plugin/client/host"; +import type { PluginComposerPillProps } from "@getpaseo/plugin/client"; +import type { PluginTheme } from "@getpaseo/plugin"; import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; import { Platform, Pressable, type PressableStateCallbackType } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; diff --git a/packages/app/src/plugins/composer-pills/store.ts b/packages/app/src/plugins/composer-pills/store.ts index ae273eb2b5..2f3629e4a4 100644 --- a/packages/app/src/plugins/composer-pills/store.ts +++ b/packages/app/src/plugins/composer-pills/store.ts @@ -1,4 +1,4 @@ -import type { PluginComposerPillContribution } from "@getpaseo/plugin"; +import type { PluginComposerPillContribution } from "@getpaseo/plugin/client"; import type { InstalledPlugin } from "../types"; const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/; diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index 1d042284aa..c66a35d5cb 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -441,11 +441,11 @@ describe("evaluatePluginClientBundle", () => { ).toThrow("must return a cleanup function"); }); - it("provides the host Icon component through @getpaseo/plugin", () => { + it("provides the host Icon component through @getpaseo/plugin/client/react-native", () => { const plugin = evaluatePluginClientBundle( "example", `(function(require) { - const { Icon } = require("@getpaseo/plugin"); + const { Icon } = require("@getpaseo/plugin/client/react-native"); const module = { exports: {} }; module.exports.default = function(plugin) { plugin.addSurface("main", function Surface() { @@ -463,11 +463,11 @@ describe("evaluatePluginClientBundle", () => { expect(element).toMatchObject({ props: { size: 18, color: "#123456" } }); }); - it("provides Paseo UI through @getpaseo/plugin/react-native", () => { + it("provides Paseo UI through @getpaseo/plugin/client/react-native", () => { const plugin = evaluatePluginClientBundle( "example", `(function(require) { - const { Icon, Modal, useToast } = require("@getpaseo/plugin/react-native"); + const { Icon, Modal, useToast } = require("@getpaseo/plugin/client/react-native"); const module = { exports: {} }; module.exports.default = function(plugin) { if (typeof Icon !== "function" || typeof Modal !== "function" || typeof Modal.Content !== "function" || typeof useToast !== "function") { @@ -483,6 +483,41 @@ describe("evaluatePluginClientBundle", () => { expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]); }); + it("keeps shared and client runtime exports separate", () => { + expect(() => + evaluatePluginClientBundle( + "example", + `(function(require) { + const shared = require("@getpaseo/plugin"); + const client = require("@getpaseo/plugin/client"); + for (const name of ["usePaseo", "useRpc", "useSettings", "useAgent", "useWorkspace"]) { + if (name in shared || typeof client[name] !== "function") throw new Error(name); + } + if ("Icon" in shared || typeof shared.PluginAttachmentItemSchema.parse !== "function") throw new Error("shared exports"); + return { default() { return () => {}; } }; + })`, + ), + ).not.toThrow(); + }); + + it.each([ + "@getpaseo/plugin/server", + "@getpaseo/plugin/server/provider", + "@getpaseo/plugin/server/acp", + "@getpaseo/plugin/client/host", + "@getpaseo/plugin/react-native", + "@getpaseo/plugin/ui", + "@getpaseo/plugin/host", + "@paseo/plugin", + ])("rejects %s in the client loader", (specifier) => { + expect(() => + evaluatePluginClientBundle( + "example", + `(function(require) { require("${specifier}"); return {}; })`, + ), + ).toThrow("not available in plugin client code"); + }); + it("resolves shared RPC helpers from @getpaseo/plugin", () => { const plugin = evaluatePluginClientBundle( "example", diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index e989d44966..bcad62dd40 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -1,6 +1,7 @@ import * as pluginUiRuntime from "./react-native/ui"; import { useSettings } from "./settings/use-settings"; -import { defineSettings } from "@getpaseo/plugin"; +import * as pluginSharedRuntime from "@getpaseo/plugin"; +import * as pluginClientRuntime from "@getpaseo/plugin/client"; import * as React from "react"; import * as ReactJsxRuntime from "react/jsx-runtime"; // eslint-disable-next-line no-restricted-imports -- plugin client runtime injects host ReactNative. @@ -9,27 +10,23 @@ import * as ReactNative from "react-native"; import * as ReactQuery from "@tanstack/react-query"; import * as Zod from "zod"; import { - defineAttachmentSource, - defineRpc, type PluginAttachmentSourceContribution, - type PluginCommandCenterItemContribution, type PluginCleanup, + type PluginThemeContribution, +} from "@getpaseo/plugin"; +import { + type PluginCommandCenterItemContribution, type PluginClientContext, type PluginClientSlashCommandContribution, type PluginSidebarContribution, type PluginSurfaceProps, - type PluginThemeContribution, type PluginTimelineRendererContribution, type PluginTimelineTransformerContribution, type PluginWorkspacePanelContribution, - usePaseo, - useAgent, - useWorkspace, - useRpc, -} from "@getpaseo/plugin"; +} from "@getpaseo/plugin/client"; import type { EvaluatedPlugin } from "./types"; import type { ComponentType } from "react"; -import { Icon, resolvePluginIcon } from "./icons"; +import { resolvePluginIcon } from "./icons"; import { pluginReactNativeRuntime } from "./react-native/runtime"; import { parsePluginThemeContribution } from "./themes"; @@ -352,29 +349,15 @@ export function runPluginClientBundle( }, }; const runtimeRequire = (name: string): unknown => { - if (name === "@getpaseo/plugin/ui") return pluginUiRuntime; + if (name === "@getpaseo/plugin/client/ui") return pluginUiRuntime; if (name === "react") return React; if (name === "react/jsx-runtime") return ReactJsxRuntime; if (name === "react-native") return ReactNative; - if (name === "@getpaseo/plugin") { - return { - defineAttachmentSource, - defineRpc, - defineSettings, - useSettings, - Icon, - usePaseo, - useAgent, - useWorkspace, - useRpc, - }; - } - if (name === "@getpaseo/plugin/react-native" || name === "@paseo/plugin/react-native") { + if (name === "@getpaseo/plugin") return pluginSharedRuntime; + if (name === "@getpaseo/plugin/client") return { ...pluginClientRuntime, useSettings }; + if (name === "@getpaseo/plugin/client/react-native") { return pluginReactNativeRuntime; } - if (name === "@getpaseo/plugin/server" || name === "@paseo/plugin/server") { - return {}; - } if (name === "@tanstack/react-query") return ReactQuery; if (name === "zod") return Zod; throw new Error(`Module "${name}" is not available in plugin client code`); diff --git a/packages/app/src/plugins/host-navigation.ts b/packages/app/src/plugins/host-navigation.ts index 835cc41657..78fdef566d 100644 --- a/packages/app/src/plugins/host-navigation.ts +++ b/packages/app/src/plugins/host-navigation.ts @@ -1,4 +1,4 @@ -import type { PluginSurfaceProps } from "@getpaseo/plugin"; +import type { PluginSurfaceProps } from "@getpaseo/plugin/client"; import { useMemo } from "react"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { navigateToAgent } from "@/utils/navigate-to-agent"; diff --git a/packages/app/src/plugins/icons.ts b/packages/app/src/plugins/icons.ts index 1e3fdb4cbf..3c5ec581ce 100644 --- a/packages/app/src/plugins/icons.ts +++ b/packages/app/src/plugins/icons.ts @@ -1,6 +1,6 @@ import { createElement, type ReactElement } from "react"; import * as LucideIcons from "lucide-react-native"; -import type { PluginIconProps } from "@getpaseo/plugin"; +import type { PluginIconProps } from "@getpaseo/plugin/client"; import type { LucideIcon } from "lucide-react-native"; function findPluginIcon(name: string): LucideIcon | null { diff --git a/packages/app/src/plugins/navigation.ts b/packages/app/src/plugins/navigation.ts index e155384d52..17e277504c 100644 --- a/packages/app/src/plugins/navigation.ts +++ b/packages/app/src/plugins/navigation.ts @@ -1,6 +1,6 @@ import { buildPluginSettingsRoute } from "./settings/routes"; import { router } from "expo-router"; -import type { PluginPanelLocation } from "@getpaseo/plugin"; +import type { PluginPanelLocation } from "@getpaseo/plugin/client"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildPluginSurfaceRoute } from "./routes"; diff --git a/packages/app/src/plugins/react-native/modal.tsx b/packages/app/src/plugins/react-native/modal.tsx index 2785531d55..b61cd925b1 100644 --- a/packages/app/src/plugins/react-native/modal.tsx +++ b/packages/app/src/plugins/react-native/modal.tsx @@ -1,6 +1,10 @@ import { QueryClientProvider, useQueryClient } from "@tanstack/react-query"; -import type { ModalComponent, ModalContentProps, ModalProps } from "@getpaseo/plugin/react-native"; -import { usePluginRuntimeContextBridge } from "@getpaseo/plugin/host"; +import type { + ModalComponent, + ModalContentProps, + ModalProps, +} from "@getpaseo/plugin/client/react-native"; +import { usePluginRuntimeContextBridge } from "@getpaseo/plugin/client/host"; import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from "react"; import { ToastApiProvider, useToast as useAppToast } from "@/contexts/toast-api-context"; diff --git a/packages/app/src/plugins/react-native/toast.ts b/packages/app/src/plugins/react-native/toast.ts index e144390bfa..fd4d503799 100644 --- a/packages/app/src/plugins/react-native/toast.ts +++ b/packages/app/src/plugins/react-native/toast.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import type { ToastApi, ToastOptions } from "@getpaseo/plugin/react-native"; +import type { ToastApi, ToastOptions } from "@getpaseo/plugin/client/react-native"; import { useToast as useAppToast } from "@/contexts/toast-api-context"; export function useToast(): ToastApi { diff --git a/packages/app/src/plugins/runtime-boundary.tsx b/packages/app/src/plugins/runtime-boundary.tsx index 7f2d62c054..eaed2e5d54 100644 --- a/packages/app/src/plugins/runtime-boundary.tsx +++ b/packages/app/src/plugins/runtime-boundary.tsx @@ -1,5 +1,5 @@ import { QueryClientProvider } from "@tanstack/react-query"; -import { PaseoApiProvider, PluginRpcProvider } from "@getpaseo/plugin/host"; +import { PaseoApiProvider, PluginRpcProvider } from "@getpaseo/plugin/client/host"; import type { ReactNode } from "react"; import type { InstalledPlugin } from "./types"; import type { PluginSurfaceRuntime } from "./surface-runtime"; diff --git a/packages/app/src/plugins/settings/index.tsx b/packages/app/src/plugins/settings/index.tsx index 175daa994d..dbf9dec18d 100644 --- a/packages/app/src/plugins/settings/index.tsx +++ b/packages/app/src/plugins/settings/index.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { Platform, Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { router } from "expo-router"; -import type { PluginHostProps } from "@getpaseo/plugin"; +import type { PluginHostProps } from "@getpaseo/plugin/client"; import { SettingsAction } from "@/components/settings"; import { Button } from "@/components/ui/button"; import { useIsCompactFormFactor } from "@/constants/layout"; diff --git a/packages/app/src/plugins/settings/use-settings.ts b/packages/app/src/plugins/settings/use-settings.ts index 8c19a4f7f2..0cefc1878f 100644 --- a/packages/app/src/plugins/settings/use-settings.ts +++ b/packages/app/src/plugins/settings/use-settings.ts @@ -1,7 +1,9 @@ +import type { SettingsState } from "@getpaseo/plugin/client"; import { useMemo } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useRpc, type SettingsDefinition, type SettingsState } from "@getpaseo/plugin"; -import { settingsRpc } from "@getpaseo/plugin/host"; +import { useRpc } from "@getpaseo/plugin/client"; +import { type SettingsDefinition } from "@getpaseo/plugin"; +import { settingsRpc } from "@getpaseo/plugin"; import { useReplicaQuery } from "@/data/query"; import { z, type ZodType } from "zod"; diff --git a/packages/app/src/plugins/surface-runtime.test.tsx b/packages/app/src/plugins/surface-runtime.test.tsx index ec3ad2d4d1..88d8f694ec 100644 --- a/packages/app/src/plugins/surface-runtime.test.tsx +++ b/packages/app/src/plugins/surface-runtime.test.tsx @@ -1,7 +1,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { PaseoApi } from "@getpaseo/client"; -import { PaseoApiProvider } from "@getpaseo/plugin/host"; -import { usePaseo } from "@getpaseo/plugin"; +import { PaseoApiProvider } from "@getpaseo/plugin/client/host"; +import { usePaseo } from "@getpaseo/plugin/client"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/app/src/plugins/surface-screen.tsx b/packages/app/src/plugins/surface-screen.tsx index 849f357ba5..6139897c5b 100644 --- a/packages/app/src/plugins/surface-screen.tsx +++ b/packages/app/src/plugins/surface-screen.tsx @@ -1,5 +1,6 @@ import { router, useLocalSearchParams } from "expo-router"; -import type { PluginSurfaceProps, PluginTheme } from "@getpaseo/plugin"; +import type { PluginSurfaceProps } from "@getpaseo/plugin/client"; +import type { PluginTheme } from "@getpaseo/plugin"; import { ChevronDown, X } from "lucide-react-native"; import { useCallback, useMemo, useRef, useState, type ComponentType } from "react"; import { Platform, Pressable, Text, View } from "react-native"; diff --git a/packages/app/src/plugins/timeline/view.tsx b/packages/app/src/plugins/timeline/view.tsx index 443172263b..6eba06c32b 100644 --- a/packages/app/src/plugins/timeline/view.tsx +++ b/packages/app/src/plugins/timeline/view.tsx @@ -1,5 +1,6 @@ -import { PluginClientStateProvider } from "@getpaseo/plugin/host"; -import type { PluginHostProps, PluginTheme, PluginTimelineItemProps } from "@getpaseo/plugin"; +import { PluginClientStateProvider } from "@getpaseo/plugin/client/host"; +import type { PluginHostProps, PluginTimelineItemProps } from "@getpaseo/plugin/client"; +import type { PluginTheme } from "@getpaseo/plugin"; import React, { type ComponentType, useMemo } from "react"; import { Platform, Text } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 9247523d29..651e5f7b28 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -2,19 +2,21 @@ import type { QueryClient } from "@tanstack/react-query"; import type { PluginRequirements } from "@getpaseo/protocol/messages"; import type { PluginAttachmentSourceContribution, + PluginCleanup, + PluginThemeContribution, +} from "@getpaseo/plugin"; +import type { PluginCommandCenterItemContribution, PluginClientSlashCommandContribution, - PluginCleanup, PluginComposerPillContribution, PluginSidebarContribution, PluginSurfaceContribution, PluginSettingsScreenContribution, - PluginThemeContribution, PluginTimelineRendererContribution, PluginTimelineTransformerContribution, PluginPanelLocation, PluginWorkspacePanelContribution, -} from "@getpaseo/plugin"; +} from "@getpaseo/plugin/client"; export type EvaluatedPluginWorkspacePanelContribution = PluginWorkspacePanelContribution & { locations: readonly PluginPanelLocation[]; diff --git a/packages/app/src/plugins/workspace-panels/locations.ts b/packages/app/src/plugins/workspace-panels/locations.ts index 7248ccbe61..1c8cfbfb90 100644 --- a/packages/app/src/plugins/workspace-panels/locations.ts +++ b/packages/app/src/plugins/workspace-panels/locations.ts @@ -1,4 +1,7 @@ -import type { PluginPanelLocation, PluginWorkspacePanelContribution } from "@getpaseo/plugin"; +import type { + PluginPanelLocation, + PluginWorkspacePanelContribution, +} from "@getpaseo/plugin/client"; import type { PaneHost } from "@/panels/panel-manifest"; import { panelSupportsHost } from "@/panels/panel-manifest"; import type { PluginWorkspaceTabTarget, WorkspaceTabTarget } from "@/workspace-tabs/model"; diff --git a/packages/app/src/plugins/workspace-panels/panel.tsx b/packages/app/src/plugins/workspace-panels/panel.tsx index 2acca92bd2..bf652140f5 100644 --- a/packages/app/src/plugins/workspace-panels/panel.tsx +++ b/packages/app/src/plugins/workspace-panels/panel.tsx @@ -1,10 +1,10 @@ import type { PluginAgentPanelProps, PluginHostProps, - PluginTheme, PluginWorkspacePanelProps, -} from "@getpaseo/plugin"; -import { PluginClientStateProvider } from "@getpaseo/plugin/host"; +} from "@getpaseo/plugin/client"; +import type { PluginTheme } from "@getpaseo/plugin"; +import { PluginClientStateProvider } from "@getpaseo/plugin/client/host"; import { CircleAlert } from "lucide-react-native"; import { useMemo } from "react"; import { Platform, Text, View } from "react-native"; diff --git a/packages/app/src/plugins/workspace-panels/resolution.ts b/packages/app/src/plugins/workspace-panels/resolution.ts index b2436c6c52..7a9355e72a 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.ts @@ -1,4 +1,4 @@ -import type { PluginWorkspacePanelContribution } from "@getpaseo/plugin"; +import type { PluginWorkspacePanelContribution } from "@getpaseo/plugin/client"; import type { InstalledPlugin } from "../types"; import type { PluginWorkspaceTabTarget } from "@/workspace-tabs/model"; diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts index ff01d2730a..5757bdba87 100644 --- a/packages/cli/src/commands/plugin/scaffold.test.ts +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -141,16 +141,8 @@ export async function inspectConfig( path.join(directory, "client", "main.tsx"), `import React from "react"; import { Text } from "react-native"; -import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native"; -import { - type PluginAgentPanelProps, - type PluginClientContext, - type PluginComposerPillProps, - type PluginSurfaceProps, - useAgent, - usePaseo, - useWorkspace, -} from "@getpaseo/plugin"; +import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native"; +import { type PluginAgentPanelProps, type PluginClientContext, type PluginComposerPillProps, type PluginSurfaceProps, useAgent, usePaseo, useWorkspace } from "@getpaseo/plugin/client"; import { inspect } from "../shared/inspect"; export function Surface({ navigation }: PluginSurfaceProps) { @@ -200,7 +192,7 @@ export function contributeClient(client: PluginClientContext) { ), writeFile( path.join(directory, "index.client.tsx"), - `import type { PluginClientContext } from "@getpaseo/plugin"; + `import type { PluginClientContext } from "@getpaseo/plugin/client"; import { AgentPanel, contributeClient, Surface } from "./client/main"; import { inspect } from "./shared/inspect"; @@ -230,7 +222,7 @@ export default function contribute(client: PluginClientContext) { ), writeFile( path.join(directory, "index.server.ts"), - `import type { PluginServerContext } from "@getpaseo/plugin"; + `import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inspectConfig } from "./server/inspect"; import { inspect } from "./shared/inspect"; diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts index d525490f07..d73f713d89 100644 --- a/packages/cli/src/commands/plugin/scaffold.ts +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -20,7 +20,7 @@ const TSCONFIG = { include: ["**/*.ts", "**/*.tsx"], }; -const CLIENT_ENTRY = `import type { PluginClientContext } from "@getpaseo/plugin"; +const CLIENT_ENTRY = `import type { PluginClientContext } from "@getpaseo/plugin/client"; import { GreetingSurface } from "./client/greeting"; export default function contribute(client: PluginClientContext) { @@ -35,7 +35,7 @@ export default function contribute(client: PluginClientContext) { } `; -const SERVER_ENTRY = `import type { PluginServerContext } from "@getpaseo/plugin"; +const SERVER_ENTRY = `import type { PluginServerContext } from "@getpaseo/plugin/server"; import { createGreeting } from "./server/greeting"; import { greetingRpc } from "./shared/greeting"; @@ -63,8 +63,8 @@ export function createGreeting({ name }: RpcInput) { } `; -const CLIENT_GREETING = `import type { PluginSurfaceProps } from "@getpaseo/plugin"; -import { useRpc } from "@getpaseo/plugin"; +const CLIENT_GREETING = `import type { PluginSurfaceProps } from "@getpaseo/plugin/client"; +import { useRpc } from "@getpaseo/plugin/client"; import { useMutation } from "@tanstack/react-query"; import { useMemo } from "react"; import { Pressable, Text, View } from "react-native"; diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 52fb25aca0..58626b1e6a 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -14,34 +14,39 @@ "default": "./dist/index.js" }, "./server": { - "types": "./dist/server.d.ts", - "source": "./src/server.ts", - "default": "./dist/server.js" + "types": "./dist/server/index.d.ts", + "source": "./src/server/index.ts", + "default": "./dist/server/index.js" }, - "./provider": { - "types": "./dist/provider.d.ts", - "source": "./src/provider.ts", - "default": "./dist/provider.js" + "./server/provider": { + "types": "./dist/server/provider.d.ts", + "source": "./src/server/provider.ts", + "default": "./dist/server/provider.js" }, - "./acp": { - "types": "./dist/acp.d.ts", - "source": "./src/acp.ts", - "default": "./dist/acp.js" + "./server/acp": { + "types": "./dist/server/acp.d.ts", + "source": "./src/server/acp.ts", + "default": "./dist/server/acp.js" }, - "./react-native": { - "types": "./dist/react-native.d.ts", - "source": "./src/react-native.ts", - "default": "./dist/react-native.js" + "./client/react-native": { + "types": "./dist/client/react-native.d.ts", + "source": "./src/client/react-native.ts", + "default": "./dist/client/react-native.js" }, - "./host": { - "types": "./dist/host.d.ts", - "source": "./src/host.ts", - "default": "./dist/host.js" + "./client/host": { + "types": "./dist/client/host.d.ts", + "source": "./src/client/host.ts", + "default": "./dist/client/host.js" }, - "./ui": { - "types": "./dist/ui.d.ts", - "source": "./src/ui.ts", - "default": "./dist/ui.js" + "./client/ui": { + "types": "./dist/client/ui.d.ts", + "source": "./src/client/ui.ts", + "default": "./dist/client/ui.js" + }, + "./client": { + "types": "./dist/client/index.d.ts", + "source": "./src/client/index.ts", + "default": "./dist/client/index.js" } }, "publishConfig": { diff --git a/packages/plugin/src/attachments.ts b/packages/plugin/src/attachments.ts index ee7c2b0888..27ca706dd7 100644 --- a/packages/plugin/src/attachments.ts +++ b/packages/plugin/src/attachments.ts @@ -1,4 +1,11 @@ import { z } from "zod"; +import type { PluginAttachmentSourceContribution } from "./contracts.js"; + +export function defineAttachmentSource( + definition: Definition, +): Definition { + return definition; +} export const PluginAttachmentItemSchema = z.object({ id: z.string(), diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts new file mode 100644 index 0000000000..8c2fa17737 --- /dev/null +++ b/packages/plugin/src/boundaries.test.ts @@ -0,0 +1,123 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { isBuiltin } from "node:module"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const sourceDirectory = path.dirname(fileURLToPath(import.meta.url)); +const entries = { + ".": "shared", + "./server": "server", + "./server/provider": "server", + "./server/acp": "server", + "./client": "client", + "./client/host": "client", + "./client/react-native": "client", + "./client/ui": "client", +} as const; +const uiDependency = /^(react(?:-dom|-native)?|use-sync-external-store)(\/|$)/; +const serverModule = /^server\//; +const clientModule = /^client\//; + +function resolveLocal(importer: string, specifier: string): string { + const base = path.resolve(path.dirname(importer), specifier.replace(/\.js$/, "")); + const result = [base + ".ts", base + ".tsx"].find(existsSync); + if (!result) throw new Error(`Cannot resolve ${specifier} from ${importer}`); + return result; +} + +function boundaryViolations(entry: string, runtime: "shared" | "server" | "client"): string[] { + const pending = [entry]; + const visited = new Set(); + const violations: string[] = []; + while (pending.length) { + const file = pending.pop()!; + if (visited.has(file)) continue; + visited.add(file); + // Includes type imports, re-exports, import types, and literal dynamic imports. + const imports = ts.preProcessFile(readFileSync(file, "utf8"), true, true).importedFiles; + for (const { fileName: specifier } of imports) { + const label = `${path.relative(sourceDirectory, file)} -> ${specifier}`; + if (specifier.startsWith(".")) { + const local = resolveLocal(file, specifier); + const module = path + .relative(sourceDirectory, local) + .replace(/\\/g, "/") + .replace(/\.tsx?$/, ".js"); + if (runtime !== "client" && clientModule.test(module)) violations.push(label); + if (runtime !== "server" && serverModule.test(module)) violations.push(label); + pending.push(local); + } else if (runtime === "shared") { + if (specifier !== "zod" && specifier !== "@getpaseo/protocol/agent-types") + violations.push(label); + } else if (runtime === "server") { + if (uiDependency.test(specifier) || /^@getpaseo\/plugin\/client(\/|$)/.test(specifier)) + violations.push(label); + } else if (isBuiltin(specifier) || /^@getpaseo\/plugin\/server(\/|$)/.test(specifier)) { + violations.push(label); + } + } + } + return violations; +} + +describe("plugin SDK import boundaries", () => { + it("classifies every published entry", () => { + const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); + expect(Object.keys(manifest.exports).sort()).toEqual(Object.keys(entries).sort()); + }); + + it.each(Object.entries(entries))( + "%s respects its %s boundary, including type dependencies", + (specifier, runtime) => { + const name = + specifier === "." + ? "index" + : specifier.slice(2) + + (specifier === "./client" || specifier === "./server" ? "/index" : ""); + expect(boundaryViolations(path.join(sourceDirectory, `${name}.ts`), runtime)).toEqual([]); + }, + ); +}); + +describe("plugin example import boundaries", () => { + const examples = path.resolve(sourceDirectory, "../../../plugin-examples"); + const files = readdirSync(examples, { recursive: true }).filter((file) => + /(? { + const [, directory] = path.relative(examples, file).split(path.sep); + if (directory?.startsWith("index.client.")) return "client"; + if (directory?.startsWith("index.server.")) return "server"; + return directory; + }; + + it.each(files)("%s respects runtime ownership, including type imports", (relative) => { + const file = path.join(examples, relative); + const runtime = owner(file); + expect(["shared", "client", "server"]).toContain(runtime); + const violations: string[] = []; + for (const { fileName: specifier } of ts.preProcessFile(readFileSync(file, "utf8"), true, true) + .importedFiles) { + if (specifier.startsWith(".")) { + const imported = owner(resolveLocal(file, specifier)); + if (imported !== "shared" && imported !== runtime) violations.push(specifier); + } else if (specifier.startsWith("@getpaseo/plugin")) { + const entry = specifier.replace("@getpaseo/plugin", ".") as keyof typeof entries; + if ( + entry === "./client/host" || + !(entry in entries) || + (entries[entry] !== "shared" && entries[entry] !== runtime) + ) + violations.push(specifier); + } else if ( + (runtime !== "client" && uiDependency.test(specifier)) || + (runtime !== "server" && isBuiltin(specifier)) + ) { + violations.push(specifier); + } + } + expect(violations).toEqual([]); + }); +}); diff --git a/packages/plugin/src/client-state.tsx b/packages/plugin/src/client/client-state.tsx similarity index 98% rename from packages/plugin/src/client-state.tsx rename to packages/plugin/src/client/client-state.tsx index 9797e8d965..b1c853ecec 100644 --- a/packages/plugin/src/client-state.tsx +++ b/packages/plugin/src/client/client-state.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, type ReactNode } from "react"; import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector"; -import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "./contracts.js"; +import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "../contracts.js"; import { shallowEqual } from "./shallow.js"; export interface PluginClientStateSource { diff --git a/packages/plugin/src/client/contracts.ts b/packages/plugin/src/client/contracts.ts new file mode 100644 index 0000000000..16456ce4e1 --- /dev/null +++ b/packages/plugin/src/client/contracts.ts @@ -0,0 +1,246 @@ +import type { ComponentType } from "react"; +import type { PaseoApi } from "@getpaseo/client"; +import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; +import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; +import type { PluginRpcContract } from "../rpc.js"; +import type { + PluginTheme, + PluginWorkspaceSnapshot, + PluginAgentSnapshot, + PluginThemeContribution, + PluginAttachmentSourceContribution, + PluginTimelineTransformResult, + PluginCleanup, +} from "../contracts.js"; + +export interface PluginHostProps { + theme: PluginTheme; + host: { + id: string; + label: string; + }; + layout: { + compact: boolean; + platform: "ios" | "android" | "web"; + }; +} + +interface PluginNavigableHostProps extends PluginHostProps { + /** Client-owned navigation. Undefined on older hosts; hide dependent affordances when absent. */ + readonly navigation?: { + readonly openAgent: (input: { readonly agentId: string }) => void; + readonly openWorkspace: (input: { readonly workspaceId: string }) => void; + }; +} + +export interface PluginSurfaceProps extends PluginNavigableHostProps {} + +export interface PluginIconProps { + name: string; + size?: number; + color?: string; +} + +export type PluginPanelLocation = "workspace" | "explorer"; + +export interface PluginOpenPanelOptions { + location?: PluginPanelLocation; +} + +interface PluginWorkspacePanelBase { + id: string; + title: string; + icon: string; + locations?: readonly PluginPanelLocation[]; +} + +export interface PluginWorkspacePanelProps extends PluginNavigableHostProps { + context: "workspace"; + workspaceId: string; +} + +export interface PluginAgentPanelProps extends PluginNavigableHostProps { + context: "agent"; + workspaceId: string; + agentId: string; +} + +export interface PluginComposerPillProps extends PluginHostProps { + workspaceId: string; + agentId: string; +} + +export interface PluginComposerPillContribution { + id: string; + title: string; + workspaceId: string; + agentId: string; + Component: ComponentType; + onPress(): void | Promise; +} + +export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions { + workspaceId: string; + agentId?: string; +} + +export interface PluginClientContext extends PluginCommandCapabilities { + addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup; + addSurface(id: string, Component: ComponentType): PluginCleanup; + addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup; + addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup; + addCommandCenterItem(contribution: PluginCommandCenterItemContribution): PluginCleanup; + addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup; + addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup; + addAttachmentSource(contribution: PluginAttachmentSourceContribution): PluginCleanup; + addTheme(contribution: PluginThemeContribution): PluginCleanup; + addTimelineTransformer( + contribution: PluginTimelineTransformerContribution, + ): PluginCleanup; + addTimelineRenderer( + contribution: PluginTimelineRendererContribution, + ): PluginCleanup; + openPanel(id: string, options: PluginClientOpenPanelOptions): void; +} + +export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup; + +export type PluginWorkspacePanelContribution = + | (PluginWorkspacePanelBase & { + context: "workspace"; + Component: ComponentType; + }) + | (PluginWorkspacePanelBase & { + context: "agent"; + Component: ComponentType; + }); + +export interface PluginSettingsScreenContribution { + id: string; + title: string; + icon: string; + Component: ComponentType; +} + +export interface PluginSurfaceContribution { + id: string; + Component: ComponentType; +} + +export interface PluginSidebarContribution { + id: string; + title: string; + icon: string; + surface: string; +} + +export type PluginTimelineTransformerContribution< + ItemType extends AgentTimelineItem["type"] = AgentTimelineItem["type"], +> = ItemType extends AgentTimelineItem["type"] + ? { + id: string; + query: { + itemType: ItemType; + }; + transform(input: { + item: Extract; + phase: "streaming" | "complete"; + }): PluginTimelineTransformResult | undefined; + } + : never; + +export interface PluginTimelineItemProps extends PluginHostProps { + agentId: string; + item: { + type: "plugin"; + kind: string; + version: number; + data: Data; + }; + timestamp: Date; +} + +export interface PluginTimelineRendererContribution { + kind: string; + version: number; + schema: Schema; + Component: ComponentType>>; +} + +export interface PluginCommandCapabilities { + paseo: PaseoApi; + rpc( + contract: PluginRpcContract, + input: ZodInput, + ): Promise>; + openSurface(id: string): void; + openSettings(id: string): void; +} + +export interface PluginGlobalCommandContext extends PluginCommandCapabilities { + context: "global"; +} + +export interface PluginWorkspaceCommandContext extends PluginCommandCapabilities { + context: "workspace"; + workspace: PluginWorkspaceSnapshot; + openPanel(id: string, options?: PluginOpenPanelOptions): void; +} + +export interface PluginAgentCommandContext extends PluginCommandCapabilities { + context: "agent"; + workspace: PluginWorkspaceSnapshot; + agent: PluginAgentSnapshot; + openPanel(id: string, options?: PluginOpenPanelOptions): void; +} + +interface PluginCommandCenterItemBase { + id: string; + title: string; + icon: string; + keywords?: readonly string[]; +} + +export type PluginCommandCenterItemContribution = + | (PluginCommandCenterItemBase & { + context: "global"; + onSelect(context: PluginGlobalCommandContext): void | Promise; + }) + | (PluginCommandCenterItemBase & { + context: "workspace"; + onSelect(context: PluginWorkspaceCommandContext): void | Promise; + }) + | (PluginCommandCenterItemBase & { + context: "agent"; + onSelect(context: PluginAgentCommandContext): void | Promise; + }); + +interface PluginClientSlashCommandBase { + name: string; + description: string; + argumentHint: string; +} + +export type PluginClientSlashCommandContribution = + | (PluginClientSlashCommandBase & { + context: "workspace"; + onSubmit(context: PluginWorkspaceCommandContext & { args: string }): void | Promise; + }) + | (PluginClientSlashCommandBase & { + context: "agent"; + onSubmit(context: PluginAgentCommandContext & { args: string }): void | Promise; + }); + +export type SettingsState = ( + | { status: "loading" } + | { status: "error"; error: string } + | { status: "invalid"; error: string; revision: string } + | { status: "ready"; values: ZodOutput; revision: string } +) & { + saving: boolean; + saveError: string | null; + /** Save an entire document against the revision currently displayed. Never throws. */ + save(values: ZodOutput, revision: string): Promise; + reset(): Promise; + reload(): Promise; +}; diff --git a/packages/plugin/src/host.test.ts b/packages/plugin/src/client/host.test.ts similarity index 99% rename from packages/plugin/src/host.test.ts rename to packages/plugin/src/client/host.test.ts index 58aab65603..894fe832a5 100644 --- a/packages/plugin/src/host.test.ts +++ b/packages/plugin/src/client/host.test.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import { z } from "zod"; -import { defineAttachmentSource, defineRpc, type RpcInput, type RpcOutput } from "./index.js"; +import { defineAttachmentSource, defineRpc, type RpcInput, type RpcOutput } from "../index.js"; import { callPluginRpc, searchPluginAttachments } from "./host.js"; const contract = defineRpc({ diff --git a/packages/plugin/src/host.ts b/packages/plugin/src/client/host.ts similarity index 75% rename from packages/plugin/src/host.ts rename to packages/plugin/src/client/host.ts index 59e1d84e6d..6cd42863e5 100644 --- a/packages/plugin/src/host.ts +++ b/packages/plugin/src/client/host.ts @@ -1,14 +1,13 @@ -export { settingsRpc } from "./settings.js"; -import { PluginAttachmentSearchPayloadSchema } from "./attachments.js"; +import { PluginAttachmentSearchPayloadSchema } from "../attachments.js"; export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js"; export { usePluginRuntimeContextBridge, type PluginRuntimeContextBridge, } from "./runtime-context-bridge.js"; -import type { PluginAttachmentSourceContribution } from "./contracts.js"; +import type { PluginAttachmentSourceContribution } from "../contracts.js"; import { PluginRpcProvider } from "./rpc-context.js"; import { PaseoApiProvider } from "./paseo-context.js"; -import { callPluginRpc } from "./rpc.js"; +import { callPluginRpc } from "../rpc.js"; export async function searchPluginAttachments( source: PluginAttachmentSourceContribution, diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts new file mode 100644 index 0000000000..233502c66d --- /dev/null +++ b/packages/plugin/src/client/index.ts @@ -0,0 +1,37 @@ +export type { + PluginHostProps, + PluginSurfaceProps, + PluginIconProps, + PluginPanelLocation, + PluginOpenPanelOptions, + PluginWorkspacePanelProps, + PluginAgentPanelProps, + PluginComposerPillProps, + PluginComposerPillContribution, + PluginClientOpenPanelOptions, + PluginClientContext, + PluginClientContribution, + PluginWorkspacePanelContribution, + PluginSettingsScreenContribution, + PluginSurfaceContribution, + PluginSidebarContribution, + PluginTimelineTransformerContribution, + PluginTimelineItemProps, + PluginTimelineRendererContribution, + PluginCommandCapabilities, + PluginGlobalCommandContext, + PluginWorkspaceCommandContext, + PluginAgentCommandContext, + PluginCommandCenterItemContribution, + PluginClientSlashCommandContribution, + SettingsState, +} from "./contracts.js"; +export { usePaseo } from "./paseo-context.js"; +export { useAgent, useWorkspace } from "./client-state.js"; +export { useRpc } from "./rpc-context.js"; +import type { SettingsDefinition } from "../settings.js"; +import type { SettingsState } from "./contracts.js"; +import type { ZodType } from "zod"; +export declare function useSettings( + definition: SettingsDefinition, +): SettingsState; diff --git a/packages/plugin/src/paseo-context.tsx b/packages/plugin/src/client/paseo-context.tsx similarity index 100% rename from packages/plugin/src/paseo-context.tsx rename to packages/plugin/src/client/paseo-context.tsx diff --git a/packages/plugin/src/react-native.ts b/packages/plugin/src/client/react-native.ts similarity index 100% rename from packages/plugin/src/react-native.ts rename to packages/plugin/src/client/react-native.ts diff --git a/packages/plugin/src/rpc-context.tsx b/packages/plugin/src/client/rpc-context.tsx similarity index 94% rename from packages/plugin/src/rpc-context.tsx rename to packages/plugin/src/client/rpc-context.tsx index 8f7ba514f5..7cd9bb176a 100644 --- a/packages/plugin/src/rpc-context.tsx +++ b/packages/plugin/src/client/rpc-context.tsx @@ -1,6 +1,6 @@ import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react"; import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; -import { callPluginRpc, type PluginRpcContract } from "./rpc.js"; +import { callPluginRpc, type PluginRpcContract } from "../rpc.js"; export interface PluginRpcContextValue { invoke(method: string, input: unknown): Promise; diff --git a/packages/plugin/src/runtime-context-bridge.tsx b/packages/plugin/src/client/runtime-context-bridge.tsx similarity index 100% rename from packages/plugin/src/runtime-context-bridge.tsx rename to packages/plugin/src/client/runtime-context-bridge.tsx diff --git a/packages/plugin/src/shallow.test.ts b/packages/plugin/src/client/shallow.test.ts similarity index 100% rename from packages/plugin/src/shallow.test.ts rename to packages/plugin/src/client/shallow.test.ts diff --git a/packages/plugin/src/shallow.ts b/packages/plugin/src/client/shallow.ts similarity index 100% rename from packages/plugin/src/shallow.ts rename to packages/plugin/src/client/shallow.ts diff --git a/packages/plugin/src/ui.ts b/packages/plugin/src/client/ui.ts similarity index 100% rename from packages/plugin/src/ui.ts rename to packages/plugin/src/client/ui.ts diff --git a/packages/plugin/src/use-sync-external-store.d.ts b/packages/plugin/src/client/use-sync-external-store.d.ts similarity index 100% rename from packages/plugin/src/use-sync-external-store.d.ts rename to packages/plugin/src/client/use-sync-external-store.d.ts diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index 3e06485f19..98df0758a6 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -1,10 +1,5 @@ -import type { PluginLifecycleRegistration } from "./lifecycle.js"; -import type { ComponentType } from "react"; -import type { PaseoApi } from "@getpaseo/client"; -import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types"; -import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; import type { PluginRpcContract } from "./rpc.js"; -import type { ProviderRegistration } from "./provider.js"; export interface PluginTheme { readonly colors: { @@ -22,34 +17,6 @@ export interface PluginTheme { }; } -export interface PluginHostProps { - theme: PluginTheme; - host: { - id: string; - label: string; - }; - layout: { - compact: boolean; - platform: "ios" | "android" | "web"; - }; -} - -interface PluginNavigableHostProps extends PluginHostProps { - /** Client-owned navigation. Undefined on older hosts; hide dependent affordances when absent. */ - readonly navigation?: { - readonly openAgent: (input: { readonly agentId: string }) => void; - readonly openWorkspace: (input: { readonly workspaceId: string }) => void; - }; -} - -export interface PluginSurfaceProps extends PluginNavigableHostProps {} - -export interface PluginIconProps { - name: string; - size?: number; - color?: string; -} - export interface PluginWorkspaceSnapshot { readonly id: string; readonly projectId: string; @@ -85,99 +52,6 @@ export interface PluginAgentSnapshot { readonly labels: Readonly>; } -export type PluginPanelLocation = "workspace" | "explorer"; - -export interface PluginOpenPanelOptions { - location?: PluginPanelLocation; -} - -interface PluginWorkspacePanelBase { - id: string; - title: string; - icon: string; - locations?: readonly PluginPanelLocation[]; -} - -export interface PluginWorkspacePanelProps extends PluginNavigableHostProps { - context: "workspace"; - workspaceId: string; -} - -export interface PluginAgentPanelProps extends PluginNavigableHostProps { - context: "agent"; - workspaceId: string; - agentId: string; -} - -export interface PluginComposerPillProps extends PluginHostProps { - workspaceId: string; - agentId: string; -} - -export interface PluginComposerPillContribution { - id: string; - title: string; - workspaceId: string; - agentId: string; - Component: ComponentType; - onPress(): void | Promise; -} - -export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions { - workspaceId: string; - agentId?: string; -} - -export interface PluginClientContext extends PluginCommandCapabilities { - addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup; - addSurface(id: string, Component: ComponentType): PluginCleanup; - addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup; - addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup; - addCommandCenterItem(contribution: PluginCommandCenterItemContribution): PluginCleanup; - addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup; - addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup; - addAttachmentSource(contribution: PluginAttachmentSourceContribution): PluginCleanup; - addTheme(contribution: PluginThemeContribution): PluginCleanup; - addTimelineTransformer( - contribution: PluginTimelineTransformerContribution, - ): PluginCleanup; - addTimelineRenderer( - contribution: PluginTimelineRendererContribution, - ): PluginCleanup; - openPanel(id: string, options: PluginClientOpenPanelOptions): void; -} - -export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup; - -export type PluginWorkspacePanelContribution = - | (PluginWorkspacePanelBase & { - context: "workspace"; - Component: ComponentType; - }) - | (PluginWorkspacePanelBase & { - context: "agent"; - Component: ComponentType; - }); - -export interface PluginSettingsScreenContribution { - id: string; - title: string; - icon: string; - Component: ComponentType; -} - -export interface PluginSurfaceContribution { - id: string; - Component: ComponentType; -} - -export interface PluginSidebarContribution { - id: string; - title: string; - icon: string; - surface: string; -} - export interface PluginThemeColors { background: string; foreground: string; @@ -219,121 +93,4 @@ export interface PluginTimelineTransformResult { items: PluginTimelineItem[]; } -export type PluginTimelineTransformerContribution< - ItemType extends AgentTimelineItem["type"] = AgentTimelineItem["type"], -> = ItemType extends AgentTimelineItem["type"] - ? { - id: string; - query: { - itemType: ItemType; - }; - transform(input: { - item: Extract; - phase: "streaming" | "complete"; - }): PluginTimelineTransformResult | undefined; - } - : never; - -export interface PluginTimelineItemProps extends PluginHostProps { - agentId: string; - item: { - type: "plugin"; - kind: string; - version: number; - data: Data; - }; - timestamp: Date; -} - -export interface PluginTimelineRendererContribution { - kind: string; - version: number; - schema: Schema; - Component: ComponentType>>; -} - -export interface PluginCommandCapabilities { - paseo: PaseoApi; - rpc( - contract: PluginRpcContract, - input: ZodInput, - ): Promise>; - openSurface(id: string): void; - openSettings(id: string): void; -} - -export interface PluginGlobalCommandContext extends PluginCommandCapabilities { - context: "global"; -} - -export interface PluginWorkspaceCommandContext extends PluginCommandCapabilities { - context: "workspace"; - workspace: PluginWorkspaceSnapshot; - openPanel(id: string, options?: PluginOpenPanelOptions): void; -} - -export interface PluginAgentCommandContext extends PluginCommandCapabilities { - context: "agent"; - workspace: PluginWorkspaceSnapshot; - agent: PluginAgentSnapshot; - openPanel(id: string, options?: PluginOpenPanelOptions): void; -} - -interface PluginCommandCenterItemBase { - id: string; - title: string; - icon: string; - keywords?: readonly string[]; -} - -export type PluginCommandCenterItemContribution = - | (PluginCommandCenterItemBase & { - context: "global"; - onSelect(context: PluginGlobalCommandContext): void | Promise; - }) - | (PluginCommandCenterItemBase & { - context: "workspace"; - onSelect(context: PluginWorkspaceCommandContext): void | Promise; - }) - | (PluginCommandCenterItemBase & { - context: "agent"; - onSelect(context: PluginAgentCommandContext): void | Promise; - }); - -interface PluginClientSlashCommandBase { - name: string; - description: string; - argumentHint: string; -} - -export type PluginClientSlashCommandContribution = - | (PluginClientSlashCommandBase & { - context: "workspace"; - onSubmit(context: PluginWorkspaceCommandContext & { args: string }): void | Promise; - }) - | (PluginClientSlashCommandBase & { - context: "agent"; - onSubmit(context: PluginAgentCommandContext & { args: string }): void | Promise; - }); - -export interface PluginHandlerContext { - paseo: PaseoApi; -} - -export interface PluginServerContext extends PluginLifecycleRegistration { - registerSettings( - definition: import("./settings.js").SettingsDefinition, - ): void; - handle( - contract: PluginRpcContract, - handler: ( - input: ZodOutput, - context: PluginHandlerContext, - ) => ZodInput | Promise>, - ): void; - registerProvider(provider: ProviderRegistration): void; -} - export type PluginCleanup = () => void | Promise; - -export type PluginServerContribution = (server: PluginServerContext) => PluginCleanup; diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 272524de39..48deede303 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -1,79 +1,22 @@ -import type { SettingsDefinition, SettingsState } from "./settings.js"; -import type { ZodType } from "zod"; -export { defineSettings, type SettingsDefinition, type SettingsState } from "./settings.js"; -export declare function useSettings( - definition: SettingsDefinition, -): SettingsState; - -import type { ComponentType } from "react"; -import type { PluginAttachmentSourceContribution, PluginIconProps } from "./contracts.js"; - +// Shared SDK entry. Keep runtime-specific imports and re-exports on /client or /server. +export type { + PluginTheme, + PluginWorkspaceSnapshot, + PluginAgentSnapshot, + PluginThemeColors, + PluginThemeContribution, + PluginAttachmentSourceContribution, + PluginTimelineData, + PluginTimelineItem, + PluginTimelineTransformResult, + PluginCleanup, +} from "./contracts.js"; +export { defineSettings, settingsRpc, type SettingsDefinition } from "./settings.js"; export { + defineAttachmentSource, PluginAttachmentItemSchema, PluginAttachmentSearchPayloadSchema, type PluginAttachmentItem, type PluginAttachmentSearchPayload, } from "./attachments.js"; export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js"; - -export function defineAttachmentSource( - definition: Definition, -): Definition { - return definition; -} - -export declare const Icon: ComponentType; -export type { - PluginAttachmentSourceContribution, - PluginAgentCommandContext, - PluginAgentPanelProps, - PluginAgentSnapshot, - PluginCleanup, - PluginCommandCapabilities, - PluginCommandCenterItemContribution, - PluginClientContext, - PluginClientContribution, - PluginClientSlashCommandContribution, - PluginClientOpenPanelOptions, - PluginComposerPillContribution, - PluginComposerPillProps, - PluginServerContribution, - PluginServerContext, - PluginGlobalCommandContext, - PluginHandlerContext, - PluginHostProps, - PluginOpenPanelOptions, - PluginIconProps, - PluginPanelLocation, - PluginTheme, - PluginSidebarContribution, - PluginSurfaceContribution, - PluginSettingsScreenContribution, - PluginSurfaceProps, - PluginThemeColors, - PluginThemeContribution, - PluginTimelineData, - PluginTimelineItem, - PluginTimelineItemProps, - PluginTimelineRendererContribution, - PluginTimelineTransformerContribution, - PluginTimelineTransformResult, - PluginWorkspaceCommandContext, - PluginWorkspacePanelContribution, - PluginWorkspacePanelProps, - PluginWorkspaceSnapshot, -} from "./contracts.js"; -export { usePaseo } from "./paseo-context.js"; -export { useAgent, useWorkspace } from "./client-state.js"; -export { useRpc } from "./rpc-context.js"; - -export type { - PluginBeforeRequests, - PluginHookAgent, - PluginHookContext, - PluginHookWorkspace, - PluginLifecycleEvents, - PluginLifecycleRegistration, - PluginSessionOpenRequest, - PluginTurnOutcome, -} from "./lifecycle.js"; diff --git a/packages/plugin/src/migration-doc.test.ts b/packages/plugin/src/migration-doc.test.ts index 3673bf6616..e6b2b512b8 100644 --- a/packages/plugin/src/migration-doc.test.ts +++ b/packages/plugin/src/migration-doc.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; describe("plugin runtime-entry migration guide", () => { it("maps every client registration method", async () => { - const contracts = await readFile(new URL("./contracts.ts", import.meta.url), "utf8"); + const contracts = await readFile(new URL("./client/contracts.ts", import.meta.url), "utf8"); const context = /export interface PluginClientContext[^{]*{([\s\S]*?)\n}/.exec(contracts)?.[1]; if (!context) throw new Error("PluginClientContext was not found"); const methods = [...context.matchAll(/^\s+(add[A-Z]\w*)\(/gm)].map((match) => match[1]); diff --git a/packages/plugin/src/server.test.ts b/packages/plugin/src/server.test.ts deleted file mode 100644 index 5117249ae8..0000000000 --- a/packages/plugin/src/server.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -const srcDir = path.dirname(fileURLToPath(import.meta.url)); -const forbiddenSpecifiers = new Set([ - "react", - "react/jsx-runtime", - "react-native", - "use-sync-external-store", - "use-sync-external-store/shim/with-selector", -]); - -function localFile(fromFile: string, specifier: string): string { - const withoutJs = specifier.replace(/\.js$/, ""); - for (const candidate of [`${withoutJs}.ts`, `${withoutJs}.tsx`, withoutJs]) { - const resolved = path.resolve(path.dirname(fromFile), candidate); - if (existsSync(resolved)) return resolved; - } - throw new Error(`Could not resolve ${specifier} from ${fromFile}`); -} - -function valueImportSpecifiers(source: string): string[] { - const specifiers: string[] = []; - const pattern = /(?:^|\n)(?:export|import)\s+(?!type\b)(?:[\s\S]*?)from\s+["']([^"']+)["']/g; - for (const match of source.matchAll(pattern)) { - if (match[1]) specifiers.push(match[1]); - } - return specifiers; -} - -function collectBareSpecifiers(entry: string): string[] { - const seen = new Set(); - const bare: string[] = []; - const queue = [entry]; - while (queue.length > 0) { - const file = queue.pop(); - if (!file || seen.has(file)) continue; - seen.add(file); - for (const specifier of valueImportSpecifiers(readFileSync(file, "utf8"))) { - if (specifier.startsWith(".")) { - queue.push(localFile(file, specifier)); - continue; - } - bare.push(specifier); - } - } - return bare; -} - -describe("@getpaseo/plugin/server", () => { - it("does not load react or the client hook graph", () => { - const specifiers = collectBareSpecifiers(path.join(srcDir, "server.ts")); - expect(specifiers.filter((specifier) => forbiddenSpecifiers.has(specifier))).toEqual([]); - }); -}); diff --git a/packages/plugin/src/acp-internal/connection.ts b/packages/plugin/src/server/acp-internal/connection.ts similarity index 100% rename from packages/plugin/src/acp-internal/connection.ts rename to packages/plugin/src/server/acp-internal/connection.ts diff --git a/packages/plugin/src/acp.test.ts b/packages/plugin/src/server/acp.test.ts similarity index 100% rename from packages/plugin/src/acp.test.ts rename to packages/plugin/src/server/acp.test.ts diff --git a/packages/plugin/src/acp.ts b/packages/plugin/src/server/acp.ts similarity index 100% rename from packages/plugin/src/acp.ts rename to packages/plugin/src/server/acp.ts diff --git a/packages/plugin/src/server/contracts.ts b/packages/plugin/src/server/contracts.ts new file mode 100644 index 0000000000..a296c28b1e --- /dev/null +++ b/packages/plugin/src/server/contracts.ts @@ -0,0 +1,26 @@ +import type { PaseoApi } from "@getpaseo/client"; +import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; +import type { PluginRpcContract } from "../rpc.js"; +import type { PluginCleanup } from "../contracts.js"; +import type { ProviderRegistration } from "./provider.js"; +import type { PluginLifecycleRegistration } from "./lifecycle.js"; + +export interface PluginHandlerContext { + paseo: PaseoApi; +} + +export interface PluginServerContext extends PluginLifecycleRegistration { + registerSettings( + definition: import("../settings.js").SettingsDefinition, + ): void; + handle( + contract: PluginRpcContract, + handler: ( + input: ZodOutput, + context: PluginHandlerContext, + ) => ZodInput | Promise>, + ): void; + registerProvider(provider: ProviderRegistration): void; +} + +export type PluginServerContribution = (server: PluginServerContext) => PluginCleanup; diff --git a/packages/plugin/src/server.ts b/packages/plugin/src/server/index.ts similarity index 66% rename from packages/plugin/src/server.ts rename to packages/plugin/src/server/index.ts index 69c8cc6c4c..c673f25303 100644 --- a/packages/plugin/src/server.ts +++ b/packages/plugin/src/server/index.ts @@ -1,12 +1,15 @@ -export type { PluginHandlerContext } from "./contracts.js"; - export type { - PluginBeforeRequests, - PluginHookAgent, + PluginHandlerContext, + PluginServerContext, + PluginServerContribution, +} from "./contracts.js"; +export type { PluginHookContext, PluginHookWorkspace, - PluginLifecycleEvents, - PluginLifecycleRegistration, + PluginHookAgent, PluginSessionOpenRequest, PluginTurnOutcome, + PluginLifecycleEvents, + PluginBeforeRequests, + PluginLifecycleRegistration, } from "./lifecycle.js"; diff --git a/packages/plugin/src/lifecycle.ts b/packages/plugin/src/server/lifecycle.ts similarity index 100% rename from packages/plugin/src/lifecycle.ts rename to packages/plugin/src/server/lifecycle.ts diff --git a/packages/plugin/src/provider.ts b/packages/plugin/src/server/provider.ts similarity index 100% rename from packages/plugin/src/provider.ts rename to packages/plugin/src/server/provider.ts diff --git a/packages/plugin/src/settings.ts b/packages/plugin/src/settings.ts index dca51d8245..30e890b3ef 100644 --- a/packages/plugin/src/settings.ts +++ b/packages/plugin/src/settings.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { output, ZodType } from "zod"; +import type { ZodType } from "zod"; import { defineRpc } from "./rpc.js"; export interface SettingsDefinition { @@ -49,17 +49,3 @@ export function settingsRpc(id: string) { }), }; } - -export type SettingsState = ( - | { status: "loading" } - | { status: "error"; error: string } - | { status: "invalid"; error: string; revision: string } - | { status: "ready"; values: output; revision: string } -) & { - saving: boolean; - saveError: string | null; - /** Save an entire document against the revision currently displayed. Never throws. */ - save(values: output, revision: string): Promise; - reset(): Promise; - reload(): Promise; -}; diff --git a/packages/server/package.json b/packages/server/package.json index 40d236670e..5db204d84a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -101,6 +101,7 @@ "sherpa-onnx-node": "1.12.28", "strip-ansi": "^7.1.2", "tree-kill": "^1.2.2", + "typescript": "^5.9.3", "uuid": "^9.0.1", "which": "^5.0.0", "ws": "^8.14.2", @@ -116,7 +117,6 @@ "@vitest/ui": "^4.1.6", "cross-env": "^10.1.0", "tsx": "^4.6.0", - "typescript": "^5.2.2", "vitest": "^4.1.6" }, "overrides": { diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 4c8abd2357..75046396be 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -3,7 +3,7 @@ import type { ProviderEvent, ProviderInput, ProviderRegistration, -} from "@getpaseo/plugin/provider"; +} from "@getpaseo/plugin/server/provider"; import { describe, expect, test } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { AgentStreamEvent } from "./agent-sdk-types.js"; diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 7b7df64a22..b5f5ff7b92 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -21,7 +21,7 @@ import { type ProviderSessionConfig, type ProviderContent, type ProviderTimelineItem, -} from "@getpaseo/plugin/provider"; +} from "@getpaseo/plugin/server/provider"; import type { AgentCapabilityFlags, AgentClient, diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index b1e50082da..7ca0510f51 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -2,7 +2,7 @@ import pino from "pino"; import { homedir } from "node:os"; import { resolve } from "node:path"; import { describe, expect, test, vi } from "vitest"; -import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider"; +import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 8eb9f20c8a..abf88d4488 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -48,7 +48,7 @@ import { type AgentConfigurationValidationInput, validateAgentConfigurationAgainstProvider, } from "./agent-configuration-validator.js"; -import type { ProviderRegistration } from "@getpaseo/plugin/provider"; +import type { ProviderRegistration } from "@getpaseo/plugin/server/provider"; import { PluginAgentClientRegistry } from "./plugin-provider.js"; const DEFAULT_REFRESH_TIMEOUT_MS = 120_000; diff --git a/packages/server/src/server/plugins/compiler-imports.ts b/packages/server/src/server/plugins/compiler-imports.ts new file mode 100644 index 0000000000..04d5549d50 --- /dev/null +++ b/packages/server/src/server/plugins/compiler-imports.ts @@ -0,0 +1,126 @@ +import { readFileSync, realpathSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import type { ImportKind } from "esbuild"; +import type { Node, NamedImportBindings, NamedExportBindings } from "typescript"; + +const nodeRequire = createRequire(import.meta.url); + +export type PluginImportKind = ImportKind | "type-reference"; + +interface ModuleImport { + specifier: string; + kind: PluginImportKind; + typeOnly: boolean; +} + +// Keep the TypeScript compiler off daemon startup. esbuild erases these edges, so +// ownership validation needs the original syntax and declaration-aware resolution. +export function createPluginImportReader(directory: string) { + const ts = nodeRequire("typescript") as typeof import("typescript"); + const configFile = ts.findConfigFile(directory, ts.sys.fileExists); + const config = configFile + ? ts.getParsedCommandLineOfConfigFile( + configFile, + {}, + { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic: (diagnostic) => { + throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); + }, + }, + ) + : undefined; + const options = { + ...config?.options, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + allowJs: true, + }; + const cache = ts.createModuleResolutionCache(directory, (file) => file, options); + return { + resolve(specifier: string, importer: string, kind: PluginImportKind): string | undefined { + const resolved = + kind === "type-reference" + ? ts.resolveTypeReferenceDirective(specifier, importer, options, ts.sys) + .resolvedTypeReferenceDirective?.resolvedFileName + : ts.resolveModuleName( + specifier, + importer, + options, + ts.sys, + cache, + undefined, + kind === "require-call" ? ts.ModuleKind.CommonJS : ts.ModuleKind.ESNext, + ).resolvedModule?.resolvedFileName; + return resolved ? realpathSync.native(resolved) : undefined; + }, + read(file: string): ModuleImport[] { + const source = ts.createSourceFile( + file, + readFileSync(file, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + const imports: ModuleImport[] = []; + function add( + node: Node | undefined, + typeOnly: boolean, + kind: ImportKind = "import-statement", + ) { + if (node && ts.isStringLiteralLike(node)) { + imports.push({ + specifier: node.text, + kind, + typeOnly: source.isDeclarationFile || typeOnly, + }); + } + } + function onlyTypeBindings( + bindings: NamedImportBindings | NamedExportBindings | undefined, + ): boolean { + return ( + !!bindings && + (ts.isNamedImports(bindings) || ts.isNamedExports(bindings)) && + bindings.elements.length > 0 && + bindings.elements.every((element) => element.isTypeOnly) + ); + } + function visit(node: Node): void { + if (ts.isImportDeclaration(node)) { + const clause = node.importClause; + const typeOnly = + !!clause?.isTypeOnly || (!clause?.name && onlyTypeBindings(clause?.namedBindings)); + add(node.moduleSpecifier, typeOnly); + } else if (ts.isExportDeclaration(node)) { + add(node.moduleSpecifier, node.isTypeOnly || onlyTypeBindings(node.exportClause)); + } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) { + add(node.argument.literal, true); + } else if ( + ts.isImportEqualsDeclaration(node) && + ts.isExternalModuleReference(node.moduleReference) + ) { + add(node.moduleReference.expression, node.isTypeOnly, "require-call"); + } else if (ts.isCallExpression(node)) { + if (node.expression.kind === ts.SyntaxKind.ImportKeyword) + add(node.arguments[0], false, "dynamic-import"); + else if (ts.isIdentifier(node.expression) && node.expression.text === "require") + add(node.arguments[0], false, "require-call"); + } + ts.forEachChild(node, visit); + } + visit(source); + for (const reference of source.referencedFiles) { + imports.push({ + specifier: path.resolve(path.dirname(file), reference.fileName), + kind: "import-statement", + typeOnly: true, + }); + } + for (const reference of source.typeReferenceDirectives) { + imports.push({ specifier: reference.fileName, kind: "type-reference", typeOnly: true }); + } + return imports; + }, + }; +} diff --git a/packages/server/src/server/plugins/compiler.test.ts b/packages/server/src/server/plugins/compiler.test.ts index a922403aa7..dca7d3e3e2 100644 --- a/packages/server/src/server/plugins/compiler.test.ts +++ b/packages/server/src/server/plugins/compiler.test.ts @@ -1,3 +1,4 @@ +import { realpathSync } from "node:fs"; import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -80,6 +81,8 @@ async function createSplitPlugin(): Promise<{ client: string; server: string; }> { + // Keep the platform's original spelling (including Windows short names) in symlink + // targets. Only diagnostic expectations use canonical paths. const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-")); temporaryDirectories.push(directory); await Promise.all([ @@ -126,7 +129,321 @@ export default function contribute(server) { return { directory, client, server }; } +async function createRootAlias(directory: string): Promise { + const aliases = await mkdtemp(path.join(tmpdir(), "paseo-plugin-root-alias-")); + temporaryDirectories.push(aliases); + const rootAlias = path.join(aliases, "plugin"); + await symlink(directory, rootAlias, process.platform === "win32" ? "junction" : "dir"); + return rootAlias; +} + describe("plugin runtime entries", () => { + it.each([ + "react", + "react/jsx-runtime", + "react-native", + "@getpaseo/plugin/client", + "@getpaseo/plugin/client/ui", + ])("rejects %s from server code", async (specifier) => { + const entries = await createSplitPlugin(); + await writeFile( + entries.server, + `import * as value from "${specifier}"; export default function contribute() { return value; }`, + ); + await expect(compilePlugin(entries)).rejects.toThrow("client-only module"); + }); + + it.each([ + "@getpaseo/plugin/server", + "@getpaseo/plugin/server/provider", + "@getpaseo/plugin/server/acp", + ])("rejects %s from client code", async (specifier) => { + const entries = await createSplitPlugin(); + await writeFile( + entries.client, + `import * as value from "${specifier}"; export default function contribute() { return value; }`, + ); + await expect(compilePlugin(entries)).rejects.toThrow("server-only module"); + }); + + it.each([ + "react", + "node:fs", + "fs", + "@getpaseo/plugin/client", + "@getpaseo/plugin/server", + "../client/surface", + "../server/handler", + ])("rejects %s from shared code", async (specifier) => { + const entries = await createSplitPlugin(); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + `import * as value from "${specifier}"; export const clientLabel = value; export const serverLabel = value;`, + ); + await expect(compilePlugin(entries)).rejects.toThrow("plugin shared"); + }); + + it.each([ + { target: "client", dependency: "react" }, + { target: "server", dependency: "node:fs" }, + { target: "server", dependency: "@getpaseo/plugin/server/provider" }, + ] as const)( + "rejects a shared dependency reaching $dependency in the $target bundle", + async ({ target, dependency }) => { + const entries = await createSplitPlugin(); + const dependencyDirectory = path.join(entries.directory, "node_modules/impure"); + await mkdir(dependencyDirectory, { recursive: true }); + await writeFile( + path.join(dependencyDirectory, "package.json"), + JSON.stringify({ name: "impure", main: "index.js" }), + ); + await writeFile(path.join(dependencyDirectory, "index.js"), `export * from "${dependency}";`); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + `import * as value from "impure"; export const clientLabel = value; export const serverLabel = value;`, + ); + await expect( + compilePlugin({ + client: target === "client" ? entries.client : null, + server: target === "server" ? entries.server : null, + }), + ).rejects.toThrow("plugin shared"); + }, + ); + + it.each([ + 'import type { PluginClientContext } from "@getpaseo/plugin/client"; export type Context = PluginClientContext;', + 'export type { PluginClientContext } from "@getpaseo/plugin/client";', + 'export type Context = import("@getpaseo/plugin/client").PluginClientContext;', + 'import { type PluginClientContext } from "@getpaseo/plugin/client"; export type Context = PluginClientContext;', + 'import type { ComponentType } from "react"; export type Component = ComponentType;', + ])("rejects runtime-owned types in shared code: %s", async (typeSource) => { + const entries = await createSplitPlugin(); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + typeSource + '\nexport const clientLabel = "client"; export const serverLabel = "server";', + ); + await expect(compilePlugin(entries)).rejects.toThrow("plugin shared"); + }); + + it.each([{ types: "./index.d.ts" }, { exports: { ".": { types: "./index.d.ts" } } }])( + "accepts declaration-only dependencies: %j", + async (manifest) => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules/neutral-types"); + await mkdir(dependency, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "neutral-types", ...manifest }), + ); + await writeFile( + path.join(dependency, "index.d.ts"), + "export type JsonValue = string | number | boolean | null;", + ); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'import type { JsonValue } from "neutral-types"; export const clientLabel: JsonValue = "client"; export const serverLabel: JsonValue = "server";', + ); + const result = await compilePlugin(entries); + expect(result.clientBundle).toContain('"client"'); + expect(result.serverBundle).toContain('"server"'); + expect(result.clientBundle).not.toContain("neutral-types"); + }, + ); + + it("accepts a declaration import used only as a type without import type", async () => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules/neutral-types"); + await mkdir(dependency, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "neutral-types", types: "./index.d.ts" }), + ); + await writeFile(path.join(dependency, "index.d.ts"), "export type Value = string;"); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'import { Value } from "neutral-types"; export const clientLabel: Value = "client"; export const serverLabel: Value = "server";', + ); + await expect(compilePlugin(entries)).resolves.toMatchObject({ + clientBundle: expect.any(String), + serverBundle: expect.any(String), + }); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'import { Value } from "neutral-types"; export const clientLabel = Value; export const serverLabel = Value;', + ); + await expect(compilePlugin(entries)).rejects.toThrow('Could not resolve "neutral-types"'); + }); + + it("preserves guarded optional requires in server dependencies", async () => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules/optional-helper"); + await mkdir(dependency, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "optional-helper", main: "index.js" }), + ); + await writeFile( + path.join(dependency, "index.js"), + 'let value = "fallback"; try { value = require("missing-accelerator"); } catch {} module.exports = value;', + ); + await writeFile(entries.server, 'import value from "optional-helper"; export default value;'); + await expect(compilePlugin({ client: null, server: entries.server })).resolves.toMatchObject({ + serverBundle: expect.stringContaining("fallback"), + }); + await writeFile( + path.join(dependency, "index.js"), + 'module.exports = require("missing-accelerator");', + ); + await expect(compilePlugin({ client: null, server: entries.server })).rejects.toThrow( + 'Could not resolve "missing-accelerator"', + ); + }); + + it.each(["path", "types"])( + "checks declaration reference %s directives", + async (referenceKind) => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules/typed-helper"); + const referencedDirectory = + referenceKind === "path" + ? dependency + : path.join(entries.directory, "node_modules/@types/referenced-helper"); + await mkdir(dependency, { recursive: true }); + await mkdir(referencedDirectory, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "typed-helper", types: "index.d.ts" }), + ); + await writeFile( + path.join(dependency, "index.d.ts"), + `/// +export type Value = string;`, + ); + const referencedFile = path.join( + referencedDirectory, + referenceKind === "path" ? "context.d.ts" : "index.d.ts", + ); + await writeFile( + referencedFile, + 'export type { PluginClientContext } from "@getpaseo/plugin/client";', + ); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'import type { Value } from "typed-helper"; export const clientLabel: Value = "client"; export const serverLabel: Value = "server";', + ); + await expect(compilePlugin(entries)).rejects.toThrow("plugin shared"); + await writeFile(referencedFile, "export type Neutral = string;"); + await expect(compilePlugin(entries)).resolves.toMatchObject({ + clientBundle: expect.any(String), + serverBundle: expect.any(String), + }); + }, + ); + + it("checks the physical owner of type-only symlinks", async () => { + const entries = await createSplitPlugin(); + await writeFile( + path.join(entries.directory, "server/context.ts"), + "export type Value = string;", + ); + await symlink( + path.join(entries.directory, "server/context.ts"), + path.join(entries.directory, "shared/context.ts"), + ); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'import type { Value } from "./context"; export const clientLabel: Value = "client"; export const serverLabel: Value = "server";', + ); + await expect(compilePlugin(entries)).rejects.toThrow("server-only module"); + }); + + it("resolves type-only path aliases from the plugin tsconfig", async () => { + const entries = await createSplitPlugin(); + await writeFile( + path.join(entries.directory, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "shared-types": ["shared/types.ts"] } }, + }), + ); + await writeFile(path.join(entries.directory, "shared/types.ts"), "export type Value = string;"); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'import type { Value } from "shared-types"; export const clientLabel: Value = "client"; export const serverLabel: Value = "server";', + ); + await expect(compilePlugin(entries)).resolves.toMatchObject({ + clientBundle: expect.any(String), + serverBundle: expect.any(String), + }); + await writeFile( + path.join(entries.directory, "shared/types.ts"), + 'export type { PluginClientContext as Value } from "@getpaseo/plugin/client";', + ); + await expect(compilePlugin(entries)).rejects.toThrow("plugin shared"); + }); + + it.each([ + { specifier: "@getpaseo/plugin/client", importKind: "import type" }, + { specifier: "@getpaseo/plugin/server", importKind: "import type" }, + { specifier: "@getpaseo/plugin/client", importKind: "import" }, + { specifier: "@getpaseo/plugin/server", importKind: "import" }, + ])( + "rejects transitive declaration dependencies on $specifier through $importKind", + async ({ specifier, importKind }) => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules/typed-helper"); + await mkdir(dependency, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "typed-helper", main: "index.js", types: "index.d.ts" }), + ); + await writeFile(path.join(dependency, "index.js"), "export {};"); + await writeFile( + path.join(dependency, "index.d.ts"), + 'export type { Context } from "./context";', + ); + const context = specifier.endsWith("client") ? "PluginClientContext" : "PluginServerContext"; + await writeFile( + path.join(dependency, "context.d.ts"), + `export type { ${context} as Context } from "${specifier}";`, + ); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + `${importKind} { Context } from "typed-helper"; export type Value = Context; export const clientLabel = "client"; export const serverLabel = "server";`, + ); + await expect(compilePlugin(entries)).rejects.toThrow("plugin shared"); + }, + ); + + it("checks files reached only through type imports", async () => { + const entries = await createSplitPlugin(); + await writeFile( + path.join(entries.directory, "shared/types.ts"), + 'export type { PluginClientContext } from "@getpaseo/plugin/client";', + ); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'export type { PluginClientContext } from "./types"; export const clientLabel = "client"; export const serverLabel = "server";', + ); + await expect(compilePlugin(entries)).rejects.toThrow("plugin shared"); + }); + + it.each([ + "@paseo/plugin", + "@getpaseo/plugin/react-native", + "@getpaseo/plugin/ui", + "@getpaseo/plugin/provider", + "@getpaseo/plugin/acp", + "@getpaseo/plugin/host", + ])("rejects retired entry %s", async (specifier) => { + const entries = await createSplitPlugin(); + await writeFile( + entries.server, + `import * as sdk from "${specifier}"; export default function contribute() { return sdk; }`, + ); + await expect(compilePlugin(entries)).rejects.toThrow(specifier); + }); + it("builds each runtime from its own entry and shares neutral modules", async () => { const entries = await createSplitPlugin(); @@ -205,7 +522,7 @@ export default function contribute() { void Surface; return () => undefined; }`, export default function contribute() { void value; return () => undefined; }`, ); await expect(compilePlugin(entries)).rejects.toThrow( - `Plugin modules belong in client/, server/, or shared/: ${path.join(entries.directory, "helper")}`, + `Plugin modules belong in client/, server/, or shared/: ${path.join(realpathSync.native(entries.directory), "helper")}`, ); }); @@ -225,7 +542,7 @@ export default function contribute() { void secret; return () => undefined; }`, ]); await expect(compilePlugin({ client: null, server })).rejects.toThrow( - `Plugin modules belong in client/, server/, or shared/: ${path.join(parent, "secret")}`, + `Plugin modules belong in client/, server/, or shared/: ${path.join(realpathSync.native(parent), "secret")}`, ); }); @@ -242,7 +559,7 @@ export default function contribute() { void secret; return () => undefined; }`, ); await expect(compilePlugin(entries)).rejects.toThrow( - `Plugin modules belong in client/, server/, or shared/: ${outside}`, + `Plugin modules belong in client/, server/, or shared/: ${realpathSync.native(outside)}`, ); }); @@ -263,7 +580,7 @@ export default function contribute() { void secret; return () => undefined; }`, ]); await expect(compilePlugin({ client: null, server })).rejects.toThrow( - `Plugin modules belong in client/, server/, or shared/: ${path.join(parent, "node_modules", "secret")}`, + `Plugin modules belong in client/, server/, or shared/: ${path.join(realpathSync.native(parent), "node_modules", "secret")}`, ); }); @@ -363,10 +680,69 @@ export default function contribute() { void handler; return () => undefined; }`, ); }); - it("classifies symlinks by their canonical target", async () => { + it.each(["file", "directory"])( + "preserves authored ownership through root and %s aliases", + async (aliasKind) => { + const entries = await createSplitPlugin(); + const rootAlias = await createRootAlias(entries.directory); + const sharedValue = path.join(entries.directory, "shared/value.ts"); + await writeFile(sharedValue, 'export const value = "shared";'); + if (aliasKind === "file") { + await symlink(sharedValue, path.join(entries.directory, "server/alias.ts")); + } else { + await rm(path.join(entries.directory, "server"), { recursive: true }); + await symlink( + path.join(entries.directory, "shared"), + path.join(entries.directory, "server"), + process.platform === "win32" ? "junction" : "dir", + ); + } + await writeFile( + entries.client, + `export { value } from ${JSON.stringify(path.join(rootAlias, "server", aliasKind === "file" ? "alias.ts" : "value.ts"))};`, + ); + await expect(compilePlugin({ client: entries.client, server: null })).rejects.toThrow( + "server-only module", + ); + await writeFile( + entries.client, + `export { value } from ${JSON.stringify(path.join(rootAlias, "shared/value.ts"))};`, + ); + await expect(compilePlugin({ client: entries.client, server: null })).resolves.toMatchObject({ + clientBundle: expect.stringContaining("shared"), + }); + }, + ); + + it("rejects generated React imports in dependencies reached through shared symlinks", async () => { const entries = await createSplitPlugin(); + const rootAlias = await createRootAlias(entries.directory); + const dependency = path.join(entries.directory, "node_modules/decoration"); + await mkdir(dependency, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "decoration", main: "index.tsx" }), + ); + await writeFile(path.join(dependency, "index.tsx"), "export const decoration = <>;"); + await writeFile( + path.join(entries.directory, "shared/decoration.ts"), + 'export { decoration } from "decoration";', + ); + await symlink( + path.join(rootAlias, "shared/decoration.ts"), + path.join(entries.directory, "client/decoration.ts"), + ); + await writeFile(entries.client, 'export { decoration } from "./client/decoration";'); + await expect(compilePlugin({ client: entries.client, server: null })).rejects.toThrow( + "plugin shared", + ); + }); + + it.each([false, true])("classifies symlink targets through a root alias: %s", async (aliased) => { + const entries = await createSplitPlugin(); + const targetRoot = aliased ? await createRootAlias(entries.directory) : entries.directory; const link = path.join(entries.directory, "client", "handler.ts"); - await symlink(path.join(entries.directory, "server", "handler.ts"), link); + await symlink(path.join(targetRoot, "server", "handler.ts"), link); await writeFile( entries.client, `import { handler } from "./client/handler"; @@ -432,33 +808,37 @@ export default function contribute() { void value; return () => undefined; }`, expect(serverBundle).toContain("linked dependency"); }); - it("does not treat the plugin package manifest as a linked dependency root", async () => { - const entries = await createSplitPlugin(); - const dependency = path.join(entries.directory, "node_modules", "fixture-dependency"); - const secret = path.join(entries.directory, "secret.ts"); - await mkdir(dependency, { recursive: true }); - await Promise.all([ - writeFile( - path.join(entries.directory, "package.json"), - JSON.stringify({ name: "fixture-dependency" }), - ), - writeFile( - path.join(dependency, "package.json"), - JSON.stringify({ name: "fixture-dependency", main: "index.js" }), - ), - writeFile(secret, `export const secret = "plugin root";`), - symlink(secret, path.join(dependency, "index.js")), - writeFile( - entries.client, - `import { secret } from "fixture-dependency"; + it.each([false, true])( + "rejects plugin manifests as linked roots through an alias: %s", + async (aliased) => { + const entries = await createSplitPlugin(); + const targetRoot = aliased ? await createRootAlias(entries.directory) : entries.directory; + const dependency = path.join(entries.directory, "node_modules", "fixture-dependency"); + const secret = path.join(entries.directory, "secret.ts"); + await mkdir(dependency, { recursive: true }); + await Promise.all([ + writeFile( + path.join(entries.directory, "package.json"), + JSON.stringify({ name: "fixture-dependency" }), + ), + writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "fixture-dependency", main: "index.js" }), + ), + writeFile(secret, `export const secret = "plugin root";`), + symlink(path.join(targetRoot, "secret.ts"), path.join(dependency, "index.js")), + writeFile( + entries.client, + `import { secret } from "fixture-dependency"; export default function contribute() { void secret; return () => undefined; }`, - ), - ]); + ), + ]); - await expect(compilePlugin(entries)).rejects.toThrow( - `Plugin modules belong in client/, server/, or shared/: ${secret}`, - ); - }); + await expect(compilePlugin(entries)).rejects.toThrow( + `Plugin modules belong in client/, server/, or shared/: ${realpathSync.native(secret)}`, + ); + }, + ); it("does not let remembered linked roots hide plugin-local runtime boundaries", async () => { const entries = await createSplitPlugin(); diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts index 981ccc1229..ca5ae9f30c 100644 --- a/packages/server/src/server/plugins/compiler.ts +++ b/packages/server/src/server/plugins/compiler.ts @@ -1,9 +1,11 @@ -import { existsSync, readFileSync } from "node:fs"; -import { createRequire } from "node:module"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { createRequire, isBuiltin } from "node:module"; import path from "node:path"; -import type { OnResolveResult, Plugin } from "esbuild"; +import { createPluginImportReader, type PluginImportKind } from "./compiler-imports.js"; +import type { Metafile, OnResolveResult, Plugin } from "esbuild"; import { - PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS, + isPluginClientOnlySdkSpecifier, + isPluginServerOnlySdkSpecifier, PLUGIN_SDK_SPECIFIERS, } from "./plugin-sdk-specifiers.js"; @@ -100,12 +102,13 @@ function findDependencyRoot( pluginDirectory: string, ): string | null { const expectedName = dependencyName(specifier); + const ambientName = `@types/${expectedName.replace(/^@/, "").replace("/", "__")}`; let directory = path.dirname(resolvedPath); for (;;) { const manifestPath = path.join(directory, "package.json"); if (existsSync(manifestPath)) { const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { name?: unknown }; - if (manifest.name === expectedName) { + if (manifest.name === expectedName || manifest.name === ambientName) { if (containsPath(pluginDirectory, directory) || containsPath(directory, pluginDirectory)) { return null; } @@ -120,7 +123,7 @@ function findDependencyRoot( function moduleBoundaryError( moduleLocation: PluginModuleLocation | null, - target: PluginBuildTarget, + target: PluginBuildTarget | "shared", filePath: string, ): OnResolveResult | null { if (moduleLocation === "invalid") { @@ -144,12 +147,22 @@ function lexicalBoundaryError( specifier: string, resolveDirectory: string, pluginDirectory: string, - target: PluginBuildTarget, + target: PluginBuildTarget | "shared", ): OnResolveResult | null { if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) return null; const lexicalPath = path.resolve(resolveDirectory, specifier); - if (!containsPath(pluginDirectory, lexicalPath)) return null; - return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath); + if (containsPath(pluginDirectory, lexicalPath)) { + return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath); + } + // Normalize only a containing root alias. Resolving the whole import would erase + // an authored server/ or client/ location when the final file is a symlink. + for (let ancestor = path.dirname(lexicalPath); ; ancestor = path.dirname(ancestor)) { + if (existsSync(ancestor) && realpathSync.native(ancestor) === pluginDirectory) { + const ownedPath = path.join(pluginDirectory, path.relative(ancestor, lexicalPath)); + return moduleBoundaryError(directoryTarget(ownedPath, pluginDirectory), target, lexicalPath); + } + if (path.dirname(ancestor) === ancestor) return null; + } } function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: string): Plugin { @@ -158,20 +171,115 @@ function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: return { name: `paseo-plugin-${target}-runtime-boundary`, setup(buildContext) { + const checked = new Set(); + const imports = createPluginImportReader(pluginDirectory); + function resolvedBoundaryError( + resolvedFile: string, + specifier: string, + importer: string, + owner: PluginBuildTarget | "shared", + ) { + const file = realpathSync.native(resolvedFile); + const location = directoryTarget(file, pluginDirectory); + if (location === "invalid") { + if ( + [...linkedDependencyRoots].some( + (root) => containsPath(root, importer) && containsPath(root, file), + ) + ) + return null; + if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) { + const root = findDependencyRoot(file, specifier, pluginDirectory); + if (root) { + linkedDependencyRoots.add(root); + return null; + } + } + } + return moduleBoundaryError(location, owner, file); + } + async function resolveImportFiles( + file: string, + specifier: string, + kind: PluginImportKind, + typeOnly: boolean, + ): Promise> { + const declaration = imports.resolve(specifier, file, kind); + const dependencyFiles = new Set(); + if (declaration && (typeOnly || /\.d\.[cm]?ts$/.test(declaration))) + dependencyFiles.add(declaration); + if (typeOnly && !declaration) { + throw new Error(`Could not resolve type dependency "${specifier}" imported by ${file}`); + } + if (!typeOnly && kind !== "type-reference") { + const resolution = await buildContext.resolve(specifier, { + importer: file, + resolveDir: path.dirname(file), + kind, + }); + // esbuild owns missing-runtime errors: erased imports and guarded optional + // requires are legal. Inspect every dependency that actually resolves. + if (!resolution.errors.length && !resolution.external && resolution.namespace === "file") + dependencyFiles.add(realpathSync.native(resolution.path)); + } + return dependencyFiles; + } + async function checkSourceImports( + sourcePath: string, + inheritedOwner: PluginBuildTarget | "shared", + ): Promise { + const file = realpathSync.native(sourcePath); + const owner = + directoryTarget(file, pluginDirectory) === "shared" ? "shared" : inheritedOwner; + const key = `${owner}:${file}`; + if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null; + checked.add(key); + for (const { specifier, kind, typeOnly } of imports.read(file)) { + const packageSpecifier = + kind === "type-reference" + ? `@types/${specifier.replace(/^@/, "").replace("/", "__")}` + : specifier; + const error = + runtimeSpecifierError(specifier, owner, file) ?? + runtimeSpecifierError(packageSpecifier, owner, file) ?? + lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner); + if (error) return error; + // Host modules have separately enforced SDK boundaries and need no local installation. + if ( + (PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) || + /^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) || + isBuiltin(specifier) || + packageSpecifier === "@types/node" + ) + continue; + const dependencyFiles = await resolveImportFiles(file, specifier, kind, typeOnly); + for (const dependencyFile of dependencyFiles) { + const boundaryError = resolvedBoundaryError(dependencyFile, specifier, file, owner); + if (boundaryError) return boundaryError; + const dependencyError = await checkSourceImports(dependencyFile, owner); + if (dependencyError) return dependencyError; + } + } + return null; + } + buildContext.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, (args) => + checkSourceImports(args.path, target), + ); buildContext.onResolve({ filter: /.*/ }, async (args) => { if (args.kind === "entry-point") return null; if (args.pluginData === boundaryResolution) return null; - const lexicalError = lexicalBoundaryError( - args.path, - args.resolveDir, - pluginDirectory, - target, - ); + const importer = + args.namespace === "file" ? realpathSync.native(args.importer) : args.importer; + const resolveDir = args.namespace === "file" ? path.dirname(importer) : args.resolveDir; + const owner = directoryTarget(importer, pluginDirectory) === "shared" ? "shared" : target; + const specifierError = runtimeSpecifierError(args.path, owner, importer); + if (specifierError) return specifierError; + const lexicalError = lexicalBoundaryError(args.path, resolveDir, pluginDirectory, owner); if (lexicalError) return lexicalError; const resolution = await buildContext.resolve(args.path, { - importer: args.importer, + importer, namespace: args.namespace, - resolveDir: args.resolveDir, + resolveDir, kind: args.kind, pluginData: boundaryResolution, with: args.with, @@ -183,26 +291,7 @@ function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: ) { return null; } - const resolvedPath = resolution.path; - const importedTarget = directoryTarget(resolvedPath, pluginDirectory); - if (importedTarget === "invalid") { - if ( - [...linkedDependencyRoots].some( - (root) => containsPath(root, args.importer) && containsPath(root, resolvedPath), - ) - ) { - return null; - } - if (!args.path.startsWith(".") && !path.isAbsolute(args.path)) { - const dependencyRoot = findDependencyRoot(resolvedPath, args.path, pluginDirectory); - if (dependencyRoot) { - linkedDependencyRoots.add(dependencyRoot); - return null; - } - } - return moduleBoundaryError(importedTarget, target, resolvedPath); - } - return moduleBoundaryError(importedTarget, target, resolvedPath); + return resolvedBoundaryError(resolution.path, args.path, importer, owner); }); }, }; @@ -219,60 +308,86 @@ function makeHermesInteropEager(code: string): string { return code.replaceAll("get: () => from[key]", "value: from[key]"); } -function exactSpecifierFilter(specifiers: readonly string[]): RegExp { - const alternatives = specifiers.map((specifier) => - specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), - ); - return new RegExp(`^(${alternatives.join("|")})$`); -} - -function createUnusedPlatformModulePlugin(): Plugin { - const filter = exactSpecifierFilter([ - "@tanstack/react-query", - "react", - "react/jsx-runtime", - "react-native", - ...PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS, - ]); - return { - name: "paseo-plugin-server-unused-platform-modules", - setup(buildContext) { - buildContext.onResolve({ filter }, (args) => ({ - path: args.path, - namespace: "paseo-unused-platform-module", - sideEffects: false, - })); - buildContext.onLoad({ filter: /.*/, namespace: "paseo-unused-platform-module" }, () => ({ - contents: "module.exports = {};", - loader: "js", - })); - }, - }; -} - -function createClientNodeImportPlugin(): Plugin { - return { - name: "paseo-plugin-client-node-imports", - setup(buildContext) { - buildContext.onResolve({ filter: /^node:/ }, (args) => ({ +function runtimeSpecifierError( + specifier: string, + target: PluginBuildTarget | "shared", + importer: string, +): OnResolveResult | null { + let kind: string | null = null; + if (specifier === "@getpaseo/plugin/client/host") kind = "host-private"; + else if ( + (specifier === "@getpaseo/plugin" || + specifier.startsWith("@getpaseo/plugin/") || + specifier === "@paseo/plugin" || + specifier.startsWith("@paseo/plugin/")) && + !(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) + ) + kind = "Unknown SDK"; + else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node")) + kind = "Node"; + else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only"; + else if ( + target !== "client" && + (isPluginClientOnlySdkSpecifier(specifier) || + /^((?:@types\/)?react(?:-dom|-native)?|use-sync-external-store|@tanstack\/react-query)(\/|$)/.test( + specifier, + )) + ) + kind = "client-only"; + return kind + ? { errors: [ { - text: `Node module cannot be imported into the plugin client bundle: ${args.path} imported by ${args.importer}`, + text: `${kind} module cannot be imported into the plugin ${target} bundle: ${specifier} imported by ${importer}`, }, ], - })); - }, - }; + } + : null; +} + +function checkSharedDependencies(inputs: Metafile["inputs"], pluginDirectory: string): void { + function inputLocation(file: string): PluginModuleLocation | null { + const absolutePath = path.resolve(file); + // Metafile keys must stay unchanged for graph traversal. Only filesystem + // inputs have canonical paths; data URLs and external specifiers do not. + return directoryTarget( + existsSync(absolutePath) ? realpathSync.native(absolutePath) : absolutePath, + pluginDirectory, + ); + } + const pending = Object.keys(inputs).filter((file) => inputLocation(file) === "shared"); + const visited = new Set(); + while (pending.length) { + const file = pending.pop()!; + if (visited.has(file)) continue; + visited.add(file); + for (const dependency of inputs[file]?.imports ?? []) { + const location = dependency.external ? null : inputLocation(dependency.path); + const error = + runtimeSpecifierError(dependency.original ?? dependency.path, "shared", file) ?? + (dependency.external + ? null + : moduleBoundaryError( + location === "invalid" ? null : location, + "shared", + dependency.path, + )); + if (error?.errors?.length) throw new Error(error.errors[0].text); + if (!dependency.external) pending.push(dependency.path); + } + } } async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise { const { build } = loadEsbuild(); - const pluginDirectory = path.dirname(entryPath); + // Use native canonical paths throughout: TypeScript expands Windows short names + // when resolving type references, while the JS realpath implementation retains them. + const pluginDirectory = realpathSync.native(path.dirname(entryPath)); const result = await build({ - entryPoints: [entryPath], + entryPoints: [realpathSync.native(entryPath)], bundle: true, format: "cjs", - jsx: target === "client" ? "automatic" : undefined, + jsx: "automatic", platform: target === "server" ? "node" : "neutral", target: target === "server" ? "node20" : "es2020", // Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro, @@ -289,16 +404,13 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom "zod", ] : [...PLUGIN_SDK_SPECIFIERS, "zod"], - plugins: [ - createRuntimeBoundaryPlugin(target, pluginDirectory), - ...(target === "client" - ? [createClientNodeImportPlugin()] - : [createUnusedPlatformModulePlugin()]), - ], + plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)], + metafile: true, logLevel: "silent", treeShaking: true, write: false, }); + checkSharedDependencies(result.metafile.inputs, pluginDirectory); const output = result.outputFiles[0]?.text; if (!output) throw new Error(`Plugin ${target} compilation produced no output`); return wrapCommonJsBundle(makeHermesInteropEager(output)); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 894b1663c4..5a449763f3 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -2,7 +2,7 @@ import type { PluginLifecycle } from "./lifecycle/index.js"; import path from "node:path"; import { stat, rm } from "node:fs/promises"; import type pino from "pino"; -import type { ProviderRegistration } from "@getpaseo/plugin/provider"; +import type { ProviderRegistration } from "@getpaseo/plugin/server/provider"; import { PluginIdSchema, type PluginLogEntry, diff --git a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts index 18e0ec3891..e2bd3f0566 100644 --- a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts +++ b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts @@ -26,7 +26,8 @@ test("plugin handlers create workspaces and agents through their Paseo API", asy ); await writeFile( path.join(pluginDirectory, "index.server.ts"), - `import { defineRpc, type PluginServerContext } from "@getpaseo/plugin"; + `import { defineRpc } from "@getpaseo/plugin"; +import { type PluginServerContext } from "@getpaseo/plugin/server"; import { z } from "zod"; const create = defineRpc({ diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index 80d925652a..0066fad500 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -3,8 +3,8 @@ import type { ProviderCatalogOptions, ProviderEvent, ProviderInput, -} from "@getpaseo/plugin/provider"; -import { ProviderEventSchema, ProviderInputSchema } from "@getpaseo/plugin/provider"; +} from "@getpaseo/plugin/server/provider"; +import { ProviderEventSchema, ProviderInputSchema } from "@getpaseo/plugin/server/provider"; import { z } from "zod"; export interface PluginProviderMetadata { diff --git a/packages/server/src/server/plugins/plugin-process.test.ts b/packages/server/src/server/plugins/plugin-process.test.ts new file mode 100644 index 0000000000..6a4380d503 --- /dev/null +++ b/packages/server/src/server/plugins/plugin-process.test.ts @@ -0,0 +1,39 @@ +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; +import { expect, it } from "vitest"; + +it("keeps React out of the plugin host's runtime dependency graph", async () => { + await expect( + build({ + entryPoints: [ + fileURLToPath(new URL("./plugin-process.ts", import.meta.url)), + "@getpaseo/plugin", + "@getpaseo/plugin/server", + "@getpaseo/plugin/server/provider", + "@getpaseo/plugin/server/acp", + ], + outdir: "unused", + conditions: ["source"], + bundle: true, + platform: "node", + format: "esm", + // Node evaluates re-exports even when the host only imports one helper. + treeShaking: false, + write: false, + logLevel: "silent", + plugins: [ + { + name: "no-react", + setup(context) { + context.onResolve( + { filter: /^(react|react-dom|react-native|use-sync-external-store)(\/|$)/ }, + ({ path, importer }) => ({ + errors: [{ text: `React dependency ${path} imported by ${importer}` }], + }), + ); + }, + }, + ], + }), + ).resolves.toMatchObject({ errors: [] }); +}); diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 41fe8fcff0..d08bd6f669 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -5,27 +5,20 @@ import { type PluginProcessRequest, } from "./plugin-process-protocol.js"; import { createRequire } from "node:module"; -import { - defineSettings, - type SettingsDefinition, - defineAttachmentSource, - defineRpc, - type PluginRpcContract, -} from "@getpaseo/plugin"; +import * as pluginSharedRuntime from "@getpaseo/plugin"; +import * as pluginProviderRuntime from "@getpaseo/plugin/server/provider"; +import * as pluginAcpRuntime from "@getpaseo/plugin/server/acp"; +import type { SettingsDefinition, PluginRpcContract } from "@getpaseo/plugin"; +import type { PluginHandlerContext } from "@getpaseo/plugin/server"; import { ProviderEventSchema, type ProviderConnection, type ProviderRegistration, -} from "@getpaseo/plugin/provider"; -import type { PluginHandlerContext } from "@getpaseo/plugin/server"; +} from "@getpaseo/plugin/server/provider"; import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { createPluginDaemonTransportFactory } from "./daemon-transport.js"; -import { - isPluginClientOnlySdkSpecifier, - isPluginSdkSpecifier, - isPluginServerTypesSdkSpecifier, -} from "./plugin-sdk-specifiers.js"; +import { isPluginClientOnlySdkSpecifier } from "./plugin-sdk-specifiers.js"; import { createPluginClientId } from "./plugin-session-identity.js"; import { PluginSettingsStore } from "./settings/index.js"; @@ -215,21 +208,16 @@ async function closeProviderConnection(connectionId: string): Promise { send({ type: "provider.closed", connectionId }); } -const pluginAuthorRuntime = { - defineAttachmentSource, - defineSettings, - defineRpc, - Icon() { - throw new Error("Icon is available only in plugin client code"); - }, -}; - function runtimeRequire(name: string): unknown { if (isPluginClientOnlySdkSpecifier(name)) { throw new Error(`${name} is available only in plugin client code`); } - if (isPluginServerTypesSdkSpecifier(name)) return {}; - if (isPluginSdkSpecifier(name)) return pluginAuthorRuntime; + if (name === "@getpaseo/plugin") return pluginSharedRuntime; + if (name === "@getpaseo/plugin/server") return {}; + if (name === "@getpaseo/plugin/server/provider") return pluginProviderRuntime; + if (name === "@getpaseo/plugin/server/acp") return pluginAcpRuntime; + if (name === "@getpaseo/plugin/client/host") + throw new Error(`${name} is private to the app host`); return nodeRequire(name); } diff --git a/packages/server/src/server/plugins/plugin-sdk-specifiers.ts b/packages/server/src/server/plugins/plugin-sdk-specifiers.ts index 9961aafc8c..2c17db0483 100644 --- a/packages/server/src/server/plugins/plugin-sdk-specifiers.ts +++ b/packages/server/src/server/plugins/plugin-sdk-specifiers.ts @@ -1,36 +1,26 @@ -// Specifiers a plugin author can import to reach the SDK. The daemon never resolves these -// from disk: the compiler marks them external and the plugin subprocess hands back its own -// runtime, so a plugin typechecks against generated declarations without installing anything. -// -// COMPAT(plugin-sdk-scope): @paseo/plugin was the SDK name through 0.5.0-beta.1 and was never -// published — that scope is not ours. Plugins scaffolded against that name still import it, so -// both spellings resolve. Remove the @paseo/* entries after 2026-11-19. +// These entries are supplied by the host and remain external in author bundles. export const PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS = [ - "@getpaseo/plugin/ui", - "@getpaseo/plugin/react-native", - "@paseo/plugin/react-native", + "@getpaseo/plugin/client", + "@getpaseo/plugin/client/ui", + "@getpaseo/plugin/client/react-native", ] as const; -export const PLUGIN_SERVER_TYPES_SDK_SPECIFIERS = [ +const PLUGIN_SERVER_ONLY_SDK_SPECIFIERS = [ "@getpaseo/plugin/server", - "@paseo/plugin/server", + "@getpaseo/plugin/server/provider", + "@getpaseo/plugin/server/acp", ] as const; export const PLUGIN_SDK_SPECIFIERS = [ "@getpaseo/plugin", - "@paseo/plugin", - ...PLUGIN_SERVER_TYPES_SDK_SPECIFIERS, + ...PLUGIN_SERVER_ONLY_SDK_SPECIFIERS, ...PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS, ] as const; -export function isPluginSdkSpecifier(name: string): boolean { - return (PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(name); -} - export function isPluginClientOnlySdkSpecifier(name: string): boolean { return (PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS as readonly string[]).includes(name); } -export function isPluginServerTypesSdkSpecifier(name: string): boolean { - return (PLUGIN_SERVER_TYPES_SDK_SPECIFIERS as readonly string[]).includes(name); +export function isPluginServerOnlySdkSpecifier(name: string): boolean { + return (PLUGIN_SERVER_ONLY_SDK_SPECIFIERS as readonly string[]).includes(name); } diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 940ca26e4c..593a4fd52b 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -1,10 +1,11 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { fork } from "node:child_process"; import { tmpdir } from "node:os"; import { PassThrough } from "node:stream"; import path from "node:path"; import { fileURLToPath } from "node:url"; import pino from "pino"; -import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider"; +import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentStreamEvent } from "../agent/agent-sdk-types.js"; import { PluginAgentClientRegistry } from "../agent/plugin-provider.js"; @@ -207,6 +208,65 @@ afterEach(async () => { }); describe("PluginRuntime", () => { + it.each([ + { specifier: "@getpaseo/plugin", moduleDirectory: "shared" }, + { specifier: "@getpaseo/plugin", moduleDirectory: "server" }, + ])( + "loads $specifier contracts without React in the subprocess module graph", + async ({ specifier, moduleDirectory }) => { + const directory = await createPlugin( + "react-free", + `import { rpc } from "./${moduleDirectory}/contract"; +export default function contribute(server) { + server.handle(rpc, (input) => input); + return () => {}; +}`, + ); + await mkdir(path.join(directory, moduleDirectory)); + await writeFile( + path.join(directory, moduleDirectory, "contract.ts"), + `import { defineRpc, defineAttachmentSource } from "${specifier}"; +import { z } from "zod"; +export const rpc = defineRpc({ name: "echo", input: z.string(), output: z.string() }); +const source = defineAttachmentSource({ id: "test", search: rpc }); +if (source.search !== rpc) throw new Error("Attachment contract was not preserved");`, + ); + // Reject imports even when the workspace has React installed. This covers the host's + // static graph and SDK imports evaluated through the compiled plugin's runtimeRequire. + const guard = `export function resolve(specifier, context, nextResolve) { + if (/^(react|react-dom|react-native|use-sync-external-store)(\\/|$)/.test(specifier)) { + throw new Error("React module reached plugin subprocess: " + specifier); + } + return nextResolve(specifier, context); +}`; + const guardUrl = `data:text/javascript,${encodeURIComponent(guard)}`; + const loaderUrl = new URL("../../terminal/terminal-ts-loader.mjs", import.meta.url).href; + const setup = `import { register } from "node:module"; +register(${JSON.stringify(loaderUrl)}); +register(${JSON.stringify(guardUrl)});`; + const runtime = createTestRuntime({ + spawnChild: () => + fork(new URL("./plugin-process.ts", import.meta.url), [], { + execArgv: [ + "--experimental-strip-types", + "--import", + `data:text/javascript,${encodeURIComponent(setup)}`, + ], + serialization: "advanced", + stdio: ["ignore", "pipe", "pipe", "ipc"], + }), + }); + try { + await runtime.startPlugin("react-free", directory); + await expect(runtime.invoke("react-free", "echo", "hello")).resolves.toBe("hello"); + } catch (error) { + throw new Error(JSON.stringify(runtime.getLogs("react-free")), { cause: error }); + } finally { + await runtime.stopAll(); + } + }, + ); + it("runs the direct example through the existing AgentClient path", async () => { const pluginId = "provider-direct-example"; const directory = fileURLToPath( @@ -253,8 +313,8 @@ describe("PluginRuntime", () => { it("runs a provider connection through the real plugin subprocess boundary", async () => { const directory = await createPlugin( "provider-round-trip", - `import type { PluginServerContext } from "@getpaseo/plugin"; -import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider"; + `import type { PluginServerContext } from "@getpaseo/plugin/server"; +import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider"; const provider: ProviderRegistration = { id: "direct-example", @@ -461,8 +521,8 @@ export default function contribute(server: PluginServerContext) { ); const directory = await createPlugin( "provider-acp-round-trip", - `import type { PluginServerContext } from "@getpaseo/plugin"; -import { runAcpProvider } from "@getpaseo/plugin/acp"; + `import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { runAcpProvider } from "@getpaseo/plugin/server/acp"; import { vendorEditTransformer } from "./server/vendor-edit.js"; export default function contribute(server: PluginServerContext) { @@ -598,7 +658,7 @@ lines.on("line", (line) => { it("rejects a malformed provider event from a real plugin subprocess", async () => { const directory = await createPlugin( "malicious-provider", - `import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider"; + `import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider"; let connectionId = ""; process.on("message", (message: unknown) => { const value = message as { type?: string; connectionId?: string }; @@ -665,7 +725,7 @@ export default function contribute(server: any) { server.registerProvider(provid it("emits runtime failure for live sessions when a real plugin process dies", async () => { const directory = await createPlugin( "dying-provider", - `import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider"; + `import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider"; const provider: ProviderRegistration = { id: "dying", label: "Dying", @@ -966,7 +1026,7 @@ export default function contribute(plugin: unknown) { const directory = await createPlugin( "shutdown-connect", `import { writeFile } from "node:fs/promises"; -import type { ProviderRegistration } from "@getpaseo/plugin/provider"; +import type { ProviderRegistration } from "@getpaseo/plugin/server/provider"; const provider: ProviderRegistration = { id: "delayed", @@ -1449,34 +1509,6 @@ export default function contribute(server: any) { await runtime.stopAll(); }); - // COMPAT(plugin-sdk-scope): plugins scaffolded through 0.5.0-beta.1 import the unpublished - // @paseo/plugin name. Drop with the specifiers in plugin-sdk-specifiers.ts. - it("loads a plugin that imports the pre-rename @paseo/plugin specifier", async () => { - const directory = await createPlugin( - "legacy-sdk", - `import { z } from "zod"; -import { defineRpc } from "@paseo/plugin"; - -const pingRpc = defineRpc({ - name: "ping", - input: z.object({}), - output: z.object({ ok: z.boolean() }), -}); - -export default function contribute(plugin: any) { - plugin.handle(pingRpc, async () => ({ ok: true })); - return () => undefined; -}`, - ); - const runtime = createTestRuntime(); - - await runtime.startPlugin("legacy-sdk", directory); - - await expect(runtime.invoke("legacy-sdk", "ping", {})).resolves.toMatchObject({ ok: true }); - - await runtime.stopAll(); - }); - it("keeps client and server modules in their target runtime", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-")); temporaryDirectories.push(directory); @@ -1493,7 +1525,7 @@ export default function contribute(plugin: any) { ), writeFile( path.join(directory, "index.client.tsx"), - `import type { PluginClientContext } from "@getpaseo/plugin"; + `import type { PluginClientContext } from "@getpaseo/plugin/client"; import { Surface } from "./client/surface"; export default function contribute(client: PluginClientContext) { client.addSurface("main", Surface); @@ -1503,7 +1535,7 @@ export default function contribute(client: PluginClientContext) { ), writeFile( path.join(directory, "index.server.ts"), - `import type { PluginServerContext } from "@getpaseo/plugin"; + `import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inspectRpc } from "./shared/inspect"; import { inspectHost } from "./server/inspect"; export default function contribute(server: PluginServerContext) { @@ -1576,7 +1608,7 @@ export function inspectHost(_input: z.input) { ), writeFile( path.join(directory, "index.client.tsx"), - `import type { PluginClientContext } from "@getpaseo/plugin"; + `import type { PluginClientContext } from "@getpaseo/plugin/client"; import { Surface } from "./client/surface"; export default function contribute(client: PluginClientContext) { @@ -1621,7 +1653,7 @@ export function Surface() { return readSecret(); }`, ), writeFile( path.join(directory, "index.server.ts"), - `import type { PluginServerContext } from "@getpaseo/plugin"; + `import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inspect } from "./server/inspect"; import { inspectRpc } from "./shared/inspect"; diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 8902793fcf..7affa3cd97 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -14,7 +14,7 @@ import { type ProviderConnection, type ProviderEvent, type ProviderInput, -} from "@getpaseo/plugin/provider"; +} from "@getpaseo/plugin/server/provider"; import type { PluginLogEntry } from "@getpaseo/protocol/messages"; import { compilePlugin } from "./compiler.js"; import { readPluginManifest } from "./manifest.js"; diff --git a/packages/server/src/server/plugins/settings.e2e.test.ts b/packages/server/src/server/plugins/settings.e2e.test.ts index ce49b33876..2823cd79ab 100644 --- a/packages/server/src/server/plugins/settings.e2e.test.ts +++ b/packages/server/src/server/plugins/settings.e2e.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { expect, test } from "vitest"; import { z } from "zod"; -import { settingsRpc } from "@getpaseo/plugin/host"; +import { settingsRpc } from "@getpaseo/plugin"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; diff --git a/packages/server/src/server/plugins/settings/index.ts b/packages/server/src/server/plugins/settings/index.ts index 8ac418330a..ccdd3e5dd6 100644 --- a/packages/server/src/server/plugins/settings/index.ts +++ b/packages/server/src/server/plugins/settings/index.ts @@ -3,7 +3,7 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; import { defineSettings, type SettingsDefinition } from "@getpaseo/plugin"; -import { settingsRpc } from "@getpaseo/plugin/host"; +import { settingsRpc } from "@getpaseo/plugin"; const envelopeSchema = z.object({ version: z.number().int().positive(), values: z.json() }); function message(error: unknown): string { diff --git a/plugin-examples/agent-configuration/index.server.ts b/plugin-examples/agent-configuration/index.server.ts index 6d74bae0f8..1f86e63a67 100644 --- a/plugin-examples/agent-configuration/index.server.ts +++ b/plugin-examples/agent-configuration/index.server.ts @@ -1,4 +1,4 @@ -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; export default function contribute(server: PluginServerContext) { server.before("agent.create", ({ request }) => { diff --git a/plugin-examples/catppuccin/index.client.ts b/plugin-examples/catppuccin/index.client.ts index 3dd78f6882..da65198710 100644 --- a/plugin-examples/catppuccin/index.client.ts +++ b/plugin-examples/catppuccin/index.client.ts @@ -1,4 +1,4 @@ -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; export default function contribute(client: PluginClientContext) { client.addTheme({ diff --git a/plugin-examples/inline-thinking/client/thinking.tsx b/plugin-examples/inline-thinking/client/thinking.tsx index c24f210f21..88bf4d6903 100644 --- a/plugin-examples/inline-thinking/client/thinking.tsx +++ b/plugin-examples/inline-thinking/client/thinking.tsx @@ -1,5 +1,5 @@ -import type { PluginTimelineItemProps } from "@getpaseo/plugin"; -import { useRevealedText } from "@getpaseo/plugin/react-native"; +import type { PluginTimelineItemProps } from "@getpaseo/plugin/client"; +import { useRevealedText } from "@getpaseo/plugin/client/react-native"; import { useMemo } from "react"; import { Text } from "react-native"; import { z } from "zod"; diff --git a/plugin-examples/inline-thinking/index.client.tsx b/plugin-examples/inline-thinking/index.client.tsx index f78b646761..f93c0a894b 100644 --- a/plugin-examples/inline-thinking/index.client.tsx +++ b/plugin-examples/inline-thinking/index.client.tsx @@ -1,4 +1,4 @@ -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { InlineThinking, inlineThinkingSchema } from "./client/thinking"; export default function contribute(client: PluginClientContext) { diff --git a/plugin-examples/lifecycle-actions/index.server.ts b/plugin-examples/lifecycle-actions/index.server.ts index 9ca88734b8..44ee2cecff 100644 --- a/plugin-examples/lifecycle-actions/index.server.ts +++ b/plugin-examples/lifecycle-actions/index.server.ts @@ -1,4 +1,4 @@ -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { latestOutputText, shellCommand } from "./server/inspect"; export default function contribute(server: PluginServerContext) { diff --git a/plugin-examples/lifecycle-logger/index.server.ts b/plugin-examples/lifecycle-logger/index.server.ts index 92f5ca85b5..04d967949c 100644 --- a/plugin-examples/lifecycle-logger/index.server.ts +++ b/plugin-examples/lifecycle-logger/index.server.ts @@ -1,4 +1,4 @@ -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { logHook } from "./server/log"; export default function contribute(server: PluginServerContext) { diff --git a/plugin-examples/linear/index.client.ts b/plugin-examples/linear/index.client.ts index 3985ff8c91..6b25651fc0 100644 --- a/plugin-examples/linear/index.client.ts +++ b/plugin-examples/linear/index.client.ts @@ -1,4 +1,4 @@ -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { issueAttachments } from "./shared/issues"; export default function contribute(client: PluginClientContext) { diff --git a/plugin-examples/linear/index.server.ts b/plugin-examples/linear/index.server.ts index 841d26dd93..544ab0081c 100644 --- a/plugin-examples/linear/index.server.ts +++ b/plugin-examples/linear/index.server.ts @@ -1,4 +1,4 @@ -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { searchIssues } from "./server/issues"; import { searchIssuesRpc } from "./shared/issues"; diff --git a/plugin-examples/local-plugin/client/main.tsx b/plugin-examples/local-plugin/client/main.tsx index 8bea0bf554..878ba5da17 100644 --- a/plugin-examples/local-plugin/client/main.tsx +++ b/plugin-examples/local-plugin/client/main.tsx @@ -1,13 +1,13 @@ import { useMutation } from "@tanstack/react-query"; +import { Icon } from "@getpaseo/plugin/client/react-native"; import { - Icon, type PluginClientContext, type PluginComposerPillProps, type PluginWorkspacePanelProps, useAgent, useRpc, useWorkspace, -} from "@getpaseo/plugin"; +} from "@getpaseo/plugin/client"; import { useCallback, useMemo } from "react"; import { Pressable, Text, View } from "react-native"; import { incrementRpc } from "../shared/increment"; diff --git a/plugin-examples/local-plugin/index.client.tsx b/plugin-examples/local-plugin/index.client.tsx index 5eee9b545d..4a95c337fc 100644 --- a/plugin-examples/local-plugin/index.client.tsx +++ b/plugin-examples/local-plugin/index.client.tsx @@ -1,4 +1,4 @@ -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { contributeClient, ExamplePanel } from "./client/main"; export default function contribute(client: PluginClientContext) { diff --git a/plugin-examples/local-plugin/index.server.ts b/plugin-examples/local-plugin/index.server.ts index 3b703a617c..c0176ac40e 100644 --- a/plugin-examples/local-plugin/index.server.ts +++ b/plugin-examples/local-plugin/index.server.ts @@ -1,4 +1,4 @@ -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { increment } from "./server/increment"; import { incrementRpc } from "./shared/increment"; diff --git a/plugin-examples/modal-ui/client/examples.tsx b/plugin-examples/modal-ui/client/examples.tsx index e834983bfc..bbd0c8c0e8 100644 --- a/plugin-examples/modal-ui/client/examples.tsx +++ b/plugin-examples/modal-ui/client/examples.tsx @@ -1,4 +1,4 @@ -import type { PluginSurfaceProps } from "@getpaseo/plugin"; +import type { PluginSurfaceProps } from "@getpaseo/plugin/client"; import { Modal, ScrollView, @@ -6,7 +6,7 @@ import { TextInput, copyText, useToast, -} from "@getpaseo/plugin/react-native"; +} from "@getpaseo/plugin/client/react-native"; import { useCallback, useMemo, useRef, useState } from "react"; import { Pressable, diff --git a/plugin-examples/modal-ui/index.client.tsx b/plugin-examples/modal-ui/index.client.tsx index 15bd5ae2c8..e7b2baf54e 100644 --- a/plugin-examples/modal-ui/index.client.tsx +++ b/plugin-examples/modal-ui/index.client.tsx @@ -1,4 +1,4 @@ -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { ModalExamples } from "./client/examples"; export default function contribute(plugin: PluginClientContext) { diff --git a/plugin-examples/provider-acp-transformer/index.server.ts b/plugin-examples/provider-acp-transformer/index.server.ts index 5b85f30fa5..bf63ffbc94 100644 --- a/plugin-examples/provider-acp-transformer/index.server.ts +++ b/plugin-examples/provider-acp-transformer/index.server.ts @@ -1,5 +1,5 @@ -import type { PluginServerContext } from "@getpaseo/plugin"; -import { runAcpProvider } from "@getpaseo/plugin/acp"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { runAcpProvider } from "@getpaseo/plugin/server/acp"; import { vendorEditTransformer } from "./server/vendor-edit.js"; export default function contribute(server: PluginServerContext) { diff --git a/plugin-examples/provider-acp-transformer/server/vendor-edit.ts b/plugin-examples/provider-acp-transformer/server/vendor-edit.ts index 58908678af..87229ca667 100644 --- a/plugin-examples/provider-acp-transformer/server/vendor-edit.ts +++ b/plugin-examples/provider-acp-transformer/server/vendor-edit.ts @@ -1,4 +1,4 @@ -import type { AcpTransformer } from "@getpaseo/plugin/acp"; +import type { AcpTransformer } from "@getpaseo/plugin/server/acp"; import { z } from "zod"; const vendorEditInputSchema = z.object({ diff --git a/plugin-examples/provider-direct/client/provider-result.tsx b/plugin-examples/provider-direct/client/provider-result.tsx index b7d2745f37..2a37e6f4d7 100644 --- a/plugin-examples/provider-direct/client/provider-result.tsx +++ b/plugin-examples/provider-direct/client/provider-result.tsx @@ -1,4 +1,4 @@ -import type { PluginTimelineItemProps } from "@getpaseo/plugin"; +import type { PluginTimelineItemProps } from "@getpaseo/plugin/client"; import { useMemo } from "react"; import { Text, View } from "react-native"; import type { z } from "zod"; diff --git a/plugin-examples/provider-direct/index.client.tsx b/plugin-examples/provider-direct/index.client.tsx index 7954fdecb2..a1e8ca87b2 100644 --- a/plugin-examples/provider-direct/index.client.tsx +++ b/plugin-examples/provider-direct/index.client.tsx @@ -1,4 +1,4 @@ -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { ProviderResult } from "./client/provider-result"; import { providerResultKind, providerResultSchema } from "./shared/provider-result"; diff --git a/plugin-examples/provider-direct/index.server.ts b/plugin-examples/provider-direct/index.server.ts index 61a094391d..d877d897d6 100644 --- a/plugin-examples/provider-direct/index.server.ts +++ b/plugin-examples/provider-direct/index.server.ts @@ -1,4 +1,4 @@ -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { createDirectExampleProvider } from "./server/provider"; export default function contribute(server: PluginServerContext) { diff --git a/plugin-examples/provider-direct/server/provider.test.ts b/plugin-examples/provider-direct/server/provider.test.ts index ce989f4b4f..db7282df86 100644 --- a/plugin-examples/provider-direct/server/provider.test.ts +++ b/plugin-examples/provider-direct/server/provider.test.ts @@ -1,4 +1,4 @@ -import type { ProviderEvent } from "@getpaseo/plugin/provider"; +import type { ProviderEvent } from "@getpaseo/plugin/server/provider"; import { describe, expect, it } from "vitest"; import { createDirectExampleProvider } from "./provider.js"; diff --git a/plugin-examples/provider-direct/server/provider.ts b/plugin-examples/provider-direct/server/provider.ts index 6a8bc957cc..31ea177dd6 100644 --- a/plugin-examples/provider-direct/server/provider.ts +++ b/plugin-examples/provider-direct/server/provider.ts @@ -8,7 +8,7 @@ import { type ProviderPersistence, type ProviderRegistration, type ProviderSessionConfig, -} from "@getpaseo/plugin/provider"; +} from "@getpaseo/plugin/server/provider"; import { randomUUID } from "node:crypto"; import { providerResultKind } from "../shared/provider-result.js"; diff --git a/plugin-examples/settings/client/display-settings.tsx b/plugin-examples/settings/client/display-settings.tsx index f3f20199cc..36999c7a45 100644 --- a/plugin-examples/settings/client/display-settings.tsx +++ b/plugin-examples/settings/client/display-settings.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo, useState } from "react"; import { Text, View } from "react-native"; -import { useSettings, type PluginSurfaceProps, type SettingsState } from "@getpaseo/plugin"; +import { useSettings, type PluginSurfaceProps } from "@getpaseo/plugin/client"; +import type { SettingsState } from "@getpaseo/plugin/client"; import { SettingsAction, SettingsCard, @@ -9,7 +10,7 @@ import { SettingsSection, SettingsSelect, SettingsSwitch, -} from "@getpaseo/plugin/ui"; +} from "@getpaseo/plugin/client/ui"; import { preferences } from "../shared/preferences"; const grouping = [ diff --git a/plugin-examples/settings/index.client.tsx b/plugin-examples/settings/index.client.tsx index 09583a6024..7e7a2dfedc 100644 --- a/plugin-examples/settings/index.client.tsx +++ b/plugin-examples/settings/index.client.tsx @@ -1,4 +1,4 @@ -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { DisplaySettings } from "./client/display-settings"; export default function contribute(client: PluginClientContext) { diff --git a/plugin-examples/settings/index.server.ts b/plugin-examples/settings/index.server.ts index 13b136c58e..1dd653a091 100644 --- a/plugin-examples/settings/index.server.ts +++ b/plugin-examples/settings/index.server.ts @@ -1,4 +1,4 @@ -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { preferences } from "./shared/preferences"; export default function contribute(server: PluginServerContext) { diff --git a/plugin-examples/timeline-items/client/pi-tasks.tsx b/plugin-examples/timeline-items/client/pi-tasks.tsx index 4d7329a18a..f9b68edc62 100644 --- a/plugin-examples/timeline-items/client/pi-tasks.tsx +++ b/plugin-examples/timeline-items/client/pi-tasks.tsx @@ -1,4 +1,4 @@ -import type { PluginTimelineItemProps } from "@getpaseo/plugin"; +import type { PluginTimelineItemProps } from "@getpaseo/plugin/client"; import { useMemo } from "react"; import { Text, View } from "react-native"; import type { z } from "zod"; diff --git a/plugin-examples/timeline-items/client/transform-pi-tasks.ts b/plugin-examples/timeline-items/client/transform-pi-tasks.ts new file mode 100644 index 0000000000..b41a2a003c --- /dev/null +++ b/plugin-examples/timeline-items/client/transform-pi-tasks.ts @@ -0,0 +1,72 @@ +import type { PluginTimelineTransformerContribution } from "@getpaseo/plugin/client"; +import { z } from "zod"; +import { piTaskListSchema } from "../shared/pi-tasks"; + +const rpivDetailsSchema = z.object({ + tasks: z.array( + z + .object({ + subject: z.string(), + status: z.enum(["pending", "in_progress", "completed", "deleted"]), + }) + .passthrough(), + ), +}); + +const piExampleDetailsSchema = z.object({ + todos: z.array( + z + .object({ + id: z.number().int().optional(), + text: z.string(), + done: z.boolean(), + }) + .passthrough(), + ), +}); + +type PiTask = z.output["tasks"][number]; +type ToolCallTransformer = PluginTimelineTransformerContribution<"tool_call">["transform"]; + +function replacement(tasks: PiTask[]) { + if (tasks.length === 0) return; + return { + items: [ + { + type: "plugin" as const, + kind: "pi-task-list", + version: 1, + data: { tasks }, + }, + ], + }; +} + +export const transformPiTodoToolCall: ToolCallTransformer = ({ item }) => { + if (item.name !== "todo" || item.detail.type !== "unknown") { + return; + } + + const result = item.detail.output; + if (!result || typeof result !== "object" || Array.isArray(result)) return; + const details = Reflect.get(result, "details"); + + const rpiv = rpivDetailsSchema.safeParse(details); + if (rpiv.success) { + return replacement( + rpiv.data.tasks.flatMap((task): PiTask[] => + task.status === "deleted" ? [] : [{ text: task.subject, status: task.status }], + ), + ); + } + + const piExample = piExampleDetailsSchema.safeParse(details); + if (piExample.success) { + return replacement( + piExample.data.todos.map((todo) => ({ + text: todo.text, + status: todo.done ? "completed" : "pending", + })), + ); + } +}; diff --git a/plugin-examples/timeline-items/index.client.tsx b/plugin-examples/timeline-items/index.client.tsx index 09724b095b..9e8c488320 100644 --- a/plugin-examples/timeline-items/index.client.tsx +++ b/plugin-examples/timeline-items/index.client.tsx @@ -1,6 +1,7 @@ -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { PiTaskList } from "./client/pi-tasks"; -import { piTaskListSchema, transformPiTodoToolCall } from "./shared/pi-tasks"; +import { piTaskListSchema } from "./shared/pi-tasks"; +import { transformPiTodoToolCall } from "./client/transform-pi-tasks"; export default function contribute(client: PluginClientContext) { client.addTimelineTransformer({ diff --git a/plugin-examples/timeline-items/pi-tasks.test.ts b/plugin-examples/timeline-items/pi-tasks.test.ts index 80fbbcd579..8007365cd0 100644 --- a/plugin-examples/timeline-items/pi-tasks.test.ts +++ b/plugin-examples/timeline-items/pi-tasks.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { transformPiTodoToolCall } from "./shared/pi-tasks"; +import { transformPiTodoToolCall } from "./client/transform-pi-tasks"; function completedTodo(output: unknown) { return { diff --git a/plugin-examples/timeline-items/shared/pi-tasks.ts b/plugin-examples/timeline-items/shared/pi-tasks.ts index 0e7c7c739c..91c60617f1 100644 --- a/plugin-examples/timeline-items/shared/pi-tasks.ts +++ b/plugin-examples/timeline-items/shared/pi-tasks.ts @@ -1,31 +1,7 @@ -import type { PluginTimelineTransformerContribution } from "@getpaseo/plugin"; import { z } from "zod"; const taskStatusSchema = z.enum(["pending", "in_progress", "completed"]); -const rpivDetailsSchema = z.object({ - tasks: z.array( - z - .object({ - subject: z.string(), - status: z.enum(["pending", "in_progress", "completed", "deleted"]), - }) - .passthrough(), - ), -}); - -const piExampleDetailsSchema = z.object({ - todos: z.array( - z - .object({ - id: z.number().int().optional(), - text: z.string(), - done: z.boolean(), - }) - .passthrough(), - ), -}); - export const piTaskListSchema = z.object({ tasks: z.array( z.object({ @@ -34,49 +10,3 @@ export const piTaskListSchema = z.object({ }), ), }); - -type PiTask = z.output["tasks"][number]; -type ToolCallTransformer = PluginTimelineTransformerContribution<"tool_call">["transform"]; - -function replacement(tasks: PiTask[]) { - if (tasks.length === 0) return; - return { - items: [ - { - type: "plugin" as const, - kind: "pi-task-list", - version: 1, - data: { tasks }, - }, - ], - }; -} - -export const transformPiTodoToolCall: ToolCallTransformer = ({ item }) => { - if (item.name !== "todo" || item.detail.type !== "unknown") { - return; - } - - const result = item.detail.output; - if (!result || typeof result !== "object" || Array.isArray(result)) return; - const details = Reflect.get(result, "details"); - - const rpiv = rpivDetailsSchema.safeParse(details); - if (rpiv.success) { - return replacement( - rpiv.data.tasks.flatMap((task): PiTask[] => - task.status === "deleted" ? [] : [{ text: task.subject, status: task.status }], - ), - ); - } - - const piExample = piExampleDetailsSchema.safeParse(details); - if (piExample.success) { - return replacement( - piExample.data.todos.map((todo) => ({ - text: todo.text, - status: todo.done ? "completed" : "pending", - })), - ); - } -}; diff --git a/public-docs/plugins/v0.8/index.md b/public-docs/plugins/v0.8/index.md index 95571e2df8..0329b31b2b 100644 --- a/public-docs/plugins/v0.8/index.md +++ b/public-docs/plugins/v0.8/index.md @@ -64,7 +64,7 @@ Each entry default-exports one function that registers contributions and returns function. `index.client.tsx` registers the surface and the sidebar item that opens it: ```tsx -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { GreetingSurface } from "./client/greeting"; export default function contribute(client: PluginClientContext) { @@ -82,7 +82,7 @@ export default function contribute(client: PluginClientContext) { `index.server.ts` registers the handler for the contract in `shared/greeting.ts`: ```ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { createGreeting } from "./server/greeting"; import { greetingRpc } from "./shared/greeting"; @@ -135,7 +135,7 @@ daemon-side output, including load errors. A workspace panel opens as a tab next to agents, terminals, and files. Create `client/overview.tsx`: ```tsx -import { type PluginWorkspacePanelProps, useWorkspace } from "@getpaseo/plugin"; +import { type PluginWorkspacePanelProps, useWorkspace } from "@getpaseo/plugin/client"; import { useMemo } from "react"; import { Text, View } from "react-native"; diff --git a/public-docs/plugins/v0.8/migration.md b/public-docs/plugins/v0.8/migration.md index a574958e02..ce5c94d075 100644 --- a/public-docs/plugins/v0.8/migration.md +++ b/public-docs/plugins/v0.8/migration.md @@ -85,14 +85,31 @@ Use this table as the complete registration checklist. | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin/server"` in shared files | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin"` | | `ZodOutput` handler parameter types | `RpcInput` from `@getpaseo/plugin`; `RpcOutput` for return types | -Import `PluginClientContext` in the client entry and `PluginServerContext` in the server entry. -Remove imports of the old context type. `@getpaseo/plugin/server` now exports only handler-side -types such as `PluginHandlerContext`. Every client `add*` now returns an idempotent removal -function. Preserve any remover the plugin calls before teardown; Paseo removes outstanding +Import `PluginClientContext` from `@getpaseo/plugin/client` and `PluginServerContext` from +`@getpaseo/plugin/server`. Remove imports of the old context type. Every client `add*` now returns an idempotent removal function. Preserve any remover the plugin calls before teardown; Paseo removes outstanding registrations after the entry cleanup runs. ## 4. Separate imports +Move hooks (`usePaseo`, `useRpc`, `useSettings`, `useAgent`, `useWorkspace`) and client contribution +types from `@getpaseo/plugin` to `@getpaseo/plugin/client`. Move `Icon` to +`@getpaseo/plugin/client/react-native`. Import server contexts and lifecycle contracts from +`@getpaseo/plugin/server`. Shared helpers (`defineRpc`, `defineSettings`, `defineAttachmentSource`), +schemas, and plain data types stay on the root. These rules include type imports. See +[Runtime modules](reference#runtime-modules) for the complete contract. + +Move the remaining SDK subpaths under their runtime owner: + +| Old entry | 0.8 entry | +| ------------------------------- | -------------------------------------- | +| `@getpaseo/plugin/react-native` | `@getpaseo/plugin/client/react-native` | +| `@getpaseo/plugin/ui` | `@getpaseo/plugin/client/ui` | +| `@getpaseo/plugin/provider` | `@getpaseo/plugin/server/provider` | +| `@getpaseo/plugin/acp` | `@getpaseo/plugin/server/acp` | + +The old entries and the pre-0.8 `@paseo/plugin` scope are removed. `/client/host` is private to +Paseo's app integration and is never a plugin-author import. + The client entry imports only `client/`, `shared/`, and client-safe packages. The server entry imports only `server/`, `shared/`, and server-safe packages. A `node:` import in the client entry or anything reachable from it is a compile error. Never import a component into the server entry merely to wire @@ -169,7 +186,7 @@ local-plugin/ ```tsx // index.client.tsx -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { contributeClient, ExamplePanel } from "./client/main"; export default function contribute(client: PluginClientContext) { @@ -196,7 +213,7 @@ export default function contribute(client: PluginClientContext) { ```ts // index.server.ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { increment } from "./server/increment"; import { incrementRpc } from "./shared/increment"; diff --git a/public-docs/plugins/v0.8/providers.md b/public-docs/plugins/v0.8/providers.md index 8b488def40..f9af6596e9 100644 --- a/public-docs/plugins/v0.8/providers.md +++ b/public-docs/plugins/v0.8/providers.md @@ -34,7 +34,7 @@ Add a server entry: ```ts // index.server.ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { createProvider } from "./server/provider"; export default function contribute(server: PluginServerContext) { @@ -49,7 +49,7 @@ export default function contribute(server: PluginServerContext) { import { negotiateProviderCapabilities, type ProviderRegistration, -} from "@getpaseo/plugin/provider"; +} from "@getpaseo/plugin/server/provider"; const supported = ["prompt.message"] as const; @@ -304,7 +304,7 @@ emit({ Register the renderer independently in `index.client.tsx`: ```tsx -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { z } from "zod"; import { ReviewVerdict } from "./client/review-verdict"; @@ -332,8 +332,8 @@ daemon timeline append. A renderer does not require a provider implementation. Use the ACP shim when the agent already speaks ACP: ```ts -import type { PluginServerContext } from "@getpaseo/plugin"; -import { runAcpProvider } from "@getpaseo/plugin/acp"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { runAcpProvider } from "@getpaseo/plugin/server/acp"; export default function contribute(server: PluginServerContext) { server.registerProvider( @@ -355,7 +355,7 @@ Use `transformers` only for vendor differences ACP cannot describe. Validate ven Zod and leave malformed or unrelated values unchanged: ```ts -import type { AcpTransformer } from "@getpaseo/plugin/acp"; +import type { AcpTransformer } from "@getpaseo/plugin/server/acp"; import { z } from "zod"; const editSchema = z.object({ diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index eeb703ae82..6147693d1f 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -108,24 +108,34 @@ plugin root is a compile error. ## Runtime modules Paseo builds each bundle from its matching entry. An import from `client/` into the daemon bundle, -from `server/` into the app bundle, or of a `node:` module anywhere in the app bundle is a compile -error. Keep `shared/` free of Node and React Native runtime code. +from `server/` into the app bundle, or of a Node module anywhere in the app bundle is a compile +error. Server imports of React, React Native, or client SDK entries also fail. Shared code imports +only shared code: no Node, React, runtime-specific SDK entries, or runtime-specific types. + +The SDK root (`@getpaseo/plugin`) contains shared data, schemas, and runtime-neutral helpers only. +Import client contexts and hooks from `/client`, server contexts and lifecycle contracts from +`/server`, and UI from `/client/react-native` or `/client/ui`. These rules include type imports and transitive +dependencies. `/client/host` is private to the app host; plugins cannot import it. ### Client runtime Paseo provides these modules to client code: -| Module | Use it for | -| ------------------------------- | ------------------------------------------------------------------------------------------------------ | -| `@getpaseo/plugin` | Contribution contracts, `defineRpc`, `defineAttachmentSource`, `RpcInput`, `RpcOutput`, and data hooks | -| `@getpaseo/plugin/ui` | Named, composable settings components | -| `@getpaseo/plugin/react-native` | Paseo UI components and UI hooks | -| `@getpaseo/plugin/server` | Handler-only types such as `PluginHandlerContext` | -| `@tanstack/react-query` | Request state and caching | -| `react` | Components and hooks | -| `react/jsx-runtime` | Compiled JSX | -| `react-native` | Cross-platform UI | -| `zod` | Shared schemas | +| Module | Use it for | +| -------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `@getpaseo/plugin` | Shared data, `defineRpc`, `defineSettings`, `defineAttachmentSource`, `RpcInput`, and `RpcOutput` | +| `@getpaseo/plugin/client/ui` | Named, composable settings components | +| `@getpaseo/plugin/client/react-native` | Paseo UI components and UI hooks | +| `@getpaseo/plugin/client` | Client contribution contexts, `usePaseo`, `useRpc`, `useSettings`, and data hooks | +| `@tanstack/react-query` | Request state and caching | +| `react` | Components and hooks | +| `react/jsx-runtime` | Compiled JSX | +| `react-native` | Cross-platform UI | +| `zod` | Shared schemas | + +The host owns its paired React and renderer versions. The SDK's React peer range permits patch +versions for tooling and Node consumers; it does not change the app's pinned React version or +guarantee compatibility with another host's renderer. These exact module specifiers use the host's runtime instances. A client bundle that requests another host module fails with `Module "" is not available in plugin client code`. @@ -176,7 +186,7 @@ Use `openSettings`, `openSurface`, and `openPanel` for your own registered contr ### Server runtime Paseo provides `@getpaseo/plugin`, `@getpaseo/plugin/server`, -`@getpaseo/plugin/provider`, `@getpaseo/plugin/acp`, and `zod` to server code. Backend +`@getpaseo/plugin/server/provider`, `@getpaseo/plugin/server/acp`, and `zod` to server code. Backend contributions run in a daemon subprocess with Node access to the host machine. Keep filesystem, process, credential, and other machine-local work under `server/`. A plugin without `index.server.ts` starts no subprocess. @@ -187,7 +197,7 @@ Follow [Build a provider plugin](/docs/plugins/v0.8/providers) for direct and AC session lifecycle, composer settings, timeline renderers, testing, and distribution. Call `server.registerProvider()` with a `ProviderRegistration` from -`@getpaseo/plugin/provider`. Its connection accepts inputs with `send()` and emits complete state +`@getpaseo/plugin/server/provider`. Its connection accepts inputs with `send()` and emits complete state snapshots through `onEvent()`. `send()` reports acceptance only; prompt disposition, turns, configuration, persistence, permissions, and failures are events. @@ -202,7 +212,7 @@ session config. Paseo refreshes an agent by closing its current provider session and opening it with current configuration and persistence. Providers re-read external state during `session.open`. -Use `runAcpProvider()` from `@getpaseo/plugin/acp` to adapt a command-backed ACP. Add transformer +Use `runAcpProvider()` from `@getpaseo/plugin/server/acp` to adapt a command-backed ACP. Add transformer hooks only for a vendor's discovery, configuration, notification, or tool-call differences. `ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`. @@ -219,7 +229,7 @@ receive `PluginClientContext`; server entries receive `PluginServerContext`. Eve returns an idempotent remover. The entry cleanup runs before Paseo removes remaining registrations. ```ts -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { Main } from "./client/main"; export default function contribute(client: PluginClientContext) { @@ -235,7 +245,7 @@ Cleanup can be async. Release timers, watchers, sockets, and other resources cre In `index.server.ts`: ```ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; export default function contribute(server: PluginServerContext) { server.on("agent.turn_ended", (event) => { @@ -339,7 +349,7 @@ Copy [server/inspect.ts](https://github.com/getpaseo/paseo/blob/main/plugin-exam into your plugin. `latestOutputText` joins text chunks after the latest user message. ```ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { latestOutputText } from "./server/inspect"; export default function contribute(server: PluginServerContext) { @@ -372,7 +382,7 @@ add limits or delays in your plugin when needed. Attachments and tool effects ar Using `shellCommand` from the same [helper file](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts): ```ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { shellCommand } from "./server/inspect"; export default function contribute(server: PluginServerContext) { @@ -558,7 +568,7 @@ Register a component, then point a sidebar item at its surface ID: `client/main.tsx`: ```tsx -import type { PluginSurfaceProps } from "@getpaseo/plugin"; +import type { PluginSurfaceProps } from "@getpaseo/plugin/client"; import { useMemo } from "react"; import { Text, View } from "react-native"; @@ -587,7 +597,7 @@ export function Main({ theme, host, layout }: PluginSurfaceProps) { `index.client.tsx`: ```ts -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { Main } from "./client/main"; export default function contribute(client: PluginClientContext) { @@ -615,12 +625,12 @@ Paseo owns the route, header, close action, host picker, error boundary, and que ## Host UI -Import Paseo-owned UI from `@getpaseo/plugin/react-native` in client code. This example +Import Paseo-owned UI from `@getpaseo/plugin/client/react-native` in client code. This example opens a controlled modal, renders a host icon, and confirms the action with a toast: ```tsx -import type { PluginSurfaceProps } from "@getpaseo/plugin"; -import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native"; +import type { PluginSurfaceProps } from "@getpaseo/plugin/client"; +import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native"; import { useState } from "react"; import { Pressable, Text, View } from "react-native"; @@ -699,7 +709,7 @@ Modal children keep the plugin runtime context. `usePaseo`, `useRpc`, `useWorksp ### Scrolling -Import `ScrollView` and `FlatList` from `@getpaseo/plugin/react-native` when content can appear in a +Import `ScrollView` and `FlatList` from `@getpaseo/plugin/client/react-native` when content can appear in a Paseo modal. They accept React Native props and refs and integrate with the sheet's gestures. Outside a sheet they use ordinary React Native scrolling. Do not import bottom-sheet libraries directly. @@ -713,7 +723,7 @@ container without changing these gestures. Expand the sheet before using list me `scrollToEnd`; the sheet locks list offsets below its largest height. ```tsx -import { FlatList, Modal } from "@getpaseo/plugin/react-native"; +import { FlatList, Modal } from "@getpaseo/plugin/client/react-native"; import { Text } from "react-native"; // Inside your controlled Modal: @@ -743,7 +753,7 @@ user action and await it before reporting success. It rejects if the platform de clipboard is unavailable; browser permissions and secure-context requirements still apply. ```tsx -import { copyText, useToast } from "@getpaseo/plugin/react-native"; +import { copyText, useToast } from "@getpaseo/plugin/client/react-native"; // Inside your component: const toast = useToast(); @@ -758,7 +768,7 @@ async function copyResult() { ``` Programmatic copying and native text selection are separate interactions. Use `` -for long-press selection and OS Copy. Import `TextInput` from `@getpaseo/plugin/react-native` for modal forms. It accepts React Native +for long-press selection and OS Copy. Import `TextInput` from `@getpaseo/plugin/client/react-native` for modal forms. It accepts React Native input props and refs, supports OS Paste, and registers focus with the native sheet so the keyboard can raise the form. Outside a sheet it uses the ordinary input. A plain React Native input supports Paste too, but does not register focus with the sheet; the keyboard can cover it. No clipboard read @@ -804,7 +814,7 @@ registrations are client contributions. Paseo applies the transformer while buil model, including every live streaming update. ```tsx -import type { PluginClientContext, PluginTimelineItemProps } from "@getpaseo/plugin"; +import type { PluginClientContext, PluginTimelineItemProps } from "@getpaseo/plugin/client"; import { Text } from "react-native"; import { z } from "zod"; @@ -860,7 +870,7 @@ assistant rows. A server handler can add a plugin-owned row to canonical history: ```ts -import type { PluginHandlerContext } from "@getpaseo/plugin"; +import type { PluginHandlerContext } from "@getpaseo/plugin/server"; async function publishReview(agentId: string, { paseo }: PluginHandlerContext) { await paseo.agents.ref(agentId).timeline.append({ @@ -919,7 +929,7 @@ Workspace and agent panels receive the same `theme`, `layout`, and optional `nav `name`. A theme is data, so it needs no component file: ```ts -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; export default function contribute(client: PluginClientContext) { client.addTheme({ @@ -984,12 +994,12 @@ A disabled or removed plugin leaves an unavailable screen with working Back navi ### Named UI components -Import settings components from `@getpaseo/plugin/ui`. They work with your own state and RPCs; +Import settings components from `@getpaseo/plugin/client/ui`. They work with your own state and RPCs; no form wrapper or storage binding is required. ```tsx import { useState } from "react"; -import { SettingsCard, SettingsSection, SettingsSwitch } from "@getpaseo/plugin/ui"; +import { SettingsCard, SettingsSection, SettingsSwitch } from "@getpaseo/plugin/client/ui"; export function DisplaySettings() { const [visible, setVisible] = useState(true); @@ -1088,7 +1098,7 @@ Register one panel for workspace or agent context: `client/review.tsx`: ```tsx -import { type PluginAgentPanelProps, useAgent, useWorkspace } from "@getpaseo/plugin"; +import { type PluginAgentPanelProps, useAgent, useWorkspace } from "@getpaseo/plugin/client"; import { useMemo } from "react"; import { Text, View } from "react-native"; @@ -1119,7 +1129,7 @@ export function ReviewPanel({ theme, layout, workspaceId, agentId }: PluginAgent `index.client.tsx`: ```ts -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { ReviewPanel } from "./client/review"; export default function contribute(client: PluginClientContext) { @@ -1291,12 +1301,12 @@ The client entry owns pill creation and removal. This can live directly in `inde a function it imports from `client/`: ```tsx +import { Icon } from "@getpaseo/plugin/client/react-native"; import { - Icon, type PluginClientContext, type PluginComposerPillProps, useAgent, -} from "@getpaseo/plugin"; +} from "@getpaseo/plugin/client"; import { Text } from "react-native"; function ReviewPill({ theme, agentId }: PluginComposerPillProps) { @@ -1366,7 +1376,7 @@ registered by the same plugin. Use `usePaseo()` for ordinary Paseo operations from a surface. It borrows the selected host's existing connection; do not create another client. ```tsx -import { usePaseo } from "@getpaseo/plugin"; +import { usePaseo } from "@getpaseo/plugin/client"; import { Pressable, Text } from "react-native"; function PullRequestAction() { @@ -1420,7 +1430,7 @@ export const greeting = defineRpc({ `client/greeting.tsx`: ```tsx -import { useRpc } from "@getpaseo/plugin"; +import { useRpc } from "@getpaseo/plugin/client"; import { greeting } from "../shared/greeting"; export function GreetingButton() { @@ -1444,7 +1454,7 @@ export function createGreeting({ name }: RpcInput) { `index.client.tsx`: ```ts -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { GreetingButton } from "./client/greeting"; export default function contribute(client: PluginClientContext) { @@ -1456,7 +1466,7 @@ export default function contribute(client: PluginClientContext) { `index.server.ts`: ```ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { createGreeting } from "./server/greeting"; import { greeting } from "./shared/greeting"; @@ -1559,7 +1569,7 @@ export function search({ query }: RpcInput) { `index.client.tsx`: ```ts -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { issues } from "./shared/issues"; export default function contribute(client: PluginClientContext) { @@ -1571,7 +1581,7 @@ export default function contribute(client: PluginClientContext) { `index.server.ts`: ```ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { search } from "./server/issues"; import { searchIssues } from "./shared/issues"; diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index cbc67341f5..997cb5c7cb 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -169,7 +169,14 @@ test("PR routing declares stable behavior ownership", () => { "packages/app/package.json", ], app: ["packages/app/**", "packages/expo-two-way-audio/**"], - sdk: ["packages/client/**", "packages/highlight/**", "packages/protocol/**"], + sdk: [ + "packages/plugin/**", + "plugin-examples/**", + "public-docs/plugins/v0.8/**", + "packages/client/**", + "packages/highlight/**", + "packages/protocol/**", + ], browser: [ "packages/server/src/server/agent/provider-snapshot-manager.ts", "packages/server/src/server/session/provider/provider-catalog-session.ts", @@ -182,7 +189,7 @@ test("PR routing declares stable behavior ownership", () => { "packages/server/src/server/agent/plugin-provider.ts", "packages/server/src/server/plugins/{index,plugin-process,plugin-process-protocol,runtime}.ts", "packages/server/src/executable-resolution/**", - "packages/plugin/src/provider.ts", + "packages/plugin/src/server/provider.ts", "packages/app/src/!(desktop)/**", "packages/app/e2e/browser/**", "packages/app/e2e/support/**", @@ -262,7 +269,7 @@ test("browser and desktop tests have exclusive, directory-owned suites", () => { "packages/server/src/server/agent/plugin-provider.ts", "packages/server/src/server/plugins/{index,plugin-process,plugin-process-protocol,runtime}.ts", "packages/server/src/executable-resolution/**", - "packages/plugin/src/provider.ts", + "packages/plugin/src/server/provider.ts", "packages/app/src/!(desktop)/**", "packages/app/e2e/browser/**", "packages/app/e2e/support/**", diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index cc2c4eddc7..9bd927cc8c 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -105,7 +105,7 @@ Default-export one contribution function from each entry and return cleanup: ```tsx // index.client.tsx -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; export default function contribute(client: PluginClientContext) { // Register components and client callbacks here. @@ -115,7 +115,7 @@ export default function contribute(client: PluginClientContext) { ```ts // index.server.ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; export default function contribute(server: PluginServerContext) { // Register daemon-side RPC handlers here. @@ -139,7 +139,7 @@ import { type PluginClientContext, type PluginWorkspacePanelProps, useWorkspace, -} from "@getpaseo/plugin"; +} from "@getpaseo/plugin/client"; import { useMemo } from "react"; import { Text, View } from "react-native"; @@ -195,7 +195,7 @@ the active workspace or agent. Command callbacks receive the selected host's `pa Plugin surfaces use React Native primitives and work across desktop, browser, iOS, and Android. Register the surface before its sidebar item. Color text from `theme.colors` and pad from `layout.compact`. ```tsx -import type { PluginClientContext, PluginSurfaceProps } from "@getpaseo/plugin"; +import type { PluginClientContext, PluginSurfaceProps } from "@getpaseo/plugin/client"; import { useMemo, useState } from "react"; import { Pressable, Text, View } from "react-native"; @@ -244,13 +244,12 @@ export default function contribute(client: PluginClientContext) { Icons are Lucide icon names. `theme` is a typed `PluginTheme` on every surface and panel. Primary text uses `theme.colors.foreground`; labels use `theme.colors.foregroundMuted`; the root view uses `theme.colors.surface0`. `layout.compact` is true on mobile and narrow windows. Paseo owns the route, header, host picker, close action, error boundary, and per-installation query client. -Client code may import `react`, `react-native`, `@tanstack/react-query`, `zod`, `@getpaseo/plugin`, and `@getpaseo/plugin/react-native`. Install dependencies locally for typechecking; Paseo supplies these runtime modules. JSX compiles with the automatic runtime, so no `React` import is needed for JSX. Importing a `node:` module from client code is a compile error. - -| Module | Use it for | -| ------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `@getpaseo/plugin` | contribution contracts, shared definitions, RPC input/output types, `usePaseo`, `useRpc`, and data hooks | -| `@getpaseo/plugin/react-native` | Paseo UI: `Icon`, `Modal`, `useToast`, `useRevealedText` | -| `@getpaseo/plugin/server` | handler-only types such as `PluginHandlerContext` | +Before writing imports, classify each module as shared, client, or server. Follow the +[SDK import boundaries](https://paseo.sh/docs/plugins/v0.8/reference.md#runtime-modules), including +transitive and type dependencies. The root is shared-only; hooks and client contexts belong to +`@getpaseo/plugin/client`, server contexts to `/server`, and host UI to `/client/react-native` or `/client/ui`. +Install dependencies locally for typechecking; Paseo supplies host runtime modules. JSX uses the +automatic runtime. Do not import `/client/host` from plugin code. ## Works on mobile @@ -281,7 +280,7 @@ Use the existing Paseo SDK for normal Paseo operations. Use plugin RPC only for `usePaseo()` borrows the selected host's current connection. Never create another client inside a surface. ```tsx -import { usePaseo } from "@getpaseo/plugin"; +import { usePaseo } from "@getpaseo/plugin/client"; function PullRequestAction() { const paseo = usePaseo(); @@ -338,7 +337,7 @@ export async function createGreeting({ name }: RpcInput) { ```ts // index.server.ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { createGreeting } from "./server/greeting"; import { greeting } from "./shared/greeting"; @@ -350,7 +349,7 @@ export default function contribute(server: PluginServerContext) { ```tsx // client/greeting.tsx -import { useRpc } from "@getpaseo/plugin"; +import { useRpc } from "@getpaseo/plugin/client"; import { greeting } from "../shared/greeting"; function Greeting() { @@ -422,7 +421,7 @@ const issues = defineAttachmentSource({ ```ts // index.server.ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { searchIssues } from "./shared/issues"; export default function contribute(server: PluginServerContext) { @@ -433,7 +432,7 @@ export default function contribute(server: PluginServerContext) { ```tsx // index.client.tsx -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { issues } from "./shared/issues"; export default function contribute(client: PluginClientContext) { @@ -519,7 +518,7 @@ client.addTimelineRenderer({ }); ``` -Transformers run while the render model is built, on fetched history and on every live update, so `phase` is `"streaming"` for a loading thought or running tool call. Identity comes from the source item, so a streaming item keeps its mounted component; set an output `id` when one source explodes into several items. Transformers must be synchronous and deterministic, `data` must be JSON, and a transformer that throws is logged and skipped. Use `useRevealedText(text, phase)` from `@getpaseo/plugin/react-native` to pace streaming text. `plugin-examples/inline-thinking` replaces the thinking row with inline text; `plugin-examples/timeline-items` replaces a Pi todo tool call with a task card. +Transformers run while the render model is built, on fetched history and on every live update, so `phase` is `"streaming"` for a loading thought or running tool call. Identity comes from the source item, so a streaming item keeps its mounted component; set an output `id` when one source explodes into several items. Transformers must be synchronous and deterministic, `data` must be JSON, and a transformer that throws is logged and skipped. Use `useRevealedText(text, phase)` from `@getpaseo/plugin/client/react-native` to pace streaming text. `plugin-examples/inline-thinking` replaces the thinking row with inline text; `plugin-examples/timeline-items` replaces a Pi todo tool call with a task card. ## Append a timeline row from the daemon From 229f3cd5a8bfb5caacda457cc918bd910cad8da2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 8 Sep 2026 08:30:52 +0200 Subject: [PATCH 036/566] Accept prerelease Paseo versions in plugin requirements and prepare plugin docs for the 0.8 beta (#4452) Accept plugin requirements against the runtime version or its stable core while preserving the legacy version boundary. Validate all example manifests, correct the ACP example metadata, and prepare plugin author documentation for the beta. --- README.md | 9 ++++ .../protocol/src/plugin-requirements.test.ts | 22 +++++----- packages/protocol/src/plugin-requirements.ts | 10 +++-- .../src/server/plugins/manifest.test.ts | 15 ++++++- .../src/plugin-docs-navigation.test.ts | 7 ++-- plugin-examples/lifecycle-actions/README.md | 4 ++ plugin-examples/linear/README.md | 19 ++++----- .../paseo-plugin.json | 3 -- public-docs/plugins/index.md | 4 +- public-docs/plugins/v0.8/index.md | 6 +-- public-docs/plugins/v0.8/migration.md | 6 +-- public-docs/plugins/v0.8/providers.md | 6 +-- public-docs/plugins/v0.8/reference.md | 42 +++++++++---------- 13 files changed, 88 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 3478e15e36..1c834f1291 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,15 @@ Run agents in parallel on your own machines. Ship from your phone or your desk. - **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal. - **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins. +## Plugins + +Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted +TypeScript plugins. Install from a local directory or Git repository with `paseo plugin add `. + +See the [plugin docs](https://paseo.sh/docs/plugins) for your Paseo version, or start with the +[0.8 beta quickstart](https://paseo.sh/docs/plugins/v0.8). Plugins run with access to your daemon +machine and inside connected clients; install only code you trust. + ## Getting Started Paseo runs a local server called the daemon that manages your coding agents. Clients like the desktop app, mobile app, web app, and CLI connect to it. diff --git a/packages/protocol/src/plugin-requirements.test.ts b/packages/protocol/src/plugin-requirements.test.ts index ec00f60694..9d47e68e1f 100644 --- a/packages/protocol/src/plugin-requirements.test.ts +++ b/packages/protocol/src/plugin-requirements.test.ts @@ -1,24 +1,26 @@ import { describe, expect, it } from "vitest"; import { assertPluginCompatibility, validatePluginRequirements } from "./plugin-requirements.js"; -describe("plugin requirements", () => { +describe.each(["daemon", "app"] as const)("plugin requirements on %s", (runtime) => { it("rejects legacy manifests on the first breaking release with migration instructions", () => { - expect(() => - assertPluginCompatibility({ id: "legacy", version: "0.8.0", runtime: "daemon" }), - ).toThrow(/legacy.*<0\.8\.0.*0\.8\.0.*https:\/\/paseo.sh\/docs\/plugins\/v0.8\/migration/); + expect(() => assertPluginCompatibility({ id: "legacy", version: "0.8.0", runtime })).toThrow( + /legacy.*<0\.8\.0.*0\.8\.0.*https:\/\/paseo.sh\/docs\/plugins\/v0.8\/migration/, + ); }); it.each([ [undefined, "0.7.2"], [">=0.8.0", "0.8.0"], + [">=0.8.0", "0.8.0-beta.1"], [">=0.8.0", "1.0.0"], ["^0.8.0", "0.8.4"], [">=0.8.0-beta.1", "0.8.0-beta.2"], + [">=0.8.0-beta.1", "0.8.0-beta.1"], [">=0.8.0-beta.1", "0.8.0"], ["^0.8.0 || ^0.9.0", "0.9.2+build.42"], ])("accepts %s on %s", (paseo, version) => { expect(() => - assertPluginCompatibility({ id: "test", requirements: { paseo }, version, runtime: "app" }), + assertPluginCompatibility({ id: "test", requirements: { paseo }, version, runtime }), ).not.toThrow(); }); @@ -27,11 +29,11 @@ describe("plugin requirements", () => { [undefined, "0.9.0"], [">=0.8.0", "0.7.2"], ["^0.8.0", "0.9.0"], - [">=0.8.0", "0.8.0-beta.1"], + ["<0.8.0", "0.8.0-beta.1"], ])("rejects %s on %s", (paseo, version) => { expect(() => - assertPluginCompatibility({ id: "test", requirements: { paseo }, version, runtime: "app" }), - ).toThrow(`Your app is ${version}`); + assertPluginCompatibility({ id: "test", requirements: { paseo }, version, runtime }), + ).toThrow(`Your ${runtime} is ${version}`); }); it.each(["", " ", "latest", ">=potato", "0.8.0 nonsense"])( @@ -47,8 +49,8 @@ describe("plugin requirements", () => { id: "test", requirements: { paseo: "*" }, version, - runtime: "app", + runtime, }), - ).toThrow("Paseo app version is unknown"); + ).toThrow(`Paseo ${runtime} version is unknown`); }); }); diff --git a/packages/protocol/src/plugin-requirements.ts b/packages/protocol/src/plugin-requirements.ts index 2e0a360099..1659a3fe52 100644 --- a/packages/protocol/src/plugin-requirements.ts +++ b/packages/protocol/src/plugin-requirements.ts @@ -1,5 +1,5 @@ import type { PluginRequirements } from "./messages.js"; -import valid from "semver/functions/valid.js"; +import parse from "semver/functions/parse.js"; import validRange from "semver/ranges/valid.js"; import satisfies from "semver/functions/satisfies.js"; @@ -21,14 +21,16 @@ interface PluginCompatibilityInput { export function assertPluginCompatibility(input: PluginCompatibilityInput): void { validatePluginRequirements(input.requirements); - // COMPAT(plugin-requirements): added in v0.8.0; remove after 2027-03-07 once pre-0.8 plugins and catalogs are unsupported. + // COMPAT(plugin-requirements): added in v0.8.0-beta.1; remove after 2027-03-07 once pre-0.8 plugins and catalogs are unsupported. The legacy range excludes 0.8 prereleases and their stable core. const range = input.requirements?.paseo ?? "<0.8.0"; - if (!input.version || !valid(input.version)) { + const version = input.version ? parse(input.version) : null; + if (!version) { throw new Error( `Cannot check plugin "${input.id}" requirements: Paseo ${input.runtime} version is unknown. Update the ${input.runtime}.`, ); } - if (satisfies(input.version, range)) return; + const stableCore = `${version.major}.${version.minor}.${version.patch}`; + if (satisfies(version, range) || satisfies(stableCore, range)) return; const action = input.requirements?.paseo === undefined ? "This plugin has no requirements.paseo and targets Paseo before 0.8. Ask its author to migrate it: https://paseo.sh/docs/plugins/v0.8/migration" diff --git a/packages/server/src/server/plugins/manifest.test.ts b/packages/server/src/server/plugins/manifest.test.ts index fa73c48b25..12637d8540 100644 --- a/packages/server/src/server/plugins/manifest.test.ts +++ b/packages/server/src/server/plugins/manifest.test.ts @@ -1,16 +1,29 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { readPluginManifest } from "./manifest.js"; const directories: string[] = []; +const examplesDirectory = fileURLToPath( + new URL("../../../../../plugin-examples/", import.meta.url), +); +const examples = (await readdir(examplesDirectory, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); afterEach(async () => { await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); }); describe("plugin manifest", () => { + it.each(examples)("validates the %s example manifest", async (name) => { + await expect(readPluginManifest(path.join(examplesDirectory, name))).resolves.toMatchObject({ + id: expect.any(String), + }); + }); + it("reads and validates requirements before any plugin code runs", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); directories.push(directory); diff --git a/packages/website/src/plugin-docs-navigation.test.ts b/packages/website/src/plugin-docs-navigation.test.ts index 1d44644b46..cd243f6acd 100644 --- a/packages/website/src/plugin-docs-navigation.test.ts +++ b/packages/website/src/plugin-docs-navigation.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildDocsNavTree, getDocs, getLegacyDocsRedirect } from "./docs"; describe("versioned plugin documentation", () => { - it("keeps current and preview releases together in the Plugins navigation", () => { + it("keeps stable and beta releases together in the Plugins navigation", () => { const plugins = buildDocsNavTree(getDocs()).find( (node) => node.type === "category" && node.label === "Plugins", ); @@ -19,11 +19,12 @@ describe("versioned plugin documentation", () => { }, { type: "group", - label: "Paseo v0.8 — Preview", + label: "Paseo v0.8 — Beta", href: "/docs/plugins/v0.8", children: [ - { type: "page", label: "Reference", href: "/docs/plugins/v0.8/reference" }, + { type: "page", label: "Provider plugins", href: "/docs/plugins/v0.8/providers" }, { type: "page", label: "Migration", href: "/docs/plugins/v0.8/migration" }, + { type: "page", label: "Reference", href: "/docs/plugins/v0.8/reference" }, ], }, ], diff --git a/plugin-examples/lifecycle-actions/README.md b/plugin-examples/lifecycle-actions/README.md index 0ff23d4cc2..71d48f0806 100644 --- a/plugin-examples/lifecycle-actions/README.md +++ b/plugin-examples/lifecycle-actions/README.md @@ -17,6 +17,10 @@ paseo plugin install /absolute/path/to/plugin-examples/lifecycle-logger paseo plugin logs lifecycle-logger ``` +Outside the Paseo repository, `server/inspect.ts` needs `@getpaseo/protocol` installed for its +type imports. Add it to your plugin development dependencies at the same version as +`@getpaseo/plugin` before installing this example. + Copy the callbacks you need into your own plugin. The follow-up example sends a new message; it does not replay attachments or undo previous tool effects. If the provider keeps returning the matching phrase, it keeps sending follow-ups. Add limits or delay in your plugin when your workflow needs them. diff --git a/plugin-examples/linear/README.md b/plugin-examples/linear/README.md index 2a227bf71c..c21c52f1d0 100644 --- a/plugin-examples/linear/README.md +++ b/plugin-examples/linear/README.md @@ -9,21 +9,16 @@ Set a personal API key in the daemon environment: export LINEAR_API_KEY="lin_api_..." ``` -Register the extension in `$PASEO_HOME/config.json`: +Start your daemon with that environment variable set. Then turn on **Enable plugins** in +**Settings → Plugins** and install the example: -```json -{ - "pluginsEnabled": true, - "plugins": { - "linear": { - "source": "directory", - "path": "/absolute/path/to/paseo/plugin-examples/linear" - } - } -} +```bash +paseo plugin add /absolute/path/to/paseo/plugin-examples/linear +paseo plugin ls linear ``` -Restart the development daemon after changing its environment or plugin configuration. +Use `paseo plugin reload linear` after source edits. Changing the API key requires restarting the +daemon with the new environment; installing or reloading plugin source does not. The split entry point demonstrates the complete attachment-source pattern: diff --git a/plugin-examples/provider-acp-transformer/paseo-plugin.json b/plugin-examples/provider-acp-transformer/paseo-plugin.json index 9c3f23a174..9aee2c4cc4 100644 --- a/plugin-examples/provider-acp-transformer/paseo-plugin.json +++ b/plugin-examples/provider-acp-transformer/paseo-plugin.json @@ -1,8 +1,5 @@ { "id": "provider-acp-transformer-example", - "name": "ACP provider transformer example", - "version": "1.0.0", - "description": "Wraps an ACP command and normalizes one vendor file-edit shape.", "requirements": { "paseo": ">=0.8.0" } diff --git a/public-docs/plugins/index.md b/public-docs/plugins/index.md index 552a0476e0..16fe2a8bbc 100644 --- a/public-docs/plugins/index.md +++ b/public-docs/plugins/index.md @@ -15,7 +15,7 @@ change between releases. Build and maintain plugins for the latest stable Paseo release. -## [Paseo v0.8 — Preview](/docs/plugins/v0.8) +## [Paseo v0.8 — Beta](/docs/plugins/v0.8) -Prepare plugins for the upcoming client and server entry layout. Existing plugin authors should +Build plugins for Paseo 0.8 beta, with separate client and server entries. Existing plugin authors should follow the [v0.8 migration guide](/docs/plugins/v0.8/migration). diff --git a/public-docs/plugins/v0.8/index.md b/public-docs/plugins/v0.8/index.md index 0329b31b2b..96e6fbb35a 100644 --- a/public-docs/plugins/v0.8/index.md +++ b/public-docs/plugins/v0.8/index.md @@ -1,15 +1,15 @@ --- title: Plugin quickstart description: Build, install, share, and update a trusted Paseo plugin. -nav: Paseo v0.8 — Preview +nav: Paseo v0.8 — Beta order: 46 category: Plugins --- # Plugin quickstart -> **For the upcoming Paseo v0.8 release.** Use the [current v0.7 docs](/docs/plugins/v0.7) -> unless you are preparing a plugin for v0.8. +> **For Paseo v0.8 beta.** Use the [v0.7 docs](/docs/plugins/v0.7) +> if you run the stable release. > **Experimental:** The plugin API is still evolving, so expect breaking changes and updates to > your plugins as Paseo evolves. See the [plugin roadmap](https://github.com/getpaseo/paseo/labels/plugins) diff --git a/public-docs/plugins/v0.8/migration.md b/public-docs/plugins/v0.8/migration.md index ce5c94d075..e77a830c8f 100644 --- a/public-docs/plugins/v0.8/migration.md +++ b/public-docs/plugins/v0.8/migration.md @@ -8,7 +8,7 @@ category: Plugins # Migrate a plugin to runtime entries -> **For the upcoming Paseo v0.8 release.** This migration is not required for Paseo v0.7. +> **For Paseo v0.8 beta.** This migration is not required for Paseo v0.7. Give this page to a coding agent with the plugin directory as its working directory. Execute the steps in order. Do not keep a compatibility entry. @@ -255,8 +255,8 @@ rejects the plugin even if its files have been moved. Adding the field alone doe code. Update the local `@getpaseo/plugin` development dependency to the version you target and reinstall dependencies before typechecking. -For a 0.8 beta, use its explicit version in both the SDK dependency and range, for example -`>=0.8.0-beta.1`. See [requirements](reference#requirements) for range and prerelease semantics. +For a 0.8 beta, use its explicit version in the SDK dependency and `>=0.8.0` in the manifest. +See [requirements](reference#requirements) for range and prerelease semantics. ## 8. Verify the migration diff --git a/public-docs/plugins/v0.8/providers.md b/public-docs/plugins/v0.8/providers.md index f9af6596e9..71eaa08dce 100644 --- a/public-docs/plugins/v0.8/providers.md +++ b/public-docs/plugins/v0.8/providers.md @@ -8,7 +8,7 @@ category: Plugins # Build a provider plugin -> **For the upcoming Paseo v0.8 release.** Start with the +> **For Paseo v0.8 beta.** Start with the > [plugin quickstart](/docs/plugins/v0.8) if you have not built a Paseo plugin before. A provider plugin connects a coding agent to Paseo without adding it to Paseo core. Publish the @@ -22,10 +22,10 @@ Choose one implementation path: | It already implements ACP | Register it with `runAcpProvider()` and add only narrow vendor transformers. | | It has a TypeScript SDK, JSON-RPC API, or custom process protocol | Implement `ProviderRegistration` directly. | -The complete examples are: +The examples are: - [`provider-direct`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-direct): sessions, settings, prompts, persistence, child sessions, and a provider-owned timeline renderer; -- [`provider-acp-transformer`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-acp-transformer): an ACP command with a Zod-validated vendor edit transformer; +- [`provider-acp-transformer`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-acp-transformer): an ACP command template with a Zod-validated vendor edit transformer. Replace `example-acp --stdio` with an installed ACP agent before loading it; - [`inline-thinking`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/inline-thinking): a renderer-only plugin that does not implement a provider. ## Register a direct provider diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index 6147693d1f..2cad2a163a 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -8,7 +8,7 @@ category: Plugins # Plugin reference -> **For the upcoming Paseo v0.8 release.** Return to the [v0.8 quickstart](/docs/plugins/v0.8). +> **For Paseo v0.8 beta.** Return to the [v0.8 quickstart](/docs/plugins/v0.8). Migrating an existing plugin? Follow the standalone [runtime-entry migration guide](/docs/plugins/v0.8/migration). @@ -55,15 +55,13 @@ range. An omitted `requirements.paseo` means `<0.8.0`: the plugin predates the f plugin release. Paseo 0.8 and later reject it with a link to the [migration guide](migration). Empty strings, invalid ranges, and unknown manifest requirement keys are rejected. -| Range | Compatible releases | -| ---------------- | ------------------------------------------------------------------- | -| `>=0.8.0` | 0.8.0 and later stable releases, including future breaking releases | -| `^0.8.0` | 0.8.x stable releases only | -| `>=0.8.3 <0.9.0` | 0.8.3 through the last 0.8 patch | -| `>=0.8.0-beta.1` | Betas from 0.8.0-beta.1, then 0.8.0 and later stable releases | +| Range | Compatible releases | +| ---------------- | ---------------------------------------------------------------------------- | +| `>=0.8.0` | 0.8.0 and later releases, including prereleases and future breaking releases | +| `^0.8.0` | 0.8.x releases, including prereleases | +| `>=0.8.3 <0.9.0` | 0.8.3 through the last 0.8 patch, including prereleases | -Standard npm prerelease rules apply: `>=0.8.0` excludes `0.8.0-beta.1`. A prerelease must be -explicitly included for its major/minor/patch tuple; no version tags are stripped. +Prerelease Paseo versions also satisfy a range their stable core (`major.minor.patch`) satisfies, so `0.8.0-beta.1` satisfies `>=0.8.0` but not `<0.8.0`. `paseo plugin init` writes `>=` followed by the current CLI version and pins the matching SDK for typechecking. Raise the minimum when adopting a newer API. Add an upper bound when a later @@ -81,10 +79,10 @@ and cannot show this new diagnostic. ### Runtime entries -| Entry | Runtime | Receives | Required | -| ------------------ | --------------------- | --------------------- | ----------------------------------------------------------------- | -| `index.client.tsx` | Paseo app, per client | `PluginClientContext` | When the plugin has any UI, callback, theme, or attachment source | -| `index.server.ts` | Daemon subprocess | `PluginServerContext` | When the plugin handles RPCs | +| Entry | Runtime | Receives | Required | +| ------------------ | --------------------- | --------------------- | ------------------------------------------------------------------------------- | +| `index.client.tsx` | Paseo app, per client | `PluginClientContext` | When the plugin has any UI, callback, theme, or attachment source | +| `index.server.ts` | Daemon subprocess | `PluginServerContext` | When the plugin contributes handlers, hooks, settings persistence, or providers | At least one entry is required; both accept `.ts` or `.tsx`. A directory that still has only the old `index.ts` fails to load and points at the [migration guide](/docs/plugins/v0.8/migration). @@ -346,7 +344,9 @@ and directory lookup/import operations are unaffected. ### Send a follow-up when a turn ends Copy [server/inspect.ts](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts) -into your plugin. `latestOutputText` joins text chunks after the latest user message. +into your plugin. The helper imports types from `@getpaseo/protocol/agent-types`; add +`@getpaseo/protocol` at the same version as your plugin SDK to your development dependencies +and install them before loading the plugin. `latestOutputText` joins text chunks after the latest user message. ```ts import type { PluginServerContext } from "@getpaseo/plugin/server"; @@ -1215,7 +1215,7 @@ import { z } from "zod"; const refreshReview = defineRpc({ name: "review.refresh", - input: z.object({ agentId: z.string() }), + input: z.object({ agentId: z.string(), scope: z.string().optional() }), output: z.object({ refreshed: z.boolean() }), }); @@ -1376,10 +1376,10 @@ registered by the same plugin. Use `usePaseo()` for ordinary Paseo operations from a surface. It borrows the selected host's existing connection; do not create another client. ```tsx -import { usePaseo } from "@getpaseo/plugin/client"; +import { type PluginSurfaceProps, usePaseo } from "@getpaseo/plugin/client"; import { Pressable, Text } from "react-native"; -function PullRequestAction() { +function PullRequestAction({ theme }: PluginSurfaceProps) { const paseo = usePaseo(); async function createReviewWorkspace() { @@ -1400,7 +1400,7 @@ function PullRequestAction() { return ( void createReviewWorkspace()}> - Create review workspace + Create review workspace ); } @@ -1659,7 +1659,7 @@ compilation, activation, or replacement. A failing command reports its output, d candidate, and leaves the installed/running version intact. The daemon log records each command and output; with the global `--host` option, execution is on that daemon host. -Run `npm run typecheck` before install or reload. Never edit the daemon config directly. +Run `npm run typecheck` before install or reload. Manage plugin source entries with the CLI or Settings. The daemon-wide **Enable plugins** switch lives under **Settings → Plugins**. A configured plugin remains `disabled` until that switch and the plugin's own enabled state are both on. @@ -1673,8 +1673,8 @@ Use `paseo plugin ls` to read the current status and error. | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `This plugin was made for an older version of Paseo` | The directory has only an `index.ts` entry. Follow the [migration guide](/docs/plugins/v0.8/migration). | | `Plugin entry points are missing` | Neither `index.client.tsx` nor `index.server.ts` exists with that exact name. | -| `server-only module cannot be imported into the plugin client bundle` | Client code imports `server/` or a `*.server.*` file. Move the work behind an RPC and import its contract from `shared/`. | -| `client-only module cannot be imported into the plugin server bundle` | Server code imports `client/` or a `*.client.*` file. Register that contribution from `index.client.tsx` instead. | +| `server-only module cannot be imported into the plugin client bundle` | Client code imports `server/`. Move the work behind an RPC and import its contract from `shared/`. | +| `client-only module cannot be imported into the plugin server bundle` | Server code imports `client/`. Register that contribution from `index.client.tsx` instead. | | `Node module cannot be imported into the plugin client bundle` | Client code imports `node:*`. Move the operation to `server/` and call it through an RPC. | | Sidebar item is missing | The plugin is `running`, the item references an existing surface, the icon name is valid, and the client is on the installation's host. | | Client module is unavailable | Import only the host-provided client modules listed above. | From 1622da40d9ea6d5d173438a90118f83a1b30aa18 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 8 Sep 2026 09:48:48 +0200 Subject: [PATCH 037/566] Keep the mobile terminal keyboard open while typing (#4469) * fix(app): keep terminal keyboard focus stable Clearing the terminal buffer remounted the shared native input, triggering an IME hide/show cycle. Keep text replacement in place and make the composer explicitly reset its editor to preserve multiline layout clearing. * test(app): verify terminal keyboard continuity on Android Replace the new JSDOM regression with a real Android harness. It detects focused native input replacement and IME hide/show requests through terminal toolbar controls, and fails with the previous remount behavior. --- docs/mobile-testing.md | 12 ++ .../e2e/mobile/terminal-keyboard/android.mjs | 117 ++++++++++++++++++ .../ui/text-input/text-input.native.test.tsx | 16 +-- .../ui/text-input/text-input.native.tsx | 29 +++-- .../ui/text-input/text-input.web.tsx | 5 + .../app/src/components/ui/text-input/types.ts | 2 + packages/app/src/composer/input/input.tsx | 12 +- .../input/text-input.web.browser.test.tsx | 14 +++ 8 files changed, 185 insertions(+), 22 deletions(-) create mode 100644 packages/app/e2e/mobile/terminal-keyboard/android.mjs diff --git a/docs/mobile-testing.md b/docs/mobile-testing.md index bd4bf1475b..b341a2d80e 100644 --- a/docs/mobile-testing.md +++ b/docs/mobile-testing.md @@ -35,6 +35,18 @@ PASEO_MOBILE_E2E_METRO_PORT=62093 npm run test:e2e:mobile [native-terminal-basic.ios.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad) and [native-terminal-basic.android.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad) are the smallest examples. Each opens a fresh terminal, types a command at zero delay, submits it, and asserts its distinct output. The app must be connected to a daemon with an active workspace. +For Android keyboard continuity, run the current checkout in the app, open an idle terminal +with an empty prompt, hide its keyboard, and run: + +```bash +ANDROID_SERIAL=emulator-5554 node packages/app/e2e/mobile/terminal-keyboard/android.mjs +``` + +Use a real docked software keyboard. The harness taps Ctrl, Esc, and Enter and checks Android's +focused input identity and IME hide/show events. It saves screenshots and logs under +`.dev/agent-device-artifacts/terminal-keyboard-android`. Set `PASEO_TERMINAL_KEYBOARD_APP_ID=sh.paseo` +to test an installed production build. It never submits a chat message. + When replay diverges, read its ranked selector suggestions. Edit the script deliberately and rerun it from the beginning. `--update` is retained for compatibility but no longer rewrites scripts. ## Maestro compatibility diff --git a/packages/app/e2e/mobile/terminal-keyboard/android.mjs b/packages/app/e2e/mobile/terminal-keyboard/android.mjs new file mode 100644 index 0000000000..9a9f1962ae --- /dev/null +++ b/packages/app/e2e/mobile/terminal-keyboard/android.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { setTimeout } from "node:timers/promises"; + +// Open an idle terminal with an empty prompt and hide its keyboard before running. +// The app must already run the checkout/build being verified against a real daemon. +const appId = process.env.PASEO_TERMINAL_KEYBOARD_APP_ID ?? "sh.paseo.debug"; +const serial = process.env.ANDROID_SERIAL ?? "emulator-5554"; +const artifacts = resolve(".dev/agent-device-artifacts/terminal-keyboard-android"); +const session = "terminal-keyboard-android"; +const env = { + ...process.env, + AGENT_DEVICE_STATE_DIR: resolve(".dev/agent-device-terminal-keyboard"), +}; +mkdirSync(artifacts, { recursive: true }); + +function run(binary, args) { + return execFileSync(binary, args, { env, encoding: "utf8", timeout: 30_000 }); +} +function ad(...args) { + return run("agent-device", [...args, "--session", session]); +} +function adb(...args) { + return run("adb", ["-s", serial, ...args]); +} +function keyboardState() { + const state = adb("shell", "dumpsys", "input_method"); + const view = state.match(/^ mServedView=(.+)$/m)?.[1] ?? ""; + return { + shown: /^ mInputShown=true$/m.test(state), + // The native object identity survives layout/flag changes but not a remount. + input: view.match(/ReactEditText\{(\w+)/)?.[1] ?? null, + }; +} +async function waitForKeyboard(shown) { + let stable = 0; + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const state = keyboardState(); + stable = state.shown === shown ? stable + 1 : 0; + if (stable === 3) return state; + await setTimeout(100); + } + throw new Error(`Keyboard did not settle at shown=${shown}`); +} +function keyboardShift() { + const state = adb("shell", "dumpsys", "window"); + const ime = state.match(/type=ime frame=\[0,(\d+)\].*visible=true/); + const navigation = state.match(/type=navigationBars frame=\[0,(\d+)\]/); + assert.ok(ime && navigation, "Expected a docked keyboard and navigation bar"); + return Number(navigation[1]) - Number(ime[1]); +} +function imeEvents() { + return adb("logcat", "-d", "-v", "threadtime", "ImeTracker:I", "*:S") + .split("\n") + .filter((line) => line.includes(`${appId}:`)); +} +function tap(rect, shift) { + ad( + "press", + String(Math.round(rect.x + rect.width / 2)), + String(Math.round(rect.y + rect.height / 2 - shift)), + ); +} + +let toggle; +try { + ad("open", appId, "--platform", "android", "--serial", serial, "--no-test-ime"); + assert.equal(keyboardState().shown, false, "Start with the terminal keyboard hidden"); + // Accessibility snapshots can hang with a real IME open. Measure the controls + // while hidden, then project them by Android's actual docked-keyboard inset. + const snapshot = JSON.parse(ad("snapshot", "-i", "--json")); + const rectFor = (id) => { + const node = snapshot.data.nodes.find((candidate) => candidate.identifier === id); + assert.ok(node?.rect, `Open a terminal first: missing ${id}`); + return node.rect; + }; + toggle = rectFor("terminal-keyboard-toggle"); + const controls = ["ctrl", "ctrl", "esc", "enter"].map((key) => ({ + key, + rect: rectFor(`terminal-key-${key}`), + })); + tap(toggle, 0); + const baseline = await waitForKeyboard(true); + assert.notEqual(baseline.input, null, "Expected the native terminal input to own the IME"); + const shift = keyboardShift(); + const before = new Set(imeEvents()); + ad("screenshot", resolve(artifacts, "before.png")); + + for (const { key, rect } of controls) { + tap(rect, shift); + const after = await waitForKeyboard(true); + const events = imeEvents().filter((line) => !before.has(line)); + writeFileSync(resolve(artifacts, "ime-events.log"), events.join("\n")); + writeFileSync( + resolve(artifacts, "focus.json"), + JSON.stringify({ key, baseline, after }, null, 2), + ); + assert.equal(after.input, baseline.input, `${key} replaced the focused native input`); + assert.deepEqual( + events.filter((line) => /onRequestHide|onRequestShow/.test(line)), + [], + `${key} cycled the software keyboard`, + ); + } + ad("screenshot", resolve(artifacts, "after.png")); + console.log("PASS: Ctrl, Esc and Enter preserve the native input without IME hide/show requests"); +} finally { + if (toggle && keyboardState().shown) { + tap(toggle, keyboardShift()); + await waitForKeyboard(false); + } + ad("close"); + run("agent-device", ["daemon", "stop", "--clean"]); +} diff --git a/packages/app/src/components/ui/text-input/text-input.native.test.tsx b/packages/app/src/components/ui/text-input/text-input.native.test.tsx index 2b04a84c63..4fa604d657 100644 --- a/packages/app/src/components/ui/text-input/text-input.native.test.tsx +++ b/packages/app/src/components/ui/text-input/text-input.native.test.tsx @@ -92,7 +92,7 @@ describe("EditingTextInputNative", () => { expect(handleRef.current?.getText()).toBe(""); }); - it("replaces the native input when clearing text", () => { + it("replaces the native input when resetting the editor", () => { const handleRef = createRef(); act(() => { @@ -107,13 +107,13 @@ describe("EditingTextInputNative", () => { const grownInput = container?.querySelector("input"); act(() => { - handleRef.current?.replaceText(""); + handleRef.current?.reset(); }); expect(container?.querySelector("input")).not.toBe(grownInput); }); - it("restores focus after replacing a cleared native input", () => { + it("restores focus after replacing a reset native input", () => { const handleRef = createRef(); act(() => { @@ -125,13 +125,13 @@ describe("EditingTextInputNative", () => { originalInput.focus(); act(() => { - handleRef.current?.replaceText(""); + handleRef.current?.reset(); }); expect(document.activeElement).toBe(container?.querySelector("input")); }); - it("focuses the replacement input when focus is requested before a cleared input remounts", () => { + it("focuses the replacement input when focus is requested before an editor reset remounts", () => { const handleRef = createRef(); act(() => { @@ -142,7 +142,7 @@ describe("EditingTextInputNative", () => { const originalFocus = vi.spyOn(originalInput, "focus"); act(() => { - handleRef.current?.replaceText(""); + handleRef.current?.reset(); handleRef.current?.focus(); }); @@ -152,7 +152,7 @@ describe("EditingTextInputNative", () => { expect(document.activeElement).toBe(replacementInput); }); - it("drops a pending focus restore when blur is requested before a cleared input remounts", () => { + it("drops a pending focus restore when blur is requested before an editor reset remounts", () => { const handleRef = createRef(); act(() => { @@ -164,7 +164,7 @@ describe("EditingTextInputNative", () => { originalInput.focus(); act(() => { - handleRef.current?.replaceText(""); + handleRef.current?.reset(); handleRef.current?.blur(); }); diff --git a/packages/app/src/components/ui/text-input/text-input.native.tsx b/packages/app/src/components/ui/text-input/text-input.native.tsx index 074367a1c9..16feafabcb 100644 --- a/packages/app/src/components/ui/text-input/text-input.native.tsx +++ b/packages/app/src/components/ui/text-input/text-input.native.tsx @@ -35,7 +35,8 @@ export const EditingTextInput = forwardRef(null); const initialTextRef = useRef(initialValue); const textRef = useRef(initialTextRef.current); - // Clearing swaps the native input for a fresh instance (see replaceText). + // Resetting the editor swaps the native input to reset intrinsic multiline + // sizing. Text replacement (including an empty buffer) keeps the input mounted. // Until React commits that swap, `inputRef` still points at the doomed // instance: focusing it asks Android for the keyboard and then tears the // focused view down, which cancels the show. Fabric also runs view @@ -74,27 +75,31 @@ export const EditingTextInput = forwardRef textRef.current, replaceText: (nextText, selection) => { textRef.current = nextText; - if (nextText === "") { - const autoFocus = inputRef.current?.isFocused?.() ?? false; - if (inputRef.current?.replaceText) { - inputRef.current.replaceText(nextText, selection); - } else { - inputRef.current?.clear?.(); - } - isAwaitingReplacementRef.current = true; - setReplacement((current) => ({ revision: current.revision + 1, autoFocus })); - return; - } if (inputRef.current?.replaceText) { inputRef.current.replaceText(nextText, selection); return; } + if (nextText === "") { + inputRef.current?.clear?.(); + return; + } inputRef.current?.setNativeProps?.({ text: nextText, ...(selection ? { selection } : {}), }); if (selection) inputRef.current?.setSelection?.(selection.start, selection.end); }, + reset: () => { + textRef.current = ""; + const autoFocus = inputRef.current?.isFocused?.() ?? false; + if (inputRef.current?.replaceText) { + inputRef.current.replaceText(""); + } else { + inputRef.current?.clear?.(); + } + isAwaitingReplacementRef.current = true; + setReplacement((current) => ({ revision: current.revision + 1, autoFocus })); + }, getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current, })); diff --git a/packages/app/src/components/ui/text-input/text-input.web.tsx b/packages/app/src/components/ui/text-input/text-input.web.tsx index 82f2f47c07..100ae185df 100644 --- a/packages/app/src/components/ui/text-input/text-input.web.tsx +++ b/packages/app/src/components/ui/text-input/text-input.web.tsx @@ -75,6 +75,11 @@ export const EditingTextInput = forwardRef { + textRef.current = ""; + const input = inputRef.current as WebTextInputElement | null; + if (input && "value" in input) input.value = ""; + }, getNativeRef: () => inputRef.current, })); diff --git a/packages/app/src/components/ui/text-input/types.ts b/packages/app/src/components/ui/text-input/types.ts index 49a0f60ff6..6a1daacbcc 100644 --- a/packages/app/src/components/ui/text-input/types.ts +++ b/packages/app/src/components/ui/text-input/types.ts @@ -7,6 +7,8 @@ export interface EditingTextInputHandle { isFocused(): boolean; getText(): string; replaceText(text: string, selection?: { start: number; end: number }): void; + /** Clear the editor and reset its intrinsic layout, preserving focus intent. */ + reset(): void; getNativeRef(): unknown; } diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index fc8a260cfb..27bd9b8a92 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1240,7 +1240,11 @@ export const MessageInput = forwardRef( valueRef.current = nextText; updateLiveTextPresence(nextText); selectionRef.current = selection ?? { start: nextText.length, end: nextText.length }; - textInputRef.current?.replaceText(nextText, selection); + if (nextText === "") { + textInputRef.current?.reset(); + } else { + textInputRef.current?.replaceText(nextText, selection); + } onChangeText(nextText); }, [onChangeText, updateComposerHeightForText, updateLiveTextPresence], @@ -1292,7 +1296,11 @@ export const MessageInput = forwardRef( updateComposerHeightForText?.(valueRef.current, textReplacement.text); valueRef.current = textReplacement.text; updateLiveTextPresence(textReplacement.text); - textInputRef.current?.replaceText(textReplacement.text); + if (textReplacement.text === "") { + textInputRef.current?.reset(); + } else { + textInputRef.current?.replaceText(textReplacement.text); + } }, [textReplacement, updateComposerHeightForText, updateLiveTextPresence]); useEffect(() => { diff --git a/packages/app/src/composer/input/text-input.web.browser.test.tsx b/packages/app/src/composer/input/text-input.web.browser.test.tsx index 91dec513e6..6e70fa3edf 100644 --- a/packages/app/src/composer/input/text-input.web.browser.test.tsx +++ b/packages/app/src/composer/input/text-input.web.browser.test.tsx @@ -82,6 +82,20 @@ afterEach(() => { }); describe("ComposerTextInput web IME composition", () => { + it("resets the composer text while preserving focus", () => { + const mounted = mountInput(ignoreTextChange); + act(() => { + mounted.inputRef.current?.focus(); + typeFromIme(mounted.textarea, "line one\nline two"); + }); + + act(() => mounted.inputRef.current?.reset()); + + expect(mounted.textarea.value).toBe(""); + expect(mounted.inputRef.current?.getText()).toBe(""); + expect(document.activeElement).toBe(mounted.textarea); + }); + it("keeps locally typed text when its parent rerenders with a stale value", () => { const recorder = createTextRecorder(); const mounted = mountInput(recorder.onChangeText); From 341a86aa523daf00cdfaa5bbdcea196776f01936 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 8 Sep 2026 09:51:21 +0200 Subject: [PATCH 038/566] docs(changelog): add 0.8.0-beta.1 entry --- CHANGELOG.md | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3349960bb..3dd69ac7ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,92 @@ # Changelog +## 0.8.0-beta.1 - 2026-09-08 + +This release considerably extends what plugins can do: complete providers, custom components in the chat, slash commands, settings screens, and agent lifecycle hooks. + +The plugin API has a breaking change. Plugins now use separate client and server entries and runtime-owned SDK entry points, so plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) before they load on 0.8. + +It also fixes regressions across desktop updates, mobile, Import session, and agent lifecycle. + +### Plugins + +- Added provider contributions: a plugin registers a complete provider, directly or through the bundled ACP shim, with its own icon, session settings, permissions, and timeline rendering ([#4314](https://github.com/getpaseo/paseo/pull/4314)) +- Added live timeline items: a plugin renders custom components in the chat by transforming timeline items at render time ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c)) +- Added durable timeline rows: a daemon handler appends its own persisted rows to an agent's timeline ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c)) +- Added slash commands: a plugin registers commands in the composer that run on the client without a provider turn ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c)) +- Added settings screens: a plugin contributes screens under Settings → Plugins from shared settings components, with host-scoped persistence, validation, and Command Center navigation ([#4357](https://github.com/getpaseo/paseo/pull/4357)) +- Added lifecycle hooks: server callbacks observe agent creation, turns, permissions, and archive events, and can answer permissions or send follow-ups ([#4435](https://github.com/getpaseo/paseo/pull/4435)) +- Added agent creation transforms: before hooks rewrite a new agent's provider, model, launch environment, MCP servers, and workspace isolation ([#4435](https://github.com/getpaseo/paseo/pull/4435)) +- Added modal bodies: a plugin owns the whole sheet body with custom padding, ScrollView or FlatList scrolling, horizontal tabs, keyboard-aware inputs, and a clipboard copy action ([#4392](https://github.com/getpaseo/paseo/pull/4392), [#4431](https://github.com/getpaseo/paseo/pull/4431)) +- Added terminals to the SDK: create, list, write, send keys, capture, and kill terminals by workspace ID ([#4358](https://github.com/getpaseo/paseo/pull/4358)) +- Added `paseo.projects.subscribe()` for live project updates ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet)) +- Added `agent.respondToPermission()` to answer pending permissions from a plugin ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet)) +- Added version requirements: `requirements.paseo` in `paseo-plugin.json` declares the supported Paseo versions, incompatible installs, updates, and reloads are rejected before any code runs, and prerelease Paseo versions satisfy any range their stable core satisfies ([#4430](https://github.com/getpaseo/paseo/pull/4430), [#4452](https://github.com/getpaseo/paseo/pull/4452)) +- Changed plugins to separate `index.client.ts` and `index.server.ts` entries with `client/`, `server/`, and `shared/` directories; plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) ([#4206](https://github.com/getpaseo/paseo/pull/4206)) +- Changed the plugin SDK into runtime-owned entry points: `@getpaseo/plugin` for shared code, `@getpaseo/plugin/client` for the app, and `@getpaseo/plugin/server` for the daemon, with crossed imports rejected at compile time ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal)) +- Changed legacy plugin load failures to say the plugin was built for an older Paseo and link to the migration guide ([#4265](https://github.com/getpaseo/paseo/pull/4265)) +- Changed `paseo plugin ls` to report runtime state, source, installed commit, and load errors without contacting a remote ([#4265](https://github.com/getpaseo/paseo/pull/4265)) +- Fixed workspace plugin panels missing from the Explorer tab-rail menu ([#4446](https://github.com/getpaseo/paseo/pull/4446)) +- Added versioned plugin documentation for v0.7 and v0.8 with a migration guide ([#4245](https://github.com/getpaseo/paseo/pull/4245), [#4452](https://github.com/getpaseo/paseo/pull/4452)) + +### Added + +- Added Import session to the sidebar footer, the History and sidebar empty states, and the Command Center ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Added search, Load more, and a This workspace / Show all scope to the Import session sheet ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Added customizable top-level sidebar items: reorder and hide New workspace, History, Search, Schedules, and plugin items from Appearance settings ([#4203](https://github.com/getpaseo/paseo/pull/4203)) +- Added provider notifications from Pi extensions and OpenCode as timeline items with info, warning, and error levels ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21)) +- Added a fullscreen viewer for Mermaid diagrams on web and desktop ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen)) +- Added Copy, Copy line, and Select all to the web diff context menu, with Cmd/Ctrl+A and Escape ([#4229](https://github.com/getpaseo/paseo/pull/4229)) +- Added Gajae Code to the ACP provider catalog ([#3471](https://github.com/getpaseo/paseo/pull/3471) by [@Yeachan-Heo](https://github.com/Yeachan-Heo)) +- Added per-provider Paseo tool policy in daemon config to disable all or selected Paseo tools for a provider ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger)) +- Added explicit setup approval for fork pull request workspaces: setup, automatic terminals, and scripts wait for Run setup or `paseo workspace setup ` ([#4215](https://github.com/getpaseo/paseo/pull/4215)) +- Added Hub follow-ups to existing agents, archived workspace recovery, and provider catalog discovery through the daemon API ([#4354](https://github.com/getpaseo/paseo/pull/4354), [#4242](https://github.com/getpaseo/paseo/pull/4242)) + +### Changed + +- Changed the desktop app to require macOS 13 or newer; macOS 12 stops receiving desktop updates ([#4322](https://github.com/getpaseo/paseo/pull/4322)) +- Changed `--host` to a global CLI option: `paseo --host ` ([#4238](https://github.com/getpaseo/paseo/pull/4238)) +- Changed the sidebar footer Home button to Import session; the Home route and tile remain ([#4216](https://github.com/getpaseo/paseo/pull/4216)) + +### Improved + +- Reconnected every host immediately on app foreground instead of waiting for reconnect backoff ([#4160](https://github.com/getpaseo/paseo/pull/4160)) +- Smoothed mobile keyboard transitions by moving the timeline and composer together instead of relaying out every frame ([#4275](https://github.com/getpaseo/paseo/pull/4275)) +- Reloaded only the providers whose configuration changed instead of restarting every provider and resetting every catalog ([#4332](https://github.com/getpaseo/paseo/pull/4332)) +- Shortened agent tab tooltips to one title line with the agent ID and last activity ([#4214](https://github.com/getpaseo/paseo/pull/4214)) +- Added macOS updater and ShipIt state to App Diagnostics ([#4322](https://github.com/getpaseo/paseo/pull/4322)) + +### Fixed + +- Fixed the mobile terminal keyboard closing and reopening when using toolbar keys or Enter ([#4469](https://github.com/getpaseo/paseo/pull/4469)) +- Fixed macOS desktop updates closing the app without installing the new version ([#4322](https://github.com/getpaseo/paseo/pull/4322)) +- Fixed the desktop updater reporting up to date while a new release had no updater manifests yet ([#4201](https://github.com/getpaseo/paseo/pull/4201)) +- Fixed `paseo restart` failing from the desktop-installed CLI on Linux `.deb`, RPM, and tarball installs ([#4207](https://github.com/getpaseo/paseo/pull/4207)) +- Fixed the packaged macOS Dock icon rendering oversized ([#4389](https://github.com/getpaseo/paseo/pull/4389)) +- Fixed cached conversations and the workspace list not painting when live updates arrived during cache reads ([#4436](https://github.com/getpaseo/paseo/pull/4436)) +- Fixed queued messages not sending after a turn completed while the app was in the background ([#4160](https://github.com/getpaseo/paseo/pull/4160)) +- Fixed the New workspace form losing the selected model after creating a workspace ([#4401](https://github.com/getpaseo/paseo/pull/4401)) +- Fixed a crash with `Select a model` when the selected provider disappeared during workspace creation ([#4332](https://github.com/getpaseo/paseo/pull/4332)) +- Fixed Codex model rows staying on Loading forever in the New workspace model picker ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed reloading a Codex agent failing with `already has an active writer` ([#4353](https://github.com/getpaseo/paseo/pull/4353)) +- Fixed Pi agents stuck running after an extension-triggered turn ([#3849](https://github.com/getpaseo/paseo/pull/3849) by [@mjakl](https://github.com/mjakl)) +- Fixed nested provider subagents shown as direct children of the root agent and their notifications appearing in the root timeline ([#4321](https://github.com/getpaseo/paseo/pull/4321)) +- Fixed repeated provider tool-call IDs across autonomous turns producing duplicate timeline rows ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413)) +- Fixed the Import session spinner never stopping when one provider hung; the provider now shows an inline error with Retry ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Fixed Import session ignoring Claude Code `/rename` titles ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Fixed importing a session into a directory with an active workspace creating a duplicate workspace ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Fixed empty ACP sessions appearing in Import session ([#4335](https://github.com/getpaseo/paseo/pull/4335)) +- Fixed diff file headers lagging behind the document while scrolling on web and desktop ([#4240](https://github.com/getpaseo/paseo/pull/4240)) +- Fixed the Changes tree and its working diff going out of sync when switching between Committed and Uncommitted ([#4199](https://github.com/getpaseo/paseo/pull/4199)) +- Fixed Mermaid diagrams shrinking on every streaming re-render and the inline diagram box collapsing ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen)) +- Fixed Mermaid source corrupted on reload when cached history overlapped the live message ([#4210](https://github.com/getpaseo/paseo/pull/4210)) +- Fixed Settings navigation retaining closed views in memory on desktop ([#4228](https://github.com/getpaseo/paseo/pull/4228)) +- Fixed the file watcher missing nested directory changes on Windows ([#4354](https://github.com/getpaseo/paseo/pull/4354), [#4358](https://github.com/getpaseo/paseo/pull/4358)) +- Fixed daemon Git commands honoring a repository's `core.fsmonitor`, which let a checkout run its own command ([#4208](https://github.com/getpaseo/paseo/pull/4208)) +- Fixed terminal activity reporting for OpenCode 2 sessions started from a terminal ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan)) +- Changed the plugin SDK's React peer range to `~19.1.0` so CLI installs resolve a patched 19.1.x instead of the version dependency scanners flag ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506)) +- Fixed the paseo.sh homepage offering the Mac download to every visitor before hydration and desktop builds to phones ([#4222](https://github.com/getpaseo/paseo/pull/4222)) + ## 0.7.2 - 2026-09-02 ### Added From 4eab53e24e1b57c74b00945aa48a89d68ed755e3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 8 Sep 2026 10:19:53 +0200 Subject: [PATCH 039/566] chore(release): cut 0.8.0-beta.1 --- package-lock.json | 54 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 8 ++-- packages/client/package.json | 6 +-- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/plugin/package.json | 10 ++--- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 12 +++--- packages/website/package.json | 2 +- 13 files changed, 53 insertions(+), 53 deletions(-) diff --git a/package-lock.json b/package-lock.json index d6775eb308..f8162e6c0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.7.2", + "version": "0.8.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.7.2", + "version": "0.8.0-beta.1", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -36196,7 +36196,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.7.2", + "version": "0.8.0-beta.1", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -37261,12 +37261,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.7.2", + "version": "0.8.0-beta.1", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.7.2", - "@getpaseo/protocol": "0.7.2", - "@getpaseo/server": "0.7.2", + "@getpaseo/client": "0.8.0-beta.1", + "@getpaseo/protocol": "0.8.0-beta.1", + "@getpaseo/server": "0.8.0-beta.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -37513,10 +37513,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.7.2", + "version": "0.8.0-beta.1", "dependencies": { - "@getpaseo/protocol": "0.7.2", - "@getpaseo/relay": "0.7.2", + "@getpaseo/protocol": "0.8.0-beta.1", + "@getpaseo/relay": "0.8.0-beta.1", "zod": "^4.4.3" }, "devDependencies": { @@ -37527,7 +37527,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.7.2", + "version": "0.8.0-beta.1", "license": "Apache-2.0", "dependencies": { "@getpaseo/cli": "*", @@ -37770,7 +37770,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.7.2", + "version": "0.8.0-beta.1", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -38666,7 +38666,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.7.2", + "version": "0.8.0-beta.1", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -38695,14 +38695,14 @@ }, "packages/plugin": { "name": "@getpaseo/plugin", - "version": "0.7.2", + "version": "0.8.0-beta.1", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.7.2", - "@getpaseo/protocol": "0.7.2", + "@getpaseo/client": "0.8.0-beta.1", + "@getpaseo/protocol": "0.8.0-beta.1", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.1.0", @@ -38712,8 +38712,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.7.2", - "@getpaseo/protocol": "0.7.2", + "@getpaseo/client": "0.8.0-beta.1", + "@getpaseo/protocol": "0.8.0-beta.1", "react": "~19.1.0", "react-native": ">=0.81.5", "zod": "^4.4.3" @@ -38735,7 +38735,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.7.2", + "version": "0.8.0-beta.1", "dependencies": { "semver": "^7.8.5", "zod": "^4.4.3" @@ -38762,7 +38762,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.7.2", + "version": "0.8.0-beta.1", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -38980,16 +38980,16 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.7.2", + "version": "0.8.0-beta.1", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.7.2", - "@getpaseo/highlight": "0.7.2", - "@getpaseo/plugin": "0.7.2", - "@getpaseo/protocol": "0.7.2", - "@getpaseo/relay": "0.7.2", + "@getpaseo/client": "0.8.0-beta.1", + "@getpaseo/highlight": "0.8.0-beta.1", + "@getpaseo/plugin": "0.8.0-beta.1", + "@getpaseo/protocol": "0.8.0-beta.1", + "@getpaseo/relay": "0.8.0-beta.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", @@ -39867,7 +39867,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.7.2", + "version": "0.8.0-beta.1", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", diff --git a/package.json b/package.json index 8a60b786db..5d13d92ad4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.7.2", + "version": "0.8.0-beta.1", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/package.json b/packages/app/package.json index e184856c1d..a4d49a0c1e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.7.2", + "version": "0.8.0-beta.1", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index b39aa4e3c0..f0e7c43313 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.7.2", + "version": "0.8.0-beta.1", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -28,9 +28,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.7.2", - "@getpaseo/protocol": "0.7.2", - "@getpaseo/server": "0.7.2", + "@getpaseo/client": "0.8.0-beta.1", + "@getpaseo/protocol": "0.8.0-beta.1", + "@getpaseo/server": "0.8.0-beta.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index d23d727fcb..8d1a6169a3 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.7.2", + "version": "0.8.0-beta.1", "description": "Paseo client SDK package", "files": [ "dist", @@ -39,8 +39,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.7.2", - "@getpaseo/relay": "0.7.2", + "@getpaseo/protocol": "0.8.0-beta.1", + "@getpaseo/relay": "0.8.0-beta.1", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 72f9ddf9c8..84d71dfafa 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.7.2", + "version": "0.8.0-beta.1", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index f67d3b68a0..8d25b8a1f9 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.7.2", + "version": "0.8.0-beta.1", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index fc017fd3f6..eabc12f4b0 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.7.2", + "version": "0.8.0-beta.1", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 58626b1e6a..4f054cf37b 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.7.2", + "version": "0.8.0-beta.1", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -65,8 +65,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.7.2", - "@getpaseo/protocol": "0.7.2", + "@getpaseo/client": "0.8.0-beta.1", + "@getpaseo/protocol": "0.8.0-beta.1", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.1.0", @@ -76,8 +76,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.7.2", - "@getpaseo/protocol": "0.7.2", + "@getpaseo/client": "0.8.0-beta.1", + "@getpaseo/protocol": "0.8.0-beta.1", "react": "~19.1.0", "react-native": ">=0.81.5", "zod": "^4.4.3" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 25cce3ad8e..63210e1212 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.7.2", + "version": "0.8.0-beta.1", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index 62eff50fe3..4805058da4 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.7.2", + "version": "0.8.0-beta.1", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 5db204d84a..5913dfdb62 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.7.2", + "version": "0.8.0-beta.1", "description": "Paseo backend server", "files": [ "dist/server", @@ -71,11 +71,11 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.7.2", - "@getpaseo/highlight": "0.7.2", - "@getpaseo/plugin": "0.7.2", - "@getpaseo/protocol": "0.7.2", - "@getpaseo/relay": "0.7.2", + "@getpaseo/client": "0.8.0-beta.1", + "@getpaseo/highlight": "0.8.0-beta.1", + "@getpaseo/plugin": "0.8.0-beta.1", + "@getpaseo/protocol": "0.8.0-beta.1", + "@getpaseo/relay": "0.8.0-beta.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/website/package.json b/packages/website/package.json index 18a246dbc0..346bbeda1f 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.7.2", + "version": "0.8.0-beta.1", "private": true, "type": "module", "scripts": { From f809651876518998d17cd767d2f99b5f5e2017c5 Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:29:09 +0000 Subject: [PATCH 040/566] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 394e1f48ef..9ad4290b4d 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-40KXWVtW9wgHYJmn7XrLHzHWdOJiSCiajj9duHnDfXA= +sha256-8P7kk9B2A81e13IIyyfo8fIoNGlrutfV/WGKAFHscUc= From 47171b419b8c1c4eadbc590403dc55d14d99395f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 8 Sep 2026 11:07:13 +0200 Subject: [PATCH 041/566] fix(release): run release scripts as main on Windows runners (#4474) The ESM main-module guard compared import.meta.url with "file://" + process.argv[1], which never matches on Windows (file://D:\a\... vs file:///D:/a/...). On the Windows desktop release job, scripts/github-release.mjs therefore printed nothing and gh release upload received an empty release lookup ("release not found"), leaving v0.8.0-beta.1 without a Windows build. Compare resolved paths instead, shared by every release script that runs as a CLI. --- scripts/github-release.mjs | 3 +- scripts/is-main-module.mjs | 10 ++++++ scripts/is-main-module.test.mjs | 34 +++++++++++++++++++ scripts/merge-mac-manifest.mjs | 3 +- scripts/stamp-rollout.mjs | 3 +- scripts/sync-fdroid-changelogs.mjs | 3 +- scripts/sync-release-notes-from-changelog.mjs | 3 +- 7 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 scripts/is-main-module.mjs create mode 100644 scripts/is-main-module.test.mjs diff --git a/scripts/github-release.mjs b/scripts/github-release.mjs index cb166ef189..5c7b7f5988 100644 --- a/scripts/github-release.mjs +++ b/scripts/github-release.mjs @@ -1,4 +1,5 @@ import { execFileSync as nodeExecFileSync } from "node:child_process"; +import { isMainModule } from "./is-main-module.mjs"; function parseJson(output) { return JSON.parse(output); @@ -67,7 +68,7 @@ function parseArgs(argv) { return { cleanupDuplicates, repo, tag }; } -if (import.meta.url === `file://${process.argv[1]}`) { +if (isMainModule(import.meta.url)) { const { cleanupDuplicates, repo, tag } = parseArgs(process.argv.slice(2)); let release = null; for (let attempt = 0; attempt < 5 && !release; attempt += 1) { diff --git a/scripts/is-main-module.mjs b/scripts/is-main-module.mjs new file mode 100644 index 0000000000..f71c95f80d --- /dev/null +++ b/scripts/is-main-module.mjs @@ -0,0 +1,10 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// `import.meta.url === \`file://${process.argv[1]}\`` is false on Windows, where argv[1] is +// `D:\a\...` and the module URL is `file:///D:/a/...`, so a script guarded that way silently +// does nothing on a Windows runner. Compare resolved filesystem paths instead. +export function isMainModule(importMetaUrl, argvPath = process.argv[1]) { + if (!argvPath) return false; + return fileURLToPath(importMetaUrl) === path.resolve(argvPath); +} diff --git a/scripts/is-main-module.test.mjs b/scripts/is-main-module.test.mjs new file mode 100644 index 0000000000..b4d84882ed --- /dev/null +++ b/scripts/is-main-module.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { test } from "node:test"; +import { pathToFileURL } from "node:url"; +import { isMainModule } from "./is-main-module.mjs"; + +test("matches the executed script on this platform", () => { + const scriptPath = path.resolve("scripts", "github-release.mjs"); + assert.equal(isMainModule(pathToFileURL(scriptPath).href, scriptPath), true); +}); + +test("does not match when another module is the entry point", () => { + const scriptPath = path.resolve("scripts", "github-release.mjs"); + const otherPath = path.resolve("scripts", "stamp-rollout.mjs"); + assert.equal(isMainModule(pathToFileURL(scriptPath).href, otherPath), false); +}); + +test("returns false without an argv entry", () => { + assert.equal( + isMainModule(pathToFileURL(path.resolve("scripts", "x.mjs")).href, undefined), + false, + ); +}); + +test("the naive string comparison that broke Windows is not used", () => { + const scriptPath = path.resolve("scripts", "github-release.mjs"); + const url = pathToFileURL(scriptPath).href; + // On Windows this naive form is `file://D:\\...` and never equals the module URL. + assert.notEqual( + `file://${"D:\\a\\paseo\\scripts\\github-release.mjs"}`, + "file:///D:/a/paseo/scripts/github-release.mjs", + ); + assert.equal(isMainModule(url, scriptPath), true); +}); diff --git a/scripts/merge-mac-manifest.mjs b/scripts/merge-mac-manifest.mjs index 78a5553b56..51fcc56df5 100644 --- a/scripts/merge-mac-manifest.mjs +++ b/scripts/merge-mac-manifest.mjs @@ -1,5 +1,6 @@ import fs from "node:fs"; import { dump, load } from "js-yaml"; +import { isMainModule } from "./is-main-module.mjs"; // electron-updater compares this with os.release(), which reports the Darwin // kernel version. Darwin 22 is macOS 13 Ventura. @@ -19,7 +20,7 @@ export function mergeMacManifest(arm64Path, x64Path, outputPath) { return output; } -if (import.meta.url === `file://${process.argv[1]}`) { +if (isMainModule(import.meta.url)) { const [, , arm64Path, x64Path, outputPath] = process.argv; if (!arm64Path || !x64Path || !outputPath) { throw new Error("Usage: node scripts/merge-mac-manifest.mjs "); diff --git a/scripts/stamp-rollout.mjs b/scripts/stamp-rollout.mjs index 2455d6b42d..a655b12e20 100644 --- a/scripts/stamp-rollout.mjs +++ b/scripts/stamp-rollout.mjs @@ -1,5 +1,6 @@ import fs from "node:fs"; import { dump, load } from "js-yaml"; +import { isMainModule } from "./is-main-module.mjs"; export function stampRollout({ releaseDate, rolloutHours }, paths) { if (releaseDate === undefined && rolloutHours === undefined) { @@ -35,7 +36,7 @@ function parseArgs(argv) { return { opts, paths }; } -if (import.meta.url === `file://${process.argv[1]}`) { +if (isMainModule(import.meta.url)) { const { opts, paths } = parseArgs(process.argv.slice(2)); if (paths.length === 0 || (opts.releaseDate === undefined && opts.rolloutHours === undefined)) { throw new Error( diff --git a/scripts/sync-fdroid-changelogs.mjs b/scripts/sync-fdroid-changelogs.mjs index 11690f39ef..8c69107640 100644 --- a/scripts/sync-fdroid-changelogs.mjs +++ b/scripts/sync-fdroid-changelogs.mjs @@ -21,6 +21,7 @@ import { fileURLToPath } from "node:url"; // require it, so the ABI table has exactly one definition across all three callers. import { getFdroidVersionCodes as defaultGetFdroidVersionCodes } from "../packages/app/native-release-version.js"; import { parseChangelogBody, parseChangelogEntries } from "./changelog-utils.mjs"; +import { isMainModule } from "./is-main-module.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, ".."); @@ -263,6 +264,6 @@ export function syncFdroidChangelogs(argv = process.argv.slice(2), deps = {}) { return { contents, version, versionCodes, written }; } -if (import.meta.url === `file://${process.argv[1]}`) { +if (isMainModule(import.meta.url)) { syncFdroidChangelogs(); } diff --git a/scripts/sync-release-notes-from-changelog.mjs b/scripts/sync-release-notes-from-changelog.mjs index fb1018c181..95c9baec4c 100644 --- a/scripts/sync-release-notes-from-changelog.mjs +++ b/scripts/sync-release-notes-from-changelog.mjs @@ -9,6 +9,7 @@ import { normalizeReleaseTag, parseReleaseVersion, } from "./release-version-utils.mjs"; +import { isMainModule } from "./is-main-module.mjs"; function usageAndExit(code = 1) { const usage = ` @@ -201,6 +202,6 @@ export function syncReleaseNotes(argv = process.argv.slice(2), deps = {}) { } } -if (import.meta.url === `file://${process.argv[1]}`) { +if (isMainModule(import.meta.url)) { syncReleaseNotes(); } From 158525624f3e7a99182b0473b8f266456561eea6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 8 Sep 2026 12:59:09 +0200 Subject: [PATCH 042/566] fix(acp): keep vendor configuration notifications atomic (#4479) ACP SDK extension dispatch can finish after a later response, letting vendor config notifications escape commit or rollback depending on pipe chunking. Consume synchronous vendor transforms at the stream boundary before forwarding later responses, and reproduce coalesced single and burst notifications through a real subprocess. --- .../src/server/acp-internal/connection.ts | 35 ++++- packages/plugin/src/server/acp.test.ts | 138 ++++++++++-------- 2 files changed, 110 insertions(+), 63 deletions(-) diff --git a/packages/plugin/src/server/acp-internal/connection.ts b/packages/plugin/src/server/acp-internal/connection.ts index eec8ec6830..be8fae25b9 100644 --- a/packages/plugin/src/server/acp-internal/connection.ts +++ b/packages/plugin/src/server/acp-internal/connection.ts @@ -2,6 +2,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { Readable, Writable } from "node:stream"; import { ClientSideConnection, + CLIENT_METHODS, PROTOCOL_VERSION, ndJsonStream, type Client, @@ -427,10 +428,11 @@ class AcpRuntime { requestPermission: (request) => this.requestPermission(request), sessionUpdate: (notification) => this.enqueueNotification(() => this.sessionUpdate(notification)), - extNotification: (method, params) => - this.enqueueNotification(() => this.vendorNotification(method, params)), }; - this.connection = new ClientSideConnection(() => client, stream); + this.connection = new ClientSideConnection( + () => client, + routeVendorNotifications(stream, (method, params) => this.vendorNotification(method, params)), + ); if (!child) void this.connection.closed.then(() => this.handleUnexpectedTransportClose()); child?.on("error", (error) => { this.processFailed = true; @@ -997,7 +999,7 @@ class AcpRuntime { }); } - private vendorNotification(method: string, params: Record): void { + private vendorNotification(method: string, params: unknown): void { const notification = { method, params: jsonValue(params) }; const context = { sessionId: this.options.boundarySessionId }; for (const transformer of this.transformers) { @@ -1086,6 +1088,31 @@ class AcpRuntime { } } +function routeVendorNotifications( + stream: Stream, + receive: (method: string, params: unknown) => void, +): Stream { + const clientMethods = new Set(Object.values(CLIENT_METHODS)); + return { + writable: stream.writable, + readable: stream.readable.pipeThrough( + new TransformStream({ + transform(message, controller) { + if ("method" in message && !("id" in message) && !clientMethods.has(message.method)) { + // The SDK dispatches extensions asynchronously and can resolve a later response first. + // Run synchronous vendor transforms in wire order, inside the configuration transaction. + try { + receive(message.method, message.params); + } catch (error) { + console.error("Error handling ACP vendor notification", error); + } + } else controller.enqueue(message); + }, + }), + ), + }; +} + function selectPermissionOption( options: RequestPermissionRequest["options"], response: ProviderPermissionResponse, diff --git a/packages/plugin/src/server/acp.test.ts b/packages/plugin/src/server/acp.test.ts index f8099c9a9d..75ea3ae2ea 100644 --- a/packages/plugin/src/server/acp.test.ts +++ b/packages/plugin/src/server/acp.test.ts @@ -823,10 +823,12 @@ lines.on("line", (line) => { await connection.close(); }); - it("commits transformer config notifications once and discards them on rollback", async () => { - const executable = await fakeAcp(`const readline = require("node:readline"); + it.each([1, 64])( + "commits transformer config notifications once and discards them on rollback (%i per response)", + async (notificationCount) => { + const executable = await fakeAcp(`const readline = require("node:readline"); const lines = readline.createInterface({ input: process.stdin }); -const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +const send = (...messages) => process.stdout.write(messages.map((message) => JSON.stringify(message) + "\\n").join("")); let model = "a"; const options = () => [ { id: "model", name: "Model", category: "model", type: "select", currentValue: model, options: [{ name: "A", value: "a" }, { name: "B", value: "b" }] }, @@ -839,66 +841,84 @@ lines.on("line", (line) => { else if (message.method === "session/set_config_option" && message.params.configId === "late") send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "late rejected" } }); else if (message.method === "session/set_config_option") { model = message.params.value; - send({ jsonrpc: "2.0", method: "vendor/config", params: { model } }); - send({ jsonrpc: "2.0", id: message.id, result: { configOptions: options() } }); + send( + ...Array.from({ length: ${notificationCount} }, () => ({ jsonrpc: "2.0", method: "vendor/config", params: { model } })), + { jsonrpc: "2.0", id: message.id, result: { configOptions: options() } }, + ); } });`); - const transformer = { - notification(notification: { method: string; params: unknown }) { - if (notification.method !== "vendor/config") return null; - const params = notification.params as { model: string }; - return { - type: "config" as const, - config: { - model: params.model, - models: [{ id: params.model, label: `Transformer ${params.model}` }], - modes: [], - thinkingOptions: [], - settings: [], - }, - }; - }, - }; - const { connection, events } = await connect( - executable, - ["prompt.message", "session.configure"], - [transformer], - ); - await connection.send(openInput()); - await waitForEvent(events, (event) => event.type === "session.ready"); - const initialConfigCount = events.filter((event) => event.type === "session.config").length; + const transformer = { + notification(notification: { method: string; params: unknown }) { + if (notification.method !== "vendor/config") return null; + const params = notification.params as { model: string }; + return { + type: "config" as const, + config: { + model: params.model, + models: [{ id: params.model, label: `Transformer ${params.model}` }], + modes: [], + thinkingOptions: [], + settings: [], + }, + }; + }, + }; + const { connection, events } = await connect( + executable, + ["prompt.message", "session.configure"], + [transformer], + ); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + const initialConfigCount = events.filter((event) => event.type === "session.config").length; - await connection.send({ - type: "session.configure", - requestId: "transformer-success", - sessionId: "session-1", - changes: { model: "b" }, - }); - await waitForEvent( - events, - (event) => event.type === "request.completed" && event.requestId === "transformer-success", - ); - let configs = events.filter((event) => event.type === "session.config"); - expect(configs).toHaveLength(initialConfigCount + 1); - expect(configs.at(-1)).toMatchObject({ - config: { model: "b", models: [{ label: "Transformer b" }] }, - }); + await connection.send({ + type: "session.configure", + requestId: "transformer-initial-failure", + sessionId: "session-1", + changes: { model: "b", settings: { late: "fail" } }, + }); + await waitForEvent( + events, + (event) => + event.type === "request.failed" && event.requestId === "transformer-initial-failure", + ); + expect(events.filter((event) => event.type === "session.config")).toHaveLength( + initialConfigCount, + ); - await connection.send({ - type: "session.configure", - requestId: "transformer-failure", - sessionId: "session-1", - changes: { model: "a", settings: { late: "fail" } }, - }); - await waitForEvent( - events, - (event) => event.type === "request.failed" && event.requestId === "transformer-failure", - ); - configs = events.filter((event) => event.type === "session.config"); - expect(configs).toHaveLength(initialConfigCount + 1); - expect(configs.at(-1)).toMatchObject({ config: { model: "b" } }); - await connection.close(); - }); + await connection.send({ + type: "session.configure", + requestId: "transformer-success", + sessionId: "session-1", + changes: { model: "b" }, + }); + await waitForEvent( + events, + (event) => event.type === "request.completed" && event.requestId === "transformer-success", + ); + let configs = events.filter((event) => event.type === "session.config"); + expect(configs).toHaveLength(initialConfigCount + 1); + expect(configs.at(-1)).toMatchObject({ + config: { model: "b", models: [{ label: "Transformer b" }] }, + }); + + await connection.send({ + type: "session.configure", + requestId: "transformer-failure", + sessionId: "session-1", + changes: { model: "a", settings: { late: "fail" } }, + }); + await waitForEvent( + events, + (event) => event.type === "request.failed" && event.requestId === "transformer-failure", + ); + configs = events.filter((event) => event.type === "session.config"); + expect(configs).toHaveLength(initialConfigCount + 1); + expect(configs.at(-1)).toMatchObject({ config: { model: "b" } }); + await connection.close(); + }, + ); it("gives discovery transformers the discovered session configuration facade", async () => { const executable = await fakeAcp(`const readline = require("node:readline"); From 18aaec2772a5bd3d5ef1384022e5fa8d7650d5e4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 8 Sep 2026 12:59:25 +0200 Subject: [PATCH 043/566] fix(client): keep daemon connections lean by default (#4470) Move supported protocol capabilities into client defaults and make session traffic subscription-driven. Preserve timeline readiness and reconnect recovery across SDK, CLI, and plugin consumers. --- docs/architecture.md | 3 +- docs/protocol-compatibility.md | 12 + packages/app/src/runtime/host-runtime.ts | 2 - packages/cli/src/commands/agent/attach.ts | 19 +- packages/cli/src/commands/agent/logs.ts | 14 +- packages/client/README.md | 5 + .../src/compat/normalize-provider-models.ts | 9 +- packages/client/src/connection.test.ts | 391 ++++++++++++++++++ packages/client/src/connection/index.ts | 119 ++++++ packages/client/src/daemon-client.test.ts | 7 +- packages/client/src/daemon-client.ts | 138 ++++++- packages/client/src/index.ts | 32 +- .../client/src/provider-snapshots/index.ts | 77 ++++ packages/protocol/src/client-capabilities.ts | 2 + packages/protocol/src/messages.ts | 23 ++ .../authorization/operation-permissions.ts | 2 + .../src/server/client-activity.e2e.test.ts | 11 +- .../src/server/daemon-client.e2e.test.ts | 2 +- .../user-message-contract.real.e2e.test.ts | 2 +- .../plugins/connection-demand.e2e.test.ts | 91 ++++ packages/server/src/server/session.ts | 65 ++- .../agent-updates-service.test.ts | 10 + .../agent-updates/agent-updates-service.ts | 12 + .../provider/provider-catalog-session.test.ts | 2 + .../provider/provider-catalog-session.ts | 2 + .../src/server/voice-local-agent.e2e.test.ts | 7 +- .../src/server/voice-roundtrip.e2e.test.ts | 2 +- .../server/src/server/websocket-server.ts | 1 + public-docs/plugins/v0.8/reference.md | 2 +- public-docs/sdk/events.md | 14 +- public-docs/sdk/reference.md | 2 +- 31 files changed, 1012 insertions(+), 68 deletions(-) create mode 100644 packages/client/src/connection.test.ts create mode 100644 packages/client/src/connection/index.ts create mode 100644 packages/client/src/provider-snapshots/index.ts create mode 100644 packages/server/src/server/plugins/connection-demand.e2e.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 134e8f3532..32fef93532 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -96,7 +96,8 @@ code imports from `@getpaseo/client`. `PaseoApi` is the capability-only boundary over workspaces, agents, terminals, providers, and config. `PaseoClient` adds connection lifecycle. App plugin surfaces borrow an API over their selected -host's client; plugin subprocesses use the same facade over a host-owned IPC transport. +host's client; plugin subprocesses use the same facade over a host-owned IPC transport. Protocol capability ownership and subscription lifetimes follow +[the client contract](protocol-compatibility.md#client-capability-ownership). ### `packages/app` — Mobile + web client (Expo) diff --git a/docs/protocol-compatibility.md b/docs/protocol-compatibility.md index dddff2c107..6a232b8061 100644 --- a/docs/protocol-compatibility.md +++ b/docs/protocol-compatibility.md @@ -38,6 +38,18 @@ The app checks for the capability and either runs the feature or tells the user Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that. +## Client capability ownership + +The client package advertises the protocol behavior it implements. Add each new capability to +its exhaustive defaults and implement the associated subscription or decoding behavior there. +The app, CLI, and plugins inherit those defaults; they supply only host resources such as browser +automation, or explicit overrides. A schema accepting a message does not establish support for +its delivery semantics. + +Connecting creates no timeline or event demand. Client subscriptions own their network membership, +release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic +without requesting streams. Application caches and which agents are visible remain caller-owned. + ## Every shim is tagged and dated A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go: diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 9d551be1c7..0dd8bf776e 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -501,8 +501,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps { } : undefined; const appCapabilities = { - [CLIENT_CAPS.selectiveAgentTimeline]: true, - [CLIENT_CAPS.timelineReplacementInvalidation]: true, ...browserAutomationCapabilities, }; diff --git a/packages/cli/src/commands/agent/attach.ts b/packages/cli/src/commands/agent/attach.ts index fb58f71fa0..e73bb0a2f3 100644 --- a/packages/cli/src/commands/agent/attach.ts +++ b/packages/cli/src/commands/agent/attach.ts @@ -12,7 +12,7 @@ import { } from "../../utils/timeline.js"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; -import type { AgentStreamEventPayload, AgentStreamMessage } from "@getpaseo/protocol/messages"; +import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; export interface AgentAttachOptions { host?: string; @@ -134,7 +134,7 @@ export async function runAttachCommand( const resolvedId = fetchResult.agent.id; // Print header - console.log(`Attaching to agent ${resolvedId.substring(0, 7)}...`); + console.log(`Fetching history for agent ${resolvedId.substring(0, 7)}...`); console.log(`(Press Ctrl+C to detach)\n`); // Print existing output from timeline fetch. @@ -152,14 +152,17 @@ export async function runAttachCommand( } // Subscribe to new events - const unsubscribe = client.on("agent_stream", (msg: unknown) => { - const message = msg as AgentStreamMessage; - if (message.type !== "agent_stream") return; - if (message.payload.agentId !== resolvedId) return; - - printStreamEvent(message.payload.event); + const unsubscribe = client.subscribeAgentTimeline(resolvedId, (message) => { + if (message.type === "agent.timeline.replacement") { + console.log("\n[Timeline replaced; earlier output is no longer current]"); + } else { + printStreamEvent(message.payload.event); + } }); + await unsubscribe.ready; + console.log(`Attached to agent ${resolvedId.substring(0, 7)}.`); + // Handle Ctrl+C to detach gracefully let detached = false; const detach = () => { diff --git a/packages/cli/src/commands/agent/logs.ts b/packages/cli/src/commands/agent/logs.ts index 14355aee81..e29c9f6c69 100644 --- a/packages/cli/src/commands/agent/logs.ts +++ b/packages/cli/src/commands/agent/logs.ts @@ -7,7 +7,6 @@ import { } from "../../utils/timeline.js"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; -import type { AgentStreamMessage } from "@getpaseo/protocol/messages"; import { curateAgentActivity } from "@getpaseo/server"; export function addLogsOptions(cmd: Command): Command { @@ -202,12 +201,12 @@ async function runFollowMode( // Subscribe to new events const tailLabel = tailCount === 0 ? "no history" : `last ${tailCount} entr${tailCount === 1 ? "y" : "ies"}`; - console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`); - const unsubscribe = client.on("agent_stream", (msg: unknown) => { - const message = msg as AgentStreamMessage; - if (message.type !== "agent_stream") return; - if (message.payload.agentId !== agentId) return; + const unsubscribe = client.subscribeAgentTimeline(agentId, (message) => { + if (message.type === "agent.timeline.replacement") { + console.log("\n[Timeline replaced; earlier output is no longer current]"); + return; + } if (message.payload.event.type === "timeline") { const item = message.payload.event.item; @@ -223,6 +222,9 @@ async function runFollowMode( } }); + await unsubscribe.ready; + console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`); + // Wait for interrupt await new Promise((resolve) => { const cleanup = () => { diff --git a/packages/client/README.md b/packages/client/README.md index 85d6757d42..29bed77a81 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -34,6 +34,11 @@ The client needs a WebSocket implementation. Modern browsers and Node.js 22 prov Use a WebSocket URL ending in `/ws`, such as `ws://127.0.0.1:6767/ws`. Pass `password` when the daemon requires authentication. +The client advertises its supported protocol capabilities by default. Optional `capabilities` +overrides extend or override that declaration; browser hosting must be supplied by the caller. +Connecting alone does not subscribe to agent timelines or catalog events. See the +[event guide](https://paseo.sh/docs/sdk/events) for subscription lifetimes and timeline replacements. + ## Stability The high-level API exported from `@getpaseo/client` is the supported SDK surface. The SDK and daemon remain protocol-compatible across versions, but newly added capabilities can require a newer daemon. diff --git a/packages/client/src/compat/normalize-provider-models.ts b/packages/client/src/compat/normalize-provider-models.ts index 023c8681e3..b2662c0d21 100644 --- a/packages/client/src/compat/normalize-provider-models.ts +++ b/packages/client/src/compat/normalize-provider-models.ts @@ -61,9 +61,16 @@ export function normalizeProvidersSnapshotPayload< T extends GetProvidersSnapshotPayload | ProvidersSnapshotUpdatePayload, >(payload: T, expand = true): T { if (payload.compactSnapshot && !expand) return payload; - const entries = payload.compactSnapshot + const decoded = payload.compactSnapshot ? expandProviderSnapshot(payload.compactSnapshot) : normalizeProviderSnapshotEntries(payload.entries); + const freshness = payload.fetchedAt; + const entries = + freshness && expand + ? decoded.map((entry) => + freshness[entry.provider] ? { ...entry, fetchedAt: freshness[entry.provider] } : entry, + ) + : decoded; return entries === payload.entries ? payload : { ...payload, entries }; } diff --git a/packages/client/src/connection.test.ts b/packages/client/src/connection.test.ts new file mode 100644 index 0000000000..6807c4bf63 --- /dev/null +++ b/packages/client/src/connection.test.ts @@ -0,0 +1,391 @@ +import { expect, test } from "vitest"; +import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; +import { DaemonClient, type DaemonTransport } from "./daemon-client"; + +function connection(options: { acknowledgeSubscriptions?: boolean } = {}) { + const sent: Array<{ + type: string; + capabilities?: Record; + message?: { type: string; requestId?: string; agentIds?: string[]; events?: string[] }; + }> = []; + let receive = (_data: unknown) => {}; + let open = () => {}; + let closed = (_event?: unknown) => {}; + const transport: DaemonTransport = { + send(data) { + const frame = JSON.parse(String(data)); + sent.push(frame); + if (frame.type === "hello") { + receive( + JSON.stringify({ + type: "session", + message: { + type: "status", + payload: { + status: "server_info", + serverId: "test", + hostname: null, + version: null, + features: { selectiveAgentTimeline: true, explicitEventSubscriptions: true }, + }, + }, + }), + ); + } else if ( + options.acknowledgeSubscriptions !== false && + frame.type === "session" && + frame.message.type.endsWith("set_subscription.request") + ) { + receive( + JSON.stringify({ + type: "session", + message: { + type: frame.message.type.replace(".request", ".response"), + payload: { requestId: frame.message.requestId, agentIds: frame.message.agentIds }, + }, + }), + ); + } + }, + close() {}, + onMessage(handler) { + receive = handler; + return () => {}; + }, + onOpen(handler) { + open = handler; + return () => {}; + }, + onClose(handler) { + closed = handler; + return () => {}; + }, + onError() { + return () => {}; + }, + }; + const client = new DaemonClient({ + url: "ws://test", + clientId: "test", + transportFactory: () => transport, + reconnect: { enabled: false }, + }); + return { + client, + sent, + open: () => open(), + disconnect: () => closed(), + receive: (message: unknown) => receive(JSON.stringify({ type: "session", message })), + }; +} + +test("a plain client advertises every protocol capability and no browser host", async () => { + const h = connection(); + try { + const ready = h.client.connect(); + h.open(); + await ready; + const { browserHost: _browser, ...protocolCapabilities } = CLIENT_CAPS; + // Every new capability needs a deliberate default or a host-owned exception. + expect(h.sent).toHaveLength(1); + expect(h.sent[0].capabilities).toEqual( + Object.fromEntries(Object.values(protocolCapabilities).map((key) => [key, true])), + ); + } finally { + await h.client.close(); + } +}); + +test("SDK timeline listeners own their union across unsubscribe and reconnect", async () => { + const { createPaseoApi } = await import("./index"); + const h = connection(); + const api = createPaseoApi(h.client); + try { + const ready = h.client.connect(); + h.open(); + await ready; + const first = api.agents.ref("a").timeline.subscribe(() => {}); + const same = api.agents.ref("a").timeline.subscribe(() => {}); + const second = api.agents.ref("b").timeline.subscribe(() => {}); + await Promise.resolve(); + const memberships = () => + h.sent + .filter((f) => f.message?.type === "agent.timeline.set_subscription.request") + .map((f) => f.message?.agentIds); + expect(memberships()).toEqual([["a"], ["a"], ["a", "b"]]); + first(); + expect(memberships()).toEqual([["a"], ["a"], ["a", "b"]]); + same(); + expect(memberships().at(-1)).toEqual(["b"]); + h.disconnect(); + const reconnect = h.client.connect(); + h.open(); + await reconnect; + expect(memberships().at(-1)).toEqual(["b"]); + second(); + expect(memberships().at(-1)).toEqual([]); + } finally { + await h.client.close(); + } +}); + +test("SDK subscribers receive timeline replacement instead of silently losing history", async () => { + const { createPaseoApi } = await import("./index"); + const h = connection(); + const received: unknown[] = []; + try { + const ready = h.client.connect(); + h.open(); + await ready; + const off = createPaseoApi(h.client) + .agents.ref("agent") + .timeline.subscribe((event) => received.push(event)); + h.receive({ type: "agent.timeline.replacement", payload: { agentId: "agent", epoch: "next" } }); + expect(received).toEqual([{ agentId: "agent", event: { type: "replacement", epoch: "next" } }]); + off(); + } finally { + await h.client.close(); + } +}); + +test("event listeners release demand and restore only remaining interests", async () => { + const h = connection(); + try { + const ready = h.client.connect(); + h.open(); + await ready; + const projects = h.client.on("project.update", () => {}); + const providers = h.client.on("providers_snapshot_update", () => {}); + const subscriptions = () => + h.sent + .filter((f) => f.message?.type === "session.events.set_subscription.request") + .map((f) => f.message?.events); + expect(subscriptions()).toEqual([ + ["project.update"], + ["project.update", "providers_snapshot_update"], + ]); + projects(); + h.disconnect(); + const reconnect = h.client.connect(); + h.open(); + await reconnect; + expect(subscriptions().at(-1)).toEqual(["providers_snapshot_update"]); + providers(); + expect(subscriptions().at(-1)).toEqual([]); + } finally { + await h.client.close(); + } +}); + +test("provider references fetch a body only for interested SDK listeners", async () => { + const h = connection(); + const received: unknown[] = []; + const payload = { + entries: [], + snapshotHash: "catalog", + fetchedAt: { test: "2026-09-08T00:00:00.000Z" }, + generatedAt: "2026-09-08T00:00:00.000Z", + }; + try { + const ready = h.client.connect(); + h.open(); + await ready; + h.receive({ type: "providers_snapshot_update", payload }); + expect(h.sent).toHaveLength(1); + const off = h.client.on("providers_snapshot_update", (m) => received.push(m.payload.entries)); + h.receive({ type: "providers_snapshot_update", payload }); + h.receive({ type: "providers_snapshot_update", payload }); + const requests = h.sent.filter((f) => f.message?.type === "get_providers_snapshot_request"); + expect(requests).toHaveLength(1); + h.receive({ + type: "get_providers_snapshot_response", + payload: { + ...payload, + requestId: requests[0].message?.requestId, + compactSnapshot: { + entries: [ + { + provider: "test", + enabled: true, + status: "ready", + models: [{ id: "one", label: "One" }], + }, + ], + thinkingSets: [], + }, + }, + }); + await expect + .poll(() => received) + .toEqual([ + [ + { + provider: "test", + enabled: true, + status: "ready", + fetchedAt: "2026-09-08T00:00:00.000Z", + models: [{ provider: "test", id: "one", label: "One" }], + }, + ], + ]); + off(); + h.receive({ type: "providers_snapshot_update", payload: { ...payload, snapshotHash: "next" } }); + expect(h.sent.filter((f) => f.message?.type === "get_providers_snapshot_request")).toHaveLength( + 1, + ); + } finally { + await h.client.close(); + } +}); + +test("SDK listeners preserve the app's explicit timeline membership", async () => { + const h = connection(); + try { + const ready = h.client.connect(); + h.open(); + await ready; + await h.client.setAgentTimelineSubscription(["visible"]); + const off = h.client.subscribeAgentTimeline("plugin", () => {}); + await h.client.setAgentTimelineSubscription(["next"]); + expect(h.sent.at(-1)?.message?.agentIds).toEqual(["next", "plugin"]); + off(); + expect(h.sent.at(-1)?.message?.agentIds).toEqual(["next"]); + } finally { + await h.client.close(); + } +}); + +test("timeline readiness waits for daemon acknowledgement, including shared listeners", async () => { + const h = connection({ acknowledgeSubscriptions: false }); + try { + const connect = h.client.connect(); + h.open(); + await connect; + const first = h.client.subscribeAgentTimeline("agent", () => {}); + const second = h.client.subscribeAgentTimeline("agent", () => {}); + expect(first.ready).toBeInstanceOf(Promise); + let ready = false; + void second.ready.then(() => { + return (ready = true); + }); + await Promise.resolve(); + expect(ready).toBe(false); + const request = h.sent.at(-1)!.message!; + h.receive({ + type: "agent.timeline.set_subscription.response", + payload: { requestId: request.requestId, agentIds: ["agent"] }, + }); + await Promise.all([first.ready, second.ready]); + expect(ready).toBe(true); + h.disconnect(); + const third = h.client.subscribeAgentTimeline("agent", () => {}); + let reconnected = false; + void third.ready.then(() => { + return (reconnected = true); + }); + const reconnect = h.client.connect(); + h.open(); + await reconnect; + expect(reconnected).toBe(false); + h.receive({ + type: "agent.timeline.set_subscription.response", + payload: { requestId: h.sent.at(-1)!.message!.requestId, agentIds: ["agent"] }, + }); + await third.ready; + expect(reconnected).toBe(true); + first(); + second(); + third(); + } finally { + await h.client.close(); + } +}); + +test("an unresolved provider reference is fetched again after reconnect", async () => { + const h = connection(); + const received: unknown[] = []; + const payload = { entries: [], snapshotHash: "updated", generatedAt: "2026-09-08T00:00:00.000Z" }; + const requests = () => h.sent.filter((f) => f.message?.type === "get_providers_snapshot_request"); + try { + const connect = h.client.connect(); + h.open(); + await connect; + const off = h.client.on("providers_snapshot_update", (m) => + received.push(m.payload.snapshotHash), + ); + h.receive({ type: "providers_snapshot_update", payload }); + expect(requests()).toHaveLength(1); + h.disconnect(); + await Promise.resolve(); + await Promise.resolve(); + const reconnect = h.client.connect(); + h.open(); + await reconnect; + expect(requests()).toHaveLength(2); + h.receive({ + type: "get_providers_snapshot_response", + payload: { + ...payload, + requestId: requests()[1].message?.requestId, + compactSnapshot: { entries: [], thinkingSets: [] }, + }, + }); + await expect.poll(() => received).toEqual(["updated"]); + off(); + } finally { + await h.client.close(); + } +}); + +test("timeline readiness remains pending before connect and rejects when released", async () => { + const h = connection(); + try { + const release = h.client.subscribeAgentTimeline("agent", () => {}); + const canceled = h.client.subscribeAgentTimeline("canceled", () => {}); + canceled(); + await expect(canceled.ready).rejects.toThrow("released"); + let established = false; + void release.ready.then(() => { + return (established = true); + }); + await Promise.resolve(); + expect(established).toBe(false); + expect(h.sent).toEqual([]); + const connect = h.client.connect(); + h.open(); + await connect; + await release.ready; + expect(established).toBe(true); + release(); + } finally { + await h.client.close(); + } +}); + +test("unsubscribing while disconnected discards unresolved provider demand", async () => { + const h = connection(); + try { + const connect = h.client.connect(); + h.open(); + await connect; + const off = h.client.on("providers_snapshot_update", () => {}); + h.receive({ + type: "providers_snapshot_update", + payload: { + entries: [], + snapshotHash: "updated", + generatedAt: "2026-09-08T00:00:00.000Z", + }, + }); + h.disconnect(); + off(); + const reconnect = h.client.connect(); + h.open(); + await reconnect; + expect(h.sent.filter((f) => f.message?.type === "get_providers_snapshot_request")).toHaveLength( + 1, + ); + } finally { + await h.client.close(); + } +}); diff --git a/packages/client/src/connection/index.ts b/packages/client/src/connection/index.ts new file mode 100644 index 0000000000..fe8553588d --- /dev/null +++ b/packages/client/src/connection/index.ts @@ -0,0 +1,119 @@ +import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; +import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; + +// Protocol support belongs to the installed client. Only browser hosting needs +// a resource supplied by the caller. Keep this exhaustive as the protocol evolves. +export const DEFAULT_CLIENT_CAPABILITIES = { + [CLIENT_CAPS.allProviders]: true, + [CLIENT_CAPS.selectiveAgentTimeline]: true, + [CLIENT_CAPS.reasoningMergeEnum]: true, + [CLIENT_CAPS.customModeIcons]: true, + [CLIENT_CAPS.terminalReflowableSnapshot]: true, + [CLIENT_CAPS.providerSubagents]: true, + [CLIENT_CAPS.projectUpdates]: true, + [CLIENT_CAPS.compactProviderSnapshots]: true, + [CLIENT_CAPS.providerSnapshotReferences]: true, + [CLIENT_CAPS.timelineReplacementInvalidation]: true, + [CLIENT_CAPS.timelineNotifications]: true, + [CLIENT_CAPS.pluginTimelineItems]: true, + [CLIENT_CAPS.workspaceSetupBlocked]: true, + [CLIENT_CAPS.explicitEventSubscriptions]: true, +} satisfies Record, true>; + +/** Calling releases demand; ready acknowledges the initial daemon membership. */ +export type TimelineSubscription = (() => void) & { readonly ready: Promise }; + +class TimelineInterest { + resolve!: () => void; + reject!: (error: unknown) => void; + readonly ready = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + + constructor() { + // Readiness is optional for fire-and-forget listeners. + void this.ready.catch(() => {}); + } +} + +/** Owns connection demand, independently of individual facades and React lifetimes. */ +export class ConnectionSubscriptions { + private viewed = new Set(); + private timelines = new Map>(); + private events: SessionEventSubscription[] = []; + + constructor( + private readonly send: { + timelines(agentIds: string[]): Promise | null; + events(events: SessionEventSubscription[]): Promise; + failed(error: unknown): void; + }, + ) {} + + private agentIds(): string[] { + return [...new Set([...this.viewed, ...this.timelines.keys()])].sort(); + } + + setViewed(agentIds: string[]): Promise { + this.viewed = new Set(agentIds); + return this.syncTimelines(); + } + + observeTimeline(agentId: string): TimelineSubscription { + const interests = this.timelines.get(agentId) ?? new Set(); + const interest = new TimelineInterest(); + interests.add(interest); + this.timelines.set(agentId, interests); + // Each caller gets acknowledgement for its own registration, even when an + // existing listener first subscribed on an earlier connection. + void this.syncTimelines().catch(this.send.failed); + let active = true; + return Object.assign( + () => { + if (!active) return; + active = false; + interest.reject(new Error("Timeline subscription released before it was ready")); + interests.delete(interest); + if (interests.size === 0) { + this.timelines.delete(agentId); + void this.syncTimelines().catch(this.send.failed); + } + }, + { ready: interest.ready }, + ); + } + + private async syncTimelines(): Promise { + const interests: TimelineInterest[] = []; + for (const listeners of this.timelines.values()) { + for (const interest of listeners) interests.push(interest); + } + try { + const sent = this.send.timelines(this.agentIds()); + if (!sent) return; + await sent; + for (const interest of interests) interest.resolve(); + } catch (error) { + for (const interest of interests) interest.reject(error); + throw error; + } + } + + setEvents(events: SessionEventSubscription[]): void { + if (JSON.stringify(this.events) === JSON.stringify(events)) return; + this.events = events; + void this.send.events(events).catch(this.send.failed); + } + + restore(): void { + if (this.agentIds().length) void this.syncTimelines().catch(this.send.failed); + if (this.events.length) void this.send.events(this.events).catch(this.send.failed); + } + + close(): void { + for (const interests of this.timelines.values()) { + for (const interest of interests) interest.reject(new Error("Daemon client closed")); + } + } +} diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 44b9189425..bfb582e5ab 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -259,7 +259,7 @@ test("does not infer browser automation capabilities from Electron runtime", asy }) .parse(JSON.parse(assertStr(mock.sent[0]))); expect(hello.capabilities[CLIENT_CAPS.browserHost]).toBeUndefined(); - expect(hello.capabilities[CLIENT_CAPS.selectiveAgentTimeline]).toBeUndefined(); + expect(hello.capabilities[CLIENT_CAPS.selectiveAgentTimeline]).toBe(true); }); test("advertises consumer-provided browser automation capabilities", async () => { @@ -755,6 +755,11 @@ test("advertises client capabilities in hello", async () => { clientType: "cli", protocolVersion: 1, capabilities: { + all_providers: true, + selective_agent_timeline: true, + timeline_replacement_invalidation: true, + provider_snapshot_references: true, + explicit_event_subscriptions: true, compact_provider_snapshots: true, custom_mode_icons: true, project_updates: true, diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 576e111a71..aa2a410ab0 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,3 +1,10 @@ +import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js"; +import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; +import { + ConnectionSubscriptions, + DEFAULT_CLIENT_CAPABILITIES, + type TimelineSubscription, +} from "./connection/index.js"; import type { z } from "zod"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification"; @@ -1066,6 +1073,17 @@ interface PingProbe { } export class DaemonClient { + private readonly providerSnapshotUpdates = new ProviderSnapshotUpdates({ + fetch: (cwd) => this.requestProvidersSnapshot({ cwd }), + emit: (message) => this.deliverSessionMessage(message), + failed: (error) => this.logger.error({ err: error }, "Failed to resolve provider snapshot"), + }); + private readonly subscriptions = new ConnectionSubscriptions({ + timelines: (agentIds) => (this.isConnected ? this.sendTimelineSubscription(agentIds) : null), + events: (events) => this.sendEventSubscription(events), + failed: (error) => + this.logger.error({ err: error }, "Failed to update connection subscriptions"), + }); private transport: DaemonTransport | null = null; private transportCleanup: Array<() => void> = []; private rawMessageListeners: Set<(message: SessionOutboundMessage) => void> = new Set(); @@ -1366,6 +1384,7 @@ export class DaemonClient { return; } this.shouldReconnect = false; + this.subscriptions.close(); this.connectPromise = null; this.connectResolve = null; this.connectReject = null; @@ -1375,6 +1394,7 @@ export class DaemonClient { } this.resetConnectTimeout(); this.disposeTransport(1000, "Client closed"); + this.providerSnapshotUpdates.clear(); this.clearWaiters(new Error("Daemon client closed")); this.rejectPendingSendQueue(new Error("Daemon client closed")); this.rejectPingProbe(new Error("Daemon client closed")); @@ -1451,7 +1471,11 @@ export class DaemonClient { subscribe(handler: DaemonEventHandler): () => void { this.eventListeners.add(handler); - return () => this.eventListeners.delete(handler); + this.updateEventSubscriptions(); + return () => { + this.eventListeners.delete(handler); + this.updateEventSubscriptions(); + }; } subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void { @@ -1481,6 +1505,7 @@ export class DaemonClient { this.messageHandlers.set(type, new Set()); } this.messageHandlers.get(type)!.add(handler); + this.updateEventSubscriptions(); return () => { const handlers = this.messageHandlers.get(type); @@ -1491,6 +1516,7 @@ export class DaemonClient { if (handlers.size === 0) { this.messageHandlers.delete(type); } + this.updateEventSubscriptions(); }; } @@ -3014,7 +3040,70 @@ export class DaemonClient { return payload; } - async setAgentTimelineSubscription(agentIds: string[]): Promise { + setAgentTimelineSubscription(agentIds: string[]): Promise { + return this.subscriptions.setViewed(agentIds); + } + + subscribeAgentTimeline( + agentId: string, + handler: ( + message: Extract< + SessionOutboundMessage, + { type: "agent_stream" | "agent.timeline.replacement" } + >, + ) => void, + ): TimelineSubscription { + const stream = this.on("agent_stream", (message) => { + if (message.payload.agentId === agentId) handler(message); + }); + const replacement = this.on("agent.timeline.replacement", (message) => { + if (message.payload.agentId === agentId) handler(message); + }); + const release = this.subscriptions.observeTimeline(agentId); + return Object.assign( + () => { + stream(); + replacement(); + release(); + }, + { ready: release.ready }, + ); + } + + private updateEventSubscriptions(): void { + const events: SessionEventSubscription[] = [ + "project.update", + "providers_snapshot_update", + "agent_attention_required", + "agent_permission_request", + "agent_permission_resolved", + ]; + if (this.eventListeners.size === 0 && !this.messageHandlers.has("providers_snapshot_update")) { + this.providerSnapshotUpdates.clear(); + } + this.subscriptions.setEvents( + events.filter( + (type) => this.eventListeners.size > 0 || (this.messageHandlers.get(type)?.size ?? 0) > 0, + ), + ); + } + + private async sendEventSubscription(events: SessionEventSubscription[]): Promise { + if (this.connectionState.status !== "connected") return; + // COMPAT(explicitEventSubscriptions): added in v0.8.0, remove legacy broadcast handling after 2027-03-08. + if ( + !this.lastServerInfoMessage?.features?.explicitEventSubscriptions || + this.config.capabilities?.[CLIENT_CAPS.explicitEventSubscriptions] === false + ) + return; + await this.sendCorrelatedSessionRequest({ + message: { type: "session.events.set_subscription.request", events }, + responseType: "session.events.set_subscription.response", + }); + } + + private async sendTimelineSubscription(agentIds: string[]): Promise { + if (this.connectionState.status !== "connected") return; // COMPAT(selectiveAgentTimeline): added in v0.1.106. Old daemons keep their // legacy global stream and do not understand this RPC. Remove after // 2027-01-12 once the supported daemon floor is >= v0.1.106. @@ -4683,7 +4772,16 @@ export class DaemonClient { ifNoneMatch?: string; requestId?: string; }): Promise { - const payload = await this.sendCorrelatedSessionRequest({ + const payload = await this.requestProvidersSnapshot(options); + return normalizeProvidersSnapshotPayload(payload, this.config.providerSnapshots !== "wire"); + } + + private requestProvidersSnapshot(options?: { + cwd?: string; + ifNoneMatch?: string; + requestId?: string; + }): Promise { + return this.sendCorrelatedSessionRequest({ requestId: options?.requestId, message: { type: "get_providers_snapshot_request", @@ -4692,7 +4790,6 @@ export class DaemonClient { }, responseType: "get_providers_snapshot_response", }); - return normalizeProvidersSnapshotPayload(payload, this.config.providerSnapshots !== "wire"); } async getDaemonConfig( @@ -5671,18 +5768,7 @@ export class DaemonClient { clientType: this.config.clientType ?? "cli", protocolVersion: 1, capabilities: { - [CLIENT_CAPS.customModeIcons]: true, - [CLIENT_CAPS.reasoningMergeEnum]: true, - [CLIENT_CAPS.terminalReflowableSnapshot]: true, - [CLIENT_CAPS.providerSubagents]: true, - [CLIENT_CAPS.projectUpdates]: true, - [CLIENT_CAPS.compactProviderSnapshots]: true, - ...(this.config.providerSnapshots === "wire" - ? { [CLIENT_CAPS.providerSnapshotReferences]: true } - : {}), - [CLIENT_CAPS.timelineNotifications]: true, - [CLIENT_CAPS.pluginTimelineItems]: true, - [CLIENT_CAPS.workspaceSetupBlocked]: true, + ...DEFAULT_CLIENT_CAPABILITIES, ...this.config.capabilities, }, ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}), @@ -5699,6 +5785,7 @@ export class DaemonClient { } private disposeTransport(code = 1001, reason = "Reconnecting"): void { + this.providerSnapshotUpdates.pause(); this.stopLivenessHeartbeat(); this.cleanupTransport(); if (this.transport) { @@ -6010,6 +6097,8 @@ export class DaemonClient { this.lastErrorValue = reason.trim(); } + this.providerSnapshotUpdates.pause(); + // Clear all pending waiters and queued sends since the connection was lost // and responses from the previous connection will never arrive. this.clearWaiters(new Error(reason ?? "Connection lost")); @@ -6108,6 +6197,21 @@ export class DaemonClient { } private handleSessionMessage(msg: SessionOutboundMessage): void { + if ( + msg.type === "providers_snapshot_update" && + this.config.providerSnapshots !== "wire" && + msg.payload.snapshotHash && + !msg.payload.compactSnapshot + ) { + if (this.messageHandlers.has(msg.type) || this.eventListeners.size > 0) { + this.providerSnapshotUpdates.receive(msg); + } + return; + } + this.deliverSessionMessage(msg); + } + + private deliverSessionMessage(msg: SessionOutboundMessage): void { const consumerMessage = normalizeProviderSnapshotUpdateMessage( msg, this.config.providerSnapshots !== "wire", @@ -6122,6 +6226,8 @@ export class DaemonClient { this.reconnectAttempt = 0; this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" }); this.startLivenessHeartbeat(); + this.subscriptions.restore(); + this.providerSnapshotUpdates.resume(); this.resubscribeCheckoutDiffSubscriptions(); this.resubscribeTerminalDirectorySubscriptions(); this.resubscribeFileSubscriptions(); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 729ea4bc94..7068cdc0bf 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,4 @@ +import type { DaemonClientConfig } from "./daemon-client.js"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import type { AgentSnapshotPayload, @@ -75,6 +76,7 @@ export interface PaseoLogger { } export interface PaseoClientConfig { + capabilities?: DaemonClientConfig["capabilities"]; url: string; clientId?: string; appVersion?: string; @@ -282,6 +284,15 @@ export type PaseoAgentStream = Extract void; +export type PaseoAgentTimelineEvent = + | PaseoAgentStream + | { + agentId: string; + event: { type: "replacement"; epoch: string }; + }; + +export type PaseoAgentTimelineSubscription = ReturnType; + export interface PaseoAgentTimelineHandle { append(item: Omit): Promise<{ seq: number; epoch: string }>; /** @@ -291,10 +302,12 @@ export interface PaseoAgentTimelineHandle { */ refetch(options?: PaseoAgentTimelineRefetchOptions): Promise; /** - * Local listener for agent_stream events matching this handle id. It does not - * retain timeline entries or own application cache state. + * Subscribe to this agent and restore demand after reconnect. A replacement + * event invalidates previously fetched history; refetch the page you need. + * Await the returned unsubscribe function's `ready` promise before starting + * work that must be observed. It rejects if establishment fails. */ - subscribe(handler: (event: PaseoAgentStream) => void): () => void; + subscribe(handler: (event: PaseoAgentTimelineEvent) => void): PaseoAgentTimelineSubscription; } export interface PaseoAgentHandle { @@ -673,10 +686,15 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor return result; }, subscribe: (handler) => - daemonClient.on("agent_stream", (message) => { - if (message.payload.agentId === id) { - handler(message.payload); - } + daemonClient.subscribeAgentTimeline(id, (message) => { + handler( + message.type === "agent_stream" + ? message.payload + : { + agentId: message.payload.agentId, + event: { type: "replacement", epoch: message.payload.epoch }, + }, + ); }), }, get workspaceId() { diff --git a/packages/client/src/provider-snapshots/index.ts b/packages/client/src/provider-snapshots/index.ts new file mode 100644 index 0000000000..7450a61639 --- /dev/null +++ b/packages/client/src/provider-snapshots/index.ts @@ -0,0 +1,77 @@ +import type { + GetProvidersSnapshotResponseMessage, + SessionOutboundMessage, +} from "@getpaseo/protocol/messages"; + +type Update = Extract; +type Snapshot = GetProvidersSnapshotResponseMessage["payload"]; + +interface PendingSnapshot { + latest: Update; + running: boolean; +} + +/** Resolve announcements only while observed, coalescing changes during a fetch. */ +export class ProviderSnapshotUpdates { + private pending = new Map(); + private paused = false; + + constructor( + private readonly host: { + fetch(cwd: string | undefined): Promise; + emit(update: Update): void; + failed(error: unknown): void; + }, + ) {} + + clear(): void { + this.pending.clear(); + } + + pause(): void { + this.paused = true; + // Invalidate old responses while retaining the announcements they would resolve. + for (const [cwd, pending] of this.pending) { + this.pending.set(cwd, { latest: pending.latest, running: false }); + } + } + + resume(): void { + this.paused = false; + for (const pending of this.pending.values()) this.receive(pending.latest); + } + + receive(update: Update): void { + const cwd = update.payload.cwd; + const pending = this.pending.get(cwd) ?? { latest: update, running: false }; + pending.latest = update; + this.pending.set(cwd, pending); + if (this.paused || pending.running) return; + pending.running = true; + void this.resolve(cwd, pending); + } + + private async resolve(cwd: string | undefined, pending: PendingSnapshot): Promise { + try { + while (this.pending.get(cwd) === pending) { + const announced = pending.latest; + const snapshot = await this.host.fetch(cwd); + if (this.pending.get(cwd) !== pending) return; + // A change arriving while the body was in flight needs the current body. + // If that response already covers it, no second fetch is necessary. + if ( + pending.latest !== announced && + pending.latest.payload.snapshotHash !== snapshot.snapshotHash + ) + continue; + this.pending.delete(cwd); + this.host.emit({ type: "providers_snapshot_update", payload: snapshot }); + return; + } + } catch (error) { + if (this.pending.get(cwd) === pending) this.host.failed(error); + } finally { + if (this.pending.get(cwd) === pending) this.pending.delete(cwd); + } + } +} diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index f15c3ea7d9..f60bf40a34 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -1,5 +1,7 @@ export const CLIENT_CAPS = { // Clients outside the Paseo app version series can explicitly accept provider IDs. + // COMPAT(explicitEventSubscriptions): added in v0.8.0, remove legacy broadcasts after 2027-03-08. + explicitEventSubscriptions: "explicit_event_subscriptions", allProviders: "all_providers", // COMPAT(selectiveAgentTimeline): added in v0.1.106. Capable clients receive // agent streams only for their explicit viewed set. Remove after 2027-01-12 diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 6be32a3525..6faceb4139 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3040,7 +3040,27 @@ export const HubExecutionControlRequestSchema = z.object({ export type HubExecutionControlRequest = z.infer; +// These connection event streams have no directory bootstrap or timeline membership. +export const SessionEventSubscriptionSchema = z.enum([ + "project.update", + "providers_snapshot_update", + "agent_attention_required", + "agent_permission_request", + "agent_permission_resolved", +]); +export type SessionEventSubscription = z.infer; +export const SessionEventsSetSubscriptionRequestSchema = z.object({ + type: z.literal("session.events.set_subscription.request"), + requestId: z.string(), + events: z.array(SessionEventSubscriptionSchema), +}); +export const SessionEventsSetSubscriptionResponseSchema = z.object({ + type: z.literal("session.events.set_subscription.response"), + payload: z.object({ requestId: z.string() }), +}); + export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ + SessionEventsSetSubscriptionRequestSchema, HubExecutionAgentCreateRequestSchema, HubExecutionAgentValidateRequestSchema, HubExecutionControlRequestSchema, @@ -3536,6 +3556,7 @@ export const ServerInfoStatusPayloadSchema = z forgeProviders: z.boolean().optional(), // COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12. selectiveAgentTimeline: z.boolean().optional(), + explicitEventSubscriptions: z.boolean().optional(), // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. canonicalSubmittedPrompts: z.boolean().optional(), // COMPAT(agentTurnIdentity): accept peers that observed pre-release v0.2.6 through 2027-01-31. @@ -6418,6 +6439,7 @@ export const AgentSkillsImportLegacySelectionResponseSchema = z.object({ }); export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ + SessionEventsSetSubscriptionResponseSchema, HubExecutionAgentCreateResponseSchema, HubExecutionAgentValidateResponseSchema, HubExecutionControlResponseSchema, @@ -7098,6 +7120,7 @@ export const WSHelloMessageSchema = z.object({ .object({ voice: z.boolean().optional(), pushNotifications: z.boolean().optional(), + [CLIENT_CAPS.explicitEventSubscriptions]: z.boolean().optional(), [CLIENT_CAPS.allProviders]: z.boolean().optional(), [CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(), [CLIENT_CAPS.selectiveAgentTimeline]: z.boolean().optional(), diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index dd65f47c9d..1f8b9e1d1f 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -20,6 +20,7 @@ const INBOUND_PERMISSION = { "agent.skills.uninstall.request": "daemon.manage", "agent.timeline.list_prompts.request": "workspace.read", "agent.timeline.append.request": "workspace.write", + "session.events.set_subscription.request": ["workspace.read", "daemon.read", "hub.execute"], "agent.timeline.set_subscription.request": ["workspace.read", "hub.execute"], agent_permission_response: "workspace.write", archive_agent_request: ["workspace.write", "hub.execute"], @@ -220,6 +221,7 @@ const OUTBOUND_PERMISSION = { "agent.timeline.list_prompts.response": "workspace.read", "agent.timeline.append.response": "workspace.write", "agent.timeline.replacement": ["workspace.read", "hub.execute"], + "session.events.set_subscription.response": ["workspace.read", "daemon.read", "hub.execute"], "agent.timeline.set_subscription.response": ["workspace.read", "hub.execute"], agent_archived: ["workspace.read", "hub.execute"], agent_attention_required: ["workspace.read", "hub.execute"], diff --git a/packages/server/src/server/client-activity.e2e.test.ts b/packages/server/src/server/client-activity.e2e.test.ts index 5348ea44d8..316fca8d85 100644 --- a/packages/server/src/server/client-activity.e2e.test.ts +++ b/packages/server/src/server/client-activity.e2e.test.ts @@ -84,14 +84,11 @@ describe("client activity tracking", () => { reject(new Error(`Timeout waiting for attention_required (${timeout}ms)`)); }, timeout); - const cleanup = client.on("agent_stream", (msg) => { - if (msg.type !== "agent_stream") return; - if (msg.payload.agentId !== agentId) return; - if (msg.payload.event.type !== "attention_required") return; - + const cleanup = client.onAgentAttentionRequired((notification) => { + if (notification.agentId !== agentId) return; clearTimeout(timer); cleanup(); - resolve(msg.payload.event); + resolve({ type: "attention_required", ...notification }); }); }); } @@ -107,7 +104,7 @@ describe("client activity tracking", () => { reject(new Error(`Timeout waiting for assistant timeline (${timeout}ms)`)); }, timeout); - const cleanup = client.on("agent_stream", (msg) => { + const cleanup = client.subscribeAgentTimeline(agentId, (msg) => { if (msg.type !== "agent_stream") return; if (msg.payload.agentId !== agentId) return; if (msg.payload.event.type !== "timeline") return; diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 1c65316bdc..934125af11 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -1473,7 +1473,7 @@ test("creates agent and exercises lifecycle", async () => { sawAssistantMessage = true; } }); - const unsubscribeRawStream = ctx.client.on("agent_stream", (message) => { + const unsubscribeRawStream = ctx.client.subscribeAgentTimeline(agent.id, (message) => { if (message.type !== "agent_stream") { return; } diff --git a/packages/server/src/server/daemon-e2e/user-message-contract.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/user-message-contract.real.e2e.test.ts index b22327c6ba..41b40fed57 100644 --- a/packages/server/src/server/daemon-e2e/user-message-contract.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/user-message-contract.real.e2e.test.ts @@ -61,7 +61,7 @@ function tmpCwd(provider: ContractProvider): string { function collectUserMessageEvents(client: DaemonClient, agentId: string): AgentStreamEvent[] { const events: AgentStreamEvent[] = []; - client.on("agent_stream", (message) => { + client.subscribeAgentTimeline(agentId, (message) => { if (message.type !== "agent_stream" || message.payload.agentId !== agentId) { return; } diff --git a/packages/server/src/server/plugins/connection-demand.e2e.test.ts b/packages/server/src/server/plugins/connection-demand.e2e.test.ts new file mode 100644 index 0000000000..7c6cbfca28 --- /dev/null +++ b/packages/server/src/server/plugins/connection-demand.e2e.test.ts @@ -0,0 +1,91 @@ +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { createPaseoApi } from "@getpaseo/client"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +test("an RPC-only plugin receives no agent, project or provider data", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-quiet-plugin-")); + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "quiet" })); + await writeFile( + path.join(directory, "index.server.ts"), + ` +import { defineRpc } from "@getpaseo/plugin"; +import { z } from "zod"; +export default function contribute(server) { + const counts = {}; + const observe = message => { + if (message.type !== "paseo_frame" || typeof message.data !== "string") return; + const frame = JSON.parse(message.data); + const type = frame.message?.type; + if (type) counts[type] = (counts[type] ?? 0) + 1; + }; + process.on("message", observe); + server.handle(defineRpc({name:"counts", input:z.object({}), output:z.record(z.string(),z.number())}), () => counts); + return () => process.off("message", observe); +}`, + ); + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const idle = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const legacy = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + capabilities: { + selective_agent_timeline: false, + explicit_event_subscriptions: false, + provider_snapshot_references: false, + }, + }); + const legacyTypes: string[] = []; + legacy.subscribeRawMessages((message) => legacyTypes.push(message.type)); + const idleTypes: string[] = []; + idle.subscribeRawMessages((message) => idleTypes.push(message.type)); + try { + await client.connect(); + await idle.connect(); + await legacy.connect(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + const agent = await client.createAgent({ provider: "claude", cwd: directory }); + const api = createPaseoApi(client); + const received: string[] = []; + const release = api.agents + .ref(agent.id) + .timeline.subscribe((event) => received.push(event.event.type)); + await release.ready; + await daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, { + type: "assistant_message", + id: "row", + text: "only the subscriber sees this", + }); + await expect.poll(() => received).toEqual(["timeline"]); + await client.sendMessage(agent.id, "Say hello"); + await client.waitForFinish(agent.id); + await client.refreshProvidersSnapshot({ cwd: directory }); + const counts = await client.invokePluginRpc("quiet", "counts", {}); + expect(counts).toEqual({ status: 1 }); + await legacy.getDaemonConfig(); + expect(legacyTypes).toEqual( + expect.arrayContaining(["project.update", "providers_snapshot_update", "agent_stream"]), + ); + expect(idleTypes.filter((type) => type !== "status")).toEqual([]); + const deliveredBeforeRelease = received.length; + release(); + await client.getDaemonConfig(); + await daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, { + type: "assistant_message", + id: "row-2", + text: "unsubscribed", + }); + await client.getDaemonConfig(); + expect(received).toHaveLength(deliveredBeforeRelease); + } finally { + await legacy.close(); + await idle.close(); + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 3bda1ec7cc..ae7a105aed 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,3 +1,4 @@ +import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; import type { AgentRequests } from "./agent/requests/index.js"; import equal from "fast-deep-equal"; import { v4 as uuidv4 } from "uuid"; @@ -715,6 +716,8 @@ export class Session { unsubscribe: () => void; } | null = null; private projectSyncEnabled = false; + private readonly defaultEventSubscriptionSource = {}; + private readonly eventSubscriptions = new Map>(); private readonly workspaceUpdateTails = new Map>(); private clientActivity: { deviceType: "web" | "mobile"; @@ -925,6 +928,7 @@ export class Session { isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider), supportsCustomModeIcons: () => this.supports(CLIENT_CAPS.customModeIcons), supportsCompactProviderSnapshots: () => this.supports(CLIENT_CAPS.compactProviderSnapshots), + wantsSnapshotChanges: () => this.wantsEvent("providers_snapshot_update"), supportsProviderSnapshotReferences: () => this.supports(CLIENT_CAPS.providerSnapshotReferences), listProviderAvailability: () => this.agentManager.listProviderAvailability(), @@ -1135,6 +1139,7 @@ export class Session { updateClientCapabilities(capabilities: Record | null, source?: object): void { this.clientCapabilities = parseClientCapabilities(capabilities); if (source) { + this.eventSubscriptions.delete(source); this.clientCapabilitiesBySource.set(source, this.clientCapabilities); } if (!source && !this.supports(CLIENT_CAPS.selectiveAgentTimeline)) { @@ -1145,6 +1150,7 @@ export class Session { clearAgentTimelineSubscription(source: object): void { this.clientCapabilitiesBySource.delete(source); + this.eventSubscriptions.delete(source); if (this.viewedTimelineAgentIdsBySource.delete(source)) { this.rebuildViewedTimelineAgentIds(); } @@ -1223,6 +1229,7 @@ export class Session { continue; const supportsSelectiveDelivery = capabilities.has(CLIENT_CAPS.selectiveAgentTimeline); if (supportsSelectiveDelivery && serializedEvent.type === "attention_required") { + if (!this.wantsEvent("agent_attention_required", source)) continue; this.onMessageToSource(source, { type: "agent_attention_required", payload: { @@ -1267,6 +1274,7 @@ export class Session { } private async publishProjectUpdate(update: ProjectUpdate): Promise { + if (!this.wantsEvent("project.update")) return; const projectedPayload = update.kind === "upsert" ? { kind: "upsert" as const, project: await this.buildProjectDescriptor(update.project) } @@ -1275,15 +1283,7 @@ export class Session { type: "project.update", payload: this.directorySync.sequenceProjectUpdate(projectedPayload, this.projectSyncEnabled), }; - if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) { - if (this.supports(CLIENT_CAPS.projectUpdates)) this.emit(message); - return; - } - for (const [source, capabilities] of this.clientCapabilitiesBySource) { - if (capabilities.has(CLIENT_CAPS.projectUpdates)) { - this.onMessageToSource(source, message); - } - } + this.emit(message); } async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise { @@ -2333,6 +2333,20 @@ export class Session { return this.handleProviderSubagentListRequest(msg); case "agent.provider_subagents.timeline.get.request": return this.handleProviderSubagentTimelineRequest(msg, source); + case "session.events.set_subscription.request": { + this.eventSubscriptions.set( + source ?? this.defaultEventSubscriptionSource, + new Set(msg.events), + ); + this.emitForSource( + { + type: "session.events.set_subscription.response", + payload: { requestId: msg.requestId }, + }, + source, + ); + return undefined; + } case "agent.timeline.set_subscription.request": { const agentIds = [...new Set(msg.agentIds)].sort(); if ( @@ -7703,10 +7717,43 @@ export class Session { /** * Emit a message to the client */ + // COMPAT(explicitEventSubscriptions): added in v0.8.0, remove legacy broadcasts after 2027-03-08. + private wantsEvent(event: SessionEventSubscription, source?: object): boolean { + if (!source && this.clientCapabilitiesBySource.size > 0) { + return [...this.clientCapabilitiesBySource.keys()].some((candidate) => + this.wantsEvent(event, candidate), + ); + } + const capabilities = source + ? this.clientCapabilitiesBySource.get(source)! + : this.clientCapabilities; + if (event === "project.update" && !capabilities.has(CLIENT_CAPS.projectUpdates)) return false; + return ( + !capabilities.has(CLIENT_CAPS.explicitEventSubscriptions) || + this.eventSubscriptions.get(source ?? this.defaultEventSubscriptionSource)?.has(event) === + true + ); + } + private emit(msg: SessionOutboundMessage): void { if (!this.authorization.allowsOutbound(msg)) { return; } + if ( + msg.type === "project.update" || + msg.type === "providers_snapshot_update" || + msg.type === "agent_attention_required" || + msg.type === "agent_permission_request" || + msg.type === "agent_permission_resolved" + ) { + if (this.clientCapabilitiesBySource.size > 0 && this.onMessageToSource) { + for (const source of this.clientCapabilitiesBySource.keys()) { + if (this.wantsEvent(msg.type, source)) this.onMessageToSource(source, msg); + } + return; + } + if (!this.wantsEvent(msg.type)) return; + } // JSON.stringify(msg) is only computed when trace is enabled — it runs for // every outbound message otherwise, and trace is disabled by default. // Optional-chained because test logger stubs don't implement isLevelEnabled. diff --git a/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts b/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts index c8b00332fe..4c83cec6a3 100644 --- a/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts +++ b/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts @@ -681,3 +681,13 @@ describe("subscription lifecycle", () => { expect(h.agentUpdates()).toEqual([{ kind: "remove", agentId: "a" }]); }); }); + +test("an idle session skips agent hydration while preserving workspace updates", async () => { + const h = buildHarness(); + const agent = { ...h.managed("unobserved"), workspaceId: "workspace" }; + // No payload is registered: attempting to hydrate this agent would fail. + await h.service.forwardLiveAgent(agent); + expect(h.loggedErrors).toEqual([]); + expect(h.agentUpdates()).toEqual([]); + expect(h.workspaceUpdates).toEqual([agent.workspaceId]); +}); diff --git a/packages/server/src/server/session/agent-updates/agent-updates-service.ts b/packages/server/src/server/session/agent-updates/agent-updates-service.ts index 61919378c5..a4b1579688 100644 --- a/packages/server/src/server/session/agent-updates/agent-updates-service.ts +++ b/packages/server/src/server/session/agent-updates/agent-updates-service.ts @@ -390,6 +390,18 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU } function forwardLiveAgent(agent: ManagedAgent): Promise { + if (!subscription) { + const workspaceId = agent.workspaceId; + return workspaceId + ? enqueueAgentUpdate(agent.id, async () => { + try { + await deps.emitWorkspaceUpdateForWorkspaceId(workspaceId); + } catch (error) { + deps.logger.error({ err: error }, "Failed to emit workspace update"); + } + }) + : Promise.resolve(); + } const payload = toAgentPayload(agent); return enqueueAgentUpdate(payload.id, () => emitLiveAgentUpdate(payload)); } diff --git a/packages/server/src/server/session/provider/provider-catalog-session.test.ts b/packages/server/src/server/session/provider/provider-catalog-session.test.ts index 653f46917c..b72a5142d9 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.test.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.test.ts @@ -54,6 +54,7 @@ function makeSubsystem(options: MakeOptions = {}) { isProviderVisibleToClient: (provider) => visible.has(provider), supportsCustomModeIcons: () => options.supportsCustomModeIcons ?? false, supportsProviderSnapshotReferences: () => false, + wantsSnapshotChanges: () => true, supportsCompactProviderSnapshots: () => options.supportsCompactProviderSnapshots ?? false, listProviderAvailability: async () => [], listDraftFeatures: async () => [], @@ -395,6 +396,7 @@ it("announces shared content without retransmitting models or hashing discovery isProviderVisibleToClient: () => true, supportsCustomModeIcons: () => true, supportsCompactProviderSnapshots: () => true, + wantsSnapshotChanges: () => true, supportsProviderSnapshotReferences: () => references, listProviderAvailability: async () => [], listDraftFeatures: async () => [], diff --git a/packages/server/src/server/session/provider/provider-catalog-session.ts b/packages/server/src/server/session/provider/provider-catalog-session.ts index f82f1ac56c..7175cabbc9 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.ts @@ -48,6 +48,7 @@ export interface ProviderCatalogSessionHost { supportsCustomModeIcons(): boolean; supportsCompactProviderSnapshots(): boolean; supportsProviderSnapshotReferences(): boolean; + wantsSnapshotChanges(): boolean; listProviderAvailability(): Promise; listDraftFeatures(config: AgentSessionConfig): Promise; } @@ -82,6 +83,7 @@ export class ProviderCatalogSession { start(): void { const handleProviderSnapshotChange = (transition: ProviderSnapshotTransition) => { + if (!this.host.wantsSnapshotChanges()) return; const previous = this.visibleSnapshot(transition.previous); const current = this.visibleSnapshot(transition.current); if (sameSnapshotRecords(previous.records, current.records)) return; diff --git a/packages/server/src/server/voice-local-agent.e2e.test.ts b/packages/server/src/server/voice-local-agent.e2e.test.ts index 9dbe49d74a..29841b7938 100644 --- a/packages/server/src/server/voice-local-agent.e2e.test.ts +++ b/packages/server/src/server/voice-local-agent.e2e.test.ts @@ -32,7 +32,7 @@ function makeErrorHandler(reject: (error: Error) => void) { } function makeSpeakToolHandler(resolve: (value: string) => void) { - return (msg: SessionMessage<"agent_stream">) => { + return (msg: SessionMessage<"agent_stream" | "agent.timeline.replacement">) => { if (msg.type !== "agent_stream") return; if (msg.payload.event.type !== "timeline") return; const item = msg.payload.event.item; @@ -131,7 +131,10 @@ function waitForSignal( }); const speakToolPromise = waitForSignal(120000, (resolve, reject) => { - const offStream = ctx.client.on("agent_stream", makeSpeakToolHandler(resolve)); + const offStream = ctx.client.subscribeAgentTimeline( + targetAgent.id, + makeSpeakToolHandler(resolve), + ); const offError = ctx.client.on("activity_log", makeErrorHandler(reject)); return () => { offStream(); diff --git a/packages/server/src/server/voice-roundtrip.e2e.test.ts b/packages/server/src/server/voice-roundtrip.e2e.test.ts index 8808421b3d..97096c9938 100644 --- a/packages/server/src/server/voice-roundtrip.e2e.test.ts +++ b/packages/server/src/server/voice-roundtrip.e2e.test.ts @@ -225,7 +225,7 @@ for (const targetProvider of [ const timelineToolAgentIds = new Set(); const activityErrors: string[] = []; - const offStream = ctx.client.on("agent_stream", (message) => { + const offStream = ctx.client.subscribeAgentTimeline(voiceAgentId, (message) => { if (message.type !== "agent_stream") { return; } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 8fba3c27ac..4d56418152 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1755,6 +1755,7 @@ export class VoiceAssistantWebSocketServer { forgeProviders: true, // COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12. selectiveAgentTimeline: true, + explicitEventSubscriptions: true, // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. canonicalSubmittedPrompts: true, // COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15. diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index 2cad2a163a..d8166f3204 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -1478,7 +1478,7 @@ export default function contribute(server: PluginServerContext) { Inputs and outputs are validated on both sides. RPC names start with a lowercase letter and contain lowercase letters, numbers, dots, hyphens, or underscores. `useRpc()` returns a typed async function. Use TanStack Query for request state, caching, and mutations. -Backend handlers receive the same `PaseoApi` as `{ paseo }`. Their connection belongs to the subprocess and closes when the plugin stops. Backend code can use Node APIs and dependencies installed in the plugin directory. +Backend handlers receive the same `PaseoApi` as `{ paseo }`. Their connection belongs to the subprocess and closes when the plugin stops. It does not subscribe to timelines or catalog events until plugin code subscribes. Follow the [SDK event contract](../../sdk/events.md) for cleanup and timeline replacements. Backend code can use Node APIs and dependencies installed in the plugin directory. ## Debug backend output diff --git a/public-docs/sdk/events.md b/public-docs/sdk/events.md index 36384554e5..dafba6bcb3 100644 --- a/public-docs/sdk/events.md +++ b/public-docs/sdk/events.md @@ -10,7 +10,7 @@ category: TypeScript SDK Subscriptions report changes after they happen. Fetch an initial snapshot first, then apply updates to it. -Every `subscribe()` method returns a local unsubscribe function. It removes your callback; it does not stop or archive the underlying resource. +Every `subscribe()` method returns an unsubscribe function. Timeline, project, and provider subscriptions establish network demand and restore it after reconnect. Unsubscribing releases that demand when its last listener leaves. Agent and workspace directories use the explicit `list({ subscribe })` bootstrap shown below. Unsubscribing never stops or archives the underlying resource. ## Follow one agent's status @@ -39,17 +39,25 @@ The handle updates its properties and `current()` value before it calls your han ## Follow timeline events ```ts -const unsubscribe = agent.timeline.subscribe(({ event, timestamp }) => { +const unsubscribe = agent.timeline.subscribe((update) => { + const { event } = update; + if (event.type === "replacement") { + // Previously fetched history belongs to an old epoch. Fetch the page your UI needs. + void agent.timeline.refetch().then((page) => console.log(page.entries)); + return; + } if (event.type === "timeline" && event.item.type === "assistant_message") { process.stdout.write(event.item.text); } if (event.type === "turn_completed") { - console.log(`\nCompleted at ${timestamp}`); + console.log("\nTurn completed"); } }); ``` +Await `unsubscribe.ready` before starting work whose events you need to observe. It resolves when the daemon acknowledges the subscription and rejects if establishment fails. Call `unsubscribe()` to release it. + Assistant messages can arrive in pieces. Concatenate their text when you need a complete message, or use `run()` and read `lastMessage` when you only need the final reply. Turn completion comes from `turn_completed`, `turn_failed`, or `turn_canceled`. Do not infer turn completion from an `agent_update` transition to `idle`. diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 0f8bf7200f..85e4d60b9d 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -108,7 +108,7 @@ Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `o `agent.timeline.refetch(options?)` fetches a page. Options are `direction`, `cursor`, `limit`, `projection`, and `requestId`. -`agent.timeline.subscribe(handler)` listens for stream events belonging to the agent and returns a local unsubscribe function. +`agent.timeline.subscribe(handler)` establishes network demand for this agent and restores it after reconnect. Its unsubscribe function releases that demand; await `unsubscribe.ready` for initial daemon acknowledgement before starting work. The handler also receives `{ agentId, event: { type: "replacement", epoch } }` when history is replaced; refetch the page you need. See [events](./events.md#follow-timeline-events). ## `client.projects` From da8c1b5c94e752b01d451645e5fa52aba2c1b2f0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 8 Sep 2026 13:40:21 +0200 Subject: [PATCH 044/566] Make orchestration setup, profiles, and agent communication discoverable (#4486) * docs(orchestration): lead with capabilities and practical delegation * docs(orchestration): correct model requirements and agent list scope --- public-docs/agent-profiles.md | 54 ++++++++++++ public-docs/cli.md | 20 +++-- public-docs/mcp.md | 35 ++++++-- public-docs/orchestration-workflows.md | 115 +++++++++++++------------ public-docs/orchestration.md | 109 +++++++++++------------ public-docs/providers.md | 3 +- public-docs/skills.md | 56 +++++------- 7 files changed, 231 insertions(+), 161 deletions(-) create mode 100644 public-docs/agent-profiles.md diff --git a/public-docs/agent-profiles.md b/public-docs/agent-profiles.md new file mode 100644 index 0000000000..e5084c0cd4 --- /dev/null +++ b/public-docs/agent-profiles.md @@ -0,0 +1,54 @@ +--- +title: Agent profiles +description: Save agent settings for one-click selection and use notes to guide delegation for UI work, planning, and reviews. +nav: Agent profiles +order: 32 +category: Orchestration +--- + +# Agent profiles + +Save the settings you use together as a named profile. Choose **UI work**, **Planning**, or **Review** when creating an agent instead of selecting its model, thinking level, and mode each time. + +- **For you:** one selection applies the saved provider, model, mode, thinking level, and available feature settings. +- **For an orchestrating agent:** profile notes explain when to choose those settings for delegated work. + +## Create a profile + +1. Open **Settings → your host → Agents → Agent profiles**. +2. Select **New profile** and give it a name, such as **UI work**. +3. Choose its provider, model, mode, thinking level, and any available features. +4. Fill in **When to use** with the kind of work this profile should handle. +5. Select **Save**. + +Profiles are saved on that host. The available settings depend on the provider and model you choose. + +## Apply settings in one click + +When creating an agent, open the model picker and select a saved profile under **Profiles**. Paseo applies its settings together; you can still adjust them before sending your prompt. + +Changing a saved profile affects future selections. It does not update agents you already launched. + +## Guide delegation with notes + +The **When to use** field is the profile's notes. Paseo exposes these notes through its tools so an orchestrating agent can read them before choosing how to launch a worker. + +Give each profile a clear specialty: + +| Profile | Example notes | +| -------- | -------------------------------------------------------------------------------------------- | +| UI work | Use for components, layout, styling, and visual polish. | +| Planning | Use for architecture, investigating root causes, and comparing implementation approaches. | +| Review | Use for independent review of diffs, correctness, missing tests, and unnecessary complexity. | + +Each profile can use a different provider and model, or different settings for the same model. + +After [enabling Paseo tools](/docs/orchestration#get-started), ask: + +> Check my Paseo profiles and their notes. Choose one for the settings-page UI change, then use a review profile for an independent review of the diff. + +The orchestrator reads the profiles, selects settings for each task, and launches the workers. Notes guide that choice; put the worker's actual assignment in its task prompt. + +The bundled [Paseo skill](/docs/skills#paseo-paseo-reference) instructs agents to check profiles and read their notes before delegating. If none fit, it directs the agent to discover configured providers and models and tell you about the fallback. + +See [Common workflows](/docs/orchestration-workflows) for delegation examples or the [MCP reference](/docs/mcp#agent-profiles) for the tool interface. diff --git a/public-docs/cli.md b/public-docs/cli.md index f47270144a..72ad71d076 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -1,12 +1,12 @@ --- -title: CLI +title: CLI reference description: "Paseo CLI reference: manage projects, workspaces, agents, plugins, scripts, schedules, daemons, and permissions from your terminal." -nav: CLI -order: 3 -category: Getting started +nav: CLI reference +order: 35 +category: Orchestration --- -# CLI +# CLI reference The Paseo CLI lets you manage agents from your terminal. It's the same interface exposed by the daemon's API, so anything you can do in the app you can do from the command line. @@ -193,10 +193,10 @@ behavior. ## Listing agents ```bash -paseo ls # Running agents in current directory -paseo ls -a # Include completed/stopped agents -paseo ls -g # All directories -paseo ls -a -g --json # Full list as JSON +paseo ls # Non-archived agents in active workspaces +paseo ls -a # Also include archived agents +paseo ls -g # Non-archived agents across all workspaces +paseo ls -a -g --json # All agents, including archived, as JSON ``` ## Streaming output @@ -213,6 +213,8 @@ Agent IDs can be shortened, `abc` works if it's unambiguous. Send follow-up tasks to a running or idle agent: +Use the recipient's agent ID from `paseo ls`, or [copy it from the agent's tab](/docs/orchestration-workflows#send-a-prompt-to-another-agent). + ```bash paseo send "now run the tests" paseo send --image screenshot.png "what's wrong here?" diff --git a/public-docs/mcp.md b/public-docs/mcp.md index ff011f2dfc..a52327174a 100644 --- a/public-docs/mcp.md +++ b/public-docs/mcp.md @@ -2,19 +2,22 @@ title: MCP reference description: Reference for the Paseo tools agents use to manage agents, workspaces, scripts, terminals, and schedules. nav: MCP reference -order: 33 +order: 34 category: Orchestration --- # MCP reference -This is the complete catalog behind the workflows in [Orchestration](/docs/orchestration) and [Common workflows](/docs/orchestration-workflows). You normally ask for an outcome in natural language and let the agent choose the tools. +[Enable Paseo tools](/docs/orchestration#get-started) to give agents this catalog. Ask for an outcome in natural language, or use the tool interfaces below. -Paseo can inject these tools into every new agent it launches. Open **Settings → your host → Agents** and turn on **Enable Paseo tools**, or set `daemon.mcp.injectIntoAgents` to `true`. +## Configuration -Depending on the provider, Paseo delivers the catalog through its native tool interface or MCP. The capabilities are the same either way. +| Setting | Default | Purpose | +| ----------------------------- | ------- | -------------------------------------------------- | +| `daemon.mcp.enabled` | `true` | Run the MCP server. | +| `daemon.mcp.injectIntoAgents` | `false` | Give agents launched by Paseo access to its tools. | -The MCP server itself is controlled by `daemon.mcp.enabled`. Existing agents may need a reload. +Depending on the provider, Paseo delivers tools through its native tool interface or MCP. The capabilities are the same. Start a new agent or reload an existing one after changing injection settings. ## Limit Paseo tools by provider @@ -87,7 +90,7 @@ MCP does not expose an agent-detach tool. Detaching is a manual user action in t | Tool | Function | | -------------------- | --------------------------------------------------------------------------------------- | | `create_agent` | Create an agent, optionally placing it in an existing workspace with `workspaceId`. | -| `send_agent_prompt` | Send a task to a running agent. | +| `send_agent_prompt` | Send a prompt to an existing agent using its `agentId` and a `prompt`. | | `get_agent_status` | Return the latest snapshot for an agent. | | `list_agents` | List recent agents as compact metadata. | | `cancel_agent` | Abort an agent's current run but keep the agent alive. | @@ -150,6 +153,26 @@ Both use the same cron engine, but they have deliberately different interfaces. MCP heartbeats are ephemeral: create or delete them. To change one, delete it and create a replacement. Pause, resume, update, inspect, logs, and run-once apply to new-agent schedules only. +### Agent profiles + +| Tool | Function | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `list_profiles` | Return the host's saved agent profiles, including their notes and launch settings. Returns an empty list when none are configured. | + +Before delegating, read each profile's `notes` and choose the profile the user named or the one that fits the task. See [Agent profiles](/docs/agent-profiles) for setup and example notes. + +`create_agent` has no profile parameter and requires a `provider/model` pair. If the profile has no model, call `list_models` for its provider and choose an available model for the task before launching. Apply the chosen profile's values to the launch request: + +| Profile field | `create_agent` field | +| ------------------------------- | --------------------------------------------------------------------------- | +| `provider` and optional `model` | `provider` as `provider/model`, using the saved or discovered model ID | +| `modeId` | `settings.modeId` | +| `thinkingOptionId` | `settings.thinkingOptionId` | +| `featureValues` | `settings.features` | +| `notes` | Selection guidance for the orchestrator; supply the task in `initialPrompt` | + +Omit absent optional settings. If no profile fits, use provider discovery to choose available settings. + ### Providers | Tool | Function | diff --git a/public-docs/orchestration-workflows.md b/public-docs/orchestration-workflows.md index c1b60328ab..e7e5f271c3 100644 --- a/public-docs/orchestration-workflows.md +++ b/public-docs/orchestration-workflows.md @@ -1,6 +1,6 @@ --- title: Common orchestration workflows -description: Copyable prompts for delegating, parallelizing, reviewing, and continuing agent work with Paseo. +description: Prompts for delegating, parallelizing, reviewing, and continuing agent work with Paseo. nav: Common workflows order: 31 category: Orchestration @@ -8,87 +8,96 @@ category: Orchestration # Common orchestration workflows -These examples are prompts for your main agent. Change the provider, model, task, and branch names to fit your work. +[Enable Paseo tools](/docs/orchestration#get-started), then ask your main agent for one of these workflows. Adapt the tasks to your project. [Agent profiles](/docs/agent-profiles) let you save settings and describe which agent to use for each kind of work. ## Send work to a different model -Keep a strong planner in the main chat and send implementation to a workhorse: +Keep planning in your main chat and delegate implementation: -```text -Stay as the orchestrator. Use Paseo to find the available Codex 5.6 model, then -create a worktree-isolated workspace and launch a subagent there. Ask it to -implement the parser change and run the focused tests. -``` +> Use Paseo to check my agent profiles and their notes. Choose one for implementation, create a workspace with worktree isolation, and launch a subagent there to implement the parser change. Have it run the focused tests and report back. -Ask the orchestrator to inspect providers first when you are unsure of the exact model ID. Available models come from your own installed and authenticated CLIs. +The worker makes the change in its own worktree. If no profile fits, the agent can discover your configured providers and models and tell you what it chose. ## Fan out research -Read-only work can safely share one workspace: +Split independent questions between workers: -```text -Create three Paseo subagents in this workspace. Have one trace the request path, -one inspect the tests, and one look for related regressions. Do not edit files. -Synthesize their findings when all three report back. -``` +> Create three Paseo subagents in this workspace. Have one trace the request path, one inspect the tests, and one look for related regressions. Do not edit files. Synthesize their findings when all three report back. -Each worker appears in the Subagents track, and the orchestrator can keep working while they run. +The workers share your files and appear in the Subagents track. Your main agent collects their findings into one answer. ## Parallelize edits without collisions -Give each independent implementation its own worktree-isolated workspace: +Give each independent change its own worktree: -```text -Split these two issues between two Paseo subagents. Create a separate workspace -with worktree isolation from main for each issue, use the best available -implementation model, and have each agent run the focused checks for its change. -Summarize both diffs when done. -``` +> Split these two issues between two Paseo subagents. Create a separate workspace with worktree isolation from main for each issue. Check my profiles for suitable implementation settings, and have each agent run the focused checks for its change. Summarize both diffs when done. -Use the current workspace for collaboration on the same files. Use worktrees when agents may edit independently. +Each worker edits a separate checkout. You get two changes to inspect, with their check results. ## Implement, then review -Use different models for making and judging the change: +Choose different agents for making and judging a change: -```text -Create a worktree-isolated workspace and launch a worker there to implement this -feature. When it finishes, create a second subagent in the same workspace to -review the diff for correctness, missing tests, and unnecessary complexity. -Bring the review back here. -``` +> Check my Paseo profiles and their notes for implementation and review. Create a workspace with worktree isolation and launch an implementation worker there. When it finishes, launch an independent reviewer in that workspace to check correctness, missing tests, and unnecessary complexity. Bring the review back here. -The second agent sees the worker's files without sharing its conversation context, which makes the review more independent. +The reviewer sees the worker's files in a fresh conversation. Use profiles from different providers when you want another model's judgment. + +## Send a prompt to another agent + +Agents can prompt each other by **agent ID**, the identifier Paseo uses to address a specific agent. They can communicate across workspaces on the same host, including with agents they did not launch. + +1. Right-click the receiving agent's tab and select **Copy agent id**. +2. Paste that ID into your conversation with the sending agent and ask: + +> Use Paseo to send this prompt to agent [paste agent ID here]: “Review the parser changes in your workspace and report any missing test cases.” + +The receiving agent gets the prompt in its existing conversation. To have it send a separate message back, give it the sender's agent ID too. + +Underneath, the agent calls `send_agent_prompt` with the recipient's `agentId` and a `prompt`. Agents can also discover IDs with `list_agents`; the CLI uses [`paseo send `](/docs/cli#sending-messages). For another host, use the [remote CLI workflow](#work-on-another-machine). ## Check, redirect, or continue work -The orchestrator can inspect a worker and send a follow-up without starting over: +Send these prompts separately as needed: -```text -Summarize what the subagents are doing and flag anything blocked. -``` +> Summarize what the subagents are doing and flag anything blocked. -```text -Tell the parser worker to add the malformed-input case and rerun its test file. -``` +You get a progress summary in the main conversation. -```text -Cancel the UI worker's current turn, but keep the agent so I can redirect it. -``` +> Tell the parser worker to add the malformed-input case and rerun its test file. + +The worker continues in its existing conversation. + +> Cancel the UI worker's current turn, but keep the agent so I can redirect it. + +The current task stops; the worker remains available for a follow-up. + +## Work on another machine + +The CLI can target another Paseo host. First [connect it to the remote daemon](/docs/cli#connecting-to-a-remote-daemon) and identify a workspace on that host. The repository and required provider must be available there. + +> Use the Paseo CLI with the remote host and workspace I supplied. Launch an agent there to investigate the failing build, inspect its output, and summarize the findings here. Do not change files. + +The worker runs on the remote machine. Use the CLI's global `--host` option for that destination; injected Paseo tools operate on the main agent's own host. ## Keep an agent working with a heartbeat -Use a heartbeat when the current agent should wake itself up, reassess the task, and continue working: +A heartbeat prompts the same agent periodically, preserving its conversation: -```text -Use Paseo to create a heartbeat every 10 minutes. Continue this migration in -small steps, run the focused checks after each step, and stop when the migration -is complete or after two hours. -``` +> Use Paseo to create a heartbeat every 10 minutes. Continue this migration in small steps, run the focused checks after each step, and delete the heartbeat when the migration is complete. Set it to expire after two hours. -```text -Create a heartbeat every 5 minutes to check this deployment. Investigate any -failure and report meaningful changes in this conversation. Stop after one hour. -``` +The agent resumes the task on that cadence. For a fresh agent on each run, [create a schedule](/docs/schedules) instead. -A heartbeat returns to the same conversation. For cron-style recurring work such as daily triage, use a [schedule](/docs/schedules). For reusable workflows such as handoffs, committees, advisors, and bounded loops, see [Orchestration skills](/docs/skills). +## Where the work appears + +Open the **Subagents track** near the composer to inspect delegated work. + +| | Paseo subagents | Native provider subagents | +| ------------ | ---------------------------------------------- | ---------------------------------------------- | +| Provider | Any configured provider | Chosen within the parent provider's own system | +| Workspace | Current or explicitly selected workspace | Managed by the provider | +| Conversation | Full agent session you can talk to | Read-only timeline | +| Controls | Follow up, change settings, archive, or detach | Lifecycle managed by the provider | + +A Paseo subagent in another workspace still belongs to its parent's track. It also opens as a tab in its own workspace. To make it a top-level agent, detach it in the app or with [`paseo agent detach`](/docs/cli#agent-modes). + +See the [MCP reference](/docs/mcp#mental-model) for workspace and parentage rules. diff --git a/public-docs/orchestration.md b/public-docs/orchestration.md index aec9b51f9e..74c15a36aa 100644 --- a/public-docs/orchestration.md +++ b/public-docs/orchestration.md @@ -1,6 +1,6 @@ --- title: Orchestration -description: Give any coding agent control of Paseo so it can launch and coordinate agents from other providers. +description: Coordinate agents across providers and machines, delegate work, and keep tasks moving with schedules and heartbeats. nav: Overview order: 30 category: Orchestration @@ -8,72 +8,63 @@ category: Orchestration # Orchestration -Paseo orchestration gives a coding agent control of the Paseo daemon. The agent can discover every provider and model you have configured, create workspaces, launch other agents, send them follow-ups, and create heartbeats or schedules. The same work stays visible in the Paseo app. +Paseo lets your coding agents coordinate other agents, split work across providers and machines, and keep tasks moving automatically. -## Native subagents vs Paseo subagents +## What your agents can do -The most important difference from native subagents is that **Paseo subagents can cross provider boundaries**. +- **Choose providers and models:** launch other agents using any provider and model configured on the host. +- **Delegate and parallelize:** split research, implementation, and review between agents, including agents from different providers. +- **Communicate with each other:** agents can [send prompts to other agents by ID](/docs/orchestration-workflows#send-a-prompt-to-another-agent) to ask questions, share findings, or request work. +- **Coordinate ongoing work:** check progress, stop tasks, and collect results. +- **Create workspaces and worktrees:** give independent changes their own [working directories](/docs/worktrees). +- **Work across machines:** use the [CLI](/docs/cli#connecting-to-a-remote-daemon) to launch and manage agents on another reachable Paseo host. +- **Choose by specialty:** use [agent profiles](/docs/agent-profiles) and their notes to select settings for UI work, planning, or reviews. +- **Create schedules:** run a prompt in a new agent at [specified times](/docs/schedules). +- **Create heartbeats:** prompt the same agent periodically to [continue its task](/docs/orchestration-workflows#keep-an-agent-working-with-a-heartbeat). -```text -Claude Code (Fable 5) => Codex (GPT-5.6) -Codex (GPT-5.6) => Grok Build -Cursor => Claude Code (Fable 5) +## Get started + +Use built-in Paseo tools or the CLI. Both let an agent launch and coordinate workers. + +### Built-in Paseo tools (MCP) + +Enable Paseo tools so agents running inside Paseo can manage agents and workspaces on their host directly. + +1. Open **Settings → your host → Agents**. +2. Turn on **Enable Paseo tools**. Tool injection is off by default. +3. Start a new agent, or reload an existing agent so it receives the tools. +4. Ask: + +> Use Paseo to launch a second agent to review this branch. Have it report potential bugs without changing files, then summarize its findings. + +The worker appears in the **Subagents track** near the composer. Open it to follow the conversation. Your main agent receives a notification when the worker finishes, and you can keep talking while it works. + +See the [MCP reference](/docs/mcp) for tool configuration and the full catalog. [Orchestration skills](/docs/skills) are optional reusable workflows. + +### CLI + +Agents with shell access can also use the Paseo CLI. This route does not require enabling tool injection. With Paseo installed, a running host, and Codex configured: + +```bash +paseo run --provider codex --background \ + "Review this branch without changing files" +paseo ls -a ``` -Native subagents belong to one provider. Claude Code launches Claude Code subagents; Codex launches Codex subagents. They are useful when the parent provider can handle the whole task itself. +The first command starts a worker and returns immediately; the second lists agents from active workspaces, including archived agents. When a Paseo agent runs the command, the worker becomes its subagent in the same workspace. From your own terminal, it starts in a new local workspace. -Paseo subagents are full agents managed by the Paseo daemon. The orchestrator can choose any configured provider and model, keep the worker in the current workspace, or place it in another workspace created for the task. Use them when you want one model to plan, another to implement, and another to review. +See the [CLI reference](/docs/cli) for follow-ups, output, worktrees, and remote hosts. -| | Native subagent | Paseo subagent | -| -------------------- | ----------------------------------------- | -------------------------------------------------- | -| Provider | Same provider as its parent | Any provider configured in Paseo | -| Working directory | Managed by the parent provider | Current or explicitly selected workspace | -| Lifecycle | Owned by the parent provider | Managed by Paseo; can receive follow-ups | -| Where you inspect it | Read-only timeline in the Subagents track | Full agent session in the Subagents track | -| Best for | Fast, provider-native delegation | Cross-provider work and explicit workspace control | +## Pick settings once with profiles -## Try it +Save a provider, model, thinking level, mode, and available feature settings as an **agent profile**, then select it in one click when creating an agent. -Open **Settings → your host → Agents**, then turn on **Enable Paseo tools**. Start a new agent, or reload an existing one so it receives the tools. +Add **When to use** notes so an orchestrating agent can choose a profile for the task: UI work, planning, or independent review. [Create profiles and write delegation notes](/docs/agent-profiles). -Then ask naturally: +## Go further -```text -Stay as the orchestrator. Use Paseo to find my available Codex models, then -create a worktree-isolated workspace, then launch a GPT-5.6 subagent there. Ask -it to implement the parser change, run the focused tests, and report back here. -``` - -The orchestrator discovers the provider and model IDs, starts the worker, and receives a notification when it finishes. You can keep talking to the orchestrator in the meantime. - -Agent creation has one default: when an agent creates another agent without a workspace ID, the new agent is its subagent in the same workspace. Passing a workspace ID changes where the subagent works, not who its parent is. - -## Where the work appears - -Spawned work appears in the **Subagents track** above the composer. Open a row to read the live conversation. - -Both kinds of subagent appear there: - -- **Paseo subagents** open as full agent sessions. You can talk to them directly, change their settings, or archive them. -- **Native provider subagents** open as read-only timelines. You can inspect their work, but their provider owns their lifecycle. - -A cross-workspace subagent still belongs to its parent's Subagents track. Paseo also opens its workspace so the work is not hidden in an otherwise empty workspace. If you want to turn any subagent into a top-level agent, detach it manually in the app or with `paseo agent detach`; detachment is not an agent-creation mode. - -If an agent says background work is running but the track is empty, update Paseo. Provider-created subagent timelines require Paseo 0.1.107 or newer. - -## Keep an agent working with a heartbeat - -A heartbeat sends a prompt back into the same agent on a cron cadence. Use one when the agent should keep reassessing a live task: continue a refactor, babysit CI, watch a deployment, or retry after an external system changes. - -Ask the agent directly: - -```text -Use Paseo to create a heartbeat every 10 minutes. Keep checking this PR, fix any -new CI failures, and stop when all checks pass or after two hours. -``` - -The base [`/paseo` orchestration skill](/docs/skills) teaches agents how to create heartbeats, so you only need to ask. A heartbeat continues the current conversation; a [schedule](/docs/schedules) is better for standalone cron-style jobs such as daily triage. - -You do not need to name MCP tools in your prompts. Ask for the workflow; the agent uses the tools underneath. - -Continue with [Common workflows](/docs/orchestration-workflows) for copyable prompts, [Orchestration skills](/docs/skills) for packaged workflows, or the [MCP reference](/docs/mcp) for the complete tool catalog. +- [Delegate implementation and get an independent review](/docs/orchestration-workflows#implement-then-review). +- [Run parallel changes in separate worktrees](/docs/orchestration-workflows#parallelize-edits-without-collisions). +- [Coordinate work on another machine](/docs/orchestration-workflows#work-on-another-machine). +- [Create recurring jobs](/docs/schedules) or [continue a task with a heartbeat](/docs/orchestration-workflows#keep-an-agent-working-with-a-heartbeat). +- [Install reusable orchestration skills](/docs/skills). diff --git a/public-docs/providers.md b/public-docs/providers.md index fbf5e0c84c..ea7f09e36e 100644 --- a/public-docs/providers.md +++ b/public-docs/providers.md @@ -24,5 +24,6 @@ Either way, **you install the underlying CLI**. Paseo runs it. ## Where to go next - [Supported providers](/docs/supported-providers), the full list with install links. -- [Custom providers](/docs/custom-providers), add your own provider, point an existing one at a different endpoint, run multiple profiles, or override the binary in `~/.paseo/config.json`. +- [Agent profiles](/docs/agent-profiles), save model, mode, and thinking settings together, with notes to guide delegation. +- [Custom providers](/docs/custom-providers), add your own provider, point an existing one at a different endpoint, configure multiple provider aliases, or override the binary in `~/.paseo/config.json`. - [paseo.sh/agents](/agents), per-agent landing page for each supported provider. diff --git a/public-docs/skills.md b/public-docs/skills.md index c1fae01515..c8ac09c4b6 100644 --- a/public-docs/skills.md +++ b/public-docs/skills.md @@ -1,65 +1,55 @@ --- title: Orchestration skills -description: "Paseo orchestration skills: teach coding agents to spawn, coordinate, and manage other agents using slash commands." +description: Reusable workflows for handing off tasks, getting a second opinion, and planning with multiple agents. nav: Skills -order: 32 +order: 33 category: Orchestration --- # Orchestration skills -Paseo ships orchestration skills that teach coding agents how to use Paseo tools and the CLI to spawn, coordinate, and manage other agents. Skills package common workflows as slash commands, so agents know how to orchestrate without you writing the briefing and safety rails each time. +Skills give your agents reusable instructions for delegation, handoffs, and reviews. You can also [ask for these workflows directly](/docs/orchestration-workflows) without installing skills. -Start with [Orchestration](/docs/orchestration) if you want the mental model, or [Common workflows](/docs/orchestration-workflows) for prompts you can use without installing skills. +| Skill | Use it to | +| ------------------ | -------------------------------------------------------------------- | +| `/paseo` | Look up how to manage agents, workspaces, schedules, and heartbeats. | +| `/paseo-handoff` | Transfer a task and its context to another agent. | +| `/paseo-committee` | Get two independent analyses of a difficult problem. | +| `/paseo-advisor` | Get a second opinion on your current work. | ## Installation -Two ways to install: +- **In Paseo:** Open **Settings → your host → Agents → Orchestration skills** and choose which skills to install on that host. +- **From the terminal:** Run `npx skills add getpaseo/paseo` on the machine where your agents run. -- **Paseo app:** Connect to the host, then open Settings → Host → Agents → Orchestration skills. The selected host installs the skills on its own machine. -- **Manual:** `npx skills add getpaseo/paseo`, this installs to `~/.agents/skills/` and sets up symlinks for each agent. - -When a daemon finds installed Paseo skills, it keeps the selected bundled skills up to date on startup without removing deselected directories. Use the host's Orchestration skills card to install, update, choose, or uninstall skills. Removal always asks for confirmation. +Use the same settings card to update or uninstall skills. The host also refreshes selected installed Paseo skills on startup. ## `/paseo`, Paseo Reference -The foundational skill. Paseo reference for managing projects, workspaces, and agents. Load it when an agent needs to register a project, create agents, send them prompts, or manage workspace isolation. +The foundational reference used by the other skills. It teaches agents to check your [agent profiles and their notes](/docs/agent-profiles#guide-delegation-with-notes) before delegating, then apply the selected launch settings. If no profile fits, it directs them to discover available providers and models and tell you about the fallback. -Not typically invoked directly by users, it's a reference that other skills depend on. - -``` -/paseo show me the Paseo CLI surface for creating an agent in a worktree-isolated workspace -``` +> /paseo show me how to create an agent in a workspace with worktree isolation ## `/paseo-handoff`, Task Handoff -Hands off the current task to another agent with full context. Use it when you say "handoff", "hand off", "hand this to", or want to pass work to another agent. +Transfer the current task with a briefing: relevant files, progress, decisions, constraints, and acceptance criteria. The skill checks profiles before choosing the receiving agent; you can name the profile you want. -The receiving agent gets a self-contained briefing with the task, context, relevant files, current state, what's been tried, decisions, acceptance criteria, and constraints. Provider comes from orchestration preferences unless you name one. Supports worktree-isolated workspaces when you ask for one. +> /paseo-handoff hand off the auth fix to an implementation agent in its own worktree -``` -/paseo-handoff hand off the auth fix to codex in a worktree-isolated workspace -/paseo-handoff hand this to claude opus for review -``` +The receiving agent gets the context it needs to continue. Ask for a separate worktree when it should edit independently. ## `/paseo-committee`, Committee Planning -Forms a committee of two high-reasoning agents to step back, do root cause analysis, and produce a plan. Use it when stuck, looping, tunnel-visioning, or facing a hard planning problem. +Get two agents to analyze a difficult problem independently. The skill checks profile notes for planning and analysis, preferring different provider families when possible. -Committee members do analysis only. They do not edit, create, or delete files. The orchestrating agent synthesizes their plans, implements, then sends the diff back for review. +> /paseo-committee why are the websocket connections dropping under load? -``` -/paseo-committee why are the websocket connections dropping under load? -/paseo-committee plan the auth system migration -``` +Committee members return analyses without editing files. The main agent synthesizes their plans, implements the solution, and sends the diff back for review. ## `/paseo-advisor`, Advisor -Spins up a single agent as an advisor, a second opinion on the current task. Use it when you say "advisor", "second opinion", "what does X think", or want an outside take without delegating the work itself. +Get another agent's judgment on a design, diff, or question. The skill chooses a profile whose notes fit the work, or uses the profile you name. -The advisor gives a judgment. You decide what to do. The advisor prompt is analysis-only and ends with a no-edits instruction. +> /paseo-advisor did I miss anything in this migration plan? -``` -/paseo-advisor did I miss anything in this migration plan? -/paseo-advisor --provider claude/opus what is the UX risk in this flow? -``` +The advisor returns a second opinion without editing files. From 613cbbe9ef7b461bdd7e859cf2303598100ad924 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 9 Sep 2026 08:51:55 +0200 Subject: [PATCH 045/566] Fix Codex continuation after workspace archive (#4512) * Fix native writer ownership when restoring archived agents * Keep archive QA mechanics in a scenario helper --- .../src/server/agent/agent-manager.test.ts | 47 ++++++++ .../server/src/server/agent/agent-manager.ts | 5 +- .../agent-archive.native.real.e2e.test.ts | 110 ++++++++++++++++++ 3 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 packages/server/src/server/daemon-e2e/agent-archive.native.real.e2e.test.ts diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index ccc9467a25..fdd3b5f763 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -7765,6 +7765,53 @@ test("fires onAgentArchived for stored-only snapshot archives", async () => { expect(archivedIds).toEqual([storedOnly.id]); }); +test("native archive and restore release the loaded session writer", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-native-archive-writer-")); + class WriterClient extends NativeArchiveRecordingClient { + session: CloseRecordingTestAgentSession | undefined; + override async createSession(config: AgentSessionConfig): Promise { + this.session = new CloseRecordingTestAgentSession(config); + return this.session; + } + override async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + return this.createSession({ provider: "codex", cwd: workdir, ...config }); + } + override async archiveNativeSession(handle: AgentPersistenceHandle): Promise { + if (!this.session?.closed) throw new Error("native thread has an active writer"); + await super.archiveNativeSession(handle); + } + override async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise { + if (!this.session?.closed) throw new Error("native thread has an active writer"); + await super.unarchiveNativeSession(handle); + } + } + const client = new WriterClient(); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + await manager.archiveAgent(agent.id); + expect(client.archivedHandles).toHaveLength(1); + // Opening archived history can retain a writer, including records archived by older daemons. + await ensureAgentLoaded(agent.id, { agentManager: manager, agentStorage: storage, logger }); + expect(client.session?.closed).toBe(false); + await manager.unarchiveSnapshot(agent.id); + expect(client.unarchivedHandles).toHaveLength(1); + expect((await storage.get(agent.id))?.archivedAt).toBeNull(); + expect(client.session?.closed).toBe(true); + await ensureAgentLoaded(agent.id, { agentManager: manager, agentStorage: storage, logger }); + expect(client.session?.closed).toBe(false); + } finally { + for (const agent of manager.listAgents()) await manager.closeAgent(agent.id); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("unarchiveSnapshot skips native provider unarchive for active records", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-unarchive-active-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index d16f0cf4cf..c1f7a781d4 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1703,6 +1703,7 @@ export class AgentManager { const { archivedAt } = await this.markRecordArchived(stored); agent.updatedAt = new Date(archivedAt); await this.closeAgentRuntime(agentId); + await this.syncNativeArchiveState(stored.provider, stored.persistence, "archive"); this.discardRetainedAgentState(agentId); await this.cascadeArchiveChildren(agentId); @@ -1762,8 +1763,6 @@ export class AgentManager { updatedAt: archivedAt, }); - await this.syncNativeArchiveState(record.provider, record.persistence, "archive"); - if (this.agents.has(record.id)) { this.notifyAgentState(record.id); } else if (!archivedRecord.internal) { @@ -2090,6 +2089,8 @@ export class AgentManager { return false; } + // Archived history may have loaded a runtime that still owns the native writer. + await this.closeAgent(agentId); await this.syncNativeArchiveState(record.provider, record.persistence, "restore"); await registry.upsert({ diff --git a/packages/server/src/server/daemon-e2e/agent-archive.native.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-archive.native.real.e2e.test.ts new file mode 100644 index 0000000000..17dce841cf --- /dev/null +++ b/packages/server/src/server/daemon-e2e/agent-archive.native.real.e2e.test.ts @@ -0,0 +1,110 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { expect, test } from "vitest"; +import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +async function expectContextAfterWorkspaceRestore(legacyNativeArchive: boolean): Promise { + const root = mkdtempSync(path.join(tmpdir(), "paseo-archive-codex-")); + const cwd = path.join(root, "repo"); + mkdirSync(cwd); + execFileSync("git", ["init", "-b", "main", cwd]); + execFileSync( + "git", + [ + "-c", + "user.name=QA", + "-c", + "user.email=qa@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "QA", + ], + { cwd }, + ); + const logger = pino({ level: "warn" }); + const provider = new CodexAppServerAgentClient(logger); + let daemon: TestPaseoDaemon | undefined; + let client: DaemonClient | undefined; + try { + daemon = await createTestPaseoDaemon({ + agentClients: { codex: provider }, + logger, + pluginsEnabled: false, + }); + client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "archive-qa" } }); + const agent = await client.createAgent({ + config: { + provider: "codex", + cwd, + model: "gpt-5.6-sol", + modeId: "full-access", + thinkingOptionId: "low", + }, + worktree: { mode: "branch-off", base: "main", newBranch: "archive-qa" }, + }); + const manager = daemon.daemon.agentManager; + const token = "ARCHIVE_CEDAR_7429"; + const first = await manager.runAgent( + agent.id, + `Remember ${token}. Reply with only that token. Do not use tools.`, + ); + expect(first.finalText).toContain(token); + const handle = manager.getAgent(agent.id)!.persistence!; + await client.archiveWorkspace(agent.workspaceId!); + expect(existsSync(agent.cwd)).toBe(false); + // Older daemons marked Paseo archived while native archive failed against its writer. + if (legacyNativeArchive) await provider.unarchiveNativeSession(handle); + await client.restoreWorkspace(agent.workspaceId!); + expect(existsSync(agent.cwd)).toBe(true); + await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 }); + await client.sendAgentMessage( + agent.id, + "What token did I ask you to remember? Reply with RECALLED=. Do not use tools.", + { activeTurnBehavior: "steer" }, + ); + const connectedClient = client; + await expect + .poll( + async () => { + const timeline = await connectedClient.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + return timeline.entries + .map(({ item }) => (item.type === "assistant_message" ? item.text : "")) + .join(""); + }, + { timeout: 30_000 }, + ) + .toContain(`RECALLED=${token}`); + expect(manager.getAgent(agent.id)?.persistence?.sessionId).toBe(handle.sessionId); + process.stdout.write( + `archived -> restored; same agent=${agent.id}; same session=${handle.sessionId}; two real replies recall ${token}; legacy=${legacyNativeArchive}\n`, + ); + await client.archiveWorkspace(agent.workspaceId!); + } finally { + await client?.close(); + await daemon?.close(); + rmSync(root, { recursive: true, force: true }); + } +} + +// Opt in: real Codex requests using local authentication. +test.runIf(process.env.PASEO_NATIVE_ARCHIVE_QA === "1").each([false, true])( + "Codex recalls context after workspace restore (legacy native archive: %s)", + async (legacyNativeArchive) => { + await expectContextAfterWorkspaceRestore(legacyNativeArchive); + }, + 90_000, +); From c172076bffa84e4ccf3beffa0993bac6579801fd Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 9 Sep 2026 09:15:28 +0200 Subject: [PATCH 046/566] Initialize Hub with organization triggers (#4517) * fix(cli): initialize current Hub triggers Hub init still authored and deployed project bundles after hosted configuration moved to organization triggers. Generate the self-contained trigger contract and keep project bundle deployment behind the existing --project path. * fix(cli): harden Hub trigger setup * docs(hub): align public setup guides with organization triggers --- docs/hub.md | 2 +- packages/cli/src/commands/hub/client.test.ts | 82 +++--- .../src/commands/hub/deploy-contract.test.ts | 177 ++++++++++++- .../src/commands/hub/deploy-triggers.test.ts | 64 +++++ .../cli/src/commands/hub/deploy-triggers.ts | 140 ++++++++++ packages/cli/src/commands/hub/deploy.ts | 136 +++++++++- .../cli/src/commands/hub/hub-client/index.ts | 55 ++-- .../hub/hub-client/internal/contracts.ts | 37 ++- .../cli/src/commands/hub/init-flow.test.ts | 177 ++++++++++--- .../cli/src/commands/hub/init-plan.test.ts | 162 ++++-------- packages/cli/src/commands/hub/init-plan.ts | 170 ++++-------- packages/cli/src/commands/hub/init.ts | 247 +++++++----------- .../src/commands/hub/starter-trigger.test.ts | 20 +- .../cli/src/commands/hub/starter-trigger.ts | 42 +-- public-docs/cli.md | 19 +- public-docs/hub/api.md | 54 +++- public-docs/hub/configuration/index.md | 122 ++++----- public-docs/hub/daemons.md | 4 +- public-docs/hub/index.md | 11 +- public-docs/hub/quickstart.md | 44 ++-- 20 files changed, 1110 insertions(+), 655 deletions(-) create mode 100644 packages/cli/src/commands/hub/deploy-triggers.test.ts create mode 100644 packages/cli/src/commands/hub/deploy-triggers.ts diff --git a/docs/hub.md b/docs/hub.md index dddad153e0..bf44067b67 100644 --- a/docs/hub.md +++ b/docs/hub.md @@ -4,7 +4,7 @@ Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Run not register it with a Hub. The relationship begins only when a user runs `paseo hub connect [url]` from the daemon machine with an explicit API key or matching stored CLI login. -The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Interactive login optionally connects the local daemon, then points to the Hub UI for trigger configuration; it does not scaffold or deploy configuration. `paseo hub init` remains the explicit triggers-as-code scaffold. `paseo hub export [directory]` writes the active organization's current triggers as one self-contained YAML file per trigger, using the active login unless another Hub or API key is selected. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential. +The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Interactive login optionally connects the local daemon, then points to the Hub UI for trigger configuration; it does not scaffold or deploy configuration. `paseo hub init` remains the explicit triggers-as-code scaffold: it writes one self-contained organization trigger under `.paseo/triggers/`, validates it through the trigger API, and optionally installs it. `paseo hub deploy` validates and installs every trigger in that directory; passing `--project` keeps deploying the legacy project bundle instead. `paseo hub export [directory]` writes the active organization's current triggers in the same layout, using the active login unless another Hub or API key is selected. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential. ## Connection and authority diff --git a/packages/cli/src/commands/hub/client.test.ts b/packages/cli/src/commands/hub/client.test.ts index c211b7d9af..edf86d3dc8 100644 --- a/packages/cli/src/commands/hub/client.test.ts +++ b/packages/cli/src/commands/hub/client.test.ts @@ -117,6 +117,40 @@ describe("Hub HTTP client", () => { assert.deepEqual(requests[0], { url: "/api/v1/triggers", body: "" }); }); + it("validates and installs self-contained organization triggers", async () => { + const requests: Array<{ url: string | undefined; body: string }> = []; + const triggerId = "a50e05af-4f20-4c8f-8dcc-58e5ea360663"; + const revisionId = "2de5b143-1c88-42db-8a8d-71ca2af97830"; + const origin = await startServer( + (url) => + url === "/api/v1/triggers/validate" + ? { status: 200, body: { name: "slack-help", valid: true } } + : { + status: 201, + body: { triggerId, name: "slack-help", revisionId, version: 1, active: true }, + }, + requests, + ); + const hub = new HubHttpClient(); + const yaml = "name: slack-help\n"; + + assert.deepEqual(await hub.validateTrigger(origin, "secret", yaml), { + name: "slack-help", + valid: true, + }); + assert.deepEqual(await hub.installTrigger(origin, "secret", yaml), { + triggerId, + name: "slack-help", + revisionId, + version: 1, + active: true, + }); + assert.deepEqual(requests, [ + { url: "/api/v1/triggers/validate", body: JSON.stringify({ yaml }) }, + { url: "/api/v1/triggers/install", body: JSON.stringify({ yaml }) }, + ]); + }); + it("reads configuration resources in the Hub's slug vocabulary", async () => { const requests: Array<{ url: string | undefined; body: string }> = []; const origin = await startServer( @@ -134,6 +168,7 @@ describe("Hub HTTP client", () => { ], discord: [{ slug: "paseo", guildName: "Paseo" }], slack: [{ slug: "paseo", teamName: "Paseo" }], + linear: [{ slug: "paseo-linear", organizationName: "Paseo" }], }, }), requests, @@ -143,55 +178,10 @@ describe("Hub HTTP client", () => { assert.equal(resources.daemons[0]?.slug, "macbook"); assert.equal(resources.discord[0]?.slug, "paseo"); + assert.equal(resources.linear[0]?.slug, "paseo-linear"); assert.equal(requests[0]?.url, "/api/v1/configuration-resources"); }); - it("reads strict provider-native setup resources", async () => { - const requests: Array<{ url: string | undefined; body: string }> = []; - const origin = await startServer( - () => ({ - status: 200, - body: { - github: [ - { - slug: "getpaseo", - accountLogin: "getpaseo", - accountType: "Organization", - repositories: ["getpaseo/paseo"], - }, - ], - discord: [{ guildId: "guild-123", guildName: "Paseo" }], - slack: [{ teamId: "team-123", teamName: "Paseo" }], - }, - }), - requests, - ); - - const resources = await new HubHttpClient().listSetupResources(origin, "secret"); - - assert.equal(resources.slack[0]?.teamId, "team-123"); - assert.equal(resources.discord[0]?.guildId, "guild-123"); - assert.deepEqual(requests[0], { url: "/api/v1/setup-resources", body: "" }); - }); - - it.each([ - { github: [], discord: [], slack: [{ teamId: "team-123", teamName: "Paseo", slug: "wrong" }] }, - { github: [], discord: [], slack: [{ teamId: 123, teamName: "Paseo" }] }, - ])("rejects malformed or unknown setup resource fields", async (body) => { - const requests: Array<{ url: string | undefined; body: string }> = []; - const origin = await startServer( - () => ({ - status: 200, - body, - }), - requests, - ); - - await assert.rejects(new HubHttpClient().listSetupResources(origin, "secret"), { - code: "HUB_INVALID_RESPONSE", - }); - }); - it("renders file-aware Hub validation issues without exposing credentials or response bodies", async () => { const requests: Array<{ url: string | undefined; body: string }> = []; const origin = await startServer( diff --git a/packages/cli/src/commands/hub/deploy-contract.test.ts b/packages/cli/src/commands/hub/deploy-contract.test.ts index c9c3103e7a..8729312707 100644 --- a/packages/cli/src/commands/hub/deploy-contract.test.ts +++ b/packages/cli/src/commands/hub/deploy-contract.test.ts @@ -17,7 +17,119 @@ afterEach(async () => { await Promise.all(servers.splice(0).map(closeServer)); }); -describe("Hub canonical bundle deployment contract", () => { +describe("Hub deployment contract", () => { + it("validates then installs discovered organization triggers when no project is selected", async () => { + const cwd = await triggerProject(); + const triggerId = "a50e05af-4f20-4c8f-8dcc-58e5ea360663"; + const revisionId = "2de5b143-1c88-42db-8a8d-71ca2af97830"; + const hub = await captureHubRequests(2, (url) => + url === "/api/v1/triggers/validate" + ? { status: 200, body: { name: "slack-help", valid: true } } + : { + status: 201, + body: { triggerId, name: "slack-help", revisionId, version: 3, active: true }, + }, + ); + + const result = await runHubDeploy( + { hub: hub.origin, apiKey: "operator-secret" }, + { cwd, env: {} }, + ); + + expect(await hub.received).toEqual([ + { + url: "/api/v1/triggers/validate", + authorization: "Bearer operator-secret", + body: { yaml: trigger }, + }, + { + url: "/api/v1/triggers/install", + authorization: "Bearer operator-secret", + body: { yaml: trigger }, + }, + ]); + expect(result).toMatchObject({ + type: "list", + data: [ + { + triggerId, + name: "slack-help", + revisionId, + version: 3, + active: true, + path: ".paseo/triggers/slack-help.yml", + origin: hub.origin, + }, + ], + }); + }); + + it("dry-runs organization triggers without installing them", async () => { + const cwd = await triggerProject(); + const hub = await captureHub(200, { name: "slack-help", valid: true }); + + const result = await runHubDeploy( + { hub: hub.origin, apiKey: "operator-secret", dryRun: true }, + { cwd, env: {} }, + ); + + expect(await hub.received).toEqual({ + url: "/api/v1/triggers/validate", + authorization: "Bearer operator-secret", + body: { yaml: trigger }, + }); + expect(result).toMatchObject({ + type: "list", + data: [ + { + name: "slack-help", + valid: true, + path: ".paseo/triggers/slack-help.yml", + origin: hub.origin, + }, + ], + }); + }); + + it("reports triggers installed before a later deployment failure", async () => { + const cwd = await triggerProject(); + const secondTrigger = trigger.replace("slack-help", "z-help"); + await writeFile(path.join(cwd, ".paseo", "triggers", "z-help.yml"), secondTrigger); + const hub = await captureHubRequests(4, (url, requestNumber) => { + if (url === "/api/v1/triggers/validate") { + return { status: 200, body: { name: "valid", valid: true } }; + } + if (requestNumber === 3) { + return { + status: 201, + body: { + triggerId: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", + name: "slack-help", + revisionId: "2de5b143-1c88-42db-8a8d-71ca2af97830", + version: 1, + active: true, + }, + }; + } + return { status: 500, body: { error: "failed" } }; + }); + + await expect( + runHubDeploy({ hub: hub.origin, apiKey: "operator-secret" }, { cwd, env: {} }), + ).rejects.toMatchObject({ + code: "HUB_TRIGGER_DEPLOY_PARTIAL", + message: "Could not deploy .paseo/triggers/z-help.yml after 1 trigger had been installed.", + details: + "Hub trigger deployment failed with HTTP 500.\nInstalled before the failure:\n- .paseo/triggers/slack-help.yml", + }); + expect((await hub.received).map(({ url }) => url)).toEqual([ + "/api/v1/triggers/validate", + "/api/v1/triggers/validate", + "/api/v1/triggers/install", + "/api/v1/triggers/install", + ]); + }); + it("installs the exact discovered bundle with finite environment and named-agent routing", async () => { const cwd = await canonicalProject(); const hub = await captureHub(201, { @@ -83,6 +195,7 @@ describe("Hub canonical bundle deployment contract", () => { expect(help).not.toContain("[file]"); expect(help).toContain("--project "); + expect(help).toContain("legacy bundle"); expect(help).toContain("--dry-run"); }); }); @@ -135,6 +248,23 @@ const workflow = [ const partial = "Treat paseo.context as evidence, not hidden prompt text.\n"; +const trigger = [ + "name: slack-help", + "enabled: true", + "on:", + " slack.mention:", + " connection: team", + " filters:", + " from_users: [U123]", + "run:", + " target: { daemon: local, cwd: /workspace/studio }", + " agent: { provider: codex, model: gpt-5, mode: full-access }", + " max_runtime: 90m", + " idle_timeout: 10m", + " prompt: ${{ paseo.prompt }}", + "", +].join("\n"); + function canonicalFiles() { return [ { path: ".paseo/hub.yml", content: resource }, @@ -154,6 +284,15 @@ async function canonicalProject(): Promise { return cwd; } +async function triggerProject(): Promise { + const cwd = await mkdtemp(path.join(tmpdir(), "paseo-hub-trigger-deploy-")); + temporaryDirectories.push(cwd); + const triggers = path.join(cwd, ".paseo", "triggers"); + await mkdir(triggers, { recursive: true }); + await writeFile(path.join(triggers, "slack-help.yml"), trigger); + return cwd; +} + async function captureHub(status: number, responseBody: unknown) { let resolveRequest!: (request: unknown) => void; const received = new Promise((resolve) => { @@ -181,6 +320,42 @@ async function captureHub(status: number, responseBody: unknown) { return { origin: `http://127.0.0.1:${address.port}`, received }; } +async function captureHubRequests( + count: number, + responseFor: ( + url: string | undefined, + requestNumber: number, + ) => { status: number; body: unknown }, +) { + const requests: unknown[] = []; + let resolveRequests!: (requests: unknown[]) => void; + const received = new Promise((resolve) => { + resolveRequests = resolve; + }); + const server = createServer((request, response) => { + let body = ""; + request.setEncoding("utf8"); + request.on("data", (chunk: string) => { + body += chunk; + }); + request.on("end", () => { + requests.push({ + url: request.url, + authorization: request.headers.authorization, + body: JSON.parse(body), + }); + if (requests.length === count) resolveRequests(requests); + const configured = responseFor(request.url, requests.length); + response.writeHead(configured.status, { "content-type": "application/json" }); + response.end(JSON.stringify(configured.body)); + }); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as AddressInfo; + return { origin: `http://127.0.0.1:${address.port}`, received }; +} + function closeServer(server: ReturnType): Promise { server.closeAllConnections(); return new Promise((resolve, reject) => { diff --git a/packages/cli/src/commands/hub/deploy-triggers.test.ts b/packages/cli/src/commands/hub/deploy-triggers.test.ts new file mode 100644 index 0000000000..934dae097f --- /dev/null +++ b/packages/cli/src/commands/hub/deploy-triggers.test.ts @@ -0,0 +1,64 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { discoverHubTriggers } from "./deploy-triggers.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +describe("Hub trigger deployment discovery", () => { + it("discovers self-contained triggers in deterministic order", async () => { + const cwd = await temporaryDirectory(); + await mkdir(path.join(cwd, ".paseo", "triggers"), { recursive: true }); + await writeFile(path.join(cwd, ".paseo", "triggers", "z.yml"), "name: z\n"); + await writeFile(path.join(cwd, ".paseo", "triggers", "a.yml"), "name: a\n"); + + await expect(discoverHubTriggers(cwd)).resolves.toEqual([ + { path: ".paseo/triggers/a.yml", yaml: "name: a\n" }, + { path: ".paseo/triggers/z.yml", yaml: "name: z\n" }, + ]); + }); + + it("rejects unsupported and unsafe trigger paths", async () => { + const cwd = await temporaryDirectory(); + const directory = path.join(cwd, ".paseo", "triggers"); + await mkdir(directory, { recursive: true }); + await writeFile(path.join(cwd, "outside.yml"), "name: outside\n"); + await symlink(path.join(cwd, "outside.yml"), path.join(directory, "linked.yml")); + + await expect(discoverHubTriggers(cwd)).rejects.toMatchObject({ + code: "HUB_TRIGGER_UNSAFE_PATH", + }); + }); + + it("requires at least one trigger", async () => { + const cwd = await temporaryDirectory(); + await mkdir(path.join(cwd, ".paseo", "triggers"), { recursive: true }); + + await expect(discoverHubTriggers(cwd)).rejects.toMatchObject({ code: "HUB_TRIGGER_MISSING" }); + }); + + it("directs legacy bundles to the explicit project deployment path", async () => { + const cwd = await temporaryDirectory(); + await mkdir(path.join(cwd, ".paseo")); + await writeFile(path.join(cwd, ".paseo", "hub.yml"), "environments: {}\n"); + + await expect(discoverHubTriggers(cwd)).rejects.toMatchObject({ + code: "HUB_PROJECT_REQUIRED", + message: + "This directory contains a legacy .paseo/hub.yml bundle. Pass --project to deploy it.", + }); + }); +}); + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-triggers-")); + temporaryDirectories.push(directory); + return directory; +} diff --git a/packages/cli/src/commands/hub/deploy-triggers.ts b/packages/cli/src/commands/hub/deploy-triggers.ts new file mode 100644 index 0000000000..e55abb53f4 --- /dev/null +++ b/packages/cli/src/commands/hub/deploy-triggers.ts @@ -0,0 +1,140 @@ +import { lstat, readFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import { HubCommandError } from "./error.js"; + +const TRIGGER_DIRECTORY = ".paseo/triggers"; + +export interface HubDeployTrigger { + path: string; + yaml: string; +} + +export async function discoverHubTriggers(cwd: string): Promise { + const root = path.resolve(cwd); + const directory = path.join(root, TRIGGER_DIRECTORY); + const paseoDirectory = await readStats(path.join(root, ".paseo"), { + code: "HUB_TRIGGER_DIRECTORY_MISSING", + message: `${TRIGGER_DIRECTORY} does not exist. Run this command from the project root.`, + }); + if (paseoDirectory.isSymbolicLink()) throw unsafeTriggerPath(TRIGGER_DIRECTORY); + const directoryStats = await readTriggerDirectoryStats(root, directory); + if (directoryStats.isSymbolicLink()) throw unsafeTriggerPath(TRIGGER_DIRECTORY); + if (!directoryStats.isDirectory()) { + throw new HubCommandError( + "HUB_TRIGGER_DIRECTORY_INVALID", + `${TRIGGER_DIRECTORY} must be a directory.`, + ); + } + + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (!isExpectedReadError(error)) throw error; + throw unreadableTriggerPath(TRIGGER_DIRECTORY); + } + const triggers: HubDeployTrigger[] = []; + for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) { + const triggerPath = `${TRIGGER_DIRECTORY}/${entry.name}`; + if (entry.isSymbolicLink()) throw unsafeTriggerPath(triggerPath); + if (!entry.isFile() || !entry.name.endsWith(".yml")) { + throw new HubCommandError( + "HUB_TRIGGER_PATH_UNSUPPORTED", + `${triggerPath} must be a direct-child .yml trigger.`, + ); + } + try { + triggers.push({ + path: triggerPath, + yaml: await readFile(path.join(root, triggerPath), "utf8"), + }); + } catch (error) { + if (!isExpectedReadError(error)) throw error; + throw unreadableTriggerPath(triggerPath); + } + } + if (triggers.length === 0) { + throw new HubCommandError( + "HUB_TRIGGER_MISSING", + `${TRIGGER_DIRECTORY} must contain at least one direct-child .yml trigger.`, + ); + } + return triggers; +} + +async function readTriggerDirectoryStats( + root: string, + directory: string, +): Promise>> { + try { + return await lstat(directory); + } catch (error) { + if (errorCode(error) !== "ENOENT") { + if (!isExpectedReadError(error)) throw error; + throw unreadableTriggerPath(TRIGGER_DIRECTORY); + } + if (await legacyBundleExists(root)) { + throw new HubCommandError( + "HUB_PROJECT_REQUIRED", + "This directory contains a legacy .paseo/hub.yml bundle. Pass --project to deploy it.", + ); + } + throw new HubCommandError( + "HUB_TRIGGER_DIRECTORY_MISSING", + `${TRIGGER_DIRECTORY} does not exist. Run this command from the project root.`, + ); + } +} + +async function legacyBundleExists(root: string): Promise { + try { + await lstat(path.join(root, ".paseo/hub.yml")); + return true; + } catch (error) { + if (errorCode(error) === "ENOENT") return false; + if (!isExpectedReadError(error)) throw error; + throw unreadableTriggerPath(".paseo/hub.yml"); + } +} + +async function readStats( + target: string, + missing: { code: string; message: string }, +): Promise>> { + try { + return await lstat(target); + } catch (error) { + if (errorCode(error) === "ENOENT") throw new HubCommandError(missing.code, missing.message); + if (!isExpectedReadError(error)) throw error; + throw unreadableTriggerPath(TRIGGER_DIRECTORY); + } +} + +function unsafeTriggerPath(triggerPath: string): HubCommandError { + return new HubCommandError("HUB_TRIGGER_UNSAFE_PATH", `${triggerPath} must not use a symlink.`); +} + +function unreadableTriggerPath(triggerPath: string): HubCommandError { + return new HubCommandError( + "HUB_TRIGGER_UNREADABLE", + `Could not read ${triggerPath}. Check the file and permissions.`, + ); +} + +function errorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null) return undefined; + const code = Reflect.get(error, "code"); + return typeof code === "string" ? code : undefined; +} + +function isExpectedReadError(error: unknown): boolean { + const code = errorCode(error); + return ( + code === "EACCES" || + code === "EPERM" || + code === "ENOENT" || + code === "ENOTDIR" || + code === "ELOOP" || + code === "EISDIR" + ); +} diff --git a/packages/cli/src/commands/hub/deploy.ts b/packages/cli/src/commands/hub/deploy.ts index d64137879a..b9cde59ae6 100644 --- a/packages/cli/src/commands/hub/deploy.ts +++ b/packages/cli/src/commands/hub/deploy.ts @@ -1,14 +1,23 @@ import type { Command } from "commander"; -import { withOutput, type OutputSchema, type SingleResult } from "../../output/index.js"; +import { + withOutput, + type ListResult, + type OutputSchema, + type SingleResult, +} from "../../output/index.js"; import { addJsonOption } from "../../utils/command-options.js"; import { resolveHubCredential, resolveHubOrigin } from "./authority.js"; import { HubHttpClient, type HubInstallResult, + type HubTriggerInstallationResult, + type HubTriggerValidationResult, type HubValidationResult, } from "./hub-client/index.js"; import { PrivateHubCredentialStore, type HubCredentialStore } from "./credentials.js"; import { discoverHubBundle, type HubDeployBundle } from "./deploy-bundle.js"; +import { discoverHubTriggers, type HubDeployTrigger } from "./deploy-triggers.js"; +import { HubCommandError } from "./error.js"; import { processHubReporter, reportHubProgress, type HubReporter } from "./reporter.js"; import { addHubResolutionHelp } from "./help.js"; @@ -24,14 +33,20 @@ export interface HubDeployEnvironment { cwd: string; env: Readonly>; credentials?: HubCredentialStore; - hub?: Pick; + hub?: Pick< + HubHttpClient, + "installConfiguration" | "validateConfiguration" | "installTrigger" | "validateTrigger" + >; reporter?: HubReporter; } export interface HubDeployCommandDependencies { env: Readonly>; credentials: HubCredentialStore; - hub: Pick; + hub: Pick< + HubHttpClient, + "installConfiguration" | "validateConfiguration" | "installTrigger" | "validateTrigger" + >; reporter: HubReporter; cwd(): string; } @@ -46,6 +61,16 @@ interface HubDryRunResult extends HubValidationResult { workflows: number; } +interface HubTriggerDeployResult extends HubTriggerInstallationResult { + origin: string; + path: string; +} + +interface HubTriggerDryRunResult extends HubTriggerValidationResult { + origin: string; + path: string; +} + const resultSchema: OutputSchema = { idField: "versionId", columns: [ @@ -68,6 +93,28 @@ const validationSchema: OutputSchema = { ], }; +const triggerResultSchema: OutputSchema = { + idField: "triggerId", + columns: [ + { header: "TRIGGER", field: "name" }, + { header: "VERSION", field: "version" }, + { header: "REVISION ID", field: "revisionId" }, + { header: "ACTIVE", field: "active" }, + { header: "PATH", field: "path" }, + { header: "HUB", field: "origin" }, + ], +}; + +const triggerValidationSchema: OutputSchema = { + idField: "name", + columns: [ + { header: "TRIGGER", field: "name" }, + { header: "VALID", field: "valid" }, + { header: "PATH", field: "path" }, + { header: "HUB", field: "origin" }, + ], +}; + export async function runHubDeploy( options: HubDeployOptions, environment: HubDeployEnvironment = { @@ -76,14 +123,84 @@ export async function runHubDeploy( credentials: new PrivateHubCredentialStore(), hub: new HubHttpClient(), }, -): Promise | SingleResult> { +): Promise< + | SingleResult + | SingleResult + | ListResult + | ListResult +> { + if (options.project === undefined) { + return runHubDeployTriggers(options, await discoverHubTriggers(environment.cwd), environment); + } const deployInput = await discoverHubBundle({ cwd: environment.cwd, - ...(options.project === undefined ? {} : { project: options.project }), + project: options.project, }); return runHubDeployBundle(options, deployInput, environment); } +export async function runHubDeployTriggers( + options: HubDeployOptions, + triggers: readonly HubDeployTrigger[], + environment: HubDeployEnvironment, +): Promise | ListResult> { + const credentials = environment.credentials ?? new PrivateHubCredentialStore(environment.env); + const resolution = { + options: { origin: options.hub, apiKey: options.apiKey }, + env: environment.env, + credentials, + }; + const origin = resolveHubOrigin(resolution); + const credential = resolveHubCredential({ ...resolution, origin }); + const action = options.dryRun === true ? "Validating" : "Deploying"; + reportHubProgress( + environment.reporter ?? processHubReporter, + options, + `${action} ${String(triggers.length)} trigger${triggers.length === 1 ? "" : "s"} ${ + options.dryRun === true ? "against" : "to" + } ${origin}`, + ); + const hub = environment.hub ?? new HubHttpClient(); + if (options.dryRun === true) { + const data = await Promise.all( + triggers.map(async (trigger) => ({ + ...(await hub.validateTrigger(origin, credential, trigger.yaml)), + origin, + path: trigger.path, + })), + ); + return { type: "list", data, schema: triggerValidationSchema }; + } + await Promise.all( + triggers.map((trigger) => hub.validateTrigger(origin, credential, trigger.yaml)), + ); + const data: HubTriggerDeployResult[] = []; + for (const trigger of triggers) { + try { + data.push({ + ...(await hub.installTrigger(origin, credential, trigger.yaml)), + origin, + path: trigger.path, + }); + } catch (error) { + if (data.length === 0) throw error; + throw new HubCommandError( + "HUB_TRIGGER_DEPLOY_PARTIAL", + `Could not deploy ${trigger.path} after ${String(data.length)} trigger${data.length === 1 ? "" : "s"} had been installed.`, + `${errorMessage(error)}\nInstalled before the failure:\n${data.map(({ path: installedPath }) => `- ${installedPath}`).join("\n")}`, + ); + } + } + return { type: "list", data, schema: triggerResultSchema }; +} + +function errorMessage(error: unknown): string { + if (error instanceof HubCommandError) { + return error.details === undefined ? error.message : `${error.message}\n${error.details}`; + } + return error instanceof Error ? error.message : String(error); +} + export async function runHubDeployBundle( options: HubDeployOptions, deployInput: HubDeployBundle, @@ -133,14 +250,17 @@ export function addHubDeployCommand( addHubResolutionHelp( hub .command("deploy") - .description("Discover, validate, and activate the canonical .paseo Hub bundle") - .option("-p, --project ", "Target project slug") + .description("Deploy .paseo organization triggers or a legacy project bundle") + .option("-p, --project ", "Deploy a legacy bundle to this project slug") .option("--hub ", "Paseo Hub origin") .option("--api-key ", "Organization API key") .option("--dry-run", "Validate without installing or activating"), ), ).action( - withOutput(async (...args) => { + withOutput< + HubDeployResult | HubDryRunResult | HubTriggerDeployResult | HubTriggerDryRunResult, + unknown[] + >(async (...args) => { const options = args.at(-2) as HubDeployOptions; return runHubDeploy(options, { cwd: dependencies.cwd(), diff --git a/packages/cli/src/commands/hub/hub-client/index.ts b/packages/cli/src/commands/hub/hub-client/index.ts index ab880705d6..857a145afb 100644 --- a/packages/cli/src/commands/hub/hub-client/index.ts +++ b/packages/cli/src/commands/hub/hub-client/index.ts @@ -8,15 +8,17 @@ import { installResponseSchema, projectsResponseSchema, triggersResponseSchema, + triggerInstallationResponseSchema, + triggerValidationResponseSchema, validationResponseSchema, type CliAuthorization, type CliAuthorizationPoll, type HubInstallResult, type HubConfigurationResources, - setupResourcesSchema, - type HubSetupResources, type HubProject, type HubTrigger, + type HubTriggerInstallationResult, + type HubTriggerValidationResult, type HubValidationResult, } from "./internal/contracts.js"; import { requestHub } from "./internal/transport.js"; @@ -26,9 +28,10 @@ export type { CliAuthorizationPoll, HubInstallResult, HubConfigurationResources, - HubSetupResources, HubProject, HubTrigger, + HubTriggerInstallationResult, + HubTriggerValidationResult, HubValidationResult, } from "./internal/contracts.js"; @@ -102,6 +105,40 @@ export class HubHttpClient { return response.triggers; } + validateTrigger( + origin: string, + apiKey: string, + yaml: string, + ): Promise { + return requestHub({ + origin, + path: "/api/v1/triggers/validate", + method: "POST", + apiKey, + body: { yaml }, + successStatus: 200, + schema: triggerValidationResponseSchema, + failureMessage: "Hub trigger validation failed", + }); + } + + installTrigger( + origin: string, + apiKey: string, + yaml: string, + ): Promise { + return requestHub({ + origin, + path: "/api/v1/triggers/install", + method: "POST", + apiKey, + body: { yaml }, + successStatus: 201, + schema: triggerInstallationResponseSchema, + failureMessage: "Hub trigger deployment failed", + }); + } + listConfigurationResources(origin: string, apiKey: string): Promise { return requestHub({ origin, @@ -114,18 +151,6 @@ export class HubHttpClient { }); } - listSetupResources(origin: string, apiKey: string): Promise { - return requestHub({ - origin, - path: "/api/v1/setup-resources", - method: "GET", - apiKey, - successStatus: 200, - schema: setupResourcesSchema, - failureMessage: "Hub guided setup resource listing failed", - }); - } - installConfiguration(input: HubConfigurationInput): Promise { return requestHub({ origin: input.origin, diff --git a/packages/cli/src/commands/hub/hub-client/internal/contracts.ts b/packages/cli/src/commands/hub/hub-client/internal/contracts.ts index 059f66cb14..c51127bc4c 100644 --- a/packages/cli/src/commands/hub/hub-client/internal/contracts.ts +++ b/packages/cli/src/commands/hub/hub-client/internal/contracts.ts @@ -48,6 +48,20 @@ const triggerSchema = z export const triggersResponseSchema = z.object({ triggers: z.array(triggerSchema) }).strict(); +export const triggerValidationResponseSchema = z + .object({ name: z.string().regex(/^[a-z][a-z0-9_-]*$/u), valid: z.literal(true) }) + .strict(); + +export const triggerInstallationResponseSchema = z + .object({ + triggerId: z.string().uuid(), + name: z.string().regex(/^[a-z][a-z0-9_-]*$/u), + revisionId: z.string().uuid(), + version: z.number().int().positive(), + active: z.literal(true), + }) + .strict(); + export const configurationResourcesSchema = z .object({ daemons: z.array(z.object({ id: z.string().uuid(), slug: z.string().min(1) }).strict()), @@ -63,25 +77,9 @@ export const configurationResourcesSchema = z ), discord: z.array(z.object({ slug: z.string().min(1), guildName: z.string().min(1) }).strict()), slack: z.array(z.object({ slug: z.string().min(1), teamName: z.string().min(1) }).strict()), - }) - .strict(); - -export const setupResourcesSchema = z - .object({ - github: z.array( - z - .object({ - slug: z.string().min(1), - accountLogin: z.string().min(1), - accountType: z.string().min(1), - repositories: z.array(z.string().min(1)), - }) - .strict(), + linear: z.array( + z.object({ slug: z.string().min(1), organizationName: z.string().min(1) }).strict(), ), - discord: z.array( - z.object({ guildId: z.string().min(1), guildName: z.string().min(1) }).strict(), - ), - slack: z.array(z.object({ teamId: z.string().min(1), teamName: z.string().min(1) }).strict()), }) .strict(); @@ -106,7 +104,8 @@ export type CliAuthorization = z.infer; export type CliAuthorizationPoll = z.infer; export type HubProject = z.infer; export type HubTrigger = z.infer; +export type HubTriggerValidationResult = z.infer; +export type HubTriggerInstallationResult = z.infer; export type HubConfigurationResources = z.infer; -export type HubSetupResources = z.infer; export type HubInstallResult = z.infer; export type HubValidationResult = z.infer; diff --git a/packages/cli/src/commands/hub/init-flow.test.ts b/packages/cli/src/commands/hub/init-flow.test.ts index 934c47b0f6..74653a6ddb 100644 --- a/packages/cli/src/commands/hub/init-flow.test.ts +++ b/packages/cli/src/commands/hub/init-flow.test.ts @@ -1,5 +1,5 @@ import { strict as assert } from "node:assert"; -import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, it } from "vitest"; @@ -112,7 +112,13 @@ describe("Hub guided setup continuation", () => { continueHubGuidedSetup( origin, setupEnvironment(cwd, credentials, daemon, prompts, [], { - setupResources: { github: [], slack: [], discord: [] }, + configurationResources: { + daemons: [], + github: [], + slack: [], + discord: [], + linear: [], + }, }), ), reporter: { progress() {} }, @@ -125,18 +131,77 @@ describe("Hub guided setup continuation", () => { ]); }); - it("keeps hub init's existing replacement confirmation", async () => { + it("preserves an existing legacy bundle while adding the organization trigger", async () => { const cwd = await temporaryDirectory(); await mkdir(path.join(cwd, ".paseo")); - const prompts = new PromptAnswers([false], [], []); + await writeFile(path.join(cwd, ".paseo", "hub.yml"), "legacy: bundle\n"); + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "secret" }); + const prompts = new PromptAnswers([], ["codex", "gpt-5", "full-access"], ["U123"]); + + await runHubGuidedSetup(setupEnvironment(cwd, credentials, new SetupDaemon(), prompts, []), { + origin: "https://hub.test", + daemonId: "daemon-1", + deploy: false, + }); + + assert.equal(await readFile(path.join(cwd, ".paseo", "hub.yml"), "utf8"), "legacy: bundle\n"); + assert.match( + await readFile(path.join(cwd, ".paseo", "triggers", "slack-help.yml"), "utf8"), + /name: slack-help/u, + ); + assert.deepEqual(prompts.confirmations, []); + }); + + it("asks before replacing only the selected trigger file", async () => { + const cwd = await temporaryDirectory(); + const triggerPath = path.join(cwd, ".paseo", "triggers", "slack-help.yml"); + await mkdir(path.dirname(triggerPath), { recursive: true }); + await writeFile(triggerPath, "name: keep-me\n"); + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "secret" }); + const prompts = new PromptAnswers([false], ["codex", "gpt-5", "full-access"], ["U123"]); + const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = []; await assert.rejects( - runHubGuidedSetup( - setupEnvironment(cwd, new MemoryCredentials(), new SetupDaemon(), prompts, []), - ), - /Existing .paseo\/ bundle left unchanged/u, + runHubGuidedSetup(setupEnvironment(cwd, credentials, new SetupDaemon(), prompts, calls), { + origin: "https://hub.test", + daemonId: "daemon-1", + deploy: true, + }), + /.paseo\/triggers\/slack-help.yml left unchanged/u, ); - assert.deepEqual(prompts.confirmations, ["Replace the existing .paseo/ Hub bundle?"]); + + assert.equal(await readFile(triggerPath, "utf8"), "name: keep-me\n"); + assert.deepEqual(prompts.confirmations, [ + "Replace the existing .paseo/triggers/slack-help.yml?", + ]); + assert.deepEqual( + calls.map(({ operation }) => operation), + ["resources", "validate-trigger"], + ); + }); + + it("rejects a symlinked trigger directory without writing outside the project", async () => { + const cwd = await temporaryDirectory(); + const outside = await temporaryDirectory(); + await mkdir(path.join(cwd, ".paseo")); + await symlink(outside, path.join(cwd, ".paseo", "triggers")); + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "secret" }); + const prompts = new PromptAnswers([], ["codex", "gpt-5", "full-access"], ["U123"]); + + await assert.rejects( + runHubGuidedSetup(setupEnvironment(cwd, credentials, new SetupDaemon(), prompts, []), { + origin: "https://hub.test", + daemonId: "daemon-1", + deploy: false, + }), + (error: unknown) => + error instanceof Error && "code" in error && error.code === "HUB_TRIGGER_UNSAFE_PATH", + ); + + await assert.rejects(readFile(path.join(outside, "slack-help.yml")), { code: "ENOENT" }); }); it("writes the explicitly selected Claude mode when the daemon has no default", async () => { @@ -167,14 +232,14 @@ describe("Hub guided setup continuation", () => { ["Sonnet (suggested)"], ["Auto"], ]); - assert.match( - await readFile(path.join(cwd, ".paseo", "hub.yml"), "utf8"), - /provider: claude\n model: sonnet\n mode: auto/u, - ); + const trigger = await readFile(path.join(cwd, ".paseo", "triggers", "slack-help.yml"), "utf8"); + assert.match(trigger, /provider: claude\n model: sonnet\n mode: auto/u); assert.deepEqual( calls.slice(-2).map(({ operation }) => operation), - ["validate", "install"], + ["validate-trigger", "install-trigger"], ); + assert.deepEqual(calls.at(-2)?.files, [trigger]); + assert.deepEqual(calls.at(-1)?.files, [trigger]); }); it("stops before runtime discovery and file writes when no Hub app connection is usable", async () => { @@ -187,15 +252,50 @@ describe("Hub guided setup continuation", () => { await assert.rejects( runHubGuidedSetup( setupEnvironment(cwd, credentials, daemon, prompts, [], { - setupResources: { github: [], slack: [], discord: [] }, + configurationResources: { + daemons: [{ id: "daemon-1", slug: "macbook" }], + github: [], + slack: [], + discord: [], + linear: [], + }, }), { origin: "https://hub.test", daemonId: "daemon-1", deploy: true }, ), - /No Hub app connection is ready for this workflow/u, + /No Hub app connection is ready for this trigger/u, ); assert.equal(daemon.snapshotCwds.length, 0); - await assert.rejects(readFile(path.join(cwd, ".paseo", "hub.yml")), { code: "ENOENT" }); + await assert.rejects(readFile(path.join(cwd, ".paseo", "triggers", "slack-help.yml")), { + code: "ENOENT", + }); + }); + + it("does not offer an agent runtime that cannot author the required execution mode", async () => { + const cwd = await temporaryDirectory(); + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "secret" }); + const daemon = new SetupDaemon([ + { + provider: "pi", + status: "ready", + enabled: true, + models: [{ provider: "pi", id: "default", label: "Default", isDefault: true }], + modes: [], + }, + ]); + const prompts = new PromptAnswers([], [], ["U123"]); + + await assert.rejects( + runHubGuidedSetup(setupEnvironment(cwd, credentials, daemon, prompts, []), { + origin: "https://hub.test", + daemonId: "daemon-1", + deploy: true, + }), + /No agent runtime with an execution mode is available/u, + ); + + assert.deepEqual(prompts.selections, []); }); it("waits for fresh-daemon provider discovery before offering runtime choices", async () => { @@ -231,7 +331,7 @@ function setupEnvironment( prompts: PromptAnswers, calls: Array<{ operation: string; origin: string; files?: readonly string[] }>, options: { - setupResources?: Awaited>; + configurationResources?: Awaited>; } = {}, ): HubGuidedSetupEnvironment { const hub = { @@ -239,42 +339,37 @@ function setupEnvironment( calls.push({ operation: "token", origin }); return "one-time-enrollment-token-with-enough-length"; }, - async listProjects(origin: string) { - calls.push({ operation: "projects", origin }); - return [{ id: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", slug: "studio", name: "Studio" }]; - }, - async listSetupResources(origin: string) { - calls.push({ operation: "setup", origin }); + async listConfigurationResources(origin: string) { + calls.push({ operation: "resources", origin }); return ( - options.setupResources ?? { + options.configurationResources ?? { + daemons: [{ id: "daemon-1", slug: "macbook" }], github: [], discord: [], - slack: [{ teamId: "T123", teamName: "Paseo" }], + slack: [{ slug: "paseo", teamName: "Paseo" }], + linear: [], } ); }, - async listConfigurationResources(origin: string) { - calls.push({ operation: "resources", origin }); - return { daemons: [{ id: "daemon-1", slug: "macbook" }], github: [], discord: [], slack: [] }; - }, - async validateConfiguration(input: { origin: string; files: readonly { path: string }[] }) { + async validateTrigger(origin: string, _apiKey: string, yaml: string) { calls.push({ - operation: "validate", - origin: input.origin, - files: input.files.map(({ path: filePath }) => filePath), + operation: "validate-trigger", + origin, + files: [yaml], }); - return { projectSlug: "studio", valid: true }; + return { name: "slack-help", valid: true }; }, - async installConfiguration(input: { origin: string; files: readonly { path: string }[] }) { + async installTrigger(origin: string, _apiKey: string, yaml: string) { calls.push({ - operation: "install", - origin: input.origin, - files: input.files.map(({ path: filePath }) => filePath), + operation: "install-trigger", + origin, + files: [yaml], }); return { - projectSlug: "studio", + triggerId: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", + name: "slack-help", version: 1, - versionId: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", + revisionId: "2de5b143-1c88-42db-8a8d-71ca2af97830", active: true, }; }, diff --git a/packages/cli/src/commands/hub/init-plan.test.ts b/packages/cli/src/commands/hub/init-plan.test.ts index 44538eb3b1..2ec2f85dff 100644 --- a/packages/cli/src/commands/hub/init-plan.test.ts +++ b/packages/cli/src/commands/hub/init-plan.test.ts @@ -1,31 +1,13 @@ -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import YAML from "yaml"; -import { afterEach, describe, expect, it } from "vitest"; -import { discoverHubBundle } from "./deploy-bundle.js"; -import { runHubDeploy } from "./deploy.js"; +import { describe, expect, it } from "vitest"; import { createHubInitScaffold, hubLoginResumeCommand, - planHubInitOpening, resolveHubInitConnection, - resolveHubInitProjects, type HubInitProvider, } from "./init-plan.js"; import { githubRepositoryFromRemote } from "./init.js"; -const directories: string[] = []; -const project = (slug: string) => ({ - id: "7e950d84-eec0-4e18-b1f9-9c115bdb31e4", - slug, - name: slug.toUpperCase(), -}); - -afterEach(async () => { - await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); -}); - describe("Hub init planning", () => { it("prints direct resumable commands for declined login continuations", () => { expect(hubLoginResumeCommand("connect", "https://hub.test")).toBe( @@ -33,36 +15,6 @@ describe("Hub init planning", () => { ); expect(hubLoginResumeCommand("init", "https://hub.test")).toBe("paseo hub init"); }); - it("includes login only when there is no active login", () => { - expect(planHubInitOpening({ loggedIn: false, paseoDirectoryExists: false })).toEqual({ - replaceExisting: false, - steps: ["login", "connect", "project", "scaffold"], - }); - expect(planHubInitOpening({ loggedIn: true, paseoDirectoryExists: false })).toEqual({ - replaceExisting: false, - steps: ["connect", "project", "scaffold"], - }); - }); - - it("plans a confirmed replacement for an existing .paseo directory", () => { - expect(planHubInitOpening({ loggedIn: true, paseoDirectoryExists: true })).toEqual({ - replaceExisting: true, - steps: ["connect", "project", "scaffold"], - }); - }); - - it("stops, defaults, or asks according to the project count", () => { - expect(resolveHubInitProjects([])).toEqual({ kind: "none" }); - expect(resolveHubInitProjects([project("one")])).toEqual({ - kind: "selected", - project: project("one"), - }); - expect(resolveHubInitProjects([project("one"), project("two")])).toEqual({ - kind: "choose", - projects: [project("one"), project("two")], - }); - }); - it("reuses a connected daemon, waits for reconnect, and rejects a different Hub", () => { const status = { state: "connected", @@ -93,31 +45,15 @@ describe("Hub init planning", () => { }); describe("Hub init scaffold", () => { - it("omits mode for a provider that explicitly exposes none", () => { - const scaffold = createHubInitScaffold({ - cwd: "/workspace", - daemonSlug: "build-studio", - agent: { provider: "pi", model: "default" }, - provider: "slack", - providerFilters: { workspace: "T123456", user: "U123456" }, - }); - - expect(YAML.parse(scaffold.hub)).toMatchObject({ - agents: { starter: { provider: "pi", model: "default" } }, - }); - expect(YAML.parse(scaffold.hub).agents.starter.mode).toBeUndefined(); - }); - it.each([ - ["github", { repo: "getpaseo/paseo", user: "boudra" }], - ["slack", { workspace: "T123456", user: "U123456" }], - ["discord", { guild: "123456789", user: "987654321" }], + ["github", { connection: "github-getpaseo", repo: "getpaseo/paseo", user: "boudra" }], + ["slack", { connection: "slack-paseo", user: "U123456" }], + ["discord", { connection: "discord-paseo", user: "987654321" }], ] satisfies readonly [HubInitProvider, Record][])( - "creates a closed %s trigger that the deploy discovery path accepts", - async (provider, providerFilters) => { - const cwd = await temporaryDirectory(); + "creates a self-contained %s organization trigger", + (provider, providerFilters) => { const scaffold = createHubInitScaffold({ - cwd, + cwd: "/workspace", daemonSlug: "build-studio", agent: { provider: "codex", @@ -127,48 +63,48 @@ describe("Hub init scaffold", () => { provider, providerFilters, }); - await mkdir(path.join(cwd, ".paseo", "workflows"), { recursive: true }); - await writeFile(path.join(cwd, ".paseo", "hub.yml"), scaffold.hub); - await writeFile(path.join(cwd, scaffold.workflowPath), scaffold.workflow); - - const bundle = await discoverHubBundle({ cwd, project: "paseo" }); - expect(bundle.workflowCount).toBe(1); - expect(bundle.files.map((file) => file.path)).toEqual([ - ".paseo/hub.yml", - scaffold.workflowPath, - ]); - const parsed = YAML.parse(scaffold.workflow) as { - filters: { from_users: string[]; channels?: string[] }; + expect(scaffold.triggerPath).toBe(`.paseo/triggers/${provider}-help.yml`); + const parsed = YAML.parse(scaffold.trigger) as { + name: string; + enabled: boolean; + max_runtime: string; + on: Record }>; + run: { + target: { daemon: string; cwd: string }; + agent: { provider: string; model?: string; mode?: string }; + continuation: { mode: string }; + max_runtime: string; + idle_timeout: string; + prompt: string; + outputs?: Record; + }; }; - expect(parsed.filters.from_users).toEqual([providerFilters.user]); - if (provider === "slack") expect(parsed.filters.workspace).toBe("T123456"); - if (provider === "discord") expect(parsed.filters.guild).toBe("123456789"); - expect(parsed.filters.channels).toBeUndefined(); - expect(YAML.parse(scaffold.hub)).toMatchObject({ - agents: { starter: { provider: "codex", model: "gpt-5", mode: "full-access" } }, + expect(parsed).toMatchObject({ + name: `${provider}-help`, + enabled: true, + max_runtime: "2h", }); - - let validatedPaths: readonly string[] = []; - const result = await runHubDeploy( - { project: "paseo", hub: "https://hub.test", dryRun: true }, - { - cwd, - env: { PASEO_HUB_API_KEY: "test-key" }, - reporter: { progress() {} }, - hub: { - async validateConfiguration(input) { - validatedPaths = input.files.map((file) => file.path); - for (const file of input.files) YAML.parse(file.content); - return { projectSlug: input.projectSlug, valid: true }; - }, - async installConfiguration() { - throw new Error("dry-run must not install"); - }, - }, - }, + expect(Object.keys(parsed.on)).toEqual([ + provider === "github" ? "github.issue_comment" : `${provider}.mention`, + ]); + const event = Object.values(parsed.on)[0]!; + expect(event.filters.from_users).toEqual([providerFilters.user]); + if (provider === "github") expect(event.connection).toBe("github-getpaseo"); + if (provider === "slack") expect(event.connection).toBe("slack-paseo"); + if (provider === "discord") expect(event.connection).toBe("discord-paseo"); + expect(event.filters.channels).toBeUndefined(); + expect(parsed.run).toMatchObject({ + target: { daemon: "build-studio", cwd: "/workspace" }, + agent: { provider: "codex", model: "gpt-5", mode: "full-access" }, + continuation: { mode: "conversation" }, + max_runtime: "90m", + idle_timeout: "10m", + }); + expect(parsed.run.prompt).toContain("hub.finish_execution"); + expect(parsed.run.prompt).toContain("${{ paseo.prompt }}"); + expect(parsed.run.outputs).toEqual( + provider === "github" ? undefined : { [`${provider}.reply`]: { max: 1, required: true } }, ); - expect(result.data).toMatchObject({ projectSlug: "paseo", valid: true, workflows: 1 }); - expect(validatedPaths).toEqual([".paseo/hub.yml", scaffold.workflowPath]); }, ); }); @@ -183,9 +119,3 @@ describe("GitHub origin detection", () => { expect(githubRepositoryFromRemote(remote)).toBe(expected); }); }); - -async function temporaryDirectory(): Promise { - const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-init-")); - directories.push(directory); - return directory; -} diff --git a/packages/cli/src/commands/hub/init-plan.ts b/packages/cli/src/commands/hub/init-plan.ts index e2764ccbff..bd2077300d 100644 --- a/packages/cli/src/commands/hub/init-plan.ts +++ b/packages/cli/src/commands/hub/init-plan.ts @@ -1,40 +1,27 @@ import path from "node:path"; import YAML from "yaml"; -import type { HubDeployBundle } from "./deploy-bundle.js"; -import type { HubProject } from "./hub-client/index.js"; import type { HubStatus } from "./daemon-client.js"; import type { HubStarterAgentRuntime } from "./starter-agent-runtime.js"; export type HubInitProvider = "github" | "slack" | "discord"; -export type HubInitProjectResolution = - | { kind: "none" } - | { kind: "selected"; project: HubProject } - | { kind: "choose"; projects: readonly HubProject[] }; - export type HubInitConnectionResolution = | { kind: "connected"; daemonId: string } | { kind: "pending"; state: "connecting" | "reconnecting" } | { kind: "connect" } | { kind: "conflict"; origin: string }; -export interface HubInitOpeningPlan { - replaceExisting: boolean; - steps: readonly ("login" | "connect" | "project" | "scaffold")[]; -} - export interface HubInitScaffoldInput { cwd: string; daemonSlug: string; - agent: HubStarterAgentRuntime; + agent: HubStarterAgentRuntime & { mode: string }; provider: HubInitProvider; providerFilters: Readonly>; } export interface HubInitScaffold { - hub: string; - workflowPath: string; - workflow: string; + triggerPath: string; + trigger: string; testAction: string; } @@ -42,36 +29,6 @@ export function hubLoginResumeCommand(step: "connect" | "init", origin: string): return step === "connect" ? `paseo hub connect ${origin}` : "paseo hub init"; } -export function planHubInitOpening(input: { - loggedIn: boolean; - paseoDirectoryExists: boolean; -}): HubInitOpeningPlan { - return { - replaceExisting: input.paseoDirectoryExists, - steps: [...(input.loggedIn ? [] : (["login"] as const)), "connect", "project", "scaffold"], - }; -} - -export function createHubInitBundle( - projectSlug: string, - scaffold: HubInitScaffold, -): HubDeployBundle { - return { - projectSlug, - workflowCount: 1, - files: [ - { path: ".paseo/hub.yml", content: scaffold.hub }, - { path: scaffold.workflowPath, content: scaffold.workflow }, - ], - }; -} - -export function resolveHubInitProjects(projects: readonly HubProject[]): HubInitProjectResolution { - if (projects.length === 0) return { kind: "none" }; - if (projects.length === 1) return { kind: "selected", project: projects[0]! }; - return { kind: "choose", projects }; -} - export function resolveHubInitConnection( status: HubStatus, origin: string, @@ -92,31 +49,21 @@ export function resolveHubInitConnection( } export function createHubInitScaffold(input: HubInitScaffoldInput): HubInitScaffold { - const environmentName = input.daemonSlug; - const hub = YAML.stringify( - { - environments: { - [environmentName]: { - kind: "daemon", - daemon: input.daemonSlug, - cwd: path.resolve(input.cwd), - }, - }, - agents: { - starter: { + const provider = providerScaffold(input.provider, input.providerFilters); + return { + triggerPath: `.paseo/triggers/${input.provider}-help.yml`, + trigger: YAML.stringify( + triggerDocument({ + ...provider, + target: { daemon: input.daemonSlug, cwd: path.resolve(input.cwd) }, + agent: { provider: input.agent.provider, model: input.agent.model, - ...(input.agent.mode === undefined ? {} : { mode: input.agent.mode }), + mode: input.agent.mode, }, - }, - }, - { lineWidth: 0 }, - ); - const provider = providerScaffold(input.provider, input.providerFilters, environmentName); - return { - hub, - workflowPath: `.paseo/workflows/${input.provider}-help.yml`, - workflow: YAML.stringify(provider.workflow, { lineWidth: 0 }), + }), + { lineWidth: 0 }, + ), testAction: provider.testAction, }; } @@ -124,80 +71,75 @@ export function createHubInitScaffold(input: HubInitScaffoldInput): HubInitScaff function providerScaffold( provider: HubInitProvider, filters: Readonly>, - environment: string, -): { workflow: object; testAction: string } { +): { + name: string; + event: string; + connection: string; + filters: object; + reply?: "slack.reply" | "discord.reply"; + testAction: string; +} { + const connection = requireFilter(filters, "connection"); if (provider === "github") { const repo = requireFilter(filters, "repo"); const user = requireFilter(filters, "user"); return { - workflow: workflow({ - name: "github-help", - on: "github.issue_comment", - filters: { repo, contains: "@paseo", from_users: [user] }, - environment, - }), + name: "github-help", + event: "github.issue_comment", + connection, + filters: { repo, contains: "@paseo", from_users: [user] }, testAction: `Comment \`@paseo have a look\` on ${repo}.`, }; } const user = requireFilter(filters, "user"); if (provider === "slack") { - const workspace = requireFilter(filters, "workspace"); return { - workflow: workflow({ - name: "slack-help", - on: "slack.mention", - filters: { workspace, from_users: [user] }, - environment, - reply: "slack.reply", - }), + name: "slack-help", + event: "slack.mention", + connection, + filters: { from_users: [user] }, + reply: "slack.reply", testAction: "Mention `@Paseo have a look` in Slack.", }; } - const guild = requireFilter(filters, "guild"); return { - workflow: workflow({ - name: "discord-help", - on: "discord.mention", - filters: { guild, from_users: [user] }, - environment, - reply: "discord.reply", - }), + name: "discord-help", + event: "discord.mention", + connection, + filters: { from_users: [user] }, + reply: "discord.reply", testAction: "Mention `@Paseo have a look` in Discord.", }; } -function workflow(input: { +function triggerDocument(input: { name: string; - on: string; + event: string; + connection: string; filters: object; - environment: string; + target: { daemon: string; cwd: string }; + agent: { provider: string; model: string; mode?: string }; reply?: "slack.reply" | "discord.reply"; }): object { const replyInstruction = input.reply === undefined ? "" : "Answer with hub.reply, then "; return { name: input.name, - on: input.on, + enabled: true, + on: { [input.event]: { connection: input.connection, filters: input.filters } }, max_runtime: "2h", - filters: input.filters, - steps: [ - { - id: "work", - environment: input.environment, - max_runtime: "90m", - idle_timeout: "10m", - agent: "starter", - prompt: [ - { - text: `${replyInstruction}complete this request and call hub.finish_execution when done.\n\n\n\${{ paseo.prompt }}\n\n`, - }, - ], - ...(input.reply === undefined - ? {} - : { allow_outputs: [{ type: input.reply, max: 1, required: true }] }), - }, - ], + run: { + target: input.target, + agent: input.agent, + continuation: { mode: "conversation" }, + max_runtime: "90m", + idle_timeout: "10m", + prompt: `${replyInstruction}complete this request and call hub.finish_execution when done.\n\n\n\${{ paseo.prompt }}\n\n`, + ...(input.reply === undefined + ? {} + : { outputs: { [input.reply]: { max: 1, required: true } } }), + }, }; } diff --git a/packages/cli/src/commands/hub/init.ts b/packages/cli/src/commands/hub/init.ts index c83ce12b09..6a97558b34 100644 --- a/packages/cli/src/commands/hub/init.ts +++ b/packages/cli/src/commands/hub/init.ts @@ -12,7 +12,7 @@ import { } from "@clack/prompts"; import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { lstat, mkdir, rename, rm, writeFile } from "node:fs/promises"; +import { link, lstat, mkdir, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import type { Command } from "commander"; @@ -21,24 +21,14 @@ import type { HubCredentialStore } from "./credentials.js"; import type { HubDaemonConnection } from "./daemon-client.js"; import { withHubDaemon } from "./daemon-client.js"; import { HubCommandError } from "./error.js"; -import type { - HubConfigurationResources, - HubHttpClient, - HubProject, - HubSetupResources, -} from "./hub-client/index.js"; +import type { HubConfigurationResources, HubHttpClient } from "./hub-client/index.js"; import { - createHubInitBundle, createHubInitScaffold, hubLoginResumeCommand, - planHubInitOpening, resolveHubInitConnection, - resolveHubInitProjects, } from "./init-plan.js"; import type { CliLoginFlow } from "./login-flow.js"; import { runHubConnect } from "./connect.js"; -import { runHubDeployBundle } from "./deploy.js"; -import { runHubProjects } from "./projects.js"; import type { HubReporter } from "./reporter.js"; import { normalizeHubOrigin } from "./origin.js"; import { @@ -93,7 +83,7 @@ function initErrorMessage(error: unknown): string { export function addHubInitCommand(parent: Command, environment: HubGuidedSetupEnvironment): void { parent .command("init") - .description("Create and optionally deploy a safe starter Hub bundle") + .description("Create and optionally deploy a safe starter Hub trigger") .action(async () => { try { await runHubInit(environment); @@ -121,25 +111,19 @@ export async function runHubGuidedSetup( const cwd = environment.cwd(); const activeLogin = environment.credentials.active(); - const opening = planHubInitOpening({ - loggedIn: activeLogin !== null, - paseoDirectoryExists: await pathExists(path.join(cwd, ".paseo")), - }); - if ( - opening.replaceExisting && - !(await requiredConfirm(environment, "Replace the existing .paseo/ Hub bundle?", false)) - ) { - throw new HubInitCancelledError("Existing .paseo/ bundle left unchanged."); - } - const origin = state.origin ?? (await ensureLogin(activeLogin?.origin, environment)); const daemonId = state.daemonId ?? (await ensureDaemonConnection(origin, environment)); - const project = await chooseProject(origin, environment); - const resources = await loadSetupResources(origin, environment); + const resources = await loadConfigurationResources(origin, environment); const triggerConnections = await resolveStarterTriggerConnections(resources, cwd); reportStarterTriggerConnections(environment, triggerConnections); const trigger = await chooseStarterTriggerConnection(environment, triggerConnections); - const daemon = await loadDaemonResource(origin, daemonId, environment); + const daemon = resources.daemons.find(({ id }) => id === daemonId); + if (daemon === undefined) { + throw new HubCommandError( + "HUB_DAEMON_RESOURCE_MISSING", + "The connected daemon is not available in this Hub organization. Reconnect it and try again.", + ); + } log.success(`Connected as ${daemon.slug}`); const agent = await chooseStarterAgentRuntime(environment, cwd); const providerFilters = await collectProviderIdentity(trigger, environment); @@ -151,40 +135,40 @@ export async function runHubGuidedSetup( providerFilters, }); - const bundle = createHubInitBundle(project.slug, scaffold); + const credential = resolveHubCredential({ + options: { origin }, + env: environment.env, + credentials: environment.credentials, + origin, + }); - await withSpinner("Validating bundle", async () => { - await runHubDeployBundle({ project: project.slug, hub: origin, dryRun: true }, bundle, { - cwd, - env: environment.env, - credentials: environment.credentials, - hub: environment.hub, - reporter: { progress() {} }, - }); + await withSpinner("Validating trigger", async () => { + await environment.hub.validateTrigger(origin, credential, scaffold.trigger); }); log.success("Dry run passed"); - await writeScaffold(cwd, scaffold, opening.replaceExisting); - log.success(`Created .paseo/hub.yml and ${scaffold.workflowPath}`); + const triggerExists = await prepareScaffoldDestination(cwd, scaffold.triggerPath); + if ( + triggerExists && + !(await requiredConfirm(environment, `Replace the existing ${scaffold.triggerPath}?`, false)) + ) { + throw new HubInitCancelledError(`${scaffold.triggerPath} left unchanged.`); + } + await writeScaffold(cwd, scaffold, triggerExists); + log.success(`Created ${scaffold.triggerPath}`); const deploy = state.deploy ?? (await requiredConfirm(environment, "Deploy now?", true)); if (deploy) { - await withSpinner("Deploying bundle", async () => { - await runHubDeployBundle({ project: project.slug, hub: origin }, bundle, { - cwd, - env: environment.env, - credentials: environment.credentials, - hub: environment.hub, - reporter: { progress() {} }, - }); + await withSpinner("Deploying trigger", async () => { + await environment.hub.installTrigger(origin, credential, scaffold.trigger); }); log.success("Deployed"); } else { - reportMessage(environment, `Skipped deployment. Run: paseo hub deploy -p ${project.slug}`); + reportMessage(environment, "Skipped deployment. Run `paseo hub deploy` when ready."); } - const activityUrl = new URL(`/projects/${project.slug}/activity`, origin).toString(); - note(`${scaffold.testAction}\nWatch it at ${activityUrl}`, "Test your workflow"); - outro(deploy ? "Hub is ready" : "Hub bundle is ready"); + const triggersUrl = new URL("/triggers", origin).toString(); + note(`${scaffold.testAction}\nManage it at ${triggersUrl}`, "Test your trigger"); + outro(deploy ? "Hub is ready" : "Hub trigger is ready"); } export async function continueHubGuidedSetup( @@ -319,7 +303,7 @@ async function ensureDaemonConnection( true, )) ) { - throw new HubInitCancelledError("A connected daemon is required to create the bundle."); + throw new HubInitCancelledError("A connected daemon is required to create the trigger."); } return connectDaemon(origin, environment, permissions); } @@ -379,48 +363,8 @@ async function waitForDaemonReady( ); } -async function chooseProject( - origin: string, - environment: HubGuidedSetupEnvironment, -): Promise { - const result = await runHubProjects( - { hub: origin }, - { - env: environment.env, - credentials: environment.credentials, - hub: environment.hub, - reporter: { progress() {} }, - }, - ); - const resolution = resolveHubInitProjects(result.data.projects); - if (resolution.kind === "none") { - throw new HubInitCancelledError( - `No Hub projects exist yet. Create one at ${new URL("/projects/new", origin).toString()}, then run paseo hub init again.`, - ); - } - if (resolution.kind === "selected") { - return resolution.project; - } - const slug = await requiredSelect(environment, { - message: "Project", - options: resolution.projects.map((project) => ({ - value: project.slug, - label: project.name, - hint: project.slug, - })), - }); - const project = resolution.projects.find((candidate) => candidate.slug === slug); - if (project === undefined) { - throw new HubCommandError( - "HUB_PROJECT_SELECTION_INVALID", - "The selected Hub project is unavailable.", - ); - } - return project; -} - async function resolveStarterTriggerConnections( - resources: HubSetupResources, + resources: HubConfigurationResources, cwd: string, ): Promise { const remote = await readCommandValue("git", ["remote", "get-url", "origin"], cwd); @@ -428,7 +372,7 @@ async function resolveStarterTriggerConnections( const connections = availableStarterTriggerConnections(resources, repository); if (connections.length === 0) { throw new HubInitCancelledError( - "No Hub app connection is ready for this workflow.\nConnect GitHub, Slack, or Discord in Hub → Apps, then run `paseo hub init` again.", + "No Hub app connection is ready for this trigger.\nConnect GitHub, Slack, or Discord in Hub → Apps, then run `paseo hub init` again.", ); } return connections; @@ -440,10 +384,10 @@ function reportStarterTriggerConnections( ): void { const details = `${connections.map(({ label }) => label).join("\n")}\n\nOnly configured connections are shown. To add another, open Hub → Apps, then run \`paseo hub init\` again.`; if (environment.prompts === undefined) { - note(details, "Hub app connections ready for this workflow"); + note(details, "Hub app connections ready for this trigger"); return; } - environment.prompts.message(`Hub app connections ready for this workflow:\n${details}`); + environment.prompts.message(`Hub app connections ready for this trigger:\n${details}`); } async function chooseStarterTriggerConnection( @@ -472,19 +416,19 @@ async function chooseStarterTriggerConnection( async function chooseStarterAgentRuntime( environment: HubGuidedSetupEnvironment, cwd: string, -): Promise { +): Promise { const providers = await waitForStarterAgentProviders(environment, cwd); const provider = await chooseStarterAgentProvider(environment, providers); const model = await chooseStarterAgentModel(environment, provider); const mode = await chooseStarterAgentMode(environment, provider); const runtime = selectedStarterAgentRuntime(provider, model, mode); - if (runtime === undefined) { + if (runtime?.mode === undefined) { throw new HubCommandError( "HUB_AGENT_RUNTIME_SELECTION_INVALID", "The selected starter agent runtime is no longer available. Run paseo hub init again.", ); } - return runtime; + return { ...runtime, mode: runtime.mode }; } async function waitForStarterAgentProviders( @@ -497,7 +441,14 @@ async function waitForStarterAgentProviders( while (true) { const snapshot = await daemon.getProvidersSnapshot({ cwd }); const state = starterAgentProviderSnapshotState(snapshot.entries); - if (state.kind === "ready") return state.providers; + if (state.kind === "ready") { + const providers = state.providers.filter(({ modes }) => modes.length > 0); + if (providers.length > 0) return providers; + throw new HubCommandError( + "HUB_AGENT_RUNTIME_REQUIRED", + "No agent runtime with an execution mode is available from this daemon. Configure one, then run paseo hub init again.", + ); + } if (state.kind === "unavailable") { throw new HubCommandError( "HUB_AGENT_RUNTIME_REQUIRED", @@ -607,13 +558,13 @@ async function collectProviderIdentity( }; } -async function loadSetupResources( +async function loadConfigurationResources( origin: string, environment: HubGuidedSetupEnvironment, -): Promise { +): Promise { try { return await withSpinner("Loading Hub connections", () => - environment.hub.listSetupResources( + environment.hub.listConfigurationResources( origin, resolveHubCredential({ options: { origin }, @@ -624,77 +575,51 @@ async function loadSetupResources( ), ); } catch (error) { - if (error instanceof HubCommandError && error.code === "HUB_NOT_FOUND") { + if ( + error instanceof HubCommandError && + (error.code === "HUB_NOT_FOUND" || error.code === "HUB_INVALID_RESPONSE") + ) { throw new HubCommandError( "HUB_UPDATE_REQUIRED", - "This Hub needs an update before guided setup can use provider IDs. Update Hub and try again.", + "This Hub needs an update before guided setup can use organization triggers. Update Hub and try again.", ); } throw error; } } -async function loadDaemonResource( - origin: string, - daemonId: string, - environment: HubGuidedSetupEnvironment, -): Promise { - const resources = await environment.hub.listConfigurationResources( - origin, - resolveHubCredential({ - options: { origin }, - env: environment.env, - credentials: environment.credentials, - origin, - }), - ); - const daemon = resources.daemons.find(({ id }) => id === daemonId); - if (daemon === undefined) { - throw new HubCommandError( - "HUB_DAEMON_RESOURCE_MISSING", - "The connected daemon is not available in this Hub organization. Reconnect it and try again.", - ); - } - return daemon; -} - async function writeScaffold( cwd: string, scaffold: ReturnType, replaceExisting: boolean, ): Promise { - const root = path.join(cwd, ".paseo"); - const staging = path.join(cwd, `.paseo-init-${randomUUID()}`); - const backup = path.join(cwd, `.paseo-backup-${randomUUID()}`); - let movedExisting = false; + const destination = path.join(cwd, scaffold.triggerPath); + const temporary = path.join( + path.dirname(destination), + `.${path.basename(destination)}.${process.pid}.${randomUUID()}.tmp`, + ); try { - await mkdir(path.join(staging, "workflows"), { recursive: true }); - await writeFile(path.join(staging, "hub.yml"), scaffold.hub, { flag: "wx" }); - await writeFile( - path.join(staging, "workflows", path.basename(scaffold.workflowPath)), - scaffold.workflow, - { - flag: "wx", - }, - ); + await writeFile(temporary, scaffold.trigger, { flag: "wx" }); if (replaceExisting) { - await rename(root, backup); - movedExisting = true; + await rename(temporary, destination); + } else { + await link(temporary, destination); + await rm(temporary); } - await rename(staging, root); - if (movedExisting) await rm(backup, { recursive: true, force: true }); } catch (error) { - await rm(staging, { recursive: true, force: true }); - if (movedExisting) { - await rename(backup, root).catch(() => undefined); - } + await rm(temporary, { force: true }); throw error; } } -async function pathExists(target: string): Promise { +async function prepareScaffoldDestination(cwd: string, triggerPath: string): Promise { + const root = path.resolve(cwd); + await requireSafeScaffoldDirectory(path.join(root, ".paseo"), ".paseo"); + await requireSafeScaffoldDirectory(path.join(root, ".paseo", "triggers"), ".paseo/triggers"); + const destination = path.join(root, triggerPath); try { - await lstat(target); + const stats = await lstat(destination); + if (stats.isSymbolicLink()) throw unsafeScaffoldPath(triggerPath); return true; } catch (error) { if (errorCode(error) === "ENOENT") return false; @@ -702,6 +627,26 @@ async function pathExists(target: string): Promise { } } +async function requireSafeScaffoldDirectory(target: string, displayPath: string): Promise { + try { + const stats = await lstat(target); + if (stats.isSymbolicLink()) throw unsafeScaffoldPath(displayPath); + if (!stats.isDirectory()) { + throw new HubCommandError( + "HUB_TRIGGER_DIRECTORY_INVALID", + `${displayPath} must be a directory.`, + ); + } + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + await mkdir(target); + } +} + +function unsafeScaffoldPath(triggerPath: string): HubCommandError { + return new HubCommandError("HUB_TRIGGER_UNSAFE_PATH", `${triggerPath} must not use a symlink.`); +} + function errorCode(error: unknown): string | undefined { return error instanceof Error && "code" in error && typeof error.code === "string" ? error.code diff --git a/packages/cli/src/commands/hub/starter-trigger.test.ts b/packages/cli/src/commands/hub/starter-trigger.test.ts index 570ea0005d..f664bc37e2 100644 --- a/packages/cli/src/commands/hub/starter-trigger.test.ts +++ b/packages/cli/src/commands/hub/starter-trigger.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { availableStarterTriggerConnections } from "./starter-trigger.js"; describe("starter trigger connections", () => { - it("returns only concrete connections that can back the generated workflow", () => { + it("returns only concrete connections that can back the generated trigger", () => { expect( availableStarterTriggerConnections( { @@ -14,8 +14,10 @@ describe("starter trigger connections", () => { repositories: ["getpaseo/paseo"], }, ], - slack: [{ teamId: "T123", teamName: "Paseo" }], - discord: [{ guildId: "456", guildName: "Paseo Discord" }], + slack: [{ slug: "paseo", teamName: "Paseo" }], + discord: [{ slug: "paseo-discord", guildName: "Paseo Discord" }], + daemons: [], + linear: [], }, "getpaseo/paseo", ), @@ -24,19 +26,19 @@ describe("starter trigger connections", () => { id: "github:getpaseo/paseo", label: "GitHub — getpaseo/paseo", provider: "github", - filters: { repo: "getpaseo/paseo" }, + filters: { connection: "github-getpaseo", repo: "getpaseo/paseo" }, }, { - id: "slack:T123", + id: "slack:paseo", label: "Slack — Paseo", provider: "slack", - filters: { workspace: "T123" }, + filters: { connection: "paseo" }, }, { - id: "discord:456", + id: "discord:paseo-discord", label: "Discord — Paseo Discord", provider: "discord", - filters: { guild: "456" }, + filters: { connection: "paseo-discord" }, }, ]); }); @@ -55,6 +57,8 @@ describe("starter trigger connections", () => { ], slack: [], discord: [], + daemons: [], + linear: [], }, "getpaseo/paseo", ), diff --git a/packages/cli/src/commands/hub/starter-trigger.ts b/packages/cli/src/commands/hub/starter-trigger.ts index c327beb32a..d5ce8b51ac 100644 --- a/packages/cli/src/commands/hub/starter-trigger.ts +++ b/packages/cli/src/commands/hub/starter-trigger.ts @@ -1,4 +1,4 @@ -import type { HubSetupResources } from "./hub-client/index.js"; +import type { HubConfigurationResources } from "./hub-client/index.js"; import type { HubInitProvider } from "./init-plan.js"; export interface HubStarterTriggerConnection { @@ -9,32 +9,36 @@ export interface HubStarterTriggerConnection { } export function availableStarterTriggerConnections( - resources: HubSetupResources, + resources: HubConfigurationResources, githubRepository?: string, ): HubStarterTriggerConnection[] { return [ - ...(githubRepository !== undefined && - resources.github.some(({ repositories }) => repositories.includes(githubRepository)) - ? [ - { - id: `github:${githubRepository}`, - label: `GitHub — ${githubRepository}`, - provider: "github" as const, - filters: { repo: githubRepository }, - }, - ] - : []), - ...resources.slack.map(({ teamId, teamName }) => ({ - id: `slack:${teamId}`, + ...resources.github.flatMap((connection) => + githubRepository !== undefined && connection.repositories.includes(githubRepository) + ? [ + { + id: `github:${githubRepository}`, + label: `GitHub — ${githubRepository}`, + provider: "github" as const, + filters: { + connection: connection.slug, + repo: githubRepository, + }, + }, + ] + : [], + ), + ...resources.slack.map(({ slug, teamName }) => ({ + id: `slack:${slug}`, label: `Slack — ${teamName}`, provider: "slack" as const, - filters: { workspace: teamId }, + filters: { connection: slug }, })), - ...resources.discord.map(({ guildId, guildName }) => ({ - id: `discord:${guildId}`, + ...resources.discord.map(({ slug, guildName }) => ({ + id: `discord:${slug}`, label: `Discord — ${guildName}`, provider: "discord" as const, - filters: { guild: guildId }, + filters: { connection: slug }, })), ]; } diff --git a/public-docs/cli.md b/public-docs/cli.md index 72ad71d076..ed626863f2 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -293,25 +293,26 @@ Use `PASEO_HOME` to run multiple isolated daemon instances. ```bash paseo hub login [url] # Approve and store organization-scoped CLI access -paseo hub init # Guided setup: scaffold and deploy a starter bundle here +paseo hub init # Create and optionally deploy a starter trigger here paseo hub connect [url] # Enroll this daemon using CLI access -paseo hub projects # List projects in the authenticated organization +paseo hub projects # List legacy projects in the authenticated organization paseo hub status # Show the current Hub relationship paseo hub disconnect # End it -paseo hub deploy -p # Discover, validate, and activate a Hub bundle -paseo hub deploy -p --dry-run # Validate without activating +paseo hub deploy # Validate and install .paseo/triggers/*.yml +paseo hub deploy --dry-run # Validate without installing +paseo hub deploy -p # Deploy an existing legacy project bundle paseo hub logout # Remove the active stored CLI login ``` -Run deploy from the project root. It reads `.paseo/hub.yml`, every direct `.paseo/workflows/*.yml` file, and referenced `.paseo/workflows/partials/*` files in deterministic path order. It does not search parents, accept an alternate resource path, or flatten the bundle into monolithic YAML. +Run deploy from the repository root. By default it reads every direct `.paseo/triggers/*.yml` file in deterministic path order. It validates all triggers before installing them one at a time. If an installation fails after earlier ones succeeded, the error lists the installed files. `--dry-run` only validates; it does not create or activate revisions. -Pass `-p, --project ` to select the target project. `--dry-run` performs the same discovery and server validation without recording or activating a revision. Both outputs include the resolved Hub, project, and discovered workflow count. +Pass `-p, --project ` for an existing legacy bundle: `.paseo/hub.yml`, direct `.paseo/workflows/*.yml` files, and referenced workflow partials. See [Deploy from the CLI](/docs/hub/configuration#deploy-from-the-cli). -`login` opens the Hub approval page and stores a durable organization-scoped CLI credential under `PASEO_HOME`. In an interactive terminal it then asks whether to connect this daemon and whether to initialize and deploy a starter workflow, both defaulting to yes. Declining the connection prints `paseo hub connect ; then paseo hub init`, because the connection alone does not produce a bundle; declining only the starter prints `paseo hub init`. `--json` and non-TTY login remain login-only and never prompt. The stored login is separate from the daemon relationship created by `connect`. +`login` opens the Hub approval page and stores a durable organization-scoped CLI credential under `PASEO_HOME`. In an interactive terminal it offers to connect this daemon, then separately asks whether to allow Hub automations to run agents. Connection defaults to yes; execution permission defaults to no. It then links to Hub's **Triggers** page and prints `paseo hub init` for setup as code. `--json` and non-TTY login remain login-only and never prompt. The stored login is separate from the daemon relationship created by `connect`. -`init` runs the same guided setup on its own and requires a TTY. It connects the daemon, uses the organization's only project or asks which one, and lists the Hub app connections that can back a starter workflow. One usable connection is selected automatically; with several, you choose a **Trigger connection**. If none is ready, setup sends you to **Hub → Apps** and stops before selecting an agent or writing files. +`init` requires a TTY. It signs in and connects the daemon as needed, then lists the organization's app connections that can back a starter trigger. One usable connection is selected automatically; with several, you choose a **Trigger connection**. If none is ready, setup sends you to **Hub → Apps** and stops before selecting an agent or writing files. -Setup then asks which agent provider, model, and mode the starter should run, choosing from what the connected daemon reports. A provider is offered only when the daemon has it enabled with a selectable model. Suggested model and mode entries are the daemon's defaults; no provider is suggested merely because it appears first. The mode question is skipped for providers that expose no modes and asked explicitly when the daemon has modes but no default. Finally, setup asks for the identity that gates the chosen connection: a GitHub username, a Slack member ID, or a Discord user ID. It writes `.paseo/hub.yml` and `.paseo/workflows/-help.yml`, validates them against Hub, and deploys. An existing `.paseo/` directory is replaced only after you confirm. See the [generated starter bundle](/docs/hub/configuration#generated-starter-bundle). +Setup asks which agent provider, model, and mode to run. Providers must be enabled and expose both a selectable model and an execution mode. Suggested model and mode entries are the daemon's defaults; a mode is still selected explicitly when there is no default. Setup then asks for the identity allowed to trigger the bot: a GitHub username, Slack member ID, or Discord user ID. It validates the trigger, writes `.paseo/triggers/-help.yml`, and asks whether to deploy. Replacing that file requires confirmation; existing legacy bundles and other trigger files are preserved. See the [generated starter trigger](/docs/hub/configuration#generated-starter-trigger). Interactive logout checks the same-origin daemon relationship and asks whether to disconnect before deleting the login. Declining removes only the login. JSON and noninteractive logout never prompt or disconnect implicitly; `--disconnect-daemon` is the explicit automation path, and `--force` applies to that daemon disconnection. If a requested disconnection fails, the login is preserved. diff --git a/public-docs/hub/api.md b/public-docs/hub/api.md index cfc0bbc7eb..36de361062 100644 --- a/public-docs/hub/api.md +++ b/public-docs/hub/api.md @@ -1,6 +1,6 @@ --- title: Hub public API -description: Use organization credentials to list projects, validate or install configuration, dispatch runs, and enroll daemons. +description: Use organization credentials to install triggers, manage legacy configuration, dispatch runs, and enroll daemons. nav: Public API order: 79 category: Hub @@ -8,7 +8,7 @@ category: Hub # Hub public API -The Hub public API lets automation operate on projects and daemons in one +The Hub public API lets automation operate on triggers, projects, and daemons in one organization. Set the Hub origin in `PASEO_HUB_URL` below, for example `https://hub.example.com`. @@ -37,13 +37,13 @@ organization is not accessible through the key. Each key has one or more selectable scopes: -| Scope | Operation | -| ------------------------ | --------------------------------------------------- | -| `projects:read` | List active projects in the organization. | -| `configuration:validate` | Validate configuration without changing Hub state. | -| `configuration:install` | Replace and activate a project's configuration. | -| `runs:dispatch` | Dispatch a configured manual trigger for a project. | -| `daemons:enroll` | Issue a short-lived daemon enrollment token. | +| Scope | Operation | +| ------------------------ | --------------------------------------------------------------------- | +| `projects:read` | List active projects in the organization. | +| `configuration:validate` | Validate triggers or legacy configuration without changing Hub state. | +| `configuration:install` | Install triggers or replace a legacy project's configuration. | +| `runs:dispatch` | Dispatch a configured manual trigger for a project. | +| `daemons:enroll` | Issue a short-lived daemon enrollment token. | API keys do not grant dashboard access. They cannot manage connections, projects, or organization members. @@ -65,6 +65,34 @@ API failures use RFC 9457 problem details. Missing, invalid, or revoked credenti A valid key without the scope required by an endpoint returns `403` in the same format. +## Trigger validation and installation + +`paseo hub deploy --dry-run` validates each `.paseo/triggers/*.yml` file through `POST /api/v1/triggers/validate`. `paseo hub deploy` validates all files first, then installs each through `POST /api/v1/triggers/install`. + +Both endpoints accept one self-contained document: + +```json +{ + "yaml": "name: manual-task\nenabled: true\non:\n manual.run: {}\nrun:\n target: { daemon: my-macbook, cwd: /workspace }\n agent: { provider: codex, model: gpt-5, mode: full-access }\n prompt: Complete the task and call hub.finish_execution.\n max_runtime: 1h\n idle_timeout: 5m\n" +} +``` + +Use a daemon slug and agent runtime available in your organization. Validation requires `configuration:validate` and returns `200` with `{ "name": "manual-task", "valid": true }`. + +Installation requires `configuration:install`. It creates or updates the organization's trigger by the YAML `name` and returns `201`: + +```json +{ + "triggerId": "00000000-0000-4000-8000-000000000001", + "name": "manual-task", + "revisionId": "00000000-0000-4000-8000-000000000002", + "version": 1, + "active": true +} +``` + +Invalid YAML or an unknown organization resource returns `422` with field issues. Each installation is a separate request; a later failure does not undo earlier successful installs. See [Deploy from the CLI](/docs/hub/configuration#deploy-from-the-cli). + ## Project list `GET /api/v1/projects` returns active projects in the bearer credential's organization. `paseo hub projects` renders the projects as a table. With `--json`, it returns `{ "origin": "...", "projects": [...] }` so even an empty result records the resolved Hub. @@ -81,7 +109,7 @@ A valid key without the scope required by an endpoint returns `403` in the same } ``` -## Configuration validation +## Legacy configuration validation `POST /api/v1/configurations/validate` accepts the same `projectSlug` and complete `files` bundle as configuration install. It performs the same compilation and resource resolution without recording a revision or changing the active configuration. @@ -94,9 +122,9 @@ On success, Hub returns `200`: } ``` -`paseo hub deploy --dry-run` calls this endpoint with the identical locally resolved payload that a deployment would send. +`paseo hub deploy --project --dry-run` calls this endpoint with the identical locally resolved payload that a deployment would send. -## Configuration install +## Legacy configuration install `configuration:install` validates the supplied canonical bundle, stores the exact authored files, and activates a new revision. @@ -157,7 +185,7 @@ curl --fail-with-body -sS -X POST "$PASEO_HUB_URL/api/v1/configurations/install" --data @configuration-install.json ``` -`paseo hub deploy -p ` calls this endpoint with the discovered local bundle. The command uses an exact-origin stored login when flags and environment credentials are absent. See [Deploy from the CLI](/docs/hub/configuration#deploy-from-the-cli). +`paseo hub deploy -p ` selects this legacy endpoint with the discovered local bundle. The command uses an exact-origin stored login when flags and environment credentials are absent. See [Deploy from the CLI](/docs/hub/configuration#deploy-from-the-cli). ## Manual run dispatch diff --git a/public-docs/hub/configuration/index.md b/public-docs/hub/configuration/index.md index 6df97c29e1..1d82ff27d6 100644 --- a/public-docs/hub/configuration/index.md +++ b/public-docs/hub/configuration/index.md @@ -1,6 +1,6 @@ --- title: Hub configuration -description: Configuration bundles, GitHub sync, CLI deployment, and revisions. +description: Generate, edit, and deploy organization triggers from your repository. nav: Configuration order: 70 category: Hub @@ -8,98 +8,75 @@ category: Hub # Hub configuration -A project configuration is one versioned bundle: +Each organization trigger is one self-contained YAML file. Keep triggers in your repository and deploy them with `paseo hub deploy`: ```text .paseo/ -├── hub.yml -└── workflows/ - ├── .yml - └── partials/ - └── .md +└── triggers/ + └── .yml ``` -`hub.yml` owns named environments and agents. Each direct-child workflow file owns one trigger and its ordered inline steps. Prompt partials referenced by those workflows live below `workflows/partials/`. Workflow discovery is fixed by convention; there is no manifest or include list. +## Generated starter trigger -[single-repo-team-bot](https://github.com/getpaseo/hub/tree/main/examples/single-repo-team-bot) is a complete bundle in this shape: Discord, Slack, and GitHub workflows running a classifier and a worker on shared partials. Copy `.paseo/` into your repository and replace the placeholders its README lists. +Run `paseo hub init` from the repository the agent should work in. Setup selects an app connection and an available agent runtime, asks which user may trigger it, validates the result, and writes one file. It then asks whether to deploy. Interactive `paseo hub login` connects the daemon and points to this command; it does not write trigger files. -## Generated starter bundle - -`paseo hub init`, and the guided setup that interactive `paseo hub login` offers, write two files for the directory you run them in: +For a Slack connection named `my-team`, the generated document looks like this: ```yaml -# .paseo/hub.yml -environments: - my-macbook: - kind: daemon +# .paseo/triggers/slack-help.yml +name: slack-help +enabled: true +on: + slack.mention: + connection: my-team + filters: + from_users: [U01234567] +max_runtime: 2h +run: + target: daemon: my-macbook cwd: /Users/you/code/your-repo -agents: - starter: + agent: provider: codex model: gpt-5 mode: full-access + continuation: + mode: conversation + max_runtime: 90m + idle_timeout: 10m + prompt: | + Answer with hub.reply, then complete this request and call hub.finish_execution when done. + + + ${{ paseo.prompt }} + + outputs: + slack.reply: + max: 1 + required: true ``` -The environment is named after the connected daemon and points at the directory setup ran in. `provider`, `model`, and `mode` are the runtime you chose from what that daemon reported, so the starter agent is a complete selection before its first run. `mode` is omitted for a provider that exposes none. +`connection` is the app connection's slug in your organization. `target.daemon` is the connected daemon's slug and `target.cwd` is the absolute directory where you ran setup. `agent` contains the provider, model, and execution mode you selected from the daemon. The mode is required. -```yaml -# .paseo/workflows/slack-help.yml -name: slack-help -on: slack.mention -max_runtime: 2h -filters: - workspace: T01234567 - from_users: - - U01234567 -steps: - - id: work - environment: my-macbook - max_runtime: 90m - idle_timeout: 10m - agent: starter - prompt: - - text: | - Answer with hub.reply, then complete this request and call hub.finish_execution when done. +`continuation.mode: conversation` keeps follow-ups in the same provider conversation on the same agent. The prompt asks the agent to reply and then call `hub.finish_execution`; replying alone does not finish the execution. - - ${{ paseo.prompt }} - - allow_outputs: - - type: slack.reply - max: 1 - required: true -``` +A Discord starter uses `discord.mention`, your Discord user ID, and `discord.reply`. A GitHub starter uses `github.issue_comment`, restricts the repository to the current GitHub remote, and requires both `@paseo` and your GitHub username. GitHub's starter has no explicit reply output declaration. -`filters` carries the identity each provider matches on: the Slack team and member ID above, the Discord guild and user ID for a Discord starter, and `owner/name` plus your login for GitHub. - -A Discord starter is `discord-help.yml` and carries `discord.reply`, the counterpart of the `slack.reply` above. A GitHub starter is `github-help.yml` and declares no reply output; GitHub has no reply capability, so a step that must comment needs a [`github` block](/docs/hub/github) instead. - -The generated workflow allows one user in one workspace. Read [Hub security](/docs/hub/security) before widening it. - -## Sources - -A configuration comes from one source: - -- **GitHub source**: the complete `.paseo` bundle on the repository's default branch. -- **Manual source**: source files edited and activated in the dashboard. -- **CLI/API install**: a complete bundle sent with organization authority. - -The **Configuration** tab shows the active revision, source files, and latest synchronization attempt. +Setup asks before replacing the selected trigger file. It preserves other triggers and any existing legacy bundle. Read [Hub security](/docs/hub/security) before widening `from_users` or the agent's authority. ## Deploy from the CLI -Run from the project root: +Run from the repository root: ```sh paseo hub login https://hub.example.com -paseo hub deploy -p my-project --dry-run -paseo hub deploy -p my-project +paseo hub deploy --dry-run +paseo hub deploy ``` -Both commands discover `.paseo/hub.yml`, every direct `.paseo/workflows/*.yml` file, and each referenced file below `.paseo/workflows/partials/`. Files are sent in deterministic path order through the same bundle request. Dry-run calls server-side validation and does not create or activate a revision. +Both deploy commands discover direct `.paseo/triggers/*.yml` files in deterministic path order. The CLI rejects nested files, `.yaml` extensions, symlinked trigger paths, and unreadable files. It does not search parent directories. -The CLI rejects missing resource or workflow files, `.yaml` workflow extensions, nested workflow files, unsafe partial paths, symlinked bundle paths, and unreadable files before contacting Hub. Errors name paths but never print file contents or credentials. +Dry-run validates each document against Hub without storing a revision. Deployment validates all documents first, then installs them one at a time through the organization trigger API. Installation creates or updates a trigger by its YAML `name`. If a later install fails, the error lists the files already installed; those revisions remain active. Errors name paths without printing file contents or credentials. Origin precedence: @@ -116,6 +93,21 @@ Credential precedence: Flags and environment keys are not stored. Endpoint and credential behavior is unchanged between deploy and dry-run. +## Legacy project bundles + +Existing project bundles use `.paseo/hub.yml`, direct `.paseo/workflows/*.yml` files, and referenced files below `.paseo/workflows/partials/`. `hub.yml` owns named environments and agents; each workflow owns its trigger and ordered steps. + +Select the legacy deployment path explicitly: + +```sh +paseo hub deploy --project my-project --dry-run +paseo hub deploy --project my-project +``` + +These commands send the complete bundle through the project configuration API. Dry-run validates without recording or activating a revision. `paseo hub init` does not create or migrate these bundles. + +The following source and revision behavior applies to legacy project bundles. + ## GitHub sync A push to the configuration repository's default branch starts a sync: diff --git a/public-docs/hub/daemons.md b/public-docs/hub/daemons.md index a9cc81267d..a208838266 100644 --- a/public-docs/hub/daemons.md +++ b/public-docs/hub/daemons.md @@ -8,7 +8,7 @@ category: Hub # Daemons in Hub -A daemon is one of your machines running the Paseo daemon. Enroll it once with your Hub organization, then any project can reference it. +A daemon is one of your machines running the Paseo daemon. Enroll it once with your Hub organization, then triggers can reference it. ## Connect @@ -20,7 +20,7 @@ paseo hub login https://hub.example.com The CLI prints a URL and a verification code and opens your browser. The approved login is stored under `PASEO_HOME`. -In an interactive terminal, login then offers to finish setup: whether to connect this daemon, and whether to initialize and deploy a starter workflow. Both default to yes. Declining the connection prints `paseo hub connect ; then paseo hub init`, since connecting alone leaves the project without a workflow. Declining only the starter prints `paseo hub init`. `--json` or non-TTY login only logs in. [Quickstart](/docs/hub/quickstart) walks through the questions. +In an interactive terminal, login offers to connect this daemon and separately asks whether to allow Hub automations to run agents. Connection defaults to yes; execution permission defaults to no. It then links to **Triggers** and prints `paseo hub init` for creating a starter trigger as code. `--json` or non-TTY login only logs in. [Quickstart](/docs/hub/quickstart) walks through connection and initialization. Enroll the daemon on its own when you declined, or when the machine is already logged in: diff --git a/public-docs/hub/index.md b/public-docs/hub/index.md index f7bc1a6ae0..595bffaee5 100644 --- a/public-docs/hub/index.md +++ b/public-docs/hub/index.md @@ -20,7 +20,7 @@ A daemon runs agents on one machine, for you. Paseo Hub is the layer above your What that gives you today: - Agents that start on their own, from activity in GitHub, Slack, and Discord. -- Configuration that lives in a repository and deploys when you push. +- Triggers you can keep in a repository and deploy from the CLI. - A record of everything that arrived, what it matched, and what ran. - One place for your team to see all of it. @@ -28,16 +28,15 @@ Your daemons keep running agents where they always did. Hub decides when to ask ## What lives in your repository -Guided setup creates a project resource file for environments and agents, plus one safe starter workflow: +`paseo hub init` creates one self-contained starter trigger: ```text .paseo/ -├── hub.yml -└── workflows/ +└── triggers/ └── slack-help.yml ``` -Guided setup deploys the bundle, and mentioning the bot starts an agent on your machine. [Quickstart](/docs/hub/quickstart) runs it end to end; the [generated starter bundle](/docs/hub/configuration#generated-starter-bundle) shows what it wrote, and [Workflows](/docs/hub/workflows) covers routing, prompt partials, and provider-specific replies. +The file names the app connection, allowed user, daemon, working directory, agent runtime, prompt, and outputs. Setup validates it and asks whether to deploy. Mentioning the bot then starts an agent on your machine. [Quickstart](/docs/hub/quickstart) runs it end to end; the [generated starter trigger](/docs/hub/configuration#generated-starter-trigger) shows what setup wrote. ## Reading order @@ -56,4 +55,4 @@ If a workflow accepts requests from GitHub, Slack, Discord, or the API, read [Hu Start on your machine with the embedded database, then add PostgreSQL or a public deployment only when you need them. [Self-hosting](/docs/hub/self-hosting) covers each step. -[Hosted Hub](/docs/hub/hosted) uses the same projects, workflows, daemons, and activity model. [Sign in to start a free trial](https://hub.paseo.sh). +[Hosted Hub](/docs/hub/hosted) uses the same triggers, daemons, and activity model. [Sign in to start a free trial](https://hub.paseo.sh). diff --git a/public-docs/hub/quickstart.md b/public-docs/hub/quickstart.md index 1f9031c1c0..a0d1f4c991 100644 --- a/public-docs/hub/quickstart.md +++ b/public-docs/hub/quickstart.md @@ -8,7 +8,7 @@ category: Hub # Hub quickstart -Run Hub on your machine, connect it to Slack without a public server, and answer a mention with an agent in your repository. Hub's browser setup hands off to your terminal, and guided setup writes and deploys the workflow for you. +Run Hub on your machine, connect it to Slack without a public server, and answer a mention with an agent in your repository. Hub's browser setup hands off to your terminal, and `paseo hub init` writes and deploys a starter trigger for you. You need [Paseo installed and running](/docs), Node.js, and a Slack workspace where you can create an app. @@ -20,7 +20,7 @@ npx @getpaseo/hub Open the address it prints, normally , and create the operator account Hub asks for. -The first run needs no database, Docker, environment variables, or API keys. Hub creates an embedded database, your organization, and a **Default** project. You never create a project by hand. +The first run needs no database, Docker, environment variables, or API keys. Hub creates an embedded database and your organization. ## 2. Connect Slack @@ -42,37 +42,39 @@ GitHub and Discord can wait. Their setup stays available under **Apps**. paseo hub login http://localhost:3000 ``` -Run it on the machine where your code lives, in the repository the agent should work in. Guided setup records that directory as the workflow's working directory. +Run it on the machine where your code lives, in the repository the agent should work in. Run the initializer below from that directory so it becomes the trigger's working directory. -Approve the login in the browser tab that opens. Leave the Hub tab open: it watches for the daemon and shows **Daemon connected** by itself. **Continue** and **Do this later** both land in the Default project. +Approve the login in the browser tab that opens. Leave the Hub tab open: it watches for the daemon and shows **Daemon connected** by itself. Choose **Continue** when the daemon is connected. -## 4. Answer the setup questions +## 4. Create the starter trigger -Your terminal confirms the login, then picks up where the browser left off. Most questions arrive with a default or a suggested answer; only your Slack member ID has to be typed. +After approving login, answer **Yes** to **Connect this daemon to Paseo Hub?** and **Allow Hub automations to run agents on this daemon?**. Execution permission defaults to no, so enable it explicitly for this setup. -| Question | What it wants | -| ----------------------------------------- | --------------------------------------------------------------------------------------- | -| Connect this daemon to this Hub? | Yes. This enrolls the machine you are on. | -| Initialize and deploy a starter workflow? | Yes. | -| Starter agent provider, model, and mode | What your daemon reports it can run. Suggested model and mode entries are its defaults. | -| Your Slack member ID | `U01234567`, the only account allowed to trigger the bot. | +Then run: -Setup lists the app connections ready for this workflow. Because you connected one Slack workspace in step 2, it selects that connection automatically instead of asking you to choose Slack. If several usable connections exist, setup asks for the **Trigger connection**. If none is ready, it sends you to **Hub → Apps** and stops before asking about the agent or writing files. +```sh +paseo hub init +``` -The agent provider list contains only runtimes the daemon can use; it does not suggest one arbitrarily. Suggested model and mode entries are defaults reported by the daemon. A provider that has modes but no default mode is still offered; setup asks you to pick the mode instead of guessing one. +Choose **Custom endpoint** and confirm `http://localhost:3000`. Setup reuses your login and daemon connection, then lists the app connections ready for this trigger. With one Slack workspace connected, it selects that connection automatically. With several usable connections, choose the **Trigger connection**. If none is ready, setup sends you to **Hub → Apps** and stops before selecting an agent or writing files. -[Find your Slack IDs](/docs/hub/triggers/slack#find-your-slack-ids) has the two clicks that copy your member ID. The Slack workspace comes from the app you connected in step 2, so setup does not ask for it. +| Question | What it wants | +| --------------------------------------- | -------------------------------------------------------------------------------------- | +| Starter agent provider, model, and mode | A runtime available on your daemon. Suggested model and mode entries are its defaults. | +| Your Slack member ID | `U01234567`, the only account allowed to trigger the bot. | +| Deploy now? | Yes, to activate this trigger in Hub. | -Setup then validates the bundle, writes it, and deploys: +Providers must expose a selectable model and execution mode. If there is no default mode, choose the one the agent should use. [Find your Slack IDs](/docs/hub/triggers/slack#find-your-slack-ids) explains how to copy your member ID. The Slack workspace comes from the selected app connection. + +Setup validates the trigger and writes: ```text .paseo/ -├── hub.yml -└── workflows/ +└── triggers/ └── slack-help.yml ``` -If `.paseo/` already exists, setup asks before replacing it. Declining the daemon connection prints `paseo hub connect ; then paseo hub init` — both commands, because connecting alone does not create the workflow. Declining only the starter workflow prints `paseo hub init`. +If `slack-help.yml` already exists, setup asks before replacing that file. Other files remain in place. If you decline deployment, run `paseo hub deploy` from this repository when ready. ## 5. Mention the bot @@ -82,12 +84,12 @@ In the channel you invited the bot to: @Paseo have a look ``` -Hub starts the agent on your daemon and posts its reply in the Slack thread. The terminal prints the project's Activity URL, where the run appears. If nothing runs, [Activity](/docs/hub/activity) tells a filtered mention from one that never matched a workflow. +Hub starts the agent on your daemon and posts its reply in the Slack thread. The terminal links to **Triggers**, where you can manage the trigger and inspect its runs. If nothing runs, [Activity](/docs/hub/activity) tells a filtered mention from one that never matched a trigger. ## Next - [How Hub works](/docs/hub/concepts) — how an event becomes a workflow run on your daemon. -- [Generated starter bundle](/docs/hub/configuration#generated-starter-bundle) — the two files setup wrote, field by field. +- [Generated starter trigger](/docs/hub/configuration#generated-starter-trigger) — the file setup wrote, field by field. - [Workflows](/docs/hub/workflows) — routing, prompts, and provider replies. - [Hub security](/docs/hub/security) — read this before widening `from_users` or giving an agent GitHub authority. From 85cd969321d3d32ac0ac3841cce9757d75e9d9c3 Mon Sep 17 00:00:00 2001 From: Edi Hasaj <34984294+edihasaj@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:01:08 +0200 Subject: [PATCH 047/566] Allow finished workspaces to be marked unread (#3603) Restore unread attention on finished workspaces and preserve the reminder when leaving the current workspace. Cover desktop and compact read flows, and retain change-only file paths for deletion observation. Co-authored-by: Edi Hasaj <34984294+edihasaj@users.noreply.github.com> --- docs/agent-lifecycle.md | 4 + .../sidebar-workspace-mark-unread.spec.ts | 229 ++++++++++++++++++ .../app/e2e/browser/sidebar-workspace.spec.ts | 41 ++++ .../app/e2e/support/helpers/seed-client.ts | 2 + .../src/components/sidebar-workspace-list.tsx | 27 ++- .../sidebar/sidebar-status-list.tsx | 24 +- .../sidebar/sidebar-workspace-menu.tsx | 19 ++ .../sidebar/sidebar-workspace-row.tsx | 24 +- .../hooks/use-clear-workspace-attention.ts | 38 --- .../app/src/hooks/use-workspace-read-state.ts | 49 ++++ .../screens/workspace/workspace-screen.tsx | 46 ++-- packages/app/src/workspace/mark-unread.ts | 26 ++ packages/client/src/daemon-client.test.ts | 39 +++ packages/client/src/daemon-client.ts | 14 ++ packages/protocol/src/messages.ts | 22 ++ .../protocol/src/messages.workspaces.test.ts | 36 +++ .../src/server/agent/agent-manager.test.ts | 50 ++++ .../server/src/server/agent/agent-manager.ts | 56 ++++- .../authorization/operation-permissions.ts | 2 + .../internal/native-recursive.test.ts | 51 ++++ .../internal/native-recursive.ts | 16 +- packages/server/src/server/session.ts | 69 +++++- .../src/server/session.workspaces.test.ts | 126 +++++++++- .../websocket-server.relay-reconnect.test.ts | 1 + .../server/src/server/websocket-server.ts | 2 + .../server/src/server/workspace-directory.ts | 2 +- 26 files changed, 930 insertions(+), 85 deletions(-) create mode 100644 packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts delete mode 100644 packages/app/src/hooks/use-clear-workspace-attention.ts create mode 100644 packages/app/src/hooks/use-workspace-read-state.ts create mode 100644 packages/app/src/workspace/mark-unread.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 418e820979..ddf69fd7c5 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -148,6 +148,10 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**. Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track. +A finished workspace can be marked unread after it has been reviewed. The daemon restores +`finished` attention on its newest eligible workspace-root agent without sending a new completion +notification. Opening the workspace clears that attention through the normal focus flow. + ## The subagents track The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`. diff --git a/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts new file mode 100644 index 0000000000..bc3d4bbf95 --- /dev/null +++ b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts @@ -0,0 +1,229 @@ +import type { Page } from "@playwright/test"; +import { test as base, expect } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { + openAgentRoute, + seedMockAgentWorkspace, + type MockAgentWorkspace, +} from "../support/helpers/mock-agent"; +import { getServerId } from "../support/helpers/server-id"; +import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar"; + +interface FinishedWorkspaces { + subject: MockAgentWorkspace; + other: MockAgentWorkspace; +} + +const test = base.extend<{ workspaces: FinishedWorkspaces }>({ + workspaces: async ({ browserName: _browserName }, provide) => { + const seeded: MockAgentWorkspace[] = []; + async function finishedWorkspace(title: string) { + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "workspace-mark-unread-", + title, + initialPrompt: "Finish this turn.", + }); + seeded.push(workspace); + await workspace.client.waitForFinish(workspace.agentId, 20_000); + await workspace.client.clearWorkspaceAttention(workspace.workspaceId); + return workspace; + } + try { + await provide({ + subject: await finishedWorkspace("Unread subject"), + other: await finishedWorkspace("Other workspace"), + }); + } finally { + for (const workspace of seeded) await workspace.cleanup(); + } + }, +}); + +function workspaceRow(page: Page, workspaceId: string) { + return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`); +} + +async function openWorkspace(page: Page, workspaceId: string) { + await workspaceRow(page, workspaceId).click(); + await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}`)); +} + +async function chooseReadAction(page: Page, workspaceId: string, action: "read" | "unread") { + await workspaceRow(page, workspaceId).hover(); + await page.getByTestId(`sidebar-workspace-kebab-${getServerId()}:${workspaceId}`).click(); + const item = page.getByRole("menuitem", { name: `Mark as ${action}`, exact: true }); + await expect(item).toBeVisible(); + await item.click(); +} + +async function markAsUnread(page: Page, workspaceId: string) { + await chooseReadAction(page, workspaceId, "unread"); + await expectStatus(page, workspaceId, "attention"); +} + +async function markAsRead(page: Page, workspaceId: string) { + await chooseReadAction(page, workspaceId, "read"); + await expectStatus(page, workspaceId, "done"); +} + +async function expectStatus(page: Page, workspaceId: string, status: "done" | "attention") { + await expect( + workspaceRow(page, workspaceId).getByTestId(`workspace-status-indicator-${status}`), + ).toBeVisible(); +} + +async function markBackgroundWorkspaceAndReopen(page: Page, workspaceId: string) { + await test.step("background workspace gains green dot and clears when clicked", async () => { + await markAsUnread(page, workspaceId); + await openWorkspace(page, workspaceId); + await expectStatus(page, workspaceId, "done"); + }); +} + +async function leaveMarkedWorkspaceAndRead(page: Page, { subject, other }: FinishedWorkspaces) { + await test.step("leaving preserves manual unread until Mark as read", async () => { + await markAsUnread(page, subject.workspaceId); + await openWorkspace(page, other.workspaceId); + await markAsRead(page, subject.workspaceId); + await openWorkspace(page, subject.workspaceId); + }); +} + +async function leaveMarkedWorkspaceAndReopen(page: Page, { subject, other }: FinishedWorkspaces) { + await test.step("leaving preserves manual unread until reopening", async () => { + await markAsUnread(page, subject.workspaceId); + await openWorkspace(page, other.workspaceId); + await expectStatus(page, subject.workspaceId, "attention"); + await openWorkspace(page, subject.workspaceId); + await expectStatus(page, subject.workspaceId, "done"); + }); +} + +async function completeTurnAndLeave(page: Page, { subject, other }: FinishedWorkspaces) { + await test.step("ordinary completion still clears on departure", async () => { + await subject.client.sendAgentMessage(subject.agentId, "Finish another turn."); + await subject.client.waitForFinish(subject.agentId, 20_000); + await expectStatus(page, subject.workspaceId, "attention"); + await openWorkspace(page, other.workspaceId); + await expectStatus(page, subject.workspaceId, "done"); + }); +} + +async function openWorkspaceOnCompact(page: Page, workspaceId: string) { + await openWorkspace(page, workspaceId); + await openMobileAgentSidebar(page); +} + +async function leaveMarkedWorkspaceAndReadOnCompact( + page: Page, + { subject, other }: FinishedWorkspaces, +) { + await test.step("leaving preserves manual unread until Mark as read", async () => { + await markAsUnread(page, subject.workspaceId); + await openWorkspaceOnCompact(page, other.workspaceId); + await markAsRead(page, subject.workspaceId); + await openWorkspaceOnCompact(page, subject.workspaceId); + }); +} + +async function leaveMarkedWorkspaceAndReopenOnCompact( + page: Page, + { subject, other }: FinishedWorkspaces, +) { + await test.step("leaving preserves manual unread until reopening", async () => { + await markAsUnread(page, subject.workspaceId); + await openWorkspaceOnCompact(page, other.workspaceId); + await expectStatus(page, subject.workspaceId, "attention"); + await openWorkspaceOnCompact(page, subject.workspaceId); + await expectStatus(page, subject.workspaceId, "done"); + }); +} + +async function completeTurnAndLeaveOnCompact(page: Page, { subject, other }: FinishedWorkspaces) { + await test.step("ordinary completion still clears on departure", async () => { + await closeMobileAgentSidebar(page); + await subject.client.sendAgentMessage(subject.agentId, "Finish another turn."); + await subject.client.waitForFinish(subject.agentId, 20_000); + await openMobileAgentSidebar(page); + await expectStatus(page, subject.workspaceId, "attention"); + await openWorkspaceOnCompact(page, other.workspaceId); + await expectStatus(page, subject.workspaceId, "done"); + }); +} + +async function markBackgroundWorkspaceAndRead(page: Page, workspaceId: string) { + await test.step("explicit Mark as read clears a background workspace", async () => { + await markAsUnread(page, workspaceId); + await markAsRead(page, workspaceId); + }); +} + +async function expectSelectedAgent(page: Page, agentId: string) { + await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toHaveAttribute( + "aria-selected", + "true", + ); +} + +async function addFinishedAgent(workspace: MockAgentWorkspace) { + return workspace.client.createAgent({ + provider: "mock", + cwd: workspace.cwd, + workspaceId: workspace.workspaceId, + title: "Newest agent", + modeId: "load-test", + model: "e2e-fast-stream", + }); +} + +async function openCompactWorkspace(page: Page, workspace: MockAgentWorkspace) { + await page.setViewportSize({ width: 390, height: 844 }); + await openAgentRoute(page, workspace); + await openMobileAgentSidebar(page); +} + +async function markUnreadThenResumeChat(page: Page, workspaceId: string) { + await test.step("interacting with the visible chat clears manual unread", async () => { + await markAsUnread(page, workspaceId); + await closeMobileAgentSidebar(page); + await page.getByRole("textbox", { name: "Message agent..." }).click(); + await openMobileAgentSidebar(page); + await expectStatus(page, workspaceId, "done"); + }); +} + +test("manual unread survives departure and clears on reopening without changing normal completions", async ({ + page, + workspaces, +}) => { + await gotoAppShell(page); + await openWorkspace(page, workspaces.other.workspaceId); + await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId); + await leaveMarkedWorkspaceAndRead(page, workspaces); + await leaveMarkedWorkspaceAndReopen(page, workspaces); + await completeTurnAndLeave(page, workspaces); + await markBackgroundWorkspaceAndRead(page, workspaces.subject.workspaceId); +}); + +test("clicking a multi-agent workspace reveals and clears its marked agent", async ({ + page, + workspaces, +}) => { + await openAgentRoute(page, workspaces.subject); + await expectSelectedAgent(page, workspaces.subject.agentId); + await openWorkspace(page, workspaces.other.workspaceId); + const newest = await addFinishedAgent(workspaces.subject); + await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId); + await expectSelectedAgent(page, newest.id); +}); + +test("manual unread survives leaving the current workspace on compact layout", async ({ + page, + workspaces, +}) => { + await openCompactWorkspace(page, workspaces.subject); + await leaveMarkedWorkspaceAndReadOnCompact(page, workspaces); + await leaveMarkedWorkspaceAndReopenOnCompact(page, workspaces); + await markUnreadThenResumeChat(page, workspaces.subject.workspaceId); + await completeTurnAndLeaveOnCompact(page, workspaces); +}); diff --git a/packages/app/e2e/browser/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts index 14421f84ca..85b52c1581 100644 --- a/packages/app/e2e/browser/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -14,6 +14,7 @@ import { getServerId } from "../support/helpers/server-id"; import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; import { escapeRegex } from "../support/helpers/regex"; import { openFilesPanel } from "../support/helpers/workspace-tabs"; +import { seedMockAgentWorkspace } from "../support/helpers/mock-agent"; const GITHUB_REMOTE_URL = "https://github.com/test-owner/test-repo.git"; @@ -48,6 +49,20 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo return row; } +async function openWorkspaceReadAction( + page: import("@playwright/test").Page, + workspaceId: string, + action: "read" | "unread", +) { + const workspaceKey = `${getServerId()}:${workspaceId}`; + const row = await waitForSidebarWorkspace(page, workspaceId); + await row.hover(); + await page.getByTestId(`sidebar-workspace-kebab-${workspaceKey}`).click(); + const item = page.getByTestId(`sidebar-workspace-menu-mark-as-${action}-${workspaceKey}`); + await expect(item).toBeVisible({ timeout: 30_000 }); + return item; +} + async function openWorkspaceHoverCard(page: import("@playwright/test").Page, workspaceId: string) { const row = await waitForSidebarWorkspace(page, workspaceId); await row.hover(); @@ -187,6 +202,32 @@ test.describe("Sidebar workspace list", () => { } }); + test("marks a finished workspace unread until it is opened again", async ({ page }) => { + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "sidebar-mark-unread-", + title: "Mark unread", + initialPrompt: "Finish this test turn.", + }); + + try { + await workspace.client.waitForFinish(workspace.agentId, 20_000); + await workspace.client.clearWorkspaceAttention(workspace.workspaceId); + expect(workspace.client.getLastServerInfoMessage()?.features?.workspaceMarkUnread).toBe(true); + await gotoAppShell(page); + + const row = await waitForSidebarWorkspace(page, workspace.workspaceId); + await expect(row.getByTestId("workspace-status-indicator-done")).toBeVisible(); + await (await openWorkspaceReadAction(page, workspace.workspaceId, "unread")).click(); + await openWorkspaceReadAction(page, workspace.workspaceId, "read"); + + await page.keyboard.press("Escape"); + await openWorkspaceFromSidebar(page, workspace.workspaceId); + await openWorkspaceReadAction(page, workspace.workspaceId, "unread"); + } finally { + await workspace.cleanup(); + } + }); + test("Paseo-owned worktree hover card shows the worktree directory name", async ({ page }) => { await withPaseoOwnedWorktree(async ({ projectName, workspaceId, worktreeSlug }) => { await gotoAppShell(page); diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index 5abfb54c1b..869e96b294 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -45,6 +45,7 @@ export interface SeedDaemonClient { entries: SeedWorkspaceDescriptor[]; }>; setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>; + clearWorkspaceAttention(workspaceId: string): Promise; setWorkspaceLabel(input: { workspaceId: string; label: { name: string; color: "red" }; @@ -159,6 +160,7 @@ export interface SeedDaemonClient { features?: { projectAdd?: boolean; workspaceRecovery?: boolean; + workspaceMarkUnread?: boolean; } | null; } | null; fetchAgentHistory(options?: { diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 347315bd19..23e4292aeb 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -124,7 +124,7 @@ import { Shortcut } from "@/components/ui/shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; -import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention"; +import { useWorkspaceReadState } from "@/hooks/use-workspace-read-state"; import type { PrHint } from "@/git/use-pr-status-query"; import { buildSidebarProjectRowModel, @@ -284,6 +284,7 @@ interface WorkspaceRowInnerProps { onCopyPath?: () => void; onRename?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; isPinned?: boolean; onTogglePin?: () => void; @@ -612,6 +613,7 @@ function WorkspaceRowRightGroup({ archiveShortcutKeys, onArchive, onMarkAsRead, + onMarkAsUnread, onCopyBranchName, onCopyPath, onRename, @@ -631,6 +633,7 @@ function WorkspaceRowRightGroup({ archiveShortcutKeys?: ShortcutKey[][] | null; onArchive?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; onRename?: () => void; @@ -682,6 +685,7 @@ function WorkspaceRowRightGroup({ onCopyBranchName={onCopyBranchName} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} @@ -1065,6 +1069,8 @@ function WorkspaceRowInner({ onCopyBranchName, onCopyPath, onRename, + onMarkAsRead, + onMarkAsUnread, archiveShortcutKeys, isPinned, onTogglePin, @@ -1135,6 +1141,8 @@ function WorkspaceRowInner({ onCopyPath={onCopyPath} onCopyBranchName={onCopyBranchName} onRename={onRename} + onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} @@ -1185,6 +1193,8 @@ function WorkspaceRowInner({ onCopyBranchName={onCopyBranchName} onCopyPath={onCopyPath} onRename={onRename} + onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} isPinned={isPinned} onTogglePin={onTogglePin} /> @@ -1286,15 +1296,21 @@ function WorkspaceRowWithMenu({ const onTogglePin = canPin ? handleTogglePin : undefined; const archiveShortcutKeys = useShortcutKeys("archive-workspace"); - const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ - serverId: workspace.serverId, - workspaceId: workspace.workspaceId, - }); + const { hasClearableAttention, canMarkUnread, clearAttention, markUnread } = + useWorkspaceReadState({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + }); const handleMarkAsRead = useCallback(() => { void clearAttention().catch((error) => { toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read"); }); }, [clearAttention, toast]); + const handleMarkAsUnread = useCallback(() => { + void markUnread().catch((error) => { + toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread"); + }); + }, [markUnread, toast]); useKeyboardActionHandler({ handlerId: `workspace-archive-${workspace.workspaceKey}`, @@ -1332,6 +1348,7 @@ function WorkspaceRowWithMenu({ onCopyPath={handleCopyPath} onRename={handleOpenRename} onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined} + onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined} archiveShortcutKeys={selected ? archiveShortcutKeys : null} isPinned={isPinned} onTogglePin={onTogglePin} diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index def203c9cd..550f2d0581 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -48,7 +48,7 @@ import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; -import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention"; +import { useWorkspaceReadState } from "@/hooks/use-workspace-read-state"; import { SidebarWorkspaceRowFrame, SidebarWorkspaceRowContent, @@ -641,15 +641,21 @@ function StatusWorkspaceRowWithMenu({ const onTogglePin = canPin ? handleTogglePin : undefined; const archiveShortcutKeys = useShortcutKeys("archive-workspace"); - const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ - serverId: workspace.serverId, - workspaceId: workspace.workspaceId, - }); + const { hasClearableAttention, canMarkUnread, clearAttention, markUnread } = + useWorkspaceReadState({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + }); const handleMarkAsRead = useCallback(() => { void clearAttention().catch((error) => { toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read"); }); }, [clearAttention, toast]); + const handleMarkAsUnread = useCallback(() => { + void markUnread().catch((error) => { + toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread"); + }); + }, [markUnread, toast]); useKeyboardActionHandler({ handlerId: `workspace-archive-${workspace.workspaceKey}`, @@ -682,6 +688,7 @@ function StatusWorkspaceRowWithMenu({ onCopyPath={handleCopyPath} onRename={handleOpenRename} onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined} + onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined} archiveShortcutKeys={selected ? archiveShortcutKeys : null} isPinned={isPinned} onTogglePin={onTogglePin} @@ -719,6 +726,7 @@ interface StatusWorkspaceRowInnerProps { onCopyPath?: () => void; onRename?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; isPinned?: boolean; onTogglePin?: () => void; @@ -765,6 +773,7 @@ function StatusWorkspaceRowInnerContent({ onCopyPath, onRename, onMarkAsRead, + onMarkAsUnread, archiveShortcutKeys, isPinned, onTogglePin, @@ -860,6 +869,7 @@ function StatusWorkspaceRowInnerContent({ onCopyBranchName={onCopyBranchName} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} @@ -907,6 +917,7 @@ function StatusWorkspaceRowInnerContent({ onCopyBranchName={onCopyBranchName} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} @@ -937,6 +948,7 @@ function StatusWorkspaceActionSlot({ onCopyBranchName, onRename, onMarkAsRead, + onMarkAsUnread, onArchive, archiveLabel, archiveStatus, @@ -956,6 +968,7 @@ function StatusWorkspaceActionSlot({ onCopyBranchName?: () => void; onRename?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; onArchive?: () => void; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; @@ -983,6 +996,7 @@ function StatusWorkspaceActionSlot({ onCopyBranchName={onCopyBranchName} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} diff --git a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx index 66df32bdbb..115003a3f2 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx @@ -4,6 +4,7 @@ import { type PressableStateCallbackType } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Archive, + Circle, CircleCheck, Copy, MoreVertical, @@ -52,6 +53,7 @@ const foregroundMutedColorMapping = (theme: Theme) => ({ const ThemedMoreVertical = withUnistyles(MoreVertical); const ThemedCopy = withUnistyles(Copy); const ThemedArchive = withUnistyles(Archive); +const ThemedCircle = withUnistyles(Circle); const ThemedPencil = withUnistyles(Pencil); const ThemedCircleCheck = withUnistyles(CircleCheck); const ThemedPin = withUnistyles(Pin); @@ -63,6 +65,7 @@ const renameLeadingIcon = ); +const markAsUnreadLeadingIcon = ; const archiveLeadingIcon = ; const pinLeadingIcon = ; const unpinLeadingIcon = ; @@ -85,6 +88,7 @@ export interface SidebarWorkspaceMenuProps { onCopyBranchName?: () => void; onRename?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; onArchive: () => void; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; @@ -132,6 +136,7 @@ function SidebarWorkspaceMenuItems({ onCopyBranchName, onRename, onMarkAsRead, + onMarkAsUnread, onArchive, archiveLabel, archiveStatus, @@ -193,6 +198,16 @@ function SidebarWorkspaceMenuItems({ Mark as read ) : null} + {onMarkAsUnread ? ( + + Mark as unread + + ) : null} {onTogglePin ? ( { void clearAttention().catch((error) => { toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read"); }); }, [clearAttention, toast]); + const handleMarkAsUnread = useCallback(() => { + void markUnread().catch((error) => { + toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread"); + }); + }, [markUnread, toast]); useKeyboardActionHandler({ handlerId: `workspace-archive-${workspace.workspaceKey}`, @@ -166,6 +172,7 @@ export function SidebarWorkspaceRow({ onCopyPath={handleCopyPath} onRename={handleOpenRename} onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined} + onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined} archiveShortcutKeys={selected ? archiveShortcutKeys : null} /> void; onRename?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; } @@ -221,6 +229,7 @@ function WorkspaceRowBody({ onCopyPath, onRename, onMarkAsRead, + onMarkAsUnread, archiveShortcutKeys, }: WorkspaceRowBodyProps) { const isCompact = useIsCompactFormFactor(); @@ -293,6 +302,7 @@ function WorkspaceRowBody({ onCopyBranchName={onCopyBranchName} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} @@ -340,6 +350,7 @@ function WorkspaceRowBody({ onCopyPath={onCopyPath} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} /> @@ -365,6 +376,7 @@ function WorkspaceRowTrailingActions({ archiveShortcutKeys, onArchive, onMarkAsRead, + onMarkAsUnread, onCopyBranchName, onCopyPath, onRename, @@ -383,6 +395,7 @@ function WorkspaceRowTrailingActions({ archiveShortcutKeys?: ShortcutKey[][] | null; onArchive?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; onRename?: () => void; @@ -430,6 +443,7 @@ function WorkspaceRowTrailingActions({ onCopyBranchName={onCopyBranchName} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} diff --git a/packages/app/src/hooks/use-clear-workspace-attention.ts b/packages/app/src/hooks/use-clear-workspace-attention.ts deleted file mode 100644 index 0ba74c97e6..0000000000 --- a/packages/app/src/hooks/use-clear-workspace-attention.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useCallback, useMemo } from "react"; -import { i18n } from "@/i18n/i18next"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; - -export interface ClearWorkspaceAttentionController { - hasClearableAttention: boolean; - clearAttention: () => Promise; -} - -export function useClearWorkspaceAttention({ - serverId, - workspaceId, -}: { - serverId: string; - workspaceId: string; -}): ClearWorkspaceAttentionController { - const hasClearableAttention = useSessionStore((state) => { - const workspace = state.sessions[serverId]?.workspaces.get(workspaceId); - return workspace?.status === "attention" || workspace?.status === "failed"; - }); - - const clearAttention = useCallback(async () => { - if (!hasClearableAttention) { - return; - } - const client = getHostRuntimeStore().getClient(serverId); - if (!client) { - throw new Error(i18n.t("workspace.terminal.hostDisconnected")); - } - await client.clearWorkspaceAttention(workspaceId); - }, [hasClearableAttention, serverId, workspaceId]); - - return useMemo( - () => ({ hasClearableAttention, clearAttention }), - [clearAttention, hasClearableAttention], - ); -} diff --git a/packages/app/src/hooks/use-workspace-read-state.ts b/packages/app/src/hooks/use-workspace-read-state.ts new file mode 100644 index 0000000000..2e7b092956 --- /dev/null +++ b/packages/app/src/hooks/use-workspace-read-state.ts @@ -0,0 +1,49 @@ +import { useCallback, useMemo } from "react"; +import { i18n } from "@/i18n/i18next"; +import { useHostFeature } from "@/runtime/host-features"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useWorkspaceFields } from "@/stores/session-store-hooks"; +import { markWorkspaceUnread } from "@/workspace/mark-unread"; + +export interface WorkspaceReadStateController { + hasClearableAttention: boolean; + canMarkUnread: boolean; + clearAttention: () => Promise; + markUnread: () => Promise; +} + +export function useWorkspaceReadState({ + serverId, + workspaceId, +}: { + serverId: string; + workspaceId: string; +}): WorkspaceReadStateController { + const status = useWorkspaceFields(serverId, workspaceId, (workspace) => workspace.status); + const supportsMarkUnread = useHostFeature(serverId, "workspaceMarkUnread"); + const hasClearableAttention = status === "attention" || status === "failed"; + const canMarkUnread = supportsMarkUnread && status === "done"; + + const clearAttention = useCallback(async () => { + if (!hasClearableAttention) { + return; + } + const client = getHostRuntimeStore().getClient(serverId); + if (!client) { + throw new Error(i18n.t("workspace.terminal.hostDisconnected")); + } + await client.clearWorkspaceAttention(workspaceId); + }, [hasClearableAttention, serverId, workspaceId]); + + const markUnread = useCallback(async () => { + if (!canMarkUnread) { + return; + } + await markWorkspaceUnread(serverId, workspaceId); + }, [canMarkUnread, serverId, workspaceId]); + + return useMemo( + () => ({ hasClearableAttention, canMarkUnread, clearAttention, markUnread }), + [canMarkUnread, clearAttention, hasClearableAttention, markUnread], + ); +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index a27a1ed418..4227e5c0cc 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -782,6 +782,7 @@ interface MobileMountedTabSlotProps { isWorkspaceFocused: boolean; isPaneFocused: boolean; paneId: string | null; + onFocusPane: (paneId: string) => void; buildPaneContentModel: (input: { paneId: string | null; tab: WorkspaceTabDescriptor; @@ -794,6 +795,7 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({ isWorkspaceFocused, isPaneFocused, paneId, + onFocusPane, buildPaneContentModel, }: MobileMountedTabSlotProps) { const content = useMemo( @@ -804,15 +806,21 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({ }), [buildPaneContentModel, paneId, tabDescriptor], ); + const handleTouch = useCallback(() => { + if (!isPaneFocused && paneId) onFocusPane(paneId); + return false; + }, [isPaneFocused, onFocusPane, paneId]); return ( - + + + ); @@ -1079,6 +1087,8 @@ interface RenderWorkspaceContentInput { focusedPaneTabDescriptorMap: Map; isRouteFocused: boolean; focusedPaneId: string | null; + paneFocusSuspended: boolean; + onFocusPane: (paneId: string) => void; buildMobilePaneContentModel: (input: { paneId: string | null; tab: WorkspaceTabDescriptor; @@ -1095,6 +1105,8 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React focusedPaneTabDescriptorMap, isRouteFocused, focusedPaneId, + paneFocusSuspended, + onFocusPane, buildMobilePaneContentModel, } = input; @@ -1134,8 +1146,9 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React tabDescriptor={tabDescriptor} isVisible={isRouteFocused && tabId === activeTabDescriptor.tabId} isWorkspaceFocused={isRouteFocused} - isPaneFocused={tabId === activeTabDescriptor.tabId} + isPaneFocused={!paneFocusSuspended && tabId === activeTabDescriptor.tabId} paneId={focusedPaneId} + onFocusPane={onFocusPane} buildPaneContentModel={buildMobilePaneContentModel} /> ); @@ -1799,6 +1812,9 @@ function WorkspaceScreenContent({ const workspaceLayout = useWorkspaceLayoutStore((state) => persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null, ); + const unfocusedPaneId = useWorkspaceLayoutStore((state) => + persistenceKey ? state.focusRestorationByWorkspace[persistenceKey]?.restorePaneId : undefined, + ); const explorerSidebarPaneId = useWorkspaceLayoutStore((state) => persistenceKey ? selectExplorerSidebarPaneId(state, persistenceKey) : null, ); @@ -1898,8 +1914,9 @@ function WorkspaceScreenContent({ deriveWorkspacePaneState({ layout: workspaceLayout, tabs: uiTabs, + paneId: workspaceLayout?.focusedPaneId ?? unfocusedPaneId, }), - [uiTabs, workspaceLayout], + [uiTabs, workspaceLayout, unfocusedPaneId], ); const viewedTimelineSync = useSessionStore( (state) => state.sessions[normalizedServerId]?.viewedTimelineSync ?? null, @@ -3621,6 +3638,12 @@ function WorkspaceScreenContent({ }, [buildPaneContentModel], ); + const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) { + if (!persistenceKey || paneFocusSuppressedRef.current) { + return; + } + focusWorkspacePane(persistenceKey, paneId); + }); const content = renderWorkspaceContent({ isMissingWorkspaceDirectory, activeTabDescriptor, @@ -3630,6 +3653,8 @@ function WorkspaceScreenContent({ focusedPaneTabDescriptorMap, isRouteFocused, focusedPaneId, + paneFocusSuspended: Boolean(unfocusedPaneId), + onFocusPane: handleFocusPane, buildMobilePaneContentModel, }); @@ -3655,13 +3680,6 @@ function WorkspaceScreenContent({ [activeTabDescriptor?.tabId, closingTabIds, hoveredCloseTabKey, tabs], ); - const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) { - if (!persistenceKey || paneFocusSuppressedRef.current) { - return; - } - focusWorkspacePane(persistenceKey, paneId); - }); - const handleSplitPane = useCallback( function handleSplitPane(input: { tabId: string; diff --git a/packages/app/src/workspace/mark-unread.ts b/packages/app/src/workspace/mark-unread.ts new file mode 100644 index 0000000000..0cb3310e19 --- /dev/null +++ b/packages/app/src/workspace/mark-unread.ts @@ -0,0 +1,26 @@ +import { i18n } from "@/i18n/i18next"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; + +export async function markWorkspaceUnread(serverId: string, workspaceId: string): Promise { + const client = getHostRuntimeStore().getClient(serverId); + if (!client) { + throw new Error(i18n.t("workspace.terminal.hostDisconnected")); + } + + const workspaceKey = `${serverId}:${workspaceId}`; + const layout = useWorkspaceLayoutStore.getState(); + // End viewing before restoring attention. The next pane focus reads it normally; + // leaving the workspace must not consume the mark the user just made. + const focusToken = layout.layoutByWorkspace[workspaceKey]?.focusedPaneId + ? layout.unfocusPane(workspaceKey) + : null; + try { + await client.markWorkspaceUnread(workspaceId); + } catch (error) { + if (focusToken) { + layout.restorePaneFocus(workspaceKey, focusToken); + } + throw error; + } +} diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index bfb582e5ab..d03fd7c754 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -3005,6 +3005,45 @@ test("sends project.add.request without creating a workspace", async () => { }); }); +test("marks a workspace unread through the dotted RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const markPromise = client.markWorkspaceUnread("workspace-1", "req-mark-unread"); + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "workspace.mark_unread.request", + workspaceId: "workspace-1", + requestId: "req-mark-unread", + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "workspace.mark_unread.response", + payload: { + requestId: "req-mark-unread", + workspaceId: "workspace-1", + markedAgentId: "agent-1", + success: true, + error: null, + }, + }), + ); + + await expect(markPromise).resolves.toBeUndefined(); +}); + test("searches GitHub repositories through the dotted RPC", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index aa2a410ab0..e22534da35 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1893,6 +1893,20 @@ export class DaemonClient { } } + async markWorkspaceUnread(workspaceId: string, requestId?: string): Promise { + const response = + await this.sendNamespacedCorrelatedSessionRequest<"workspace.mark_unread.response">({ + requestId, + message: { + type: "workspace.mark_unread.request", + workspaceId, + }, + }); + if (!response.success) { + throw new Error(response.error ?? "Failed to mark workspace unread"); + } + } + sendHeartbeat(params: { deviceType: "web" | "mobile"; focusedAgentId: string | null; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 6faceb4139..ed36eeb1ad 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2593,6 +2593,12 @@ export const WorkspaceClearAttentionRequestSchema = z.object({ requestId: z.string(), }); +export const WorkspaceMarkUnreadRequestSchema = z.object({ + type: z.literal("workspace.mark_unread.request"), + workspaceId: z.string(), + requestId: z.string(), +}); + // Highlighted diff token schema // Note: style can be a compound class name (e.g., "heading meta") from the syntax highlighter const HighlightTokenSchema = z.object({ @@ -3203,6 +3209,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ArchiveWorkspaceRequestSchema, WorkspaceCreateRequestSchema, WorkspaceClearAttentionRequestSchema, + WorkspaceMarkUnreadRequestSchema, FileExplorerRequestSchema, FileSubscribeRequestSchema, FileUnsubscribeRequestSchema, @@ -3529,6 +3536,8 @@ export const ServerInfoStatusPayloadSchema = z providerSubagentNesting: z.boolean().optional(), // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. workspacePinning: z.boolean().optional(), + // COMPAT(workspaceMarkUnread): added in v0.5.0, remove after 2027-08-20. + workspaceMarkUnread: z.boolean().optional(), // COMPAT(hubRelationship): added in v0.1.X, drop the gate when floor >= v0.1.X. hubRelationship: z.boolean().optional(), // COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15. @@ -4677,6 +4686,17 @@ export const WorkspaceClearAttentionResponseSchema = z.object({ }), }); +export const WorkspaceMarkUnreadResponseSchema = z.object({ + type: z.literal("workspace.mark_unread.response"), + payload: z.object({ + requestId: z.string(), + workspaceId: z.string(), + markedAgentId: z.string().nullable(), + success: z.boolean(), + error: z.string().nullable(), + }), +}); + export const SendAgentMessageResponseMessageSchema = z.object({ type: z.literal("send_agent_message_response"), payload: z.object({ @@ -6526,6 +6546,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ClearAgentAttentionResponseMessageSchema, WorkspaceCreateResponseSchema, WorkspaceClearAttentionResponseSchema, + WorkspaceMarkUnreadResponseSchema, SendAgentMessageResponseMessageSchema, SetVoiceModeResponseMessageSchema, DaemonGetStatusResponseSchema, @@ -7026,6 +7047,7 @@ export type ProjectGithubCloneRequest = z.infer; export type ArchiveWorkspaceRequest = z.infer; export type WorkspaceClearAttentionRequest = z.infer; +export type WorkspaceMarkUnreadRequest = z.infer; export type FileExplorerRequest = z.infer; export type FileExplorerResponse = z.infer; export type FileVersion = z.infer; diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index a9e8f87c9a..3cb9b0d5bd 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -10,6 +10,42 @@ import { } from "./messages.js"; describe("workspace message schemas", () => { + test("parses mark-unread request and response", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "workspace.mark_unread.request", + workspaceId: "workspace-1", + requestId: "req-mark-unread", + }), + ).toEqual({ + type: "workspace.mark_unread.request", + workspaceId: "workspace-1", + requestId: "req-mark-unread", + }); + + expect( + SessionOutboundMessageSchema.parse({ + type: "workspace.mark_unread.response", + payload: { + requestId: "req-mark-unread", + workspaceId: "workspace-1", + markedAgentId: "agent-1", + success: true, + error: null, + }, + }), + ).toEqual({ + type: "workspace.mark_unread.response", + payload: { + requestId: "req-mark-unread", + workspaceId: "workspace-1", + markedAgentId: "agent-1", + success: true, + error: null, + }, + }); + }); + test("parses blocked setup status and the explicit setup run RPC", () => { expect( SessionInboundMessageSchema.parse({ diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index fdd3b5f763..1e18ba9de4 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -4919,6 +4919,56 @@ test("archiveSnapshot dispatches archived state for stored-only agents", async ( expect(last.lifecycle).toBe("closed"); }); +test("markAgentUnread dispatches stored attention to every subscriber", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-mark-unread-dispatch-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + }); + const created = await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Stored unread dispatch" }, + undefined, + { workspaceId: "workspace-1" }, + ); + await manager.closeAgent(created.id); + + const firstClientEvents: ManagedAgent[] = []; + const secondClientEvents: ManagedAgent[] = []; + for (const events of [firstClientEvents, secondClientEvents]) { + manager.subscribe( + (event) => { + if (event.type === "agent_state" && event.agent.id === created.id) { + events.push(event.agent); + } + }, + { replayState: false }, + ); + } + + await manager.markAgentUnread(created.id); + + for (const events of [firstClientEvents, secondClientEvents]) { + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + id: created.id, + lifecycle: "closed", + workspaceId: "workspace-1", + attention: { + requiresAttention: true, + attentionReason: "finished", + attentionTimestamp: expect.any(Date), + }, + }); + } + expect(await storage.get(created.id)).toMatchObject({ + requiresAttention: true, + attentionReason: "finished", + attentionTimestamp: expect.any(String), + }); +}); + test("reloadAgentSession cancels active run and resumes existing session once thread_started is observed", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-active-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index c1f7a781d4..d5dc1ab52b 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1766,7 +1766,7 @@ export class AgentManager { if (this.agents.has(record.id)) { this.notifyAgentState(record.id); } else if (!archivedRecord.internal) { - this.dispatchArchivedStoredAgent(archivedRecord); + this.dispatchStoredAgentState(archivedRecord); } await this.fireAgentArchived(record.id); @@ -1801,8 +1801,16 @@ export class AgentManager { } } - private dispatchArchivedStoredAgent(record: StoredAgentRecord): void { + private dispatchStoredAgentState(record: StoredAgentRecord): void { const updatedAt = new Date(record.updatedAt); + const attention: AttentionState = + record.requiresAttention && record.attentionReason && record.attentionTimestamp + ? { + requiresAttention: true, + attentionReason: record.attentionReason, + attentionTimestamp: new Date(record.attentionTimestamp), + } + : { requiresAttention: false }; this.dispatch({ type: "agent_state", agent: { @@ -1836,7 +1844,7 @@ export class AgentManager { lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null, lastUsage: undefined, lastError: record.lastError ?? undefined, - attention: { requiresAttention: false }, + attention, internal: record.internal, labels: record.labels, }, @@ -2046,6 +2054,46 @@ export class AgentManager { } } + async markAgentUnread(agentId: string): Promise { + const liveAgent = this.agents.get(agentId); + if (liveAgent) { + const isFinished = liveAgent.lifecycle === "idle"; + const hasPendingPermissions = liveAgent.pendingPermissions.size > 0; + const canMarkUnread = + isFinished && !liveAgent.attention.requiresAttention && !hasPendingPermissions; + if (!canMarkUnread) { + throw new Error(`Agent is no longer finished and read: ${agentId}`); + } + liveAgent.attention = { + requiresAttention: true, + attentionReason: "finished", + attentionTimestamp: new Date(), + }; + await this.persistSnapshot(liveAgent); + this.emitState(liveAgent, { persist: false }); + return; + } + + const registry = this.requireRegistry(); + const record = await registry.get(agentId); + const hasFinishedStatus = record?.lastStatus === "idle" || record?.lastStatus === "closed"; + const canMarkUnread = + record && !record.internal && !record.archivedAt && !record.requiresAttention; + if (!canMarkUnread || !hasFinishedStatus) { + throw new Error(`Agent is no longer finished and read: ${agentId}`); + } + const updatedAt = this.nextStoredUpdatedAt(record); + const nextRecord: StoredAgentRecord = { + ...record, + updatedAt, + requiresAttention: true, + attentionReason: "finished", + attentionTimestamp: updatedAt, + }; + await registry.upsert(nextRecord); + this.dispatchStoredAgentState(nextRecord); + } + async archiveSnapshot(agentId: string, archivedAt: string): Promise { const registry = this.requireRegistry(); const liveAgent = this.getAgent(agentId); @@ -2069,7 +2117,7 @@ export class AgentManager { } else { this.discardRetainedAgentState(agentId); if (!nextRecord.internal) { - this.dispatchArchivedStoredAgent(nextRecord); + this.dispatchStoredAgentState(nextRecord); } } diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 1f8b9e1d1f..ec6c4db2dd 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -185,6 +185,7 @@ const INBOUND_PERMISSION = { voice_audio_chunk: "workspace.write", wait_for_finish_request: "workspace.read", "workspace.clear_attention.request": "workspace.write", + "workspace.mark_unread.request": "workspace.write", "workspace.create.request": "workspace.manage", "workspace.github.search_repositories.request": "workspace.read", "workspace.label.assignment.set.request": "workspace.manage", @@ -396,6 +397,7 @@ const OUTBOUND_PERMISSION = { voice_input_state: "workspace.write", wait_for_finish_response: "workspace.read", "workspace.clear_attention.response": "workspace.write", + "workspace.mark_unread.response": "workspace.write", "workspace.create.response": "workspace.manage", "workspace.github.search_repositories.response": "workspace.read", "workspace.label.assignment.set.response": "workspace.manage", diff --git a/packages/server/src/server/file-observer/internal/native-recursive.test.ts b/packages/server/src/server/file-observer/internal/native-recursive.test.ts index c97048ab47..b37e12657d 100644 --- a/packages/server/src/server/file-observer/internal/native-recursive.test.ts +++ b/packages/server/src/server/file-observer/internal/native-recursive.test.ts @@ -69,3 +69,54 @@ test("shallow parent scans retain nested change scopes", async () => { await rm(root, { recursive: true, force: true }); } }); + +test("a file announced only as changed remains visible to coalesced deletion scans", async () => { + const root = await mkdtemp(join(tmpdir(), "native-change-only-")); + const directory = join(root, "nested"); + await mkdir(directory); + const paths = createObserverPaths(process.platform); + const events: FileChange[] = []; + const notifications = new EventEmitter(); + let active = true; + const observer = createFileObserver(); + const backend = createNativeRecursiveBackend( + { + root, + metrics: observer.getDiagnostics(), + isActive: () => active, + isIgnored: () => false, + isPathInside: paths.isInside, + queueEvent: (type, path) => events.push({ type, path }), + fail: (error) => { + throw error; + }, + }, + paths, + (_root, listener) => { + notifications.on("change", listener); + return { + close: () => notifications.removeAllListeners(), + on: (event, onError) => notifications.on(event, onError), + }; + }, + ); + try { + await backend.start(); + const path = join(directory, "changed.txt"); + await writeFile(path, "created"); + notifications.emit("change", "change", path); + await expect.poll(() => backend.getDiagnostics().nativeTrackedFileCount).toBe(1); + + await rm(path); + notifications.emit("change", "change", directory); + await expect + .poll(() => events.filter((event) => event.type === "delete"), { timeout: 10_000 }) + .toEqual([{ path, type: "delete" }]); + } finally { + vi.useRealTimers(); + active = false; + await backend.close(); + await observer.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/file-observer/internal/native-recursive.ts b/packages/server/src/server/file-observer/internal/native-recursive.ts index 02450503d3..10dece67cc 100644 --- a/packages/server/src/server/file-observer/internal/native-recursive.ts +++ b/packages/server/src/server/file-observer/internal/native-recursive.ts @@ -139,15 +139,19 @@ class NativeRecursiveBackend implements ObservationBackend { const path = resolve(this.host.root, filename.toString()); if (this.host.isIgnored(path)) return; const scope = path === this.host.root ? this.host.root : dirname(path); + const knownDirectory = this.directories.has(path); + this.requestChangeAudit(scope); if (eventType === "change") { this.host.metrics.nativeChangeEventCount += 1; this.host.queueEvent("update", path); - this.requestChangeAudit(scope); - if (this.directories.has(path)) this.requestAudit(path, true); - return; + if (knownDirectory) this.requestAudit(path, true); + if (knownDirectory || this.files.has(path)) return; + // A coalesced creation can arrive only as a change. Classify unknown + // paths so later deletion scans can find them in the inventory. + } else { + this.host.metrics.nativeRenameEventCount += 1; + if (knownDirectory) this.requestAudit(scope); } - this.host.metrics.nativeRenameEventCount += 1; - const knownDirectory = this.directories.has(path); this.classify(path, (isDirectory) => { if (!isDirectory) { // Remember files as soon as we announce them. A coalesced delete must @@ -159,8 +163,6 @@ class NativeRecursiveBackend implements ObservationBackend { if (knownDirectory) this.requestAudit(path, true); else this.requestAudit(scope); }); - this.requestChangeAudit(scope); - if (knownDirectory) this.requestAudit(scope); }); watcher.on("error", (error) => { if (this.host.isActive()) this.host.fail(toError(error)); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index ae7a105aed..c1e149ee5e 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -230,6 +230,7 @@ import { import type { ForgeService } from "../services/forge-service.js"; import type { ProviderUsageService } from "../services/quota-fetcher/service.js"; import { + resolveWorkspaceRootAgent, summarizeFetchWorkspacesEntries, workspaceIdsOnCheckout, WorkspaceDirectory, @@ -2018,7 +2019,7 @@ export class Session { private dispatchWorkspaceLifecycleMessage(msg: SessionInboundMessage): Promise | undefined { return ( - this.dispatchWorkspaceRecoveryMessage(msg) ?? + this.dispatchWorkspaceStateMessage(msg) ?? this.dispatchWorkspaceLabelMessage(msg) ?? this.dispatchWorkspaceSetupMessage(msg) ?? this.dispatchWorkspaceAndProjectMessage(msg) @@ -2582,8 +2583,6 @@ export class Session { return this.handleProjectRemoveRequest(msg); case "workspace.create.request": return this.handleWorkspaceCreateRequest(msg); - case "workspace.clear_attention.request": - return this.handleWorkspaceClearAttentionRequest(msg); case "workspace.title.set.request": return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId); case "workspace.pin.set.request": @@ -2656,12 +2655,16 @@ export class Session { } } - private dispatchWorkspaceRecoveryMessage(msg: SessionInboundMessage): Promise | undefined { + private dispatchWorkspaceStateMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "workspace.recovery.inspect.request": return this.handleWorkspaceRecoveryInspectRequest(msg); case "workspace.recovery.restore.request": return this.handleWorkspaceRecoveryRestoreRequest(msg); + case "workspace.clear_attention.request": + return this.handleWorkspaceClearAttentionRequest(msg); + case "workspace.mark_unread.request": + return this.handleWorkspaceMarkUnreadRequest(msg); default: return undefined; } @@ -7026,6 +7029,64 @@ export class Session { }); } + private async handleWorkspaceMarkUnreadRequest( + request: Extract, + ): Promise { + const { requestId, workspaceId } = request; + let markedAgentId: string | null = null; + try { + const workspace = await this.workspaceRegistry.get(workspaceId); + if (!workspace || workspace.archivedAt) { + throw new Error(`Workspace not found: ${workspaceId}`); + } + + const agents = (await this.listAgentPayloads()).filter((agent) => + this.isProviderVisibleToClient(agent.provider), + ); + const agentsById = new Map(agents.map((agent) => [agent.id, agent] as const)); + const candidates = agents + .filter((agent) => !agent.archivedAt && agent.workspaceId === workspace.workspaceId) + .filter((agent) => resolveWorkspaceRootAgent(agent, agentsById)?.id === agent.id) + .filter((agent) => agent.status === "idle" || agent.status === "closed") + .filter((agent) => agent.requiresAttention !== true) + .filter((agent) => (agent.pendingPermissions?.length ?? 0) === 0) + .sort( + (left, right) => + right.updatedAt.localeCompare(left.updatedAt) || left.id.localeCompare(right.id), + ); + const candidate = candidates[0]; + if (!candidate) { + throw new Error(`Workspace has no finished agent to mark unread: ${workspaceId}`); + } + + await this.agentManager.markAgentUnread(candidate.id); + markedAgentId = candidate.id; + this.emit({ + type: "workspace.mark_unread.response", + payload: { + requestId, + workspaceId, + markedAgentId, + success: true, + error: null, + }, + }); + } catch (error) { + const message = getErrorMessage(error); + this.sessionLogger.error({ err: error, workspaceId }, "Failed to mark workspace unread"); + this.emit({ + type: "workspace.mark_unread.response", + payload: { + requestId, + workspaceId, + markedAgentId, + success: false, + error: message, + }, + }); + } + } + private async handleFetchAgent(agentIdOrIdentifier: string, requestId: string): Promise { const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier); if (!resolved.ok) { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index ac3a928ac1..2914e58215 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -233,6 +233,7 @@ function makeAgent(input: { requiresAttention?: boolean; attentionReason?: AgentSnapshotPayload["attentionReason"]; attentionTimestamp?: string | null; + labels?: Record; }): AgentSnapshotPayload { const pendingPermissionCount = input.pendingPermissions ?? 0; return { @@ -269,7 +270,7 @@ function makeAgent(input: { sessionId: null, }, title: null, - labels: {}, + labels: input.labels ?? {}, requiresAttention: input.requiresAttention ?? false, attentionReason: input.attentionReason ?? null, attentionTimestamp: input.attentionTimestamp ?? null, @@ -283,17 +284,20 @@ function makeStoredAgent(input: { updatedAt: string; requiresAttention?: boolean; attentionReason?: StoredAgentRecord["attentionReason"]; + workspaceId?: string; + labels?: Record; }): StoredAgentRecord { return { id: input.id, provider: "codex", cwd: input.cwd, + ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), createdAt: input.updatedAt, updatedAt: input.updatedAt, lastActivityAt: input.updatedAt, lastUserMessageAt: null, title: null, - labels: {}, + labels: input.labels ?? {}, lastStatus: "closed", lastModeId: null, config: { provider: "codex", cwd: input.cwd }, @@ -583,6 +587,7 @@ function createSessionForWorkspaceTests( archiveSnapshot: async () => ({}), unarchiveSnapshot: async () => true, clearAgentAttention: async () => {}, + markAgentUnread: async () => {}, notifyAgentState: () => {}, ...options.agentManager, }); @@ -1771,6 +1776,123 @@ test("workspace clear attention responds with an error instead of timing out", a }); }); +test("workspace mark unread selects the newest finished workspace root", async () => { + const emitted: SessionOutboundMessage[] = []; + const workspace = createPersistedWorkspaceRecord({ + workspaceId: REPO_CWD, + projectId: REPO_CWD, + cwd: REPO_CWD, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-30T15:00:00.000Z", + updatedAt: "2026-03-30T15:00:00.000Z", + }); + const project = createPersistedProjectRecord({ + projectId: REPO_CWD, + rootPath: REPO_CWD, + kind: "non_git", + displayName: "repo", + createdAt: "2026-03-30T15:00:00.000Z", + updatedAt: "2026-03-30T15:00:00.000Z", + }); + const storedRecords = new Map([ + [ + "root-agent", + makeStoredAgent({ + id: "root-agent", + cwd: REPO_CWD, + workspaceId: workspace.workspaceId, + updatedAt: "2026-03-30T16:00:00.000Z", + }), + ], + [ + "newer-child", + makeStoredAgent({ + id: "newer-child", + cwd: REPO_CWD, + workspaceId: workspace.workspaceId, + updatedAt: "2026-03-30T17:00:00.000Z", + labels: { "paseo.parent-agent-id": "root-agent" }, + }), + ], + ]); + const markedAgentIds: string[] = []; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + agentManager: { + markAgentUnread: async (agentId: string) => { + markedAgentIds.push(agentId); + }, + }, + }); + + session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.get = async (id: string) => + id === workspace.workspaceId ? workspace : null; + session.projectRegistry.list = async () => [project]; + session.projectRegistry.get = async (id: string) => (id === project.projectId ? project : null); + session.listAgentPayloads = async () => + Array.from(storedRecords.values()).map((record) => + makeAgent({ + id: record.id, + cwd: record.cwd, + workspaceId: record.workspaceId, + status: record.lastStatus, + updatedAt: record.updatedAt, + requiresAttention: record.requiresAttention, + attentionReason: record.attentionReason, + attentionTimestamp: record.attentionTimestamp, + labels: record.labels, + }), + ); + + await session.handleMessage({ + type: "workspace.mark_unread.request", + workspaceId: workspace.workspaceId, + requestId: "req-mark-unread", + }); + + expect(markedAgentIds).toEqual(["root-agent"]); + expect(findByType(emitted, "workspace.mark_unread.response").payload).toEqual({ + requestId: "req-mark-unread", + workspaceId: workspace.workspaceId, + markedAgentId: "root-agent", + success: true, + error: null, + }); +}); + +test("workspace mark unread rejects workspaces without a finished root agent", async () => { + const emitted: SessionOutboundMessage[] = []; + const workspace = createPersistedWorkspaceRecord({ + workspaceId: REPO_CWD, + projectId: REPO_CWD, + cwd: REPO_CWD, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-30T15:00:00.000Z", + updatedAt: "2026-03-30T15:00:00.000Z", + }); + const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); + session.workspaceRegistry.get = async (id: string) => + id === workspace.workspaceId ? workspace : null; + session.listAgentPayloads = async () => []; + + await session.handleMessage({ + type: "workspace.mark_unread.request", + workspaceId: workspace.workspaceId, + requestId: "req-mark-unread", + }); + + expect(findByType(emitted, "workspace.mark_unread.response").payload).toEqual({ + requestId: "req-mark-unread", + workspaceId: workspace.workspaceId, + markedAgentId: null, + success: false, + error: `Workspace has no finished agent to mark unread: ${workspace.workspaceId}`, + }); +}); + test("workspace clear attention can clear multiple workspaces in one request", async () => { const emitted: SessionOutboundMessage[] = []; const workspaces = [ diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 3839263f52..c470476913 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -1008,6 +1008,7 @@ describe("relay external socket reconnect behavior", () => { expect(serverInfo.features?.canonicalSubmittedPrompts).toBe(true); expect(serverInfo.features?.providersSnapshotCwd).toBe(true); expect(serverInfo.features?.pluginLogs).toBe(true); + expect(serverInfo.features?.workspaceMarkUnread).toBe(true); expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true); expect(serverInfo.features?.["terminal-size-ownership"]).toBe(true); expect(serverInfo.features?.agentTurnIdentity).toBeUndefined(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 4d56418152..30fce35b13 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1732,6 +1732,8 @@ export class VoiceAssistantWebSocketServer { providerSubagentNesting: true, // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. workspacePinning: true, + // COMPAT(workspaceMarkUnread): added in v0.5.0, remove after 2027-08-20. + workspaceMarkUnread: true, // COMPAT(hubRelationship): added in v0.1.X, drop the gate when floor >= v0.1.X. hubRelationship: true, // COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15. diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index a50713e16f..8817576885 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -693,7 +693,7 @@ function groupAgentsByWorkspaceId( return byWorkspaceId; } -function resolveWorkspaceRootAgent( +export function resolveWorkspaceRootAgent( agent: AgentSnapshotPayload, activeAgentsById: ReadonlyMap, ): AgentSnapshotPayload | null { From 96ea6698d722eec23167c175a2a6c2b440035bbb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 9 Sep 2026 11:06:29 +0200 Subject: [PATCH 048/566] fix(app): preserve plugin provider icons in model buttons (#4549) * fix(app): preserve plugin provider icons in model buttons Pass the selected host to the composer glyph so it resolves the same plugin SVG as the model picker. Require the nullable glyph host prop to catch omitted identity at compile time. * test(app): isolate plugin icon regression setup --- .../e2e/browser/plugin-provider-icons.spec.ts | 16 ++ .../support/helpers/plugin-provider-icons.ts | 175 ++++++++++++++++++ .../components/combined-model-selector.tsx | 1 + packages/app/src/components/model-browser.tsx | 2 +- 4 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 packages/app/e2e/browser/plugin-provider-icons.spec.ts create mode 100644 packages/app/e2e/support/helpers/plugin-provider-icons.ts diff --git a/packages/app/e2e/browser/plugin-provider-icons.spec.ts b/packages/app/e2e/browser/plugin-provider-icons.spec.ts new file mode 100644 index 0000000000..32093cb275 --- /dev/null +++ b/packages/app/e2e/browser/plugin-provider-icons.spec.ts @@ -0,0 +1,16 @@ +import { + test, + verifyProviderSettings, + verifyNewWorkspaceModelIcon, + verifyCompactModelIcon, + verifyExistingAgentModelIcon, +} from "../support/helpers/plugin-provider-icons"; + +test("plugin provider icons follow the model through settings and wide and compact composers", async ({ + providerIcons, +}) => { + await verifyProviderSettings(providerIcons); + await verifyNewWorkspaceModelIcon(providerIcons); + await verifyCompactModelIcon(providerIcons); + await verifyExistingAgentModelIcon(providerIcons); +}); diff --git a/packages/app/e2e/support/helpers/plugin-provider-icons.ts b/packages/app/e2e/support/helpers/plugin-provider-icons.ts new file mode 100644 index 0000000000..22a8e46ecc --- /dev/null +++ b/packages/app/e2e/support/helpers/plugin-provider-icons.ts @@ -0,0 +1,175 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import type { Locator, TestInfo } from "@playwright/test"; +import { expect, test as base, type Page } from "../fixtures"; +import { gotoAppShell, openSettings } from "./app"; +import { openModelPicker } from "./agent-profiles"; +import { openAgentRoute } from "./mock-agent"; +import { connectNewWorkspaceDaemonClient, openGlobalNewWorkspaceComposer } from "./new-workspace"; +import { copyPluginExample } from "./plugin-fixture"; +import { seedWorkspace, type SeededWorkspace } from "./seed-client"; +import { getServerId } from "./server-id"; +import { openSettingsHostSection } from "./settings"; + +const MODEL_LABEL = "Select model (Example 1)"; +const WIDE = { width: 1400, height: 950 }; +const COMPACT = { width: 390, height: 844 }; + +async function readIconPaths(page: Page, pluginDirectory: string): Promise { + const svg = await readFile(path.join(pluginDirectory, "icon.svg"), "utf8"); + return page.evaluate( + (source) => + Array.from( + new DOMParser().parseFromString(source, "image/svg+xml").querySelectorAll("path"), + (element) => element.getAttribute("d") ?? "", + ), + svg, + ); +} + +function readIconDrawings(icons: SVGElement[]): string[][] { + return icons.map((icon) => + Array.from(icon.querySelectorAll("path"), (element) => element.getAttribute("d") ?? ""), + ); +} + +async function expectProviderIcon(surface: Locator, paths: string[]): Promise { + await expect(surface).toBeVisible(); + // Provider icons are decorative SVGs without accessible names. Compare the + // plugin asset's drawing, allowing surface-specific size and theme colours. + await expect + .poll(() => surface.locator("svg").evaluateAll(readIconDrawings)) + .toContainEqual(paths); +} + +async function selectPluginModel(page: Page): Promise { + await openModelPicker(page); + await page.getByRole("button", { name: "Back", exact: true }).click(); + await page.getByText("Direct provider example", { exact: true }).click(); + await page.getByText("Example 1", { exact: true }).click(); + await expect(page.getByRole("button", { name: MODEL_LABEL, exact: true })).toBeVisible(); +} + +async function capture(page: Page, testInfo: TestInfo, name: string): Promise { + const screenshot = testInfo.outputPath(`${name}.png`); + await page.screenshot({ path: screenshot }); + await testInfo.attach(name, { path: screenshot, contentType: "image/png" }); +} + +interface ProviderIconJourney { + page: Page; + testInfo: TestInfo; + iconPaths: string[]; + workspace: SeededWorkspace; +} + +export const test = base.extend<{ providerIcons: ProviderIconJourney }>({ + providerIcons: async ({ page }, provide, testInfo) => { + const client = await connectNewWorkspaceDaemonClient(); + try { + const previous = await client.getDaemonConfig(); + const plugin = await copyPluginExample("provider-direct"); + try { + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(plugin.directory); + const workspace = await seedWorkspace({ repoPrefix: "plugin-provider-icons-" }); + try { + await page.setViewportSize(WIDE); + await gotoAppShell(page); + const iconPaths = await readIconPaths(page, plugin.directory); + await provide({ page, testInfo, iconPaths, workspace }); + } finally { + await workspace.cleanup(); + } + } finally { + try { + await client.removePlugin("provider-direct-example"); + } finally { + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }); + } + } + } finally { + await plugin.cleanup(); + } + } finally { + await client.close(); + } + }, +}); + +export async function verifyProviderSettings({ + page, + iconPaths, + testInfo, +}: ProviderIconJourney): Promise { + await test.step("provider settings render the registered icon", async () => { + await openSettings(page); + await openSettingsHostSection(page, getServerId(), "providers"); + await expectProviderIcon( + page.getByRole("button", { name: "Direct provider example provider details", exact: true }), + iconPaths, + ); + await capture(page, testInfo, "provider-settings"); + await page.getByTestId("settings-back-to-workspace").click(); + }); +} + +export async function verifyNewWorkspaceModelIcon({ + page, + iconPaths, + testInfo, +}: ProviderIconJourney): Promise { + await test.step("new workspace keeps the picker icon on its selected model button", async () => { + await openGlobalNewWorkspaceComposer(page); + await selectPluginModel(page); + await openModelPicker(page); + await expectProviderIcon(page.getByTestId("model-row-direct-example-example-1"), iconPaths); + await capture(page, testInfo, "new-workspace-picker"); + await page.keyboard.press("Escape"); + await expectProviderIcon( + page.getByRole("button", { name: MODEL_LABEL, exact: true }), + iconPaths, + ); + await capture(page, testInfo, "new-workspace-selected-model"); + }); +} + +export async function verifyCompactModelIcon({ + page, + iconPaths, + testInfo, +}: ProviderIconJourney): Promise { + await test.step("compact composer preserves the same provider icon", async () => { + await page.setViewportSize(COMPACT); + await expectProviderIcon( + page.getByRole("button", { name: MODEL_LABEL, exact: true }), + iconPaths, + ); + await capture(page, testInfo, "compact-selected-model"); + }); +} + +export async function verifyExistingAgentModelIcon({ + page, + iconPaths, + testInfo, + workspace, +}: ProviderIconJourney): Promise { + await test.step("existing agent composer uses the same icon", async () => { + const agent = await workspace.client.createAgent({ + provider: "direct-example", + model: "example-1", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Plugin icon agent", + }); + await page.setViewportSize(WIDE); + await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id }); + await expectProviderIcon( + page.getByRole("button", { name: MODEL_LABEL, exact: true }), + iconPaths, + ); + await capture(page, testInfo, "agent-selected-model"); + }); +} diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index dda77246c0..8473760c92 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -255,6 +255,7 @@ export function CombinedModelSelector({ diff --git a/packages/app/src/components/model-browser.tsx b/packages/app/src/components/model-browser.tsx index f724619c1f..67cb8d23ce 100644 --- a/packages/app/src/components/model-browser.tsx +++ b/packages/app/src/components/model-browser.tsx @@ -219,7 +219,7 @@ export function ModelProviderGlyph({ tone = "muted", }: { provider: string; - serverId?: string | null; + serverId: string | null; size: number; tone?: ProviderGlyphTone; }) { From fdf3b4b47f1aae0f4f44e8c97210f8b907159edb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 9 Sep 2026 11:30:28 +0200 Subject: [PATCH 049/566] fix(app): show loading state for restored terminal titles (#4553) Restored tabs render before the terminal list arrives. Mark their titles as loading until metadata is available so they do not briefly display the generic terminal name. --- .../terminal-tab-title-loading.spec.ts | 86 +++++++++++++++++++ packages/app/src/panels/terminal-panel.tsx | 2 +- .../workspace/workspace-desktop-tabs-row.tsx | 4 +- 3 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 packages/app/e2e/browser/terminal-tab-title-loading.spec.ts diff --git a/packages/app/e2e/browser/terminal-tab-title-loading.spec.ts b/packages/app/e2e/browser/terminal-tab-title-loading.spec.ts new file mode 100644 index 0000000000..93e9e72aed --- /dev/null +++ b/packages/app/e2e/browser/terminal-tab-title-loading.spec.ts @@ -0,0 +1,86 @@ +import { test, expect, type Page } from "../support/fixtures"; +import { clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher"; +import { renameModalInput, renameModalSubmit } from "../support/helpers/rename"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { selectWorkspaceInSidebar } from "../support/helpers/sidebar"; + +function terminalTab(page: Page, terminalId: string) { + // Terminal tab chips have no tab role; their persistent identity survives title loading. + return page.getByTestId(`workspace-tab-terminal_${terminalId}`).filter({ visible: true }).first(); +} + +async function createNamedTerminal(page: Page, workspace: SeededWorkspace, title: string) { + await gotoWorkspace(page, workspace.workspaceId); + await clickNewTerminal(page); + const list = () => + workspace.client.listTerminals(workspace.repoPath, undefined, { + workspaceId: workspace.workspaceId, + }); + await expect.poll(async () => (await list()).terminals.length).toBe(1); + const terminalId = (await list()).terminals[0]!.id; + await terminalTab(page, terminalId).click({ button: "right" }); + await page.getByRole("menuitem", { name: "Rename", exact: true }).click(); + const modal = `workspace-tab-rename-modal-terminal-${terminalId}`; + await renameModalInput(page, modal).fill(title); + await renameModalSubmit(page, modal).click(); + await expect(renameModalInput(page, modal)).toHaveCount(0); + await expect(terminalTab(page, terminalId)).toHaveText(title); + return terminalId; +} + +async function recordRestoredTabText(page: Page, terminalId: string) { + // Install before the next document loads: a final-state assertion would miss the flash. + await page.addInitScript((id) => { + const samples: string[] = []; + Object.assign(window, { terminalTitleSamples: samples }); + new MutationObserver(() => { + const tab = document.querySelector(`[data-testid="workspace-tab-terminal_${id}"]`); + if (!tab) return; + const text = (tab.textContent ?? "").trim(); + if (samples.at(-1) !== text) samples.push(text); + }).observe(document, { subtree: true, childList: true, characterData: true }); + }, terminalId); +} + +async function expectRestoredTitle(page: Page, terminalId: string, title: string, stage: string) { + await expect(terminalTab(page, terminalId)).toHaveText(title, { timeout: 30_000 }); + const samples = await page.evaluate( + () => (window as unknown as { terminalTitleSamples: string[] }).terminalTitleSamples, + ); + await test.info().attach(`${stage}-tab-text`, { + body: JSON.stringify(samples), + contentType: "application/json", + }); + expect(samples).toContain(title); + expect(samples).not.toContain("Terminal"); +} + +test("restored terminal titles do not flash a default name while loading", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "terminal-title-loading-", git: false }); + const other = await seedWorkspace({ repoPrefix: "terminal-title-other-", git: false }); + try { + const title = "My named terminal"; + const terminalId = await createNamedTerminal(page, workspace, title); + await recordRestoredTabText(page, terminalId); + + await test.step("reload with a terminal tab restored", async () => { + await page.reload(); + await expectRestoredTitle(page, terminalId, title, "reload"); + }); + + await test.step("first workspace visit in a fresh page session", async () => { + await gotoWorkspace(page, other.workspaceId); + await selectWorkspaceInSidebar(page, workspace.workspaceId); + await expectRestoredTitle(page, terminalId, title, "first-visit"); + }); + + await test.step("return to the workspace with its titles already loaded", async () => { + await selectWorkspaceInSidebar(page, other.workspaceId); + await selectWorkspaceInSidebar(page, workspace.workspaceId); + await expectRestoredTitle(page, terminalId, title, "return-visit"); + }); + } finally { + await workspace.cleanup(); + await other.cleanup(); + } +}); diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index 32dd1f188d..8082bd36e5 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -69,7 +69,7 @@ function useTerminalPanelDescriptor( label, subtitle: t("workspace.tabs.fallback.terminal"), tooltip: label, - titleState: "ready", + titleState: terminalsQuery.isPending ? "loading" : "ready", icon: Terminal, statusBucket: deriveTerminalActivityStatusBucket(terminal?.activity), }; diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index b462cf0cd5..5c183dc94c 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -1487,9 +1487,7 @@ function ResolvedDesktopTabChip({ ); const rawTooltipLabel = - presentation.titleState === "loading" - ? t("workspace.tabs.loadingAgentTitle") - : presentation.tooltip; + presentation.titleState === "loading" ? t("common.states.loading") : presentation.tooltip; const accessibilityLabel = item.tab.target.kind === "agent" ? normalizeAgentTooltipTitle(rawTooltipLabel) From 16bfff8e0fe95300851af9039692dcae9e52646d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 9 Sep 2026 15:04:06 +0200 Subject: [PATCH 050/566] docs(product): clarify core philosophy and contribution expectations --- CONTRIBUTING.md | 160 ++++++++++++++++++------------------------------ docs/product.md | 127 ++++++++++++++++++++++---------------- 2 files changed, 135 insertions(+), 152 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 476a317db4..7b846fa92e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,142 +1,104 @@ # Contributing to Paseo -Thank you for taking the time to contribute to Paseo. +This guide is here to save us both time and help you find a useful way to contribute. + +Paseo grows through bug reports, testing, workflow discussions, plugins, documentation, and people helping each other. The sections below explain where each contribution belongs and what to expect. ## Philosophy -Paseo is an opinionated product, built on freedom and flexibility: any agent provider, any device, running on your own machine. It is meant to be composable, so you can build the workflow you want. Read more about the product vision [here](docs/product.md). +**Paseo has a lean, opinionated core built to be extended.** -Given Paseo's scope, contributing to it takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer. +The goal is a low floor and a high ceiling: a polished default experience that is easy to understand, with the flexibility to choose your providers, run on your own infrastructure, and build your own workflows. Paseo should remain self-hosted and respectful of your privacy and control. -I pick what to build based on whether it fits the product, how many workflows it improves, whether it keeps things composable, whether we can hold the quality bar, and whether I want to build it. +The core continues to evolve through improvements to the shared experience. Specialized workflows and integrations belong in the extension ecosystem. Product, design, architecture, and workflow decisions remain with the maintainer. -## Report bugs in GitHub issues or Discord +Read the [product philosophy](docs/product.md) for the reasoning behind these choices and how Paseo develops. -Open an [issue](https://github.com/getpaseo/paseo/issues) or post in [Discord](https://discord.gg/jz8T2uahpH). +## Build a plugin -> [!IMPORTANT] -> Feature requests opened as issues will get closed automatically +Most specialized workflows and integrations are better served by plugins. -If you used an agent to investigate, paste the raw evidence and repro steps it collected, not its diagnosis. +Plugins let you build what you need, share it, and maintain it independently. Start with the [plugin documentation](https://paseo.sh/docs/plugins). For applications and integrations built around Paseo, see the [SDK documentation](https://paseo.sh/docs/sdk). -You may submit a PR to fix a bug, please read the PR guidelines below. +If an extension point is missing, describe the workflow in [Discussions](https://github.com/getpaseo/paseo/discussions). A reusable capability that enables several plugins may be a better addition than implementing one particular workflow in core. -## Product discussions go in GitHub Discussions or Discord +## Report bugs -There's no feature request backlog. Open a product discussion in [Discussions](https://github.com/getpaseo/paseo/discussions) or `#product` in [Discord](https://discord.gg/jz8T2uahpH), and frame it as a workflow: +Open a [GitHub issue](https://github.com/getpaseo/paseo/issues) or report it in [Discord](https://discord.gg/jz8T2uahpH). GitHub is preferred because the issue template helps collect the necessary information. + +Include: + +- What you did, what you expected, and what happened. +- Reproduction steps, Paseo version, and platform. +- Relevant logs, screenshots, or a recording. + +If an agent investigated, include the evidence and reproduction steps it collected. Its explanation of the cause still needs verification. + +Focused fixes for reproducible bugs are welcome. If you submit one, follow the pull request and QA guidance below. + +## Discuss workflows before proposing features + +Feature requests belong in [GitHub Discussions](https://github.com/getpaseo/paseo/discussions) or the product discussion forum in [Discord](https://discord.gg/jz8T2uahpH). Feature requests opened as issues will be closed. + +Explain: - What are you trying to do? - How do you do it today? - Where does Paseo get in the way? -- What would the flow look like if it worked for you? +- What would a better workflow look like? -"Can you build X" is not useful because it doesn't tell me about your problem. +This helps other people describe their needs and gives me useful context when deciding what to build. -A discussion also lets other people add their own version of the same problem, and that's what I read when I decide what to build. +There is no feature request backlog or commitment to implement a discussion. I may read and learn from it without replying. -Due to the volume and my limited bandwidth, I may not participate in all the discussions, if something gains interest it will make it's way to me. +## If you still want to submit a pull request -## Pull requests +After considering plugins and discussing the workflow, you may still want to propose a change to core. Focused fixes for reproducible bugs are also welcome. -For plugin changes, start with the [SDK import boundaries](docs/plugins.md#sdk-import-boundaries). +**Pull requests are closed by default.** I may reopen the ones I want to take forward. Submit one only if you are comfortable with it being closed without a detailed review or explanation. -Anyone can open a pull request, but there are no guarantees of it getting merged, only submit one if you're okay with it being closed. +Paseo receives more pull requests than I can responsibly review. Reviewing every submission and explaining every decision would consume the time available for developing the product. -Open it as a draft if the work isn't ready for review, if you want to run the checks against it, or if you want feedback on the direction before you finish it. Mark it ready when you want it looked at. +I can prompt an agent to write code just as you can. The bottlenecks are choosing what to build, shaping it, verifying its behavior, and taking responsibility for its maintenance. A finished implementation still leaves that work to do. -Here is the criteria I use to decide: +If you choose to submit a PR, these are the basics to cover. -✅ Likely to be accepted +### What I look for in a core feature -- Fits the product vision -- One focused change -- Links the bug it fixes, or the discussion behind it if there is one -- Addresses bot reviews -- Explains the problem you're solving -- QA evidence -- Automated tests -- Screenshots or video for UI changes, on every affected platform -- Says which platforms you tested and which you didn't -- Maintainer edits enabled +- **Demonstrated demand.** Link a discussion with concrete examples of other users needing the workflow. A feature for a small, specialized use case belongs in a plugin. +- **Value across workflows.** The feature should improve how existing capabilities work together and benefit a broad set of users. A reusable improvement is more valuable than an isolated control for one task. +- **A coherent design across platforms and providers.** Shared capabilities should work across platforms and providers wherever applicable. Do not reshape a shared abstraction around one provider while leaving the others unsupported or inconsistent. +- **A finished experience.** Follow the [design guidelines](docs/design.md). The feature must look and feel right in Paseo, including loading states, layout stability, and interaction performance. PRs with janky or rushed interfaces will be closed. Design judgment remains with the maintainer. -⛔️ Will be rejected +### Keep the scope small -- Bundles unrelated changes -- Fails checks -- Ignores bot reviews -- Takes a feature or the design in a direction I don't want -- No QA evidence -- No tests -- Clearly fully AI-generated PR +Submit one focused change. PRs adding more than roughly **3,000 lines of production code** are unlikely to be accepted. Prefer the smallest complete improvement that can be tested and understood on its own. -### What to expect +### QA evidence -- PRs that were explicitly approved in a discussion are preferred. -- Unsolicited PR can be closed without a detailed review. -- Your PR can be narrowed, refactored, or redesigned. -- Your PR might be accepted but not merged immediately. -- You will be attributed for your work in the changelog, even if I redesign your PR +Run the changed application or service and test the affected workflow yourself. Include detailed QA evidence so I can see what you exercised and what happened. PRs without this verification and evidence will be closed, whether they fix a bug or add a feature. -## QA evidence +Explain the problem and link the relevant bug report or discussion. Include: -QA is the main bottleneck of Paseo's product development, and it's not just whether the feature works, it's also whether it meets the quality bar. +- Automated tests that exercise the changed behavior. A bug fix needs a regression test that fails on the broken version. +- The commands you ran and their output. +- A recording for interactive UI changes, or before-and-after screenshots for static changes. +- The platforms you tested and any affected platforms you could not test. -Pull requests without evidence will be closed. +The [QA guide](docs/qa.md) explains the expected evidence and available tooling. -What's accepted: +Using an agent is welcome. Sending it to implement a change and submitting its output without trying the result yourself does not meet this bar. -- The shell commands you ran, pasted with their output -- The tests you added and their results -- Before and after screenshots -- A video of the whole interaction -- Logs, requests, responses +**Satisfying all of the above does not mean your pull request will be merged.** -Bugs and features both need automated tests, and the tests have to exercise the real thing. UI changes need a video, or a screenshot if it's static. +### What happens to your contribution -The [QA guide](docs/qa.md) covers what to check for each of these and the tooling to do it. +If I take a contribution forward, I may narrow it, reshape it, or use it as a reference for my own implementation. **You will be credited for your contribution, including when I reimplement it.** -## I want to become a maintainer +A closed PR does not necessarily mean the underlying problem was dismissed. It means I am not taking that submission forward. Detailed reviews, individual explanations, and follow-up discussions cannot be provided at this volume. -There's no formal process to become a maintainer, just consistently get involved with the project by answering questions, testing, reproducing bugs, discussing workflows with the community and offer to implement features when we reach an agreement. +## Help the community -## FAQ +Testing betas, reproducing bugs, improving documentation, sharing plugins, and answering questions all help Paseo develop. -### I'm blocked, where do I go? - -Join [Discord](https://discord.gg/jz8T2uahpH), ask your questions there. - -### Do you prefer Github or Discord? - -I am more active on Discord for general questions. - -I prefer Github for bugs because the template helps collect information more efficiently. - -### Why was my feature request closed? - -Issues are only for bugs. Open it as a product discussion instead, framed as a workflow. - -### Why didn't my PR get a detailed review? - -Writing a review is very time consuming and Paseo receives a lot of PRs, I cannot give detailed explanations at volume. - -### How long until someone looks at my pull request? - -It depends on what else is going on. QA takes longer than reading the diff. You are welcome to join Discord to ask. - -### Why do you want tests and a video for a small change? - -Because it's faster and safer than me checking out and running every PR myself to test it manually. - -QA evidence help me take your PR seriously for a deeper review and maintainer QA. - -### I only have one platform, can I still contribute? - -Yes. Test what you have, and say what you didn't test. - -### Can I use an agent to write my contribution? - -Yes, as long as you understand what you're submitting and you tested it yourself. - -### Is there a roadmap I can read? - -[docs/product.md](docs/product.md) covers the direction. There's no public list of what I'm building next. +There is no formal process for becoming a maintainer. Consistent involvement and good judgment build the shared context needed to take on more responsibility. diff --git a/docs/product.md b/docs/product.md index ad47f5694a..fd93c881da 100644 --- a/docs/product.md +++ b/docs/product.md @@ -1,81 +1,102 @@ # Product -What Paseo is, who it's for, and where it's going. +Paseo is an environment for running, monitoring, and interacting with coding agents across desktop, mobile, web, and the command line. -## What is Paseo +**Paseo has a lean, opinionated core built to be extended.** It should be easy to start using and leave room for people to build far beyond the default experience. -Paseo is a next-generation development environment built around agents. One interface to run, monitor, and interact with coding agents across desktop, mobile, terminal, and web. +## Agents are the focus -The development workflow is shifting from manually editing files to orchestrating agents that do the editing. Paseo is built for that workflow. +The central workflow is giving an agent a task, understanding what it is doing, providing direction, and reviewing the result. Files, terminals, diffs, and other supporting tools help you do that. -## Core philosophy +Paseo should make this workflow coherent across providers and devices. You should be able to use the agent that fits a task and keep working from another device without rebuilding your environment around it. -Freedom and flexibility. Every design decision follows from this: +This is the basis for product decisions. A familiar feature from another development tool earns its place by improving that experience. Familiarity alone does not make it a requirement for Paseo. -- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor lock-in. -- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal. -- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency. -- **Respectful** - No telemetry, no forced cloud, no forced accounts -- **Open source** — Apache-2.0. Users can inspect, fork, and contribute. -- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top. +## A low floor and a high ceiling -## How it works +### Easy to start -### Projects and workspaces +The default experience should be lean, polished, and understandable to someone who has never run a server or configured a VPN. -Projects are grouped in the sidebar, detected automatically from your filesystem and tagged by git remote when available. +With a supported coding agent installed and authenticated, you can open the desktop app and start working. The app manages its local daemon. To connect your phone through the optional Paseo relay, enable pairing and scan a QR code. You do not need to configure a VPN or understand the networking behind it. -Each project opens as a workspace. For git projects, the default workspace is the main checkout. Users can create additional workspaces, which are isolated copies (git worktrees) where agents work without affecting main. +You should not need to know that plugins exist to get a useful, complete experience. Extra capability should become discoverable when it helps with something you want to do. -### Inside a workspace +Keeping this starting point approachable is an ongoing responsibility. A technically correct feature can still make the product harder to understand through extra choices, controls, or setup. -A workspace is a flexible canvas: +### Room to build your own setup -- Launch multiple agents side by side in split panes -- Open terminals alongside agents -- Mix and match providers within the same workspace +The same product should also work for someone running agents on a home server, using their own network, or building an automated workflow. -### The daemon +The daemon can run independently of the desktop app. Clients connect to it locally or remotely, and multiple clients can use the same daemon. This separation lets you choose where the work runs and how you interact with it. -Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely. +For example, someone can use the relay to connect their phone with minimal setup. Someone else can run a daemon on their own infrastructure and connect through SSH or a VPN. Both use the same underlying system. -This architecture means: +Paseo should provide useful defaults while keeping these choices available. You should not have to adopt every part of the system to benefit from the parts you want. -- The daemon can run on any machine: laptop, VM, remote server -- Multiple clients can connect simultaneously -- Agents keep running when a client disconnects — the daemon owns them, not the client -- Quitting the desktop app stops the daemon it started, so "restart the app" is a real fix; a daemon you run yourself is unaffected +See the [connectivity guide](https://paseo.sh/docs/connectivity) for supported connection methods and [architecture](architecture.md) for the system design. -## Target user +## Freedom, ownership, and privacy -Anyone who builds software: +Paseo's flexibility depends on keeping control with the user: -- Care about owning their tools and their data -- Use multiple AI providers and want to switch freely -- Run agents on real tasks across real projects -- Want to work from multiple devices +- **Self-hosted:** Run agents on your own machines, with your own environment, files, and credentials. +- **Provider choice:** Use supported agent harnesses and extend provider support through plugins. Your workflow should be able to evolve as your choice of provider changes. +- **Cross-device:** Use desktop, mobile, web, and CLI clients according to the situation. +- **Privacy:** No Paseo telemetry, tracking, or forced account. The relay is optional and end-to-end encrypted. +- **Open source:** Paseo is Apache-2.0 licensed. You can inspect it, modify it, and build on it. -## What compounds over time +Convenience and ownership should work together. Offering a straightforward way to connect should preserve the option to operate your own setup. -- **Trust** — Showing up daily, shipping in public, being open source. Earned slowly, lost quickly. -- **Community contributions** — Code, packaging, skills, agent configs. Contributors become advocates. -- **Ecosystem** — Skills, integrations, shared configs. Community-built content that makes the platform more valuable. +## A lean core that keeps improving -## Strategic bets +The core provides the shared experience: starting work, interacting with agents, understanding their state, and moving between workspaces and devices. -1. **Models commoditize.** Value moves to the orchestration layer. The best model changes monthly — the workflow layer stays. -2. **Multi-provider wins.** No single provider stays on top. Developers want the best model for each task. -3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere. -4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates. +Core development continues through improvements to reliability, performance, interaction design, and workflows with broad value. A lean core takes ongoing work. -## Current state (May 2026) +Every addition also has a lasting cost. It needs a place in the interface, an understandable relationship with existing behavior, testing across affected platforms, and maintenance as the rest of the product changes. A feature can work exactly as intended and still be the wrong addition. -- Desktop (Electron), mobile (iOS/Android), web, CLI -- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP -- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers -- Voice mode: dictate prompts or talk through problems hands-free -- MCP server exposes the daemon to other agents (workspaces, create/detach agent, schedules, heartbeats, terminals, workspace renaming) -- Scheduled agents (cron-style triggers) via app, CLI, and MCP -- Frequent releases (multiple per week) -- Community contributions across packaging, providers, and bug fixes -- Key UX: split panes, keybinding customization, workspace model, in-app browser +The preference is for a small number of well-shaped capabilities that work together. Before adding a specialized workflow, consider whether an existing capability can serve it or whether a reusable extension point would let people build it themselves. + +The boundary can evolve. Some built-in capabilities may eventually make more sense as plugins. Any such move needs to preserve a coherent default experience and account for people who already depend on it. + +## Extensibility and composition + +Plugins are a central part of how Paseo gains capability. + +Someone who needs a different agent provider, a specialized workspace panel, or a custom command should be able to build and share it independently. They can make the choices that fit their users and maintain the integration on their own schedule. + +Paseo's role is to provide interfaces that make those contributions possible across the daemon and connected clients. The [plugin documentation](https://paseo.sh/docs/plugins) describes what each released API supports. + +The [SDK](https://paseo.sh/docs/sdk) lets applications and services use the daemon directly. A separate dashboard, automation service, or client can build on the same agent infrastructure. Those projects do not all need to become features of the main app. + +Composition means useful pieces can be combined in ways the maintainer did not have to predict. Keeping the daemon independent, offering multiple connection methods, and exposing agent operations through APIs gives people room to choose those combinations. + +The extension interfaces need the same care as the core experience. They should be understandable, reusable, and maintainable. New extension points should follow concrete workflows that need them. + +## How Paseo develops + +Product, design, architecture, and workflow decisions remain with the maintainer. + +Those decisions draw on the history of the product: why a workflow has its current shape, what previous approaches taught us, how it interacts with the rest of the system, and where it is intended to go. Documentation shares that context, but familiarity also develops through sustained use and maintenance. + +Development starts with the smallest useful change that can make a substantial difference. After shipping it, leave room to learn: + +- Can people discover and understand it? +- Does it work well in their actual workflows? +- What friction or missing capability appears with repeated use? +- Does the next addition improve the shared experience or belong in a plugin? + +That feedback guides the next step. Expanding a workflow too far before people use it creates commitments that are difficult to reverse. Users build habits and integrations around what ships, so removing features or changing interfaces has a real cost. + +Writing an implementation is one part of this process. Product judgment, design, QA, review, and learning from use determine how quickly a change can responsibly become part of Paseo. + +## An ecosystem people can build on + +People contribute through reproducible bug reports, testing, workflow discussions, documentation, plugins, integrations, and helping each other. + +A plugin can solve a problem for its users without adding that workflow to everyone's default experience. An alternative client can explore a different interface while using the same daemon. These projects let people take ownership of the experience they want. + +The aim is an ecosystem that can develop independently of the maintainer's ability to review core changes. Its value comes from useful things people build, maintain, and share. + +Paseo is an independent project. Sustainable development, a coherent product, and the freedom to keep building are priorities. See [Contributing](../CONTRIBUTING.md) for how to participate. From 19bd6e59093d5a774b0b3c3fed12bfbf5aad6266 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 9 Sep 2026 15:14:07 +0200 Subject: [PATCH 051/566] docs(contributing): route bugs and product discussions to GitHub --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7b846fa92e..b25810262f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ If an extension point is missing, describe the workflow in [Discussions](https:/ ## Report bugs -Open a [GitHub issue](https://github.com/getpaseo/paseo/issues) or report it in [Discord](https://discord.gg/jz8T2uahpH). GitHub is preferred because the issue template helps collect the necessary information. +Open a [GitHub issue](https://github.com/getpaseo/paseo/issues) and fill out the bug report template. Include: @@ -38,7 +38,7 @@ Focused fixes for reproducible bugs are welcome. If you submit one, follow the p ## Discuss workflows before proposing features -Feature requests belong in [GitHub Discussions](https://github.com/getpaseo/paseo/discussions) or the product discussion forum in [Discord](https://discord.gg/jz8T2uahpH). Feature requests opened as issues will be closed. +Product discussions, including feature requests, belong in [GitHub Discussions](https://github.com/getpaseo/paseo/discussions). Feature requests opened as issues will be closed. Explain: From 509a3e17bdb6de9d17f99305cb60b84d62c0918a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 9 Sep 2026 15:23:01 +0200 Subject: [PATCH 052/566] feat(app): read the changelog in the app instead of the browser (#4576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What's new opens an adaptive sheet — bottom sheet on compact, centred card on desktop — that fetches CHANGELOG.md from the repository and renders it with the app's own chrome. The desktop update callout, the sidebar help menu, and a new row in Settings → About all open it; the sheet header links out to paseo.sh/changelog. The parser commits to two facts only: `##` starts a release and `###` starts a section. Section titles are read from the document rather than matched against a list, and everything under a section passes through verbatim to the Markdown renderer, so a future callout, block quote, code fence or image needs no parser change. Fences are tracked because a fenced sample is the one thing that can fool a line scan. The document is fetched rather than bundled because the app has to describe a release that shipped after it was built, which is the update callout's whole purpose. It goes straight to the repository rather than through a host: the notes describe the app, and routing them through a daemon would make them depend on which host happens to be connected. Nothing is fetched or parsed until the sheet opens. The latest release renders as soon as there is one and the rest of the first page follows in a transition, so React slices that work around the opening animation instead of committing hundreds of list items at once. --- docs/release.md | 2 + packages/app/e2e/browser/sidebar-help.spec.ts | 53 ++- packages/app/e2e/support/helpers/changelog.ts | 29 ++ packages/app/src/app/_layout.tsx | 2 + packages/app/src/changelog/changelog-host.tsx | 10 + packages/app/src/changelog/index.ts | 11 + .../changelog/internal/changelog-sheet.tsx | 241 ++++++++++++++ .../changelog/internal/changelog-source.ts | 72 +++++ .../src/changelog/internal/changelog-store.ts | 13 + .../internal/parse-changelog.test.ts | 301 ++++++++++++++++++ .../src/changelog/internal/parse-changelog.ts | 151 +++++++++ .../internal/use-revealed-releases.ts | 42 +++ packages/app/src/changelog/open-changelog.ts | 6 + .../src/components/adaptive-modal-sheet.tsx | 4 + .../components/sidebar/sidebar-help-menu.tsx | 6 +- .../desktop/updates/update-callout-source.tsx | 7 +- packages/app/src/i18n/resources/ar.ts | 11 + packages/app/src/i18n/resources/en.ts | 11 + packages/app/src/i18n/resources/es.ts | 11 + packages/app/src/i18n/resources/fr.ts | 11 + packages/app/src/i18n/resources/ja.ts | 11 + packages/app/src/i18n/resources/ko.ts | 11 + packages/app/src/i18n/resources/pt-BR.ts | 11 + packages/app/src/i18n/resources/ru.ts | 11 + packages/app/src/i18n/resources/zh-CN.ts | 11 + packages/app/src/screens/settings-screen.tsx | 30 ++ 26 files changed, 1063 insertions(+), 16 deletions(-) create mode 100644 packages/app/e2e/support/helpers/changelog.ts create mode 100644 packages/app/src/changelog/changelog-host.tsx create mode 100644 packages/app/src/changelog/index.ts create mode 100644 packages/app/src/changelog/internal/changelog-sheet.tsx create mode 100644 packages/app/src/changelog/internal/changelog-source.ts create mode 100644 packages/app/src/changelog/internal/changelog-store.ts create mode 100644 packages/app/src/changelog/internal/parse-changelog.test.ts create mode 100644 packages/app/src/changelog/internal/parse-changelog.ts create mode 100644 packages/app/src/changelog/internal/use-revealed-releases.ts create mode 100644 packages/app/src/changelog/open-changelog.ts diff --git a/docs/release.md b/docs/release.md index 342fa9eaf9..52b0f17ac2 100644 --- a/docs/release.md +++ b/docs/release.md @@ -486,6 +486,8 @@ Release notes depend on the changelog heading format. The heading **must** be st No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or `## X.Y.Z-beta.N`) line for the pushed tag to extract the version. A malformed heading breaks the release-notes sync for that tag. +`CHANGELOG.md` on `main` is also what the app's **What's new** sheet fetches and renders, so the file is a shipped product surface, not just a release input. `##` starts a release and `###` starts a section; the app reads section titles from the document, so renaming or adding one needs no app change. Everything under a section is rendered as Markdown: prose, lists, links, inline code, fenced code, block quotes, tables, and images. Raw HTML does not render — the shared Markdown parser runs with `html: false`, so a `