From a7ce602c83c1230c76c30fb6f2a6e2d952962e37 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 12 Sep 2026 08:47:44 +0200 Subject: [PATCH 01/17] fix(agents): restore archived Codex workspaces without losing focus (#4736) * fix(agents): restore archived Codex workspaces without losing focus History selection must survive without cached agent records, and reading an archived Codex thread must not acquire an interactive writer. Keep workspace restore explicit, serialize native lifecycle transitions, and leave transcript lifetime with its owner. * test(app): express Codex restore specs as user journeys * test(app): finish restore fixture cleanup after teardown errors * test(app): align restore coverage and await fixture readiness Workspace restore leaves agents archived until explicit Unarchive. Keep that distinction covered for multiple agents. Compile the recovery fixture before its interaction test starts, and wait for sidebar agent hydration before asserting tab presence. --- docs/agent-lifecycle.md | 32 +- docs/timeline-sync.md | 5 + .../e2e/browser/root-error-recovery.spec.ts | 6 + .../app/e2e/browser/sidebar-model-b.spec.ts | 6 +- .../worktree-codex-restore.real.spec.ts | 63 ++++ .../app/e2e/browser/worktree-restore.spec.ts | 9 +- .../helpers/codex-workspace-restore.ts | 277 ++++++++++++++++++ .../workspace/[workspaceId]/index.tsx | 17 +- packages/app/src/panels/agent-panel.tsx | 26 +- .../directory-sync/agent-replica.test.ts | 50 +++- .../runtime/directory-sync/agent-replica.ts | 2 +- .../directory-sync/internal/agent-store.ts | 36 ++- .../screens/workspace/workspace-screen.tsx | 6 - .../src/stores/workspace-layout-actions.ts | 29 +- .../src/stores/workspace-layout-store.test.ts | 88 ++++-- .../app/src/stores/workspace-layout-store.ts | 73 ++--- .../src/utils/agent-directory-sync.test.ts | 97 +++--- .../app/src/workspace-recovery/model.test.ts | 23 +- packages/app/src/workspace-recovery/model.ts | 16 - .../use-workspace-recovery.ts | 13 +- .../workspace-tabs/agent-visibility.test.ts | 43 +-- .../src/workspace-tabs/agent-visibility.ts | 17 +- .../src/server/agent/agent-loading.test.ts | 2 +- .../src/server/agent/agent-manager.test.ts | 78 ++++- .../server/src/server/agent/agent-manager.ts | 96 ++++-- .../providers/codex-app-server-agent.test.ts | 22 +- .../agent/providers/codex-app-server-agent.ts | 60 ++-- .../codex-history-lifecycle.real.e2e.test.ts | 151 ++++++++++ 28 files changed, 945 insertions(+), 398 deletions(-) create mode 100644 packages/app/e2e/browser/worktree-codex-restore.real.spec.ts create mode 100644 packages/app/e2e/support/helpers/codex-workspace-restore.ts create mode 100644 packages/server/src/server/agent/providers/codex-history-lifecycle.real.e2e.test.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index ddf69fd7c5..57bd7889cb 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -109,16 +109,28 @@ archived workspace. History navigation must not infer workspace lifecycle from ` or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state; only the route's explicit Unarchive or Restore action changes the archived workspace. -History navigation preserves the selected agent as an explicit recovery target. If both that agent -and its workspace are archived, the workspace recovery action restores the workspace and unarchives -the selected agent as one user action. Other archived agents in the restored workspace remain -recoverable from History. Opening one pins its tab and renders the archived-agent callout. Authoritative -timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must -leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains -the only transition back to an interactive runtime: it runs the provider's native unarchive hook -(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow. A -provider session can be archived outside Paseo while its Paseo agent remains active. Interactive -resume repairs that drift through the provider's native unarchive hook; history resume does not. +History navigation opens the selected agent without changing either archive state. Workspace +**Restore** recovers only the workspace; the selected archived agent stays open with its callout. +The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and +history hydration. Other archived agents stay archived. + +Opening an agent is a navigation choice, independent of whether its details are cached. The +layout retains that choice across reload while the panel fetches the agent from the daemon. +Once the daemon reports the agent active, its tab follows normal archive propagation again. +An empty active list cannot cancel an explicit History selection. Agent-detail loading does +not own selection or release the explicit open. + +Persisted resume, native restore, and both live and stored-only archive enter the same per-agent +lifecycle queue. Resume chooses its history or interactive purpose from the durable record after +entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed +close retains the runtime for cleanup and blocks replacement through that close operation. + +Authoritative timeline catch-up can use a runtime-only `history` resume purpose. For Codex, that +purpose initializes a temporary app-server, reads the persisted thread and child histories, and +releases the process before returning. It never loads, resumes, or unarchives a native thread, +including legacy records whose native archive failed. The retained history session contains only +the read results. Interactive resume remains responsible for repairing a provider session archived +outside Paseo while its Paseo agent is active. Provider session connection owns every process it spawns until the session is registered with `AgentManager`. If initialization, persisted-session resume, or initial history hydration fails, diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 5d3464a30b..02c5723162 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -95,6 +95,11 @@ owner wraps cached preparation, network catch-up, accepted timeline application, behind one interface. React supplies transport and projection operations without selecting a cache path or issuing a separate persistence notification. +Active-agent list reconciliation does not own transcript lifetime. An archived agent's fresh +history response can arrive before the active-list response that omits it. Clearing history there +would discard accepted rows while the viewed owner still considers them synchronized. Entity +deletion clears the transcript; list membership changes do not. + Removing the host from the registry is the destructive boundary: it stops the runtime and clears the session and host-scoped setup state together. diff --git a/packages/app/e2e/browser/root-error-recovery.spec.ts b/packages/app/e2e/browser/root-error-recovery.spec.ts index 26fc70c4bb..55f5f37b42 100644 --- a/packages/app/e2e/browser/root-error-recovery.spec.ts +++ b/packages/app/e2e/browser/root-error-recovery.spec.ts @@ -4,9 +4,15 @@ import { expect, test, type Page, type TestInfo } from "@playwright/test"; let server: Server; let appUrl: string; test.beforeAll(async () => { + // Compile this separate fixture entrypoint in setup, like the main app warmup. + // Cold Metro compilation must not consume the recovery interaction's timeout. + test.setTimeout(120_000); const metroPort = process.env.E2E_METRO_PORT; if (!metroPort) throw new Error("E2E_METRO_PORT is required"); const bundle = `http://localhost:${metroPort}/packages/app/e2e/support/recovery-app.bundle?platform=web&dev=true&minify=false&hot=false&lazy=true&transform.engine=hermes&transform.routerRoot=src%2Fapp`; + const compiled = await fetch(bundle, { signal: AbortSignal.timeout(120_000) }); + if (!compiled.ok) throw new Error(`Recovery fixture compilation failed: HTTP ${compiled.status}`); + await compiled.arrayBuffer(); server = createServer((_request, response) => { response.setHeader("Content-Type", "text/html"); response.end( diff --git a/packages/app/e2e/browser/sidebar-model-b.spec.ts b/packages/app/e2e/browser/sidebar-model-b.spec.ts index 33e6ff8ddc..336ed97245 100644 --- a/packages/app/e2e/browser/sidebar-model-b.spec.ts +++ b/packages/app/e2e/browser/sidebar-model-b.spec.ts @@ -7,7 +7,6 @@ import { getServerId } from "../support/helpers/server-id"; import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; import { selectSidebarStatusGrouping } from "../support/helpers/sidebar"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; -import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs"; // Model B sidebar shape: every project — git or non-git, single- or // multi-workspace — renders as the same expandable parent, the deepest sidebar @@ -96,8 +95,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( + page.getByTestId(`workspace-tab-agent_${mock.agentId}`).filter({ visible: true }), + ).toBeVisible(); await clickNewTerminal(page); await expect( diff --git a/packages/app/e2e/browser/worktree-codex-restore.real.spec.ts b/packages/app/e2e/browser/worktree-codex-restore.real.spec.ts new file mode 100644 index 0000000000..8b9010e742 --- /dev/null +++ b/packages/app/e2e/browser/worktree-codex-restore.real.spec.ts @@ -0,0 +1,63 @@ +import { test } from "../support/helpers/codex-workspace-restore"; + +test.use({ + e2eDaemonConfig: { version: 1, daemon: { mcp: { enabled: true } } }, + trace: "on", + video: "on", +}); +test.describe.configure({ timeout: 600_000 }); + +test("a fresh client restores an archived Codex agent across a reload without agent cache", async ({ + codexRestore, +}) => { + await test.step("Create, finish, and archive Codex before this browser connects", async () => { + await codexRestore.createWorktreeWithCodex(); + await codexRestore.archiveBeforeBrowserConnects(); + }); + await test.step("Connect the fresh browser, open History, and restore the workspace", async () => { + await codexRestore.connectFreshBrowser(); + await codexRestore.openArchivedWorkspaceFromHistory(); + await codexRestore.restoreWorkspace(); + await codexRestore.expectArchivedAgentSelectedWithHistory(); + }); + await test.step("Reload without cached agent data and keep the selected archived agent", async () => { + await codexRestore.reloadWithoutAgentCache(); + await codexRestore.waitForWorkspaceHydration(); + await codexRestore.expectArchivedAgentSelectedWithHistory(); + }); + await test.step("Unarchive the agent and keep its idle composer after another cache-empty reload", async () => { + await codexRestore.unarchiveAgent(); + await codexRestore.reloadWithoutAgentCache(); + await codexRestore.expectIdleAgentWithVisibleComposer(); + }); +}); + +test("restore an archived worktree, then unarchive its completed Codex agent", async ({ + codexRestore, +}) => { + await test.step("1. Create a managed worktree with a real Codex agent through Paseo MCP", async () => { + await codexRestore.createWorktreeWithCodex(); + }); + await test.step("2. Wait for Codex to finish and show its reply", async () => { + await codexRestore.waitForCompletedReply(); + }); + await test.step("3. Archive the workspace from its sidebar menu", async () => { + await codexRestore.archiveWorkspaceFromSidebar(); + }); + await test.step("4. Assert workspace and agent are archived and the worktree is removed", async () => { + await codexRestore.expectWorkspaceAndAgentArchived(); + }); + await test.step("5. Open the archived workspace from History", async () => { + await codexRestore.openArchivedWorkspaceFromHistory(); + }); + await test.step("6. Restore the workspace", async () => { + await codexRestore.restoreWorkspace(); + await codexRestore.expectWorktreeRestoredWithArchivedAgentSelected(); + }); + await test.step("7. Click Unarchive on the agent", async () => { + await codexRestore.unarchiveAgent(); + }); + await test.step("8. Expect an idle agent with a visible editable composer", async () => { + await codexRestore.expectIdleAgentWithVisibleComposer(60_000); + }); +}); diff --git a/packages/app/e2e/browser/worktree-restore.spec.ts b/packages/app/e2e/browser/worktree-restore.spec.ts index 4f6096ed07..3926b43a97 100644 --- a/packages/app/e2e/browser/worktree-restore.spec.ts +++ b/packages/app/e2e/browser/worktree-restore.spec.ts @@ -265,7 +265,7 @@ test.describe("Worktree restore", () => { ).toHaveText(switchedBranch, { timeout: 30_000 }); }); - test("recovers the selected agent with its workspace and later rescues another archived agent", async ({ + test("restores the workspace before explicitly unarchiving each selected agent", async ({ page, }) => { const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", { @@ -292,9 +292,16 @@ test.describe("Worktree restore", () => { await expect( page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(), ).toBeVisible({ timeout: 30_000 }); + await expect( + page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(), + ).toHaveAttribute("aria-selected", "true"); + expect(await fetchAgentArchivedAt(client, firstAgent.id)).not.toBeNull(); + expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull(); + await page.getByRole("button", { name: "Unarchive", exact: true }).click(); await expect .poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 }) .toBeNull(); + expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull(); await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, { timeout: 30_000, }); diff --git a/packages/app/e2e/support/helpers/codex-workspace-restore.ts b/packages/app/e2e/support/helpers/codex-workspace-restore.ts new file mode 100644 index 0000000000..7595b4eb20 --- /dev/null +++ b/packages/app/e2e/support/helpers/codex-workspace-restore.ts @@ -0,0 +1,277 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { z } from "zod"; +import type { TestInfo } from "@playwright/test"; +import { test as base, expect, type Page } from "../fixtures"; +import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { openSessions } from "./archive-tab"; +import { getE2EDaemonPort } from "./daemon-port"; +import { assertComposerIdle } from "./rewind-flow"; +import { getServerId } from "./server-id"; +import { archiveWorkspaceFromSidebar, expectWorkspaceAbsentFromSidebar } from "./sidebar"; +import { createTempGitRepo } from "./workspace"; +import { waitForSidebarHydration } from "./workspace-ui"; +import type { SeedDaemonClient } from "./seed-client"; + +const REPLY = "CODEX_WORKTREE_RESTORE_READY"; +const workspaceSchema = z.object({ + workspaceId: z.string(), + projectId: z.string(), + cwd: z.string(), + isolation: z.literal("worktree"), +}); + +async function callPaseoTool( + mcp: Client, + name: string, + args: Record, +): Promise { + const result = await mcp.callTool({ name, arguments: args }, undefined, { timeout: 240_000 }); + expect(result).not.toMatchObject({ isError: true }); + return result.structuredContent; +} + +async function defaultCodexModel(mcp: Client): Promise { + const result = z + .object({ models: z.array(z.object({ id: z.string(), isDefault: z.boolean() })) }) + .parse(await callPaseoTool(mcp, "list_models", { provider: "codex" })); + const model = result.models.find((entry) => entry.isDefault); + if (!model) throw new Error("Codex did not advertise a default model"); + return model.id; +} + +function recordTimelineWire(page: Page): string[] { + const wire: string[] = []; + page.on("websocket", (socket) => { + const recordFrame = (direction: string, message: string) => { + if (/agent.*timeline|fetch_agents|agent_update/.test(message)) { + wire.push(JSON.stringify({ at: Date.now(), direction, message })); + } + }; + socket.on("framesent", ({ payload }) => recordFrame("sent", payload.toString())); + socket.on("framereceived", ({ payload }) => recordFrame("received", payload.toString())); + }); + return wire; +} + +async function createCodexRestoreJourney(page: Page, client: SeedDaemonClient, info: TestInfo) { + const repo = await createTempGitRepo("codex-worktree-restore-"); + const mcp = new Client({ name: "worktree-codex-restore-playwright", version: "1.0.0" }); + const wire = recordTimelineWire(page); + let workspace: z.infer | undefined; + let agentId: string | undefined; + + function created() { + if (!workspace || !agentId) throw new Error("Create the Codex worktree before using it"); + return { ...workspace, agentId }; + } + + async function expectSelected() { + await expect(page.getByTestId(`workspace-tab-agent_${created().agentId}`)).toHaveAttribute( + "aria-selected", + "true", + ); + } + + return { + async createWorktreeWithCodex() { + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents`), + ), + ); + workspace = workspaceSchema.parse( + await callPaseoTool(mcp, "create_workspace", { + isolation: "worktree", + path: repo.path, + baseBranch: "main", + title: "Codex worktree restore reproduction", + }), + ); + const agent = z.object({ agentId: z.string() }).parse( + await callPaseoTool(mcp, "create_agent", { + workspaceId: workspace.workspaceId, + provider: `codex/${await defaultCodexModel(mcp)}`, + settings: { modeId: "full-access", thinkingOptionId: "low" }, + title: "Codex worktree restore reproduction", + initialPrompt: `Reply with exactly ${REPLY} and nothing else. Do not use tools.`, + background: true, + }), + ); + agentId = agent.agentId; + }, + async waitForCompletedReply() { + const target = created(); + await page.goto(buildHostAgentDetailRoute(getServerId(), target.agentId, target.workspaceId)); + await waitForSidebarHydration(page); + await expect(page.getByTestId("assistant-message")).toContainText(REPLY, { + timeout: 240_000, + }); + await expect + .poll(() => client.fetchAgent({ agentId: target.agentId })) + .toMatchObject({ + agent: { status: "idle", archivedAt: null }, + }); + await assertComposerIdle({ page }); + }, + async archiveBeforeBrowserConnects() { + const target = created(); + await client.waitForFinish(target.agentId, 240_000); + expect((await client.archiveWorkspace(target.workspaceId)).error).toBeNull(); + await expect.poll(() => existsSync(target.cwd)).toBe(false); + await expect + .poll(() => client.fetchAgent({ agentId: target.agentId })) + .toMatchObject({ + agent: { archivedAt: expect.any(String) }, + }); + }, + async connectFreshBrowser() { + await page.goto("/"); + }, + async archiveWorkspaceFromSidebar() { + await archiveWorkspaceFromSidebar(page, created().workspaceId); + }, + async expectWorkspaceAndAgentArchived() { + const target = created(); + await expectWorkspaceAbsentFromSidebar(page, target.workspaceId); + await expect + .poll(() => client.fetchAgent({ agentId: target.agentId })) + .toMatchObject({ + agent: { archivedAt: expect.any(String) }, + }); + await expect.poll(() => existsSync(target.cwd), { timeout: 30_000 }).toBe(false); + await expect + .poll(() => client.fetchWorkspaces()) + .not.toMatchObject({ + entries: expect.arrayContaining([expect.objectContaining({ id: target.workspaceId })]), + }); + }, + async openArchivedWorkspaceFromHistory() { + await openSessions(page); + const row = page.getByTestId(`agent-row-${getServerId()}-${created().agentId}`); + await expect(row).toContainText("Archived"); + await row.click(); + await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible(); + }, + async restoreWorkspace() { + await page.getByRole("button", { name: "Restore", exact: true }).click(); + await expect(page.getByText("Workspace archived", { exact: true })).toHaveCount(0, { + timeout: 60_000, + }); + }, + async expectWorktreeRestoredWithArchivedAgentSelected() { + await expect.poll(() => existsSync(created().cwd), { timeout: 30_000 }).toBe(true); + await expectSelected(); + await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible(); + await info.attach("after-workspace-restore", { + body: JSON.stringify(await client.fetchAgent({ agentId: created().agentId }), null, 2), + contentType: "application/json", + }); + }, + async expectArchivedAgentSelectedWithHistory() { + await expectSelected(); + await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible(); + await expect(page.getByTestId("assistant-message")).toContainText(REPLY); + }, + async reloadWithoutAgentCache() { + const url = page.url(); + const cdp = await page.context().newCDPSession(page); + // Unmount first so an open connection cannot repopulate the cache while it is cleared. + await page.goto("about:blank"); + await cdp.send("Storage.clearDataForOrigin", { + origin: new URL(url).origin, + storageTypes: "indexeddb,cache_storage", + }); + await cdp.send("Network.clearBrowserCache"); + await cdp.send("Network.setCacheDisabled", { cacheDisabled: true }); + await page.goto(url); + await cdp.detach(); + }, + async waitForWorkspaceHydration() { + await waitForSidebarHydration(page); + }, + async unarchiveAgent() { + await page.getByRole("button", { name: "Unarchive", exact: true }).click({ timeout: 30_000 }); + await expect(page.getByText("This agent is archived", { exact: true })).toHaveCount(0, { + timeout: 60_000, + }); + }, + async expectIdleAgentWithVisibleComposer(statusTimeout?: number) { + await expect + .poll(() => client.fetchAgent({ agentId: created().agentId }), { timeout: statusTimeout }) + .toMatchObject({ agent: { status: "idle", archivedAt: null } }); + await expectSelected(); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible(); + await assertComposerIdle({ page }); + await info.attach("restored-agent-screen", { + body: await page.screenshot(), + contentType: "image/png", + }); + await info.attach("restored-agent-accessibility", { + body: await page.locator("body").ariaSnapshot(), + contentType: "text/plain", + }); + }, + async dispose() { + const testFailed = info.status !== info.expectedStatus; + const errors: unknown[] = []; + try { + await info.attach("final-screen", { + body: await page.screenshot(), + contentType: "image/png", + }); + await info.attach("timeline-wire", { body: wire.join("\n"), contentType: "text/plain" }); + await info.attach("final-agent", { + body: JSON.stringify(agentId ? await client.fetchAgent({ agentId }) : null, null, 2), + contentType: "application/json", + }); + await info.attach("daemon-log", { + body: await readFile(path.join(process.env.E2E_PASEO_HOME!, "daemon.log")), + contentType: "text/plain", + }); + } catch (error) { + errors.push(error); + } + const cleanup: Array<() => Promise> = [() => mcp.close()]; + if (!testFailed || process.env.E2E_KEEP_PASEO_HOME !== "1") { + const projectId = workspace?.projectId; + if (projectId) cleanup.push(() => client.removeProject(projectId)); + cleanup.push(() => repo.cleanup()); + } + for (const release of cleanup) { + try { + await release(); + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) { + const error = new AggregateError(errors, "Codex restore fixture teardown failed"); + if (!testFailed) throw error; + // Keep the journey failure primary while reporting teardown failures in the test log. + console.error(error); + } + }, + }; +} + +export const test = base.extend<{ + codexRestore: Awaited>; +}>({ + // This fixture owns cleanup so a failed reproduction can retain its daemon state. + projectOwnership: async ({ e2eWorkerClient }, provide) => { + void e2eWorkerClient; + await provide(); + }, + codexRestore: async ({ page, e2eWorkerClient }, provide, info) => { + const journey = await createCodexRestoreJourney(page, e2eWorkerClient, info); + try { + await provide(journey); + } finally { + await journey.dispose(); + } + }, +}); diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx index 8396d00b36..7dc11b08f1 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx @@ -115,8 +115,7 @@ function HostWorkspaceRouteContent() { const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId); const workspaceExists = useWorkspaceExists(serverId, workspaceId); const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]); - const recoveryAgentId = openIntent?.kind === "agent" ? openIntent.agentId : null; - const isAgentOpenIntent = recoveryAgentId !== null; + const isAgentOpenIntent = openIntent?.kind === "agent"; const isOpenIntentWaitingForWorkspace = Boolean( isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists), ); @@ -191,16 +190,10 @@ function HostWorkspaceRouteContent() { return null; } - return ; + return ; } -function WorkspaceDeck({ - recoveryRequested, - recoveryAgentId, -}: { - recoveryRequested: boolean; - recoveryAgentId: string | null; -}) { +function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) { const activeSelection = useActiveWorkspaceSelection(); const [retainedSelections, setRetainedSelections] = useState(() => activeSelection ? [{ selection: activeSelection, inactiveSince: null }] : [], @@ -272,7 +265,6 @@ function WorkspaceDeck({ selection={selection} active={active} recoveryRequested={recoveryRequested} - recoveryAgentId={recoveryAgentId} onUnmountInactive={unmountWorkspaceSelection} /> ); @@ -286,13 +278,11 @@ function WorkspaceDeckEntry({ selection, active, recoveryRequested, - recoveryAgentId, onUnmountInactive, }: { selection: ActiveWorkspaceSelection; active: boolean; recoveryRequested: boolean; - recoveryAgentId: string | null; onUnmountInactive: (selection: ActiveWorkspaceSelection) => void; }) { const hasHydratedWorkspaces = useHasHydratedWorkspaces(selection.serverId); @@ -323,7 +313,6 @@ function WorkspaceDeckEntry({ workspaceId={selection.workspaceId} isRouteFocused={active} recoveryRequested={active && recoveryRequested} - recoveryAgentId={active ? recoveryAgentId : null} /> ); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index c2129c654c..84f86bf745 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -626,8 +626,6 @@ function AgentPanelBody({ const [lookupState, setLookupState] = useState({ tag: "idle" }); const lookupAttemptTokenRef = useRef(0); const retryAgentLookup = useCallback(() => setLookupState({ tag: "idle" }), []); - const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - const resolvePendingAgent = useWorkspaceLayoutStore((state) => state.resolvePendingAgent); useEffect(() => { lookupAttemptTokenRef.current += 1; @@ -639,9 +637,6 @@ function AgentPanelBody({ return; } if (agentState.id) { - if (workspaceKey) { - resolvePendingAgent(workspaceKey, agentId); - } if (lookupState.tag !== "idle") { setLookupState({ tag: "idle" }); } @@ -664,9 +659,6 @@ function AgentPanelBody({ return; } if (!result) { - if (workspaceKey) { - resolvePendingAgent(workspaceKey, agentId); - } setLookupState({ tag: "not_found", message: `Agent not found: ${agentId}`, @@ -675,9 +667,6 @@ function AgentPanelBody({ } storeFetchedAgentDetail({ serverId, result }); - if (workspaceKey) { - resolvePendingAgent(workspaceKey, agentId); - } setLookupState({ tag: "idle" }); return; }) @@ -687,25 +676,12 @@ function AgentPanelBody({ } const message = toErrorMessage(error); if (isNotFoundErrorMessage(message)) { - if (workspaceKey) { - resolvePendingAgent(workspaceKey, agentId); - } setLookupState({ tag: "not_found", message }); return; } setLookupState({ tag: "error", message }); }); - }, [ - agentId, - agentState.id, - client, - hasSession, - isConnected, - lookupState.tag, - resolvePendingAgent, - serverId, - workspaceKey, - ]); + }, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]); if (lookupState.tag === "not_found") { return ( 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..316e8e03bf 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,11 @@ import { describe, expect, it } from "vitest"; import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store"; +import { + selectAgentTimelineState, + selectAgentTurnPresentation, + useSessionStore, +} from "@/stores/session-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; import { AgentDirectoryReplica } from "./agent-replica"; @@ -53,6 +57,50 @@ function entry(agent: AgentSnapshotPayload): FetchAgentsEntry { } describe("AgentDirectoryReplica", () => { + it.each(["delta", "snapshot"] as const)( + "does not erase freshly fetched history when the active directory removes it by %s", + (removal) => { + const serverId = `agent-replica-history-${removal}`; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); + const archived = { ...payload("archived"), archivedAt: "2026-09-11T00:00:00.000Z" }; + replica.submitTimelineAgent(replica.captureTimeline("agent"), archived); + store.applyAgentTimelineResponseState(serverId, "agent", { + items: [ + { + kind: "assistant_message", + id: "reply", + text: "server history", + timestamp: new Date(), + timelineCursor: { epoch: "e", seq: 1 }, + }, + ], + head: [], + range: { epoch: "e", startSeq: 1, endSeq: 1 }, + older: "none", + newer: false, + synchronized: true, + acknowledgedClientMessageIds: [], + }); + if (removal === "delta") replica.applyDelta({ kind: "remove", agentId: "agent" }); + else replica.commitSnapshot([], []); + expect(selectAgentTimelineState(store.getSession(serverId), "agent")).toMatchObject({ + status: "synced", + items: [{ text: "server history" }], + }); + replica.remove("agent"); + expect(selectAgentTimelineState(store.getSession(serverId), "agent")).toMatchObject({ + status: "cold", + }); + store.clearSession(serverId); + }, + ); + it("does not let a late cache read replace newer live turn state", () => { const serverId = "agent-replica-late-cache"; const store = 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 1bdf3ea3de..dac4ec5d28 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.ts @@ -134,7 +134,7 @@ export class AgentDirectoryReplica { if (!this.members.has(agentId)) this.advance(agentId); } for (const agentId of previous.keys()) { - if (!nextIds.has(agentId)) this.storeProjection.remove(agentId); + if (!nextIds.has(agentId)) this.storeProjection.removeFromDirectory(agentId); } this.members.clear(); this.pendingCacheReads.clear(); 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..97c8208da5 100644 --- a/packages/app/src/runtime/directory-sync/internal/agent-store.ts +++ b/packages/app/src/runtime/directory-sync/internal/agent-store.ts @@ -12,6 +12,15 @@ import { useDraftStore } from "@/stores/draft-store"; import { getInitDeferred, getInitKey, rejectInitDeferred } from "@/utils/agent-initialization"; import { reduceTurnLiveness, type TurnLivenessTransition } from "@/timeline/turn-liveness"; +function withoutAgent(agentId: string) { + return (current: Map): Map => { + if (!current.has(agentId)) return current; + const next = new Map(current); + next.delete(agentId); + return next; + }; +} + function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent { if (!previous) return incoming; const activeTurn = @@ -65,7 +74,7 @@ export class AgentStoreProjection { agent?: Agent; } { if (delta.kind === "remove") { - this.remove(delta.agentId); + this.removeFromDirectory(delta.agentId); return { agentId: delta.agentId, stoppedRunning: false }; } const normalized = normalizeAgentSnapshot(delta.agent, this.serverId); @@ -141,28 +150,19 @@ export class AgentStoreProjection { useSessionStore.getState().setPendingPermissions(this.serverId, pending); } - remove(agentId: string): void { + removeFromDirectory(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; - }; + const removeKey = withoutAgent(agentId); 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); @@ -178,6 +178,18 @@ export class AgentStoreProjection { } } + remove(agentId: string): void { + this.removeFromDirectory(agentId); + // Only entity deletion destroys the transcript. A filtered active-list + // response can arrive after a fresh history response for an archived agent. + const store = useSessionStore.getState(); + const removeKey = withoutAgent(agentId); + store.setAgentTimelineCursor(this.serverId, removeKey); + store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false); + store.setAgentStreamTail(this.serverId, removeKey); + store.clearAgentStreamHead(this.serverId, agentId); + } + archive(agentId: string, archivedAt: string): Agent | null { let archived: Agent | null = null; useSessionStore.getState().setAgents(this.serverId, (current) => { diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index d9510f517f..a898fc0472 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -257,13 +257,11 @@ interface WorkspaceScreenProps { workspaceId: string; isRouteFocused?: boolean; recoveryRequested?: boolean; - recoveryAgentId?: string | null; } type WorkspaceScreenContentProps = WorkspaceScreenProps & { isRouteFocused: boolean; recoveryRequested: boolean; - recoveryAgentId: string | null; }; function trimNonEmpty(value: string | null | undefined): string | null { @@ -859,7 +857,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({ workspaceId, isRouteFocused, recoveryRequested, - recoveryAgentId, }: WorkspaceScreenProps) { const navigationFocused = useIsFocused(); useEffect(() => { @@ -874,7 +871,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({ workspaceId={workspaceId} isRouteFocused={isRouteFocused ?? navigationFocused} recoveryRequested={recoveryRequested ?? false} - recoveryAgentId={recoveryAgentId ?? null} /> ); }); @@ -1539,7 +1535,6 @@ function WorkspaceScreenContent({ workspaceId, isRouteFocused, recoveryRequested, - recoveryAgentId, }: WorkspaceScreenContentProps) { const { t } = useTranslation(); const _insets = useSafeAreaInsets(); @@ -1665,7 +1660,6 @@ function WorkspaceScreenContent({ const workspaceRecovery = useWorkspaceRecovery({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, - agentId: recoveryAgentId, enabled: shouldInspectWorkspaceRecovery( hasHydratedWorkspaces, workspaceDescriptor, diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 9496479f4e..d46952626e 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -252,7 +252,6 @@ interface ReorderPaneTabsInLayoutInput { export interface WorkspaceTabReconcileState { layout: WorkspaceLayout; pinnedAgentIds?: ReadonlySet | null; - pendingAgentIds?: ReadonlySet | null; hiddenAgentIds?: ReadonlySet | null; explorerSidebarPaneId: string | null; } @@ -262,7 +261,6 @@ export interface WorkspaceTabSnapshot { terminalsHydrated: boolean; activeAgentIds: Iterable; autoOpenAgentIds: Iterable; - knownAgentIds: Iterable; knownTerminalIds?: Iterable; standaloneTerminalIds: Iterable; hasActivePendingTerminalCreate?: boolean; @@ -2238,17 +2236,10 @@ interface EntityTabGroup { function applyPinnedAndHidden(input: { baseAgentIds: Set; pinnedAgentIds: Set; - pendingAgentIds: Set; hiddenAgentIds: Set; - knownAgentIds: Set; }): Set { - const { baseAgentIds, pinnedAgentIds, pendingAgentIds, hiddenAgentIds, knownAgentIds } = input; - const result = new Set(baseAgentIds); - for (const agentId of pinnedAgentIds) { - if (knownAgentIds.has(agentId) || pendingAgentIds.has(agentId)) { - result.add(agentId); - } - } + const { baseAgentIds, pinnedAgentIds, hiddenAgentIds } = input; + const result = new Set([...baseAgentIds, ...pinnedAgentIds]); for (const agentId of hiddenAgentIds) { result.delete(agentId); } @@ -2414,12 +2405,17 @@ export function reconcileWorkspaceTabs( const originalFocusedTabId = findPaneById(nextLayout.root, nextLayout.focusedPaneId)?.focusedTabId ?? null; let reconciledFocusedTabId = originalFocusedTabId; - const pinnedAgentIds = new Set(state.pinnedAgentIds ?? []); - const pendingAgentIds = new Set(state.pendingAgentIds ?? []); const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []); const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds); const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds); - const knownAgentIds = normalizeStringSet(snapshot.knownAgentIds); + // An explicit open owns the tab until the agent joins the active directory. + // From then on it follows the normal server archive lifecycle. Detail/cache + // hydration never decides whether the user's target is allowed to stay open. + const pinnedAgentIds = new Set( + [...(state.pinnedAgentIds ?? [])].filter( + (agentId) => !snapshot.agentsHydrated || !activeAgentIds.has(agentId), + ), + ); const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds); const knownTerminalIds = snapshot.knownTerminalIds ? normalizeStringSet(snapshot.knownTerminalIds) @@ -2427,16 +2423,12 @@ export function reconcileWorkspaceTabs( const visibleAgentIds = applyPinnedAndHidden({ baseAgentIds: activeAgentIds, pinnedAgentIds, - pendingAgentIds, hiddenAgentIds, - knownAgentIds, }); const autoOpenSet = applyPinnedAndHidden({ baseAgentIds: autoOpenAgentIds, pinnedAgentIds, - pendingAgentIds, hiddenAgentIds, - knownAgentIds, }); const initialTabs = collectAllTabs(nextLayout.root); @@ -2513,5 +2505,6 @@ export function reconcileWorkspaceTabs( return { ...state, layout: nextLayout, + pinnedAgentIds, }; } diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index e53f9c1911..4f861a04ee 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -450,7 +450,7 @@ describe("workspace-layout-store version 2 migration", () => { }); }); - it("writes version 2 with the persisted Explorer key accepted by released v0.6", async () => { + it("writes version 2 preserving the existing Explorer key", async () => { await persistVersionOneLayout("v0.6"); const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); @@ -463,6 +463,7 @@ describe("workspace-layout-store version 2 migration", () => { "explorerPaneIdByWorkspace", "explorerSidebarWidthByWorkspace", "layoutByWorkspace", + "pinnedAgentIdsByWorkspace", "sidePaneIdByWorkspace", "splitSizesByWorkspace", ]); @@ -1483,7 +1484,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: ["terminal-1"], standaloneTerminalIds: ["terminal-1"], hasActivePendingTerminalCreate: true, @@ -1497,7 +1497,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: ["terminal-1"], standaloneTerminalIds: ["terminal-1"], }); @@ -1580,7 +1579,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1", "agent-2"], autoOpenAgentIds: ["agent-1", "agent-2"], - knownAgentIds: ["agent-1", "agent-2"], standaloneTerminalIds: [], }); @@ -2943,6 +2941,7 @@ describe("workspace-layout-store actions", () => { expect(persisted).toEqual({ layoutByWorkspace: { [workspaceKey]: layout }, + pinnedAgentIdsByWorkspace: {}, splitSizesByWorkspace: currentState.splitSizesByWorkspace, explorerSidebarWidthByWorkspace: currentState.explorerSidebarWidthByWorkspace, explorerPaneIdByWorkspace: {}, @@ -3092,7 +3091,7 @@ describe("workspace-layout-store actions", () => { expect(findPaneById(layout.root, "explorer")?.hidden).toBe(true); }); - it("keeps explicitly opened pinned agents in memory per workspace without persisting them", () => { + it("persists explicit agent opens per workspace", () => { const workspaceKey = createWorkspaceKey(); const otherWorkspaceKey = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, @@ -3131,7 +3130,9 @@ describe("workspace-layout-store actions", () => { const partialize = workspaceLayoutStore.persist.getOptions().partialize; expect(partialize).toBeTypeOf("function"); - expect(partialize?.(state)).not.toHaveProperty("pinnedAgentIdsByWorkspace"); + expect(partialize?.(state)).toHaveProperty("pinnedAgentIdsByWorkspace", { + [otherWorkspaceKey as string]: ["agent-2"], + }); }); it("keeps hidden agent intents in memory per workspace without persisting them", () => { @@ -3166,6 +3167,7 @@ describe("workspace-layout-store actions", () => { expect(partialize).toBeTypeOf("function"); expect(partialize?.(state)).toEqual({ layoutByWorkspace: {}, + pinnedAgentIdsByWorkspace: {}, splitSizesByWorkspace: {}, explorerSidebarWidthByWorkspace: {}, explorerPaneIdByWorkspace: {}, @@ -3258,7 +3260,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1"], autoOpenAgentIds: ["agent-1"], - knownAgentIds: ["agent-1", "agent-2"], standaloneTerminalIds: ["term-1"], hasActivePendingDraftCreate: false, }); @@ -3314,7 +3315,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1"], autoOpenAgentIds: ["agent-1"], - knownAgentIds: ["agent-1"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3346,7 +3346,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1"], autoOpenAgentIds: ["agent-1"], - knownAgentIds: ["agent-1"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3362,7 +3361,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1"], autoOpenAgentIds: ["agent-1"], - knownAgentIds: ["agent-1"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3380,7 +3378,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: [], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, @@ -3444,7 +3441,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: false, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: [], standaloneTerminalIds: [], }); @@ -3467,7 +3463,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: [], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, @@ -3495,7 +3490,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["parent-agent", "child-agent"], autoOpenAgentIds: ["parent-agent"], - knownAgentIds: ["parent-agent", "child-agent"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3522,7 +3516,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["parent-agent", "child-agent"], autoOpenAgentIds: ["parent-agent"], - knownAgentIds: ["parent-agent", "child-agent"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3549,7 +3542,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["parent-agent"], autoOpenAgentIds: ["parent-agent"], - knownAgentIds: ["parent-agent", "child-agent"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3571,7 +3563,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["child-agent"], autoOpenAgentIds: [], - knownAgentIds: ["child-agent"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3609,7 +3600,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: ["term-script", "term-manual"], standaloneTerminalIds: ["term-manual"], hasActivePendingDraftCreate: false, @@ -3629,7 +3619,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: ["term-script"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, @@ -3675,7 +3664,7 @@ describe("workspace-layout-store actions", () => { expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]); }); - it("keeps an explicitly pinned archived agent before its detail is hydrated", () => { + it("keeps an explicitly opened agent when its detail leaves client memory", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); @@ -3690,7 +3679,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], standaloneTerminalIds: [], }); @@ -3706,7 +3694,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], standaloneTerminalIds: [], }); @@ -3717,13 +3704,11 @@ describe("workspace-layout-store actions", () => { .map((tab) => tab.tabId), ).toEqual(["agent_archived-agent"]); - store.resolvePendingAgent(workspaceKey, "archived-agent"); store.reconcileTabs(workspaceKey, { agentsHydrated: true, terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], standaloneTerminalIds: [], }); @@ -3732,7 +3717,7 @@ describe("workspace-layout-store actions", () => { .getWorkspaceTabs(workspaceKey) .filter((tab) => tab.target.kind === "agent") .map((tab) => tab.tabId), - ).toEqual([]); + ).toEqual(["agent_archived-agent"]); store.openTab({ workspaceKey: workspaceKey, @@ -3745,7 +3730,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], standaloneTerminalIds: [], }); @@ -3757,6 +3741,57 @@ describe("workspace-layout-store actions", () => { ).toEqual(["agent_archived-agent"]); }); + it("restores the selected historical agent before any server data or cache is available", async () => { + const workspaceKey = createWorkspaceKey(); + workspaceLayoutStore.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "archived-agent" }, + intent: "reveal", + pin: true, + }); + const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + await restored.persist.rehydrate(); + restored.getState().reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + autoOpenAgentIds: [], + standaloneTerminalIds: [], + }); + const layout = restored.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe( + "agent_archived-agent", + ); + }); + + it("follows server archive after an explicitly opened agent becomes active", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + store.openTab({ + workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + pin: true, + }); + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: ["agent-1"], + autoOpenAgentIds: ["agent-1"], + standaloneTerminalIds: [], + }); + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + autoOpenAgentIds: [], + standaloneTerminalIds: [], + }); + expect(store.getWorkspaceTabs(workspaceKey).some((tab) => tab.target.kind === "agent")).toBe( + false, + ); + }); + it("atomically reveals, focuses, and pins an archived agent against reconciliation", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); @@ -3783,7 +3818,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["first-agent", "second-agent"], autoOpenAgentIds: ["first-agent", "second-agent"], - knownAgentIds: ["first-agent", "second-agent"], standaloneTerminalIds: [], }); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 974b8c6964..afb1d2694d 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -107,7 +107,6 @@ interface WorkspaceLayoutStore { splitSizesByWorkspace: Record>; explorerSidebarWidthByWorkspace: Record; pinnedAgentIdsByWorkspace: Record>; - pendingAgentIdsByWorkspace: Record>; hiddenAgentIdsByWorkspace: Record>; focusRestorationByWorkspace: Record; explorerSidebarPaneIdByWorkspace: Record; @@ -130,7 +129,6 @@ interface WorkspaceLayoutStore { setTabState: (workspaceKey: string, tabId: string, state: JsonValue | undefined) => void; convertDraftToAgent: (workspaceKey: string, tabId: string, agentId: string) => string | null; reconcileTabs: (workspaceKey: string, snapshot: WorkspaceTabSnapshot) => void; - resolvePendingAgent: (workspaceKey: string, agentId: string) => void; reorderTabs: (workspaceKey: string, tabIds: string[]) => void; getWorkspaceTabs: (workspaceKey: string) => WorkspaceTab[]; splitPane: ( @@ -270,6 +268,7 @@ const WorkspaceLayoutStorageSchema: z.ZodType = z.strictObject( parentTabIdByTabId: z.record(z.string(), z.string()).optional(), }); const WorkspaceLayoutPersistedStateSchema = z.strictObject({ + pinnedAgentIdsByWorkspace: z.record(z.string(), z.array(z.string())).optional(), layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema), splitSizesByWorkspace: z.record(z.string(), z.record(z.string(), z.array(z.number()))).optional(), explorerSidebarWidthByWorkspace: z.record(z.string(), z.number()).optional(), @@ -764,7 +763,6 @@ export function createWorkspaceLayoutStore( splitSizesByWorkspace: {}, explorerSidebarWidthByWorkspace: {}, pinnedAgentIdsByWorkspace: {}, - pendingAgentIdsByWorkspace: {}, hiddenAgentIdsByWorkspace: {}, focusRestorationByWorkspace: {}, explorerSidebarPaneIdByWorkspace: {}, @@ -830,13 +828,6 @@ export function createWorkspaceLayoutStore( normalizedTarget.agentId, ) : state.pinnedAgentIdsByWorkspace, - pendingAgentIdsByWorkspace: shouldPinAgent - ? addAgentIdToWorkspaceSet( - state.pendingAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedTarget.agentId, - ) - : state.pendingAgentIdsByWorkspace, layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: input.parentTabId @@ -1212,7 +1203,6 @@ export function createWorkspaceLayoutStore( { layout: currentLayout, pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, - pendingAgentIds: state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, explorerSidebarPaneId, }, @@ -1223,11 +1213,25 @@ export function createWorkspaceLayoutStore( explorerSidebarPaneId, currentLayout.focusedPaneId, ); - if (nextLayout === rawLayout) { + let pinnedAgentIdsByWorkspace = state.pinnedAgentIdsByWorkspace; + for (const agentId of state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? []) { + if (!nextState.pinnedAgentIds?.has(agentId)) { + pinnedAgentIdsByWorkspace = removeAgentIdFromWorkspaceSet( + pinnedAgentIdsByWorkspace, + normalizedWorkspaceKey, + agentId, + ); + } + } + if ( + nextLayout === rawLayout && + pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace + ) { return state; } return { + pinnedAgentIdsByWorkspace, layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout, @@ -1235,25 +1239,6 @@ export function createWorkspaceLayoutStore( }; }); }, - resolvePendingAgent: (workspaceKey, agentId) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedAgentId = trimNonEmpty(agentId); - if (!normalizedWorkspaceKey || !normalizedAgentId) { - return; - } - - set((state) => { - const pendingAgentIdsByWorkspace = removeAgentIdFromWorkspaceSet( - state.pendingAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedAgentId, - ); - if (pendingAgentIdsByWorkspace === state.pendingAgentIdsByWorkspace) { - return state; - } - return { pendingAgentIdsByWorkspace }; - }); - }, reorderTabs: (workspaceKey, tabIds) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); if (!normalizedWorkspaceKey) { @@ -1676,11 +1661,6 @@ export function createWorkspaceLayoutStore( delete nextPinnedAgentIdsByWorkspace[normalizedWorkspaceKey]; return { pinnedAgentIdsByWorkspace: nextPinnedAgentIdsByWorkspace, - pendingAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( - state.pendingAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedAgentId, - ), }; } @@ -1692,11 +1672,6 @@ export function createWorkspaceLayoutStore( ...state.pinnedAgentIdsByWorkspace, [normalizedWorkspaceKey]: nextPinnedAgentIds, }, - pendingAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( - state.pendingAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedAgentId, - ), }; }); }, @@ -1756,7 +1731,6 @@ export function createWorkspaceLayoutStore( normalizedWorkspaceKey in state.splitSizesByWorkspace || normalizedWorkspaceKey in state.explorerSidebarWidthByWorkspace || normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace || - normalizedWorkspaceKey in state.pendingAgentIdsByWorkspace || normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace || normalizedWorkspaceKey in state.focusRestorationByWorkspace || normalizedWorkspaceKey in state.explorerSidebarPaneIdByWorkspace || @@ -1774,8 +1748,6 @@ export function createWorkspaceLayoutStore( } = state.explorerSidebarWidthByWorkspace; const { [normalizedWorkspaceKey]: _pinned, ...pinnedAgentIdsByWorkspace } = state.pinnedAgentIdsByWorkspace; - const { [normalizedWorkspaceKey]: _pending, ...pendingAgentIdsByWorkspace } = - state.pendingAgentIdsByWorkspace; const { [normalizedWorkspaceKey]: _hidden, ...hiddenAgentIdsByWorkspace } = state.hiddenAgentIdsByWorkspace; const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } = @@ -1791,7 +1763,6 @@ export function createWorkspaceLayoutStore( splitSizesByWorkspace, explorerSidebarWidthByWorkspace, pinnedAgentIdsByWorkspace, - pendingAgentIdsByWorkspace, hiddenAgentIdsByWorkspace, focusRestorationByWorkspace, explorerSidebarPaneIdByWorkspace, @@ -1817,6 +1788,12 @@ export function createWorkspaceLayoutStore( } return { layoutByWorkspace, + pinnedAgentIdsByWorkspace: Object.fromEntries( + Object.entries(state.pinnedAgentIdsByWorkspace).map(([key, agentIds]) => [ + key, + Array.from(agentIds), + ]), + ), splitSizesByWorkspace: state.splitSizesByWorkspace, explorerSidebarWidthByWorkspace: state.explorerSidebarWidthByWorkspace, explorerPaneIdByWorkspace: state.explorerSidebarPaneIdByWorkspace, @@ -1855,6 +1832,12 @@ export function createWorkspaceLayoutStore( return { ...currentState, layoutByWorkspace, + pinnedAgentIdsByWorkspace: Object.fromEntries( + Object.entries(result.data.pinnedAgentIdsByWorkspace ?? {}).map(([key, agentIds]) => [ + key, + new Set(agentIds), + ]), + ), splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {}, explorerSidebarWidthByWorkspace: result.data.explorerSidebarWidthByWorkspace ?? diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index c8e2479894..0362aa56de 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -187,55 +187,60 @@ describe("replaceFetchedAgentDirectory", () => { store.clearSession(serverId); }); - it("removes every replica-owned artifact for a removed agent", () => { - const serverId = "server-removal"; - const agentId = "removed-agent"; - const store = useSessionStore.getState(); - store.initializeSession(serverId, null as unknown as DaemonClient); - const agent = { - ...normalizeAgentSnapshot(createAgentPayload({ id: agentId }), serverId), - projectPlacement: null, - }; - store.setAgents(serverId, new Map([[agentId, agent]])); - store.setAgentDetails(serverId, new Map([[agentId, agent]])); - store.setQueuedMessages( - serverId, - new Map([[agentId, [{ id: "queued", text: "next", attachments: [] }]]]), - ); - 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 }]]), - ); - store.setInitializingAgents(serverId, new Map([[agentId, true]])); - setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); + it.each(["directory", "entity"] as const)( + "cleans removed %s state without invalidating an independent transcript", + (kind) => { + const serverId = "server-removal"; + const agentId = "removed-agent"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const agent = { + ...normalizeAgentSnapshot(createAgentPayload({ id: agentId }), serverId), + projectPlacement: null, + }; + store.setAgents(serverId, new Map([[agentId, agent]])); + store.setAgentDetails(serverId, new Map([[agentId, agent]])); + store.setQueuedMessages( + serverId, + new Map([[agentId, [{ id: "queued", text: "next", attachments: [] }]]]), + ); + 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 }]]), + ); + store.setInitializingAgents(serverId, new Map([[agentId, true]])); + setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); - new AgentStoreProjection(serverId).applyDelta({ kind: "remove", agentId }); + const projection = new AgentStoreProjection(serverId); + if (kind === "directory") projection.applyDelta({ kind: "remove", agentId }); + else projection.remove(agentId); - const session = useSessionStore.getState().sessions[serverId]; - expect({ - agents: session?.agents.has(agentId), - details: session?.agentDetails.has(agentId), - queued: session?.queuedMessages.has(agentId), - cursor: session?.agentTimelineCursor.has(agentId), - permissions: session?.pendingPermissions.size, - initializing: session?.initializingAgents.has(agentId), - archivePending: isAgentArchiving({ queryClient, serverId, agentId }), - }).toEqual({ - agents: false, - details: false, - queued: false, - cursor: false, - permissions: 0, - initializing: false, - archivePending: false, - }); + const session = useSessionStore.getState().sessions[serverId]; + expect({ + agents: session?.agents.has(agentId), + details: session?.agentDetails.has(agentId), + queued: session?.queuedMessages.has(agentId), + cursor: session?.agentTimelineCursor.has(agentId), + permissions: session?.pendingPermissions.size, + initializing: session?.initializingAgents.has(agentId), + archivePending: isAgentArchiving({ queryClient, serverId, agentId }), + }).toEqual({ + agents: false, + details: false, + queued: false, + cursor: kind === "directory", + permissions: 0, + initializing: false, + archivePending: false, + }); - store.clearSession(serverId); - }); + store.clearSession(serverId); + }, + ); it("keeps newer metadata while accepting usage-only updates and legacy workspace ownership", () => { const serverId = "server-usage"; diff --git a/packages/app/src/workspace-recovery/model.test.ts b/packages/app/src/workspace-recovery/model.test.ts index 46f7aba864..c4a6de7a21 100644 --- a/packages/app/src/workspace-recovery/model.test.ts +++ b/packages/app/src/workspace-recovery/model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { recoverWorkspaceSelection, resolveWorkspaceRecoveryModel } from "./model"; +import { resolveWorkspaceRecoveryModel } from "./model"; describe("resolveWorkspaceRecoveryModel", () => { it("keeps newer recovery actions visible but non-actionable", () => { @@ -29,24 +29,3 @@ describe("resolveWorkspaceRecoveryModel", () => { }); }); }); - -describe("recoverWorkspaceSelection", () => { - it("restores the workspace and selected archived agent as one recovery action", async () => { - const operations: string[] = []; - - await recoverWorkspaceSelection({ - workspaceId: "workspace-1", - agentId: "agent-1", - client: { - restoreWorkspace: async (workspaceId) => { - operations.push(`workspace:${workspaceId}`); - }, - refreshAgent: async (agentId) => { - operations.push(`agent:${agentId}`); - }, - }, - }); - - expect(operations).toEqual(["workspace:workspace-1", "agent:agent-1"]); - }); -}); diff --git a/packages/app/src/workspace-recovery/model.ts b/packages/app/src/workspace-recovery/model.ts index 2c85a06f4a..5d5ebf15e0 100644 --- a/packages/app/src/workspace-recovery/model.ts +++ b/packages/app/src/workspace-recovery/model.ts @@ -34,22 +34,6 @@ export interface WorkspaceRecoveryController { retryInspection: () => void; } -export interface WorkspaceSelectionRecoveryClient { - restoreWorkspace: (workspaceId: string) => Promise; - refreshAgent: (agentId: string) => Promise; -} - -export async function recoverWorkspaceSelection(input: { - client: WorkspaceSelectionRecoveryClient; - workspaceId: string; - agentId?: string | null; -}): Promise { - await input.client.restoreWorkspace(input.workspaceId); - if (input.agentId) { - await input.client.refreshAgent(input.agentId); - } -} - function resolveRecoveryPhase(input: { pending: boolean; error: string | null; diff --git a/packages/app/src/workspace-recovery/use-workspace-recovery.ts b/packages/app/src/workspace-recovery/use-workspace-recovery.ts index ab19957190..d91aa3ce6e 100644 --- a/packages/app/src/workspace-recovery/use-workspace-recovery.ts +++ b/packages/app/src/workspace-recovery/use-workspace-recovery.ts @@ -4,11 +4,7 @@ import { useFetchQuery } from "@/data/query"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { toErrorMessage } from "@/utils/error-messages"; -import { - recoverWorkspaceSelection, - resolveWorkspaceRecoveryModel, - type WorkspaceRecoveryController, -} from "./model"; +import { resolveWorkspaceRecoveryModel, type WorkspaceRecoveryController } from "./model"; export type { WorkspaceRecoveryController, WorkspaceRecoveryModel } from "./model"; @@ -32,7 +28,6 @@ function waitForMinimumRecoveryLoadingTime(): Promise { export function useWorkspaceRecovery(input: { serverId: string; workspaceId: string; - agentId?: string | null; enabled: boolean; }): WorkspaceRecoveryController { const client = useHostRuntimeClient(input.serverId); @@ -61,11 +56,7 @@ export function useWorkspaceRecovery(input: { } await waitForRecoveryLoadingPresentation(); await waitForMinimumRecoveryLoadingTime(); - await recoverWorkspaceSelection({ - client, - workspaceId: input.workspaceId, - agentId: input.agentId, - }); + await client.restoreWorkspace(input.workspaceId); }, }); diff --git a/packages/app/src/workspace-tabs/agent-visibility.test.ts b/packages/app/src/workspace-tabs/agent-visibility.test.ts index bece625a89..3b16c6b8d5 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.test.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts @@ -61,7 +61,7 @@ function makeAgent(input: { const WORKSPACE_ID = "ws-1"; describe("workspace agent visibility", () => { - it("keeps subagents active and known while excluding them from auto-open", () => { + it("keeps subagents active while excluding them from auto-open", () => { const parent = makeAgent({ id: "parent-agent", cwd: "/repo/worktree", @@ -84,10 +84,9 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["parent-agent", "child-agent"])); }); - it("keeps archived subagents known but excludes them from active and auto-open", () => { + it("excludes archived subagents from active and auto-open", () => { const archivedChild = makeAgent({ id: "archived-child", cwd: "/repo/worktree", @@ -103,7 +102,6 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set()); expect(result.autoOpenAgentIds).toEqual(new Set()); - expect(result.knownAgentIds).toEqual(new Set(["archived-child"])); }); it("excludes a child from auto-open even when its snapshot arrives before the parent", () => { @@ -129,7 +127,6 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set(["child-agent", "parent-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["child-agent", "parent-agent"])); }); it("auto-opens a subagent whose parent belongs to another workspace", () => { @@ -155,10 +152,9 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set(["child-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["child-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["child-agent"])); }); - it("keeps archived agents out of activeAgentIds but present in knownAgentIds", () => { + it("excludes archived agents from the active directory", () => { const visible = makeAgent({ id: "visible-agent", cwd: "/repo/worktree", @@ -191,12 +187,9 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set(["visible-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["visible-agent"])); - expect(result.knownAgentIds.has("visible-agent")).toBe(true); - expect(result.knownAgentIds.has("archived-agent")).toBe(true); - expect(result.knownAgentIds.has("other-workspace-agent")).toBe(false); }); - it("treats lazy historical details as known without making them active", () => { + it("does not make historical details active", () => { const active = makeAgent({ id: "active-agent", cwd: "/repo/worktree", @@ -216,7 +209,6 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set(["active-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["active-agent", "historical-agent"])); }); it("prunes archived agent tabs so archiving on one client closes tabs on all clients", () => { @@ -281,7 +273,6 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set(["stamped-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["stamped-agent"])); }); it("excludes a stamped agent whose workspaceId belongs to another workspace sharing the cwd", () => { @@ -302,7 +293,6 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set()); - expect(result.knownAgentIds).toEqual(new Set()); }); it("excludes agents without a workspaceId", () => { @@ -316,14 +306,12 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set()); - expect(result.knownAgentIds).toEqual(new Set()); }); it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => { const agentVisibility = { activeAgentIds: new Set(["active-agent"]), autoOpenAgentIds: new Set(["root-agent"]), - knownAgentIds: new Set(["active-agent", "archived-agent"]), }; expect( @@ -341,7 +329,6 @@ describe("workspace agent visibility", () => { terminalsHydrated: true, activeAgentIds: agentVisibility.activeAgentIds, autoOpenAgentIds: agentVisibility.autoOpenAgentIds, - knownAgentIds: agentVisibility.knownAgentIds, knownTerminalIds: ["terminal-1", "script-terminal"], standaloneTerminalIds: ["terminal-1"], hasActivePendingTerminalCreate: false, @@ -354,12 +341,10 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a", "b", "c"]), }; const b = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a", "b", "c"]), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); }); @@ -368,12 +353,10 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a"]), autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a"]), }; const b = { activeAgentIds: new Set(["b"]), autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a"]), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); }); @@ -382,26 +365,10 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a", "b"]), }; const b = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["b"]), - knownAgentIds: new Set(["a", "b"]), - }; - expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); - }); - - it("returns false when knownAgentIds differ", () => { - const a = { - activeAgentIds: new Set(["a"]), - autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a"]), - }; - const b = { - activeAgentIds: new Set(["a"]), - autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a", "b"]), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); }); @@ -410,12 +377,10 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), - knownAgentIds: new Set(), }; const b = { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), - knownAgentIds: new Set(), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); }); diff --git a/packages/app/src/workspace-tabs/agent-visibility.ts b/packages/app/src/workspace-tabs/agent-visibility.ts index 1f8f5c5ed4..399b786ef6 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.ts @@ -6,7 +6,6 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; export interface WorkspaceAgentVisibility { activeAgentIds: Set; autoOpenAgentIds: Set; - knownAgentIds: Set; } function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean { @@ -24,13 +23,11 @@ export function deriveWorkspaceAgentVisibility(input: { return { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), - knownAgentIds: new Set(), }; } const activeAgentIds = new Set(); const autoOpenAgentIds = new Set(); - const knownAgentIds = new Set(); const agentsById = new Map([ ...(agentDetails?.entries() ?? []), ...(sessionAgents?.entries() ?? []), @@ -39,7 +36,6 @@ export function deriveWorkspaceAgentVisibility(input: { if (!agentBelongsToWorkspace(agent, workspaceId)) { continue; } - knownAgentIds.add(agent.id); if (!agent.archivedAt) { activeAgentIds.add(agent.id); const parentAgent = agent.parentAgentId ? agentsById.get(agent.parentAgentId) : undefined; @@ -48,14 +44,7 @@ export function deriveWorkspaceAgentVisibility(input: { } } } - for (const agent of agentDetails?.values() ?? []) { - if (!agentBelongsToWorkspace(agent, workspaceId)) { - continue; - } - knownAgentIds.add(agent.id); - } - - return { activeAgentIds, autoOpenAgentIds, knownAgentIds }; + return { activeAgentIds, autoOpenAgentIds }; } export function buildWorkspaceTabSnapshot(input: { @@ -72,7 +61,6 @@ export function buildWorkspaceTabSnapshot(input: { terminalsHydrated: input.terminalsHydrated, activeAgentIds: input.agentVisibility.activeAgentIds, autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds, - knownAgentIds: input.agentVisibility.knownAgentIds, knownTerminalIds: input.knownTerminalIds, standaloneTerminalIds: input.standaloneTerminalIds, hasActivePendingTerminalCreate: input.hasActivePendingTerminalCreate, @@ -86,8 +74,7 @@ export function workspaceAgentVisibilityEqual( ): boolean { return ( setsEqual(a.activeAgentIds, b.activeAgentIds) && - setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) && - setsEqual(a.knownAgentIds, b.knownAgentIds) + setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) ); } diff --git a/packages/server/src/server/agent/agent-loading.test.ts b/packages/server/src/server/agent/agent-loading.test.ts index e4943a079d..0f58d94c62 100644 --- a/packages/server/src/server/agent/agent-loading.test.ts +++ b/packages/server/src/server/agent/agent-loading.test.ts @@ -69,7 +69,7 @@ test("loads archived records for history and active records with the interactive await ensureAgentLoaded(archived.id, { agentManager: manager, agentStorage: storage, logger }); await ensureAgentLoaded(active.id, { agentManager: manager, agentStorage: storage, logger }); - expect(resumeOptions).toEqual([{ purpose: "history" }, undefined]); + expect(resumeOptions).toEqual([{ purpose: "history" }, { purpose: "interactive" }]); } finally { await Promise.all([ manager.closeAgent(archivedId).catch(() => undefined), diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index f4d4069d26..3ea6413561 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -37,6 +37,7 @@ import type { AgentProvider, AgentPersistenceHandle, AgentRunOptions, + AgentResumeSessionOptions, AgentRunResult, AgentSession, AgentSessionConfig, @@ -5095,14 +5096,14 @@ test("archiveSnapshot clears persisted attention and normalizes running status", const archivedRecord = await manager.archiveSnapshot(snapshot.id, archivedAt); expect(archivedRecord.archivedAt).toBe(archivedAt); - expect(archivedRecord.lastStatus).toBe("idle"); + expect(archivedRecord.lastStatus).toBe("closed"); expect(archivedRecord.requiresAttention).toBe(false); expect(archivedRecord.attentionReason).toBeNull(); expect(archivedRecord.attentionTimestamp).toBeNull(); const persisted = await storage.get(snapshot.id); expect(persisted?.archivedAt).toBe(archivedAt); - expect(persisted?.lastStatus).toBe("idle"); + expect(persisted?.lastStatus).toBe("closed"); expect(persisted?.requiresAttention).toBe(false); expect(persisted?.attentionReason).toBeNull(); expect(persisted?.attentionTimestamp).toBeNull(); @@ -9616,7 +9617,7 @@ test("ensureUnarchivedAgentLoaded does not resume an archived agent", async () = } }); -test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", async () => { +test("a stored-only archive waits for an in-flight resume and then closes it", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-resume-race-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const resumeStarted = deferred(); @@ -9640,16 +9641,17 @@ test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", a }); await manager.closeAgent(agent.id); - const load = ensureUnarchivedAgentLoaded(agent.id, { + const load = ensureAgentLoaded(agent.id, { agentManager: manager, agentStorage: storage, logger, }); await resumeStarted.promise; - await manager.archiveSnapshot(agent.id, new Date().toISOString()); + const archive = manager.archiveSnapshot(agent.id, new Date().toISOString()); resumeAllowed.resolve(); + await archive; - await expect(load).rejects.toThrow(`Agent is archived: ${agent.id}`); + await expect(load).resolves.toMatchObject({ id: agent.id }); expect(manager.getAgent(agent.id)).toBeNull(); expect((await storage.get(agent.id))?.archivedAt).toEqual(expect.any(String)); } finally { @@ -9659,7 +9661,7 @@ test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", a } }); -test("ensureUnarchivedAgentLoaded fences an archived agent after joining a shared resume", async () => { +test("a stored-only archive closes a shared resume before a later protected load returns", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-shared-resume-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const resumeStarted = deferred(); @@ -9694,8 +9696,9 @@ test("ensureUnarchivedAgentLoaded fences an archived agent after joining a share agentStorage: storage, logger, }); - await manager.archiveSnapshot(agent.id, new Date().toISOString()); + const archive = manager.archiveSnapshot(agent.id, new Date().toISOString()); resumeAllowed.resolve(); + await archive; await sharedLoad; await expect(protectedLoad).rejects.toThrow(`Agent is archived: ${agent.id}`); @@ -9933,7 +9936,7 @@ test("concurrent explicit closes tear down the runtime once", async () => { } }); -test("provider close failure still persists and emits a resumable closed agent", async () => { +test("provider close failure retains the runtime for cleanup instead of allowing another writer", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-failure-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const client = new (class extends TestAgentClient { @@ -9953,12 +9956,10 @@ test("provider close failure still persists and emits a resumable closed agent", "00000000-0000-4000-8000-000000000217", { workspaceId: undefined }, ); - const closed = waitForAgentLifecycle(manager, created.id, "closed"); - await expect(manager.closeAgent(created.id)).rejects.toThrow("provider cleanup failed"); - await closed; + expect(manager.getAgent(created.id)?.session).toBe(created.session); const stored = await storage.get(created.id); - expect(stored).toMatchObject({ lastStatus: "closed" }); + expect(stored).toMatchObject({ lastStatus: "idle" }); expect(stored?.archivedAt).toBeFalsy(); await expect( @@ -10973,3 +10974,54 @@ test("onWorkspaceStateMayHaveChanged is not called for running shell tool calls" expect(onWorkspaceStateMayHaveChanged).not.toHaveBeenCalled(); }); + +test("concurrent native restores run once before resuming the same agent", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-unarchive-ownership-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const restoreStarted = deferred(); + const restoreAllowed = deferred(); + const operations: string[] = []; + const client = new (class extends TestAgentClient { + async unarchiveNativeSession(): Promise { + operations.push("restore"); + restoreStarted.resolve(); + await restoreAllowed.promise; + } + override async resumeSession( + handle: AgentPersistenceHandle, + config?: Partial, + _launchContext?: AgentLaunchContext, + options?: AgentResumeSessionOptions, + ): Promise { + operations.push(`resume:${options?.purpose}`); + return super.resumeSession(handle, config); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + let agentId: string | undefined; + try { + const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = created.id; + await manager.archiveAgent(agentId); + const first = manager.unarchiveSnapshot(agentId); + await restoreStarted.promise; + const second = manager.unarchiveSnapshot(agentId); + const load = ensureAgentLoaded(agentId, { + agentManager: manager, + agentStorage: storage, + logger, + }); + restoreAllowed.resolve(); + await Promise.all([first, second, load]); + expect(operations).toEqual(["restore", "resume:interactive"]); + expect((await storage.get(agentId))?.archivedAt).toBeNull(); + expect(manager.getAgent(agentId)?.persistence?.sessionId).toBe(created.persistence?.sessionId); + } finally { + restoreAllowed.resolve(); + if (agentId) await manager.closeAgent(agentId); + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 70b4e62dbc..adbb25bf34 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1282,8 +1282,20 @@ export class AgentManager { }, resumeOptions?: AgentResumeSessionOptions, ): Promise { + const resolvedAgentId = validateAgentId( + agentId ?? this.idFactory(), + "resumeAgentFromPersistence", + ); return this.trackAgentRegistrationOperation( - this.resumeAgentFromPersistenceInternal(handle, overrides, agentId, options, resumeOptions), + this.runLifecycleMutation(resolvedAgentId, () => + this.resumeAgentFromPersistenceInternal( + handle, + overrides, + resolvedAgentId, + options, + resumeOptions, + ), + ), ); } @@ -1317,6 +1329,12 @@ export class AgentManager { resolvedAgentId, ); + // Decide residency from durable state inside the lifecycle lane. A loader may + // have read the record before a queued archive or restore completed. + const record = this.registry ? await this.registry.get(resolvedAgentId) : null; + const currentResumeOptions = record + ? { purpose: record.archivedAt ? ("history" as const) : ("interactive" as const) } + : resumeOptions; const client = this.requireClient(handle.provider); const available = await client.isAvailable(); if (!available) { @@ -1333,7 +1351,7 @@ export class AgentManager { undefined, { reason: "resume", - purpose: resumeOptions?.purpose ?? "interactive", + purpose: currentResumeOptions?.purpose ?? "interactive", workspaceId: options?.workspaceId ?? null, }, ); @@ -1342,7 +1360,7 @@ export class AgentManager { handle, providerLaunchConfig, launchContext, - resumeOptions, + currentResumeOptions, ); await this.requireExternalMcpSupport(session, storedConfig); return this.registerSession(session, storedConfig, resolvedAgentId, { @@ -1649,14 +1667,11 @@ export class AgentManager { "agent.manager.close.start", ); await this.drainSessionEvents(agentId); + // Retain ownership until shutdown succeeds. A failed close may still own a + // native writer, so publishing a resumable closed snapshot would orphan it. + await agent.session.close(); this.cancelRunningProviderSubagents(agentId); const closedAgent = this.prepareAgentForClosure(agent, "agent closed"); - let closeError: unknown; - try { - await agent.session.close(); - } catch (error) { - closeError = error; - } let persistError: unknown; try { @@ -1674,9 +1689,6 @@ export class AgentManager { "agent.manager.close.complete", ); - if (closeError !== undefined) { - throw closeError; - } if (persistError !== undefined) { throw persistError; } @@ -1700,7 +1712,10 @@ export class AgentManager { return this.runLifecycleMutation(agentId, () => this.archiveAgentUnlocked(agentId)); } - private async archiveAgentUnlocked(agentId: string): Promise<{ archivedAt: string }> { + private async archiveAgentUnlocked( + agentId: string, + requestedArchivedAt?: string, + ): Promise<{ archivedAt: string }> { const agent = this.requireAgent(agentId); if (!this.registry) { throw new Error("Agent storage is not configured"); @@ -1714,7 +1729,7 @@ export class AgentManager { throw new Error(`Agent ${agentId} not found in storage after snapshot`); } - const { archivedAt } = await this.markRecordArchived(stored); + const { archivedAt } = await this.markRecordArchived(stored, requestedArchivedAt); agent.updatedAt = new Date(archivedAt); await this.closeAgentRuntime(agentId); await this.syncNativeArchiveState(stored.provider, stored.persistence, "archive"); @@ -1764,14 +1779,16 @@ export class AgentManager { } else if (this.agents.has(currentChild.id)) { await this.archiveAgentUnlocked(currentChild.id); } else { - await this.archiveSnapshot(currentChild.id, new Date().toISOString()); + await this.archiveSnapshotUnlocked(currentChild.id, new Date().toISOString()); } }); } } - private async markRecordArchived(record: StoredAgentRecord): Promise { - const archivedAt = new Date().toISOString(); + private async markRecordArchived( + record: StoredAgentRecord, + archivedAt = new Date().toISOString(), + ): Promise { const archivedRecord = await this.persistArchivedRecord(record, { archivedAt, updatedAt: archivedAt, @@ -2109,12 +2126,23 @@ export class AgentManager { } async archiveSnapshot(agentId: string, archivedAt: string): Promise { + return this.runLifecycleMutation(agentId, () => + this.archiveSnapshotUnlocked(agentId, archivedAt), + ); + } + + private async archiveSnapshotUnlocked( + agentId: string, + archivedAt: string, + ): Promise { const registry = this.requireRegistry(); - const liveAgent = this.getAgent(agentId); - if (liveAgent) { - await this.persistSnapshot(liveAgent, { - internal: liveAgent.internal, - }); + // A stored-only archive can have waited behind a persisted resume. Reuse the + // live archive transition so its newly acquired runtime is closed as well. + if (this.agents.has(agentId)) { + await this.archiveAgentUnlocked(agentId, archivedAt); + const archivedRecord = await registry.get(agentId); + if (!archivedRecord) throw new Error(`Agent not found: ${agentId}`); + return archivedRecord; } const record = await registry.get(agentId); @@ -2126,14 +2154,8 @@ export class AgentManager { await this.syncNativeArchiveState(record.provider, record.persistence, "archive"); - if (this.agents.has(agentId)) { - this.notifyAgentState(agentId); - } else { - this.discardRetainedAgentState(agentId); - if (!nextRecord.internal) { - this.dispatchStoredAgentState(nextRecord); - } - } + this.discardRetainedAgentState(agentId); + if (!nextRecord.internal) this.dispatchStoredAgentState(nextRecord); await this.fireAgentArchived(agentId); await this.cascadeArchiveChildren(agentId); @@ -2144,6 +2166,15 @@ export class AgentManager { async unarchiveSnapshot( agentId: string, updates?: { workspaceId?: string; labels?: AgentLabelPatch }, + ): Promise { + return this.runLifecycleMutation(agentId, () => + this.unarchiveSnapshotUnlocked(agentId, updates), + ); + } + + private async unarchiveSnapshotUnlocked( + agentId: string, + updates?: { workspaceId?: string; labels?: AgentLabelPatch }, ): Promise { const registry = this.requireRegistry(); const record = await registry.get(agentId); @@ -2151,8 +2182,9 @@ export class AgentManager { return false; } - // Archived history may have loaded a runtime that still owns the native writer. - await this.closeAgent(agentId); + // Close and native restore share the lifecycle lane with persisted resume. + // No new history or interactive runtime can acquire the writer between them. + if (this.agents.has(agentId)) await this.closeAgentRuntime(agentId); await this.syncNativeArchiveState(record.provider, record.persistence, "restore"); await registry.upsert({ 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 15e7cfdf54..b86418814d 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 @@ -111,7 +111,7 @@ interface CodexSessionTestAccess { ensureThreadLoaded(): Promise; handleToolApprovalRequest(params: unknown): Promise; handleNotification(method: string, params: unknown): void; - loadPersistedHistory(): Promise; + loadPersistedHistory(client: CodexClientLike | null): Promise; refreshResolvedCollaborationMode(): void; serviceTier: "fast" | null; planModeEnabled: boolean; @@ -124,7 +124,7 @@ interface CodexClientLike { } type CodexTestSession = AgentSession & { - connected: boolean; + connectionState: "disconnected" | "history-ready" | "connected"; currentThreadId: string | null; activeForegroundTurnId: string | null; client: CodexClientLike | null; @@ -165,7 +165,7 @@ function createSession( options.goalsEnabled === true, options.autoReviewEnabled === true, ) as CodexTestSession; - session.connected = true; + session.connectionState = "connected"; session.currentThreadId = "test-thread"; session.activeForegroundTurnId = "test-turn"; return session; @@ -1435,7 +1435,7 @@ describe("Codex app-server provider", () => { purpose: "history", }); - expect(threadRequests).toEqual(["thread/loaded/list", "thread/resume", "thread/read"]); + expect(threadRequests).toEqual(["thread/read"]); await session.close(); appServer.assertNoErrors(); }); @@ -3931,7 +3931,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { @@ -3985,7 +3985,7 @@ describe("Codex app-server provider", () => { })), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); expect(asInternals(session).codexUserMessageTurns().resolve("message-history")).toEqual({ index: 0, @@ -4054,7 +4054,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { @@ -4216,7 +4216,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { @@ -4281,7 +4281,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { @@ -4455,7 +4455,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { @@ -4516,7 +4516,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { 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 7123db17cc..5e2cb3795a 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 @@ -3371,7 +3371,7 @@ export class CodexAppServerAgentSession implements AgentSession { // `thread/compacted` and a completed `contextCompaction` item. private unpairedCompactionNotificationCompletions = 0; private unpairedCompactionItemCompletions = 0; - private connected = false; + private connectionState: "disconnected" | "history-ready" | "connected" = "disconnected"; private connectionPromise: Promise | null = null; private closed = false; private collaborationModes: Array<{ @@ -3444,7 +3444,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (this.closed) { throw this.createClosedError(); } - if (this.connected) return; + if (this.connectionState !== "disconnected") return; if (this.connectionPromise) { await this.connectionPromise; if (this.closed) { @@ -3468,6 +3468,11 @@ export class CodexAppServerAgentSession implements AgentSession { } private async establishConnection(): Promise { + if (this.initialResumePurpose === "history") { + await this.readArchivedHistory(); + this.connectionState = "history-ready"; + return; + } const child = await this.spawnAppServer(); const client = new CodexAppServerClient(child, this.logger, () => this.traceContext()); if (this.closed) { @@ -3490,16 +3495,14 @@ export class CodexAppServerAgentSession implements AgentSession { await this.loadSkills(); if (this.currentThreadId) { - await this.ensureThreadLoaded({ - allowArchivedHistory: this.initialResumePurpose === "history", - }); - await this.loadPersistedHistory(); + await this.ensureThreadLoaded(); + await this.loadPersistedHistory(this.client); } if (this.closed) { throw this.createClosedError(); } - this.connected = true; + this.connectionState = "connected"; } catch (error) { try { if (this.client === client) { @@ -3550,7 +3553,7 @@ export class CodexAppServerAgentSession implements AgentSession { } private handleUnexpectedTermination(error: Error): void { - this.connected = false; + this.connectionState = "disconnected"; const hasActiveRootTurn = this.activeForegroundTurnId !== null || this.currentTurnId !== null; this.clearPendingPermissions({ preservePlanApprovals: !hasActiveRootTurn }); if (hasActiveRootTurn) { @@ -3782,9 +3785,20 @@ export class CodexAppServerAgentSession implements AgentSession { ); } - private async loadPersistedHistory(): Promise { - if (!this.client || !this.currentThreadId) return; - const client = this.client; + private async readArchivedHistory(): Promise { + const child = await this.spawnAppServer(); + const client = new CodexAppServerClient(child, this.logger, () => this.traceContext()); + try { + await client.request("initialize", buildCodexAppServerInitializeParams()); + client.notify("initialized", {}); + await this.loadPersistedHistory(client); + } finally { + await client.dispose(); + } + } + + private async loadPersistedHistory(client: CodexAppServerClientLike | null): Promise { + if (!client || !this.currentThreadId) return; const threadId = this.currentThreadId; const history = await loadCodexThreadHistoryTimeline({ @@ -3865,9 +3879,7 @@ export class CodexAppServerAgentSession implements AgentSession { } } - private async ensureThreadLoaded( - options: { allowArchivedHistory?: boolean } = {}, - ): Promise { + private async ensureThreadLoaded(): Promise { if (!this.client || !this.currentThreadId) return; const params: Record = { threadId: this.currentThreadId }; const developerInstructions = composeSystemPromptParts( @@ -3892,16 +3904,6 @@ export class CodexAppServerAgentSession implements AgentSession { } catch (error) { const threadId = this.currentThreadId; const message = error instanceof Error ? error.message : String(error); - if ( - options.allowArchivedHistory === true && - isArchivedCodexThreadResumeError(error, threadId) - ) { - this.logger.info( - { threadId }, - "Loading archived Codex thread history without resuming the native session", - ); - return; - } if (isArchivedCodexThreadResumeError(error, threadId)) { try { await this.client.request("thread/unarchive", { threadId }); @@ -3975,7 +3977,7 @@ export class CodexAppServerAgentSession implements AgentSession { return expandCodexCustomPrompt(parsed.body, args); } - if (!this.connected) { + if (this.connectionState === "disconnected") { await this.connect(); } else { await this.loadSkills(); @@ -4397,7 +4399,7 @@ export class CodexAppServerAgentSession implements AgentSession { async getRuntimeInfo(): Promise { if (this.cachedRuntimeInfo) return { ...this.cachedRuntimeInfo }; - if (!this.connected) { + if (this.connectionState === "disconnected") { await this.connect(); } if (!this.currentThreadId) { @@ -4770,7 +4772,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.cachedRuntimeInfo = null; this.persistedHistory = []; this.historyPending = false; - await this.loadPersistedHistory(); + await this.loadPersistedHistory(this.client); this.reconcileAsyncQuestionsAfterRewind(); }, }); @@ -4859,7 +4861,7 @@ export class CodexAppServerAgentSession implements AgentSession { private async disposeClient(): Promise { const client = this.client; - this.connected = false; + this.connectionState = "disconnected"; this.currentTurnId = null; if (client) { await client.dispose(); @@ -4869,7 +4871,7 @@ export class CodexAppServerAgentSession implements AgentSession { async listCommands(): Promise { const prompts = await listCodexCustomPrompts(); - if (!this.connected) { + if (this.connectionState === "disconnected") { await this.connect(); } else { await this.loadSkills(); diff --git a/packages/server/src/server/agent/providers/codex-history-lifecycle.real.e2e.test.ts b/packages/server/src/server/agent/providers/codex-history-lifecycle.real.e2e.test.ts new file mode 100644 index 0000000000..0d14f29930 --- /dev/null +++ b/packages/server/src/server/agent/providers/codex-history-lifecycle.real.e2e.test.ts @@ -0,0 +1,151 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { z } from "zod"; +import { CodexAppServerClient } from "./codex/app-server-transport.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { CodexAppServerAgentClient, CodexAppServerAgentSession } from "./codex-app-server-agent.js"; +import type { AgentSession } from "../agent-sdk-types.js"; + +async function readNativeThreadPath(threadId: string): Promise { + const client = new CodexAppServerClient( + spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] }), + createTestLogger(), + ); + try { + await client.request("initialize", { + clientInfo: { name: "paseo-archive-regression", version: "1.0.0" }, + }); + client.notify("initialized", {}); + const response = await client.request("thread/read", { threadId }); + return z.object({ thread: z.object({ path: z.string() }) }).parse(response).thread.path; + } finally { + await client.dispose(); + } +} + +// Real native processes and a real completion. The second case represents records +// archived by older Paseo versions whose best-effort native archive failed. +test.runIf(process.env.PASEO_NATIVE_ARCHIVE_QA === "1").each([true, false])( + "Codex history releases its process and leaves native archive unchanged (native archived: %s)", + async (nativeArchived) => { + const cwd = await mkdtemp(path.join(tmpdir(), "codex-history-lifecycle-")); + const logger = createTestLogger(); + const provider = new CodexAppServerAgentClient(logger); + const spawned: ChildProcessWithoutNullStreams[] = []; + let active: AgentSession | undefined; + let history: CodexAppServerAgentSession | undefined; + try { + const catalog = await provider.fetchCatalog({ scope: "global", force: false }); + const model = catalog.models.find((entry) => entry.isDefault)!; + const config = { + provider: "codex", + cwd, + model: model.id, + modeId: "full-access", + thinkingOptionId: "low", + }; + const initialProcesses: ChildProcessWithoutNullStreams[] = []; + const initial = new CodexAppServerAgentSession(config, null, logger, async () => { + const child = spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] }); + initialProcesses.push(child); + return child; + }); + active = initial; + await initial.connect(); + const reply = await active.run( + "Reply with exactly HISTORY_REMAINS_READ_ONLY. Do not use tools.", + ); + expect(reply.finalText).toContain("HISTORY_REMAINS_READ_ONLY"); + const handle = active.describePersistence()!; + await active.close(); + active = undefined; + expect( + initialProcesses.every((child) => child.exitCode !== null || child.signalCode !== null), + ).toBe(true); + if (nativeArchived) await provider.archiveNativeSession(handle); + + const nativePath = await readNativeThreadPath(handle.sessionId); + expect(nativePath.includes(`${path.sep}archived_sessions${path.sep}`)).toBe(nativeArchived); + history = new CodexAppServerAgentSession( + config, + handle, + logger, + async () => { + const child = spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] }); + spawned.push(child); + return child; + }, + {}, + false, + false, + false, + undefined, + "history", + ); + await history.connect(); + const text: string[] = []; + for await (const event of history.streamHistory()) { + if (event.type === "timeline" && event.item.type === "assistant_message") + text.push(event.item.text); + } + expect(text.join("\n")).toContain("HISTORY_REMAINS_READ_ONLY"); + expect(spawned).toHaveLength(1); + expect( + spawned.filter((child) => child.exitCode === null && child.signalCode === null), + ).toEqual([]); + + expect(await readNativeThreadPath(handle.sessionId)).toBe(nativePath); + + // Native unarchive is possible after reading history; history held no writer. + await provider.unarchiveNativeSession(handle); + active = await provider.resumeSession(handle, config); + expect(active.describePersistence()?.sessionId).toBe(handle.sessionId); + const followUp = await active.run( + "Repeat exactly your previous assistant reply from this conversation. Do not use tools.", + ); + expect(followUp.finalText).toContain("HISTORY_REMAINS_READ_ONLY"); + expect(active.describePersistence()?.sessionId).toBe(handle.sessionId); + } finally { + await history?.close(); + await active?.close(); + await rm(cwd, { recursive: true, force: true }); + } + }, + 120_000, +); + +test.runIf(process.env.PASEO_NATIVE_ARCHIVE_QA === "1")( + "a failed Codex history read releases its temporary process", + async () => { + const spawned: ChildProcessWithoutNullStreams[] = []; + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: tmpdir() }, + { sessionId: "00000000-0000-4000-8000-000000000001" }, + createTestLogger(), + async () => { + const child = spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] }); + spawned.push(child); + return child; + }, + {}, + false, + false, + false, + undefined, + "history", + ); + try { + await expect(session.connect()).rejects.toThrow(); + expect(spawned).toHaveLength(1); + expect(spawned.every((child) => child.exitCode !== null || child.signalCode !== null)).toBe( + true, + ); + } finally { + await session.close(); + } + }, + 30_000, +); From 2d7733cd6514a4b3422870163b749395ca2634e6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 12 Sep 2026 08:49:25 +0200 Subject: [PATCH 02/17] fix(app): keep incomplete Markdown formatted while streaming (#4742) * fix(app): keep incomplete Markdown formatted while streaming Treat unfinished formatting at the growing assistant-message tail as provisional so delimiters and link destinations do not flash while tokens arrive. Completed messages retain ordinary Markdown parsing. * test(app): express streaming Markdown checks as user actions --- .../e2e/browser/streaming-markdown.spec.ts | 21 +++ .../e2e/support/helpers/streaming-markdown.ts | 81 ++++++++++ packages/app/src/components/message.tsx | 10 +- .../utils/assistant-markdown-parser.test.ts | 141 ++++++++++++++++ .../src/utils/assistant-markdown-parser.ts | 7 +- .../app/src/utils/streaming-markdown/index.ts | 153 ++++++++++++++++++ 6 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 packages/app/e2e/browser/streaming-markdown.spec.ts create mode 100644 packages/app/e2e/support/helpers/streaming-markdown.ts create mode 100644 packages/app/src/utils/streaming-markdown/index.ts diff --git a/packages/app/e2e/browser/streaming-markdown.spec.ts b/packages/app/e2e/browser/streaming-markdown.spec.ts new file mode 100644 index 0000000000..00aa16e7cc --- /dev/null +++ b/packages/app/e2e/browser/streaming-markdown.spec.ts @@ -0,0 +1,21 @@ +import { test } from "../support/fixtures"; +import { + expectFinishedMarkdown, + expectReloadedMarkdown, + expectUnfinishedBold, + expectUnfinishedLink, + requestStreamingMarkdown, + withStreamingMarkdown, +} from "../support/helpers/streaming-markdown"; + +test("formats unfinished Markdown while streaming and preserves the completed rendering", async ({ + page, +}, testInfo) => { + await withStreamingMarkdown(page, testInfo, async (agent) => { + await requestStreamingMarkdown(agent); + await expectUnfinishedBold(page); + await expectUnfinishedLink(page, testInfo); + await expectFinishedMarkdown(page, agent, testInfo); + await expectReloadedMarkdown(page); + }); +}); diff --git a/packages/app/e2e/support/helpers/streaming-markdown.ts b/packages/app/e2e/support/helpers/streaming-markdown.ts new file mode 100644 index 0000000000..144b706f5d --- /dev/null +++ b/packages/app/e2e/support/helpers/streaming-markdown.ts @@ -0,0 +1,81 @@ +import { expect, type Page, type TestInfo } from "@playwright/test"; +import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; + +export async function withStreamingMarkdown( + page: Page, + testInfo: TestInfo, + run: (agent: MockAgentWorkspace) => Promise, +): Promise { + testInfo.setTimeout(120_000); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "streaming-markdown-", + title: "Streaming Markdown", + featureValues: { + mockStreamingAssistantResponse: + "**Bold text stays bold** and [Paseo docs](https://example.com/documentation). Done.", + mockStreamingAssistantIntervalMs: 400, + }, + }); + try { + await openAgentRoute(page, agent); + await run(agent); + } finally { + await agent.cleanup(); + } +} + +export async function requestStreamingMarkdown(agent: MockAgentWorkspace): Promise { + await agent.client.sendAgentMessage(agent.agentId, "Show the formatted streaming response."); +} + +export async function expectUnfinishedBold(page: Page): Promise { + const message = page.getByTestId("assistant-message").last(); + const bold = message.locator('[data-paseo-markdown-tag="strong"]'); + await expect(bold).toContainText("Bold"); + await expect(message).not.toContainText("stays bold"); + await expect(bold).toHaveCSS("font-weight", "500"); + await expect(message).not.toContainText("*"); +} + +export async function expectUnfinishedLink(page: Page, testInfo: TestInfo): Promise { + const message = page.getByTestId("assistant-message").last(); + await expect(message).toContainText("Paseo docs"); + await expect(message.getByRole("link", { name: "Paseo docs" })).toHaveCount(0); + await expect(message).not.toContainText("["); + await expect(message).not.toContainText("https:"); + await captureMarkdown(page, testInfo, "unfinished-link"); +} + +export async function expectFinishedMarkdown( + page: Page, + agent: MockAgentWorkspace, + testInfo: TestInfo, +): Promise { + await agent.client.waitForFinish(agent.agentId, 30_000); + await expectCompletedMarkdown(page); + await captureMarkdown(page, testInfo, "completed-markdown"); +} + +export async function expectReloadedMarkdown(page: Page): Promise { + await page.reload({ waitUntil: "domcontentloaded" }); + await expectCompletedMarkdown(page); +} + +async function captureMarkdown(page: Page, testInfo: TestInfo, name: string): Promise { + await testInfo.attach(name, { + body: await page.screenshot({ path: testInfo.outputPath(`${name}.png`) }), + contentType: "image/png", + }); +} + +async function expectCompletedMarkdown(page: Page): Promise { + const message = page.getByTestId("assistant-message").last(); + await expect(message).toHaveText("Bold text stays bold and Paseo docs. Done."); + await expect( + message.getByRole("link", { name: "Paseo docs" }).and(message.locator("a")), + ).toHaveAttribute("href", "https://example.com/documentation"); + await expect(message.locator('[data-paseo-markdown-tag="strong"]')).toHaveCSS( + "font-weight", + "500", + ); +} diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 830eb265ea..a8a56dee89 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1501,6 +1501,10 @@ export const AssistantMessage = memo(function AssistantMessage({ }: AssistantMessageProps) { const { t } = useTranslation(); const markdownParser = useMemo(createAssistantMarkdownParser, []); + const streamingMarkdownParser = useMemo( + () => createAssistantMarkdownParser({ streaming: true }), + [], + ); const renderedMessage = useMemo(() => capAssistantMessageForRender(message), [message]); // Paint a paced prefix while the turn is streaming so text arrives at a steady // rate instead of in whatever lumps the daemon's coalescing window produced. @@ -1982,7 +1986,11 @@ export const AssistantMessage = memo(function AssistantMessage({ diff --git a/packages/app/src/utils/assistant-markdown-parser.test.ts b/packages/app/src/utils/assistant-markdown-parser.test.ts index 70d806a1fb..05b1366e1c 100644 --- a/packages/app/src/utils/assistant-markdown-parser.test.ts +++ b/packages/app/src/utils/assistant-markdown-parser.test.ts @@ -2,6 +2,147 @@ import { describe, expect, it } from "vitest"; import { createAssistantMarkdownParser } from "./assistant-markdown-parser"; describe("createAssistantMarkdownParser", () => { + it("keeps bold text bold through every partial closing marker", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + + for (const source of ["**bold", "**bold*", "**bold**"]) { + expect(parser.renderInline(source)).toBe("bold"); + } + }); + + it("shows the growing link label and only links a complete destination", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + const source = "[docs](https://example.com/path)"; + for (let length = 1; length < source.length; length++) { + expect(parser.renderInline(source.slice(0, length))).toBe( + "docs".slice(0, Math.max(0, length - 1)), + ); + } + expect(parser.renderInline(source)).toBe('docs'); + }); + + it.each([ + ["**", "strong"], + ["__", "strong"], + ["*", "em"], + ["_", "em"], + ["~~", "s"], + ["`", "code"], + ["``", "code"], + ])("keeps %s formatting stable as text and closing markers arrive", (marker, tag) => { + const parser = createAssistantMarkdownParser({ streaming: true }); + for (let length = 1; length <= 4; length++) { + expect(parser.renderInline(marker + "text".slice(0, length))).toBe( + `<${tag}>${"text".slice(0, length)}`, + ); + } + for (let length = 0; length <= marker.length; length++) { + expect(parser.renderInline(marker + "text" + marker.slice(0, length))).toBe( + `<${tag}>text`, + ); + } + }); + + it("keeps combined and nested emphasis stable through closing markers", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + for (const closing of ["", "*", "**", "***"]) { + expect(parser.renderInline("***both" + closing)).toBe("both"); + } + expect(parser.renderInline("**bold and *italic")).toBe( + "bold and italic", + ); + expect(parser.renderInline("*italic and **bold")).toBe( + "italic and bold", + ); + }); + + it.each(["*", "**", "***", "_", "__", "~~", "`", "``"])( + "hides an opening %s while waiting for its text", + (marker) => { + expect( + createAssistantMarkdownParser({ streaming: true }).renderInline("hello " + marker), + ).toBe("hello "); + }, + ); + + it.each([ + "[docs](https://example.com/a(b)c)", + '[docs](https://example.com "a title)")', + "[docs]()", + "[docs](file:///tmp/example.ts)", + ])("waits for the entire destination of %s", (source) => { + const parser = createAssistantMarkdownParser({ streaming: true }); + for (let length = 6; length < source.length; length++) { + expect(parser.renderInline(source.slice(0, length))).toBe("docs"); + } + expect(parser.renderInline(source)).toBe(createAssistantMarkdownParser().renderInline(source)); + }); + + it("preserves formatting in incomplete labels and around incomplete links", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + expect(parser.renderInline("[**bold")).toBe("bold"); + expect(parser.renderInline("[**bold**](https://exam")).toBe("bold"); + expect(parser.renderInline("**see [docs](https://exam")).toBe("see docs"); + }); + + it("does not auto-link a URL label before the destination is complete", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + expect(parser.renderInline("Read [example.com](https://exam")).toBe("Read example.com"); + expect(parser.renderInline("Read [example.com](https://example.org)")).toBe( + 'Read example.com', + ); + }); + + it.each([ + "\\*literal", + "\\[literal", + "some_identifier", + "some__identifier", + "`**literal [link](url`", + "``a ` b``", + "**already closed** after", + "[bad](javascript:alert(1))", + " { + expect(createAssistantMarkdownParser({ streaming: true }).renderInline(source)).toBe( + createAssistantMarkdownParser().renderInline(source), + ); + }); + + it.each([ + "```md\n**literal [link](url", + "~~~md\n**literal [link](url", + " **literal [link](url", + "**earlier\n\nplain tail", + "- **earlier\n- plain tail", + ])("leaves literal code and earlier blocks alone: %s", (source) => { + expect(createAssistantMarkdownParser({ streaming: true }).render(source)).toBe( + createAssistantMarkdownParser().render(source), + ); + }); + + it("completes inline formatting inside the final list item", () => { + expect(createAssistantMarkdownParser({ streaming: true }).render("- first\n- **bold")).toBe( + "
    \n
  • first
  • \n
  • bold
  • \n
\n", + ); + }); + + it("hides incomplete images until their source is complete", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + expect(parser.renderInline("before ![alt](https://exam")).toBe("before "); + expect(parser.renderInline("before ![alt](https://example.com/image.png)")).toBe( + 'before alt', + ); + }); + + it("keeps ordinary parsing for completed messages", () => { + const parser = createAssistantMarkdownParser(); + expect(parser.renderInline("**unfinished")).toBe("**unfinished"); + expect(parser.renderInline("[unfinished")).toBe("[unfinished"); + }); + it("renders agent text verbatim", () => { const parser = createAssistantMarkdownParser(); diff --git a/packages/app/src/utils/assistant-markdown-parser.ts b/packages/app/src/utils/assistant-markdown-parser.ts index 5e64fb629f..e1e8f6f38a 100644 --- a/packages/app/src/utils/assistant-markdown-parser.ts +++ b/packages/app/src/utils/assistant-markdown-parser.ts @@ -1,7 +1,8 @@ import type MarkdownIt from "markdown-it"; import { createMarkdownParser } from "@/utils/markdown-parser"; +import { enableStreamingMarkdown } from "@/utils/streaming-markdown"; -export function createAssistantMarkdownParser(): MarkdownIt { +export function createAssistantMarkdownParser({ streaming = false } = {}): MarkdownIt { const parser = createMarkdownParser({ linkify: true }); const defaultValidateLink = parser.validateLink.bind(parser); @@ -10,5 +11,9 @@ export function createAssistantMarkdownParser(): MarkdownIt { parser.validateLink = (url: string) => url.trim().toLowerCase().startsWith("file://") || defaultValidateLink(url); + if (streaming) { + enableStreamingMarkdown(parser); + } + return parser; } diff --git a/packages/app/src/utils/streaming-markdown/index.ts b/packages/app/src/utils/streaming-markdown/index.ts new file mode 100644 index 0000000000..b70b464d76 --- /dev/null +++ b/packages/app/src/utils/streaming-markdown/index.ts @@ -0,0 +1,153 @@ +import type MarkdownIt from "markdown-it"; +import type StateInline from "markdown-it/lib/rules_inline/state_inline.mjs"; + +const STREAMING_TAIL = Symbol("streaming markdown tail"); + +/** Adds provisional inline formatting without changing the source or literal code blocks. */ +export function enableStreamingMarkdown(parser: MarkdownIt): void { + parser.core.ruler.at("inline", (state) => { + const tail = state.tokens.findLast((token) => token.nesting !== -1); + for (const token of state.tokens) { + if (token.type === "inline") { + const env = { ...state.env, [STREAMING_TAIL]: token === tail }; + token.children = []; + state.md.inline.parse(token.content, state.md, env, token.children); + } + } + }); + parser.inline.ruler.before("backticks", "streaming_code", completeCode); + parser.inline.ruler.before("strikethrough", "streaming_marker", hideOpeningMarker); + parser.inline.ruler.after("image", "streaming_link", completeLink); + parser.inline.ruler2.after("balance_pairs", "streaming_emphasis", completeEmphasis); + parser.core.ruler.after("linkify", "streaming_link_text", (state) => { + for (const block of state.tokens) { + for (const token of block.children ?? []) { + if (token.type === "streaming_link_text") token.type = "text"; + } + } + }); +} + +function isStreamingTail(state: StateInline): boolean { + return state.env[STREAMING_TAIL] === true && state.posMax === state.src.length; +} + +function hideOpeningMarker(state: StateInline, silent: boolean): boolean { + if (silent || !isStreamingTail(state)) return false; + if (!/^(?:\*{1,3}|_{1,3}|~{1,2})$/.test(state.src.slice(state.pos))) return false; + if (state.scanDelims(state.pos, state.src[state.pos] !== "_").can_close) return false; + state.pos = state.posMax; + return true; +} + +function completeCode(state: StateInline, silent: boolean): boolean { + if (silent || !isStreamingTail(state) || state.src[state.pos] !== "`") return false; + const runs = /`+/g; + runs.lastIndex = state.pos; + const opening = runs.exec(state.src)!; + let closing: RegExpExecArray | null; + while ((closing = runs.exec(state.src))) { + if (closing[0].length === opening[0].length) return false; + } + let content = state.src.slice(state.pos + opening[0].length); + // A shorter trailing run may be the first characters of the closing marker. + const partialClosing = content.match(/`+$/); + if (partialClosing && partialClosing[0].length < opening[0].length) { + content = content.slice(0, -partialClosing[0].length); + } + if (content) { + const token = state.push("code_inline", "code", 0); + token.markup = opening[0]; + token.content = content.replace(/\n/g, " ").replace(/^ (.+) $/, "$1"); + } + state.pos = state.posMax; + return true; +} + +function completeLink(state: StateInline, silent: boolean): boolean { + if (silent || !isStreamingTail(state)) return false; + const image = state.src[state.pos] === "!"; + const start = state.pos + (image ? 1 : 0); + if (state.src[start] !== "[") return false; + const labelEnd = state.md.helpers.parseLinkLabel(state, start, false); + if (labelEnd !== -1 && labelEnd + 1 < state.posMax) { + if (state.src[labelEnd + 1] !== "(" || hasClosedDestination(state.src, labelEnd + 1)) { + return false; + } + } + const end = state.posMax; + if (!image) { + if (state.pending) state.pushPending(); + const firstLabelToken = state.tokens.length; + state.pos = start + 1; + state.posMax = labelEnd === -1 ? end : labelEnd; + state.level++; + state.md.inline.tokenize(state); + state.level--; + // Keep URL-shaped labels out of the automatic linkifier until the real + // destination arrives. Restore ordinary text before tokens reach the view. + for (let index = firstLabelToken; index < state.tokens.length; index++) { + if (state.tokens[index].type === "text") state.tokens[index].type = "streaming_link_text"; + } + } + state.pos = state.posMax = end; + return true; +} + +function hasClosedDestination(source: string, start: number): boolean { + let depth = 0; + let quote = ""; + for (let index = start; index < source.length; index++) { + const char = source[index]; + if (char === "\\") { + index++; + } else if (quote) { + if (char === quote) quote = ""; + } else if (char === "<") { + quote = ">"; + } else if ((char === '"' || char === "'") && /\s/.test(source[index - 1])) { + quote = char; + } else if (char === "(") { + depth++; + } else if (char === ")" && --depth === 0) { + return true; + } + } + return false; +} + +function completeEmphasis(state: StateInline): boolean { + if (!isStreamingTail(state)) return false; + const open = []; + let closedThrough = -1; + // Reuse markdown-it's escape, word-boundary, nesting and delimiter matching + // decisions. Only unmatched openers outside already-closed spans reach EOF. + for (const [index, delimiter] of state.delimiters.entries()) { + if (delimiter.end !== -1) { + closedThrough = Math.max(closedThrough, delimiter.end); + } else if (index > closedThrough && delimiter.open) { + open.push(delimiter); + } + } + for (const delimiter of open.toReversed()) { + if (delimiter.marker === 126 && /(? Date: Sat, 12 Sep 2026 03:50:09 -0300 Subject: [PATCH 03/17] fix(server): show the right thinking options for each Cursor model (#4180) * fix(server): probe Cursor thinking options per model Cursor ACP only reports thought_level for the current model, so the catalog was copying Haiku's Off/On onto Grok 4.6 until the next probe happened to land on Grok. Co-authored-by: Cursor * fix(server): drop inherited thinking options when an ACP catalog probe fails A rejected model switch was leaving the probe session's thinking list on the failed model, so Grok could still advertise Haiku's Off/On. Co-authored-by: Cursor * fix(server): probe per-model thinking when session/new has no thought_level Composer/Auto as the Cursor ACP default omits thought_level, so the catalog helper skipped every model switch and Grok kept an empty thinking list. Co-authored-by: Cursor * fix(server): discover model options without changing preferences Use the provider model catalog extension instead of probing model switches that persist user preferences. Keep provider-specific discovery in each shim and validate extension responses at the boundary. --------- Co-authored-by: Cursor Co-authored-by: Mohamed Boudra --- docs/providers.md | 7 + .../server/agent/providers/acp-agent.test.ts | 7 +- .../src/server/agent/providers/acp-agent.ts | 9 +- .../agent/providers/cursor-acp-agent.test.ts | 7 + .../agent/providers/cursor-acp-agent.ts | 62 ++++- .../providers/cursor-acp-catalog.test.ts | 213 ++++++++++++++++++ .../agent/providers/kimi-acp-agent.test.ts | 32 ++- .../server/agent/providers/kimi-acp-agent.ts | 40 ++-- 8 files changed, 340 insertions(+), 37 deletions(-) create mode 100644 packages/server/src/server/agent/providers/cursor-acp-catalog.test.ts diff --git a/docs/providers.md b/docs/providers.md index f47d408d54..992b6c555f 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -49,6 +49,13 @@ Copilot custom agents are exposed through ACP session config, not the slash-comm ACP permission options are rendered as ordered actions and Paseo returns the selected option's exact `optionId`. Agents can therefore encode a single-choice question as multiple options of the same allow kind. Auto-accept does not resolve those chooser requests; they always wait for the user. +ACP shims can own model discovery through `catalogModelResolver`; the shared client owns the probe +process and refresh deadline. Keep vendor RPCs in the shim. Cursor uses +`cursor/list_available_models` because switching models during discovery writes its saved CLI +preferences and selection history. Cursor versions without that extension must be updated. Kimi +still probes model selections in its own shim. The initial session supplies modes and the current +model; it does not override the model list returned by a resolver. + ### Direct Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch. 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 1c77a6a25d..237e98892c 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1794,10 +1794,9 @@ describe("ACPAgentClient modelTransformer", () => { describe("ACPAgentClient catalog discovery without a model resolver", () => { test("never switches models during catalog discovery even with multiple models and a thinking picker", async () => { - // The per-model probing that switches models lives on KimiACPAgentClient - // (see kimi-acp-agent.test.ts). The base client ships no catalog model resolver, so a - // slow or nonconforming ACP can't stall its catalog probe on extra setSessionConfigOption - // round trips. + // Model discovery extensions live on providers that opt in. The base + // client ships no catalog model resolver, so a slow or nonconforming ACP can't + // stall its catalog probe on extra setSessionConfigOption round trips. const setSessionConfigOption = vi.fn(); class TestACPAgentClient extends ACPAgentClient { diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 0adb620136..4b14f924ef 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -393,7 +393,7 @@ export type ACPExtensionCommandsParser = ( * the catalog plumbing. */ export interface ACPCatalogModelResolverContext { - connection: ClientSideConnection; + connection: Pick; sessionId: string; models: AgentModelDefinition[]; configOptions: SessionConfigOption[] | null | undefined; @@ -404,10 +404,9 @@ export interface ACPCatalogModelResolverContext { } /** - * Optional hook that refines the catalog's model list using the live probe session. - * The base client ships no resolver — catalog discovery derives models from the initial - * session response and never mutates the probe. Providers that need per-model data (Kimi) - * inject a resolver so the extra round trips stay off every other ACP. + * Providers own model discovery through this hook, including extension RPCs. Without a + * resolver, the base client derives models from the initial session response. The client + * owns the probe lifetime and refresh deadline; resolvers return the complete model list. */ export type ACPCatalogModelResolver = ( context: ACPCatalogModelResolverContext, diff --git a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts index a38fb77128..fbe4c762e1 100644 --- a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts @@ -33,6 +33,13 @@ describe("CursorACPAgentClient model discovery", () => { child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() }, connection: { newSession: vi.fn().mockResolvedValue(this.response), + extMethod: async () => ({ + models: (this.response.models?.availableModels ?? []).map((model) => ({ + value: model.modelId, + name: model.name, + configOptions: [], + })), + }), }, initialize: { agentCapabilities: {} }, } as SpawnedACPProcess; diff --git a/packages/server/src/server/agent/providers/cursor-acp-agent.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.ts index 6dce76c973..0a7ce428b3 100644 --- a/packages/server/src/server/agent/providers/cursor-acp-agent.ts +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.ts @@ -1,6 +1,13 @@ +import { zSessionConfigOption } from "@agentclientprotocol/sdk/dist/schema/zod.gen.js"; import type { Logger } from "pino"; +import { z } from "zod"; -import type { ACPConfigFeatureOption } from "./acp-agent.js"; +import type { AgentModelDefinition } from "../agent-sdk-types.js"; +import { + deriveSelectorOptions, + type ACPCatalogModelResolverContext, + type ACPConfigFeatureOption, +} from "./acp-agent.js"; import { GenericACPAgentClient } from "./generic-acp-agent.js"; interface CursorACPAgentClientOptions { @@ -26,6 +33,58 @@ export const CURSOR_FAST_FEATURE_OPTION: ACPConfigFeatureOption = { icon: "zap", }; +const CursorModelCatalogSchema = z.object({ + models: z.array( + z.object({ + value: z.string().min(1), + name: z.string(), + configOptions: z.array(zSessionConfigOption), + }), + ), +}); + +// Cursor model switches persist CLI preferences, even in a throwaway probe session. +// Its extension returns each model's parameter definitions without selecting it. +export async function resolveCursorCatalogModels({ + connection, + models, + provider, + runRequest, +}: ACPCatalogModelResolverContext): Promise { + const catalog = await runRequest(() => fetchCursorModelCatalog(connection)); + const currentModelId = models.find((model) => model.isDefault)?.id; + + return catalog.models.map((model) => { + const thinkingOptions = deriveSelectorOptions(model.configOptions, "thought_level"); + const defaultThinkingOptionId = thinkingOptions.find((option) => option.isDefault)?.id; + return { + provider, + id: model.value, + label: model.name, + isDefault: model.value === currentModelId, + thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined, + defaultThinkingOptionId, + }; + }); +} + +async function fetchCursorModelCatalog(connection: ACPCatalogModelResolverContext["connection"]) { + try { + const response = await connection.extMethod("cursor/list_available_models", {}); + return CursorModelCatalogSchema.parse(response); + } catch (error) { + const extensionUnavailable = + typeof error === "object" && error !== null && "code" in error && error.code === -32601; + if (extensionUnavailable) { + throw new Error( + "Update Cursor CLI: this version does not support cursor/list_available_models.", + { cause: error }, + ); + } + throw error; + } +} + export class CursorACPAgentClient extends GenericACPAgentClient { constructor(options: CursorACPAgentClientOptions) { super({ @@ -40,6 +99,7 @@ export class CursorACPAgentClient extends GenericACPAgentClient { initialCommandsWaitTimeoutMs: CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS, clientCapabilityMeta: CURSOR_CLIENT_CAPABILITY_META, configFeatureOptions: [CURSOR_FAST_FEATURE_OPTION], + catalogModelResolver: resolveCursorCatalogModels, }); } } diff --git a/packages/server/src/server/agent/providers/cursor-acp-catalog.test.ts b/packages/server/src/server/agent/providers/cursor-acp-catalog.test.ts new file mode 100644 index 0000000000..9facfda374 --- /dev/null +++ b/packages/server/src/server/agent/providers/cursor-acp-catalog.test.ts @@ -0,0 +1,213 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { expect, test } from "vitest"; +import { ZodError } from "zod"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { deriveModelDefinitionsFromACP, type ACPCatalogModelResolverContext } from "./acp-agent.js"; +import { resolveCursorCatalogModels } from "./cursor-acp-agent.js"; + +const modelOption: SessionConfigOption = { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "claude-haiku-4-5", + options: [ + { value: "claude-haiku-4-5", name: "Haiku 4.5" }, + { value: "grok-4.6", name: "Grok 4.6" }, + ], +}; + +const booleanThinking: SessionConfigOption = { + id: "thinking", + name: "Thinking", + category: "thought_level", + type: "select", + currentValue: "true", + options: [ + { value: "false", name: "Off" }, + { value: "true", name: "On" }, + ], +}; + +const effortThinking: SessionConfigOption = { + id: "effort", + name: "Effort", + category: "thought_level", + type: "select", + currentValue: "xhigh", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + { value: "xhigh", name: "Extra High" }, + ], +}; + +class CursorCatalogConnection { + selectedModel = "claude-haiku-4-5"; + selectionHistory = ["claude-haiku-4-5"]; + error: unknown = null; + catalog: Record = { + models: [ + { value: "claude-haiku-4-5", name: "Haiku 4.5", configOptions: [booleanThinking] }, + { value: "grok-4.6", name: "Grok 4.6", configOptions: [effortThinking] }, + ], + }; + + async extMethod(method: string, params: Record) { + if (method !== "cursor/list_available_models" || Object.keys(params).length !== 0) { + throw new Error(`Unsupported catalog request: ${method}`); + } + if (this.error) throw this.error; + return this.catalog; + } + + async setSessionConfigOption({ value }: { value: string }) { + this.selectedModel = value; + this.selectionHistory.push(value); + const thinking = value === "grok-4.6" ? effortThinking : booleanThinking; + return { configOptions: [thinking] }; + } +} + +function createCatalogContext( + connection: CursorCatalogConnection, + configOptions: SessionConfigOption[] = [modelOption, booleanThinking], +): ACPCatalogModelResolverContext { + return { + connection, + sessionId: "catalog-session", + models: deriveModelDefinitionsFromACP("acp", null, configOptions), + configOptions, + runRequest: (request) => request(), + transformConfigOptions: (options) => options, + logger: createTestLogger(), + provider: "acp", + }; +} + +test("lists each model's thinking options without changing Cursor preferences", async () => { + const connection = new CursorCatalogConnection(); + const context = createCatalogContext(connection); + + const models = await resolveCursorCatalogModels(context); + + expect(connection.selectedModel).toBe("claude-haiku-4-5"); + expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]); + expect( + models.map(({ id, isDefault, thinkingOptions, defaultThinkingOptionId }) => ({ + id, + isDefault, + thinking: thinkingOptions?.map((option) => option.id), + defaultThinkingOptionId, + })), + ).toEqual([ + { + id: "claude-haiku-4-5", + isDefault: true, + thinking: ["false", "true"], + defaultThinkingOptionId: "true", + }, + { + id: "grok-4.6", + isDefault: false, + thinking: ["low", "medium", "high", "xhigh"], + defaultThinkingOptionId: "xhigh", + }, + ]); +}); + +test("reports that Cursor must be updated when the model extension is unavailable", async () => { + const connection = new CursorCatalogConnection(); + connection.error = { code: -32601, message: "Method not found" }; + + await expect(resolveCursorCatalogModels(createCatalogContext(connection))).rejects.toThrow( + "Update Cursor CLI", + ); + expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]); +}); + +test("discovers thinking models when the session default has no thinking selector", async () => { + const connection = new CursorCatalogConnection(); + const context = createCatalogContext(connection, [modelOption]); + + const models = await resolveCursorCatalogModels(context); + + expect(models[1].thinkingOptions?.map((option) => option.id)).toEqual([ + "low", + "medium", + "high", + "xhigh", + ]); + expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]); +}); + +test("uses the extension's complete catalog even when session/new lists only one model", async () => { + const connection = new CursorCatalogConnection(); + const context = createCatalogContext(connection); + context.models = context.models.slice(0, 1); + + const models = await resolveCursorCatalogModels(context); + + expect(models.map((model) => model.id)).toEqual(["claude-haiku-4-5", "grok-4.6"]); + expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]); +}); + +test("does not copy session thinking options onto a model without a selector", async () => { + const connection = new CursorCatalogConnection(); + connection.catalog = { + models: [{ value: "composer-2", name: "Composer", configOptions: [] }], + }; + + const models = await resolveCursorCatalogModels(createCatalogContext(connection)); + + expect(models).toEqual([ + { + provider: "acp", + id: "composer-2", + label: "Composer", + isDefault: false, + thinkingOptions: undefined, + defaultThinkingOptionId: undefined, + }, + ]); +}); + +test("keeps an empty extension catalog empty", async () => { + const connection = new CursorCatalogConnection(); + connection.catalog = { models: [] }; + + await expect(resolveCursorCatalogModels(createCatalogContext(connection))).resolves.toEqual([]); +}); + +test.each([ + {}, + { models: [{ value: "grok-4.6", name: "Grok 4.6" }] }, + { + models: [ + { + value: "grok-4.6", + name: "Grok 4.6", + configOptions: [{ ...effortThinking, currentValue: 1 }], + }, + ], + }, +])("rejects malformed extension responses: %j", async (catalog) => { + const connection = new CursorCatalogConnection(); + connection.catalog = catalog; + + await expect(resolveCursorCatalogModels(createCatalogContext(connection))).rejects.toBeInstanceOf( + ZodError, + ); + expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]); +}); + +test("propagates extension failures without changing preferences or returning stale options", async () => { + const connection = new CursorCatalogConnection(); + const error = new Error("Connection closed"); + connection.error = error; + + await expect(resolveCursorCatalogModels(createCatalogContext(connection))).rejects.toBe(error); + expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]); +}); diff --git a/packages/server/src/server/agent/providers/kimi-acp-agent.test.ts b/packages/server/src/server/agent/providers/kimi-acp-agent.test.ts index ce296726a1..b388fdb01a 100644 --- a/packages/server/src/server/agent/providers/kimi-acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/kimi-acp-agent.test.ts @@ -156,8 +156,13 @@ describe("KimiACPAgentClient per-model thinking options", () => { expect(setSessionConfigOption).not.toHaveBeenCalled(); }); - test("skips per-model probing when the provider has no thinking picker", async () => { - const setSessionConfigOption = vi.fn(); + test("probes each model when session/new has no thought_level so a later model can still expose thinking options", async () => { + const setSessionConfigOption = vi.fn(async ({ value }: { value: string }) => ({ + configOptions: + value === "kimi-k3" + ? [modelConfigOption(value), effortThinkingConfigOption()] + : [modelConfigOption(value)], + })); const client = createKimiClient( async () => @@ -174,16 +179,28 @@ describe("KimiACPAgentClient per-model thinking options", () => { }) as unknown as SpawnedACPProcess, ); - await client.fetchCatalog({ + const catalog = await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/acp-kimi-no-thinking", force: false, }); - expect(setSessionConfigOption).not.toHaveBeenCalled(); + expect(setSessionConfigOption).toHaveBeenCalledTimes(2); + + const kimiForCoding = catalog.models.find((model) => model.id === "kimi-for-coding"); + const kimiK3 = catalog.models.find((model) => model.id === "kimi-k3"); + expect(kimiForCoding?.thinkingOptions).toBeUndefined(); + expect(kimiForCoding?.defaultThinkingOptionId).toBeUndefined(); + expect(kimiK3?.thinkingOptions).toEqual([ + expect.objectContaining({ id: "off", isDefault: false }), + expect.objectContaining({ id: "low", isDefault: false }), + expect.objectContaining({ id: "medium", isDefault: true }), + expect.objectContaining({ id: "high", isDefault: false }), + ]); + expect(kimiK3?.defaultThinkingOptionId).toBe("medium"); }); - test("keeps a model's default thinking options when its probe fails", async () => { + test("omits thinking options when a model's probe fails instead of keeping another model's list", async () => { const setSessionConfigOption = vi.fn(async ({ value }: { value: string }) => { if (value === "kimi-k3") { throw new Error("probe rejected model switch"); @@ -212,10 +229,13 @@ describe("KimiACPAgentClient per-model thinking options", () => { force: false, }); + const kimiForCoding = catalog.models.find((model) => model.id === "kimi-for-coding"); const kimiK3 = catalog.models.find((model) => model.id === "kimi-k3"); - expect(kimiK3?.thinkingOptions).toEqual([ + expect(kimiForCoding?.thinkingOptions).toEqual([ expect.objectContaining({ id: "off", isDefault: true }), expect.objectContaining({ id: "on", isDefault: false }), ]); + expect(kimiK3?.thinkingOptions).toBeUndefined(); + expect(kimiK3?.defaultThinkingOptionId).toBeUndefined(); }); }); diff --git a/packages/server/src/server/agent/providers/kimi-acp-agent.ts b/packages/server/src/server/agent/providers/kimi-acp-agent.ts index e8ff7ce252..50effdf33a 100644 --- a/packages/server/src/server/agent/providers/kimi-acp-agent.ts +++ b/packages/server/src/server/agent/providers/kimi-acp-agent.ts @@ -18,23 +18,8 @@ interface KimiACPAgentClientOptions { providerParams?: unknown; } -/** - * Kimi reports different thinking-effort levels per model (a boolean on/off toggle for one - * model, a multi-level select for another), but only exposes the *currently selected* - * model's levels through `configOptions` — the model list itself carries no per-model - * effort metadata. `deriveModelDefinitionsFromACP` can only see whichever model the probe - * session defaulted to, so every other model would otherwise inherit that one model's - * thinking options. - * - * Reuse the single catalog probe session to switch through each candidate model in turn and - * read back its real thinking options, rather than spawning a probe per model. Skipped - * entirely when the provider reports one model or no thinking picker, so a misbehaving ACP - * that only advertises a single model pays no extra round trips. - * - * This lives on the Kimi client, not the base ACP adapter: only Kimi needs the extra - * `setSessionConfigOption` round trips, so no other ACP provider risks a slow or - * nonconforming agent stalling its catalog probe on model switching. - */ +// Kimi exposes thinking options only for the selected model. Keep its model-switching +// discovery here: other providers can supply a read-only catalog instead. export async function resolveKimiCatalogModels({ connection, sessionId, @@ -49,7 +34,7 @@ export async function resolveKimiCatalogModels({ return models; } const modelOption = findSelectConfigOption({ configOptions, category: "model" }); - if (!modelOption || !findSelectConfigOption({ configOptions, category: "thought_level" })) { + if (!modelOption) { return models; } @@ -72,11 +57,24 @@ export async function resolveKimiCatalogModels({ thinkingOptions.find((option) => option.isDefault)?.id ?? undefined, }); } catch (error) { + const errorMessage = toDiagnosticErrorMessage(error); + if (model.isDefault) { + logger.warn( + { modelId: model.id, error: errorMessage }, + `${provider} catalog probe could not refresh thinking options for current model "${model.id}"; keeping session options`, + ); + resolved.push(model); + continue; + } logger.warn( - { modelId: model.id, error: toDiagnosticErrorMessage(error) }, - `${provider} catalog probe could not resolve thinking options for model "${model.id}"; keeping its default options`, + { modelId: model.id, error: errorMessage }, + `${provider} catalog probe could not resolve thinking options for model "${model.id}"; omitting thinking options`, ); - resolved.push(model); + resolved.push({ + ...model, + thinkingOptions: undefined, + defaultThinkingOptionId: undefined, + }); } } return resolved; From 03a54a171da5b08c039e4e4c86b66480cf9e43da Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 12 Sep 2026 08:50:16 +0200 Subject: [PATCH 04/17] Keep older daemons usable and let clients choose timeline recovery (#4737) * fix(subscriptions): preserve older-host workflows and explicit timeline recovery Keep established app and SDK workflows available on older daemons without emulating independent directory filters. Restore live timeline membership without choosing the consumer's history recovery policy. Preserve daemon filesystem paths separately from legacy workspace IDs. * fix(client): keep legacy workspace replies scoped to the requested page --- docs/plugins.md | 6 +- docs/protocol-compatibility.md | 19 +- .../owned-subscriptions-old-daemon.spec.ts | 118 +++- .../app/src/runtime/directory-sync/index.ts | 9 +- packages/app/src/runtime/host-runtime.test.ts | 12 +- packages/app/src/runtime/host-runtime.ts | 11 - packages/cli/src/commands/agent/attach.ts | 5 +- packages/cli/src/commands/agent/logs.ts | 8 +- .../client/examples/events-and-timeline.ts | 4 +- packages/client/src/connection.test.ts | 622 ++++++++++++++---- packages/client/src/connection/index.ts | 123 +++- .../src/connection/legacy-workspaces.ts | 176 +++++ packages/client/src/connection/legacy.ts | 282 ++++++++ packages/client/src/connection/owned.ts | 2 +- packages/client/src/daemon-client.test.ts | 18 +- packages/client/src/daemon-client.ts | 64 +- packages/client/src/index.test.ts | 42 +- packages/client/src/index.ts | 60 +- .../client/src/terminal-stream-router.test.ts | 17 + packages/client/src/terminal-stream-router.ts | 57 +- .../client/src/timeline-subscription/index.ts | 79 +-- .../owned-subscriptions-client.e2e.test.ts | 26 +- .../server/owned-subscriptions.e2e.test.ts | 83 ++- .../terminal-session-controller.test.ts | 56 +- .../terminal/terminal-session-controller.ts | 17 +- public-docs/sdk/events.md | 31 +- public-docs/sdk/reference.md | 8 +- 27 files changed, 1560 insertions(+), 395 deletions(-) create mode 100644 packages/client/src/connection/legacy-workspaces.ts create mode 100644 packages/client/src/connection/legacy.ts diff --git a/docs/plugins.md b/docs/plugins.md index dfd4d6dde3..6cf231932a 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -356,8 +356,10 @@ query, and toast providers inside the surface content, including overflow pages plugins. Providers only around the trigger do not reach those bodies. Request observation with `client.paseo.agents.list({ subscribe: {} })` and consume the returned -`subscription` handle. Plain `list()` and local `.subscribe(handler)` listeners create no daemon -demand. Each list-and-subscribe call has its own server ID, even for the same query. Handle snapshots +`subscription` handle. Plain `list()` and agent/workspace directory `.subscribe(handler)` listeners create no daemon +demand. Provider and project `subscribe()` calls establish their own demand. On capable daemons, each +list-and-subscribe call has its own server ID, even for the same query. Older daemons retain +[shared delivery behavior](protocol-compatibility.md#owned-observations). Handle snapshots also run after reconnect; replace your view before applying its subsequent updates. The installation owns every observation created through its API and releases them on unload, including setup failure. Mounted surfaces and command invocations have shorter API lifetimes. diff --git a/docs/protocol-compatibility.md b/docs/protocol-compatibility.md index 089bf1a3e8..eab220aa1d 100644 --- a/docs/protocol-compatibility.md +++ b/docs/protocol-compatibility.md @@ -46,15 +46,26 @@ The app, CLI, and plugins inherit those defaults; they supply only host resource 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, +On capable daemons, 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. ## Owned observations -`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the entire source-owned -contract described in [architecture](architecture.md#websocket-protocol). The client gates observation -once and tells users to update an older host. It does not emulate independent handles over legacy slots. +`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the source-owned +contract described in [architecture](architecture.md#websocket-protocol). The client selects delivery +behavior once at its connection boundary. App workflows use the same observation interface on both. + +With an older daemon, the client uses the existing connection and legacy RPCs. Directory subscriptions +remain shared and last-query-wins. Local handle IDs identify listeners; they do not promise independent +server filters. Timeline and event membership retain their existing shared behavior. Releasing a handle +detaches its listener and uses the old unsubscribe operation where one exists. Broadcast-only hosts keep +broadcasting; readiness there means local attachment, not a daemon acknowledgement. No extra sockets +or multiplexing emulation are introduced. + +Preserve established workflows when changing delivery internals. Independent filters and quiet +connections require a capable daemon; opening the app, reading history and using terminals do not. +Pre-registry workspace grouping and legacy event normalization belong inside the client boundary. Old clients keep their existing wire shapes and slot behavior at the daemon's source boundary. The adapter keys legacy slots by physical socket, so an old connection cannot replace a modern diff --git a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts index 0f0109ee3c..1bd1f549cf 100644 --- a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts +++ b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts @@ -1,14 +1,42 @@ import { randomUUID } from "node:crypto"; +import { submitMessage, expectComposerEditable } from "../support/helpers/composer"; +import { + focusTerminalSurface, + typeInTerminal, + getTerminalBufferText, +} from "../support/helpers/terminal-perf"; +import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; import { metroTest as test, expect } from "../support/fixtures"; import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry"; import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; -import { buildAgentRoute } from "../support/helpers/mock-agent"; +import type { MockAgentWorkspace } from "../support/helpers/mock-agent"; import { seedWorkspace } from "../support/helpers/seed-client"; +import { + expectTimelinePromptVisible, + holdOlderHistoryPages, + openAgentTimeline, + scrollThroughOlderHistoryPages, +} from "../support/helpers/timeline-pagination"; -test("requires a host update before observing a published 0.2.5 daemon", async ({ page }) => { +test("does not repeat an assistant block when the current app paginates a published 0.2.5 daemon", async ({ + page, +}) => { test.setTimeout(120_000); const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`; const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" }); + const observationSockets = new Set(); + page.on("websocket", (socket) => { + socket.on("framesent", ({ payload }) => { + if (typeof payload !== "string") return; + const frame = JSON.parse(payload); + if ( + frame.type === "session" && + frame.message.type === "fetch_agents_request" && + frame.message.subscribe + ) + observationSockets.add(socket); + }); + }); const workspace = await seedWorkspace({ repoPrefix: "timeline-old-daemon-pagination-", port: daemon.port, @@ -21,16 +49,32 @@ test("requires a host update before observing a published 0.2.5 daemon", async ( modeId: "load-test", model: "ten-second-stream", }); - const requests: unknown[] = []; - page.on("websocket", (socket) => - socket.on("framesent", ({ payload }) => { - if (typeof payload !== "string") return; - const frame = JSON.parse(payload); - if (frame.type === "session" && frame.message.type !== "ping") requests.push(frame.message); - }), - ); + const agent: MockAgentWorkspace = { + agentId: createdAgent.id, + workspaceId: workspace.workspaceId, + cwd: workspace.repoPath, + client: workspace.client, + cleanup: workspace.cleanup, + }; try { + for (let index = 0; index < 40; index += 1) { + await agent.client.sendAgentMessage( + agent.agentId, + `timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`, + ); + await agent.client.waitForFinish(agent.agentId, 15_000); + } + await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline"); + await agent.client.waitForFinish(agent.agentId, 20_000); + for (let index = 0; index < 20; index += 1) { + await agent.client.sendAgentMessage( + agent.agentId, + `timeline-pagination-turn-${index}: emit 1 coalesced agent stream updates`, + ); + await agent.client.waitForFinish(agent.agentId, 15_000); + } + const host = buildSeededHost({ serverId, endpoint: `127.0.0.1:${daemon.port}`, @@ -45,20 +89,50 @@ test("requires a host update before observing a published 0.2.5 daemon", async ( { seededHost: host, preferences: buildCreateAgentPreferences() }, ); - await page.goto(buildAgentRoute(workspace.workspaceId, createdAgent.id, serverId)); - const updateHost = page.getByText("Update the host to use this version of Paseo.", { - exact: true, + const history = await holdOlderHistoryPages(page, agent, daemon.port); + await openAgentTimeline(page, agent, serverId); + await expectTimelinePromptVisible( + page, + "timeline-pagination-turn-19: emit 1 coalesced agent stream updates", + ); + await scrollThroughOlderHistoryPages(page, 3, history); + + history.expectRepeatedEntries(); + await history.expectOwnedTextRendered("Now I have a clearer picture."); + expect(observationSockets.size).toBe(1); + + await test.step("send a live turn after reconnecting the old daemon", async () => { + await daemon.restart(); + await expect.poll(() => observationSockets.size, { timeout: 30_000 }).toBe(2); + await expectComposerEditable(page); + await submitMessage( + page, + "compatibility-after-reconnect: emit 1 coalesced agent stream updates", + ); + await expectTimelinePromptVisible( + page, + "compatibility-after-reconnect: emit 1 coalesced agent stream updates", + ); + await agent.client.waitForFinish(agent.agentId, 20_000); + await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") }); + }); + + await test.step("open a terminal and receive its output", async () => { + const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal"); + expect(terminal.error).toBeNull(); + const terminalId = terminal.terminal!.id; + const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId); + await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`); + await focusTerminalSurface(page); + await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n"); + await expect + .poll(() => getTerminalBufferText(page), { timeout: 15_000 }) + .toContain("compatibility-terminal-output"); + await page.screenshot({ path: test.info().outputPath("old-daemon-terminal.png") }); + await agent.client.killTerminal(terminalId); }); - await expect(updateHost).toHaveCount(1); - await expect(updateHost).toBeVisible(); - await expect(page.getByRole("button", { name: "Manage host", exact: true })).toBeVisible(); - expect(requests).toEqual([]); - await page.reload(); - await expect(updateHost).toHaveCount(1); - await expect(updateHost).toBeVisible(); - expect(requests).toEqual([]); } finally { - await workspace.cleanup(); + await agent.cleanup(); await daemon.close(); } }); diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index 9d457f63b2..51e8b37397 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -487,13 +487,8 @@ export class DirectorySync { 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.applyWorkspaceDelta(delta); - return; - } - const supportsProjectList = serverInfo.features?.projectList === true; - const supportsDirectorySync = serverInfo.features?.directorySync === true; + const supportsProjectList = serverInfo?.features?.projectList === true; + const supportsDirectorySync = serverInfo?.features?.directorySync === true; if (supportsProjectList) { await this.fetchProjectSnapshot(client, source, transaction, supportsDirectorySync); } diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 52686df9c6..fbaac8b7df 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -570,7 +570,7 @@ class BrowserClientLifecycle { } describe("HostRuntimeController", () => { - it("gates an old host before publishing the app client or mounting observations", async () => { + it("publishes an old host and mounts observations through the client interface", async () => { const host = makeHost(); const client = new FakeDaemonClient(); client.ownedSubscriptions = false; @@ -595,12 +595,12 @@ describe("HostRuntimeController", () => { const stop = controller.subscribe(() => publishedClients.push(controller.getSnapshot().client)); await controller.activateConnection({ connectionId: host.connections[0]!.id }); expect(controller.getSnapshot()).toMatchObject({ - connectionStatus: "error", - client: null, - lastError: "Update the host to use this version of Paseo.", + connectionStatus: "online", + client, + lastError: null, }); - expect(mounts).toBe(0); - expect(publishedClients.every((value) => value === null)).toBe(true); + expect(mounts).toBe(1); + expect(publishedClients).toContain(client); expect(client.fetchAgentsCalls).toEqual([]); stop(); await controller.stop(); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 6f4d4bd6ad..416cff911e 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1273,24 +1273,13 @@ export class HostRuntimeController { /* Preserve the compatibility/connection error. */ } }; - const requireAppHost = () => { - if (client.getLastServerInfoMessage()?.features?.ownedSubscriptions !== true) - throw new Error("Update the host to use this version of Paseo."); - }; try { if (!existingClient) await client.connect(); if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) return; - requireAppHost(); this.unsubscribeClientHandlers = this.deps.mountClientHandlers?.({ client, host: this.host, connection }) ?? null; this.unsubscribeClientStatus = client.subscribeConnectionStatus((state) => { if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) return; - try { - if (state.status === "connected") requireAppHost(); - } catch (error) { - void failConnection(error); - return; - } this.applyConnectionEvent({ type: "client_state", state, lastError: client.lastError }); this.updateSnapshot({ ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), diff --git a/packages/cli/src/commands/agent/attach.ts b/packages/cli/src/commands/agent/attach.ts index 36cb8e4fc0..522905c033 100644 --- a/packages/cli/src/commands/agent/attach.ts +++ b/packages/cli/src/commands/agent/attach.ts @@ -145,9 +145,8 @@ export async function runAttachCommand( const unsubscribe = client.subscribeAgentTimeline(resolvedId, (message) => { if (message.type === "agent.timeline.replacement") { console.log("\n[Timeline replaced; earlier output is no longer current]"); - } else if (message.type === "agent.timeline.snapshot") { - console.log("\n[Reconnected; current recent history follows]"); - for (const entry of message.payload.page.entries) printTimelineItem(entry.item); + } else if (message.type === "agent.timeline.subscription_restored") { + console.log("\n[Reconnected; live output resumed. Events may have been missed.]"); } else if (message.type === "agent.timeline.error") { console.error(`Timeline observation stopped: ${message.payload.error}`); } else { diff --git a/packages/cli/src/commands/agent/logs.ts b/packages/cli/src/commands/agent/logs.ts index d32ab549a8..0226ed2b06 100644 --- a/packages/cli/src/commands/agent/logs.ts +++ b/packages/cli/src/commands/agent/logs.ts @@ -203,12 +203,8 @@ async function runFollowMode( console.error(`Timeline observation stopped: ${message.payload.error}`); return; } - if (message.type === "agent.timeline.snapshot") { - console.log("\n[Reconnected; current recent history follows]"); - const items = message.payload.page.entries - .map((entry) => entry.item) - .filter((item) => !options.filter || matchesFilter(item, options.filter)); - console.log(formatAgentActivityTranscript(items, tailCount)); + if (message.type === "agent.timeline.subscription_restored") { + console.log("\n[Reconnected; live output resumed. Events may have been missed.]"); return; } if (message.payload.event.type === "timeline") { diff --git a/packages/client/examples/events-and-timeline.ts b/packages/client/examples/events-and-timeline.ts index a56a826677..f0269f87b9 100644 --- a/packages/client/examples/events-and-timeline.ts +++ b/packages/client/examples/events-and-timeline.ts @@ -30,8 +30,8 @@ export async function subscribeToEvents( }); const unsubscribeTimeline = client.agents.ref(agentId).timeline.subscribe((event) => { - if (event.event.type === "snapshot") { - console.log("Replace recent history", event.event.page.epoch, event.event.page.entries); + if (event.event.type === "subscription_restored") { + console.log("Live subscription restored; request any missed history explicitly."); } else if (event.event.type === "error") { console.error(event.event.error); } diff --git a/packages/client/src/connection.test.ts b/packages/client/src/connection.test.ts index 5a87b64e9c..a3d12e48f6 100644 --- a/packages/client/src/connection.test.ts +++ b/packages/client/src/connection.test.ts @@ -6,6 +6,10 @@ function connection( options: { acknowledgeSubscriptions?: boolean; ownedSubscriptions?: boolean; + ownedCapability?: boolean; + workspaceMultiplicity?: boolean; + broadcasts?: boolean; + browserHost?: { hostKind: string; supportedCommands: string[] }; acknowledgeTimelineReads?: boolean; } = {}, ) { @@ -40,8 +44,9 @@ function connection( version: null, features: { ...(options.ownedSubscriptions === false ? {} : { ownedSubscriptions: true }), - selectiveAgentTimeline: true, - explicitEventSubscriptions: true, + workspaceMultiplicity: options.workspaceMultiplicity ?? true, + selectiveAgentTimeline: !options.broadcasts, + explicitEventSubscriptions: !options.broadcasts, }, }, }, @@ -112,6 +117,12 @@ function connection( const client = new DaemonClient({ url: "ws://test", clientId: "test", + capabilities: { + [CLIENT_CAPS.browserHost]: options.browserHost, + ...(options.ownedCapability === undefined + ? {} + : { [CLIENT_CAPS.ownedSubscriptions]: options.ownedCapability }), + }, transportFactory: () => transport, reconnect: { enabled: false }, }); @@ -382,19 +393,30 @@ test("failed terminal bootstrap reports the domain error without reconnecting or } }); -test("an old host produces an update-host error without sending a legacy subscription", async () => { +test("older hosts share event demand on one connection and release only their own listeners", async () => { const h = connection({ ownedSubscriptions: false }); try { const connecting = h.client.connect(); h.open(); await connecting; - const observation = h.client.observeAgents({ filter: { labels: { role: "orchestrator" } } }); - await expect(observation.ready).rejects.toThrow( - "Update the host to use independent subscriptions.", - ); - await observation.release(); - expect(h.sent.filter((frame) => frame.type === "session")).toEqual([]); - expect(h.client.isConnected).toBe(true); + const first = h.client.observeEvents(["project.update"]); + const second = h.client.observeEvents(["project.update"]); + await Promise.all([first.ready, second.ready]); + const received: string[] = []; + first.subscribe({ snapshot: () => {}, update: () => received.push("first") }); + second.subscribe({ snapshot: () => {}, update: () => received.push("second") }); + h.receive({ type: "project.update", payload: { kind: "remove", projectId: "project" } }); + expect(received).toEqual(["first", "second"]); + await first.release(); + h.receive({ type: "project.update", payload: { kind: "remove", projectId: "project" } }); + expect(received).toEqual(["first", "second", "second"]); + expect(h.sent.at(-1)?.message?.events).toEqual(["project.update"]); + await second.release(); + expect(h.sent.at(-1)?.message?.events).toEqual([]); + expect(h.sent.filter((frame) => frame.type === "hello")).toHaveLength(1); + expect( + h.sent.filter((frame) => frame.message?.type === "subscription.release.request"), + ).toEqual([]); } finally { await h.client.close(); } @@ -474,9 +496,13 @@ function timelinePage(requestId: string | undefined, epoch: string, seq: number) }; } -test("timeline recovery snapshots precede live updates for independent owners and survive epoch changes", async () => { +test.each([ + { name: "modern", ownedSubscriptions: true, broadcasts: false }, + { name: "legacy selective", ownedSubscriptions: false, broadcasts: false }, + { name: "legacy broadcast", ownedSubscriptions: false, broadcasts: true }, +])("$name timelines restore live delivery without requesting history", async (mode) => { const { createPaseoApi } = await import("./index"); - const h = connection({ acknowledgeTimelineReads: false }); + const h = connection(mode); const api = createPaseoApi(h.client); const received: import("./index").PaseoAgentTimelineEvent[][] = [[], [], []]; try { @@ -487,118 +513,154 @@ test("timeline recovery snapshots precede live updates for independent owners an api.agents.ref("agent").timeline.subscribe((event) => events.push(event)), ); await Promise.all(owners.map((owner) => owner.ready)); - const reads = () => - h.sent.filter((frame) => frame.message?.type === "fetch_agent_timeline_request"); - expect(reads()).toHaveLength(0); // Initial subscription remains live-only. - const oldIds = new Set(owners.map((owner) => owner.subscriptionId)); - h.disconnect(); - const reconnected = h.client.connect(); - h.open(); - await reconnected; - await expect.poll(() => reads().length).toBe(3); - expect(new Set(owners.map((owner) => owner.subscriptionId)).size).toBe(3); - expect(owners.every((owner) => !oldIds.has(owner.subscriptionId))).toBe(true); - await owners[2].release(); // The history reply can arrive after the owner is gone. - const live = (index: number, seq: number, epoch: string) => - h.receive({ - type: "agent_stream", - payload: { + expect(received).toEqual([[], [], []]); + for (let cycle = 0; cycle < 2; cycle++) { + const oldIds = owners.map((owner) => owner.subscriptionId); + h.disconnect(); + await owners[2].release(); + const reconnected = h.client.connect(); + h.open(); + await reconnected; + await expect.poll(() => owners[0].subscriptionId).not.toBe(oldIds[0]); + expect( + h.sent.filter((frame) => frame.message?.type === "fetch_agent_timeline_request"), + ).toEqual([]); + for (const index of [0, 1]) { + expect(owners[index].subscriptionId).not.toBeNull(); + expect(received[index].at(-1)).toEqual({ agentId: "agent", subscriptionId: owners[index].subscriptionId, - seq, - epoch, - timestamp: new Date(0).toISOString(), - event: { - type: "timeline", - provider: "codex", - item: { type: "user_message", text: `live-${seq}` }, - }, - }, - }); - live(0, 4, "old"); - live(0, 5, "old"); - h.receive(timelinePage(reads()[1].message?.requestId, "new", 4)); - h.receive(timelinePage(reads()[2].message?.requestId, "new", 4)); - await expect.poll(() => received[1].length).toBe(1); - expect(received[0]).toEqual([]); - live(1, 5, "new"); - h.receive(timelinePage(reads()[0].message?.requestId, "old", 4)); - await expect.poll(() => received[0].length).toBe(2); - for (const [index, epoch] of [ - [0, "old"], - [1, "new"], - ] as const) { - expect(received[index][0]).toMatchObject({ - event: { - type: "snapshot", - reason: "reconnect", - page: { - epoch, - hasOlder: true, - startCursor: { epoch, seq: 4 }, - entries: [{ item: { text: `persisted-${epoch}-4` } }], - }, - }, - }); - expect(received[index][1]).toMatchObject({ seq: 5 }); + event: { type: "subscription_restored" }, + }); + } + const live = { + agentId: "agent", + timestamp: new Date(0).toISOString(), + event: { type: "turn_completed", provider: "codex" }, + }; + if (mode.ownedSubscriptions) { + for (const owner of owners.slice(0, 2)) + h.receive({ + type: "agent_stream", + payload: { ...live, subscriptionId: owner.subscriptionId }, + }); + } else h.receive({ type: "agent_stream", payload: live }); + for (const events of received.slice(0, 2)) + expect(events.map((update) => update.event.type)).toEqual( + Array.from({ length: cycle + 1 }, () => [ + "subscription_restored", + "turn_completed", + ]).flat(), + ); + expect(received[2]).toEqual([]); } - expect(received[2]).toEqual([]); - await owners[0].release(); - live(1, 6, "new"); - expect(received[1]).toHaveLength(3); } finally { await api.dispose(); await h.client.close(); } }); -test("timeline recovery replaces an obsolete read after bounded overflow and epoch invalidation", async () => { +test("a consumer chooses its recovery cursor and a failed read leaves live delivery active", async () => { + const { createPaseoApi } = await import("./index"); const h = connection({ acknowledgeTimelineReads: false }); - const messages: unknown[] = []; + const api = createPaseoApi(h.client); + const received: import("./index").PaseoAgentTimelineEvent[] = []; + let read: Promise | undefined; try { const connected = h.client.connect(); h.open(); await connected; - const owner = h.client.subscribeAgentTimeline("agent", (message) => messages.push(message)); + const timeline = api.agents.ref("agent").timeline; + const owner = timeline.subscribe((message) => { + received.push(message); + if (message.event.type === "subscription_restored") { + read = timeline.refetch({ + direction: "after", + cursor: { epoch: "cached", seq: 42 }, + limit: 7, + }); + void read.catch(() => {}); + } + }); await owner.ready; h.disconnect(); const reconnected = h.client.connect(); h.open(); await reconnected; - const reads = () => - h.sent.filter((frame) => frame.message?.type === "fetch_agent_timeline_request"); - await expect.poll(() => reads().length).toBe(1); - for (let seq = 1; seq <= 130; seq++) + await expect.poll(() => read !== undefined).toBe(true); + const requests = h.sent.filter( + (frame) => frame.message?.type === "fetch_agent_timeline_request", + ); + expect(requests).toHaveLength(1); + expect(requests[0].message).toMatchObject({ + direction: "after", + cursor: { epoch: "cached", seq: 42 }, + limit: 7, + }); + // A slow consumer-owned history read must not buffer or filter live events. + for (let seq = 43; seq < 173; seq++) h.receive({ type: "agent_stream", payload: { agentId: "agent", subscriptionId: owner.subscriptionId, seq, - epoch: "old", + epoch: "cached", timestamp: new Date(0).toISOString(), - event: { - type: "timeline", - provider: "codex", - item: { type: "assistant_message", text: "piece" }, - }, + event: { type: "turn_completed", provider: "codex" }, }, }); - h.receive(timelinePage(reads()[0].message?.requestId, "old", 1)); - await expect.poll(() => reads().length).toBe(2); - expect(messages).toEqual([]); + expect(received).toHaveLength(131); h.receive({ type: "agent.timeline.replacement", payload: { agentId: "agent", subscriptionId: owner.subscriptionId, epoch: "new" }, }); - h.receive(timelinePage(reads()[1].message?.requestId, "old", 130)); - await expect.poll(() => reads().length).toBe(3); - expect(messages).toEqual([]); - h.receive(timelinePage(reads()[2].message?.requestId, "new", 2)); - await expect.poll(() => messages.length).toBe(2); - expect(messages).toMatchObject([ - { type: "agent.timeline.replacement", payload: { epoch: "new" } }, - { type: "agent.timeline.snapshot", payload: { page: { epoch: "new" } } }, + expect(received.at(-1)?.event).toEqual({ type: "replacement", epoch: "new" }); + const page = timelinePage(requests[0].message?.requestId, "cached", 42); + h.receive({ ...page, payload: { ...page.payload, error: "History unavailable" } }); + await expect(read).rejects.toThrow("History unavailable"); + expect(owner.subscriptionId).not.toBeNull(); + expect(received.some((message) => message.event.type === "error")).toBe(false); + expect( + h.sent.filter((frame) => frame.message?.type === "fetch_agent_timeline_request"), + ).toHaveLength(1); + await owner.release(); + } finally { + await api.dispose(); + await h.client.close(); + } +}); + +test("subscription-restored notification waits for the new membership acknowledgement", async () => { + const h = connection({ acknowledgeSubscriptions: false }); + const received: unknown[] = []; + const acknowledge = (id: string) => + h.receive({ + type: "agent.timeline.set_subscription.response", + payload: { + agentIds: ["agent"], + requestId: h.sent.at(-1)?.message?.requestId, + subscriptionId: id, + }, + }); + try { + const connected = h.client.connect(); + h.open(); + await connected; + const owner = h.client.subscribeAgentTimeline("agent", (message) => received.push(message)); + acknowledge("first"); + await owner.ready; + h.disconnect(); + const reconnected = h.client.connect(); + h.open(); + await reconnected; + expect(received).toEqual([]); + acknowledge("second"); + expect(received).toEqual([ + { + type: "agent.timeline.subscription_restored", + payload: { agentId: "agent", subscriptionId: "second" }, + }, ]); await owner.release(); } finally { @@ -606,31 +668,361 @@ test("timeline recovery replaces an obsolete read after bounded overflow and epo } }); -test("timeline recovery failure is observable and releases its owner", async () => { - const h = connection({ acknowledgeTimelineReads: false }); - const messages: unknown[] = []; - try { - const connected = h.client.connect(); - h.open(); - await connected; - const owner = h.client.subscribeAgentTimeline("agent", (message) => messages.push(message)); - await owner.ready; - h.disconnect(); - const reconnected = h.client.connect(); - h.open(); - await reconnected; - const reads = () => - h.sent.filter((frame) => frame.message?.type === "fetch_agent_timeline_request"); - await expect.poll(() => reads().length).toBe(1); - const page = timelinePage(reads()[0].message?.requestId, "epoch", 0); - h.receive({ ...page, payload: { ...page.payload, error: "Agent no longer available" } }); - await expect.poll(() => owner.subscriptionId).toBe(null); - expect(messages).toEqual([ - { - type: "agent.timeline.error", - payload: { agentId: "agent", error: "Agent no longer available" }, +function legacyAgent(input: { + id: string; + cwd: string; + status?: "idle" | "running"; + updatedAt?: string; + projectRoot?: string; +}) { + const updatedAt = input.updatedAt ?? "2026-06-18T10:00:00.000Z"; + return { + agent: { + id: input.id, + provider: "mock", + cwd: input.cwd, + model: null, + createdAt: updatedAt, + updatedAt, + lastUserMessageAt: null, + status: input.status ?? "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + labels: {}, + }, + project: { + projectKey: "/repo", + projectName: "repo", + workspaceName: "app", + checkout: { + cwd: input.cwd, + isGit: true, + currentBranch: "main", + remoteUrl: "git@example.com:repo/app.git", + worktreeRoot: input.cwd, + isPaseoOwnedWorktree: false, + mainRepoRoot: input.projectRoot ?? "/repo", + }, + }, + }; +} + +test.each([ + { cwd: "/repo/app", id: "/repo/app", root: "/repo" }, + { cwd: "C:\\repo\\app", id: "C:/repo/app", root: "C:\\repo" }, + { cwd: "C:\\", id: "C:", root: "C:\\" }, + { cwd: "C:/", id: "C:", root: "C:/" }, + { cwd: "\\\\server\\share\\", id: "//server/share", root: "\\\\server\\share\\" }, + { cwd: "\\\\?\\C:\\repo\\app", id: "//?/C:/repo/app", root: "\\\\?\\C:\\repo" }, + { cwd: "/repo/trailing space ", id: "/repo/trailing space", root: "/repo/ " }, + { cwd: "/repo/back\\slash", id: "/repo/back/slash", root: "/repo" }, +])( + "old workspaces preserve daemon filesystem paths ($cwd) and stable IDs", + async ({ cwd, id, root }) => { + const h = connection({ ownedSubscriptions: false, workspaceMultiplicity: false }); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const workspaces = h.client.observeWorkspaces(); + const request = h.sent.at(-1)!.message!; + expect(request.type).toBe("fetch_agents_request"); + h.receive({ + type: "fetch_agents_response", + payload: { + requestId: request.requestId, + entries: [legacyAgent({ id: "agent", cwd, projectRoot: root, status: "idle" })], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + }, + }); + expect((await workspaces.ready).entries).toEqual([ + expect.objectContaining({ + id, + workspaceDirectory: cwd, + projectRootPath: root, + projectId: "/repo", + status: "done", + }), + ]); + const updates: unknown[] = []; + workspaces.subscribe({ snapshot: () => {}, update: (message) => updates.push(message) }); + const changed = legacyAgent({ id: "agent", cwd, projectRoot: root, status: "running" }); + h.receive({ type: "agent_update", payload: { kind: "upsert", ...changed } }); + expect(updates).toEqual([ + expect.objectContaining({ + type: "workspace_update", + payload: expect.objectContaining({ + kind: "upsert", + workspace: expect.objectContaining({ + id, + workspaceDirectory: cwd, + projectRootPath: root, + status: "running", + }), + }), + }), + ]); + const fetching = h.client.fetchAgent("agent"); + const detail = h.sent.at(-1)!.message!; + h.receive({ + type: "fetch_agent_response", + payload: { requestId: detail.requestId, ...changed, error: null }, + }); + expect((await fetching).agent?.workspaceId).toBe(id); + expect(h.sent.filter((frame) => frame.type === "hello")).toHaveLength(1); + await workspaces.release(); + } finally { + await h.client.close(); + } + }, +); + +test("legacy workspace pages contain only that page's groups and retain earlier live state", async () => { + const h = connection({ ownedSubscriptions: false, workspaceMultiplicity: false }); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const workspaces = h.client.observeWorkspaces(); + h.receive({ + type: "fetch_agents_response", + payload: { + requestId: h.sent.at(-1)!.message!.requestId, + entries: [legacyAgent({ id: "first", cwd: "/first", status: "running" })], + pageInfo: { hasMore: true, nextCursor: "next-page", prevCursor: null }, + }, + }); + const first = await workspaces.ready; + const next = h.client.fetchWorkspaces({ + page: { cursor: first.pageInfo.nextCursor!, limit: 1 }, + }); + h.receive({ + type: "fetch_agents_response", + payload: { + requestId: h.sent.at(-1)!.message!.requestId, + entries: [legacyAgent({ id: "second", cwd: "/second" })], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: "previous-page" }, + }, + }); + const second = await next; + expect([...first.entries, ...second.entries].map((workspace) => workspace.id)).toEqual([ + "/first", + "/second", ]); + const updates: unknown[] = []; + workspaces.subscribe({ snapshot: () => {}, update: (message) => updates.push(message) }); + // Earlier pages must remain in the aggregate: this idle sibling cannot + // replace the running status of the first workspace. + h.receive({ + type: "agent_update", + payload: { kind: "upsert", ...legacyAgent({ id: "sibling", cwd: "/first" }) }, + }); + expect(updates).toEqual([]); + h.receive({ type: "agent_update", payload: { kind: "remove", agentId: "first" } }); + expect(updates).toEqual([ + expect.objectContaining({ + type: "workspace_update", + payload: expect.objectContaining({ + kind: "upsert", + workspace: expect.objectContaining({ id: "/first", status: "done" }), + }), + }), + ]); + await workspaces.release(); + } finally { + await h.client.close(); + } +}); + +test("old attention stream events reach the current notification interface", async () => { + const h = connection({ ownedSubscriptions: false }); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const observation = h.client.observeEvents(["agent_attention_required"]); + await observation.ready; + const received: unknown[] = []; + observation.subscribe({ snapshot: () => {}, update: (message) => received.push(message) }); + h.receive({ + type: "agent_stream", + payload: { + agentId: "agent", + timestamp: "2026-09-11T00:00:00Z", + event: { + type: "attention_required", + provider: "mock", + reason: "finished", + timestamp: "2026-09-11T00:00:00Z", + shouldNotify: true, + }, + }, + }); + expect(received).toEqual([ + { + type: "agent_attention_required", + payload: { + agentId: "agent", + reason: "finished", + timestamp: "2026-09-11T00:00:00Z", + shouldNotify: true, + subscriptionId: observation.subscriptionId, + }, + }, + ]); + await observation.release(); + } finally { + await h.client.close(); + } +}); + +test("broadcast-only hosts expose local timeline readiness without sending an unsupported RPC", async () => { + const h = connection({ ownedSubscriptions: false, broadcasts: true }); + try { + const connected = h.client.connect(); + h.open(); + await connected; + const observation = h.client.observeTimeline(["agent"]); + expect(await observation.ready).toEqual({ + agentIds: ["agent"], + requestId: expect.any(String), + subscriptionId: observation.subscriptionId, + }); + expect(h.sent).toHaveLength(1); + await observation.release(); + expect(h.sent).toHaveLength(1); + } finally { + await h.client.close(); + } +}); + +test("old browser hosting requires the registration sent in hello", async () => { + const h = connection({ ownedSubscriptions: false }); + try { + const connected = h.client.connect(); + h.open(); + await connected; + const observation = h.client.registerBrowserHost({ + hostKind: "test", + supportedCommands: ["list_tabs"], + }); + await expect(observation.ready).rejects.toThrow("browser_host"); + expect(h.sent).toHaveLength(1); + } finally { + await h.client.close(); + } +}); + +test("old timeline observers retain shared membership and restore surviving interests", async () => { + const h = connection({ ownedSubscriptions: false }); + try { + const connected = h.client.connect(); + h.open(); + await connected; + const app = h.client.observeTimeline(["app"]); + const plugin = h.client.observeTimeline(["plugin"]); + await Promise.all([app.ready, plugin.ready]); + expect(h.sent.at(-1)?.message?.agentIds).toEqual(["app", "plugin"]); + await plugin.release(); + expect(h.sent.at(-1)?.message?.agentIds).toEqual(["app"]); + h.disconnect(); + const reconnecting = h.client.connect(); + h.open(); + await reconnecting; + await expect.poll(() => h.sent.at(-1)?.message?.agentIds).toEqual(["app"]); + await app.release(); + expect(h.sent.at(-1)?.message?.agentIds).toEqual([]); + expect(h.sent.some((frame) => frame.message?.type === "subscription.release.request")).toBe( + false, + ); + } finally { + await h.client.close(); + } +}); + +test("old browser hosts attach to their hello registration without another connection", async () => { + const registration = { + hostKind: "desktop app", + supportedCommands: ["list_tabs"] as ["list_tabs"], + }; + const h = connection({ ownedSubscriptions: false, browserHost: registration }); + try { + const connected = h.client.connect(); + h.open(); + await connected; + const observation = h.client.registerBrowserHost(registration); + expect(await observation.ready).toEqual({ + requestId: expect.any(String), + subscriptionId: observation.subscriptionId, + }); + await observation.release(); + expect(h.sent).toHaveLength(1); + } finally { + await h.client.close(); + } +}); + +test("delivery follows the negotiated client capability as well as the server feature", async () => { + const h = connection({ ownedCapability: false }); + try { + const connected = h.client.connect(); + h.open(); + await connected; + const observation = h.client.observeEvents(["project.update"]); + expect((await observation.ready).subscriptionId).toMatch(/^legacy:/); + await observation.release(); + expect(h.sent.at(-1)?.message).toMatchObject({ + type: "session.events.set_subscription.request", + events: [], + }); + } finally { + await h.client.close(); + } +}); + +test("subscribeFile returns its initial version without calling the change callback", async () => { + const h = connection(); + try { + const connected = h.client.connect(); + h.open(); + await connected; + const changes: unknown[] = []; + const initial = { status: "missing" as const, cwd: "/repo", path: "file.txt" }; + const subscribing = h.client.subscribeFile({ cwd: "/repo", path: "file.txt" }, (value) => + changes.push(value), + ); + h.receive({ + type: "fs.file.subscribe.response", + payload: { + requestId: h.sent.at(-1)!.message!.requestId, + subscriptionId: "file-owner", + initial, + }, + }); + const file = await subscribing; + expect(file.initial).toEqual(initial); + expect(changes).toEqual([]); + const version = { + status: "ready", + cwd: "/repo", + path: "file.txt", + size: 1, + modifiedAt: "2026-09-11T00:00:00Z", + }; + h.receive({ type: "fs.file.update", payload: { subscriptionId: "file-owner", version } }); + expect(changes).toEqual([version]); + await file.unsubscribe(); } finally { await h.client.close(); } diff --git a/packages/client/src/connection/index.ts b/packages/client/src/connection/index.ts index 8d36083c99..c0681852d7 100644 --- a/packages/client/src/connection/index.ts +++ b/packages/client/src/connection/index.ts @@ -1,6 +1,127 @@ export { OwnedSubscriptions, type OwnedSubscription, type SubscriptionObserver } from "./owned.js"; +import { + OwnedSubscriptions, + type OwnedSubscription, + type OwnedSubscriptionsHost, +} from "./owned.js"; +import { LegacySubscriptions, type ObservationRequest } from "./legacy.js"; +import { + type SessionInboundMessage, + type SessionOutboundMessage, + type ServerInfoStatusPayload, +} from "@getpaseo/protocol/messages"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; +export class ConnectionSubscriptions extends OwnedSubscriptions { + private legacy: LegacySubscriptions | null = null; + + constructor( + private readonly connection: OwnedSubscriptionsHost & { + send(message: ObservationRequest): Promise; + }, + ) { + super({ + ...connection, + release: async (id) => { + if (!this.legacy) return connection.release(id); + const message = this.legacy.release(id); + if (message) await connection.send(message); + }, + }); + } + + override restore( + info?: ServerInfoStatusPayload, + capabilities?: Partial>, + ): void { + if (info) { + const clientCapabilities = capabilities ?? DEFAULT_CLIENT_CAPABILITIES; + const owned = + info.features?.ownedSubscriptions === true && + clientCapabilities[CLIENT_CAPS.ownedSubscriptions] === true; + this.legacy = null; + if (!owned) { + this.legacy = new LegacySubscriptions( + info, + this.connection.send, + capabilities?.[CLIENT_CAPS.browserHost], + ); + } + } + super.restore(); + } + + override disconnected(): void { + super.disconnected(); + this.legacy = null; + } + + observeRequest( + query: ObservationRequest, + request: (query: ObservationRequest, accept: (snapshot: T) => void) => Promise, + signal?: AbortSignal, + failed?: () => void, + ): OwnedSubscription { + return super.observe( + async (accept) => { + const legacy = this.legacy; + if (!legacy) return request(query, accept); + const interest = legacy.start(query); + const snapshot = (payload: T): T & { subscriptionId: string } => ({ + ...payload, + subscriptionId: interest.id, + }); + try { + const message = legacy.request(interest); + if (!message) { + // Broadcast-era hosts have no acknowledgement. Readiness means the + // local listener is attached; no server membership is being claimed. + const value = snapshot({ + requestId: interest.id, + ...(query.type === "agent.timeline.set_subscription.request" + ? { agentIds: query.agentIds } + : {}), + } as T); + accept(value); + return value; + } + return snapshot(await request(message, (payload) => accept(snapshot(payload)))); + } catch (error) { + const release = legacy.release(interest.id); + if (release && this.legacy === legacy) + await this.connection.send(release).catch(this.connection.failed); + throw error; + } + }, + signal, + failed, + ); + } + + override owns(message: SessionOutboundMessage): boolean { + return this.legacy ? this.legacy.owns(message) : super.owns(message); + } + + override receive(message: SessionOutboundMessage): void { + if (this.legacy) this.legacy.receive(message, (update) => super.receive(update)); + else super.receive(message); + } + + normalize(message: SessionOutboundMessage): SessionOutboundMessage { + return this.legacy?.normalize(message) ?? message; + } + + prepareRequest(message: SessionInboundMessage) { + return ( + this.legacy?.prepareRequest(message) ?? { + message, + receive: (value: SessionOutboundMessage) => value, + finish: async () => {}, + } + ); + } +} + // 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 = { @@ -21,7 +142,7 @@ export const DEFAULT_CLIENT_CAPABILITIES = { [CLIENT_CAPS.explicitEventSubscriptions]: true, } satisfies Record, true>; -/** Calling releases demand; ready acknowledges the initial daemon membership. */ +/** Calling releases demand; ready waits for membership, or local attachment on broadcast hosts. */ export type TimelineSubscription = (() => void) & { readonly ready: Promise; readonly subscriptionId: string | null; diff --git a/packages/client/src/connection/legacy-workspaces.ts b/packages/client/src/connection/legacy-workspaces.ts new file mode 100644 index 0000000000..2cf90423b5 --- /dev/null +++ b/packages/client/src/connection/legacy-workspaces.ts @@ -0,0 +1,176 @@ +import { AgentSnapshotPayloadSchema } from "@getpaseo/protocol/messages"; +import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { + deriveAgentStateBucket, + getWorkspaceStateBucketPriority, +} from "@getpaseo/protocol/agent-state-bucket"; + +type AgentEntry = Extract< + SessionOutboundMessage, + { type: "fetch_agents_response" } +>["payload"]["entries"][number]; +type Workspace = Extract< + SessionOutboundMessage, + { type: "fetch_workspaces_response" } +>["payload"]["entries"][number]; +type WorkspaceUpdate = Extract; + +// Preserve the pre-registry app's identity format. This is an opaque ID, never +// a filesystem path: converting C:\ to C: changes a drive root into a relative path. +function legacyWorkspaceId(value: string): string { + return value.trim().replace(/\\/g, "/").replace(/\/+$/, "") || "/"; +} + +// COMPAT(legacyWorkspaceDaemon): restored in v0.8.0; remove after 2027-03-11 once daemon floor >= v0.1.97. +// The pre-registry app grouped agents by checkout path. Keep that representation +// at the client edge, including live updates, so app workflows need no version branch. +export class LegacyWorkspaces { + private readonly agents = new Map(); + + normalize(message: SessionOutboundMessage): SessionOutboundMessage { + const stamp = (agent: AgentSnapshotPayload): AgentSnapshotPayload => ({ + ...agent, + workspaceId: agent.workspaceId ?? legacyWorkspaceId(agent.cwd), + }); + if (message.type === "fetch_agents_response") + return { + ...message, + payload: { + ...message.payload, + entries: message.payload.entries.map((entry) => ({ + ...entry, + agent: stamp(entry.agent), + })), + }, + }; + if (message.type === "agent_update" && message.payload.kind === "upsert") + return { ...message, payload: { ...message.payload, agent: stamp(message.payload.agent) } }; + if ( + (message.type === "fetch_agent_response" || + message.type === "fetch_agent_timeline_response" || + message.type === "cancel_agent_response") && + message.payload.agent + ) { + return { + ...message, + payload: { ...message.payload, agent: stamp(message.payload.agent) }, + } as SessionOutboundMessage; + } + if ( + message.type === "status" && + (message.payload.status === "agent_created" || message.payload.status === "agent_resumed") + ) { + return { + ...message, + payload: { + ...message.payload, + agent: stamp(AgentSnapshotPayloadSchema.parse(message.payload.agent)), + }, + }; + } + return message; + } + + read(entries: AgentEntry[], reset: boolean): Workspace[] { + if (reset) this.agents.clear(); + for (const entry of entries) this.agents.set(entry.agent.id, entry); + const pageIds = new Set(entries.map(workspaceId)); + return [...this.workspaces().values()].filter((workspace) => pageIds.has(workspace.id)); + } + + update(message: SessionOutboundMessage): WorkspaceUpdate[] { + if (message.type !== "agent_update") return []; + const before = this.workspaces(); + const update = message.payload; + if (update.kind === "remove") this.agents.delete(update.agentId); + else { + const project = update.project ?? this.agents.get(update.agent.id)?.project; + if (update.agent.archivedAt) this.agents.delete(update.agent.id); + else if (project) this.agents.set(update.agent.id, { agent: update.agent, project }); + } + const after = this.workspaces(); + const changes: WorkspaceUpdate[] = []; + for (const [id, workspace] of after) { + if (JSON.stringify(before.get(id)) !== JSON.stringify(workspace)) + changes.push({ type: "workspace_update", payload: { kind: "upsert", workspace } }); + } + for (const id of before.keys()) + if (!after.has(id)) + changes.push({ type: "workspace_update", payload: { kind: "remove", id } }); + return changes; + } + + private workspaces(): Map { + const workspaces = new Map(); + for (const entry of this.agents.values()) { + const { agent, project } = entry; + const { checkout } = project; + const id = workspaceId(entry); + const status = deriveAgentStateBucket({ + status: agent.status, + pendingPermissionCount: agent.pendingPermissions.length, + requiresAttention: agent.requiresAttention, + attentionReason: agent.attentionReason, + }); + const existing = workspaces.get(id); + if ( + existing && + getWorkspaceStateBucketPriority(existing.status) <= getWorkspaceStateBucketPriority(status) + ) + continue; + workspaces.set(id, { + id, + projectId: project.projectKey, + projectDisplayName: project.projectName, + projectCustomName: null, + projectRootPath: checkout.mainRepoRoot ?? checkout.worktreeRoot ?? checkout.cwd, + workspaceDirectory: checkout.cwd, + projectKind: checkout.isGit ? "git" : "non_git", + workspaceKind: workspaceKind(checkout), + name: workspaceName(entry, id), + title: null, + status, + statusEnteredAt: agent.attentionTimestamp ?? agent.updatedAt, + activityAt: agent.updatedAt, + archivingAt: null, + diffStat: null, + scripts: [], + gitRuntime: gitRuntime(checkout), + githubRuntime: null, + project, + }); + } + return workspaces; + } +} + +function workspaceId(entry: AgentEntry): string { + return entry.agent.workspaceId ?? legacyWorkspaceId(entry.project.checkout.cwd); +} + +function workspaceKind(checkout: AgentEntry["project"]["checkout"]): Workspace["workspaceKind"] { + if (!checkout.isGit) return "directory"; + if (checkout.isPaseoOwnedWorktree) return "worktree"; + return "checkout"; +} + +function workspaceName(entry: AgentEntry, id: string): string { + const name = entry.project.workspaceName?.trim(); + if (name) return name; + const branch = entry.project.checkout.currentBranch?.trim(); + if (branch && branch !== "HEAD") return branch; + return id.slice(id.lastIndexOf("/") + 1); +} + +function gitRuntime(checkout: AgentEntry["project"]["checkout"]): Workspace["gitRuntime"] { + if (!checkout.isGit) return null; + return { + currentBranch: checkout.currentBranch, + remoteUrl: checkout.remoteUrl, + isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree, + isDirty: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + }; +} diff --git a/packages/client/src/connection/legacy.ts b/packages/client/src/connection/legacy.ts new file mode 100644 index 0000000000..5dec96e428 --- /dev/null +++ b/packages/client/src/connection/legacy.ts @@ -0,0 +1,282 @@ +import { SessionInboundMessageSchema } from "@getpaseo/protocol/messages"; +import { LegacyWorkspaces } from "./legacy-workspaces.js"; +import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities"; +import type { + ServerInfoStatusPayload, + SessionInboundMessage, + SessionOutboundMessage, +} from "@getpaseo/protocol/messages"; + +export type ObservationRequest = { type: SessionInboundMessage["type"] } & Record; +interface Interest { + id: string; + query: ObservationRequest; +} + +// COMPAT(ownedSubscriptions): added in v0.8.0; remove after 2027-03-11 once daemon floor >= v0.8.0. +// Legacy directories retain their connection-wide, last-query-wins semantics. +// IDs below identify local listeners, not independent server subscriptions. +export class LegacySubscriptions { + private readonly interests = new Map(); + + private readonly workspaces: LegacyWorkspaces | null; + constructor( + private readonly info: ServerInfoStatusPayload, + private readonly send: (message: ObservationRequest) => Promise, + private readonly browserHost: unknown, + ) { + this.workspaces = info.features?.workspaceMultiplicity === true ? null : new LegacyWorkspaces(); + } + + normalize(message: SessionOutboundMessage): SessionOutboundMessage { + return this.workspaces?.normalize(message) ?? message; + } + + prepareRequest(message: SessionInboundMessage): { + message: SessionInboundMessage; + receive(message: SessionOutboundMessage): SessionOutboundMessage; + finish(): Promise; + } { + const identity = { + message, + receive: (value: SessionOutboundMessage) => value, + finish: async () => {}, + }; + const workspaces = this.workspaces; + if (workspaces && message.type === "fetch_workspaces_request") { + return { + ...identity, + message: SessionInboundMessageSchema.parse({ + type: "fetch_agents_request", + requestId: message.requestId, + scope: "active", + sort: [{ key: "updated_at", direction: "desc" }], + page: message.page, + subscribe: message.subscribe, + }), + receive: (value) => { + if (value.type !== "fetch_agents_response") return value; + if (value.payload.requestId !== message.requestId) return value; + return { + type: "fetch_workspaces_response", + payload: { + ...value.payload, + entries: workspaces.read(value.payload.entries, !message.page?.cursor), + emptyProjects: [], + }, + }; + }, + }; + } + if (message.type === "subscribe_terminals_request") { + return { + ...identity, + receive: (value) => { + if (value.type !== "terminals_changed" || value.payload.cwd !== message.cwd) return value; + return { ...value, payload: { ...value.payload, requestId: message.requestId } }; + }, + }; + } + if (message.type === "workspace.label.list.request" && !message.subscribe) { + return { + ...identity, + message: { ...message, subscribe: { subscriptionId: `legacy:${crypto.randomUUID()}` } }, + }; + } + if (message.type === "checkout.diff.get.request") { + const subscriptionId = `legacy:${crypto.randomUUID()}`; + return { + message: SessionInboundMessageSchema.parse({ + ...message, + type: "subscribe_checkout_diff_request", + subscriptionId, + }), + receive: (value) => { + if (value.type !== "subscribe_checkout_diff_response") return value; + if (value.payload.requestId !== message.requestId) return value; + return { ...value, type: "checkout.diff.get.response" }; + }, + finish: () => this.send({ type: "unsubscribe_checkout_diff_request", subscriptionId }), + }; + } + return identity; + } + + start(query: ObservationRequest): Interest { + const interest = { id: `legacy:${crypto.randomUUID()}`, query }; + this.interests.set(interest.id, interest); + return interest; + } + + request({ id, query }: Interest): ObservationRequest | null { + switch (query.type) { + case "fetch_agents_request": + case "fetch_workspaces_request": + case "workspace.label.list.request": + return { ...query, subscribe: { subscriptionId: id } }; + case "fs.file.subscribe.request": + case "subscribe_checkout_diff_request": + return { ...query, subscriptionId: id }; + case "agent.timeline.set_subscription.request": + if (!this.info.features?.selectiveAgentTimeline) return null; + return { ...query, agentIds: this.members(query.type, "agentIds") }; + case "session.events.set_subscription.request": + if (!this.info.features?.explicitEventSubscriptions) return null; + return { + ...query, + events: this.members(query.type, "events").filter(isLegacyEvent), + }; + case "browser.host.register.request": + if (!matchesBrowserRegistration(this.browserHost, query)) { + throw new Error( + "This daemon requires browser_host registration in the client's hello capabilities", + ); + } + return null; // Older hosts register in hello. + default: + return query; + } + } + + private members(type: ObservationRequest["type"], field: string): string[] { + const members = new Set(); + for (const interest of this.interests.values()) { + if (interest.query.type !== type) continue; + for (const value of interest.query[field] as string[]) members.add(value); + } + return [...members].sort(); + } + + release(id: string): ObservationRequest | null { + const interest = this.interests.get(id); + if (!interest) return null; + this.interests.delete(id); + const { query } = interest; + switch (query.type) { + case "agent.timeline.set_subscription.request": + case "session.events.set_subscription.request": + return this.request(interest); + case "fs.file.subscribe.request": + return { type: "fs.file.unsubscribe.request", subscriptionId: id }; + case "subscribe_checkout_diff_request": + return { type: "unsubscribe_checkout_diff_request", subscriptionId: id }; + case "subscribe_terminal_request": + if ( + [...this.interests.values()].some( + (item) => item.query.type === query.type && item.query.terminalId === query.terminalId, + ) + ) + return null; + return { type: "unsubscribe_terminal_request", terminalId: query.terminalId }; + case "subscribe_terminals_request": + if ( + [...this.interests.values()].some( + (item) => + item.query.type === query.type && + item.query.cwd === query.cwd && + item.query.workspaceId === query.workspaceId, + ) + ) + return null; + return { + type: "unsubscribe_terminals_request", + cwd: query.cwd, + workspaceId: query.workspaceId, + }; + default: + // Directory slots have no release RPC. Detach the local listener. + return null; + } + } + + owns(message: SessionOutboundMessage): boolean { + return [...this.interests.values()].some((interest) => this.matches(interest, message)); + } + + receive( + message: SessionOutboundMessage, + deliver: (message: SessionOutboundMessage) => void, + ): void { + const updates: SessionOutboundMessage[] = [ + message, + ...(this.workspaces?.update(message) ?? []), + ]; + if (message.type === "agent_stream" && message.payload.event.type === "attention_required") { + const { agentId, event } = message.payload; + updates.push({ + type: "agent_attention_required", + payload: { + agentId, + reason: event.reason, + timestamp: event.timestamp, + shouldNotify: event.shouldNotify, + ...(event.notification ? { notification: event.notification } : {}), + }, + }); + } + for (const update of updates) + for (const interest of this.interests.values()) { + if (!this.matches(interest, update)) continue; + // Normalize at the connection edge; all consumers receive the same handle shape. + deliver( + ("payload" in update + ? { ...update, payload: { ...update.payload, subscriptionId: interest.id } } + : { ...update, subscriptionId: interest.id }) as SessionOutboundMessage, + ); + } + } + + private matches({ id, query }: Interest, message: SessionOutboundMessage): boolean { + switch (query.type) { + case "fetch_agents_request": + return message.type === "agent_update"; + case "fetch_workspaces_request": + return message.type === "workspace_update"; + case "workspace.label.list.request": + return message.type === "workspace.label.update"; + case "fs.file.subscribe.request": + return message.type === "fs.file.update" && message.payload.subscriptionId === id; + case "subscribe_checkout_diff_request": + return message.type === "checkout_diff_update" && message.payload.subscriptionId === id; + case "subscribe_terminals_request": + return message.type === "terminals_changed" && message.payload.cwd === query.cwd; + case "subscribe_terminal_request": + return ( + message.type === "terminal_stream_exit" && message.payload.terminalId === query.terminalId + ); + case "agent.timeline.set_subscription.request": + return ( + (message.type === "agent_stream" || message.type === "agent.timeline.replacement") && + (query.agentIds as string[]).includes(message.payload.agentId) + ); + case "session.events.set_subscription.request": + return (query.events as string[]).includes( + message.type === "status" ? `status.${message.payload.status}` : message.type, + ); + case "browser.host.register.request": + return message.type === "browser.automation.execute.request"; + default: + return false; + } + } +} + +function isLegacyEvent(event: string): boolean { + return [ + "project.update", + "providers_snapshot_update", + "agent_attention_required", + "agent_permission_request", + "agent_permission_resolved", + ].includes(event); +} + +function matchesBrowserRegistration(advertised: unknown, request: ObservationRequest): boolean { + const host = BrowserAutomationHostCapabilitySchema.safeParse(advertised); + if (!host.success) return false; + if (host.data.hostKind !== request.hostKind) return false; + for (const command of request.supportedCommands as string[]) { + if (!host.data.supportedCommands.some((supported) => supported === command)) return false; + } + return true; +} diff --git a/packages/client/src/connection/owned.ts b/packages/client/src/connection/owned.ts index 8aa3c94b17..56bf6b98f0 100644 --- a/packages/client/src/connection/owned.ts +++ b/packages/client/src/connection/owned.ts @@ -8,7 +8,7 @@ export interface SubscriptionObserver { error?(error: unknown): void; } -/** A stable local lifetime. The host ID changes when the transport reconnects. */ +/** A stable local lifetime. IDs change on reconnect; legacy IDs identify local listeners only. */ export interface OwnedSubscription { readonly subscriptionId: string | null; readonly ready: Promise>; diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index bf41e06961..7ecf6130b8 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -328,7 +328,7 @@ test("Hub management requires daemon support before dispatching requests", async expect(mock.sent).toEqual([]); }); -test("owned timeline observation requires the host feature", async () => { +test("timeline observation consumes broadcasts from a host without selective delivery", async () => { const mock = createMockTransport(); const client = new DaemonClient({ url: "ws://test", @@ -341,7 +341,21 @@ test("owned timeline observation requires the host feature", async () => { mock.triggerOpen({ features: {} }); await connecting; const observation = client.observeTimeline(["agent"]); - await expect(observation.ready).rejects.toThrow("Update the host"); + await expect(observation.ready).resolves.toMatchObject({ agentIds: ["agent"] }); + const updates: unknown[] = []; + observation.subscribe({ snapshot: () => {}, update: (message) => updates.push(message) }); + mock.triggerMessage( + wrapSessionMessage({ + type: "agent_stream", + payload: { + agentId: "agent", + timestamp: "2026-09-11T00:00:00Z", + event: { type: "turn_completed", provider: "codex" }, + }, + }), + ); + expect(updates).toEqual([expect.objectContaining({ type: "agent_stream" })]); + await observation.release(); expect(mock.sent).toEqual([]); }); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 042541f5ca..e012c7c3df 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,7 +1,7 @@ import { subscribeTimeline, type TimelineMessage } from "./timeline-subscription/index.js"; import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js"; import { - OwnedSubscriptions, + ConnectionSubscriptions, type OwnedSubscription, DEFAULT_CLIENT_CAPABILITIES, type TimelineSubscription, @@ -1094,7 +1094,11 @@ export class DaemonClient { emit: (message) => this.deliverSessionMessage(message), failed: (error) => this.logger.error({ err: error }, "Failed to resolve provider snapshot"), }); - private readonly owned = new OwnedSubscriptions({ + private readonly owned = new ConnectionSubscriptions({ + send: (message) => + this.sendSessionMessageOrThrow( + SessionInboundMessageSchema.parse({ ...message, requestId: this.createRequestId() }), + ), release: async (subscriptionId) => { if (!this.isConnected) return; try { @@ -1731,6 +1735,7 @@ export class DaemonClient { select: (msg: SessionOutboundMessage) => T | null; options?: { skipQueue?: boolean }; }): Promise { + const wire = this.owned.prepareRequest(params.message); const timeout = params.timeout ?? DEFAULT_SESSION_RPC_TIMEOUT_MS; const { promise, cancel } = this.waitForWithCancel>( (msg) => { @@ -1745,7 +1750,7 @@ export class DaemonClient { }), }; } - const value = params.select(msg); + const value = params.select(wire.receive(msg)); if (value === null) { return null; } @@ -1756,7 +1761,7 @@ export class DaemonClient { ); try { - await this.sendSessionMessageOrThrow(params.message); + await this.sendSessionMessageOrThrow(wire.message); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); cancel(err); @@ -1764,11 +1769,13 @@ export class DaemonClient { throw err; } - const result = await promise; - if (result.kind === "error") { - throw result.error; + try { + const result = await promise; + if (result.kind === "error") throw result.error; + return result.value; + } finally { + await wire.finish(); } - return result.value; } private async sendCorrelatedRequest< @@ -2098,23 +2105,18 @@ export class DaemonClient { // Agent RPCs (requestId-correlated) // ============================================================================ - private requireOwnedSubscriptions(): void { - if (this.lastServerInfoMessage?.features?.ownedSubscriptions !== true) - throw new Error("Update the host to use independent subscriptions."); - } - private observe( responseType: T, message: { type: SessionInboundMessage["type"] } & Record, options?: { requestId?: string; timeout?: number; signal?: AbortSignal }, ): OwnedSubscription> { let resetSource = false; - return this.owned.observe>( - async (accept) => { + return this.owned.observeRequest>( + message, + async (query, accept) => { resetSource = false; - this.requireOwnedSubscriptions(); const requestId = this.createRequestId(options?.requestId); - const request = SessionInboundMessageSchema.parse({ ...message, requestId }); + const request = SessionInboundMessageSchema.parse({ ...query, requestId }); try { return await this.sendCorrelatedRequest>({ message: request, @@ -3177,17 +3179,8 @@ export class DaemonClient { agentId: string, handler: (message: TimelineMessage) => void, ): TimelineSubscription { - return subscribeTimeline( - agentId, - this.observeTimeline([agentId]), - () => - this.fetchAgentTimeline(agentId, { - direction: "before", - limit: 100, - projection: "projected", - }), - handler, - (error) => this.logger.error({ err: error }, "Timeline observation failed"), + return subscribeTimeline(agentId, this.observeTimeline([agentId]), handler, (error) => + this.logger.error({ err: error }, "Timeline observation failed"), ); } @@ -3900,7 +3893,6 @@ export class DaemonClient { compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, requestId?: string, ): Promise { - this.requireOwnedSubscriptions(); return this.sendCorrelatedSessionRequest({ message: { type: "checkout.diff.get.request", @@ -4550,8 +4542,13 @@ export class DaemonClient { unsubscribe: () => Promise; }> { const subscription = this.observeFile(input); + let initial = true; subscription.subscribe({ - snapshot: (snapshot) => onUpdate(snapshot.initial), + snapshot: (snapshot) => { + // The initial version is returned below. Subsequent snapshots repair reconnects. + if (!initial) onUpdate(snapshot.initial); + initial = false; + }, update: (message) => { if (message.type === "fs.file.update") onUpdate(message.payload.version); }, @@ -6142,6 +6139,7 @@ export class DaemonClient { this.lastErrorValue = reason.trim(); } + this.owned.disconnected(); this.providerSnapshotUpdates.clear(); // Clear all pending waiters and queued sends since the connection was lost @@ -6242,6 +6240,7 @@ export class DaemonClient { } private handleSessionMessage(msg: SessionOutboundMessage): void { + msg = this.owned.normalize(msg); if ( msg.type === "providers_snapshot_update" && this.config.providerSnapshots !== "wire" && @@ -6269,7 +6268,10 @@ export class DaemonClient { this.reconnectAttempt = 0; this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" }); this.startLivenessHeartbeat(); - this.owned.restore(); + this.owned.restore(serverInfo, { + ...DEFAULT_CLIENT_CAPABILITIES, + ...this.config.capabilities, + }); this.flushPendingSendQueue(); this.resolveConnect(); } diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 508eba4c61..9aed42febc 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -102,9 +102,9 @@ async function connectClient( ownedSubscriptions: true, }, ): Promise<{ client: PaseoClient; ws: FakeWebSocket }> { - vi.stubGlobal("WebSocket", FakeWebSocket); const client = createPaseoClient({ url: "ws://daemon.test", + webSocketFactory: (url) => new FakeWebSocket(url), reconnect: { enabled: false }, }); @@ -377,13 +377,15 @@ test("project actions list registered projects through the existing RPC", async test("project actions subscribe to existing project updates", async () => { const { client, ws } = await connectClient(); - const observation = client.observeEvents(["project.update"]); - acknowledgeObservation(ws, "projects-sdk"); - await observation.ready; const updates: string[] = []; const unsubscribe = client.projects.subscribe((update) => { updates.push(update.kind === "upsert" ? update.project.projectDisplayName : update.projectId); }); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "session.events.set_subscription.request", + events: ["project.update"], + }); + acknowledgeObservation(ws, "projects-sdk"); ws.message( sessionMessage({ @@ -1367,15 +1369,17 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd ], }); - const observation = client.observeEvents(["providers_snapshot_update"]); - acknowledgeObservation(ws, "provider-updates"); - await observation.ready; const snapshotUpdates: string[] = []; const snapshotModelDefaults: Array = []; const unsubscribe = client.providers.subscribe((update) => { snapshotUpdates.push(update.generatedAt); snapshotModelDefaults.push(update.entries[0]?.models?.[0]?.defaultThinkingOptionId); }); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "session.events.set_subscription.request", + events: ["providers_snapshot_update"], + }); + acknowledgeObservation(ws, "provider-updates"); ws.message( sessionMessage({ type: "providers_snapshot_update", @@ -1408,15 +1412,25 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd await client.close(); }); -test("waitForReady requires owned subscriptions from the host", async () => { +test("waitForReady reads an older host on the existing connection", async () => { const { client, ws } = await connectClient({}); - const sentBeforeWait = ws.sent.length; - - await expect(client.providers.waitForReady({ cwd: "/repo/./sdk" })).rejects.toThrow( - "Update the host to use independent subscriptions.", + const waiting = client.providers.waitForReady({ cwd: "/repo/./sdk" }); + await expect + .poll(() => parseSentSessionMessage(ws.sent.at(-1)).type) + .toBe("get_providers_snapshot_request"); + const request = parseSentSessionMessage(ws.sent.at(-1)); + ws.message( + sessionMessage({ + type: "get_providers_snapshot_response", + payload: { + requestId: request.requestId, + entries: [], + generatedAt: "2026-09-11T00:00:00.000Z", + }, + }), ); - expect(ws.sent).toHaveLength(sentBeforeWait); - + await expect(waiting).resolves.toMatchObject({ entries: [] }); + expect(FakeWebSocket.instances).toHaveLength(1); await client.close(); }); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index fb67a4228a..8485b063af 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -303,7 +303,7 @@ export type PaseoAgentTimelineEvent = | { agentId: string; subscriptionId: string; - event: { type: "snapshot"; reason: "reconnect"; page: FetchAgentTimelinePayload }; + event: { type: "subscription_restored" }; } | { agentId: string; event: { type: "error"; error: string } }; @@ -318,10 +318,10 @@ export interface PaseoAgentTimelineHandle { */ refetch(options?: PaseoAgentTimelineRefetchOptions): Promise; /** - * Initially delivers live events only. Reconnect delivers a projected snapshot - * of the latest 100 entries before subsequent updates. Replace your recent view - * with this page; use its cursors to load older history. A replacement event - * invalidates the previous epoch. Recovery errors release this observation. + * Delivers live events only. After reconnect, subscription_restored precedes + * subsequent updates. History may have been missed; use refetch() to request + * the range you need. No history is fetched automatically. A replacement event + * invalidates the previous epoch. Subscription errors release this observation. * Await the returned unsubscribe function's `ready` promise before starting * work that must be observed. It rejects if establishment fails. */ @@ -520,8 +520,6 @@ export function createPaseoApi( const handles = new Set<{ release(): Promise }>(); const agentListeners = new Set(); const workspaceListeners = new Set(); - const projectListeners = new Set(); - const providerListeners = new Set<(update: PaseoProviderSnapshotUpdate) => void>(); const lifetime = new AbortController(); const own = }>(create: () => T): T => { if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); @@ -598,8 +596,6 @@ export function createPaseoApi( scopeOptions?.signal?.removeEventListener("abort", abort); agentListeners.clear(); workspaceListeners.clear(); - projectListeners.clear(); - providerListeners.clear(); disposal = Promise.allSettled([...handles].map((handle) => handle.release())).then( (results) => { handles.clear(); @@ -619,18 +615,20 @@ export function createPaseoApi( if (scopeOptions?.signal?.aborted) abort(); else scopeOptions?.signal?.addEventListener("abort", abort, { once: true }); - const observeEvents: DaemonClient["observeEvents"] = (events, options) => { - const subscription = own(() => daemonClient.observeEvents(events, options)); - subscription.subscribe({ - snapshot: () => {}, - update: (message) => { - if (message.type === "project.update") - for (const listener of projectListeners) listener(message.payload); - if (message.type === "providers_snapshot_update") - for (const listener of providerListeners) listener(message.payload); - }, - }); - return subscription; + const observeEvents: DaemonClient["observeEvents"] = (events, options) => + own(() => daemonClient.observeEvents(events, options)); + + const subscribeEvent = ( + event: "project.update" | "providers_snapshot_update", + update: (message: SessionOutboundMessage) => void, + ): (() => void) => { + const observation = observeEvents([event]); + observation.subscribe({ snapshot: () => {}, update }); + return () => { + void observation + .release() + .catch((error) => console.error("Event subscription cleanup failed", error)); + }; }; function listWorkspaces(options: PaseoWorkspaceListOptions & { subscribe: {} }): Promise< @@ -686,11 +684,9 @@ export function createPaseoApi( projects: { list: (options) => daemonClient.listProjects(options), subscribe: (handler) => { - if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); - projectListeners.add(handler); - return () => { - projectListeners.delete(handler); - }; + return subscribeEvent("project.update", (message) => { + if (message.type === "project.update") handler(message.payload); + }); }, }, workspaces: { @@ -735,11 +731,9 @@ export function createPaseoApi( diagnostic: (provider, options) => daemonClient.getProviderDiagnostic(provider, options), listUsage: (options) => listProviderUsage(daemonClient, options), subscribe: (handler) => { - if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); - providerListeners.add(handler); - return () => { - providerListeners.delete(handler); - }; + return subscribeEvent("providers_snapshot_update", (message) => { + if (message.type === "providers_snapshot_update") handler(message.payload); + }); }, }, config: { @@ -865,11 +859,11 @@ function createAgentHandleFactory( switch (message.type) { case "agent_stream": return handler(message.payload); - case "agent.timeline.snapshot": + case "agent.timeline.subscription_restored": return handler({ agentId: id, subscriptionId: message.payload.subscriptionId, - event: { type: "snapshot", reason: "reconnect", page: message.payload.page }, + event: { type: "subscription_restored" }, }); case "agent.timeline.error": return handler({ diff --git a/packages/client/src/terminal-stream-router.test.ts b/packages/client/src/terminal-stream-router.test.ts index 88b79efc29..7b9e598410 100644 --- a/packages/client/src/terminal-stream-router.test.ts +++ b/packages/client/src/terminal-stream-router.test.ts @@ -44,3 +44,20 @@ test("two registrations for one terminal retain independent slots and release", expect(a).toHaveLength(1); expect(b).toHaveLength(2); }); + +test("legacy listeners share a terminal slot without releasing each other", () => { + const router = new TerminalStreamRouter(); + const received: string[] = []; + const release = router.attach("first", "terminal", 1, () => received.push("first")); + router.attach("second", "terminal", 1, () => received.push("second")); + const frame = { + opcode: TerminalStreamOpcode.Output, + slot: 1, + payload: new TextEncoder().encode("bytes"), + }; + router.handleFrame(frame); + expect(received).toEqual(["first", "second"]); + release(); + router.handleFrame(frame); + expect(received).toEqual(["first", "second", "second"]); +}); diff --git a/packages/client/src/terminal-stream-router.ts b/packages/client/src/terminal-stream-router.ts index fa4d5e7613..a2c9575450 100644 --- a/packages/client/src/terminal-stream-router.ts +++ b/packages/client/src/terminal-stream-router.ts @@ -14,11 +14,11 @@ export type TerminalStreamEvent = { terminalId: string; subscriptionId: string } export class TerminalStreamRouter { private readonly slots = new Map< number, - { + Set<{ subscriptionId: string; terminalId: string; receive: (event: TerminalStreamEvent) => void; - } + }> >(); private readonly listeners = new Set<(event: TerminalStreamEvent) => void>(); @@ -37,9 +37,12 @@ export class TerminalStreamRouter { receive: (event: TerminalStreamEvent) => void, ): () => void { const registration = { subscriptionId, terminalId, receive }; - this.slots.set(slot, registration); + const registrations = this.slots.get(slot) ?? new Set(); + registrations.add(registration); + this.slots.set(slot, registrations); return () => { - if (this.slots.get(slot) === registration) this.slots.delete(slot); + registrations.delete(registration); + if (registrations.size === 0) this.slots.delete(slot); }; } @@ -48,28 +51,30 @@ export class TerminalStreamRouter { } handleFrame(frame: TerminalStreamFrame): void { - const registration = this.slots.get(frame.slot); - if (!registration) return; - const identity = { - subscriptionId: registration.subscriptionId, - terminalId: registration.terminalId, - }; - let event: TerminalStreamEvent; - if (frame.opcode === TerminalStreamOpcode.Output) - event = { ...identity, type: "output", data: frame.payload }; - else if (frame.opcode === TerminalStreamOpcode.Restore) - event = { ...identity, type: "restore", data: frame.payload }; - else if (frame.opcode === TerminalStreamOpcode.Snapshot) { - const state = decodeTerminalSnapshotPayload(frame.payload); - if (!state) return; - event = { ...identity, type: "snapshot", state }; - } else return; - registration.receive(event); - for (const listener of this.listeners) { - try { - listener(event); - } catch { - /* Passive diagnostics cannot interrupt delivery. */ + const registrations = this.slots.get(frame.slot); + if (!registrations) return; + for (const registration of registrations) { + const identity = { + subscriptionId: registration.subscriptionId, + terminalId: registration.terminalId, + }; + let event: TerminalStreamEvent; + if (frame.opcode === TerminalStreamOpcode.Output) + event = { ...identity, type: "output", data: frame.payload }; + else if (frame.opcode === TerminalStreamOpcode.Restore) + event = { ...identity, type: "restore", data: frame.payload }; + else if (frame.opcode === TerminalStreamOpcode.Snapshot) { + const state = decodeTerminalSnapshotPayload(frame.payload); + if (!state) return; + event = { ...identity, type: "snapshot", state }; + } else return; + registration.receive(event); + for (const listener of this.listeners) { + try { + listener(event); + } catch { + /* Passive diagnostics cannot interrupt delivery. */ + } } } } diff --git a/packages/client/src/timeline-subscription/index.ts b/packages/client/src/timeline-subscription/index.ts index 6671b26707..9293a0a739 100644 --- a/packages/client/src/timeline-subscription/index.ts +++ b/packages/client/src/timeline-subscription/index.ts @@ -1,5 +1,4 @@ import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; -import type { FetchAgentTimelinePayload } from "../daemon-client.js"; import type { OwnedSubscription, TimelineSubscription } from "../connection/index.js"; type TimelineUpdate = Extract< @@ -7,36 +6,24 @@ type TimelineUpdate = Extract< { type: "agent_stream" | "agent.timeline.replacement" } >; -/** Local SDK recovery messages; these are not additional wire RPCs. */ +/** Local SDK lifecycle messages; these are not additional wire RPCs. */ export type TimelineMessage = | TimelineUpdate | { - type: "agent.timeline.snapshot"; - payload: { agentId: string; subscriptionId: string; page: FetchAgentTimelinePayload }; + type: "agent.timeline.subscription_restored"; + payload: { agentId: string; subscriptionId: string }; } | { type: "agent.timeline.error"; payload: { agentId: string; error: string } }; -const MAX_RECOVERY_UPDATES = 128; - -/** A live-only initial observation, with a fresh bounded history view after each outage. */ +/** Restores live delivery; the consumer owns history reads and gap recovery. */ export function subscribeTimeline( agentId: string, observation: OwnedSubscription<{ agentIds: string[] }>, - readSnapshot: () => Promise, handler: (message: TimelineMessage) => void, reportError: (error: unknown) => void, ): TimelineSubscription { let established = false; let released = false; - interface Recovery { - id: string; - revision: number; - updates: TimelineUpdate[]; - replacement?: Extract; - } - let recovery: Recovery | null = null; - const current = (state: Recovery) => - !released && recovery === state && observation.subscriptionId === state.id; const notify = (message: TimelineMessage) => { if (released) return; try { @@ -47,7 +34,6 @@ export function subscribeTimeline( }; const release = () => { released = true; - recovery = null; return observation.release(); }; const stop = () => { @@ -61,65 +47,20 @@ export function subscribeTimeline( }); stop(); }; - const recover = async (state: Recovery) => { - while (current(state)) { - const revision = state.revision; - let page: FetchAgentTimelinePayload; - try { - page = await readSnapshot(); - } catch (error) { - if (current(state)) fail(error); - return; - } - if (!current(state)) return; - // Overflow or replacement makes this in-flight read obsolete. Read a fresh - // bounded page instead of retaining an unbounded backlog of transient events. - if (revision !== state.revision) continue; - if (state.replacement) notify(state.replacement); - if (!current(state)) return; - notify({ - type: "agent.timeline.snapshot", - payload: { agentId, subscriptionId: state.id, page }, - }); - if (!current(state)) return; - recovery = null; - for (const message of state.updates) { - if (released || observation.subscriptionId !== state.id) break; - if (message.type === "agent_stream" && typeof message.payload.seq === "number") { - if ( - message.payload.epoch !== page.epoch || - message.payload.seq <= (page.window.maxSeq ?? 0) - ) - continue; - } - notify(message); - } - } - }; observation.subscribe({ snapshot: ({ subscriptionId }) => { if (!established) { established = true; return; } - const state: Recovery = { id: subscriptionId, revision: 0, updates: [] }; - recovery = state; - void recover(state); + notify({ + type: "agent.timeline.subscription_restored", + payload: { agentId, subscriptionId }, + }); }, update: (message) => { - if (message.type !== "agent_stream" && message.type !== "agent.timeline.replacement") return; - if (!recovery) return notify(message); - if (message.type === "agent.timeline.replacement") { - recovery.replacement = message; - recovery.revision++; - recovery.updates.length = 0; - } else { - if (recovery.updates.length === MAX_RECOVERY_UPDATES) { - recovery.revision++; - recovery.updates.length = 0; - } - recovery.updates.push(message); - } + if (message.type === "agent_stream" || message.type === "agent.timeline.replacement") + notify(message); }, error: fail, }); diff --git a/packages/server/src/server/owned-subscriptions-client.e2e.test.ts b/packages/server/src/server/owned-subscriptions-client.e2e.test.ts index 1502f39047..623fa23ac6 100644 --- a/packages/server/src/server/owned-subscriptions-client.e2e.test.ts +++ b/packages/server/src/server/owned-subscriptions-client.e2e.test.ts @@ -104,13 +104,13 @@ test("public SDK scope cancellation during bootstrap releases the returned ID", } }); -test("a surviving public SDK timeline recovers persistent rows written during its socket outage", async () => { +test("a restored public SDK timeline leaves missed history to the consumer", async () => { const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); const sockets: WebSocket[] = []; const makeClient = (observer: boolean) => new PublicDaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, - clientId: observer ? "r5-observer" : "r5-actor", + clientId: observer ? "timeline-observer" : "timeline-actor", appVersion: "0.8.0", reconnect: { enabled: false }, webSocketFactory: (url) => { @@ -136,28 +136,32 @@ test("a surviving public SDK timeline recovers persistent rows written during it const handle = api.agents.ref(agent.id); const subscription = handle.timeline.subscribe((event) => received.push(event)); await subscription.ready; - await actor.sendMessage(agent.id, "before-outage-r5"); + await actor.sendMessage(agent.id, "before-outage"); await actor.waitForFinish(agent.id); - await expect.poll(() => JSON.stringify(received)).toContain("before-outage-r5"); + await expect.poll(() => JSON.stringify(received)).toContain("before-outage"); + const cached = await handle.timeline.refetch(); + expect(cached.endCursor).not.toBeNull(); const oldId = subscription.subscriptionId; sockets[0]!.terminate(); await expect.poll(() => observer.getConnectionState().status).not.toBe("connected"); - await actor.sendMessage(agent.id, "during-outage-r5"); + await actor.sendMessage(agent.id, "during-outage"); await actor.waitForFinish(agent.id); await observer.connect(); await expect .poll(() => subscription.subscriptionId) .toSatisfy((id: unknown) => typeof id === "string" && id !== oldId); - await actor.sendMessage(agent.id, "after-outage-r5"); + await expect.poll(() => JSON.stringify(received)).toContain("subscription_restored"); + expect(JSON.stringify(received)).not.toContain("during-outage"); + await actor.sendMessage(agent.id, "after-outage"); await actor.waitForFinish(agent.id); - await expect.poll(() => JSON.stringify(received)).toContain("after-outage-r5"); + await expect.poll(() => JSON.stringify(received)).toContain("after-outage"); const history = await handle.timeline.refetch({ - direction: "before", - limit: 100, + direction: "after", + cursor: cached.endCursor!, projection: "projected", }); - expect(JSON.stringify(history)).toContain("during-outage-r5"); - expect(JSON.stringify(received)).toContain("during-outage-r5"); + expect(JSON.stringify(history)).toContain("during-outage"); + expect(JSON.stringify(received)).not.toContain("during-outage"); } finally { await api.dispose(); await observer.close(); diff --git a/packages/server/src/server/owned-subscriptions.e2e.test.ts b/packages/server/src/server/owned-subscriptions.e2e.test.ts index 0ae518fae8..68437daf70 100644 --- a/packages/server/src/server/owned-subscriptions.e2e.test.ts +++ b/packages/server/src/server/owned-subscriptions.e2e.test.ts @@ -1,4 +1,5 @@ import { MockLoadTestAgentClient } from "./agent/providers/mock-load-test-agent.js"; +import { createPaseoApi } from "@getpaseo/client"; import { execFileSync } from "node:child_process"; import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; @@ -952,7 +953,7 @@ test("browser capability advertisement is passive and explicit hosts own command } }); -test("adding public SDK listeners does not create server observation demand", async () => { +test("adding raw client listeners does not create server observation demand", async () => { const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, @@ -1672,3 +1673,83 @@ test("mark unread replies remain source-owned while directory observers receive await daemon.close(); } }); + +test("legacy event subscribers retain notifications without the new notifications flag", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let legacy: SubscriptionPeer | undefined; + try { + await admin.connect(); + const workspace = ( + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) + ).workspace!; + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + workspaceId: workspace.id, + }); + legacy = await SubscriptionPeer.connect(daemon.port, "legacy-notifications", { + owned_subscriptions: false, + selective_agent_timeline: true, + explicit_event_subscriptions: true, + }); + await legacy.request({ + type: "fetch_agents_request", + requestId: "directory", + subscribe: { subscriptionId: "directory" }, + }); + await legacy.request({ + type: "session.events.set_subscription.request", + requestId: "events", + events: ["agent_attention_required"], + }); + legacy.send({ + type: "client_heartbeat", + deviceType: "web", + focusedAgentId: null, + lastActivityAt: new Date().toISOString(), + appVisible: true, + }); + await legacy.request({ type: "ping", requestId: "barrier", clientSentAt: 1 }); + const peer = legacy; + await admin.sendMessage(agent.id, "Finish a legacy notification turn"); + await expect + .poll( + () => + peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "agent_attention_required" + ? [frame.message.payload.shouldNotify] + : [], + ), + { timeout: 10_000 }, + ) + .toEqual([true]); + } finally { + legacy?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("public project subscriptions request updates and release their producer demand", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const api = createPaseoApi(client); + try { + await client.connect(); + const updates: unknown[] = []; + const unsubscribe = api.projects.subscribe((update) => updates.push(update)); + await client.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }); + await expect + .poll(() => updates) + .toEqual(expect.arrayContaining([expect.objectContaining({ kind: "upsert" })])); + unsubscribe(); + await expect + .poll(async () => (await client.collectDiagnostics()).diagnostic) + .toContain("Registrations: 0"); + } finally { + await api.dispose(); + await client.close(); + await daemon.close(); + } +}); diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index 301292edeb..b438af8381 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -1143,7 +1143,7 @@ describe("terminal output before natural exit", () => { }); describe("terminal snapshot failure", () => { - test.each(["initial", "legacy initial", "backpressure"])( + test.each(["initial", "backpressure"])( "releases only the failed observer after %s rejection", async (phase) => { vi.useFakeTimers(); @@ -1155,17 +1155,13 @@ describe("terminal snapshot failure", () => { }, phase === "backpressure"); try { const source = {}; - const failed = await f.subscribe(source, phase !== "legacy initial", "failed"); + const failed = await f.subscribe(source, true, "failed"); await vi.advanceTimersByTimeAsync(0); if (phase === "backpressure") { f.output("x".repeat(300 * 1024), 1); await vi.advanceTimersByTimeAsync(5); } - const healthy = await f.subscribe( - phase === "legacy initial" ? {} : source, - phase !== "legacy initial", - "healthy", - ); + const healthy = await f.subscribe(source, true, "healthy"); await vi.advanceTimersByTimeAsync(0); f.output("SIBLING-ALIVE", 2); await vi.advanceTimersByTimeAsync(5); @@ -1182,7 +1178,7 @@ describe("terminal snapshot failure", () => { type: "terminal_stream_exit", payload: { terminalId: "exit-terminal", - ...(phase === "legacy initial" ? {} : { subscriptionId: failed.id }), + subscriptionId: failed.id, error: "Terminal worker request timed out: getTerminalState", }, }, @@ -1201,7 +1197,7 @@ describe("terminal snapshot failure", () => { type: "terminal_stream_exit", payload: { terminalId: "exit-terminal", - ...(phase === "legacy initial" ? {} : { subscriptionId: healthy.id }), + subscriptionId: healthy.id, }, }); expect(f.delivery.registrationCount).toBe(0); @@ -1238,3 +1234,45 @@ describe("terminal snapshot failure", () => { } }); }); + +test.each([false, true])( + "legacy snapshot failure retries without reporting a live PTY exited (backpressure=%s)", + async (backpressure) => { + vi.useFakeTimers(); + let reads = 0; + const f = exitFixture(async () => { + if (++reads === (backpressure ? 2 : 1)) throw new Error("Snapshot unavailable"); + return { state: terminalState("recovered"), revision: 0 }; + }, backpressure); + try { + await f.subscribe({}, false, "legacy-recovery"); + await vi.advanceTimersByTimeAsync(0); + if (backpressure) { + f.output("x".repeat(300 * 1024), 1); + await vi.advanceTimersByTimeAsync(5); + } + expect(f.frames.filter((row) => row.message?.type === "terminal_stream_exit")).toEqual([]); + f.output("RECOVERED-OUTPUT", 2); + for (const listener of f.outputs) listener({ type: "snapshotReady", revision: 2 }); + await vi.advanceTimersByTimeAsync(5); + expect(reads).toBe(backpressure ? 3 : 2); + expect(f.frames.some((row) => row.binary?.opcode === TerminalStreamOpcode.Snapshot)).toBe( + true, + ); + f.exit(); + await vi.advanceTimersByTimeAsync(0); + expect(f.frames.filter((row) => row.message?.type === "terminal_stream_exit")).toHaveLength( + 1, + ); + expect(f.frames.at(-1)?.message).toEqual({ + type: "terminal_stream_exit", + payload: { terminalId: "exit-terminal" }, + }); + expect(f.delivery.registrationCount).toBe(0); + expect(f.outputs.size + f.exits.size).toBe(0); + } finally { + await f.delivery.close(); + vi.useRealTimers(); + } + }, +); diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 9925ed1846..4f46dbc88c 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -66,6 +66,7 @@ interface ActiveTerminalStream { unsubscribe: () => void; needsSnapshot: boolean; snapshotInFlight: boolean; + retrySnapshotErrors: boolean; readyRevision?: number; restore?: TerminalRestoreOptions; bufferedOutputs: BufferedTerminalOutput[]; @@ -706,7 +707,10 @@ export class TerminalSessionController { }); } - const slot = this.bindActiveStream(session, owner, { restore: msg.restore }); + const slot = this.bindActiveStream(session, owner, { + restore: msg.restore, + retrySnapshotErrors: !ownership.isModern(owner.source), + }); if (slot === null) { await owner.release(); this.sessionLogger.warn( @@ -852,7 +856,7 @@ export class TerminalSessionController { private bindActiveStream( terminal: TerminalSession, owner: OwnedSubscription, - options?: { restore?: TerminalRestoreOptions }, + options: { restore?: TerminalRestoreOptions; retrySnapshotErrors: boolean }, ): number | null { if (!this.hasBinaryChannel()) { return null; @@ -870,6 +874,7 @@ export class TerminalSessionController { unsubscribe: () => {}, needsSnapshot: true, snapshotInFlight: false, + retrySnapshotErrors: options.retrySnapshotErrors, exiting: false, readyRevision: undefined, restore: options?.restore, @@ -1010,6 +1015,14 @@ export class TerminalSessionController { ); // Natural completion owns the buffered final bytes and waits for this read. if (activeStream.exiting) return; + // COMPAT(terminalSnapshotErrors): restored in v0.8.0; remove after 2027-03-11 once client floor >= v0.8.0. + // Old clients interpret every stream exit as PTY exit. Preserve their + // attached stream after failure. Another snapshot notification can retry; + // this does not schedule recovery. Source teardown still releases the stream. + if (activeStream.retrySnapshotErrors) { + activeStream.needsSnapshot = true; + return; + } activeStream.owner.emit({ type: "terminal_stream_exit", payload: { diff --git a/public-docs/sdk/events.md b/public-docs/sdk/events.md index 03f69907fa..9762323dce 100644 --- a/public-docs/sdk/events.md +++ b/public-docs/sdk/events.md @@ -8,7 +8,7 @@ category: TypeScript SDK # SDK events -Use an owned subscription to fetch a snapshot and follow its changes. Connecting, plain reads, and local listeners do not start observation. Each observation gets a new server-issued ID, even when its filter matches another observation. +Use an owned subscription to fetch a snapshot and follow its changes. On capable daemons, connecting, plain reads, and local directory listeners do not start observation. Each observation gets a new server-issued ID, even when its filter matches another observation. ## Follow one agent's status @@ -47,10 +47,9 @@ await directory.subscription.release(); ```ts const unsubscribe = agent.timeline.subscribe((update) => { const { event } = update; - if (event.type === "snapshot") { - // Replace your recent history view; these are complete projected entries, - // not additional live message fragments. - console.log("Reconnected", event.page.epoch, event.page.entries); + if (event.type === "subscription_restored") { + // Live delivery has resumed. Choose whether to fetch missed history. + console.log("Reconnected; events may have been missed"); return; } if (event.type === "error") { @@ -72,11 +71,13 @@ const unsubscribe = agent.timeline.subscribe((update) => { }); ``` -Await `unsubscribe.ready` before starting work whose events you need to observe. It acknowledges the initial live subscription; initial history is a separate [read](#fetch-timeline-history). Call `unsubscribe()` to release demand, or await `unsubscribe.release()` for teardown. +Await `unsubscribe.ready` before starting work whose events you need to observe. It waits for the initial live subscription (local listener attachment on broadcast-only hosts); initial history is a separate [read](#fetch-timeline-history). Call `unsubscribe()` to release demand, or await `unsubscribe.release()` for teardown. -After reconnect, the same handle receives `{ agentId, subscriptionId, event: { type: "snapshot", reason: "reconnect", page } }` before subsequent updates. The host assigns a fresh subscription ID. `page` is the latest 100 projected history entries, with the same epoch, cursors and paging flags as `timeline.refetch()`. Replace your recent view with this page; do not concatenate it with earlier message fragments. If `hasOlder` is true, fetch older pages with `page.startCursor`. Compare epochs before retaining any previously loaded history: a reconnect may preserve or replace the epoch. A live `replacement` event still means the previous epoch is invalid. +After reconnect, the same handle receives `{ agentId, subscriptionId, event: { type: "subscription_restored" } }` before subsequent live updates. This is a local SDK notification after membership acknowledgement (local attachment on broadcast-only hosts). The subscription gets a fresh ID. No history is fetched automatically, and missed events are not replayed. -Persisted history is recovered; transient events such as an offline turn-completion notification are not replayed. Recovery buffers at most 128 current-connection updates. Overflow or a concurrent replacement discards the obsolete buffer and reads a fresh page. A recovery read failure delivers `{ agentId, event: { type: "error", error } }` and releases that observation; establish a new subscription when ready to retry. Release suppresses pending recovery callbacks. Each same-agent subscription recovers independently. +Choose recovery for your consumer: continue live, request a recent page, or call `timeline.refetch({ direction: "after", cursor: { epoch, seq } })` using your saved position. Live delivery continues while your read is pending; buffer or reconcile those events with the returned page by epoch and sequence. Follow `hasNewer` and `endCursor` to read further pages when needed. A live `replacement` invalidates the previous epoch. A failed explicit history read rejects that read and leaves the live subscription active. + +Subscription establishment failures deliver `{ agentId, event: { type: "error", error } }` and release the observation. Establish a new subscription when ready to retry. Releasing a subscription stops its callbacks and prevents restoration after reconnect. 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. @@ -118,7 +119,6 @@ await directory.subscription.release(); ## Follow provider catalog changes ```ts -const observation = client.observeEvents(["providers_snapshot_update"]); const unsubscribe = client.providers.subscribe((update) => { const ready = update.entries.filter((entry) => entry.status === "ready"); console.log( @@ -126,14 +126,19 @@ const unsubscribe = client.providers.subscribe((update) => { ready.map((entry) => entry.provider), ); }); -await observation.ready; console.log(await client.providers.snapshot()); // When this view closes: unsubscribe(); -await observation.release(); ``` -Project listeners work the same way: request `client.observeEvents(["project.update"])` before relying on `client.projects.subscribe()`. For an initial project cache, buffer updates while awaiting `client.projects.list()`, then apply them after the snapshot. +Provider and project `subscribe()` calls request their own updates and release that demand on unsubscribe. For an initial project cache, buffer updates while awaiting `client.projects.list()`, then apply them after the snapshot. -Call `client.close()` when the application no longer needs the connection. Observation requires a host that advertises independent subscriptions; an older host returns an update-host error. Plain reads remain available. +Call `client.close()` when the application no longer needs the connection. + +## Older daemons + +The same methods use the existing connection and legacy delivery behavior. Old directory subscriptions +share a server slot: a later filtered observation replaces that slot's filter. Handles have local IDs +and separate callbacks, but independent server filters require an updated daemon. Releasing a handle +detaches its callbacks; old daemons may continue sending broadcasts. diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 960404c58a..620f398691 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -55,7 +55,7 @@ Create a new client after `close()`. | `ref(agentOrId)` | `PaseoAgentHandle` | Creates a local handle without fetching. | | `subscribe(handler)` | Unsubscribe function | Local listener for this API instance. Requires an owned `list({ subscribe: {} })` observation. | -`list({ subscribe: {} })` also returns a server-issued `subscriptionId` and an owned `subscription`. Its `subscribe({ snapshot, update, error? })` callbacks receive the snapshot before scoped wire updates; `release()` ends that observation. Plain lists create no observation. The same contract applies to workspace lists. See [events](./events.md). +`list({ subscribe: {} })` also returns a `subscriptionId` and an owned `subscription`. Its `subscribe({ snapshot, update, error? })` callbacks receive the snapshot before scoped wire updates; `release()` ends that observation. Capable daemons assign the ID and keep observations independent. Older daemons use local IDs and their established shared delivery behavior. Plain lists create no observation. The same contract applies to workspace lists. See [events](./events.md). Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `outputSchema`, `images`, `attachments`, `git`, `worktree`, `autoArchive`, and `labels`. @@ -110,14 +110,14 @@ 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)` 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. Initial delivery is live-only. Reconnect delivers a bounded projected history snapshot before later updates; a live replacement invalidates the previous epoch. See the callback shapes, paging and failure behavior in [timeline events](./events.md#follow-timeline-events). +`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. Delivery is live-only. Reconnect emits a local `subscription_restored` event; request missed history explicitly with `refetch()`. A live replacement invalidates the previous epoch. See the callback shapes, paging and failure behavior in [timeline events](./events.md#follow-timeline-events). ## `client.projects` | Method | Result | Behavior | | -------------------- | ------------------------ | ---------------------------------------------------------------------------------------------- | | `list(options?)` | `PaseoProjectListResult` | Lists every registered project, including projects with no active workspaces. | -| `subscribe(handler)` | Unsubscribe function | Local listener. Requires `observeEvents(["project.update"])`; `list()` supplies initial state. | +| `subscribe(handler)` | Unsubscribe function | Requests future project updates; unsubscribe releases demand. `list()` supplies initial state. | See [events](./events.md#follow-provider-catalog-changes) for explicit event observation and cleanup. @@ -187,7 +187,7 @@ Use `workspace.terminals.create(options?)` and `workspace.terminals.list(options | `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 | Local listener. Requires `observeEvents(["providers_snapshot_update"])`. | +| `subscribe(handler)` | Unsubscribe function | Requests future catalog updates; unsubscribe releases demand. | ## `client.config` From 0d7760e68b5d3aa9d4f0e4384e231af32cde4a91 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 12 Sep 2026 09:33:26 +0200 Subject: [PATCH 05/17] fix(agents): keep rejected plans collapsed in conversation order (#4756) Keep plan proposals at their original timeline position through approval, rejection, and cancellation. Make plan cards collapsible while retaining readable history after follow-up prompts and daemon restart. --- docs/timeline-sync.md | 5 + .../e2e/browser/permission-steer.real.spec.ts | 199 +++++++++++++----- .../browser/plan-approval.claude.real.spec.ts | 164 +++++++++++++++ packages/app/src/agent-stream/view.tsx | 1 + packages/app/src/components/message.tsx | 1 + packages/app/src/components/plan-card.tsx | 131 ++++++++---- packages/app/src/i18n/resources/ar.ts | 4 + packages/app/src/i18n/resources/en.ts | 4 + packages/app/src/i18n/resources/es.ts | 4 + packages/app/src/i18n/resources/fr.ts | 4 + packages/app/src/i18n/resources/ja.ts | 4 + packages/app/src/i18n/resources/ko.ts | 4 + packages/app/src/i18n/resources/pt-BR.ts | 4 + packages/app/src/i18n/resources/ru.ts | 4 + packages/app/src/i18n/resources/zh-CN.ts | 4 + .../detail-level/projection.test.ts | 22 ++ .../src/tool-calls/detail-level/projection.ts | 29 ++- packages/app/src/tool-calls/presentation.ts | 11 + packages/app/src/types/stream.test.ts | 45 ++++ packages/app/src/types/stream.ts | 4 - .../server/agent/providers/claude/agent.ts | 43 ++-- .../providers/claude/tool-call-mapper.test.ts | 35 +++ .../providers/claude/tool-call-mapper.ts | 24 +++ ...odex-app-server-agent.process-exit.test.ts | 82 ++++++++ .../providers/codex-app-server-agent.test.ts | 30 ++- .../agent/providers/codex-app-server-agent.ts | 71 ++++--- 26 files changed, 772 insertions(+), 161 deletions(-) create mode 100644 packages/app/e2e/browser/plan-approval.claude.real.spec.ts diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 02c5723162..1c3b1e4593 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -88,6 +88,11 @@ The installed tail carries `hasOlder`, so history skipped by a replacement remai ordinary backward pagination. A backward page is accepted only when it is adjacent to the current history start; a response requested from a pre-replacement range is stale and is discarded. +A plan approval keeps the original proposal's tool-call identity through resolution and provider +history replay. The pending approval UI can hide that tool from presentation, but the client model +must retain its position. Creating a new history card on rejection places it after the prompt that +rejected it; changing steer-event ordering would also put new assistant output before that prompt. + ## Client replica lifetime The session projection remains host-scoped for as long as the host is registered. The viewed-timeline diff --git a/packages/app/e2e/browser/permission-steer.real.spec.ts b/packages/app/e2e/browser/permission-steer.real.spec.ts index 802ae763ac..e88027cd6c 100644 --- a/packages/app/e2e/browser/permission-steer.real.spec.ts +++ b/packages/app/e2e/browser/permission-steer.real.spec.ts @@ -1,4 +1,5 @@ import { mkdtempSync, realpathSync } from "node:fs"; +import type { Page, TestInfo } from "@playwright/test"; import { tmpdir } from "node:os"; import path from "node:path"; import { expect, test } from "../support/fixtures"; @@ -18,7 +19,7 @@ const scenarios = [ }, { provider: "codex" as const, - providerConfig: { featureValues: { plan_mode: true } }, + providerConfig: { model: "gpt-5.6-sol", featureValues: { plan_mode: true } }, planPrompt: "Produce a concise implementation plan with exactly these steps: Inspect permission steering; Implement permission steering; Verify permission steering. Do not implement it.", planText: "Inspect permission steering", @@ -27,57 +28,151 @@ const scenarios = [ }, ]; -test.describe("composer steer supersedes plan approval", () => { - for (const scenario of scenarios) { - test(`${scenario.provider} keeps the rejected plan in the timeline`, async ({ +async function readConversationOrder(page: Page, prompt: string, reply: string) { + const plan = page.getByTestId("timeline-plan-card"); + const question = page.getByTestId("user-message").filter({ hasText: prompt }); + const answer = page.getByTestId("assistant-message").filter({ hasText: reply }); + const boxes = await Promise.all([ + plan.boundingBox(), + question.boundingBox(), + answer.boundingBox(), + ]); + return boxes + .map((box, index) => { + if (!box) throw new Error("Expected the plan, follow-up, and answer to be rendered"); + return { role: ["plan", "question", "answer"][index], y: box.y }; + }) + .sort((a, b) => a.y - b.y) + .map((item) => item.role); +} + +async function togglePlan( + page: Page, + testId: string, + name: string, + expanded: boolean, +): Promise { + const toggle = page.getByTestId(testId).getByRole("button", { name, exact: true }); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", String(expanded)); +} + +async function expectRejectedPlanCollapsed(page: Page): Promise { + await expect( + page + .getByTestId("timeline-plan-card") + .getByRole("button", { name: "Rejected plan", exact: true }), + ).toHaveAttribute("aria-expanded", "false"); +} + +async function reopenConversation(page: Page, reply: string): Promise { + await page.reload(); + await expect(page.getByTestId("timeline-plan-card")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("assistant-message").filter({ hasText: reply })).toBeVisible(); +} + +type PlanScenario = (typeof scenarios)[number]; +interface PlanSteer { + page: Page; + scenario: PlanScenario; + testInfo: TestInfo; +} + +async function withPlanSteer(context: PlanSteer, journey: () => Promise): Promise { + const { page, scenario } = context; + const cwd = realpathSync( + mkdtempSync(path.join(tmpdir(), `paseo-permission-steer-${scenario.provider}-`)), + ); + let handle: AgentHandle | undefined; + try { + handle = await launchAgent({ page, - }, testInfo) => { - test.setTimeout(420_000); - const cwd = realpathSync( - mkdtempSync(path.join(tmpdir(), `paseo-permission-steer-${scenario.provider}-`)), - ); - let handle: AgentHandle | undefined; - - try { - handle = await launchAgent({ - page, - provider: scenario.provider, - cwd, - mode: "full-access", - providerConfig: scenario.providerConfig, - }); - await submitMessage(page, scenario.planPrompt); - await waitForPermissionPrompt(page, 180_000); - - const pendingPlan = page.getByTestId("permission-plan-card"); - await expect(pendingPlan).toContainText(scenario.planText); - const pendingScreenshot = testInfo.outputPath(`${scenario.provider}-pending-plan.png`); - await page.screenshot({ path: pendingScreenshot }); - await testInfo.attach(`${scenario.provider} pending plan`, { - path: pendingScreenshot, - contentType: "image/png", - }); - - await submitMessage(page, scenario.steerPrompt); - await expect(pendingPlan).toHaveCount(0, { timeout: 30_000 }); - const rejectedPlan = page.getByTestId("timeline-plan-card"); - await expect(rejectedPlan).toBeVisible({ timeout: 30_000 }); - await expect(rejectedPlan).toContainText(scenario.planText); - await expect( - page.getByTestId("assistant-message").filter({ hasText: scenario.reply }), - ).toBeVisible({ - timeout: 180_000, - }); - - const rejectedScreenshot = testInfo.outputPath(`${scenario.provider}-rejected-plan.png`); - await page.screenshot({ path: rejectedScreenshot }); - await testInfo.attach(`${scenario.provider} rejected plan`, { - path: rejectedScreenshot, - contentType: "image/png", - }); - } finally { - await cleanupRewindFlow({ handle, cwd }); - } + provider: scenario.provider, + cwd, + mode: "full-access", + providerConfig: scenario.providerConfig, }); + await journey(); + } finally { + await cleanupRewindFlow({ handle, cwd }); } -}); +} + +async function reviewCollapsibleProposal({ page, scenario, testInfo }: PlanSteer): Promise { + await submitMessage(page, scenario.planPrompt); + await waitForPermissionPrompt(page, 180_000); + + const pendingPlan = page.getByTestId("permission-plan-card"); + await expect(pendingPlan).toContainText(scenario.planText); + await togglePlan(page, "permission-plan-card", "Plan", false); + await expect(page.getByTestId("permission-request-deny")).toBeVisible(); + await togglePlan(page, "permission-plan-card", "Plan", true); + const pendingScreenshot = testInfo.outputPath(`${scenario.provider}-pending-plan.png`); + await page.screenshot({ path: pendingScreenshot }); + await testInfo.attach(`${scenario.provider} pending plan`, { + path: pendingScreenshot, + contentType: "image/png", + }); +} + +async function rejectWithFollowUp({ page, scenario, testInfo }: PlanSteer): Promise { + await submitMessage(page, scenario.steerPrompt); + await expect(page.getByTestId("permission-plan-card")).toHaveCount(0, { timeout: 30_000 }); + const rejectedPlan = page.getByTestId("timeline-plan-card"); + await expect(rejectedPlan).toBeVisible({ timeout: 30_000 }); + await expectRejectedPlanCollapsed(page); + await expect( + page.getByTestId("assistant-message").filter({ hasText: scenario.reply }), + ).toBeVisible({ + timeout: 180_000, + }); + + const rejectedScreenshot = testInfo.outputPath(`${scenario.provider}-rejected-plan.png`); + await page.screenshot({ path: rejectedScreenshot }); + await testInfo.attach(`${scenario.provider} rejected plan`, { + path: rejectedScreenshot, + contentType: "image/png", + }); + await expect( + page.getByTestId("user-message").filter({ hasText: scenario.steerPrompt }), + ).toBeVisible(); + expect + .soft(await readConversationOrder(page, scenario.steerPrompt, scenario.reply)) + .toEqual(["plan", "question", "answer"]); +} + +async function rereadRejectedPlan({ page, scenario, testInfo }: PlanSteer): Promise { + const rejectedPlan = page.getByTestId("timeline-plan-card"); + await togglePlan(page, "timeline-plan-card", "Rejected plan", true); + await expect(rejectedPlan).toContainText(scenario.planText); + await rejectedPlan.screenshot({ + path: testInfo.outputPath(`${scenario.provider}-expanded-rejected-plan.png`), + }); + await togglePlan(page, "timeline-plan-card", "Rejected plan", false); +} + +async function restoreConversationOrder({ page, scenario }: PlanSteer): Promise { + await reopenConversation(page, scenario.reply); + await expectRejectedPlanCollapsed(page); + expect(await readConversationOrder(page, scenario.steerPrompt, scenario.reply)).toEqual([ + "plan", + "question", + "answer", + ]); +} + +for (const scenario of scenarios) { + test(`${scenario.provider} keeps the rejected plan in the timeline`, async ({ + page, + }, testInfo) => { + test.setTimeout(420_000); + const context = { page, scenario, testInfo }; + await withPlanSteer(context, async () => { + await test.step("Review a collapsible proposal", () => reviewCollapsibleProposal(context)); + await test.step("Reject with a follow-up below the plan", () => rejectWithFollowUp(context)); + await test.step("Reopen the rejected plan", () => rereadRejectedPlan(context)); + await test.step("Keep conversation order after reload", () => + restoreConversationOrder(context)); + }); + }); +} diff --git a/packages/app/e2e/browser/plan-approval.claude.real.spec.ts b/packages/app/e2e/browser/plan-approval.claude.real.spec.ts new file mode 100644 index 0000000000..2994c63caf --- /dev/null +++ b/packages/app/e2e/browser/plan-approval.claude.real.spec.ts @@ -0,0 +1,164 @@ +import { mkdtempSync, readFileSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { Page, TestInfo } from "@playwright/test"; +import { expect, test } from "../support/fixtures"; +import { submitMessage } from "../support/helpers/composer"; +import { + allowPermission, + denyPermission, + waitForPermissionPrompt, +} from "../support/helpers/permissions"; +import { + assertComposerIdle, + cleanupRewindFlow, + launchAgent, + type AgentHandle, +} from "../support/helpers/rewind-flow"; +import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; +import { getE2EDaemonPort } from "../support/helpers/daemon-port"; + +const PLAN_PROMPT = + "Propose a concise plan to append one line Hello to README.md. Present it for approval using ExitPlanMode. Do not ask questions. Only implement after approval. If the plan is rejected, reply PLAN_REJECTED and stop without proposing another plan. After implementing an approved plan, reply PLAN_IMPLEMENTED."; + +async function requestPlan(page: Page, prompt: string): Promise { + await submitMessage(page, prompt); + await waitForPermissionPrompt(page, 180_000); + await expect(page.getByTestId("permission-plan-card")).toContainText("README.md"); +} + +function planWithOutcome(page: Page, label: string) { + return page + .getByTestId("timeline-plan-card") + .filter({ has: page.getByRole("button", { name: label, exact: true }) }); +} + +async function expectPlanOutcome(page: Page, label: string, expanded: boolean): Promise { + const card = planWithOutcome(page, label); + await expect(card).toHaveCount(1); + await expect(card.getByRole("button", { name: label, exact: true })).toHaveAttribute( + "aria-expanded", + String(expanded), + ); +} + +async function toggleRecordedPlan(page: Page, label: string, expanded: boolean): Promise { + await planWithOutcome(page, label).getByRole("button", { name: label, exact: true }).click(); + await expectPlanOutcome(page, label, expanded); +} + +async function expectClaudeReply(page: Page, marker: string): Promise { + await expect(page.getByTestId("assistant-message").filter({ hasText: marker })).toBeVisible({ + timeout: 180_000, + }); + await assertComposerIdle({ page }); +} + +async function restartIsolatedDaemon(): Promise { + const port = Number(getE2EDaemonPort()); + expect([6767, 6768]).not.toContain(port); + const client = await connectDaemonClient<{ + connect(): Promise; + close(): Promise; + getDaemonStatus(): Promise<{ pid: number }>; + restartServer(reason: string): Promise; + }>({ port, clientIdPrefix: "plan-history-restart" }); + try { + const before = (await client.getDaemonStatus()).pid; + await client.restartServer("Verify plan history replay in the isolated Playwright daemon"); + await expect + .poll( + async () => { + try { + return (await client.getDaemonStatus()).pid; + } catch { + return before; + } + }, + { timeout: 60_000 }, + ) + .not.toBe(before); + } finally { + await client.close(); + } +} + +async function expectRecordedPlans(page: Page): Promise { + await expect(page.getByTestId("timeline-plan-card")).toHaveCount(2, { timeout: 60_000 }); + await expectPlanOutcome(page, "Rejected plan", false); + await expectPlanOutcome(page, "Approved plan", true); +} + +interface PlanReview { + page: Page; + cwd: string; + testInfo: TestInfo; +} + +async function withPlanReview( + page: Page, + testInfo: TestInfo, + review: (context: PlanReview) => Promise, +): Promise { + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-plan-lifecycle-"))); + let handle: AgentHandle | undefined; + try { + handle = await launchAgent({ + page, + provider: "claude", + cwd, + mode: "full-access", + providerConfig: { model: "haiku", modeId: "plan" }, + }); + await review({ page, cwd, testInfo }); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } +} + +async function rejectPlanWithoutImplementing({ page, cwd, testInfo }: PlanReview): Promise { + await requestPlan(page, PLAN_PROMPT); + await denyPermission(page); + await expectClaudeReply(page, "PLAN_REJECTED"); + await expectPlanOutcome(page, "Rejected plan", false); + await toggleRecordedPlan(page, "Rejected plan", true); + await expect(planWithOutcome(page, "Rejected plan")).toContainText("README.md"); + await page.screenshot({ path: testInfo.outputPath("button-rejected-plan-expanded.png") }); + await toggleRecordedPlan(page, "Rejected plan", false); + expect(readFileSync(path.join(cwd, "README.md"), "utf8")).not.toContain("Hello"); +} + +async function approveFreshPlanAndImplement({ page, cwd, testInfo }: PlanReview): Promise { + await requestPlan(page, `Propose the plan again for a fresh approval. ${PLAN_PROMPT}`); + await allowPermission(page); + await expectClaudeReply(page, "PLAN_IMPLEMENTED"); + expect(readFileSync(path.join(cwd, "README.md"), "utf8")).toContain("Hello"); + await expectRecordedPlans(page); + await toggleRecordedPlan(page, "Approved plan", false); + await toggleRecordedPlan(page, "Approved plan", true); + await page.screenshot({ path: testInfo.outputPath("approved-plan.png") }); +} + +async function restoreReadablePlanHistory({ page, testInfo }: PlanReview): Promise { + await restartIsolatedDaemon(); + await page.reload(); + await expectRecordedPlans(page); + await toggleRecordedPlan(page, "Rejected plan", true); + await expect(planWithOutcome(page, "Rejected plan")).toContainText("README.md"); + await toggleRecordedPlan(page, "Rejected plan", false); + await page.screenshot({ path: testInfo.outputPath("restored-plans.png") }); +} + +test("Claude button rejection and approval preserve collapsible plans after daemon restart", async ({ + page, +}, testInfo) => { + test.setTimeout(420_000); + await withPlanReview(page, testInfo, async (review) => { + await test.step("Reject a plan without implementing it", () => + rejectPlanWithoutImplementing(review)); + await test.step("Approve and implement a fresh plan", () => + approveFreshPlanAndImplement(review)); + await test.step("Restore readable plans after daemon restart", () => + restoreReadablePlanHistory(review)); + }); +}); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index eb68675580..117132199e 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -1565,6 +1565,7 @@ function PermissionRequestCard({ title={title} description={description} text={planMarkdown} + outcome="pending" footer={footer} testID="permission-plan-card" disableOuterSpacing diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index a8a56dee89..99ae316d95 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -3167,6 +3167,7 @@ export const ToolCall = memo(function ToolCall({ return ( diff --git a/packages/app/src/components/plan-card.tsx b/packages/app/src/components/plan-card.tsx index 2ac00f1b7e..52c70683fb 100644 --- a/packages/app/src/components/plan-card.tsx +++ b/packages/app/src/components/plan-card.tsx @@ -1,9 +1,19 @@ -import { useMemo, type ReactNode } from "react"; -import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; -import Markdown, { type ASTNode } from "react-native-markdown-display"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { + Pressable, + Text, + View, + type StyleProp, + type TextStyle, + type ViewStyle, +} from "react-native"; +import { type ASTNode } from "react-native-markdown-display"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { createMarkdownStyles } from "@/styles/markdown-styles"; +import { MarkdownRenderer } from "@/components/markdown/renderer"; +import { ChevronRight } from "lucide-react-native"; +import { isWeb } from "@/constants/platform"; +import type { Theme } from "@/styles/theme"; import { getMarkdownListMarker } from "@/utils/markdown-list"; import { createMarkdownParser } from "@/utils/markdown-parser"; @@ -180,54 +190,81 @@ function createPlanMarkdownRules() { }; } -export function PlanCard({ - title, - description, - text, - footer, - disableOuterSpacing = false, - testID, -}: { +export type PlanOutcome = "pending" | "approved" | "rejected" | "canceled"; + +interface PlanCardProps { title?: string; description?: string; text: string; + outcome?: PlanOutcome; footer?: ReactNode; disableOuterSpacing?: boolean; testID?: string; -}) { - const { theme } = useUnistyles(); - const { t } = useTranslation(); - const markdownStyles = createMarkdownStyles(theme); - const markdownRules = createPlanMarkdownRules(); - const resolvedTitle = title ?? t("agentStream.permission.plan"); +} +const ThemedChevron = withUnistyles(ChevronRight); +const chevronColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const markdownRules = createPlanMarkdownRules(); + +export function PlanCard(props: PlanCardProps) { + // A resolution starts its own presentation state; subsequent taps stay local. + return ; +} + +function PlanCardContent({ + title, + description, + text, + outcome, + footer, + disableOuterSpacing = false, + testID, +}: PlanCardProps) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(outcome !== "rejected" && outcome !== "canceled"); + const labels = { + pending: title ?? t("agentStream.permission.plan"), + rejected: t("agentStream.permission.rejectedPlan"), + approved: t("agentStream.permission.approvedPlan"), + canceled: t("agentStream.permission.canceledPlan"), + }; + const resolvedTitle = labels[outcome ?? "pending"]; + const accessibilityState = useMemo(() => ({ expanded }), [expanded]); + const webExpandedState = useMemo( + () => (isWeb ? ({ "aria-expanded": expanded } as const) : null), + [expanded], + ); + const toggleExpanded = useCallback(() => setExpanded((value) => !value), []); const containerStyle = useMemo( - () => [ - styles.container, - disableOuterSpacing && styles.containerCompact, - { - backgroundColor: theme.colors.surface1, - borderColor: theme.colors.border, - }, - ], - [disableOuterSpacing, theme.colors.surface1, theme.colors.border], + () => [styles.container, disableOuterSpacing && styles.containerCompact], + [disableOuterSpacing], ); - const titleStyle = useMemo( - () => [styles.title, { color: theme.colors.foreground }], - [theme.colors.foreground], - ); - const descriptionStyle = useMemo( - () => [styles.description, { color: theme.colors.foregroundMuted }], - [theme.colors.foregroundMuted], + const chevronStyle = useMemo( + () => [styles.chevron, expanded && styles.chevronExpanded], + [expanded], ); return ( - {resolvedTitle} - {description ? {description} : null} - - {text} - + + + + + {resolvedTitle} + + {expanded ? ( + + {description ? {description} : null} + + + ) : null} {footer ? {footer} : null} ); @@ -239,16 +276,30 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[3], borderRadius: theme.spacing[2], borderWidth: 1, + backgroundColor: theme.colors.surface1, + borderColor: theme.colors.border, gap: theme.spacing[2], }, containerCompact: { marginVertical: 0, }, + header: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + minHeight: 24, + }, + chevron: {}, + chevronExpanded: { transform: [{ rotate: "90deg" }] }, + body: { gap: theme.spacing[2] }, title: { + color: theme.colors.foreground, + flexShrink: 1, fontSize: theme.fontSize.base, lineHeight: 22, }, description: { + color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, lineHeight: 20, }, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 728374a242..90683d5ae9 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -209,6 +209,10 @@ export const ar: TranslationResources = { historyLoadFailed: "تعذر تحميل سجل الوكيل", messageCapped: "تم اقتطاع هذه الرسالة ({{bytes}} بايت).", permission: { + rejectedPlan: "خطة مرفوضة", + approvedPlan: "خطة معتمدة", + canceledPlan: "خطة ملغاة", + plan: "يخطط", required: "الإذن مطلوب", deny: "ينكر", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 3046971400..3ebc7e5b4b 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -206,6 +206,10 @@ export const en = { historyLoadFailed: "Couldn't load agent history", messageCapped: "This message was capped ({{bytes}} bytes).", permission: { + rejectedPlan: "Rejected plan", + approvedPlan: "Approved plan", + canceledPlan: "Canceled plan", + plan: "Plan", required: "Permission Required", deny: "Deny", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 0eb0e3b092..83955b2ed7 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -209,6 +209,10 @@ export const es: TranslationResources = { historyLoadFailed: "No se pudo cargar el historial del agente", messageCapped: "Este mensaje fue truncado ({{bytes}} bytes).", permission: { + rejectedPlan: "Plan rechazado", + approvedPlan: "Plan aprobado", + canceledPlan: "Plan cancelado", + plan: "Plan", required: "Permiso requerido", deny: "Denegar", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index e46c5701ec..9eedd70af4 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -211,6 +211,10 @@ export const fr: TranslationResources = { historyLoadFailed: "Impossible de charger l’historique de l’agent", messageCapped: "Ce message a été tronqué ({{bytes}} octets).", permission: { + rejectedPlan: "Plan refusé", + approvedPlan: "Plan approuvé", + canceledPlan: "Plan annulé", + plan: "Plan", required: "Autorisation requise", deny: "Refuser", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 09d32471a3..f70f133412 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -209,6 +209,10 @@ export const ja: TranslationResources = { historyLoadFailed: "エージェントの履歴を読み込めませんでした", messageCapped: "このメッセージは上限で切り詰められました({{bytes}}バイト)。", permission: { + rejectedPlan: "却下されたプラン", + approvedPlan: "承認されたプラン", + canceledPlan: "キャンセルされたプラン", + plan: "プラン", required: "権限が必要です", deny: "拒否", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 8d9ca884b0..78ec33c3b4 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -209,6 +209,10 @@ export const ko: TranslationResources = { historyLoadFailed: "에이전트 기록을 로드할 수 없습니다.", messageCapped: "이 메시지는 길이 제한으로 잘렸습니다({{bytes}}바이트).", permission: { + rejectedPlan: "거부된 계획", + approvedPlan: "승인된 계획", + canceledPlan: "취소된 계획", + plan: "계획", required: "권한 필요", deny: "거부", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 75dcd62990..84c4352e11 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -209,6 +209,10 @@ export const ptBR: TranslationResources = { historyLoadFailed: "Não foi possível carregar o histórico do agente", messageCapped: "Esta mensagem foi truncada ({{bytes}} bytes).", permission: { + rejectedPlan: "Plano rejeitado", + approvedPlan: "Plano aprovado", + canceledPlan: "Plano cancelado", + plan: "Plano", required: "Permissão necessária", deny: "Negar", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 5cd4942ef4..9fdefa4a1e 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -209,6 +209,10 @@ export const ru: TranslationResources = { historyLoadFailed: "Не удалось загрузить историю агента", messageCapped: "Это сообщение было обрезано ({{bytes}} байт).", permission: { + rejectedPlan: "Отклонённый план", + approvedPlan: "Одобренный план", + canceledPlan: "Отменённый план", + plan: "План", required: "Требуется разрешение", deny: "Отклонить", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 6c908d90ad..299aec11fc 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -209,6 +209,10 @@ export const zhCN: TranslationResources = { historyLoadFailed: "无法加载智能体历史记录", messageCapped: "此消息已被截断({{bytes}} 字节)。", permission: { + rejectedPlan: "已拒绝的计划", + approvedPlan: "已批准的计划", + canceledPlan: "已取消的计划", + plan: "Plan", required: "需要权限", deny: "拒绝", diff --git a/packages/app/src/tool-calls/detail-level/projection.test.ts b/packages/app/src/tool-calls/detail-level/projection.test.ts index c0e32c3124..dabf87b73d 100644 --- a/packages/app/src/tool-calls/detail-level/projection.test.ts +++ b/packages/app/src/tool-calls/detail-level/projection.test.ts @@ -63,6 +63,28 @@ function project(input: { } describe("tool call detail-level projection", () => { + it.each(["detailed", "overview"] as const)( + "keeps pending approval tools out of %s presentation without removing their canonical position", + (level) => { + const pending = toolCall( + "1", + { type: "plan", text: "Ship it" }, + { name: "ExitPlanMode", status: "running" }, + ); + const followUp = { + kind: "user_message", + id: "question", + text: "What about tests?", + timestamp: new Date(2), + } satisfies StreamItem; + const tail = [pending, followUp]; + expect(project({ level, tail }).tail).toEqual([followUp]); + expect(tail).toEqual([pending, followUp]); + const rejected = toolCall("1", { type: "plan", text: "Ship it" }, { name: "plan_approval" }); + expect(project({ level, tail: [rejected, followUp] }).tail).toEqual([rejected, followUp]); + }, + ); + it("passes detailed timelines through without grouping work", () => { const tail = [toolCall("1", { type: "shell", command: "one" })]; const head = [toolCall("2", { type: "shell", command: "two" })]; diff --git a/packages/app/src/tool-calls/detail-level/projection.ts b/packages/app/src/tool-calls/detail-level/projection.ts index 1740e8500b..5dc1067914 100644 --- a/packages/app/src/tool-calls/detail-level/projection.ts +++ b/packages/app/src/tool-calls/detail-level/projection.ts @@ -20,6 +20,24 @@ export interface ToolCallDetailProjection extends GroupedToolCalls(); +// Approval UI owns pending plan presentation. Retain the canonical tool in the +// stream model so resolving it can reveal a card at its original position. +const visibleItemsCache = new WeakMap(); +function visibleToolCallItems(items: StreamItem[]): StreamItem[] { + const cached = visibleItemsCache.get(items); + if (cached) return cached; + const visible = items.filter((item) => { + if (item.kind !== "tool_call" || item.payload.source !== "agent") return true; + const data = item.payload.data; + return ( + data.name !== "ExitPlanMode" && !(data.name === "plan_approval" && data.status === "running") + ); + }); + const result = visible.length === items.length ? items : visible; + visibleItemsCache.set(items, result); + return result; +} + export function prepareToolCallHistory( level: ToolCallDetailLevel, tail: StreamItem[], @@ -29,7 +47,10 @@ export function prepareToolCallHistory( } return { mode: "overview", - grouped: prepareGroupedHistory({ tail, buildGroup: buildOverviewGroup }), + grouped: prepareGroupedHistory({ + tail: visibleToolCallItems(tail), + buildGroup: buildOverviewGroup, + }), }; } @@ -42,8 +63,8 @@ export function projectToolCallDetailLevel(input: { }): ToolCallDetailProjection { if (input.level === "detailed") { return { - tail: input.tail, - head: input.head, + tail: visibleToolCallItems(input.tail), + head: visibleToolCallItems(input.head), groupsByHostId: EMPTY_TOOL_CALL_GROUPS, historyGroupUpdatesByHostId: EMPTY_TOOL_CALL_GROUPS, }; @@ -53,7 +74,7 @@ export function projectToolCallDetailLevel(input: { } return groupLiveToolCalls({ history: input.preparedHistory.grouped, - head: input.head, + head: visibleToolCallItems(input.head), isTurnActive: input.isTurnActive, buildGroup: buildOverviewGroup, }); diff --git a/packages/app/src/tool-calls/presentation.ts b/packages/app/src/tool-calls/presentation.ts index e43aacf771..3db13ccbbd 100644 --- a/packages/app/src/tool-calls/presentation.ts +++ b/packages/app/src/tool-calls/presentation.ts @@ -1,3 +1,4 @@ +import type { PlanOutcome } from "@/components/plan-card"; import type { ComponentType } from "react"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import type { ToolCallDisplayInput } from "@/utils/tool-call-display"; @@ -31,6 +32,7 @@ export interface ToolCallPresentation { canOpenDetails: boolean; openFilePath: string | null; isPlan: boolean; + planOutcome?: PlanOutcome; } export type ToolCallIconResolver = ( @@ -75,5 +77,14 @@ export function buildToolCallPresentation( canOpenDetails: hasDetails || isLoadingDetails, openFilePath: extractToolCallFilePath(input.detail), isPlan: input.detail?.type === "plan", + planOutcome: input.detail?.type === "plan" ? resolvePlanOutcome(input) : undefined, }; } + +function resolvePlanOutcome(input: BuildToolCallPresentationInput): PlanOutcome | undefined { + if (input.status === "canceled") return "canceled"; + if (input.metadata?.approved === false) return "rejected"; + if (input.metadata?.approved === true) return "approved"; + if (input.status === "running" || input.status === "executing") return "pending"; + return undefined; +} diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index cd05fd0826..49fdf7daf6 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -23,6 +23,51 @@ import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity"; type CanonicalToolStatus = "running" | "completed" | "failed" | "canceled"; +it("updates a resolved Claude plan at its proposal position across a follow-up", () => { + const proposal = { + type: "timeline", + provider: "claude", + turnId: "turn-1", + item: { + type: "tool_call", + callId: "plan-1", + name: "ExitPlanMode", + status: "running", + error: null, + detail: { type: "plan", text: "Ship it" }, + }, + } satisfies AgentStreamEventPayload; + const pending = reduceStreamUpdate([], proposal, new Date(1)); + const steered = reduceStreamUpdate( + pending, + { + type: "timeline", + provider: "claude", + turnId: "turn-1", + item: { type: "user_message", text: "What about tests?", messageId: "question" }, + }, + new Date(2), + ); + const resolved = reduceStreamUpdate( + steered, + { + ...proposal, + item: { + ...proposal.item, + name: "plan_approval", + status: "completed", + metadata: { approved: false }, + }, + }, + new Date(3), + ); + expect(resolved.map((item) => item.kind)).toEqual(["tool_call", "user_message"]); + expect(resolved[0]?.id).toBe(pending[0]?.id); + expect(resolved[0]).toMatchObject({ + payload: { data: { name: "plan_approval", metadata: { approved: false } } }, + }); +}); + 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..1e8a32f06f 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -1399,10 +1399,6 @@ function reduceTimelineToolCall( .trim() .replace(/[.\s-]+/g, "_") .toLowerCase(); - if (event.provider === "claude" && normalizedToolName === "exitplanmode") { - return state; - } - if ( event.provider === "claude" && (normalizedToolName === "todowrite" || normalizedToolName === "todo_write") diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 4adbf0ce1c..496dfe2687 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2515,9 +2515,8 @@ class ClaudeAgentSession implements AgentSession { } /** - * A denied request is the only record the transcript gets. Plans especially: - * the pending card is the only place the plan text lives, so losing it means - * the user can no longer read what they just declined. + * Settle the original call immediately, before waiting for the SDK tool result. + * Plan resolution must use the native call ID so it cannot move past a follow-up. */ private recordDeniedPermissionTimeline( request: AgentPermissionRequest, @@ -2538,28 +2537,24 @@ class ClaudeAgentSession implements AgentSession { return; } if (request.kind === "plan") { - let planText: string | null = null; - if (typeof request.metadata?.planText === "string") { - planText = request.metadata.planText; - } else if (typeof request.input?.plan === "string") { - planText = request.input.plan; - } - if (!planText) return; - this.pushToolCall({ - type: "tool_call", - name: "plan_approval", - callId: request.id, - status: "completed", - error: null, - detail: { type: "plan", text: planText }, - metadata: { - approved: false, - actionId: response.selectedActionId ?? "reject", - }, - }); + this.pushToolCall( + mapClaudeFailedToolCall({ + name: "ExitPlanMode", + callId: this.planToolCallId(request), + input: request.input, + error: response.message ?? "Permission denied", + metadata: { actionId: response.selectedActionId ?? "reject" }, + }), + ); } } + private planToolCallId(request: AgentPermissionRequest): string { + return typeof request.metadata?.toolUseId === "string" + ? request.metadata.toolUseId + : request.id; + } + private resolveDeniedPermission( request: AgentPermissionRequest, response: Extract, @@ -2610,8 +2605,8 @@ class ClaudeAgentSession implements AgentSession { await this.setMode(targetMode); this.pushToolCall( mapClaudeCompletedToolCall({ - name: "plan_approval", - callId: pending.request.id, + name: "ExitPlanMode", + callId: this.planToolCallId(pending.request), input: pending.request.input ?? null, output: { approved: true, diff --git a/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts index 0522d0cd50..f0b3ea2af1 100644 --- a/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + mapClaudeCanceledToolCall, mapClaudeCompletedToolCall, mapClaudeFailedToolCall, mapClaudeRunningToolCall, @@ -15,6 +16,40 @@ function expectMapped(item: T | null): T { } describe("claude tool-call mapper", () => { + it("preserves a plan's original tool identity and outcome for live and replayed results", () => { + const proposal = { name: "ExitPlanMode", callId: "plan-tool-1", input: { plan: "Ship it" } }; + const pending = expectMapped(mapClaudeRunningToolCall(proposal)); + const rejected = expectMapped( + mapClaudeFailedToolCall({ ...proposal, error: "Denied by user" }), + ); + const approved = expectMapped(mapClaudeCompletedToolCall(proposal)); + const canceled = expectMapped(mapClaudeCanceledToolCall(proposal)); + expect(canceled).toMatchObject({ + callId: proposal.callId, + name: "plan_approval", + status: "canceled", + detail: { type: "plan", text: "Ship it" }, + }); + expect(pending).toMatchObject({ + callId: "plan-tool-1", + detail: { type: "plan", text: "Ship it" }, + }); + expect(rejected).toMatchObject({ + callId: pending.callId, + name: "plan_approval", + status: "completed", + error: null, + detail: pending.detail, + metadata: { approved: false }, + }); + expect(approved).toMatchObject({ + callId: pending.callId, + name: "plan_approval", + status: "completed", + detail: pending.detail, + metadata: { approved: true }, + }); + }); it("maps running shell calls with canonical fields", () => { const item = expectMapped( mapClaudeRunningToolCall({ diff --git a/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts b/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts index cc8ed529f0..896cdc962a 100644 --- a/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts @@ -12,6 +12,8 @@ interface MapperParams { metadata?: Record; } +const ClaudePlanInputSchema = z.object({ plan: z.string() }); + const ClaudeToolCallStatusSchema = z.enum(["running", "completed", "failed", "canceled"]); type ClaudeToolCallStatus = z.infer; @@ -113,6 +115,28 @@ function mapClaudeToolCall( } const trimmedName = raw.name.trim(); + if (trimmedName === "ExitPlanMode") { + const plan = ClaudePlanInputSchema.safeParse(raw.input); + if (plan.success) { + // The permission callback and transcript tool_result describe the same call. + // Keep its native identity so resolution updates the proposal's original position. + return { + type: "tool_call", + callId, + // Older clients suppress the pending native call while showing its approval card. + name: status === "running" ? trimmedName : "plan_approval", + status: status === "failed" ? "completed" : status, + error: null, + detail: { type: "plan", text: plan.data.plan }, + metadata: { + ...raw.metadata, + ...(status === "completed" || status === "failed" + ? { approved: status === "completed" } + : {}), + }, + }; + } + } const toolKind = resolveClaudeToolKind(trimmedName); const name = toolKind === "speak" ? "speak" : trimmedName; const input = raw.input ?? null; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts index 63a45be061..9cfeca60d3 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts @@ -11,6 +11,7 @@ import type { AgentLaunchContext, AgentSession, AgentSessionConfig, + AgentStreamEvent, } from "../agent-sdk-types.js"; import { CodexAppServerAgentClient, CodexAppServerAgentSession } from "./codex-app-server-agent.js"; import { @@ -20,6 +21,87 @@ import { const logger = createTestLogger(); +interface PendingPlanLifecycle { + close(): Promise; + crashDuringActiveTurn(): Promise; + expectCanceledPlan(): void; +} + +async function withPendingPlan( + runScenario: (plan: PendingPlanLifecycle) => Promise, +): Promise { + const workdir = mkdtempSync(join(tmpdir(), "codex-plan-cancel-")); + const appServer = createFakeCodexAppServer(); + const client = new ProcessExitCodexClient([appServer]); + const session = await client.createSession({ + provider: "codex", + cwd: workdir, + modeId: "auto", + model: "gpt-5.4", + featureValues: { plan_mode: true }, + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + const run = session.run("make a plan"); + await appServer.waitForTurnStart(); + appServer.startsTurn({ threadId: "thread-1", turnId: "plan-turn" }); + appServer.updatesPlan({ threadId: "thread-1", steps: ["Inspect README"] }); + appServer.completeTurn(); + await run; + const proposal = events.find( + (event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.name === "plan_approval", + ); + expect(proposal).toBeDefined(); + const [request] = session.getPendingPermissions?.() ?? []; + expect(request).toBeDefined(); + await runScenario({ + close: () => session.close(), + async crashDuringActiveTurn() { + appServer.startsTurn({ threadId: "thread-1", turnId: "autonomous-turn" }); + await expect + .poll(() => events.filter((event) => event.type === "turn_started").length) + .toBe(2); + appServer.child.emit("exit", 17, null); + await expect.poll(() => events.some((event) => event.type === "turn_failed")).toBe(true); + }, + expectCanceledPlan() { + expect(session.getPendingPermissions?.()).toEqual([]); + expect(events).toContainEqual({ + ...proposal, + item: expect.objectContaining({ + type: "tool_call", + name: "plan_approval", + callId: request?.id, + status: "canceled", + detail: { type: "plan", text: "- Inspect README" }, + }), + }); + }, + }); + } finally { + await session.close(); + rmSync(workdir, { recursive: true, force: true }); + } +} + +test("closing a session retains a canceled pending plan", async () => { + await withPendingPlan(async (plan) => { + await plan.close(); + plan.expectCanceledPlan(); + }); +}); + +test("an active provider crash retains a canceled pending plan", async () => { + await withPendingPlan(async (plan) => { + await plan.crashDuringActiveTurn(); + plan.expectCanceledPlan(); + }); +}); + class ProcessExitCodexClient extends CodexAppServerAgentClient implements AgentClient { constructor(private readonly appServers: FakeCodexAppServer[]) { super(logger); 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 b86418814d..1b1ff3738d 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 @@ -5023,14 +5023,22 @@ describe("Codex app-server provider", () => { turn: { status: "completed", error: null }, }); - expect( - events.some( - (event) => - event.type === "timeline" && - event.item.type === "tool_call" && - event.item.detail.type === "plan", - ), - ).toBe(false); + expect(events.at(-3)).toEqual({ + type: "timeline", + provider: "codex", + turnId: "test-turn", + item: { + type: "tool_call", + callId: session.getPendingPermissions()[0]?.id, + name: "plan_approval", + status: "running", + error: null, + detail: { + type: "plan", + text: "- Inspect the existing auth flow\n- Implement the button behavior", + }, + }, + }); expect(events.at(-2)).toEqual({ type: "permission_requested", provider: "codex", @@ -5065,7 +5073,7 @@ describe("Codex app-server provider", () => { }); }); - test("does not emit Codex plan thread items as timeline cards while plan approval is pending", () => { + test("does not complete Codex plan timeline cards while plan approval is pending", () => { const session = createSession({ featureValues: { plan_mode: true, fast_mode: true }, }); @@ -5091,6 +5099,7 @@ describe("Codex app-server provider", () => { type: "timeline", item: expect.objectContaining({ type: "tool_call", + status: "completed", detail: expect.objectContaining({ type: "plan" }), }), }), @@ -6073,7 +6082,8 @@ describe("Codex denied plan approvals", () => { (event) => event.type === "timeline" && event.item.type === "tool_call" && - event.item.name === "plan_approval", + event.item.name === "plan_approval" && + event.item.status === "completed", ); } 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 5e2cb3795a..8bf9ede447 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 @@ -3286,7 +3286,7 @@ interface CodexPendingPermissionHandler { resolve: (value: unknown) => void; kind: "command" | "file" | "question" | "mcp_elicitation" | "plan"; questions?: CodexQuestionPrompt[]; - planText?: string; + plan?: { text: string; turnId: string | undefined }; } interface ConsumedRootCompaction { @@ -3745,7 +3745,20 @@ export class CodexAppServerAgentSession implements AgentSession { this.pendingPermissionHandlers.set(requestId, { resolve: () => undefined, kind: "plan", - planText, + plan: { text: planText, turnId: this.activeForegroundTurnId ?? undefined }, + }); + // Reserve the proposal's place before a later prompt can resolve it. + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + item: { + type: "tool_call", + callId: requestId, + name: "plan_approval", + status: "running", + error: null, + detail: { type: "plan", text: planText }, + }, }); this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request }); } @@ -4617,7 +4630,7 @@ export class CodexAppServerAgentSession implements AgentSession { let followUpPrompt: string | undefined; if (response.behavior === "allow") { followUpPrompt = this.preparePlanImplementation({ - planText: pending.planText ?? pendingRequest?.metadata?.planText, + planText: pending.plan?.text ?? pendingRequest?.metadata?.planText, }); } @@ -4649,29 +4662,7 @@ export class CodexAppServerAgentSession implements AgentSession { } private resolvePlanPermission(requestId: string, resolution: AgentPermissionResponse): void { - if (resolution.behavior === "deny") { - // Every route into a denial lands here — the response handler, a new - // prompt, and an accepted steer — so the transcript record belongs here - // rather than in handlePlanPermissionResponse. - const planText = - this.pendingPermissionHandlers.get(requestId)?.planText ?? - this.pendingPermissions.get(requestId)?.metadata?.planText; - if (typeof planText === "string") { - this.emitEvent({ - type: "timeline", - provider: CODEX_PROVIDER, - item: { - type: "tool_call", - callId: requestId, - name: "plan_approval", - status: "completed", - error: null, - detail: { type: "plan", text: planText }, - metadata: { approved: false }, - }, - }); - } - } + this.recordPlanOutcome(requestId, resolution.behavior === "allow" ? "approved" : "rejected"); this.pendingPermissionHandlers.delete(requestId); this.pendingPermissions.delete(requestId); this.resolvedPermissionRequests.add(requestId); @@ -4683,6 +4674,29 @@ export class CodexAppServerAgentSession implements AgentSession { }); } + private recordPlanOutcome( + requestId: string, + outcome: "approved" | "rejected" | "canceled", + ): void { + const plan = this.pendingPermissionHandlers.get(requestId)?.plan; + if (plan) { + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + turnId: plan.turnId, + item: { + type: "tool_call", + callId: requestId, + name: "plan_approval", + status: outcome === "canceled" ? "canceled" : "completed", + error: null, + detail: { type: "plan", text: plan.text }, + metadata: outcome === "canceled" ? {} : { approved: outcome === "approved" }, + }, + }); + } + } + private emitDeniedToolCallTimelineEvent(params: { requestId: string; response: Extract; @@ -4851,6 +4865,9 @@ export class CodexAppServerAgentSession implements AgentSession { if (options?.preservePlanApprovals && pending.kind === "plan") { continue; } + if (pending.kind === "plan") { + this.recordPlanOutcome(requestId, "canceled"); + } pending.resolve({ decision: "cancel" }); this.pendingPermissionHandlers.delete(requestId); this.pendingPermissions.delete(requestId); @@ -5172,7 +5189,7 @@ export class CodexAppServerAgentSession implements AgentSession { } private notifySubscribers(event: AgentStreamEvent): void { - const turnId = this.activeForegroundTurnId; + const turnId = getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId; const tagged = turnId ? { ...event, turnId } : event; this.logger.trace( { From d1b705a0cd91617a5707fae25d80cb0be3057950 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 12 Sep 2026 09:50:25 +0200 Subject: [PATCH 06/17] fix(app): restore Android clipboard image pasting (#4758) Decode clipboard image data URLs locally because native fetch rejects them. Add native-runtime and byte-integrity regression coverage, and clarify empty-clipboard guidance. --- packages/app/e2e/mobile/clipboard-image.mjs | 96 +++++++++++++++++++ .../local-file-attachment-store.test.ts | 73 ++++++++++++-- .../local-file-attachment-store.ts | 9 +- packages/app/src/i18n/resources/ar.ts | 2 +- packages/app/src/i18n/resources/en.ts | 2 +- 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 | 2 +- packages/app/src/i18n/resources/pt-BR.ts | 2 +- packages/app/src/i18n/resources/ru.ts | 3 +- packages/app/src/i18n/resources/zh-CN.ts | 2 +- 12 files changed, 180 insertions(+), 20 deletions(-) create mode 100644 packages/app/e2e/mobile/clipboard-image.mjs diff --git a/packages/app/e2e/mobile/clipboard-image.mjs b/packages/app/e2e/mobile/clipboard-image.mjs new file mode 100644 index 0000000000..2a93f5ce20 --- /dev/null +++ b/packages/app/e2e/mobile/clipboard-image.mjs @@ -0,0 +1,96 @@ +// Run against a native dev app loaded from this checkout: +// node packages/app/e2e/mobile/clipboard-image.mjs +// Exercises real native attachment persistence; Node's fetch(data:) hides this regression. +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { setTimeout as delay } from "node:timers/promises"; +import { WebSocket } from "ws"; + +const [, , metroPort, appId] = process.argv; +assert(metroPort && appId, "Pass the Metro port and native app ID"); +const targets = await fetch(`http://127.0.0.1:${Number(metroPort)}/json/list`).then((r) => + r.json(), +); +const target = targets.find((candidate) => candidate.appId === appId); +assert(target, `Missing Hermes target for ${appId}`); +const png = await readFile(new URL("../../assets/images/icon.png", import.meta.url)); +const base64 = png.toString("base64"); +const socket = new WebSocket(target.webSocketDebuggerUrl); +await new Promise((resolve, reject) => { + socket.once("open", resolve); + socket.once("error", reject); +}); + +let nextId = 0; +function evaluate(expression) { + return new Promise((resolve, reject) => { + const id = ++nextId; + const timeout = setTimeout(() => { + socket.off("message", onMessage); + reject(new Error("Hermes evaluation timed out")); + }, 10000); + function onMessage(message) { + const response = JSON.parse(message.toString()); + if (response.id !== id) return; + clearTimeout(timeout); + socket.off("message", onMessage); + if (response.error || response.result.exceptionDetails) { + reject(new Error(JSON.stringify(response.error ?? response.result.exceptionDetails))); + } else { + resolve(response.result.result.value); + } + } + socket.on("message", onMessage); + socket.send( + JSON.stringify({ + id, + method: "Runtime.evaluate", + params: { expression, returnByValue: true }, + }), + ); + }); +} + +async function waitForNativeClipboardImage() { + const deadline = Date.now() + 15000; + do { + const result = await evaluate("globalThis.__clipboardImageTest"); + if (result) return result; + await delay(100); + } while (Date.now() < deadline); + assert.fail("Native image persistence timed out"); +} + +try { + // Metro's debugger module table locates the same public service used by the composer. + // Hermes does not await promises through Runtime.evaluate, so collect the async result. + await evaluate(`(() => { + const entry = Array.from(__r.getModules().entries()).find( + ([, module]) => module.verboseName === "src/attachments/service.ts" + ); + if (!entry) throw new Error("Open the composer before running this test"); + const service = __r(entry[0]); + globalThis.__clipboardImageTest = null; + service.persistAttachmentFromDataUrl({ + dataUrl: ${JSON.stringify(`data:image/png;base64,${base64}`)}, + fileName: "clipboard.png" + }).then(attachment => { + return Promise.all([ + service.encodeAttachmentsForSend([attachment]), + service.resolveAttachmentPreviewUrl(attachment) + ]).then(([encoded, preview]) => ({ attachment, encoded, preview })) + .finally(() => service.deleteAttachments([attachment])); + }).then(result => { globalThis.__clipboardImageTest = result; }) + .catch(error => { globalThis.__clipboardImageTest = { error: String(error) }; }); + })()`); + const result = await waitForNativeClipboardImage(); + assert.equal(result.error, undefined, result.error); + assert.equal(result.attachment.storageType, "native-file"); + assert.equal(result.attachment.byteSize, png.length); + assert.equal(result.attachment.fileName, "clipboard.png"); + assert.match(result.preview, /^file:\/\//); + assert.deepEqual(result.encoded, [{ data: base64, mimeType: "image/png" }]); + console.log(`PASS: native clipboard PNG persisted, previewed, and encoded (${png.length} bytes)`); +} finally { + await evaluate("delete globalThis.__clipboardImageTest").finally(() => socket.close()); +} diff --git a/packages/app/src/attachments/local-file-attachment-store.test.ts b/packages/app/src/attachments/local-file-attachment-store.test.ts index 48f974fa1a..82774aa723 100644 --- a/packages/app/src/attachments/local-file-attachment-store.test.ts +++ b/packages/app/src/attachments/local-file-attachment-store.test.ts @@ -2,16 +2,75 @@ import { describe, expect, it } from "vitest"; import { createLocalFileAttachmentStore } from "./local-file-attachment-store"; import { createTestAttachmentFileSystem } from "./test-attachment-file-system"; +function createStore() { + const fileSystem = createTestAttachmentFileSystem(); + const store = createLocalFileAttachmentStore({ + storageType: "native-file", + baseDirectoryName: "preview-assets", + fileSystem, + resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`, + }); + return { fileSystem, store }; +} + describe("local file attachment store", () => { - it("writes raw byte sources directly to the managed file path", async () => { - const fileSystem = createTestAttachmentFileSystem(); - const store = createLocalFileAttachmentStore({ - storageType: "native-file", - baseDirectoryName: "preview-assets", - fileSystem, - resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`, + it.each([ + "data:image/png;base64,AAECAwQF+/8=", + "data:image/png;charset=utf-8;base64,AAEC\nAwQF+/8=", + ])("preserves binary image bytes from %s", async (dataUrl) => { + const { fileSystem, store } = createStore(); + const attachment = await store.save({ + id: "clipboard", + fileName: "clipboard.png", + source: { kind: "data_url", dataUrl }, }); + expect(attachment).toMatchObject({ + mimeType: "image/png", + fileName: "clipboard.png", + byteSize: 8, + storageType: "native-file", + storageKey: "/cache/preview-assets/clipboard.png", + }); + expect(fileSystem.files.get("file:///cache/preview-assets/clipboard.png")).toEqual( + new Uint8Array([0, 1, 2, 3, 4, 5, 251, 255]), + ); + await expect(store.encodeBase64({ attachment })).resolves.toBe("AAECAwQF+/8="); + await expect(store.resolvePreviewUrl({ attachment })).resolves.toBe( + "file:///cache/preview-assets/clipboard.png", + ); + }); + + it.each([ + "not-a-data-url", + "data:image/png,hello", + "data:image/png;base64,", + "data:image/png;base64,%%%%", + "data:image/png;base64,A", + ])("rejects malformed image data without writing a file: %s", async (dataUrl) => { + const { fileSystem, store } = createStore(); + await expect(store.save({ source: { kind: "data_url", dataUrl } })).rejects.toThrow(); + expect(fileSystem.files.size).toBe(0); + }); + + it("copies file URI images without altering the source", async () => { + const { fileSystem, store } = createStore(); + const bytes = new Uint8Array([0, 1, 254, 255]); + fileSystem.setFile("file:///keyboard/image.png", bytes); + const attachment = await store.save({ + id: "keyboard", + mimeType: "image/png", + source: { kind: "file_uri", uri: "file:///keyboard/image.png" }, + }); + + expect(fileSystem.files.get("file:///cache/preview-assets/keyboard.png")).toEqual(bytes); + expect(fileSystem.files.get("file:///keyboard/image.png")).toEqual(bytes); + expect(attachment.byteSize).toBe(bytes.length); + }); + + it("writes raw byte sources directly to the managed file path", async () => { + const { fileSystem, store } = createStore(); + const attachment = await store.save({ id: "preview_8_test", mimeType: "image/png", diff --git a/packages/app/src/attachments/local-file-attachment-store.ts b/packages/app/src/attachments/local-file-attachment-store.ts index b9e76a86e6..54b6347fd4 100644 --- a/packages/app/src/attachments/local-file-attachment-store.ts +++ b/packages/app/src/attachments/local-file-attachment-store.ts @@ -44,9 +44,10 @@ async function ensureDirectory(fileSystem: AttachmentFileSystem, uri: string): P await fileSystem.makeDirectory(uri, { intermediates: true }); } -async function dataUrlToBytes(dataUrl: string): Promise { - const response = await fetch(dataUrl); - return new Uint8Array(await response.arrayBuffer()); +function dataUrlToBytes(dataUrl: string): Uint8Array { + // React Native's fetch cannot read data URLs. Decode the embedded bytes locally. + const { base64 } = parseDataUrl(dataUrl); + return Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)); } async function blobToBytes(blob: Blob): Promise { @@ -70,7 +71,7 @@ async function writeFromSource(input: { let bytes: Uint8Array; if (input.source.kind === "data_url") { - bytes = await dataUrlToBytes(input.source.dataUrl); + bytes = dataUrlToBytes(input.source.dataUrl); } else if (input.source.kind === "blob") { bytes = await blobToBytes(input.source.blob); } else { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 90683d5ae9..f0e080ea66 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -154,7 +154,7 @@ export const ar: TranslationResources = { initialPromptRequired: "مطلوب موجه الأولي", alreadyLoading: "جارٍ التحميل بالفعل", uploadFailed: "Failed to upload file", - noClipboardImage: "لا توجد صورة في الحافظة", + noClipboardImage: "لا توجد صورة في الحافظة الحالية. جرّب اللصق من لوحة المفاتيح.", pasteImageFailed: "تعذر لصق الصورة", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 3ebc7e5b4b..b186b013e0 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -151,7 +151,7 @@ export const en = { initialPromptRequired: "Initial prompt is required", alreadyLoading: "Already loading", uploadFailed: "Failed to upload file", - noClipboardImage: "No image in clipboard", + noClipboardImage: "No image in the current clipboard. Try pasting from your keyboard.", pasteImageFailed: "Failed to paste image", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 83955b2ed7..35ae22fb11 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -154,7 +154,8 @@ export const es: TranslationResources = { initialPromptRequired: "Se requiere aviso inicial", alreadyLoading: "Ya cargando", uploadFailed: "Failed to upload file", - noClipboardImage: "No hay ninguna imagen en el portapapeles", + noClipboardImage: + "No hay ninguna imagen en el portapapeles actual. Prueba a pegar desde el teclado.", pasteImageFailed: "No se pudo pegar la imagen", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 9eedd70af4..da62e2d85e 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -156,7 +156,8 @@ export const fr: TranslationResources = { initialPromptRequired: "Une invite initiale est requise", alreadyLoading: "Déjà en cours de chargement", uploadFailed: "Failed to upload file", - noClipboardImage: "Aucune image dans le presse-papiers", + noClipboardImage: + "Aucune image dans le presse-papiers actuel. Essayez de coller depuis votre clavier.", pasteImageFailed: "Impossible de coller l’image", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index f70f133412..f07938d044 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -154,7 +154,8 @@ export const ja: TranslationResources = { initialPromptRequired: "初期プロンプトが必要です", alreadyLoading: "すでに読み込み中です", uploadFailed: "ファイルのアップロードに失敗しました", - noClipboardImage: "クリップボードに画像がありません", + noClipboardImage: + "現在のクリップボードに画像がありません。キーボードから貼り付けてみてください。", pasteImageFailed: "画像を貼り付けられませんでした", fileTooLarge: "{{fileName}}が大きすぎます(最大{{size}})", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 78ec33c3b4..b45f44a63b 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -154,7 +154,7 @@ export const ko: TranslationResources = { initialPromptRequired: "초기 프롬프트가 필요합니다", alreadyLoading: "이미 불러오는 중입니다", uploadFailed: "파일을 업로드하지 못했습니다", - noClipboardImage: "클립보드에 이미지가 없습니다.", + noClipboardImage: "현재 클립보드에 이미지가 없습니다. 키보드에서 붙여넣기를 시도해 보세요.", pasteImageFailed: "이미지를 붙여넣지 못했습니다.", fileTooLarge: "{{fileName}}이(가) 너무 큽니다 (최대 {{size}})", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 84c4352e11..f9e1f030f7 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -154,7 +154,7 @@ export const ptBR: TranslationResources = { initialPromptRequired: "O prompt inicial é obrigatório", alreadyLoading: "Já está carregando", uploadFailed: "Falha ao enviar arquivo", - noClipboardImage: "Não há imagem na área de transferência", + noClipboardImage: "Não há imagem na área de transferência atual. Tente colar pelo teclado.", pasteImageFailed: "Falha ao colar a imagem", fileTooLarge: "{{fileName}} é grande demais (máximo {{size}})", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 9fdefa4a1e..370a2b95d5 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -154,7 +154,8 @@ export const ru: TranslationResources = { initialPromptRequired: "Необходим инициализирующий промпт", alreadyLoading: "Уже загружается", uploadFailed: "Ошибка загрузки файла", - noClipboardImage: "В буфере обмена нет изображения", + noClipboardImage: + "В текущем буфере обмена нет изображения. Попробуйте вставить его с клавиатуры.", pasteImageFailed: "Не удалось вставить изображение", fileTooLarge: "Файл {{fileName}} слишком большой (максимальный размер: {{size}})", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 299aec11fc..210e8724d0 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -154,7 +154,7 @@ export const zhCN: TranslationResources = { initialPromptRequired: "初始 prompt 必填", alreadyLoading: "正在加载", uploadFailed: "Failed to upload file", - noClipboardImage: "剪贴板中没有图片", + noClipboardImage: "当前剪贴板中没有图片。请尝试通过键盘粘贴。", pasteImageFailed: "无法粘贴图片", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, From d1a53255fcd559bccd95f3f9e0b6ba85dc11d420 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 14 Sep 2026 12:25:29 +0200 Subject: [PATCH 07/17] fix(app): keep growing composers below the header (#4824) * fix(app): keep growing composers below the header Native keyboard translation left the dock at full height, so intrinsic composer growth could extend behind the header. Reserve the keyboard destination at motion boundaries and bound the composer to its stationary viewport while preserving native input scrolling and animation. * docs: reuse existing test suites and automation --- CLAUDE.md | 1 + docs/floating-panels.md | 11 ++ docs/mobile-testing.md | 7 + .../e2e/mobile/composer-keyboard/android.sh | 25 +++ .../assert-composer-keyboard.mjs | 37 +++- .../app/src/composer/draft/workspace-tab.tsx | 129 +++++++------- packages/app/src/composer/index.tsx | 3 +- .../src/composer/viewport/capacity.test.ts | 35 ++++ .../app/src/composer/viewport/capacity.ts | 13 ++ .../src/composer/viewport/index.native.tsx | 66 ++++++++ packages/app/src/composer/viewport/index.tsx | 18 ++ .../app/src/hooks/keyboard-shift-context.ts | 2 + .../app/src/hooks/keyboard-shift-policy.ts | 10 ++ .../hooks/use-keyboard-shift-style.test.ts | 23 +++ .../app/src/hooks/use-keyboard-shift-style.ts | 33 +++- packages/app/src/panels/agent-panel.tsx | 9 +- .../app/src/screens/new-workspace-screen.tsx | 159 ++++++++++-------- 17 files changed, 440 insertions(+), 141 deletions(-) create mode 100644 packages/app/src/composer/viewport/capacity.test.ts create mode 100644 packages/app/src/composer/viewport/capacity.ts create mode 100644 packages/app/src/composer/viewport/index.native.tsx create mode 100644 packages/app/src/composer/viewport/index.tsx diff --git a/CLAUDE.md b/CLAUDE.md index b6ecb7ef7e..b1f8a294ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,7 @@ and updating `next`, integrating it after a release, and releasing a hotfix from - If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run --bail=1 > /tmp/test-output.txt 2>&1` then read the file. - Never re-run a test suite that another agent already ran and reported green — trust the result. - For full suite verification, push to CI and check GitHub Actions instead. +- Add tests to existing suites and reuse their npm scripts and CI jobs instead of creating feature-specific ones. - **Always run typecheck and lint after every change.** - **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current: - `npm run build:client` — rebuild protocol and client declarations. diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 69e8f2b105..616288fbf3 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -154,6 +154,17 @@ 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. +The translated dock's height is not the composer's available height. Bound the +composer against the stationary space below the header through +`composer/viewport`, including its controls and attachments. Reserve the +keyboard destination at move start and release space at move end on the UI +thread. Waiting for a settled JS update lets a long draft disappear behind the +header during opening. This is a boundary-time layout change, not an animated +layout prop. Keep the input's native intrinsic sizing and internal scrolling. +New workspace also needs its setup fields to scroll when the form exceeds this +space. The Android regression must check both the header and IME boundaries; +checking only the bottom controls allowed this regression through previously. + Move the stream and composer together through `KeyboardDock`. Do not translate them independently. diff --git a/docs/mobile-testing.md b/docs/mobile-testing.md index b341a2d80e..b51033d484 100644 --- a/docs/mobile-testing.md +++ b/docs/mobile-testing.md @@ -47,6 +47,13 @@ focused input identity and IME hide/show events. It saves screenshots and logs u `.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. +`npm run test:e2e:composer-keyboard:android` covers composer growth in chat and +New workspace, plus keyboard and control interactions. Configure its daemon, +Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in +`packages/app/e2e/mobile/composer-keyboard/android.sh`. Use a software keyboard; +the headless input helper cannot verify the +[visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors). + 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/composer-keyboard/android.sh b/packages/app/e2e/mobile/composer-keyboard/android.sh index c6739b02b1..86554ff998 100755 --- a/packages/app/e2e/mobile/composer-keyboard/android.sh +++ b/packages/app/e2e/mobile/composer-keyboard/android.sh @@ -275,6 +275,11 @@ node "${ASSERT}" xml-above-y \ "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \ "Add attachment" \ "$(read_ime_top)" +display_density="$(adb shell wm density | awk '/density:/ { density = $NF } END { print density }')" +node "${ASSERT}" xml-composer-contained \ + "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \ + "$(read_ime_top)" \ + "${display_density}" adb shell ime set "${HELPER_IME}" >/dev/null ad fill 'editable=true' "${MESSAGE}" --settle open_gboard "${input_x}" "${input_y}" @@ -337,4 +342,24 @@ node "${ASSERT}" same-region \ 1400 \ 430 +ad press 'id="menu-button"' --settle +ad press 'id="sidebar-global-new-workspace"' --settle +ad wait 'id="workspace-create-submit"' 10000 +adb shell ime set "${HELPER_IME}" >/dev/null +ad fill 'editable=true' "${LONG_MESSAGE}" --settle +snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json" +read -r new_input_x new_input_y new_input_height < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-long-draft.json" "editable" +) +# The editor shrinks when the keyboard opens. Its bottom stays anchored, while +# its old center can move into the setup fields after the height constraint. +new_input_y="$((new_input_y + new_input_height / 2 - 8))" +open_gboard "${new_input_x}" "${new_input_y}" +capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" +capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.png" +node "${ASSERT}" xml-composer-contained \ + "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \ + "$(read_ime_top)" \ + "${display_density}" + echo "Composer keyboard invariants passed" diff --git a/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs b/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs index 3aaff5b2e0..255d442fde 100644 --- a/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs +++ b/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs @@ -4,11 +4,44 @@ import sharp from "sharp"; const [, , command, ...args] = process.argv; -if (command === "rect") { +if (command === "xml-composer-contained") { + const [snapshotPath, imeTopArgument, densityArgument] = args; + const snapshot = await fs.readFile(snapshotPath, "utf8"); + const boundsFor = (id) => { + const node = snapshot.match(new RegExp(`]*resource-id="${id}"[^>]*>`)); + if (!node) throw new Error(`Missing node: ${id}`); + const bounds = node[0].match(/bounds="\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]"/); + if (!bounds) throw new Error(`Missing bounds: ${id}`); + return { top: Number(bounds[2]), bottom: Number(bounds[4]) }; + }; + const viewport = boundsFor("composer-viewport"); + const content = boundsFor("composer-viewport-content"); + const composer = boundsFor("message-input-root"); + const controls = boundsFor("message-input-attach-button"); + const clearance = (5 * Number(densityArgument)) / 160; + const minimumTop = viewport.top + clearance; + // Android rounds layout units to physical pixels; allow one pixel of rounding. + if (content.top < minimumTop - 1 || composer.top < minimumTop - 1) { + throw new Error( + `Composer overlaps header: content=${content.top}, editor=${composer.top}, minimum=${minimumTop}`, + ); + } + if (composer.bottom > Number(imeTopArgument) || controls.bottom > Number(imeTopArgument)) { + throw new Error( + `Composer extends behind keyboard: editor=${composer.bottom}, controls=${controls.bottom}, keyboard=${imeTopArgument}`, + ); + } + process.stdout.write( + `Composer contained: top=${composer.top}, header=${viewport.top}, bottom=${composer.bottom}, keyboard=${imeTopArgument}\n`, + ); +} else if (command === "rect") { const [snapshotPath, identifier] = args; const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8")); const node = snapshot.data.nodes.find( - (candidate) => candidate.identifier === identifier || candidate.label === identifier, + (candidate) => + candidate.identifier === identifier || + candidate.label === identifier || + (identifier === "editable" && candidate.type.endsWith("EditText")), ); if (!node) throw new Error(`Missing node: ${identifier}`); const { x, y, width, height } = node.rect; diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 4dca9df166..742380789f 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { KeyboardTranslateView } from "@/components/keyboard-translate-view"; +import { ComposerViewport, ComposerViewportContent } from "@/composer/viewport"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; import invariant from "tiny-invariant"; import { Composer } from "@/composer"; @@ -621,75 +622,83 @@ export function WorkspaceDraftAgentTab({ [composerState.agentControls, handleDropdownCloseFocus, isSubmitting], ); return ( - - - {isSubmitting && draftAgent ? ( - - + + + {isSubmitting && draftAgent ? ( + + + + ) : ( + + + {formErrorMessage ? ( + + {formErrorMessage} + + ) : null} + + + )} + + + + + {importPillPress ? ( + + + + + + ) : null} + - - ) : ( - - - {formErrorMessage ? ( - - {formErrorMessage} - - ) : null} - - - )} - - - - {importPillPress ? ( - - - - - - ) : null} - - - + + + + ); } const animatedStaticStyles = RNStyleSheet.create({ inputAreaWrapper: { width: "100%", + flexShrink: 1, }, }); diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 84de49a3b9..fa2f994fc9 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -2407,8 +2407,7 @@ function ComposerContentImpl({ const animatedStaticStyles = RNStyleSheet.create({ container: { flexDirection: "column", - // KeyboardDock reduces the available column height with bottom padding. - // Keep the composer's constraint chain shrinkable down to its scrolling input. + // Propagate the viewport's height constraint down to the scrolling input. flexShrink: 1, position: "relative", }, diff --git a/packages/app/src/composer/viewport/capacity.test.ts b/packages/app/src/composer/viewport/capacity.test.ts new file mode 100644 index 0000000000..04b82e04ea --- /dev/null +++ b/packages/app/src/composer/viewport/capacity.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { resolveComposerCapacity } from "./capacity"; + +describe("composer viewport", () => { + it("leaves five points below the header for a bottom-anchored composer", () => { + const height = resolveComposerCapacity({ + height: 582, + bottomInset: 24, + keyboardShift: 308, + centered: false, + }); + expect(height).toBe(245); + expect(582 - 24 - 308 - height).toBe(5); + }); + + it("keeps a centered tablet form below the header after translation", () => { + const height = resolveComposerCapacity({ + height: 1000, + bottomInset: 80, + keyboardShift: 300, + centered: true, + }); + expect(height).toBe(310); + expect((1000 - 80 - height) / 2 - 300).toBe(5); + }); + + it("releases space after closing and responds to rotation instead of using window height", () => { + expect( + resolveComposerCapacity({ height: 582, bottomInset: 24, keyboardShift: 0, centered: false }), + ).toBe(553); + expect( + resolveComposerCapacity({ height: 300, bottomInset: 0, keyboardShift: 200, centered: false }), + ).toBe(95); + }); +}); diff --git a/packages/app/src/composer/viewport/capacity.ts b/packages/app/src/composer/viewport/capacity.ts new file mode 100644 index 0000000000..7e80b22964 --- /dev/null +++ b/packages/app/src/composer/viewport/capacity.ts @@ -0,0 +1,13 @@ +export function resolveComposerCapacity(input: { + height: number; + bottomInset: number; + keyboardShift: number; + centered: boolean; +}): number { + "worklet"; + // A centered form grows upward by half its height. Reserve both halves so + // translating it still leaves five layout points below the header. + const clearance = input.keyboardShift + 5; + const reservedSpace = input.centered ? clearance * 2 : clearance; + return Math.max(0, input.height - input.bottomInset - reservedSpace); +} diff --git a/packages/app/src/composer/viewport/index.native.tsx b/packages/app/src/composer/viewport/index.native.tsx new file mode 100644 index 0000000000..0ee128a0f8 --- /dev/null +++ b/packages/app/src/composer/viewport/index.native.tsx @@ -0,0 +1,66 @@ +import { createContext, useCallback, useContext } from "react"; +import { View, type LayoutChangeEvent, type ViewProps } from "react-native"; +import Animated, { + useAnimatedStyle, + useSharedValue, + useDerivedValue, + type SharedValue, +} from "react-native-reanimated"; +import { useKeyboardShift } from "@/hooks/keyboard-shift-context"; +import { resolveComposerCapacity } from "./capacity"; + +const ViewportCapacity = createContext | null>(null); + +interface ComposerViewportProps extends ViewProps { + bottomInset?: number; + centered?: boolean; +} + +/** Measure the stationary space below the header, outside keyboard translation. */ +export function ComposerViewport({ + children, + onLayout, + bottomInset = 0, + centered = false, + ...props +}: ComposerViewportProps) { + const measuredHeight = useSharedValue(0); + const { layoutShift } = useKeyboardShift(); + const capacity = useDerivedValue(() => { + if (measuredHeight.value === 0) return undefined; + return resolveComposerCapacity({ + height: measuredHeight.value, + bottomInset, + keyboardShift: layoutShift.value, + centered, + }); + }); + const measureViewport = useCallback( + (event: LayoutChangeEvent) => { + measuredHeight.value = event.nativeEvent.layout.height; + onLayout?.(event); + }, + [measuredHeight, onLayout], + ); + + return ( + + {children} + + ); +} + +/** Bound the complete composer (including its controls), not just the text input. */ +export function ComposerViewportContent({ style, ...props }: ViewProps) { + const capacity = useContext(ViewportCapacity); + if (!capacity) throw new Error("ComposerViewportContent requires ComposerViewport"); + const constraint = useAnimatedStyle(() => ({ + // Only viewport changes and keyboard start/end events trigger layout. Motion + // stays in KeyboardTranslateView and never changes this constraint per frame. + maxHeight: capacity.value, + })); + + return ( + + ); +} diff --git a/packages/app/src/composer/viewport/index.tsx b/packages/app/src/composer/viewport/index.tsx new file mode 100644 index 0000000000..bc2846a8bd --- /dev/null +++ b/packages/app/src/composer/viewport/index.tsx @@ -0,0 +1,18 @@ +import { View, type ViewProps } from "react-native"; + +interface ComposerViewportProps extends ViewProps { + bottomInset?: number; + centered?: boolean; +} + +export function ComposerViewport({ + bottomInset: _bottomInset, + centered: _centered, + ...props +}: ComposerViewportProps) { + return ; +} + +export function ComposerViewportContent(props: ViewProps) { + return ; +} diff --git a/packages/app/src/hooks/keyboard-shift-context.ts b/packages/app/src/hooks/keyboard-shift-context.ts index 9d41f13224..dcb0b4cf64 100644 --- a/packages/app/src/hooks/keyboard-shift-context.ts +++ b/packages/app/src/hooks/keyboard-shift-context.ts @@ -3,6 +3,8 @@ import type { SharedValue } from "react-native-reanimated"; export interface KeyboardShiftContextValue { shift: SharedValue; + /** Space reserved at keyboard boundaries, never updated by animation frames. */ + layoutShift: SharedValue; isMoving: SharedValue; bottomInset: SharedValue; } diff --git a/packages/app/src/hooks/keyboard-shift-policy.ts b/packages/app/src/hooks/keyboard-shift-policy.ts index 19c021a26c..97435027fa 100644 --- a/packages/app/src/hooks/keyboard-shift-policy.ts +++ b/packages/app/src/hooks/keyboard-shift-policy.ts @@ -1,5 +1,15 @@ export const DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT = 120; +/** Reserve both endpoints during motion so content cannot grow into a closing IME. */ +export function reserveKeyboardLayoutShift(input: { + current: number; + target: number; + phase: "start" | "end"; +}): number { + "worklet"; + return input.phase === "start" ? Math.max(input.current, input.target) : input.target; +} + export function resolveStreamKeyboardInset(input: { platform: "android" | "ios"; settledShift: number; 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 bada7dd74c..c85f26dde4 100644 --- a/packages/app/src/hooks/use-keyboard-shift-style.test.ts +++ b/packages/app/src/hooks/use-keyboard-shift-style.test.ts @@ -3,9 +3,32 @@ import { resolveStreamKeyboardInset, shouldReconcileHiddenKeyboardEnd, resolveKeyboardShift, + reserveKeyboardLayoutShift, shouldUseCompactExplorerKeyboardPadding, } from "./keyboard-shift-policy"; +describe("keyboard layout reservation", () => { + it("reserves the opening destination before motion and releases it only after closing", () => { + const opened = reserveKeyboardLayoutShift({ current: 0, target: 308, phase: "start" }); + expect(opened).toBe(308); + const closing = reserveKeyboardLayoutShift({ current: opened, target: 0, phase: "start" }); + expect(closing).toBe(308); + expect(reserveKeyboardLayoutShift({ current: closing, target: 0, phase: "end" })).toBe(0); + }); + + it("keeps enough space when a resize or reversal interrupts keyboard motion", () => { + const resizing = reserveKeyboardLayoutShift({ current: 308, target: 250, phase: "start" }); + expect(resizing).toBe(308); + const reversing = reserveKeyboardLayoutShift({ + current: resizing, + target: 340, + phase: "start", + }); + expect(reversing).toBe(340); + expect(reserveKeyboardLayoutShift({ current: reversing, target: 340, phase: "end" })).toBe(340); + }); +}); + describe("resolveStreamKeyboardInset", () => { it("uses the native scroll inset on iOS without changing content size", () => { expect(resolveStreamKeyboardInset({ platform: "ios", settledShift: 311 })).toEqual({ diff --git a/packages/app/src/hooks/use-keyboard-shift-style.ts b/packages/app/src/hooks/use-keyboard-shift-style.ts index 126ab9e9b3..2bd57d105c 100644 --- a/packages/app/src/hooks/use-keyboard-shift-style.ts +++ b/packages/app/src/hooks/use-keyboard-shift-style.ts @@ -17,6 +17,7 @@ import { scheduleOnRN } from "react-native-worklets"; import { DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, resolveKeyboardShift, + reserveKeyboardLayoutShift, shouldReconcileHiddenKeyboardEnd, } from "@/hooks/keyboard-shift-policy"; import { @@ -33,6 +34,7 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { const bottomInset = useSharedValue(insets.bottom); const isIos = Platform.OS === "ios"; const isMoving = useSharedValue(false); + const layoutShift = useSharedValue(0); const [settledShift, setSettledShift] = useState(0); const publishSettledShift = useCallback((nextShift: number) => { setSettledShift((currentShift) => (currentShift === nextShift ? currentShift : nextShift)); @@ -55,12 +57,36 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { useGenericKeyboardHandler( { - onStart: () => { + onStart: (event) => { "worklet"; + const target = resolveKeyboardShift({ + rawKeyboardHeight: event.height, + keyboardProgress: event.height > 0 ? 1 : 0, + bottomInset: bottomInset.value, + isIos, + iosMinHeight: DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, + }); + layoutShift.value = reserveKeyboardLayoutShift({ + current: layoutShift.value, + target, + phase: "start", + }); isMoving.value = true; }, onEnd: (event) => { "worklet"; + const target = resolveKeyboardShift({ + rawKeyboardHeight: event.height, + keyboardProgress: event.progress, + bottomInset: bottomInset.value, + isIos, + iosMinHeight: DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, + }); + layoutShift.value = reserveKeyboardLayoutShift({ + current: layoutShift.value, + target, + phase: "end", + }); if (isIos && shouldReconcileHiddenKeyboardEnd(event)) { keyboardHeight.value = 0; keyboardProgress.value = 0; @@ -68,7 +94,7 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { isMoving.value = false; }, }, - [isIos, isMoving, keyboardHeight, keyboardProgress], + [bottomInset, isIos, isMoving, keyboardHeight, keyboardProgress, layoutShift], ); useAnimatedReaction( @@ -84,10 +110,11 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { const value = useMemo( () => ({ shift, + layoutShift, isMoving, bottomInset, }), - [bottomInset, isMoving, shift], + [bottomInset, isMoving, layoutShift, shift], ); return createElement( diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 84f86bf745..5f26342542 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -24,6 +24,7 @@ import { useStoreWithEqualityFn } from "zustand/traditional"; import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view"; import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; import { KeyboardDock } from "@/components/keyboard-dock"; +import { ComposerViewport, ComposerViewportContent } from "@/composer/viewport"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { Composer } from "@/composer"; @@ -1359,7 +1360,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ setText={agentInputDraft.replaceText} onRewindComplete={handleRewindComplete} > - + {contentContainer} @@ -1386,7 +1387,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ ) : null} - {composerSection} + + {composerSection} + {showHistorySyncOverlay ? ( @@ -1404,7 +1407,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ {t("agentPanel.states.archivingSubtitle")} ) : null} - + ); }); diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 7f7b6b13a8..a9baaf007d 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -12,6 +12,8 @@ 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 { ComposerViewport, ComposerViewportContent } from "@/composer/viewport"; +import { ScrollView } from "@/components/ui/scroll-view"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { resolveComposerAttachmentSubmitFormat, @@ -745,7 +747,7 @@ function getContentStyle(input: { isCompact: boolean; insetBottom: number }) { if (input.isCompact) { return [styles.content, styles.contentCompact, { paddingBottom: input.insetBottom }]; } - return [styles.content, styles.contentCentered]; + return [styles.content, styles.contentCentered, { paddingBottom: input.insetBottom }]; } function normalizeBranchDetails( @@ -2282,9 +2284,13 @@ export function NewWorkspaceScreen({ ], ); + const contentBottomInset = useMemo( + () => (isCompact ? insets.bottom : HEADER_INNER_HEIGHT + theme.spacing[6]), + [isCompact, insets.bottom, theme.spacing], + ); const contentStyle = useMemo( - () => getContentStyle({ isCompact, insetBottom: insets.bottom }), - [isCompact, insets.bottom], + () => getContentStyle({ isCompact, insetBottom: contentBottomInset }), + [isCompact, contentBottomInset], ); const agentControlsWithDisabled = useMemo( @@ -2370,85 +2376,97 @@ export function NewWorkspaceScreen({ return ( - + - - {t("newWorkspace.title")} - - {formStack} - {isTerminalLaunch ? ( - - ) : ( - - )} - {errorMessage ? {errorMessage} : null} + + + + {t("newWorkspace.title")} + + {formStack} + + {isTerminalLaunch ? ( + + ) : ( + + )} + {errorMessage ? {errorMessage} : null} + - + ); } const animatedStaticStyles = RNStyleSheet.create({ centered: { + flexShrink: 1, width: "100%", maxWidth: MAX_CONTENT_WIDTH, }, + form: { + flexShrink: 1, + }, + setup: { + flexGrow: 0, + flexShrink: 1, + }, }); const styles = StyleSheet.create((theme) => ({ @@ -2464,7 +2482,6 @@ const styles = StyleSheet.create((theme) => ({ }, contentCentered: { justifyContent: "center", - paddingBottom: HEADER_INNER_HEIGHT + theme.spacing[6], }, contentCompact: { justifyContent: "flex-end", From cdc669fde6deb54fee6d4ad6e1f3756cd8726421 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 14 Sep 2026 16:51:10 +0200 Subject: [PATCH 08/17] fix(agents): prevent OOM from cumulative tool history (#4838) * fix(agents): prevent OOM from cumulative tool history Repeated progress updates retained complete historical copies of growing tool output. Retain projected items on ingestion and serve them for every history fetch, preserving source sequence coverage for catch-up. Keep intermediate payloads only in streaming; confine compatibility degradation to child transcripts and overwritten fork checkpoints. * test(client): expect projected subagent capability in hello * fix(app): report unexpected child history failures Keep connection failures recoverable while forwarding unexpected fetch and reconciliation errors to diagnostics. Name observer arguments to keep server and agent identities explicit. * test(agents): assert projected history after rewind * docs(protocol): tag projected child compatibility gates --- docs/timeline-sync.md | 25 +- .../src/panels/provider-subagent-panel.tsx | 36 ++- .../app/src/subagents/provider-store.test.ts | 272 +++++++++++++++++- packages/app/src/subagents/provider-store.ts | 256 ++++++++++------- packages/client/src/connection/index.ts | 1 + packages/client/src/daemon-client.test.ts | 1 + packages/client/src/daemon-client.ts | 4 + packages/protocol/src/client-capabilities.ts | 2 + packages/protocol/src/messages.ts | 15 + .../src/server/agent/activity-curator.test.ts | 76 +++-- .../src/server/agent/activity-curator.ts | 17 +- .../agent-manager-stream-coalescing.test.ts | 94 +++--- .../src/server/agent/agent-manager.test.ts | 49 ++-- .../server/src/server/agent/agent-manager.ts | 6 +- .../agent/agent-timeline-store-types.ts | 7 +- .../server/agent/agent-timeline-store.test.ts | 128 ++++++++- .../src/server/agent/agent-timeline-store.ts | 269 ++++------------- .../agent/projected-timeline-contract.test.ts | 201 +++++++++++++ .../agent/provider-subagents/store.test.ts | 9 +- .../server/agent/provider-subagents/store.ts | 26 +- .../src/server/agent/rewind/rewind.test.ts | 16 +- .../src/server/agent/timeline-projection.ts | 132 ++++++--- .../timeline-reconnect-contract.e2e.test.ts | 50 +++- .../daemon-e2e/timeline-window.e2e.test.ts | 29 +- packages/server/src/server/session.ts | 180 ++++-------- .../server/src/server/websocket-server.ts | 2 + 26 files changed, 1190 insertions(+), 713 deletions(-) create mode 100644 packages/server/src/server/agent/projected-timeline-contract.test.ts diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 1c3b1e4593..740a040d90 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -5,8 +5,10 @@ Agent chat delivery has two paths: 1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates. 2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas. -The daemon keeps canonical rows only for its runtime. Provider history is the durable transcript -authority and repopulates those rows when an agent resumes. +The daemon retains projected items in memory. Each source event advances the stream sequence, +then replaces the previous tool state or merges into the current text item. Intermediate payloads +are never retained for history or catch-up. Provider history is the durable transcript authority +and rebuilds the projection when an agent resumes. The invariants are: @@ -67,7 +69,10 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position. -The daemon validates that the epoch is current and the exact source sequence still exists before slicing rows. It slices before projection so later lifecycle updates cannot leak into the selected context. +The daemon validates the epoch and locates the projected item at the selected position. A fork +includes projected items through that checkpoint. If an item spans the checkpoint and changed +afterward, the daemon refuses that fork with an actionable error: discarded historical payloads +cannot be reconstructed from sequence metadata. Forking the current context remains available. ## Resume behavior @@ -93,6 +98,20 @@ history replay. The pending approval UI can hide that tool from presentation, bu must retain its position. Creating a new history card on rejection places it after the prompt that rejected it; changing steer-event ordering would also put new assistant output before that prompt. +## Provider child history + +Child transcripts use the same projected-page reconciliation as the main conversation. The client +retains rendered items and sequence cursors, never a second cache of source events. Pagination +uses the projected display anchor; live updates advance the source cursor without moving tools. + +Clients advertising `projected_subagent_timeline` receive child streams and projected fetches. +Updated clients require `features.projectedSubagentTimeline` for child history; older hosts show +an update-host notice for that pane. +Older clients retain child names/status and receive an upgrade message when opening a child +conversation. This gate affects only the child transcript; it does not gate the connection, +main conversation, or current-context forks. A legacy `canonical` root request still receives +projected items. + ## Client replica lifetime The session projection remains host-scoped for as long as the host is registered. The viewed-timeline diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index 80323561ba..8fc18eb0e3 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -21,6 +21,7 @@ import { providerSubagentKey, providerSubagentLifecycleStatus, refreshProviderSubagents, + observeProviderSubagentTimeline, useProviderSubagentStore, } from "@/subagents/provider-store"; import { useTranslation } from "react-i18next"; @@ -126,8 +127,8 @@ function ProviderSubagentPanel() { ); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const serverInfo = useSessionStore((state) => state.sessions[serverId]?.serverInfo ?? null); - // COMPAT(providerSubagents): added in v0.2.11, remove after 2027-01-12. - const supported = serverInfo?.features?.providerSubagents === true; + // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove after 2027-03-14. + const supported = serverInfo?.features?.projectedSubagentTimeline === true; const [isLoadingOlder, setIsLoadingOlder] = useState(false); const isCompact = useIsCompactFormFactor(); const childRows = useSubagentsForParent({ @@ -150,23 +151,28 @@ function ProviderSubagentPanel() { useEffect(() => { if (!client || !supported) return; - void client - .fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, { - direction: "tail", - limit: TIMELINE_FETCH_PAGE_SIZE, - }) - .then((payload) => { - useProviderSubagentStore.getState().replaceTimeline(serverId, payload); - return undefined; - }) - .catch(() => undefined); - }, [client, serverId, supported, target.parentAgentId, target.subagentId]); + return observeProviderSubagentTimeline({ + client, + serverId, + parentAgentId: target.parentAgentId, + subagentId: target.subagentId, + limit: TIMELINE_FETCH_PAGE_SIZE, + reportError: (error) => { + console.error("[ProviderSubagentTimeline] Failed to refresh child history", { + error, + serverId, + parentAgentId: target.parentAgentId, + subagentId: target.subagentId, + }); + }, + }); + }, [client, supported, serverId, target.parentAgentId, target.subagentId]); const loadOlder = useCallback((): boolean => { if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) { return false; } - const firstSeq = timeline.rows.size ? Math.min(...timeline.rows.keys()) : null; + const firstSeq = timeline.cursor?.startSeq ?? null; if (firstSeq === null) return false; setIsLoadingOlder(true); void client @@ -191,7 +197,7 @@ function ProviderSubagentPanel() { target.subagentId, timeline, ]); - const firstTimelineSeq = timeline?.rows.size ? Math.min(...timeline.rows.keys()) : null; + const firstTimelineSeq = timeline?.cursor?.startSeq ?? null; const progressKey = timeline?.epoch && firstTimelineSeq !== null ? `${timeline.epoch}:${firstTimelineSeq}` : null; const subtitle = descriptor?.subtitle?.trim(); diff --git a/packages/app/src/subagents/provider-store.test.ts b/packages/app/src/subagents/provider-store.test.ts index ea863ea37a..96badfd354 100644 --- a/packages/app/src/subagents/provider-store.test.ts +++ b/packages/app/src/subagents/provider-store.test.ts @@ -1,5 +1,10 @@ +import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; import { afterEach, describe, expect, test } from "vitest"; -import { providerSubagentKey, useProviderSubagentStore } from "./provider-store"; +import { + observeProviderSubagentTimeline, + providerSubagentKey, + useProviderSubagentStore, +} from "./provider-store"; const SERVER_ID = "server-1"; const PARENT_ID = "parent-1"; @@ -41,6 +46,7 @@ describe("provider subagent client store", () => { item: { type: "assistant_message", text: "New live output." }, }); subagents.replaceTimeline(SERVER_ID, { + projection: "projected", requestId: "history-1", parentAgentId: PARENT_ID, subagentId: SUBAGENT_ID, @@ -103,12 +109,12 @@ describe("provider subagent client store", () => { const state = useProviderSubagentStore.getState(); expect(state.descriptors.get(key)?.status).toBe("completed"); expect(state.timelines.get(key)?.head).toEqual([]); - expect(state.timelines.get(key)?.tail).toEqual([ - expect.objectContaining({ - kind: "assistant_message", - text: "Older history.New live output.", - }), - ]); + expect( + state.timelines + .get(key) + ?.tail.map((item) => (item.kind === "assistant_message" ? item.text : "")) + .join(""), + ).toBe("Older history.New live output."); }); test("removes timelines for children no longer returned by the provider", () => { @@ -325,6 +331,7 @@ describe("provider subagent client store", () => { test("merges bounded older pages and tracks whether more history remains", () => { const store = useProviderSubagentStore.getState(); store.replaceTimeline(SERVER_ID, { + projection: "projected", requestId: "tail-page", parentAgentId: PARENT_ID, subagentId: SUBAGENT_ID, @@ -347,6 +354,7 @@ describe("provider subagent client store", () => { error: null, }); store.replaceTimeline(SERVER_ID, { + projection: "projected", requestId: "older-page", parentAgentId: PARENT_ID, subagentId: SUBAGENT_ID, @@ -373,8 +381,8 @@ describe("provider subagent client store", () => { .getState() .timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)); expect(timeline?.hasOlder).toBe(false); - expect([...timeline!.rows.keys()]).toEqual([2, 1]); - expect(timeline?.head).toEqual([ + expect(timeline?.cursor).toMatchObject({ startSeq: 1, endSeq: 2 }); + expect([...(timeline?.tail ?? []), ...(timeline?.head ?? [])]).toEqual([ expect.objectContaining({ kind: "assistant_message", text: "Older output.Recent output." }), ]); }); @@ -382,6 +390,7 @@ describe("provider subagent client store", () => { test("ignores delayed live updates from a stale timeline epoch", () => { const store = useProviderSubagentStore.getState(); store.replaceTimeline(SERVER_ID, { + projection: "projected", requestId: "current-page", parentAgentId: PARENT_ID, subagentId: SUBAGENT_ID, @@ -419,8 +428,8 @@ describe("provider subagent client store", () => { .getState() .timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)); expect(timeline?.epoch).toBe("epoch-current"); - expect([...timeline!.rows.keys()]).toEqual([2]); - expect(timeline?.head).toEqual([ + expect(timeline?.cursor).toMatchObject({ startSeq: 2, endSeq: 2 }); + expect([...(timeline?.tail ?? []), ...(timeline?.head ?? [])]).toEqual([ expect.objectContaining({ kind: "assistant_message", text: "Current output." }), ]); }); @@ -428,6 +437,7 @@ describe("provider subagent client store", () => { test("replaces cached rows with an authoritative tail page after a reconnect gap", () => { const store = useProviderSubagentStore.getState(); store.replaceTimeline(SERVER_ID, { + projection: "projected", requestId: "old-tail", parentAgentId: PARENT_ID, subagentId: SUBAGENT_ID, @@ -450,6 +460,7 @@ describe("provider subagent client store", () => { error: null, }); store.replaceTimeline(SERVER_ID, { + projection: "projected", requestId: "reconnect-tail", parentAgentId: PARENT_ID, subagentId: SUBAGENT_ID, @@ -475,9 +486,244 @@ describe("provider subagent client store", () => { const timeline = useProviderSubagentStore .getState() .timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)); - expect([...timeline!.rows.keys()]).toEqual([401]); - expect(timeline?.head).toEqual([ + expect(timeline?.cursor).toMatchObject({ startSeq: 401, endSeq: 401 }); + expect([...(timeline?.tail ?? []), ...(timeline?.head ?? [])]).toEqual([ expect.objectContaining({ kind: "assistant_message", text: "Current tail output." }), ]); }); }); + +describe("projected child history", () => { + const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); + const timestamp = "2026-09-14T00:00:00.000Z"; + const current = () => useProviderSubagentStore.getState().timelines.get(key)!; + const text = () => + [...current().tail, ...current().head] + .map((item) => (item.kind === "assistant_message" ? item.text : "")) + .join(""); + function stream(seq: number, value: string) { + useProviderSubagentStore.getState().applyUpdate(SERVER_ID, { + kind: "timeline", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "e", + seq, + timestamp, + item: { type: "assistant_message", messageId: "m", text: value }, + }); + } + type Payload = Parameters< + ReturnType["replaceTimeline"] + >[1]; + function response( + value: string, + seqStart: number, + seqEnd: number, + direction: "tail" | "after" = "tail", + ): Payload { + return { + requestId: "r", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "e", + direction, + projection: "projected", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: seqEnd, nextSeq: seqEnd + 1 }, + startCursor: { epoch: "e", seq: seqStart }, + endCursor: { epoch: "e", seq: seqEnd }, + hasOlder: false, + hasNewer: false, + error: null, + rows: [ + { + seq: seqEnd, + seqStart, + seqEnd, + sourceSeqRanges: [{ startSeq: seqStart, endSeq: seqEnd }], + timestamp, + item: { type: "assistant_message", messageId: "m", text: value }, + }, + ], + }; + } + function page( + value: string, + seqStart: number, + seqEnd: number, + direction: "tail" | "after" = "tail", + ) { + useProviderSubagentStore + .getState() + .replaceTimeline(SERVER_ID, response(value, seqStart, seqEnd, direction)); + } + test("replaces overlapping fetched text and continues live streaming", () => { + page("AB", 1, 2); + stream(3, "C"); + page("ABCD", 1, 4, "after"); + expect(text()).toBe("ABCD"); + stream(5, "E"); + expect(text()).toBe("ABCDE"); + }); + test("reconciles a tail snapshot racing newer live text", () => { + stream(1, "A"); + stream(2, "B"); + stream(3, "C"); + page("AB", 1, 2); + page("ABC", 1, 3); + expect(text()).toBe("ABC"); + expect(current().needsRefresh).toBe(false); + }); + test("recovers a live sequence gap while observed and stops fetching when closed", async () => { + let calls = 0; + const client = { + async fetchProviderSubagentTimeline() { + calls++; + return calls === 1 ? response("A", 1, 1) : response("ABC", 1, 3); + }, + }; + const errors: unknown[] = []; + const stop = observeProviderSubagentTimeline({ + client, + serverId: SERVER_ID, + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + limit: 100, + reportError: (error) => { + errors.push(error); + }, + }); + try { + await expect.poll(() => current()?.cursor?.endSeq).toBe(1); + stream(3, "C"); + await expect.poll(text).toBe("ABC"); + expect(calls).toBe(2); + expect(current().needsRefresh).toBe(false); + } finally { + stop(); + } + stream(5, "E"); + await Promise.resolve(); + expect(calls).toBe(2); + expect(errors).toEqual([]); + }); + test("retries a disconnected history read on the next stream update", async () => { + let calls = 0; + const errors: unknown[] = []; + const stop = observeProviderSubagentTimeline({ + client: { + async fetchProviderSubagentTimeline() { + calls++; + if (calls === 2) throw new DaemonConnectionError("Connection lost"); + return calls === 1 ? response("A", 1, 1) : response("ABCD", 1, 4); + }, + }, + serverId: SERVER_ID, + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + limit: 100, + reportError: (error) => { + errors.push(error); + }, + }); + try { + await expect.poll(() => current()?.cursor?.endSeq).toBe(1); + stream(3, "C"); + await expect.poll(() => calls).toBe(2); + expect(current().needsRefresh).toBe(true); + stream(4, "D"); + await expect.poll(text).toBe("ABCD"); + expect(current().needsRefresh).toBe(false); + expect(errors).toEqual([]); + } finally { + stop(); + } + }); + test("reports unexpected child history failures", async () => { + const failure = new TypeError("Invalid child timeline response"); + const errors: unknown[] = []; + const stop = observeProviderSubagentTimeline({ + client: { + async fetchProviderSubagentTimeline() { + throw failure; + }, + }, + serverId: SERVER_ID, + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + limit: 100, + reportError: (error) => { + errors.push(error); + }, + }); + try { + await expect.poll(() => errors).toEqual([failure]); + } finally { + stop(); + } + }); + test("keeps a completed tool at its original position after fetching its latest update", () => { + const payload = response("Answer", 1, 5); + payload.rows = [ + { + seq: 5, + seqStart: 1, + seqEnd: 5, + sourceSeqRanges: [ + { startSeq: 1, endSeq: 1 }, + { startSeq: 5, endSeq: 5 }, + ], + timestamp, + item: { + type: "tool_call", + callId: "t", + name: "shell", + status: "completed", + error: null, + detail: { type: "plain_text", label: "work" }, + }, + }, + { + seq: 4, + seqStart: 2, + seqEnd: 4, + sourceSeqRanges: [{ startSeq: 2, endSeq: 4 }], + timestamp, + item: { type: "assistant_message", text: "Answer", messageId: "m" }, + }, + ]; + useProviderSubagentStore.getState().replaceTimeline(SERVER_ID, payload); + expect([...current().tail, ...current().head].map((item) => item.kind)).toEqual([ + "tool_call", + "assistant_message", + ]); + }); + test("retains projected display state rather than cumulative tool snapshots", () => { + for (let seq = 1; seq <= 2000; seq++) { + useProviderSubagentStore.getState().applyUpdate(SERVER_ID, { + kind: "timeline", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "e", + seq, + timestamp, + item: { + type: "tool_call", + callId: "tool", + name: "task", + status: "running", + error: null, + detail: { type: "sub_agent", description: "Child", log: "x".repeat(seq * 128) }, + }, + }); + } + expect([...current().tail, ...current().head]).toHaveLength(1); + expect(JSON.stringify(current()).length).toBeLessThan(1_000_000); + expect(current().lastSeq).toBe(2000); + }); +}); diff --git a/packages/app/src/subagents/provider-store.ts b/packages/app/src/subagents/provider-store.ts index 15a9eb9b71..86e857d774 100644 --- a/packages/app/src/subagents/provider-store.ts +++ b/packages/app/src/subagents/provider-store.ts @@ -3,30 +3,25 @@ import type { ProviderSubagentDescriptorPayload, SessionOutboundMessage, } from "@getpaseo/protocol/messages"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { DaemonConnectionError, type DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { create } from "zustand"; import { applyStreamEvent } from "@/types/stream"; +import { + processTimelineResponse, + processAgentStreamEvent, + type TimelineCursor, +} from "@/timeline/session-stream-reducers"; import type { StreamItem } from "@/types/stream"; import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle"; -type ProviderSubagentTimelineItem = Extract< - Extract["payload"], - { kind: "timeline" } ->["item"]; - -interface ProviderSubagentTimelineRow { - provider: ProviderSubagentDescriptorPayload["provider"]; - item: ProviderSubagentTimelineItem; - timestamp: string; -} - export interface ProviderSubagentTimelineState { tail: StreamItem[]; head: StreamItem[]; epoch: string | null; lastSeq: number; hasOlder: boolean; - rows: Map; + cursor: TimelineCursor | undefined; + needsRefresh: boolean; } interface ProviderSubagentState { @@ -112,7 +107,8 @@ const EMPTY_TIMELINE: ProviderSubagentTimelineState = { epoch: null, lastSeq: 0, hasOlder: false, - rows: new Map(), + cursor: undefined, + needsRefresh: false, }; function providerSubagentTerminalEvent( @@ -130,68 +126,23 @@ function providerSubagentTerminalEvent( return { type: "turn_completed", provider: subagent.provider }; } -function buildTimelineState( - rows: ProviderSubagentTimelineState["rows"], - epoch: string | null, +function settleTimeline( + timeline: ProviderSubagentTimelineState, descriptor?: ProviderSubagentDescriptorPayload, - hasOlder = false, ): ProviderSubagentTimelineState { - let timeline = { tail: [] as StreamItem[], head: [] as StreamItem[] }; - for (const [, row] of [...rows].sort(([left], [right]) => left - right)) { - timeline = applyStreamEvent({ - ...timeline, - event: { type: "timeline", provider: row.provider, item: row.item }, - timestamp: new Date(row.timestamp), - }); - } - const terminalEvent = descriptor ? providerSubagentTerminalEvent(descriptor) : null; - if (terminalEvent && descriptor) { - timeline = applyStreamEvent({ - ...timeline, - event: terminalEvent, - timestamp: new Date(descriptor.updatedAt), - }); - } + const event = descriptor ? providerSubagentTerminalEvent(descriptor) : null; + if (!event || !descriptor) return timeline; return { ...timeline, - epoch, - lastSeq: rows.size ? Math.max(...rows.keys()) : 0, - hasOlder, - rows, + ...applyStreamEvent({ + tail: timeline.tail, + head: timeline.head, + event, + timestamp: new Date(descriptor.updatedAt), + }), }; } -function buildTimelineResponseRows( - existing: ProviderSubagentTimelineState | undefined, - payload: Extract< - SessionOutboundMessage, - { type: "agent.provider_subagents.timeline.get.response" } - >["payload"], - provider: ProviderSubagentDescriptorPayload["provider"], -): ProviderSubagentTimelineState["rows"] { - const rows = new Map(); - for (const row of payload.rows) { - rows.set(row.seq, { provider, item: row.item, timestamp: row.timestamp }); - } - if (payload.reset || existing?.epoch !== payload.epoch) { - return rows; - } - if (payload.direction !== "tail") { - return new Map([...existing.rows, ...rows]); - } - - let nextSeq = payload.rows.length - ? Math.max(...payload.rows.map((row) => row.seq)) + 1 - : payload.window.maxSeq + 1; - for (const [seq, row] of [...existing.rows].sort(([left], [right]) => left - right)) { - if (seq < nextSeq) continue; - if (seq !== nextSeq) break; - rows.set(seq, row); - nextSeq += 1; - } - return rows; -} - export const useProviderSubagentStore = create((set) => ({ descriptors: new Map(), timelines: new Map(), @@ -229,10 +180,7 @@ export const useProviderSubagentStore = create((set) => ( const current = timelines.get(key); const previous = state.descriptors.get(key); if (current && previous?.status !== subagent.status) { - timelines.set( - key, - buildTimelineState(current.rows, current.epoch, subagent, current.hasOlder), - ); + timelines.set(key, settleTimeline(current, subagent)); } } return { descriptors, timelines, hiddenFromTrack }; @@ -257,10 +205,7 @@ export const useProviderSubagentStore = create((set) => ( const current = state.timelines.get(key); if (current && previous?.status !== payload.subagent.status) { timelines = new Map(state.timelines); - timelines.set( - key, - buildTimelineState(current.rows, current.epoch, payload.subagent, current.hasOlder), - ); + timelines.set(key, settleTimeline(current, payload.subagent)); } return { descriptors, timelines, hiddenFromTrack }; } @@ -281,30 +226,33 @@ export const useProviderSubagentStore = create((set) => ( if (payload.seq <= current.lastSeq) { return state; } - const rows = new Map(current.rows); - rows.set(payload.seq, { - provider: payload.provider, - item: payload.item, - timestamp: payload.timestamp, - }); - const descriptor = state.descriptors.get(key); - const next = - descriptor && descriptor.status !== "running" - ? buildTimelineState(rows, payload.epoch, descriptor, current.hasOlder) - : applyStreamEvent({ - tail: current.tail, - head: current.head, - event: { type: "timeline", provider: payload.provider, item: payload.item }, - timestamp: new Date(payload.timestamp), - }); - const timelines = new Map(state.timelines); - timelines.set(key, { - ...next, + const next = processAgentStreamEvent({ + currentTail: current.tail, + currentHead: current.head, + currentCursor: current.cursor, + hasAuthoritativeBaseline: current.cursor !== undefined, + event: { type: "timeline", provider: payload.provider, item: payload.item }, + timestamp: new Date(payload.timestamp), + seq: payload.seq, epoch: payload.epoch, - lastSeq: payload.seq, - hasOlder: current.hasOlder, - rows, }); + const timelines = new Map(state.timelines); + timelines.set( + key, + settleTimeline( + { + tail: next.tail, + head: next.head, + epoch: payload.epoch, + lastSeq: payload.seq, + cursor: next.cursor ?? current.cursor, + hasOlder: current.hasOlder, + needsRefresh: + current.needsRefresh || next.sideEffects.some((effect) => effect.type === "catch_up"), + }, + state.descriptors.get(key), + ), + ); return { timelines }; }); }, @@ -316,11 +264,115 @@ export const useProviderSubagentStore = create((set) => ( set((state) => { const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId); const existing = state.timelines.get(key); - const rows = buildTimelineResponseRows(existing, payload, provider); - const descriptor = state.descriptors.get(key); + const current = existing ?? EMPTY_TIMELINE; + // Normalize optional wire metadata at the child timeline boundary. Legacy + // notices are complete singleton items; history always comes from a projected host. + const entries = payload.rows.map((row) => ({ + ...row, + provider, + seqStart: row.seqStart ?? row.seq, + seqEnd: row.seqEnd ?? row.seq, + })); + const result = processTimelineResponse({ + payload: { + ...payload, + agentId: payload.subagentId, + projection: "projected", + startCursor: + payload.startCursor ?? + (entries.length ? { seq: Math.min(...entries.map((entry) => entry.seqStart)) } : null), + endCursor: + payload.endCursor ?? + (entries.length ? { seq: Math.max(...entries.map((entry) => entry.seqEnd)) } : null), + entries, + }, + currentTail: current.tail, + currentHead: current.head, + currentCursor: current.cursor, + isInitializing: current.cursor === undefined, + hasActiveInitDeferred: current.cursor === undefined, + initRequestDirection: "tail", + sendingClientMessageIds: [], + }); const timelines = new Map(state.timelines); - timelines.set(key, buildTimelineState(rows, payload.epoch, descriptor, payload.hasOlder)); + timelines.set( + key, + settleTimeline( + { + tail: result.tail, + head: result.head, + epoch: payload.epoch, + lastSeq: + payload.reset || current.epoch !== payload.epoch + ? (result.cursor?.endSeq ?? 0) + : Math.max(current.lastSeq, result.cursor?.endSeq ?? 0), + cursor: result.cursor ?? undefined, + hasOlder: + result.older === "unchanged" ? current.hasOlder : result.older === "available", + needsRefresh: + result.sideEffects.some((effect) => effect.type === "catch_up") && + (payload.hasNewer || current.lastSeq > (result.cursor?.endSeq ?? 0)), + }, + state.descriptors.get(key), + ), + ); return { timelines }; }); }, })); + +/** Owns child history bootstrap and recovery while a pane observes the child. */ +export function observeProviderSubagentTimeline({ + client, + serverId, + parentAgentId, + subagentId, + limit, + reportError, +}: { + client: Pick; + serverId: string; + parentAgentId: string; + subagentId: string; + limit: number; + reportError: (error: unknown) => void; +}): () => void { + const key = providerSubagentKey(serverId, parentAgentId, subagentId); + let active = true; + let fetching = false; + const refresh = async () => { + if (!active || fetching) return; + fetching = true; + let succeeded = false; + try { + const payload = await client.fetchProviderSubagentTimeline(parentAgentId, subagentId, { + direction: "tail", + limit, + }); + if (active) useProviderSubagentStore.getState().replaceTimeline(serverId, payload); + succeeded = true; + } catch (error) { + // A later stream update or reopening the pane retries a disconnected read. + if (!(error instanceof DaemonConnectionError)) throw error; + } finally { + fetching = false; + if ( + succeeded && + active && + useProviderSubagentStore.getState().timelines.get(key)?.needsRefresh + ) + requestRefresh(); + } + }; + const requestRefresh = () => { + void refresh().catch(reportError); + }; + const unsubscribe = useProviderSubagentStore.subscribe((state) => { + if (state.timelines.get(key)?.needsRefresh) requestRefresh(); + }); + requestRefresh(); + return () => { + active = false; + unsubscribe(); + }; +} diff --git a/packages/client/src/connection/index.ts b/packages/client/src/connection/index.ts index c0681852d7..e1d1761774 100644 --- a/packages/client/src/connection/index.ts +++ b/packages/client/src/connection/index.ts @@ -132,6 +132,7 @@ export const DEFAULT_CLIENT_CAPABILITIES = { [CLIENT_CAPS.customModeIcons]: true, [CLIENT_CAPS.terminalReflowableSnapshot]: true, [CLIENT_CAPS.providerSubagents]: true, + [CLIENT_CAPS.projectedSubagentTimeline]: true, [CLIENT_CAPS.projectUpdates]: true, [CLIENT_CAPS.compactProviderSnapshots]: true, [CLIENT_CAPS.providerSnapshotReferences]: true, diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 7ecf6130b8..866f5bba22 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -740,6 +740,7 @@ test("advertises client capabilities in hello", async () => { custom_mode_icons: true, project_updates: true, provider_subagents: true, + projected_subagent_timeline: true, reasoning_merge_enum: true, terminal_reflowable_snapshot: true, timeline_notifications: true, diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index e012c7c3df..5c2e15d906 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -3137,6 +3137,10 @@ export class DaemonClient { subagentId: string, options: FetchProviderSubagentTimelineOptions = {}, ): Promise { + // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove after 2027-03-14. + if (this.lastServerInfoMessage?.features?.projectedSubagentTimeline !== true) { + throw new Error("Update the host to view subagent conversations."); + } const requestId = this.createRequestId(options.requestId); const message = SessionInboundMessageSchema.parse({ type: "agent.provider_subagents.timeline.get.request", diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index d7f5da1b7c..68488f4d66 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -24,6 +24,8 @@ export const CLIENT_CAPS = { // COMPAT(providerSubagents): added in v0.1.107. The daemon emits provider-owned // child descriptors and timelines only to clients that understand the new messages. providerSubagents: "provider_subagents", + // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain wire capability. + projectedSubagentTimeline: "projected_subagent_timeline", // COMPAT(projectUpdates): added in v0.1.109, remove gate after 2027-01-15. projectUpdates: "project_updates", // COMPAT(compactProviderSnapshots): added in v0.2.X. Capable clients receive diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index b8d0e56cc2..0b6c1b1882 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3579,6 +3579,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(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain wire field. + projectedSubagentTimeline: 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. @@ -4608,6 +4610,9 @@ export const ProviderSubagentTimelineResponseMessageSchema = z.object({ provider: AgentProviderSchema.nullable(), direction: z.enum(["tail", "before", "after"]), epoch: z.string(), + projection: z.literal("projected").optional(), + startCursor: AgentTimelineCursorSchema.nullable().optional(), + endCursor: AgentTimelineCursorSchema.nullable().optional(), reset: z.boolean(), staleCursor: z.boolean(), gap: z.boolean(), @@ -4623,6 +4628,16 @@ export const ProviderSubagentTimelineResponseMessageSchema = z.object({ item: AgentTimelineItemPayloadSchema, timestamp: z.string(), seq: z.number().int().nonnegative(), + seqStart: z.number().int().nonnegative().optional(), + seqEnd: z.number().int().nonnegative().optional(), + sourceSeqRanges: z + .array( + z.object({ + startSeq: z.number().int().nonnegative(), + endSeq: z.number().int().nonnegative(), + }), + ) + .optional(), }), ), error: z.string().nullable(), diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 7256a0669d..15ad9db90e 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -354,46 +354,44 @@ second line'`, expect(result.attachment.text).toContain("[Assistant] Done."); }); - it("selects the fork boundary before collapsing later tool updates", () => { - const result = buildAgentForkContextAttachment({ - boundaryMessageId: "assistant-1", - rows: [ - row(1, { type: "user_message", text: "Run it", messageId: "user-1" }), - row( - 2, - toolCallItem({ - callId: "terminal-1", - name: "terminal", - status: "running", - detail: { - type: "plain_text", - label: "before boundary", - }, + it("rejects a checkpoint whose projected tool state changed later", () => { + expect(() => + buildAgentForkContextAttachment({ + boundaryMessageId: "assistant-1", + rows: [ + row(1, { type: "user_message", text: "Run it", messageId: "user-1" }), + row( + 2, + toolCallItem({ + callId: "terminal-1", + name: "terminal", + status: "running", + detail: { + type: "plain_text", + label: "before boundary", + }, + }), + ), + row(3, { + type: "assistant_message", + text: "Partial result.", + messageId: "assistant-1", }), - ), - row(3, { - type: "assistant_message", - text: "Partial result.", - messageId: "assistant-1", - }), - row( - 4, - toolCallItem({ - callId: "terminal-1", - name: "terminal", - status: "completed", - detail: { - type: "plain_text", - label: "after boundary", - }, - }), - ), - ], - }); - - expect(result.attachment.text).toContain("[Terminal] before boundary"); - expect(result.attachment.text).toContain("[Assistant] Partial result."); - expect(result.attachment.text).not.toContain("after boundary"); + row( + 4, + toolCallItem({ + callId: "terminal-1", + name: "terminal", + status: "completed", + detail: { + type: "plain_text", + label: "after boundary", + }, + }), + ), + ], + }), + ).toThrow("Fork from a later completed response"); }); it("selects a synthetic assistant error by its timeline cursor", () => { diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index da3318c1a3..3bcab550e7 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -230,12 +230,12 @@ function selectForkContextRows(input: { boundaryCursor: { epoch: string; seq: number } | null; boundaryMessageId: string | null; } { + const projectedRows = projectTimelineRows({ rows: input.rows, mode: "projected" }); const boundaryCursor = input.cursorBoundary?.cursor ?? null; const boundaryMessageId = input.boundaryMessageId?.trim() || null; if (!boundaryCursor && !boundaryMessageId) { - const projected = projectTimelineRows({ rows: input.rows, mode: "projected" }); return { - items: projected.map((entry) => entry.item), + items: projectedRows.map((entry) => entry.item), boundaryCursor: null, boundaryMessageId: null, }; @@ -248,8 +248,8 @@ function selectForkContextRows(input: { throw new Error("Selected timeline position is no longer available."); } const boundaryIndex = boundaryCursor - ? input.rows.findIndex((row) => row.seq === boundaryCursor.seq) - : input.rows.findLastIndex( + ? projectedRows.findIndex((row) => row.seqEnd === boundaryCursor.seq) + : projectedRows.findLastIndex( (row) => row.item.type === "assistant_message" && row.item.messageId === boundaryMessageId, ); if (boundaryIndex < 0) { @@ -259,8 +259,13 @@ function selectForkContextRows(input: { : "Selected assistant message is no longer available.", ); } - const selectedRows = input.rows.slice(0, boundaryIndex + 1); - const projected = projectTimelineRows({ rows: selectedRows, mode: "projected" }); + const boundarySeq = projectedRows[boundaryIndex].seqEnd; + if (projectedRows.some((row) => row.seqStart <= boundarySeq && row.seqEnd > boundarySeq)) { + throw new Error( + "This checkpoint changed after it was created. Fork from a later completed response instead.", + ); + } + const projected = projectedRows.filter((row) => row.seqEnd <= boundarySeq); return { items: projected.map((entry) => entry.item), diff --git a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts index d3fb7c4b58..9dc194fa11 100644 --- a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts +++ b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts @@ -366,7 +366,16 @@ function getProjectedTimelineItems(rows: AgentTimelineRow[]): AgentTimelineItem[ } function expectContiguousRowSeqs(rows: AgentTimelineRow[], expected: number[]): void { - expect(rows.map((row) => row.seq)).toEqual(expected); + const projected = projectTimelineRows({ rows, mode: "projected" }); + const covered = projected.flatMap((row) => + row.sourceSeqRanges.flatMap((range) => + Array.from( + { length: range.endSeq - range.startSeq + 1 }, + (_, index) => range.startSeq + index, + ), + ), + ); + expect([...new Set(covered)].sort((a, b) => a - b)).toEqual(expected); } function expectContiguousLiveSeqs(events: AgentManagerEvent[], expected: number[]): void { @@ -524,11 +533,10 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); const events = getTimelineStreamEvents(harness.events, agentId); - expect(rows).toHaveLength(2); + expect(rows).toHaveLength(1); expect(events).toHaveLength(2); expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "x" }, - { type: "assistant_message", text: "x".repeat(999) }, + { type: "assistant_message", text: "x".repeat(1000) }, ]); expect(events.map((event) => (event.type === "agent_stream" ? event.event : null))).toEqual([ { @@ -567,10 +575,7 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); const events = getTimelineStreamEvents(harness.events, agentId); - expect(getTimelineItems(rows)).toEqual([ - { type: "reasoning", text: "r" }, - { type: "reasoning", text: "r".repeat(99) }, - ]); + expect(getTimelineItems(rows)).toEqual([{ type: "reasoning", text: "r".repeat(100) }]); expect(events).toHaveLength(2); expectContiguousRowSeqs(rows, [1, 2]); expectContiguousLiveSeqs(events, [1, 2]); @@ -604,8 +609,7 @@ describe("target coalesced behavior", () => { await vi.advanceTimersByTimeAsync(COALESCE_WINDOW_MS); expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([ - { type: "assistant_message", text: "a" }, - { type: "assistant_message", text: "a".repeat(499) }, + { type: "assistant_message", text: "a".repeat(500) }, runningToolCall, ]); expectContiguousLiveSeqs(getTimelineStreamEvents(harness.events, agentId), [1, 2, 3]); @@ -617,15 +621,14 @@ describe("target coalesced behavior", () => { // The "b" burst arrives inside the window that just flushed, so it gets no // leading flush of its own and coalesces whole. await vi.advanceTimersByTimeAsync(BEFORE_COALESCE_WINDOW_MS); - expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(3); + expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(2); await vi.advanceTimersByTimeAsync(1); const rows = await harness.manager.getTimelineRows(agentId); const events = getTimelineStreamEvents(harness.events, agentId); expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "a" }, - { type: "assistant_message", text: "a".repeat(499) }, + { type: "assistant_message", text: "a".repeat(500) }, runningToolCall, { type: "assistant_message", text: "b".repeat(500) }, ]); @@ -665,10 +668,7 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); const events = getTimelineStreamEvents(harness.events, agentId); - expect(getTimelineItems(rows)).toEqual([ - toolCall({ output: "chunk-0" }), - toolCall({ output: "chunk-199" }), - ]); + expect(getTimelineItems(rows)).toEqual([toolCall({ output: "chunk-199" })]); expect(events).toHaveLength(2); expectContiguousRowSeqs(rows, [1, 2]); expectContiguousLiveSeqs(events, [1, 2]); @@ -695,9 +695,8 @@ describe("target coalesced behavior", () => { const events = getTimelineStreamEvents(harness.events, agentId); expect(getTimelineItems(rows)).toEqual([ - toolCall({ callId: "tool-1", output: "one-a" }), - toolCall({ callId: "tool-2", output: "two-b" }), toolCall({ callId: "tool-1", output: "one-b" }), + toolCall({ callId: "tool-2", output: "two-b" }), ]); expect(events).toHaveLength(3); expectContiguousRowSeqs(rows, [1, 2, 3]); @@ -876,14 +875,8 @@ describe("target coalesced behavior", () => { const firstEvents = getTimelineStreamEvents(harness.events, first.agentId); const secondEvents = getTimelineStreamEvents(harness.events, second.agentId); - expect(getTimelineItems(firstRows)).toEqual([ - { type: "assistant_message", text: "a" }, - { type: "assistant_message", text: "a" }, - ]); - expect(getTimelineItems(secondRows)).toEqual([ - { type: "assistant_message", text: "b" }, - { type: "assistant_message", text: "b" }, - ]); + expect(getTimelineItems(firstRows)).toEqual([{ type: "assistant_message", text: "aa" }]); + expect(getTimelineItems(secondRows)).toEqual([{ type: "assistant_message", text: "bb" }]); expect(firstEvents).toHaveLength(2); expect(secondEvents).toHaveLength(2); expectContiguousRowSeqs(firstRows, [1, 2]); @@ -916,10 +909,7 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); const events = getTimelineStreamEvents(harness.events, agentId); - expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "codex" }, - { type: "assistant_message", text: "claude" }, - ]); + expect(getTimelineItems(rows)).toEqual([{ type: "assistant_message", text: "codexclaude" }]); expect(events.map((event) => (event.type === "agent_stream" ? event.event : null))).toEqual([ { type: "timeline", provider: "codex", item: { type: "assistant_message", text: "codex" } }, { @@ -991,10 +981,7 @@ describe("target coalesced behavior", () => { const events = getTimelineStreamEvents(harness.events, agentId); // The empty chunk is dropped before the coalescer, so " " leads the burst. - expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: " " }, - { type: "assistant_message", text: "\n\tdone" }, - ]); + expect(getTimelineItems(rows)).toEqual([{ type: "assistant_message", text: " \n\tdone" }]); expect(events).toHaveLength(2); expectContiguousRowSeqs(rows, [1, 2]); expectContiguousLiveSeqs(events, [1, 2]); @@ -1189,9 +1176,7 @@ describe("target coalesced behavior", () => { const timelineEvents = getTimelineStreamEvents(idempotencyHarness.events, agentId); expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "a" }, - { type: "assistant_message", text: "bc" }, - TOOL_CALL, + { type: "assistant_message", text: "abc" }, TOOL_CALL, ]); expect(timelineEvents).toHaveLength(4); @@ -1243,8 +1228,7 @@ describe("target coalesced behavior", () => { const rowsAfterSecondFlush = await reentryHarness.manager.getTimelineRows(agentId); const timelineEvents = getTimelineStreamEvents(reentryHarness.events, agentId); expect(getTimelineItems(rowsAfterSecondFlush)).toEqual([ - { type: "assistant_message", text: "A" }, - { type: "assistant_message", text: "B" }, + { type: "assistant_message", text: "AB" }, ]); expect(timelineEvents).toHaveLength(2); expectContiguousRowSeqs(rowsAfterSecondFlush, [1, 2]); @@ -1274,20 +1258,19 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); const events = getTimelineStreamEvents(harness.events, agentId); expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "flush" }, - { type: "assistant_message", text: "-pending" }, + { type: "assistant_message", text: "flush-pending" }, ]); expect(events).toHaveLength(2); await vi.advanceTimersByTimeAsync(100); - expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(2); + expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(1); expect(getTimelineStreamEvents(harness.events, agentId)).toHaveLength(2); } finally { harness.cleanup(); } }); - test("history hydration is uncoalesced and immediate", async () => { + test("history hydration projects immediately without waiting for a streaming window", async () => { vi.useFakeTimers(); const harness = createHarness(); try { @@ -1298,20 +1281,16 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "a1" }, - { type: "assistant_message", text: "a2" }, - { type: "reasoning", text: "r1" }, - { type: "reasoning", text: "r2" }, + { type: "assistant_message", text: "a1a2" }, + { type: "reasoning", text: "r1r2" }, ]); expectContiguousRowSeqs(rows, [1, 2, 3, 4]); expect(getTimelineStreamEvents(harness.events, agentId)).toHaveLength(0); await vi.advanceTimersByTimeAsync(100); expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([ - { type: "assistant_message", text: "a1" }, - { type: "assistant_message", text: "a2" }, - { type: "reasoning", text: "r1" }, - { type: "reasoning", text: "r2" }, + { type: "assistant_message", text: "a1a2" }, + { type: "reasoning", text: "r1r2" }, ]); } finally { harness.cleanup(); @@ -1362,10 +1341,7 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); const streamEvents = getStreamEvents(harness.events, agentId); - expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "hello " }, - { type: "assistant_message", text: "world" }, - ]); + expect(getTimelineItems(rows)).toEqual([{ type: "assistant_message", text: "hello world" }]); expect(getProjectedTimelineItems(rows)).toEqual([ { type: "assistant_message", text: "hello world" }, ]); @@ -1417,8 +1393,7 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "a1" }, - { type: "assistant_message", text: "a2" }, + { type: "assistant_message", text: "a1a2" }, { type: "reasoning", text: "r1r2" }, { type: "assistant_message", text: "b1b2" }, ]); @@ -1434,8 +1409,7 @@ describe("target coalesced behavior", () => { await vi.advanceTimersByTimeAsync(100); expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([ - { type: "assistant_message", text: "a1" }, - { type: "assistant_message", text: "a2" }, + { type: "assistant_message", text: "a1a2" }, { type: "reasoning", text: "r1r2" }, { type: "assistant_message", text: "b1b2" }, ]); diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 3ea6413561..2abdf65040 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -5386,14 +5386,17 @@ test("fetchTimeline returns a bounded reset window when cursor epoch is stale", await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "one", text: "one", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "two", text: "two", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "three", text: "three", }); @@ -5467,21 +5470,14 @@ test("getTimelineRows falls back to the in-memory timeline when no durable store }); await expect(manager.getTimelineRows(snapshot.id)).resolves.toEqual([ - { - seq: 1, - timestamp: expect.any(String), - item: { - type: "assistant_message", - text: "row one", - }, - }, { seq: 2, + seqStart: 1, + seqEnd: 2, + sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }], + collapsed: ["assistant_merge"], timestamp: expect.any(String), - item: { - type: "assistant_message", - text: "row two", - }, + item: { type: "assistant_message", text: "row onerow two" }, }, ]); }); @@ -5543,7 +5539,7 @@ test("getAgent does not expose committed history internals once manager owns the expect(fetched.rows.map((row) => row.seq)).toEqual([1, 2]); }); -test("coalesces assistant chunks and persists the canonical row", async () => { +test("streams coalesced assistant chunks and retains the projected message", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provisional-timeline-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); @@ -5600,8 +5596,8 @@ test("coalesces assistant chunks and persists the canonical row", async () => { } // The coalescer flushes the first chunk on the leading edge, so "final " ships - // as its own row and "reply" follows on the trailing window. Clients read the - // projected timeline, which merges the two back into one assistant message. + // as its own event and "reply" follows on the trailing window. History retains + // their complete projected assistant message. const assistantTimelineEvents = streamEvents.filter( (event) => event.itemType === "assistant_message", ); @@ -5624,18 +5620,15 @@ test("coalesces assistant chunks and persists the canonical row", async () => { expect(manager.getTimeline(snapshot.id)).toEqual([ { type: "assistant_message", - text: "final ", - }, - { - type: "assistant_message", - text: "reply", + text: "final reply", }, ]); const fetched = await manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 0, }); - expect(fetched.rows).toHaveLength(2); + expect(fetched.rows).toHaveLength(1); + expect(fetched.rows[0]).toMatchObject({ seqStart: 1, seqEnd: 2 }); expect(assistantTimelineEvents[0]?.epoch).toBe(fetched.epoch); expect(projectTimelineRows({ rows: fetched.rows, mode: "projected" }).map((e) => e.item)).toEqual( [ @@ -5671,28 +5664,34 @@ test("fetchTimeline supports older-history pagination with before seq", async () await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "first", text: "first", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "second", text: "second", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "third", text: "third", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "fourth", text: "fourth", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "fifth", text: "fifth", }); const result = await manager.fetchTimeline(snapshot.id, { direction: "before", cursor: { + epoch: manager.fetchTimeline(snapshot.id).epoch, seq: 5, }, limit: 2, @@ -5729,14 +5728,17 @@ test("does not trim committed history", async () => { await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "first", text: "first", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "second", text: "second", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "third", text: "third", }); @@ -5834,8 +5836,7 @@ test("hydrateTimeline preserves assistant chunk, reasoning, and tool timeline hi await manager.hydrateTimelineFromProvider(snapshot.id, { force: true }); expect(manager.getTimeline(snapshot.id)).toEqual([ - { type: "assistant_message", text: "chunk one " }, - { type: "assistant_message", text: "chunk two" }, + { type: "assistant_message", text: "chunk one chunk two" }, { type: "reasoning", text: "internal" }, { type: "tool_call", @@ -10305,7 +10306,7 @@ test("canonical submitted prompt keeps wire identity while rewind resolves provi ]); const timeline = manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 20 }).rows; - expect(timeline).toEqual([ + expect(timeline).toMatchObject([ { seq: 1, timestamp: expect.any(String), diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index adbb25bf34..5232b33a17 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1,3 +1,4 @@ +import { projectTimelineRows } from "./timeline-projection.js"; import type { PluginLifecycle } from "../plugins/lifecycle/index.js"; import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js"; import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server"; @@ -1154,7 +1155,10 @@ export class AgentManager { async getTimelineRows(id: string): Promise { this.requireAgent(id); if (this.durableTimelineStore) { - return await this.durableTimelineStore.getCommittedRows(id); + return projectTimelineRows({ + rows: await this.durableTimelineStore.getCommittedRows(id), + mode: "projected", + }).map((entry) => Object.assign({ seq: entry.seqEnd }, entry)); } return this.timelineStore.getRows(id); } diff --git a/packages/server/src/server/agent/agent-timeline-store-types.ts b/packages/server/src/server/agent/agent-timeline-store-types.ts index d73f52299c..639b24a904 100644 --- a/packages/server/src/server/agent/agent-timeline-store-types.ts +++ b/packages/server/src/server/agent/agent-timeline-store-types.ts @@ -1,3 +1,4 @@ +import type { ProjectedTimelineRow } from "./timeline-projection.js"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; export interface AgentTimelineRow { @@ -19,7 +20,7 @@ export interface AgentTimelineFetchOptions { direction?: AgentTimelineFetchDirection; cursor?: AgentTimelineCursor; /** - * Number of canonical rows to return. + * Number of projected items to return. * - undefined: store default * - 0: all rows in the selected window */ @@ -41,7 +42,9 @@ export interface AgentTimelineFetchResult { window: AgentTimelineWindow; hasOlder: boolean; hasNewer: boolean; - rows: AgentTimelineRow[]; + startSeq: number | null; + endSeq: number | null; + rows: ProjectedTimelineRow[]; } export interface AgentTimelineStore { diff --git a/packages/server/src/server/agent/agent-timeline-store.test.ts b/packages/server/src/server/agent/agent-timeline-store.test.ts index 2f801e0271..895aec2684 100644 --- a/packages/server/src/server/agent/agent-timeline-store.test.ts +++ b/packages/server/src/server/agent/agent-timeline-store.test.ts @@ -11,17 +11,17 @@ describe("InMemoryAgentTimelineStore", () => { { seq: 5, timestamp: "2026-01-01T00:00:00.000Z", - item: { type: "assistant_message", text: "five" }, + item: { type: "assistant_message", text: "five", messageId: "five" }, }, { seq: 6, timestamp: "2026-01-01T00:00:01.000Z", - item: { type: "assistant_message", text: "six" }, + item: { type: "assistant_message", text: "six", messageId: "six" }, }, { seq: 7, timestamp: "2026-01-01T00:00:02.000Z", - item: { type: "assistant_message", text: "seven" }, + item: { type: "assistant_message", text: "seven", messageId: "seven" }, }, ], }); @@ -32,7 +32,7 @@ describe("InMemoryAgentTimelineStore", () => { limit: 2, }); - expect(result).toEqual({ + expect(result).toMatchObject({ epoch: "epoch-1", direction: "before", reset: false, @@ -45,12 +45,12 @@ describe("InMemoryAgentTimelineStore", () => { { seq: 6, timestamp: "2026-01-01T00:00:01.000Z", - item: { type: "assistant_message", text: "six" }, + item: { type: "assistant_message", text: "six", messageId: "six" }, }, { seq: 7, timestamp: "2026-01-01T00:00:02.000Z", - item: { type: "assistant_message", text: "seven" }, + item: { type: "assistant_message", text: "seven", messageId: "seven" }, }, ], }); @@ -65,17 +65,17 @@ describe("InMemoryAgentTimelineStore", () => { { seq: 5, timestamp: "2026-01-01T00:00:00.000Z", - item: { type: "assistant_message", text: "five" }, + item: { type: "assistant_message", text: "five", messageId: "five" }, }, { seq: 6, timestamp: "2026-01-01T00:00:01.000Z", - item: { type: "assistant_message", text: "six" }, + item: { type: "assistant_message", text: "six", messageId: "six" }, }, { seq: 7, timestamp: "2026-01-01T00:00:02.000Z", - item: { type: "assistant_message", text: "seven" }, + item: { type: "assistant_message", text: "seven", messageId: "seven" }, }, ], }); @@ -86,7 +86,7 @@ describe("InMemoryAgentTimelineStore", () => { limit: 1, }); - expect(result).toEqual({ + expect(result).toMatchObject({ epoch: "epoch-1", direction: "after", reset: true, @@ -99,9 +99,115 @@ describe("InMemoryAgentTimelineStore", () => { { seq: 7, timestamp: "2026-01-01T00:00:02.000Z", - item: { type: "assistant_message", text: "seven" }, + item: { type: "assistant_message", text: "seven", messageId: "seven" }, }, ], }); }); }); + +describe("projected timeline retention", () => { + it("retains one full tool state while streaming every source update", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("agent"); + for (let seq = 1; seq <= 2000; seq++) { + const item = { + type: "tool_call" as const, + callId: "child", + name: "task", + status: "running" as const, + error: null, + detail: { type: "plain_text" as const, label: "Child", text: "x".repeat(seq * 128) }, + }; + expect(store.append("agent", item)).toMatchObject({ seq, item }); + } + const result = store.fetch("agent", { limit: 0 }); + expect(result.rows).toHaveLength(1); + expect(JSON.stringify(result.rows).length).toBeLessThan(260_000); + expect(result.window.maxSeq).toBe(2000); + }); + + it("catches up a mid-message cursor with the complete projected message", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("agent", { epoch: "e" }); + store.append("agent", { type: "assistant_message", messageId: "m", text: "A" }); + store.append("agent", { type: "assistant_message", messageId: "m", text: "B" }); + expect( + store.fetch("agent", { direction: "after", cursor: { epoch: "e", seq: 1 } }).rows, + ).toMatchObject([ + { + item: { text: "AB" }, + seqStart: 1, + seqEnd: 2, + sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }], + }, + ]); + }); +}); + +describe("projected sequence ownership", () => { + it("preserves fetched coverage when another source chunk arrives", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("a"); + store.append("a", { type: "assistant_message", text: "A" }); + const before = store.fetch("a"); + store.append("a", { type: "assistant_message", text: "B" }); + expect(before.rows[0]).toMatchObject({ + item: { text: "A" }, + seqEnd: 1, + sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], + }); + expect(store.fetch("a").rows[0]).toMatchObject({ + item: { text: "AB" }, + seqEnd: 2, + sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }], + }); + }); + it("preserves source positions when seeding a projected history whose last update is an earlier tool", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("a"); + const tool = { + type: "tool_call" as const, + callId: "t", + name: "shell", + error: null, + detail: { type: "plain_text" as const, label: "work" }, + }; + store.append("a", { ...tool, status: "running" }); + store.append("a", { type: "assistant_message", text: "Answer" }); + store.append("a", { ...tool, status: "completed" }); + store.initialize("b", { rows: store.getRows("a") }); + expect(store.append("b", { type: "user_message", text: "next" }).seq).toBe(4); + expect(store.fetch("b").rows.map((row) => row.seqStart)).toEqual([1, 2, 4]); + }); + it("returns the last projected assistant message without joining different messages", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("a"); + store.append("a", { type: "assistant_message", messageId: "first", text: "First" }); + store.append("a", { type: "assistant_message", messageId: "second", text: "Sec" }); + store.append("a", { type: "assistant_message", messageId: "second", text: "ond" }); + expect(store.getLastAssistantMessage("a")).toBe("Second"); + }); +}); + +it("includes transitive tool updates in a contiguous projected tail", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("a"); + const tool = (callId: string, status: "running" | "completed") => ({ + type: "tool_call" as const, + callId, + name: "shell", + status, + error: null, + detail: { type: "plain_text" as const, label: "work" }, + }); + store.append("a", tool("first", "running")); + store.append("a", tool("second", "running")); + store.append("a", { type: "user_message", text: "Continue" }); + store.append("a", tool("first", "completed")); + store.append("a", { type: "assistant_message", text: "Answer" }); + store.append("a", tool("second", "completed")); + const tail = store.fetch("a", { limit: 1 }); + expect(tail.rows.map((row) => row.seqStart)).toEqual([1, 2, 3, 5]); + expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false }); +}); diff --git a/packages/server/src/server/agent/agent-timeline-store.ts b/packages/server/src/server/agent/agent-timeline-store.ts index e57080711a..6a37e11ef0 100644 --- a/packages/server/src/server/agent/agent-timeline-store.ts +++ b/packages/server/src/server/agent/agent-timeline-store.ts @@ -1,4 +1,9 @@ import { randomUUID } from "node:crypto"; +import { + TimelineProjection, + selectProjectedTimelinePage, + type ProjectedTimelineRow, +} from "./timeline-projection.js"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; import type { AgentTimelineFetchOptions, @@ -16,125 +21,15 @@ export interface SeedAgentTimelineOptions { interface AgentTimelineState { epoch: string; - rows: AgentTimelineRow[]; + projection: TimelineProjection; + minSeq: number; nextSeq: number; } - const DEFAULT_TIMELINE_FETCH_LIMIT = 200; - -function cloneRow(row: AgentTimelineRow): AgentTimelineRow { +function cloneRow(row: T): T { return { ...row }; } -interface FetchContext { - state: AgentTimelineState; - direction: NonNullable; - limit: number; - selectAll: boolean; - cursor: AgentTimelineFetchOptions["cursor"]; - minSeq: number; - maxSeq: number; - window: { minSeq: number; maxSeq: number; nextSeq: number }; -} - -function fetchTail(ctx: FetchContext): AgentTimelineFetchResult { - const { state, direction, limit, selectAll, minSeq, window } = ctx; - const selected = - selectAll || limit >= state.rows.length - ? state.rows - : state.rows.slice(state.rows.length - limit); - return { - epoch: state.epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder: selected.length > 0 && selected[0].seq > minSeq, - hasNewer: false, - rows: selected.map(cloneRow), - }; -} - -function fetchAfter(ctx: FetchContext): AgentTimelineFetchResult { - const { state, direction, limit, selectAll, cursor, minSeq, maxSeq, window } = ctx; - const baseSeq = cursor?.seq ?? 0; - const startIdx = state.rows.findIndex((row) => row.seq > baseSeq); - if (startIdx < 0) { - return { - epoch: state.epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder: baseSeq >= minSeq, - hasNewer: false, - rows: [], - }; - } - - const selected = selectAll - ? state.rows.slice(startIdx) - : state.rows.slice(startIdx, startIdx + limit); - const lastSelected = selected[selected.length - 1]; - return { - epoch: state.epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder: selected[0].seq > minSeq, - hasNewer: lastSelected !== null && lastSelected !== undefined && lastSelected.seq < maxSeq, - rows: selected.map(cloneRow), - }; -} - -function fetchBefore(ctx: FetchContext): AgentTimelineFetchResult { - const { state, direction, limit, selectAll, cursor, minSeq, window } = ctx; - const beforeSeq = cursor?.seq ?? state.nextSeq; - const endExclusive = state.rows.findIndex((row) => row.seq >= beforeSeq); - const boundedRows = endExclusive < 0 ? state.rows : state.rows.slice(0, endExclusive); - const selected = - selectAll || limit >= boundedRows.length - ? boundedRows - : boundedRows.slice(boundedRows.length - limit); - return { - epoch: state.epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder: selected.length > 0 && selected[0].seq > minSeq, - hasNewer: endExclusive >= 0, - rows: selected.map(cloneRow), - }; -} - -function fetchReset( - ctx: FetchContext, - flags: { staleCursor: boolean; gap: boolean }, -): AgentTimelineFetchResult { - const { state, direction, limit, selectAll, minSeq, window } = ctx; - const rows = - selectAll || limit >= state.rows.length - ? state.rows.map(cloneRow) - : state.rows.slice(state.rows.length - limit).map(cloneRow); - return { - epoch: state.epoch, - direction, - reset: true, - staleCursor: flags.staleCursor, - gap: flags.gap, - window, - hasOlder: rows.length > 0 && rows[0].seq > minSeq, - hasNewer: false, - rows, - }; -} - export class InMemoryAgentTimelineStore { private readonly states = new Map(); @@ -147,10 +42,13 @@ export class InMemoryAgentTimelineStore { const rows = options?.rows?.length ? options.rows.map(cloneRow) : this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp); - const nextSeq = options?.nextSeq ?? (rows.length ? rows[rows.length - 1].seq + 1 : 1); + const nextSeq = rows.reduce((next, row) => Math.max(next, row.seq + 1), options?.nextSeq ?? 1); + const projection = new TimelineProjection(); + for (const row of rows) projection.append(row); this.states.set(agentId, { epoch: options?.epoch ?? randomUUID(), - rows, + projection, + minSeq: projection.getRows()[0]?.seqStart ?? 0, nextSeq, }); } @@ -160,19 +58,23 @@ export class InMemoryAgentTimelineStore { } getItems(agentId: string): AgentTimelineItem[] { - return this.requireState(agentId).rows.map((row) => row.item); + return this.requireState(agentId) + .projection.getRows() + .map((row) => row.item); } - getRows(agentId: string): AgentTimelineRow[] { - return this.requireState(agentId).rows.map(cloneRow); + getRows(agentId: string): ProjectedTimelineRow[] { + return this.requireState(agentId).projection.getRows().map(cloneRow); } getSubmittedUserMessage(agentId: string, clientMessageId: string): AgentTimelineRow | null { - const row = this.requireState(agentId).rows.find( - (candidate) => - candidate.item.type === "user_message" && - candidate.item.clientMessageId === clientMessageId, - ); + const row = this.requireState(agentId) + .projection.getRows() + .find( + (candidate) => + candidate.item.type === "user_message" && + candidate.item.clientMessageId === clientMessageId, + ); return row ? cloneRow(row) : null; } @@ -181,19 +83,10 @@ export class InMemoryAgentTimelineStore { clientMessageId: string, providerMessageId: string, ): AgentTimelineRow | null { - const state = this.requireState(agentId); - const index = state.rows.findIndex( - (candidate) => - candidate.item.type === "user_message" && - candidate.item.clientMessageId === clientMessageId, + return this.requireState(agentId).projection.enrichSubmittedUserMessage( + clientMessageId, + providerMessageId, ); - const row = state.rows[index]; - if (!row || row.item.type !== "user_message") { - return null; - } - const enriched: AgentTimelineRow = { ...row, providerMessageId }; - state.rows[index] = enriched; - return cloneRow(enriched); } getEpoch(agentId: string): string { @@ -203,62 +96,37 @@ export class InMemoryAgentTimelineStore { fetch(agentId: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult { const state = this.requireState(agentId); const direction = options?.direction ?? "tail"; - const requestedLimit = options?.limit; - const limit = - requestedLimit === undefined - ? DEFAULT_TIMELINE_FETCH_LIMIT - : Math.max(0, Math.floor(requestedLimit)); const cursor = options?.cursor; - const minSeq = state.rows.length ? state.rows[0].seq : 0; - const maxSeq = state.rows.length ? state.rows[state.rows.length - 1].seq : 0; - const selectAll = limit === 0; - - const window = { - minSeq, - maxSeq, - nextSeq: state.nextSeq, - }; - - const ctx: FetchContext = { - state, + const rows = state.projection.getRows(); + const window = { minSeq: state.minSeq, maxSeq: state.nextSeq - 1, nextSeq: state.nextSeq }; + const staleCursor = cursor !== undefined && cursor.epoch !== state.epoch; + const gap = + !staleCursor && + direction === "after" && + cursor !== undefined && + rows.length > 0 && + cursor.seq < state.minSeq - 1; + const reset = staleCursor || gap; + const page = selectProjectedTimelinePage({ + rows, + bounds: window, + direction: reset ? "tail" : direction, + cursorSeq: cursor?.seq, + limit: options?.limit ?? DEFAULT_TIMELINE_FETCH_LIMIT, + }); + return { + epoch: state.epoch, direction, - limit, - selectAll, - cursor, - minSeq, - maxSeq, + reset, + staleCursor, + gap, window, + hasOlder: page.hasOlder, + hasNewer: page.hasNewer, + startSeq: page.startSeq, + endSeq: page.endSeq, + rows: page.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)), }; - - if (cursor && typeof cursor.epoch === "string" && cursor.epoch !== state.epoch) { - return fetchReset(ctx, { staleCursor: true, gap: false }); - } - - if (direction === "after" && cursor && state.rows.length > 0 && cursor.seq < minSeq - 1) { - return fetchReset(ctx, { staleCursor: false, gap: true }); - } - - if (state.rows.length === 0) { - return { - epoch: state.epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder: false, - hasNewer: false, - rows: [], - }; - } - - if (direction === "tail") { - return fetchTail(ctx); - } - if (direction === "after") { - return fetchAfter(ctx); - } - return fetchBefore(ctx); } append( @@ -275,34 +143,21 @@ export class InMemoryAgentTimelineStore { ...(options?.providerMessageId ? { providerMessageId: options.providerMessageId } : {}), }; state.nextSeq += 1; - state.rows.push(row); + if (state.minSeq === 0) state.minSeq = row.seq; + state.projection.append(row); return cloneRow(row); } getLastItem(agentId: string): AgentTimelineItem | null { const state = this.requireState(agentId); - return state.rows[state.rows.length - 1]?.item ?? null; + return state.projection.getRows().find((row) => row.seqEnd === state.nextSeq - 1)?.item ?? null; } getLastAssistantMessage(agentId: string): string | null { - const rows = this.requireState(agentId).rows; - const chunks: string[] = []; - for (let i = rows.length - 1; i >= 0; i -= 1) { - const item = rows[i].item; - if (item.type !== "assistant_message") { - if (chunks.length > 0) { - break; - } - continue; - } - chunks.push(item.text); - } - - if (chunks.length === 0) { - return null; - } - - return chunks.toReversed().join(""); + const row = this.requireState(agentId) + .projection.getRows() + .findLast((candidate) => candidate.item.type === "assistant_message"); + return row?.item.type === "assistant_message" ? row.item.text : null; } private requireState(agentId: string): AgentTimelineState { diff --git a/packages/server/src/server/agent/projected-timeline-contract.test.ts b/packages/server/src/server/agent/projected-timeline-contract.test.ts new file mode 100644 index 0000000000..ee76fe50a7 --- /dev/null +++ b/packages/server/src/server/agent/projected-timeline-contract.test.ts @@ -0,0 +1,201 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import pino from "pino"; +import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; +import { createDaemonTestContext } from "../test-utils/daemon-test-context.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createMessageCollector } from "../test-utils/message-collector.js"; +import { CodexAppServerAgentSession } from "./providers/codex-app-server-agent.js"; +import { createFakeCodexAppServer } from "./providers/codex/test-utils/fake-app-server.js"; +import type { AgentClient } from "./agent-sdk-types.js"; + +test("projects Codex child history and confines old-client degradation to the child transcript", async () => { + const cwd = await mkdtemp(join(tmpdir(), "paseo-projected-contract-")); + const app = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd }, + null, + pino({ level: "silent" }), + async () => app.child, + ); + await session.connect(); + const provider: AgentClient = { + provider: "codex", + capabilities: session.capabilities, + isAvailable: async () => true, + createSession: async () => session, + resumeSession: async () => session, + fetchCatalog: async () => ({ models: [], modes: [] }), + }; + const ctx = await createDaemonTestContext({ + pluginsEnabled: false, + relayEnabled: false, + agentClients: { codex: provider }, + }); + const legacy = new DaemonClient({ + url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, + capabilities: { [CLIENT_CAPS.projectedSubagentTimeline]: false }, + }); + await legacy.connect(); + const messages = createMessageCollector(legacy); + const observation = legacy.observeEvents(["agent.provider_subagents.update"]); + const unsubscribe = observation.subscribe({ snapshot() {}, update() {} }); + await observation.ready; + try { + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd, + title: "Projected contract", + }); + app.startsTurn({ threadId: "thread-1" }); + app.startsSubAgent({ callId: "spawn-child", threadId: "child-thread", agentPath: "child" }); + for (const delta of ["A", "B", "C"]) { + app.child.stdout.write( + JSON.stringify({ + method: "item/agentMessage/delta", + params: { threadId: "child-thread", itemId: "message-1", delta }, + }) + "\n", + ); + } + await expect + .poll(() => ctx.daemon.daemon.agentManager.listProviderSubagents(agent.id).length) + .toBe(1); + await expect + .poll( + () => + ctx.daemon.daemon.agentManager.fetchProviderSubagentTimeline(agent.id, "child-thread") + .window.maxSeq, + ) + .toBe(3); + const child = await ctx.client.fetchProviderSubagentTimeline(agent.id, "child-thread", { + limit: 1, + }); + expect(child.projection).toBe("projected"); + expect(child.rows).toMatchObject([{ seqStart: 1, seqEnd: 3, item: { text: "ABC" } }]); + const catchUp = await ctx.client.fetchProviderSubagentTimeline(agent.id, "child-thread", { + direction: "after", + cursor: { epoch: child.epoch, seq: 1 }, + }); + expect(catchUp.rows).toMatchObject([{ item: { text: "ABC" } }]); + const oldChild = await legacy.fetchProviderSubagentTimeline(agent.id, "child-thread"); + expect(oldChild.rows).toMatchObject([ + { item: { type: "assistant_message", text: expect.stringContaining("upgrade") } }, + ]); + expect((await legacy.listProviderSubagents(agent.id)).subagents).toHaveLength(1); + expect( + messages.messages.some( + (message) => + message.type === "agent.provider_subagents.update" && message.payload.kind === "upsert", + ), + ).toBe(true); + expect( + messages.messages.some( + (message) => + message.type === "agent.provider_subagents.update" && message.payload.kind === "timeline", + ), + ).toBe(false); + const root = await legacy.fetchAgentTimeline(agent.id, { projection: "canonical", limit: 0 }); + expect(root.error).toBeNull(); + expect(root.projection).toBe("projected"); + expect(root.entries).toHaveLength(1); + expect((await legacy.buildAgentForkContext(agent.id)).attachment.text).toContain( + "[Child] child", + ); + app.assertNoErrors(); + } finally { + unsubscribe(); + messages.unsubscribe(); + await legacy.close(); + await ctx.cleanup(); + await rm(cwd, { recursive: true, force: true }); + } +}, 30_000); + +test("resuming Codex restores child descriptors and complete messages without historical deltas", async () => { + const app = createFakeCodexAppServer({ + "thread/read": (params) => { + const { threadId } = params as { threadId: string }; + return { + thread: { + id: threadId, + turns: [ + { + id: "turn", + items: + threadId === "root" + ? [ + { + type: "subAgentActivity", + id: "spawn", + kind: "started", + agentThreadId: "child", + agentPath: "explore", + }, + ] + : [{ type: "agentMessage", id: "message", text: "Complete child answer" }], + }, + ], + }, + }; + }, + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: tmpdir() }, + { sessionId: "root" }, + pino({ level: "silent" }), + async () => app.child, + ); + try { + await session.connect(); + const children = []; + for await (const event of session.streamHistory()) { + if (event.type === "provider_subagent") children.push(event.event); + } + expect(children).toMatchObject([ + { type: "upsert", id: "child", status: "completed" }, + { + type: "timeline", + id: "child", + item: { type: "assistant_message", text: "Complete child answer" }, + }, + ]); + app.assertNoErrors(); + } finally { + await session.close(); + } +}); + +test("retains only the latest cumulative Pi tool progress payload", async () => { + const { parseToolArgs, parseToolResult, mapToolDetail } = + await import("./providers/pi/tool-call-mapper.js"); + const { InMemoryAgentTimelineStore } = await import("./agent-timeline-store.js"); + const store = new InMemoryAgentTimelineStore(); + store.initialize("agent"); + const tool = parseToolArgs("custom_progress", {}); + const messages: string[] = []; + for (let index = 0; index < 20; index++) { + messages.push(`Update ${index}: ${"x".repeat(2048)}`); + const result = parseToolResult({ + content: [{ type: "text", text: "running" }], + details: { results: [{ messages: [...messages] }] }, + }); + store.append("agent", { + type: "tool_call", + callId: "same-call", + name: "custom_progress", + status: "running", + error: null, + detail: mapToolDetail(tool, result), + }); + } + const history = store.fetch("agent", { limit: 0 }); + expect(history.rows).toHaveLength(1); + expect(history.rows[0]).toMatchObject({ + seqStart: 1, + seqEnd: 20, + item: { detail: { output: { details: { results: [{ messages }] } } } }, + }); + expect(JSON.stringify(history.rows).length).toBeLessThan(50_000); +}); diff --git a/packages/server/src/server/agent/provider-subagents/store.test.ts b/packages/server/src/server/agent/provider-subagents/store.test.ts index ba1f3b5270..dc71c0f5c6 100644 --- a/packages/server/src/server/agent/provider-subagents/store.test.ts +++ b/packages/server/src/server/agent/provider-subagents/store.test.ts @@ -45,7 +45,7 @@ describe("ProviderSubagentStore", () => { updatedAt: "2026-07-12T10:00:02.000Z", }), ]); - expect(subagents.fetchTimeline("parent-a", "child-1").rows).toEqual([ + expect(subagents.fetchTimeline("parent-a", "child-1").rows).toMatchObject([ { seq: 1, timestamp: "2026-07-12T10:00:01.000Z", @@ -98,8 +98,11 @@ describe("ProviderSubagentStore", () => { direction: "tail", limit: 1, }); - expect(page.rows).toHaveLength(101); - expect(page.rows[0]?.seq).toBe(1); + expect(page.rows).toHaveLength(1); + expect(page.rows[0]?.seqStart).toBe(1); + expect(page.rows[0]?.item).toMatchObject({ + text: Array.from({ length: 101 }, (_, i) => String(i)).join(""), + }); expect(page.rows.at(-1)?.seq).toBe(101); expect(page.hasOlder).toBe(false); }); diff --git a/packages/server/src/server/agent/provider-subagents/store.ts b/packages/server/src/server/agent/provider-subagents/store.ts index d9a298e790..b04246054a 100644 --- a/packages/server/src/server/agent/provider-subagents/store.ts +++ b/packages/server/src/server/agent/provider-subagents/store.ts @@ -6,7 +6,6 @@ import type { AgentTimelineFetchResult, AgentTimelineRow, } from "../agent-timeline-store-types.js"; -import { selectTimelineWindowByProjectedLimit } from "../timeline-projection.js"; export type ProviderSubagentStatus = "running" | "completed" | "failed" | "canceled"; @@ -151,30 +150,7 @@ export class ProviderSubagentStore { subagentId: string, options?: AgentTimelineFetchOptions, ): AgentTimelineFetchResult { - const direction = options?.direction ?? "tail"; - const limit = options?.limit === undefined ? 200 : Math.max(0, Math.floor(options.limit)); - const timeline = this.timelines.fetch(storeKey(parentAgentId, subagentId), { - ...options, - limit: 0, - }); - if (limit === 0 || timeline.rows.length === 0) { - return timeline; - } - const selected = selectTimelineWindowByProjectedLimit({ - rows: timeline.rows, - direction: timeline.reset ? "tail" : direction, - limit, - }); - const firstRow = selected.selectedRows[0]; - const lastRow = selected.selectedRows[selected.selectedRows.length - 1]; - return { - ...timeline, - rows: selected.selectedRows, - hasOlder: - timeline.hasOlder || (firstRow !== undefined && firstRow.seq > timeline.window.minSeq), - hasNewer: - timeline.hasNewer || (lastRow !== undefined && lastRow.seq < timeline.window.maxSeq), - }; + return this.timelines.fetch(storeKey(parentAgentId, subagentId), options); } deleteParent(parentAgentId: string): ProviderSubagentStoreEvent[] { diff --git a/packages/server/src/server/agent/rewind/rewind.test.ts b/packages/server/src/server/agent/rewind/rewind.test.ts index 7040c42e3e..df5c242251 100644 --- a/packages/server/src/server/agent/rewind/rewind.test.ts +++ b/packages/server/src/server/agent/rewind/rewind.test.ts @@ -82,10 +82,11 @@ describe("AgentManager rewind", () => { test("replaces the canonical epoch without replaying reconstructed parent rows", async () => { const { manager, session, agentId } = await createRewindHarness(); - session.history = Array.from({ length: 250 }, (_, index) => ({ + const history = Array.from({ length: 250 }, (_, index) => ({ type: "assistant_message" as const, text: `rewound ${index}`, })); + session.history = history; const epochBefore = manager.fetchTimeline(agentId, { limit: 0 }).epoch; const events: string[] = []; const unsubscribe = manager.subscribe((event) => events.push(event.type), { @@ -97,7 +98,18 @@ describe("AgentManager rewind", () => { const replacement = manager.fetchTimeline(agentId, { limit: 0 }); expect(replacement.epoch).not.toBe(epochBefore); - expect(replacement.rows).toHaveLength(250); + expect(replacement.rows).toEqual([ + expect.objectContaining({ + seq: 250, + seqStart: 1, + seqEnd: 250, + sourceSeqRanges: [{ startSeq: 1, endSeq: 250 }], + item: { + type: "assistant_message", + text: history.map((item) => item.text).join(""), + }, + }), + ]); expect(events.filter((type) => type === "agent_stream")).toEqual([]); }); diff --git a/packages/server/src/server/agent/timeline-projection.ts b/packages/server/src/server/agent/timeline-projection.ts index cbcde7944e..d8128a6a2b 100644 --- a/packages/server/src/server/agent/timeline-projection.ts +++ b/packages/server/src/server/agent/timeline-projection.ts @@ -1,7 +1,8 @@ import type { AgentTimelineItem, ToolCallDetail } from "./agent-sdk-types.js"; -import type { AgentTimelineRow } from "./agent-manager.js"; import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity"; +import type { AgentTimelineRow } from "./agent-timeline-store-types.js"; + export type TimelineProjectionMode = "canonical" | "projected"; export interface TimelineSeqRange { @@ -19,6 +20,7 @@ export type TimelineLimitDirection = "tail" | "before" | "after"; export interface TimelineProjectionEntry { item: AgentTimelineItem; turnId?: string; + providerMessageId?: string; timestamp: string; seqStart: number; seqEnd: number; @@ -54,7 +56,7 @@ function appendSeqToRanges(ranges: TimelineSeqRange[], seq: number): TimelineSeq } if (seq <= last.endSeq + 1) { - last.endSeq = Math.max(last.endSeq, seq); + next[next.length - 1] = { ...last, endSeq: Math.max(last.endSeq, seq) }; return next; } @@ -114,15 +116,19 @@ function mergeToolCallItems( } function makeCanonicalEntries(rows: readonly AgentTimelineRow[]): WorkingEntry[] { - return rows.map((row) => ({ - item: row.item, - ...(row.turnId ? { turnId: row.turnId } : {}), - timestamp: row.timestamp, - seqStart: row.seq, - seqEnd: row.seq, - sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }], - collapsed: [], - })); + return rows.map((row) => { + if ("seqStart" in row) return { ...(row as ProjectedTimelineRow) }; + return { + item: row.item, + timestamp: row.timestamp, + ...(row.turnId ? { turnId: row.turnId } : {}), + ...(row.providerMessageId ? { providerMessageId: row.providerMessageId } : {}), + seqStart: row.seq, + seqEnd: row.seq, + sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }], + collapsed: [], + }; + }); } function mergeIdentityMetadata( @@ -285,6 +291,54 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[] return output; } +/** A retained item. `seq` is its latest source event, not its display anchor. */ +export type ProjectedTimelineRow = AgentTimelineRow & TimelineProjectionEntry; + +/** Owns projected state; source events are consumed, never retained. */ +export class TimelineProjection { + private readonly rows: ProjectedTimelineRow[] = []; + private readonly identities = new Map(); + + append(row: AgentTimelineRow): void { + const entry = makeCanonicalEntries([row])[0]; + const identity = timelineItemIdentity(row.item); + const index = identity === null ? undefined : this.identities.get(identity); + const existing = index === undefined ? undefined : this.rows[index]; + const merged = existing ? mergeIdentityEntries(existing, entry) : null; + if (merged && index !== undefined) { + this.rows[index] = { ...merged, seq: merged.seqEnd }; + return; + } + const previous = this.rows.at(-1); + const adjacent = previous + ? mergeReasoningChunks(mergeAssistantChunks([previous, entry])) + : [entry]; + if (previous && adjacent.length === 1) { + const mergedText = adjacent[0]; + this.rows[this.rows.length - 1] = { ...mergedText, seq: mergedText.seqEnd }; + return; + } + if (identity !== null) this.identities.set(identity, this.rows.length); + this.rows.push({ ...entry, seq: entry.seqEnd }); + } + + getRows(): ProjectedTimelineRow[] { + return this.rows.map((row) => ({ ...row })); + } + + enrichSubmittedUserMessage( + clientMessageId: string, + providerMessageId: string, + ): ProjectedTimelineRow | null { + const index = this.rows.findIndex( + (row) => row.item.type === "user_message" && row.item.clientMessageId === clientMessageId, + ); + if (index < 0) return null; + this.rows[index] = { ...this.rows[index], providerMessageId }; + return { ...this.rows[index] }; + } +} + export function projectTimelineRows(input: { rows: readonly AgentTimelineRow[]; mode: TimelineProjectionMode; @@ -397,7 +451,11 @@ function getTimelineBounds( if (!first || !last) { return null; } - return { minSeq: first.seq, maxSeq: last.seq }; + const entries = makeCanonicalEntries(rows); + return { + minSeq: entries.reduce((min, e) => Math.min(min, e.seqStart), Infinity), + maxSeq: entries.reduce((max, e) => Math.max(max, e.seqEnd), 0), + }; } function firstSourceSeqInRange( @@ -420,7 +478,6 @@ interface ProjectedEntryCandidate { function selectProjectedEntriesAfter(input: { entries: readonly TimelineProjectionEntry[]; - rows: readonly AgentTimelineRow[]; startSeq: number; maxSeq: number; limit: number; @@ -445,16 +502,10 @@ function selectProjectedEntriesAfter(input: { // Wide projected entries can include future, discontiguous source ranges. The // page cursor advances only through source rows covered without a gap. let endSeq = input.startSeq - 1; - let rangeIndex = 0; - for (const row of input.rows) { - if (row.seq < input.startSeq) continue; - if (row.seq > input.maxSeq || row.seq !== endSeq + 1) break; - while (selectedRanges[rangeIndex] && selectedRanges[rangeIndex].endSeq < row.seq) { - rangeIndex += 1; - } - const range = selectedRanges[rangeIndex]; - if (!range || row.seq < range.startSeq || row.seq > range.endSeq) break; - endSeq = row.seq; + for (const range of selectedRanges) { + if (range.endSeq <= endSeq) continue; + if (range.startSeq > endSeq + 1) break; + endSeq = Math.min(range.endSeq, input.maxSeq); } return { @@ -484,6 +535,27 @@ function selectProjectedEntriesBefore(input: { }; } +function selectProjectedEntriesTail( + projectedAll: TimelineProjectionEntry[], + limit: number, + bounds: { minSeq: number; maxSeq: number }, +): ProjectedTimelinePageSelection { + let start = limit === 0 ? 0 : Math.max(0, projectedAll.length - limit); + // Include earlier display anchors with updates in the selected source window, + // so a tail certifies contiguous coverage even with interleaved tool updates. + for (let i = start - 1; i >= 0; i--) { + if (projectedAll[i].seqEnd >= projectedAll[start].seqStart) start = i; + } + const selected = projectedAll.slice(start); + return { + entries: selected, + startSeq: selected[0]?.seqStart ?? null, + endSeq: bounds.maxSeq, + hasOlder: start > 0, + hasNewer: false, + }; +} + export function selectProjectedTimelinePage(input: { rows: readonly AgentTimelineRow[]; bounds?: { minSeq: number; maxSeq: number }; @@ -535,18 +607,7 @@ export function selectProjectedTimelinePage(input: { } if (input.direction === "tail") { - const selected = selectTimelineWindowByProjectedLimit({ - rows: input.rows, - direction: "tail", - limit, - }); - return { - entries: selected.projectedEntries, - startSeq: selected.minSeq, - endSeq: selected.maxSeq, - hasOlder: selected.minSeq !== null && selected.minSeq > bounds.minSeq, - hasNewer: false, - }; + return selectProjectedEntriesTail(projectedAll, limit, bounds); } if (input.direction === "after") { @@ -554,7 +615,6 @@ export function selectProjectedTimelinePage(input: { const startSeq = Math.max(bounds.minSeq, cursorSeq + 1); const selected = selectProjectedEntriesAfter({ entries: projectedAll, - rows: input.rows, startSeq, maxSeq: bounds.maxSeq, limit, diff --git a/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts b/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts index 510fb9db5e..ae01a8539d 100644 --- a/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, test } from "vitest"; +import { afterEach, beforeEach, expect, onTestFinished, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -11,6 +11,23 @@ import { import { createMessageCollector } from "../test-utils/message-collector.js"; import type { SessionOutboundMessage } from "../messages.js"; +async function observeTimeline( + client: DaemonClient, + agentId: string, + messages: SessionOutboundMessage[], +): Promise { + const subscription = client.observeTimeline([agentId]); + onTestFinished( + subscription.subscribe({ + snapshot() {}, + update(message) { + messages.push(message); + }, + }), + ); + await subscription.ready; +} + function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); } @@ -32,7 +49,6 @@ async function waitFor( function isLiveAssistantTimeline( message: SessionOutboundMessage, agentId: string, - epoch?: string, text?: string, ): boolean { return ( @@ -41,8 +57,7 @@ function isLiveAssistantTimeline( message.payload.event.type === "timeline" && message.payload.event.item.type === "assistant_message" && message.payload.seq === undefined && - typeof message.payload.epoch === "string" && - (epoch === undefined || message.payload.epoch === epoch) && + message.payload.epoch === undefined && (text === undefined || message.payload.event.item.text === text) ); } @@ -69,6 +84,8 @@ test("reconnect catches up committed rows without replaying a provisional seed", modeId: "full-access", }); + await observeTimeline(ctx.client, agent.id, primaryCollector.messages); + for (let seq = 1; seq <= 120; seq += 1) { await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { type: "assistant_message", @@ -91,7 +108,7 @@ test("reconnect catches up committed rows without replaying a provisional seed", }); await waitFor(() => primaryCollector.messages.some((message) => - isLiveAssistantTimeline(message, agent.id, epoch, "partial before disconnect"), + isLiveAssistantTimeline(message, agent.id, "partial before disconnect"), ), ); @@ -107,6 +124,7 @@ test("reconnect catches up committed rows without replaying a provisional seed", }); await reconnectClient.connect(); const reconnectCollector = createMessageCollector(reconnectClient); + await observeTimeline(reconnectClient, agent.id, reconnectCollector.messages); try { await reconnectClient.fetchAgents({ @@ -114,9 +132,7 @@ test("reconnect catches up committed rows without replaying a provisional seed", }); expect( - reconnectCollector.messages.some((message) => - isLiveAssistantTimeline(message, agent.id, epoch), - ), + reconnectCollector.messages.some((message) => isLiveAssistantTimeline(message, agent.id)), ).toBe(false); const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, { @@ -133,11 +149,14 @@ test("reconnect catches up committed rows without replaying a provisional seed", expect(catchUp.entries).toHaveLength(1); expect(catchUp.startCursor).toEqual({ epoch, seq: 121 }); expect(catchUp.endCursor).toEqual({ epoch, seq: 121 }); - expect(catchUp.entries[0]?.seqStart).toBe(121); + expect(catchUp.projection).toBe("projected"); + expect(catchUp.entries[0]?.seqStart).toBe(1); expect(catchUp.entries[0]?.seqEnd).toBe(121); expect(catchUp.entries[0]?.item).toEqual({ type: "assistant_message", - text: "finalized while disconnected", + text: + Array.from({ length: 120 }, (_, index) => `committed row ${index + 1}`).join("") + + "finalized while disconnected", }); } finally { reconnectCollector.unsubscribe(); @@ -161,6 +180,8 @@ test("reconnect with no new committed rows resumes from future live provisional modeId: "full-access", }); + await observeTimeline(ctx.client, agent.id, primaryCollector.messages); + for (let seq = 1; seq <= 120; seq += 1) { await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { type: "assistant_message", @@ -183,7 +204,7 @@ test("reconnect with no new committed rows resumes from future live provisional }); await waitFor(() => primaryCollector.messages.some((message) => - isLiveAssistantTimeline(message, agent.id, epoch, "partial before disconnect"), + isLiveAssistantTimeline(message, agent.id, "partial before disconnect"), ), ); @@ -194,6 +215,7 @@ test("reconnect with no new committed rows resumes from future live provisional }); await reconnectClient.connect(); const reconnectCollector = createMessageCollector(reconnectClient); + await observeTimeline(reconnectClient, agent.id, reconnectCollector.messages); try { await reconnectClient.fetchAgents({ @@ -201,9 +223,7 @@ test("reconnect with no new committed rows resumes from future live provisional }); expect( - reconnectCollector.messages.some((message) => - isLiveAssistantTimeline(message, agent.id, epoch), - ), + reconnectCollector.messages.some((message) => isLiveAssistantTimeline(message, agent.id)), ).toBe(false); const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, { @@ -228,7 +248,7 @@ test("reconnect with no new committed rows resumes from future live provisional }); await waitFor(() => reconnectCollector.messages.some((message) => - isLiveAssistantTimeline(message, agent.id, epoch, "fresh live after reconnect"), + isLiveAssistantTimeline(message, agent.id, "fresh live after reconnect"), ), ); } finally { diff --git a/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts b/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts index 1e809314be..78134d7865 100644 --- a/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts @@ -178,7 +178,7 @@ describe("daemon E2E - timeline window", () => { } }); - test("canonical after fetch returns only committed rows after the cursor", async () => { + test("legacy canonical requests return full projected items after the cursor", async () => { const cwd = tmpCwd(); try { const agent = await ctx.client.createAgent({ @@ -201,13 +201,16 @@ describe("daemon E2E - timeline window", () => { projection: "canonical", }); - expect(timeline.projection).toBe("canonical"); + expect(timeline.projection).toBe("projected"); expect(timeline.entries).toHaveLength(1); expect(timeline.startCursor?.seq).toBe(250); expect(timeline.endCursor?.seq).toBe(250); - expect(timeline.entries[0]?.seqStart).toBe(250); + expect(timeline.entries[0]?.seqStart).toBe(1); expect(timeline.entries[0]?.seqEnd).toBe(250); - expect(timeline.entries[0]?.sourceSeqRanges).toEqual([{ startSeq: 250, endSeq: 250 }]); + expect(timeline.entries[0]?.sourceSeqRanges).toEqual([ + { startSeq: 1, endSeq: 1 }, + { startSeq: 250, endSeq: 250 }, + ]); expect(timeline.entries[0]?.item.type).toBe("tool_call"); } finally { rmSync(cwd, { recursive: true, force: true }); @@ -301,7 +304,6 @@ describe("daemon E2E - timeline window", () => { }); } - const fetchSpy = vi.spyOn(ctx.daemon.daemon.agentManager, "fetchTimeline"); const timeline = await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 100, @@ -311,11 +313,6 @@ describe("daemon E2E - timeline window", () => { expect(timeline.startCursor?.seq).toBe(501); expect(timeline.endCursor?.seq).toBe(600); expect(timeline.hasOlder).toBe(true); - expect( - fetchSpy.mock.calls.some( - ([, options]) => options?.direction === "tail" && options.limit === 0, - ), - ).toBe(false); } finally { rmSync(cwd, { recursive: true, force: true }); } @@ -341,7 +338,6 @@ describe("daemon E2E - timeline window", () => { limit: 1, }).epoch; - const fetchSpy = vi.spyOn(ctx.daemon.daemon.agentManager, "fetchTimeline"); const timeline = await ctx.client.fetchAgentTimeline(agent.id, { direction: "after", cursor: { epoch, seq: 300 }, @@ -352,11 +348,6 @@ describe("daemon E2E - timeline window", () => { expect(timeline.startCursor?.seq).toBe(301); expect(timeline.endCursor?.seq).toBe(400); expect(timeline.hasNewer).toBe(true); - expect( - fetchSpy.mock.calls.some( - ([, options]) => options?.direction === "tail" && options.limit === 0, - ), - ).toBe(false); } finally { rmSync(cwd, { recursive: true, force: true }); } @@ -490,7 +481,6 @@ describe("daemon E2E - timeline window", () => { limit: 1, }).epoch; - const fetchSpy = vi.spyOn(ctx.daemon.daemon.agentManager, "fetchTimeline"); const timeline = await ctx.client.fetchAgentTimeline(agent.id, { direction: "before", cursor: { epoch, seq: 501 }, @@ -501,11 +491,6 @@ describe("daemon E2E - timeline window", () => { expect(timeline.startCursor?.seq).toBe(401); expect(timeline.endCursor?.seq).toBe(500); expect(timeline.hasOlder).toBe(true); - expect( - fetchSpy.mock.calls.some( - ([, options]) => options?.direction === "tail" && options.limit === 0, - ), - ).toBe(false); } finally { rmSync(cwd, { recursive: true, force: true }); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 20f4143edf..21aca8bf90 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -119,12 +119,6 @@ import { } from "./agent/timeline-append.js"; import { assertPluginTimelineDataSize } from "./agent/agent-timeline-content.js"; import { parsePluginClientId } from "./plugins/plugin-session-identity.js"; -import { - projectTimelineRows, - selectProjectedTimelinePage, - type TimelineProjectionEntry, - type TimelineProjectionMode, -} from "./agent/timeline-projection.js"; import { buildAgentForkContextAttachment } from "./agent/activity-curator.js"; import { buildAgentPrompt } from "./agent/prompt-attachments.js"; import type { StructuredGenerationDaemonConfig } from "./agent/structured-generation-providers.js"; @@ -594,15 +588,6 @@ function sessionRequestId(message: SessionInboundMessage): string | null { return null; } -interface AgentTimelineProjectionSelection { - timeline: AgentTimelineFetchResult; - entries: TimelineProjectionEntry[]; - startSeq: number | null; - endSeq: number | null; - hasOlder: boolean; - hasNewer: boolean; -} - type RegistryTransition = "created" | "unarchived" | "existing"; interface ArchivedRecordSnapshot { @@ -1798,6 +1783,17 @@ export class Session { } } + private supportsSubagentTimelineItem( + item: AgentTimelineFetchResult["rows"][number]["item"], + source?: object, + ): boolean { + // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gate after 2027-03-14. + const supportsProjection = source + ? this.supportsForSource(CLIENT_CAPS.projectedSubagentTimeline, source) + : this.supports(CLIENT_CAPS.projectedSubagentTimeline); + return supportsProjection && this.supportsTimelineItem(item, source); + } + private forwardProviderSubagentUpdate( update: Extract["event"], ): void { @@ -1837,7 +1833,7 @@ export class Session { if (!subscription.events.has("agent.provider_subagents.update")) continue; if ( update.type === "timeline" && - !this.supportsTimelineItem(update.row.item, subscription.owner.source) + !this.supportsSubagentTimelineItem(update.row.item, subscription.owner.source) ) continue; subscription.owner.emit(message); @@ -1846,7 +1842,7 @@ export class Session { if (this.clientSources.size === 0 || !this.onMessageToSource) { if ( this.supports(CLIENT_CAPS.providerSubagents) && - (update.type !== "timeline" || this.supportsTimelineItem(update.row.item)) + (update.type !== "timeline" || this.supportsSubagentTimelineItem(update.row.item)) ) { this.emit(message); } @@ -1859,7 +1855,7 @@ export class Session { !capabilities.has(CLIENT_CAPS.providerSubagents) ) continue; - if (update.type === "timeline" && !this.supportsTimelineItem(update.row.item, source)) + if (update.type === "timeline" && !this.supportsSubagentTimelineItem(update.row.item, source)) continue; this.onMessageToSource(source, message); } @@ -7390,105 +7386,12 @@ export class Session { }); } - private shouldUseFullTimelineForProjectedPage(input: { - timeline: AgentTimelineFetchResult; - pageLimit: number; - }): boolean { - const { timeline } = input; - if (timeline.rows.length === 0) return false; - - if (timeline.rows.some((row) => row.item.type === "tool_call")) return true; - - const firstRow = timeline.rows[0]; - if ( - timeline.hasOlder && - (firstRow?.item.type === "assistant_message" || firstRow?.item.type === "reasoning") - ) { - return true; - } - - const lastRow = timeline.rows.at(-1); - if ( - timeline.hasNewer && - (lastRow?.item.type === "assistant_message" || lastRow?.item.type === "reasoning") - ) { - return true; - } - - if (!timeline.hasNewer || input.pageLimit === 0) return false; - return projectTimelineRows({ rows: timeline.rows, mode: "projected" }).length < input.pageLimit; - } - - private selectCanonicalTimelineProjection(input: { - timeline: AgentTimelineFetchResult; - }): AgentTimelineProjectionSelection { - const entries = projectTimelineRows({ rows: input.timeline.rows, mode: "canonical" }); - return { - timeline: input.timeline, - entries, - startSeq: entries[0]?.seqStart ?? null, - endSeq: entries[entries.length - 1]?.seqEnd ?? null, - hasOlder: input.timeline.hasOlder, - hasNewer: input.timeline.hasNewer, - }; - } - - private selectProjectedTimelineProjection(input: { - agentId: string; - controlTimeline: AgentTimelineFetchResult; - direction: AgentTimelineFetchDirection; - cursor?: AgentTimelineCursor; - pageLimit: number; - fullTimeline?: AgentTimelineFetchResult; - }): AgentTimelineProjectionSelection { - const selectedTimeline = this.shouldUseFullTimelineForProjectedPage({ - timeline: input.controlTimeline, - pageLimit: input.pageLimit, - }) - ? (input.fullTimeline ?? - this.agentManager.fetchTimeline(input.agentId, { direction: "tail", limit: 0 })) - : input.controlTimeline; - const page = selectProjectedTimelinePage({ - rows: selectedTimeline.rows, - bounds: selectedTimeline.window, - direction: input.controlTimeline.reset ? "tail" : input.direction, - ...(input.cursor ? { cursorSeq: input.cursor.seq } : {}), - limit: input.pageLimit, - }); - - return { - timeline: selectedTimeline, - entries: page.entries, - startSeq: page.startSeq, - endSeq: page.endSeq, - hasOlder: - page.hasOlder || (page.startSeq !== null && page.startSeq > selectedTimeline.window.minSeq), - hasNewer: page.hasNewer, - }; - } - - private selectTimelineProjection(input: { - agentId: string; - projection: TimelineProjectionMode; - controlTimeline: AgentTimelineFetchResult; - direction: AgentTimelineFetchDirection; - cursor?: AgentTimelineCursor; - pageLimit: number; - fullTimeline?: AgentTimelineFetchResult; - }): AgentTimelineProjectionSelection { - if (input.projection === "canonical") { - return this.selectCanonicalTimelineProjection({ timeline: input.controlTimeline }); - } - - return this.selectProjectedTimelineProjection(input); - } - private async handleFetchAgentTimelineRequest( msg: Extract, source?: object, ): Promise { const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail"); - const projection: TimelineProjectionMode = msg.projection ?? "projected"; + const projection = "projected" as const; const requestedLimit = msg.limit; const pageLimit = requestedLimit ?? (direction === "after" ? 0 : 200); const cursor: AgentTimelineCursor | undefined = msg.cursor @@ -7511,14 +7414,14 @@ export class Session { cursor, limit: pageLimit, }); - const selectedTimeline = this.selectTimelineProjection({ - agentId: msg.agentId, - projection, - controlTimeline: fetchedControlTimeline, - direction, - ...(cursor ? { cursor } : {}), - pageLimit, - }); + const selectedTimeline = { + timeline: fetchedControlTimeline, + entries: fetchedControlTimeline.rows, + startSeq: fetchedControlTimeline.startSeq, + endSeq: fetchedControlTimeline.endSeq, + hasOlder: fetchedControlTimeline.hasOlder, + hasNewer: fetchedControlTimeline.hasNewer, + }; const startCursor = selectedTimeline.startSeq !== null ? { epoch: selectedTimeline.timeline.epoch, seq: selectedTimeline.startSeq } @@ -7719,6 +7622,10 @@ export class Session { if (!descriptor) { throw new Error("Provider subagent not found"); } + // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove after 2027-03-14. + const supportsProjection = source + ? this.supportsForSource(CLIENT_CAPS.projectedSubagentTimeline, source) + : this.supports(CLIENT_CAPS.projectedSubagentTimeline); const timeline = this.agentManager.fetchProviderSubagentTimeline( msg.parentAgentId, msg.subagentId, @@ -7739,17 +7646,36 @@ export class Session { provider: descriptor.provider, direction, epoch: timeline.epoch, + projection: "projected", + startCursor: + timeline.startSeq === null ? null : { epoch: timeline.epoch, seq: timeline.startSeq }, + endCursor: + timeline.endSeq === null ? null : { epoch: timeline.epoch, seq: timeline.endSeq }, 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, - })), + hasOlder: supportsProjection && timeline.hasOlder, + hasNewer: supportsProjection && timeline.hasNewer, + rows: supportsProjection + ? rows.map((row) => ({ + item: row.item, + timestamp: row.timestamp, + seq: row.seqEnd, + seqStart: row.seqStart, + seqEnd: row.seqEnd, + sourceSeqRanges: row.sourceSeqRanges, + })) + : [ + { + seq: timeline.window.maxSeq, + timestamp: new Date().toISOString(), + item: { + type: "assistant_message", + text: "Please upgrade the Paseo app to view this subagent conversation.", + }, + }, + ], error: null, }, }, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 8b0ab3879f..d14094a674 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1741,6 +1741,8 @@ export class VoiceAssistantWebSocketServer { agentForkContextCursor: true, // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: true, + // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain advertisement. + projectedSubagentTimeline: 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. From d63132a37dd49c38af400dbbcd8f8b7ebd519339 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 14 Sep 2026 16:55:42 +0200 Subject: [PATCH 09/17] fix(creation): prevent duplicate workspaces and agents on retries (#4442) * fix(creation): prevent duplicate workspaces and agents on retries * test(creation): cover plugin hooks across receipt replays * fix(creation): recover draft retries without accepting partial workspaces * refactor(creation): name receipt inputs and tag compatibility gate * fix(file-observer): track files announced by native change events * fix(creation): keep initial prompts in daemon-owned creation requests * fix(creation): reuse connection-owned progress and recovery * test(creation): adapt handoff gates to combined requests * ci(desktop): avoid xz compression for smoke packages * ci(desktop): pass valid RPM compression through FPM config * refactor(creation): give legacy and modern requests one identity owner * fix(creation): log observer failures without interrupting creation --- .github/workflows/ci.yml | 5 +- docs/architecture.md | 30 ++ docs/hub.md | 4 + ...agent-profile-modeless-preferences.spec.ts | 53 +- .../e2e/browser/creation-idempotency.spec.ts | 130 +++++ ...w-workspace-codex-mode-preferences.spec.ts | 80 +-- packages/app/e2e/support/creation-fixtures.ts | 92 ++++ .../support/helpers/agent-timeline-gate.ts | 27 +- packages/app/e2e/support/helpers/creation.ts | 277 +++++++++++ .../support/helpers/daemon-client-loader.ts | 7 + .../support/helpers/daemon-websocket-gate.ts | 1 + .../helpers/new-workspace-navigation.ts | 9 +- .../app/e2e/support/helpers/new-workspace.ts | 125 ++--- packages/app/e2e/support/helpers/workspace.ts | 7 +- .../src/components/workspace-setup-dialog.tsx | 79 +-- .../app/src/composer/draft/create-flow.ts | 37 +- .../app/src/composer/draft/workspace-tab.tsx | 36 +- packages/app/src/contexts/session-context.tsx | 48 +- .../app/src/screens/new-workspace-screen.tsx | 321 ++++++------ .../app/src/stores/create-flow-store.test.ts | 27 + packages/app/src/stores/create-flow-store.ts | 22 +- .../workspace-draft-submission-store.test.ts | 71 +++ .../workspace-draft-submission-store.ts | 23 +- .../src/stores/workspace-setup-store.test.ts | 2 + .../app/src/stores/workspace-setup-store.ts | 14 +- packages/client/src/creation/index.test.ts | 234 +++++++++ packages/client/src/creation/index.ts | 276 +++++++++++ packages/client/src/daemon-client.test.ts | 166 +++++++ packages/client/src/daemon-client.ts | 121 ++++- packages/client/src/index.ts | 65 ++- packages/protocol/src/messages.ts | 99 +++- .../src/server/agent/create-agent/create.ts | 3 + .../src/server/agent/requests/index.test.ts | 143 ------ .../server/src/server/agent/requests/index.ts | 138 ------ .../authorization/operation-permissions.ts | 6 + .../src/server/creation/creation.e2e.test.ts | 286 +++++++++++ .../server/src/server/creation/index.test.ts | 324 ++++++++++++ packages/server/src/server/creation/index.ts | 351 +++++++++++++ .../internal/native-recursive.test.ts | 127 ++--- .../src/server/message-receipts/index.test.ts | 77 +++ .../src/server/message-receipts/index.ts | 81 +++ .../src/server/paseo-worktree-service.ts | 2 + .../src/server/plugins/lifecycle.e2e.test.ts | 11 +- packages/server/src/server/session.test.ts | 8 +- packages/server/src/server/session.ts | 461 ++++++++++++++---- .../session.workspace-git-watch.test.ts | 8 +- ...on.workspace-resolution-invariants.test.ts | 8 +- .../src/server/session.workspaces.test.ts | 32 +- .../workspace-provisioning-service.ts | 9 +- .../snapshot-mutation-ownership.test.ts | 8 +- .../server/test-utils/fake-agent-client.ts | 2 + .../src/server/test-utils/session-stubs.ts | 28 +- .../server/src/server/websocket-server.ts | 40 +- .../server/src/server/wire-compat.test.ts | 8 +- .../workspace-create-errors.e2e.test.ts | 29 +- 55 files changed, 3702 insertions(+), 976 deletions(-) create mode 100644 packages/app/e2e/browser/creation-idempotency.spec.ts create mode 100644 packages/app/e2e/support/creation-fixtures.ts create mode 100644 packages/app/e2e/support/helpers/creation.ts create mode 100644 packages/app/src/stores/workspace-draft-submission-store.test.ts create mode 100644 packages/client/src/creation/index.test.ts create mode 100644 packages/client/src/creation/index.ts delete mode 100644 packages/server/src/server/agent/requests/index.test.ts delete mode 100644 packages/server/src/server/agent/requests/index.ts create mode 100644 packages/server/src/server/creation/creation.e2e.test.ts create mode 100644 packages/server/src/server/creation/index.test.ts create mode 100644 packages/server/src/server/creation/index.ts create mode 100644 packages/server/src/server/message-receipts/index.test.ts create mode 100644 packages/server/src/server/message-receipts/index.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b735b2b06c..47ee95f36e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -251,7 +251,10 @@ jobs: - name: Build Linux desktop artifacts if: ${{ runner.os == 'Linux' }} - run: npm run build:desktop -- --publish never --linux --x64 + # Smoke package contents and installation without spending minutes on xz compression. + run: | + printf '{"extends":"%s/packages/desktop/electron-builder.yml","deb":{"compression":"gz"},"rpm":{"fpm":["--rpm-compression=gzip"]}}' "$GITHUB_WORKSPACE" > "$RUNNER_TEMP/desktop-smoke-builder.json" + npm run build:desktop -- --publish never --linux --x64 --config "$RUNNER_TEMP/desktop-smoke-builder.json" env: EP_GH_IGNORE_TIME: true PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke diff --git a/docs/architecture.md b/docs/architecture.md index 96d8cdcc56..6d21103cb6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -319,6 +319,36 @@ Example: adding a new enum value // 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value" ``` +### Creation ownership + +Creation is owned by the daemon across socket lifetimes. A workspace request can include +its initial agent and prompt. Workspace readiness is published before provider startup; +the app keeps its existing disk-ready navigation and observes the remaining creation. +Callbacks do not advance the workflow. Resource reservations in acknowledgement are +identities, not ready workspace records. + +The creation journal (`server/creation/`) owns identity and execution for both legacy and +modern creation RPCs. Requesting progress never selects a different journal. Existing +agent receipts are imported at this boundary; message delivery receipts remain separate. +It keeps cumulative milestones and permanent IDs under an operation kind and idempotency +key. Reconnect subscribes to that key; retries +join active work or reuse committed stages. A persisted resource alone cannot prove +that a provider accepted its initial prompt. Interrupted side effects with no conclusive +receipt return an unknown outcome instead of being repeated. + +`packages/client/src/creation/` owns capability selection and legacy orchestration. +Callers always pass the initial prompt to agent creation. On an older host, the client +adapts keyed creation to the legacy create/send sequence; it cannot continue that +sequence after the client process disappears. Explicitly requested IDs or receipts +that an old host cannot honor produce an unsupported error. Keep these adapters inside +the client package, as an exception to the default no-fallback feature policy. + +Creation executes through the existing Session capabilities. Connection-owned delivery +controls observation only: detaching a socket or cleaning up its Session does not cancel +accepted creation. Updates require an explicit subscription and go only to that socket; +reconnect uses the shared subscription owner. Legacy consumers, including Hub, keep their +existing response contract. + ## Agent lifecycle The lifecycle states are defined in `shared/agent-lifecycle.ts`: diff --git a/docs/hub.md b/docs/hub.md index bf44067b67..8c6493504a 100644 --- a/docs/hub.md +++ b/docs/hub.md @@ -49,6 +49,10 @@ Provider controls remain provider-native; see [providers.md](providers.md). `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. +`workspace.create.request.idempotencyKey` provides the same creation guarantee for directory and +worktree workspaces; check `server_info.features.workspaceRequestReceipts` before using it. +Workspace creation keys and agent creation keys have separate namespaces. The app keeps these keys +for the lifetime of a draft and reuses the first message ID after failures. 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 diff --git a/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts b/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts index 2558d3b427..a10afd2cca 100644 --- a/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts +++ b/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts @@ -9,7 +9,7 @@ import { seedModelProvider, } from "../support/helpers/agent-profiles"; import { gotoAppShell } from "../support/helpers/app"; -import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { captureWorkspaceAgentRequest } from "../support/helpers/creation"; import { openGlobalNewWorkspaceComposer, selectNewWorkspaceProject, @@ -22,53 +22,6 @@ const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; const MODELESS_PROVIDER = "modeless-profile-e2e"; const MODELESS_MODEL = "pi-profile-model"; -type WebSocketMessage = string | Buffer; - -interface CreateAgentRequestMessage { - type: "create_agent_request"; - config?: { - provider?: unknown; - modeId?: unknown; - featureValues?: unknown; - }; -} - -function getSessionMessage(message: WebSocketMessage): Record | null { - const rawMessage = typeof message === "string" ? message : message.toString("utf8"); - try { - const envelope = JSON.parse(rawMessage) as { type?: unknown; message?: unknown }; - return envelope.type === "session" && envelope.message && typeof envelope.message === "object" - ? (envelope.message as Record) - : null; - } catch { - return null; - } -} - -async function recordAndBlockCreateAgentRequest(page: Page): Promise<{ - waitForRequest(): Promise; -}> { - let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null; - const request = new Promise((resolve) => { - resolveRequest = resolve; - }); - - await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { - const server = ws.connectToServer(); - ws.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - if (sessionMessage?.type === "create_agent_request") { - resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage); - return; - } - server.send(message); - }); - server.onMessage((message) => ws.send(message)); - }); - - return { waitForRequest: () => request }; -} - async function seedPoisonedModelessPreference(page: Page): Promise { await page.addInitScript( ({ preferencesKey, provider, model }) => { @@ -143,7 +96,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => { ], }); await seedPoisonedModelessPreference(page); - const createAgentRecorder = await recordAndBlockCreateAgentRequest(page); + const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true }); try { await gotoAppShell(page); @@ -206,7 +159,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => { }, ], }); - const createAgentRecorder = await recordAndBlockCreateAgentRequest(page); + const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true }); try { await gotoAppShell(page); diff --git a/packages/app/e2e/browser/creation-idempotency.spec.ts b/packages/app/e2e/browser/creation-idempotency.spec.ts new file mode 100644 index 0000000000..39e73dbded --- /dev/null +++ b/packages/app/e2e/browser/creation-idempotency.spec.ts @@ -0,0 +1,130 @@ +import type { createCreationScenario } from "../support/helpers/creation"; +import { expect } from "../support/fixtures"; +import { test } from "../support/creation-fixtures"; + +for (const isolation of ["local", "worktree"] as const) { + test(`repeated Create clicks before a render create only one ${isolation} workspace`, async ({ + creation, + }) => { + await creation.openWorkspaceForm(isolation); + await creation.submitRepeatedly("Create"); + await creation.expectOneCreatedWorkspace(); + }); +} + +test("repeated Send clicks before a render create only one agent", async ({ creation }) => { + await creation.openAgentDraft(); + await creation.submitRepeatedly("Send message", "Start exactly one agent for this prompt."); + await creation.expectPromptVisible(); + await creation.expectAgentCount(1); +}); + +test("retrying the app's agent creation returns the same agent for every attempt", async ({ + creation, + agentRetries, +}) => { + await creation.openAgentDraft(); + await creation.submitPrompt("Start exactly one agent for this prompt."); + expect(new Set(await agentRetries.completedAgentIds()).size).toBe(1); + await creation.expectAgentCount(1); +}); + +test("the first prompt still names an agent created with a receipt", async ({ creation }) => { + await creation.openAgentDraft(); + await creation.submitPrompt("Name this new agent from its first prompt."); + await creation.expectPromptVisible(); + await creation.expectAgentTitle("Name this new agent from its first prompt."); +}); + +test("reconnecting after a lost creation response recovers the agent and initial message", async ({ + creation, + promptRetry, +}) => { + await creation.openAgentDraft(); + promptRetry.holdAcknowledgement(); + await creation.submitPrompt("Deliver this initial prompt once."); + await promptRetry.waitForDeliveredPrompt(); + await promptRetry.disconnectAndReconnect(); + await promptRetry.expectSameAgentAndMessage(); + await creation.expectAgentCount(1); +}); + +test("a remounted draft reconciles its original creation after reconnect", async ({ + creation, + promptRetry, +}) => { + await creation.openAgentDraft(); + promptRetry.holdAcknowledgement(); + await creation.submitPrompt("Deliver this initial prompt once."); + await promptRetry.waitForDeliveredPrompt(); + await creation.evictAndReturnToDraft(); + await creation.expectPromptVisible("Deliver this initial prompt once."); + await promptRetry.disconnectAndReconnect(); + await promptRetry.expectSameAgentAndMessage(); + await creation.expectAgentCount(1); +}); + +test("repeated new-workspace prompt submissions create one workspace and one agent", async ({ + creation, +}) => { + await creation.openWorkspaceForm("local"); + await creation.submitRepeatedly("Create", "Start one agent in one new workspace."); + await creation.expectPromptVisible(); + await creation.expectOneCreatedWorkspace(); + await creation.expectAgentCount(1); +}); + +test("separate drafts can intentionally create two agents in the same workspace", async ({ + creation, + delayedCreation, +}) => { + await creation.openAgentDraft(); + await creation.submitPrompt("Start the first agent."); + await creation.expectPromptVisible("Start the first agent."); + await delayedCreation.waitForDelayedCreatedStatus(); + await creation.startAnotherDraft(); + await creation.submitPrompt("Start the second agent."); + await creation.expectPromptVisible("Start the second agent."); + await delayedCreation.waitForDelayedCreatedStatus(); + delayedCreation.release(); + await creation.expectAgentCount(2); +}); + +test("new workspace navigation and optimistic prompt precede agent initialization", async ({ + creation, + startup, + delayedCreation, +}) => { + await creation.openWorkspaceForm("worktree"); + await creation.submitPrompt("Show this prompt while the agent is starting.", "Create"); + await creation.expectAgentStillStarting(); + delayedCreation.expectSingleWorkspaceIntent(); + delayedCreation.release(); + await startup.release(); + await creation.expectOneCreatedWorkspace(); + await creation.expectAgentCount(1); +}); + +for (const scenario of [ + { suffix: "", prepare: async () => {} }, + { + suffix: " after remount", + prepare: async (creation: Awaited>) => + creation.evictAndReturnToDraft(), + }, +]) { + test(`retrying failed agent initialization preserves its workspace${scenario.suffix}`, async ({ + creation, + startup, + }) => { + await creation.openWorkspaceForm("local"); + await creation.submitPrompt("Retry this workspace and agent together.", "Create"); + await creation.expectAgentStillStarting(); + await startup.fail(); + await creation.expectStartupFailure(); + await creation.expectOneCreatedWorkspace(); + await scenario.prepare(creation); + await creation.submitPrompt("Retry this workspace and agent together."); + await creation.expectAgentCount(1); + }); +} 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 327666ecb8..956340db74 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,9 +1,10 @@ -import { expect, test, type Page } from "../support/fixtures"; +import { expect, type Page } from "../support/fixtures"; +import { test } from "../support/creation-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"; +import { captureWorkspaceAgentRequest } from "../support/helpers/creation"; import { openAgentRoute } from "../support/helpers/mock-agent"; import { openGlobalNewWorkspaceComposer, @@ -17,40 +18,6 @@ import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; -type WebSocketMessage = string | Buffer; - -interface CreateAgentRequestMessage { - type: "create_agent_request"; - config?: { - provider?: unknown; - modeId?: unknown; - }; -} - -function parseWebSocketJson(message: WebSocketMessage): unknown { - const rawMessage = typeof message === "string" ? message : message.toString("utf8"); - try { - return JSON.parse(rawMessage); - } catch { - return null; - } -} - -function getSessionMessage(message: WebSocketMessage): Record | null { - const envelope = parseWebSocketJson(message); - if (!envelope || typeof envelope !== "object") { - return null; - } - const maybeEnvelope = envelope as { type?: unknown; message?: unknown }; - if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) { - return null; - } - if (typeof maybeEnvelope.message !== "object") { - return null; - } - return maybeEnvelope.message as Record; -} - async function seedCodexDefaultPermissionPreferences(page: Page, cwd: string): Promise { const client = await connectDaemonClient({ clientIdPrefix: "codex-mode-preferences", @@ -142,44 +109,15 @@ async function expectThinkingOptionsFit(page: Page): Promise { await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, { timeout: 5_000 }); } -async function recordAndBlockCreateAgentRequests(page: Page): Promise<{ - waitForCreateAgentRequest(): Promise; -}> { - let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null; - const createAgentSeen = new Promise((resolve) => { - resolveRequest = resolve; - }); - - await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { - const server = ws.connectToServer(); - - ws.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - if (sessionMessage?.type === "create_agent_request") { - resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage); - return; - } - server.send(message); - }); - - server.onMessage((message) => { - ws.send(message); - }); - }); - - return { - waitForCreateAgentRequest: () => createAgentSeen, - }; -} - test.describe("New workspace Codex mode preferences", () => { test.describe.configure({ timeout: 240_000 }); test("keeps Full Access as the global Codex mode after the workspace draft auto-submit handoff", async ({ page, + startup, }) => { const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" }); - const createAgentRecorder = await recordAndBlockCreateAgentRequests(page); + const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: false }); try { await seedCodexDefaultPermissionPreferences(page, seeded.repoPath); await gotoAppShell(page); @@ -200,8 +138,12 @@ test.describe("New workspace Codex mode preferences", () => { ).toBeVisible(); await submitNewWorkspacePrompt(page, "Keep Codex full access selected globally."); - const createAgentRequest = await createAgentRecorder.waitForCreateAgentRequest(); + const createAgentRequest = await createAgentRecorder.waitForRequest(); + await expect(page).toHaveURL(/\/workspace\//); + await expect( + page.getByText("Keep Codex full access selected globally.", { exact: true }).first(), + ).toBeVisible(); expect(createAgentRequest.config).toMatchObject({ provider: "codex", modeId: "full-access", @@ -209,6 +151,8 @@ test.describe("New workspace Codex mode preferences", () => { await expect .poll(() => readCodexModePreference(page), { timeout: 10_000 }) .toBe("full-access"); + await startup.fail(); + await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible(); } finally { await seeded.cleanup(); } diff --git a/packages/app/e2e/support/creation-fixtures.ts b/packages/app/e2e/support/creation-fixtures.ts new file mode 100644 index 0000000000..67a9528b05 --- /dev/null +++ b/packages/app/e2e/support/creation-fixtures.ts @@ -0,0 +1,92 @@ +import { writeFile, rm } from "node:fs/promises"; +import path from "node:path"; +import { copyPluginExample } from "./helpers/plugin-fixture"; +import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace"; +import { test as base } from "./fixtures"; +import { + createCreationScenario, + createPromptRetryScenario, + retryNextAgentCreation, +} from "./helpers/creation"; +import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; +import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace"; + +export const test = base.extend<{ + startup: { release(): Promise; fail(): Promise }; + creation: Awaited>; + promptRetry: ReturnType; + agentRetries: Awaited>; + delayedCreation: Awaited>; +}>({ + startup: async ({ page }, provide) => { + void page; + const client = await connectNewWorkspaceDaemonClient(); + const plugin = await copyPluginExample("lifecycle-logger"); + const gate = path.join(plugin.directory, "startup.txt"); + const previous = await client.getDaemonConfig(); + try { + await writeFile(gate, "hold"); + await writeFile( + path.join(plugin.directory, "index.server.ts"), + ` +import { readFile, rm } from "node:fs/promises"; +export default function contribute(server) { + return server.before("agent.session_open", async ({ request }) => { + if (request.reason !== "create") return request; + const gate = ${JSON.stringify(gate)}; + let command = await readFile(gate, "utf8").catch(() => "release"); + while (command === "hold") { + await new Promise(resolve => setTimeout(resolve, 20)); + command = await readFile(gate, "utf8").catch(() => "release"); + } + if (command === "fail") { + await rm(gate, { force: true }); + throw new Error("Creation startup failed for test"); + } + return request; + }); +} +`, + ); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(plugin.directory); + await provide({ + release: () => rm(gate, { force: true }), + fail: () => writeFile(gate, "fail"), + }); + } finally { + await rm(gate, { force: true }); + await client.removePlugin("lifecycle-logger"); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }); + await plugin.cleanup(); + await client.close(); + } + }, + creation: async ({ page }, provide) => { + const creation = await createCreationScenario(page); + try { + await provide(creation); + } finally { + await creation.cleanup(); + } + }, + promptRetry: async ({ page }, provide) => { + const gate = await installDaemonWebSocketGate(page); + try { + await provide(createPromptRetryScenario(page, gate)); + } finally { + gate.restore(); + } + }, + agentRetries: async ({ page }, provide) => { + await provide(await retryNextAgentCreation(page)); + }, + delayedCreation: async ({ page }, provide) => { + const delayed = await delayBrowserAgentCreatedStatus(page); + try { + await provide(delayed); + } finally { + delayed.release(); + } + }, +}); diff --git a/packages/app/e2e/support/helpers/agent-timeline-gate.ts b/packages/app/e2e/support/helpers/agent-timeline-gate.ts index 91b6867cf0..690a3067ae 100644 --- a/packages/app/e2e/support/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/support/helpers/agent-timeline-gate.ts @@ -1,4 +1,5 @@ import type { Page } from "@playwright/test"; +import { loadSessionMessageReaders } from "./new-workspace"; import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port"; type WebSocketMessage = string | Buffer; @@ -321,6 +322,7 @@ export async function holdRewindCompletion( export async function delayCreatedAgentInitialTailResponse( page: Page, ): Promise { + const frames = await loadSessionMessageReaders(); let createdAgentId: string | null = null; let releaseRequested = false; let delayedResponseSeen = false; @@ -350,19 +352,24 @@ export async function delayCreatedAgentInitialTailResponse( }); server.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - const payload = sessionMessage ? getPayload(sessionMessage) : null; - if (sessionMessage?.type === "status" && payload?.status === "agent_created") { - const agentId = payload.agentId; - if (typeof agentId === "string") { - createdAgentId = agentId; - resolveCreatedAgent?.(agentId); - } + const sessionMessage = frames.server(message); + let createdId: unknown; + if (sessionMessage?.type === "agent.create.response") { + createdId = sessionMessage.payload.agent?.id; + } else if ( + sessionMessage?.type === "status" && + sessionMessage.payload.status === "agent_created" + ) { + createdId = sessionMessage.payload.agentId; + } + if (typeof createdId === "string") { + createdAgentId = createdId; + resolveCreatedAgent?.(createdId); } if (sessionMessage?.type === "fetch_agent_timeline_response") { - const agentId = payload?.agentId; - const direction = payload?.direction; + const agentId = sessionMessage.payload.agentId; + const direction = sessionMessage.payload.direction; if ( !delayedResponseSeen && typeof agentId === "string" && diff --git a/packages/app/e2e/support/helpers/creation.ts b/packages/app/e2e/support/helpers/creation.ts new file mode 100644 index 0000000000..c29bbeeeeb --- /dev/null +++ b/packages/app/e2e/support/helpers/creation.ts @@ -0,0 +1,277 @@ +import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages"; +import { expect, type Page } from "@playwright/test"; +import { daemonWsRoutePattern } from "./daemon-port"; +import { gotoAppShell } from "./app"; +import { gotoWorkspace } from "./launcher"; +import { fillComposerDraft } from "./composer"; +import { createAgentTabFromMenu } from "./workspace-tabs"; +import { + openNewWorkspaceComposer, + selectWorkspaceIsolation, + loadSessionMessageReaders, +} from "./new-workspace"; +import { seedWorkspace } from "./seed-client"; +import { + waitForSidebarHydration, + switchWorkspaceViaSidebar, + workspaceDeckEntryLocator, +} from "./workspace-ui"; +import { getServerId } from "./server-id"; +import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention"; +import type { installDaemonWebSocketGate } from "./daemon-websocket-gate"; + +/** Capture the submitted agent options; optionally stop provisioning for wire-only assertions. */ +export async function captureWorkspaceAgentRequest(page: Page, options: { block: boolean }) { + const frames = await loadSessionMessageReaders(); + type AgentIntent = NonNullable; + let resolveRequest!: (agent: AgentIntent) => void; + const request = new Promise((resolve) => { + resolveRequest = resolve; + }); + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((frame) => { + const message = frames.client(frame); + if (message?.type === "workspace.create.request" && message.agent) { + resolveRequest(message.agent); + if (options.block) return; + } + server.send(frame); + }); + server.onMessage((frame) => ws.send(frame)); + }); + return { waitForRequest: () => request }; +} + +export async function pressSubmitBeforeTheNextRender(page: Page, name: string): Promise { + const create = page.getByRole("button", { name, exact: true }); + await expect(create).toBeEnabled(); + // Dispatch the queued clicks in one JS task, before pending state can paint. + // Exercise DOM events rather than calling the app's submit handler directly. + await create.evaluate((button) => { + for (let click = 0; click < 3; click++) { + button.dispatchEvent(new MouseEvent("click", { bubbles: true })); + } + }); +} + +export async function observeCreationRequests(page: Page) { + const frames = await loadSessionMessageReaders(); + const pending = new Set(); + page.on("websocket", (socket) => { + socket.on("framesent", ({ payload }) => { + const request = frames.client(payload); + if ( + request?.type === "workspace.create.request" || + request?.type === "agent.create.request" + ) { + pending.add(request.requestId); + } + }); + socket.on("framereceived", ({ payload }) => { + const response = frames.server(payload); + if ( + response?.type === "workspace.create.response" || + response?.type === "agent.create.response" || + (response?.type === "status" && + (response.payload.status === "agent_created" || + response.payload.status === "agent_create_failed")) + ) { + const requestId = response.payload.requestId; + if (typeof requestId === "string") pending.delete(requestId); + } + }); + }); + return { + async settled() { + await expect.poll(() => pending.size).toBe(0); + }, + }; +} + +export async function retryNextAgentCreation(page: Page) { + const frames = await loadSessionMessageReaders(); + const retryIds = new Set(); + const results: Array<{ status: string; agentId?: string }> = []; + let repeated = false; + await page.routeWebSocket(daemonWsRoutePattern(), (browser) => { + const server = browser.connectToServer(); + browser.onMessage((frame) => { + const request = frames.client(frame); + if (!repeated && request?.type === "agent.create.request") { + repeated = true; + for (let attempt = 1; attempt <= 3; attempt++) { + const requestId = attempt === 1 ? request.requestId : `${request.requestId}-${attempt}`; + retryIds.add(requestId); + // Keep the app's operation key and payload; only RPC correlation changes. + server.send(JSON.stringify({ type: "session", message: { ...request, requestId } })); + } + return; + } + server.send(frame); + }); + server.onMessage((frame) => { + const response = frames.server(frame); + if ( + response?.type === "agent.create.response" && + retryIds.delete(response.payload.requestId) + ) { + results.push({ + status: response.payload.error ? "agent_create_failed" : "agent_created", + agentId: response.payload.agent?.id, + }); + } + browser.send(frame); + }); + }); + return { + async completedAgentIds() { + await expect.poll(() => results.length).toBe(3); + expect(results.map((result) => result.status)).toEqual([ + "agent_created", + "agent_created", + "agent_created", + ]); + return results.map((result) => result.agentId); + }, + }; +} + +export async function createCreationScenario(page: Page) { + const requests = await observeCreationRequests(page); + const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" }); + let workspaceId = project.workspaceId; + return { + cleanup: project.cleanup, + async openWorkspaceForm(isolation: "local" | "worktree") { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, project); + await selectWorkspaceIsolation(page, isolation); + }, + async openAgentDraft() { + await gotoWorkspace(page, project.workspaceId); + await createAgentTabFromMenu(page); + }, + async startAnotherDraft() { + await createAgentTabFromMenu(page); + }, + async submitPrompt(prompt: string, button = "Send message") { + await fillComposerDraft(page, prompt); + await page.getByRole("button", { name: button, exact: true }).click(); + }, + async submitRepeatedly(button: string, prompt?: string) { + if (prompt) await fillComposerDraft(page, prompt); + await pressSubmitBeforeTheNextRender(page, button); + }, + async expectPromptVisible(prompt?: string) { + const rows = page.getByTestId("user-message"); + await expect(prompt ? rows.filter({ hasText: prompt }) : rows.first()).toBeVisible(); + }, + async expectWorkspaceReadyBeforeAgentCompletion() { + await expect(page).toHaveURL(/\/workspace\//); + await expect(page.getByTestId("user-message").first()).toBeVisible(); + await expect( + page + .locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]') + .filter({ visible: true }), + ).toBeVisible(); + }, + async expectAgentStillStarting() { + await this.expectWorkspaceReadyBeforeAgentCompletion(); + expect((await project.client.fetchAgents()).entries).toHaveLength(0); + }, + async expectStartupFailure() { + await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible(); + }, + async expectOneCreatedWorkspace() { + await expect(page).toHaveURL(/\/workspace\//); + await requests.settled(); + const workspaces = (await project.client.fetchWorkspaces()).entries.filter( + (workspace) => + workspace.projectId === project.projectId && workspace.id !== project.workspaceId, + ); + expect(workspaces).toHaveLength(1); + workspaceId = workspaces[0]!.id; + }, + async expectAgentCount(count: number) { + await requests.settled(); + await expect + .poll( + async () => + (await project.client.fetchAgents()).entries.filter( + ({ agent }) => agent.workspaceId === workspaceId, + ).length, + ) + .toBe(count); + }, + async expectAgentTitle(title: string) { + await expect + .poll( + async () => + (await project.client.fetchAgents()).entries.find( + ({ agent }) => agent.workspaceId === workspaceId, + )?.agent.title, + ) + .toBe(title); + }, + async evictAndReturnToDraft() { + const draft = page + .locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]') + .filter({ visible: true }) + .first(); + const draftTestId = await draft.getAttribute("data-testid"); + expect(draftTestId).not.toBeNull(); + for (let index = 0; index < WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES; index++) { + const result = await project.client.createWorkspace({ + source: { kind: "directory", path: project.repoPath }, + }); + if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace"); + await switchWorkspaceViaSidebar({ + page, + serverId: getServerId(), + workspaceId: result.workspace.id, + }); + } + await expect(workspaceDeckEntryLocator(page, getServerId(), workspaceId)).toHaveCount(0); + await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId }); + await page + .getByTestId(draftTestId!) + .filter({ visible: true }) + .click({ position: { x: 12, y: 13 } }); + }, + }; +} + +export function createPromptRetryScenario( + page: Page, + gate: Awaited>, +) { + let firstMessage: ReturnType[number] | undefined; + return { + holdAcknowledgement() { + gate.holdNextServerMessage("agent.create.response"); + }, + async waitForDeliveredPrompt() { + await gate.waitForHeldServerMessage("agent.create.response"); + firstMessage = gate.getClientRequests("agent.create.request").at(-1); + }, + async disconnectAndReconnect() { + const fetches = gate.getClientRequestCount("fetch_agents_request"); + await gate.drop(); + gate.restore(); + await expect + .poll(() => gate.getClientRequestCount("fetch_agents_request")) + .toBeGreaterThan(fetches); + }, + async expectSameAgentAndMessage() { + expect(gate.getClientRequestCount("agent.create.request")).toBe(1); + await expect + .poll(() => gate.getClientRequestCount("creation.subscribe.request")) + .toBeGreaterThan(0); + expect(gate.getClientRequests("agent.create.request").at(-1)).toEqual(firstMessage); + expect(gate.getClientRequestCount("send_agent_message_request")).toBe(0); + await expect(page.getByTestId("user-message").filter({ visible: true })).toHaveCount(1); + }, + }; +} diff --git a/packages/app/e2e/support/helpers/daemon-client-loader.ts b/packages/app/e2e/support/helpers/daemon-client-loader.ts index b36bbae32a..eee8cdaffb 100644 --- a/packages/app/e2e/support/helpers/daemon-client-loader.ts +++ b/packages/app/e2e/support/helpers/daemon-client-loader.ts @@ -64,3 +64,10 @@ function loadAppVersion(): string { } return packageJson.version; } + +export async function loadProtocolSchemas(): Promise { + const moduleUrl = pathToFileURL( + path.resolve(__dirname, "../../../../../packages/protocol/dist/messages.js"), + ).href; + return import(moduleUrl); +} diff --git a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts index 2f3d6b22c6..7c9e212c93 100644 --- a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts +++ b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts @@ -21,6 +21,7 @@ interface ClientRequest { mode?: unknown; path?: unknown; agentId?: unknown; + messageId?: unknown; text?: unknown; activeTurnBehavior?: unknown; } diff --git a/packages/app/e2e/support/helpers/new-workspace-navigation.ts b/packages/app/e2e/support/helpers/new-workspace-navigation.ts index 6e6220c581..1b3106bdf7 100644 --- a/packages/app/e2e/support/helpers/new-workspace-navigation.ts +++ b/packages/app/e2e/support/helpers/new-workspace-navigation.ts @@ -134,15 +134,20 @@ export async function verifyDelayedWorkspaceCreation( ); if (!upload) throw new Error("The create request did not contain the submitted file"); expect(await readFile(upload.path)).toEqual(CONTEXT.buffer); + const agents = await client.fetchAgents(); + expect( + agents.entries.find((entry) => entry.agent.workspaceId === created.id)?.agent, + ).toMatchObject({ + cwd: created.workspaceDirectory, + }); expect(delay.agentRequests[0]).toMatchObject({ - workspaceId: created.id, initialPrompt: PROMPT, config: { provider: "mock", model: "ten-second-stream", modeId: "load-test", thinkingOptionId: "low", - cwd: created.workspaceDirectory, + cwd: repo.path, }, attachments: [ expect.objectContaining({ diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index 5cebc309b1..c6695a268d 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -1,8 +1,8 @@ import { expect, type BrowserContext, type Page } from "@playwright/test"; -import type { CreateAgentRequestMessage } from "@getpaseo/protocol/messages"; +import type { CreateAgentRequestMessage, SessionInboundMessage } from "@getpaseo/protocol/messages"; import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client"; import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; -import { connectDaemonClient } from "./daemon-client-loader"; +import { connectDaemonClient, loadProtocolSchemas } from "./daemon-client-loader"; import { daemonWsRoutePattern } from "./daemon-port"; import { projectEquivalenceViewKey } from "./project-view-key"; import { expectWorkspaceHeader } from "./workspace-ui"; @@ -535,37 +535,35 @@ function parseWebSocketJson(message: WebSocketMessage): unknown { } } -function getSessionMessage(message: WebSocketMessage): Record | null { - const envelope = parseWebSocketJson(message); - if (!envelope || typeof envelope !== "object") { - return null; - } - const maybeEnvelope = envelope as { type?: unknown; message?: unknown }; - if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) { - return null; - } - if (typeof maybeEnvelope.message !== "object") { - return null; - } - return maybeEnvelope.message as Record; -} - -function getStringField(input: Record, key: string): string | null { - const value = input[key]; - return typeof value === "string" ? value : null; +export async function loadSessionMessageReaders() { + // Use the same ESM module instance as the dynamically loaded daemon client. + const { WSInboundMessageSchema, WSOutboundMessageSchema } = await loadProtocolSchemas(); + return { + client(message: WebSocketMessage) { + const parsed = WSInboundMessageSchema.safeParse(parseWebSocketJson(message)); + return parsed.success && parsed.data.type === "session" ? parsed.data.message : null; + }, + server(message: WebSocketMessage) { + const parsed = WSOutboundMessageSchema.safeParse(parseWebSocketJson(message)); + return parsed.success && parsed.data.type === "session" ? parsed.data.message : null; + }, + }; } export interface AgentCreatedDelayControl { release(): void; waitForCreateRequest(): Promise; waitForDelayedCreatedStatus(): Promise; + expectSingleWorkspaceIntent(): void; } export async function delayBrowserAgentCreatedStatus( page: Page, ): Promise { + const frames = await loadSessionMessageReaders(); const daemonPortPattern = daemonWsRoutePattern(); const createRequestIds = new Set(); + const creationRequests: SessionInboundMessage[] = []; const delayedForwards: Array<() => void> = []; let releaseRequested = false; let resolveCreateRequest: (() => void) | null = null; @@ -581,26 +579,32 @@ export async function delayBrowserAgentCreatedStatus( const server = ws.connectToServer(); ws.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - if (sessionMessage?.type === "create_agent_request") { - const requestId = getStringField(sessionMessage, "requestId"); - if (requestId) { - createRequestIds.add(requestId); - resolveCreateRequest?.(); - } + const sessionMessage = frames.client(message); + if (sessionMessage?.type === "send_agent_message_request") + creationRequests.push(sessionMessage); + if ( + sessionMessage?.type === "create_agent_request" || + sessionMessage?.type === "agent.create.request" || + sessionMessage?.type === "workspace.create.request" + ) { + creationRequests.push(sessionMessage); + createRequestIds.add(sessionMessage.requestId); + resolveCreateRequest?.(); } server.send(message); }); server.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - const payload = - sessionMessage?.type === "status" && typeof sessionMessage.payload === "object" - ? (sessionMessage.payload as Record) - : null; - const requestId = payload ? getStringField(payload, "requestId") : null; - - if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) { + const sessionMessage = frames.server(message); + const isCreated = + (sessionMessage?.type === "status" && sessionMessage.payload.status === "agent_created") || + sessionMessage?.type === "agent.create.response" || + sessionMessage?.type === "workspace.create.response"; + if ( + isCreated && + typeof sessionMessage.payload.requestId === "string" && + createRequestIds.has(sessionMessage.payload.requestId) + ) { resolveDelayedCreatedStatus?.(); if (releaseRequested) { ws.send(message); @@ -621,26 +625,41 @@ export async function delayBrowserAgentCreatedStatus( forward(); } }, + + expectSingleWorkspaceIntent() { + expect(creationRequests).toHaveLength(1); + expect(creationRequests[0]).toMatchObject({ + type: "workspace.create.request", + agent: { initialPrompt: expect.any(String) }, + }); + }, waitForCreateRequest: () => createRequestSeen, waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen, }; } +type AgentCreationIntent = Pick< + CreateAgentRequestMessage, + "config" | "initialPrompt" | "attachments" +>; + export interface WorkspaceCreatedDelayControl { - agentRequests: readonly CreateAgentRequestMessage[]; + agentRequests: readonly AgentCreationIntent[]; release(): void; waitForCreateRequest(): Promise; } /** - * Holds `workspace.create.response` so a test can act as the user does while the daemon is still + * Holds workspace readiness events and responses so a test can act while the daemon is still * running `git worktree add` — most importantly, navigate somewhere else. */ export async function delayBrowserWorkspaceCreatedResponse( page: Page, ): Promise { + const frames = await loadSessionMessageReaders(); const daemonPortPattern = daemonWsRoutePattern(); - const agentRequests: CreateAgentRequestMessage[] = []; + const agentRequests: AgentCreationIntent[] = []; + const creationKeys = new Set(); const createRequestIds = new Set(); const delayedForwards: Array<() => void> = []; let releaseRequested = false; @@ -653,29 +672,25 @@ export async function delayBrowserWorkspaceCreatedResponse( const server = ws.connectToServer(); ws.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - if (sessionMessage?.type === "create_agent_request") - agentRequests.push(sessionMessage as CreateAgentRequestMessage); + const sessionMessage = frames.client(message); + if (sessionMessage?.type === "create_agent_request") agentRequests.push(sessionMessage); if (sessionMessage?.type === "workspace.create.request") { - const requestId = getStringField(sessionMessage, "requestId"); - if (requestId) { - createRequestIds.add(requestId); - resolveCreateRequest?.(); - } + createRequestIds.add(sessionMessage.requestId); + if (sessionMessage.idempotencyKey) creationKeys.add(sessionMessage.idempotencyKey); + if (sessionMessage.agent) agentRequests.push(sessionMessage.agent); + resolveCreateRequest?.(); } server.send(message); }); server.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - const payload = - sessionMessage?.type === "workspace.create.response" && - typeof sessionMessage.payload === "object" - ? (sessionMessage.payload as Record) - : null; - const requestId = payload ? getStringField(payload, "requestId") : null; - - if (requestId && createRequestIds.has(requestId) && !releaseRequested) { + const sessionMessage = frames.server(message); + const isCreation = + (sessionMessage?.type === "workspace.create.response" && + createRequestIds.has(sessionMessage.payload.requestId)) || + (sessionMessage?.type === "workspace.create.update" && + creationKeys.has(sessionMessage.payload.idempotencyKey)); + if (isCreation && !releaseRequested) { delayedForwards.push(() => ws.send(message)); return; } diff --git a/packages/app/e2e/support/helpers/workspace.ts b/packages/app/e2e/support/helpers/workspace.ts index 23bfc4bf9b..14b31be926 100644 --- a/packages/app/e2e/support/helpers/workspace.ts +++ b/packages/app/e2e/support/helpers/workspace.ts @@ -18,12 +18,11 @@ export interface TempDirectory { } /** - * The temp root for E2E fixtures. On macOS we resolve symlinks (/tmp → - * /private/tmp) so fixture paths match the daemon's resolved paths; on Windows - * `/tmp` doesn't exist, so fall back to the OS temp dir. + * Honor the OS temp directory (including TMPDIR), resolving symlinks so fixture + * paths match the daemon's resolved paths. */ export async function resolveTempRoot(): Promise { - return process.platform === "win32" ? tmpdir() : await realpath("/tmp"); + return await realpath(tmpdir()); } async function configureRemote(input: { diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 968b721eb2..4b2da86ea5 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -1,9 +1,10 @@ +import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/daemon-client"; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; 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"; import { useTranslation } from "react-i18next"; -import { createNameId } from "mnemonic-id"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { Composer } from "@/composer"; @@ -32,7 +33,6 @@ import type { DaemonClient, } from "@getpaseo/client/internal/daemon-client"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; -import { requireWorkspaceDirectory } from "@/utils/workspace-directory"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import type { MessagePayload } from "@/composer/types"; @@ -85,20 +85,23 @@ function buildChatDraftComposerArgs({ async function callWorkspaceCreation({ creationMethod, connectedClient, + creationId, + worktreeSlug, input, }: { creationMethod: "create_worktree" | "open_project"; connectedClient: DaemonClient; - input: { cwd: string }; + creationId: string; + worktreeSlug: string; + input: { cwd: string; agent?: CreateWorkspaceRequestOptions["agent"] }; }) { - if (creationMethod === "create_worktree") { - return connectedClient.createPaseoWorktree({ - cwd: input.cwd, - worktreeSlug: createNameId(), - }); - } return connectedClient.createWorkspace({ - source: { kind: "directory", path: input.cwd }, + idempotencyKey: creationId, + agent: input.agent, + source: + creationMethod === "create_worktree" + ? { kind: "worktree", cwd: input.cwd, worktreeSlug } + : { kind: "directory", path: input.cwd }, }); } @@ -208,7 +211,7 @@ export function WorkspaceSetupDialog() { setErrorMessage(null); setCreatedWorkspace(null); setPendingAction(null); - }, [pendingWorkspaceSetup?.creationMethod, serverId, sourceDirectory]); + }, [pendingWorkspaceSetup?.creationId]); const handleClose = useCallback(() => { clearWorkspaceSetup(); @@ -249,18 +252,25 @@ export function WorkspaceSetupDialog() { }, [client, isConnected, t]); const ensureWorkspace = useCallback( - async (input: { cwd: string; attachments: MessagePayload["attachments"] }) => { + async (input: { + cwd: string; + attachments: MessagePayload["attachments"]; + agent?: CreateWorkspaceRequestOptions["agent"]; + onAgentCreated?: (agent: AgentSnapshotPayload) => void; + }) => { if (!pendingWorkspaceSetup) { throw new Error(t("workspaceSetup.errors.pendingRequired")); } - if (createdWorkspace) { + if (createdWorkspace && !input.agent) { return createdWorkspace; } const connectedClient = withConnectedClient(); const payload = await callWorkspaceCreation({ creationMethod: pendingWorkspaceSetup.creationMethod, + creationId: pendingWorkspaceSetup.creationId, + worktreeSlug: pendingWorkspaceSetup.worktreeSlug, connectedClient, input, }); @@ -271,6 +281,7 @@ export function WorkspaceSetupDialog() { ); } + if (payload.agent) input.onAgentCreated?.(payload.agent); const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace); mergeWorkspaces(pendingWorkspaceSetup.serverId, [normalizedWorkspace]); if (pendingWorkspaceSetup.creationMethod === "open_project") { @@ -307,8 +318,7 @@ export function WorkspaceSetupDialog() { try { setPendingAction("chat"); setErrorMessage(null); - const ensuredWorkspace = await ensureWorkspace({ cwd, attachments }); - const connectedClient = withConnectedClient(); + if (!composerState) { throw new Error(t("workspaceSetup.errors.composerStateRequired")); } @@ -322,21 +332,30 @@ export function WorkspaceSetupDialog() { }), }); const encodedImages = await encodeImages(wirePayload.images); - const workspaceDirectory = requireWorkspaceDirectory({ - workspaceId: ensuredWorkspace.id, - workspaceDirectory: ensuredWorkspace.workspaceDirectory, + if (!pendingWorkspaceSetup) throw new Error(t("workspaceSetup.errors.pendingRequired")); + let createdAgent: AgentSnapshotPayload | undefined; + const { workspaceId: _workspaceId, ...agentInput } = buildCreateAgentOptions({ + composerState, + text, + attachments: wirePayload.attachments, + encodedImages: encodedImages ?? null, + workspaceDirectory: cwd, + workspaceId: "", + provider: composerState.selectedProvider, }); - const agent = await connectedClient.createAgent( - buildCreateAgentOptions({ - composerState, - text, - attachments: wirePayload.attachments, - encodedImages: encodedImages ?? null, - workspaceDirectory, - workspaceId: ensuredWorkspace.id, - provider: composerState.selectedProvider, - }), - ); + const ensuredWorkspace = await ensureWorkspace({ + cwd, + attachments, + agent: { + ...agentInput, + clientMessageId: `${pendingWorkspaceSetup.creationId}:initial-message`, + }, + onAgentCreated: (value) => { + createdAgent = value; + }, + }); + if (!createdAgent) throw new Error("The daemon did not create the requested agent"); + const agent = createdAgent; if (!getIsStillActive()) { return; @@ -362,13 +381,13 @@ export function WorkspaceSetupDialog() { }, [ composerState, + pendingWorkspaceSetup, getIsStillActive, navigateAfterCreation, serverId, ensureWorkspace, t, toast, - withConnectedClient, supportsForgeSearch, ], ); diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index d1cfce1161..bd4d0fe95c 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -5,12 +5,11 @@ import { resolveComposerAttachmentSubmitFormat, splitComposerAttachmentsForSubmit, } from "@/composer/attachments/submit"; -import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store"; import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer"; import { useSessionStore } from "@/stores/session-store"; import { createUserMessage, - generateMessageId, type StreamItem, type UserMessageImageAttachment, } from "@/types/stream"; @@ -123,7 +122,7 @@ export function useDraftAgentCreateFlow({ onCreateError, }: UseDraftAgentCreateFlowOptions) { const { t } = useTranslation(); - const [machine, dispatch] = useReducer( + const [localMachine, dispatch] = useReducer( reducer, initialAttempt, (attempt): DraftAgentMachineState => @@ -135,10 +134,22 @@ export function useDraftAgentCreateFlow({ }, ); + const pending = useCreateFlowStore((state) => state.pendingByDraftId[draftId]); + // Remounts can precede model hydration. Rebuild the preview when its inputs + // arrive, and observe the original request's failure through shared state. + const machine = useMemo>(() => { + if (pending?.lifecycle === "abandoned") { + return { tag: "draft", errorMessage: pending.errorMessage ?? "" }; + } + if (pending?.lifecycle === "active" && localMachine.tag === "draft" && initialAttempt) { + return prepareCreateAttempt(initialAttempt, buildDraftAgent); + } + return localMachine; + }, [pending, localMachine, initialAttempt, buildDraftAgent]); + const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending); const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId); const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle); - const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear); const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : ""; const isSubmitting = machine.tag === "creating"; @@ -233,14 +244,16 @@ export function useDraftAgentCreateFlow({ const resolved = error instanceof Error ? error : new Error(t("composer.errors.failedToCreateAgent")); dispatch({ type: "CREATE_FAILED", message: resolved.message }); - markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" }); - clearPendingCreateAttempt({ draftId }); + markPendingCreateLifecycle({ + draftId, + lifecycle: "abandoned", + errorMessage: resolved.message, + }); onCreateError?.(resolved); throw error; } }, [ - clearPendingCreateAttempt, createRequest, draftId, getPendingServerId, @@ -255,7 +268,11 @@ export function useDraftAgentCreateFlow({ const handleCreateFromInput = useCallback( async ({ text, attachments, cwd }: SubmitContext) => { - if (isSubmitting) { + const existing = useCreateFlowStore.getState().pendingByDraftId[draftId]; + if ( + isSubmitting || + isActiveCreateFlowForDraft({ pending: existing, serverId: getPendingServerId(), draftId }) + ) { throw new Error(t("composer.errors.alreadyLoading")); } @@ -296,10 +313,10 @@ export function useDraftAgentCreateFlow({ } const attempt: CreateAttempt = { - clientMessageId: generateMessageId(), + clientMessageId: `${draftId}:initial-message`, text: trimmedPrompt, timestamp: new Date(), - ...(images && images.length > 0 ? { images } : {}), + ...(images.length > 0 ? { images } : {}), ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), }; diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 742380789f..0457dfb36f 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -25,7 +25,7 @@ import type { Agent } from "@/stores/session-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration"; -import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request"; +import { encodeImages } from "@/utils/encode-images"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; import { @@ -137,6 +137,7 @@ function resolveDraftModeId(input: { } async function submitDraftCreateRequest(input: { + draftId: string; attempt: { clientMessageId: string }; text: string; images?: UserMessageImageAttachment[]; @@ -196,14 +197,18 @@ async function submitDraftCreateRequest(input: { }); const attachmentsArray = Array.isArray(attachments) ? attachments : undefined; - const result = await requestWorkspaceDraftAgent(client, { + const imagesData = await encodeImages(images); + const options = { + idempotencyKey: input.draftId, config, workspaceId, - text, + initialPrompt: text, clientMessageId: attempt.clientMessageId, - ...(images ? { images } : {}), - ...(attachmentsArray ? { attachments: attachmentsArray } : {}), - }); + ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), + ...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}), + }; + const creation = useWorkspaceDraftSubmissionStore.getState().creationByDraftId[input.draftId]; + const result = creation ? await creation.retry(options) : await client.createAgent(options); return { agentId: result.id, @@ -390,10 +395,7 @@ export function WorkspaceDraftAgentTab({ ); const autoSubmitConfig = resolveAutoSubmitConfig(pendingAutoSubmit); const initialCreateAttempt = useMemo(() => { - if (!pendingAutoSubmit || !pendingCreateAttempt) { - return null; - } - if (pendingAutoSubmit.clientMessageId !== pendingCreateAttempt.clientMessageId) { + if (!pendingCreateAttempt) { return null; } return { @@ -407,7 +409,7 @@ export function WorkspaceDraftAgentTab({ ? { attachments: pendingCreateAttempt.attachments } : {}), }; - }, [pendingAutoSubmit, pendingCreateAttempt]); + }, [pendingCreateAttempt]); const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true; const isCompactFormFactor = useIsCompactFormFactor(); const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow( @@ -487,8 +489,13 @@ export function WorkspaceDraftAgentTab({ composerState, selectModelMessage: t("workspaceSetup.errors.selectModel"), }), - createRequest: async ({ attempt, text, images, attachments, cwd }) => - submitDraftCreateRequest({ + createRequest: async ({ attempt, text, images, attachments, cwd }) => { + if (pendingAutoSubmit?.agentCreation) { + const result = await pendingAutoSubmit.agentCreation.result; + return { agentId: result.id, result }; + } + return submitDraftCreateRequest({ + draftId, attempt, text, images, @@ -501,7 +508,8 @@ export function WorkspaceDraftAgentTab({ composerState, hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), selectModelMessage: t("workspaceSetup.errors.selectModel"), - }), + }); + }, onCreateSuccess: ({ result }) => { clearDraftInput("sent"); clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey }); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 8adf2a188a..e097593db9 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -15,7 +15,7 @@ import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducer import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { requestTimelineReplacement } from "@/timeline/timeline-replacement"; import { type ViewedTimelineOwner } from "@/timeline/viewed-timeline-sync"; -import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages"; import { buildAgentAttentionNotificationPayload, @@ -25,8 +25,6 @@ import { } from "@getpaseo/protocol/agent-attention-notification"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types"; -import type { GitSetupOptions } from "@getpaseo/protocol/messages"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context"; @@ -45,9 +43,7 @@ import { createInitDeferred, rejectInitDeferred, } from "@/utils/agent-initialization"; -import { encodeImages } from "@/utils/encode-images"; import { derivePendingPermissionKey } from "@/utils/agent-snapshots"; -import type { AttachmentMetadata } from "@/attachments/types"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; @@ -825,48 +821,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider [client], ); - const _createAgent = useCallback( - async ({ - config, - initialPrompt, - images, - attachments, - git, - worktreeName, - requestId, - }: { - config: AgentSessionConfig; - initialPrompt: string; - images?: AttachmentMetadata[]; - attachments?: AgentAttachment[]; - git?: GitSetupOptions; - worktreeName?: string; - requestId?: string; - }) => { - if (!client) { - console.warn("[Session] createAgent skipped: daemon unavailable"); - return; - } - const trimmedPrompt = initialPrompt.trim(); - let imagesData: Array<{ data: string; mimeType: string }> | undefined; - try { - imagesData = await encodeImages(images); - } catch (error) { - console.error("[Session] Failed to prepare images for agent creation:", error); - } - await client.createAgent({ - config, - ...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}), - ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), - ...(attachments && attachments.length > 0 ? { attachments } : {}), - ...(git ? { git } : {}), - ...(worktreeName ? { worktreeName } : {}), - ...(requestId ? { requestId } : {}), - }); - }, - [client], - ); - const _setAgentMode = useCallback( (agentId: string, modeId: string) => { if (!client) { diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index a9baaf007d..ba858297f4 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -1,3 +1,9 @@ +import type { + CreateAgentRequestOptions, + CreateWorkspaceRequestOptions, +} from "@getpaseo/client/internal/daemon-client"; +import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages"; +import { encodeImages } from "@/utils/encode-images"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import type { ReactElement, RefObject } from "react"; @@ -70,7 +76,6 @@ import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; import { useFormPreferences } from "@/hooks/use-form-preferences"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; -import { generateMessageId } from "@/types/stream"; import { toErrorMessage } from "@/utils/error-messages"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { @@ -87,15 +92,10 @@ import { useProjectIcons } from "@/projects/icons"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { ComposerAttachment } from "@/attachments/types"; import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; -import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request"; -import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config"; import type { MessagePayload } from "@/composer/types"; import type { UserComposerAttachment } from "@/attachments/types"; import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages"; -import type { - CreatePaseoWorktreeInput, - DaemonClient, -} from "@getpaseo/client/internal/daemon-client"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty"; @@ -130,10 +130,7 @@ import { type ListTerminalsPayload, upsertCreatedTerminalPayload, } from "./workspace/terminals/state"; -import { - captureWorkspaceDraftCleanup, - createWorkspaceAgentInBackground, -} from "./new-workspace/background-handoff"; +import { captureWorkspaceDraftCleanup } from "./new-workspace/background-handoff"; import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence"; const ThemedFolderPlus = withUnistyles(FolderPlus); @@ -767,6 +764,10 @@ type SubmitOutcome = "navigated" | "background"; interface SubmitDraftInput { clearConsumedDraft: () => void; + agentCreation?: { + result: Promise; + retry: (input: CreateAgentRequestOptions) => Promise; + }; serverId: string; draftKey: string; clearDraft: (lifecycle: "sent" | "abandoned") => void; @@ -798,37 +799,22 @@ interface WorkspaceDraftSubmissionConfig { target: WorkspaceTabTarget; } -async function createAndMergeWorkspace(input: { - client: NonNullable>; - createInput: Parameters< - NonNullable>["createPaseoWorktree"] - >[0]; - mergeWorkspaces: ( - serverId: string, - workspaces: ReturnType[], - ) => void; - serverId: string; - createFailedMessage: string; -}): Promise> { - const payload = await input.client.createPaseoWorktree(input.createInput); - if (payload.error || !payload.workspace) { - throw new Error(payload.error ?? input.createFailedMessage); - } - const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace); - const workspaceForInitialMerge = input.createInput.firstAgentContext - ? { ...normalizedWorkspace, status: "running" as const, statusEnteredAt: new Date() } - : normalizedWorkspace; - input.mergeWorkspaces(input.serverId, [workspaceForInitialMerge]); - return normalizedWorkspace; +interface WorkspaceCreationResult { + workspace: ReturnType; + agent?: AgentSnapshotPayload; } async function createMultiplicityWorkspace(input: { + idempotencyKey: string; + worktreeSlug: string; client: NonNullable>; isolation: "local" | "worktree"; project: HostProjectListItem; sourceDirectory: string; checkoutRequest: PickerCheckoutRequest | undefined; withInitialAgent: boolean; + agent?: CreateWorkspaceRequestOptions["agent"]; + onEvent?: (snapshot: CreationSnapshot) => void; prompt: string; attachments: AgentAttachment[]; mergeWorkspaces: ( @@ -837,7 +823,7 @@ async function createMultiplicityWorkspace(input: { ) => void; serverId: string; createFailedMessage: string; -}): Promise> { +}): Promise { const projectId = getHostProjectId(input.project, input.serverId); if (!projectId) throw new Error("Project is not available on the selected host"); const isWorktree = input.isolation === "worktree"; @@ -846,12 +832,15 @@ async function createMultiplicityWorkspace(input: { attachments: input.attachments, }); const payload = await input.client.createWorkspace({ + idempotencyKey: input.idempotencyKey, + agent: input.agent, + onEvent: input.onEvent, source: isWorktree ? { kind: "worktree", cwd: input.sourceDirectory, projectId, - worktreeSlug: createNameId(), + worktreeSlug: input.worktreeSlug, ...input.checkoutRequest, } : { @@ -869,7 +858,7 @@ async function createMultiplicityWorkspace(input: { ? { ...normalizedWorkspace, status: "running" as const, statusEnteredAt: new Date() } : normalizedWorkspace; input.mergeWorkspaces(input.serverId, [workspaceForInitialMerge]); - return normalizedWorkspace; + return { workspace: normalizedWorkspace, agent: payload.agent }; } interface CreateChatAgentInput { @@ -881,7 +870,9 @@ interface CreateChatAgentInput { prompt: string; attachments: AgentAttachment[]; withInitialAgent: boolean; - }) => Promise>; + agent?: CreateWorkspaceRequestOptions["agent"]; + onEvent?: (snapshot: CreationSnapshot) => void; + }) => Promise; serverId: string; draftKey: string; clearDraft: (lifecycle: "sent" | "abandoned") => void; @@ -945,7 +936,18 @@ function buildComposerInitialValues(input: { return undefined; } -async function runCreateChatAgent(input: CreateChatAgentInput): Promise { +const pendingWorkspaceSubmissions = new Map>(); +function runCreateChatAgent(input: CreateChatAgentInput): Promise { + const key = JSON.stringify([input.serverId, input.draftId]); + const pending = pendingWorkspaceSubmissions.get(key); + if (pending) return pending; + const submission = Promise.resolve().then(() => createWorkspaceChatAgent(input)); + pendingWorkspaceSubmissions.set(key, submission); + void submission.finally(() => pendingWorkspaceSubmissions.delete(key)).catch(() => undefined); + return submission; +} + +async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise { const { payload, composerState, ensureWorkspace, serverId, clearDraft } = input; const clearConsumedDraft = captureWorkspaceDraftCleanup(input); const { text, attachments, cwd } = payload; @@ -963,36 +965,86 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise = { + config: { + provider, + cwd, + modeId: composerState.selectedMode || undefined, + model: composerState.effectiveModelId || undefined, + thinkingOptionId: composerState.effectiveThinkingOptionId || undefined, + featureValues: composerState.featureValues, + }, + initialPrompt: text, + clientMessageId: `${input.draftId}:initial-message`, + images: images?.length ? images : undefined, + attachments: wirePayload.attachments?.length ? wirePayload.attachments : undefined, + }; + const execute = async (requestedAgent = initialAgent): Promise => { + const { agent } = await ensureWorkspace({ + cwd, + prompt: text, + attachments: workspaceNamingAttachments, + withInitialAgent: true, + agent: requestedAgent, + onEvent: (snapshot) => { + if (!snapshot.workspace || navigated) return; + navigated = true; + if (!input.isStillOnCreateScreen()) return; + const workspace = normalizeWorkspaceDescriptor(snapshot.workspace); + getHostRuntimeStore().acceptWorkspaceSnapshots(serverId, [ + { ...workspace, status: "running" }, + ]); + const initialSetup = buildWorkspaceDraftSetupForCreatedWorkspace({ + forkDraftSetup: input.forkDraftSetup, + workspaceDirectory: workspace.workspaceDirectory, + provider, + composerState, + }); + outcome = submitWorkspaceDraft({ + clearConsumedDraft, + draftKey: input.draftKey, + draftContextScopeKey: input.draftContextScopeKey, + resolveClient: input.resolveClient, + isStillOnCreateScreen: input.isStillOnCreateScreen, + serverId, + clearDraft, + draftId: input.draftId, + initialSetup, + workspaceId: workspace.id, + workspaceDirectory: workspace.workspaceDirectory, + text, + attachments, + provider, + composerState, + supportsForgeSearch: input.supportsForgeSearch, + agentCreation, + }); + }, + }); + if (!agent) throw new Error("Workspace creation returned no agent"); + return agent; + }; + const agentCreation = { + result: Promise.resolve().then(() => execute()), + retry: (request: CreateAgentRequestOptions) => + execute({ + ...initialAgent, + config: { ...request.config!, cwd }, + initialPrompt: request.initialPrompt ?? "", + clientMessageId: initialAgent.clientMessageId, + images: request.images, + attachments: request.attachments, + }), + }; + await agentCreation.result; + if (outcome === "background") clearConsumedDraft(); + return outcome; } function buildComposerConfig(input: { @@ -1054,7 +1106,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: { }; } -async function submitWorkspaceDraft(input: SubmitDraftInput): Promise { +function submitWorkspaceDraft(input: SubmitDraftInput): SubmitOutcome { const { serverId, clearDraft, @@ -1068,7 +1120,7 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise - requestWorkspaceDraftAgent(input.resolveClient(), { - workspaceId, - config: buildWorkspaceDraftAgentConfig({ - provider: submission.provider, - cwd: submission.cwd, - ...(submission.modeId ? { modeId: submission.modeId } : {}), - ...(submission.model ? { model: submission.model } : {}), - ...(submission.thinkingOptionId - ? { thinkingOptionId: submission.thinkingOptionId } - : {}), - ...(submission.featureValues ? { featureValues: submission.featureValues } : {}), - }), - text: text.trim(), - clientMessageId, - ...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}), - ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), - }), - }); - return "background"; - } - - useCreateFlowStore.getState().setPending({ + const started = useCreateFlowStore.getState().trySetPending({ serverId, draftId, workspaceId, @@ -1120,6 +1145,7 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise 0 ? { images: wirePayload.images } : {}), ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), }); + if (!started) return "navigated"; useWorkspaceDraftSubmissionStore.getState().setPending({ serverId, workspaceId, @@ -1135,6 +1161,7 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise= v0.1.97 const supportsWorkspaceMultiplicity = useHostFeature(selectedServerId, "workspaceMultiplicity"); const supportsForgeSearch = useHostFeature(selectedServerId, "forgeSearch"); + const [creationIdentity] = useState(() => ({ + draftId: draftId ?? generateDraftId(), + worktreeSlug: createNameId(), + })); const [errorMessage, setErrorMessage] = useState(null); - const [createdWorkspace, setCreatedWorkspace] = useState | null>(null); + const [creationResult, setCreationResult] = useState< + WorkspaceCreationResult | { workspace: null } + >({ workspace: null }); const [pendingAction, setPendingAction] = useState<"chat" | "empty" | "terminal" | null>(null); const [pickerOpen, setPickerOpen] = useState(false); const [projectPickerOpen, setProjectPickerOpen] = useState(false); @@ -1696,7 +1727,7 @@ export function NewWorkspaceScreen({ return () => clearTimeout(timer); }, [pickerSearchQuery]); - const workspace = createdWorkspace; + const { workspace } = creationResult; const client = useHostRuntimeClient(selectedServerId); const isConnected = useHostRuntimeIsConnected(selectedServerId); const { @@ -1997,45 +2028,17 @@ export function NewWorkspaceScreen({ setProjectPickerOpen(nextOpen); }, []); - const buildCreateWorktreeInput = useCallback( - (input: { - cwd: string; - prompt: string; - attachments: AgentAttachment[]; - checkoutRequest: PickerCheckoutRequest | undefined; - }): CreatePaseoWorktreeInput => { - if (!selectedProject) { - throw new Error("Choose a project"); - } - if (!selectedSourceDirectory) { - throw new Error("Choose a host for this project"); - } - const firstAgentContext = buildFirstAgentContext(input); - const hostProjectId = getHostProjectId(selectedProject, selectedServerId); - if (!hostProjectId) { - throw new Error("Project is not available on the selected host"); - } - - return { - cwd: selectedSourceDirectory, - projectId: hostProjectId, - worktreeSlug: createNameId(), - ...(firstAgentContext ? { firstAgentContext } : {}), - ...input.checkoutRequest, - }; - }, - [selectedProject, selectedServerId, selectedSourceDirectory], - ); - const ensureWorkspace = useCallback( async (input: { cwd: string; prompt: string; attachments: AgentAttachment[]; withInitialAgent: boolean; + agent?: CreateWorkspaceRequestOptions["agent"]; + onEvent?: (snapshot: CreationSnapshot) => void; }) => { - if (createdWorkspace) { - return createdWorkspace; + if (creationResult.workspace) { + return creationResult; } if (!selectedProject) { throw new Error("Choose a project"); @@ -2058,33 +2061,29 @@ export function NewWorkspaceScreen({ selectedItem ?? defaultBasePickerItem(checkoutStatusForCreate), ) : undefined; - const normalizedWorkspace = supportsWorkspaceMultiplicity - ? await createMultiplicityWorkspace({ - client: connectedClient, - isolation: effectiveIsolation, - project: selectedProject, - sourceDirectory: selectedSourceDirectory, - checkoutRequest, - withInitialAgent: input.withInitialAgent, - prompt: input.prompt, - attachments: input.attachments, - mergeWorkspaces, - serverId: selectedServerId, - createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), - }) - : await createAndMergeWorkspace({ - client: connectedClient, - createInput: buildCreateWorktreeInput({ ...input, checkoutRequest }), - mergeWorkspaces, - serverId: selectedServerId, - createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), - }); - setCreatedWorkspace(normalizedWorkspace); + const normalizedWorkspace = await createMultiplicityWorkspace({ + idempotencyKey: creationIdentity.draftId, + worktreeSlug: creationIdentity.worktreeSlug, + client: connectedClient, + isolation: createsWorktree ? "worktree" : "local", + project: selectedProject, + sourceDirectory: selectedSourceDirectory, + checkoutRequest, + withInitialAgent: input.withInitialAgent, + prompt: input.prompt, + attachments: input.attachments, + agent: input.agent, + onEvent: input.onEvent, + mergeWorkspaces, + serverId: selectedServerId, + createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), + }); + setCreationResult(normalizedWorkspace); return normalizedWorkspace; }, [ - buildCreateWorktreeInput, - createdWorkspace, + creationIdentity, + creationResult, effectiveIsolation, mergeWorkspaces, queryClient, @@ -2109,7 +2108,7 @@ export function NewWorkspaceScreen({ let outcome: SubmitOutcome = "background"; await runCreateEmptyWorkspace({ payload, - ensureWorkspace, + ensureWorkspace: async (request) => (await ensureWorkspace(request)).workspace, serverId: selectedServerId, navigate: (targetServerId, workspaceId) => { if (!isStillOnCreateScreen()) { @@ -2136,7 +2135,7 @@ export function NewWorkspaceScreen({ serverId: selectedServerId, clearDraft: chatDraft.clear, draftKey, - draftId, + draftId: creationIdentity.draftId, draftContextScopeKey, supportsForgeSearch, resolveClient: withConnectedClient, @@ -2159,7 +2158,7 @@ export function NewWorkspaceScreen({ [ composerState, draftContextScopeKey, - draftId, + creationIdentity, chatDraft.clear, draftKey, ensureWorkspace, @@ -2186,7 +2185,7 @@ export function NewWorkspaceScreen({ prompt: terminalPromptText, profile: selectedTerminalProfile, profileName: selectedTerminalProfile?.name, - ensureWorkspace, + ensureWorkspace: async (request) => (await ensureWorkspace(request)).workspace, createTerminal: async (input) => { const connectedClient = withConnectedClient(); const createdTerminal = await connectedClient.createTerminal( diff --git a/packages/app/src/stores/create-flow-store.test.ts b/packages/app/src/stores/create-flow-store.test.ts index c1b4ec8d77..4b5d73bd15 100644 --- a/packages/app/src/stores/create-flow-store.test.ts +++ b/packages/app/src/stores/create-flow-store.test.ts @@ -6,6 +6,33 @@ describe("create-flow-store", () => { useCreateFlowStore.setState({ pendingByDraftId: {} }); }); + it("accepts a retry after failure while rejecting repeated active or completed submissions", () => { + const store = useCreateFlowStore.getState(); + const submission = { + draftId: "draft-retry", + serverId: "server-1", + workspaceId: "workspace-1", + agentId: null, + clientMessageId: "draft-retry:initial-message", + text: "hello", + timestamp: 1, + }; + expect(store.trySetPending(submission)).toBe(true); + expect(store.trySetPending(submission)).toBe(false); + store.markLifecycle({ + draftId: submission.draftId, + lifecycle: "abandoned", + errorMessage: "Disconnected", + }); + expect(store.trySetPending(submission)).toBe(true); + expect(useCreateFlowStore.getState().pendingByDraftId[submission.draftId]).toEqual({ + ...submission, + lifecycle: "active", + }); + store.markLifecycle({ draftId: submission.draftId, lifecycle: "sent" }); + expect(store.trySetPending(submission)).toBe(false); + }); + it("tracks lifecycle transitions explicitly", () => { const store = useCreateFlowStore.getState(); store.setPending({ diff --git a/packages/app/src/stores/create-flow-store.ts b/packages/app/src/stores/create-flow-store.ts index 2056e42c0f..95a85137a5 100644 --- a/packages/app/src/stores/create-flow-store.ts +++ b/packages/app/src/stores/create-flow-store.ts @@ -13,13 +13,14 @@ export interface PendingCreateAttempt { text: string; timestamp: number; lifecycle: CreateFlowLifecycleState; + errorMessage?: string; images?: UserMessageImageAttachment[]; attachments?: AgentAttachment[]; } export function isActiveCreateFlowForDraft(input: { pending: PendingCreateAttempt | null | undefined; - serverId: string; + serverId: string | null; draftId: string | null | undefined; }): boolean { const draftId = input.draftId?.trim(); @@ -34,15 +35,20 @@ export function isActiveCreateFlowForDraft(input: { interface CreateFlowState { pendingByDraftId: Record; setPending: (pending: Omit) => void; + trySetPending: (pending: Omit) => boolean; updateAgentId: (input: { draftId: string; agentId: string }) => void; - markLifecycle: (input: { draftId: string; lifecycle: CreateFlowLifecycleState }) => void; + markLifecycle: (input: { + draftId: string; + lifecycle: CreateFlowLifecycleState; + errorMessage?: string; + }) => void; rekeyDraft: (input: { fromDraftId: string; toDraftId: string }) => void; clear: (input: { draftId: string }) => void; clearByAgent: (input: { serverId: string; agentId: string }) => void; clearAll: () => void; } -export const useCreateFlowStore = create((set) => ({ +export const useCreateFlowStore = create((set, get) => ({ pendingByDraftId: {}, setPending: (pending) => set((state) => ({ @@ -54,6 +60,12 @@ export const useCreateFlowStore = create((set) => ({ }, }, })), + trySetPending: (pending) => { + const existing = get().pendingByDraftId[pending.draftId]; + if (existing?.serverId === pending.serverId && existing.lifecycle !== "abandoned") return false; + get().setPending(pending); + return true; + }, updateAgentId: ({ draftId, agentId }) => set((state) => { const current = state.pendingByDraftId[draftId]; @@ -67,7 +79,7 @@ export const useCreateFlowStore = create((set) => ({ }, }; }), - markLifecycle: ({ draftId, lifecycle }) => + markLifecycle: ({ draftId, lifecycle, errorMessage }) => set((state) => { const current = state.pendingByDraftId[draftId]; if (!current || current.lifecycle === lifecycle) { @@ -76,7 +88,7 @@ export const useCreateFlowStore = create((set) => ({ return { pendingByDraftId: { ...state.pendingByDraftId, - [draftId]: { ...current, lifecycle }, + [draftId]: { ...current, lifecycle, errorMessage }, }, }; }), diff --git a/packages/app/src/stores/workspace-draft-submission-store.test.ts b/packages/app/src/stores/workspace-draft-submission-store.test.ts new file mode 100644 index 0000000000..73e1a40899 --- /dev/null +++ b/packages/app/src/stores/workspace-draft-submission-store.test.ts @@ -0,0 +1,71 @@ +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { transformFileSync } from "@babel/core"; +import { expect, test } from "vitest"; +import type { useWorkspaceDraftSubmissionStore } from "./workspace-draft-submission-store"; + +// Exercise the emitted app code: plain TypeScript execution misses Expo's omit bug. +function loadProductionStore() { + const filename = fileURLToPath(new URL("./workspace-draft-submission-store.ts", import.meta.url)); + const caller = { name: "metro", platform: "web", isDev: false, supportsStaticESM: false }; + const result = transformFileSync(filename, { + cwd: fileURLToPath(new URL("../../", import.meta.url)), + envName: "production", + caller, + }); + if (!result?.code) throw new Error("Expo produced no store code"); + const module = { + exports: {} as { useWorkspaceDraftSubmissionStore: typeof useWorkspaceDraftSubmissionStore }, + }; + new Function("require", "module", "exports", result.code)( + createRequire(import.meta.url), + module, + module.exports, + ); + return module.exports.useWorkspaceDraftSubmissionStore; +} + +test("the production app consumes a draft once and leaves other drafts pending", () => { + const store = loadProductionStore(); + const submission = { + serverId: "server", + workspaceId: "workspace", + draftId: "draft-1", + text: "hello", + attachments: [], + cwd: "/repo", + provider: "codex", + clientMessageId: "message", + timestamp: 1, + } as const; + store.getState().setPending({ ...submission, attachments: [] }); + store.getState().setPending({ ...submission, attachments: [], draftId: "draft-2" }); + expect(store.getState().consumePending({ ...submission, workspaceId: "wrong" })).toBeNull(); + expect(store.getState().consumePending(submission)).toEqual(submission); + expect(store.getState().consumePending(submission)).toBeNull(); + expect(Object.keys(store.getState().pendingByDraftId)).toEqual(["draft-2"]); +}); + +test("consuming draft presentation retains its running creation through remount and retry", () => { + const store = loadProductionStore(); + const result = new Promise(() => {}); + const creation = { result, retry: () => result }; + const submission = { + serverId: "server", + workspaceId: "workspace", + draftId: "draft-one", + text: "hello", + attachments: [], + cwd: "/repo", + provider: "codex", + clientMessageId: "message", + timestamp: 1, + agentCreation: creation, + }; + store.getState().setPending(submission); + store.getState().consumePending(submission); + expect(store.getState().pendingByDraftId[submission.draftId]).toBeUndefined(); + expect(store.getState().creationByDraftId[submission.draftId]).toBe(creation); + store.getState().clearDraftSetup({ draftId: submission.draftId }); + expect(store.getState().creationByDraftId[submission.draftId]).toBeUndefined(); +}); diff --git a/packages/app/src/stores/workspace-draft-submission-store.ts b/packages/app/src/stores/workspace-draft-submission-store.ts index 2dbd27f2a5..639701d740 100644 --- a/packages/app/src/stores/workspace-draft-submission-store.ts +++ b/packages/app/src/stores/workspace-draft-submission-store.ts @@ -1,9 +1,16 @@ +import type { CreateAgentRequestOptions } from "@getpaseo/client/internal/daemon-client"; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import { create } from "zustand"; import type { ComposerAttachment } from "@/attachments/types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model"; export interface PendingWorkspaceDraftSubmission { + /** Already-running creation. Mounting the draft only observes its result. */ + agentCreation?: { + result: Promise; + retry: (input: CreateAgentRequestOptions) => Promise; + }; serverId: string; workspaceId: string; draftId: string; @@ -28,6 +35,7 @@ export interface PendingWorkspaceDraftSetup { interface WorkspaceDraftSubmissionState { pendingByDraftId: Record; setupByDraftId: Record; + creationByDraftId: Record>; setPending: (submission: PendingWorkspaceDraftSubmission) => void; setDraftSetup: (input: { draftId: string; @@ -61,8 +69,12 @@ export const useWorkspaceDraftSubmissionStore = create ({ pendingByDraftId: {}, setupByDraftId: {}, + creationByDraftId: {}, setPending: (submission) => set((state) => ({ + creationByDraftId: submission.agentCreation + ? { ...state.creationByDraftId, [submission.draftId]: submission.agentCreation } + : state.creationByDraftId, pendingByDraftId: { ...state.pendingByDraftId, [submission.draftId]: submission, @@ -82,9 +94,10 @@ export const useWorkspaceDraftSubmissionStore = create { - if (!state.setupByDraftId[normalizedDraftId]) return state; + const creationByDraftId = { ...state.creationByDraftId }; + delete creationByDraftId[normalizedDraftId]; const { [normalizedDraftId]: _removed, ...setupByDraftId } = state.setupByDraftId; - return { setupByDraftId }; + return { setupByDraftId, creationByDraftId }; }); }, consumePending: (input) => { @@ -96,8 +109,10 @@ export const useWorkspaceDraftSubmissionStore = create { sourceWorkspaceId: "42", displayName: "project", creationMethod: "open_project", + creationId: expect.any(String), + worktreeSlug: expect.any(String), }); }); diff --git a/packages/app/src/stores/workspace-setup-store.ts b/packages/app/src/stores/workspace-setup-store.ts index 31e3bfc024..d0205ba27a 100644 --- a/packages/app/src/stores/workspace-setup-store.ts +++ b/packages/app/src/stores/workspace-setup-store.ts @@ -1,3 +1,5 @@ +import { createNameId } from "mnemonic-id"; +import { generateDraftId } from "@/stores/draft-keys"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { create } from "zustand"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; @@ -44,7 +46,9 @@ export function shouldSeedWorkspaceSetupTab(snapshot: WorkspaceSetupSnapshot | n } interface WorkspaceSetupStoreState { - pendingWorkspaceSetup: PendingWorkspaceSetup | null; + pendingWorkspaceSetup: + | (PendingWorkspaceSetup & { creationId: string; worktreeSlug: string }) + | null; snapshots: Record; requestedKeys: Set; surfacedFailedSetupKeys: Set; @@ -71,7 +75,13 @@ export const useWorkspaceSetupStore = create()((set, g requestedKeys: new Set(), surfacedFailedSetupKeys: new Set(), beginWorkspaceSetup: (value) => { - set({ pendingWorkspaceSetup: value }); + set({ + pendingWorkspaceSetup: { + ...value, + creationId: generateDraftId(), + worktreeSlug: createNameId(), + }, + }); }, clearWorkspaceSetup: () => { set({ pendingWorkspaceSetup: null }); diff --git a/packages/client/src/creation/index.test.ts b/packages/client/src/creation/index.test.ts new file mode 100644 index 0000000000..40828d4bfb --- /dev/null +++ b/packages/client/src/creation/index.test.ts @@ -0,0 +1,234 @@ +import { expect, test } from "vitest"; +import type { + AgentSnapshotPayload, + CreationSnapshot, + WorkspaceDescriptorPayload, +} from "@getpaseo/protocol/messages"; +import { CreationClient, type CreationResult } from "./index.js"; + +const workspace: WorkspaceDescriptorPayload = { + id: "wks_0123456789abcdef", + projectId: "project", + projectDisplayName: "Project", + projectRootPath: "/project", + workspaceDirectory: "/project/worktree", + projectKind: "git", + workspaceKind: "worktree", + name: "Worktree", + status: "running", + statusEnteredAt: null, + activityAt: null, + archivingAt: null, + scripts: [], +}; +const agent: AgentSnapshotPayload = { + id: "00000000-0000-4000-8000-000000000001", + workspaceId: workspace.id, + provider: "codex", + cwd: "/project/worktree", + model: null, + createdAt: "2026-09-11T00:00:00Z", + updatedAt: "2026-09-11T00:00:00Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: true, + supportsRewindConversation: false, + supportsRewindFiles: false, + supportsRewindBoth: false, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: "Start once", + labels: {}, +}; + +function fixture(modern: boolean) { + const requests: Array<{ kind: string; input: Record }> = []; + const legacy: Array<{ kind: string; input: unknown }> = []; + let resolve!: (result: CreationResult) => void; + const result = new Promise((done) => { + resolve = done; + }); + const client = new CreationClient({ + supports: () => modern, + requestId: () => "generated-key", + request: async (kind, input) => { + requests.push({ kind, input }); + return result; + }, + observe: () => () => {}, + legacyWorkspace: async (input) => { + legacy.push({ kind: "workspace", input }); + return { requestId: "legacy-workspace", workspace, error: null, setupTerminalId: null }; + }, + legacyAgent: async (input) => { + legacy.push({ kind: "agent", input }); + return agent; + }, + sendMessage: async (id, text, options) => { + legacy.push({ kind: "message", input: { id, text, ...options } }); + }, + }); + const input = { + idempotencyKey: "intent-one", + source: { kind: "directory" as const, path: "/project" }, + agent: { + config: { provider: "codex", cwd: "/project" }, + initialPrompt: "Start once", + clientMessageId: "message-one", + }, + }; + return { client, requests, legacy, resolve, input }; +} + +test("duplicate client submissions join one complete intent and cumulative updates never regress", async () => { + const f = fixture(true); + const phases: string[] = []; + const first = f.client.createWorkspace({ + ...f.input, + onEvent: (snapshot) => phases.push(snapshot.phase), + }); + const duplicate = f.client.createWorkspace(f.input); + expect(duplicate).toBe(first); + expect(f.requests).toEqual([{ kind: "workspace", input: { ...f.input, subscribe: true } }]); + await expect( + f.client.createWorkspace({ + ...f.input, + agent: { ...f.input.agent, initialPrompt: "Different intent" }, + }), + ).rejects.toThrow("workspace_request_key_conflict"); + const snapshot: CreationSnapshot = { + kind: "workspace", + idempotencyKey: "intent-one", + revision: 2, + phase: "workspace_ready", + workspaceId: workspace.id, + agentId: agent.id, + workspace, + error: null, + }; + f.client.receive(snapshot); + f.client.receive({ ...snapshot, revision: 1, phase: "accepted" }); + expect(phases).toEqual(["workspace_ready"]); + const late: string[] = []; + f.client.createWorkspace({ ...f.input, onEvent: (update) => late.push(update.phase) }); + expect(late).toEqual(["workspace_ready"]); + f.resolve({ + workspace, + agent, + error: null, + setupSkippedReason: "Untrusted source", + requestId: "response-one", + }); + expect(await first).toMatchObject({ + workspace, + agent, + setupSkippedReason: "Untrusted source", + requestId: "response-one", + }); + expect(await duplicate).toEqual(await first); + expect(f.legacy).toEqual([]); + f.client.close(); +}); + +test("legacy adaptation keeps workspace, agent and initial prompt sequencing inside the client", async () => { + const f = fixture(false); + const phases: string[] = []; + const result = await f.client.createWorkspace({ + ...f.input, + onEvent: (snapshot) => phases.push(snapshot.phase), + }); + expect(result).toMatchObject({ workspace, agent, error: null }); + expect(phases).toEqual(["workspace_ready"]); + expect(f.legacy).toEqual([ + { kind: "workspace", input: { idempotencyKey: "intent-one", source: f.input.source } }, + { + kind: "agent", + input: { + config: { provider: "codex", cwd: workspace.workspaceDirectory }, + workspaceId: workspace.id, + idempotencyKey: "intent-one:agent", + clientMessageId: "message-one", + }, + }, + { + kind: "message", + input: { + id: agent.id, + text: "Start once", + messageId: "message-one", + images: undefined, + attachments: undefined, + }, + }, + ]); + expect(f.requests).toEqual([]); + f.client.close(); +}); + +test("legacy daemons cannot silently replace caller-selected IDs", async () => { + const f = fixture(false); + await expect(f.client.createWorkspace({ ...f.input, workspaceId: workspace.id })).rejects.toThrow( + "caller-selected", + ); + await expect( + f.client.createAgent({ agentId: agent.id, config: f.input.agent.config }), + ).rejects.toThrow("caller-selected"); + expect(f.legacy).toEqual([]); + f.client.close(); +}); + +test("keyed creation with an empty prompt works on legacy daemons without sending an empty message", async () => { + const f = fixture(false); + await f.client.createAgent({ + idempotencyKey: "empty-agent", + config: f.input.agent.config, + initialPrompt: "", + }); + expect(f.legacy).toEqual([ + { kind: "agent", input: { idempotencyKey: "empty-agent", config: f.input.agent.config } }, + ]); + f.client.close(); +}); + +test.each([ + ["/repo/project", "/repo/project/../outside"], + ["C:\\repo\\project", "C:\\repo\\project\\..\\outside"], +])("legacy creation rejects escaping %s before creating a workspace", async (source, cwd) => { + const f = fixture(false); + await expect( + f.client.createWorkspace({ + ...f.input, + source: { kind: "directory", path: source }, + agent: { ...f.input.agent, config: { provider: "codex", cwd } }, + }), + ).rejects.toThrow("inside the workspace source"); + expect(f.legacy).toEqual([]); + f.client.close(); +}); + +test.each([ + { source: { kind: "directory" as const, path: "/project" }, cwd: "/project/src" }, + { source: { kind: "directory" as const, path: "C:\\project" }, cwd: "C:\\project\\src" }, + { source: { kind: "worktree" as const, projectId: "project" }, cwd: "/project/src" }, +])("legacy creation preserves the agent subdirectory for $source", async ({ source, cwd }) => { + const f = fixture(false); + await f.client.createWorkspace({ + ...f.input, + source, + agent: { config: { provider: "codex", cwd } }, + }); + expect(f.legacy[1]).toMatchObject({ + kind: "agent", + input: { workspaceId: workspace.id, config: { cwd: "/project/worktree/src" } }, + }); + f.client.close(); +}); diff --git a/packages/client/src/creation/index.ts b/packages/client/src/creation/index.ts new file mode 100644 index 0000000000..4ae64c34ff --- /dev/null +++ b/packages/client/src/creation/index.ts @@ -0,0 +1,276 @@ +import type { + AgentSnapshotPayload, + CreationSnapshot, + WorkspaceCreateResponse, +} from "@getpaseo/protocol/messages"; +import type { CreateAgentRequestOptions, CreateWorkspaceRequestOptions } from "../daemon-client.js"; + +type Kind = CreationSnapshot["kind"]; +type Observer = (snapshot: CreationSnapshot) => void; +export interface CreationResult { + agent?: AgentSnapshotPayload | null; + workspace?: WorkspaceCreateResponse["payload"]["workspace"]; + creation?: CreationSnapshot; + error: string | null; + errorCode?: string; + setupTerminalId?: string | null; + setupSkippedReason?: string; + requestId?: string; +} +interface Dependencies { + supports: () => boolean; + requestId: () => string; + request: (kind: Kind, input: Record) => Promise; + observe: ( + kind: Kind, + key: string, + next: (snapshot: CreationSnapshot | null) => void, + error: (error: unknown) => void, + ) => () => void; + legacyAgent: (input: CreateAgentRequestOptions) => Promise; + legacyWorkspace: ( + input: CreateWorkspaceRequestOptions, + ) => Promise; + sendMessage: ( + id: string, + text: string, + options: { + messageId: string; + images?: CreateAgentRequestOptions["images"]; + attachments?: CreateAgentRequestOptions["attachments"]; + }, + ) => Promise; +} +interface Operation { + kind: Kind; + key: string; + fingerprint: string; + input: Record; + observers: Set; + snapshot?: CreationSnapshot; + result: Promise; + resolve: (result: CreationResult) => void; + reject: (error: Error) => void; + settled: boolean; + modern: boolean; + recovering: boolean; + stopObserving?: () => void; +} + +/** Client-owned observation and compatibility. UI callbacks never advance creation. */ +export class CreationClient { + private readonly operations = new Map(); + constructor(private readonly deps: Dependencies) {} + + createAgent(input: CreateAgentRequestOptions): Promise { + const { onEvent, ...request } = input; + return this.start("agent", request, onEvent, () => this.legacyAgent(request)); + } + createWorkspace(input: CreateWorkspaceRequestOptions): Promise { + const { onEvent, ...request } = input; + return this.start("workspace", request, onEvent, async (operation) => { + if (request.workspaceId || request.agent?.agentId) + throw new Error("Update the host to use caller-selected creation IDs."); + const { agent, ...workspaceInput } = request; + const sourceCwd = + request.source.kind === "directory" ? request.source.path : request.source.cwd; + const relativeCwd = + agent && sourceCwd ? relativeDirectory(agent.config!.cwd, sourceCwd) : undefined; + const workspace = await this.deps.legacyWorkspace(workspaceInput); + if (workspace.error || !workspace.workspace) return workspace; + this.receive({ + kind: "workspace", + idempotencyKey: operation.key, + revision: 1, + phase: "workspace_ready", + workspaceId: workspace.workspace.id, + agentId: null, + workspace: workspace.workspace, + setupSkippedReason: workspace.setupSkippedReason, + error: null, + }); + if (!agent) return workspace; + const cwd = `${workspace.workspace.workspaceDirectory!.replace(/[\\/]+$/, "")}${ + relativeCwd ?? relativeDirectory(agent.config!.cwd, workspace.workspace.projectRootPath) + }`; + const created = await this.legacyAgent({ + ...agent, + config: { ...agent.config!, cwd }, + workspaceId: workspace.workspace.id, + idempotencyKey: `${operation.key}:agent`, + }); + return { ...workspace, agent: created.agent }; + }); + } + + private async legacyAgent(input: CreateAgentRequestOptions): Promise { + if (input.agentId) throw new Error("Update the host to use caller-selected creation IDs."); + // COMPAT(creationLifecycle): added in v0.8.0, remove after 2027-03-11 once the daemon floor supports creationLifecycle. + // Old keyed creates reject initialPrompt. Keep this adaptation private to the SDK. + if (input.idempotencyKey) { + const { initialPrompt = "", images, attachments, ...creation } = input; + const hasPrompt = Boolean(initialPrompt.trim() || images?.length || attachments?.length); + if (hasPrompt && input.outputSchema) + throw new Error("Update the host to create a keyed agent with structured initial output."); + const agent = await this.deps.legacyAgent(creation); + if (hasPrompt) + await this.deps.sendMessage(agent.id, initialPrompt, { + messageId: input.clientMessageId ?? `${input.idempotencyKey}:initial-message`, + images, + attachments, + }); + return { agent, error: null }; + } + return { agent: await this.deps.legacyAgent(input), error: null }; + } + + private start( + kind: Kind, + input: Record, + observer: Observer | undefined, + legacy: (operation: Operation) => Promise, + ): Promise { + const key = + typeof input.idempotencyKey === "string" ? input.idempotencyKey : this.deps.requestId(); + const identity = JSON.stringify([kind, key]); + const { requestId: _requestId, ...intent } = input; + const fingerprint = JSON.stringify(intent, (_key, value: unknown) => + value && typeof value === "object" && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) + : value, + ); + const previous = this.operations.get(identity); + if (previous) { + if (previous.fingerprint !== fingerprint) + return Promise.reject(new Error(`${kind}_request_key_conflict`)); + if (observer) { + previous.observers.add(observer); + if (previous.snapshot) this.notify(observer, previous.snapshot); + } + return previous.result; + } + let resolve!: Operation["resolve"]; + let reject!: Operation["reject"]; + const result = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + const operation: Operation = { + kind, + key, + input: { ...input, idempotencyKey: key }, + fingerprint, + observers: new Set(observer ? [observer] : []), + result, + resolve, + reject, + settled: false, + modern: this.deps.supports(), + recovering: false, + }; + this.operations.set(identity, operation); + if (operation.modern) void this.submit(operation); + else + void legacy(operation).then( + (value) => this.finish(operation, value), + (error) => this.fail(operation, error), + ); + return result; + } + + private async submit(operation: Operation): Promise { + try { + const result = await this.deps.request(operation.kind, { + ...operation.input, + subscribe: true, + }); + if (result.creation) this.receive(result.creation); + this.finish(operation, result); + } catch (error) { + if ( + operation.settled || + (error instanceof Error && "code" in error && error.code === "DAEMON_CONNECTION_LOST") + ) + return; + this.fail(operation, error); + } + } + receive(snapshot: CreationSnapshot): void { + const operation = this.operations.get(JSON.stringify([snapshot.kind, snapshot.idempotencyKey])); + if (!operation || (operation.snapshot && operation.snapshot.revision > snapshot.revision)) + return; + if (operation.snapshot?.revision !== snapshot.revision) { + operation.snapshot = snapshot; + for (const observer of operation.observers) this.notify(observer, snapshot); + } + if (operation.recovering && (snapshot.phase === "completed" || snapshot.phase === "failed")) { + this.finish(operation, { + agent: snapshot.agent, + workspace: snapshot.workspace, + creation: snapshot, + setupSkippedReason: snapshot.setupSkippedReason, + error: snapshot.error, + errorCode: snapshot.errorCode, + }); + } + } + reconnect(): void { + for (const operation of this.operations.values()) { + if (operation.settled || !operation.modern || operation.recovering) continue; + operation.recovering = true; + operation.stopObserving = this.deps.observe( + operation.kind, + operation.key, + (snapshot) => { + if (!snapshot) void this.submit(operation); + else { + // Re-subscription is authoritative even if its revision matches the last live event. + this.receive(snapshot); + } + }, + (error) => this.fail(operation, error), + ); + } + } + close(): void { + for (const operation of this.operations.values()) + if (!operation.settled) this.fail(operation, new Error("Daemon client closed")); + this.operations.clear(); + } + private finish(operation: Operation, result: CreationResult): void { + if (operation.settled) return; + operation.settled = true; + operation.stopObserving?.(); + operation.resolve(result); + operation.observers.clear(); + this.operations.delete(JSON.stringify([operation.kind, operation.key])); + } + private fail(operation: Operation, error: unknown): void { + if (operation.settled) return; + operation.settled = true; + operation.stopObserving?.(); + operation.reject(error instanceof Error ? error : new Error(String(error))); + operation.observers.clear(); + this.operations.delete(JSON.stringify([operation.kind, operation.key])); + } + private notify(observer: Observer, snapshot: CreationSnapshot): void { + try { + observer(snapshot); + } catch { + /* Observation does not control execution. */ + } + } +} + +function relativeDirectory(cwd: string, source: string): string { + const normalize = (value: string) => value.replace(/\\/g, "/").replace(/\/+$/, ""); + const normalized = normalize(cwd), + root = normalize(source); + // Parent traversal must never survive mapping into the newly created checkout. + if ([normalized, root].some((path) => path.split("/").includes(".."))) + throw new Error("Agent directory must be inside the workspace source"); + if (normalized === root) return ""; + if (!normalized.startsWith(`${root}/`)) + throw new Error("Agent directory must be inside the workspace source"); + return normalized.slice(root.length); +} diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 866f5bba22..4d36bb686d 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -6323,3 +6323,169 @@ test("wire snapshot callers own expansion and receive hash references unchanged" ); expect(await request).toEqual(body); }); + +test("creation lifecycle sends the keyed agent and initial prompt as one intent", async () => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "creation-contract", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connected = client.connect(); + transport.triggerOpen({ features: { creationLifecycle: true, agentRequestReceipts: true } }); + await connected; + const creation = client.createAgent({ + idempotencyKey: "draft-one", + provider: "codex", + cwd: "/project", + workspaceId: "wks_0123456789abcdef", + initialPrompt: "Start once", + clientMessageId: "first-message", + }); + void creation.catch(() => undefined); + const request = parseSentFrame(transport.sent[0]); + expect(request).toMatchObject({ + type: "agent.create.request", + idempotencyKey: "draft-one", + initialPrompt: "Start once", + clientMessageId: "first-message", + }); + transport.triggerMessage( + wrapSessionMessage({ + type: "agent.create.response", + payload: { requestId: request.requestId, agent: null, error: "provider unavailable" }, + }), + ); + await expect(creation).rejects.toThrow("provider unavailable"); + expect(transport.sent).toHaveLength(1); +}); + +test("creation lifecycle acknowledgement reaches the observer before the final response", async () => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "creation-progress", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connected = client.connect(); + transport.triggerOpen({ features: { creationLifecycle: true } }); + await connected; + const phases: string[] = []; + const creation = client.createAgent({ + provider: "codex", + cwd: "/project", + idempotencyKey: "observe-one", + onEvent: (snapshot) => phases.push(snapshot.phase), + }); + void creation.catch(() => undefined); + const request = parseSentFrame(transport.sent[0]); + transport.triggerMessage( + wrapSessionMessage({ + type: "agent.create.update", + payload: { + kind: "agent", + idempotencyKey: "observe-one", + revision: 0, + phase: "accepted", + workspaceId: null, + agentId: "00000000-0000-4000-8000-000000000001", + error: null, + }, + }), + ); + expect(phases).toEqual(["accepted"]); + transport.triggerMessage( + wrapSessionMessage({ + type: "agent.create.response", + payload: { requestId: request.requestId, agent: null, error: "provider unavailable" }, + }), + ); + await expect(creation).rejects.toThrow("provider unavailable"); +}); + +test("creation reconnect observation uses connection-owned subscriptions and releases on completion", async () => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "creation-reconnect", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const features = { creationLifecycle: true, ownedSubscriptions: true }; + const connect = client.connect(); + transport.triggerOpen({ features }); + await connect; + const phases: string[] = []; + const creation = client.createWorkspace({ + idempotencyKey: "reconnect-one", + source: { kind: "directory", path: "/project" }, + onEvent: (snapshot) => phases.push(snapshot.phase), + }); + void creation.catch(() => undefined); + const snapshot = { + kind: "workspace", + idempotencyKey: "reconnect-one", + phase: "accepted", + revision: 0, + workspaceId: "wks_0123456789abcdef", + agentId: null, + error: null, + }; + for (const subscriptionId of ["first-connection", "second-connection"]) { + transport.triggerClose(); + const reconnected = client.connect(); + transport.triggerOpen({ features }); + await reconnected; + await expect.poll(() => transport.sent.length).toBe(1); + const request = parseSentFrame(transport.sent[0]); + expect(request).toMatchObject({ + type: "creation.subscribe.request", + idempotencyKey: "reconnect-one", + }); + transport.triggerMessage( + wrapSessionMessage({ + type: "creation.subscribe.response", + payload: { + requestId: request.requestId, + subscriptionId, + snapshot, + error: null, + }, + }), + ); + } + transport.triggerMessage( + wrapSessionMessage({ + type: "workspace.create.update", + payload: { + ...snapshot, + subscriptionId: "second-connection", + revision: 1, + phase: "failed", + error: "Provisioning failed", + }, + }), + ); + expect(await creation).toMatchObject({ error: "Provisioning failed" }); + await expect.poll(() => transport.sent.length).toBe(2); + const release = parseSentFrame(transport.sent.at(-1)); + expect(release).toMatchObject({ + type: "subscription.release.request", + subscriptionId: "second-connection", + }); + transport.triggerMessage( + wrapSessionMessage({ + type: "subscription.release.response", + payload: { + requestId: release.requestId, + subscriptionId: "second-connection", + }, + }), + ); + expect(phases).toEqual(["accepted", "failed"]); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 5c2e15d906..458ccf5c92 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -6,6 +6,8 @@ import { DEFAULT_CLIENT_CAPABILITIES, type TimelineSubscription, } from "./connection/index.js"; +import { CreationClient } from "./creation/index.js"; +import type { CreationSnapshot } from "@getpaseo/protocol/messages"; import type { z } from "zod"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; import type { ClientCapability } from "@getpaseo/protocol/client-capabilities"; @@ -370,6 +372,8 @@ export interface AgentAttentionRequiredNotification { type AgentConfigOverrides = Partial>; export interface CreateAgentRequestOptions extends AgentConfigOverrides { + agentId?: string; + onEvent?: (snapshot: CreationSnapshot) => void; config?: AgentSessionConfig; provider?: AgentProvider; cwd?: string; @@ -392,6 +396,20 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { labels?: Record; } +export interface CreateWorkspaceRequestOptions { + source: WorkspaceCreateRequest["source"]; + title?: string; + idempotencyKey?: string; + workspaceId?: string; + agent?: Omit< + CreateAgentRequestOptions, + "workspaceId" | "onEvent" | "worktree" | "git" | "worktreeName" | "idempotencyKey" | "requestId" + >; + onEvent?: (snapshot: CreationSnapshot) => void; + firstAgentContext?: WorkspaceCreateRequest["firstAgentContext"]; + requestId?: string; +} + export interface CreatePaseoWorktreeInput extends Pick< CreatePaseoWorktreeRequest, | "cwd" @@ -1416,6 +1434,7 @@ export class DaemonClient { this.providerSnapshotUpdates.clear(); this.clearWaiters(new Error("Daemon client closed")); await this.owned.close(); + this.creations.close(); this.rejectPendingSendQueue(new Error("Daemon client closed")); this.rejectPingProbe(new Error("Daemon client closed")); this.terminalStreams.clearSlots(); @@ -2656,7 +2675,58 @@ export class DaemonClient { // Agent Lifecycle // ============================================================================ + private readonly creations = new CreationClient({ + supports: () => this.lastServerInfoMessage?.features?.creationLifecycle === true, + requestId: () => this.createRequestId(), + request: (kind, input) => + kind === "workspace" + ? this.sendCorrelatedSessionRequest({ + requestId: input.requestId as string | undefined, + message: { ...input, type: "workspace.create.request" }, + responseType: "workspace.create.response", + timeout: 0, + }) + : this.sendCorrelatedSessionRequest({ + requestId: input.requestId as string | undefined, + message: { ...input, type: "agent.create.request" }, + responseType: "agent.create.response", + timeout: 0, + }), + observe: (kind, idempotencyKey, next, error) => { + const observation = this.observe("creation.subscribe.response", { + type: "creation.subscribe.request", + kind, + idempotencyKey, + }); + observation.subscribe({ + snapshot: (result) => next(result.snapshot), + update: (message) => { + if (message.type === "workspace.create.update" || message.type === "agent.create.update") + next(message.payload); + }, + error, + }); + return () => { + void observation.release().catch(error); + }; + }, + legacyAgent: (input) => this.createLegacyAgent(input), + legacyWorkspace: (input) => this.createLegacyWorkspace(input, input.requestId), + sendMessage: (id, text, options) => this.sendMessage(id, text, options), + }); + async createAgent(options: CreateAgentRequestOptions): Promise { + const result = await this.creations.createAgent({ + ...options, + config: resolveAgentConfig(options), + }); + if (result.error || !result.agent) throw new Error(result.error ?? "Agent creation failed"); + return result.agent; + } + + private async createLegacyAgent( + options: CreateAgentRequestOptions, + ): Promise { if (options.idempotencyKey !== undefined) this.requireAgentRequestReceipts(); const requestId = this.createRequestId(options.requestId); const config = resolveAgentConfig(options); @@ -4334,18 +4404,43 @@ export class DaemonClient { } async createWorkspace( - input: { - source: WorkspaceCreateRequest["source"]; - title?: string; - firstAgentContext?: WorkspaceCreateRequest["firstAgentContext"]; - }, + input: CreateWorkspaceRequestOptions, requestId?: string, ): Promise { + const resolvedRequestId = this.createRequestId(requestId ?? input.requestId); + const result = await this.creations.createWorkspace({ + ...input, + requestId: resolvedRequestId, + ...(input.agent + ? { agent: { ...input.agent, config: resolveAgentConfig(input.agent) } } + : {}), + }); + return { + ...result, + workspace: result.workspace ?? null, + agent: result.agent ?? undefined, + setupTerminalId: result.setupTerminalId ?? null, + requestId: result.requestId ?? resolvedRequestId, + }; + } + + private async createLegacyWorkspace( + input: CreateWorkspaceRequestOptions, + requestId?: string, + ): Promise { + // COMPAT(workspaceRequestReceipts): added in v0.8.0; remove gate after 2027-03-07. + if ( + input.idempotencyKey !== undefined && + !this.lastServerInfoMessage?.features?.workspaceRequestReceipts + ) { + throw new Error("Update the host to use retry-safe workspace creation."); + } return this.sendCorrelatedSessionRequest({ requestId, message: { type: "workspace.create.request", source: input.source, + ...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}), ...(input.title !== undefined ? { title: input.title } : {}), ...(input.firstAgentContext !== undefined ? { firstAgentContext: input.firstAgentContext } @@ -6276,6 +6371,7 @@ export class DaemonClient { ...DEFAULT_CLIENT_CAPABILITIES, ...this.config.capabilities, }); + this.creations.reconnect(); this.flushPendingSendQueue(); this.resolveConnect(); } @@ -6310,6 +6406,12 @@ export class DaemonClient { } } + if ( + consumerMessage.type === "workspace.create.update" || + consumerMessage.type === "agent.create.update" + ) { + if (!consumerMessage.payload.subscriptionId) this.creations.receive(consumerMessage.payload); + } this.resolveWaiters(consumerMessage); this.owned.receive(consumerMessage); } @@ -6493,6 +6595,15 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon config, provider, cwd, + agentId: _agentId, + onEvent: _onEvent, + idempotencyKey: _idempotencyKey, + clientMessageId: _clientMessageId, + callerAgentId: _callerAgentId, + outputSchema: _outputSchema, + attachments: _attachments, + worktree: _worktree, + autoArchive: _autoArchive, env: _env, workspaceId: _workspaceId, initialPrompt: _initialPrompt, diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 8485b063af..e113d48667 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -4,6 +4,7 @@ import type { DaemonClientConfig } from "./daemon-client.js"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import type { AgentSnapshotPayload, + CreationSnapshot, CreateAgentRequestMessage, FetchWorkspacesRequestMessage, FetchWorkspacesResponseMessage, @@ -28,7 +29,7 @@ import type { WorkspaceDescriptorPayload, WorkspaceCreateRequest, } from "@getpaseo/protocol/messages"; -import { DaemonClient } from "./daemon-client.js"; +import { DaemonClient, type CreateAgentRequestOptions } from "./daemon-client.js"; import { createTerminalActions, type PaseoTerminalActions, @@ -142,8 +143,16 @@ export interface PaseoWorkspaceOpenOptions { requestId?: string; } -export type PaseoWorkspaceCreateOptions = Omit & { +export type PaseoWorkspaceCreateOptions = Omit< + WorkspaceCreateRequest, + "type" | "requestId" | "agent" | "subscribe" +> & { requestId?: string; + agent?: Omit< + PaseoAgentCreateOptions, + "worktree" | "git" | "onEvent" | "idempotencyKey" | "requestId" + >; + onEvent?: (snapshot: CreationSnapshot) => void; }; export interface PaseoWorkspaceArchiveResult { @@ -232,6 +241,9 @@ export interface PaseoAgentConfig { } export interface PaseoAgentCreateOptions { + idempotencyKey?: string; + agentId?: string; + onEvent?: (snapshot: CreationSnapshot) => void; config: PaseoAgentConfig; cwd: string; parent?: string | PaseoAgentHandle; @@ -513,6 +525,29 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient { }; } +function toDaemonAgentCreateOptions( + options: PaseoAgentCreateOptions, + placement?: { workspaceId: string; cwd: string }, +): CreateAgentRequestOptions { + const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options; + const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig; + const { provider, model } = parseProviderModel(providerModel); + return { + ...requestOptions, + config: { + ...runtimeConfig, + provider, + model, + cwd: placement?.cwd ?? cwd, + ...(title !== undefined ? { title } : {}), + ...(providerOptions !== undefined ? { providerOptions } : {}), + }, + ...(placement ? { workspaceId: placement.workspaceId } : {}), + ...(parent ? { callerAgentId: resolveAgentId(parent) } : {}), + ...(prompt !== undefined ? { initialPrompt: prompt } : {}), + }; +} + export function createPaseoApi( daemonClient: DaemonClient, scopeOptions?: { signal?: AbortSignal }, @@ -555,24 +590,7 @@ export function createPaseoApi( options: PaseoAgentCreateOptions, placement?: { workspaceId: string; cwd: string }, ) => { - const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options; - const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig; - const { provider, model } = parseProviderModel(providerModel); - const effectiveCwd = placement?.cwd ?? cwd; - const agent = await daemonClient.createAgent({ - ...requestOptions, - config: { - ...runtimeConfig, - provider, - model, - cwd: effectiveCwd, - ...(title !== undefined ? { title } : {}), - ...(providerOptions !== undefined ? { providerOptions } : {}), - }, - ...(placement ? { workspaceId: placement.workspaceId } : {}), - ...(parent ? { callerAgentId: resolveAgentId(parent) } : {}), - ...(prompt !== undefined ? { initialPrompt: prompt } : {}), - }); + const agent = await daemonClient.createAgent(toDaemonAgentCreateOptions(options, placement)); return createAgentHandle(agent); }; const terminals = createTerminalActions(daemonClient, async (workspaceId) => { @@ -694,8 +712,11 @@ export function createPaseoApi( ref: (workspace) => createWorkspaceHandle(workspace), open: (input, requestId) => openWorkspace(daemonClient, createWorkspaceHandle, input, requestId), - create: async ({ requestId, ...options }) => { - const result = await daemonClient.createWorkspace(options, requestId); + create: async ({ requestId, agent, ...options }) => { + const result = await daemonClient.createWorkspace( + { ...options, ...(agent ? { agent: toDaemonAgentCreateOptions(agent) } : {}) }, + requestId, + ); if (result.error || !result.workspace) { throw new Error(result.error ?? "The daemon did not create a workspace"); } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 0b6c1b1882..a44311d76b 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1681,7 +1681,7 @@ export type CreateAgentWorktreeTarget = z.infer; +export type AgentCreateRequest = z.infer; +export type WorkspaceInitialAgent = z.infer; + +export const WorkspaceCreationUpdateSchema = z.object({ + type: z.literal("workspace.create.update"), + payload: CreationSnapshotSchema.extend({ subscriptionId: z.string().optional() }), +}); +export const AgentCreationUpdateSchema = WorkspaceCreationUpdateSchema.extend({ + type: z.literal("agent.create.update"), +}); +export const CreationSubscribeResponseSchema = z.object({ + type: z.literal("creation.subscribe.response"), + payload: z.object({ + requestId: z.string(), + subscriptionId: z.string().optional(), + snapshot: CreationSnapshotSchema.nullable(), + error: z.string().nullable(), + }), +}); +export const AgentCreateResponseSchema = z.object({ + type: z.literal("agent.create.response"), + payload: z.object({ + requestId: z.string(), + agent: AgentSnapshotPayloadSchema.nullable(), + error: z.string().nullable(), + creation: CreationSnapshotSchema.optional(), + }), +}); + export const WorkspaceCreateResponseSchema = z.object({ type: z.literal("workspace.create.response"), payload: z.object({ + agent: AgentSnapshotPayloadSchema.optional(), + creation: CreationSnapshotSchema.optional(), workspace: WorkspaceDescriptorPayloadSchema.nullable(), setupTerminalId: z.string().nullable(), setupSkippedReason: z.string().optional(), @@ -6649,6 +6742,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CancelAgentResponseMessageSchema, ClearAgentAttentionResponseMessageSchema, WorkspaceCreateResponseSchema, + AgentCreateResponseSchema, + WorkspaceCreationUpdateSchema, + AgentCreationUpdateSchema, + CreationSubscribeResponseSchema, WorkspaceClearAttentionResponseSchema, WorkspaceMarkUnreadResponseSchema, SendAgentMessageResponseMessageSchema, diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index bd4dcb16d8..814a8f3aec 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -55,6 +55,7 @@ export type EnsureWorkspaceForCreate = ( export interface CreateAgentFromSessionInput { kind: "session"; + onAgentReady?: (agent: ManagedAgent) => Promise; agentId?: string; config: AgentSessionConfig; workspaceId: string; @@ -190,6 +191,8 @@ export async function createAgentCommand( agentId: snapshot.id, }); + if (input.kind === "session") await input.onAgentReady?.(snapshot); + let liveSnapshot = snapshot; let initialPromptStarted = false; let initialPromptError: unknown | null = null; diff --git a/packages/server/src/server/agent/requests/index.test.ts b/packages/server/src/server/agent/requests/index.test.ts deleted file mode 100644 index de3b010148..0000000000 --- a/packages/server/src/server/agent/requests/index.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterEach, expect, test } from "vitest"; -import { AgentRequests } from "./index.js"; - -const directories: string[] = []; -afterEach(async () => { - 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 deleted file mode 100644 index ad64331c35..0000000000 --- a/packages/server/src/server/agent/requests/index.ts +++ /dev/null @@ -1,138 +0,0 @@ -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/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 6a6a1e78ec..89d0d3f030 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -62,6 +62,8 @@ const INBOUND_PERMISSION = { client_heartbeat: "workspace.read", close_items_request: "workspace.manage", create_agent_request: ["workspace.write", "hub.execute"], + "agent.create.request": ["workspace.write", "hub.execute"], + "creation.subscribe.request": "workspace.read", create_paseo_worktree_request: "workspace.manage", create_terminal_request: "workspace.write", "daemon.config.reload.request": "daemon.manage", @@ -209,6 +211,10 @@ const INBOUND_PERMISSION = { } as const satisfies Record; const OUTBOUND_PERMISSION = { + "agent.create.response": ["workspace.write", "hub.execute"], + "agent.create.update": ["workspace.write", "hub.execute"], + "workspace.create.update": "workspace.manage", + "creation.subscribe.response": "workspace.read", activity_log: "workspace.read", "agent.config.apply.response": ["workspace.write", "hub.execute"], "agent.detach.response": "workspace.write", diff --git a/packages/server/src/server/creation/creation.e2e.test.ts b/packages/server/src/server/creation/creation.e2e.test.ts new file mode 100644 index 0000000000..f0d21be18f --- /dev/null +++ b/packages/server/src/server/creation/creation.e2e.test.ts @@ -0,0 +1,286 @@ +import { execFileSync } from "node:child_process"; +import type { z } from "zod"; +import { WebSocket } from "ws"; +import { SessionInboundMessageSchema, WSOutboundMessageSchema } from "@getpaseo/protocol/messages"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test, vi } from "vitest"; +import pino from "pino"; +import type { CreationSnapshot, SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +test("creation progresses before agent readiness and continues after the disconnected Session is cleaned up", async () => { + const provider = deferred(); + const ready = deferred(); + const disconnected = deferred(); + const cleaned = deferred(); + const directory = await mkdtemp(join(tmpdir(), "creation-wire-")); + let agents = 0; + let prompts = 0; + const daemon = await createTestPaseoDaemon({ + logger: pino( + { level: "trace" }, + { + write(line) { + const { msg } = JSON.parse(line); + if (msg === "Client disconnected; waiting for reconnect") disconnected.resolve(); + if (msg === "agent.session.lifecycle.cleanup") cleaned.resolve(); + }, + }, + ), + agentClients: createTestAgentClients({ + beforeCreateSession: async () => { + agents++; + await provider.promise; + }, + onStartTurn: () => { + prompts++; + }, + }), + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.7.2", + clientId: "creation-subscriber", + }); + const observer = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.7.2", + clientId: "creation-independent-observer", + }); + const observerMessages: SessionOutboundMessage[] = []; + const unsubscribe = observer.subscribeRawMessages((message) => observerMessages.push(message)); + const workspaceId = "wks_0123456789abcdef"; + const agentId = randomUUID(); + const request = { + idempotencyKey: "workspace-one", + workspaceId, + source: { kind: "directory" as const, path: directory }, + agent: { + agentId, + provider: "codex", + cwd: directory, + initialPrompt: "Create once", + clientMessageId: "initial-one", + }, + }; + try { + await client.connect(); + await observer.connect(); + const first = client.createWorkspace({ + ...request, + onEvent: (snapshot) => { + if (snapshot.phase === "workspace_ready") ready.resolve(snapshot); + }, + }); + void first.catch(() => undefined); + const snapshot = await ready.promise; + expect(snapshot).toMatchObject({ workspaceId, agentId, phase: "workspace_ready" }); + expect((await observer.fetchAgents()).entries).toHaveLength(0); + expect( + observerMessages.filter((message) => message.type === "workspace.create.update"), + ).toHaveLength(0); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + await client.close(); + await disconnected.promise; + await vi.advanceTimersByTimeAsync(90_000); + await cleaned.promise; + vi.useRealTimers(); + await expect(first).rejects.toThrow("closed"); + // A disconnected form cannot prevent the provider or initial prompt from starting. + provider.resolve(); + await expect.poll(() => prompts).toBe(1); + const replay = await observer.createWorkspace(request); + expect(replay.error).toBeNull(); + expect(replay.workspace?.id).toBe(workspaceId); + expect(replay.agent?.id).toBe(agentId); + expect(replay.agent?.title).toBe("Create once"); + expect(agents).toBe(1); + expect(prompts).toBe(1); + const conflict = await observer.createWorkspace({ + ...request, + idempotencyKey: "another-intent", + }); + expect(conflict.error).toBe("workspace_id_conflict"); + expect(agents).toBe(1); + } finally { + vi.useRealTimers(); + provider.resolve(); + unsubscribe(); + await client.close(); + await observer.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60000); + +async function connectCreationPeer(port: number) { + const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`); + const frames: SessionOutboundMessage[] = []; + socket.on("message", (data) => { + const frame = WSOutboundMessageSchema.parse(JSON.parse(data.toString())); + if (frame.type === "session") frames.push(frame.message); + }); + await new Promise((resolve, reject) => { + socket.once("open", resolve); + socket.once("error", reject); + }); + socket.send( + JSON.stringify({ + type: "hello", + clientType: "browser", + clientId: randomUUID(), + protocolVersion: 1, + appVersion: "0.8.0", + }), + ); + await expect + .poll(() => frames.some((m) => m.type === "status" && m.payload.status === "server_info")) + .toBe(true); + return { + close: () => socket.close(), + request: async ( + message: z.input & { requestId: string }, + ) => { + socket.send(JSON.stringify({ type: "session", message })); + const response = () => + frames.find( + (m) => + "payload" in m && "requestId" in m.payload && m.payload.requestId === message.requestId, + ); + await expect.poll(response).toBeDefined(); + return response()!; + }, + }; +} + +test.each([false, true])( + "workspace identity does not depend on subscribing (first subscribe=%s)", + async (subscribe) => { + const directory = await mkdtemp(join(tmpdir(), "creation-identity-")); + const daemon = await createTestPaseoDaemon(); + const peer = await connectCreationPeer(daemon.port); + try { + const request = { + type: "workspace.create.request" as const, + source: { kind: "directory" as const, path: directory }, + idempotencyKey: "same-workspace", + }; + const first = await peer.request({ ...request, requestId: "first", subscribe }); + const replay = await peer.request({ ...request, requestId: "replay", subscribe: !subscribe }); + if (first.type !== "workspace.create.response" || replay.type !== "workspace.create.response") + throw new Error("Expected workspace responses"); + expect(first.payload.error).toBeNull(); + expect(replay.payload.error).toBeNull(); + expect(replay.payload.workspace?.id).toBe(first.payload.workspace?.id); + } finally { + peer.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } + }, + 60000, +); + +test.each(["create_agent_request", "agent.create.request"] as const)( + "legacy and modern agent RPCs share one creation identity (first %s)", + async (type) => { + const directory = await mkdtemp(join(tmpdir(), "creation-agent-identity-")); + let creations = 0; + const daemon = await createTestPaseoDaemon({ + agentClients: createTestAgentClients({ + beforeCreateSession: async () => { + creations++; + }, + }), + }); + const peer = await connectCreationPeer(daemon.port); + try { + const request = { + config: { provider: "codex", cwd: directory }, + idempotencyKey: "same-agent", + }; + const first = await peer.request({ ...request, type, requestId: "first" }); + const replay = await peer.request({ + ...request, + type: type === "agent.create.request" ? "create_agent_request" : "agent.create.request", + requestId: "replay", + }); + const createdAgent = (message: SessionOutboundMessage) => { + if (message.type === "agent.create.response") { + expect(message.payload.error).toBeNull(); + return message.payload.agent; + } + if (message.type === "status" && message.payload.status === "agent_created") + return message.payload.agent; + throw new Error(`Unexpected creation result ${JSON.stringify(message)}`); + }; + expect(createdAgent(replay)?.id).toBe(createdAgent(first)?.id); + const agentId = createdAgent(first)!.id; + await daemon.daemon.agentManager.setTitle(agentId, "Updated after creation"); + await daemon.daemon.agentManager.flush(); + const legacyReplay = await peer.request({ + ...request, + type: "create_agent_request", + requestId: "legacy-after-update", + }); + expect(createdAgent(legacyReplay)?.title).toBe("Updated after creation"); + expect(creations).toBe(1); + } finally { + peer.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } + }, + 60000, +); + +test("legacy keyed creation preserves checkout error codes", async () => { + const directory = await mkdtemp(join(tmpdir(), "creation-checkout-error-")); + execFileSync("git", ["init", "-b", "main", directory], { stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "-m", + "initial", + ], + { cwd: directory, stdio: "pipe" }, + ); + const daemon = await createTestPaseoDaemon({ agentClients: createTestAgentClients() }); + const peer = await connectCreationPeer(daemon.port); + try { + const result = await peer.request({ + type: "create_agent_request", + requestId: "missing-branch", + idempotencyKey: "missing-branch", + config: { provider: "codex", cwd: directory }, + worktree: { mode: "checkout-branch", branch: "does-not-exist" }, + }); + expect(result).toMatchObject({ + type: "status", + payload: { status: "agent_create_failed", errorCode: "unknown_branch" }, + }); + } finally { + peer.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60000); diff --git a/packages/server/src/server/creation/index.test.ts b/packages/server/src/server/creation/index.test.ts new file mode 100644 index 0000000000..2f2c024078 --- /dev/null +++ b/packages/server/src/server/creation/index.test.ts @@ -0,0 +1,324 @@ +import pino from "pino"; +import { createHash } from "node:crypto"; +import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import type { + AgentSnapshotPayload, + CreationSnapshot, + WorkspaceDescriptorPayload, +} from "@getpaseo/protocol/messages"; +import { CreationService, type CreationInput } from "./index.js"; + +const silentLogger = pino({ level: "silent" }); +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +const workspace: WorkspaceDescriptorPayload = { + id: "wks_0123456789abcdef", + projectId: "project", + projectDisplayName: "Project", + projectRootPath: "/project", + workspaceDirectory: "/project/worktree", + projectKind: "git", + workspaceKind: "worktree", + name: "Worktree", + status: "running", + statusEnteredAt: null, + activityAt: null, + archivingAt: null, + scripts: [], +}; +const agent: AgentSnapshotPayload = { + id: "00000000-0000-4000-8000-000000000001", + workspaceId: workspace.id, + provider: "codex", + cwd: "/project/worktree", + model: null, + createdAt: "2026-09-11T00:00:00Z", + updatedAt: "2026-09-11T00:00:00Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: true, + supportsRewindConversation: false, + supportsRewindFiles: false, + supportsRewindBoth: false, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: "Start once", + labels: {}, +}; +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), "creation-lifecycle-")); + directories.push(directory); + const service = new CreationService(directory, silentLogger); + const provider = deferred(); + const ready = deferred(); + const calls: string[] = []; + const input: CreationInput = { + kind: "workspace", + key: "submit-one", + request: { source: "/project", prompt: "Start once" }, + workspaceId: workspace.id, + agentId: agent.id, + hasAgent: true, + hasPrompt: true, + exists: async () => false, + provision: async () => { + calls.push("workspace"); + return { workspace }; + }, + createAgent: async (_id, _workspace, onReady) => { + await provider.promise; + calls.push("agent"); + await onReady(agent); + calls.push("prompt"); + return agent; + }, + }; + const updates: CreationSnapshot[] = []; + const observe = (snapshot: CreationSnapshot) => { + updates.push(snapshot); + if (snapshot.phase === "workspace_ready") ready.resolve(); + }; + return { directory, service, input, provider, ready, calls, updates, observe }; +} + +test("workspace readiness is observable before provider startup and duplicate requests join the same work", async () => { + const f = await fixture(); + const first = f.service.create(f.input, f.observe); + await f.ready.promise; + expect(f.calls).toEqual(["workspace"]); + expect(f.updates.map((s) => s.phase)).toEqual(["accepted", "workspace_ready"]); + expect(f.updates[0]).toMatchObject({ workspaceId: workspace.id, agentId: agent.id }); + const duplicate = f.service.create(f.input); + f.provider.resolve(); + expect(await first).toMatchObject({ phase: "completed", workspace, agent }); + expect(await duplicate).toEqual(await first); + expect(f.calls).toEqual(["workspace", "agent", "prompt"]); + expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(await first); + expect(f.calls).toEqual(["workspace", "agent", "prompt"]); +}); + +test("a failed agent startup retries only that stage with the reserved IDs", async () => { + const f = await fixture(); + let attempts = 0; + f.input.createAgent = async (id, readyWorkspace, onReady) => { + attempts++; + expect(id).toBe(agent.id); + expect(readyWorkspace).toEqual(workspace); + if (attempts === 1) throw new Error("provider unavailable"); + await onReady(agent); + return agent; + }; + expect(await f.service.create(f.input)).toMatchObject({ + phase: "failed", + failedStage: "agent", + outcomeUnknown: false, + workspace, + }); + expect(await new CreationService(f.directory, silentLogger).create(f.input)).toMatchObject({ + phase: "completed", + agent, + }); + expect(f.calls).toEqual(["workspace"]); + expect(attempts).toBe(2); +}); + +test("an uncertain prompt outcome never resends or removes its workspace and agent", async () => { + const f = await fixture(); + let prompts = 0; + f.input.createAgent = async (_id, _workspace, onReady) => { + await onReady(agent); + prompts++; + throw new Error("connection lost after provider accepted prompt"); + }; + const result = await f.service.create(f.input); + expect(result).toMatchObject({ + phase: "failed", + failedStage: "prompt", + outcomeUnknown: true, + workspace, + agent, + }); + expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(result); + expect(prompts).toBe(1); +}); + +test("conflicting intents and resource IDs cannot start more work", async () => { + const f = await fixture(); + const first = f.service.create(f.input, f.observe); + await f.ready.promise; + await expect(f.service.create({ ...f.input, request: { prompt: "changed" } })).rejects.toThrow( + "workspace_request_key_conflict", + ); + await expect(f.service.create({ ...f.input, key: "another-intent" })).rejects.toThrow( + "workspace_id_conflict", + ); + f.provider.resolve(); + await first; + expect(f.calls).toEqual(["workspace", "agent", "prompt"]); +}); + +test("late subscribers recover readiness and removing an observer cannot stop creation", async () => { + const f = await fixture(); + const first = f.service.create(f.input, f.observe); + await f.ready.promise; + const updates: CreationSnapshot[] = []; + const subscription = await f.service.subscribe("workspace", f.input.key, (snapshot) => + updates.push(snapshot), + ); + expect(subscription.snapshot).toMatchObject({ phase: "workspace_ready", workspace }); + subscription.unsubscribe(); + f.provider.resolve(); + await first; + expect(updates).toEqual([]); + const completed = await new CreationService(f.directory, silentLogger).subscribe( + "workspace", + f.input.key, + () => {}, + ); + expect(completed.snapshot).toMatchObject({ phase: "completed", agent, workspace }); + completed.unsubscribe(); +}); + +test("omitted resource IDs are generated once and preserved on replay", async () => { + const f = await fixture(); + f.input.workspaceId = undefined; + f.input.agentId = undefined; + f.input.provision = async (id) => ({ workspace: { ...workspace, id } }); + f.input.createAgent = async (id, createdWorkspace, onReady) => { + const created = { ...agent, id, workspaceId: createdWorkspace!.id }; + await onReady(created); + return created; + }; + const result = await f.service.create(f.input); + expect(result.workspaceId).toMatch(/^wks_[a-f0-9]{16}$/); + expect(result.agentId).toMatch( + /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/, + ); + expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(result); +}); + +test("restart after a committed agent without a prompt completes without recreating the agent", async () => { + const f = await fixture(); + const recoveredDirectory = await mkdtemp(join(tmpdir(), "creation-checkpoint-")); + directories.push(recoveredDirectory); + f.input.hasPrompt = false; + let starts = 0; + f.input.createAgent = async (_id, _workspace, onReady) => { + starts++; + await onReady(agent); + // Capture the durable state at the exact milestone a process could stop after. + await cp(f.directory, recoveredDirectory, { recursive: true }); + return agent; + }; + await f.service.create(f.input); + const recovered = await new CreationService(recoveredDirectory, silentLogger).create(f.input); + expect(recovered).toMatchObject({ phase: "completed", workspace, agent }); + expect(starts).toBe(1); +}); + +test("partial provisioning without a registered workspace is not repeated", async () => { + const f = await fixture(); + let provisions = 0; + f.input.provision = async () => { + provisions++; + await writeFile(join(f.directory, "partial-checkout"), "disk was changed"); + throw new Error("Git failed after creating the checkout"); + }; + const result = await f.service.create(f.input); + expect(result).toMatchObject({ phase: "failed", failedStage: "workspace", outcomeUnknown: true }); + expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(result); + expect(provisions).toBe(1); +}); + +test.each(["pending", "completed"])( + "imports a %s legacy agent receipt without creating another agent", + async (state) => { + const f = await fixture(); + const legacyDirectory = join(f.directory, "legacy"); + await mkdir(legacyDirectory); + const request = { config: { cwd: "/project", provider: "codex" } }; + const hash = (value: unknown) => + createHash("sha256").update(JSON.stringify(value)).digest("hex"); + await writeFile( + join(legacyDirectory, `${hash(["create", "old-key"])}.json`), + JSON.stringify({ + fingerprint: hash({ ...request, type: "create_agent_request" }), + state, + agentId: agent.id, + }), + ); + const input: CreationInput = { + kind: "agent", + key: "old-key", + request, + hasAgent: true, + hasPrompt: false, + exists: async () => true, + readAgent: async () => agent, + createAgent: async () => { + throw new Error("Must not create again"); + }, + }; + const service = new CreationService(f.directory, silentLogger, undefined, legacyDirectory); + await expect( + service.create({ ...input, request: { config: { ...request.config, cwd: "/changed" } } }), + ).rejects.toThrow("agent_request_key_conflict"); + expect(await service.create(input)).toMatchObject({ phase: "completed", agent }); + expect(await new CreationService(f.directory, silentLogger).create(input)).toMatchObject({ + phase: "completed", + agent, + }); + }, +); + +test("observer failures are logged without failing creation", async () => { + const f = await fixture(); + const logs: unknown[] = []; + const logger = pino( + { level: "warn" }, + { + write: (line) => { + logs.push(JSON.parse(line)); + }, + }, + ); + const service = new CreationService(f.directory, logger); + f.provider.resolve(); + const result = await service.create(f.input, () => { + throw new Error("delivery failed"); + }); + expect(result.phase).toBe("completed"); + expect(f.calls).toEqual(["workspace", "agent", "prompt"]); + expect(logs).toContainEqual( + expect.objectContaining({ + level: 40, + kind: "workspace", + idempotencyKey: f.input.key, + phase: "workspace_ready", + err: expect.objectContaining({ message: "delivery failed" }), + }), + ); +}); diff --git a/packages/server/src/server/creation/index.ts b/packages/server/src/server/creation/index.ts new file mode 100644 index 0000000000..cf8b2adc2a --- /dev/null +++ b/packages/server/src/server/creation/index.ts @@ -0,0 +1,351 @@ +import type { Logger } from "pino"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "zod"; +import { + CreationSnapshotSchema, + type AgentSnapshotPayload, + type CreationSnapshot, + type WorkspaceDescriptorPayload, +} from "@getpaseo/protocol/messages"; +import { writeJsonFileAtomic } from "../atomic-file.js"; +import { generateWorkspaceId } from "../workspace-registry-model.js"; + +type Observer = (snapshot: CreationSnapshot) => void; +interface CreationRequest { + key: string; + request: { [key: string]: unknown }; + workspaceId?: string; + agentId?: string; + hasAgent: boolean; + hasPrompt: boolean; + exists: (kind: "workspace" | "agent", id: string) => Promise; + provision?: ( + workspaceId: string, + ) => Promise<{ workspace: WorkspaceDescriptorPayload; setupSkippedReason?: string }>; + createAgent?: ( + agentId: string, + workspace: WorkspaceDescriptorPayload | undefined, + onReady: (agent: AgentSnapshotPayload) => Promise, + ) => Promise; +} +export type CreationInput = CreationRequest & + ( + | { kind: "workspace" } + | { kind: "agent"; readAgent: (id: string) => Promise } + ); +const RecordSchema = z.object({ + fingerprint: z.string(), + snapshot: CreationSnapshotSchema, + inFlight: z.enum(["workspace", "agent", "prompt"]).nullable(), +}); +type Record = z.infer; + +/** Owns creation across socket lifetimes. Resource owners perform work; this module commits its milestones. */ +export class CreationService { + private admission: Promise = Promise.resolve(); + private readonly active = new Map>(); + private readonly observers = new Map>(); + constructor( + private readonly directory: string, + private readonly logger: Logger, + private readonly validateCompleted: ( + snapshot: CreationSnapshot, + ) => Promise = async () => {}, + private readonly legacyDirectory?: string, + ) {} + + async create(input: CreationInput, observer?: Observer): Promise { + const identity = identityFor(input.kind, input.key); + const admitted = this.admission.then(async () => { + await mkdir(this.directory, { recursive: true }); + const fingerprint = digest(input.request); + let record = await this.read(identity); + if (!record) record = await this.readLegacyAgent(input, fingerprint); + if (record && record.fingerprint !== fingerprint) + throw new Error(`${input.kind}_request_key_conflict`); + const running = this.active.get(identity); + if (running && record) return { completion: running, snapshot: record.snapshot }; + if (record?.snapshot.phase === "completed") await this.validateCompleted(record.snapshot); + if (record?.snapshot.phase === "completed" || record?.snapshot.outcomeUnknown) { + return { completion: Promise.resolve(record.snapshot), snapshot: record.snapshot }; + } + if (record?.inFlight) { + await this.publish(identity, record, { + phase: "failed", + error: `${input.kind}_request_outcome_unknown`, + failedStage: record.inFlight, + outcomeUnknown: true, + }); + return { completion: Promise.resolve(record.snapshot), snapshot: record.snapshot }; + } + if (!record) { + record = initialRecord(input, fingerprint); + // Persist identity before claiming resources. A failed admission can only retry these IDs. + await this.write(identity, record); + } + await this.claimResources(identity, record.snapshot, input); + if (record.snapshot.phase === "failed") { + await this.publish(identity, record, { + phase: record.snapshot.workspace ? "workspace_ready" : "accepted", + error: null, + errorCode: undefined, + failedStage: undefined, + outcomeUnknown: undefined, + }); + } + const snapshot = record.snapshot; + // Defer the runner until admission has installed the initiating observer. + const completion = Promise.resolve().then(() => this.run(identity, record, input)); + this.active.set(identity, completion); + void completion.finally(() => this.active.delete(identity)).catch(() => undefined); + return { completion, snapshot }; + }); + this.admission = admitted.then( + () => undefined, + () => undefined, + ); + // Install before admission starts, so even a fast local provision cannot outrun observation. + const unsubscribe = observer ? this.observe(identity, observer) : () => {}; + try { + const { completion, snapshot } = await admitted; + if (observer) this.notify(observer, snapshot); + return await completion; + } finally { + unsubscribe(); + } + } + + async subscribe( + kind: CreationSnapshot["kind"], + key: string, + observer: Observer, + ): Promise<{ snapshot: CreationSnapshot | null; unsubscribe: () => void }> { + const identity = identityFor(kind, key); + const unsubscribe = this.observe(identity, observer); + try { + await this.admission; + const record = await this.read(identity); + if (record?.inFlight && !this.active.has(identity)) { + await this.publish(identity, record, { + phase: "failed", + failedStage: record.inFlight, + outcomeUnknown: true, + error: `${kind}_request_outcome_unknown`, + }); + } + if (record?.snapshot.phase === "completed") await this.validateCompleted(record.snapshot); + return { snapshot: record?.snapshot ?? null, unsubscribe }; + } catch (error) { + unsubscribe(); + throw error; + } + } + + private async run( + identity: string, + record: Record, + input: CreationInput, + ): Promise { + try { + if (input.provision && !record.snapshot.workspace) { + record.inFlight = "workspace"; + await this.write(identity, record); + const { workspace, setupSkippedReason } = await input.provision( + record.snapshot.workspaceId!, + ); + record.inFlight = null; + await this.publish(identity, record, { + phase: "workspace_ready", + workspace, + workspaceId: workspace.id, + setupSkippedReason, + }); + } + if (input.hasAgent && input.createAgent && !record.snapshot.agent) { + record.inFlight = "agent"; + await this.write(identity, record); + const agent = await input.createAgent( + record.snapshot.agentId!, + record.snapshot.workspace, + async (readyAgent) => { + record.inFlight = input.hasPrompt ? "prompt" : null; + await this.publish(identity, record, { + phase: "agent_ready", + agent: readyAgent, + workspaceId: readyAgent.workspaceId ?? record.snapshot.workspaceId, + }); + }, + ); + record.inFlight = null; + if (input.hasPrompt) + await this.publish(identity, record, { phase: "prompt_started", agent }); + else record.snapshot = { ...record.snapshot, agent }; + } + record.inFlight = null; + await this.publish(identity, record, { phase: "completed" }); + } catch (error) { + const stage = record.inFlight ?? "agent"; + const resourceId = + stage === "workspace" ? record.snapshot.workspaceId : record.snapshot.agentId; + const code = + error instanceof Error && "code" in error && typeof error.code === "string" + ? error.code + : undefined; + const rejectedBeforeProvision = code === "directory_not_found" || code === "source_required"; + const unknown = + stage === "prompt" || + (stage === "workspace" && !rejectedBeforeProvision) || + (resourceId !== null && (await input.exists(stage, resourceId))); + // A failed provider startup with no registered resource can retry under the same ID. + // A partially provisioned resource or an attempted prompt needs reconciliation. + if (!unknown) record.inFlight = null; + await this.publish(identity, record, { + phase: "failed", + error: error instanceof Error ? error.message : String(error), + errorCode: code, + failedStage: stage, + outcomeUnknown: unknown, + }); + } + return record.snapshot; + } + + // COMPAT(agentRequestReceipts): added in v0.8.0, remove after 2027-03-11 once old creation receipts can expire. + // Import the previous receipt format; it is never a second execution path. + private async readLegacyAgent(input: CreationInput, fingerprint: string): Promise { + if (input.kind !== "agent" || !this.legacyDirectory) return null; + const text = await readOptional( + join(this.legacyDirectory, `${digest(["create", input.key])}.json`), + ); + if (text === null) return null; + const receipt = z + .object({ + fingerprint: z.string(), + state: z.enum(["pending", "completed"]), + agentId: z.string(), + }) + .parse(JSON.parse(text)); + if (receipt.fingerprint !== digest({ type: "create_agent_request", ...input.request })) + throw new Error("agent_request_key_conflict"); + const agent = await input.readAgent(receipt.agentId); + if (!agent && receipt.state === "completed") + throw new Error("Previously created agent no longer exists"); + const record = initialRecord({ ...input, agentId: receipt.agentId }, fingerprint); + record.snapshot = { + ...record.snapshot, + phase: agent ? "completed" : "failed", + agent: agent ?? undefined, + workspaceId: agent?.workspaceId ?? record.snapshot.workspaceId, + error: agent ? null : "agent_request_outcome_unknown", + ...(agent ? {} : { failedStage: "agent", outcomeUnknown: true }), + }; + await this.write(identityFor(input.kind, input.key), record); + return record; + } + + private async claimResources( + identity: string, + snapshot: CreationSnapshot, + input: CreationInput, + ): Promise { + if (input.kind === "workspace" && snapshot.workspaceId) + await this.claim(identity, "workspace", snapshot.workspaceId, input.exists); + if (snapshot.agentId) await this.claim(identity, "agent", snapshot.agentId, input.exists); + } + + private async claim( + identity: string, + kind: "workspace" | "agent", + id: string, + exists: CreationInput["exists"], + ): Promise { + const file = join(this.directory, `${digest([kind, id])}.claim`); + const owner = await readOptional(file); + if (owner !== null) { + if (owner !== identity) throw new Error(`${kind}_id_conflict`); + return; + } + if (await exists(kind, id)) throw new Error(`${kind}_id_conflict`); + await writeFile(file, identity, { flag: "wx", mode: 0o600 }); + } + private observe(identity: string, observer: Observer): () => void { + const observers = this.observers.get(identity) ?? new Set(); + observers.add(observer); + this.observers.set(identity, observers); + return () => { + observers.delete(observer); + if (!observers.size) this.observers.delete(identity); + }; + } + private notify(observer: Observer, snapshot: CreationSnapshot): void { + try { + observer(snapshot); + } catch (err) { + this.logger.warn( + { + err, + kind: snapshot.kind, + idempotencyKey: snapshot.idempotencyKey, + phase: snapshot.phase, + }, + "Creation observer failed", + ); + } + } + private async publish( + identity: string, + record: Record, + update: Partial, + ): Promise { + record.snapshot = { ...record.snapshot, ...update, revision: record.snapshot.revision + 1 }; + await this.write(identity, record); + for (const observer of this.observers.get(identity) ?? []) + this.notify(observer, record.snapshot); + } + private write(identity: string, record: Record): Promise { + return writeJsonFileAtomic(join(this.directory, `${identity}.json`), record); + } + private async read(identity: string): Promise { + const text = await readOptional(join(this.directory, `${identity}.json`)); + return text === null ? null : RecordSchema.parse(JSON.parse(text)); + } +} +function initialRecord(input: CreationInput, fingerprint: string): Record { + return { + fingerprint, + inFlight: null, + snapshot: { + kind: input.kind, + idempotencyKey: input.key, + revision: 0, + phase: "accepted", + error: null, + workspaceId: input.workspaceId ?? (input.kind === "workspace" ? generateWorkspaceId() : null), + agentId: input.hasAgent ? (input.agentId ?? randomUUID()) : null, + }, + }; +} +function identityFor(kind: string, key: string): string { + return digest([kind, key]); +} +function digest(input: unknown): string { + return createHash("sha256") + .update( + JSON.stringify(input, (_key, value: unknown) => { + if (value && typeof value === "object" && !Array.isArray(value)) + return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))); + return value; + }), + ) + .digest("hex"); +} +async function readOptional(file: string): Promise { + try { + return await readFile(file, "utf8"); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; + throw error; + } +} 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 b37e12657d..5133f2f6c9 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 @@ -9,66 +9,75 @@ 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(); +test.each(["existing", "new"])( + "shallow scans recover coalesced deletes of %s files in nested scopes", + async (population) => { + 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 }); + if (population === "existing") { + 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; }, - 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 }); - } -}); + }, + paths, + (_root, listener) => { + notifications.on("change", listener); + return { + close: () => { + notifications.removeAllListeners(); + }, + on: (event, onError) => notifications.on(event, onError), + }; + }, + ); + try { + await backend.start(); + if (population === "new") { + await Promise.all(removed.map((path) => writeFile(path, "before"))); + for (const path of removed) notifications.emit("change", "change", path); + } + 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 }); + } + }, +); test("a file announced only as changed remains visible to coalesced deletion scans", async () => { const root = await mkdtemp(join(tmpdir(), "native-change-only-")); diff --git a/packages/server/src/server/message-receipts/index.test.ts b/packages/server/src/server/message-receipts/index.test.ts new file mode 100644 index 0000000000..10c1615a6a --- /dev/null +++ b/packages/server/src/server/message-receipts/index.test.ts @@ -0,0 +1,77 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { MessageReceipts } from "./index.js"; + +const directories: string[] = []; +afterEach(async () => { + 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 MessageReceipts(directory) }; +} + +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 MessageReceipts(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 MessageReceipts(directory).send(input)).rejects.toThrow( + "agent_request_outcome_unknown", + ); + expect(deliveries).toBe(1); +}); + +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 MessageReceipts(directory).send(input); + available = false; + await requests.send(input); + expect(sends).toBe(1); +}); diff --git a/packages/server/src/server/message-receipts/index.ts b/packages/server/src/server/message-receipts/index.ts new file mode 100644 index 0000000000..6103160b12 --- /dev/null +++ b/packages/server/src/server/message-receipts/index.ts @@ -0,0 +1,81 @@ +import { createHash } from "node:crypto"; +import { readFile } 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(), + state: z.enum(["pending", "completed"]), + agentId: z.string(), +}); +interface SendMessageInput { + agentId: string; + messageId: string; + request: unknown; + send: () => Promise; + prepare?: () => Promise; +} + +/** Owns message delivery receipts; creation is owned by CreationService. */ +export class MessageReceipts { + private readonly pending = new Map>(); + constructor(private readonly directory: string) {} + + send(input: SendMessageInput): Promise { + // Preserve the existing on-disk identity and shape across daemon upgrades. + const key = digest(["send", input.agentId, input.messageId]); + const previous = this.pending.get(key); + const result = (previous ? previous.catch(() => undefined) : Promise.resolve()).then(() => + this.sendOnce(key, input), + ); + this.pending.set(key, result); + void result + .finally(() => { + if (this.pending.get(key) === result) this.pending.delete(key); + }) + .catch(() => undefined); + return result; + } + + private async sendOnce(key: string, input: SendMessageInput): Promise { + const file = path.join(this.directory, `${key}.json`); + const fingerprint = digest(input.request); + const existing = await readReceipt(file); + if (existing) { + if (existing.fingerprint !== fingerprint) throw new Error("agent_request_key_conflict"); + if (existing.state === "completed") return; + // A provider may have accepted the message before its receipt was committed. + throw new Error("agent_request_outcome_unknown"); + } + await input.prepare?.(); + const receipt = { fingerprint, agentId: input.agentId }; + await writeJsonFileAtomic(file, { ...receipt, state: "pending" }); + await input.send(); + await writeJsonFileAtomic(file, { ...receipt, state: "completed" }); + } +} + +async function readReceipt(file: string): Promise | null> { + 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/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index dea17e1bb9..dc93e30298 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -31,6 +31,7 @@ import type { FirstAgentContext } from "@getpaseo/protocol/messages"; import { runWithGitCommandPriority } from "../utils/run-git-command.js"; export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput { + workspaceId?: string; projectId?: string; title?: string; } @@ -93,6 +94,7 @@ async function createPaseoWorktreeWithPriority( const workspace = await deps.workspaceProvisioning.createWorkspaceForWorktree({ sourceCwd: workspaceCwdPlan.inputCwd, projectId: input.projectId, + workspaceId: input.workspaceId, repoRoot: createdWorktree.repoRoot, cwd: workspaceCwd, worktreeRoot: createdWorktree.worktree.worktreePath, diff --git a/packages/server/src/server/plugins/lifecycle.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.e2e.test.ts index 48af2cd80c..a6a27ba5bc 100644 --- a/packages/server/src/server/plugins/lifecycle.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle.e2e.test.ts @@ -5,7 +5,7 @@ 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 () => { +test("a plugin transforms workspace creation once across receipt replays 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" }); @@ -19,6 +19,7 @@ test("a plugin transforms workspace creation and observes its committed lifecycl ` export default function contribute(server) { server.before("workspace.create", ({ request }) => { + console.log(JSON.stringify({ hook: "workspace.before" })); return { ...request, title: "Created through a hook" }; }); server.on("workspace.created", (event) => { @@ -36,10 +37,17 @@ export default function contribute(server) { await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin(directory); const created = await client.createWorkspace({ + idempotencyKey: "plugin-workspace", source: { kind: "directory", path: directory }, }); expect(created.workspace?.name).toBe("Created through a hook"); const workspaceId = created.workspace!.id; + const replay = await client.createWorkspace({ + idempotencyKey: "plugin-workspace", + source: { kind: "directory", path: directory }, + }); + expect(replay.error).toBeNull(); + expect(replay.workspace?.id).toBe(workspaceId); await client.archiveWorkspace(workspaceId); await expect .poll(async () => { @@ -53,6 +61,7 @@ export default function contribute(server) { }); }) .toMatchObject([ + { hook: "workspace.before" }, { hook: "workspace.created", event: { workspace: { id: workspaceId, cwd: directory } } }, { hook: "workspace.archived", event: { workspace: { id: workspaceId } } }, ]); diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index a32f5265c9..7a6384f9a6 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import { execSync } from "child_process"; import { existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs"; import { tmpdir } from "os"; @@ -364,7 +367,8 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { const messages = options.messages ?? []; const sessionOptions: SessionOptions = { - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), 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 21aca8bf90..a5daf44b52 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,7 +1,11 @@ import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; -import type { AgentRequests } from "./agent/requests/index.js"; +import { relative } from "node:path"; +import { isAbsolute } from "node:path"; +import { CreationService } from "./creation/index.js"; +import type { CreationSnapshot, AgentCreateRequest } from "@getpaseo/protocol/messages"; +import type { MessageReceipts } from "./message-receipts/index.js"; import equal from "fast-deep-equal"; import { SessionDelivery, type OwnedSubscription } from "./session/owned-subscriptions/index.js"; import { v4 as uuidv4 } from "uuid"; @@ -195,7 +199,6 @@ import { } from "./session/git-mutation/git-mutation-service.js"; import { createWorkspaceProvisioningService, - WorkspaceProvisioningError, type WorkspaceProvisioningService, } from "./session/workspace-provisioning/workspace-provisioning-service.js"; import { @@ -452,7 +455,8 @@ export interface SessionOptions { worktreesRoot?: string; agentManager: AgentManager; agentStorage: AgentStorage; - agentRequests: Pick; + messageReceipts: Pick; + creationService: Pick; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; directorySync?: DirectorySyncService; @@ -621,6 +625,16 @@ function resolveWorkspaceLabelService( return service ?? null; } +function hasInitialCreationPrompt( + request?: Pick, +): boolean { + return Boolean( + request && + buildAgentPrompt(request.initialPrompt?.trim() ?? "", request.images, request.attachments) + .length, + ); +} + function workspaceLabelErrorCode(error: unknown): string { if ( error instanceof WorkspaceLabelError || @@ -765,8 +779,9 @@ export class Session { private readonly daemonSession: DaemonSession; private readonly hubExecutionController: HubExecutionController | null; private readonly workspaceScripts: WorkspaceScriptsService; - private readonly agentRequests: Pick; + private readonly messageReceipts: Pick; private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch; + private readonly creationService: Pick; constructor(options: SessionOptions) { const { @@ -840,7 +855,8 @@ export class Session { this.onWorkspaceRecovered = onWorkspaceRecovered ?? null; this.pushNotifications = pushNotifications; this.paseoHome = paseoHome; - this.agentRequests = options.agentRequests; + this.messageReceipts = options.messageReceipts; + this.creationService = options.creationService; this.projectIcons = new ProjectIconReader(paseoHome); this.worktreesRoot = worktreesRoot; this.pluginRuntime = pluginRuntime; @@ -2255,6 +2271,7 @@ export class Session { this.dispatchAgentRelationshipMessage(msg) ?? this.dispatchAgentTimelineMessage(msg, source) ?? this.dispatchHubExecutionMessage(msg) ?? + this.dispatchCreationMessage(msg, source) ?? this.dispatchAgentLifecycleMessage(msg) ?? this.dispatchAgentConfigMessage(msg) ?? this.dispatchCheckoutMessage(msg) ?? @@ -2631,6 +2648,22 @@ export class Session { return undefined; } + private dispatchCreationMessage( + msg: SessionInboundMessage, + source?: object, + ): Promise | undefined { + switch (msg.type) { + case "agent.create.request": + return this.handleAgentCreation(msg, source); + case "creation.subscribe.request": + return this.handleCreationSubscription(msg, source); + case "workspace.create.request": + return this.handleWorkspaceCreation(msg, source); + default: + return undefined; + } + } + private dispatchAgentLifecycleMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "fetch_agents_request": @@ -2828,8 +2861,6 @@ export class Session { return this.handleArchiveWorkspaceRequest(msg); case "project.remove.request": return this.handleProjectRemoveRequest(msg); - case "workspace.create.request": - return this.handleWorkspaceCreateRequest(msg); case "workspace.title.set.request": return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId); case "workspace.pin.set.request": @@ -3814,6 +3845,265 @@ export class Session { /** * Handle create agent request */ + private creationUpdate(snapshot: CreationSnapshot): SessionOutboundMessage { + return { + type: snapshot.kind === "workspace" ? "workspace.create.update" : "agent.create.update", + payload: snapshot, + }; + } + + private async handleCreationSubscription( + request: Extract, + source?: object, + ): Promise { + let unsubscribe = () => {}; + const owner = + request.subscribe === false + ? undefined + : this.delivery.begin( + "creation", + undefined, + () => unsubscribe(), + JSON.stringify([request.kind, request.idempotencyKey]), + ); + try { + const observation = await this.creationService.subscribe( + request.kind, + request.idempotencyKey, + (snapshot) => owner?.emit(this.creationUpdate(snapshot)), + ); + unsubscribe = observation.unsubscribe; + if (!owner || owner.signal.aborted) unsubscribe(); + this.emitForSource( + { + type: "creation.subscribe.response", + payload: { + requestId: request.requestId, + subscriptionId: owner?.responseId, + snapshot: observation.snapshot, + error: null, + }, + }, + source, + ); + } catch (error) { + await owner?.release(); + this.emitForSource( + { + type: "creation.subscribe.response", + payload: { + requestId: request.requestId, + snapshot: null, + error: error instanceof Error ? error.message : String(error), + }, + }, + source, + ); + } + } + + private creationResourceExists = async ( + kind: "workspace" | "agent", + id: string, + ): Promise => + kind === "workspace" + ? (await this.workspaceRegistry.get(id)) !== null + : this.agentManager.getAgent(id) != null || (await this.agentStorage.get(id)) !== null; + + private createRequestedAgent( + request: AgentCreateRequest, + observer?: (snapshot: CreationSnapshot) => void, + ): Promise { + const { requestId, type: _type, subscribe: _subscribe, idempotencyKey, ...intent } = request; + return this.creationService.create( + { + kind: "agent", + key: idempotencyKey ?? requestId, + request: intent, + workspaceId: request.workspaceId, + agentId: request.agentId, + hasAgent: true, + hasPrompt: hasInitialCreationPrompt(request), + exists: this.creationResourceExists, + readAgent: async (id) => { + const record = await this.agentStorage.get(id); + return record ? this.buildStoredAgentPayload(record) : null; + }, + createAgent: async (id, _workspace, onReady) => { + try { + return await this.createSessionAgent( + { ...request, type: "create_agent_request" }, + id, + onReady, + ); + } catch (error) { + throw new WorktreeRequestError(toWorktreeWireError(error)); + } + }, + }, + observer, + ); + } + + private async handleAgentCreation(request: AgentCreateRequest, source?: object): Promise { + const { requestId, subscribe } = request; + const progress = subscribe + ? this.delivery.operation( + (message) => + message.type === "workspace.create.update" || message.type === "agent.create.update", + () => {}, + ) + : undefined; + try { + const creation = await this.createRequestedAgent( + request, + progress ? (snapshot) => progress.emit(this.creationUpdate(snapshot)) : undefined, + ); + this.emitForSource( + { + type: "agent.create.response", + payload: { requestId, agent: creation.agent ?? null, error: creation.error, creation }, + }, + source, + ); + } catch (error) { + this.emitForSource( + { + type: "agent.create.response", + payload: { + requestId, + agent: null, + error: error instanceof Error ? error.message : String(error), + }, + }, + source, + ); + } finally { + await progress?.release(); + } + } + + private async handleWorkspaceCreation( + request: Extract, + source?: object, + ): Promise { + const { requestId, subscribe, type: _type, idempotencyKey, ...intent } = request; + const progress = subscribe + ? this.delivery.operation( + (message) => + message.type === "workspace.create.update" || message.type === "agent.create.update", + () => {}, + ) + : undefined; + try { + if ( + request.agent && + !this.authorization.allowsPermission("workspace.write") && + !this.authorization.allowsPermission("hub.execute") + ) { + throw new Error("Session is not authorized to create an agent"); + } + const agentInput = request.agent; + const creation = await this.creationService.create( + { + kind: "workspace", + key: idempotencyKey ?? requestId, + request: intent, + workspaceId: request.workspaceId, + agentId: agentInput?.agentId, + hasAgent: Boolean(agentInput), + hasPrompt: hasInitialCreationPrompt(agentInput), + exists: this.creationResourceExists, + provision: async (id) => { + const descriptor = await this.createRequestedWorkspace( + { + ...request, + firstAgentContext: agentInput + ? { prompt: agentInput.initialPrompt, attachments: agentInput.attachments } + : request.firstAgentContext, + }, + id, + ); + const workspace = await this.workspaceRegistry.get(descriptor.id); + return { + workspace: descriptor, + setupSkippedReason: workspace?.untrustedSource + ? formatWorkspaceAutomationBlockedMessage(workspace.untrustedSource) + : undefined, + }; + }, + createAgent: agentInput + ? async (id, workspace, onReady) => { + if (!workspace?.workspaceDirectory) + throw new Error("Created workspace has no directory"); + const sourceCwd = + request.source.kind === "directory" + ? request.source.path + : await resolveWorktreeSourceCwd(request.source, this.projectRegistry); + const relativeCwd = relative(resolve(sourceCwd), resolve(agentInput.config.cwd)); + if ( + relativeCwd === ".." || + relativeCwd.startsWith(`..${sep}`) || + isAbsolute(relativeCwd) + ) + throw new Error("Agent directory must be inside the workspace source"); + return this.createSessionAgent( + { + ...agentInput, + type: "create_agent_request", + requestId, + config: { + ...agentInput.config, + cwd: resolve(workspace.workspaceDirectory, relativeCwd), + }, + workspaceId: workspace.id, + }, + id, + onReady, + ); + } + : undefined, + }, + progress ? (snapshot) => progress.emit(this.creationUpdate(snapshot)) : undefined, + ); + this.emitForSource( + { + type: "workspace.create.response", + payload: { + requestId, + workspace: creation.workspace ?? null, + agent: creation.agent, + creation, + setupSkippedReason: creation.setupSkippedReason, + error: creation.error, + errorCode: creation.errorCode, + setupTerminalId: null, + }, + }, + source, + ); + } catch (error) { + this.emitForSource( + { + type: "workspace.create.response", + payload: { + requestId, + workspace: null, + error: error instanceof Error ? error.message : String(error), + errorCode: + error instanceof Error && "code" in error && typeof error.code === "string" + ? error.code + : undefined, + setupTerminalId: null, + }, + }, + source, + ); + } finally { + await progress?.release(); + } + } + private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise { try { let agent: AgentSnapshotPayload; @@ -3821,18 +4111,13 @@ export class Session { 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); + const creation = await this.createRequestedAgent({ ...msg, type: "agent.create.request" }); + if (creation.error || !creation.agent) + throw new SessionRequestError( + creation.errorCode ?? "unknown", + creation.error ?? "Agent creation failed", + ); + const record = await this.agentStorage.get(creation.agent.id); if (!record) throw new Error("Previously created agent no longer exists"); agent = this.buildStoredAgentPayload(record); } else { @@ -3848,7 +4133,7 @@ export class Session { }, }); } catch (error) { - const wireError = toWorktreeWireError(error); + const wireError = error instanceof SessionRequestError ? error : toWorktreeWireError(error); this.sessionLogger.error({ err: error }, "Failed to create agent"); this.emit({ type: "status", @@ -3874,6 +4159,7 @@ export class Session { private async createSessionAgent( msg: CreateAgentRequestMessage, agentId?: string, + onAgentReady?: (agent: AgentSnapshotPayload) => Promise, ): Promise { const { config, @@ -3943,6 +4229,10 @@ export class Session { }, { kind: "session", + onAgentReady: async (agent) => { + createdAgentId = agent.id; + await onAgentReady?.(await this.buildAgentPayload(agent)); + }, agentId, config: resolvedIntent.config, workspaceId: resolvedIntent.intent.workspaceId, @@ -6338,61 +6628,35 @@ export class Session { } } - private async handleWorkspaceCreateRequest( + private async createRequestedWorkspace( 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; - } - await this.handleWorkspaceCreateWorktree(request); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to create workspace"; - this.sessionLogger.error( - { err: error, sourceKind: request.source.kind, requestId: request.requestId }, - "Failed to create workspace", - ); - const errorCode = error instanceof WorkspaceProvisioningError ? error.code : undefined; - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: null, - setupTerminalId: null, - error: message, - errorCode, - }, - }); + workspaceId?: string, + ): Promise { + let creationRequest = request; + // Hooks belong to the operation: retries fingerprint the caller's input + // and must not rerun hooks or compare their potentially changing output. + if (this.pluginRuntime) { + const { type, requestId, ...input } = request; + const transformed = await this.pluginRuntime.before("workspace.create", input); + creationRequest = { ...transformed, type, requestId }; } + return creationRequest.source.kind === "directory" + ? this.handleWorkspaceCreateLocal(creationRequest, workspaceId) + : this.handleWorkspaceCreateWorktree(creationRequest, workspaceId); } private async handleWorkspaceCreateLocal( request: Extract, - ): Promise { + workspaceId?: string, + ): Promise { if (request.source.kind !== "directory") { - return; + throw new Error("Unexpected workspace source"); } const cwd = expandTilde(request.source.path); const directoryExists = await this.filesystem.isDirectory(cwd).catch(() => false); if (!directoryExists) { - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: null, - setupTerminalId: null, - error: `Directory not found: ${cwd}`, - errorCode: "directory_not_found", - }, - }); - return; + throw new SessionRequestError("directory_not_found", `Directory not found: ${cwd}`); } const explicitTitle = request.title?.trim() || null; @@ -6401,19 +6665,10 @@ export class Session { cwd, explicitTitle ?? promptTitle, request.source.projectId, - { expectsInitialAgent: Boolean(request.firstAgentContext) }, + { expectsInitialAgent: Boolean(request.firstAgentContext), workspaceId }, ); await this.syncWorkspaceGitObserverForWorkspace(workspace); const descriptor = await this.describeWorkspaceRecord(workspace); - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: descriptor, - setupTerminalId: null, - error: null, - }, - }); await this.emitCreatedWorkspaceUpdate( descriptor, request.firstAgentContext ? "running" : undefined, @@ -6437,29 +6692,24 @@ export class Session { { currentSelection: this.getFocusedAgentSelectionForCwd(workspace.cwd) }, ); } + return descriptor; } private async handleWorkspaceCreateWorktree( request: Extract, - ): Promise { + workspaceId?: string, + ): Promise { if (request.source.kind !== "worktree") { - return; + throw new Error("Unexpected workspace source"); } const source = request.source; if (!source.cwd && !source.projectId) { - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: null, - setupTerminalId: null, - error: "cwd or projectId is required for a worktree-backed workspace", - errorCode: "source_required", - }, - }); - return; + throw new SessionRequestError( + "source_required", + "cwd or projectId is required for a worktree-backed workspace", + ); } const sourceCwd = await resolveWorktreeSourceCwd(source, this.projectRegistry); @@ -6467,6 +6717,7 @@ export class Session { const result = await this.createPaseoWorktreeWorkflow( { cwd: sourceCwd, + workspaceId, projectId: source.projectId, worktreeSlug: source.worktreeSlug, action: source.action, @@ -6483,26 +6734,11 @@ export class Session { ); const descriptor = await this.describeCreatedWorktreeWorkspace(result); - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: descriptor, - setupTerminalId: null, - ...(result.workspace.untrustedSource - ? { - setupSkippedReason: formatWorkspaceAutomationBlockedMessage( - result.workspace.untrustedSource, - ), - } - : {}), - error: null, - }, - }); await this.emitCreatedWorkspaceUpdate( descriptor, request.firstAgentContext ? "running" : undefined, ); + return descriptor; } private async handleOpenProjectRequest( @@ -7763,6 +7999,19 @@ export class Session { } } + private async prepareAgentMessage(agentId: string, text: string): Promise { + await ensureAgentLoaded(agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }); + const stored = await this.agentStorage.get(agentId); + if (stored && !stored.title && !stored.lastUserMessageAt) { + const { provisionalTitle } = resolveCreateAgentTitles({ initialPrompt: text }); + if (provisionalTitle) await this.agentManager.setTitle(agentId, provisionalTitle); + } + } + private async handleSendAgentMessageRequest( msg: Extract, ): Promise { @@ -7813,16 +8062,12 @@ export class Session { } }; if (msg.messageId) { - await this.agentRequests.send({ + await this.messageReceipts.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, - }); + await this.prepareAgentMessage(agentId, msg.text); }, send, }); 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 581a142572..836703fd30 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import { describe, expect, test, vi } from "vitest"; import path from "node:path"; import type pino from "pino"; @@ -185,7 +188,8 @@ function createSessionForWorkspaceGitWatchTests(options?: { }; const session = new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), 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 c444d9ed24..e848e46f01 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { + createMessageReceiptsStub, + createTestCreationService, +} 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 @@ -89,7 +92,8 @@ function createHarness(input: { }; const session = new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), 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 e8df2788cf..2866732abe 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import { execFileSync } from "node:child_process"; import { existsSync, @@ -642,7 +645,8 @@ function createSessionForWorkspaceTests( const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, appVersion: options.appVersion ?? null, @@ -1003,7 +1007,8 @@ test("create_agent_request keeps requested child cwd when grouped under an exist const emitted: SessionOutboundMessage[] = []; const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", serverId: "test-server", permissions: OWNER_PERMISSIONS, @@ -1157,7 +1162,8 @@ test("create_agent_request launches from an exact subdirectory in a created work const emitted: SessionOutboundMessage[] = []; const session = new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, appVersion: null, @@ -1295,7 +1301,8 @@ test("create_agent_request does not title an existing workspace from the agent p let generateCalls = 0; const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, appVersion: null, @@ -1565,7 +1572,8 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -2048,7 +2056,8 @@ 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(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -2217,7 +2226,8 @@ test("close_items_request archives stored agents that are not currently loaded", const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -2377,7 +2387,8 @@ test("close_items_request continues after an archive failure", async () => { const killTerminalBestEffort = vi.fn(); const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -3649,7 +3660,8 @@ test("workspace update stream keeps persisted workspace visible after agents sto const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), 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 a89e044883..ecd338ff4e 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 @@ -40,6 +40,7 @@ export interface ImportWorkspaceResult { export interface CreateWorktreeWorkspaceInput { sourceCwd: string; projectId?: string; + workspaceId?: string; repoRoot: string; cwd: string; worktreeRoot: string; @@ -61,7 +62,7 @@ export interface WorkspaceProvisioningService { cwd: string, title?: string | null, projectId?: string, - context?: { expectsInitialAgent?: boolean }, + context?: { expectsInitialAgent?: boolean; workspaceId?: string }, ): Promise; createWorkspaceForWorktree( input: CreateWorktreeWorkspaceInput, @@ -201,7 +202,7 @@ export function createWorkspaceProvisioningService(deps: { cwd: string, title?: string | null, projectId?: string, - context?: { expectsInitialAgent?: boolean }, + context?: { expectsInitialAgent?: boolean; workspaceId?: string }, ): Promise { const normalizedCwd = resolve(cwd); const checkout = await workspaceGitService.getCheckout(normalizedCwd); @@ -211,7 +212,7 @@ export function createWorkspaceProvisioningService(deps: { await findOrCreateProjectForDirectory(normalizedCwd); const timestamp = new Date().toISOString(); const workspace = createPersistedWorkspaceRecord({ - workspaceId: generateWorkspaceId(), + workspaceId: context?.workspaceId ?? generateWorkspaceId(), projectId: project.projectId, ...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }), title: title?.trim() || null, @@ -237,7 +238,7 @@ export function createWorkspaceProvisioningService(deps: { }); const timestamp = new Date().toISOString(); const workspace = createPersistedWorkspaceRecord({ - workspaceId: generateWorkspaceId(), + workspaceId: input.workspaceId ?? generateWorkspaceId(), projectId: project.projectId, ...initialWorkspacePlacement({ source: "created_worktree", diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts index 8e32c27cbe..2cfe5d3e18 100644 --- a/packages/server/src/server/snapshot-mutation-ownership.test.ts +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import os from "node:os"; import path from "node:path"; import { mkdtempSync, rmSync } from "node:fs"; @@ -100,7 +103,8 @@ describe("snapshot mutation ownership boundary", () => { const session = asInternals( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage, diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 323e9944c7..2a437f9bbf 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -62,6 +62,7 @@ interface FakeAgentSessionOptions { } export interface TestAgentClientOptions { + beforeCreateSession?: () => Promise; closeSession?: () => Promise; onStartTurn?: (prompt: AgentPromptInput) => void; supportsMcpServers?: boolean; @@ -1212,6 +1213,7 @@ class FakeAgentClient implements AgentClient { config: AgentSessionConfig, _launchContext?: AgentLaunchContext, ): Promise { + await this.options.beforeCreateSession?.(); return new FakeAgentSession({ providerName: this.provider, config: { ...config }, diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index a6f404e62a..1c98686042 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -1,5 +1,10 @@ +import pino from "pino"; import { randomUUID } from "node:crypto"; -import { vi } from "vitest"; +import { afterEach, vi } from "vitest"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CreationService } from "../creation/index.js"; import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; @@ -281,13 +286,8 @@ export function createProviderSnapshotManagerStub(): { }; } -export function createAgentRequestsStub(): SessionOptions["agentRequests"] { +export function createMessageReceiptsStub(): SessionOptions["messageReceipts"] { return { - async create(input) { - const agentId = randomUUID(); - await input.create(agentId); - return agentId; - }, send: (input) => input.send(), }; } @@ -304,3 +304,17 @@ export function createProviderSnapshot( }), }; } + +const creationDirectories: string[] = []; +afterEach(async () => { + await Promise.all( + creationDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); +export function createTestCreationService(): SessionOptions["creationService"] { + const directory = join(tmpdir(), `session-creation-${randomUUID()}`); + creationDirectories.push(directory); + return new CreationService(directory, pino({ level: "silent" })); +} diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index d14094a674..705a53342e 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,4 +1,7 @@ -import { AgentRequests } from "./agent/requests/index.js"; +import { stat } from "node:fs/promises"; +import type { CreationSnapshot } from "@getpaseo/protocol/messages"; +import { CreationService } from "./creation/index.js"; +import { MessageReceipts } from "./message-receipts/index.js"; import { WebSocket, WebSocketServer } from "ws"; import type { IncomingMessage, Server as HTTPServer } from "http"; import { join } from "path"; @@ -529,7 +532,8 @@ export class VoiceAssistantWebSocketServer { private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined; private readonly agentManager: AgentManager; private readonly agentStorage: AgentStorage; - private readonly agentRequests: AgentRequests; + private readonly messageReceipts: MessageReceipts; + private readonly creationService: CreationService; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; private readonly workspaceLabelService: WorkspaceLabelService | null; @@ -585,6 +589,25 @@ export class VoiceAssistantWebSocketServer { private readonly pluginRuntime: SessionOptions["pluginRuntime"]; private readonly orchestrationSkills: SessionOptions["orchestrationSkills"]; + private async validateCompletedCreation(snapshot: CreationSnapshot): Promise { + if (snapshot.workspace && snapshot.kind === "workspace") { + const workspace = await this.workspaceRegistry.get(snapshot.workspace.id); + if (!workspace) throw new Error("Previously created workspace no longer exists"); + const directory = await stat(workspace.cwd).catch(() => null); + if (!directory?.isDirectory()) + throw Object.assign(new Error(`Directory not found: ${workspace.cwd}`), { + code: "directory_not_found", + }); + } + if ( + snapshot.agentId && + !this.agentManager.getAgent(snapshot.agentId) && + !(await this.agentStorage.get(snapshot.agentId)) + ) { + throw new Error("Previously created agent no longer exists"); + } + } + constructor( server: HTTPServer, logger: pino.Logger, @@ -649,7 +672,13 @@ export class VoiceAssistantWebSocketServer { this.orchestrationSkills = orchestrationSkills; this.agentManager = agentManager; this.agentStorage = agentStorage; - this.agentRequests = new AgentRequests(join(paseoHome, "agent-requests")); + this.messageReceipts = new MessageReceipts(join(paseoHome, "agent-requests")); + this.creationService = new CreationService( + join(paseoHome, "creations"), + this.logger.child({ module: "creation" }), + (snapshot) => this.validateCompletedCreation(snapshot), + join(paseoHome, "agent-requests"), + ); this.projectRegistry = projectRegistry ?? createNoopProjectRegistry(); this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry(); this.workspaceLabelService = workspaceLabelService ?? null; @@ -1425,7 +1454,8 @@ export class VoiceAssistantWebSocketServer { worktreesRoot: this.worktreesRoot, agentManager: this.agentManager, agentStorage: this.agentStorage, - agentRequests: this.agentRequests, + messageReceipts: this.messageReceipts, + creationService: this.creationService, projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, workspaceLabelService: this.workspaceLabelService ?? undefined, @@ -1646,6 +1676,8 @@ export class VoiceAssistantWebSocketServer { features: { ownedSubscriptions: true, agentRequestReceipts: true, + workspaceRequestReceipts: true, + creationLifecycle: true, hubAgentRpc: true, // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. directorySync: true, diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index aca7046728..34abdebc3f 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import pino from "pino"; import { z } from "zod"; import { describe, expect, test } from "vitest"; @@ -208,7 +211,8 @@ function createSessionForWireCompatTest(options?: { ]; const session = new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "wire-compat-client", permissions: OWNER_PERMISSIONS, clientCapabilities: options?.clientCapabilities ?? null, diff --git a/packages/server/src/server/workspace-create-errors.e2e.test.ts b/packages/server/src/server/workspace-create-errors.e2e.test.ts index bc42b77596..a5c442fcd6 100644 --- a/packages/server/src/server/workspace-create-errors.e2e.test.ts +++ b/packages/server/src/server/workspace-create-errors.e2e.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "vitest"; -import { rmSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -49,3 +49,30 @@ test("workspace.create surfaces each early-reject error branch", async () => { rmSync(missingDir, { recursive: true, force: true }); } }, 180000); + +test("workspace creation replay rejects a checkout removed after completion", async () => { + const daemon = await createTestPaseoDaemon(); + const directory = mkdtempSync(path.join(tmpdir(), "workspace-replay-")); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + try { + await client.connect(); + const request = { + source: { kind: "directory" as const, path: directory }, + idempotencyKey: "removed-checkout", + }; + const created = await client.createWorkspace(request); + expect(created.error).toBeNull(); + expect(created.workspace).not.toBeNull(); + rmSync(directory, { recursive: true, force: true }); + const replayed = await client.createWorkspace(request); + expect(replayed.workspace).toBeNull(); + expect(replayed.errorCode).toBe("directory_not_found"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(directory, { recursive: true, force: true }); + } +}, 180000); From 799c91b39d6a8dad6fb4ccf385d789285a29e260 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 14 Sep 2026 19:10:32 +0200 Subject: [PATCH 10/17] fix(app): restore Android tablet model picker interactions (#4845) Restore provider/model selection in Android tablet modal windows by giving both native modal hosts their own gesture-handler root. Preserve phone sheet and web portal behavior. Include native regression scripts, test dependency resolution, and verified CI test corrections. Generated media and logs are excluded. Refs #4475 and #4377; consolidates #4721 and #4771. Co-authored-by: Mike P. Kuhl Co-authored-by: Cameron Chin --- docs/floating-panels.md | 5 +- packages/app/e2e/mobile/modal-sheet/README.md | 49 ++++++++++++++++++- .../modal-sheet/model-tablet-agent.android.ad | 23 +++++++++ .../modal-sheet/model-tablet.android.ad | 30 ++++++++++++ .../support/helpers/agent-timeline-gate.ts | 7 ++- packages/app/e2e/support/helpers/creation.ts | 3 +- .../src/components/adaptive-modal-sheet.tsx | 9 +++- packages/app/src/components/ui/combobox.tsx | 4 +- packages/app/vitest.config.ts | 28 +++++++++-- .../src/commands/daemon/lifecycle.e2e.test.ts | 7 +-- 10 files changed, 149 insertions(+), 16 deletions(-) create mode 100644 packages/app/e2e/mobile/modal-sheet/model-tablet-agent.android.ad create mode 100644 packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 616288fbf3..54f3c92630 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -58,7 +58,10 @@ Android touches sailed straight through to the chat scroll view behind it. Two escape hatches in the codebase: - **`Modal`** (combobox, dropdown menu and tooltip on native) — opens a new Android window, so - hit-testing starts fresh in that window. Side effect: a Modal opening on + hit-testing starts fresh in that window. If its children use gesture-handler, + the Modal owner must wrap its content in a full-height `GestureHandlerRootView`: + the app's root does not span Android windows. React context alone does not + establish a native gesture root. Side effect: a Modal opening on Android can detach the IME from an underlying TextInput. Fine for combobox (it has its own input) and tooltip (no input). **Not** fine for autocomplete (the composer's input must stay focused so the user keeps typing). diff --git a/packages/app/e2e/mobile/modal-sheet/README.md b/packages/app/e2e/mobile/modal-sheet/README.md index f72dc883eb..d95034dad4 100644 --- a/packages/app/e2e/mobile/modal-sheet/README.md +++ b/packages/app/e2e/mobile/modal-sheet/README.md @@ -1,4 +1,6 @@ -# Native sheet gestures +# Native modal and sheet gestures + +## Compact 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. @@ -22,7 +24,7 @@ its parent without selecting a model or submitting a prompt. It does not assert 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 +### 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 @@ -45,3 +47,46 @@ 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. + +## Tablet model selection + +`model-tablet.android.ad` exercises the New workspace popover. `model-tablet-agent.android.ad` +exercises the modal used by an existing agent's narrow composer on a wide device. Both drill into +a populated provider, select a real model, assert the updated trigger, reopen, and dismiss with +Android Back. New workspace also pans beyond the short list viewport and flings back without +selecting a row or dismissing the popover. They do not submit a prompt. + +Use an English-language Android tablet at 1600×2560, density 320 (800dp), with this checkout's +JavaScript and a connected isolated daemon. On a Pixel Tablet AVD, rotate to portrait explicitly; +its natural landscape orientation letterboxes this portrait-locked app into the compact layout: + +```sh +adb -s "$ANDROID_SERIAL" shell settings put system accelerometer_rotation 0 +adb -s "$ANDROID_SERIAL" shell settings put system user_rotation 1 +adb -s "$ANDROID_SERIAL" shell wm density 320 +``` + +Prepare an idle agent through the daemon's public `createAgent` API with a real provider/model and +no initial prompt. Set `MODEL_QA_AGENT_ID` and `MODEL_QA_AGENT_TITLE` to that agent. In New workspace, +remember a model first (the phone layout works on the broken baseline), then close the picker. +Choose a model visible on the provider's first page and set its catalog ID and displayed label. +Set `MODEL_QA_LAST_MODEL_ID` to its initially offscreen last model; use a short overflowing catalog +(six rows during this verification) for the coordinate-based pan/fling regression: + +```sh +agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad \ + packages/app/e2e/mobile/modal-sheet/model-tablet-agent.android.ad \ + --serial "$ANDROID_SERIAL" --metro-port "$MODEL_QA_METRO_PORT" \ + --env FORM_URL="paseo://new?serverId=${SHEET_QA_SERVER_ID}" \ + --env AGENT_URL="paseo://h/${SHEET_QA_SERVER_ID}/agent/${MODEL_QA_AGENT_ID}" \ + --env AGENT_TITLE="$MODEL_QA_AGENT_TITLE" \ + --env PROVIDER_ID=codex --env MODEL_ID="$MODEL_QA_MODEL_ID" \ + --env MODEL_LABEL="$MODEL_QA_MODEL_LABEL" --env LAST_MODEL_ID="$MODEL_QA_LAST_MODEL_ID" \ + --artifacts-dir /tmp/paseo-model-tablet-qa +``` + +The existing-agent journey needs the sidebar pinned so its composer stays narrow. Start each run with no modal open; +a failed provider tap leaves its modal open, so dismiss it before the next run. These host-dependent +journeys remain outside the default mobile suite, alongside the compact sheet journeys above. + +Keep generated screenshots, recordings, and run logs outside the repository. diff --git a/packages/app/e2e/mobile/modal-sheet/model-tablet-agent.android.ad b/packages/app/e2e/mobile/modal-sheet/model-tablet-agent.android.ad new file mode 100644 index 0000000000..b8a570c4fb --- /dev/null +++ b/packages/app/e2e/mobile/modal-sheet/model-tablet-agent.android.ad @@ -0,0 +1,23 @@ +# Idle existing agent in a narrow composer on a wide Android device (AdaptiveModalSheet). +# AGENT_URL=paseo://h//agent/; AGENT_TITLE identifies the loaded route. +context platform=android timeout=60000 retries=0 +env APP_ID=sh.paseo.debug +env PROVIDER_ID=codex + +open "${APP_ID}" "${AGENT_URL}" +wait "label=\"${AGENT_TITLE}\"" 20000 +wait "id=\"combined-model-selector\"" 10000 +press "id=\"combined-model-selector\"" +wait "id=\"model-provider-${PROVIDER_ID}\"" 10000 +press "id=\"model-provider-${PROVIDER_ID}\"" +wait "id=\"compact-model-list\"" 3000 +press "id=\"model-row-${PROVIDER_ID}-${MODEL_ID}\"" +wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000 + +press "id=\"combined-model-selector\"" +wait "id=\"model-provider-${PROVIDER_ID}\"" 10000 +press "id=\"model-provider-${PROVIDER_ID}\"" +wait "id=\"model-row-${PROVIDER_ID}-${MODEL_ID}\"" 3000 +back +wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000 +close diff --git a/packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad b/packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad new file mode 100644 index 0000000000..74564a13c6 --- /dev/null +++ b/packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad @@ -0,0 +1,30 @@ +# Connected host with a selected model, 1600x2560 at density 320. See README.md. +# FORM_URL=paseo://new?serverId=; MODEL_ID and MODEL_LABEL name a real catalog model. +context platform=android timeout=60000 retries=0 +env APP_ID=sh.paseo.debug +env PROVIDER_ID=codex + +open "${APP_ID}" "${FORM_URL}" +wait "label=\"Workspace project\"" 20000 +press "id=\"combined-model-selector\"" +wait "id=\"combobox-desktop-container\"" 10000 +# A remembered model opens its provider's list. Return to the provider page first. +press "label=\"Back\"" +wait "id=\"model-provider-${PROVIDER_ID}\"" 10000 +press "id=\"model-provider-${PROVIDER_ID}\"" +wait "id=\"compact-model-list\"" 3000 +press "id=\"model-row-${PROVIDER_ID}-${MODEL_ID}\"" +wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000 + +# A drag that leaves the short viewport must scroll without selecting or dismissing. +press "id=\"combined-model-selector\"" +wait "id=\"model-row-${PROVIDER_ID}-${MODEL_ID}\"" 3000 +gesture pan 1000 1850 0 -700 600 +wait "id=\"compact-model-list\"" 3000 +wait "id=\"model-row-${PROVIDER_ID}-${LAST_MODEL_ID}\"" 3000 +gesture fling down 1000 1750 500 +wait "id=\"model-row-${PROVIDER_ID}-${MODEL_ID}\"" 3000 +# Android Back dismisses the whole popover without changing the remembered selection. +back +wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000 +close diff --git a/packages/app/e2e/support/helpers/agent-timeline-gate.ts b/packages/app/e2e/support/helpers/agent-timeline-gate.ts index 690a3067ae..9e435068ad 100644 --- a/packages/app/e2e/support/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/support/helpers/agent-timeline-gate.ts @@ -293,11 +293,14 @@ export async function holdRewindCompletion( } if ( !released && - sessionMessage?.type === "agent.rewind.response" && + (sessionMessage?.type === "agent.rewind.response" || + sessionMessage?.type === "agent.timeline.replacement") && payload?.agentId === agentId ) { + // The replacement also signals completion and removes the rewound row/menu. + // Hold it with the response so the pending-state assertion observes a pending rewind. delayedForwards.push(() => ws.send(message)); - resolveDelayedResponse?.(); + if (sessionMessage.type === "agent.rewind.response") resolveDelayedResponse?.(); return; } ws.send(message); diff --git a/packages/app/e2e/support/helpers/creation.ts b/packages/app/e2e/support/helpers/creation.ts index c29bbeeeeb..df0ec30066 100644 --- a/packages/app/e2e/support/helpers/creation.ts +++ b/packages/app/e2e/support/helpers/creation.ts @@ -165,7 +165,8 @@ export async function createCreationScenario(page: Page) { await pressSubmitBeforeTheNextRender(page, button); }, async expectPromptVisible(prompt?: string) { - const rows = page.getByTestId("user-message"); + // Inactive agent tabs retain their timeline DOM beside the visible draft. + const rows = page.getByTestId("user-message").filter({ visible: true }); await expect(prompt ? rows.filter({ hasText: prompt }) : rows.first()).toBeVisible(); }, async expectWorkspaceReadyBeforeAgentCompletion() { diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 8c20d2f7bb..6be02a5f74 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -19,6 +19,7 @@ import { type BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; import { ArrowLeft, Search, X } from "lucide-react-native"; import { IsolatedBottomSheetModal, @@ -71,6 +72,9 @@ const SCROLL_CONTENT_GROW = { flexGrow: 1 }; const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject }; const styles = StyleSheet.create((theme) => ({ + nativeModalRoot: { + flex: 1, + }, desktopOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0,0,0,0.55)", @@ -724,7 +728,10 @@ export function AdaptiveModalSheet({ onDismiss={notifyNativeModalDismiss} hardwareAccelerated > - {desktopContent} + {/* Android Modal opens a separate window outside the app's gesture root. */} + + {desktopContent} + ); } diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 7ee9407dca..74748e423a 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -34,6 +34,7 @@ import { BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; import { Check, File, Folder, Search } from "lucide-react-native"; import { flip, @@ -1266,7 +1267,8 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement { visible={props.isOpen} onRequestClose={props.handleClose} > - {overlay} + {/* Android Modal opens a separate window outside the app's gesture root. */} + {overlay} ); } diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 3f96ad3bc0..ad77e0d20f 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -56,17 +56,27 @@ export default defineConfig({ server: { deps: { fallbackCJS: true, - inline: ["zustand", "@tanstack/react-query", "react-native-web"], + inline: [ + "zustand", + "@tanstack/react-query", + "react-native-web", + "react-native-gesture-handler", + ], }, }, }, - // Reanimated ships one file per platform and picks between them by extension - // (`findHostInstance.web.js`). Vite's dependency optimizer does not apply `resolve.extensions`, + // Reanimated and gesture-handler pick platform files by extension + // (e.g. `GestureHandlerRootView.web.js`). Vite's optimizer does not apply `resolve.extensions`, // so it scans the native files and dies on imports react-native-web has no answer for. // Unbundled, the same imports go through the resolver below and land on the web files. optimizeDeps: { - include: ["react/jsx-runtime"], - exclude: ["react-native-reanimated"], + // Bundle the CJS dependencies of the excluded gesture-handler package for the browser. + include: [ + "react/jsx-runtime", + "react-native-gesture-handler > hoist-non-react-statics", + "react-native-gesture-handler > invariant", + ], + exclude: ["react-native-reanimated", "react-native-gesture-handler"], }, // The globals a React Native bundler defines, which esbuild is no longer there to supply for // the package excluded above. @@ -101,6 +111,14 @@ export default defineConfig({ replacement: path.resolve(__dirname, "../relay/src/index.ts"), }, { find: "@", replacement: path.resolve(__dirname, "src") }, + // The CJS entry bypasses Vite's React Native alias and web-extension resolution. + { + find: /^react-native-gesture-handler$/, + replacement: path.resolve( + rootNodeModules, + "react-native-gesture-handler/lib/module/index.js", + ), + }, // Must precede the `react-native` alias: a string `find` matches by prefix, so this subpath // would otherwise resolve inside a react-native-web *file* and break the dependency scan. // Reanimated only imports it on the native path, which no test takes. diff --git a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts index ea6cfc48f9..6c2da1a350 100644 --- a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts +++ b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts @@ -76,11 +76,12 @@ async function fixture() { .poll( async () => { status = await ok(["status", "--home", home], overrides); - return status.connectedDaemon; + return status; }, { timeout: 30_000 }, ) - .toBe("reachable"); + // A connected socket can outlive a status RPC timeout without reporting the worker. + .toMatchObject({ connectedDaemon: "reachable", workerPid: expect.any(Number) }); return status; } async function configure(home: string, listen: string) { @@ -132,7 +133,7 @@ test("managed two-home restart retains its supervisor and never routes ordinary expect(launchA.listen).toBe(`127.0.0.1:${portA}`); expect(launchB.listen).toBe(`127.0.0.1:${portB}`); const beforeA = await f.liveStatus(a); - const beforeB = await f.ok(["--home", b, "daemon", "status"], poisoned); + const beforeB = await f.liveStatus(b, poisoned); if (process.platform !== "win32") { for (const home of [a, b, path.join(f.root, ".paseo")]) expect((await stat(home)).mode & 0o777).toBe(0o700); From fdb5540a943586d8e9149101ebc713667b2c36f1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 14 Sep 2026 21:24:51 +0200 Subject: [PATCH 11/17] docs(skills): clarify workspace comparison base selection (#4860) --- skills/paseo/SKILL.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 280db160fe..60ca70057a 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -23,6 +23,8 @@ For a local daemon, `project create` defaults to the current directory and resol **`create_workspace`** — create a workspace independently of any agent. Required: `isolation` (`local` or `worktree`). Worktree isolation supports `mode: "branch-off" | "checkout-branch" | "checkout-pr"`: use `branchName`/`baseBranch` for a new branch, `branch` for an existing branch, or `prNumber` plus optional `forge`/`projectPath` for a change request. `worktreeSlug` controls the managed path. Returns the workspace descriptor centered on `workspaceId`. +Choose `baseBranch` explicitly: `origin/main` selects the remote-tracking branch; `refs/heads/main` selects local main. Bare `main` prefers local main when it exists, otherwise origin/main. Paseo retains the resolved ref for workspace comparisons, even after rebasing the branch or changing its PR target. + **`list_workspaces`** — list active workspaces. **`archive_workspace`** — `{ workspaceId }`. Archives the workspace, its agents, and its terminals. Local directories remain; Paseo removes an owned worktree only after its final active workspace reference is archived. @@ -113,11 +115,11 @@ Don't poll `list_agents` or `get_agent_status` to "check on" a running agent. Th The CLI and tools use the same ownership semantics even where their syntax differs: ```bash -paseo workspace create --isolation worktree --mode branch-off --new-branch fix-x --base main +paseo workspace create --isolation worktree --mode branch-off --new-branch fix-x --base origin/main paseo workspace create --isolation worktree --mode checkout-branch --branch existing-work paseo workspace create --isolation worktree --mode checkout-pr --pr-number 42 paseo run --provider codex/gpt-5.4 --mode full-access --workspace "" -paseo run --provider codex/gpt-5.4 --mode full-access --new-workspace worktree --worktree-mode branch-off --new-branch fix-x --base main "" +paseo run --provider codex/gpt-5.4 --mode full-access --new-workspace worktree --worktree-mode branch-off --new-branch fix-x --base origin/main "" paseo send "" paseo ls paseo schedule create --cron "*/15 * * * *" "ping main build" From 836f1a9c23f7c59a09c6112f2e73c3f875687e80 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 14 Sep 2026 21:25:38 +0200 Subject: [PATCH 12/17] fix(ci): consolidate E2E repairs and wait for Metro readiness (#4858) * fix(desktop): wait for Metro HTTP readiness before launching Electron Expo briefly listens while probing port availability. A successful TCP connection can therefore launch Electron before Metro starts and leave it exiting on connection refusal. Require the configured renderer origin to report the running packager status. * test(ci): consolidate fixture and readiness corrections Await the connection and UI states each journey owns, isolate Git fixture contents before observation, and hold real Markdown frames at asserted checkpoints. Keep creation completion diagnostics explicit without claiming the original Windows failure is fixed. * test(desktop): describe Metro readiness through HTTP states Keep protocol mechanics in the fixture and assert readiness only after a successful packager response. Cover non-success status and unrelated HTTP content without inline synchronization in the test body. --- .../e2e/browser/composer-autocomplete.spec.ts | 3 +- .../e2e/browser/composer-diff-stat.spec.ts | 9 ++- .../owned-subscriptions-old-daemon.spec.ts | 1 + .../settings-toggle-tab-regression.spec.ts | 14 ++-- .../e2e/browser/streaming-markdown.spec.ts | 2 +- .../support/helpers/agent-timeline-gate.ts | 68 +++++++++++++++++++ .../command-center-workspace-actions.ts | 2 +- .../app/e2e/support/helpers/import-session.ts | 6 +- .../e2e/support/helpers/streaming-markdown.ts | 27 ++++++-- packages/desktop/e2e/browser-tabs.e2e.mjs | 22 +++--- .../desktop/scripts/dev-runner-readiness.mjs | 17 +++++ .../scripts/dev-runner-readiness.test.mjs | 41 +++++++++++ packages/desktop/scripts/dev-runner.mjs | 33 +-------- .../server/src/server/creation/index.test.ts | 2 +- 14 files changed, 183 insertions(+), 64 deletions(-) create mode 100644 packages/desktop/scripts/dev-runner-readiness.mjs create mode 100644 packages/desktop/scripts/dev-runner-readiness.test.mjs diff --git a/packages/app/e2e/browser/composer-autocomplete.spec.ts b/packages/app/e2e/browser/composer-autocomplete.spec.ts index 4119f7c692..01bdfd45d2 100644 --- a/packages/app/e2e/browser/composer-autocomplete.spec.ts +++ b/packages/app/e2e/browser/composer-autocomplete.spec.ts @@ -9,6 +9,7 @@ import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/arch import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { getServerId } from "../support/helpers/server-id"; import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui"; +import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar"; const TEST_COMMANDS = [ { @@ -671,7 +672,7 @@ test.describe("Composer autocomplete", () => { }); await page.getByRole("button", { name: "Open menu" }).click(); - await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 }); + await expectMobileAgentSidebarVisible(page); const popoverBox = await popover.boundingBox(); expect(popoverBox).not.toBeNull(); diff --git a/packages/app/e2e/browser/composer-diff-stat.spec.ts b/packages/app/e2e/browser/composer-diff-stat.spec.ts index 5926f30c73..455c58180a 100644 --- a/packages/app/e2e/browser/composer-diff-stat.spec.ts +++ b/packages/app/e2e/browser/composer-diff-stat.spec.ts @@ -1,4 +1,4 @@ -import { rm, writeFile } from "node:fs/promises"; +import { writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; @@ -30,10 +30,13 @@ async function seedChangedAgent(repoPrefix: string) { const workspace = await seedMockAgentWorkspace({ repoPrefix, title: "Composer diff stat", - repo: { withRemote: true }, + repo: { + withRemote: true, + // Exclude the local remote before the daemon starts observing this checkout. + files: [{ path: ".gitignore", content: "/remote.git/\n" }], + }, }); try { - await rm(path.join(workspace.cwd, "remote.git"), { recursive: true }); await writeFile( path.join(workspace.cwd, "README.md"), "# Temp Repo\nexport const one = 1;\nexport const two = 2;\n", diff --git a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts index 1bd1f549cf..5040cf9f46 100644 --- a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts +++ b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts @@ -113,6 +113,7 @@ test("does not repeat an assistant block when the current app paginates a publis page, "compatibility-after-reconnect: emit 1 coalesced agent stream updates", ); + await agent.client.connect(); await agent.client.waitForFinish(agent.agentId, 20_000); await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") }); }); diff --git a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts index 5b52c7025d..2c1fd0cc24 100644 --- a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts +++ b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts @@ -1,6 +1,6 @@ import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; import { expect, test } from "../support/fixtures"; -import { createIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab"; +import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab"; import { waitForTabBar, expectAgentTabActive } from "../support/helpers/launcher"; import { seedWorkspace } from "../support/helpers/seed-client"; import { getServerId } from "../support/helpers/server-id"; @@ -104,12 +104,12 @@ test.describe("Settings toggle tab regression", () => { const workspace = await seedWorkspace({ repoPrefix: "settings-toggle-tab-" }); try { - const firstAgent = await createIdleAgent(workspace.client, { + const firstAgent = await createMockIdleAgent(workspace.client, { cwd: workspace.repoPath, workspaceId: workspace.workspaceId, title: `settings-toggle-a-${Date.now()}`, }); - const secondAgent = await createIdleAgent(workspace.client, { + const secondAgent = await createMockIdleAgent(workspace.client, { cwd: workspace.repoPath, workspaceId: workspace.workspaceId, title: `settings-toggle-b-${Date.now()}`, @@ -165,12 +165,12 @@ test.describe("Settings toggle tab regression", () => { const workspace = await seedWorkspace({ repoPrefix: "settings-composer-height-" }); try { - const firstAgent = await createIdleAgent(workspace.client, { + const firstAgent = await createMockIdleAgent(workspace.client, { cwd: workspace.repoPath, workspaceId: workspace.workspaceId, title: `settings-composer-height-a-${Date.now()}`, }); - const secondAgent = await createIdleAgent(workspace.client, { + const secondAgent = await createMockIdleAgent(workspace.client, { cwd: workspace.repoPath, workspaceId: workspace.workspaceId, title: `settings-composer-height-b-${Date.now()}`, @@ -225,12 +225,12 @@ test.describe("Settings toggle tab regression", () => { const workspace = await seedWorkspace({ repoPrefix: "agent-route-refresh-" }); try { - const firstAgent = await createIdleAgent(workspace.client, { + const firstAgent = await createMockIdleAgent(workspace.client, { cwd: workspace.repoPath, workspaceId: workspace.workspaceId, title: `agent-route-refresh-a-${Date.now()}`, }); - const secondAgent = await createIdleAgent(workspace.client, { + const secondAgent = await createMockIdleAgent(workspace.client, { cwd: workspace.repoPath, workspaceId: workspace.workspaceId, title: `agent-route-refresh-b-${Date.now()}`, diff --git a/packages/app/e2e/browser/streaming-markdown.spec.ts b/packages/app/e2e/browser/streaming-markdown.spec.ts index 00aa16e7cc..b1c76f3d89 100644 --- a/packages/app/e2e/browser/streaming-markdown.spec.ts +++ b/packages/app/e2e/browser/streaming-markdown.spec.ts @@ -14,7 +14,7 @@ test("formats unfinished Markdown while streaming and preserves the completed re await withStreamingMarkdown(page, testInfo, async (agent) => { await requestStreamingMarkdown(agent); await expectUnfinishedBold(page); - await expectUnfinishedLink(page, testInfo); + await expectUnfinishedLink(page, agent, testInfo); await expectFinishedMarkdown(page, agent, testInfo); await expectReloadedMarkdown(page); }); diff --git a/packages/app/e2e/support/helpers/agent-timeline-gate.ts b/packages/app/e2e/support/helpers/agent-timeline-gate.ts index 9e435068ad..7651f32a94 100644 --- a/packages/app/e2e/support/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/support/helpers/agent-timeline-gate.ts @@ -609,3 +609,71 @@ async function delayAgentTimelineResponse( waitForDelayedResponse: () => delayedResponse, }; } + +/** Holds real incoming frames so intermediate rendering assertions do not race the producer. */ +export async function holdAssistantStream(page: Page, agentId: string) { + const pending: Array<{ forward(): void; text: string }> = []; + let holding = false; + let released = false; + let forwardedText = ""; + let notifyFrame: (() => void) | undefined; + let notifyInitialTimeline!: () => void; + const initialTimeline = new Promise((resolve) => { + notifyInitialTimeline = resolve; + }); + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => server.send(message)); + server.onMessage((message) => { + const session = getSessionMessage(message); + const payload = session ? getPayload(session) : null; + if (session?.type === "fetch_agent_timeline_response" && payload?.agentId === agentId) { + notifyInitialTimeline(); + } + const event = payload?.event as + | { type?: string; item?: { type?: string; text?: string } } + | undefined; + const text = + session?.type === "agent_stream" && + payload?.agentId === agentId && + event?.type === "timeline" && + event.item?.type === "assistant_message" + ? (event.item.text ?? "") + : ""; + if (text) holding = true; + if (holding && !released) { + pending.push({ forward: () => ws.send(message), text }); + notifyFrame?.(); + } else { + ws.send(message); + } + }); + }); + + return { + waitForInitialTimeline: () => initialTimeline, + async showThrough(prefix: string): Promise { + while (forwardedText.length < prefix.length) { + const frame = pending.shift(); + if (!frame) { + await new Promise((resolve) => { + notifyFrame = resolve; + }); + continue; + } + forwardedText += frame.text; + frame.forward(); + } + if (forwardedText !== prefix) { + throw new Error( + `Stream did not stop at ${JSON.stringify(prefix)}: ${JSON.stringify(forwardedText)}`, + ); + } + }, + release() { + released = true; + for (const frame of pending.splice(0)) frame.forward(); + }, + }; +} diff --git a/packages/app/e2e/support/helpers/command-center-workspace-actions.ts b/packages/app/e2e/support/helpers/command-center-workspace-actions.ts index 5aba36208a..f3908dea28 100644 --- a/packages/app/e2e/support/helpers/command-center-workspace-actions.ts +++ b/packages/app/e2e/support/helpers/command-center-workspace-actions.ts @@ -28,7 +28,7 @@ export async function openWorkspaceFromCommandCenter( ): Promise { const panel = await openCommandCenter(page); await panel.getByTestId("command-center-input").fill(title); - await page.keyboard.press("Enter"); + await action(panel, title).click(); await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), { timeout: 30_000, }); diff --git a/packages/app/e2e/support/helpers/import-session.ts b/packages/app/e2e/support/helpers/import-session.ts index 86818f6f4e..0bf2fc16e9 100644 --- a/packages/app/e2e/support/helpers/import-session.ts +++ b/packages/app/e2e/support/helpers/import-session.ts @@ -23,8 +23,7 @@ export class ImportSessionFlow { await expectMobileAgentSidebarVisible(this.page); const button = this.page.getByTestId("sidebar-import-session"); await expect(button).toHaveAccessibleName("Import session"); - await button.hover(); - await expect(this.page.getByText("Import session", { exact: true })).toBeVisible(); + await expect(button).toBeInViewport(); } async openGlobally() { await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible(); @@ -49,6 +48,9 @@ export class ImportSessionFlow { before?: [string, string]; folders?: Array<[string, string]>; }) { + for (const id of input.first ?? input.before ?? []) { + await expect(this.page.getByTestId(rowTestId(id))).toHaveCount(1); + } const ids = await this.rowIds(); if (input.first) expect(ids.slice(0, input.first.length)).toEqual(input.first.map(rowTestId)); if (input.before) { diff --git a/packages/app/e2e/support/helpers/streaming-markdown.ts b/packages/app/e2e/support/helpers/streaming-markdown.ts index 144b706f5d..3609641d15 100644 --- a/packages/app/e2e/support/helpers/streaming-markdown.ts +++ b/packages/app/e2e/support/helpers/streaming-markdown.ts @@ -1,10 +1,15 @@ +import { holdAssistantStream } from "./agent-timeline-gate"; import { expect, type Page, type TestInfo } from "@playwright/test"; import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; +interface StreamingMarkdownAgent extends MockAgentWorkspace { + stream: Awaited>; +} + export async function withStreamingMarkdown( page: Page, testInfo: TestInfo, - run: (agent: MockAgentWorkspace) => Promise, + run: (agent: StreamingMarkdownAgent) => Promise, ): Promise { testInfo.setTimeout(120_000); const agent = await seedMockAgentWorkspace({ @@ -16,16 +21,22 @@ export async function withStreamingMarkdown( mockStreamingAssistantIntervalMs: 400, }, }); + const stream = await holdAssistantStream(page, agent.agentId); try { await openAgentRoute(page, agent); - await run(agent); + await stream.waitForInitialTimeline(); + await run({ ...agent, stream }); } finally { + stream.release(); await agent.cleanup(); } } -export async function requestStreamingMarkdown(agent: MockAgentWorkspace): Promise { +export async function requestStreamingMarkdown(agent: StreamingMarkdownAgent): Promise { await agent.client.sendAgentMessage(agent.agentId, "Show the formatted streaming response."); + // Exercise late assertions: the producer finishes before the browser consumes its frames. + await agent.client.waitForFinish(agent.agentId, 30_000); + await agent.stream.showThrough("**Bold"); } export async function expectUnfinishedBold(page: Page): Promise { @@ -37,8 +48,13 @@ export async function expectUnfinishedBold(page: Page): Promise { await expect(message).not.toContainText("*"); } -export async function expectUnfinishedLink(page: Page, testInfo: TestInfo): Promise { +export async function expectUnfinishedLink( + page: Page, + agent: StreamingMarkdownAgent, + testInfo: TestInfo, +): Promise { const message = page.getByTestId("assistant-message").last(); + await agent.stream.showThrough("**Bold text stays bold** and [Paseo docs"); await expect(message).toContainText("Paseo docs"); await expect(message.getByRole("link", { name: "Paseo docs" })).toHaveCount(0); await expect(message).not.toContainText("["); @@ -48,9 +64,10 @@ export async function expectUnfinishedLink(page: Page, testInfo: TestInfo): Prom export async function expectFinishedMarkdown( page: Page, - agent: MockAgentWorkspace, + agent: StreamingMarkdownAgent, testInfo: TestInfo, ): Promise { + agent.stream.release(); await agent.client.waitForFinish(agent.agentId, 30_000); await expectCompletedMarkdown(page); await captureMarkdown(page, testInfo, "completed-markdown"); diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 20c9866bc0..208661e033 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -390,17 +390,12 @@ async function clickGuestElement(page, client, browserId, selector) { }); const elementRect = JSON.parse(evaluated.resultJson); assert(elementRect, `Guest element ${selector} was unavailable`); - const webviewRect = await page.evaluate((id) => { - const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`); - if (!(webview instanceof HTMLElement)) return null; - const rect = webview.getBoundingClientRect(); - return { x: rect.x, y: rect.y }; - }, browserId); - assert(webviewRect, `Browser webview ${browserId} was unavailable`); - await page.mouse.click( - webviewRect.x + elementRect.x + elementRect.width / 2, - webviewRect.y + elementRect.y + elementRect.height / 2, - ); + await page.locator(`[data-paseo-browser-id="${browserId}"]`).click({ + position: { + x: elementRect.x + elementRect.width / 2, + y: elementRect.y + elementRect.height / 2, + }, + }); } async function selectDeviceSize(page, label) { @@ -614,7 +609,10 @@ async function runRegression({ await page.waitForFunction( (id) => { const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`); - return webview && webview.parentElement?.id !== "paseo-browser-resident-webviews"; + const surface = webview?.parentElement; + return ( + surface?.getAttribute("aria-hidden") === "false" && surface.style.pointerEvents === "auto" + ); }, browserId, { timeout: timeoutMs }, diff --git a/packages/desktop/scripts/dev-runner-readiness.mjs b/packages/desktop/scripts/dev-runner-readiness.mjs new file mode 100644 index 0000000000..b3fc07eb6e --- /dev/null +++ b/packages/desktop/scripts/dev-runner-readiness.mjs @@ -0,0 +1,17 @@ +export async function waitForMetro(url, timeoutMs = 60_000) { + const statusUrl = new URL("/status", url); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const response = await fetch(statusUrl, { signal: AbortSignal.timeout(1_000) }); + const body = (await response.text()).trim(); + if (response.status === 200 && body === "packager-status:running") { + return; + } + } catch { + // Expo temporarily binds the port while checking availability, before Metro starts. + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for Metro at ${url}`); +} diff --git a/packages/desktop/scripts/dev-runner-readiness.test.mjs b/packages/desktop/scripts/dev-runner-readiness.test.mjs new file mode 100644 index 0000000000..c3e2cc9fcb --- /dev/null +++ b/packages/desktop/scripts/dev-runner-readiness.test.mjs @@ -0,0 +1,41 @@ +import http from "node:http"; +import { expect, test } from "vitest"; +import { waitForMetro } from "./dev-runner-readiness.mjs"; + +async function startMetroStatusServer(responses) { + let lastResponse = null; + let nextResponse = 0; + const server = http.createServer((request, response) => { + const reply = responses[Math.min(nextResponse++, responses.length - 1)]; + lastResponse = { path: request.url, ...reply }; + response.writeHead(reply.status); + response.end(reply.body); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return { + url: `http://127.0.0.1:${server.address().port}`, + lastResponse: () => lastResponse, + async close() { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }, + }; +} + +test("Metro readiness rejects an unrelated listener before accepting the packager status", async () => { + const metro = await startMetroStatusServer([ + { status: 503, body: "packager-status:running" }, + { status: 200, body: "unrelated HTTP server" }, + { status: 200, body: "packager-status:running" }, + ]); + try { + await waitForMetro(metro.url, 2_000); + expect(metro.lastResponse()).toEqual({ + path: "/status", + status: 200, + body: "packager-status:running", + }); + } finally { + await metro.close(); + } +}); diff --git a/packages/desktop/scripts/dev-runner.mjs b/packages/desktop/scripts/dev-runner.mjs index 852bf7d85c..008b77d15f 100644 --- a/packages/desktop/scripts/dev-runner.mjs +++ b/packages/desktop/scripts/dev-runner.mjs @@ -1,5 +1,4 @@ #!/usr/bin/env node -import net from "node:net"; import path from "node:path"; import { execFileSync, spawn } from "node:child_process"; import { createRequire } from "node:module"; @@ -10,6 +9,7 @@ import { resolveChildKillTarget, } from "./dev-runner-config.mjs"; +import { waitForMetro } from "./dev-runner-readiness.mjs"; import { resolveDevElectronArgs } from "./dev-runner-args.mjs"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); @@ -134,35 +134,6 @@ function stopAll(signal) { }, 50); } -async function waitForPort(port, host = "127.0.0.1", timeoutMs = 60_000) { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - if (await canConnect(port, host)) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - throw new Error(`Timed out waiting for ${host}:${port}`); -} - -function canConnect(port, host) { - return new Promise((resolve) => { - const socket = net.createConnection({ port, host }); - socket.setTimeout(1000); - socket.once("connect", () => { - socket.destroy(); - resolve(true); - }); - socket.once("timeout", () => { - socket.destroy(); - resolve(false); - }); - socket.once("error", () => resolve(false)); - }); -} - registerDevRunnerShutdownSignals({ signalSource: process, stop: stopAll }); spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], { @@ -179,7 +150,7 @@ spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], { }); try { - await waitForPort(expoPort); + await waitForMetro(expoDevUrl); } catch (error) { console.error(`[dev] ${error.message}`); exitCode = 1; diff --git a/packages/server/src/server/creation/index.test.ts b/packages/server/src/server/creation/index.test.ts index 2f2c024078..f4f0205225 100644 --- a/packages/server/src/server/creation/index.test.ts +++ b/packages/server/src/server/creation/index.test.ts @@ -176,7 +176,7 @@ test("conflicting intents and resource IDs cannot start more work", async () => "workspace_id_conflict", ); f.provider.resolve(); - await first; + expect(await first).toMatchObject({ phase: "completed", error: null }); expect(f.calls).toEqual(["workspace", "agent", "prompt"]); }); From 77c5c8f17e1f33ec6c77d413bb6c6485b510d8b3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 14 Sep 2026 22:25:31 +0200 Subject: [PATCH 13/17] Prevent duplicate catalog choices from crashing agent drafts (#4839) * fix(providers): keep duplicate catalogs from breaking agent drafts Normalize model identities before publication and when materializing older catalogs, preserving the first definition and configured overrides. Keep explicit default variants separate from the base model selection. * fix(providers): preserve saved variant identities across resumes Persist the selection encoding so legacy names remain literal. Refresh provider persistence when a selection changes, before another prompt is sent. * test(app): let fixtures own catalog journey cleanup * fix(providers): preserve the existing default variant contract Keep one Default catalog choice and let the provider resolve an omitted variant. Remove the encoding and persistence machinery; other variant IDs retain their literal meaning. * test(creation): expose failed completion after conflicting requests Creation returns a failed snapshot instead of rejecting. Assert the result before its side effects so platform failures report the underlying error. * test(app): wait for the seed client after daemon restart The browser and seed client reconnect independently. Wait for the seed connection before issuing post-restart RPCs instead of assuming browser readiness covers both clients. * test(app): isolate visible prompts and checkout diff fixtures Inactive tabs retain message DOM, so prompt assertions must inspect visible rows. Ignore the local fixture remote before observation rather than deleting it after the daemon has started tracking the checkout. * fix(file-observer): preserve final deletions when batching events A duplicate creation notification followed by deletion was collapsed into an update, hiding final absence from consumers that distinguish deletes. Keep final deletes while retaining update semantics for atomic replacement. * fix(desktop): wait for Metro HTTP readiness before launching Electron Expo briefly listens while probing port availability. A successful TCP connection can therefore launch Electron before Metro starts and leave it exiting on connection refusal. Require the configured renderer origin to report the running packager status. * test(ci): consolidate fixture and readiness corrections Await the connection and UI states each journey owns, isolate Git fixture contents before observation, and hold real Markdown frames at asserted checkpoints. Keep creation completion diagnostics explicit without claiming the original Windows failure is fixed. --- .../model-search-across-providers.spec.ts | 114 +++++++++++++++++- .../app/e2e/support/fixtures/catalog-acp.cjs | 18 +++ .../src/data/provider-snapshot-cache.test.ts | 37 ++++++ .../app/src/data/provider-snapshot-cache.ts | 22 +++- packages/protocol/src/agent-types.ts | 11 ++ .../agent/provider-snapshot-manager.test.ts | 73 +++++++++++ .../server/agent/provider-snapshot-manager.ts | 10 +- .../agent/providers/opencode-agent.test.ts | 109 +++++++++++++++++ .../server/agent/providers/opencode-agent.ts | 7 +- 9 files changed, 392 insertions(+), 9 deletions(-) create mode 100644 packages/app/e2e/support/fixtures/catalog-acp.cjs diff --git a/packages/app/e2e/browser/model-search-across-providers.spec.ts b/packages/app/e2e/browser/model-search-across-providers.spec.ts index 4a4ab31ea2..ba2c37f895 100644 --- a/packages/app/e2e/browser/model-search-across-providers.spec.ts +++ b/packages/app/e2e/browser/model-search-across-providers.spec.ts @@ -1,4 +1,7 @@ -import { test } from "../support/fixtures"; +import path from "node:path"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { test, expect, type Page } from "../support/fixtures"; +import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; import { closeModelPicker, expectModelSearchEmptyState, @@ -15,7 +18,7 @@ import { expectSearchResultsVirtualized, } from "../support/helpers/agent-profiles"; import { expectComposerVisible } from "../support/helpers/composer"; -import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; +import { clickNewChat, clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher"; import { seedWorkspace } from "../support/helpers/seed-client"; const MOCK_PROVIDER_LABEL = "Mock Load Test"; @@ -158,3 +161,110 @@ test.describe("Cross-provider model search", () => { } }); }); + +async function setRuntimeCatalog(client: DaemonClient, count: number, cwd: string) { + await client.patchDaemonConfig({ + providers: { + gemini: { + extends: "acp", + label: "Catalog provider", + enabled: true, + command: [ + process.execPath, + path.resolve(__dirname, "../support/fixtures/catalog-acp.cjs"), + String(count), + ], + }, + }, + }); + await expect + .poll( + async () => + (await client.getProvidersSnapshot({ cwd })).entries.find( + (entry) => entry.provider === "gemini", + )?.status, + { timeout: 30_000 }, + ) + .toBe("ready"); +} +async function reloadSavedDraft(page: Page) { + await page.evaluate(() => + localStorage.setItem( + "@paseo:e2e-disable-default-seed-once", + localStorage.getItem("@paseo:e2e-seed-nonce")!, + ), + ); + await page.reload(); + await expectComposerVisible(page); +} +async function expectOneCatalogChoice(page: Page, label: string) { + await openModelPicker(page); + await page.getByRole("button", { name: "Back", exact: true }).click(); + await searchAllModels(page, label); + await expect(page.getByTestId("model-row-gemini-gemini-3.5-flash")).toHaveCount(1); + await closeModelPicker(page); +} + +async function configureModelOverride(client: DaemonClient, cwd: string) { + await client.patchDaemonConfig({ + providers: { + gemini: { + additionalModels: [{ id: "gemini-3.5-flash", label: "Configured model", isDefault: true }], + }, + }, + }); + await expect + .poll( + async () => + (await client.getProvidersSnapshot({ cwd: cwd })).entries.find( + (entry) => entry.provider === "gemini", + )?.models, + { timeout: 30_000 }, + ) + .toMatchObject([{ id: "gemini-3.5-flash", label: "Configured model", isDefault: true }]); +} + +const catalogTest = test.extend<{ catalogClient: DaemonClient }>({ + catalogClient: async ({ e2eWorker }, provide) => { + void e2eWorker; + const client = await connectDaemonClient({ clientIdPrefix: "catalog-models" }); + try { + await provide(client); + } finally { + try { + await client.patchDaemonConfig({ removeProviders: ["gemini"] }); + } finally { + await client.close(); + } + } + }, +}); + +catalogTest( + "New Agent and saved drafts stay usable with repeated runtime model rows", + async ({ page, withWorkspace, catalogClient: client }, testInfo) => { + const workspace = await withWorkspace({ prefix: "catalog-models-" }); + await test.step("open a draft with a one-row runtime catalog", async () => { + await setRuntimeCatalog(client, 1, workspace.repoPath); + await gotoWorkspace(page, workspace.workspaceId); + await expectComposerVisible(page); + }); + await test.step("open New Agent after the provider publishes repeated model rows", async () => { + await clickNewTerminal(page); + await setRuntimeCatalog(client, 2, workspace.repoPath); + await clickNewChat(page); + await expectComposerVisible(page); + await expectOneCatalogChoice(page, "Gemini 3.5 Flash"); + await page.screenshot({ path: testInfo.outputPath("duplicate-catalog-draft.png") }); + }); + await test.step("reopen the saved draft with the same catalog", async () => { + await reloadSavedDraft(page); + await expectOneCatalogChoice(page, "Gemini 3.5 Flash"); + }); + await test.step("retain configured model overrides when runtime IDs repeat", async () => { + await configureModelOverride(client, workspace.repoPath); + await reloadSavedDraft(page); + await expectOneCatalogChoice(page, "Configured model"); + }); + }, +); diff --git a/packages/app/e2e/support/fixtures/catalog-acp.cjs b/packages/app/e2e/support/fixtures/catalog-acp.cjs new file mode 100644 index 0000000000..fc6eafee73 --- /dev/null +++ b/packages/app/e2e/support/fixtures/catalog-acp.cjs @@ -0,0 +1,18 @@ +const readline = require("node:readline"); +const model = { modelId: "gemini-3.5-flash", name: "Gemini 3.5 Flash" }; +readline.createInterface({ input: process.stdin }).on("line", (line) => { + const request = JSON.parse(line); + if (request.id === undefined) return; + let result = {}; + if (request.method === "initialize") + result = { protocolVersion: 1, agentCapabilities: {}, authMethods: [] }; + if (request.method === "session/new") + result = { + sessionId: "catalog-diagnostic", + models: { + currentModelId: model.modelId, + availableModels: Array.from({ length: Number(process.argv[2]) }, () => model), + }, + }; + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\n"); +}); diff --git a/packages/app/src/data/provider-snapshot-cache.test.ts b/packages/app/src/data/provider-snapshot-cache.test.ts index b69b0f5d52..424657e3fa 100644 --- a/packages/app/src/data/provider-snapshot-cache.test.ts +++ b/packages/app/src/data/provider-snapshot-cache.test.ts @@ -130,6 +130,43 @@ function writeSnapshot( } describe("provider snapshot cache", () => { + it("normalizes duplicate model identities in live, legacy and persisted catalogs", async () => { + const storage = createStorage(); + const entries = snapshotEntries("shared"); + const first = entries[0].models![0]; + entries[0].models!.push({ ...first, label: "Duplicate" }); + const compactSnapshot = compactProviderSnapshot(entries); + const payload = { requestId: "catalog", generatedAt: "2026-09-01T00:00:00.000Z", entries }; + const cache = createProviderSnapshotCache(storage); + const legacy = await cache.materialize("server-1", payload); + expect(legacy.entries[0].models).toEqual([first]); + const live = await cache.materialize("server-1", { + ...payload, + snapshotHash: "old-hash", + compactSnapshot, + }); + expect(live.entries[0].models).toEqual([first]); + expect(live.compactSnapshot).toEqual(compactSnapshot); + await cache.write({ + serverId: "server-1", + cwd: "/repo", + hash: "old-hash", + generatedAt: payload.generatedAt, + compactSnapshot, + }); + const restarted = createProviderSnapshotCache(storage); + const saved = await restarted.read("server-1", "/repo"); + expect(saved?.entries[0].models).toEqual([first]); + const unchanged = await restarted.materialize("server-1", { + ...payload, + entries: [], + snapshotHash: "old-hash", + notModified: true, + }); + expect(unchanged.entries[0].models).toEqual([first]); + expect(unchanged.snapshotHash).toBe("old-hash"); + }); + it("removes externally lost body references and retains independent orphan bodies on reconciliation", async () => { const storage = createStorage(); const cache = createProviderSnapshotCache(storage); diff --git a/packages/app/src/data/provider-snapshot-cache.ts b/packages/app/src/data/provider-snapshot-cache.ts index 04a10303e3..3871cbb1e0 100644 --- a/packages/app/src/data/provider-snapshot-cache.ts +++ b/packages/app/src/data/provider-snapshot-cache.ts @@ -1,6 +1,9 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { Buffer } from "buffer"; -import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import { + normalizeAgentModelCatalog, + type ProviderSnapshotEntry, +} from "@getpaseo/protocol/agent-types"; import { expandProviderSnapshot, type CompactProviderSnapshot, @@ -77,6 +80,17 @@ export class ProviderSnapshotCacheMissError extends Error { } } +// COMPAT(duplicateModelCatalog): added in v0.8.1, remove after 2027-03-14 once +// the daemon floor is v0.8.1 and pre-fix cached catalogs are no longer supported. +// Keep the original compact body/hash: normalization belongs to its decoded view. +function normalizeCatalogs(entries: ProviderSnapshotEntry[]): ProviderSnapshotEntry[] { + return entries.map((entry) => { + if (!entry.models) return entry; + const models = normalizeAgentModelCatalog(entry.models); + return models === entry.models ? entry : { ...entry, models }; + }); +} + function cacheKey(serverId: string, cwd: string | null): string { return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, "cwd", cwd])}`; } @@ -303,7 +317,7 @@ export function createProviderSnapshotCache( const body = { ...stored, compactSnapshot: stored.compactSnapshot, - entries: expandProviderSnapshot(stored.compactSnapshot), + entries: normalizeCatalogs(expandProviderSnapshot(stored.compactSnapshot)), }; bodies.set(key, body); return body; @@ -328,7 +342,7 @@ export function createProviderSnapshotCache( return { async materialize(serverId, snapshot) { const { snapshotHash: hash, compactSnapshot } = snapshot; - if (!hash) return snapshot; + if (!hash) return { ...snapshot, entries: normalizeCatalogs(snapshot.entries) }; let body = await safely(async () => { const cached = await readBody(serverId, hash); if (cached) return cached; @@ -348,7 +362,7 @@ export function createProviderSnapshotCache( hash, generatedAt: snapshot.generatedAt, compactSnapshot, - entries: expandProviderSnapshot(compactSnapshot), + entries: normalizeCatalogs(expandProviderSnapshot(compactSnapshot)), }; } return { diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index 83932d3506..ea412c16be 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -102,6 +102,17 @@ export function normalizeAgentModelDefinition(model: AgentModelDefinition): Agen return { ...model, defaultThinkingOptionId }; } +/** A provider addresses models by ID; repeated rows retain the first definition. */ +export function normalizeAgentModelCatalog(models: AgentModelDefinition[]): AgentModelDefinition[] { + const ids = new Set(); + const unique = models.filter((model) => { + if (ids.has(model.id)) return false; + ids.add(model.id); + return true; + }); + return unique.length === models.length ? models : unique; +} + export interface ProviderSnapshotEntry { provider: AgentProvider; status: ProviderStatus; 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 68837c8c5f..3f81e5e9c7 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -138,6 +138,79 @@ async function runTestCatalogActivities( } describe("ProviderSnapshotManager public surface", () => { + test("publishes one model per provider identity and retains the first definition", async () => { + const first = { provider: "codex", id: "shared", label: "First", isDefault: true }; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog: async () => ({ + models: [first, { ...first, label: "Duplicate" }], + modes: [], + }), + }), + claude: createExtraClient("claude", { + isAvailable: async () => true, + fetchCatalog: async () => ({ models: [{ ...first, provider: "claude" }], modes: [] }), + }), + }, + }); + try { + const codex = await manager.getProvider({ provider: "codex", wait: true }); + const claude = await manager.getProvider({ provider: "claude", wait: true }); + expect(codex.models).toEqual([first]); + expect(claude.models).toEqual([{ ...first, provider: "claude" }]); + } finally { + manager.destroy(); + } + }); + + test("normalizes plugin model catalogs at the same publication boundary", async () => { + const manager = new ProviderSnapshotManager({ logger: createTestLogger() }); + manager.replacePluginProviders([ + { + id: "catalog-plugin", + label: "Catalog plugin", + async connect() { + let listener: ((event: ProviderEvent) => void) | undefined; + return { + version: 1, + capabilities: [], + async send(input) { + if (input.type === "catalog") + listener?.({ + type: "catalog", + requestId: input.requestId, + catalog: { + models: [ + { id: "same", label: "First" }, + { id: "same", label: "Second" }, + ], + modes: [], + }, + }); + }, + onEvent(next) { + listener = next; + return () => { + listener = undefined; + }; + }, + async close() {}, + }; + }, + }, + ]); + try { + const entry = await manager.getProvider({ provider: "catalog-plugin", wait: true }); + expect(entry.models).toHaveLength(1); + expect(entry.models?.[0]).toMatchObject({ id: "same", label: "First" }); + } finally { + manager.destroy(); + } + }); + test("carries a plugin provider icon in snapshot metadata", () => { const iconSvg = ''; const registration: ProviderRegistration = { diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 610f7c8322..0307dc14be 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -1,3 +1,4 @@ +import { normalizeAgentModelCatalog } from "@getpaseo/protocol/agent-types"; import { createHash } from "node:crypto"; import { EventEmitter } from "node:events"; import { homedir } from "node:os"; @@ -1021,13 +1022,20 @@ export class ProviderSnapshotManager { return; } + const models = normalizeAgentModelCatalog(catalog.models); + if (models.length !== catalog.models.length) { + this.logger.warn( + { provider, discardedRows: catalog.models.length - models.length }, + "Provider catalog contains repeated model IDs; retaining the first definition", + ); + } setEntry({ ...base, defaultModeId: catalog.defaultModeId === undefined ? base.defaultModeId : catalog.defaultModeId, status: "ready", enabled: true, - models: catalog.models, + models, modes: catalog.modes, fetchedAt: new Date().toISOString(), }); 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 8b2acae1bd..b1c9df8a03 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -612,6 +612,115 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { rmSync(cwd, { recursive: true, force: true }); }, 120_000); + test.each([ + { name: "absent", variants: {} }, + { name: "empty", variants: { default: {} } }, + { name: "configured", variants: { default: { reasoningEffort: "low" } } }, + ])("catalog exposes one Default when upstream default is $name", async ({ variants }) => { + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.providerListResponse = { + data: { + connected: ["catalog-provider"], + all: [ + { + id: "catalog-provider", + name: "Catalog provider", + source: "api", + models: { + model: { + name: "Variant model", + variants: { ...variants, high: {}, "variant:default": {} }, + }, + }, + }, + ], + }, + }; + runtime.enqueueClient(openCodeClient); + const cwd = tmpCwd(); + try { + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + resolveHomeDir: () => cwd, + }); + const catalog = await client.fetchCatalog({ scope: "global", force: false }); + const options = catalog.models[0].thinkingOptions ?? []; + expect(options).toEqual([ + { id: "default", label: "Default", isDefault: true }, + { id: "high", label: "high" }, + { id: "variant:default", label: "variant:default" }, + ]); + expect(catalog.models[0].defaultThinkingOptionId).toBe("default"); + const execution = new TestOpenCodeClient(); + execution.sessionCreateResponse = { data: { id: "ses_catalog_variants" } }; + execution.sessionPromptAsyncEvents = [ + { type: "session.idle", properties: { sessionID: "ses_catalog_variants" } }, + ]; + runtime.enqueueClient(execution); + const session = await client.createSession({ + provider: "opencode", + cwd, + model: "catalog-provider/model", + thinkingOptionId: "default", + }); + try { + await collectTurnEvents(streamSession(session, "Use OpenCode's default")); + await session.setThinkingOption!("high"); + await collectTurnEvents(streamSession(session, "Use high")); + await session.setThinkingOption!("variant:default"); + await collectTurnEvents(streamSession(session, "Use the literal variant name")); + await session.setThinkingOption!("default"); + await collectTurnEvents(streamSession(session, "Return to OpenCode's default")); + expect(execution.calls.sessionPromptAsync).toEqual([ + expect.not.objectContaining({ variant: expect.anything() }), + expect.objectContaining({ variant: "high" }), + expect.objectContaining({ variant: "variant:default" }), + expect.not.objectContaining({ variant: expect.anything() }), + ]); + } finally { + await session.close(); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test.each([ + { saved: "default", variant: undefined }, + { saved: "high", variant: "high" }, + { saved: "variant:default", variant: "variant:default" }, + ])("resume preserves saved thinking choice $saved", async ({ saved, variant }) => { + const runtime = new TestOpenCodeHarness(); + const execution = new TestOpenCodeClient(); + execution.sessionPromptAsyncEvents = [ + { type: "session.idle", properties: { sessionID: "ses_saved_variant" } }, + ]; + runtime.enqueueClient(execution); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const cwd = tmpCwd(); + try { + const session = await client.resumeSession( + { provider: "opencode", sessionId: "ses_saved_variant", metadata: { cwd } }, + { thinkingOptionId: saved }, + ); + try { + await collectTurnEvents(streamSession(session, "Keep my saved choice")); + expect(execution.calls.sessionPromptAsync.map((request) => request.variant)).toEqual([ + variant, + ]); + } finally { + await session.close(); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + test("fetchCatalog returns models with required fields", async () => { const runtime = new TestOpenCodeHarness(); const openCodeClient = new TestOpenCodeClient(); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 19d5f95939..084ca70d7c 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -806,11 +806,14 @@ function buildOpenCodeModelDefinition( }, ): AgentModelDefinition { const rawVariants = model.variants ? Object.keys(model.variants) : []; - // OpenCode lists only overrides; its base model behavior is selected by omitting `variant`. + // Like OpenCode's web UI, Default omits `variant` and lets OpenCode resolve it. + // Reserve that choice instead of exposing a second upstream `default` entry. const thinkingOptions = rawVariants.length ? [ { id: OPENCODE_DEFAULT_VARIANT_ID, label: "Default", isDefault: true }, - ...rawVariants.map((id) => ({ id, label: id })), + ...rawVariants + .filter((id) => id !== OPENCODE_DEFAULT_VARIANT_ID) + .map((id) => ({ id, label: id })), ] : []; From 13979e9c386ec2b403562e0846ef18f175fcae14 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 14 Sep 2026 23:02:45 +0200 Subject: [PATCH 14/17] fix(server): discover fork PRs with URL-valued branch remotes (#4862) Resolve direct repository URLs as well as named remotes when deriving checkout PR identity, preserving fork-owner filtering and batched polling. --- .../server/src/utils/checkout-git.test.ts | 30 +++++++++++++++++++ packages/server/src/utils/checkout-git.ts | 23 ++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index f83ee46ae5..b5f505ea2d 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -2991,6 +2991,36 @@ const x = 1; }); }); + it.each([ + "git@github.com:contributor/paseo.git", + "https://github.com/contributor/paseo.git", + "ssh://git@github.com/contributor/paseo.git", + ])("preserves fork PR identity with branch.remote=%s", async (branchRemote) => { + execFileSync("git", ["remote", "add", "origin", "git@github.com:getpaseo/paseo.git"], { + cwd: repoDir, + }); + execFileSync("git", ["checkout", "-b", "topic"], { cwd: repoDir }); + execFileSync("git", ["config", "branch.topic.remote", branchRemote], { + cwd: repoDir, + }); + execFileSync("git", ["config", "branch.topic.pushRemote", branchRemote], { + cwd: repoDir, + }); + execFileSync("git", ["config", "branch.topic.merge", "refs/heads/topic"], { + cwd: repoDir, + }); + const headSha = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: repoDir, + encoding: "utf8", + }).trim(); + + expect(await readPullRequestLookupTargetFromFacts(repoDir, paseoHome)).toEqual({ + headRef: "topic", + headRepositoryOwner: "contributor", + headSha, + }); + }); + it("does not attach an owner when the tracked remote is the same GitHub repository", async () => { execFileSync("git", ["checkout", "-b", "local-feature"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "git@github.com:getpaseo/paseo.git"], { diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 8e2a89f0bd..e1246a90a5 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1369,6 +1369,23 @@ async function getGitConfigValue( } } +async function resolveGitRemoteUrl({ + cwd, + remote, + context, +}: { + cwd: string; + remote: string; + context?: CheckoutContext; +}): Promise { + const configuredUrl = await getGitConfigValue(cwd, `remote.${remote}.url`, context); + if (configuredUrl) { + return configuredUrl; + } + // Branch remote settings accept repository URLs as well as configured remote names. + return parseGitRemoteLocation(remote) ? remote : null; +} + async function getGitRemotePushUrl( cwd: string, remoteName: string, @@ -1433,7 +1450,7 @@ async function resolvePullRequestStatusLookupTarget( } const [branchRemoteUrl, originRemoteUrl, resolvedBaseRef] = await Promise.all([ - branchRemoteName ? getGitConfigValue(cwd, `remote.${branchRemoteName}.url`, context) : null, + branchRemoteName ? resolveGitRemoteUrl({ cwd, remote: branchRemoteName, context }) : null, getGitConfigValue(cwd, "remote.origin.url", context), getResolvedBaseRefForCwd(cwd, context), ]); @@ -1950,7 +1967,7 @@ async function resolvePullRequestLookupTargetFromPushConfig( const [pushRefspec, pushRemoteUrl, originRemoteUrl, resolvedBaseRef] = await Promise.all([ getGitConfigValue(cwd, `remote.${pushRemoteName}.push`, context), - getGitConfigValue(cwd, `remote.${pushRemoteName}.url`, context), + resolveGitRemoteUrl({ cwd, remote: pushRemoteName, context }), knownOriginRemoteUrl === null ? getGitConfigValue(cwd, "remote.origin.url", context) : null, knownResolvedBaseRef === null ? getResolvedBaseRefForCwd(cwd, context) : null, ]); @@ -2058,7 +2075,7 @@ export async function getCheckoutSnapshotFacts( getGitConfigValue(cwd, `branch.${inspected.currentBranch}.merge`, context), branchRemoteName === "origin" ? inspected.remoteUrl - : getGitConfigValue(cwd, `remote.${branchRemoteName}.url`, context), + : resolveGitRemoteUrl({ cwd, remote: branchRemoteName, context }), ]); } } From 0c5f472da1e22ea7227bc99769981674d6f7ab04 Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Mon, 14 Sep 2026 14:03:20 -0700 Subject: [PATCH 15/17] fix(plugins): expose server settings access (#4674) * fix(plugins): expose server settings access * fix(plugins): isolate settings subscriber state * test(plugins): read persisted settings before clients connect * test(app): reconnect seed client after old daemon restart * test(server): drain file creation events before deletion assertions * test(creation): expose errors when completion fails --------- Co-authored-by: Mohamed Boudra --- docs/plugins.md | 4 + .../owned-subscriptions-old-daemon.spec.ts | 1 + packages/plugin/src/server/contracts.ts | 19 ++- packages/plugin/src/server/index.ts | 2 + .../server/src/server/creation/index.test.ts | 2 +- .../src/server/file-observer/index.test.ts | 13 +- .../src/server/plugins/plugin-process.ts | 4 +- .../src/server/plugins/settings.e2e.test.ts | 122 +++++++++++++++++- .../src/server/plugins/settings/index.test.ts | 46 +++++++ .../src/server/plugins/settings/index.ts | 122 +++++++++++++----- public-docs/plugins/v0.8/providers.md | 5 + public-docs/plugins/v0.8/reference.md | 32 ++++- 12 files changed, 325 insertions(+), 47 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 6cf231932a..10c5b6413d 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -477,6 +477,10 @@ Its writer lives with the plugin subprocess, while its directory lives outside m 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. +`server.registerSettings(definition)` returns a server-side handle. Use `read()` for the current +`ready` or `invalid` state and `subscribe()` for successful saves, resets, and migrations. The +subscription cleanup belongs in the plugin's contribution cleanup when it outlives the entry. + ## 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/owned-subscriptions-old-daemon.spec.ts b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts index 5040cf9f46..17f4db2e85 100644 --- a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts +++ b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts @@ -113,6 +113,7 @@ test("does not repeat an assistant block when the current app paginates a publis page, "compatibility-after-reconnect: emit 1 coalesced agent stream updates", ); + // The browser and the seed client reconnect independently after the restart. await agent.client.connect(); await agent.client.waitForFinish(agent.agentId, 20_000); await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") }); diff --git a/packages/plugin/src/server/contracts.ts b/packages/plugin/src/server/contracts.ts index a296c28b1e..c1cf10fc15 100644 --- a/packages/plugin/src/server/contracts.ts +++ b/packages/plugin/src/server/contracts.ts @@ -9,10 +9,27 @@ export interface PluginHandlerContext { paseo: PaseoApi; } +export type PluginSettingsState = + | { + status: "ready"; + revision: string; + values: ZodOutput; + } + | { + status: "invalid"; + revision: string; + error: string; + }; + +export interface PluginSettings { + read(): Promise>; + subscribe(listener: (state: PluginSettingsState) => void | Promise): PluginCleanup; +} + export interface PluginServerContext extends PluginLifecycleRegistration { registerSettings( definition: import("../settings.js").SettingsDefinition, - ): void; + ): PluginSettings; handle( contract: PluginRpcContract, handler: ( diff --git a/packages/plugin/src/server/index.ts b/packages/plugin/src/server/index.ts index c673f25303..9e1d78b8bb 100644 --- a/packages/plugin/src/server/index.ts +++ b/packages/plugin/src/server/index.ts @@ -2,6 +2,8 @@ export type { PluginHandlerContext, PluginServerContext, PluginServerContribution, + PluginSettings, + PluginSettingsState, } from "./contracts.js"; export type { PluginHookContext, diff --git a/packages/server/src/server/creation/index.test.ts b/packages/server/src/server/creation/index.test.ts index f4f0205225..1eaf4a7fd9 100644 --- a/packages/server/src/server/creation/index.test.ts +++ b/packages/server/src/server/creation/index.test.ts @@ -113,7 +113,7 @@ test("workspace readiness is observable before provider startup and duplicate re expect(f.updates[0]).toMatchObject({ workspaceId: workspace.id, agentId: agent.id }); const duplicate = f.service.create(f.input); f.provider.resolve(); - expect(await first).toMatchObject({ phase: "completed", workspace, agent }); + expect(await first).toMatchObject({ phase: "completed", error: null, workspace, agent }); expect(await duplicate).toEqual(await first); expect(f.calls).toEqual(["workspace", "agent", "prompt"]); expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(await first); diff --git a/packages/server/src/server/file-observer/index.test.ts b/packages/server/src/server/file-observer/index.test.ts index 0427cd823b..455a75b806 100644 --- a/packages/server/src/server/file-observer/index.test.ts +++ b/packages/server/src/server/file-observer/index.test.ts @@ -213,7 +213,8 @@ test("observes a thousand concurrent writes and remains healthy after delete and const directories = Array.from({ length: 20 }, (_, index) => join(root, `dir-${index}`)); await Promise.all(directories.map((directory) => mkdir(directory))); const observed = new Map>(); - const subscription = await subscribeToFileChanges(root, (error, events) => { + const observer = createObserver(); + const subscription = await observer.subscribe(root, (error, events) => { expect(error).toBeNull(); for (const event of events) { const types = observed.get(event.path) ?? new Set(); @@ -240,6 +241,16 @@ test("observes a thousand concurrent writes and remains healthy after delete and .poll(() => paths.filter((path) => !observed.has(path)), { timeout: 60_000 }) .toEqual([]); + // Native notifications arrive before reconciliation finishes. Drain that work + // so pending creates cannot coalesce with this test's separate deletion phase. + await expect + .poll(() => observer.getDiagnostics(), { timeout: 10_000 }) + .toMatchObject({ + pendingEventCount: 0, + pendingReconciliationWorkCount: 0, + reconciliationInFlightCount: 0, + }); + const removedPaths = paths.slice(0, 100); await Promise.all(removedPaths.map((path) => rm(path))); await expect diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index ee8a598a64..29523a7c00 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -10,6 +10,7 @@ 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 type { ZodType } from "zod"; import { ProviderEventSchema, type ProviderConnection, @@ -23,7 +24,7 @@ import { createPluginClientId } from "./plugin-session-identity.js"; import { PluginSettingsStore } from "./settings/index.js"; let settingsStore: PluginSettingsStore | null = null; -function registerSettings(definition: SettingsDefinition) { +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); @@ -33,6 +34,7 @@ function registerSettings(definition: SettingsDefinition) { register(handlers.reset.contract, (input) => handlers.reset.handle(handlers.reset.contract.input.parse(input)), ); + return handlers.settings; } type RpcHandler = (input: unknown, context: PluginHandlerContext) => unknown | Promise; diff --git a/packages/server/src/server/plugins/settings.e2e.test.ts b/packages/server/src/server/plugins/settings.e2e.test.ts index 5828e84beb..a6ccf3f2cc 100644 --- a/packages/server/src/server/plugins/settings.e2e.test.ts +++ b/packages/server/src/server/plugins/settings.e2e.test.ts @@ -1,19 +1,102 @@ import { resolveDaemonVersion } from "../daemon-version.js"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { expect, test } from "vitest"; +import { expect, onTestFinished, test } from "vitest"; import { z } from "zod"; -import { settingsRpc } from "@getpaseo/plugin"; +import { defineRpc, settingsRpc } from "@getpaseo/plugin"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +test("server reads saved settings after daemon restart before any client connects", async () => { + const root = await mkdtemp(path.join(tmpdir(), "settings-restart-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const directory = path.join(root, "plugin"); + const startupReport = path.join(root, "startup.json"); + await mkdir(path.join(directory, "server"), { recursive: true }); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ + id: "settings-startup", + requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` }, + }), + ); + await writeFile( + path.join(directory, "server", "report.ts"), + `import { writeFile } from "node:fs/promises"; +export async function reportStartup(settings) { + const state = await settings.read(); + await writeFile(${JSON.stringify(startupReport)}, JSON.stringify(state)); +}`, + ); + await writeFile( + path.join(directory, "index.server.ts"), + `import { defineSettings } from "@getpaseo/plugin"; +import { z } from "zod"; +import { reportStartup } from "./server/report"; +export default function(server) { + const settings = server.registerSettings(defineSettings({ id: "display", scope: "host", version: 1, schema: z.object({ enabled: z.boolean().default(true) }) })); + const startup = reportStartup(settings); + return async () => { await startup; }; +}`, + ); + const options = { + paseoHomeRoot: path.join(root, "daemon"), + staticDir: path.join(root, "static"), + cleanup: false, + pluginsEnabled: true, + plugins: { + "settings-startup": { source: "directory" as const, path: directory, enabled: true }, + }, + }; + const first = await createTestPaseoDaemon(options); + onTestFinished(() => first.close()); + const rpc = settingsRpc("display"); + const readStartup = async () => + rpc.read.output.parse(JSON.parse(await readFile(startupReport, "utf8"))); + await expect + .poll(readStartup) + .toEqual({ status: "ready", revision: "missing", values: { enabled: true } }); + + const client = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws`, appVersion: "0.8.0" }); + onTestFinished(() => client.close()); + await client.connect(); + const saved = rpc.write.output.parse( + await client.invokePluginRpc("settings-startup", rpc.write.name, { + revision: "missing", + values: { enabled: false }, + }), + ); + expect(saved).toMatchObject({ status: "saved", values: { enabled: false } }); + const persisted = rpc.read.output.parse( + await client.invokePluginRpc("settings-startup", rpc.read.name, {}), + ); + await client.close(); + await first.close(); + await rm(startupReport); + + const restarted = await createTestPaseoDaemon(options); + onTestFinished(() => restarted.close()); + // No client is created for this daemon: the report comes from server startup alone. + await expect.poll(readStartup).toEqual(persisted); +}, 60_000); + 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 serverReadRpc = defineRpc({ + name: "settings-test.server-read", + input: z.object({}), + output: z.json(), + }); + const serverChangesRpc = defineRpc({ + name: "settings-test.server-changes", + input: z.object({}), + output: z.object({ count: z.number().int() }), + }); const read = async (client: DaemonClient, pluginId = "settings-test") => rpc.read.output.parse(await client.invokePluginRpc(pluginId, rpc.read.name, {})); const changed: string[] = []; @@ -27,9 +110,18 @@ test("two clients share settings, observe changes, and preserve values through p ); await writeFile( path.join(directory, "index.server.ts"), - `import { defineSettings } from "@getpaseo/plugin"; + `import { defineRpc, 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 () => {}; }`, +const serverRead = defineRpc({ name: "settings-test.server-read", input: z.object({}), output: z.json() }); +const serverChanges = defineRpc({ name: "settings-test.server-changes", input: z.object({}), output: z.object({ count: z.number().int() }) }); +export default function(server) { + const settings = server.registerSettings(defineSettings({ id: "display", scope: "host", version: 1, schema: z.object({ enabled: z.boolean().default(true) }) })); + let changes = 0; + settings.subscribe(() => { changes += 1; }); + server.handle(serverRead, () => settings.read()); + server.handle(serverChanges, () => ({ count: changes })); + return () => {}; +}`, ); await first.connect(); await second.connect(); @@ -42,6 +134,16 @@ export default function(server) { server.registerSettings(defineSettings({ id: " await first.installDirectoryPlugin(directory); const initial = await read(first); expect(initial).toMatchObject({ status: "ready", values: { enabled: true } }); + expect( + serverReadRpc.output.parse( + await first.invokePluginRpc("settings-test", serverReadRpc.name, {}), + ), + ).toMatchObject({ status: "ready", values: { enabled: true } }); + expect( + serverChangesRpc.output.parse( + await first.invokePluginRpc("settings-test", serverChangesRpc.name, {}), + ), + ).toEqual({ count: 0 }); expect( await second.invokePluginRpc("settings-test", rpc.write.name, { revision: initial.revision, @@ -50,6 +152,16 @@ export default function(server) { server.registerSettings(defineSettings({ id: " ).toMatchObject({ status: "saved" }); await expect.poll(() => changed).toEqual(["display"]); expect(await read(first)).toMatchObject({ values: { enabled: false } }); + expect( + serverReadRpc.output.parse( + await first.invokePluginRpc("settings-test", serverReadRpc.name, {}), + ), + ).toMatchObject({ status: "ready", values: { enabled: false } }); + expect( + serverChangesRpc.output.parse( + await first.invokePluginRpc("settings-test", serverChangesRpc.name, {}), + ), + ).toEqual({ count: 1 }); expect( await first.invokePluginRpc("settings-test", rpc.write.name, { revision: initial.revision, diff --git a/packages/server/src/server/plugins/settings/index.test.ts b/packages/server/src/server/plugins/settings/index.test.ts index c367c93be3..de81919eae 100644 --- a/packages/server/src/server/plugins/settings/index.test.ts +++ b/packages/server/src/server/plugins/settings/index.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, expect, test } from "vitest"; import { defineSettings } from "@getpaseo/plugin"; +import type { PluginSettingsState } from "@getpaseo/plugin/server"; import { z } from "zod"; import { PluginSettingsStore } from "./index.js"; @@ -19,6 +20,10 @@ const definition = defineSettings({ count: z.number().int().min(1).default(5), }), }); +type DisplaySettingsState = PluginSettingsState; +function mutateNotification(state: DisplaySettingsState): void { + if (state.status === "ready") state.values.count = 99; +} async function setup() { const directory = await mkdtemp(path.join(tmpdir(), "plugin-settings-")); roots.push(directory); @@ -47,6 +52,47 @@ test("defaults, atomic saves, concurrent revisions, and restart persistence", as expect(changes).toEqual(["display"]); }); +test("server settings read current values and notify after successful writes", async () => { + const { handlers } = await setup(); + const notifications: unknown[] = []; + const unsubscribe = handlers.settings.subscribe((state) => { + notifications.push(state); + mutateNotification(state); + }); + + expect(await handlers.settings.read()).toEqual({ + status: "ready", + revision: "missing", + values: { enabled: true, count: 5 }, + }); + const saved = await handlers.write.handle({ + revision: "missing", + values: { enabled: false, count: 10 }, + }); + expect(saved).toMatchObject({ status: "saved", values: { enabled: false, count: 10 } }); + const current = await handlers.settings.read(); + expect(current).toMatchObject({ + status: "ready", + values: { enabled: false, count: 10 }, + }); + expect(notifications).toEqual([ + { + status: "ready", + revision: current.revision, + values: { enabled: false, count: 99 }, + }, + ]); + + expect( + await handlers.write.handle({ revision: current.revision, values: { count: -1 } }), + ).toMatchObject({ status: "invalid" }); + expect(notifications).toHaveLength(1); + + unsubscribe(); + await handlers.reset.handle({ revision: current.revision }); + expect(notifications).toHaveLength(1); +}); + 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( diff --git a/packages/server/src/server/plugins/settings/index.ts b/packages/server/src/server/plugins/settings/index.ts index ccdd3e5dd6..775a47f6a4 100644 --- a/packages/server/src/server/plugins/settings/index.ts +++ b/packages/server/src/server/plugins/settings/index.ts @@ -1,9 +1,9 @@ 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"; +import { z, type ZodType } from "zod"; +import { defineSettings, settingsRpc, type SettingsDefinition } from "@getpaseo/plugin"; +import type { PluginSettings, PluginSettingsState } from "@getpaseo/plugin/server"; const envelopeSchema = z.object({ version: z.number().int().positive(), values: z.json() }); function message(error: unknown): string { @@ -12,6 +12,25 @@ function message(error: unknown): string { } const revisionOf = (raw: string) => createHash("sha256").update(raw).digest("hex"); +type SettingsListener = ( + state: PluginSettingsState, +) => void | Promise; +type SettingsWriteState = + | { status: "saved"; revision: string; values: z.output } + | { status: "conflict"; error: string } + | { status: "invalid"; error: string }; + +function reportListenerError(id: string, error: unknown): void { + console.error(`Plugin settings subscriber failed for ${id}`, error); +} + +function copySettingsState( + state: PluginSettingsState, +): PluginSettingsState { + if (state.status === "invalid") return { ...state }; + return { ...state, values: structuredClone(state.values) }; +} + /** One instance per installation. The subprocess lifetime gives writes a single owner. */ export class PluginSettingsStore { private readonly definitions = new Map(); @@ -24,24 +43,43 @@ export class PluginSettingsStore { this.changed = changed; } - register(definition: SettingsDefinition) { + 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 listeners = new Set>(); + const notify = (state: PluginSettingsState): void => { + for (const listener of listeners) { + try { + void Promise.resolve(listener(copySettingsState(state))).catch((error) => + reportListenerError(definition.id, error), + ); + } catch (error) { + reportListenerError(definition.id, error); + } + } + }; const rpc = settingsRpc(definition.id); + const read = () => this.serial(() => this.read(definition, notify)); + const write = (input: z.output) => + this.serial(() => this.write(definition, input.revision, input.values, "save", notify)); + const reset = (input: z.output) => + this.serial(() => this.write(definition, input.revision, {}, "reset", notify)); + const settings: PluginSettings = { + read, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; 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")), - }, + settings, + read: { contract: rpc.read, handle: read }, + write: { contract: rpc.write, handle: write }, + reset: { contract: rpc.reset, handle: reset }, }; } @@ -62,9 +100,10 @@ export class PluginSettingsStore { } } - private async read( - definition: SettingsDefinition, - ): Promise["read"]["output"]>> { + private async read( + definition: SettingsDefinition, + notify: (state: PluginSettingsState) => void, + ): Promise> { const stored = await this.stored(definition.id); try { const envelope = stored.raw === null ? null : envelopeSchema.parse(JSON.parse(stored.raw)); @@ -76,58 +115,71 @@ export class PluginSettingsStore { 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 }; + const parsed = await definition.schema.parseAsync(values); + z.json().parse(parsed); + const migrated = envelope !== null && envelope.version !== definition.version; + const revision = migrated ? await this.persist(definition, parsed) : stored.revision; + const result: PluginSettingsState = { + status: "ready", + values: parsed, + revision, + }; + if (migrated) { + notify(result); + this.changed(definition.id); + } + return result; } catch (error) { return { status: "invalid", revision: stored.revision, error: message(error) }; } } - private async write( - definition: SettingsDefinition, + private async write( + definition: SettingsDefinition, revision: string, values: unknown, intent: "save" | "reset", - ): Promise["write"]["output"]>> { + notify: (state: PluginSettingsState) => void, + ): Promise> { 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>; + 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)); + parsed = await definition.schema.parseAsync(values); + z.json().parse(parsed); } catch (error) { return { status: "invalid", error: message(error) }; } - return { status: "saved", values: parsed, revision: await this.persist(definition, parsed) }; + const nextRevision = await this.persist(definition, parsed); + notify({ status: "ready", values: parsed, revision: nextRevision }); + this.changed(definition.id); + return { status: "saved", values: parsed, revision: nextRevision }; } - private async persist( - definition: SettingsDefinition, - values: z.output>, + private async persist( + definition: SettingsDefinition, + values: z.output, ) { + const jsonValues = z.json().parse(values); 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 }); + const raw = JSON.stringify({ version: definition.version, values: jsonValues }); 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/public-docs/plugins/v0.8/providers.md b/public-docs/plugins/v0.8/providers.md index 71eaa08dce..744366ade6 100644 --- a/public-docs/plugins/v0.8/providers.md +++ b/public-docs/plugins/v0.8/providers.md @@ -117,6 +117,11 @@ later through `session.config`. prompt, MCP servers, tool policy, model, mode, settings, opaque provider options, and persistence preference. +For persisted host-scoped plugin settings, use the handle returned by `server.registerSettings()`. +Read it in catalog and session callbacks, and subscribe when the provider keeps in-memory +configuration. See [settings](./reference.md#persisted-values) for the `read()` and `subscribe()` +contract. + Create the native session with the selected catalog values, then publish its effective state: ```ts diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index b8049c27d0..2dd5bd3cdd 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -1044,9 +1044,22 @@ export const preferences = defineSettings({ }); ``` -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. +Register it in `index.server.ts` before returning cleanup. The returned handle lets server code +read the document and react to changes. A screen using its own data can remain client-only. + +```ts +export default function contribute(server: PluginServerContext) { + const settings = server.registerSettings(preferences); + + settings.subscribe((next) => { + if (next.status === "ready") { + console.log("Settings changed", next.revision); + } + }); + + return () => {}; +} +``` | Definition field | Contract | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | @@ -1065,6 +1078,19 @@ Call `useSettings(preferences)` in any contributed component. It returns a discr | `invalid` | `error` and `revision`; stored data is preserved. | | `error` | `error` from the read/connection. | +The server handle exposes `read()` and `subscribe()`. `read()` returns the same `ready` or +`invalid` state as the client hook, including the opaque revision. `subscribe()` returns a cleanup +function and receives a new `ready` state after a successful save, reset, or migration. Invalid +writes and revision conflicts do not notify subscribers. Listener failures are logged without +turning a committed write into a failed save. + +```ts +const current = await settings.read(); +if (current.status === "ready") { + // Use current.values and current.revision. +} +``` + Every state also exposes `saving`, `saveError`, and these actions: - `save(values, revision): Promise` validates and saves a complete document. Returns From 0aca3b605a6f3dc779428d8c9aa394287a1381f6 Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Mon, 14 Sep 2026 14:03:38 -0700 Subject: [PATCH 16/17] fix(plugins): transform whole timeline items before grouping and Markdown splitting (#4675) Send every original tool call and each whole accumulated assistant message through plugin transformers before display grouping or Markdown splitting. Preserve streaming callbacks, original payloads, native Markdown block identities, and chat outline reading position. Reject legacy fragment caches and retain failing-first plugin-interface and browser regressions. Fixes #4673. Fixes #4789. Consolidates #4820. Co-authored-by: Matt Cowger Co-authored-by: Jegor Kitskerkin Co-authored-by: Mohamed Boudra --- docs/agent-stream-performance.md | 6 +- docs/plugins.md | 6 +- packages/app/e2e/browser/chat-outline.spec.ts | 27 ++ .../app/e2e/browser/plugin-timeline.spec.ts | 35 ++ .../app/e2e/support/helpers/chat-outline.ts | 35 ++ .../e2e/support/helpers/plugin-timeline.ts | 144 +++++++ .../support/helpers/timeline-pagination.ts | 11 +- .../chat-outline/use-chat-outline.ts | 23 +- .../app/src/agent-stream/presentation.test.ts | 351 ++++++++++++++++++ packages/app/src/agent-stream/presentation.ts | 138 +++++++ packages/app/src/agent-stream/view.tsx | 60 ++- .../app/src/plugins/timeline/projection.ts | 19 +- .../src/runtime/replica-cache/index.test.ts | 25 +- .../app/src/runtime/replica-cache/index.ts | 7 +- .../timeline/session-stream-reducers.test.ts | 55 ++- .../src/timeline/session-stream-reducers.ts | 46 +-- packages/app/src/types/stream.test.ts | 18 +- packages/app/src/types/stream.ts | 95 +---- public-docs/plugins/v0.8/reference.md | 7 +- 19 files changed, 865 insertions(+), 243 deletions(-) create mode 100644 packages/app/e2e/browser/plugin-timeline.spec.ts create mode 100644 packages/app/e2e/support/helpers/plugin-timeline.ts create mode 100644 packages/app/src/agent-stream/presentation.test.ts create mode 100644 packages/app/src/agent-stream/presentation.ts diff --git a/docs/agent-stream-performance.md b/docs/agent-stream-performance.md index 0d2fd30fa6..19d84d2f50 100644 --- a/docs/agent-stream-performance.md +++ b/docs/agent-stream-performance.md @@ -12,7 +12,8 @@ provider deltas (every provider streams incrementally) → recordTimeline: one canonical row per flushed item → agent_stream ws message → reducer queue (app, one commit per frame) → session store - → paced reveal (app, per assistant/reasoning item) → markdown blocks → paint + → source-item plugin transforms → native Markdown blocks / tool grouping + → paced reveal (app, per displayed item) → paint ``` Every provider delivers incremental text, so there is no provider that needs special handling: Claude via `includePartialMessages`, Codex via `agent_message_delta`, ACP agents via `agent_message_chunk`, Pi and OMP via `text_delta`. @@ -28,6 +29,9 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste - **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`. - **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not. - **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal. +- **Markdown blocks belong to presentation.** `agent-stream/presentation.ts` retains completed native + blocks and parses only the growing last block during append. Splitting in the reducer discards + paragraph separators and exposes fragments to plugin callbacks, which need the whole source text. - **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each. - **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete. - **The reducer queue commits on a frame, with a timer as the ceiling.** 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. diff --git a/docs/plugins.md b/docs/plugins.md index 10c5b6413d..98ae45093f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -371,8 +371,10 @@ work inside it. See the maintained [composer pill example](../plugin-examples/lo Timeline transformers and renderers are client contributions. The daemon's canonical rows and built-in projection stay unchanged. The app transforms each source item while building the render -model, for both fetched history and live events. The input includes `phase: "streaming" | "complete"`. -Paseo memoizes by source-item reference and derives every replacement ID from the source identity, so +model, for both fetched history and live events, before native Markdown splitting and Overview +tool grouping. Assistant callbacks receive the accumulated source text, never display fragments. +Live assistant messages use `phase: "streaming"`; committing to history makes them `"complete"`. +Paseo memoizes by source-item reference and phase and derives replacement IDs from source identity, so streaming updates preserve mounted component identity. `query.itemType` selects one public `AgentTimelineItem.type`. The callback owns any detailed diff --git a/packages/app/e2e/browser/chat-outline.spec.ts b/packages/app/e2e/browser/chat-outline.spec.ts index 8e4532ee4f..bc58e46ab5 100644 --- a/packages/app/e2e/browser/chat-outline.spec.ts +++ b/packages/app/e2e/browser/chat-outline.spec.ts @@ -20,6 +20,8 @@ import { pointAtChatOutlineRowEdge, pressEnterOnFocusedPrompt, splitCurrentPanelRight, + withStreamingMarkdownOutline, + expectReadingStreamedMarkdown, } from "../support/helpers/chat-outline"; import { expectTimelineAtMaximumScrollWithPromptVisible, @@ -41,6 +43,31 @@ const WIDE_VIEWPORT = { width: 1440, height: 900 }; const LOADED_TURNS = 16; test.describe("desktop chat outline", () => { + test("keeps the prompt marked while reading split Markdown blocks and after completion", async ({ + page, + }) => { + test.setTimeout(120_000); + const prompt = "Explain in several paragraphs."; + await withStreamingMarkdownOutline(async (agent) => { + await agent.client.sendAgentMessage(agent.agentId, "Earlier prompt."); + await agent.client.waitForFinish(agent.agentId, 30_000); + await page.setViewportSize(WIDE_VIEWPORT); + await openAgentTimeline(page, agent); + await agent.client.sendAgentMessage(agent.agentId, prompt); + await expectChatOutlinePrompts(page, 2); + + await expectReadingStreamedMarkdown(page, prompt); + await expectActiveChatOutlinePrompt(page, 2); + await agent.client.waitForFinish(agent.agentId, 30_000); + await expectActiveChatOutlinePrompt(page, 2); + + await page.reload({ waitUntil: "domcontentloaded" }); + await expectActiveChatOutlinePrompt(page, 2); + await clickChatOutlineRowEdge(page, 2); + await expectTimelinePromptLandedBelowTop(page, prompt); + }); + }); + test("indexes unloaded prompts and jumps with one bounded merged page", async ({ page }) => { test.setTimeout(120_000); const agent = await seedLongMockAgentTimeline({ turns: 80 }); diff --git a/packages/app/e2e/browser/plugin-timeline.spec.ts b/packages/app/e2e/browser/plugin-timeline.spec.ts new file mode 100644 index 0000000000..a344611a4f --- /dev/null +++ b/packages/app/e2e/browser/plugin-timeline.spec.ts @@ -0,0 +1,35 @@ +import { test } from "../support/fixtures"; +import { + withTimelinePlugin, + requestPluginTimeline, + interactWithStreamingCard, + expectWholeCompletedCard, + expectBothConsecutiveTools, +} from "../support/helpers/plugin-timeline"; + +for (const width of [1100, 390]) { + test(`assistant plugin receives the whole streaming message at width ${width}`, async ({ + page, + }, info) => { + await page.setViewportSize({ width, height: 900 }); + await withTimelinePlugin(page, info, "assistant", async (agent) => { + await requestPluginTimeline(agent); + await interactWithStreamingCard(page); + await agent.client.waitForFinish(agent.agentId, 30_000); + await expectWholeCompletedCard(page); + // Local state is checked during growth; the viewport remounts on the history handoff. + await page.reload({ waitUntil: "domcontentloaded" }); + await expectWholeCompletedCard(page); + }); + }); +} + +test("Overview preserves both consecutive tool plugin cards", async ({ page }, info) => { + await withTimelinePlugin(page, info, "tools", async (agent) => { + await requestPluginTimeline(agent); + await agent.client.waitForFinish(agent.agentId, 30_000); + await expectBothConsecutiveTools(page); + await page.reload({ waitUntil: "domcontentloaded" }); + await expectBothConsecutiveTools(page); + }); +}); diff --git a/packages/app/e2e/support/helpers/chat-outline.ts b/packages/app/e2e/support/helpers/chat-outline.ts index b89f4e6e2a..8ace116ec7 100644 --- a/packages/app/e2e/support/helpers/chat-outline.ts +++ b/packages/app/e2e/support/helpers/chat-outline.ts @@ -2,6 +2,41 @@ import { expect, type Locator, type Page } from "@playwright/test"; import { openSettings } from "./app"; import { openSettingsSection } from "./settings"; import { runWorkspaceActionFromCommandCenter } from "./command-center-workspace-actions"; +import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; + +export async function withStreamingMarkdownOutline( + run: (agent: MockAgentWorkspace) => Promise, +): Promise { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "chat-outline-markdown-", + title: "Streaming Markdown outline", + featureValues: { + mockStreamingAssistantResponse: Array.from( + { length: 60 }, + (_, index) => `Paragraph ${index + 1}.`, + ).join("\n\n"), + mockStreamingAssistantIntervalMs: 80, + }, + }); + try { + await run(agent); + } finally { + await agent.cleanup(); + } +} + +export async function expectReadingStreamedMarkdown(page: Page, prompt: string): Promise { + await expect(page.getByText("Paragraph 30.", { exact: true }).last()).toBeVisible(); + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const promptRow = timeline.getByTestId("user-message").filter({ hasText: prompt }); + await expect + .poll(async () => { + const [viewport, row] = await Promise.all([timeline.boundingBox(), promptRow.boundingBox()]); + return viewport && row ? row.y + row.height - viewport.y : Number.POSITIVE_INFINITY; + }) + .toBeLessThan(0); + await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible(); +} export function chatOutlineRail(page: Page): Locator { return page.getByTestId("chat-outline-rail"); diff --git a/packages/app/e2e/support/helpers/plugin-timeline.ts b/packages/app/e2e/support/helpers/plugin-timeline.ts new file mode 100644 index 0000000000..d6656f3d3a --- /dev/null +++ b/packages/app/e2e/support/helpers/plugin-timeline.ts @@ -0,0 +1,144 @@ +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 { pluginRequirements } from "./plugin-fixture"; +import { connectNewWorkspaceDaemonClient } from "./new-workspace"; +import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; + +const PLUGIN_ID = "timeline-transform-e2e"; +export const ASSISTANT_TEXT = + "Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming."; + +const CLIENT_SOURCE = `import React, { useState } from "react"; +import { Text, View, Pressable } from "react-native"; +import { z } from "zod"; + +function Card({ item }) { + const [count, setCount] = useState(0); + return + {item.data.label + " " + item.data.phase} + {item.data.text} + setCount(count + 1)}> + {"Card clicks " + count} + + ; +} + +export default function contribute(client) { + for (const itemType of ["assistant_message", "tool_call"]) { + client.addTimelineTransformer({ + id: itemType.replaceAll("_", "-"), + query: { itemType }, + transform({ item, phase }) { + return { items: [{ type: "plugin", kind: "card", version: 1, data: { + label: item.type === "tool_call" ? "Tool " + item.name : "Assistant", + text: item.type === "tool_call" ? item.callId : item.text, + phase, + } }] }; + }, + }); + } + client.addTimelineRenderer({ + kind: "card", version: 1, + schema: z.object({ label: z.string(), text: z.string(), phase: z.enum(["streaming", "complete"]) }), + Component: Card, + }); + return () => {}; +}`; + +export async function withTimelinePlugin( + page: Page, + info: TestInfo, + mode: "assistant" | "tools", + run: (agent: MockAgentWorkspace) => Promise, +): Promise { + info.setTimeout(120_000); + await page.addInitScript(() => { + localStorage.setItem( + "@paseo:app-settings", + JSON.stringify({ toolCallDetailLevel: "overview" }), + ); + }); + const directory = await mkdtemp(path.join(tmpdir(), "paseo-timeline-plugin-")); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "timeline-plugin-", + title: "Timeline plugin regression", + model: "ten-second-stream", + featureValues: + mode === "assistant" + ? { + mockStreamingAssistantResponse: ASSISTANT_TEXT, + mockStreamingAssistantIntervalMs: 300, + } + : {}, + }); + const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await pluginClient.getDaemonConfig(); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }), + ); + await writeFile(path.join(directory, "index.client.tsx"), CLIENT_SOURCE); + await pluginClient.patchDaemonConfig({ pluginsEnabled: true }); + await pluginClient.installDirectoryPlugin(directory); + await openAgentRoute(page, agent); + await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible(); + await run(agent); + await info.attach("timeline-plugin", { + body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }), + contentType: "image/png", + }); + } catch (error) { + await info.attach("timeline-before-cleanup", { + body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }), + contentType: "image/png", + }); + throw error; + } finally { + await pluginClient.removePlugin(PLUGIN_ID); + await pluginClient.patchDaemonConfig({ + pluginsEnabled: previous.config.pluginsEnabled ?? false, + }); + await pluginClient.close(); + await agent.cleanup(); + await rm(directory, { recursive: true, force: true }); + } +} + +export async function requestPluginTimeline(agent: MockAgentWorkspace): Promise { + await agent.client.sendAgentMessage(agent.agentId, "Exercise timeline transforms."); +} + +export async function interactWithStreamingCard(page: Page): Promise { + await expect(page.getByRole("heading", { name: "Assistant streaming", exact: true })).toHaveCount( + 1, + ); + await page.getByRole("button", { name: "Card clicks 0", exact: true }).click(); + await expect(page.getByText(/Skill header\s+# Instructions/)).toBeVisible(); + await expect(page.getByRole("heading", { name: "Assistant streaming", exact: true })).toHaveCount( + 1, + ); + await expect(page.getByRole("button", { name: "Card clicks 1", exact: true })).toBeVisible(); + await expect(page.getByTestId("assistant-message")).toHaveCount(0); +} + +export async function expectWholeCompletedCard(page: Page): Promise { + await expect(page.getByRole("heading", { name: "Assistant complete", exact: true })).toHaveCount( + 1, + ); + await expect(page.getByText(ASSISTANT_TEXT, { exact: true })).toBeVisible(); + await expect(page.getByTestId("assistant-message")).toHaveCount(0); +} + +export async function expectBothConsecutiveTools(page: Page): Promise { + // read and grep are consecutive source calls in the fixture. Overview used to + // send only the later call to the plugin, so the read card never existed. + const read = page.getByRole("heading", { name: "Tool read complete", exact: true }).first(); + await read.scrollIntoViewIfNeeded(); + await expect(read).toBeVisible(); + const grep = page.getByRole("heading", { name: "Tool grep complete", exact: true }).first(); + await grep.scrollIntoViewIfNeeded(); + await expect(grep).toBeVisible(); +} diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts index 0cdd34857c..d6ca1b38e4 100644 --- a/packages/app/e2e/support/helpers/timeline-pagination.ts +++ b/packages/app/e2e/support/helpers/timeline-pagination.ts @@ -402,8 +402,15 @@ export async function expectStableHistoryStartGutter(page: Page): Promise export async function scrollTimelinePromptIntoView(page: Page, prompt: string): Promise { const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); const row = timeline.getByTestId("user-message").filter({ hasText: prompt }); - await row.scrollIntoViewIfNeeded(); - await expect(row).toBeVisible(); + const [viewport, target] = await Promise.all([timeline.boundingBox(), row.boundingBox()]); + if (!viewport || !target) { + throw new Error(`Expected a rendered timeline prompt for ${prompt}`); + } + // Programmatic scrolling does not express the user's intent to stop following output. + await timeline.hover(); + await page.mouse.wheel(0, target.y - viewport.y - (viewport.height - target.height) / 2); + await expect(row).toBeInViewport({ ratio: 1 }); + await waitForTimelineGeometryToSettle(page); await expect .poll(async () => (await readTimelineViewport(page)).scrollTop) .toBeGreaterThan(HISTORY_START_THRESHOLD_PX); diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts index 6d66ec4379..39938a4b9c 100644 --- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts @@ -41,7 +41,7 @@ export interface ChatOutline { prompts: ChatOutlinePrompt[]; activePrompt: ActivePromptSource; jumpToPrompt: (seq: number) => void; - reportReadingPosition: (rowId: string | null) => void; + reportReadingPosition: (seq: number | null) => void; } export function useChatOutline({ @@ -59,7 +59,7 @@ export function useChatOutline({ const [index, setIndex] = useState(null); const [pendingJump, setPendingJump] = useState(null); const [activePrompt] = useState(createActivePromptPublisher); - const readingRowIdRef = useRef(null); + const readingSeqRef = useRef(null); const nextJumpRequestIdRef = useRef(0); const nextIndexRequestIdRef = useRef(0); const loadedItems = useMemo(() => [...tail, ...(head ?? NO_STREAM_ITEMS)], [head, tail]); @@ -107,26 +107,21 @@ export function useChatOutline({ }; }, [agentId, enabled, serverId, timelineEpoch, latestPromptSeq]); - // The transcript names the row it is showing; the outline turns that into a prompt using the - // complete index, so unloaded rows never have to exist in the DOM to be marked. + // The transcript resolves display rows (including Markdown blocks and plugin cards) to + // timeline positions. The outline uses the complete index, including unloaded prompts. const publishActivePrompt = useStableEvent(() => { - const rowId = readingRowIdRef.current; - const anchorSeq = - rowId === null - ? null - : (loadedItems.find((item) => item.id === rowId)?.timelineCursor?.seq ?? null); - activePrompt.publish(resolveActivePromptSeq(prompts, anchorSeq)); + activePrompt.publish(resolveActivePromptSeq(prompts, readingSeqRef.current)); }); - const reportReadingPosition = useStableEvent((rowId: string | null) => { - readingRowIdRef.current = rowId; + const reportReadingPosition = useStableEvent((seq: number | null) => { + readingSeqRef.current = seq; publishActivePrompt(); }); useEffect(() => { nextJumpRequestIdRef.current += 1; setPendingJump(null); - readingRowIdRef.current = null; + readingSeqRef.current = null; activePrompt.publish(null); }, [activePrompt, agentId, timelineEpoch]); @@ -134,7 +129,7 @@ export function useChatOutline({ // who never scrolls would otherwise sit on an unmarked rail. useEffect(() => { publishActivePrompt(); - }, [loadedItems, prompts, publishActivePrompt]); + }, [prompts, publishActivePrompt]); useEffect(() => { if (pendingJump === null) return; diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts new file mode 100644 index 0000000000..05df7180bf --- /dev/null +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -0,0 +1,351 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; +import { runPluginClientBundle, type PluginClientRuntime } from "@/plugins/evaluate"; +import type { InstalledPlugin } from "@/plugins/types"; +import { + applyStreamEvent, + hydrateStreamState, + type StreamItem, + type ToolCallItem, +} from "@/types/stream"; +import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model"; +import { createStreamPresentation } from "./presentation"; + +const runtime = { + paseo: {}, + async rpc() {}, + openSettings() {}, + openSurface() {}, + openPanel() {}, + addHeaderButton() { + return { update() {}, remove() {} }; + }, + addComposerPill() { + return { update() {}, remove() {} }; + }, +} as unknown as PluginClientRuntime; + +function installProbe( + itemType: "assistant_message" | "tool_call" | "reasoning", + condition = "true", +): InstalledPlugin { + const clientBundle = `(function() { + return { default: function(client) { + client.addTimelineTransformer({ + id: "probe", + query: { itemType: ${JSON.stringify(itemType)} }, + transform: function({ item, phase }) { + if (!(${condition})) return undefined; + return { + items: [{ + type: "plugin", + kind: "probe", + version: 1, + data: item.type === "tool_call" + ? { callId: item.callId, name: item.name, status: item.status, + detail: item.detail, ...(item.metadata ? { metadata: item.metadata } : {}), error: item.error, phase: phase } + : { text: item.text, phase: phase } + }] + }; + } + }); + return function() {}; + } }; + })`; + const evaluated = runPluginClientBundle("probe-plugin", clientBundle, runtime); + return { + ...evaluated, + serverId: "host-1", + clientBundle, + lifetime: new AbortController(), + queryClient: new QueryClient(), + }; +} + +function installedTransform(plugin: InstalledPlugin): TimelineItemTransform { + return (input) => transformTimelineItem({ ...input, plugins: [plugin] }); +} + +function toolCall(callId: string, name: string): ToolCallItem { + return { + kind: "tool_call", + id: `tool-${callId}`, + timestamp: new Date("2026-01-01T00:00:00.000Z"), + payload: { + source: "agent", + data: { + provider: "codex", + callId, + name, + status: "completed", + error: null, + detail: { type: "unknown", input: { callId }, output: { callId } }, + }, + }, + }; +} + +function pluginData(items: StreamItem[]): unknown[] { + return items.flatMap((item) => (item.kind === "plugin" ? [item.data] : [])); +} + +const presentationOptions = { level: "overview" as const, isTurnActive: true }; + +function streamHarness(transform?: TimelineItemTransform) { + let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] }; + const present = createStreamPresentation(); + return { + send(event: AgentStreamEventPayload) { + state = applyStreamEvent({ ...state, event, timestamp: new Date(1000) }); + return this.render(); + }, + render(nextTransform = transform) { + return present({ ...presentationOptions, ...state, transform: nextTransform }); + }, + source: () => state, + }; +} + +function assistant(text: string, messageId = "message-1"): AgentStreamEventPayload { + return { + type: "timeline", + provider: "claude", + item: { type: "assistant_message", messageId, text }, + }; +} + +function rows(result: { tail: StreamItem[]; head: StreamItem[] }): StreamItem[] { + return [...result.tail, ...result.head]; +} + +describe("stream presentation through installed plugins", () => { + it("offers every source tool call to an installed transformer in Overview mode", () => { + const calls = [toolCall("call-1", "bash"), toolCall("call-2", "read")]; + const rendered = createStreamPresentation()({ + ...presentationOptions, + tail: calls, + head: [], + transform: installedTransform(installProbe("tool_call")), + }); + expect(pluginData([...rendered.tail, ...rendered.head])).toMatchObject([ + { callId: "call-1", name: "bash", phase: "complete" }, + { callId: "call-2", name: "read", phase: "complete" }, + ]); + }); + + it.each([ + { type: "turn_completed", provider: "claude" }, + { type: "turn_canceled", provider: "claude", reason: "test cancellation" }, + { type: "turn_failed", provider: "claude", error: "test failure" }, + ] satisfies AgentStreamEventPayload[])( + "streams accumulated assistant text with one identity through $type", + (completion) => { + const harness = streamHarness(installedTransform(installProbe("assistant_message"))); + const first = rows(harness.send(assistant("Header"))); + const fullText = "Header\n\n\n# Body\n\n```ts\nconst x = 1;\n\nconst y = 2;\n```\n"; + const live = rows(harness.send(assistant(fullText.slice("Header".length)))); + const completed = rows(harness.send(completion)); + + expect(pluginData(first)).toEqual([{ text: "Header", phase: "streaming" }]); + expect(pluginData(live)).toEqual([{ text: fullText, phase: "streaming" }]); + expect(pluginData(completed)).toEqual([{ text: fullText, phase: "complete" }]); + expect(live.map((item) => item.id)).toEqual(first.map((item) => item.id)); + expect(completed.map((item) => item.id)).toEqual(first.map((item) => item.id)); + expect(rows(harness.source())).toMatchObject([{ kind: "assistant_message", text: fullText }]); + }, + ); + + it("finishes one message before streaming a different message ID", () => { + const harness = streamHarness(installedTransform(installProbe("assistant_message"))); + harness.send(assistant("First\n\nBody", "first")); + const result = harness.send(assistant("Second", "second")); + expect(pluginData(rows(result))).toEqual([ + { text: "First\n\nBody", phase: "complete" }, + { text: "Second", phase: "streaming" }, + ]); + expect(new Set(rows(result).map((item) => item.id)).size).toBe(2); + }); + + it("lets a plugin claim the accumulated message after earlier text used native Markdown", () => { + const harness = streamHarness(); + harness.send(assistant("Header\n\nBody")); + const transform = installedTransform( + installProbe("assistant_message", 'item.text.includes("Body")'), + ); + expect(pluginData(rows(harness.render(transform)))).toEqual([ + { text: "Header\n\nBody", phase: "streaming" }, + ]); + expect(rows(harness.render(transform))).toHaveLength(1); + expect(rows(harness.render()).map((item) => item.kind)).toEqual([ + "assistant_message", + "assistant_message", + ]); + }); + + it("keeps completed native blocks stable while the last block grows and finishes", () => { + const harness = streamHarness(); + const first = harness.send(assistant("Intro\n\n![Image](image.png)")); + const growing = harness.send(assistant("\n\nAfter\n")); + const last = harness.send(assistant("- item")); + const completed = harness.send({ type: "turn_completed", provider: "claude" }); + expect(first.tail).toMatchObject([{ text: "Intro", blockIndex: 0 }]); + expect(first.head).toMatchObject([{ text: "![Image](image.png)", blockIndex: 1 }]); + expect(growing.tail[0]).toBe(first.tail[0]); + expect(growing.tail[1]).toBe(first.head[0]); + expect(last.tail).toBe(growing.tail); + expect(last.head).toMatchObject([{ text: "After\n- item", blockIndex: 2 }]); + expect(completed.tail).toEqual(rows(last)); + expect(completed.tail[2]).toBe(last.head[0]); + expect(completed.head).toEqual([]); + }); + + it("keeps blank lines inside an open native code fence", () => { + const harness = streamHarness(); + harness.send(assistant("Intro\n\n```ts\nconst a = 1;")); + const result = harness.send(assistant("\n\nconst b = 2;")); + expect(result.tail).toMatchObject([{ text: "Intro" }]); + expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]); + }); + + it("leaves fetched native Markdown intact, including cross-paragraph references", () => { + const source = hydrateStreamState([ + { + event: assistant("[Link][docs]\n\n[docs]: https://example.com"), + timestamp: new Date(1000), + }, + ]); + const result = createStreamPresentation()({ + ...presentationOptions, + tail: source, + head: [], + transform: undefined, + }); + expect(result.tail).toEqual(source); + expect(result.tail[0]).toBe(source[0]); + }); + + it("continues to stream inline reasoning with a stable plugin row", () => { + const harness = streamHarness(installedTransform(installProbe("reasoning"))); + const first = harness.send({ + type: "timeline", + provider: "claude", + item: { type: "reasoning", text: "First" }, + }); + const second = harness.send({ + type: "timeline", + provider: "claude", + item: { type: "reasoning", text: "\n\nSecond" }, + }); + const done = harness.send({ type: "turn_completed", provider: "claude" }); + expect(pluginData(rows(second))).toEqual([{ text: "First\n\nSecond", phase: "streaming" }]); + expect(pluginData(rows(done))).toEqual([{ text: "First\n\nSecond", phase: "complete" }]); + expect(rows(first)[0]?.id).toBe(rows(done)[0]?.id); + }); + + it("groups only unclaimed tools and passes unchanged tool details to the callback", () => { + const calls = [ + toolCall("call-1", "read"), + toolCall("call-2", "read"), + toolCall("call-3", "bash"), + ]; + const result = createStreamPresentation()({ + ...presentationOptions, + isTurnActive: false, + tail: calls, + head: [], + transform: installedTransform(installProbe("tool_call", 'item.name === "bash"')), + }); + expect(result.groupsByHostId.get(result.tail[0]!.id)?.run.calls).toEqual(calls.slice(0, 2)); + expect(pluginData(rows(result))).toEqual([ + { + callId: "call-3", + name: "bash", + status: "completed", + error: null, + detail: { type: "unknown", input: { callId: "call-3" }, output: { callId: "call-3" } }, + phase: "complete", + }, + ]); + }); + + it.each([ + { status: "completed", error: null }, + { status: "failed", error: { message: "Command failed" } }, + { status: "canceled", error: null }, + ] as const)("keeps a tool card's identity and full payload through $status", (completion) => { + const harness = streamHarness(installedTransform(installProbe("tool_call"))); + const source = { + type: "tool_call" as const, + callId: "call-1", + name: "bash", + detail: { type: "shell" as const, command: "pwd", cwd: "/repo" }, + metadata: { sequence: 1 }, + }; + const running = rows( + harness.send({ + type: "timeline", + provider: "claude", + item: { + ...source, + status: "running", + error: null, + }, + }), + ); + const terminalItem = { ...source, ...completion }; + const terminal = rows( + harness.send({ + type: "timeline", + provider: "claude", + item: { + ...terminalItem, + }, + }), + ); + expect(pluginData(running)).toMatchObject([ + { callId: "call-1", status: "running", phase: "streaming" }, + ]); + expect(pluginData(terminal)).toEqual([ + { + callId: source.callId, + name: source.name, + detail: source.detail, + metadata: source.metadata, + phase: "complete", + status: completion.status, + error: terminalItem.error, + }, + ]); + expect(terminal.map((item) => item.id)).toEqual(running.map((item) => item.id)); + }); + + it("offers one complete live source assistant message to an installed transformer", () => { + const sourceText = + '[IMPORTANT: User invoked the "diagnose" skill; follow its instructions. Full skill below.]\n\n# Diagnose\n\nFind the root cause.'; + const event = { + type: "timeline" as const, + provider: "opencode" as const, + item: { type: "assistant_message" as const, messageId: "message-1", text: sourceText }, + }; + const timestamp = new Date("2026-01-01T00:00:00.000Z"); + const transform = installedTransform(installProbe("assistant_message")); + const present = createStreamPresentation(); + + const fetched = hydrateStreamState([{ event, timestamp }]); + const history = present({ ...presentationOptions, tail: fetched, head: [], transform }); + expect(pluginData(history.tail)).toEqual([{ text: sourceText, phase: "complete" }]); + + const stream = applyStreamEvent({ + tail: [], + head: [], + event, + timestamp, + }); + + const rendered = present({ ...presentationOptions, ...stream, transform }); + expect(pluginData([...rendered.tail, ...rendered.head])).toEqual([ + { text: sourceText, phase: "streaming" }, + ]); + }); +}); diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts new file mode 100644 index 0000000000..9d05bdea5b --- /dev/null +++ b/packages/app/src/agent-stream/presentation.ts @@ -0,0 +1,138 @@ +import type { AssistantMessageItem, StreamItem } from "@/types/stream"; +import type { TimelineItemTransform } from "@/plugins/timeline/model"; +import { projectPluginTimelineItems } from "@/plugins/timeline/projection"; +import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; +import { + prepareToolCallHistory, + projectToolCallDetailLevel, + type PreparedToolCallHistory, + type ToolCallDetailLevel, +} from "@/tool-calls/detail-level/projection"; + +interface PresentationInput { + tail: StreamItem[]; + head: StreamItem[]; + transform: TimelineItemTransform | undefined; + level: ToolCallDetailLevel; + isTurnActive: boolean; +} + +function retainItems(previous: StreamItem[], next: StreamItem[]): StreamItem[] { + return previous.length === next.length && previous.every((item, index) => item === next[index]) + ? previous + : next; +} + +/** Source messages reach plugins before any Markdown splitting or Overview grouping. */ +export function createStreamPresentation() { + const blocksBySource = new WeakMap(); + let liveSources = new Map(); + let historySource: StreamItem[] | undefined; + let historyTransform: TimelineItemTransform | undefined; + let historyRows: StreamItem[] = []; + let displayTail: StreamItem[] = []; + let displayHistory: StreamItem[] | undefined; + let promotedRows: StreamItem[] = []; + let preparedTail: StreamItem[] | undefined; + let preparedLevel: ToolCallDetailLevel | undefined; + let preparedHistory: PreparedToolCallHistory | null = null; + + function nativeBlocks(item: StreamItem): StreamItem[] { + if (item.kind !== "assistant_message") return [item]; + const cached = blocksBySource.get(item); + if (cached) return cached; + const previousSource = liveSources.get(item.id); + const previous = previousSource && blocksBySource.get(previousSource); + // Parse only the growing last block on append, as the old live reducer did. + // Canonical text replacements are parsed afresh instead of joining fragments. + const isAppend = previousSource && previous && item.text.startsWith(previousSource.text); + let prefix: AssistantMessageItem[] = []; + let growingText = item.text; + if (isAppend) { + prefix = previous.slice(0, -1); + growingText = + previous[previous.length - 1]!.text + item.text.slice(previousSource.text.length); + } + const textBlocks = splitMarkdownBlocks(growingText); + if (prefix.length + textBlocks.length < 2) { + blocksBySource.set(item, [item]); + return [item]; + } + + const blocks = [...prefix]; + for (const [offset, text] of textBlocks.entries()) { + const index = prefix.length + offset; + let blockText = text; + if (offset === textBlocks.length - 1) { + const trailingNewlines = /\n+$/.exec(item.text)?.[0] ?? ""; + blockText += trailingNewlines; + } + const existing = previous?.[index]; + const id = `${item.id}:block:${index}`; + // Completed display blocks keep their first cursor and object identity while + // the source message grows. The source itself always retains the latest cursor. + if (existing?.id === id && existing.text === blockText && existing.turnId === item.turnId) { + blocks.push(existing); + continue; + } + blocks.push({ + ...item, + id, + blockGroupId: item.id, + blockIndex: index, + text: blockText, + }); + } + blocksBySource.set(item, blocks); + return blocks; + } + + return (input: PresentationInput) => { + // Retained history is not reprojected or regrouped on each live text update. + if (historySource !== input.tail || historyTransform !== input.transform) { + historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap( + (item) => { + // Preserve live block identities at completion; fetched native Markdown + // stays whole so links and other cross-block constructs keep their context. + if (item.kind === "assistant_message") return blocksBySource.get(item) ?? [item]; + return [item]; + }, + ); + historySource = input.tail; + historyTransform = input.transform; + } + const head: StreamItem[] = []; + const promoted: StreamItem[] = []; + const nextLiveSources = new Map(); + for (const item of projectPluginTimelineItems(input.head, input.transform, "streaming")) { + const blocks = nativeBlocks(item); + if (item.kind === "assistant_message") nextLiveSources.set(item.id, item); + if (item.kind === "assistant_message" && blocks.length > 1) { + promoted.push(...blocks.slice(0, -1)); + head.push(blocks[blocks.length - 1]!); + } else { + head.push(...blocks); + } + } + liveSources = nextLiveSources; + const nextPromoted = retainItems(promotedRows, promoted); + if (displayHistory !== historyRows || nextPromoted !== promotedRows) { + displayTail = historyRows; + if (nextPromoted.length > 0) displayTail = [...historyRows, ...nextPromoted]; + displayHistory = historyRows; + promotedRows = nextPromoted; + } + if (preparedTail !== displayTail || preparedLevel !== input.level) { + preparedHistory = prepareToolCallHistory(input.level, displayTail); + preparedTail = displayTail; + preparedLevel = input.level; + } + return projectToolCallDetailLevel({ + level: input.level, + tail: displayTail, + head, + preparedHistory, + isTurnActive: input.isTurnActive, + }); + }; +} diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 117132199e..df9670fb1d 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -61,10 +61,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { ToolCallDetailsContent } from "@/components/tool-call-details"; import { QuestionFormCard } from "@/components/question-form-card"; import { ToolCallSheetProvider } from "@/components/tool-call-sheet"; -import { - prepareToolCallHistory, - projectToolCallDetailLevel, -} from "@/tool-calls/detail-level/projection"; +import { createStreamPresentation } from "./presentation"; import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view"; import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model"; import { resolveStreamRenderStrategy } from "./strategy-resolver"; @@ -108,7 +105,6 @@ import { recordRenderProfileReasons } from "@/utils/render-profiler"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useStreamHistoryWindow } from "./use-stream-history-window"; import { PluginTimelineItemView, useInstalledTimelineTransform } from "@/plugins/timeline"; -import { projectPluginTimelineItems } from "@/plugins/timeline/projection"; function renderLiveAuxiliaryNode(input: { pendingPermissions: ReactNode; @@ -537,35 +533,25 @@ const AgentStreamViewComponent = forwardRef prepareToolCallHistory(toolCallDetailLevel, effectiveStreamItems), - [effectiveStreamItems, toolCallDetailLevel], - ); - const projectedToolCalls = useMemo( + const presentStream = useMemo(() => createStreamPresentation(), []); + const presentation = useMemo( () => - projectToolCallDetailLevel({ - level: toolCallDetailLevel, + presentStream({ tail: effectiveStreamItems, head: effectiveStreamHead ?? EMPTY_STREAM_HEAD, - preparedHistory: preparedToolCallHistory, + transform: transformTimelineItem, + level: toolCallDetailLevel, isTurnActive, }), [ - effectiveStreamHead, + presentStream, effectiveStreamItems, - isTurnActive, - preparedToolCallHistory, + effectiveStreamHead, + transformTimelineItem, toolCallDetailLevel, + isTurnActive, ], ); - const projectedPlugins = useMemo( - () => ({ - tail: projectPluginTimelineItems(projectedToolCalls.tail, transformTimelineItem), - head: projectPluginTimelineItems(projectedToolCalls.head, transformTimelineItem), - }), - [projectedToolCalls.head, projectedToolCalls.tail, transformTimelineItem], - ); const { start: historyWindowStart, hasLocalHistory, @@ -573,7 +559,7 @@ const AgentStreamViewComponent = forwardRef - projectedToolCalls.groupsByHostId.get(hostId), + presentation.groupsByHostId.get(hostId), ); const renderToolCallItem = useCallback( (layoutItem: StreamLayoutItem, item: Extract) => { @@ -1017,6 +1003,14 @@ const AgentStreamViewComponent = forwardRef { + const row = + rowId === null + ? undefined + : (layoutHistoryItemById.get(rowId) ?? layoutLiveHeadItemById.get(rowId)); + chatOutline.reportReadingPosition(row?.item.timelineCursor?.seq ?? null); + }); + const renderHistoryRow = useCallback( (item: StreamItem) => renderHistoryStreamItem({ @@ -1080,11 +1074,11 @@ const AgentStreamViewComponent = forwardRef ({ - contentById: projectedToolCalls.historyGroupUpdatesByHostId, + contentById: presentation.historyGroupUpdatesByHostId, displayStateById: expandedToolCallGroupIds, globalDisplayState: isMobile, }), - [expandedToolCallGroupIds, isMobile, projectedToolCalls.historyGroupUpdatesByHostId], + [expandedToolCallGroupIds, isMobile, presentation.historyGroupUpdatesByHostId], ); return ( @@ -1103,7 +1097,7 @@ const AgentStreamViewComponent = forwardRef>(); +type AssistantPhase = "streaming" | "complete"; + +const projectionCache = new WeakMap< + TimelineItemTransform, + WeakMap>> +>(); function cloneAndFreeze(value: T): T { if (Array.isArray(value)) { @@ -77,6 +82,7 @@ function sourceTimelineItem(item: StreamItem): AgentTimelineItem | null { function transformSourceItem( item: StreamItem, transformTimelineItem: TimelineItemTransform, + assistantPhase: AssistantPhase, ): StreamItem[] { const source = sourceTimelineItem(item); if (!source) return [item]; @@ -85,7 +91,9 @@ function transformSourceItem( item.kind === "tool_call" && item.payload.source === "agent" && item.payload.data.status === "running"; - const phase = isStreamingThought || isStreamingToolCall ? "streaming" : "complete"; + const isStreamingAssistant = item.kind === "assistant_message" && assistantPhase === "streaming"; + const phase = + isStreamingAssistant || isStreamingThought || isStreamingToolCall ? "streaming" : "complete"; const transformed = transformTimelineItem({ item: cloneAndFreeze(source), phase, @@ -112,6 +120,7 @@ function transformSourceItem( export function projectPluginTimelineItems( items: StreamItem[], transformTimelineItem: TimelineItemTransform | undefined, + assistantPhase: AssistantPhase = "complete", ): StreamItem[] { if (!transformTimelineItem) return items; let bySource = projectionCache.get(transformTimelineItem); @@ -121,13 +130,13 @@ export function projectPluginTimelineItems( } let changed = false; const projected = items.flatMap((item) => { - const cached = bySource.get(item); + const cached = bySource.get(item)?.[assistantPhase]; if (cached) { changed = changed || cached.length !== 1 || cached[0] !== item; return cached; } - const output = transformSourceItem(item, transformTimelineItem); - bySource.set(item, output); + const output = transformSourceItem(item, transformTimelineItem, assistantPhase); + bySource.set(item, { ...bySource.get(item), [assistantPhase]: output }); changed = changed || output.length !== 1 || output[0] !== item; return output; }); diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index df6c3a0aa0..e4e759bc15 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -110,6 +110,12 @@ function createCache(storage: MemoryStorage, maxBytes?: number): ReplicaCache { return cache; } +function requireTimelineRow(storage: MemoryStorage): ReplicaRow { + const row = [...storage.rows.values()].find((candidate) => candidate.kind === "timeline"); + if (!row) throw new Error("timeline row was not written"); + return row; +} + function agent(id = "agent-1"): Agent { return { ...normalizeAgentSnapshot( @@ -501,8 +507,7 @@ describe("ReplicaCache", () => { hasOlder: true, }); await writer.flush(); - const row = [...storage.rows.values()].find((candidate) => candidate.kind === "timeline"); - if (!row) throw new Error("timeline row was not written"); + const row = requireTimelineRow(storage); const payload = JSON.parse(row.payload) as { items: Array> }; delete payload.items[0]?.pluginItemId; storage.rows.set(`${row.serverId}:${row.kind}:${row.id}`, { @@ -513,6 +518,22 @@ describe("ReplicaCache", () => { expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toBeUndefined(); }); + it("refetches old cached Markdown fragments instead of offering them as whole messages", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + writer.commitTimeline(SERVER_ID, "agent-1", timeline()); + await writer.flush(); + const row = requireTimelineRow(storage); + const payload = JSON.parse(row.payload) as { items: Array> }; + Object.assign(payload.items[0]!, { blockGroupId: "message-1", blockIndex: 0 }); + storage.rows.set(`${row.serverId}:${row.kind}:${row.id}`, { + ...row, + payload: JSON.stringify(payload), + }); + + 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); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index c1db2f8418..4a6e284b32 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -124,8 +124,7 @@ const StoredTimelineItemSchema = z.discriminatedUnion("kind", [ kind: z.literal("assistant_message"), messageId: z.string().optional(), text: z.string(), - blockGroupId: z.string().optional(), - blockIndex: z.number().int().nonnegative().optional(), + // Reject old caches containing display fragments; refetch the complete source text. }), z.strictObject({ ...TimelineItemBaseShape, @@ -435,8 +434,6 @@ function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null { kind: item.kind, ...(item.messageId ? { messageId: item.messageId } : {}), text: item.text, - ...(item.blockGroupId ? { blockGroupId: item.blockGroupId } : {}), - ...(item.blockIndex !== undefined ? { blockIndex: item.blockIndex } : {}), }; case "thought": return { ...base, kind: item.kind, text: item.text, status: item.status }; @@ -527,8 +524,6 @@ function deserializeBuiltinTimelineItem( kind: item.kind, ...(item.messageId ? { messageId: item.messageId } : {}), text: item.text, - ...(item.blockGroupId ? { blockGroupId: item.blockGroupId } : {}), - ...(item.blockIndex !== undefined ? { blockIndex: item.blockIndex } : {}), }; case "thought": return { ...base, kind: item.kind, text: item.text, status: item.status }; diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 13897f8454..9e57142846 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -1901,14 +1901,14 @@ describe("processTimelineResponse", () => { expect(assistants[0]).toMatchObject({ text: "Hello", messageId: "answer-1" }); }); - it("replaces every promoted assistant block when reconciling a projected message", () => { + it("replaces the whole live assistant message when reconciling projected history", () => { const live = processAgentStreamEvents({ events: [makeStreamReducerEvent(makeAssistantTimelineEvent("First paragraph.\n\nSec"), 2)], currentTail: [], currentHead: [], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, }); - expect(getAssistantTexts(live.tail)).toHaveLength(1); + expect(getAssistantTexts(live.tail)).toHaveLength(0); expect(getAssistantTexts(live.head)).toHaveLength(1); const result = processTimelineResponse({ @@ -2173,7 +2173,7 @@ describe("processTimelineResponse", () => { [...result.tail, ...result.head] .filter((item) => item.kind === "assistant_message" || item.kind === "user_message") .map((item) => item.text), - ).toEqual(["New prompt", "First paragraph.", "Second paragraph", "Missed answer"]); + ).toEqual(["New prompt", "First paragraph.\n\nSecond paragraph", "Missed answer"]); }); it("does not move a prompt or its live answer around catch-up tool history", () => { @@ -2212,7 +2212,6 @@ describe("processTimelineResponse", () => { expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([ "user_message", "assistant_message", - "assistant_message", "tool_call", ]); }); @@ -2777,7 +2776,7 @@ describe("processTimelineResponse", () => { expect(result.sideEffects).not.toContainEqual(expect.objectContaining({ type: "catch_up" })); }); - it("keeps live assistant blocks ordered when merging a disjoint prompt-jump window", () => { + it("keeps whole live assistant messages ordered when merging a disjoint prompt-jump window", () => { const live = processAgentStreamEvents({ events: [ makeStreamReducerEvent( @@ -2792,8 +2791,7 @@ describe("processTimelineResponse", () => { currentCursor: undefined, }); expect(getAssistantTexts([...live.tail, ...live.head])).toEqual([ - "First paragraph.", - "Second paragraph.", + "First paragraph.\n\nSecond paragraph.", ]); const result = processTimelineResponse({ @@ -2815,8 +2813,7 @@ describe("processTimelineResponse", () => { }); expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([ - "First paragraph.", - "Second paragraph.", + "First paragraph.\n\nSecond paragraph.", ]); }); @@ -3980,7 +3977,7 @@ describe("processAgentStreamEvents", () => { expect(getAssistantTexts(result.head)).toEqual(["Identified"]); }); - it("keeps a promoted live image block identity when the turn completes", () => { + it("keeps the whole live message identity when the turn completes", () => { const imageMarkdown = "![Architecture](docs/architecture.png)"; const streaming = processAgentStreamEvents({ events: [ @@ -3992,9 +3989,11 @@ describe("processAgentStreamEvents", () => { currentCursor: undefined, }); - expect([streaming.changedTail, streaming.changedHead]).toEqual([true, true]); - expect(getAssistantTexts(streaming.tail)).toEqual(["Introductory paragraph"]); - expect(getAssistantTexts(streaming.head)).toEqual([imageMarkdown]); + expect([streaming.changedTail, streaming.changedHead]).toEqual([false, true]); + expect(getAssistantTexts(streaming.tail)).toEqual([]); + expect(getAssistantTexts(streaming.head)).toEqual([ + `Introductory paragraph\n\n${imageMarkdown}`, + ]); const liveImageId = streaming.head[0]?.id; const completed = processAgentStreamEvent({ @@ -4009,13 +4008,12 @@ describe("processAgentStreamEvents", () => { item.kind === "assistant_message", ); expect(assistantBlocks.map((item) => [item.id, item.text])).toEqual([ - [streaming.tail[0]?.id, "Introductory paragraph"], - [liveImageId, imageMarkdown], + [liveImageId, `Introductory paragraph\n\n${imageMarkdown}`], ]); expect(assistantBlocks.filter((item) => item.id === liveImageId)).toHaveLength(1); }); - it("preserves a live block trailing newline after promoting completed markdown blocks", () => { + it("preserves all newlines in the accumulated live message", () => { const result = processAgentStreamEvents({ events: [ makeStreamReducerEvent( @@ -4032,21 +4030,17 @@ describe("processAgentStreamEvents", () => { currentCursor: undefined, }); - expect(result.changedTail).toBe(true); + expect(result.changedTail).toBe(false); expect(result.changedHead).toBe(true); - expect(result.tail).toHaveLength(1); - expect(result.tail[0]).toMatchObject({ - kind: "assistant_message", - text: "Done. I added `[TimelineMerge] ...` logging around the suspicious merge/reconcile paths.", - }); + expect(result.tail).toHaveLength(0); expect(result.head).toHaveLength(1); expect(result.head[0]).toMatchObject({ kind: "assistant_message", - text: "Changed:\n- [timeline-debug.ts]", + text: "Done. I added `[TimelineMerge] ...` logging around the suspicious merge/reconcile paths.\n\nChanged:\n- [timeline-debug.ts]", }); }); - it("does not promote a markdown block that is still inside an open code fence", () => { + it("preserves an open code fence in the whole source message", () => { const result = processAgentStreamEvents({ events: [ makeStreamReducerEvent(makeTimelineEvent("Before fence\n\n```ts\nconst a = 1;"), 1), @@ -4057,16 +4051,12 @@ describe("processAgentStreamEvents", () => { currentCursor: undefined, }); - expect(result.changedTail).toBe(true); - expect(result.tail).toHaveLength(1); - expect(result.tail[0]).toMatchObject({ - kind: "assistant_message", - text: "Before fence", - }); + expect(result.changedTail).toBe(false); + expect(result.tail).toHaveLength(0); expect(result.head).toHaveLength(1); expect(result.head[0]).toMatchObject({ kind: "assistant_message", - text: "```ts\nconst a = 1;\n\nconst b = 2;", + text: "Before fence\n\n```ts\nconst a = 1;\n\nconst b = 2;", }); }); @@ -4175,8 +4165,7 @@ describe("processAgentStreamEvents", () => { expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([ "ABC", imageMarkdown, - "D", - "E", + "D\n\nE", ]); }); diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index b04ad00f02..dce9daa4da 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -422,13 +422,7 @@ function mergeTimelineWindow(args: { return cursor?.epoch !== payload.epoch || cursor.seq < startSeq || cursor.seq > endSeq; }); const retainedHead = projected.head; - const reservedItemIds = new Set( - [...retainedTail, ...retainedHead].flatMap((item) => - item.kind === "assistant_message" && item.blockGroupId - ? [item.id, item.blockGroupId] - : [item.id], - ), - ); + const reservedItemIds = new Set([...retainedTail, ...retainedHead].map((item) => item.id)); const hydrated = hydrateStreamState( toHydratedEvents(timelineUnits.filter((unit) => !projected.reconciledUnits.has(unit))), { @@ -823,51 +817,31 @@ function reconcileOverlappingProjectedAssistant(params: { return { tail: params.tail, head: params.head, reconciled: false }; } - const blockGroupId = match.current.blockGroupId; const messageId = projectedMessageId ?? match.current.messageId; const replacement: AssistantMessageItem = { kind: "assistant_message", - id: blockGroupId ?? match.current.id, + id: match.current.id, ...(messageId !== undefined ? { messageId } : {}), text: projectedText, 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; - } - } + const next = [...items]; + next[index] = replacement; return next; }; if (headMatch) { return { - tail: removeBlockGroup(params.tail), + tail: params.tail, head: replaceMatch(params.head, headMatch.index), reconciled: true, }; } return { tail: replaceMatch(params.tail, match.index), - head: removeBlockGroup(params.head), + head: params.head, reconciled: true, }; } @@ -1162,13 +1136,7 @@ function applyAcceptedTimelinePage(input: { })), { source: "canonical", - reservedItemIds: new Set( - currentTail.flatMap((item) => - item.kind === "assistant_message" && item.blockGroupId - ? [item.id, item.blockGroupId] - : [item.id], - ), - ), + reservedItemIds: new Set(currentTail.map((item) => item.id)), }, ); return { diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index 49fdf7daf6..f33a9c524b 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -883,7 +883,7 @@ describe("stream reducer canonical tool calls", () => { expect(new Set(messages.map((message) => message.id)).size).toBe(2); }); - it("keeps every promoted block when an assistant message resumes after a tool", () => { + it("keeps whole messages when an assistant resumes after a tool", () => { const messageId = "msg-promoted-resume"; let tail: StreamItem[] = []; let head: StreamItem[] = []; @@ -926,15 +926,13 @@ describe("stream reducer canonical tool calls", () => { item.kind === "assistant_message", ); expect(messages.map((message) => message.text)).toEqual([ - "Before one.", - "Before two.", - "After one.", - "After two.", + "Before one.\n\nBefore two.", + "After one.\n\nAfter two.", ]); expect(new Set(messages.map((message) => message.id)).size).toBe(messages.length); }); - it("keeps the timeline position on every promoted assistant block", () => { + it("keeps the timeline position on the whole assistant message", () => { const timelineCursor = { epoch: "epoch-1", seq: 42 }; const result = applyStreamEvent({ tail: [], @@ -949,13 +947,9 @@ describe("stream reducer canonical tool calls", () => { item.kind === "assistant_message", ); expect(messages.map((message) => message.text)).toEqual([ - "First paragraph.", - "Second paragraph.", - ]); - expect(messages.map((message) => message.timelineCursor)).toEqual([ - timelineCursor, - timelineCursor, + "First paragraph.\n\nSecond paragraph.", ]); + expect(messages.map((message) => message.timelineCursor)).toEqual([timelineCursor]); }); it("preserves old assistant merge behavior when message ids are absent", () => { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 1e8a32f06f..78789fa2d8 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -8,7 +8,6 @@ import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity"; import type { AgentAttachment, AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import type { AttachmentMetadata } from "@/attachments/types"; import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers"; -import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; /** * Simple hash function for deterministic ID generation @@ -711,6 +710,7 @@ export interface AssistantMessageItem { timelineCursor?: TimelinePosition; text: string; timestamp: Date; + /** Display-only fields, assigned after source-item plugin transforms. */ blockGroupId?: string; blockIndex?: number; } @@ -1757,14 +1757,6 @@ function finalizeHeadItems(head: StreamItem[]): StreamItem[] { }); } -function createAssistantBlockId(params: { groupId: string; blockIndex: number }): string { - return `${params.groupId}:block:${params.blockIndex}`; -} - -function getTrailingNewlineSuffix(text: string): string { - return /\n+$/.exec(text)?.[0] ?? ""; -} - function getActiveAssistantHeadIndex(head: StreamItem[]): number { for (let index = head.length - 1; index >= 0; index -= 1) { if (head[index]?.kind === "assistant_message") { @@ -1793,73 +1785,6 @@ function getTailAssistantToResume(params: { return params.tailAssistant; } -function promoteCompletedAssistantBlocks(params: { tail: StreamItem[]; head: StreamItem[] }): { - tail: StreamItem[]; - head: StreamItem[]; - changedTail: boolean; - changedHead: boolean; -} { - const assistantIndex = getActiveAssistantHeadIndex(params.head); - const activeItem = params.head[assistantIndex]; - if (assistantIndex < 0 || !activeItem || activeItem.kind !== "assistant_message") { - return { - tail: params.tail, - head: params.head, - changedTail: false, - changedHead: false, - }; - } - - const blocks = splitMarkdownBlocks(activeItem.text); - if (blocks.length < 2) { - return { - tail: params.tail, - head: params.head, - changedTail: false, - changedHead: false, - }; - } - - const blockGroupId = activeItem.blockGroupId ?? activeItem.id; - 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, - })); - - 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 nextHead = [ - ...params.head.slice(0, assistantIndex), - liveItem, - ...params.head.slice(assistantIndex + 1), - ]; - - return { - tail: nextTail, - head: nextHead, - changedTail: nextTail !== params.tail, - changedHead: true, - }; -} - /** * Flush head items to tail, avoiding duplicates. */ @@ -2103,13 +2028,7 @@ export function applyStreamEvent(params: { if (incomingKind !== null && isStreamableKind(incomingKind)) { const reservedItemIds = incomingKind === "assistant_message" && getActiveAssistantHeadIndex(nextHead) < 0 - ? new Set( - nextTail.flatMap((item) => - item.kind === "assistant_message" && item.blockGroupId - ? [item.id, item.blockGroupId] - : [item.id], - ), - ) + ? new Set(nextTail.map((item) => item.id)) : undefined; const reduced = reduceStreamUpdate(nextHead, event, timestamp, { source, @@ -2120,16 +2039,6 @@ export function applyStreamEvent(params: { nextHead = reduced; changedHead = true; } - if (incomingKind === "assistant_message") { - const promoted = promoteCompletedAssistantBlocks({ - tail: nextTail, - head: nextHead, - }); - nextTail = promoted.tail; - nextHead = promoted.head; - changedTail = changedTail || promoted.changedTail; - changedHead = changedHead || promoted.changedHead; - } return { tail: nextTail, head: nextHead, changedTail, changedHead }; } diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index 2dd5bd3cdd..62c2f0ed80 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -854,7 +854,12 @@ export default function contribute(client: PluginClientContext) { `query.itemType` is the stable, coarse selector. Inspect the selected item inside `transform` for provider- or tool-specific recognition. Returning `undefined` keeps the original entry. Returning `items` replaces it; an empty array removes it. Item `data` must be JSON-compatible. The `phase` -input is `"streaming"` for running tool calls and loading reasoning, and `"complete"` otherwise. +input is `"streaming"` for the live assistant message, running tool calls, and loading reasoning; +it is `"complete"` for committed or fetched messages and finished tools or reasoning. +Assistant and reasoning callbacks receive the full accumulated text on each update, including +paragraph separators. Paseo invokes transformers before splitting native Markdown or grouping +tools in Overview. A claimed assistant message remains one source item throughout streaming; +return `undefined` until recognizable if the first text is insufficient to identify it. Each replacement may set an optional plugin-local `id`; otherwise Paseo uses its index within that source item's output. From 174055a63c4651bff2a5ab5a316e8d7a403a3444 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 15 Sep 2026 00:30:35 +0200 Subject: [PATCH 17/17] feat(app): give compact Changes an overview and a straight rail (#4861) * feat(app): give compact Changes an overview and a straight rail Three fixes to the Explorer's Changes tab on phones, where there is no tree rail and the diff is the only view of what changed. No placeholder stat while the sidebar slides in. `useCheckoutDiffQuery` reported `isLoading` only when the query was enabled and connected, so an inactive retained panel settled on `files: []`. The comparison toolbar summed that into "+0 -0" and the body rendered "No changes" for a diff nobody had fetched yet, both replaced a moment later by real numbers. The result now derives from the payload alone: no payload means loading, whatever the query is doing. Callers already handle loading correctly, so nothing downstream needed a special case. Jump to file. A floating action button over the diff opens a sheet with the same changed-files tree the desktop rail renders; tapping a file closes the sheet and scrolls the diff there. `ChangedFilesTree` moved out of `diff-pane.tsx` into its own module so both surfaces own one tree, and its list is now the sheet-aware one so it scrolls with the sheet gesture on native. `DiffDocument` takes a `contentInsetBottom` so the button never traps the last line. Rails on the compact header. The tab labels sat at 20dp and the close X at 16dp while every toolbar row below them used 12dp leading and 8dp trailing. The close button was also a hand-rolled Pressable rather than a ToolbarButton. `paneContentToolbarTrailingPadding` now takes whether the row's last control is a bare glyph or a framed one: a glyph's hitbox overhangs its ink so the row pads by less than the rail, but the compact git-actions trigger paints its own frame and was landing flush against the screen edge. The commits header chevron moved to the depth-0 tree rail. Desktop geometry is unchanged. * fix(app): preserve Explorer touch targets and reset file overview * fix(app): preserve diff selections across repaints Header typography refreshes and height resizes changed the repaint callback and inadvertently cleared active source selections. Keep selection updates stable while retaining explicit source and selection-layout invalidation. * test(app): express diff copy regression as user actions * test(app): express file overview flows as user actions --- docs/design.md | 2 +- docs/explorer-sidebar.md | 2 +- packages/app/e2e/browser/changes-pane.spec.ts | 146 +++++++++++-- .../src/components/adaptive-modal-sheet.tsx | 7 +- .../components/compact-explorer-sidebar.tsx | 78 ++++--- .../explorer-sidebar-layout.test.ts | 18 ++ .../src/components/explorer-sidebar-layout.ts | 25 +++ .../app/src/components/file-explorer-pane.tsx | 5 +- .../components/ui/floating-action-button.tsx | 65 ++++++ .../ui/pane-content-toolbar.test.ts | 43 ++++ .../components/ui/pane-content-toolbar.tsx | 22 +- packages/app/src/git/changed-files-tree.tsx | 158 ++++++++++++++ .../commits-section/commits-section.test.tsx | 21 -- .../git/commits-section/commits-section.tsx | 5 +- .../src/git/diff-document/surface.native.tsx | 8 +- .../app/src/git/diff-document/surface.web.tsx | 40 ++-- packages/app/src/git/diff-document/types.ts | 2 + packages/app/src/git/diff-pane.tsx | 199 +++++------------- packages/app/src/git/jump-to-file/index.tsx | 127 +++++++++++ packages/app/src/git/use-diff-query.test.ts | 70 ++++++ packages/app/src/git/use-diff-query.ts | 27 ++- 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 + .../workspace/explorer-sidebar-tab-rail.tsx | 3 +- .../workspace/workspace-desktop-tabs-row.tsx | 2 +- 32 files changed, 858 insertions(+), 244 deletions(-) create mode 100644 packages/app/src/components/ui/floating-action-button.tsx create mode 100644 packages/app/src/components/ui/pane-content-toolbar.test.ts create mode 100644 packages/app/src/git/changed-files-tree.tsx create mode 100644 packages/app/src/git/jump-to-file/index.tsx create mode 100644 packages/app/src/git/use-diff-query.test.ts diff --git a/docs/design.md b/docs/design.md index 0b54be46a3..541fe9c6f5 100644 --- a/docs/design.md +++ b/docs/design.md @@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`. -A pressable styled to look like a button is wrong; the button is `