diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 15637840a7..7712bd66cf 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -1,11 +1,8 @@ name: Deploy Relay on: - push: - branches: [main] - paths: - - "packages/relay/**" - - ".github/workflows/deploy-relay.yml" + # Manual-only while relay.paseo.sh bridges traffic to the Fly deployment. + # A release or main push must not redeploy the temporary Cloudflare bridge. workflow_dispatch: jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd08046ba..8a41ee296a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.1.106-beta.1 - 2026-07-11 +## 0.1.106 - 2026-07-12 ### Added @@ -13,6 +13,8 @@ ### Fixed - Reduced mobile chat freezes and blank screens when switching workspaces while agents are streaming ([#1989](https://github.com/getpaseo/paseo/pull/1989)) +- OpenCode sessions start reliably instead of occasionally losing the first turn ([#2015](https://github.com/getpaseo/paseo/pull/2015) by [@mcowger](https://github.com/mcowger)) +- Switching between workspaces no longer flashes a white screen - Pi keeps your existing MCP tools and settings when Paseo adds its own ([#1990](https://github.com/getpaseo/paseo/pull/1990) by [@mcowger](https://github.com/mcowger)) ## 0.1.105 - 2026-07-10 diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index e9e2f70f5b..2e2a2a2a1f 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -71,13 +71,21 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**: ## The subagents track -The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`). Membership rule (`packages/app/src/subagents/select.ts`): +The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`) combines two kinds of children: + +- **Paseo subagents** are full managed agents. Their membership rule (`packages/app/src/subagents/select.ts`) is: ``` parentAgentId === thisAgent.id AND !archivedAt ``` -Archived subagents disappear from the track, by design. To remove a subagent from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. That same archive shows the subagent leave the track on every connected client. +- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. + +Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical. + +Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only. + +Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no Paseo lifecycle controls and disappear only when the provider removes them or the parent session is discarded. To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot. diff --git a/docs/custom-providers.md b/docs/custom-providers.md index 05e039c5b6..b1b3ce2da0 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -457,6 +457,37 @@ Paseo tools such as subagent creation come from the shared internal tool catalog } ``` +ACP agents execute filesystem and terminal operations in their own environment +by default. To let a compliant agent delegate those operations to Paseo instead, +enable the corresponding client capabilities: + +```json +{ + "agents": { + "providers": { + "local-agent": { + "extends": "acp", + "label": "Local Agent", + "command": ["local-agent", "acp"], + "params": { + "clientCapabilities": { + "fs": { + "readTextFile": true, + "writeTextFile": true + }, + "terminal": true + } + } + } + } + } +} +``` + +Only enable capabilities Paseo should execute. When the agent and Paseo run in +different environments, configure equivalent absolute workspace paths before +delegating filesystem or terminal operations to Paseo. + ### Generic ACP diagnostics Paseo diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status. diff --git a/docs/expo-router.md b/docs/expo-router.md index af5ce6792b..14344a3f16 100644 --- a/docs/expo-router.md +++ b/docs/expo-router.md @@ -96,6 +96,18 @@ Keep workspace identity and retention outside native-stack `getId` and Navigation `getId`, and `getId` has broken Android native-stack/Fabric by reordering an already-mounted workspace screen. +Use `ThemedStack` from `packages/app/src/navigation/themed-stack.tsx` for every +Expo Router stack. React Navigation otherwise paints each native stack screen +with its light default background. A screen-level wrapper can hide that surface +while settled, but Android may expose it for one frame when navigation crosses +from a nested stack to its parent stack. This is especially visible when an +app-wide route such as `/new` opens from a dark workspace. + +Do not read the active theme with `useUnistyles()` in a layout to build +`screenOptions`. `ThemedStack` keeps that third-party prop theme-reactive through +a small `withUnistyles` boundary without subscribing the route tree itself to +every Unistyles runtime update. + ## Regression Shape Pure helper tests are useful but not enough. The failure mode here is native diff --git a/docs/release.md b/docs/release.md index 7ca078a206..668ba5782e 100644 --- a/docs/release.md +++ b/docs/release.md @@ -52,6 +52,8 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`. +Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fly deployment. Releases and pushes to `main` do not deploy the Cloudflare relay worker. Deploy it explicitly with `gh workflow run deploy-relay.yml` only when the production bridge should change. + **Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below). **Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta". diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index f2b9bf6b1d..86414257ac 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -9,6 +9,11 @@ The invariant is: > If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail. +Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced +to 64 KiB, and the same bounded item is used for durable timeline rows and live stream events. +Provider history hydration applies the same rule so reopening an agent cannot restore an oversized +tool payload. + ## Presence is not delivery Client heartbeat reports presence: diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index bd42902bf7..0f7df22e57 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-bUcqRcbaOGK2TgXJ/H/eLg6Y5VcZeVwvu3OWn+nnmCw= +sha256-lYvP0RKgxshO68+bjkkUXX9YJtzgoGIp8kGdGFYOT+o= diff --git a/package-lock.json b/package-lock.json index 1cacd3b270..86163beb9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.106-beta.1", + "version": "0.1.106", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.106-beta.1", + "version": "0.1.106", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -35152,7 +35152,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.106-beta.1", + "version": "0.1.106", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -36170,12 +36170,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.1.106-beta.1", + "version": "0.1.106", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.1.106-beta.1", - "@getpaseo/protocol": "0.1.106-beta.1", - "@getpaseo/server": "0.1.106-beta.1", + "@getpaseo/client": "0.1.106", + "@getpaseo/protocol": "0.1.106", + "@getpaseo/server": "0.1.106", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -36421,10 +36421,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.1.106-beta.1", + "version": "0.1.106", "dependencies": { - "@getpaseo/protocol": "0.1.106-beta.1", - "@getpaseo/relay": "0.1.106-beta.1", + "@getpaseo/protocol": "0.1.106", + "@getpaseo/relay": "0.1.106", "zod": "^4.4.3" }, "devDependencies": { @@ -36435,7 +36435,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.1.106-beta.1", + "version": "0.1.106", "license": "AGPL-3.0-or-later", "dependencies": { "@getpaseo/cli": "*", @@ -36678,7 +36678,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.106-beta.1", + "version": "0.1.106", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -37574,7 +37574,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.1.106-beta.1", + "version": "0.1.106", "dependencies": { "@codemirror/language": "^6.12.3", "@codemirror/legacy-modes": "^6.5.3", @@ -37806,7 +37806,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.1.106-beta.1", + "version": "0.1.106", "dependencies": { "zod": "^4.4.3" }, @@ -37819,7 +37819,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.106-beta.1", + "version": "0.1.106", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -38037,15 +38037,15 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.106-beta.1", + "version": "0.1.106", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.195", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.1.106-beta.1", - "@getpaseo/highlight": "0.1.106-beta.1", - "@getpaseo/protocol": "0.1.106-beta.1", - "@getpaseo/relay": "0.1.106-beta.1", + "@getpaseo/client": "0.1.106", + "@getpaseo/highlight": "0.1.106", + "@getpaseo/protocol": "0.1.106", + "@getpaseo/relay": "0.1.106", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", @@ -38582,7 +38582,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.106-beta.1", + "version": "0.1.106", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", diff --git a/package.json b/package.json index dc167dddda..f938c04c23 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.106-beta.1", + "version": "0.1.106", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/e2e/helpers/rewind-flow.ts b/packages/app/e2e/helpers/rewind-flow.ts index faf8d49b9b..a567e161d2 100644 --- a/packages/app/e2e/helpers/rewind-flow.ts +++ b/packages/app/e2e/helpers/rewind-flow.ts @@ -154,6 +154,10 @@ export async function launchAgent(input: { provider: RewindFlowProvider; cwd: string; mode: "full-access"; + providerConfig?: { + model?: string; + extra?: { codex?: { features?: { multi_agent_v2?: boolean } } }; + }; }): Promise { execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" }); execFileSync("git", ["config", "user.email", "paseo-test@example.com"], { @@ -180,6 +184,7 @@ export async function launchAgent(input: { } const agent = await client.createAgent({ ...fullAccessConfig(input.provider), + ...input.providerConfig, cwd: input.cwd, workspaceId: createdWorkspace.workspace.id, title: `rewind-flow-${input.provider}-${randomUUID()}`, diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/new-workspace.spec.ts index b189bf6e8b..2b5faa246f 100644 --- a/packages/app/e2e/new-workspace.spec.ts +++ b/packages/app/e2e/new-workspace.spec.ts @@ -35,6 +35,8 @@ import { hasGithubAuth, } from "./helpers/github-fixtures"; import { getServerId } from "./helpers/server-id"; +import { getE2EDaemonPort } from "./helpers/daemon-port"; +import { seedSavedSettingsHosts } from "./helpers/settings"; import { expectSidebarWorkspaceSelected, expectWorkspaceHeader, @@ -209,6 +211,53 @@ test.describe("New workspace flow", () => { await client?.close().catch(() => undefined); }); + test("adds a project from the selected empty host", async ({ page }) => { + const repo = await createTempGitRepo("new-workspace-project-picker-"); + const primaryServerId = getServerId(); + const emptyServerId = "empty-new-workspace-host"; + + try { + const openedProject = await openProjectViaDaemon(client, repo.path); + localWorkspaceIds.add(openedProject.workspaceId); + await seedSavedSettingsHosts(page, [ + { + serverId: primaryServerId, + label: "Primary host", + endpoint: `127.0.0.1:${getE2EDaemonPort()}`, + }, + { + serverId: emptyServerId, + label: "Empty host", + endpoint: "127.0.0.1:9", + }, + ]); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openGlobalNewWorkspaceComposer(page); + + const projectTrigger = page.getByTestId("new-workspace-project-picker-trigger"); + await projectTrigger.click(); + await page.getByPlaceholder("Search projects").fill("no matching project"); + await expect(page.getByTestId("new-workspace-project-picker-add-project")).toBeVisible(); + await page.keyboard.press("Escape"); + + await page.getByTestId("host-picker-trigger").click(); + await page.getByTestId(`new-workspace-host-picker-option-${emptyServerId}`).click(); + await expect(projectTrigger).toContainText("Choose project"); + await projectTrigger.click(); + + const addProject = page.getByTestId("new-workspace-project-picker-add-project"); + await expect(addProject).toContainText("Add project"); + await expect(addProject).toContainText(/(?:⌘|Ctrl\+)O/); + await addProject.click(); + + await expect(page.getByTestId("project-picker-input")).toBeVisible(); + } finally { + await repo.cleanup(); + } + }); + test("sidebar workspace navigation updates URL and header", async ({ page }) => { const serverId = getServerId(); diff --git a/packages/app/e2e/provider-subagents.real.spec.ts b/packages/app/e2e/provider-subagents.real.spec.ts new file mode 100644 index 0000000000..62c08694ec --- /dev/null +++ b/packages/app/e2e/provider-subagents.real.spec.ts @@ -0,0 +1,84 @@ +import { mkdtempSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test, expect } from "./fixtures"; +import { + cleanupRewindFlow, + launchAgent, + sendMessage, + type AgentHandle, + type RewindFlowProvider, +} from "./helpers/rewind-flow"; +import { openSubagentsTrack } from "./helpers/subagents"; + +interface ProviderSubagentCase { + provider: RewindFlowProvider; + sentinel: string; + prompt: string; + providerConfig?: Parameters[0]["providerConfig"]; +} + +const cases: ProviderSubagentCase[] = [ + { + provider: "claude", + sentinel: "CLAUDE_CHILD_SENTINEL", + providerConfig: { model: "opus" }, + prompt: + "Use the Task tool exactly once with the Explore subagent. Ask it to reply with exactly CLAUDE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.", + }, + { + provider: "codex", + sentinel: "CODEX_CHILD_SENTINEL", + providerConfig: { extra: { codex: { features: { multi_agent_v2: true } } } }, + prompt: + 'Use collaboration.spawn_agent exactly once with task_name "sentinel_child" and fork_turns "none". Ask it to reply with exactly CODEX_CHILD_SENTINEL and do nothing else. Wait for it with collaboration.wait_agent, then reply ROOT_DONE.', + }, + { + provider: "opencode", + sentinel: "OPENCODE_CHILD_SENTINEL", + prompt: + "Use the task tool exactly once with the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.", + }, +]; + +test.describe("real provider subagent timelines", () => { + test.setTimeout(600_000); + + for (const scenario of cases) { + test(`${scenario.provider} exposes native child output from the subagent track`, async ({ + page, + }) => { + const cwd = realpathSync( + mkdtempSync(path.join(tmpdir(), `paseo-provider-subagent-${scenario.provider}-`)), + ); + let handle: AgentHandle | undefined; + + try { + handle = await launchAgent({ + page, + provider: scenario.provider, + cwd, + mode: "full-access", + providerConfig: scenario.providerConfig, + }); + await sendMessage(handle, scenario.prompt); + await openSubagentsTrack(page); + + const rows = page.locator('[data-testid^="subagents-track-row-"]'); + await expect(rows).toHaveCount(1, { timeout: 60_000 }); + await rows.first().click(); + + const panel = page.getByTestId("provider-subagent-panel"); + await expect(panel).toBeVisible({ timeout: 30_000 }); + await expect( + panel.getByTestId("assistant-message").filter({ hasText: scenario.sentinel }), + ).toBeVisible({ timeout: 30_000 }); + await expect( + panel.getByText("Start chatting with this agent...", { exact: true }), + ).toHaveCount(0); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } + }); + } +}); diff --git a/packages/app/package.json b/packages/app/package.json index 0941a3b633..0e0ef7a478 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.1.106-beta.1", + "version": "0.1.106", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 0a93327c9b..b5edb8a2d1 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -228,13 +228,20 @@ export interface AgentStreamViewHandle { export interface AgentStreamViewProps { agentId: string; serverId?: string; - agent: AgentScreenAgent; + context: AgentScreenAgent; streamItems: StreamItem[]; + streamHead?: StreamItem[]; pendingPermissions: Map; routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady?: boolean; toast?: ToastApi | null; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; + readOnly?: boolean; + historyPagination?: { + hasOlder: boolean; + isLoadingOlder: boolean; + onLoadOlder: () => void; + }; } const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [ @@ -306,13 +313,16 @@ const AgentStreamViewComponent = forwardRef state.setExplorerTabForCheckout); // Get serverId (fallback to agent's serverId if not provided) - const resolvedServerId = serverId ?? agent.serverId ?? ""; + const resolvedServerId = serverId ?? context.serverId ?? ""; const client = useSessionStore((state) => state.sessions[resolvedServerId]?.client ?? null); - const streamHead = useSessionStore((state) => + const sessionStreamHead = useSessionStore((state) => state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId), ); + const streamHead = providedStreamHead ?? sessionStreamHead; const supportsAgentForkContext = useSessionStore( - (state) => state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true, + (state) => + !readOnly && + state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true, ); - const workspaceRoot = agent.cwd?.trim() || ""; + const workspaceRoot = context.cwd?.trim() || ""; const { requestDirectoryListing } = useFileExplorerActions({ serverId: resolvedServerId, - workspaceId: agent.workspaceId, + workspaceId: context.workspaceId, workspaceRoot, }); - const { isLoadingOlder, hasOlder, loadOlder } = useLoadOlderAgentHistory({ + const agentHistoryPagination = useLoadOlderAgentHistory({ serverId: resolvedServerId, agentId, toast, }); + const { isLoadingOlder, hasOlder, loadOlder } = historyPagination + ? { + isLoadingOlder: historyPagination.isLoadingOlder, + hasOlder: historyPagination.hasOlder, + loadOlder: historyPagination.onLoadOlder, + } + : agentHistoryPagination; // Keep entry/exit animations off on Android due to RN dispatchDraw crashes // tracked in react-native-reanimated#8422. const shouldDisableEntryExitAnimations = Platform.OS === "android"; @@ -379,7 +399,7 @@ const AgentStreamViewComponent = forwardRef { const draftId = generateDraftId(); const payload = await client.buildAgentForkContext( @@ -466,7 +486,7 @@ const AgentStreamViewComponent = forwardRef { return buildAgentStreamRenderModel({ - agentStatus: agent.status, + agentStatus: context.status, tail: effectiveStreamItems, head: effectiveStreamHead ?? EMPTY_STREAM_HEAD, platform: isWeb ? "web" : "native", isMobileBreakpoint: isMobile, }); - }, [agent.status, isMobile, effectiveStreamHead, effectiveStreamItems]); + }, [context.status, isMobile, effectiveStreamHead, effectiveStreamItems]); const streamLayout = useMemo( () => layoutStream({ strategy: streamRenderStrategy, - agentStatus: agent.status, + agentStatus: context.status, history: baseRenderModel.history, liveHead: baseRenderModel.segments.liveHead, timingByAssistantId: baseRenderModel.turnTiming.byAssistantId, }), [ - agent.status, + context.status, baseRenderModel.history, baseRenderModel.segments.liveHead, baseRenderModel.turnTiming.byAssistantId, @@ -590,14 +610,14 @@ const AgentStreamViewComponent = forwardRef ); }, - [agent.capabilities, agentId, client, resolvedServerId], + [context.capabilities, agentId, client, resolvedServerId], ); const renderAssistantMessageItem = useCallback( @@ -668,7 +688,7 @@ const AgentStreamViewComponent = forwardRef ); }, - [agent.cwd, setInlineDetailsExpanded, handleToolCallOpenFile], + [context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile], ); const renderStreamItemContent = useCallback( @@ -747,10 +767,10 @@ const AgentStreamViewComponent = forwardRef renderPendingPermissionsNode({ @@ -775,11 +795,12 @@ const AgentStreamViewComponent = forwardRef ) : null, [ handleForkAssistantTurn, + readOnly, showRunningTurnFooter, baseRenderModel.turnTiming.runningStartedAt, bottomTurnFooterHost, @@ -1000,6 +1021,17 @@ function bottomAnchorRouteRequestsEqual( ); } +function historyPaginationPropsEqual( + left: AgentStreamViewProps["historyPagination"], + right: AgentStreamViewProps["historyPagination"], +): boolean { + return ( + left?.hasOlder === right?.hasOlder && + left?.isLoadingOlder === right?.isLoadingOlder && + left?.onLoadOlder === right?.onLoadOlder + ); +} + function agentStreamViewPropsEqual( left: AgentStreamViewProps, right: AgentStreamViewProps, @@ -1007,8 +1039,9 @@ function agentStreamViewPropsEqual( const reasons: string[] = []; if (left.agentId !== right.agentId) reasons.push("agentId"); if (left.serverId !== right.serverId) reasons.push("serverId"); - reasons.push(...collectAgentScreenAgentDiffs(left.agent, right.agent)); + reasons.push(...collectAgentScreenAgentDiffs(left.context, right.context)); if (left.streamItems !== right.streamItems) reasons.push("streamItems"); + if (left.streamHead !== right.streamHead) reasons.push("streamHead"); if (left.pendingPermissions !== right.pendingPermissions) reasons.push("pendingPermissions"); if ( !bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest) @@ -1020,6 +1053,10 @@ function agentStreamViewPropsEqual( } if (left.toast !== right.toast) reasons.push("toast"); if (left.onOpenWorkspaceFile !== right.onOpenWorkspaceFile) reasons.push("onOpenWorkspaceFile"); + if (left.readOnly !== right.readOnly) reasons.push("readOnly"); + if (!historyPaginationPropsEqual(left.historyPagination, right.historyPagination)) { + reasons.push("historyPagination"); + } recordRenderProfileReasons(`AgentStreamView:${right.agentId}`, reasons); return reasons.length === 0; } diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index e397affd54..db6db10e56 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -52,6 +52,7 @@ import { type StartupBlocker, } from "@/navigation/host-runtime-bootstrap"; import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation"; +import { ThemedStack } from "@/navigation/themed-stack"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { listenToDesktopEvent } from "@/desktop/electron/events"; import { updateDesktopWindowControls } from "@/desktop/electron/window"; @@ -784,21 +785,15 @@ function FaviconStatusSync() { return null; } +const ROOT_STACK_SCREEN_OPTIONS = { + headerShown: false, + animation: "none" as const, +}; + function RootStack() { const storeReady = useStoreReady(); - const { theme } = useUnistyles(); - const stackScreenOptions = useMemo( - () => ({ - headerShown: false, - animation: "none" as const, - contentStyle: { - backgroundColor: theme.colors.surface0, - }, - }), - [theme.colors.surface0], - ); return ( - + @@ -815,7 +810,7 @@ function RootStack() { - + ); } diff --git a/packages/app/src/app/h/[serverId]/_layout.tsx b/packages/app/src/app/h/[serverId]/_layout.tsx index 70f7fcf551..4cd3428e08 100644 --- a/packages/app/src/app/h/[serverId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/_layout.tsx @@ -2,6 +2,7 @@ import { Redirect, Stack, useLocalSearchParams } from "expo-router"; import { useHostRuntimeBootstrapState } from "@/app/_layout"; import { HostRouteProvider } from "@/navigation/host-route-context"; import { resolveStartupRoute } from "@/navigation/host-runtime-bootstrap"; +import { ThemedStack } from "@/navigation/themed-stack"; import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime"; const HOST_STACK_SCREEN_OPTIONS = { @@ -33,14 +34,14 @@ function KnownHostRoute() { } const stack = ( - + - + ); if (!routeServerId) { diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 8bc4540a29..f8df25b51d 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -101,9 +101,9 @@ interface SidebarSharedProps { interface SidebarLabels { addProject: string; newWorkspace: string; + hosts: string; home: string; settings: string; - switchHost: string; searchHosts: string; sessions: string; schedules: string; @@ -226,9 +226,9 @@ export const LeftSidebar = memo(function LeftSidebar() { (): SidebarLabels => ({ addProject: t("sidebar.actions.addProject"), newWorkspace: t("sidebar.actions.newWorkspace"), + hosts: t("sidebar.actions.hosts"), home: t("sidebar.actions.home"), settings: t("sidebar.actions.settings"), - switchHost: t("sidebar.host.switchTitle"), searchHosts: t("sidebar.host.searchPlaceholder"), sessions: t("sidebar.sections.sessions"), schedules: t("sidebar.sections.schedules"), @@ -301,47 +301,58 @@ function FooterIconButton({ buttonRef, onPress, testID, - accessibilityLabel, + label, icon: Icon, iconSize, + shortcutKeys, theme, }: { onPress: () => void; testID: string; - accessibilityLabel: string; + label: string; icon: typeof FolderPlus; iconSize?: number; + shortcutKeys?: ReturnType; theme: SidebarTheme; buttonRef?: RefObject; }) { return ( - - {({ hovered }) => ( - - )} - + + + + {({ hovered }) => ( + + )} + + + + + + ); } function SidebarHostPicker({ theme, + label, onAddHost, onOpenHostSettings, }: { theme: SidebarTheme; + label: string; onAddHost: () => void; onOpenHostSettings: (serverId: string) => void; }) { @@ -380,7 +391,7 @@ function SidebarHostPicker({ buttonRef={triggerRef} onPress={handleOpen} testID="sidebar-hosts-trigger" - accessibilityLabel="Hosts" + label={label} icon={Server} iconSize={theme.iconSize.sm} theme={theme} @@ -389,22 +400,7 @@ function SidebarHostPicker({ ); } -function AddProjectTooltipContent({ - newAgentKeys, - label, -}: { - newAgentKeys: ReturnType; - label: string; -}) { - return ( - - {label} - {newAgentKeys ? : null} - - ); -} - -function HeaderIconTooltipContent({ +function IconTooltipContent({ label, shortcutKeys, }: { @@ -489,50 +485,47 @@ function SidebarFooter({ handleSettings: () => void; labels: { addProject: string; + hosts: string; home: string; settings: string; - switchHost: string; searchHosts: string; }; handleAddHost: () => void; handleOpenHostSettings: (serverId: string) => void; }) { const newAgentKeys = useShortcutKeys("new-agent"); + const settingsKeys = useShortcutKeys("toggle-settings"); return ( - - - - - - - - + @@ -876,7 +869,7 @@ function WorkspacesSectionHeader() { - + @@ -886,7 +879,7 @@ function WorkspacesSectionHeader() { - + diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 05f398d3cd..f6d9c655f7 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -21,6 +21,7 @@ import { resolvePendingModifierDataInput, } from "@/utils/terminal-keys"; import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session"; +import { rememberTerminalViewportSize } from "@/terminal/runtime/terminal-size-cache"; import { TerminalStreamController, type TerminalStreamControllerStatus, @@ -635,6 +636,9 @@ export function TerminalPane({ const normalizedCols = Math.floor(cols); const nextSize = { rows: normalizedRows, cols: normalizedCols }; measuredTerminalSizeRef.current = nextSize; + // Seed future terminals in this workspace with the current pane size so they are born at + // the right size instead of the daemon's 80x24 default (see terminal-size-cache). + rememberTerminalViewportSize({ serverId, cwd, size: nextSize }); if (!input.shouldClaim || !client || !terminalId || !isWorkspaceFocused || !isAppVisible) { return; } diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 867685faf2..2799544044 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -116,6 +116,8 @@ export interface ComboboxProps { desktopFixedHeight?: number; /** Content rendered above the scroll area on desktop (sticky header). */ stickyHeader?: ReactNode; + /** Content rendered below the scroll area. */ + footer?: ReactNode; /** When true, selecting an option does not close the picker (multi-select mode). */ keepOpenOnSelect?: boolean; anchorRef: React.RefObject; @@ -928,6 +930,7 @@ interface MobileBodyProps { header: SheetHeader | undefined; onClose: () => void; stickyHeader: ReactNode; + footer: ReactNode; searchable: boolean; hasChildren: boolean; mobileChildrenScrollEnabled: boolean; @@ -1026,6 +1029,7 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { {body} )} + {props.footer ? {props.footer} : null} ); } @@ -1039,6 +1043,7 @@ interface DesktopBodyProps { handleDesktopContentLayout: (event: LayoutChangeEvent) => void; header: SheetHeader | undefined; stickyHeader: ReactNode; + footer: ReactNode; searchable: boolean; searchPlaceholder: string; searchQuery: string; @@ -1192,6 +1197,7 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement { renderOption={props.renderOption} /> )} + {props.footer ? {props.footer} : null} @@ -1224,6 +1230,7 @@ export function Combobox({ desktopMinWidth, desktopFixedHeight, stickyHeader, + footer, keepOpenOnSelect = false, anchorRef, children, @@ -1504,6 +1511,7 @@ export function Combobox({ header={header} onClose={handleClose} stickyHeader={stickyHeader} + footer={footer} searchable={searchable} hasChildren={hasChildren} mobileChildrenScrollEnabled={mobileChildrenScrollEnabled} @@ -1537,6 +1545,7 @@ export function Combobox({ handleDesktopContentLayout={handleDesktopContentLayout} header={header} stickyHeader={stickyHeader} + footer={footer} searchable={searchable} searchPlaceholder={effectiveSearchPlaceholder} searchQuery={searchQuery} @@ -1650,6 +1659,10 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, + footer: { + borderTopWidth: 1, + borderTopColor: theme.colors.border, + }, bottomSheetHeader: { paddingHorizontal: theme.spacing[6], paddingBottom: theme.spacing[2], diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index dc1befb39d..1a0338f757 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -673,7 +673,7 @@ export function WorkspaceDraftAgentTab({ { + if (message.type !== "agent.provider_subagents.update") return; + useProviderSubagentStore.getState().applyUpdate(serverId, message.payload); + }); + const unsubWorkspaceUpdate = client.on("workspace_update", (message) => { if (message.type !== "workspace_update") return; if (message.payload.kind === "remove") { @@ -1750,6 +1756,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider unsubAgentUpdate(); unsubAgentStream(); unsubAgentTimeline(); + unsubProviderSubagentUpdate(); unsubWorkspaceUpdate(); unsubScriptStatusUpdate(); unsubCheckoutStatusUpdate(); diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 52867c76a7..19a17acdc1 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -370,6 +370,7 @@ describe("translation resources", () => { expect(en.sidebar.host.switchTitle).toBe("Switch host"); expect(en.sidebar.host.searchPlaceholder).toBe("Search hosts..."); expect(en.sidebar.actions.addProject).toBe("Add project"); + expect(en.sidebar.actions.hosts).toBe("Hosts"); expect(en.sidebar.actions.home).toBe("Home"); expect(en.sidebar.actions.settings).toBe("Settings"); expect(en.sidebar.actions.closeSidebar).toBe("Close sidebar"); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 09ccba2f4a..b485854700 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -782,6 +782,7 @@ export const ar: TranslationResources = { actions: { addProject: "إضافة مشروع", newWorkspace: "مساحة عمل جديدة", + hosts: "المضيفون", home: "بيت", settings: "إعدادات", closeSidebar: "إغلاق الشريط الجانبي", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 66ae84aaff..abf7dff6a8 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -789,6 +789,7 @@ export const en = { actions: { addProject: "Add project", newWorkspace: "New workspace", + hosts: "Hosts", home: "Home", settings: "Settings", closeSidebar: "Close sidebar", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 1c1efbb12c..b1ca990c1b 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -809,6 +809,7 @@ export const es: TranslationResources = { actions: { addProject: "Agregar proyecto", newWorkspace: "Nuevo espacio de trabajo", + hosts: "Hosts", home: "Hogar", settings: "Ajustes", closeSidebar: "Cerrar barra lateral", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 5a42a88583..614957bc9b 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -808,6 +808,7 @@ export const fr: TranslationResources = { actions: { addProject: "Ajouter un projet", newWorkspace: "Nouvel espace de travail", + hosts: "Hôtes", home: "Maison", settings: "Paramètres", closeSidebar: "Fermer la barre latérale", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 14612aa4e7..cdd7001ac5 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -794,6 +794,7 @@ export const ja: TranslationResources = { actions: { addProject: "プロジェクトを追加", newWorkspace: "新しいワークスペース", + hosts: "ホスト", home: "ホーム", settings: "設定", closeSidebar: "サイドバーを閉じる", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index a7a124be81..f57e7e31b7 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -800,6 +800,7 @@ export const ptBR: TranslationResources = { actions: { addProject: "Adicionar projeto", newWorkspace: "Novo workspace", + hosts: "Hosts", home: "Início", settings: "Configurações", closeSidebar: "Fechar barra lateral", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 7fed4d9a76..e5868a3ecc 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -801,6 +801,7 @@ export const ru: TranslationResources = { actions: { addProject: "Добавить проект", newWorkspace: "Новое рабочее пространство", + hosts: "Хосты", home: "Дом", settings: "Настройки", closeSidebar: "Закрыть боковую панель", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 6eb6adefbb..f07bfe2f36 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -777,6 +777,7 @@ export const zhCN: TranslationResources = { actions: { addProject: "添加 project", newWorkspace: "新建工作区", + hosts: "Hosts", home: "首页", settings: "设置", closeSidebar: "关闭侧边栏", diff --git a/packages/app/src/navigation/themed-stack.tsx b/packages/app/src/navigation/themed-stack.tsx new file mode 100644 index 0000000000..cf83f701c9 --- /dev/null +++ b/packages/app/src/navigation/themed-stack.tsx @@ -0,0 +1,26 @@ +import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; +import { Stack } from "expo-router"; +import { type ReactNode, useMemo } from "react"; +import { withUnistyles } from "react-native-unistyles"; + +interface ThemedStackBaseProps { + backgroundColor: string; + children?: ReactNode; + screenOptions?: NativeStackNavigationOptions; +} + +function ThemedStackBase({ backgroundColor, children, screenOptions }: ThemedStackBaseProps) { + const themedScreenOptions = useMemo( + () => ({ + ...screenOptions, + contentStyle: [{ backgroundColor }, screenOptions?.contentStyle], + }), + [backgroundColor, screenOptions], + ); + + return {children}; +} + +export const ThemedStack = withUnistyles(ThemedStackBase, (theme) => ({ + backgroundColor: theme.colors.surface0, +})); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index f806f68c6b..73bf635f9e 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1270,7 +1270,7 @@ const AgentStreamSection = memo(function AgentStreamSection({ ref={streamViewRef} agentId={agent.id} serverId={serverId} - agent={agent} + context={agent} streamItems={streamItems} pendingPermissions={pendingPermissions} routeBottomAnchorRequest={routeBottomAnchorRequest} @@ -1364,7 +1364,7 @@ function ActiveAgentComposer({ { initialIsBelow: isCompactFormFactor }, ); const paneContext = usePaneContext(); - const { workspaceId, tabId, retargetCurrentTab } = paneContext; + const { workspaceId, tabId, retargetCurrentTab, openTab } = paneContext; const { archiveAgent } = useArchiveAgent(); const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent); @@ -1382,6 +1382,12 @@ function ActiveAgentComposer({ }, [serverId], ); + const handleOpenProviderSubagent = useCallback( + (parentAgentId: string, subagentId: string) => { + openTab({ kind: "provider_subagent", parentAgentId, subagentId }); + }, + [openTab], + ); const handleArchiveSubagent = useArchiveSubagent({ serverId }); const handleDetachSubagent = useDetachSubagent({ serverId }); const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({ @@ -1482,6 +1488,7 @@ function ActiveAgentComposer({ diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx new file mode 100644 index 0000000000..2bff0118cf --- /dev/null +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -0,0 +1,190 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import invariant from "tiny-invariant"; +import { useShallow } from "zustand/react/shallow"; +import { AgentStreamView } from "@/agent-stream/view"; +import { getProviderIcon } from "@/components/provider-icons"; +import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import { useSessionStore } from "@/stores/session-store"; +import { + providerSubagentKey, + providerSubagentLifecycleStatus, + refreshProviderSubagents, + useProviderSubagentStore, +} from "@/subagents/provider-store"; +import { useTranslation } from "react-i18next"; +import type { PendingPermission } from "@/types/shared"; +import type { StreamItem } from "@/types/stream"; +import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; +import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy"; + +const EMPTY_PERMISSIONS = new Map(); +const EMPTY_STREAM_ITEMS: StreamItem[] = []; + +function formatProviderLabel(provider: string): string { + return provider + .split(/[-_\s]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function useProviderSubagentDescriptor( + target: { kind: "provider_subagent"; parentAgentId: string; subagentId: string }, + context: { serverId: string }, +): PanelDescriptor { + const descriptor = useProviderSubagentStore((state) => + state.descriptors.get( + providerSubagentKey(context.serverId, target.parentAgentId, target.subagentId), + ), + ); + const parentProvider = useSessionStore( + (state) => state.sessions[context.serverId]?.agents.get(target.parentAgentId)?.provider, + ); + const provider = descriptor?.provider ?? parentProvider ?? "agent"; + const label = descriptor?.title?.trim() || descriptor?.description?.trim() || "Subagent"; + return { + label, + subtitle: `${formatProviderLabel(provider)} subagent`, + titleState: descriptor ? "ready" : "loading", + icon: getProviderIcon(provider), + statusBucket: descriptor + ? deriveSidebarStateBucket({ + status: providerSubagentLifecycleStatus(descriptor.status), + requiresAttention: descriptor.status === "failed", + }) + : null, + }; +} + +function ProviderSubagentPanel() { + const { t } = useTranslation(); + const { serverId, target, openFileInWorkspace } = usePaneContext(); + invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target"); + const key = providerSubagentKey(serverId, target.parentAgentId, target.subagentId); + const streamId = `provider:${encodeURIComponent(target.parentAgentId)}:${encodeURIComponent(target.subagentId)}`; + const { descriptor, timeline } = useProviderSubagentStore( + useShallow((state) => ({ + descriptor: state.descriptors.get(key) ?? null, + timeline: state.timelines.get(key) ?? null, + })), + ); + const parent = useSessionStore( + (state) => + state.sessions[serverId]?.agents.get(target.parentAgentId) ?? + state.sessions[serverId]?.agentDetails.get(target.parentAgentId) ?? + null, + ); + 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; + const [isLoadingOlder, setIsLoadingOlder] = useState(false); + + useEffect(() => { + if (!client || !supported) return; + void refreshProviderSubagents(client, serverId, target.parentAgentId).catch(() => undefined); + }, [client, serverId, supported, target.parentAgentId]); + + 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]); + + const loadOlder = useCallback(() => { + if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) return; + const firstSeq = timeline.rows.size ? Math.min(...timeline.rows.keys()) : null; + if (firstSeq === null) return; + setIsLoadingOlder(true); + void client + .fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, { + direction: "before", + cursor: { epoch: timeline.epoch, seq: firstSeq }, + limit: TIMELINE_FETCH_PAGE_SIZE, + }) + .then((payload) => { + useProviderSubagentStore.getState().replaceTimeline(serverId, payload); + return undefined; + }) + .catch(() => undefined) + .finally(() => setIsLoadingOlder(false)); + }, [ + client, + isLoadingOlder, + serverId, + supported, + target.parentAgentId, + target.subagentId, + timeline, + ]); + + const streamContext = useMemo( + () => ({ + serverId, + id: streamId, + provider: descriptor?.provider ?? parent?.provider, + status: descriptor ? providerSubagentLifecycleStatus(descriptor.status) : "initializing", + cwd: descriptor?.cwd ?? parent?.cwd ?? "", + workspaceId: parent?.workspaceId, + projectPlacement: parent?.projectPlacement, + }), + [descriptor, parent, serverId, streamId], + ); + const historyPagination = useMemo( + () => ({ + hasOlder: timeline?.hasOlder === true, + isLoadingOlder, + onLoadOlder: loadOlder, + }), + [isLoadingOlder, loadOlder, timeline?.hasOlder], + ); + + if (serverInfo && !supported) { + return ( + + {t("message.actions.forkUnavailable")} + + ); + } + + return ( + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { flex: 1 }, + unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }, + unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" }, +})); + +export const providerSubagentPanelRegistration: PanelRegistration<"provider_subagent"> = { + kind: "provider_subagent", + component: ProviderSubagentPanel, + useDescriptor: useProviderSubagentDescriptor, +}; diff --git a/packages/app/src/panels/register-panels.ts b/packages/app/src/panels/register-panels.ts index ebcf73428f..b6d160d795 100644 --- a/packages/app/src/panels/register-panels.ts +++ b/packages/app/src/panels/register-panels.ts @@ -5,6 +5,7 @@ import { filePanelRegistration } from "@/panels/file-panel"; import { registerPanel } from "@/panels/panel-registry"; import { setupPanelRegistration } from "@/panels/setup-panel"; import { terminalPanelRegistration } from "@/panels/terminal-panel"; +import { providerSubagentPanelRegistration } from "@/panels/provider-subagent-panel"; let panelsRegistered = false; @@ -14,6 +15,7 @@ export function ensurePanelsRegistered(): void { } registerPanel(draftPanelRegistration); registerPanel(agentPanelRegistration); + registerPanel(providerSubagentPanelRegistration); registerPanel(setupPanelRegistration); registerPanel(terminalPanelRegistration); registerPanel(browserPanelRegistration); diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 5e4d1faa08..a5a3e32795 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -5,11 +5,19 @@ import type { TFunction } from "i18next"; import { Pressable, Text, View } from "react-native"; import type { PressableStateCallbackType } from "react-native"; import ReanimatedAnimated from "react-native-reanimated"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { createNameId } from "mnemonic-id"; import { useQuery } from "@tanstack/react-query"; -import { Check, ChevronDown, Folder, GitBranch, GitPullRequest, X } from "lucide-react-native"; +import { + Check, + ChevronDown, + Folder, + FolderPlus, + GitBranch, + GitPullRequest, + X, +} from "lucide-react-native"; import { Composer } from "@/composer"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control"; @@ -20,6 +28,7 @@ import { ProjectIconView } from "@/components/project-icon-view"; import { Combobox, ComboboxItem } from "@/components/ui/combobox"; import type { ComboboxOption as ComboboxOptionType, ComboboxProps } from "@/components/ui/combobox"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; +import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { SidebarMenuToggle } from "@/components/headers/menu-header"; @@ -43,6 +52,7 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session- import { useWorkspace } from "@/stores/session-store-hooks"; import { generateDraftId } from "@/stores/draft-keys"; import { useDraftStore } from "@/stores/draft-store"; +import { useProjectPickerStore } from "@/stores/project-picker-store"; import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store"; import { useWorkspaceDraftSubmissionStore, @@ -50,6 +60,7 @@ import { } from "@/stores/workspace-draft-submission-store"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; 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"; @@ -63,6 +74,7 @@ import { type HostProjectListItem, } from "@/projects/host-projects"; import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import type { MessagePayload } from "@/composer/types"; @@ -87,6 +99,12 @@ import { } from "./new-workspace-initial-context"; import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker"; +const ThemedFolderPlus = withUnistyles(FolderPlus); +const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const addProjectIcon = ( + +); + function resolveCheckoutRequest( selectedItem: PickerItem | null, currentBranch: string | null, @@ -596,6 +614,25 @@ function NewWorkspaceProjectPickerOption({ ); } +function AddProjectPickerAction({ onPress }: { onPress: () => void }) { + const { t } = useTranslation(); + const openProjectKeys = useShortcutKeys("new-agent"); + const shortcut = useMemo( + () => (openProjectKeys ? : null), + [openProjectKeys], + ); + + return ( + + ); +} + function formatPrLabel(item: { number: number; title: string }): string { return `#${item.number} ${item.title}`; } @@ -1357,6 +1394,7 @@ interface NewWorkspaceFormStackInput { iconDataByProjectKey: Map; selectedOptionId: string; onSelect: (id: string) => void; + onAddProject: () => void; renderOption: RefPickerRenderOption; }; host: FormPickerControl & { @@ -1394,6 +1432,10 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host"; const showHostControl = host.allHosts.length > 1; const isolationTriggerLabel = isolationLabel(t, isolation.effectiveIsolation); + const addProjectAction = useMemo( + () => , + [project.onAddProject], + ); const badgePressableStyle = useCallback( ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ @@ -1410,7 +1452,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme ); @@ -1589,6 +1632,7 @@ export function NewWorkspaceScreen({ const [manualPickerSelection, setManualPickerSelection] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); const [projectPickerOpen, setProjectPickerOpen] = useState(false); + const openAddProjectPicker = useProjectPickerStore((state) => state.open); const [isolationPickerOpen, setIsolationPickerOpen] = useState(false); const [pickerSearchQuery, setPickerSearchQuery] = useState(""); const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState(""); @@ -1623,7 +1667,6 @@ export function NewWorkspaceScreen({ lastActiveProject, allowAllProjects: supportsWorkspaceMultiplicity, }); - const projectIconTargets = useMemo( () => projects.flatMap((project) => { @@ -1797,6 +1840,11 @@ export function NewWorkspaceScreen({ [selectProjectOption], ); + const handleAddProject = useCallback(() => { + setProjectPickerOpen(false); + openAddProjectPicker(selectedServerId); + }, [openAddProjectPicker, selectedServerId]); + const checkoutHintPrAttachment = useMemo( () => findCheckoutHintPrAttachment({ @@ -2099,6 +2147,7 @@ export function NewWorkspaceScreen({ iconDataByProjectKey: projectIconDataByProjectKey, selectedOptionId: selectedProjectOptionId, onSelect: handleSelectProjectOption, + onAddProject: handleAddProject, openState: projectPickerOpen, onOpenChange: handleProjectPickerOpenChange, renderOption: renderProjectOption, diff --git a/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts index 8bb9162051..92cb82b036 100644 --- a/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts +++ b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts @@ -5,6 +5,7 @@ import type { WorkspaceDescriptor } from "@/stores/session-store"; import { useTranslation } from "react-i18next"; import { useReplicaQuery } from "@/data/query"; import { workspaceTerminalsPushRoute } from "@/data/push-router"; +import { estimateTerminalViewportSize } from "@/terminal/runtime/terminal-size-cache"; import { buildTerminalsQueryKey, canCreateWorkspaceTerminal, @@ -135,14 +136,20 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { if (!client || !workspaceDirectory) { throw new Error(t("workspace.terminal.hostDisconnected")); } + // Seed the new PTY with the workspace's measured pane size so it isn't born at 80x24. + const estimatedSize = + estimateTerminalViewportSize({ serverId: normalizedServerId, cwd: workspaceDirectory }) ?? + undefined; const payload = _input?.profile ? await client.createTerminal(workspaceDirectory, _input.profile.name, undefined, { command: _input.profile.command, args: _input.profile.args, workspaceId: normalizedWorkspaceId || undefined, + size: estimatedSize, }) : await client.createTerminal(workspaceDirectory, undefined, undefined, { workspaceId: normalizedWorkspaceId || undefined, + size: estimatedSize, }); // The daemon reports a failed spawn (e.g. a profile command that isn't // installed) via payload.error with a null terminal. Surface it instead diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index ad250ea9a5..b35b3a1b4c 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -377,6 +377,9 @@ function getFallbackTabOptionDescription( if (tab.target.kind === "browser") { return labels.browser; } + if (tab.target.kind === "provider_subagent") { + return labels.agent; + } return tab.target.path; } diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index 334f68bb86..2114445178 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -138,6 +138,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "setup") { return `workspace-setup-close-${encodeWorkspaceIdForPathSegment(tab.target.workspaceId)}`; } + if (tab.target.kind === "provider_subagent") { + return `workspace-provider-subagent-close-${tab.target.subagentId}`; + } return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`; } diff --git a/packages/app/src/stores/workspace-tabs-store/state.ts b/packages/app/src/stores/workspace-tabs-store/state.ts index 509f187fbd..6d4ebcb771 100644 --- a/packages/app/src/stores/workspace-tabs-store/state.ts +++ b/packages/app/src/stores/workspace-tabs-store/state.ts @@ -19,6 +19,7 @@ export interface WorkspaceDraftTabSetup { export type WorkspaceTabTarget = | { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup } | { kind: "agent"; agentId: string } + | { kind: "provider_subagent"; parentAgentId: string; subagentId: string } | { kind: "terminal"; terminalId: string } | { kind: "browser"; browserId: string } | WorkspaceFileTabTarget @@ -508,6 +509,17 @@ function coerceWorkspaceTabTarget(raw: Record): WorkspaceTabTar if (kind === "agent" && typeof raw.agentId === "string") { return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId }); } + if ( + kind === "provider_subagent" && + typeof raw.parentAgentId === "string" && + typeof raw.subagentId === "string" + ) { + return normalizeWorkspaceTabTarget({ + kind: "provider_subagent", + parentAgentId: raw.parentAgentId, + subagentId: raw.subagentId, + }); + } if (kind === "terminal" && typeof raw.terminalId === "string") { return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId }); } diff --git a/packages/app/src/subagents/provider-store.test.ts b/packages/app/src/subagents/provider-store.test.ts new file mode 100644 index 0000000000..03c6ade169 --- /dev/null +++ b/packages/app/src/subagents/provider-store.test.ts @@ -0,0 +1,362 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { providerSubagentKey, useProviderSubagentStore } from "./provider-store"; + +const SERVER_ID = "server-1"; +const PARENT_ID = "parent-1"; +const SUBAGENT_ID = "child-1"; + +afterEach(() => { + useProviderSubagentStore.setState({ descriptors: new Map(), timelines: new Map() }); +}); + +describe("provider subagent client store", () => { + test("builds a shared stream model from ordered provider updates", () => { + const subagents = useProviderSubagentStore.getState(); + subagents.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex", + title: "Explore", + description: "Inspect the repository", + status: "running", + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:00.000Z", + toolCallId: "call-1", + }, + }); + subagents.applyUpdate(SERVER_ID, { + kind: "timeline", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "epoch-1", + seq: 2, + timestamp: "2026-07-12T10:00:02.000Z", + item: { type: "assistant_message", text: "New live output." }, + }); + subagents.replaceTimeline(SERVER_ID, { + requestId: "history-1", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + direction: "tail", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, + hasOlder: false, + hasNewer: true, + rows: [ + { + seq: 1, + timestamp: "2026-07-12T10:00:01.000Z", + item: { type: "assistant_message", text: "Older history." }, + }, + ], + error: null, + }); + const liveTimeline = useProviderSubagentStore + .getState() + .timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)); + subagents.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex", + title: "Explore", + description: "Inspect the repository", + status: "running", + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:01.500Z", + toolCallId: "call-1", + }, + }); + expect( + useProviderSubagentStore + .getState() + .timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)), + ).toBe(liveTimeline); + subagents.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex", + title: "Explore", + description: "Inspect the repository", + status: "completed", + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:02.000Z", + toolCallId: "call-1", + }, + }); + + const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); + 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.", + }), + ]); + }); + + test("removes timelines for children no longer returned by the provider", () => { + const store = useProviderSubagentStore.getState(); + store.applyUpdate(SERVER_ID, { + kind: "timeline", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "epoch-1", + seq: 1, + timestamp: "2026-07-12T10:00:01.000Z", + item: { type: "assistant_message", text: "Removed child output." }, + }); + + store.replaceList(SERVER_ID, PARENT_ID, []); + + expect( + useProviderSubagentStore + .getState() + .timelines.has(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)), + ).toBe(false); + }); + + test("applies terminal list status to a timeline received before its descriptor", () => { + const store = useProviderSubagentStore.getState(); + store.applyUpdate(SERVER_ID, { + kind: "timeline", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "epoch-1", + seq: 1, + timestamp: "2026-07-12T10:00:01.000Z", + item: { type: "assistant_message", text: "Restored output." }, + }); + const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); + expect(useProviderSubagentStore.getState().timelines.get(key)?.head).not.toEqual([]); + + store.replaceList(SERVER_ID, PARENT_ID, [ + { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex", + title: "Restored child", + description: null, + status: "completed", + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:02.000Z", + toolCallId: "call-1", + }, + ]); + + const timeline = useProviderSubagentStore.getState().timelines.get(key); + expect(timeline?.head).toEqual([]); + expect(timeline?.tail).toEqual([ + expect.objectContaining({ kind: "assistant_message", text: "Restored output." }), + ]); + }); + + test("keeps late timeline rows terminal after the descriptor completes", () => { + const store = useProviderSubagentStore.getState(); + store.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex", + title: "Restored child", + description: null, + status: "completed", + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:02.000Z", + toolCallId: "call-1", + }, + }); + store.applyUpdate(SERVER_ID, { + kind: "timeline", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "epoch-1", + seq: 1, + timestamp: "2026-07-12T10:00:01.000Z", + item: { type: "assistant_message", text: "Late restored output." }, + }); + + const timeline = useProviderSubagentStore + .getState() + .timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)); + expect(timeline?.head).toEqual([]); + expect(timeline?.tail).toEqual([ + expect.objectContaining({ kind: "assistant_message", text: "Late restored output." }), + ]); + }); + + test("merges bounded older pages and tracks whether more history remains", () => { + const store = useProviderSubagentStore.getState(); + store.replaceTimeline(SERVER_ID, { + requestId: "tail-page", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + direction: "tail", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 2, maxSeq: 2, nextSeq: 3 }, + hasOlder: true, + hasNewer: false, + rows: [ + { + seq: 2, + timestamp: "2026-07-12T10:00:02.000Z", + item: { type: "assistant_message", text: "Recent output." }, + }, + ], + error: null, + }); + store.replaceTimeline(SERVER_ID, { + requestId: "older-page", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + direction: "before", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: 2, nextSeq: 3 }, + hasOlder: false, + hasNewer: true, + rows: [ + { + seq: 1, + timestamp: "2026-07-12T10:00:01.000Z", + item: { type: "assistant_message", text: "Older output." }, + }, + ], + error: null, + }); + + const timeline = useProviderSubagentStore + .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.objectContaining({ kind: "assistant_message", text: "Older output.Recent output." }), + ]); + }); + + test("ignores delayed live updates from a stale timeline epoch", () => { + const store = useProviderSubagentStore.getState(); + store.replaceTimeline(SERVER_ID, { + requestId: "current-page", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + direction: "tail", + epoch: "epoch-current", + reset: true, + staleCursor: false, + gap: false, + window: { minSeq: 2, maxSeq: 2, nextSeq: 3 }, + hasOlder: false, + hasNewer: false, + rows: [ + { + seq: 2, + timestamp: "2026-07-12T10:00:02.000Z", + item: { type: "assistant_message", text: "Current output." }, + }, + ], + error: null, + }); + + store.applyUpdate(SERVER_ID, { + kind: "timeline", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "epoch-stale", + seq: 3, + timestamp: "2026-07-12T10:00:03.000Z", + item: { type: "assistant_message", text: "Stale output." }, + }); + + const timeline = useProviderSubagentStore + .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.objectContaining({ kind: "assistant_message", text: "Current output." }), + ]); + }); + + test("replaces cached rows with an authoritative tail page after a reconnect gap", () => { + const store = useProviderSubagentStore.getState(); + store.replaceTimeline(SERVER_ID, { + requestId: "old-tail", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + direction: "tail", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: 500, nextSeq: 501 }, + hasOlder: true, + hasNewer: false, + rows: [ + { + seq: 100, + timestamp: "2026-07-12T10:00:00.000Z", + item: { type: "assistant_message", text: "Old cached output." }, + }, + ], + error: null, + }); + store.replaceTimeline(SERVER_ID, { + requestId: "reconnect-tail", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + direction: "tail", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: 500, nextSeq: 501 }, + hasOlder: true, + hasNewer: false, + rows: [ + { + seq: 401, + timestamp: "2026-07-12T10:00:01.000Z", + item: { type: "assistant_message", text: "Current tail output." }, + }, + ], + error: null, + }); + + const timeline = useProviderSubagentStore + .getState() + .timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)); + expect([...timeline!.rows.keys()]).toEqual([401]); + expect(timeline?.head).toEqual([ + expect.objectContaining({ kind: "assistant_message", text: "Current tail output." }), + ]); + }); +}); diff --git a/packages/app/src/subagents/provider-store.ts b/packages/app/src/subagents/provider-store.ts new file mode 100644 index 0000000000..f0d76ee704 --- /dev/null +++ b/packages/app/src/subagents/provider-store.ts @@ -0,0 +1,304 @@ +import type { + AgentStreamEventPayload, + ProviderSubagentDescriptorPayload, + SessionOutboundMessage, +} from "@getpaseo/protocol/messages"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { create } from "zustand"; +import { applyStreamEvent } from "@/types/stream"; +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; +} + +interface ProviderSubagentState { + descriptors: Map; + timelines: Map; + replaceList( + serverId: string, + parentAgentId: string, + subagents: ProviderSubagentDescriptorPayload[], + ): void; + applyUpdate( + serverId: string, + payload: Extract< + SessionOutboundMessage, + { type: "agent.provider_subagents.update" } + >["payload"], + ): void; + replaceTimeline( + serverId: string, + payload: Extract< + SessionOutboundMessage, + { type: "agent.provider_subagents.timeline.get.response" } + >["payload"], + ): void; +} + +export function providerSubagentKey( + serverId: string, + parentAgentId: string, + subagentId: string, +): string { + return `${serverId}\0${parentAgentId}\0${subagentId}`; +} + +export function providerSubagentLifecycleStatus( + status: ProviderSubagentDescriptorPayload["status"], +): AgentLifecycleStatus { + if (status === "running") return "running"; + if (status === "failed") return "error"; + return "idle"; +} + +type ProviderSubagentListClient = Pick; + +const pendingListRequests = new WeakMap>>(); + +export function refreshProviderSubagents( + client: ProviderSubagentListClient, + serverId: string, + parentAgentId: string, +): Promise { + const requestKey = `${serverId}\0${parentAgentId}`; + let clientRequests = pendingListRequests.get(client); + if (!clientRequests) { + clientRequests = new Map(); + pendingListRequests.set(client, clientRequests); + } + const pending = clientRequests.get(requestKey); + if (pending) return pending; + + const request = client + .listProviderSubagents(parentAgentId) + .then((payload) => { + useProviderSubagentStore.getState().replaceList(serverId, parentAgentId, payload.subagents); + return undefined; + }) + .finally(() => { + clientRequests?.delete(requestKey); + }); + clientRequests.set(requestKey, request); + return request; +} + +function parentPrefix(serverId: string, parentAgentId: string): string { + return `${serverId}\0${parentAgentId}\0`; +} + +const EMPTY_TIMELINE: ProviderSubagentTimelineState = { + tail: [], + head: [], + epoch: null, + lastSeq: 0, + hasOlder: false, + rows: new Map(), +}; + +function providerSubagentTerminalEvent( + subagent: ProviderSubagentDescriptorPayload, +): AgentStreamEventPayload | null { + if (subagent.status === "running") { + return null; + } + if (subagent.status === "failed") { + return { type: "turn_failed", provider: subagent.provider, error: "Subagent failed" }; + } + if (subagent.status === "canceled") { + return { type: "turn_canceled", provider: subagent.provider, reason: "canceled" }; + } + return { type: "turn_completed", provider: subagent.provider }; +} + +function buildTimelineState( + rows: ProviderSubagentTimelineState["rows"], + epoch: string | null, + 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), + }); + } + return { + ...timeline, + epoch, + lastSeq: rows.size ? Math.max(...rows.keys()) : 0, + hasOlder, + rows, + }; +} + +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(), + replaceList(serverId, parentAgentId, subagents) { + set((state) => { + const prefix = parentPrefix(serverId, parentAgentId); + const descriptors = new Map( + [...state.descriptors].filter(([key]) => !key.startsWith(prefix)), + ); + for (const subagent of subagents) { + descriptors.set(providerSubagentKey(serverId, parentAgentId, subagent.id), subagent); + } + const retainedKeys = new Set(descriptors.keys()); + const timelines = new Map( + [...state.timelines].filter(([key]) => !key.startsWith(prefix) || retainedKeys.has(key)), + ); + for (const subagent of subagents) { + const key = providerSubagentKey(serverId, parentAgentId, subagent.id); + const current = timelines.get(key); + const previous = state.descriptors.get(key); + if (current && previous?.status !== subagent.status) { + timelines.set( + key, + buildTimelineState(current.rows, current.epoch, subagent, current.hasOlder), + ); + } + } + return { descriptors, timelines }; + }); + }, + applyUpdate(serverId, payload) { + set((state) => { + if (payload.kind === "upsert") { + const key = providerSubagentKey( + serverId, + payload.subagent.parentAgentId, + payload.subagent.id, + ); + const descriptors = new Map(state.descriptors); + const previous = descriptors.get(key); + descriptors.set(key, payload.subagent); + let timelines = state.timelines; + const current = state.timelines.get(key); + if (current && previous?.status !== payload.subagent.status) { + timelines = new Map(state.timelines); + timelines.set( + key, + buildTimelineState(current.rows, current.epoch, payload.subagent, current.hasOlder), + ); + } + return { descriptors, timelines }; + } + if (payload.kind === "remove") { + const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId); + const descriptors = new Map(state.descriptors); + const timelines = new Map(state.timelines); + descriptors.delete(key); + timelines.delete(key); + return { descriptors, timelines }; + } + const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId); + const existing = state.timelines.get(key); + if (existing?.epoch && existing.epoch !== payload.epoch) { + return state; + } + const current = existing ?? EMPTY_TIMELINE; + if (payload.seq <= current.lastSeq) { + return state; + } + const 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, + epoch: payload.epoch, + lastSeq: payload.seq, + hasOlder: current.hasOlder, + rows, + }); + return { timelines }; + }); + }, + replaceTimeline(serverId, payload) { + const provider = payload.provider; + if (!provider) { + return; + } + set((state) => { + const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId); + const existing = state.timelines.get(key); + const rows = buildTimelineResponseRows(existing, payload, provider); + const descriptor = state.descriptors.get(key); + const timelines = new Map(state.timelines); + timelines.set(key, buildTimelineState(rows, payload.epoch, descriptor, payload.hasOlder)); + return { timelines }; + }); + }, +})); diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index c8022735bb..4a8599467a 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -1,6 +1,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { afterEach, describe, expect, it } from "vitest"; -import { selectSubagentsForParent } from "./select"; +import { selectProviderSubagentsForParent, selectSubagentsForParent } from "./select"; +import { useProviderSubagentStore } from "./provider-store"; import { useSessionStore, type Agent } from "@/stores/session-store"; const SERVER_ID = "server-1"; @@ -58,9 +59,37 @@ function setAgents(agents: Agent[]): void { afterEach(() => { useSessionStore.getState().clearSession(SERVER_ID); + useProviderSubagentStore.setState({ descriptors: new Map(), timelines: new Map() }); }); describe("selectSubagentsForParent", () => { + it("hides cached provider children when the host does not support them", () => { + useProviderSubagentStore.getState().applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { + id: "provider-child", + parentAgentId: "parent-a", + provider: "codex", + title: "Provider child", + description: null, + status: "completed", + createdAt: "2026-03-08T10:01:00.000Z", + updatedAt: "2026-03-08T10:02:00.000Z", + toolCallId: "call-1", + }, + }); + const params = { serverId: SERVER_ID, parentAgentId: "parent-a" }; + + expect( + selectProviderSubagentsForParent(useProviderSubagentStore.getState(), params, false), + ).toEqual([]); + expect( + selectProviderSubagentsForParent(useProviderSubagentStore.getState(), params, true).map( + (row) => row.id, + ), + ).toEqual(["provider-child"]); + }); + it("returns only non-archived children for the requested parent", () => { setAgents([ makeAgent({ id: "parent-a" }), @@ -194,6 +223,7 @@ describe("selectSubagentsForParent", () => { expect(rows).toEqual([ { + kind: "paseo", id: "child", provider: "claude", title: "Review child", @@ -205,6 +235,7 @@ describe("selectSubagentsForParent", () => { expect(Object.keys(rows[0] ?? {}).sort()).toEqual([ "createdAt", "id", + "kind", "provider", "requiresAttention", "status", diff --git a/packages/app/src/subagents/select.ts b/packages/app/src/subagents/select.ts index 08807b689c..a235cc445a 100644 --- a/packages/app/src/subagents/select.ts +++ b/packages/app/src/subagents/select.ts @@ -1,9 +1,13 @@ +import { useEffect, useMemo } from "react"; import { usePendingArchiveAgentIds } from "@/hooks/use-archive-agent"; import equal from "fast-deep-equal"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useSessionStore, type Agent } from "@/stores/session-store"; +import { refreshProviderSubagents, useProviderSubagentStore } from "./provider-store"; +import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages"; -export interface SubagentRow { +export interface PaseoSubagentRow { + kind: "paseo"; id: Agent["id"]; provider: Agent["provider"]; title: Agent["title"]; @@ -12,7 +16,21 @@ export interface SubagentRow { createdAt: Agent["createdAt"]; } +export interface ProviderSubagentRow { + kind: "provider"; + id: string; + parentAgentId: string; + provider: ProviderSubagentDescriptorPayload["provider"]; + title: string | null; + status: ProviderSubagentDescriptorPayload["status"]; + requiresAttention: boolean; + createdAt: Date; +} + +export type SubagentRow = PaseoSubagentRow | ProviderSubagentRow; + type SessionStoreSnapshot = ReturnType; +type ProviderSubagentStoreSnapshot = ReturnType; interface SelectSubagentsParams { serverId: string; @@ -20,9 +38,11 @@ interface SelectSubagentsParams { } const EMPTY_SUBAGENT_ROWS: SubagentRow[] = []; +const EMPTY_PROVIDER_SUBAGENT_ROWS: ProviderSubagentRow[] = []; function toSubagentRow(agent: Agent): SubagentRow { return { + kind: "paseo", id: agent.id, provider: agent.provider, title: agent.title, @@ -62,11 +82,59 @@ export function selectSubagentsForParent( return rows; } +export function selectProviderSubagentsForParent( + state: ProviderSubagentStoreSnapshot, + params: SelectSubagentsParams, + supported: boolean, +): ProviderSubagentRow[] { + if (!supported) return EMPTY_PROVIDER_SUBAGENT_ROWS; + const rows: ProviderSubagentRow[] = []; + const prefix = `${params.serverId}\0${params.parentAgentId}\0`; + for (const [key, subagent] of state.descriptors) { + if (!key.startsWith(prefix)) continue; + rows.push({ + kind: "provider", + id: subagent.id, + parentAgentId: subagent.parentAgentId, + provider: subagent.provider, + title: subagent.title ?? subagent.description, + status: subagent.status, + requiresAttention: subagent.status === "failed", + createdAt: new Date(subagent.createdAt), + }); + } + rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()); + return rows; +} + export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] { const pendingArchiveIds = usePendingArchiveAgentIds(params.serverId); - return useStoreWithEqualityFn( + const paseoRows = useStoreWithEqualityFn( useSessionStore, (state) => selectSubagentsForParent(state, params, pendingArchiveIds), equal, ); + const supported = useSessionStore( + (state) => state.sessions[params.serverId]?.serverInfo?.features?.providerSubagents === true, + ); + const providerRows = useStoreWithEqualityFn( + useProviderSubagentStore, + (state) => selectProviderSubagentsForParent(state, params, supported), + equal, + ); + const client = useSessionStore((state) => state.sessions[params.serverId]?.client ?? null); + + useEffect(() => { + if (!client || !supported) return; + void refreshProviderSubagents(client, params.serverId, params.parentAgentId).catch( + () => undefined, + ); + }, [client, params.parentAgentId, params.serverId, supported]); + + return useMemo(() => { + if (providerRows.length === 0) return paseoRows; + const rows = [...paseoRows, ...providerRows]; + rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()); + return rows; + }, [paseoRows, providerRows]); } diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts index 82170690e5..2802c1707f 100644 --- a/packages/app/src/subagents/track-presentation.test.ts +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it } from "vitest"; -import type { SubagentRow } from "./select"; +import type { PaseoSubagentRow, SubagentRow } from "./select"; import { buildSubagentRowPresentationData, formatHeaderLabel, resolveRowLabel, } from "./track-presentation"; -function row(overrides: Partial & Pick): SubagentRow { +function row( + overrides: Partial & Pick, +): PaseoSubagentRow { return { + kind: "paseo", id: overrides.id, provider: overrides.provider ?? "codex", title: overrides.title ?? `Agent ${overrides.id}`, @@ -91,7 +94,9 @@ describe("resolveRowLabel", () => { describe("buildSubagentRowPresentationData", () => { it("namespaces the key with a subagent prefix", () => { - expect(buildSubagentRowPresentationData(row({ id: "child-a" })).key).toBe("subagent_child-a"); + expect(buildSubagentRowPresentationData(row({ id: "child-a" })).key).toBe( + "paseo_subagent_child-a", + ); }); it("marks the row ready when the title resolves to a real label", () => { diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts index d9c626313c..d7255cc77b 100644 --- a/packages/app/src/subagents/track-presentation.ts +++ b/packages/app/src/subagents/track-presentation.ts @@ -1,6 +1,12 @@ import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import type { SubagentRow } from "./select"; +import { providerSubagentLifecycleStatus } from "./provider-store"; + +function presentationStatus(row: SubagentRow) { + if (row.kind === "paseo") return row.status; + return providerSubagentLifecycleStatus(row.status); +} export interface SubagentRowPresentationData { key: string; @@ -13,14 +19,15 @@ export interface SubagentRowPresentationData { export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowPresentationData { const label = resolveRowLabel(row.title); + const status = presentationStatus(row); return { - key: `subagent_${row.id}`, + key: `${row.kind}_subagent_${row.id}`, kind: "agent", label: label ?? "", subtitle: "", titleState: label ? "ready" : "loading", statusBucket: deriveSidebarStateBucket({ - status: row.status, + status, requiresAttention: false, }), }; diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index c768e82edc..c0ff19ec31 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -28,6 +28,7 @@ const foregroundMutedColorMapping = (theme: Theme) => ({ export interface SubagentsTrackProps { rows: SubagentRow[]; onOpenSubagent: (id: string) => void; + onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; onArchiveSubagent: (id: string) => void; onDetachSubagent?: (id: string) => void; } @@ -44,6 +45,7 @@ function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation { export function SubagentsTrack({ rows, onOpenSubagent, + onOpenProviderSubagent, onArchiveSubagent, onDetachSubagent, }: SubagentsTrackProps): ReactElement | null { @@ -105,6 +107,7 @@ export function SubagentsTrack({ key={row.id} row={row} onOpenSubagent={onOpenSubagent} + onOpenProviderSubagent={onOpenProviderSubagent} onArchiveSubagent={onArchiveSubagent} onDetachSubagent={onDetachSubagent} /> @@ -120,6 +123,7 @@ export function SubagentsTrack({ interface SubagentsTrackRowProps { row: SubagentRow; onOpenSubagent: (id: string) => void; + onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; onArchiveSubagent: (id: string) => void; onDetachSubagent?: (id: string) => void; } @@ -127,6 +131,7 @@ interface SubagentsTrackRowProps { function SubagentsTrackRow({ row, onOpenSubagent, + onOpenProviderSubagent, onArchiveSubagent, onDetachSubagent, }: SubagentsTrackRowProps): ReactElement { @@ -137,8 +142,12 @@ function SubagentsTrackRow({ const displayLabel = presentation.titleState === "loading" ? t("common.states.loading") : presentation.label; const handlePress = useCallback(() => { - onOpenSubagent(row.id); - }, [onOpenSubagent, row.id]); + if (row.kind === "provider") { + onOpenProviderSubagent(row.parentAgentId, row.id); + } else { + onOpenSubagent(row.id); + } + }, [onOpenProviderSubagent, onOpenSubagent, row]); const handleArchivePress = useCallback(() => { onArchiveSubagent(row.id); }, [onArchiveSubagent, row.id]); @@ -167,13 +176,15 @@ function SubagentsTrackRow({ {displayLabel} - + {row.kind === "paseo" ? ( + + ) : null} )} diff --git a/packages/app/src/terminal/runtime/terminal-size-cache.test.ts b/packages/app/src/terminal/runtime/terminal-size-cache.test.ts new file mode 100644 index 0000000000..41aaad19e0 --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-size-cache.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + estimateTerminalViewportSize, + rememberTerminalViewportSize, + resetTerminalViewportSizeCacheForTests, +} from "./terminal-size-cache"; + +describe("terminal-size-cache", () => { + beforeEach(() => { + resetTerminalViewportSizeCacheForTests(); + }); + + it("returns null before any size has been measured", () => { + expect(estimateTerminalViewportSize({ serverId: "s1", cwd: "/repo" })).toBeNull(); + }); + + it("returns the last measured size for the same workspace", () => { + rememberTerminalViewportSize({ serverId: "s1", cwd: "/repo", size: { rows: 55, cols: 136 } }); + expect(estimateTerminalViewportSize({ serverId: "s1", cwd: "/repo" })).toEqual({ + rows: 55, + cols: 136, + }); + }); + + it("falls back to the most recent size from another workspace", () => { + rememberTerminalViewportSize({ serverId: "s1", cwd: "/repo-a", size: { rows: 40, cols: 100 } }); + // No terminal has been measured in /repo-b yet — the estimate uses the global most-recent size. + expect(estimateTerminalViewportSize({ serverId: "s1", cwd: "/repo-b" })).toEqual({ + rows: 40, + cols: 100, + }); + }); + + it("prefers the same-workspace size over the global most-recent", () => { + rememberTerminalViewportSize({ serverId: "s1", cwd: "/repo-a", size: { rows: 40, cols: 100 } }); + rememberTerminalViewportSize({ serverId: "s1", cwd: "/repo-b", size: { rows: 55, cols: 136 } }); + // /repo-a keeps its own measured size even though /repo-b was measured more recently. + expect(estimateTerminalViewportSize({ serverId: "s1", cwd: "/repo-a" })).toEqual({ + rows: 40, + cols: 100, + }); + }); + + it("keys by server + cwd so different hosts do not collide", () => { + rememberTerminalViewportSize({ serverId: "s1", cwd: "/repo", size: { rows: 40, cols: 100 } }); + rememberTerminalViewportSize({ serverId: "s2", cwd: "/repo", size: { rows: 55, cols: 136 } }); + expect(estimateTerminalViewportSize({ serverId: "s1", cwd: "/repo" })).toEqual({ + rows: 40, + cols: 100, + }); + expect(estimateTerminalViewportSize({ serverId: "s2", cwd: "/repo" })).toEqual({ + rows: 55, + cols: 136, + }); + }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-size-cache.ts b/packages/app/src/terminal/runtime/terminal-size-cache.ts new file mode 100644 index 0000000000..3f965ce25f --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-size-cache.ts @@ -0,0 +1,50 @@ +export interface TerminalViewportSize { + rows: number; + cols: number; +} + +function cacheKey(input: { serverId: string; cwd: string }): string { + // JSON-encode the pair so a `:` inside serverId or cwd (e.g. a Windows `C:\` path) can't + // make two different (serverId, cwd) pairs collide onto the same key. + return JSON.stringify([input.serverId, input.cwd]); +} + +const sizeByWorkspace = new Map(); +let mostRecentSize: TerminalViewportSize | null = null; + +/** + * Remember the latest measured terminal viewport size for a workspace. Every terminal in a + * workspace renders into the same pane, so this is the best estimate of the size a *new* + * terminal in that workspace will render at — used to seed the PTY at creation time instead + * of the daemon's 80x24 default (which otherwise shows briefly, or sticks, until the first + * resize lands). + */ +export function rememberTerminalViewportSize(input: { + serverId: string; + cwd: string; + size: TerminalViewportSize; +}): void { + const size: TerminalViewportSize = { rows: input.size.rows, cols: input.size.cols }; + sizeByWorkspace.set(cacheKey(input), size); + mostRecentSize = size; +} + +/** + * Best estimate of the viewport size a new terminal in this workspace will render at: + * the last measured size for the same workspace, else the most recently measured size + * anywhere (panes are usually the same size across workspaces on one device), else null + * when nothing has been measured yet this session — in which case the daemon keeps its + * 80x24 default and the first resize corrects it as before. + */ +export function estimateTerminalViewportSize(input: { + serverId: string; + cwd: string; +}): TerminalViewportSize | null { + return sizeByWorkspace.get(cacheKey(input)) ?? mostRecentSize; +} + +/** Test-only: clear all remembered sizes so cases don't leak into each other. */ +export function resetTerminalViewportSizeCacheForTests(): void { + sizeByWorkspace.clear(); + mostRecentSize = null; +} diff --git a/packages/app/src/workspace-tabs/identity.test.ts b/packages/app/src/workspace-tabs/identity.test.ts new file mode 100644 index 0000000000..b75270958d --- /dev/null +++ b/packages/app/src/workspace-tabs/identity.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "vitest"; +import { + buildDeterministicWorkspaceTabId, + normalizeWorkspaceTabTarget, + workspaceTabTargetsEqual, +} from "./identity"; + +describe("provider subagent tab identity", () => { + test("normalizes and compares the parent and provider child as one tab identity", () => { + const target = normalizeWorkspaceTabTarget({ + kind: "provider_subagent", + parentAgentId: " parent-a ", + subagentId: " child-a ", + }); + + expect(target).toEqual({ + kind: "provider_subagent", + parentAgentId: "parent-a", + subagentId: "child-a", + }); + expect( + target && + workspaceTabTargetsEqual(target, { + kind: "provider_subagent", + parentAgentId: "parent-a", + subagentId: "child-a", + }), + ).toBe(true); + }); + + test("does not collide when parent and child ids contain separators", () => { + const first = buildDeterministicWorkspaceTabId({ + kind: "provider_subagent", + parentAgentId: "a_b", + subagentId: "c", + }); + const second = buildDeterministicWorkspaceTabId({ + kind: "provider_subagent", + parentAgentId: "a", + subagentId: "b_c", + }); + + expect(first).not.toBe(second); + }); +}); diff --git a/packages/app/src/workspace-tabs/identity.ts b/packages/app/src/workspace-tabs/identity.ts index 35a2693e87..3e5bf004ef 100644 --- a/packages/app/src/workspace-tabs/identity.ts +++ b/packages/app/src/workspace-tabs/identity.ts @@ -21,6 +21,13 @@ export function normalizeWorkspaceTabTarget( const agentId = trimNonEmpty(value.agentId); return agentId ? { kind: "agent", agentId } : null; } + if (value.kind === "provider_subagent") { + const parentAgentId = trimNonEmpty(value.parentAgentId); + const subagentId = trimNonEmpty(value.subagentId); + return parentAgentId && subagentId + ? { kind: "provider_subagent", parentAgentId, subagentId } + : null; + } if (value.kind === "terminal") { const terminalId = trimNonEmpty(value.terminalId); return terminalId ? { kind: "terminal", terminalId } : null; @@ -76,6 +83,9 @@ export function workspaceTabTargetsEqual( if (left.kind === "agent" && right.kind === "agent") { return left.agentId === right.agentId; } + if (left.kind === "provider_subagent" && right.kind === "provider_subagent") { + return left.parentAgentId === right.parentAgentId && left.subagentId === right.subagentId; + } if (left.kind === "terminal" && right.kind === "terminal") { return left.terminalId === right.terminalId; } @@ -131,6 +141,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st if (target.kind === "agent") { return `agent_${target.agentId}`; } + if (target.kind === "provider_subagent") { + return `provider_subagent_${target.parentAgentId.length}_${target.parentAgentId}_${target.subagentId.length}_${target.subagentId}`; + } if (target.kind === "terminal") { return `terminal_${target.terminalId}`; } diff --git a/packages/cli/package.json b/packages/cli/package.json index 85121d1b62..f7e054f236 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.1.106-beta.1", + "version": "0.1.106", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -27,9 +27,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.1.106-beta.1", - "@getpaseo/protocol": "0.1.106-beta.1", - "@getpaseo/server": "0.1.106-beta.1", + "@getpaseo/client": "0.1.106", + "@getpaseo/protocol": "0.1.106", + "@getpaseo/server": "0.1.106", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index 826a194b0a..daf4d87899 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.1.106-beta.1", + "version": "0.1.106", "description": "Paseo client SDK package", "files": [ "dist", @@ -35,8 +35,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.1.106-beta.1", - "@getpaseo/relay": "0.1.106-beta.1", + "@getpaseo/protocol": "0.1.106", + "@getpaseo/relay": "0.1.106", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index de15526f0a..a636fa4d5a 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -555,6 +555,7 @@ test("advertises client capabilities in hello", async () => { protocolVersion: 1, capabilities: { custom_mode_icons: true, + provider_subagents: true, reasoning_merge_enum: true, terminal_reflowable_snapshot: true, browser_host: { diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 4e1784c89e..571f9221ec 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -490,6 +490,22 @@ export interface FetchAgentTimelineOptions { timeout?: number; } +export type ProviderSubagentListPayload = Extract< + SessionOutboundMessage, + { type: "agent.provider_subagents.list.response" } +>["payload"]; +export type ProviderSubagentTimelinePayload = Extract< + SessionOutboundMessage, + { type: "agent.provider_subagents.timeline.get.response" } +>["payload"]; +export interface FetchProviderSubagentTimelineOptions { + direction?: ProviderSubagentTimelinePayload["direction"]; + cursor?: FetchAgentTimelineCursor; + limit?: number; + requestId?: string; + timeout?: number; +} + // COMPAT(daemon-client-object-options): added in v0.1.102; remove after // 2026-12-29 once SDK callers have migrated to object parameters. function normalizeFetchAgentOptions( @@ -2429,6 +2445,65 @@ export class DaemonClient { return payload; } + async listProviderSubagents( + parentAgentId: string, + options: { requestId?: string; timeout?: number } = {}, + ): Promise { + const requestId = this.createRequestId(options.requestId); + const message = SessionInboundMessageSchema.parse({ + type: "agent.provider_subagents.list.request", + parentAgentId, + requestId, + }); + const payload = await this.sendRequest({ + requestId, + message, + timeout: options.timeout, + options: { skipQueue: true }, + select: (response) => + response.type === "agent.provider_subagents.list.response" && + response.payload.requestId === requestId + ? response.payload + : null, + }); + if (payload.error) { + throw new Error(payload.error); + } + return payload; + } + + async fetchProviderSubagentTimeline( + parentAgentId: string, + subagentId: string, + options: FetchProviderSubagentTimelineOptions = {}, + ): Promise { + const requestId = this.createRequestId(options.requestId); + const message = SessionInboundMessageSchema.parse({ + type: "agent.provider_subagents.timeline.get.request", + parentAgentId, + subagentId, + requestId, + ...(options.direction ? { direction: options.direction } : {}), + ...(options.cursor ? { cursor: options.cursor } : {}), + ...(typeof options.limit === "number" ? { limit: options.limit } : {}), + }); + const payload = await this.sendRequest({ + requestId, + message, + timeout: options.timeout, + options: { skipQueue: true }, + select: (response) => + response.type === "agent.provider_subagents.timeline.get.response" && + response.payload.requestId === requestId + ? response.payload + : null, + }); + if (payload.error) { + throw new Error(payload.error); + } + return payload; + } + async buildAgentForkContext( agentId: string, options: AgentForkContextOptions = {}, @@ -4200,7 +4275,13 @@ export class DaemonClient { cwd: string, name?: string, requestId?: string, - options?: { agentId?: string; command?: string; args?: string[]; workspaceId?: string }, + options?: { + agentId?: string; + command?: string; + args?: string[]; + workspaceId?: string; + size?: { rows: number; cols: number }; + }, ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ @@ -4211,6 +4292,7 @@ export class DaemonClient { command: options?.command, args: options?.args, ...(options?.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}), + ...(options?.size !== undefined ? { size: options.size } : {}), requestId: resolvedRequestId, }); return this.sendCorrelatedRequest({ @@ -4672,6 +4754,7 @@ export class DaemonClient { [CLIENT_CAPS.customModeIcons]: true, [CLIENT_CAPS.reasoningMergeEnum]: true, [CLIENT_CAPS.terminalReflowableSnapshot]: true, + [CLIENT_CAPS.providerSubagents]: true, ...this.config.capabilities, }, ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}), diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 4a0f2186b1..8e909e345c 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.1.106-beta.1", + "version": "0.1.106", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 919c342817..0a2a21b2a6 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.106-beta.1", + "version": "0.1.106", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 2cd0b0d594..5722eed0d9 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.106-beta.1", + "version": "0.1.106", "files": [ "dist", "!dist/**/*.map" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 7bc32b6ed9..4c4117c876 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.1.106-beta.1", + "version": "0.1.106", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index 1b315d5d9f..63e01a5e45 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -11,6 +11,9 @@ export const CLIENT_CAPS = { // Old clients use a strict TerminalState schema and would reject the extra fields. // Drop the gate (always send the flags) when floor >= v0.1.88. terminalReflowableSnapshot: "terminal_reflowable_snapshot", + // 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", browserHost: "browser_host", } as const; diff --git a/packages/protocol/src/messages.create-terminal-size.test.ts b/packages/protocol/src/messages.create-terminal-size.test.ts new file mode 100644 index 0000000000..0cc88dd983 --- /dev/null +++ b/packages/protocol/src/messages.create-terminal-size.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { CreateTerminalRequestSchema } from "./messages"; + +// COMPAT(createTerminalSize): the size field is optional so old clients (which send no size) +// still parse, and old daemons ignore it. These tests pin that contract. +describe("CreateTerminalRequest size", () => { + const base = { + type: "create_terminal_request" as const, + cwd: "/work/repo", + requestId: "req-1", + }; + + it("parses a request without a size (old client / back-compat)", () => { + const parsed = CreateTerminalRequestSchema.parse({ ...base }); + expect(parsed).toEqual(base); + }); + + it("parses a request carrying a viewport size", () => { + const parsed = CreateTerminalRequestSchema.parse({ ...base, size: { rows: 55, cols: 136 } }); + expect(parsed.size).toEqual({ rows: 55, cols: 136 }); + }); + + it("rejects a non-positive or non-integer size", () => { + expect(() => + CreateTerminalRequestSchema.parse({ ...base, size: { rows: 0, cols: 80 } }), + ).toThrow(); + expect(() => + CreateTerminalRequestSchema.parse({ ...base, size: { rows: 24, cols: -1 } }), + ).toThrow(); + expect(() => + CreateTerminalRequestSchema.parse({ ...base, size: { rows: 24.5, cols: 80 } }), + ).toThrow(); + }); +}); diff --git a/packages/protocol/src/messages.provider-subagents.test.ts b/packages/protocol/src/messages.provider-subagents.test.ts new file mode 100644 index 0000000000..db75d6a61b --- /dev/null +++ b/packages/protocol/src/messages.provider-subagents.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "vitest"; +import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js"; + +describe("provider subagent protocol", () => { + test("accepts a scoped timeline request and structured live update", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "agent.provider_subagents.timeline.get.request", + parentAgentId: "parent-1", + subagentId: "child-1", + requestId: "request-1", + }), + ).toMatchObject({ parentAgentId: "parent-1", subagentId: "child-1" }); + + expect( + SessionOutboundMessageSchema.parse({ + type: "agent.provider_subagents.update", + payload: { + kind: "timeline", + parentAgentId: "parent-1", + subagentId: "child-1", + provider: "claude", + epoch: "epoch-1", + seq: 4, + timestamp: "2026-07-12T10:00:00.000Z", + item: { type: "assistant_message", text: "Found it." }, + }, + }), + ).toMatchObject({ + payload: { + kind: "timeline", + parentAgentId: "parent-1", + subagentId: "child-1", + seq: 4, + }, + }); + }); + + test("accepts a provider child working directory while remaining compatible when absent", () => { + const descriptor = { + id: "child-1", + parentAgentId: "parent-1", + provider: "opencode", + title: "Explore", + description: null, + status: "running", + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:00.000Z", + toolCallId: null, + }; + + expect( + SessionOutboundMessageSchema.parse({ + type: "agent.provider_subagents.list.response", + payload: { + requestId: "request-1", + parentAgentId: "parent-1", + subagents: [{ ...descriptor, cwd: "/workspace/child" }], + error: null, + }, + }), + ).toMatchObject({ payload: { subagents: [{ cwd: "/workspace/child" }] } }); + + expect( + SessionOutboundMessageSchema.parse({ + type: "agent.provider_subagents.list.response", + payload: { + requestId: "request-2", + parentAgentId: "parent-1", + subagents: [descriptor], + error: null, + }, + }), + ).toMatchObject({ payload: { subagents: [{ id: "child-1" }] } }); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index f4d89490fe..a1465c92bc 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1295,6 +1295,22 @@ export const FetchAgentTimelineRequestMessageSchema = z.object({ projection: z.enum(["projected", "canonical"]).optional(), }); +export const ProviderSubagentListRequestMessageSchema = z.object({ + type: z.literal("agent.provider_subagents.list.request"), + parentAgentId: z.string(), + requestId: z.string(), +}); + +export const ProviderSubagentTimelineRequestMessageSchema = z.object({ + type: z.literal("agent.provider_subagents.timeline.get.request"), + parentAgentId: z.string(), + subagentId: z.string(), + requestId: z.string(), + direction: z.enum(["tail", "before", "after"]).optional(), + cursor: AgentTimelineCursorSchema.optional(), + limit: z.number().int().nonnegative().optional(), +}); + export const AgentForkContextRequestMessageSchema = z.object({ type: z.literal("agent.fork_context.request"), agentId: z.string(), @@ -1968,6 +1984,16 @@ export const CreateTerminalRequestSchema = z.object({ agentId: z.string().optional(), command: z.string().optional(), args: z.array(z.string()).optional(), + // COMPAT(createTerminalSize): added in v0.1.107, drop the optional gate when floor >= v0.1.107. + // The client seeds the PTY with its measured viewport size so a new terminal isn't born at the + // 80x24 default and then visibly reflowed. Old daemons ignore this field and start at 80x24; + // the client's first resize corrects it as before. + size: z + .object({ + rows: z.number().int().positive(), + cols: z.number().int().positive(), + }) + .optional(), requestId: z.string(), }); @@ -2089,6 +2115,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ RestartServerRequestMessageSchema, DaemonUpdateRequestMessageSchema, FetchAgentTimelineRequestMessageSchema, + ProviderSubagentListRequestMessageSchema, + ProviderSubagentTimelineRequestMessageSchema, AgentForkContextRequestMessageSchema, SetAgentModeRequestMessageSchema, SetAgentModelRequestMessageSchema, @@ -2367,6 +2395,8 @@ export const ServerInfoStatusPayloadSchema = z daemonSelfUpdate: z.boolean().optional(), // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. agentForkContext: z.boolean().optional(), + // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. + providerSubagents: z.boolean().optional(), }) .optional(), }) @@ -2958,6 +2988,88 @@ export const FetchAgentTimelineResponseMessageSchema = z.object({ }), }); +export const ProviderSubagentDescriptorPayloadSchema = z.object({ + id: z.string(), + parentAgentId: z.string(), + provider: AgentProviderSchema, + title: z.string().nullable(), + description: z.string().nullable(), + status: z.enum(["running", "completed", "failed", "canceled"]), + createdAt: z.string(), + updatedAt: z.string(), + toolCallId: z.string().nullable(), + cwd: z.string().nullable().optional(), +}); + +export type ProviderSubagentDescriptorPayload = z.infer< + typeof ProviderSubagentDescriptorPayloadSchema +>; + +export const ProviderSubagentListResponseMessageSchema = z.object({ + type: z.literal("agent.provider_subagents.list.response"), + payload: z.object({ + requestId: z.string(), + parentAgentId: z.string(), + subagents: z.array(ProviderSubagentDescriptorPayloadSchema), + error: z.string().nullable(), + }), +}); + +export const ProviderSubagentTimelineResponseMessageSchema = z.object({ + type: z.literal("agent.provider_subagents.timeline.get.response"), + payload: z.object({ + requestId: z.string(), + parentAgentId: z.string(), + subagentId: z.string(), + provider: AgentProviderSchema.nullable(), + direction: z.enum(["tail", "before", "after"]), + epoch: z.string(), + reset: z.boolean(), + staleCursor: z.boolean(), + gap: z.boolean(), + window: z.object({ + minSeq: z.number().int().nonnegative(), + maxSeq: z.number().int().nonnegative(), + nextSeq: z.number().int().nonnegative(), + }), + hasOlder: z.boolean(), + hasNewer: z.boolean(), + rows: z.array( + z.object({ + item: AgentTimelineItemPayloadSchema, + timestamp: z.string(), + seq: z.number().int().nonnegative(), + }), + ), + error: z.string().nullable(), + }), +}); + +export const ProviderSubagentUpdateMessageSchema = z.object({ + type: z.literal("agent.provider_subagents.update"), + payload: z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("upsert"), + subagent: ProviderSubagentDescriptorPayloadSchema, + }), + z.object({ + kind: z.literal("timeline"), + parentAgentId: z.string(), + subagentId: z.string(), + provider: AgentProviderSchema, + item: AgentTimelineItemPayloadSchema, + timestamp: z.string(), + seq: z.number().int().nonnegative(), + epoch: z.string(), + }), + z.object({ + kind: z.literal("remove"), + parentAgentId: z.string(), + subagentId: z.string(), + }), + ]), +}); + export const AgentForkContextResponseMessageSchema = z.object({ type: z.literal("agent.fork_context.response"), payload: z.object({ @@ -4207,6 +4319,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ArchiveWorkspaceResponseMessageSchema, FetchAgentResponseMessageSchema, FetchAgentTimelineResponseMessageSchema, + ProviderSubagentListResponseMessageSchema, + ProviderSubagentTimelineResponseMessageSchema, + ProviderSubagentUpdateMessageSchema, AgentForkContextResponseMessageSchema, CancelAgentResponseMessageSchema, ClearAgentAttentionResponseMessageSchema, @@ -4679,6 +4794,7 @@ export const WSHelloMessageSchema = z.object({ [CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(), [CLIENT_CAPS.customModeIcons]: z.boolean().optional(), [CLIENT_CAPS.terminalReflowableSnapshot]: z.boolean().optional(), + [CLIENT_CAPS.providerSubagents]: z.boolean().optional(), [CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(), }) .passthrough() diff --git a/packages/relay/package.json b/packages/relay/package.json index 56285ef3cd..ed683b81de 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.1.106-beta.1", + "version": "0.1.106", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 8df636c2f7..07ee3df57f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.1.106-beta.1", + "version": "0.1.106", "description": "Paseo backend server", "files": [ "dist/server", @@ -67,10 +67,10 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.195", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.1.106-beta.1", - "@getpaseo/highlight": "0.1.106-beta.1", - "@getpaseo/protocol": "0.1.106-beta.1", - "@getpaseo/relay": "0.1.106-beta.1", + "@getpaseo/client": "0.1.106", + "@getpaseo/highlight": "0.1.106", + "@getpaseo/protocol": "0.1.106", + "@getpaseo/relay": "0.1.106", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", 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 b34e9a31be..6e7f813c5c 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 @@ -30,6 +30,7 @@ import type { const COALESCE_WINDOW_MS = AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS; const BEFORE_COALESCE_WINDOW_MS = Math.max(COALESCE_WINDOW_MS - 1, 0); +const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024; const TEST_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: false, @@ -377,6 +378,122 @@ afterEach(() => { }); describe("target coalesced behavior", () => { + test("bounds tool output before persisting and streaming it", async () => { + const harness = createHarness(); + try { + const { agentId, session } = await createManagedSession(harness); + const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`; + const expectedItem = toolCall({ + status: "completed", + output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH), + }); + + session.pushEvent(timelineEvent(toolCall({ status: "completed", output }))); + await waitForSessionEventQueue(); + + const rows = await harness.manager.getTimelineRows(agentId); + const events = getTimelineStreamEvents(harness.events, agentId); + + expect(getTimelineItems(rows)).toEqual([expectedItem]); + expect( + events.map((event) => (event.type === "agent_stream" ? event.event.item : null)), + ).toEqual([expectedItem]); + } finally { + harness.cleanup(); + } + }); + + test("bounds appended tool output before persisting and streaming it", async () => { + const harness = createHarness(); + try { + const { agentId } = await createManagedSession(harness); + const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`; + const expectedItem = toolCall({ + status: "completed", + output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH), + }); + + await harness.manager.appendTimelineItem(agentId, toolCall({ status: "completed", output })); + + const rows = await harness.manager.getTimelineRows(agentId); + const events = getTimelineStreamEvents(harness.events, agentId); + + expect(getTimelineItems(rows)).toEqual([expectedItem]); + expect( + events.map((event) => (event.type === "agent_stream" ? event.event.item : null)), + ).toEqual([expectedItem]); + } finally { + harness.cleanup(); + } + }); + + test("bounds tool output while hydrating provider history", async () => { + const harness = createHarness(); + try { + const { agentId, session } = await createManagedSession(harness); + const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`; + const expectedItem = toolCall({ + status: "completed", + output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH), + }); + session.setHistory([timelineEvent(toolCall({ status: "completed", output }))]); + + await harness.manager.hydrateTimelineFromProvider(agentId); + + expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([ + expectedItem, + ]); + } finally { + harness.cleanup(); + } + }); + + test("bounds tool output emitted only to the live stream", async () => { + const harness = createHarness(); + try { + const { agentId } = await createManagedSession(harness); + const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`; + const expectedItem = toolCall({ + status: "completed", + output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH), + }); + + await harness.manager.emitLiveTimelineItem( + agentId, + toolCall({ status: "completed", output }), + ); + + const events = getTimelineStreamEvents(harness.events, agentId); + expect(await harness.manager.getTimelineRows(agentId)).toEqual([]); + expect( + events.map((event) => (event.type === "agent_stream" ? event.event.item : null)), + ).toEqual([expectedItem]); + } finally { + harness.cleanup(); + } + }); + + test("bounds failed shell output carried in the error", async () => { + const harness = createHarness(); + try { + const { agentId, session } = await createManagedSession(harness); + const content = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`; + const expectedItem = toolCall({ + status: "failed", + error: { content: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH) }, + }); + + session.pushEvent(timelineEvent(toolCall({ status: "failed", error: { content } }))); + await waitForSessionEventQueue(); + + expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([ + expectedItem, + ]); + } finally { + harness.cleanup(); + } + }); + test(`coalesces a same-tick assistant burst after the ${COALESCE_WINDOW_MS}ms window`, async () => { vi.useFakeTimers(); const harness = createHarness(); diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index c8d4e89c18..5cbff4b038 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -2314,6 +2314,43 @@ test("importProviderSession imports the selected session without listing and pub item: { type: "assistant_message" as const, text: "Done" }, timestamp: "2026-01-02T00:00:01.000Z", }, + { + item: { + type: "tool_call" as const, + callId: "large-shell-result", + name: "shell", + status: "completed" as const, + error: null, + detail: { + type: "shell" as const, + command: "print output", + output: "x".repeat(1024 * 1024), + exitCode: 0, + }, + }, + timestamp: "2026-01-02T00:00:02.000Z", + }, + ], + providerSubagentEvents: [ + { + type: "provider_subagent" as const, + provider: "codex" as const, + event: { + type: "upsert" as const, + id: "thread-child", + title: "Imported child", + status: "completed" as const, + }, + }, + { + type: "provider_subagent" as const, + provider: "codex" as const, + event: { + type: "timeline" as const, + id: "thread-child", + item: { type: "assistant_message" as const, text: "Child result" }, + }, + }, ], }; } @@ -2343,8 +2380,27 @@ test("importProviderSession imports the selected session without listing and pub expect(manager.getTimeline(imported.id)).toEqual([ { type: "user_message", text: "Trace provider imports" }, { type: "assistant_message", text: "Done" }, + { + type: "tool_call", + callId: "large-shell-result", + name: "shell", + status: "completed", + error: null, + detail: { + type: "shell", + command: "print output", + output: "x".repeat(64 * 1024), + exitCode: 0, + }, + }, ]); - expect(events).toHaveLength(1); + expect(manager.listProviderSubagents(imported.id)).toEqual([ + expect.objectContaining({ id: "thread-child", title: "Imported child", status: "completed" }), + ]); + expect(manager.fetchProviderSubagentTimeline(imported.id, "thread-child").rows).toEqual([ + expect.objectContaining({ item: { type: "assistant_message", text: "Child result" } }), + ]); + expect(events).toHaveLength(3); expect(events[0]).toMatchObject({ type: "agent_state", agent: { @@ -2524,6 +2580,150 @@ test("reloadAgentSession preserves timeline and does not force history replay", expect(afterHydrate).toEqual(beforeReload); }); +test("reloadAgentSession clears provider children before rehydrating from disk", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provider-child-reload-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + let activeSession: TestAgentSession | null = null; + class ProviderChildClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + activeSession = new TestAgentSession(config); + return activeSession; + } + + override async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + return new TestAgentSession({ + provider: "codex", + cwd: config?.cwd ?? workdir, + }); + } + } + const manager = new AgentManager({ + clients: { codex: new ProviderChildClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000116", + }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + activeSession?.pushEvent({ + type: "provider_subagent", + provider: "codex", + event: { type: "upsert", id: "stale-child", title: "Stale child", status: "running" }, + }); + await vi.waitFor(() => expect(manager.listProviderSubagents(snapshot.id)).toHaveLength(1)); + + await manager.reloadAgentSession(snapshot.id, undefined, { rehydrateFromDisk: true }); + + expect(manager.listProviderSubagents(snapshot.id)).toEqual([]); +}); + +test("hydrateTimelineFromProvider restores and broadcasts provider children from session history", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provider-child-history-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + class ProviderChildHistorySession extends TestAgentSession { + override async *streamHistory(): AsyncGenerator { + yield { + type: "provider_subagent", + provider: "codex", + event: { + type: "upsert", + id: "restored-child", + title: "Restored child", + status: "completed", + }, + }; + } + } + class ProviderChildHistoryClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new ProviderChildHistorySession(config); + } + } + const manager = new AgentManager({ + clients: { codex: new ProviderChildHistoryClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000117", + }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const events: AgentManagerEvent[] = []; + manager.subscribe((event) => events.push(event), { + agentId: snapshot.id, + replayState: false, + }); + + await manager.hydrateTimelineFromProvider(snapshot.id, { broadcast: true }); + + expect(manager.listProviderSubagents(snapshot.id)).toEqual([ + expect.objectContaining({ + id: "restored-child", + parentAgentId: snapshot.id, + title: "Restored child", + status: "completed", + }), + ]); + expect(events).toContainEqual({ + type: "provider_subagent", + event: { + type: "upsert", + subagent: expect.objectContaining({ + id: "restored-child", + parentAgentId: snapshot.id, + }), + }, + }); +}); + +test("force provider hydration removes children absent from current history", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provider-child-force-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + let session: TestAgentSession | null = null; + class ProviderChildForceClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + session = new TestAgentSession(config); + return session; + } + } + const manager = new AgentManager({ + clients: { codex: new ProviderChildForceClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000118", + }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + session?.pushEvent({ + type: "provider_subagent", + provider: "codex", + event: { type: "upsert", id: "removed-by-rewind", status: "completed" }, + }); + await vi.waitFor(() => expect(manager.listProviderSubagents(snapshot.id)).toHaveLength(1)); + const events: AgentManagerEvent[] = []; + manager.subscribe((event) => events.push(event), { + agentId: snapshot.id, + replayState: false, + }); + + await manager.hydrateTimelineFromProvider(snapshot.id, { force: true, broadcast: true }); + + expect(manager.listProviderSubagents(snapshot.id)).toEqual([]); + expect(events).toContainEqual({ + type: "provider_subagent", + event: { + type: "remove", + parentAgentId: snapshot.id, + subagentId: "removed-by-rewind", + }, + }); +}); + test("reloadAgentSession preserves current title when config title is unset", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-title-")); const storagePath = join(workdir, "agents"); @@ -4977,6 +5177,67 @@ test("subscribe does not emit state events for internal agents to global subscri expect(receivedEvents.filter((id) => id === generatedAgentIds[1]).length).toBe(0); }); +test("subscribe hides provider subagents of internal parents from global subscribers", async () => { + const internalAgentId = "00000000-0000-4000-8000-000000000117"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-internal-provider-child-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const sessionHolder: { current: TestAgentSession | null } = { current: null }; + class InternalProviderChildClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + sessionHolder.current = new TestAgentSession(config); + return sessionHolder.current; + } + } + const manager = new AgentManager({ + clients: { codex: new InternalProviderChildClient() }, + registry: storage, + logger, + idFactory: () => internalAgentId, + }); + const globalEvents: AgentManagerEvent[] = []; + const scopedEvents: AgentManagerEvent[] = []; + manager.subscribe((event) => globalEvents.push(event), { replayState: false }); + await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Internal Agent", internal: true }, + undefined, + { workspaceId: undefined }, + ); + manager.subscribe((event) => scopedEvents.push(event), { + agentId: internalAgentId, + replayState: false, + }); + + sessionHolder.current?.pushEvent({ + type: "provider_subagent", + provider: "codex", + event: { type: "upsert", id: "hidden-child", title: "Hidden child", status: "running" }, + }); + await manager.flush(); + + expect(globalEvents.filter((event) => event.type === "provider_subagent")).toEqual([]); + expect(scopedEvents).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ + type: "upsert", + subagent: expect.objectContaining({ + id: "hidden-child", + parentAgentId: internalAgentId, + }), + }), + }), + ); + expect(() => manager.listProviderSubagents(internalAgentId)).toThrow( + `Unknown agent '${internalAgentId}'`, + ); + expect(() => manager.getProviderSubagent(internalAgentId, "hidden-child")).toThrow( + `Unknown agent '${internalAgentId}'`, + ); + expect(() => manager.fetchProviderSubagentTimeline(internalAgentId, "hidden-child")).toThrow( + `Unknown agent '${internalAgentId}'`, + ); +}); + test("subscribe emits state events for internal agents when subscribed by agentId", async () => { const internalAgentId = "00000000-0000-4000-8000-000000000110"; const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 232f5c57af..f74bbdc995 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -58,6 +58,7 @@ import { AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS, AgentStreamCoalescer, } from "./agent-stream-coalescer.js"; +import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; import { ForegroundRunState, type ForegroundTurnWaiter } from "./foreground-run-state.js"; import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js"; @@ -65,6 +66,11 @@ import { isSystemInjectedEnvelope } from "./agent-prompt.js"; import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; import { resolveCreateAgentTitles } from "./create-agent-title.js"; import type { PaseoToolCatalogFactory } from "./tools/types.js"; +import { + ProviderSubagentStore, + type ProviderSubagentDescriptor, + type ProviderSubagentStoreEvent, +} from "./provider-subagents/store.js"; const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000; const INTERRUPT_SESSION_TIMEOUT_MS = 2_000; @@ -144,6 +150,7 @@ export type { export type AgentManagerEvent = | { type: "agent_state"; agent: ManagedAgent } + | { type: "provider_subagent"; event: ProviderSubagentStoreEvent } | { type: "agent_stream"; agentId: string; @@ -493,7 +500,7 @@ function buildImportedTimelineRows(entries: readonly ImportedTimelineEntry[]): A rows.push({ seq: rows.length + 1, timestamp: entry.timestamp ?? new Date().toISOString(), - item: entry.item, + item: limitAgentTimelineItemContent(entry.item), }); } return rows; @@ -533,6 +540,7 @@ export class AgentManager { private readonly providerEnabled = new Map(); private readonly agents = new Map(); private readonly timelineStore = new InMemoryAgentTimelineStore(); + private readonly providerSubagents = new ProviderSubagentStore(); private readonly agentsAwaitingInitialSnapshotPersist = new Set(); private readonly sessionEventTails = new Map>(); private readonly foregroundRuns = new ForegroundRunState(); @@ -935,6 +943,28 @@ export class AgentManager { return this.timelineStore.fetch(id, options); } + listProviderSubagents(parentAgentId: string): ProviderSubagentDescriptor[] { + this.requirePublicAgent(parentAgentId); + return this.providerSubagents.list(parentAgentId); + } + + getProviderSubagent( + parentAgentId: string, + subagentId: string, + ): ProviderSubagentDescriptor | null { + this.requirePublicAgent(parentAgentId); + return this.providerSubagents.get(parentAgentId, subagentId); + } + + fetchProviderSubagentTimeline( + parentAgentId: string, + subagentId: string, + options?: AgentTimelineFetchOptions, + ): AgentTimelineFetchResult { + this.requirePublicAgent(parentAgentId); + return this.providerSubagents.fetchTimeline(parentAgentId, subagentId, options); + } + createAgent( config: AgentSessionConfig, agentId: string | undefined, @@ -1085,7 +1115,7 @@ export class AgentManager { const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows); handedToRegistration = true; - return this.registerSession(imported.session, importedConfig, resolvedAgentId, { + const agent = await this.registerSession(imported.session, importedConfig, resolvedAgentId, { labels: input.labels, workspaceId: input.workspaceId, timelineRows, @@ -1095,6 +1125,11 @@ export class AgentManager { initialTitle, publishWhenReady: true, }); + for (const event of imported.providerSubagentEvents ?? []) { + const update = this.providerSubagents.apply(agent.id, event.provider, event.event); + this.dispatch({ type: "provider_subagent", event: update }); + } + return agent; } finally { if (!handedToRegistration) { await this.closeUnregisteredSession(imported.session); @@ -1167,6 +1202,9 @@ export class AgentManager { // provider history into an empty timeline. await this.deleteCommittedTimeline(agentId); this.timelineStore.delete(agentId); + for (const event of this.providerSubagents.deleteParent(agentId)) { + this.dispatch({ type: "provider_subagent", event }); + } } // Preserve existing labels and timeline during reload. @@ -1260,6 +1298,9 @@ export class AgentManager { const closedAgent = this.prepareAgentForClosure(agent, "agent closed"); await agent.session.close(); this.timelineStore.delete(agentId); + for (const event of this.providerSubagents.deleteParent(agentId)) { + this.dispatch({ type: "provider_subagent", event }); + } await this.persistSnapshot(closedAgent); this.emitClosedAgent(closedAgent, { persist: false }); this.logger.trace( @@ -1752,6 +1793,7 @@ export class AgentManager { async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise { const agent = this.requireAgent(agentId); + item = limitAgentTimelineItemContent(item); this.touchUpdatedAt(agent); const row = this.recordTimeline(agentId, item); this.dispatchStream( @@ -2835,6 +2877,11 @@ export class AgentManager { agent: ActiveManagedAgent, event: AgentStreamEvent, ): Promise { + if (event.type === "provider_subagent") { + const update = this.providerSubagents.apply(agent.id, event.provider, event.event); + this.dispatch({ type: "provider_subagent", event: update }); + return; + } const turnId = getAgentStreamEventTurnId(event); const matchingWaiters = this.foregroundRuns.getMatchingWaiters(agent, turnId); this.logger.trace( @@ -2973,44 +3020,79 @@ export class AgentManager { } if (options?.force) { - const historyEvents: Extract[] = []; - for await (const event of agent.session.streamHistory()) { - if (event.type === "timeline") { - if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) { - continue; - } - historyEvents.push(event); - } - } - - this.agentStreamCoalescer.flushAndDiscard(agent.id); - await this.deleteCommittedTimeline(agent.id); - this.timelineStore.delete(agent.id); - this.timelineStore.initialize(agent.id, { timestamp: new Date().toISOString() }); - agent.historyPrimed = true; - - for (const event of historyEvents) { - const row = this.recordTimeline( - agent.id, - event.item, - event.timestamp ? { timestamp: event.timestamp } : undefined, - ); - if (options?.broadcast) { - this.dispatchStream(agent.id, event, { - seq: row.seq, - epoch: this.timelineStore.getEpoch(agent.id), - timestamp: row.timestamp, - }); - } - } - this.touchUpdatedAt(agent); - this.emitState(agent); + await this.forceHydrateTimelineFromLegacyProviderHistory(agent, options.broadcast === true); return; } + await this.primeTimelineFromLegacyProviderHistory(agent, options?.broadcast === true); + } + + private async forceHydrateTimelineFromLegacyProviderHistory( + agent: ActiveManagedAgent, + broadcast: boolean, + ): Promise { + const historyEvents: Extract[] = []; + const providerSubagentEvents: Extract[] = []; + for await (const event of agent.session.streamHistory()) { + if (event.type === "timeline") { + if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) { + continue; + } + historyEvents.push(event); + } else if (event.type === "provider_subagent") { + providerSubagentEvents.push(event); + } + } + + this.agentStreamCoalescer.flushAndDiscard(agent.id); + await this.deleteCommittedTimeline(agent.id); + this.timelineStore.delete(agent.id); + this.timelineStore.initialize(agent.id, { timestamp: new Date().toISOString() }); + agent.historyPrimed = true; + + for (const event of this.providerSubagents.deleteParent(agent.id)) { + if (broadcast) { + this.dispatch({ type: "provider_subagent", event }); + } + } + for (const event of providerSubagentEvents) { + const update = this.providerSubagents.apply(agent.id, event.provider, event.event); + if (broadcast) { + this.dispatch({ type: "provider_subagent", event: update }); + } + } + for (const event of historyEvents) { + const row = this.recordTimeline( + agent.id, + event.item, + event.timestamp ? { timestamp: event.timestamp } : undefined, + ); + if (broadcast) { + this.dispatchStream(agent.id, event, { + seq: row.seq, + epoch: this.timelineStore.getEpoch(agent.id), + timestamp: row.timestamp, + }); + } + } + this.touchUpdatedAt(agent); + this.emitState(agent); + } + + private async primeTimelineFromLegacyProviderHistory( + agent: ActiveManagedAgent, + broadcast: boolean, + ): Promise { agent.historyPrimed = true; try { for await (const event of agent.session.streamHistory()) { + if (event.type === "provider_subagent") { + const update = this.providerSubagents.apply(agent.id, event.provider, event.event); + if (broadcast) { + this.dispatch({ type: "provider_subagent", event: update }); + } + continue; + } if (event.type !== "timeline") { continue; } @@ -3057,6 +3139,12 @@ export class AgentManager { event: AgentStreamEvent, options?: HandleStreamEventOptions, ): Promise { + if (event.type === "timeline") { + event = { + ...event, + item: limitAgentTimelineItemContent(event.item), + }; + } const eventTurnId = getAgentStreamEventTurnId(event); const isForegroundEvent = Boolean(eventTurnId && agent.activeForegroundTurnId === eventTurnId); this.traceHandleStreamEventStart(agent, event, eventTurnId, isForegroundEvent); @@ -3553,6 +3641,7 @@ export class AgentManager { item: AgentTimelineItem, options?: { timestamp?: string }, ): AgentTimelineRow { + item = limitAgentTimelineItemContent(item); const row = this.timelineStore.append(agentId, item, options); this.enqueueDurableTimelineAppend(agentId, row); return row; @@ -3742,6 +3831,12 @@ export class AgentManager { event: AgentStreamEvent, metadata?: { seq?: number; epoch?: string; timestamp?: string }, ): void { + if (event.type === "timeline") { + event = { + ...event, + item: limitAgentTimelineItemContent(event.item), + }; + } const agent = this.agents.get(agentId); this.logger.trace( { @@ -3773,22 +3868,35 @@ export class AgentManager { ) { continue; } + if ( + subscriber.agentId && + event.type === "provider_subagent" && + subscriber.agentId !== + (event.event.type === "upsert" + ? event.event.subagent.parentAgentId + : event.event.parentAgentId) + ) { + continue; + } // Skip internal agents for global subscribers (those without a specific agentId) - if (!subscriber.agentId) { - if (event.type === "agent_state" && event.agent.internal) { - continue; - } - if (event.type === "agent_stream") { - const agent = this.agents.get(event.agentId); - if (agent?.internal) { - continue; - } - } + if (!subscriber.agentId && this.eventBelongsToInternalAgent(event)) { + continue; } subscriber.callback(event); } } + private eventBelongsToInternalAgent(event: AgentManagerEvent): boolean { + if (event.type === "agent_state") return event.agent.internal === true; + if (event.type === "agent_stream") return this.agents.get(event.agentId)?.internal === true; + if (event.type !== "provider_subagent") return false; + const parentAgentId = + event.event.type === "upsert" + ? event.event.subagent.parentAgentId + : event.event.parentAgentId; + return this.agents.get(parentAgentId)?.internal === true; + } + private async normalizeConfig( config: AgentSessionConfig, options: NormalizeConfigOptions = {}, @@ -4011,6 +4119,14 @@ export class AgentManager { } return agent; } + + private requirePublicAgent(id: string): LiveManagedAgent { + const agent = this.requireAgent(id); + if (agent.internal) { + throw new Error(`Unknown agent '${agent.id}'`); + } + return agent; + } } export function commandMayHaveChangedExternalState(command: string): boolean { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 86dddab26a..9bfcf03ef0 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -426,6 +426,11 @@ export type AgentStreamEvent = provider: AgentProvider; reason: "finished" | "error" | "permission"; timestamp: string; + } + | { + type: "provider_subagent"; + provider: AgentProvider; + event: import("./provider-subagents/store.js").ProviderSubagentInputEvent; }; export function getAgentStreamEventTurnId(event: AgentStreamEvent): string | undefined { @@ -541,6 +546,7 @@ export interface ImportedProviderSession { config: AgentSessionConfig; persistence: AgentPersistenceHandle; timeline: ImportedTimelineEntry[]; + providerSubagentEvents?: Extract[]; } export interface AgentSessionConfig { diff --git a/packages/server/src/server/agent/agent-timeline-content.ts b/packages/server/src/server/agent/agent-timeline-content.ts new file mode 100644 index 0000000000..5bdc17e0af --- /dev/null +++ b/packages/server/src/server/agent/agent-timeline-content.ts @@ -0,0 +1,46 @@ +import type { AgentTimelineItem } from "./agent-sdk-types.js"; + +const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024; + +function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem { + if ( + item.type !== "tool_call" || + item.detail.type !== "shell" || + item.status !== "failed" || + typeof item.error !== "object" || + item.error === null || + !("content" in item.error) || + typeof item.error.content !== "string" || + item.error.content.length <= TOOL_CALL_CONTENT_MAX_LENGTH + ) { + return item; + } + return { + ...item, + error: { + ...item.error, + content: item.error.content.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH), + }, + }; +} + +export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTimelineItem { + item = limitFailedShellError(item); + if ( + item.type !== "tool_call" || + item.detail.type !== "shell" || + typeof item.detail.output !== "string" + ) { + return item; + } + if (item.detail.output.length <= TOOL_CALL_CONTENT_MAX_LENGTH) { + return item; + } + return { + ...item, + detail: { + ...item.detail, + output: item.detail.output.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH), + }, + }; +} diff --git a/packages/server/src/server/agent/provider-session-import.ts b/packages/server/src/server/agent/provider-session-import.ts index 98311e6c37..b09a5ab874 100644 --- a/packages/server/src/server/agent/provider-session-import.ts +++ b/packages/server/src/server/agent/provider-session-import.ts @@ -33,13 +33,14 @@ export async function importSessionFromPersistence(input: { const persistence = input.persistence ?? buildImportPersistenceHandle(input.provider, input.request, storedConfig); const session = await input.resumeSession(persistence, config, input.context.launchContext); - const timeline = await collectImportedTimeline(session.streamHistory()); + const history = await collectImportedHistory(session.streamHistory()); return { session, config: storedConfig, persistence, - timeline, + timeline: history.timeline, + providerSubagentEvents: history.providerSubagentEvents, }; } @@ -60,11 +61,17 @@ function buildImportPersistenceHandle( }; } -async function collectImportedTimeline( - events: AsyncGenerator, -): Promise { +async function collectImportedHistory(events: AsyncGenerator): Promise<{ + timeline: ImportedTimelineEntry[]; + providerSubagentEvents: Extract[]; +}> { const timeline: ImportedTimelineEntry[] = []; + const providerSubagentEvents: Extract[] = []; for await (const event of events) { + if (event.type === "provider_subagent") { + providerSubagentEvents.push(event); + continue; + } if (event.type !== "timeline") { continue; } @@ -73,5 +80,5 @@ async function collectImportedTimeline( ...(event.timestamp ? { timestamp: event.timestamp } : {}), }); } - return timeline; + return { timeline, providerSubagentEvents }; } diff --git a/packages/server/src/server/agent/provider-subagents/store.test.ts b/packages/server/src/server/agent/provider-subagents/store.test.ts new file mode 100644 index 0000000000..ba1f3b5270 --- /dev/null +++ b/packages/server/src/server/agent/provider-subagents/store.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "vitest"; +import { ProviderSubagentStore } from "./store.js"; + +describe("ProviderSubagentStore", () => { + test("keeps provider children and their timelines scoped to the parent agent", () => { + const subagents = new ProviderSubagentStore(); + + subagents.apply("parent-a", "codex", { + type: "upsert", + id: "child-1", + title: "Explore", + cwd: "/workspace/child", + status: "running", + timestamp: "2026-07-12T10:00:00.000Z", + }); + subagents.apply("parent-a", "codex", { + type: "timeline", + id: "child-1", + item: { type: "assistant_message", text: "Found it." }, + timestamp: "2026-07-12T10:00:01.000Z", + }); + subagents.apply("parent-a", "codex", { + type: "upsert", + id: "child-1", + status: "completed", + timestamp: "2026-07-12T10:00:02.000Z", + }); + subagents.apply("parent-b", "claude", { + type: "upsert", + id: "child-1", + title: "Review", + status: "running", + timestamp: "2026-07-12T10:00:03.000Z", + }); + + expect(subagents.list("parent-a")).toEqual([ + expect.objectContaining({ + id: "child-1", + parentAgentId: "parent-a", + provider: "codex", + title: "Explore", + cwd: "/workspace/child", + status: "completed", + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:02.000Z", + }), + ]); + expect(subagents.fetchTimeline("parent-a", "child-1").rows).toEqual([ + { + seq: 1, + timestamp: "2026-07-12T10:00:01.000Z", + item: { type: "assistant_message", text: "Found it." }, + }, + ]); + expect(subagents.list("parent-b")[0]).toMatchObject({ provider: "claude", title: "Review" }); + expect(subagents.deleteParent("parent-a")).toEqual([ + { type: "remove", parentAgentId: "parent-a", subagentId: "child-1" }, + ]); + expect(subagents.list("parent-a")).toEqual([]); + expect(subagents.list("parent-b")).toHaveLength(1); + }); + + test("limits oversized provider child tool output before storage", () => { + const subagents = new ProviderSubagentStore(); + const output = "x".repeat(70 * 1024); + const update = subagents.apply("parent-a", "opencode", { + type: "timeline", + id: "child-1", + item: { + type: "tool_call", + callId: "call-1", + name: "shell", + status: "completed", + error: null, + detail: { type: "shell", command: "print", output }, + }, + }); + + expect(update.type).toBe("timeline"); + const [row] = subagents.fetchTimeline("parent-a", "child-1").rows; + expect(row?.item).toMatchObject({ + type: "tool_call", + detail: { type: "shell", output: "x".repeat(64 * 1024) }, + }); + }); + + test("pages provider history on projected item boundaries", () => { + const subagents = new ProviderSubagentStore(); + for (let index = 0; index < 101; index += 1) { + subagents.apply("parent-a", "opencode", { + type: "timeline", + id: "child-1", + item: { type: "assistant_message", text: String(index) }, + }); + } + + const page = subagents.fetchTimeline("parent-a", "child-1", { + direction: "tail", + limit: 1, + }); + expect(page.rows).toHaveLength(101); + expect(page.rows[0]?.seq).toBe(1); + 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 new file mode 100644 index 0000000000..0d05e64269 --- /dev/null +++ b/packages/server/src/server/agent/provider-subagents/store.ts @@ -0,0 +1,168 @@ +import type { AgentProvider, AgentTimelineItem } from "../agent-sdk-types.js"; +import { limitAgentTimelineItemContent } from "../agent-timeline-content.js"; +import { InMemoryAgentTimelineStore } from "../agent-timeline-store.js"; +import type { + AgentTimelineFetchOptions, + AgentTimelineFetchResult, + AgentTimelineRow, +} from "../agent-timeline-store-types.js"; +import { selectTimelineWindowByProjectedLimit } from "../timeline-projection.js"; + +export type ProviderSubagentStatus = "running" | "completed" | "failed" | "canceled"; + +export interface ProviderSubagentDescriptor { + id: string; + parentAgentId: string; + provider: AgentProvider; + title: string | null; + description: string | null; + status: ProviderSubagentStatus; + createdAt: string; + updatedAt: string; + toolCallId: string | null; + cwd: string | null; +} + +export type ProviderSubagentInputEvent = + | { + type: "upsert"; + id: string; + title?: string | null; + description?: string | null; + status: ProviderSubagentStatus; + toolCallId?: string | null; + cwd?: string | null; + timestamp?: string; + } + | { + type: "timeline"; + id: string; + item: AgentTimelineItem; + timestamp?: string; + } + | { type: "remove"; id: string }; + +export type ProviderSubagentStoreEvent = + | { type: "upsert"; subagent: ProviderSubagentDescriptor } + | { + type: "timeline"; + parentAgentId: string; + subagentId: string; + provider: AgentProvider; + row: AgentTimelineRow; + epoch: string; + } + | { type: "remove"; parentAgentId: string; subagentId: string }; + +function storeKey(parentAgentId: string, subagentId: string): string { + return `${parentAgentId}\0${subagentId}`; +} + +export class ProviderSubagentStore { + private readonly descriptors = new Map(); + private readonly timelines = new InMemoryAgentTimelineStore(); + + apply( + parentAgentId: string, + provider: AgentProvider, + event: ProviderSubagentInputEvent, + ): ProviderSubagentStoreEvent { + const key = storeKey(parentAgentId, event.id); + if (event.type === "remove") { + this.descriptors.delete(key); + this.timelines.delete(key); + return { type: "remove", parentAgentId, subagentId: event.id }; + } + + if (event.type === "timeline") { + if (!this.timelines.has(key)) { + this.timelines.initialize(key); + } + const row = this.timelines.append(key, limitAgentTimelineItemContent(event.item), { + timestamp: event.timestamp, + }); + return { + type: "timeline", + parentAgentId, + subagentId: event.id, + provider, + row, + epoch: this.timelines.getEpoch(key), + }; + } + + const previous = this.descriptors.get(key); + if (!this.timelines.has(key)) { + this.timelines.initialize(key); + } + const timestamp = event.timestamp ?? new Date().toISOString(); + const subagent: ProviderSubagentDescriptor = { + id: event.id, + parentAgentId, + provider, + title: event.title === undefined ? (previous?.title ?? null) : event.title, + description: + event.description === undefined ? (previous?.description ?? null) : event.description, + status: event.status, + createdAt: previous?.createdAt ?? timestamp, + updatedAt: timestamp, + toolCallId: + event.toolCallId === undefined ? (previous?.toolCallId ?? null) : event.toolCallId, + cwd: event.cwd === undefined ? (previous?.cwd ?? null) : event.cwd, + }; + this.descriptors.set(key, subagent); + return { type: "upsert", subagent }; + } + + list(parentAgentId: string): ProviderSubagentDescriptor[] { + return [...this.descriptors.values()] + .filter((subagent) => subagent.parentAgentId === parentAgentId) + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + } + + get(parentAgentId: string, subagentId: string): ProviderSubagentDescriptor | null { + return this.descriptors.get(storeKey(parentAgentId, subagentId)) ?? null; + } + + fetchTimeline( + parentAgentId: string, + 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), + }; + } + + deleteParent(parentAgentId: string): ProviderSubagentStoreEvent[] { + const events: ProviderSubagentStoreEvent[] = []; + for (const subagent of this.list(parentAgentId)) { + const key = storeKey(parentAgentId, subagent.id); + this.descriptors.delete(key); + this.timelines.delete(key); + events.push({ type: "remove", parentAgentId, subagentId: subagent.id }); + } + return events; + } +} 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 124101dead..546ff8c80e 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -20,6 +20,7 @@ import { ACPAgentSession, type SpawnedACPProcess, type SessionStateResponse, + buildACPClientCapabilities, createLoggedNdJsonStream, deriveModelDefinitionsFromACP, deriveModesFromACP, @@ -50,6 +51,39 @@ import { buildStringCommandShellInvocation } from "../../../utils/string-command import { asInternals } from "../../test-utils/class-mocks.js"; import * as spawnUtils from "../../../utils/spawn.js"; +describe("buildACPClientCapabilities", () => { + test("keeps filesystem and terminal execution with the agent by default", () => { + expect(buildACPClientCapabilities()).toEqual({ + fs: { + readTextFile: false, + writeTextFile: false, + }, + terminal: false, + }); + }); + + test("applies provider capability overrides without dropping metadata", () => { + expect( + buildACPClientCapabilities( + { source: "provider" }, + { + fs: { + readTextFile: true, + }, + terminal: true, + }, + ), + ).toEqual({ + fs: { + readTextFile: true, + writeTextFile: false, + }, + terminal: true, + _meta: { source: "provider" }, + }); + }); +}); + interface ACPSessionInternals { sessionId: string | null; connection: { prompt: (...args: unknown[]) => Promise }; @@ -2056,11 +2090,16 @@ describe("ACPAgentSession", () => { test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => { const session = createSession(); - const events: Array<{ type: string; item?: { type: string; text?: string } }> = []; + const events: Array<{ + type: string; + item?: { type: string; text?: string; messageId?: string }; + }> = []; asInternals(session).sessionId = "session-1"; session.subscribe((event) => { - events.push(event as { type: string; item?: { type: string; text?: string } }); + events.push( + event as { type: string; item?: { type: string; text?: string; messageId?: string } }, + ); }); await session.sessionUpdate({ @@ -2118,8 +2157,8 @@ describe("ACPAgentSession", () => { .filter(Boolean); expect(timeline).toEqual([ - { type: "assistant_message", text: "Hey!" }, - { type: "assistant_message", text: " How are you?" }, + { type: "assistant_message", text: "Hey!", messageId: "assistant-1" }, + { type: "assistant_message", text: " How are you?", messageId: "assistant-1" }, { type: "reasoning", text: "Thinking" }, { type: "reasoning", text: " more" }, { type: "user_message", text: "hel", messageId: "user-1" }, @@ -2127,6 +2166,48 @@ describe("ACPAgentSession", () => { ]); }); + test("assigns one fallback ID per contiguous assistant message", async () => { + const session = createSession(); + const assistantMessages: Array<{ text: string; messageId?: string }> = []; + asInternals(session).sessionId = "session-1"; + + session.subscribe((event) => { + if (event.type === "timeline" && event.item.type === "assistant_message") { + assistantMessages.push(event.item); + } + }); + + for (const text of ["First", " message"]) { + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + } as SessionUpdate, + }); + } + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "Next response" }, + } as SessionUpdate, + }); + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Second message" }, + } as SessionUpdate, + }); + + expect(assistantMessages).toHaveLength(3); + expect(assistantMessages[0].messageId).toEqual(expect.any(String)); + expect(assistantMessages[1].messageId).toBe(assistantMessages[0].messageId); + expect(assistantMessages[2].messageId).toEqual(expect.any(String)); + expect(assistantMessages[2].messageId).not.toBe(assistantMessages[0].messageId); + }); + test("startTurn returns before the ACP prompt settles and completes later via subscribers", async () => { const session = createSession(); const events: Array<{ type: string; turnId?: string }> = []; @@ -2693,6 +2774,103 @@ describe("ACP session/load invariant — cwd and mcpServers always passed", () = }); }); + test("preserves assistant message IDs from loadSession replay", async () => { + let session!: ACPAgentSession; + const loadSession = async () => { + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + messageId: "assistant-replay-1", + content: { type: "text", text: "Welcome back" }, + } as SessionUpdate, + }); + return { + sessionId: "session-1", + modes: null, + models: null, + configOptions: [], + }; + }; + ({ session } = makeTestSession({ + capabilities: { loadSession: true }, + handle: { sessionId: "session-1", provider: "claude-acp" }, + loadSession, + })); + + await session.initializeResumedSession(); + + const history: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) { + history.push(event); + } + expect(history).toEqual([ + { + type: "timeline", + provider: "claude-acp", + item: { + type: "assistant_message", + text: "Welcome back", + messageId: "assistant-replay-1", + }, + }, + ]); + }); + + test("assigns stable fallback IDs to ID-less assistant messages during loadSession replay", async () => { + let session!: ACPAgentSession; + const loadSession = async () => { + for (const text of ["Loaded", " response"]) { + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + } as SessionUpdate, + }); + } + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "Follow up" }, + } as SessionUpdate, + }); + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Loaded second response" }, + } as SessionUpdate, + }); + return { + sessionId: "session-1", + modes: null, + models: null, + configOptions: [], + }; + }; + ({ session } = makeTestSession({ + capabilities: { loadSession: true }, + handle: { sessionId: "session-1", provider: "claude-acp" }, + loadSession, + })); + + await session.initializeResumedSession(); + + const assistantMessages: Array<{ text: string; messageId?: string }> = []; + for await (const event of session.streamHistory()) { + if (event.type === "timeline" && event.item.type === "assistant_message") { + assistantMessages.push(event.item); + } + } + expect(assistantMessages).toHaveLength(3); + expect(assistantMessages[0].messageId).toEqual(expect.any(String)); + expect(assistantMessages[1].messageId).toBe(assistantMessages[0].messageId); + expect(assistantMessages[2].messageId).toEqual(expect.any(String)); + expect(assistantMessages[2].messageId).not.toBe(assistantMessages[0].messageId); + }); + test("loadSession is always called with mcpServers even when supportsMcpServers is false", async () => { const { session, loadSession } = makeTestSession({ capabilities: { loadSession: true, supportsMcpServers: false }, diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 5e7e3c99e5..6da37dad6f 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -231,22 +231,27 @@ export const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = { const BASE_ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = { fs: { - readTextFile: true, - writeTextFile: true, + readTextFile: false, + writeTextFile: false, }, - terminal: true, + terminal: false, }; export type ACPClientCapabilityMeta = Record; -export function buildACPClientCapabilities(meta?: ACPClientCapabilityMeta): ACPClientCapabilities { - if (!meta || Object.keys(meta).length === 0) { - return BASE_ACP_CLIENT_CAPABILITIES; - } - return { +export function buildACPClientCapabilities( + meta?: ACPClientCapabilityMeta, + override?: ACPClientCapabilities, +): ACPClientCapabilities { + const capabilities: ACPClientCapabilities = { ...BASE_ACP_CLIENT_CAPABILITIES, - _meta: meta, + ...override, + fs: { + ...BASE_ACP_CLIENT_CAPABILITIES.fs, + ...override?.fs, + }, }; + return meta && Object.keys(meta).length > 0 ? { ...capabilities, _meta: meta } : capabilities; } // Suppress interactive auth side-effects (e.g. Gemini CLI opening a Google @@ -371,6 +376,7 @@ interface ACPAgentClientOptions { sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse; configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[]; configFeatureOptions?: ACPConfigFeatureOption[]; + clientCapabilities?: ACPClientCapabilities; clientCapabilityMeta?: ACPClientCapabilityMeta; modeIdTransformer?: (modeId: string) => string | null; toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; @@ -400,6 +406,7 @@ interface ACPAgentSessionOptions { sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse; configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[]; configFeatureOptions?: ACPConfigFeatureOption[]; + clientCapabilities?: ACPClientCapabilities; clientCapabilityMeta?: ACPClientCapabilityMeta; modeIdTransformer?: (modeId: string) => string | null; toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; @@ -708,6 +715,7 @@ export class ACPAgentClient implements AgentClient { configOptions: SessionConfigOption[], ) => SessionConfigOption[]; private readonly configFeatureOptions: ACPConfigFeatureOption[]; + private readonly clientCapabilities?: ACPClientCapabilities; private readonly clientCapabilityMeta?: ACPClientCapabilityMeta; private readonly modeIdTransformer?: (modeId: string) => string | null; private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; @@ -742,6 +750,7 @@ export class ACPAgentClient implements AgentClient { this.sessionResponseTransformer = options.sessionResponseTransformer; this.configOptionsTransformer = options.configOptionsTransformer; this.configFeatureOptions = options.configFeatureOptions ?? []; + this.clientCapabilities = options.clientCapabilities; this.clientCapabilityMeta = options.clientCapabilityMeta; this.modeIdTransformer = options.modeIdTransformer; this.toolSnapshotTransformer = options.toolSnapshotTransformer; @@ -770,6 +779,7 @@ export class ACPAgentClient implements AgentClient { sessionResponseTransformer: this.sessionResponseTransformer, configOptionsTransformer: this.configOptionsTransformer, configFeatureOptions: this.configFeatureOptions, + clientCapabilities: this.clientCapabilities, clientCapabilityMeta: this.clientCapabilityMeta, modeIdTransformer: this.modeIdTransformer, toolSnapshotTransformer: this.toolSnapshotTransformer, @@ -819,6 +829,7 @@ export class ACPAgentClient implements AgentClient { sessionResponseTransformer: this.sessionResponseTransformer, configOptionsTransformer: this.configOptionsTransformer, configFeatureOptions: this.configFeatureOptions, + clientCapabilities: this.clientCapabilities, clientCapabilityMeta: this.clientCapabilityMeta, modeIdTransformer: this.modeIdTransformer, toolSnapshotTransformer: this.toolSnapshotTransformer, @@ -1057,7 +1068,10 @@ export class ACPAgentClient implements AgentClient { Promise.race([ transport.connection.initialize({ protocolVersion: PROTOCOL_VERSION, - clientCapabilities: buildACPClientCapabilities(this.clientCapabilityMeta), + clientCapabilities: buildACPClientCapabilities( + this.clientCapabilityMeta, + this.clientCapabilities, + ), clientInfo: { name: "Paseo", version: "dev" }, }), transport.spawnError, @@ -1269,6 +1283,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { configOptions: SessionConfigOption[], ) => SessionConfigOption[]; private readonly configFeatureOptions: ACPConfigFeatureOption[]; + private readonly clientCapabilities?: ACPClientCapabilities; private readonly clientCapabilityMeta?: ACPClientCapabilityMeta; private readonly modeIdTransformer?: (modeId: string) => string | null; private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; @@ -1316,6 +1331,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { private readonly extensionCommandsParser?: ACPExtensionCommandsParser; private currentTurnUsage: AgentUsage | undefined; private activeForegroundTurnId: string | null = null; + private fallbackAssistantMessageId: string | null = null; private closed = false; private historyPending = false; private replayingHistory = false; @@ -1334,6 +1350,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.sessionResponseTransformer = options.sessionResponseTransformer; this.configOptionsTransformer = options.configOptionsTransformer; this.configFeatureOptions = options.configFeatureOptions ?? []; + this.clientCapabilities = options.clientCapabilities; this.clientCapabilityMeta = options.clientCapabilityMeta; this.modeIdTransformer = options.modeIdTransformer; this.toolSnapshotTransformer = options.toolSnapshotTransformer; @@ -1459,6 +1476,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { const turnId = randomUUID(); const messageId = options?.messageId ?? randomUUID(); this.activeForegroundTurnId = turnId; + this.fallbackAssistantMessageId = null; this.activeSubmittedUserMessage = null; this.emitBootstrapThreadEvent(); this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); @@ -2320,7 +2338,10 @@ export class ACPAgentSession implements AgentSession, ACPClient { const initialize = await this.runACPRequest(() => connection.initialize({ protocolVersion: PROTOCOL_VERSION, - clientCapabilities: buildACPClientCapabilities(this.clientCapabilityMeta), + clientCapabilities: buildACPClientCapabilities( + this.clientCapabilityMeta, + this.clientCapabilities, + ), clientInfo: { name: "Paseo", version: "dev" }, }), ); @@ -2424,6 +2445,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { private translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[] { switch (update.sessionUpdate) { case "user_message_chunk": { + this.fallbackAssistantMessageId = null; const item = this.createMessageTimelineItem("user_message", update); if (!item) { return []; @@ -2441,10 +2463,12 @@ export class ACPAgentSession implements AgentSession, ACPClient { return item ? [this.wrapTimeline(item)] : []; } case "agent_thought_chunk": { + this.fallbackAssistantMessageId = null; const item = this.createMessageTimelineItem("reasoning", update); return item ? [this.wrapTimeline(item)] : []; } case "tool_call": + this.fallbackAssistantMessageId = null; return this.handleToolCallUpdate(update.toolCallId, update, undefined); case "tool_call_update": return this.handleToolCallUpdate( @@ -2453,6 +2477,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.toolCalls.get(update.toolCallId), ); case "plan": + this.fallbackAssistantMessageId = null; return [this.wrapTimeline(mapPlanToTimeline(update))]; case "current_mode_update": this.handleCurrentModeUpdate(update); @@ -2507,7 +2532,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { >, ): | { type: "user_message"; text: string; messageId?: string } - | { type: "assistant_message"; text: string } + | { type: "assistant_message"; text: string; messageId: string } | { type: "reasoning"; text: string } | null { const chunkText = contentBlockToText(update.content); @@ -2523,11 +2548,24 @@ export class ACPAgentSession implements AgentSession, ACPClient { return { type: "user_message", text: state.text, messageId: update.messageId ?? undefined }; } if (type === "assistant_message") { - return { type: "assistant_message", text: chunkText }; + return { + type: "assistant_message", + text: chunkText, + messageId: this.resolveAssistantMessageId(update.messageId), + }; } return { type: "reasoning", text: chunkText }; } + private resolveAssistantMessageId(messageId: string | null | undefined): string { + if (messageId) { + this.fallbackAssistantMessageId = null; + return messageId; + } + this.fallbackAssistantMessageId ??= randomUUID(); + return this.fallbackAssistantMessageId; + } + private messageAssemblyKey( type: "user_message" | "assistant_message" | "reasoning", messageId: string | null | undefined, @@ -2682,6 +2720,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { event: Extract, ): void { this.activeForegroundTurnId = null; + this.fallbackAssistantMessageId = null; if (this.activeSubmittedUserMessage?.turnId === event.turnId) { this.activeSubmittedUserMessage = null; } diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts index a7b8e0fe76..07380ca38d 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts @@ -211,6 +211,21 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { parent_tool_use_id: "task-call-1", elapsed_time_seconds: 1, }, + { + type: "user", + parent_tool_use_id: "task-call-1", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "sub-read-1", + tool_name: "Read", + content: "README contents", + is_error: false, + }, + ], + }, + }, { type: "assistant", parent_tool_use_id: "task-call-1", @@ -307,6 +322,44 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { ); expect(projectedTaskCalls).toHaveLength(1); + + const providerEvents = events.flatMap((event) => + event.type === "provider_subagent" ? [event.event] : [], + ); + expect(providerEvents).toContainEqual({ + type: "timeline", + id: "task-call-1", + item: expect.objectContaining({ + type: "tool_call", + callId: "sub-read-1", + status: "running", + }), + }); + expect(providerEvents).toContainEqual({ + type: "timeline", + id: "task-call-1", + item: expect.objectContaining({ + type: "tool_call", + callId: "sub-read-1", + status: "completed", + }), + }); + expect(providerEvents).toContainEqual({ + type: "timeline", + id: "task-call-1", + item: { + type: "assistant_message", + messageId: "subagent-message-1", + text: "Sub-agent narration belongs inside the Task row, not the parent transcript.", + }, + }); + expect(providerEvents.at(-1)).toMatchObject({ + type: "upsert", + id: "task-call-1", + title: "Explore", + description: "Inspect repository structure", + status: "completed", + }); }); test("keeps sidechain assistant text out of the parent transcript", async () => { @@ -349,6 +402,46 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { }); }); + test("keeps a failed Task subagent failed when the parent turn succeeds", async () => { + const failedEvents = buildTailScenarioEvents(1); + const taskResult = failedEvents.find( + (event) => + typeof event === "object" && + event !== null && + "type" in event && + event.type === "assistant", + ) as { message: Record } | undefined; + if (!taskResult) throw new Error("expected Task result fixture"); + taskResult.message = { + ...taskResult.message, + content: [ + { + type: "tool_result", + tool_use_id: "task-tail-1", + tool_name: "Task", + content: "failed", + is_error: true, + }, + ], + }; + queryFactory.mockImplementation(() => buildQueryMock(failedEvents)); + const session = await new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd() }); + + const events = await collectUntilTerminal(streamSession(session, "delegate work")); + await session.close(); + + expect( + events + .filter((event) => event.type === "provider_subagent") + .map((event) => event.event) + .at(-1), + ).toMatchObject({ type: "upsert", id: "task-tail-1", status: "failed" }); + }); + test("tails sub-agent actions instead of dropping latest entries at cap", async () => { queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205))); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 4b2d636279..a2dde63a63 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1897,6 +1897,10 @@ class ClaudeAgentSession implements AgentSession { getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null, }); private persistedHistory: PersistedTimelineEntry[] = []; + private persistedProviderSubagentEvents: Extract< + AgentStreamEvent, + { type: "provider_subagent" } + >[] = []; private historyPending = false; private turnState: TurnState = "idle"; private nextTurnOrdinal = 1; @@ -2117,11 +2121,16 @@ class ClaudeAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator { - if (!this.historyPending || this.persistedHistory.length === 0) { + if ( + !this.historyPending || + (this.persistedHistory.length === 0 && this.persistedProviderSubagentEvents.length === 0) + ) { return; } const history = this.persistedHistory; + const providerSubagentEvents = this.persistedProviderSubagentEvents; this.persistedHistory = []; + this.persistedProviderSubagentEvents = []; this.historyPending = false; for (const entry of history) { yield { @@ -2131,6 +2140,7 @@ class ClaudeAgentSession implements AgentSession { timestamp: entry.timestamp, }; } + yield* providerSubagentEvents; } async getAvailableModes(): Promise { @@ -2613,6 +2623,7 @@ class ClaudeAgentSession implements AgentSession { this.cachedRuntimeInfo = null; this.queryRestartNeeded = true; this.persistedHistory = []; + this.persistedProviderSubagentEvents = []; this.historyPending = false; this.userMessageIds = []; this.emittedUserMessageIds.clear(); @@ -2642,6 +2653,7 @@ class ClaudeAgentSession implements AgentSession { this.cachedRuntimeInfo = null; this.queryRestartNeeded = true; this.persistedHistory = []; + this.persistedProviderSubagentEvents = []; this.historyPending = false; this.userMessageIds = []; this.emittedUserMessageIds.clear(); @@ -3539,6 +3551,7 @@ class ClaudeAgentSession implements AgentSession { } this.persistence = null; this.persistedHistory = []; + this.persistedProviderSubagentEvents = []; this.historyPending = false; this.cachedRuntimeInfo = null; this.queryRestartNeeded = false; @@ -3610,6 +3623,7 @@ class ClaudeAgentSession implements AgentSession { break; case "user": this.appendUserMessageEvents(message, events); + this.appendSidechainResultEvents(message, events); break; case "assistant": { const timelineItems = this.mapBlocksToTimeline(message.message.content, { @@ -3619,6 +3633,7 @@ class ClaudeAgentSession implements AgentSession { for (const item of timelineItems) { events.push({ type: "timeline", item, provider: "claude" }); } + this.appendSidechainResultEvents(message, events); break; } case "stream_event": @@ -3634,6 +3649,18 @@ class ClaudeAgentSession implements AgentSession { return events; } + private appendSidechainResultEvents(message: SDKMessage, events: AgentStreamEvent[]): void { + const content = toObjectRecord(toObjectRecord(message)?.message)?.content; + if (!Array.isArray(content)) return; + for (const block of content) { + const chunk = toObjectRecord(block); + if (chunk?.type !== "tool_result" || typeof chunk.tool_use_id !== "string") continue; + events.push( + ...this.sidechainTracker.finish(chunk.tool_use_id, chunk.is_error ? "failed" : "completed"), + ); + } + } + private emitSubmittedUserMessage( message: Extract, turnId: string, @@ -3830,6 +3857,7 @@ class ClaudeAgentSession implements AgentSession { ): void { const usage = this.convertUsage(message, message.modelUsage); if (message.subtype === "success") { + events.push(...this.sidechainTracker.finishAll("completed")); // Built-in slash commands (e.g. /voice, /usage, "Unknown command: …") // run client-side in the Claude CLI with no model turn — output_tokens // is 0 and the user-visible text is carried in `result`. Surface it only @@ -3855,6 +3883,7 @@ class ClaudeAgentSession implements AgentSession { "errors" in message && Array.isArray(message.errors) && message.errors.length > 0 ? message.errors.join("\n") : "Claude run failed"; + events.push(...this.sidechainTracker.finishAll("failed")); events.push(this.buildTurnFailedEvent(errorMessage)); } @@ -4166,7 +4195,9 @@ class ClaudeAgentSession implements AgentSession { if (!historyPath || !fs.existsSync(historyPath)) { return; } - this.ingestPersistedHistory(fs.readFileSync(historyPath, "utf8")); + const content = fs.readFileSync(historyPath, "utf8"); + this.ingestPersistedHistory(content); + this.ingestPersistedSidechains(content, readClaudeSidechainHistory(historyPath)); } catch { // ignore history load failures } @@ -4188,6 +4219,24 @@ class ClaudeAgentSession implements AgentSession { } } + private ingestPersistedSidechains(parentContent: string, sidechainContents: string[]): void { + const parentEntries = parseClaudeHistoryRecords(parentContent).filter( + (entry) => entry.isSidechain !== true, + ); + const sidechainEntries = [parentContent, ...sidechainContents] + .flatMap(parseClaudeHistoryRecords) + .filter((entry) => entry.isSidechain === true && typeof entry.agentId === "string"); + if (sidechainEntries.length === 0) { + return; + } + this.persistedProviderSubagentEvents.push( + ...buildClaudePersistedSidechainEvents(parentEntries, sidechainEntries, (entry) => + this.convertHistoryEntry(entry), + ), + ); + this.historyPending = true; + } + private ingestPersistedHistoryLine(line: string, timeline: PersistedTimelineEntry[]): void { const trimmed = line.trim(); if (!trimmed) { @@ -4465,7 +4514,6 @@ class ClaudeAgentSession implements AgentSession { if (typeof block.tool_use_id === "string") { this.toolUseCache.delete(block.tool_use_id); - this.sidechainTracker.delete(block.tool_use_id); } } @@ -4947,11 +4995,194 @@ function normalizeHistoryBlocks(content: unknown): ClaudeContentChunk[] | null { return null; } +function parseClaudeHistoryRecords(content: string): ClaudeHistoryEntry[] { + const entries: ClaudeHistoryEntry[] = []; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const entry = toObjectRecord(JSON.parse(trimmed)); + if (entry) entries.push(entry); + } catch { + // Ignore individual corrupt history rows, matching the parent history replay behavior. + } + } + return entries; +} + +function readClaudeSidechainHistory(historyPath: string): string[] { + const sessionDirectory = path.join( + path.dirname(historyPath), + path.basename(historyPath, ".jsonl"), + ); + const sidechainDirectory = path.join(sessionDirectory, "subagents"); + if (!fs.existsSync(sidechainDirectory)) return []; + + const contents: string[] = []; + const directories = [sidechainDirectory]; + while (directories.length > 0) { + const directory = directories.pop(); + if (!directory) continue; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + directories.push(entryPath); + } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { + contents.push(fs.readFileSync(entryPath, "utf8")); + } + } + } + return contents; +} + +interface ClaudeHistoricalSubagentToolCall { + subagentType?: string; + description?: string; +} + +function readClaudeHistoricalSubagentToolCalls( + entries: ClaudeHistoryEntry[], +): Map { + const toolCalls = new Map(); + for (const entry of entries) { + const content = toObjectRecord(entry.message)?.content; + if (!Array.isArray(content)) continue; + for (const value of content) { + const block = toObjectRecord(value); + if ( + block?.type !== "tool_use" || + (block.name !== "Task" && block.name !== "Agent") || + typeof block.id !== "string" + ) { + continue; + } + const input = toObjectRecord(block.input); + const subagentType = readNonEmptyString(input?.subagent_type); + const description = readNonEmptyString(input?.description); + toolCalls.set(block.id, { + ...(subagentType ? { subagentType } : {}), + ...(description ? { description } : {}), + }); + } + } + return toolCalls; +} + +function readClaudeHistoricalSubagentToolResults( + entries: ClaudeHistoryEntry[], +): Map { + const results = new Map(); + for (const entry of entries) { + const content = toObjectRecord(entry.message)?.content; + if (!Array.isArray(content)) continue; + for (const value of content) { + const block = toObjectRecord(value); + if (block?.type !== "tool_result" || typeof block.tool_use_id !== "string") continue; + const match = /agentId:\s*([\w-]+)/.exec(JSON.stringify(block.content)); + if (!match?.[1]) continue; + results.set(match[1], { toolCallId: block.tool_use_id, failed: block.is_error === true }); + } + } + return results; +} + +function groupClaudeSidechainEntries( + entries: ClaudeHistoryEntry[], +): Map { + const entriesByAgentId = new Map(); + for (const entry of entries) { + if (typeof entry.agentId !== "string") continue; + const grouped = entriesByAgentId.get(entry.agentId) ?? []; + grouped.push(entry); + entriesByAgentId.set(entry.agentId, grouped); + } + return entriesByAgentId; +} + +function buildClaudePersistedSidechainEvents( + parentEntries: ClaudeHistoryEntry[], + sidechainEntries: ClaudeHistoryEntry[], + convertEntry: (entry: ClaudeHistoryEntry) => AgentTimelineItem[], +): Extract[] { + const events: Extract[] = []; + const toolCalls = readClaudeHistoricalSubagentToolCalls(parentEntries); + const toolResults = readClaudeHistoricalSubagentToolResults(parentEntries); + for (const [agentId, entries] of groupClaudeSidechainEntries(sidechainEntries)) { + events.push( + ...buildClaudePersistedSidechainAgentEvents( + agentId, + entries, + toolCalls, + toolResults, + convertEntry, + ), + ); + } + return events; +} + +function buildClaudePersistedSidechainAgentEvents( + agentId: string, + entries: ClaudeHistoryEntry[], + toolCalls: ReadonlyMap, + toolResults: ReadonlyMap, + convertEntry: (entry: ClaudeHistoryEntry) => AgentTimelineItem[], +): Extract[] { + const result = toolResults.get(agentId); + const id = result?.toolCallId ?? agentId; + const toolCall = result ? toolCalls.get(result.toolCallId) : undefined; + const firstTimestamp = normalizeProviderReplayTimestamp(entries[0]?.timestamp); + const events: Extract[] = [ + { + type: "provider_subagent", + provider: "claude", + event: { + type: "upsert", + id, + title: toolCall?.subagentType ?? "Claude subagent", + description: toolCall?.description ?? null, + status: "running", + toolCallId: result?.toolCallId ?? null, + ...(firstTimestamp ? { timestamp: firstTimestamp } : {}), + }, + }, + ]; + for (const entry of entries) { + const timestamp = normalizeProviderReplayTimestamp(entry.timestamp); + for (const item of convertEntry(entry)) { + events.push({ + type: "provider_subagent", + provider: "claude", + event: { + type: "timeline", + id, + item, + ...(timestamp ? { timestamp } : {}), + }, + }); + } + } + const lastTimestamp = normalizeProviderReplayTimestamp(entries.at(-1)?.timestamp); + events.push({ + type: "provider_subagent", + provider: "claude", + event: { + type: "upsert", + id, + status: result?.failed ? "failed" : "completed", + ...(lastTimestamp ? { timestamp: lastTimestamp } : {}), + }, + }); + return events; +} + interface ClaudeHistoryEntry { type?: unknown; subtype?: unknown; isCompactSummary?: unknown; isSidechain?: unknown; + agentId?: unknown; + timestamp?: unknown; uuid?: unknown; message?: { content?: unknown; [key: string]: unknown }; [key: string]: unknown; diff --git a/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts b/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts index 919c4256c0..a9d852a455 100644 --- a/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts @@ -15,6 +15,7 @@ let lastQuery: ReturnType | null = null; const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER"; const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER"; const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER"; +const HISTORY_SIDECHAIN_MARKER = "HISTORY_ONLY_SIDECHAIN_MARKER"; function buildSdkQueryMock() { const events = [ @@ -126,6 +127,52 @@ describe("ClaudeAgentSession history replay regression", () => { content: HISTORY_ASSISTANT_MARKER, }, }), + JSON.stringify({ + type: "assistant", + uuid: "history-task-call-message", + sessionId: "history-session", + cwd, + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: "history-task-call", + name: "Agent", + input: { description: "Inspect persisted history" }, + }, + ], + }, + }), + JSON.stringify({ + type: "assistant", + isSidechain: true, + agentId: "history-child", + uuid: "history-child-message", + timestamp: "2026-07-12T10:00:01.000Z", + sessionId: "history-session", + cwd, + message: { + id: "history-child-message", + role: "assistant", + content: [{ type: "text", text: HISTORY_SIDECHAIN_MARKER }], + }, + }), + JSON.stringify({ + type: "user", + sessionId: "history-session", + cwd, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "history-task-call", + content: "done\nagentId: history-child", + }, + ], + }, + }), ].join("\n"), "utf8", ); @@ -219,6 +266,54 @@ describe("ClaudeAgentSession history replay regression", () => { expect(timelineText).toContain(HISTORY_ASSISTANT_MARKER); }); + test("replays persisted sidechains as provider subagent timelines", async () => { + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.resumeSession( + { + provider: "claude", + sessionId: "history-session", + nativeHandle: "history-session", + metadata: { provider: "claude", cwd }, + }, + { cwd }, + ); + const historyEvents: AgentStreamEvent[] = []; + + try { + for await (const event of session.streamHistory()) historyEvents.push(event); + } finally { + await session.close(); + } + + expect(historyEvents).toContainEqual({ + type: "provider_subagent", + provider: "claude", + event: { + type: "timeline", + id: "history-task-call", + item: { + type: "assistant_message", + text: HISTORY_SIDECHAIN_MARKER, + messageId: "history-child-message", + }, + timestamp: "2026-07-12T10:00:01.000Z", + }, + }); + expect(historyEvents).toContainEqual({ + type: "provider_subagent", + provider: "claude", + event: expect.objectContaining({ + type: "upsert", + id: "history-task-call", + status: "completed", + }), + }); + }); + test("listCommands includes rewind command", async () => { const logger = createTestLogger(); const client = new ClaudeAgentClient({ diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts index d1634117dd..aef9e91b60 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -1,6 +1,10 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; -import { mapClaudeRunningToolCall } from "./tool-call-mapper.js"; +import { + mapClaudeCompletedToolCall, + mapClaudeFailedToolCall, + mapClaudeRunningToolCall, +} from "./tool-call-mapper.js"; import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; import type { AgentMetadata, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js"; @@ -13,6 +17,7 @@ interface ClaudeContentChunk { interface SubAgentActionEntry { index: number; toolName: string; + input: unknown; summary?: string; } @@ -23,6 +28,7 @@ interface SubAgentActivityState { actionKeys: string[]; nextActionIndex: number; actionIndexByKey: Map; + completedActionKeys: Set; } interface SubAgentActionCandidate { @@ -64,19 +70,34 @@ export class ClaudeSidechainTracker { actionKeys: [], nextActionIndex: 1, actionIndexByKey: new Map(), + completedActionKeys: new Set(), } satisfies SubAgentActivityState); this.activeSidechains.set(parentToolUseId, state); const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId); const actionCandidates = this.extractSubAgentActionCandidates(message); + const childTimelineItems = [ + ...this.extractSubAgentTimelineItems(message), + ...this.extractSubAgentToolResults(message, state), + ]; let actionUpdated = false; for (const action of actionCandidates) { + if (state.completedActionKeys.has(action.key)) continue; if (this.appendSubAgentAction(state, action)) { actionUpdated = true; + const toolCall = mapClaudeRunningToolCall({ + name: action.toolName, + callId: action.key, + input: action.input, + output: null, + }); + if (toolCall) { + childTimelineItems.push(toolCall); + } } } - if (!contextUpdated && !actionUpdated) { + if (!contextUpdated && !actionUpdated && childTimelineItems.length === 0) { return []; } @@ -103,6 +124,25 @@ export class ClaudeSidechainTracker { }; return [ + { + type: "provider_subagent", + provider: "claude", + event: { + type: "upsert", + id: parentToolUseId, + title: state.subAgentType ?? "Claude subagent", + description: state.description ?? null, + status: "running", + toolCallId: parentToolUseId, + }, + }, + ...childTimelineItems.map( + (item): AgentStreamEvent => ({ + type: "provider_subagent", + provider: "claude", + event: { type: "timeline", id: parentToolUseId, item }, + }), + ), { type: "timeline", item: { @@ -114,6 +154,46 @@ export class ClaudeSidechainTracker { ]; } + finishAll(status: "completed" | "failed" | "canceled"): AgentStreamEvent[] { + const events: AgentStreamEvent[] = []; + for (const [id, state] of this.activeSidechains) { + events.push({ + type: "provider_subagent", + provider: "claude", + event: { + type: "upsert", + id, + title: state.subAgentType ?? "Claude subagent", + description: state.description ?? null, + status, + toolCallId: id, + }, + }); + } + this.activeSidechains.clear(); + return events; + } + + finish(id: string, status: "completed" | "failed" | "canceled"): AgentStreamEvent[] { + const state = this.activeSidechains.get(id); + if (!state) return []; + this.activeSidechains.delete(id); + return [ + { + type: "provider_subagent", + provider: "claude", + event: { + type: "upsert", + id, + title: state.subAgentType ?? "Claude subagent", + description: state.description ?? null, + status, + toolCallId: id, + }, + }, + ]; + } + delete(toolUseId: string): void { this.activeSidechains.delete(toolUseId); } @@ -122,6 +202,66 @@ export class ClaudeSidechainTracker { this.activeSidechains.clear(); } + private extractSubAgentTimelineItems(message: SDKMessage): AgentTimelineItem[] { + if (message.type !== "assistant" || !Array.isArray(message.message?.content)) { + return []; + } + const messageId = readTrimmedString(message.message.id); + const items: AgentTimelineItem[] = []; + for (const block of message.message.content) { + if (!isClaudeContentChunk(block)) continue; + if (block.type === "text") { + const text = readTrimmedString(block.text); + if (text) { + items.push({ + type: "assistant_message", + text, + ...(messageId ? { messageId } : {}), + }); + } + } else if (block.type === "thinking") { + const text = readTrimmedString(block.thinking); + if (text) items.push({ type: "reasoning", text }); + } + } + return items; + } + + private extractSubAgentToolResults( + message: SDKMessage, + state: SubAgentActivityState, + ): AgentTimelineItem[] { + const messageRecord = message as unknown as Record; + const messageContainer = messageRecord.message as Record | undefined; + const content = messageContainer?.content; + if (!Array.isArray(content)) return []; + + const items: AgentTimelineItem[] = []; + for (const block of content) { + if (!isClaudeContentChunk(block) || !block.type.endsWith("tool_result")) continue; + const callId = readTrimmedString(block.tool_use_id); + if (!callId || state.completedActionKeys.has(callId)) continue; + const actionIndex = state.actionIndexByKey.get(callId); + const action = actionIndex === undefined ? undefined : state.actions[actionIndex]; + const toolName = action?.toolName ?? readTrimmedString(block.tool_name); + if (!toolName) continue; + const params = { + name: toolName, + callId, + input: action?.input ?? null, + output: block.content ?? null, + }; + const toolCall = block.is_error + ? mapClaudeFailedToolCall({ ...params, error: block }) + : mapClaudeCompletedToolCall(params); + if (toolCall) { + state.completedActionKeys.add(callId); + items.push(toolCall); + } + } + return items; + } + private updateSubAgentContextFromTaskInput( state: SubAgentActivityState, parentToolUseId: string, @@ -259,6 +399,7 @@ export class ClaudeSidechainTracker { state.actions[existingIndex] = { ...existing, toolName: normalizedToolName, + input: existing.input ?? candidate.input, ...(nextSummary ? { summary: nextSummary } : {}), }; return true; @@ -267,6 +408,7 @@ export class ClaudeSidechainTracker { state.actions.push({ index: state.nextActionIndex, toolName: normalizedToolName, + input: candidate.input, ...(summary ? { summary } : {}), }); state.nextActionIndex += 1; @@ -279,7 +421,8 @@ export class ClaudeSidechainTracker { private trimSubAgentTail(state: SubAgentActivityState): void { while (state.actions.length > MAX_SUB_AGENT_LOG_ENTRIES) { state.actions.shift(); - state.actionKeys.shift(); + const removedKey = state.actionKeys.shift(); + if (removedKey) state.completedActionKeys.delete(removedKey); } } 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 cc4c009298..5522feaa8c 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 @@ -1567,7 +1567,7 @@ describe("Codex app-server provider", () => { asInternals(session).handleNotification("item/agentMessage/delta", { threadId: "child-thread-1", itemId: "child-message-1", - delta: "Found the path.", + delta: "Found", }); asInternals(session).handleNotification("item/completed", { threadId: "child-thread-1", @@ -1599,6 +1599,128 @@ describe("Codex app-server provider", () => { actions: [], }, }); + + const providerEvents = events.flatMap((event) => + event.type === "provider_subagent" ? [event.event] : [], + ); + expect(providerEvents).toContainEqual( + expect.objectContaining({ + type: "upsert", + id: "child-thread-1", + description: "Report findings.", + }), + ); + expect(providerEvents).toContainEqual({ + type: "timeline", + id: "child-thread-1", + item: { + type: "assistant_message", + messageId: "child-message-1", + text: "Found", + }, + }); + expect(providerEvents).toContainEqual({ + type: "timeline", + id: "child-thread-1", + item: { + type: "assistant_message", + messageId: "child-message-1", + text: " the path.", + }, + }); + expect(providerEvents.at(-1)).toMatchObject({ + type: "upsert", + id: "child-thread-1", + status: "completed", + }); + }); + + test("renders child MCP image results in the provider subagent timeline", () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + asInternals(session).handleNotification("item/completed", { + threadId: "test-thread", + item: { + type: "subAgentActivity", + id: "spawn-image-child", + kind: "started", + agentThreadId: "image-child-thread", + agentPath: "/root/image-child", + }, + }); + + asInternals(session).handleNotification("item/completed", { + threadId: "image-child-thread", + item: { + id: "child-mcp-image", + type: "mcpToolCall", + status: "completed", + server: "paseo", + tool: "browser_screenshot", + arguments: {}, + result: { + content: [{ type: "image", data: ONE_BY_ONE_PNG_BASE64, mimeType: "image/png" }], + }, + }, + }); + + const childItems = events.flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.id === "image-child-thread" + ? [event.event.item] + : [], + ); + expect(childItems).toHaveLength(2); + expect(childItems[0]).toMatchObject({ type: "tool_call", callId: "child-mcp-image" }); + expect(childItems[1]).toMatchObject({ type: "assistant_message" }); + if (childItems[1]?.type !== "assistant_message") { + throw new Error("Expected child image markdown"); + } + const source = markdownImageSource(childItems[1].text); + expect(existsSync(source)).toBe(true); + rmSync(source, { force: true }); + }); + + test("renders a child user message once across lifecycle notifications", () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + asInternals(session).handleNotification("item/completed", { + threadId: "test-thread", + item: { + type: "subAgentActivity", + id: "spawn-user-child", + kind: "started", + agentThreadId: "user-child-thread", + agentPath: "/root/user-child", + }, + }); + const childUserMessage = { + type: "userMessage", + id: "child-user-message", + content: [{ type: "text", text: "Inspect this path." }], + }; + + asInternals(session).handleNotification("item/started", { + threadId: "user-child-thread", + item: childUserMessage, + }); + asInternals(session).handleNotification("item/completed", { + threadId: "user-child-thread", + item: childUserMessage, + }); + + expect( + events.filter( + (event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.id === "user-child-thread" && + event.event.item.type === "user_message", + ), + ).toHaveLength(1); }); test("keeps the parent running when a MultiAgentV2 sub-agent finishes", async () => { @@ -2095,6 +2217,20 @@ describe("Codex app-server provider", () => { event.item.callId === "child-command", ), ).toBe(false); + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: { + type: "timeline", + id: "legacy-envelope-child", + item: expect.objectContaining({ + type: "tool_call", + callId: "child-command", + status: "running", + }), + }, + }), + ); expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(0); expect(events.at(-1)).toMatchObject({ type: "timeline", @@ -2343,10 +2479,28 @@ describe("Codex app-server provider", () => { test("loads mixed legacy and MultiAgentV2 sub-agent history", async () => { const session = createSession(); session.client = { - request: vi.fn(async (method: string) => { + request: vi.fn(async (method: string, params: unknown) => { if (method !== "thread/read") { return {}; } + const threadId = (params as { threadId?: string }).threadId; + if (threadId !== "test-thread") { + return { + thread: { + turns: [ + { + items: [ + { + type: "agentMessage", + id: `message-${threadId}`, + text: `History from ${threadId}`, + }, + ], + }, + ], + }, + }; + } return { thread: { turns: [ @@ -2382,6 +2536,38 @@ describe("Codex app-server provider", () => { for await (const event of session.streamHistory()) { history.push(event); } + expect( + history.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [], + ), + ).toMatchObject([ + { type: "upsert", id: "legacy-child-thread", status: "completed" }, + { type: "upsert", id: "v2-child-thread", status: "completed" }, + ]); + expect( + history.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event] : [], + ), + ).toEqual([ + { + type: "timeline", + id: "legacy-child-thread", + item: { + type: "assistant_message", + messageId: "message-legacy-child-thread", + text: "History from legacy-child-thread", + }, + }, + { + type: "timeline", + id: "v2-child-thread", + item: { + type: "assistant_message", + messageId: "message-v2-child-thread", + text: "History from v2-child-thread", + }, + }, + ]); expect( history .filter((event) => event.type === "timeline" && event.item.type === "tool_call") @@ -2447,10 +2633,13 @@ describe("Codex app-server provider", () => { test("coalesces persisted MultiAgentV2 activity for one child into one terminal card", async () => { const session = createSession(); session.client = { - request: vi.fn(async (method: string) => { + request: vi.fn(async (method: string, params: unknown) => { if (method !== "thread/read") { return {}; } + if ((params as { threadId?: string }).threadId !== "test-thread") { + return { thread: { turns: [] } }; + } return { thread: { turns: [ @@ -2495,6 +2684,15 @@ describe("Codex app-server provider", () => { history.push(event); } expect(history).toEqual([ + { + type: "provider_subagent", + provider: "codex", + event: expect.objectContaining({ + type: "upsert", + id: "history-child-thread", + status: "canceled", + }), + }, { type: "timeline", provider: "codex", 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 c9239dd0ad..aa23cf47fe 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 @@ -3061,6 +3061,7 @@ interface CodexSubAgentCallState { pendingFileChangeOutputDeltas: Map; childItemOrder: string[]; childItems: Map; + childThreadIds: Set; } export class CodexAppServerAgentSession implements AgentSession { @@ -3081,6 +3082,8 @@ export class CodexAppServerAgentSession implements AgentSession { private planModeEnabled = false; private historyPending = false; private persistedHistory: PersistedTimelineEntry[] = []; + private loadingPersistedHistory = false; + private persistedProviderSubagentEvents: AgentStreamEvent[] = []; private pendingPermissions = new Map(); private mcpElicitationPermissionIds = new Map(); private pendingPermissionHandlers = new Map< @@ -3105,6 +3108,7 @@ export class CodexAppServerAgentSession implements AgentSession { private emittedExecCommandCompletedCallIds = new Set(); private emittedItemStartedIds = new Set(); private emittedItemCompletedIds = new Set(); + private emittedProviderSubagentUserMessageKeys = new Set(); private subAgentCallsByCallId = new Map(); private subAgentCallIdByChildThreadId = new Map(); private pendingSubAgentNotificationsByThreadId = new Map(); @@ -3454,12 +3458,12 @@ export class CodexAppServerAgentSession implements AgentSession { this.subAgentCallsByCallId.clear(); this.subAgentCallIdByChildThreadId.clear(); this.pendingSubAgentNotificationsByThreadId.clear(); - for (const route of subAgentRoutes) { - this.registerSubAgentToolCall({ - timelineItem: route.toolCall, - rawItem: { agentThreadId: route.childThreadId }, - parentCallId: null, - }); + this.persistedProviderSubagentEvents = []; + this.loadingPersistedHistory = true; + try { + await this.loadPersistedSubAgentHistories(client, subAgentRoutes); + } finally { + this.loadingPersistedHistory = false; } this.resetCodexUserMessageTurns(); for (const entry of timeline) { @@ -3471,6 +3475,44 @@ export class CodexAppServerAgentSession implements AgentSession { this.historyPending = timeline.length > 0; } + private async loadPersistedSubAgentHistories( + client: CodexAppServerClientLike, + rootRoutes: readonly PersistedSubAgentRoute[], + ): Promise { + const queue = rootRoutes.map((route) => ({ route, parentCallId: null as string | null })); + const visitedThreadIds = new Set(); + while (queue.length > 0 && visitedThreadIds.size < 100) { + const next = queue.shift(); + if (!next || visitedThreadIds.has(next.route.childThreadId)) { + continue; + } + visitedThreadIds.add(next.route.childThreadId); + this.registerSubAgentToolCall({ + timelineItem: next.route.toolCall, + rawItem: { agentThreadId: next.route.childThreadId }, + parentCallId: next.parentCallId, + }); + try { + const childHistory = await loadCodexThreadHistoryTimeline({ + threadId: next.route.childThreadId, + cwd: this.config.cwd ?? null, + requestThread: (childThreadId) => readCodexThread(client, childThreadId), + }); + for (const entry of childHistory.timeline) { + this.emitProviderSubagentTimeline(next.route.childThreadId, entry.item, entry.timestamp); + } + for (const route of childHistory.subAgentRoutes) { + queue.push({ route, parentCallId: next.route.toolCall.callId }); + } + } catch (error) { + this.logger.trace( + { err: error, childThreadId: next.route.childThreadId }, + "Failed to load persisted Codex child history", + ); + } + } + } + private async ensureThreadLoaded(): Promise { if (!this.client || !this.currentThreadId) return; try { @@ -3818,12 +3860,20 @@ export class CodexAppServerAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator { - if (!this.historyPending || this.persistedHistory.length === 0) { + if ( + (!this.historyPending || this.persistedHistory.length === 0) && + this.persistedProviderSubagentEvents.length === 0 + ) { return; } const history = this.persistedHistory; + const providerSubagents = this.persistedProviderSubagentEvents; this.persistedHistory = []; + this.persistedProviderSubagentEvents = []; this.historyPending = false; + for (const event of providerSubagents) { + yield event; + } for (const entry of history) { yield { type: "timeline", @@ -4454,6 +4504,10 @@ export class CodexAppServerAgentSession implements AgentSession { } private emitEvent(event: AgentStreamEvent): void { + if (this.loadingPersistedHistory && event.type === "provider_subagent") { + this.persistedProviderSubagentEvents.push(event); + return; + } this.notifySubscribers(event); } @@ -4724,6 +4778,7 @@ export class CodexAppServerAgentSession implements AgentSession { pendingFileChangeOutputDeltas: new Map(), childItemOrder: [], childItems: new Map(), + childThreadIds: new Set(), } satisfies CodexSubAgentCallState); state.toolCall = { @@ -4754,6 +4809,8 @@ export class CodexAppServerAgentSession implements AgentSession { ); for (const receiverThreadId of childThreadIds) { this.subAgentCallIdByChildThreadId.set(receiverThreadId, timelineItem.callId); + state.childThreadIds.add(receiverThreadId); + this.emitProviderSubagentUpsert(receiverThreadId, state, timelineItem.status); } return childThreadIds; } @@ -4835,12 +4892,20 @@ export class CodexAppServerAgentSession implements AgentSession { private emitCodexToolTimelineItem( timelineItem: ToolCallTimelineItem, subAgentCallId: string | null, + childThreadId?: string | null, ): void { if (!subAgentCallId) { this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); return; } this.upsertSubAgentChildItem(subAgentCallId, timelineItem.callId, timelineItem); + const state = this.subAgentCallsByCallId.get(subAgentCallId); + if (state) { + const targetThreadIds = childThreadId ? [childThreadId] : state.childThreadIds; + for (const targetThreadId of targetThreadIds) { + this.emitProviderSubagentTimeline(targetThreadId, timelineItem); + } + } this.emitSubAgentActivityUpdate( subAgentCallId, timelineItem.status === "running" ? "running" : undefined, @@ -4867,6 +4932,9 @@ export class CodexAppServerAgentSession implements AgentSession { ? curateAgentActivity(childTimeline, { labelAssistantMessages: true }) : ""; const resolvedStatus = status ?? state.toolCall.status; + for (const childThreadId of state.childThreadIds) { + this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus); + } const baseToolCall = { ...state.toolCall, detail: { @@ -4895,6 +4963,100 @@ export class CodexAppServerAgentSession implements AgentSession { this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: nextToolCall }); } + private emitProviderSubagentUpsert( + childThreadId: string, + state: CodexSubAgentCallState, + status: ToolCallTimelineItem["status"], + ): void { + const detail = state.toolCall.detail; + if (detail.type !== "sub_agent") { + return; + } + let providerStatus: "running" | "completed" | "failed" | "canceled" = "running"; + if (status === "completed") { + providerStatus = "completed"; + } else if (status === "failed") { + providerStatus = "failed"; + } else if (status === "canceled") { + providerStatus = "canceled"; + } + this.emitEvent({ + type: "provider_subagent", + provider: CODEX_PROVIDER, + event: { + type: "upsert", + id: childThreadId, + title: detail.subAgentType ?? "Codex subagent", + description: detail.description ?? null, + status: providerStatus, + toolCallId: state.callId, + }, + }); + } + + private emitProviderSubagentTimeline( + childThreadId: string, + item: AgentTimelineItem, + timestamp?: string, + ): void { + this.emitEvent({ + type: "provider_subagent", + provider: CODEX_PROVIDER, + event: { + type: "timeline", + id: childThreadId, + item, + ...(timestamp ? { timestamp } : {}), + }, + }); + } + + private emitCompletedProviderSubagentItem( + parsed: Extract, + timelineItem: AgentTimelineItem, + ): void { + const itemId = parsed.item.id; + if (!parsed.threadId) return; + if (timelineItem.type === "assistant_message" && itemId) { + const streamedText = this.pendingAgentMessages.get(itemId); + if (streamedText !== undefined) { + const suffix = this.buildMissingFinalTextSuffix(timelineItem, streamedText); + if (suffix) this.emitProviderSubagentTimeline(parsed.threadId, suffix); + return; + } + } + if (timelineItem.type === "reasoning" && itemId) { + const streamedText = this.pendingReasoning.get(itemId)?.join(""); + if (streamedText !== undefined) { + const suffix = this.buildMissingFinalTextSuffix(timelineItem, streamedText); + if (suffix) this.emitProviderSubagentTimeline(parsed.threadId, suffix); + return; + } + } + this.emitProviderSubagentTimeline(parsed.threadId, timelineItem); + } + + private emitStartedProviderSubagentItem( + threadId: string | null, + timelineItem: AgentTimelineItem, + ): void { + if (threadId) { + this.emitProviderSubagentTimeline(threadId, timelineItem); + } + } + + private emitProviderSubagentTimelineItems( + threadId: string | null, + timelineItems: readonly AgentTimelineItem[], + ): void { + if (!threadId) { + return; + } + for (const timelineItem of timelineItems) { + this.emitProviderSubagentTimeline(threadId, timelineItem); + } + } + private handleSubAgentChildItemCompleted( callId: string, itemId: string | undefined, @@ -4949,6 +5111,13 @@ export class CodexAppServerAgentSession implements AgentSession { this.pendingAgentMessages.set(parsed.itemId, text); const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); if (subAgentCallId) { + if (parsed.threadId) { + this.emitProviderSubagentTimeline(parsed.threadId, { + type: "assistant_message", + messageId: parsed.itemId, + text: parsed.delta, + }); + } this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, { type: "assistant_message", messageId: parsed.itemId, @@ -4981,6 +5150,12 @@ export class CodexAppServerAgentSession implements AgentSession { this.pendingReasoning.set(parsed.itemId, prev); const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); if (subAgentCallId) { + if (parsed.threadId) { + this.emitProviderSubagentTimeline(parsed.threadId, { + type: "reasoning", + text: parsed.delta, + }); + } this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, { type: "reasoning", text: prev.join(""), @@ -5080,6 +5255,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.latestPlanResult = null; this.emittedItemStartedIds.clear(); this.emittedItemCompletedIds.clear(); + this.emittedProviderSubagentUserMessageKeys.clear(); this.emittedExecCommandStartedCallIds.clear(); this.emittedExecCommandCompletedCallIds.clear(); this.pendingAgentMessages.clear(); @@ -5224,7 +5400,7 @@ export class CodexAppServerAgentSession implements AgentSession { running: true, }); if (timelineItem) { - this.emitCodexToolTimelineItem(timelineItem, subAgentCallId); + this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId); } } @@ -5257,7 +5433,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (!subAgentCallId) { this.emittedExecCommandCompletedCallIds.add(timelineItem.callId); } - this.emitCodexToolTimelineItem(timelineItem, subAgentCallId); + this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId); } } @@ -5306,7 +5482,7 @@ export class CodexAppServerAgentSession implements AgentSession { callId: parsed.callId, changes: parsed.changes, }); - this.emitCodexToolTimelineItem(timelineItem, subAgentCallId); + this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId); } } @@ -5336,7 +5512,7 @@ export class CodexAppServerAgentSession implements AgentSession { changes: parsed.changes, stdout: parsed.stdout, }); - this.emitCodexToolTimelineItem(timelineItem, subAgentCallId); + this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId); } } @@ -5372,8 +5548,11 @@ export class CodexAppServerAgentSession implements AgentSession { parentCallId: childSubAgentCallId, }) : []; + const imageItems = mcpToolResultImagesToTimeline(parsed.item); if (childSubAgentCallId) { + this.emitCompletedProviderSubagentItem(parsed, timelineItem); this.handleSubAgentChildItemCompleted(childSubAgentCallId, parsed.item.id, timelineItem); + this.emitProviderSubagentTimelineItems(parsed.threadId, imageItems); this.replayPendingSubAgentNotifications(registeredChildThreadIds); return; } @@ -5408,7 +5587,6 @@ export class CodexAppServerAgentSession implements AgentSession { } this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item); } - const imageItems = mcpToolResultImagesToTimeline(parsed.item); this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); if (timelineItem.type === "assistant_message") { this.pendingAssistantMessageBoundary = true; @@ -5452,26 +5630,24 @@ export class CodexAppServerAgentSession implements AgentSession { timelineItem: Extract, streamedText: string, ): void { - if (!timelineItem.text.startsWith(streamedText)) { - this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); - return; - } + const item = this.buildMissingFinalTextSuffix(timelineItem, streamedText); + if (item) this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item }); + } + + private buildMissingFinalTextSuffix( + timelineItem: Extract, + streamedText: string, + ): AgentTimelineItem | null { + if (!timelineItem.text.startsWith(streamedText)) return timelineItem; const suffix = timelineItem.text.slice(streamedText.length); - if (!suffix) { - return; - } - this.emitEvent({ - type: "timeline", - provider: CODEX_PROVIDER, - item: - timelineItem.type === "assistant_message" - ? { - type: timelineItem.type, - text: suffix, - ...(timelineItem.messageId ? { messageId: timelineItem.messageId } : {}), - } - : { type: timelineItem.type, text: suffix }, - }); + if (!suffix) return null; + return timelineItem.type === "assistant_message" + ? { + type: timelineItem.type, + text: suffix, + ...(timelineItem.messageId ? { messageId: timelineItem.messageId } : {}), + } + : { type: timelineItem.type, text: suffix }; } private applyBufferedDeltaTextToTimelineItem( @@ -5484,14 +5660,15 @@ export class CodexAppServerAgentSession implements AgentSession { if (timelineItem.type === "assistant_message") { const buffered = this.pendingAgentMessages.get(itemId); if (buffered && buffered.length > 0) { - timelineItem.text = buffered; + if (!timelineItem.text.startsWith(buffered)) timelineItem.text = buffered; } return; } if (timelineItem.type === "reasoning") { const buffered = this.pendingReasoning.get(itemId); if (buffered && buffered.length > 0) { - timelineItem.text = buffered.join(""); + const streamedText = buffered.join(""); + if (!timelineItem.text.startsWith(streamedText)) timelineItem.text = streamedText; } } } @@ -5537,6 +5714,7 @@ export class CodexAppServerAgentSession implements AgentSession { parentCallId: childSubAgentCallId, }); if (childSubAgentCallId) { + this.emitStartedProviderSubagentItem(parsed.threadId, timelineItem); if (parsed.item.id) { this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem); } @@ -5580,6 +5758,18 @@ export class CodexAppServerAgentSession implements AgentSession { } const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); if (childSubAgentCallId) { + const childMessageId = itemId ?? timelineItem.messageId; + if (!childMessageId) { + return; + } + const childMessageKey = `${parsed.threadId ?? childSubAgentCallId}:${childMessageId}`; + if (this.emittedProviderSubagentUserMessageKeys.has(childMessageKey)) { + return; + } + this.emittedProviderSubagentUserMessageKeys.add(childMessageKey); + if (parsed.threadId) { + this.emitProviderSubagentTimeline(parsed.threadId, timelineItem); + } if (itemId) { this.upsertSubAgentChildItem(childSubAgentCallId, itemId, timelineItem); } diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts index f29b844314..785838bb64 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts @@ -9,6 +9,13 @@ import { buildVersionProbeCommand, GenericACPAgentClient } from "./generic-acp-a const TEST_ACP_TIMEOUT_MS = 1_000; +function parseInitializeTrace(content: string): Array<{ clientCapabilities: unknown }> { + return content + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { clientCapabilities: unknown }); +} + describe("GenericACPAgentClient diagnostics", () => { test("probes npx-backed agent packages instead of npx itself", () => { expect(buildVersionProbeCommand(["npx", "-y", "@google/gemini-cli@0.41.1", "--acp"])).toEqual({ @@ -92,6 +99,53 @@ describe("GenericACPAgentClient diagnostics", () => { }); }); + test("sends configured client capabilities in catalog and live session initialization", async () => { + await withFakeACPAgent("success", async (scriptPath, mode, testDir) => { + const initializeTracePath = path.join(testDir, "initialize.jsonl"); + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, scriptPath, mode, "", initializeTracePath], + providerParams: { + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + terminal: true, + }, + }, + }); + + await client.fetchCatalog({ cwd: testDir, force: true, timeoutMs: TEST_ACP_TIMEOUT_MS }); + const session = await client.createSession({ provider: "acp", cwd: testDir }); + await session.close(); + + const initializeRequests = parseInitializeTrace(await readFile(initializeTracePath, "utf8")); + + expect(initializeRequests).toHaveLength(2); + expect(initializeRequests).toEqual([ + { + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + terminal: true, + }, + }, + { + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + terminal: true, + }, + }, + ]); + }); + }); + test("reports a missing launcher without dropping the rest of the diagnostic", async () => { await withTempDir("paseo-missing-acp-agent-", async (testDir) => { const missingCommand = path.join(testDir, "missing-acp-agent"); @@ -167,6 +221,7 @@ const readline = require("node:readline"); const mode = process.argv[2]; const pidPath = process.argv[3]; +const initializeTracePath = process.argv[4]; if (pidPath) { fs.writeFileSync(pidPath, String(process.pid)); } @@ -179,6 +234,12 @@ function send(id, result) { rl.on("line", (line) => { const message = JSON.parse(line); if (message.method === "initialize") { + if (initializeTracePath) { + fs.appendFileSync( + initializeTracePath, + JSON.stringify({ clientCapabilities: message.params?.clientCapabilities }) + "\\n", + ); + } send(message.id, { protocolVersion: message.params?.protocolVersion ?? 1, agentCapabilities: {}, diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.ts b/packages/server/src/server/agent/providers/generic-acp-agent.ts index c1a0419b2f..72f29faa13 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.ts @@ -20,6 +20,17 @@ import { export const GenericACPProviderParamsSchema = z .object({ supportsMcpServers: z.boolean().optional(), + clientCapabilities: z + .object({ + fs: z + .object({ + readTextFile: z.boolean().optional(), + writeTextFile: z.boolean().optional(), + }) + .optional(), + terminal: z.boolean().optional(), + }) + .optional(), }) .passthrough(); @@ -47,6 +58,7 @@ export class GenericACPAgentClient extends ACPAgentClient { private readonly diagnosticPhaseTimeoutMs?: number; constructor(options: GenericACPAgentClientOptions) { + const providerParams = parseGenericACPProviderParams(options.providerParams); super({ provider: "acp", logger: options.logger, @@ -54,9 +66,10 @@ export class GenericACPAgentClient extends ACPAgentClient { env: options.env, }, defaultCommand: options.command, - capabilities: buildGenericACPCapabilities(options), + capabilities: buildGenericACPCapabilities(providerParams), waitForInitialCommands: options.waitForInitialCommands, initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs, + clientCapabilities: providerParams.clientCapabilities, clientCapabilityMeta: options.clientCapabilityMeta, configFeatureOptions: options.configFeatureOptions, extensionCommandsParser: options.extensionCommandsParser, @@ -145,8 +158,7 @@ export class GenericACPAgentClient extends ACPAgentClient { } } -function buildGenericACPCapabilities(options: GenericACPAgentClientOptions): AgentCapabilityFlags { - const params = parseGenericACPProviderParams(options.providerParams); +function buildGenericACPCapabilities(params: GenericACPProviderParams): AgentCapabilityFlags { return { ...DEFAULT_ACP_CAPABILITIES, supportsMcpServers: params.supportsMcpServers ?? DEFAULT_ACP_CAPABILITIES.supportsMcpServers, 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 139a63637b..54232acd75 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -8,6 +8,7 @@ import type { Event as OpenCodeEvent } from "@opencode-ai/sdk/v2/client"; import { __openCodeInternals, OpenCodeAgentClient, + type OpenCodeEventTranslationState, translateOpenCodeEvent, } from "./opencode-agent.js"; import { streamSession } from "./test-utils/session-stream-adapter.js"; @@ -80,6 +81,16 @@ async function collectTurnEvents(iterator: AsyncGenerator): Pr return result; } +function providerAssistantMessages(events: AgentStreamEvent[], text: string): AgentStreamEvent[] { + return events.filter( + (event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.item.type === "assistant_message" && + event.event.item.text === text, + ); +} + function assistantTurnEvents({ sessionId = "session-1", text = "Hello from OpenCode", @@ -215,12 +226,13 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { expect(turn.turnCompleted).toBe(true); expect(turn.turnFailed).toBe(false); - expect(turn.assistantMessages.length).toBeGreaterThan(0); - for (const msg of turn.assistantMessages) { - expect(msg.text.length).toBeGreaterThan(0); - } - const fullResponse = turn.assistantMessages.map((m) => m.text).join(""); - expect(fullResponse).toBe("Hello from OpenCode"); + expect(turn.assistantMessages).toEqual([ + { + type: "assistant_message", + text: "Hello from OpenCode", + messageId: "msg_assistant", + }, + ]); expect(openCodeClient.calls.sessionPromptAsync).toEqual([ expect.objectContaining({ sessionID: "session-1", @@ -234,6 +246,74 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { rmSync(cwd, { recursive: true, force: true }); }, 120_000); + test("completed and structured assistant messages preserve OpenCode message IDs", async () => { + const cwd = tmpCwd(); + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.sessionPromptAsyncEvents = [ + { + type: "message.updated", + properties: { + info: { + id: "msg_structured", + sessionID: "session-1", + role: "assistant", + structured: "structured reply", + time: { completed: 1 }, + }, + }, + }, + { + type: "message.updated", + properties: { + info: { + id: "msg_completed", + sessionID: "session-1", + role: "assistant", + }, + }, + }, + { + type: "message.part.updated", + properties: { + part: { + id: "prt_completed", + sessionID: "session-1", + messageID: "msg_completed", + type: "text", + text: "completed reply", + time: { start: 1, end: 2 }, + }, + }, + }, + { type: "session.idle", properties: { sessionID: "session-1" } }, + ]; + runtime.enqueueClient(openCodeClient); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession(buildConfig(cwd)); + + const turn = await collectTurnEvents(streamSession(session, "Reply twice")); + + expect(turn.assistantMessages).toEqual([ + { + type: "assistant_message", + text: "structured reply", + messageId: "msg_structured", + }, + { + type: "assistant_message", + text: "completed reply", + messageId: "msg_completed", + }, + ]); + + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + }); + test("manual compact hides the generated summary text", async () => { const cwd = tmpCwd(); const runtime = new TestOpenCodeHarness(); @@ -1197,6 +1277,7 @@ describe("OpenCode adapter startTurn error handling", () => { test("keeps a turn active while OpenCode is retrying", async () => { vi.useFakeTimers(); const eventsGate = createTestDeferred(); + let eventStreamSignal: AbortSignal | undefined; const retryStream: AsyncIterable = { [Symbol.asyncIterator]: () => { let emitted = false; @@ -1222,14 +1303,27 @@ describe("OpenCode adapter startTurn error handling", () => { }, }; } - return new Promise(() => {}); + return new Promise>((resolve) => { + if (eventStreamSignal?.aborted) { + resolve({ done: true, value: undefined }); + return; + } + eventStreamSignal?.addEventListener( + "abort", + () => resolve({ done: true, value: undefined }), + { once: true }, + ); + }); }, }; }, }; const fakeClient = { global: { - event: vi.fn().mockResolvedValue({ stream: retryStream }), + event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => { + eventStreamSignal = signal; + return { stream: retryStream }; + }), }, session: { abort: vi.fn().mockResolvedValue({ error: null }), @@ -1319,6 +1413,52 @@ describe("OpenCode adapter startTurn error handling", () => { expect(fakeClient.session.delete).not.toHaveBeenCalled(); }); + test("waits for the OpenCode event stream to finish after close aborts it", async () => { + const streamAborted = createTestDeferred(); + const finishStreamCleanup = createTestDeferred(); + const fakeClient = { + global: { + event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => ({ + stream: { + [Symbol.asyncIterator]: () => ({ + next: async () => { + if (!signal.aborted) { + await waitForAbort(signal); + } + streamAborted.resolve(); + await finishStreamCleanup.promise; + return { done: true, value: undefined }; + }, + }), + }, + })), + }, + session: { + abort: vi.fn().mockResolvedValue({ error: null }), + update: vi.fn().mockResolvedValue({ error: null }), + }, + } as never; + const session = new __openCodeInternals.OpenCodeAgentSession( + { provider: "opencode", cwd: "/tmp/test" }, + fakeClient, + "ses_unit_test", + createTestLogger(), + ); + let closeSettled = false; + + const closePromise = session.close().then(() => { + closeSettled = true; + return undefined; + }); + await streamAborted.promise; + await new Promise((resolve) => setImmediate(resolve)); + + expect(closeSettled).toBe(false); + + finishStreamCleanup.resolve(); + await closePromise; + }); + test("streamHistory preserves OpenCode replay timestamps from message and part times", async () => { const fakeClient = { session: { @@ -1410,7 +1550,11 @@ describe("OpenCode adapter startTurn error handling", () => { type: "timeline", provider: "opencode", timestamp: "2026-05-14T12:41:23.612Z", - item: { type: "assistant_message", text: "probe ok" }, + item: { + type: "assistant_message", + text: "probe ok", + messageId: "msg_assistant", + }, }, ]); }); @@ -1462,7 +1606,11 @@ describe("OpenCode adapter startTurn error handling", () => { { type: "timeline", provider: "opencode", - item: { type: "assistant_message", text: "no clocks here" }, + item: { + type: "assistant_message", + text: "no clocks here", + messageId: "msg_assistant", + }, }, ]); }); @@ -2164,6 +2312,1068 @@ describe("OpenCode persisted sessions", () => { }); }); +describe("OpenCode provider subagent contract", () => { + async function createAdoptedChildSession(): Promise<{ + readonly runtime: TestOpenCodeHarness; + readonly parent: Awaited>; + readonly child: Awaited>; + readonly childClient: TestOpenCodeClient; + }> { + const runtime = new TestOpenCodeHarness(); + const parentClient = new TestOpenCodeClient(); + const childClient = new TestOpenCodeClient(); + parentClient.sessionCreateResponse = { data: { id: "ses_parent_external" } }; + runtime.enqueueClient(parentClient); + runtime.enqueueClient(childClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const parent = await client.createSession( + { provider: "opencode", cwd: "/workspace/repo" }, + { env: { PASEO_AGENT_ID: "parent-agent" } }, + ); + + parentClient.emitEvent({ + type: "session.created", + properties: { + info: { + id: "ses_child_external", + parentID: "ses_parent_external", + title: "Externally driven child", + }, + }, + }); + await Promise.resolve(); + await Promise.resolve(); + + const child = await client.resumeSession( + { + provider: "opencode", + sessionId: "ses_child_external", + nativeHandle: "ses_child_external", + metadata: { cwd: "/workspace/repo" }, + }, + undefined, + { env: { PASEO_AGENT_ID: "child-agent" } }, + ); + return { runtime, parent, child, childClient }; + } + + test("resumes an adopted child on the parent's registered OpenCode server", async () => { + const runtime = new TestOpenCodeHarness(); + const parentClient = new TestOpenCodeClient(); + const childClient = new TestOpenCodeClient(); + parentClient.sessionCreateResponse = { data: { id: "ses_parent_registry" } }; + runtime.enqueueClient(parentClient); + runtime.enqueueClient(childClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const parent = await client.createSession( + { provider: "opencode", cwd: "/workspace/repo" }, + { env: { PASEO_AGENT_ID: "parent-agent" } }, + ); + const events: AgentStreamEvent[] = []; + parent.subscribe((event) => events.push(event)); + + parentClient.emitEvent({ + type: "session.created", + properties: { + info: { + id: "ses_child_registry", + parentID: "ses_parent_registry", + title: "Live child", + }, + }, + }); + await Promise.resolve(); + await Promise.resolve(); + + const child = await client.resumeSession( + { + provider: "opencode", + sessionId: "ses_child_registry", + nativeHandle: "ses_child_registry", + metadata: { cwd: "/workspace/repo" }, + }, + undefined, + { env: { PASEO_AGENT_ID: "child-agent" } }, + ); + await child.close(); + await parent.close(); + + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: "ses_child_registry", + title: "Live child", + status: "running", + }, + }); + expect(runtime.acquisitions).toEqual([ + { kind: "dedicated", env: { PASEO_AGENT_ID: "parent-agent" }, releaseCount: 1 }, + { kind: "existing", url: runtime.server.url, releaseCount: 1 }, + ]); + expect(runtime.clientCreations).toEqual([ + { baseUrl: runtime.server.url, directory: "/workspace/repo" }, + { baseUrl: runtime.server.url, directory: "/workspace/repo" }, + ]); + }); + + test("synthesizes a turn for externally driven adopted child timeline events", async () => { + const { child, childClient, parent } = await createAdoptedChildSession(); + const completed = createTestDeferred(); + const events: AgentStreamEvent[] = []; + child.subscribe((event) => { + events.push(event); + if (event.type === "turn_completed") { + completed.resolve(); + } + }); + + for (const event of [ + ...assistantTurnEvents({ sessionId: "ses_child_external", text: "child says hi" }).slice( + 0, + 2, + ), + { + type: "session.status", + properties: { sessionID: "ses_child_external", status: { type: "busy" } }, + }, + { type: "session.idle", properties: { sessionID: "ses_child_external" } }, + ]) { + childClient.emitEvent(event); + } + + await completed.promise; + await child.close(); + await parent.close(); + + expect(events.map((event) => event.type)).toEqual([ + "turn_started", + "timeline", + "turn_completed", + ]); + expect(events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([ + "opencode-turn-0", + "opencode-turn-0", + "opencode-turn-0", + ]); + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: { type: "assistant_message", text: "child says hi" }, + }), + ); + }); + + test("synthesizes a turn for externally driven adopted child permissions", async () => { + const { child, childClient, parent } = await createAdoptedChildSession(); + const completed = createTestDeferred(); + const events: AgentStreamEvent[] = []; + child.subscribe((event) => { + events.push(event); + if (event.type === "turn_completed") { + completed.resolve(); + } + }); + + childClient.emitEvent({ + type: "permission.asked", + properties: { + id: "perm_child_external", + sessionID: "ses_child_external", + permission: "bash", + patterns: ["npm test"], + metadata: { command: "npm test", cwd: "/workspace/repo" }, + }, + }); + childClient.emitEvent({ + type: "session.idle", + properties: { sessionID: "ses_child_external" }, + }); + + await completed.promise; + await child.close(); + await parent.close(); + + expect(events.map((event) => event.type)).toEqual([ + "turn_started", + "permission_requested", + "turn_completed", + ]); + expect(events).toContainEqual( + expect.objectContaining({ + type: "permission_requested", + request: expect.objectContaining({ id: "perm_child_external" }), + turnId: "opencode-turn-0", + }), + ); + }); + + test("forwards provider child permissions through the parent session", async () => { + const runtime = new TestOpenCodeHarness(); + const parentClient = new TestOpenCodeClient(); + parentClient.sessionCreateResponse = { data: { id: "ses_parent_permission" } }; + runtime.enqueueClient(parentClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const parent = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" }); + const events: AgentStreamEvent[] = []; + parent.subscribe((event) => events.push(event)); + + parentClient.emitEvent({ + type: "session.created", + properties: { + info: { + id: "ses_provider_child_permission", + parentID: "ses_parent_permission", + title: "Permission child", + directory: "/workspace/child", + }, + }, + }); + await vi.waitFor(() => { + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: expect.objectContaining({ + type: "upsert", + id: "ses_provider_child_permission", + }), + }); + }); + + parentClient.emitEvent({ + type: "permission.asked", + properties: { + id: "perm_provider_child", + sessionID: "ses_provider_child_permission", + permission: "bash", + patterns: ["npm test"], + metadata: { command: "npm test", cwd: "/workspace/repo" }, + }, + }); + await vi.waitFor(() => { + expect(parent.getPendingPermissions()).toEqual([ + expect.objectContaining({ id: "perm_provider_child" }), + ]); + }); + await vi.waitFor(() => { + expect(events).toContainEqual( + expect.objectContaining({ + type: "permission_requested", + request: expect.objectContaining({ id: "perm_provider_child" }), + }), + ); + }); + expect( + events.filter( + (event) => + event.type === "permission_requested" && event.request.id === "perm_provider_child", + ), + ).toHaveLength(1); + + await parent.respondToPermission("perm_provider_child", { behavior: "allow" }); + expect(parentClient.calls.permissionReply).toContainEqual( + expect.objectContaining({ + requestID: "perm_provider_child", + directory: "/workspace/child", + reply: "once", + }), + ); + await parent.close(); + }); + + test("auto-approves provider child permissions in the child directory", async () => { + const runtime = new TestOpenCodeHarness(); + const parentClient = new TestOpenCodeClient(); + parentClient.sessionCreateResponse = { data: { id: "ses_parent_auto_permission" } }; + runtime.enqueueClient(parentClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const parent = await client.createSession({ + provider: "opencode", + cwd: "/workspace/repo", + featureValues: { auto_accept: true }, + }); + parent.subscribe(() => undefined); + + parentClient.emitEvent({ + type: "session.created", + properties: { + info: { + id: "ses_provider_child_auto_permission", + parentID: "ses_parent_auto_permission", + title: "Auto permission child", + directory: "/workspace/auto-child", + }, + }, + }); + await vi.waitFor(() => + expect(parentClient.calls.sessionChildren.length).toBeGreaterThanOrEqual(1), + ); + parentClient.emitEvent({ + type: "permission.asked", + properties: { + id: "perm_provider_child_auto", + sessionID: "ses_provider_child_auto_permission", + permission: "bash", + patterns: ["npm test"], + metadata: { command: "npm test" }, + }, + }); + + await vi.waitFor(() => + expect(parentClient.calls.permissionReply).toContainEqual( + expect.objectContaining({ + requestID: "perm_provider_child_auto", + directory: "/workspace/auto-child", + reply: "once", + }), + ), + ); + expect(parent.getPendingPermissions()).toEqual([]); + await parent.close(); + }); + + test("forwards provider child questions through the parent session", async () => { + const runtime = new TestOpenCodeHarness(); + const parentClient = new TestOpenCodeClient(); + parentClient.sessionCreateResponse = { data: { id: "ses_parent_question" } }; + runtime.enqueueClient(parentClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const parent = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" }); + const events: AgentStreamEvent[] = []; + parent.subscribe((event) => events.push(event)); + + parentClient.emitEvent({ + type: "session.created", + properties: { + info: { + id: "ses_provider_child_question", + parentID: "ses_parent_question", + title: "Question child", + directory: "/workspace/question-child", + }, + }, + }); + await vi.waitFor(() => + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ id: "ses_provider_child_question" }), + }), + ), + ); + + parentClient.emitEvent({ + type: "question.asked", + properties: { + id: "question_provider_child", + sessionID: "ses_provider_child_question", + questions: [ + { + question: "Which path?", + header: "Path", + options: [{ label: "A", description: "Choose A" }], + }, + ], + }, + }); + + await vi.waitFor(() => { + expect(parent.getPendingPermissions()).toEqual([ + expect.objectContaining({ + id: "question_provider_child", + kind: "question", + input: expect.objectContaining({ + questions: [expect.objectContaining({ question: "Which path?" })], + }), + }), + ]); + }); + expect( + events.filter( + (event) => + event.type === "permission_requested" && event.request.id === "question_provider_child", + ), + ).toHaveLength(1); + + await parent.respondToPermission("question_provider_child", { + behavior: "allow", + updatedInput: { answers: { Path: "A" } }, + }); + expect(parentClient.calls.questionReply).toContainEqual( + expect.objectContaining({ + requestID: "question_provider_child", + directory: "/workspace/question-child", + answers: [["A"]], + }), + ); + await parent.close(); + }); + + test("emits a provider subagent for a child created while the parent has no active turn", async () => { + const releaseChildEvent = createTestDeferred(); + const childConsumed = createTestDeferred(); + const fakeClient = { + global: { + event: vi.fn().mockResolvedValue({ + stream: (async function* () { + yield { type: "server.connected", properties: {} }; + await releaseChildEvent.promise; + yield { + type: "session.created", + properties: { + info: { + id: "ses_child_background", + parentID: "ses_parent", + title: "Plugin child", + }, + }, + }; + yield { + type: "message.updated", + properties: { + info: { + id: "msg_child_background", + sessionID: "ses_child_background", + role: "assistant", + }, + }, + }; + yield { + type: "message.part.updated", + properties: { + part: { + id: "part_child_background", + sessionID: "ses_child_background", + messageID: "msg_child_background", + type: "text", + text: "Background findings.", + time: { start: 1, end: 2 }, + }, + }, + }; + yield { + type: "session.idle", + properties: { sessionID: "ses_child_background" }, + }; + childConsumed.resolve(); + })(), + }), + }, + session: { + abort: vi.fn().mockResolvedValue({ error: null }), + update: vi.fn().mockResolvedValue({ error: null }), + }, + } as never; + const session = new __openCodeInternals.OpenCodeAgentSession( + { provider: "opencode", cwd: "/tmp/test" }, + fakeClient, + "ses_parent", + createTestLogger(), + ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + releaseChildEvent.resolve(); + await childConsumed.promise; + await session.close(); + + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: "ses_child_background", + title: "Plugin child", + status: "running", + }, + }); + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "timeline", + id: "ses_child_background", + item: { type: "assistant_message", text: "Background findings." }, + }, + }); + expect(events.at(-1)).toEqual({ + type: "provider_subagent", + provider: "opencode", + event: { type: "upsert", id: "ses_child_background", status: "completed" }, + }); + }); + + test("translates plugin child sessions without a waiting sub_agent tool call", () => { + const state = createOpenCodeTranslationState("ses_parent"); + + const events = translateOpenCodeEvent( + { + type: "session.updated", + properties: { + info: { + id: "ses_child_plugin", + parentID: "ses_parent", + title: "Background plugin child", + }, + }, + } as OpenCodeEvent, + state, + ); + + expect(events).toEqual([ + { + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: "ses_child_plugin", + title: "Background plugin child", + status: "running", + }, + }, + ]); + }); + + test("translates provider deletion of a known child session", () => { + const state = createOpenCodeTranslationState("ses_parent"); + state.subAgentCallIdByChildSessionId?.set("ses_child_deleted", "call_task"); + + const events = translateOpenCodeEvent( + { + type: "session.deleted", + properties: { sessionID: "ses_child_deleted" }, + } as OpenCodeEvent, + state, + ); + + expect(events).toEqual([ + { + type: "provider_subagent", + provider: "opencode", + event: { type: "remove", id: "ses_child_deleted" }, + }, + ]); + }); + + test("hydrates existing children breadth-first and emits each detection once after subscribe", async () => { + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.sessionCreateResponse = { data: { id: "ses_parent" } }; + openCodeClient.sessionChildrenResponses = [ + { + data: [ + { id: "ses_child_a", parentID: "ses_parent", title: "Child A" }, + { id: "ses_child_b", parentID: "ses_parent", title: "Child B" }, + ], + }, + { data: [{ id: "ses_grandchild_a", parentID: "ses_child_a", title: "Grandchild A" }] }, + { data: [] }, + { data: [] }, + ]; + runtime.enqueueClient(openCodeClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(4)); + await session.close(); + + expect(openCodeClient.calls.sessionChildren).toEqual([ + { path: { id: "ses_parent" } }, + { path: { id: "ses_child_a" } }, + { path: { id: "ses_child_b" } }, + { path: { id: "ses_grandchild_a" } }, + ]); + expect(events).toEqual([ + { + type: "provider_subagent", + provider: "opencode", + event: { type: "upsert", id: "ses_child_a", title: "Child A", status: "completed" }, + }, + { + type: "provider_subagent", + provider: "opencode", + event: { type: "upsert", id: "ses_child_b", title: "Child B", status: "completed" }, + }, + { + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: "ses_grandchild_a", + title: "Grandchild A", + status: "completed", + }, + }, + ]); + }); + + test("hydrates persisted child messages into the provider subagent timeline", async () => { + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_with_history" } }; + openCodeClient.sessionChildrenResponses = [ + { + data: [ + { + id: "ses_child_with_history", + parentID: "ses_parent_with_history", + title: "Historical child", + directory: "/workspace/child", + }, + ], + }, + { data: [] }, + ]; + openCodeClient.sessionMessagesResponse = { + data: [ + { + info: { + id: "msg_child_history", + sessionID: "ses_child_with_history", + role: "assistant", + time: { created: 2, completed: 2.1 }, + }, + parts: [ + { + id: "prt_child_history", + sessionID: "ses_child_with_history", + messageID: "msg_child_history", + type: "text", + text: "Persisted child result.", + time: { start: 2, end: 2.1 }, + }, + ], + }, + ], + }; + runtime.enqueueClient(openCodeClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(2)); + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: "ses_child_with_history", + title: "Historical child", + status: "completed", + cwd: "/workspace/child", + }, + }); + await vi.waitFor(() => + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "timeline", + id: "ses_child_with_history", + item: { + type: "assistant_message", + text: "Persisted child result.", + }, + timestamp: "1970-01-01T00:00:02.000Z", + }, + }), + ); + expect(openCodeClient.calls.sessionMessages).toEqual([ + { sessionID: "ses_child_with_history", directory: "/workspace/child" }, + ]); + await session.close(); + }); + + test("preserves child events that arrive while existing children hydrate", async () => { + const hydration = createTestDeferred<{ + data: Array<{ id: string; parentID: string; title: string }>; + }>(); + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_hydrating" } }; + let childListRequest = 0; + openCodeClient.sessionChildrenImplementation = async () => { + childListRequest += 1; + return childListRequest === 1 ? await hydration.promise : { data: [] }; + }; + runtime.enqueueClient(openCodeClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(1)); + + openCodeClient.emitEvent({ + type: "message.updated", + properties: { + info: { + id: "msg_child_hydrating", + sessionID: "ses_child_hydrating", + role: "assistant", + }, + }, + }); + openCodeClient.emitEvent({ + type: "message.part.updated", + properties: { + part: { + id: "prt_child_hydrating", + sessionID: "ses_child_hydrating", + messageID: "msg_child_hydrating", + type: "text", + text: "Hydration did not lose this.", + time: { start: 1, end: 2 }, + }, + }, + }); + hydration.resolve({ + data: [ + { + id: "ses_child_hydrating", + parentID: "ses_parent_hydrating", + title: "Hydrating child", + }, + ], + }); + + await vi.waitFor(() => + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + provider: "opencode", + event: expect.objectContaining({ + type: "timeline", + id: "ses_child_hydrating", + item: { + type: "assistant_message", + text: "Hydration did not lose this.", + }, + }), + }), + ), + ); + await session.close(); + }); + + test("does not duplicate persisted child output when the matching live event was buffered", async () => { + const hydration = createTestDeferred<{ + data: Array<{ id: string; parentID: string; title: string }>; + }>(); + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_dedupe" } }; + let childListRequest = 0; + openCodeClient.sessionChildrenImplementation = async () => { + childListRequest += 1; + return childListRequest === 1 ? await hydration.promise : { data: [] }; + }; + const message = { + id: "msg_child_dedupe", + sessionID: "ses_child_dedupe", + role: "assistant" as const, + time: { created: 1, completed: 2 }, + }; + const part = { + id: "prt_child_dedupe", + sessionID: "ses_child_dedupe", + messageID: "msg_child_dedupe", + type: "text" as const, + text: "Only once.", + time: { start: 1, end: 2 }, + }; + openCodeClient.sessionMessagesResponse = { data: [{ info: message, parts: [part] }] }; + runtime.enqueueClient(openCodeClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(1)); + + openCodeClient.emitEvent({ type: "message.updated", properties: { info: message } }); + openCodeClient.emitEvent({ type: "message.part.updated", properties: { part } }); + hydration.resolve({ + data: [ + { + id: "ses_child_dedupe", + parentID: "ses_parent_dedupe", + title: "Dedupe child", + }, + ], + }); + + await vi.waitFor(() => expect(providerAssistantMessages(events, "Only once.")).toHaveLength(1)); + await session.close(); + }); + + test("marks a hydrated child running when OpenCode reports it busy", async () => { + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_busy" } }; + openCodeClient.sessionChildrenResponses = [ + { data: [{ id: "ses_child_busy", parentID: "ses_parent_busy", title: "Busy child" }] }, + { data: [] }, + ]; + runtime.enqueueClient(openCodeClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(2)); + + openCodeClient.emitEvent({ + type: "session.status", + properties: { sessionID: "ses_child_busy", status: { type: "busy" } }, + }); + + await vi.waitFor(() => + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: { type: "upsert", id: "ses_child_busy", status: "running" }, + }), + ); + await session.close(); + }); + + test("preserves a live provider child user prompt", async () => { + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_child_prompt" } }; + runtime.enqueueClient(openCodeClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + openCodeClient.emitEvent({ + type: "session.created", + properties: { + info: { + id: "ses_child_prompt", + parentID: "ses_parent_child_prompt", + title: "Prompt child", + }, + }, + }); + openCodeClient.emitEvent({ + type: "message.updated", + properties: { + info: { id: "msg_child_prompt", sessionID: "ses_child_prompt", role: "user" }, + }, + }); + openCodeClient.emitEvent({ + type: "message.part.updated", + properties: { + part: { + id: "prt_child_prompt", + sessionID: "ses_child_prompt", + messageID: "msg_child_prompt", + type: "text", + text: "Inspect the auth flow.", + time: { start: 1, end: 2 }, + }, + }, + }); + + await vi.waitFor(() => + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "timeline", + id: "ses_child_prompt", + item: { + type: "user_message", + text: "Inspect the auth flow.", + messageId: "msg_child_prompt", + }, + timestamp: undefined, + }, + }), + ); + await session.close(); + }); + + test("does not rehydrate children for repeated events from an unrelated session", async () => { + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_isolated" } }; + runtime.enqueueClient(openCodeClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" }); + session.subscribe(() => undefined); + await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(1)); + await Promise.resolve(); + + for (const messageId of ["sibling-message-1", "sibling-message-2"]) { + openCodeClient.emitEvent({ + type: "message.updated", + properties: { + info: { + id: messageId, + sessionID: "ses_unrelated_sibling", + role: "assistant", + }, + }, + }); + } + await Promise.resolve(); + await Promise.resolve(); + + expect(openCodeClient.calls.sessionChildren).toHaveLength(1); + await session.close(); + }); + + test("closes without waiting for child hydration", async () => { + const hydration = createTestDeferred<{ data: [] }>(); + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_close" } }; + openCodeClient.sessionChildrenImplementation = async () => await hydration.promise; + runtime.enqueueClient(openCodeClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" }); + session.subscribe(() => undefined); + await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(1)); + + await session.close(); + + expect(runtime.acquisitions[0]?.releaseCount).toBe(1); + hydration.resolve({ data: [] }); + }); + + test("does not fold child tool parts into the parent sub_agent action log", () => { + const state = createOpenCodeTranslationState("ses_parent"); + const events: AgentStreamEvent[] = []; + + events.push( + ...translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: "prt_parent_task", + sessionID: "ses_parent", + messageID: "msg_parent", + type: "tool", + tool: "task", + callID: "call_task", + state: { + status: "running", + input: { subagent_type: "explore", description: "Inspect repo" }, + }, + }, + }, + } as OpenCodeEvent, + state, + ), + ...translateOpenCodeEvent( + { + type: "session.created", + properties: { info: { id: "ses_child", parentID: "ses_parent" } }, + } as OpenCodeEvent, + state, + ), + ...translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: "prt_child_tool", + sessionID: "ses_child", + messageID: "msg_child", + type: "tool", + tool: "bash", + callID: "call_bash", + state: { status: "completed", input: { command: "echo child" }, output: "child\n" }, + }, + }, + } as OpenCodeEvent, + state, + ), + ); + + const subAgentItems = events.flatMap((event) => { + if (event.type !== "timeline" || event.item.type !== "tool_call") { + return []; + } + return event.item.detail.type === "sub_agent" ? [event.item] : []; + }); + const latest = subAgentItems.at(-1); + if (!latest || latest.detail.type !== "sub_agent") { + throw new Error("expected parent sub_agent timeline item"); + } + + expect(latest.detail).toEqual({ + type: "sub_agent", + subAgentType: "explore", + description: "Inspect repo", + childSessionId: "ses_child", + log: "", + actions: [], + }); + }); +}); + +function createOpenCodeTranslationState(sessionId: string): OpenCodeEventTranslationState { + return { + sessionId, + cwd: "/workspace/repo", + messageRoles: new Map(), + accumulatedUsage: {}, + streamedPartKeys: new Set(), + emittedStructuredMessageIds: new Set(), + compactionSummaryMessageIds: new Set(), + emittedCompactionPartIds: new Set(), + partTypes: new Map(), + subAgentsByCallId: new Map(), + subAgentCallIdByChildSessionId: new Map(), + }; +} + function createTestDeferred(): { promise: Promise; resolve: (value: T) => void; @@ -2178,6 +3388,15 @@ function createTestDeferred(): { return { promise, resolve, reject }; } +function waitForAbort(signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.resolve(); + } + return new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(), { once: true }); + }); +} + function abortableOpenCodeStream(signal: AbortSignal): AsyncIterable { return { [Symbol.asyncIterator]: () => { diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 0cd170764c..35aaa6c53f 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -65,7 +65,6 @@ import { } from "../provider-launch-config.js"; import { withTimeout } from "../../../utils/promise-timeout.js"; import { execCommand } from "../../../utils/spawn.js"; -import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; import { OpenCodeServerManager, @@ -104,9 +103,35 @@ const OPENCODE_LEGACY_FULL_ACCESS_MODE_ID = "full-access"; const OPENCODE_AUTO_ACCEPT_FEATURE_ID = "auto_accept"; const OPENCODE_PERSISTED_SESSION_LIMIT = 200; const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10_000; +const OPENCODE_CHILD_SESSION_HYDRATION_LIMIT = 100; +const OPENCODE_CHILD_SESSION_SERVER_REGISTRY_LIMIT = 500; const OPENCODE_PERMISSION_ACTION_ALLOW_ONCE = "allow_once"; const OPENCODE_PERMISSION_ACTION_ALLOW_ALWAYS = "allow_always"; +// OpenCode child sessions run on the server process that spawned them. Adoption +// resumes must attach to that same helper server to receive live global events. +const openCodeChildSessionServerUrls = new Map(); + +function registerOpenCodeChildSessionServerUrl(sessionId: string, serverUrl: string): void { + openCodeChildSessionServerUrls.delete(sessionId); + openCodeChildSessionServerUrls.set(sessionId, serverUrl); + if (openCodeChildSessionServerUrls.size <= OPENCODE_CHILD_SESSION_SERVER_REGISTRY_LIMIT) { + return; + } + const oldestSessionId = openCodeChildSessionServerUrls.keys().next().value; + if (typeof oldestSessionId === "string") { + openCodeChildSessionServerUrls.delete(oldestSessionId); + } +} + +function unregisterOpenCodeChildSessionServerUrl(sessionId: string): void { + openCodeChildSessionServerUrls.delete(sessionId); +} + +function getOpenCodeChildSessionServerUrl(sessionId: string): string | undefined { + return openCodeChildSessionServerUrls.get(sessionId); +} + const DEFAULT_MODES: AgentMode[] = [ { id: OPENCODE_BUILD_MODE_ID, @@ -532,6 +557,19 @@ function resolvePartDedupeKey( return null; } +function matchesHydratedFingerprint( + fingerprints: Map | undefined, + id: string, + value: unknown, +): boolean { + const hydratedFingerprint = fingerprints?.get(id); + if (!hydratedFingerprint) { + return false; + } + fingerprints?.delete(id); + return hydratedFingerprint === JSON.stringify(value); +} + function normalizeOpenCodeModeId(modeId: string | null | undefined): string { const trimmed = typeof modeId === "string" ? modeId.trim() : ""; if (!trimmed || trimmed === "default") { @@ -989,12 +1027,16 @@ function buildOpenCodeReplayTimelineEvent(params: { function buildOpenCodeReplayPartTimelineEvent(params: { part: OpenCodePart; - message: { structured?: unknown; time?: { created?: number; completed?: number } | undefined }; + message: { + id: string; + structured?: unknown; + time?: { created?: number; completed?: number } | undefined; + }; }): Extract | null { const { part, message } = params; if (part.type === "text" && part.text) { return buildOpenCodeReplayTimelineEvent({ - item: { type: "assistant_message", text: part.text }, + item: { type: "assistant_message", text: part.text, messageId: message.id }, message, part, }); @@ -1183,7 +1225,7 @@ function buildOpenCodeReplayTimelineEvents( if (text) { events.push( buildOpenCodeReplayTimelineEvent({ - item: { type: "assistant_message", text }, + item: { type: "assistant_message", text, messageId: info.id }, message: info, }), ); @@ -1299,6 +1341,7 @@ export class OpenCodeAgentClient implements AgentClient { acquisition.release, options?.persistSession, launchContext?.agentId, + url, ); } catch (error) { acquisition.release(); @@ -1324,7 +1367,15 @@ export class OpenCodeAgentClient implements AgentClient { cwd, }; const openCodeConfig = this.assertConfig(config); - const acquisition = await this.serverManager.acquireCurrent(); + const registeredServerUrl = getOpenCodeChildSessionServerUrl(handle.sessionId); + const registeredAcquisition = registeredServerUrl + ? this.serverManager.acquireExisting(registeredServerUrl) + : null; + const acquisition = + registeredAcquisition ?? + (launchContext?.env + ? await this.serverManager.acquireDedicated(launchContext.env) + : await this.serverManager.acquireCurrent()); const { url } = acquisition.server; const client = this.createOpenCodeClient({ baseUrl: url, @@ -1343,6 +1394,8 @@ export class OpenCodeAgentClient implements AgentClient { acquisition.release, undefined, launchContext?.agentId, + url, + registeredAcquisition !== null, ); } catch (error) { acquisition.release(); @@ -1624,12 +1677,14 @@ export interface OpenCodeEventTranslationState { emittedStructuredMessageIds: Set; compactionSummaryMessageIds: Set; emittedCompactionPartIds: Set; + hydratedMessageFingerprints?: Map; + hydratedPartFingerprints?: Map; suppressAssistantMessagesUntilIdle?: { active: boolean }; /** Tracks the type of each part by ID, learned from message.part.updated events. */ partTypes: Map; subAgentsByCallId?: Map; subAgentCallIdByChildSessionId?: Map; - pendingChildToolPartsBySessionId?: Map; + knownChildSessionIds?: Set; modelContextWindowsByModelKey?: ReadonlyMap; onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void; } @@ -1661,24 +1716,19 @@ type OpenCodeToolPartEventPart = Extract< { type: "tool" } >; -interface OpenCodeSubAgentActionEntry { - index: number; - key: string; - toolName: string; - summary?: string; +interface OpenCodeChildSessionInfo { + id: string; + parentSessionId: string; + title?: string; + directory?: string; + revert?: OpenCodePersistedSession["revert"]; } interface OpenCodeSubAgentActivityState { toolCall: ToolCallTimelineItem; - actions: OpenCodeSubAgentActionEntry[]; - actionIndexByKey: Map; - nextActionIndex: number; childSessionId?: string; } -const MAX_OPENCODE_SUB_AGENT_ACTIONS = 200; -const MAX_OPENCODE_PENDING_CHILD_TOOL_PARTS = 200; - function stringifyStructuredAssistantMessage(value: unknown): string | null { if (value === undefined) { return null; @@ -1933,6 +1983,9 @@ export function translateOpenCodeEvent( case "session.updated": appendOpenCodeSessionCreatedOrUpdated(event, state, events); break; + case "session.deleted": + appendOpenCodeSessionDeleted(event, state, events); + break; case "message.updated": appendOpenCodeMessageUpdated(event, state, events); break; @@ -1996,27 +2049,64 @@ function resetOpenCodeTurnTrackingState(state: OpenCodeEventTranslationState): v function getOpenCodeSubAgentMaps(state: OpenCodeEventTranslationState): { byCallId: Map; callIdByChildSessionId: Map; - pendingChildToolPartsBySessionId: Map; } { state.subAgentsByCallId ??= new Map(); state.subAgentCallIdByChildSessionId ??= new Map(); - state.pendingChildToolPartsBySessionId ??= new Map(); return { byCallId: state.subAgentsByCallId, callIdByChildSessionId: state.subAgentCallIdByChildSessionId, - pendingChildToolPartsBySessionId: state.pendingChildToolPartsBySessionId, }; } +function getOpenCodeKnownChildSessionIds(state: OpenCodeEventTranslationState): Set { + state.knownChildSessionIds ??= new Set(); + return state.knownChildSessionIds; +} + function isOpenCodeSessionTrackedByParent( sessionId: string, state: OpenCodeEventTranslationState, ): boolean { return ( - sessionId === state.sessionId || state.subAgentCallIdByChildSessionId?.has(sessionId) === true + sessionId === state.sessionId || + state.knownChildSessionIds?.has(sessionId) === true || + state.subAgentCallIdByChildSessionId?.has(sessionId) === true ); } +function appendOpenCodeChildSessionDetected( + child: OpenCodeChildSessionInfo, + state: OpenCodeEventTranslationState, + events: AgentStreamEvent[], + status: "running" | "completed" = "running", +): boolean { + if ( + child.id === state.sessionId || + !isOpenCodeSessionTrackedByParent(child.parentSessionId, state) + ) { + return false; + } + + const knownChildSessionIds = getOpenCodeKnownChildSessionIds(state); + if (knownChildSessionIds.has(child.id)) { + return false; + } + + knownChildSessionIds.add(child.id); + events.push({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: child.id, + title: child.title ?? "OpenCode subagent", + status, + ...(child.directory ? { cwd: child.directory } : {}), + }, + }); + return true; +} + function getOpenCodeSubAgentState( callId: string, state: OpenCodeEventTranslationState, @@ -2031,9 +2121,6 @@ function getOpenCodeSubAgentState( const created: OpenCodeSubAgentActivityState = { toolCall, - actions: [], - actionIndexByKey: new Map(), - nextActionIndex: 1, }; maps.byCallId.set(callId, created); return created; @@ -2049,19 +2136,6 @@ function linkOpenCodeSubAgentChildSession( maps.callIdByChildSessionId.set(childSessionId, activity.toolCall.callId); } -function buildOpenCodeSubAgentLog( - detail: Extract, - activity: OpenCodeSubAgentActivityState, -): string { - const actionLog = activity.actions - .map((action) => - action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`, - ) - .join("\n"); - const parts = [actionLog, detail.log].filter((part) => part.trim().length > 0); - return parts.join("\n\n"); -} - function buildOpenCodeSubAgentTimelineItem( activity: OpenCodeSubAgentActivityState, ): ToolCallTimelineItem { @@ -2075,7 +2149,6 @@ function buildOpenCodeSubAgentTimelineItem( detail: { ...toolCall.detail, ...(childSessionId ? { childSessionId } : {}), - log: buildOpenCodeSubAgentLog(toolCall.detail, activity), }, }; } @@ -2094,42 +2167,6 @@ function registerOpenCodeSubAgentToolCall( return buildOpenCodeSubAgentTimelineItem(activity); } -function bufferOpenCodeSubAgentChildToolPart( - part: OpenCodeToolPartEventPart, - state: OpenCodeEventTranslationState, -): void { - const maps = getOpenCodeSubAgentMaps(state); - if (maps.byCallId.size === 0) { - return; - } - const totalPending = [...maps.pendingChildToolPartsBySessionId.values()].reduce( - (total, parts) => total + parts.length, - 0, - ); - if (totalPending >= MAX_OPENCODE_PENDING_CHILD_TOOL_PARTS) { - return; - } - const pending = maps.pendingChildToolPartsBySessionId.get(part.sessionID) ?? []; - pending.push(part); - maps.pendingChildToolPartsBySessionId.set(part.sessionID, pending); -} - -function flushOpenCodeSubAgentChildToolParts( - childSessionId: string, - state: OpenCodeEventTranslationState, - events: AgentStreamEvent[], -): void { - const maps = getOpenCodeSubAgentMaps(state); - const pending = maps.pendingChildToolPartsBySessionId.get(childSessionId); - if (!pending || pending.length === 0) { - return; - } - maps.pendingChildToolPartsBySessionId.delete(childSessionId); - for (const part of pending) { - appendOpenCodeSubAgentChildToolPart(part, state, events); - } -} - function findOnlyOpenCodeSubAgentWaitingForChild( state: OpenCodeEventTranslationState, ): OpenCodeSubAgentActivityState | null { @@ -2143,60 +2180,6 @@ function findOnlyOpenCodeSubAgentWaitingForChild( return candidates.length === 1 ? (candidates[0] ?? null) : null; } -function summarizeOpenCodeSubAgentAction( - item: ToolCallTimelineItem, - cwd: string | undefined, -): string | undefined { - const display = buildToolCallDisplayModel({ - name: item.name, - status: item.status, - error: item.error, - metadata: item.metadata, - detail: item.detail, - cwd, - }); - return display.summary ?? display.errorText; -} - -function appendOpenCodeSubAgentAction( - activity: OpenCodeSubAgentActivityState, - item: ToolCallTimelineItem, - cwd: string | undefined, -): boolean { - const key = item.callId || `${item.name}:${activity.actions.length}`; - const existingIndex = activity.actionIndexByKey.get(key); - const summary = summarizeOpenCodeSubAgentAction(item, cwd); - - if (existingIndex !== undefined) { - const action = activity.actions[existingIndex]; - if (!action) { - return false; - } - const changed = action.toolName !== item.name || action.summary !== summary; - action.toolName = item.name; - if (summary) { - action.summary = summary; - } else { - delete action.summary; - } - return changed; - } - - if (activity.actions.length >= MAX_OPENCODE_SUB_AGENT_ACTIONS) { - return false; - } - - activity.actionIndexByKey.set(key, activity.actions.length); - activity.actions.push({ - index: activity.nextActionIndex, - key, - toolName: item.name, - ...(summary ? { summary } : {}), - }); - activity.nextActionIndex += 1; - return true; -} - function appendOpenCodeToolCallTimelineItem( item: ToolCallTimelineItem, state: OpenCodeEventTranslationState, @@ -2208,9 +2191,6 @@ function appendOpenCodeToolCallTimelineItem( provider: "opencode", item: timelineItem, }); - if (timelineItem.detail.type === "sub_agent" && timelineItem.detail.childSessionId) { - flushOpenCodeSubAgentChildToolParts(timelineItem.detail.childSessionId, state, events); - } } function appendOpenCodeSubAgentChildSessionLinked( @@ -2228,36 +2208,6 @@ function appendOpenCodeSubAgentChildSessionLinked( provider: "opencode", item: buildOpenCodeSubAgentTimelineItem(activity), }); - flushOpenCodeSubAgentChildToolParts(childSessionId, state, events); -} - -function appendOpenCodeSubAgentChildToolPart( - part: OpenCodeToolPartEventPart, - state: OpenCodeEventTranslationState, - events: AgentStreamEvent[], -): void { - const maps = getOpenCodeSubAgentMaps(state); - const parentCallId = maps.callIdByChildSessionId.get(part.sessionID); - if (!parentCallId) { - bufferOpenCodeSubAgentChildToolPart(part, state); - return; - } - const activity = maps.byCallId.get(parentCallId); - if (!activity) { - return; - } - const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part); - if (!parsedToolPart.success || !parsedToolPart.data) { - return; - } - if (!appendOpenCodeSubAgentAction(activity, parsedToolPart.data, state.cwd)) { - return; - } - events.push({ - type: "timeline", - provider: "opencode", - item: buildOpenCodeSubAgentTimelineItem(activity), - }); } function appendOpenCodeSessionCreatedOrUpdated( @@ -2281,11 +2231,45 @@ function appendOpenCodeSessionCreatedOrUpdated( } const parentSessionId = readNonEmptyString(info?.parentID) ?? readNonEmptyString(info?.parentId); + if (parentSessionId) { + appendOpenCodeChildSessionDetected( + { + id: event.properties.info.id, + parentSessionId, + ...(readNonEmptyString(info?.title) + ? { title: readNonEmptyString(info?.title) ?? undefined } + : {}), + ...(readNonEmptyString(info?.directory) + ? { directory: readNonEmptyString(info?.directory) ?? undefined } + : {}), + }, + state, + events, + ); + } if (parentSessionId === state.sessionId) { appendOpenCodeSubAgentChildSessionLinked(event.properties.info.id, state, events); } } +function appendOpenCodeSessionDeleted( + event: Extract, + state: OpenCodeEventTranslationState, + events: AgentStreamEvent[], +): void { + const sessionId = event.properties.sessionID; + if (!isOpenCodeSessionTrackedByParent(sessionId, state)) { + return; + } + state.knownChildSessionIds?.delete(sessionId); + state.subAgentCallIdByChildSessionId?.delete(sessionId); + events.push({ + type: "provider_subagent", + provider: "opencode", + event: { type: "remove", id: sessionId }, + }); +} + function appendOpenCodeMessageUpdated( event: Extract, state: OpenCodeEventTranslationState, @@ -2296,6 +2280,9 @@ function appendOpenCodeMessageUpdated( return; } state.messageRoles.set(info.id, info.role); + if (matchesHydratedFingerprint(state.hydratedMessageFingerprints, info.id, info)) { + return; + } if (info.role === "user") { appendOpenCodeUserMessageUpdated(info, state, events); return; @@ -2329,7 +2316,7 @@ function appendOpenCodeMessageUpdated( events.push({ type: "timeline", provider: "opencode", - item: { type: "assistant_message", text }, + item: { type: "assistant_message", text, messageId: info.id }, }); } @@ -2360,9 +2347,9 @@ function appendOpenCodeMessagePartUpdated( return; } if (part.sessionID !== state.sessionId) { - if (part.type === "tool") { - appendOpenCodeSubAgentChildToolPart(part, state, events); - } + return; + } + if (matchesHydratedFingerprint(state.hydratedPartFingerprints, part.id, part)) { return; } const messageRole = state.messageRoles.get(part.messageID); @@ -2444,6 +2431,15 @@ function appendOpenCodeTextPart( events: AgentStreamEvent[], ): void { if (messageRole === "user") { + if (!part.time?.end || !part.text || state.emittedUserMessageIds?.has(part.messageID)) { + return; + } + state.emittedUserMessageIds?.add(part.messageID); + events.push({ + type: "timeline", + provider: "opencode", + item: { type: "user_message", text: part.text, messageId: part.messageID }, + }); return; } if (!part.time?.end) { @@ -2457,7 +2453,7 @@ function appendOpenCodeTextPart( events.push({ type: "timeline", provider: "opencode", - item: { type: "assistant_message", text: part.text }, + item: { type: "assistant_message", text: part.text, messageId: part.messageID }, }); } } @@ -2523,8 +2519,12 @@ function appendOpenCodeMessagePartDelta( if (messageRole === "user") { return; } - if (messageID && state.suppressAssistantMessagesUntilIdle?.active === true) { - state.compactionSummaryMessageIds.add(messageID); + const assistantMessageId = messageID || partID; + if (!assistantMessageId) { + return; + } + if (state.suppressAssistantMessagesUntilIdle?.active === true) { + state.compactionSummaryMessageIds.add(assistantMessageId); return; } if (partID) { @@ -2533,7 +2533,11 @@ function appendOpenCodeMessagePartDelta( events.push({ type: "timeline", provider: "opencode", - item: { type: "assistant_message", text: delta }, + item: { + type: "assistant_message", + text: delta, + messageId: assistantMessageId, + }, }); } @@ -2727,6 +2731,23 @@ function unwrapOpenCodeGlobalEvent(event: unknown): OpenCodeEvent | null { return null; } +function getOpenCodeEventSessionId(event: OpenCodeEvent): string | null { + const properties = readOpenCodeRecord(event.properties); + const info = readOpenCodeRecord(properties?.info); + const part = readOpenCodeRecord(properties?.part); + return ( + readNonEmptyString(properties?.sessionID) ?? + readNonEmptyString(properties?.sessionId) ?? + readNonEmptyString(info?.sessionID) ?? + readNonEmptyString(info?.sessionId) ?? + readNonEmptyString(part?.sessionID) ?? + readNonEmptyString(part?.sessionId) ?? + (event.type === "session.created" || event.type === "session.updated" + ? readNonEmptyString(info?.id) + : null) + ); +} + function isOpenCodeUserMessageEvent(event: OpenCodeEvent, sessionId: string): boolean { return ( event.type === "message.updated" && @@ -2746,6 +2767,76 @@ function isOpenCodeTerminalEvent(event: OpenCodeEvent, sessionId: string): boole ); } +function isOpenCodeProviderInternalEvent(event: AgentStreamEvent): boolean { + return event.type === "provider_subagent"; +} + +function readOpenCodeChildSessionInfo(value: unknown): OpenCodeChildSessionInfo | null { + const record = readOpenCodeRecord(value); + if (!record) { + return null; + } + const id = readNonEmptyString(record.id); + const parentSessionId = + readNonEmptyString(record.parentID) ?? readNonEmptyString(record.parentId); + if (!id || !parentSessionId) { + return null; + } + const title = readNonEmptyString(record.title); + const directory = readNonEmptyString(record.directory); + const revert = readOpenCodeRecord(record.revert) as OpenCodePersistedSession["revert"] | null; + return { + id, + parentSessionId, + ...(title ? { title } : {}), + ...(directory ? { directory } : {}), + ...(revert ? { revert } : {}), + }; +} + +function readOpenCodeChildSessionInfosFromResponse( + response: unknown, +): OpenCodeChildSessionInfo[] | null { + const record = readOpenCodeRecord(response); + if (!record || record.error) { + return null; + } + const data = record.data; + if (!Array.isArray(data)) { + return null; + } + return data.flatMap((item) => { + const child = readOpenCodeChildSessionInfo(item); + return child ? [child] : []; + }); +} + +async function listOpenCodeChildSessions( + client: OpencodeClient, + sessionId: string, + directory: string, +): Promise { + try { + const pathResponse: unknown = await Reflect.apply(client.session.children, client.session, [ + { path: { id: sessionId } }, + ]); + const pathChildren = readOpenCodeChildSessionInfosFromResponse(pathResponse); + if (pathChildren) { + return pathChildren; + } + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + } + + const sessionIdResponse = await client.session.children({ + sessionID: sessionId, + directory, + }); + return readOpenCodeChildSessionInfosFromResponse(sessionIdResponse) ?? []; +} + class OpenCodeAgentSession implements AgentSession { readonly provider = "opencode" as const; readonly capabilities = OPENCODE_CAPABILITIES; @@ -2781,14 +2872,22 @@ class OpenCodeAgentSession implements AgentSession { private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private nextTurnOrdinal = 0; private activeForegroundTurnId: string | null = null; + private activeForegroundTurnSource: "paseo" | "external" | null = null; private readonly runningToolCalls = new Map(); private subAgentsByCallId = new Map(); private subAgentCallIdByChildSessionId = new Map(); - private pendingChildToolPartsBySessionId = new Map(); + private knownChildSessionIds = new Set(); + private readonly childTranslationStates = new Map(); + private readonly childSessionCwds = new Map(); + private readonly pendingPermissionDirectories = new Map(); + private childHydrationPromise: Promise | null = null; + private childHydrationCompleted = false; + private readonly unrelatedSessionIds = new Set(); private selectedModelContextWindowMaxTokens: number | undefined; private releaseServer: (() => void) | null; private eventStreamAbortController: AbortController | null = null; private eventStreamReady: Deferred | null = null; + private eventStreamTask: Promise | null = null; private suppressTerminalUntilNextUserMessage = false; private closed = false; private readonly persistSession: boolean; @@ -2802,6 +2901,8 @@ class OpenCodeAgentSession implements AgentSession { releaseServer?: () => void, persistSession = true, private readonly agentId?: string, + private readonly serverUrl?: string, + private readonly externallyDriven = false, ) { this.config = config; this.client = client; @@ -2863,7 +2964,7 @@ class OpenCodeAgentSession implements AgentSession { } async interrupt(): Promise { - const turnId = this.activeForegroundTurnId; + let turnId = this.activeForegroundTurnId; const turnAbortController = this.abortController; turnAbortController?.abort(); // COMPAT(opencodeSlowAbort): OpenCode 1.14.42+ blocks session.abort until @@ -2942,14 +3043,21 @@ class OpenCodeAgentSession implements AgentSession { options?: AgentRunOptions, ): Promise<{ turnId: string }> { if (this.activeForegroundTurnId) { - throw new Error("A foreground turn is already active"); + if (this.activeForegroundTurnSource === "external") { + // A direct Paseo prompt owns the foreground; close the adopted child run first. + this.finishForegroundTurn( + { type: "turn_completed", provider: "opencode", usage: undefined }, + this.activeForegroundTurnId, + ); + } else { + throw new Error("A foreground turn is already active"); + } } await this.awaitPendingAbortBeforeStartingTurn(); this.runningToolCalls.clear(); this.subAgentsByCallId.clear(); this.subAgentCallIdByChildSessionId.clear(); - this.pendingChildToolPartsBySessionId.clear(); const turnAbortController = new AbortController(); this.abortController = turnAbortController; await this.ensureMcpServersConfigured(); @@ -2975,6 +3083,7 @@ class OpenCodeAgentSession implements AgentSession { const turnId = this.createTurnId(); this.activeForegroundTurnId = turnId; + this.activeForegroundTurnSource = "paseo"; this.notifySubscribers({ type: "turn_started", provider: "opencode" }, turnId); const slashCommand = await this.resolveSlashCommandInvocation(prompt); @@ -3140,11 +3249,135 @@ class OpenCodeAgentSession implements AgentSession { } subscribe(callback: (event: AgentStreamEvent) => void): () => void { this.subscribers.add(callback); + this.startChildSessionHydration(); return () => { this.subscribers.delete(callback); }; } + private startChildSessionHydration(): void { + if (this.childHydrationPromise) { + return; + } + const hydration = this.hydrateChildSessions() + .then(() => { + this.childHydrationCompleted = true; + return undefined; + }) + .finally(() => { + if (this.childHydrationPromise === hydration) { + this.childHydrationPromise = null; + } + }); + this.childHydrationPromise = hydration; + void hydration.catch((error) => { + this.logger.warn( + { err: error, sessionId: this.sessionId }, + "OpenCode child hydration failed", + ); + }); + } + + private async hydrateChildSessions(): Promise { + const queue = [this.sessionId]; + const visited = new Set(); + while (queue.length > 0 && visited.size < OPENCODE_CHILD_SESSION_HYDRATION_LIMIT) { + const parentSessionId = queue.shift(); + if (!parentSessionId || visited.has(parentSessionId)) { + continue; + } + visited.add(parentSessionId); + const children = await listOpenCodeChildSessions( + this.client, + parentSessionId, + this.config.cwd, + ); + if (this.closed) return; + for (const child of children) { + const detectionEvents: AgentStreamEvent[] = []; + appendOpenCodeChildSessionDetected( + child, + this.createTranslationState(), + detectionEvents, + "completed", + ); + for (const event of detectionEvents) { + this.recordProviderInternalEvent(event); + this.notifySubscribers(event, null); + } + try { + await this.hydrateChildSessionTimeline(child); + } catch (error) { + this.logger.warn( + { err: error, sessionId: child.id }, + "OpenCode child timeline hydration failed", + ); + } + if (visited.size + queue.length < OPENCODE_CHILD_SESSION_HYDRATION_LIMIT) { + queue.push(child.id); + } + } + } + } + + private async hydrateChildSessionTimeline(child: OpenCodeChildSessionInfo): Promise { + const messages = await readOpenCodeSessionMessagesFromSdk(this.client, { + id: child.id, + directory: child.directory ?? this.config.cwd, + ...(child.revert ? { revert: child.revert } : {}), + } as OpenCodePersistedSession); + const translationState = this.getChildTranslationState(child.id); + let latestReplayedMessage: OpenCodeSessionMessage | null = null; + for (const message of messages) { + if (message.info.role === "assistant" && message.info.time?.completed === undefined) { + continue; + } + latestReplayedMessage = message; + for (const timelineEvent of buildOpenCodeReplayTimelineEvents(message)) { + const event: AgentStreamEvent = { + type: "provider_subagent", + provider: "opencode", + event: { + type: "timeline", + id: child.id, + item: timelineEvent.item, + ...(timelineEvent.timestamp ? { timestamp: timelineEvent.timestamp } : {}), + }, + }; + this.recordProviderInternalEvent(event); + this.notifySubscribers(event, null); + } + } + if (latestReplayedMessage) { + translationState.hydratedMessageFingerprints?.set( + latestReplayedMessage.info.id, + JSON.stringify(latestReplayedMessage.info), + ); + for (const part of latestReplayedMessage.parts) { + translationState.hydratedPartFingerprints?.set(part.id, JSON.stringify(part)); + } + } + } + + private recordProviderInternalEvent(event: AgentStreamEvent): void { + if (event.type !== "provider_subagent") { + return; + } + if (event.event.type === "upsert") { + this.unrelatedSessionIds.delete(event.event.id); + if (event.event.cwd) { + this.childSessionCwds.set(event.event.id, event.event.cwd); + } + if (this.serverUrl) { + registerOpenCodeChildSessionServerUrl(event.event.id, this.serverUrl); + } + } else if (event.event.type === "remove") { + unregisterOpenCodeChildSessionServerUrl(event.event.id); + this.childTranslationStates.delete(event.event.id); + this.childSessionCwds.delete(event.event.id); + } + } + private startEventStream(): void { void this.ensureEventStreamReady().catch((error) => { this.logger.warn({ err: error, sessionId: this.sessionId }, "OpenCode event stream failed"); @@ -3160,11 +3393,24 @@ class OpenCodeAgentSession implements AgentSession { const eventStreamReady = createDeferred(); this.eventStreamAbortController = eventStreamAbortController; this.eventStreamReady = eventStreamReady; - void this.consumeEventStream(eventStreamAbortController, eventStreamReady).finally(() => { + const eventStreamTask = this.consumeEventStream( + eventStreamAbortController, + eventStreamReady, + ).finally(() => { if (this.eventStreamAbortController === eventStreamAbortController) { this.eventStreamAbortController = null; this.eventStreamReady = null; } + if (this.eventStreamTask === eventStreamTask) { + this.eventStreamTask = null; + } + }); + this.eventStreamTask = eventStreamTask; + void eventStreamTask.catch((error) => { + this.logger.warn( + { err: error, sessionId: this.sessionId }, + "OpenCode event stream task failed", + ); }); return eventStreamReady.promise; @@ -3250,7 +3496,7 @@ class OpenCodeAgentSession implements AgentSession { eventCount: number; }): Promise { const { rawEvent, eventCount } = params; - const turnId = this.activeForegroundTurnId; + let turnId = this.activeForegroundTurnId; const event = unwrapOpenCodeGlobalEvent(rawEvent); this.traceOpenCode("provider.opencode.raw_event", { turnId: turnId ?? undefined, @@ -3264,7 +3510,20 @@ class OpenCodeAgentSession implements AgentSession { if (!event) { return; } + const translated = await this.translateEvent(event); + const foregroundEvents: AgentStreamEvent[] = []; + for (const translatedEvent of translated) { + if (isOpenCodeProviderInternalEvent(translatedEvent)) { + this.notifySubscribers(translatedEvent, null); + } else { + foregroundEvents.push(translatedEvent); + } + } + if (!turnId && this.shouldStartExternalDrivenTurn(event, foregroundEvents)) { + turnId = this.startExternalDrivenTurn(); + } if (!turnId) { + this.emitBackgroundPermissionRequests(foregroundEvents); this.traceOpenCode("provider.opencode.event.skip", { n: eventCount, reason: "no_active_turn", @@ -3284,16 +3543,15 @@ class OpenCodeAgentSession implements AgentSession { return; } } - const translated = await this.translateEvent(event); this.traceOpenCode("provider.opencode.parsed_event", { turnId, n: eventCount, - count: translated.length, - types: translated.map((t) => t.type), - events: translated, + count: foregroundEvents.length, + types: foregroundEvents.map((t) => t.type), + events: foregroundEvents, }); - for (const e of translated) { + for (const e of foregroundEvents) { if (this.activeForegroundTurnId !== turnId) { this.traceOpenCode("provider.opencode.parsed_event.skip_active", { turnId, type: e.type }); return; @@ -3314,6 +3572,47 @@ class OpenCodeAgentSession implements AgentSession { } } + private emitBackgroundPermissionRequests(events: readonly AgentStreamEvent[]): void { + for (const event of events) { + if (event.type === "permission_requested") { + this.notifySubscribers(event, null); + } + } + } + + private shouldStartExternalDrivenTurn( + event: OpenCodeEvent, + foregroundEvents: readonly AgentStreamEvent[], + ): boolean { + if (!this.externallyDriven) { + return false; + } + if (this.activeForegroundTurnId) { + return false; + } + if (foregroundEvents.some((foregroundEvent) => !toTerminalTurnEvent(foregroundEvent))) { + return true; + } + return ( + event.type === "session.status" && + event.properties.sessionID === this.sessionId && + event.properties.status.type === "busy" + ); + } + + private startExternalDrivenTurn(): string { + const turnId = this.createTurnId(); + this.activeForegroundTurnId = turnId; + this.activeForegroundTurnSource = "external"; + this.runningToolCalls.clear(); + this.subAgentsByCallId.clear(); + this.subAgentCallIdByChildSessionId.clear(); + this.pendingUserMessageText = null; + this.abortController = null; + this.notifySubscribers({ type: "turn_started", provider: "opencode" }, turnId); + return turnId; + } + private finishForegroundTurn( event: Extract, turnId: string, @@ -3335,6 +3634,7 @@ class OpenCodeAgentSession implements AgentSession { } this.pendingUserMessageText = null; this.activeForegroundTurnId = null; + this.activeForegroundTurnSource = null; this.abortController = null; this.notifySubscribers(event, turnId); } @@ -3375,11 +3675,11 @@ class OpenCodeAgentSession implements AgentSession { this.runningToolCalls.clear(); } - private notifySubscribers(event: AgentStreamEvent, turnIdOverride?: string): void { + private notifySubscribers(event: AgentStreamEvent, turnIdOverride?: string | null): void { if (this.closed) { return; } - const turnId = turnIdOverride ?? this.activeForegroundTurnId; + const turnId = turnIdOverride === null ? null : (turnIdOverride ?? this.activeForegroundTurnId); const tagged = turnId ? { ...event, turnId } : event; this.traceOpenCode("provider.opencode.event_emit", { turnId: getAgentStreamEventTurnId(tagged), @@ -3497,11 +3797,12 @@ class OpenCodeAgentSession implements AgentSession { throw new Error(`No pending permission request with id '${requestId}'`); } + const directory = this.pendingPermissionDirectories.get(requestId) ?? this.config.cwd; if (pending.kind === "question") { if (response.behavior === "deny") { await this.client.question.reject({ requestID: requestId, - directory: this.config.cwd, + directory, }); } else { const answersRecord = readOpenCodeRecord(response.updatedInput?.answers); @@ -3520,24 +3821,26 @@ class OpenCodeAgentSession implements AgentSession { await this.client.question.reply({ requestID: requestId, - directory: this.config.cwd, + directory, answers, }); } this.pendingPermissions.delete(requestId); + this.pendingPermissionDirectories.delete(requestId); return; } const reply = resolveOpenCodePermissionReply(response); await this.client.permission.reply({ requestID: requestId, - directory: this.config.cwd, + directory, reply, message: response.behavior === "deny" ? response.message : undefined, }); this.pendingPermissions.delete(requestId); + this.pendingPermissionDirectories.delete(requestId); } describePersistence(): AgentPersistenceHandle | null { @@ -3561,9 +3864,19 @@ class OpenCodeAgentSession implements AgentSession { // unhandled rejection in whichever test the daemon hops to next. this.closed = true; this.abortController?.abort(); + const eventStreamTask = this.eventStreamTask; this.eventStreamAbortController?.abort(); + if (eventStreamTask) { + await eventStreamTask.catch((error) => { + this.logger.debug( + { err: error, sessionId: this.sessionId }, + "OpenCode event stream failed during close", + ); + }); + } this.eventStreamAbortController = null; this.eventStreamReady = null; + this.eventStreamTask = null; this.subscribers.clear(); await reconcileOpenCodeSessionClose({ client: this.client, @@ -3712,8 +4025,8 @@ class OpenCodeAgentSession implements AgentSession { ); } - private async translateEvent(event: OpenCodeEvent): Promise { - const translated = translateOpenCodeEvent(event, { + private createTranslationState(): OpenCodeEventTranslationState { + return { sessionId: this.sessionId, cwd: this.config.cwd, messageRoles: this.messageRoles, @@ -3729,7 +4042,7 @@ class OpenCodeAgentSession implements AgentSession { partTypes: this.partTypes, subAgentsByCallId: this.subAgentsByCallId, subAgentCallIdByChildSessionId: this.subAgentCallIdByChildSessionId, - pendingChildToolPartsBySessionId: this.pendingChildToolPartsBySessionId, + knownChildSessionIds: this.knownChildSessionIds, modelContextWindowsByModelKey: this.modelContextWindowsByModelKey, onAssistantModelContextWindowResolved: (contextWindowMaxTokens) => { this.accumulatedUsage.contextWindowMaxTokens = contextWindowMaxTokens; @@ -3737,7 +4050,127 @@ class OpenCodeAgentSession implements AgentSession { this.selectedModelContextWindowMaxTokens = contextWindowMaxTokens; } }, - }); + }; + } + + private getChildTranslationState(sessionId: string): OpenCodeEventTranslationState { + const existing = this.childTranslationStates.get(sessionId); + if (existing) { + return existing; + } + const state: OpenCodeEventTranslationState = { + sessionId, + cwd: this.config.cwd, + messageRoles: new Map(), + emittedUserMessageIds: new Set(), + accumulatedUsage: {}, + streamedPartKeys: new Set(), + emittedStructuredMessageIds: new Set(), + compactionSummaryMessageIds: new Set(), + emittedCompactionPartIds: new Set(), + hydratedMessageFingerprints: new Map(), + hydratedPartFingerprints: new Map(), + suppressAssistantMessagesUntilIdle: { active: false }, + partTypes: new Map(), + subAgentsByCallId: new Map(), + subAgentCallIdByChildSessionId: new Map(), + knownChildSessionIds: new Set(), + modelContextWindowsByModelKey: this.modelContextWindowsByModelKey, + }; + this.childTranslationStates.set(sessionId, state); + return state; + } + + private appendProviderSubagentEvents(event: OpenCodeEvent, translated: AgentStreamEvent[]): void { + const childSessionId = getOpenCodeEventSessionId(event); + const isKnownChild = childSessionId && this.knownChildSessionIds.has(childSessionId); + if (!childSessionId || childSessionId === this.sessionId || !isKnownChild) { + return; + } + translated.push(...this.translateProviderSubagentEvent(childSessionId, event)); + } + + private translateProviderSubagentEvent( + sessionId: string, + event: OpenCodeEvent, + ): AgentStreamEvent[] { + const translated = translateOpenCodeEvent(event, this.getChildTranslationState(sessionId)); + const events: AgentStreamEvent[] = []; + let markedRunning = false; + const markRunning = () => { + if (markedRunning) return; + markedRunning = true; + events.push({ + type: "provider_subagent", + provider: "opencode", + event: { type: "upsert", id: sessionId, status: "running" }, + }); + }; + if (event.type === "session.status" && event.properties.status.type === "busy") { + markRunning(); + } + for (const childEvent of translated) { + if (childEvent.type === "timeline") { + markRunning(); + events.push({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "timeline", + id: sessionId, + item: childEvent.item, + timestamp: childEvent.timestamp, + }, + }); + } else if (childEvent.type === "turn_started") { + markRunning(); + } else if (childEvent.type === "turn_completed") { + events.push({ + type: "provider_subagent", + provider: "opencode", + event: { type: "upsert", id: sessionId, status: "completed" }, + }); + } else if (childEvent.type === "turn_failed") { + events.push({ + type: "provider_subagent", + provider: "opencode", + event: { type: "upsert", id: sessionId, status: "failed" }, + }); + } else if (childEvent.type === "turn_canceled") { + events.push({ + type: "provider_subagent", + provider: "opencode", + event: { type: "upsert", id: sessionId, status: "canceled" }, + }); + } else if ( + childEvent.type === "permission_requested" && + childEvent.request.kind === "question" + ) { + events.push(childEvent); + } + } + return events; + } + + private async translateEvent(event: OpenCodeEvent): Promise { + const eventSessionId = getOpenCodeEventSessionId(event); + if ( + event.type !== "session.created" && + eventSessionId && + eventSessionId !== this.sessionId && + !this.knownChildSessionIds.has(eventSessionId) && + !this.unrelatedSessionIds.has(eventSessionId) + ) { + if (!this.childHydrationCompleted) { + this.startChildSessionHydration(); + await this.childHydrationPromise?.catch(() => undefined); + } + if (!this.knownChildSessionIds.has(eventSessionId)) { + this.unrelatedSessionIds.add(eventSessionId); + } + } + const translated = translateOpenCodeEvent(event, this.createTranslationState()); + this.appendProviderSubagentEvents(event, translated); const events: AgentStreamEvent[] = []; if (typeof this.accumulatedUsage.totalCostUsd === "number") { @@ -3748,12 +4181,20 @@ class OpenCodeAgentSession implements AgentSession { } for (const translatedEvent of translated) { + this.recordProviderInternalEvent(translatedEvent); if (translatedEvent.type === "permission_requested") { - const autoApproved = await this.tryAutoApproveToolPermission(translatedEvent.request); + const directory = + (eventSessionId ? this.childSessionCwds.get(eventSessionId) : undefined) ?? + this.config.cwd; + const autoApproved = await this.tryAutoApproveToolPermission( + translatedEvent.request, + directory, + ); if (autoApproved) { continue; } this.pendingPermissions.set(translatedEvent.request.id, translatedEvent.request); + this.pendingPermissionDirectories.set(translatedEvent.request.id, directory); } if (translatedEvent.type === "turn_completed") { if (hasNormalizedOpenCodeUsage(this.accumulatedUsage)) { @@ -3769,7 +4210,10 @@ class OpenCodeAgentSession implements AgentSession { return events; } - private async tryAutoApproveToolPermission(request: AgentPermissionRequest): Promise { + private async tryAutoApproveToolPermission( + request: AgentPermissionRequest, + directory: string, + ): Promise { if (!this.autoAcceptEnabled || request.kind !== "tool") { return false; } @@ -3777,7 +4221,7 @@ class OpenCodeAgentSession implements AgentSession { try { await this.client.permission.reply({ requestID: request.id, - directory: this.config.cwd, + directory, reply: "once", }); return true; diff --git a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts index e4ad07d14d..60c93c6ef9 100644 --- a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts +++ b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts @@ -164,6 +164,34 @@ describe("OpenCodeServerManager generations", () => { expect(runtime.terminatedPorts).toEqual([4474]); }); + test("acquireExisting keeps a retired dedicated server alive until every reference releases", async () => { + const { manager, runtime } = createTestManager([4475]); + + const dedicatedAcquisition = await manager.acquireDedicated({ PASEO_AGENT_ID: "parent" }); + const existingAcquisition = manager.acquireExisting(dedicatedAcquisition.server.url); + + expect(existingAcquisition?.server.url).toBe("http://127.0.0.1:4475"); + + dedicatedAcquisition.release(); + expect(runtime.terminatedPorts).toEqual([]); + + existingAcquisition?.release(); + expect(runtime.terminatedPorts).toEqual([4475]); + }); + + test("acquireExisting returns null for unknown or dead server urls", async () => { + const { manager, runtime } = createTestManager([4476]); + + const acquisition = await manager.acquireDedicated({ PASEO_AGENT_ID: "parent" }); + const url = acquisition.server.url; + + expect(manager.acquireExisting("http://127.0.0.1:9999")).toBe(null); + + acquisition.release(); + expect(runtime.terminatedPorts).toEqual([4476]); + expect(manager.acquireExisting(url)).toBe(null); + }); + test("repeated rotations leave zero unreferenced retired servers", async () => { const { manager, runtime } = createTestManager([4501, 4502, 4503]); diff --git a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts index 6c42962f37..e8f7f5d94a 100644 --- a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts @@ -124,7 +124,11 @@ describe("translateOpenCodeEvent", () => { { type: "timeline", provider: "opencode", - item: { type: "assistant_message", text: "hey! what can I help with?" }, + item: { + type: "assistant_message", + text: "hey! what can I help with?", + messageId: "message-1", + }, }, ]); }); @@ -167,7 +171,7 @@ describe("translateOpenCodeEvent", () => { { type: "timeline", provider: "opencode", - item: { type: "assistant_message", text: "final text" }, + item: { type: "assistant_message", text: "final text", messageId: "message-2" }, }, ]); }); @@ -269,16 +273,66 @@ describe("translateOpenCodeEvent", () => { { type: "timeline", provider: "opencode", - item: { type: "assistant_message", text: "hey! " }, + item: { type: "assistant_message", text: "hey! ", messageId: "msg-d1" }, }, { type: "timeline", provider: "opencode", - item: { type: "assistant_message", text: "what's up?" }, + item: { type: "assistant_message", text: "what's up?", messageId: "msg-d1" }, }, ]); }); + it("uses the part id when an assistant delta omits its message id", () => { + const state = createState(); + + const events = translateOpenCodeEvent( + { + type: "message.part.delta", + properties: { + sessionID: "session-1", + partID: "part-without-message", + field: "text", + delta: "still visible", + }, + }, + state, + ); + + expect(events).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { + type: "assistant_message", + text: "still visible", + messageId: "part-without-message", + }, + }, + ]); + }); + + it("suppresses part-id-only assistant deltas by their resolved identity", () => { + const state = createState(); + state.suppressAssistantMessagesUntilIdle = { active: true }; + + const events = translateOpenCodeEvent( + { + type: "message.part.delta", + properties: { + sessionID: "session-1", + partID: "compaction-part", + field: "text", + delta: "hidden summary", + }, + }, + state, + ); + + expect(events).toEqual([]); + expect(state.compactionSummaryMessageIds).toEqual(new Set(["compaction-part"])); + }); + it("humanizes permission requests and includes shell detail when command metadata exists", () => { const state = createState(); @@ -1073,7 +1127,7 @@ describe("translateOpenCodeEvent", () => { { type: "timeline", provider: "opencode", - item: { type: "assistant_message", text: "hello there" }, + item: { type: "assistant_message", text: "hello there", messageId: "msg-dd1" }, }, ]); }); @@ -1274,7 +1328,11 @@ describe("translateOpenCodeEvent", () => { { type: "timeline", provider: "opencode", - item: { type: "assistant_message", text: '{"summary":"hello"}' }, + item: { + type: "assistant_message", + text: '{"summary":"hello"}', + messageId: "message-structured-1", + }, }, ]); expect(second).toEqual([]); diff --git a/packages/server/src/server/agent/providers/opencode/server-manager.ts b/packages/server/src/server/agent/providers/opencode/server-manager.ts index 566151f73d..3fbb89ad2e 100644 --- a/packages/server/src/server/agent/providers/opencode/server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/server-manager.ts @@ -29,6 +29,7 @@ export interface OpenCodeServerManagerLike { acquireCurrent(): Promise; acquireNew(): Promise; acquireDedicated(env: Record): Promise; + acquireExisting(url: string): OpenCodeServerAcquisition | null; shutdown(): Promise; } @@ -164,6 +165,27 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { } } + acquireExisting(url: string): OpenCodeServerAcquisition | null { + const server = this.findLiveServerByUrl(url); + return server ? this.acquireServer(server) : null; + } + + private findLiveServerByUrl(url: string): OpenCodeServerGeneration | null { + const servers = [ + ...(this.currentServer ? [this.currentServer] : []), + ...Array.from(this.retiredServers), + ]; + return servers.find((server) => server.url === url && this.isServerLive(server)) ?? null; + } + + private isServerLive(server: OpenCodeServerGeneration): boolean { + return ( + !server.process.killed && + server.process.exitCode === null && + server.process.signalCode === null + ); + } + private acquireServer(server: OpenCodeServerGeneration): OpenCodeServerAcquisition { server.refCount += 1; let released = false; diff --git a/packages/server/src/server/agent/providers/opencode/test-server-manager.ts b/packages/server/src/server/agent/providers/opencode/test-server-manager.ts index d7461f672f..f13a2eb952 100644 --- a/packages/server/src/server/agent/providers/opencode/test-server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/test-server-manager.ts @@ -1,8 +1,9 @@ import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "./server-manager.js"; export interface TestOpenCodeServerAcquisition { - kind: "current" | "new" | "dedicated"; + kind: "current" | "new" | "dedicated" | "existing"; env?: Record; + url?: string; released: boolean; } @@ -28,14 +29,20 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike { return this.recordAcquisition({ kind: "dedicated", env }); } + acquireExisting(url: string): OpenCodeServerAcquisition | null { + return url === this.server.url ? this.recordAcquisition({ kind: "existing", url }) : null; + } + private recordAcquisition(input: { kind: TestOpenCodeServerAcquisition["kind"]; env?: Record; + url?: string; }): OpenCodeServerAcquisition { const acquisition: TestOpenCodeServerAcquisition = { kind: input.kind, released: false, ...(input.env ? { env: input.env } : {}), + ...(input.url ? { url: input.url } : {}), }; this.acquisitions.push(acquisition); return { diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts index 0410c8abb6..754b1b86d0 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts @@ -9,8 +9,9 @@ interface OpenCodeResponse { export class TestOpenCodeHarness implements OpenCodeServerManagerLike { readonly acquisitions: Array<{ - kind: "current" | "new" | "dedicated"; + kind: "current" | "new" | "dedicated" | "existing"; env?: Record; + url?: string; releaseCount: number; }> = []; readonly clientCreations: Array<{ baseUrl: string; directory: string }> = []; @@ -34,14 +35,20 @@ export class TestOpenCodeHarness implements OpenCodeServerManagerLike { return this.recordAcquisition({ kind: "dedicated", env }); } + acquireExisting(url: string): OpenCodeServerAcquisition | null { + return url === this.server.url ? this.recordAcquisition({ kind: "existing", url }) : null; + } + private recordAcquisition(input: { - kind: "current" | "new" | "dedicated"; + kind: "current" | "new" | "dedicated" | "existing"; env?: Record; + url?: string; }): OpenCodeServerAcquisition { const acquisition = { kind: input.kind, releaseCount: 0, ...(input.env ? { env: input.env } : {}), + ...(input.url ? { url: input.url } : {}), }; this.acquisitions.push(acquisition); return { @@ -82,6 +89,7 @@ export class TestOpenCodeClient { sessionCommand: [] as unknown[], sessionCreate: [] as unknown[], sessionDelete: [] as unknown[], + sessionChildren: [] as unknown[], sessionGet: [] as unknown[], sessionMessages: [] as unknown[], sessionPromptAsync: [] as unknown[], @@ -106,6 +114,8 @@ export class TestOpenCodeClient { sessionCommandResponse: OpenCodeResponse = {}; sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } }; sessionDeleteResponse: OpenCodeResponse = {}; + sessionChildrenResponses: OpenCodeResponse[] = []; + sessionChildrenImplementation: ((parameters: unknown) => Promise) | null = null; sessionGetResponse: OpenCodeResponse = { data: { id: "session-1", directory: "/workspace/repo", title: null }, }; @@ -156,7 +166,10 @@ export class TestOpenCodeClient { global: { event: async (options: unknown) => { this.calls.globalEvent.push(options); - return { stream: this.eventStream }; + const signal = (options as { signal?: AbortSignal }).signal; + return { + stream: signal ? stopEventStreamOnAbort(this.eventStream, signal) : this.eventStream, + }; }, }, mcp: { @@ -216,6 +229,13 @@ export class TestOpenCodeClient { this.calls.sessionDelete.push(parameters); return this.sessionDeleteResponse; }, + children: async (parameters: unknown) => { + this.calls.sessionChildren.push(parameters); + if (this.sessionChildrenImplementation) { + return await this.sessionChildrenImplementation(parameters); + } + return this.sessionChildrenResponses.shift() ?? { data: [] }; + }, get: async (parameters: unknown) => { this.calls.sessionGet.push(parameters); return this.sessionGetResponse; @@ -247,6 +267,38 @@ export class TestOpenCodeClient { } } +function stopEventStreamOnAbort( + stream: AsyncIterable, + signal: AbortSignal, +): AsyncIterable { + return { + [Symbol.asyncIterator]: () => { + const iterator = stream[Symbol.asyncIterator](); + return { + next: () => { + if (signal.aborted) { + return Promise.resolve({ done: true, value: undefined }); + } + return new Promise>((resolve, reject) => { + const onAbort = () => resolve({ done: true, value: undefined }); + signal.addEventListener("abort", onAbort, { once: true }); + void iterator.next().then( + (result) => { + signal.removeEventListener("abort", onAbort); + return resolve(result); + }, + (error) => { + signal.removeEventListener("abort", onAbort); + return reject(error); + }, + ); + }); + }, + }; + }, + }; +} + export function createEventStream(events: unknown[]): AsyncGenerator { return (async function* () { for (const event of events) { diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 4372f0b0a6..c8b4aaba8f 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -138,6 +138,13 @@ class SessionEvents { ); } + nextTurnCancellation(): Promise> { + return this.nextEvent( + (event): event is Extract => + event.type === "turn_canceled", + ); + } + nextPermissionRequest(): Promise> { return this.nextEvent( (event): event is Extract => @@ -419,10 +426,19 @@ describe("PiRpcAgentSession", () => { const fakeSession = pi.latestSession(); await session.startTurn("hello"); + fakeSession.emit({ + type: "message_start", + message: { role: "assistant", content: [], responseId: "response-1" }, + }); fakeSession.emit({ type: "message_update", - message: { role: "assistant", content: [] }, - assistantMessageEvent: { type: "text_delta", delta: "hello" }, + message: { role: "assistant", content: [], responseId: "response-1" }, + assistantMessageEvent: { type: "text_delta", delta: "hel" }, + }); + fakeSession.emit({ + type: "message_update", + message: { role: "assistant", content: [], responseId: "response-1" }, + assistantMessageEvent: { type: "text_delta", delta: "lo" }, }); fakeSession.emit({ type: "message_update", @@ -447,7 +463,8 @@ describe("PiRpcAgentSession", () => { await events.nextTurnCompletion(); expect(events.timelineItems()).toEqual([ - { type: "assistant_message", text: "hello" }, + { type: "assistant_message", text: "hel", messageId: "response-1" }, + { type: "assistant_message", text: "lo", messageId: "response-1" }, { type: "reasoning", text: "thinking" }, { type: "tool_call", @@ -468,6 +485,60 @@ describe("PiRpcAgentSession", () => { ]); }); + test("keeps one generated message id when Pi omits message start and response id", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + + await session.startTurn("hello"); + fakeSession.emit({ + type: "message_update", + message: { role: "assistant", content: [] }, + assistantMessageEvent: { type: "text_delta", delta: "hel" }, + }); + fakeSession.emit({ + type: "message_update", + message: { role: "assistant", content: [] }, + assistantMessageEvent: { type: "text_delta", delta: "lo" }, + }); + + const [firstChunk, secondChunk] = events.timelineItems(); + expect(firstChunk).toMatchObject({ + type: "assistant_message", + text: "hel", + messageId: expect.any(String), + }); + const firstMessageId = (firstChunk as { messageId: string }).messageId; + expect(secondChunk).toEqual({ + type: "assistant_message", + text: "lo", + messageId: firstMessageId, + }); + }); + + test("uses a response id that first appears on the assistant update", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + + await session.startTurn("hello"); + fakeSession.emit({ + type: "message_start", + message: { role: "assistant", content: [] }, + }); + fakeSession.emit({ + type: "message_update", + message: { role: "assistant", content: [], responseId: "late-response-id" }, + assistantMessageEvent: { type: "text_delta", delta: "hello" }, + }); + + expect(events.timelineItems()).toEqual([ + { + type: "assistant_message", + text: "hello", + messageId: "late-response-id", + }, + ]); + }); + test("emits live user messages with captured Pi tree entry ids", async () => { const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); @@ -508,6 +579,38 @@ describe("PiRpcAgentSession", () => { ]); }); + test("canceling a silent Pi extension command leaves the session usable", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + + fakeSession.holdNextPrompt(); + const firstTurn = await session.startTurn("/silent-search"); + fakeSession.emit({ + type: "extension_ui_request", + id: "notify-1", + method: "notify", + message: "Search finished", + }); + await session.interrupt(); + const cancellation = await events.nextTurnCancellation(); + await session.startTurn("next request"); + await fakeSession.failHeldPrompt(new Error("Canceled prompt timed out")); + + expect(cancellation).toEqual({ + type: "turn_canceled", + provider: "pi", + reason: "interrupted", + turnId: firstTurn.turnId, + }); + expect(fakeSession.prompts).toEqual([ + { message: "/silent-search", imageCount: 0 }, + { message: "next request", imageCount: 0 }, + ]); + await expect(session.startTurn("overlapping request")).rejects.toThrow( + "A Pi turn is already active", + ); + }); + test("adds Pi assistant context to generic provider finish errors", async () => { const { pi, session, events } = await createSession(); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 9d0bece601..c72e5d358f 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -1064,6 +1064,7 @@ export class PiRpcAgentSession implements AgentSession { private activeAskUserDialog: ActiveAskUserDialog | null = null; private pendingCombinedAskUserResponse: PendingCombinedAskUserResponse | null = null; private activeTurnId: string | null = null; + private activeAssistantMessageId: string | null = null; private lastKnownThinkingOptionId: string | null; currentLeafOverrideId: string | null | undefined; private readonly capturedUserEntries: PiCapturedEntry[] = []; @@ -1123,15 +1124,18 @@ export class PiRpcAgentSession implements AgentSession { const payload = convertPromptInput(prompt, { model: this.state.model }); const turnId = randomUUID(); this.activeTurnId = turnId; + this.activeAssistantMessageId = null; void this.runtimeSession.prompt(payload.text, payload.images).catch((error) => { - const failedTurnId = this.activeTurnId ?? turnId; + if (this.activeTurnId !== turnId) { + return; + } this.activeTurnId = null; if (isPiRequestAbortError(error)) { this.emit({ type: "turn_canceled", provider: PI_PROVIDER, - turnId: failedTurnId, + turnId, reason: toDiagnosticErrorMessage(error), }); return; @@ -1139,7 +1143,7 @@ export class PiRpcAgentSession implements AgentSession { this.emit({ type: "turn_failed", provider: PI_PROVIDER, - turnId: failedTurnId, + turnId, error: toDiagnosticErrorMessage(error), }); }); @@ -1234,7 +1238,12 @@ export class PiRpcAgentSession implements AgentSession { } async interrupt(): Promise { + const turnId = this.activeTurnId; await this.runtimeSession.abort(); + if (turnId && this.activeTurnId === turnId) { + this.activeTurnId = null; + this.emit({ type: "turn_canceled", provider: PI_PROVIDER, reason: "interrupted", turnId }); + } } async revertConversation(input: { messageId: string }): Promise { @@ -1704,6 +1713,7 @@ export class PiRpcAgentSession implements AgentSession { }); return; case "message_start": + this.handleMessageStart(event); return; case "message_end": this.handleMessageEnd(event, turnId); @@ -1811,6 +1821,8 @@ export class PiRpcAgentSession implements AgentSession { return; } if (event.assistantMessageEvent.type === "text_delta") { + // Pi-compatible runtimes may emit updates without a preceding message_start. + this.activeAssistantMessageId ??= event.message.responseId || randomUUID(); this.emit({ type: "timeline", provider: PI_PROVIDER, @@ -1818,6 +1830,7 @@ export class PiRpcAgentSession implements AgentSession { item: { type: "assistant_message", text: event.assistantMessageEvent.delta ?? "", + messageId: this.activeAssistantMessageId, }, }); return; @@ -1835,10 +1848,20 @@ export class PiRpcAgentSession implements AgentSession { } } + private handleMessageStart(event: Extract): void { + if (event.message.role === "assistant") { + this.activeAssistantMessageId = event.message.responseId || null; + } + } + private handleMessageEnd( event: Extract, turnId: string | undefined, ): void { + if (event.message.role === "assistant") { + this.activeAssistantMessageId = null; + return; + } if (event.message.role === "custom") { const text = getUserMessageText(event.message.content); if (text) { @@ -1899,6 +1922,7 @@ export class PiRpcAgentSession implements AgentSession { private completeTurn(turnId: string | undefined, messages: PiAgentMessage[]): void { this.activeTurnId = null; + this.activeAssistantMessageId = null; const errorMessage = latestPiErrorMessage(messages); if (typeof errorMessage === "string" && errorMessage.length > 0) { this.emit({ diff --git a/packages/server/src/server/agent/providers/pi/history-mapper.test.ts b/packages/server/src/server/agent/providers/pi/history-mapper.test.ts index 75c9ad77a3..84c08c7e7a 100644 --- a/packages/server/src/server/agent/providers/pi/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/pi/history-mapper.test.ts @@ -29,6 +29,7 @@ describe("Pi history mapper", () => { }, { role: "assistant", + responseId: "response-1", content: [ { type: "thinking", thinking: "checking file" }, { type: "toolCall", id: "tool-1", name: "read", arguments: { path: "note.txt" } }, @@ -77,7 +78,7 @@ describe("Pi history mapper", () => { { type: "timeline", provider: "pi", - item: { type: "assistant_message", text: "done" }, + item: { type: "assistant_message", text: "done", messageId: "response-1" }, }, { type: "timeline", @@ -153,7 +154,11 @@ describe("Pi history mapper", () => { { type: "timeline", provider: "pi", - item: { type: "assistant_message", text: "first answer" }, + item: { + type: "assistant_message", + text: "first answer", + messageId: "pi-history-assistant-1", + }, }, { type: "timeline", diff --git a/packages/server/src/server/agent/providers/pi/history-mapper.ts b/packages/server/src/server/agent/providers/pi/history-mapper.ts index b6653ece54..03fb02b9dd 100644 --- a/packages/server/src/server/agent/providers/pi/history-mapper.ts +++ b/packages/server/src/server/agent/providers/pi/history-mapper.ts @@ -44,6 +44,7 @@ export async function* streamPiHistory( ): AsyncGenerator { const pendingToolCalls = new Map(); let userIndex = 0; + let assistantIndex = 0; for (const message of messages) { if (message.role === "user") { @@ -65,12 +66,14 @@ export async function* streamPiHistory( } if (message.role === "assistant") { + assistantIndex += 1; + const messageId = message.responseId || `${provider}-history-assistant-${assistantIndex}`; for (const content of message.content) { if (content.type === "text" && content.text) { yield { type: "timeline", provider, - item: { type: "assistant_message", text: content.text }, + item: { type: "assistant_message", text: content.text, messageId }, }; continue; } diff --git a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts index 76e0f4b7ff..2fc4072e10 100644 --- a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts +++ b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts @@ -76,6 +76,9 @@ export class FakePiSession implements PiRuntimeSession { state: PiSessionState; private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>(); + private nextHeldPrompt: { promise: Promise; reject: (error: Error) => void } | null = null; + private activeHeldPrompt: { promise: Promise; reject: (error: Error) => void } | null = + null; constructor(launch: PiRuntimeLaunch) { this.state = { @@ -103,10 +106,42 @@ export class FakePiSession implements PiRuntimeSession { images?: Array<{ type: "image"; data: string; mimeType: string }>, ): Promise { this.prompts.push({ message, imageCount: images?.length ?? 0 }); + const heldPrompt = this.nextHeldPrompt; + if (heldPrompt) { + this.nextHeldPrompt = null; + this.activeHeldPrompt = heldPrompt; + try { + await heldPrompt.promise; + } finally { + if (this.activeHeldPrompt === heldPrompt) { + this.activeHeldPrompt = null; + } + } + } this.handleTreeNavigationCommand(message); this.handleEntryCaptureCommand(message); } + holdNextPrompt(): void { + if (this.nextHeldPrompt || this.activeHeldPrompt) { + throw new Error("FakePi already has a held prompt"); + } + let reject!: (error: Error) => void; + const promise = new Promise((_resolve, rejectPromise) => { + reject = rejectPromise; + }); + this.nextHeldPrompt = { promise, reject }; + } + + async failHeldPrompt(error: Error): Promise { + const heldPrompt = this.activeHeldPrompt ?? this.nextHeldPrompt; + if (!heldPrompt) { + throw new Error("FakePi has no held prompt"); + } + heldPrompt.reject(error); + await new Promise((resolve) => setImmediate(resolve)); + } + async compact(customInstructions?: string): Promise { this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions }); this.emit({ type: "compaction_start", reason: "manual" }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 894ccb300f..53edfa8713 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1165,6 +1165,43 @@ export class Session { return; } + if (event.type === "provider_subagent") { + if (!this.supports(CLIENT_CAPS.providerSubagents)) { + return; + } + const update = event.event; + if (update.type === "upsert") { + this.emit({ + type: "agent.provider_subagents.update", + payload: { kind: "upsert", subagent: update.subagent }, + }); + } else if (update.type === "timeline") { + this.emit({ + type: "agent.provider_subagents.update", + payload: { + kind: "timeline", + parentAgentId: update.parentAgentId, + subagentId: update.subagentId, + provider: update.provider, + item: update.row.item, + timestamp: update.row.timestamp, + seq: update.row.seq, + epoch: update.epoch, + }, + }); + } else { + this.emit({ + type: "agent.provider_subagents.update", + payload: { + kind: "remove", + parentAgentId: update.parentAgentId, + subagentId: update.subagentId, + }, + }); + } + return; + } + if ( this.voiceSession.isActiveForAgent(event.agentId) && event.event.type === "permission_requested" && @@ -1450,6 +1487,10 @@ export class Session { switch (msg.type) { case "fetch_agent_timeline_request": return this.handleFetchAgentTimelineRequest(msg); + case "agent.provider_subagents.list.request": + return this.handleProviderSubagentListRequest(msg); + case "agent.provider_subagents.timeline.get.request": + return this.handleProviderSubagentTimelineRequest(msg); case "agent.fork_context.request": return this.handleAgentForkContextRequest(msg); default: @@ -2708,7 +2749,7 @@ export class Session { extractTimestamps(record), ); } - await this.agentManager.hydrateTimelineFromProvider(agentId); + await this.agentManager.hydrateTimelineFromProvider(agentId, { broadcast: true }); await this.agentUpdates.forwardLiveAgent(snapshot); const timelineSize = this.agentManager.getTimeline(agentId).length; if (requestId) { @@ -5215,6 +5256,96 @@ export class Session { } } + private async handleProviderSubagentListRequest( + msg: Extract, + ): Promise { + try { + this.emit({ + type: "agent.provider_subagents.list.response", + payload: { + requestId: msg.requestId, + parentAgentId: msg.parentAgentId, + subagents: this.agentManager.listProviderSubagents(msg.parentAgentId), + error: null, + }, + }); + } catch (error) { + this.emit({ + type: "agent.provider_subagents.list.response", + payload: { + requestId: msg.requestId, + parentAgentId: msg.parentAgentId, + subagents: [], + error: error instanceof Error ? error.message : String(error), + }, + }); + } + } + + private async handleProviderSubagentTimelineRequest( + msg: Extract, + ): Promise { + const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail"); + try { + const descriptor = this.agentManager.getProviderSubagent(msg.parentAgentId, msg.subagentId); + if (!descriptor) { + throw new Error("Provider subagent not found"); + } + const timeline = this.agentManager.fetchProviderSubagentTimeline( + msg.parentAgentId, + msg.subagentId, + { + direction, + cursor: msg.cursor, + limit: msg.limit ?? (direction === "after" ? 0 : 200), + }, + ); + this.emit({ + type: "agent.provider_subagents.timeline.get.response", + payload: { + requestId: msg.requestId, + parentAgentId: msg.parentAgentId, + subagentId: msg.subagentId, + provider: descriptor.provider, + direction, + epoch: timeline.epoch, + reset: timeline.reset, + staleCursor: timeline.staleCursor, + gap: timeline.gap, + window: timeline.window, + hasOlder: timeline.hasOlder, + hasNewer: timeline.hasNewer, + rows: timeline.rows.map((row) => ({ + item: row.item, + timestamp: row.timestamp, + seq: row.seq, + })), + error: null, + }, + }); + } catch (error) { + this.emit({ + type: "agent.provider_subagents.timeline.get.response", + payload: { + requestId: msg.requestId, + parentAgentId: msg.parentAgentId, + subagentId: msg.subagentId, + provider: null, + direction, + epoch: "", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 0, maxSeq: 0, nextSeq: 0 }, + hasOlder: false, + hasNewer: false, + rows: [], + error: error instanceof Error ? error.message : String(error), + }, + }); + } + } + private async handleAgentForkContextRequest( msg: Extract, ): Promise { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 5b45cf7a33..6a74ad5af8 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1259,6 +1259,8 @@ export class VoiceAssistantWebSocketServer { daemonSelfUpdate: true, // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. agentForkContext: true, + // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. + providerSubagents: true, }, }; } diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index 8bd35ac5db..df4ced389b 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -60,6 +60,8 @@ export interface TerminalManager { env?: Record; command?: string; args?: string[]; + rows?: number; + cols?: number; activityToken?: string; activityUrl?: string | null; }): Promise; @@ -315,6 +317,8 @@ export function createTerminalManager( env?: Record; command?: string; args?: string[]; + rows?: number; + cols?: number; activityToken?: string; activityUrl?: string | null; }): Promise { @@ -348,6 +352,8 @@ export function createTerminalManager( ...(options.title ? { title: options.title } : {}), ...(options.command ? { command: options.command } : {}), ...(options.args ? { args: options.args } : {}), + ...(options.rows !== undefined ? { rows: options.rows } : {}), + ...(options.cols !== undefined ? { cols: options.cols } : {}), ...(mergedEnv ? { env: mergedEnv } : {}), activityEnv, }), diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index 3c6dee01b3..81c3a15d6c 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -259,6 +259,59 @@ describe("terminal-session-controller legacy terminal creation", () => { }, ]); }); + + test("forwards the client-provided viewport size to the terminal manager", async () => { + const outboundMessages: SessionOutboundMessage[] = []; + const createTerminal = vi.fn( + async (options: Parameters[0]) => + listSession({ + id: "term-1", + name: options.name ?? "Terminal 1", + cwd: options.cwd, + workspaceId: options.workspaceId, + }), + ); + const terminalManager: TerminalManager = { + getTerminals: vi.fn(), + createTerminal, + registerCwdEnv: vi.fn(), + validateTerminalActivityToken: vi.fn(() => "unknown"), + getTerminal: vi.fn(), + getTerminalState: vi.fn(), + setTerminalTitle: vi.fn(), + setTerminalActivity: vi.fn(), + clearTerminalAttention: vi.fn(), + killTerminal: vi.fn(), + killTerminalAndWait: vi.fn(), + captureTerminal: vi.fn(), + listDirectories: vi.fn(() => []), + killAll: vi.fn(), + subscribeTerminalsChanged: vi.fn(() => vi.fn()), + subscribeTerminalActivity: vi.fn(() => vi.fn()), + subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), + }; + const controller = new TerminalSessionController({ + terminalManager, + emit: (message) => outboundMessages.push(message), + emitBinary: vi.fn(), + hasBinaryChannel: () => true, + isPathWithinRoot: isSameOrDescendantPath, + sessionLogger: createLogger(), + listTerminalWorkspaceRefs: async () => [], + }); + + await controller.dispatch({ + type: "create_terminal_request", + cwd: "/work/repo", + workspaceId: "ws-1", + size: { rows: 55, cols: 136 }, + requestId: "req-size", + }); + + expect(createTerminal).toHaveBeenCalledWith( + expect.objectContaining({ cwd: "/work/repo", workspaceId: "ws-1", rows: 55, cols: 136 }), + ); + }); }); async function flushMicrotasks(): Promise { diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index dd6c549272..dd7d053d62 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -549,6 +549,8 @@ export class TerminalSessionController { name: msg.name, command: msg.command, args: msg.args, + rows: msg.size?.rows, + cols: msg.size?.cols, }); this.ensureExitSubscription(session); this.emit({ diff --git a/packages/server/src/terminal/terminal-worker-protocol.ts b/packages/server/src/terminal/terminal-worker-protocol.ts index 2da2f5f128..6b722c978a 100644 --- a/packages/server/src/terminal/terminal-worker-protocol.ts +++ b/packages/server/src/terminal/terminal-worker-protocol.ts @@ -27,6 +27,8 @@ export interface WorkerCreateTerminalOptions { env?: Record; command?: string; args?: string[]; + rows?: number; + cols?: number; activityToken?: string; activityUrl?: string | null; } diff --git a/packages/website/package.json b/packages/website/package.json index e018e3850a..dd45f64e34 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.106-beta.1", + "version": "0.1.106", "private": true, "type": "module", "scripts": { diff --git a/paseo.json b/paseo.json index 50ebd4399b..ab984058ef 100644 --- a/paseo.json +++ b/paseo.json @@ -3,7 +3,7 @@ "setup": [ "npm ci", "node ./scripts/seed-ios-native-cache.mjs", - "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_SEED_HOME=\"$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home\" PASEO_HOME=\"$PWD/.dev/paseo-home\" ./scripts/dev-home.sh", + "PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_SEED_HOME=\"$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home\" PASEO_HOME=\"$PWD/.dev/paseo-home\" ./scripts/dev-home.sh", "npm run build:server", "npm run build --workspace=@getpaseo/expo-two-way-audio", "cp \"$PASEO_SOURCE_CHECKOUT_PATH/packages/server/.env\" \"$PWD/packages/server/.env\"" @@ -12,19 +12,19 @@ "scripts": { "daemon": { "type": "service", - "command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"${PASEO_WORKTREE_PATH:-$PWD}\" PASEO_HOME=\"${PASEO_WORKTREE_PATH:-$PWD}/.dev/paseo-home\" PASEO_SKIP_DEV_SERVER_BUILD=1 PASEO_LISTEN=0.0.0.0:${PASEO_PORT:-6768} ./scripts/dev-daemon.sh" + "command": "PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"${PASEO_WORKTREE_PATH:-$PWD}\" PASEO_HOME=\"${PASEO_WORKTREE_PATH:-$PWD}/.dev/paseo-home\" PASEO_SKIP_DEV_SERVER_BUILD=1 PASEO_LISTEN=0.0.0.0:${PASEO_PORT:-6768} ./scripts/dev-daemon.sh" }, "app": { "type": "service", - "command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"${PASEO_WORKTREE_PATH:-$PWD}\" PASEO_HOME=\"${PASEO_WORKTREE_PATH:-$PWD}/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT:-6768} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT:-6768} EXPO_PORT=${PASEO_PORT:-} ./scripts/dev-app.sh" + "command": "PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"${PASEO_WORKTREE_PATH:-$PWD}\" PASEO_HOME=\"${PASEO_WORKTREE_PATH:-$PWD}/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT:-6768} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT:-6768} EXPO_PORT=${PASEO_PORT:-} ./scripts/dev-app.sh" }, "desktop": { "type": "service", - "command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"${PASEO_WORKTREE_PATH:-$PWD}\" PASEO_HOME=\"${PASEO_WORKTREE_PATH:-$PWD}/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT:-6768} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT:-6768} EXPO_PORT=${PASEO_PORT:-} npm run dev --workspace=@getpaseo/desktop" + "command": "PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"${PASEO_WORKTREE_PATH:-$PWD}\" PASEO_HOME=\"${PASEO_WORKTREE_PATH:-$PWD}/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT:-6768} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT:-6768} EXPO_PORT=${PASEO_PORT:-} npm run dev --workspace=@getpaseo/desktop" }, "ios-simulator": { "type": "service", - "command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"$PWD\" PASEO_HOME=\"$PWD/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT} ./scripts/paseo-ios-simulator-service.sh" + "command": "PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"$PWD\" PASEO_HOME=\"$PWD/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT} ./scripts/paseo-ios-simulator-service.sh" }, "typecheck": { "command": "npm run typecheck"