mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
19 Commits
android-ch
...
v0.1.107
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9553f4b328 | ||
|
|
fc7c753382 | ||
|
|
96e68fea76 | ||
|
|
1a4d7852a8 | ||
|
|
c2a1ac7c3b | ||
|
|
4a1534cacd | ||
|
|
a849bc6bda | ||
|
|
a788a0f843 | ||
|
|
a1581e66b0 | ||
|
|
2658132384 | ||
|
|
ec93ca866e | ||
|
|
66445adc07 | ||
|
|
41d882859c | ||
|
|
88397655f7 | ||
|
|
18de06c2a4 | ||
|
|
a9ba0392b7 | ||
|
|
cf6c014b6b | ||
|
|
e18cfb7639 | ||
|
|
c05e337cde |
25
CHANGELOG.md
25
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
42
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}`,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
84
packages/app/e2e/provider-subagents.real.spec.ts
Normal file
84
packages/app/e2e/provider-subagents.real.spec.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.106",
|
||||
"version": "0.1.107",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
14
packages/app/src/components/markdown-text-selection.test.ts
Normal file
14
packages/app/src/components/markdown-text-selection.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
17
packages/app/src/components/markdown-text-selection.tsx
Normal file
17
packages/app/src/components/markdown-text-selection.tsx
Normal 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";
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -673,7 +673,7 @@ export function WorkspaceDraftAgentTab({
|
||||
<AgentStreamView
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
agent={draftAgent}
|
||||
context={draftAgent}
|
||||
streamItems={optimisticStreamItems}
|
||||
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -782,6 +782,7 @@ export const ar: TranslationResources = {
|
||||
actions: {
|
||||
addProject: "إضافة مشروع",
|
||||
newWorkspace: "مساحة عمل جديدة",
|
||||
hosts: "المضيفون",
|
||||
home: "بيت",
|
||||
settings: "إعدادات",
|
||||
closeSidebar: "إغلاق الشريط الجانبي",
|
||||
|
||||
@@ -789,6 +789,7 @@ export const en = {
|
||||
actions: {
|
||||
addProject: "Add project",
|
||||
newWorkspace: "New workspace",
|
||||
hosts: "Hosts",
|
||||
home: "Home",
|
||||
settings: "Settings",
|
||||
closeSidebar: "Close sidebar",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -794,6 +794,7 @@ export const ja: TranslationResources = {
|
||||
actions: {
|
||||
addProject: "プロジェクトを追加",
|
||||
newWorkspace: "新しいワークスペース",
|
||||
hosts: "ホスト",
|
||||
home: "ホーム",
|
||||
settings: "設定",
|
||||
closeSidebar: "サイドバーを閉じる",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -801,6 +801,7 @@ export const ru: TranslationResources = {
|
||||
actions: {
|
||||
addProject: "Добавить проект",
|
||||
newWorkspace: "Новое рабочее пространство",
|
||||
hosts: "Хосты",
|
||||
home: "Дом",
|
||||
settings: "Настройки",
|
||||
closeSidebar: "Закрыть боковую панель",
|
||||
|
||||
@@ -777,6 +777,7 @@ export const zhCN: TranslationResources = {
|
||||
actions: {
|
||||
addProject: "添加 project",
|
||||
newWorkspace: "新建工作区",
|
||||
hosts: "Hosts",
|
||||
home: "首页",
|
||||
settings: "设置",
|
||||
closeSidebar: "关闭侧边栏",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
190
packages/app/src/panels/provider-subagent-panel.tsx
Normal file
190
packages/app/src/panels/provider-subagent-panel.tsx
Normal 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,
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
362
packages/app/src/subagents/provider-store.test.ts
Normal file
362
packages/app/src/subagents/provider-store.test.ts
Normal 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." }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
304
packages/app/src/subagents/provider-store.ts
Normal file
304
packages/app/src/subagents/provider-store.ts
Normal 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 };
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -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",
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
50
packages/app/src/terminal/runtime/terminal-size-cache.ts
Normal file
50
packages/app/src/terminal/runtime/terminal-size-cache.ts
Normal 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;
|
||||
}
|
||||
45
packages/app/src/workspace-tabs/identity.test.ts
Normal file
45
packages/app/src/workspace-tabs/identity.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.106",
|
||||
"version": "0.1.107",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
34
packages/protocol/src/messages.create-terminal-size.test.ts
Normal file
34
packages/protocol/src/messages.create-terminal-size.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
76
packages/protocol/src/messages.provider-subagents.test.ts
Normal file
76
packages/protocol/src/messages.provider-subagents.test.ts
Normal 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" }] } });
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
168
packages/server/src/server/agent/provider-subagents/store.ts
Normal file
168
packages/server/src/server/agent/provider-subagents/store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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]);
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user