Compare commits

..

19 Commits

Author SHA1 Message Date
Mohamed Boudra
9553f4b328 chore(release): cut 0.1.107 2026-07-13 00:27:43 +02:00
Mohamed Boudra
fc7c753382 docs: add 0.1.107 changelog 2026-07-13 00:10:29 +02:00
Mohamed Boudra
96e68fea76 chore: refresh ACP provider catalog 2026-07-13 00:09:25 +02:00
psyche
1a4d7852a8 fix(agent): allow cross-provider creation for modeless providers (#2000)
* fix: 允许无模式 Provider 创建子 Agent

* test: 补充无模式 Provider 继承场景
2026-07-12 21:47:02 +00:00
psyche
c2a1ac7c3b Stop Pi metadata tasks from cluttering session history (#1999)
* fix: 避免内部 Pi Agent 持久化会话

* test: 聚焦 Pi 会话持久化行为
2026-07-12 21:35:38 +00:00
Mohamed Boudra
4a1534cacd Show only commands in Codex shell tool calls (#2029)
* fix(codex): hide shell launchers from command summaries

Codex only unwrapped POSIX shells from /bin. Commands launched through /usr/bin/zsh therefore exposed the wrapper in the tool-call row.

* test(codex): preserve shell wrapper path coverage
2026-07-12 21:12:20 +00:00
Mohamed Boudra
a849bc6bda Merge branch 'main' of github.com:getpaseo/paseo 2026-07-12 22:30:21 +02:00
Mohamed Boudra
a788a0f843 Keep agent stream controls clickable under the scroll button (#2007)
* fix(app): keep stream controls clickable under scroll button

The full-width animated overlay won browser hit testing even outside the circular control. Keep positioning on a pass-through container and animate only the button-sized child.

* test(app): preserve scroll hit-test diagnostics

* test(app): align scroll hit-test fixture deterministically

* test(server): include OpenCode message IDs in subagent assertions
2026-07-13 04:30:02 +08:00
Mohamed Boudra
a1581e66b0 fix(chat): keep large tables responsive on iOS
Selectable table cells each installed a window-level gesture recognizer, causing every touch to fan out across the entire table. Keep prose selectable while rendering iOS table text without selection.
2026-07-12 22:15:42 +02:00
Christoph Leiter
2658132384 fix(terminal): create PTYs at the client viewport size, not 80x24 (#2023)
New terminals were always spawned by the daemon at the hardcoded 80x24
default and only resized once the client's first resize arrived. On a
slow first mount that leaves a window -- or a stuck state -- where a new
terminal renders far smaller than its pane (e.g. vim in a corner).

Seed the PTY with the client's measured viewport size at creation time
so it is born at the right size and the race window is gone.

- protocol: optional `size` on create_terminal_request. Old daemons
  ignore it and keep 80x24; old clients send nothing. Fully backward
  compatible, no capability gate.
- client: a small last-measured-size cache keyed by serverId+cwd (every
  terminal in a workspace shares the pane) with a most-recent fallback;
  written on fit, read at create.
- server: thread rows/cols through the controller, terminal manager, and
  the worker-thread boundary into the existing size-aware createTerminal.

Tests: cache logic, protocol back-compat parse, controller forwarding.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 03:26:52 +08:00
Mohamed Boudra
ec93ca866e Make adding projects discoverable in new workspaces (#2026)
* fix(app): make project setup discoverable in new workspaces

* fix(app): keep project picker actions outside options
2026-07-12 19:20:31 +00:00
Mohamed Boudra
66445adc07 Show provider subagents in the subagents track (#2013)
* Add provider-owned subagent stream contract

* OpenCode provider: detect child sessions, attach adopted children to the hosting server, synthesize external turns

- Emit internal provider_child_session_detected / provider_session_deleted stream events from the global event stream (before the no-active-turn gate), including BFS hydration of existing descendants on subscribe
- Track child sessions in a registry mapping child session id -> hosting server url; resumeSession attaches via acquireExisting() so adopted children share the parent's server generation and keep receiving live events
- Synthesize external turns for adopted (externally driven) sessions so timeline, tool calls, and permission requests stream without a Paseo-initiated prompt
- Honor launchContext.env in resumeSession (acquireDedicated parity with createSession)
- Stop folding child tool parts into the parent's sub_agent action log; the parent row stays as a pointer

* Render provider subagents in workspace timelines

* fix provider subagent timeline reliability

* fix provider subagent lifecycle edge cases

* fix provider subagent event fidelity

* Fix provider subagent history fidelity

* Fix restored provider subagent state

* Fix provider subagent visibility edges

* Restore provider subagent timelines

* Page provider subagent history

* Forward subagent questions reliably

* fix(subagents): preserve OpenCode child context

* fix(subagents): harden provider timeline routing

* fix(subagents): preserve provider page continuity

* fix(subagents): retain live child prompts

---------

Co-authored-by: Omer <639682+omercnet@users.noreply.github.com>
2026-07-13 03:13:00 +08:00
Mohamed Boudra
41d882859c Show Fork chat for every agent provider (#2022)
* fix(chat): show fork controls for every provider

Real assistant messages require stable identity so provider-agnostic chat actions can address the selected turn. Preserve provider IDs where available and generate stable adapter-owned IDs otherwise.

* test(opencode): expect assistant message identities

* test(pi): cover assistant updates without message start

* fix(providers): preserve late assistant identities

* fix(opencode): track fallback compaction identities
2026-07-13 03:01:08 +08:00
Mohamed Boudra
88397655f7 fix(acp): keep tool execution with agents by default (#2024)
Paseo should only advertise filesystem and terminal execution when a provider explicitly opts into delegating those operations to the host.
2026-07-12 20:43:40 +02:00
Mohamed Boudra
18de06c2a4 Show tooltips for sidebar footer actions (#2025)
* fix(app): label sidebar footer actions

* fix(app): translate hosts tooltip
2026-07-13 02:36:01 +08:00
Victor Araújo
a9ba0392b7 feat(acp): configure generic client capabilities
Closes #2012
2026-07-12 20:10:35 +02:00
Mohamed Boudra
cf6c014b6b Keep oversized tool output out of chat timelines (#2020)
* fix(server): bound shell tool output in timelines

Provider tool output could enter timeline persistence and live streams without a size limit. Bound canonical shell output before coalescing, storage, history hydration, and dispatch so every provider follows the same 64 KiB budget.

* refactor(server): slice oversized tool output directly

* fix(server): cover imported and failed shell output
2026-07-12 14:24:18 +00:00
Mohamed Boudra
e18cfb7639 Keep Pi chats usable after canceling extension commands (#2019)
* Fix Pi sessions stuck after interrupt

* Guard Pi retries from stale prompt failures
2026-07-12 15:33:51 +02:00
Christoph Leiter
c05e337cde fix(dev): drop unneeded cross-env from paseo.json dev scripts (#1637)
When running Paseo on the Paseo repo itself, the worktree setup step and the
paseo.json service scripts (daemon/app/desktop/ios-simulator) fail with
"cross-env: command not found" unless cross-env is installed globally. The
daemon runs paseo.json commands in a plain shell without the project's
node_modules/.bin on PATH (unlike npm run), so the bare cross-env (a local
devDependency) does not resolve.

cross-env only exists for Windows cmd compatibility, and every one of these
commands launches a unix ./scripts/*.sh, so it was not buying anything here.
Drop it and set the env vars inline. The cross-env dependency stays -- it is
still used by the npm run scripts where it is actually needed.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 11:40:39 +00:00
109 changed files with 6565 additions and 605 deletions

View File

@@ -1,5 +1,30 @@
# Changelog
## 0.1.107 - 2026-07-13
### Added
- Inspect provider-created subagents and their live conversations from the Subagents track ([#2013](https://github.com/getpaseo/paseo/pull/2013) by [@omercnet](https://github.com/omercnet))
- Fork chats with every supported agent provider ([#2022](https://github.com/getpaseo/paseo/pull/2022))
### Improved
- Add projects directly from New Workspace when none are configured ([#2026](https://github.com/getpaseo/paseo/pull/2026))
- New terminals open at the correct size immediately ([#2023](https://github.com/getpaseo/paseo/pull/2023) by [@cleiter](https://github.com/cleiter))
- Sidebar footer actions now explain themselves with tooltips ([#2025](https://github.com/getpaseo/paseo/pull/2025))
- Codex shell tool calls show only the command being run ([#2029](https://github.com/getpaseo/paseo/pull/2029))
- Custom ACP providers keep file and terminal work in the agent environment by default ([#2024](https://github.com/getpaseo/paseo/pull/2024))
- ACP provider catalog updated to the latest registry versions
### Fixed
- Large tables no longer make iOS chats unresponsive
- Chat controls remain clickable near the scroll-to-bottom button ([#2007](https://github.com/getpaseo/paseo/pull/2007))
- Oversized tool output no longer slows or floods chat timelines ([#2020](https://github.com/getpaseo/paseo/pull/2020))
- Cross-provider subagents can use providers without mode settings ([#2000](https://github.com/getpaseo/paseo/pull/2000) by [@githubbzxs](https://github.com/githubbzxs))
- Pi's internal metadata tasks no longer clutter normal session history ([#1999](https://github.com/getpaseo/paseo/pull/1999) by [@githubbzxs](https://github.com/githubbzxs))
- Pi chats remain usable after canceling extension commands ([#2019](https://github.com/getpaseo/paseo/pull/2019))
## 0.1.106 - 2026-07-12
### Added

View File

@@ -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.

View File

@@ -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.

View File

@@ -9,10 +9,10 @@ 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 and failed
shell error text are capped at 64 KiB of UTF-8 data, 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.
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

42
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.106",
"version": "0.1.107",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.106",
"version": "0.1.107",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -35152,7 +35152,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.106",
"version": "0.1.107",
"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",
"version": "0.1.107",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.106",
"@getpaseo/protocol": "0.1.106",
"@getpaseo/server": "0.1.106",
"@getpaseo/client": "0.1.107",
"@getpaseo/protocol": "0.1.107",
"@getpaseo/server": "0.1.107",
"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",
"version": "0.1.107",
"dependencies": {
"@getpaseo/protocol": "0.1.106",
"@getpaseo/relay": "0.1.106",
"@getpaseo/protocol": "0.1.107",
"@getpaseo/relay": "0.1.107",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36435,7 +36435,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.106",
"version": "0.1.107",
"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",
"version": "0.1.107",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37574,7 +37574,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.106",
"version": "0.1.107",
"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",
"version": "0.1.107",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37819,7 +37819,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.106",
"version": "0.1.107",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38037,15 +38037,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.106",
"version": "0.1.107",
"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",
"@getpaseo/highlight": "0.1.106",
"@getpaseo/protocol": "0.1.106",
"@getpaseo/relay": "0.1.106",
"@getpaseo/client": "0.1.107",
"@getpaseo/highlight": "0.1.107",
"@getpaseo/protocol": "0.1.107",
"@getpaseo/relay": "0.1.107",
"@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",
"version": "0.1.107",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.106",
"version": "0.1.107",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [

View File

@@ -8,6 +8,7 @@ import {
} from "./helpers/agent-stream";
import {
expectScrollStaysFixed,
clickToolCallBesideScrollToBottomButton,
readScrollMetrics,
scrollAgentChatToBottom,
scrollChatAwayFromBottom,
@@ -205,6 +206,37 @@ test.describe("Agent stream UI", () => {
await expectScrollStaysFixed(page, baseline);
});
test("keeps tool calls clickable beside the scroll-to-bottom button", async ({ page }) => {
test.setTimeout(60_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "stream-scroll-button-hit-area-",
title: "Scroll button hit area",
model: "ten-second-stream",
initialPrompt: "Stream enough content to exercise the scroll button hit area.",
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await openAgentRoute(page, {
workspaceId: agent.workspaceId,
agentId: agent.agentId,
});
await waitForScrollableChat(page, {
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
timeout: 30_000,
});
const hitArea = await clickToolCallBesideScrollToBottomButton(page);
expect(hitArea).toEqual({
outsideButton: true,
toolCallReceivesPointer: true,
withinButtonBand: true,
});
} finally {
await agent.cleanup();
}
});
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
test.setTimeout(60_000);
const agent = await startRunningMockAgent(page, {

View File

@@ -124,6 +124,111 @@ export async function scrollChatAwayFromBottom(
return readScrollMetrics(page);
}
export async function clickToolCallBesideScrollToBottomButton(page: Page): Promise<{
outsideButton: boolean;
toolCallReceivesPointer: boolean;
withinButtonBand: boolean;
}> {
await scrollChatAwayFromBottom(page, {
deltaY: -900,
minDistanceFromBottom: 300,
});
const scrollToBottomButton = page.getByRole("button", { name: "Scroll to bottom" });
await expect(scrollToBottomButton).toBeVisible();
const buttonBounds = await scrollToBottomButton.boundingBox();
expect(buttonBounds, "Expected visible scroll-to-bottom button bounds").not.toBeNull();
const visibleButtonBounds = buttonBounds!;
const toolCalls = page.locator('[data-testid="tool-call-badge"] [role="button"]');
const toolCallBounds = await Promise.all(
Array.from({ length: await toolCalls.count() }, async (_, index) => ({
index,
bounds: await toolCalls.nth(index).boundingBox(),
})),
);
const buttonCenterY = visibleButtonBounds.y + visibleButtonBounds.height / 2;
const candidate = toolCallBounds
.filter(
(entry): entry is { index: number; bounds: NonNullable<typeof entry.bounds> } =>
entry.bounds !== null && entry.bounds.width > 0,
)
.sort(
(left, right) =>
Math.abs(left.bounds.y + left.bounds.height / 2 - buttonCenterY) -
Math.abs(right.bounds.y + right.bounds.height / 2 - buttonCenterY),
)[0];
expect(
candidate,
`Expected at least one rendered tool-call badge: ${JSON.stringify({
buttonBounds,
scrollMetrics: await readScrollMetrics(page),
toolCallBounds,
})}`,
).toBeDefined();
const visibleToolCall = candidate!;
const initialToolCallCenterY = visibleToolCall.bounds.y + visibleToolCall.bounds.height / 2;
await getVisibleChatScroll(page).evaluate((scroll, deltaY) => {
(scroll as HTMLElement).scrollTop += deltaY;
}, initialToolCallCenterY - buttonCenterY);
const alignedToolCall = toolCalls.nth(visibleToolCall.index);
await expect
.poll(async () => {
const [currentButtonBounds, currentToolCallBounds] = await Promise.all([
scrollToBottomButton.boundingBox(),
alignedToolCall.boundingBox(),
]);
if (!currentButtonBounds || !currentToolCallBounds) {
return false;
}
const toolCallCenterY = currentToolCallBounds.y + currentToolCallBounds.height / 2;
return (
toolCallCenterY >= currentButtonBounds.y &&
toolCallCenterY <= currentButtonBounds.y + currentButtonBounds.height
);
})
.toBe(true);
const [alignedButtonBounds, visibleToolCallBounds] = await Promise.all([
scrollToBottomButton.boundingBox(),
alignedToolCall.boundingBox(),
]);
expect(alignedButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
expect(
visibleToolCallBounds,
"Expected aligned tool-call badge to remain visible",
).not.toBeNull();
const finalButtonBounds = alignedButtonBounds!;
const finalToolCallBounds = visibleToolCallBounds!;
const clickPoint = {
x: finalToolCallBounds.x + 24,
y: finalToolCallBounds.y + finalToolCallBounds.height / 2,
};
const toolCallReceivesPointer = await alignedToolCall.evaluate((toolCall, point) => {
const hit = document.elementFromPoint(point.x, point.y);
return hit !== null && toolCall.contains(hit);
}, clickPoint);
const hitArea = {
clickPoint,
outsideButton:
clickPoint.x < finalButtonBounds.x ||
clickPoint.x > finalButtonBounds.x + finalButtonBounds.width,
toolCallReceivesPointer,
withinButtonBand:
clickPoint.y >= finalButtonBounds.y &&
clickPoint.y <= finalButtonBounds.y + finalButtonBounds.height,
};
await page.mouse.click(hitArea.clickPoint.x, hitArea.clickPoint.y);
return {
outsideButton: hitArea.outsideButton,
toolCallReceivesPointer: hitArea.toolCallReceivesPointer,
withinButtonBand: hitArea.withinButtonBand,
};
}
export async function expectScrollStaysFixed(
page: Page,
baseline: ScrollMetrics,

View File

@@ -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<AgentHandle> {
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()}`,

View File

@@ -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();

View File

@@ -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<typeof launchAgent>[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 });
}
});
}
});

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.106",
"version": "0.1.107",
"private": true,
"main": "index.ts",
"scripts": {

View File

@@ -228,13 +228,20 @@ export interface AgentStreamViewHandle {
export interface AgentStreamViewProps {
agentId: string;
serverId?: string;
agent: AgentScreenAgent;
context: AgentScreenAgent;
streamItems: StreamItem[];
streamHead?: StreamItem[];
pendingPermissions: Map<string, PendingPermission>;
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<AgentStreamViewHandle, AgentStreamVi
{
agentId,
serverId,
agent,
context,
streamItems,
streamHead: providedStreamHead,
pendingPermissions,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
toast,
onOpenWorkspaceFile,
readOnly = false,
historyPagination,
},
ref,
) {
@@ -337,27 +347,37 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const setExplorerTabForCheckout = usePanelStore((state) => 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<AgentStreamViewHandle, AgentStreamVi
return;
}
const normalized = normalizeInlinePathTarget(target.path, agent.cwd);
const normalized = normalizeInlinePathTarget(target.path, context.cwd);
if (!normalized) {
return;
}
@@ -402,10 +422,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return;
}
if (agent.workspaceId) {
if (context.workspaceId) {
navigateToPreparedWorkspaceTab({
serverId: resolvedServerId,
workspaceId: agent.workspaceId,
workspaceId: context.workspaceId,
target: createWorkspaceFileTabTarget(location),
});
}
@@ -419,8 +439,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const checkout = {
serverId: resolvedServerId,
cwd: agent.cwd,
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
cwd: context.cwd,
isGit: context.projectPlacement?.checkout?.isGit ?? true,
};
setExplorerTabForCheckout({ ...checkout, tab: "files" });
openFileExplorerForCheckout({
@@ -444,7 +464,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const draftSetup = buildForkDraftSetup(agent);
const draftSetup = buildForkDraftSetup(context);
const prepareForkDraft = async () => {
const draftId = generateDraftId();
const payload = await client.buildAgentForkContext(
@@ -466,7 +486,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
};
if (target === "tab") {
const workspaceId = agent.workspaceId;
const workspaceId = context.workspaceId;
if (!workspaceId) {
throw new Error(t("message.actions.forkMissingWorkspace"));
}
@@ -481,7 +501,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const draftId = await prepareForkDraft();
const sourceDirectory =
agent.projectPlacement?.checkout?.cwd?.trim() || agent.cwd.trim() || undefined;
context.projectPlacement?.checkout?.cwd?.trim() || context.cwd.trim() || undefined;
if (draftSetup) {
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
draftId,
@@ -493,8 +513,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
buildNewWorkspaceRoute({
serverId: resolvedServerId,
sourceDirectory,
displayName: agent.projectPlacement?.projectName,
projectId: agent.projectPlacement?.projectKey,
displayName: context.projectPlacement?.projectName,
projectId: context.projectPlacement?.projectKey,
draftId,
}),
);
@@ -520,24 +540,24 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const baseRenderModel = useMemo(() => {
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<AgentStreamViewHandle, AgentStreamVi
images={item.images}
attachments={item.attachments}
timestamp={item.timestamp.getTime()}
capabilities={agent.capabilities}
capabilities={context.capabilities}
client={client}
isFirstInGroup={layoutItem.isFirstInUserGroup}
isLastInGroup={layoutItem.isLastInUserGroup}
/>
);
},
[agent.capabilities, agentId, client, resolvedServerId],
[context.capabilities, agentId, client, resolvedServerId],
);
const renderAssistantMessageItem = useCallback(
@@ -668,7 +688,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
error={data.error}
status={data.status}
detail={data.detail}
cwd={agent.cwd}
cwd={context.cwd}
metadata={data.metadata}
isLastInSequence={layoutItem.isLastInToolSequence}
onOpenFilePath={handleToolCallOpenFile}
@@ -690,7 +710,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
/>
);
},
[agent.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
);
const renderStreamItemContent = useCallback(
@@ -747,10 +767,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
content,
layoutItem,
strategy: streamRenderStrategy,
onForkAssistantTurn: handleForkAssistantTurn,
onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn,
});
},
[handleForkAssistantTurn, renderStreamItemContent, streamRenderStrategy],
[handleForkAssistantTurn, readOnly, renderStreamItemContent, streamRenderStrategy],
);
const pendingPermissionItems = useMemo(
@@ -758,7 +778,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[pendingPermissions, agentId],
);
const showRunningTurnFooter = agent.status === "running";
const showRunningTurnFooter = context.status === "running";
const pendingPermissionsNode = useMemo(
() =>
renderPendingPermissionsNode({
@@ -775,11 +795,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
host={bottomTurnFooterHost}
strategy={streamRenderStrategy}
onForkAssistantTurn={handleForkAssistantTurn}
onForkAssistantTurn={readOnly ? undefined : handleForkAssistantTurn}
/>
) : null,
[
handleForkAssistantTurn,
readOnly,
showRunningTurnFooter,
baseRenderModel.turnTiming.runningStartedAt,
bottomTurnFooterHost,
@@ -905,12 +926,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
})}
</MessageOuterSpacingProvider>
{!isNearBottom && (
<Animated.View
style={stylesheet.scrollToBottomContainer}
entering={scrollIndicatorFadeIn}
exiting={scrollIndicatorFadeOut}
>
<View style={stylesheet.scrollToBottomInner}>
<View style={stylesheet.scrollToBottomContainer} pointerEvents="box-none">
<Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}>
<Pressable
style={stylesheet.scrollToBottomButton}
onPress={scrollToBottom}
@@ -920,8 +937,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
>
<ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} />
</Pressable>
</View>
</Animated.View>
</Animated.View>
</View>
)}
</View>
</ToolCallSheetProvider>
@@ -1004,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,
@@ -1011,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)
@@ -1024,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;
}
@@ -1393,13 +1426,6 @@ const stylesheet = StyleSheet.create((theme) => ({
left: 0,
right: 0,
alignItems: "center",
pointerEvents: "box-none",
},
scrollToBottomInner: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
alignSelf: "center",
alignItems: "center",
},
scrollToBottomButton: {
width: 48,

View File

@@ -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<typeof useShortcutKeys>;
theme: SidebarTheme;
buttonRef?: RefObject<View | null>;
}) {
return (
<Pressable
ref={buttonRef}
style={styles.footerIconButton}
testID={testID}
nativeID={testID}
collapsable={false}
accessible
accessibilityLabel={accessibilityLabel}
accessibilityRole="button"
onPress={onPress}
>
{({ hovered }) => (
<Icon
size={iconSize ?? theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
ref={buttonRef}
style={styles.footerIconButton}
testID={testID}
nativeID={testID}
collapsable={false}
accessible
accessibilityLabel={label}
accessibilityRole="button"
onPress={onPress}
>
{({ hovered }) => (
<Icon
size={iconSize ?? theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<IconTooltipContent label={label} shortcutKeys={shortcutKeys} />
</TooltipContent>
</Tooltip>
);
}
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<typeof useShortcutKeys>;
label: string;
}) {
return (
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{label}</Text>
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
</View>
);
}
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 (
<View style={styles.sidebarFooter}>
<View style={styles.footerIconRow}>
<SidebarHostPicker
theme={theme}
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
accessibilityLabel={labels.addProject}
icon={FolderPlus}
theme={theme}
/>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<AddProjectTooltipContent newAgentKeys={newAgentKeys} label={labels.addProject} />
</TooltipContent>
</Tooltip>
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
theme={theme}
/>
<FooterIconButton
onPress={handleHome}
testID="sidebar-home"
accessibilityLabel={labels.home}
label={labels.home}
icon={Home}
theme={theme}
/>
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
accessibilityLabel={labels.settings}
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
theme={theme}
/>
</View>
@@ -876,7 +869,7 @@ function WorkspacesSectionHeader() {
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<HeaderIconTooltipContent label="Search" shortcutKeys={commandCenterKeys} />
<IconTooltipContent label="Search" shortcutKeys={commandCenterKeys} />
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
@@ -886,7 +879,7 @@ function WorkspacesSectionHeader() {
</View>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<HeaderIconTooltipContent label="Display preferences" />
<IconTooltipContent label="Display preferences" />
</TooltipContent>
</Tooltip>
</View>

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { iosMarkdownTextIsSelectable } from "./markdown-text-selection";
describe("markdown text selection", () => {
it("uses plain text only for iOS table cells", () => {
expect({
tableCell: iosMarkdownTextIsSelectable("table-cell"),
prose: iosMarkdownTextIsSelectable("prose"),
}).toEqual({
tableCell: false,
prose: true,
});
});
});

View File

@@ -0,0 +1,17 @@
import { createContext, type ReactNode, useContext } from "react";
export type MarkdownTextSurface = "prose" | "table-cell";
const MarkdownTextSurfaceContext = createContext<MarkdownTextSurface>("prose");
export function MarkdownTableCellText({ children }: { children: ReactNode }) {
return <MarkdownTextSurfaceContext value="table-cell">{children}</MarkdownTextSurfaceContext>;
}
export function useMarkdownTextSurface(): MarkdownTextSurface {
return useContext(MarkdownTextSurfaceContext);
}
export function iosMarkdownTextIsSelectable(surface: MarkdownTextSurface): boolean {
return surface !== "table-cell";
}

View File

@@ -1,7 +1,18 @@
import { useMemo, type ReactNode } from "react";
import { View, type StyleProp, type TextProps, type TextStyle, type ViewStyle } from "react-native";
import {
Text,
View,
type StyleProp,
type TextProps,
type TextStyle,
type ViewStyle,
} from "react-native";
import { UITextView } from "react-native-uitextview";
import { resolvePlainMarkdownTextStyle } from "@/components/markdown-text-style";
import {
iosMarkdownTextIsSelectable,
useMarkdownTextSurface,
} from "@/components/markdown-text-selection";
interface MarkdownTextSpanProps {
style?: StyleProp<TextStyle>;
@@ -29,6 +40,22 @@ export function MarkdownTextSpan({
accessibilityRole,
}: MarkdownTextSpanProps) {
const plainStyle = useMemo(() => resolvePlainMarkdownTextStyle(style), [style]);
const surface = useMarkdownTextSurface();
// Each selectable span creates a UIKit UITextView with a window-level tap recognizer.
// A large table would create one per cell and make every app touch fan out across them.
if (!iosMarkdownTextIsSelectable(surface)) {
return (
<Text
selectable={false}
style={plainStyle}
onPress={onPress}
accessibilityRole={accessibilityRole}
>
{children}
</Text>
);
}
return (
<UITextView

View File

@@ -25,6 +25,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
import { MarkdownTableCellText } from "@/components/markdown-text-selection";
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
import { markdownNodeContainsType } from "@/utils/markdown-ast";
import { createCompactMarkdownStyles, createMarkdownStyles } from "@/styles/markdown-styles";
@@ -661,6 +662,16 @@ export function createSharedMarkdownRules(): RenderRules {
</View>
);
},
th: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
<MarkdownTableCellText key={node.key}>
<View style={styles._VIEW_SAFE_th}>{children}</View>
</MarkdownTableCellText>
),
td: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
<MarkdownTableCellText key={node.key}>
<View style={styles._VIEW_SAFE_td}>{children}</View>
</MarkdownTableCellText>
),
paragraph: (
node: ASTNode,
children: ReactNode[],

View File

@@ -12,6 +12,7 @@ import {
} from "react-native";
import { useTranslation } from "react-i18next";
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
import { MarkdownTableCellText } from "@/components/markdown-text-selection";
import * as React from "react";
import {
useState,
@@ -1825,6 +1826,16 @@ export const AssistantMessage = memo(function AssistantMessage({
</View>
);
},
th: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
<MarkdownTableCellText key={node.key}>
<View style={styles._VIEW_SAFE_th}>{children}</View>
</MarkdownTableCellText>
),
td: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
<MarkdownTableCellText key={node.key}>
<View style={styles._VIEW_SAFE_td}>{children}</View>
</MarkdownTableCellText>
),
paragraph: (
node: ASTNode,
children: ReactNode[],

View File

@@ -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;
}

View File

@@ -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<View | null>;
@@ -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}
</BottomSheetScrollView>
)}
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
</IsolatedBottomSheetModal>
);
}
@@ -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 ? <View style={styles.footer}>{props.footer}</View> : null}
</FloatingSurface>
</View>
</Modal>
@@ -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],

View File

@@ -673,7 +673,7 @@ export function WorkspaceDraftAgentTab({
<AgentStreamView
agentId={tabId}
serverId={serverId}
agent={draftAgent}
context={draftAgent}
streamItems={optimisticStreamItems}
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
onOpenWorkspaceFile={onOpenWorkspaceFile}

View File

@@ -78,6 +78,7 @@ import {
applyLegacyDaemonWorkspaceOwnership,
backfillLegacyDaemonWorkspaceDirectoryIfEmpty,
} from "@/workspace/legacy-daemon-workspaces";
import { useProviderSubagentStore } from "@/subagents/provider-store";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -1349,6 +1350,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
applyTimelineResponse(message.payload);
});
const unsubProviderSubagentUpdate = client.on("agent.provider_subagents.update", (message) => {
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();

View File

@@ -68,10 +68,10 @@ const CATALOG_DATA = [
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.119.2",
version: "2.119.3",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.119.2", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.119.3", "--acp"],
},
{
id: "codewhale",
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.9.5",
version: "0.9.7",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.9.5", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.9.7", "fast-agent-acp", "-x"],
},
{
id: "gemini",

View File

@@ -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");

View File

@@ -782,6 +782,7 @@ export const ar: TranslationResources = {
actions: {
addProject: "إضافة مشروع",
newWorkspace: "مساحة عمل جديدة",
hosts: "المضيفون",
home: "بيت",
settings: "إعدادات",
closeSidebar: "إغلاق الشريط الجانبي",

View File

@@ -789,6 +789,7 @@ export const en = {
actions: {
addProject: "Add project",
newWorkspace: "New workspace",
hosts: "Hosts",
home: "Home",
settings: "Settings",
closeSidebar: "Close sidebar",

View File

@@ -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",

View File

@@ -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",

View File

@@ -794,6 +794,7 @@ export const ja: TranslationResources = {
actions: {
addProject: "プロジェクトを追加",
newWorkspace: "新しいワークスペース",
hosts: "ホスト",
home: "ホーム",
settings: "設定",
closeSidebar: "サイドバーを閉じる",

View File

@@ -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",

View File

@@ -801,6 +801,7 @@ export const ru: TranslationResources = {
actions: {
addProject: "Добавить проект",
newWorkspace: "Новое рабочее пространство",
hosts: "Хосты",
home: "Дом",
settings: "Настройки",
closeSidebar: "Закрыть боковую панель",

View File

@@ -777,6 +777,7 @@ export const zhCN: TranslationResources = {
actions: {
addProject: "添加 project",
newWorkspace: "新建工作区",
hosts: "Hosts",
home: "首页",
settings: "设置",
closeSidebar: "关闭侧边栏",

View File

@@ -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({
<SubagentsTrack
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
onOpenProviderSubagent={handleOpenProviderSubagent}
onArchiveSubagent={handleArchiveSubagent}
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
/>

View File

@@ -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<string, PendingPermission>();
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<AgentScreenAgent>(
() => ({
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 (
<View style={styles.unsupported} testID="provider-subagent-panel-unsupported">
<Text style={styles.unsupportedText}>{t("message.actions.forkUnavailable")}</Text>
</View>
);
}
return (
<View style={styles.container} testID="provider-subagent-panel">
<AgentStreamView
agentId={streamId}
serverId={serverId}
context={streamContext}
streamItems={timeline?.tail ?? EMPTY_STREAM_ITEMS}
streamHead={timeline?.head ?? EMPTY_STREAM_ITEMS}
pendingPermissions={EMPTY_PERMISSIONS}
isAuthoritativeHistoryReady
onOpenWorkspaceFile={openFileInWorkspace}
readOnly
historyPagination={historyPagination}
/>
</View>
);
}
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,
};

View File

@@ -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);

View File

@@ -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 = (
<ThemedFolderPlus size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
);
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 ? <Shortcut chord={openProjectKeys} /> : null),
[openProjectKeys],
);
return (
<ComboboxItem
testID="new-workspace-project-picker-add-project"
label={t("sidebar.actions.addProject")}
onPress={onPress}
leadingSlot={addProjectIcon}
trailingSlot={shortcut}
/>
);
}
function formatPrLabel(item: { number: number; title: string }): string {
return `#${item.number} ${item.title}`;
}
@@ -1357,6 +1394,7 @@ interface NewWorkspaceFormStackInput {
iconDataByProjectKey: Map<string, string | null>;
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(
() => <AddProjectPickerAction onPress={project.onAddProject} />,
[project.onAddProject],
);
const badgePressableStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
@@ -1410,7 +1452,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
<ProjectPickerTrigger
pickerAnchorRef={project.anchorRef}
onPress={project.open}
disabled={isPending || project.options.length === 0}
disabled={isPending}
badgePressableStyle={badgePressableStyle}
label={project.triggerLabel}
projectKey={project.selectedProject?.projectKey ?? null}
@@ -1435,6 +1477,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
anchorRef={project.anchorRef}
emptyText="No projects available."
renderOption={project.renderOption}
footer={addProjectAction}
/>
</View>
);
@@ -1589,6 +1632,7 @@ export function NewWorkspaceScreen({
const [manualPickerSelection, setManualPickerSelection] = useState<PickerSelection | null>(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,

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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)}`;
}

View File

@@ -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<string, unknown>): 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 });
}

View File

@@ -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." }),
]);
});
});

View File

@@ -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<SessionOutboundMessage, { type: "agent.provider_subagents.update" }>["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<number, ProviderSubagentTimelineRow>;
}
interface ProviderSubagentState {
descriptors: Map<string, ProviderSubagentDescriptorPayload>;
timelines: Map<string, ProviderSubagentTimelineState>;
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<DaemonClient, "listProviderSubagents">;
const pendingListRequests = new WeakMap<ProviderSubagentListClient, Map<string, Promise<void>>>();
export function refreshProviderSubagents(
client: ProviderSubagentListClient,
serverId: string,
parentAgentId: string,
): Promise<void> {
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<number, ProviderSubagentTimelineRow>();
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<ProviderSubagentState>((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 };
});
},
}));

View File

@@ -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",

View File

@@ -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<typeof useSessionStore.getState>;
type ProviderSubagentStoreSnapshot = ReturnType<typeof useProviderSubagentStore.getState>;
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]);
}

View File

@@ -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<SubagentRow> & Pick<SubagentRow, "id">): SubagentRow {
function row(
overrides: Partial<PaseoSubagentRow> & Pick<PaseoSubagentRow, "id">,
): 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", () => {

View File

@@ -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,
}),
};

View File

@@ -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({
<Text style={styles.rowLabel} numberOfLines={1}>
{displayLabel}
</Text>
<SubagentRowActions
rowId={row.id}
displayLabel={displayLabel}
visible={actionsVisible}
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
onArchivePress={handleArchivePress}
/>
{row.kind === "paseo" ? (
<SubagentRowActions
rowId={row.id}
displayLabel={displayLabel}
visible={actionsVisible}
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
onArchivePress={handleArchivePress}
/>
) : null}
</View>
)}
</Pressable>

View File

@@ -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,
});
});
});

View File

@@ -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<string, TerminalViewportSize>();
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;
}

View File

@@ -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);
});
});

View File

@@ -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}`;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.106",
"version": "0.1.107",
"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",
"@getpaseo/protocol": "0.1.106",
"@getpaseo/server": "0.1.106",
"@getpaseo/client": "0.1.107",
"@getpaseo/protocol": "0.1.107",
"@getpaseo/server": "0.1.107",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.1.106",
"version": "0.1.107",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -35,8 +35,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.1.106",
"@getpaseo/relay": "0.1.106",
"@getpaseo/protocol": "0.1.107",
"@getpaseo/relay": "0.1.107",
"zod": "^4.4.3"
},
"devDependencies": {

View File

@@ -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: {

View File

@@ -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<ProviderSubagentListPayload> {
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<ProviderSubagentTimelinePayload> {
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<CreateTerminalPayload> {
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 } : {}),

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.106",
"version": "0.1.107",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.106",
"version": "0.1.107",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.106",
"version": "0.1.107",
"files": [
"dist",
"!dist/**/*.map"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.1.106",
"version": "0.1.107",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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" }] } });
});
});

View File

@@ -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()

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.106",
"version": "0.1.107",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.106",
"version": "0.1.107",
"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",
"@getpaseo/highlight": "0.1.106",
"@getpaseo/protocol": "0.1.106",
"@getpaseo/relay": "0.1.106",
"@getpaseo/client": "0.1.107",
"@getpaseo/highlight": "0.1.107",
"@getpaseo/protocol": "0.1.107",
"@getpaseo/relay": "0.1.107",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",

View File

@@ -30,7 +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_BYTES = 64 * 1024;
const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024;
const TEST_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: false,
@@ -385,7 +385,7 @@ describe("target coalesced behavior", () => {
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
});
session.pushEvent(timelineEvent(toolCall({ status: "completed", output })));
@@ -403,27 +403,6 @@ describe("target coalesced behavior", () => {
}
});
test("bounds tool output by UTF-8 bytes", async () => {
const harness = createHarness();
try {
const { agentId, session } = await createManagedSession(harness);
const output = "漢".repeat(TOOL_CALL_CONTENT_MAX_BYTES);
const expectedItem = toolCall({
status: "completed",
output: "漢".repeat(Math.floor(TOOL_CALL_CONTENT_MAX_BYTES / 3)),
});
session.pushEvent(timelineEvent(toolCall({ status: "completed", output })));
await waitForSessionEventQueue();
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
expectedItem,
]);
} finally {
harness.cleanup();
}
});
test("bounds appended tool output before persisting and streaming it", async () => {
const harness = createHarness();
try {
@@ -431,7 +410,7 @@ describe("target coalesced behavior", () => {
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
});
await harness.manager.appendTimelineItem(agentId, toolCall({ status: "completed", output }));
@@ -455,7 +434,7 @@ describe("target coalesced behavior", () => {
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
});
session.setHistory([timelineEvent(toolCall({ status: "completed", output }))]);
@@ -476,7 +455,7 @@ describe("target coalesced behavior", () => {
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
});
await harness.manager.emitLiveTimelineItem(
@@ -494,22 +473,17 @@ describe("target coalesced behavior", () => {
}
});
test("bounds failed shell output carried in error content and message", async () => {
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_BYTES),
message: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
},
error: { content: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH) },
});
session.pushEvent(
timelineEvent(toolCall({ status: "failed", error: { content, message: content } })),
);
session.pushEvent(timelineEvent(toolCall({ status: "failed", error: { content } })));
await waitForSessionEventQueue();
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([

View File

@@ -2331,6 +2331,27 @@ test("importProviderSession imports the selected session without listing and pub
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" },
},
},
],
};
}
}
@@ -2373,7 +2394,13 @@ test("importProviderSession imports the selected session without listing and pub
},
},
]);
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: {
@@ -2553,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<AgentSession> {
activeSession = new TestAgentSession(config);
return activeSession;
}
override async resumeSession(
_handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
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<AgentStreamEvent> {
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<AgentSession> {
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<AgentSession> {
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");
@@ -5006,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<AgentSession> {
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-"));

View File

@@ -66,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;
@@ -145,6 +150,7 @@ export type {
export type AgentManagerEvent =
| { type: "agent_state"; agent: ManagedAgent }
| { type: "provider_subagent"; event: ProviderSubagentStoreEvent }
| {
type: "agent_stream";
agentId: string;
@@ -534,6 +540,7 @@ export class AgentManager {
private readonly providerEnabled = new Map<AgentProvider, boolean>();
private readonly agents = new Map<string, LiveManagedAgent>();
private readonly timelineStore = new InMemoryAgentTimelineStore();
private readonly providerSubagents = new ProviderSubagentStore();
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
private readonly sessionEventTails = new Map<string, Promise<void>>();
private readonly foregroundRuns = new ForegroundRunState();
@@ -936,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,
@@ -1086,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,
@@ -1096,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);
@@ -1168,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.
@@ -1261,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(
@@ -2837,6 +2877,11 @@ export class AgentManager {
agent: ActiveManagedAgent,
event: AgentStreamEvent,
): Promise<void> {
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(
@@ -2975,49 +3020,79 @@ export class AgentManager {
}
if (options?.force) {
const historyEvents: Extract<AgentStreamEvent, { type: "timeline" }>[] = [];
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 item = limitAgentTimelineItemContent(event.item);
const row = this.recordTimeline(
agent.id,
item,
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
if (options?.broadcast) {
this.dispatchStream(
agent.id,
{ ...event, item },
{
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<void> {
const historyEvents: Extract<AgentStreamEvent, { type: "timeline" }>[] = [];
const providerSubagentEvents: Extract<AgentStreamEvent, { type: "provider_subagent" }>[] = [];
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<void> {
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;
}
@@ -3793,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 = {},
@@ -4031,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 {

View File

@@ -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<AgentStreamEvent, { type: "provider_subagent" }>[];
}
export interface AgentSessionConfig {

View File

@@ -1,15 +1,6 @@
import { StringDecoder } from "node:string_decoder";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
const TOOL_CALL_CONTENT_MAX_BYTES = 64 * 1024;
function limitTextContent(value: string): string {
if (Buffer.byteLength(value, "utf8") <= TOOL_CALL_CONTENT_MAX_BYTES) {
return value;
}
const bytes = Buffer.from(value, "utf8").subarray(0, TOOL_CALL_CONTENT_MAX_BYTES);
return new StringDecoder("utf8").write(bytes);
}
const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024;
function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
if (
@@ -17,30 +8,19 @@ function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
item.detail.type !== "shell" ||
item.status !== "failed" ||
typeof item.error !== "object" ||
item.error === null
item.error === null ||
!("content" in item.error) ||
typeof item.error.content !== "string" ||
item.error.content.length <= TOOL_CALL_CONTENT_MAX_LENGTH
) {
return item;
}
const error: Record<string, unknown> = { ...item.error };
let changed = false;
for (const key of ["content", "message"] as const) {
const value = error[key];
if (typeof value !== "string") {
continue;
}
const limitedValue = limitTextContent(value);
if (limitedValue !== value) {
error[key] = limitedValue;
changed = true;
}
}
if (!changed) {
return item;
}
return {
...item,
error,
error: {
...item.error,
content: item.error.content.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH),
},
};
}
@@ -53,15 +33,14 @@ export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTim
) {
return item;
}
const output = limitTextContent(item.detail.output);
if (output === item.detail.output) {
if (item.detail.output.length <= TOOL_CALL_CONTENT_MAX_LENGTH) {
return item;
}
return {
...item,
detail: {
...item.detail,
output,
output: item.detail.output.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH),
},
};
}

View File

@@ -96,6 +96,32 @@ describe("resolveAndValidateCreateAgentMode", () => {
);
});
it("uses the provider default when the cross-provider target has no modes", () => {
const resolved = resolveAndValidateCreateAgentMode({
requestedMode: undefined,
targetProvider: "pi",
parent: agentParent("codex", "auto"),
unattended: false,
availableModes: [],
targetUnattendedMode: undefined,
});
expect(resolved).toBeUndefined();
});
it("uses the provider default when an unattended parent targets a provider with no modes", () => {
const resolved = resolveAndValidateCreateAgentMode({
requestedMode: undefined,
targetProvider: "pi",
parent: agentParent("claude", "bypassPermissions", true),
unattended: false,
availableModes: [],
targetUnattendedMode: undefined,
});
expect(resolved).toBeUndefined();
});
it("passes through an explicit mode when the target provider's modes are unknown", () => {
const resolved = resolveAndValidateCreateAgentMode({
requestedMode: "default",

View File

@@ -14,6 +14,7 @@ export interface ResolveCreateAgentModeInput {
unattended: boolean;
// `undefined` = target provider's modes unknown: explicit modes pass through
// unvalidated, but cross-provider inheritance is still refused.
// `[]` = target provider explicitly has no modes: use its default behavior.
availableModes: string[] | undefined;
// Target provider's own unattended mode id, if it has one. Used to bridge
// unattended parents into unattended children across providers.
@@ -71,6 +72,10 @@ export function resolveAndValidateCreateAgentMode(
return input.targetUnattendedMode;
}
if (availableModes?.length === 0) {
return undefined;
}
throw new Error(
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
);

View File

@@ -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<AgentStreamEvent>,
): Promise<ImportedTimelineEntry[]> {
async function collectImportedHistory(events: AsyncGenerator<AgentStreamEvent>): Promise<{
timeline: ImportedTimelineEntry[];
providerSubagentEvents: Extract<AgentStreamEvent, { type: "provider_subagent" }>[];
}> {
const timeline: ImportedTimelineEntry[] = [];
const providerSubagentEvents: Extract<AgentStreamEvent, { type: "provider_subagent" }>[] = [];
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 };
}

View File

@@ -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);
});
});

View File

@@ -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<string, ProviderSubagentDescriptor>();
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;
}
}

View File

@@ -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<PromptResponse> };
@@ -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<ACPSessionInternals>(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<ACPSessionInternals>(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 },

View File

@@ -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<string, unknown>;
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<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
): void {
this.activeForegroundTurnId = null;
this.fallbackAssistantMessageId = null;
if (this.activeSubmittedUserMessage?.turnId === event.turnId) {
this.activeSubmittedUserMessage = null;
}

View File

@@ -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<string, unknown> } | 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)));

View File

@@ -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<AgentStreamEvent> {
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<AgentMode[]> {
@@ -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<SDKMessage, { type: "user" }>,
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<string, ClaudeHistoricalSubagentToolCall> {
const toolCalls = new Map<string, ClaudeHistoricalSubagentToolCall>();
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<string, { toolCallId: string; failed: boolean }> {
const results = new Map<string, { toolCallId: string; failed: boolean }>();
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<string, ClaudeHistoryEntry[]> {
const entriesByAgentId = new Map<string, ClaudeHistoryEntry[]>();
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<AgentStreamEvent, { type: "provider_subagent" }>[] {
const events: Extract<AgentStreamEvent, { type: "provider_subagent" }>[] = [];
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<string, ClaudeHistoricalSubagentToolCall>,
toolResults: ReadonlyMap<string, { toolCallId: string; failed: boolean }>,
convertEntry: (entry: ClaudeHistoryEntry) => AgentTimelineItem[],
): Extract<AgentStreamEvent, { type: "provider_subagent" }>[] {
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<AgentStreamEvent, { type: "provider_subagent" }>[] = [
{
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;

View File

@@ -15,6 +15,7 @@ let lastQuery: ReturnType<typeof buildSdkQueryMock> | 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({

View File

@@ -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<string, number>;
completedActionKeys: Set<string>;
}
interface SubAgentActionCandidate {
@@ -64,19 +70,34 @@ export class ClaudeSidechainTracker {
actionKeys: [],
nextActionIndex: 1,
actionIndexByKey: new Map<string, number>(),
completedActionKeys: new Set<string>(),
} 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<string, unknown>;
const messageContainer = messageRecord.message as Record<string, unknown> | 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);
}
}

View File

@@ -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",

View File

@@ -3061,6 +3061,7 @@ interface CodexSubAgentCallState {
pendingFileChangeOutputDeltas: Map<string, string[]>;
childItemOrder: string[];
childItems: Map<string, AgentTimelineItem>;
childThreadIds: Set<string>;
}
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<string, AgentPermissionRequest>();
private mcpElicitationPermissionIds = new Map<number, string>();
private pendingPermissionHandlers = new Map<
@@ -3105,6 +3108,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private emittedExecCommandCompletedCallIds = new Set<string>();
private emittedItemStartedIds = new Set<string>();
private emittedItemCompletedIds = new Set<string>();
private emittedProviderSubagentUserMessageKeys = new Set<string>();
private subAgentCallsByCallId = new Map<string, CodexSubAgentCallState>();
private subAgentCallIdByChildThreadId = new Map<string, string>();
private pendingSubAgentNotificationsByThreadId = new Map<string, ParsedCodexNotification[]>();
@@ -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<void> {
const queue = rootRoutes.map((route) => ({ route, parentCallId: null as string | null }));
const visitedThreadIds = new Set<string>();
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<void> {
if (!this.client || !this.currentThreadId) return;
try {
@@ -3818,12 +3860,20 @@ export class CodexAppServerAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
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<string, string[]>(),
childItemOrder: [],
childItems: new Map<string, AgentTimelineItem>(),
childThreadIds: new Set<string>(),
} 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<ParsedCodexNotification, { kind: "item_completed" }>,
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<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>,
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<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>,
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);
}

View File

@@ -51,23 +51,26 @@ describe("codex tool-call mapper", () => {
});
});
it("unwraps shell wrapper strings for commandExecution", () => {
const item = expectMapped(
mapCodexToolCallFromThreadItem({
type: "commandExecution",
id: "codex-call-wrapper-string",
status: "running",
command: '/bin/zsh -lc "echo hello"',
cwd: "/tmp/repo",
}),
);
it.each(['/bin/zsh -lc "echo hello"', '/usr/bin/zsh -lc "echo hello"'])(
"unwraps zsh wrapper strings for commandExecution: %s",
(command) => {
const item = expectMapped(
mapCodexToolCallFromThreadItem({
type: "commandExecution",
id: "codex-call-wrapper-string",
status: "running",
command,
cwd: "/tmp/repo",
}),
);
expect(item.detail).toEqual({
type: "shell",
command: "echo hello",
cwd: "/tmp/repo",
});
});
expect(item.detail).toEqual({
type: "shell",
command: "echo hello",
cwd: "/tmp/repo",
});
},
);
it("unwraps pwsh wrapper strings for commandExecution on Windows", () => {
const item = expectMapped(

View File

@@ -221,7 +221,9 @@ const CodexThreadItemSchema = z.discriminatedUnion("type", [
function maybeUnwrapShellWrapperCommand(command: string): string {
const trimmed = command.trim();
const unixWrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/);
const unixWrapperMatch = trimmed.match(
/^(?:(?:\/[^/\s]+)*\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/,
);
if (unixWrapperMatch) {
const candidate = unixWrapperMatch[1]?.trim() ?? "";
if (!candidate) {

View File

@@ -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: {},

View File

@@ -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,

File diff suppressed because it is too large Load Diff

View File

@@ -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]);

View File

@@ -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([]);

View File

@@ -29,6 +29,7 @@ export interface OpenCodeServerManagerLike {
acquireCurrent(): Promise<OpenCodeServerAcquisition>;
acquireNew(): Promise<OpenCodeServerAcquisition>;
acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition>;
acquireExisting(url: string): OpenCodeServerAcquisition | null;
shutdown(): Promise<void>;
}
@@ -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;

View File

@@ -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<string, string>;
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<string, string>;
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 {

View File

@@ -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<string, string>;
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<string, string>;
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<OpenCodeResponse>) | null = null;
sessionGetResponse: OpenCodeResponse = {
data: { id: "session-1", directory: "/workspace/repo", title: null },
};
@@ -219,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;

View File

@@ -82,6 +82,29 @@ test("forwards launch-context env to the Pi process launch", async () => {
await session.close();
});
test("starts internal Pi agents without persisting a native session", async () => {
const pi = new FakePi();
const client = createClient(pi);
const session = await client.createSession(createConfig({ internal: true }));
expect(pi.recordedLaunches[0]).toMatchObject({
noSession: true,
argv: expect.arrayContaining(["--no-session"]),
});
await session.close();
});
test("keeps normal Pi agent sessions persisted", async () => {
const pi = new FakePi();
const client = createClient(pi);
const session = await client.createSession(createConfig());
expect(pi.recordedLaunches[0]?.argv).not.toContain("--no-session");
await session.close();
});
class SessionEvents {
private readonly events: AgentStreamEvent[] = [];
private readonly waiters: Array<{
@@ -138,6 +161,13 @@ class SessionEvents {
);
}
nextTurnCancellation(): Promise<Extract<AgentStreamEvent, { type: "turn_canceled" }>> {
return this.nextEvent(
(event): event is Extract<AgentStreamEvent, { type: "turn_canceled" }> =>
event.type === "turn_canceled",
);
}
nextPermissionRequest(): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> {
return this.nextEvent(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
@@ -419,10 +449,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 +486,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 +508,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 +602,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();

View File

@@ -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<void> {
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<void> {
@@ -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<PiAgentSessionEvent, { type: "message_start" }>): void {
if (event.message.role === "assistant") {
this.activeAssistantMessageId = event.message.responseId || null;
}
}
private handleMessageEnd(
event: Extract<PiAgentSessionEvent, { type: "message_end" }>,
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({
@@ -1972,6 +1996,7 @@ export class PiRpcAgentClient implements AgentClient {
model: config.model,
thinkingOptionId:
normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL,
noSession: config.internal === true,
systemPrompt: composeSystemPromptParts(
config.systemPrompt,
config.daemonAppendSystemPrompt,

View File

@@ -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",

View File

@@ -44,6 +44,7 @@ export async function* streamPiHistory(
): AsyncGenerator<AgentStreamEvent> {
const pendingToolCalls = new Map<string, PiTrackedToolCall>();
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;
}

View File

@@ -15,6 +15,7 @@ export interface PiRuntimeLaunch {
model?: string;
thinkingOptionId?: string;
session?: string;
noSession?: boolean;
systemPrompt?: string;
mcpConfigPath?: string;
extensionPaths?: string[];
@@ -26,6 +27,7 @@ export interface PiStartSessionInput {
model?: string;
thinkingOptionId?: string;
session?: string;
noSession?: boolean;
systemPrompt?: string;
mcpConfigPath?: string;
extensionPaths?: string[];
@@ -79,7 +81,9 @@ export function buildPiLaunch(input: {
if (input.session.thinkingOptionId) {
argv.push("--thinking", input.session.thinkingOptionId);
}
if (input.session.session) {
if (input.session.noSession) {
argv.push("--no-session");
} else if (input.session.session) {
argv.push("--session", input.session.session);
}
const systemPrompt = input.session.systemPrompt?.trim();
@@ -106,6 +110,7 @@ export function buildPiLaunch(input: {
model: input.session.model,
thinkingOptionId: input.session.thinkingOptionId,
session: input.session.session,
noSession: input.session.noSession,
systemPrompt,
mcpConfigPath: input.session.mcpConfigPath,
extensionPaths: input.session.extensionPaths,

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