mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 13:42:50 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c05f69402 | ||
|
|
8fdc41f06c | ||
|
|
928c124356 | ||
|
|
0a398833d8 |
@@ -1,30 +1,5 @@
|
|||||||
# Changelog
|
# 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
|
## 0.1.106 - 2026-07-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+2
-10
@@ -71,21 +71,13 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**:
|
|||||||
|
|
||||||
## The subagents track
|
## The subagents track
|
||||||
|
|
||||||
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`) combines two kinds of children:
|
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`):
|
||||||
|
|
||||||
- **Paseo subagents** are full managed agents. Their membership rule (`packages/app/src/subagents/select.ts`) is:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
parentAgentId === thisAgent.id AND !archivedAt
|
parentAgentId === thisAgent.id AND !archivedAt
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track.
|
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.
|
||||||
|
|
||||||
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.
|
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,37 +457,6 @@ 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
|
### 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.
|
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.
|
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
|
||||||
|
|
||||||
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
|
Tool output is bounded before it enters either delivery path. Canonical shell tool output and failed
|
||||||
to 64 KiB, and the same bounded item is used for durable timeline rows and live stream events.
|
shell error text are capped at 64 KiB of UTF-8 data, and the same bounded item is used for durable
|
||||||
Provider history hydration applies the same rule so reopening an agent cannot restore an oversized
|
timeline rows and live stream events. Provider history hydration applies the same rule so reopening
|
||||||
tool payload.
|
an agent cannot restore an oversized tool payload.
|
||||||
|
|
||||||
## Presence is not delivery
|
## Presence is not delivery
|
||||||
|
|
||||||
|
|||||||
Generated
+21
-21
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -35152,7 +35152,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -36170,12 +36170,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.1.107",
|
"@getpaseo/client": "0.1.106",
|
||||||
"@getpaseo/protocol": "0.1.107",
|
"@getpaseo/protocol": "0.1.106",
|
||||||
"@getpaseo/server": "0.1.107",
|
"@getpaseo/server": "0.1.106",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -36421,10 +36421,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.1.107",
|
"@getpaseo/protocol": "0.1.106",
|
||||||
"@getpaseo/relay": "0.1.107",
|
"@getpaseo/relay": "0.1.106",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -36435,7 +36435,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -36678,7 +36678,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -37574,7 +37574,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "^6.12.3",
|
"@codemirror/language": "^6.12.3",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -37806,7 +37806,7 @@
|
|||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -37819,7 +37819,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -38037,15 +38037,15 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.1.107",
|
"@getpaseo/client": "0.1.106",
|
||||||
"@getpaseo/highlight": "0.1.107",
|
"@getpaseo/highlight": "0.1.106",
|
||||||
"@getpaseo/protocol": "0.1.107",
|
"@getpaseo/protocol": "0.1.106",
|
||||||
"@getpaseo/relay": "0.1.107",
|
"@getpaseo/relay": "0.1.106",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
@@ -38582,7 +38582,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.29.1",
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
"@cloudflare/workers-types": "^4.20260317.1",
|
"@cloudflare/workers-types": "^4.20260317.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
} from "./helpers/agent-stream";
|
} from "./helpers/agent-stream";
|
||||||
import {
|
import {
|
||||||
expectScrollStaysFixed,
|
expectScrollStaysFixed,
|
||||||
clickToolCallBesideScrollToBottomButton,
|
|
||||||
readScrollMetrics,
|
readScrollMetrics,
|
||||||
scrollAgentChatToBottom,
|
scrollAgentChatToBottom,
|
||||||
scrollChatAwayFromBottom,
|
scrollChatAwayFromBottom,
|
||||||
@@ -206,37 +205,6 @@ test.describe("Agent stream UI", () => {
|
|||||||
await expectScrollStaysFixed(page, baseline);
|
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("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
const agent = await startRunningMockAgent(page, {
|
const agent = await startRunningMockAgent(page, {
|
||||||
|
|||||||
@@ -124,111 +124,6 @@ export async function scrollChatAwayFromBottom(
|
|||||||
return readScrollMetrics(page);
|
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(
|
export async function expectScrollStaysFixed(
|
||||||
page: Page,
|
page: Page,
|
||||||
baseline: ScrollMetrics,
|
baseline: ScrollMetrics,
|
||||||
|
|||||||
@@ -154,10 +154,6 @@ export async function launchAgent(input: {
|
|||||||
provider: RewindFlowProvider;
|
provider: RewindFlowProvider;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
mode: "full-access";
|
mode: "full-access";
|
||||||
providerConfig?: {
|
|
||||||
model?: string;
|
|
||||||
extra?: { codex?: { features?: { multi_agent_v2?: boolean } } };
|
|
||||||
};
|
|
||||||
}): Promise<AgentHandle> {
|
}): Promise<AgentHandle> {
|
||||||
execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" });
|
execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" });
|
||||||
execFileSync("git", ["config", "user.email", "paseo-test@example.com"], {
|
execFileSync("git", ["config", "user.email", "paseo-test@example.com"], {
|
||||||
@@ -184,7 +180,6 @@ export async function launchAgent(input: {
|
|||||||
}
|
}
|
||||||
const agent = await client.createAgent({
|
const agent = await client.createAgent({
|
||||||
...fullAccessConfig(input.provider),
|
...fullAccessConfig(input.provider),
|
||||||
...input.providerConfig,
|
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
workspaceId: createdWorkspace.workspace.id,
|
workspaceId: createdWorkspace.workspace.id,
|
||||||
title: `rewind-flow-${input.provider}-${randomUUID()}`,
|
title: `rewind-flow-${input.provider}-${randomUUID()}`,
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ import {
|
|||||||
hasGithubAuth,
|
hasGithubAuth,
|
||||||
} from "./helpers/github-fixtures";
|
} from "./helpers/github-fixtures";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
|
||||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
|
||||||
import {
|
import {
|
||||||
expectSidebarWorkspaceSelected,
|
expectSidebarWorkspaceSelected,
|
||||||
expectWorkspaceHeader,
|
expectWorkspaceHeader,
|
||||||
@@ -211,53 +209,6 @@ test.describe("New workspace flow", () => {
|
|||||||
await client?.close().catch(() => undefined);
|
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 }) => {
|
test("sidebar workspace navigation updates URL and header", async ({ page }) => {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
|
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
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",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -228,20 +228,13 @@ export interface AgentStreamViewHandle {
|
|||||||
export interface AgentStreamViewProps {
|
export interface AgentStreamViewProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
context: AgentScreenAgent;
|
agent: AgentScreenAgent;
|
||||||
streamItems: StreamItem[];
|
streamItems: StreamItem[];
|
||||||
streamHead?: StreamItem[];
|
|
||||||
pendingPermissions: Map<string, PendingPermission>;
|
pendingPermissions: Map<string, PendingPermission>;
|
||||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||||
isAuthoritativeHistoryReady?: boolean;
|
isAuthoritativeHistoryReady?: boolean;
|
||||||
toast?: ToastApi | null;
|
toast?: ToastApi | null;
|
||||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||||
readOnly?: boolean;
|
|
||||||
historyPagination?: {
|
|
||||||
hasOlder: boolean;
|
|
||||||
isLoadingOlder: boolean;
|
|
||||||
onLoadOlder: () => void;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
|
const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
|
||||||
@@ -313,16 +306,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
{
|
{
|
||||||
agentId,
|
agentId,
|
||||||
serverId,
|
serverId,
|
||||||
context,
|
agent,
|
||||||
streamItems,
|
streamItems,
|
||||||
streamHead: providedStreamHead,
|
|
||||||
pendingPermissions,
|
pendingPermissions,
|
||||||
routeBottomAnchorRequest = null,
|
routeBottomAnchorRequest = null,
|
||||||
isAuthoritativeHistoryReady = true,
|
isAuthoritativeHistoryReady = true,
|
||||||
toast,
|
toast,
|
||||||
onOpenWorkspaceFile,
|
onOpenWorkspaceFile,
|
||||||
readOnly = false,
|
|
||||||
historyPagination,
|
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
@@ -347,37 +337,27 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||||
|
|
||||||
// Get serverId (fallback to agent's serverId if not provided)
|
// Get serverId (fallback to agent's serverId if not provided)
|
||||||
const resolvedServerId = serverId ?? context.serverId ?? "";
|
const resolvedServerId = serverId ?? agent.serverId ?? "";
|
||||||
|
|
||||||
const client = useSessionStore((state) => state.sessions[resolvedServerId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[resolvedServerId]?.client ?? null);
|
||||||
const sessionStreamHead = useSessionStore((state) =>
|
const streamHead = useSessionStore((state) =>
|
||||||
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId),
|
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId),
|
||||||
);
|
);
|
||||||
const streamHead = providedStreamHead ?? sessionStreamHead;
|
|
||||||
const supportsAgentForkContext = useSessionStore(
|
const supportsAgentForkContext = useSessionStore(
|
||||||
(state) =>
|
(state) => state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true,
|
||||||
!readOnly &&
|
|
||||||
state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const workspaceRoot = context.cwd?.trim() || "";
|
const workspaceRoot = agent.cwd?.trim() || "";
|
||||||
const { requestDirectoryListing } = useFileExplorerActions({
|
const { requestDirectoryListing } = useFileExplorerActions({
|
||||||
serverId: resolvedServerId,
|
serverId: resolvedServerId,
|
||||||
workspaceId: context.workspaceId,
|
workspaceId: agent.workspaceId,
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
});
|
});
|
||||||
const agentHistoryPagination = useLoadOlderAgentHistory({
|
const { isLoadingOlder, hasOlder, loadOlder } = useLoadOlderAgentHistory({
|
||||||
serverId: resolvedServerId,
|
serverId: resolvedServerId,
|
||||||
agentId,
|
agentId,
|
||||||
toast,
|
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
|
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
|
||||||
// tracked in react-native-reanimated#8422.
|
// tracked in react-native-reanimated#8422.
|
||||||
const shouldDisableEntryExitAnimations = Platform.OS === "android";
|
const shouldDisableEntryExitAnimations = Platform.OS === "android";
|
||||||
@@ -399,7 +379,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = normalizeInlinePathTarget(target.path, context.cwd);
|
const normalized = normalizeInlinePathTarget(target.path, agent.cwd);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -422,10 +402,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.workspaceId) {
|
if (agent.workspaceId) {
|
||||||
navigateToPreparedWorkspaceTab({
|
navigateToPreparedWorkspaceTab({
|
||||||
serverId: resolvedServerId,
|
serverId: resolvedServerId,
|
||||||
workspaceId: context.workspaceId,
|
workspaceId: agent.workspaceId,
|
||||||
target: createWorkspaceFileTabTarget(location),
|
target: createWorkspaceFileTabTarget(location),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -439,8 +419,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
|
|
||||||
const checkout = {
|
const checkout = {
|
||||||
serverId: resolvedServerId,
|
serverId: resolvedServerId,
|
||||||
cwd: context.cwd,
|
cwd: agent.cwd,
|
||||||
isGit: context.projectPlacement?.checkout?.isGit ?? true,
|
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
|
||||||
};
|
};
|
||||||
setExplorerTabForCheckout({ ...checkout, tab: "files" });
|
setExplorerTabForCheckout({ ...checkout, tab: "files" });
|
||||||
openFileExplorerForCheckout({
|
openFileExplorerForCheckout({
|
||||||
@@ -464,7 +444,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||||
}
|
}
|
||||||
const draftSetup = buildForkDraftSetup(context);
|
const draftSetup = buildForkDraftSetup(agent);
|
||||||
const prepareForkDraft = async () => {
|
const prepareForkDraft = async () => {
|
||||||
const draftId = generateDraftId();
|
const draftId = generateDraftId();
|
||||||
const payload = await client.buildAgentForkContext(
|
const payload = await client.buildAgentForkContext(
|
||||||
@@ -486,7 +466,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (target === "tab") {
|
if (target === "tab") {
|
||||||
const workspaceId = context.workspaceId;
|
const workspaceId = agent.workspaceId;
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
throw new Error(t("message.actions.forkMissingWorkspace"));
|
throw new Error(t("message.actions.forkMissingWorkspace"));
|
||||||
}
|
}
|
||||||
@@ -501,7 +481,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
|
|
||||||
const draftId = await prepareForkDraft();
|
const draftId = await prepareForkDraft();
|
||||||
const sourceDirectory =
|
const sourceDirectory =
|
||||||
context.projectPlacement?.checkout?.cwd?.trim() || context.cwd.trim() || undefined;
|
agent.projectPlacement?.checkout?.cwd?.trim() || agent.cwd.trim() || undefined;
|
||||||
if (draftSetup) {
|
if (draftSetup) {
|
||||||
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
||||||
draftId,
|
draftId,
|
||||||
@@ -513,8 +493,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
buildNewWorkspaceRoute({
|
buildNewWorkspaceRoute({
|
||||||
serverId: resolvedServerId,
|
serverId: resolvedServerId,
|
||||||
sourceDirectory,
|
sourceDirectory,
|
||||||
displayName: context.projectPlacement?.projectName,
|
displayName: agent.projectPlacement?.projectName,
|
||||||
projectId: context.projectPlacement?.projectKey,
|
projectId: agent.projectPlacement?.projectKey,
|
||||||
draftId,
|
draftId,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -540,24 +520,24 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
|
|
||||||
const baseRenderModel = useMemo(() => {
|
const baseRenderModel = useMemo(() => {
|
||||||
return buildAgentStreamRenderModel({
|
return buildAgentStreamRenderModel({
|
||||||
agentStatus: context.status,
|
agentStatus: agent.status,
|
||||||
tail: effectiveStreamItems,
|
tail: effectiveStreamItems,
|
||||||
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
|
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
|
||||||
platform: isWeb ? "web" : "native",
|
platform: isWeb ? "web" : "native",
|
||||||
isMobileBreakpoint: isMobile,
|
isMobileBreakpoint: isMobile,
|
||||||
});
|
});
|
||||||
}, [context.status, isMobile, effectiveStreamHead, effectiveStreamItems]);
|
}, [agent.status, isMobile, effectiveStreamHead, effectiveStreamItems]);
|
||||||
const streamLayout = useMemo(
|
const streamLayout = useMemo(
|
||||||
() =>
|
() =>
|
||||||
layoutStream({
|
layoutStream({
|
||||||
strategy: streamRenderStrategy,
|
strategy: streamRenderStrategy,
|
||||||
agentStatus: context.status,
|
agentStatus: agent.status,
|
||||||
history: baseRenderModel.history,
|
history: baseRenderModel.history,
|
||||||
liveHead: baseRenderModel.segments.liveHead,
|
liveHead: baseRenderModel.segments.liveHead,
|
||||||
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
|
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
context.status,
|
agent.status,
|
||||||
baseRenderModel.history,
|
baseRenderModel.history,
|
||||||
baseRenderModel.segments.liveHead,
|
baseRenderModel.segments.liveHead,
|
||||||
baseRenderModel.turnTiming.byAssistantId,
|
baseRenderModel.turnTiming.byAssistantId,
|
||||||
@@ -610,14 +590,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
images={item.images}
|
images={item.images}
|
||||||
attachments={item.attachments}
|
attachments={item.attachments}
|
||||||
timestamp={item.timestamp.getTime()}
|
timestamp={item.timestamp.getTime()}
|
||||||
capabilities={context.capabilities}
|
capabilities={agent.capabilities}
|
||||||
client={client}
|
client={client}
|
||||||
isFirstInGroup={layoutItem.isFirstInUserGroup}
|
isFirstInGroup={layoutItem.isFirstInUserGroup}
|
||||||
isLastInGroup={layoutItem.isLastInUserGroup}
|
isLastInGroup={layoutItem.isLastInUserGroup}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[context.capabilities, agentId, client, resolvedServerId],
|
[agent.capabilities, agentId, client, resolvedServerId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderAssistantMessageItem = useCallback(
|
const renderAssistantMessageItem = useCallback(
|
||||||
@@ -688,7 +668,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
error={data.error}
|
error={data.error}
|
||||||
status={data.status}
|
status={data.status}
|
||||||
detail={data.detail}
|
detail={data.detail}
|
||||||
cwd={context.cwd}
|
cwd={agent.cwd}
|
||||||
metadata={data.metadata}
|
metadata={data.metadata}
|
||||||
isLastInSequence={layoutItem.isLastInToolSequence}
|
isLastInSequence={layoutItem.isLastInToolSequence}
|
||||||
onOpenFilePath={handleToolCallOpenFile}
|
onOpenFilePath={handleToolCallOpenFile}
|
||||||
@@ -710,7 +690,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
|
[agent.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderStreamItemContent = useCallback(
|
const renderStreamItemContent = useCallback(
|
||||||
@@ -767,10 +747,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
content,
|
content,
|
||||||
layoutItem,
|
layoutItem,
|
||||||
strategy: streamRenderStrategy,
|
strategy: streamRenderStrategy,
|
||||||
onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn,
|
onForkAssistantTurn: handleForkAssistantTurn,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[handleForkAssistantTurn, readOnly, renderStreamItemContent, streamRenderStrategy],
|
[handleForkAssistantTurn, renderStreamItemContent, streamRenderStrategy],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pendingPermissionItems = useMemo(
|
const pendingPermissionItems = useMemo(
|
||||||
@@ -778,7 +758,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
[pendingPermissions, agentId],
|
[pendingPermissions, agentId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const showRunningTurnFooter = context.status === "running";
|
const showRunningTurnFooter = agent.status === "running";
|
||||||
const pendingPermissionsNode = useMemo(
|
const pendingPermissionsNode = useMemo(
|
||||||
() =>
|
() =>
|
||||||
renderPendingPermissionsNode({
|
renderPendingPermissionsNode({
|
||||||
@@ -795,12 +775,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
|
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
|
||||||
host={bottomTurnFooterHost}
|
host={bottomTurnFooterHost}
|
||||||
strategy={streamRenderStrategy}
|
strategy={streamRenderStrategy}
|
||||||
onForkAssistantTurn={readOnly ? undefined : handleForkAssistantTurn}
|
onForkAssistantTurn={handleForkAssistantTurn}
|
||||||
/>
|
/>
|
||||||
) : null,
|
) : null,
|
||||||
[
|
[
|
||||||
handleForkAssistantTurn,
|
handleForkAssistantTurn,
|
||||||
readOnly,
|
|
||||||
showRunningTurnFooter,
|
showRunningTurnFooter,
|
||||||
baseRenderModel.turnTiming.runningStartedAt,
|
baseRenderModel.turnTiming.runningStartedAt,
|
||||||
bottomTurnFooterHost,
|
bottomTurnFooterHost,
|
||||||
@@ -926,8 +905,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
})}
|
})}
|
||||||
</MessageOuterSpacingProvider>
|
</MessageOuterSpacingProvider>
|
||||||
{!isNearBottom && (
|
{!isNearBottom && (
|
||||||
<View style={stylesheet.scrollToBottomContainer} pointerEvents="box-none">
|
<Animated.View
|
||||||
<Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}>
|
style={stylesheet.scrollToBottomContainer}
|
||||||
|
entering={scrollIndicatorFadeIn}
|
||||||
|
exiting={scrollIndicatorFadeOut}
|
||||||
|
>
|
||||||
|
<View style={stylesheet.scrollToBottomInner}>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={stylesheet.scrollToBottomButton}
|
style={stylesheet.scrollToBottomButton}
|
||||||
onPress={scrollToBottom}
|
onPress={scrollToBottom}
|
||||||
@@ -937,8 +920,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
>
|
>
|
||||||
<ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} />
|
<ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Animated.View>
|
</View>
|
||||||
</View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ToolCallSheetProvider>
|
</ToolCallSheetProvider>
|
||||||
@@ -1021,17 +1004,6 @@ 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(
|
function agentStreamViewPropsEqual(
|
||||||
left: AgentStreamViewProps,
|
left: AgentStreamViewProps,
|
||||||
right: AgentStreamViewProps,
|
right: AgentStreamViewProps,
|
||||||
@@ -1039,9 +1011,8 @@ function agentStreamViewPropsEqual(
|
|||||||
const reasons: string[] = [];
|
const reasons: string[] = [];
|
||||||
if (left.agentId !== right.agentId) reasons.push("agentId");
|
if (left.agentId !== right.agentId) reasons.push("agentId");
|
||||||
if (left.serverId !== right.serverId) reasons.push("serverId");
|
if (left.serverId !== right.serverId) reasons.push("serverId");
|
||||||
reasons.push(...collectAgentScreenAgentDiffs(left.context, right.context));
|
reasons.push(...collectAgentScreenAgentDiffs(left.agent, right.agent));
|
||||||
if (left.streamItems !== right.streamItems) reasons.push("streamItems");
|
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 (left.pendingPermissions !== right.pendingPermissions) reasons.push("pendingPermissions");
|
||||||
if (
|
if (
|
||||||
!bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest)
|
!bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest)
|
||||||
@@ -1053,10 +1024,6 @@ function agentStreamViewPropsEqual(
|
|||||||
}
|
}
|
||||||
if (left.toast !== right.toast) reasons.push("toast");
|
if (left.toast !== right.toast) reasons.push("toast");
|
||||||
if (left.onOpenWorkspaceFile !== right.onOpenWorkspaceFile) reasons.push("onOpenWorkspaceFile");
|
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);
|
recordRenderProfileReasons(`AgentStreamView:${right.agentId}`, reasons);
|
||||||
return reasons.length === 0;
|
return reasons.length === 0;
|
||||||
}
|
}
|
||||||
@@ -1426,6 +1393,13 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
pointerEvents: "box-none",
|
||||||
|
},
|
||||||
|
scrollToBottomInner: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: MAX_CONTENT_WIDTH,
|
||||||
|
alignSelf: "center",
|
||||||
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
scrollToBottomButton: {
|
scrollToBottomButton: {
|
||||||
width: 48,
|
width: 48,
|
||||||
|
|||||||
@@ -101,9 +101,9 @@ interface SidebarSharedProps {
|
|||||||
interface SidebarLabels {
|
interface SidebarLabels {
|
||||||
addProject: string;
|
addProject: string;
|
||||||
newWorkspace: string;
|
newWorkspace: string;
|
||||||
hosts: string;
|
|
||||||
home: string;
|
home: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
|
switchHost: string;
|
||||||
searchHosts: string;
|
searchHosts: string;
|
||||||
sessions: string;
|
sessions: string;
|
||||||
schedules: string;
|
schedules: string;
|
||||||
@@ -226,9 +226,9 @@ export const LeftSidebar = memo(function LeftSidebar() {
|
|||||||
(): SidebarLabels => ({
|
(): SidebarLabels => ({
|
||||||
addProject: t("sidebar.actions.addProject"),
|
addProject: t("sidebar.actions.addProject"),
|
||||||
newWorkspace: t("sidebar.actions.newWorkspace"),
|
newWorkspace: t("sidebar.actions.newWorkspace"),
|
||||||
hosts: t("sidebar.actions.hosts"),
|
|
||||||
home: t("sidebar.actions.home"),
|
home: t("sidebar.actions.home"),
|
||||||
settings: t("sidebar.actions.settings"),
|
settings: t("sidebar.actions.settings"),
|
||||||
|
switchHost: t("sidebar.host.switchTitle"),
|
||||||
searchHosts: t("sidebar.host.searchPlaceholder"),
|
searchHosts: t("sidebar.host.searchPlaceholder"),
|
||||||
sessions: t("sidebar.sections.sessions"),
|
sessions: t("sidebar.sections.sessions"),
|
||||||
schedules: t("sidebar.sections.schedules"),
|
schedules: t("sidebar.sections.schedules"),
|
||||||
@@ -301,58 +301,47 @@ function FooterIconButton({
|
|||||||
buttonRef,
|
buttonRef,
|
||||||
onPress,
|
onPress,
|
||||||
testID,
|
testID,
|
||||||
label,
|
accessibilityLabel,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
iconSize,
|
iconSize,
|
||||||
shortcutKeys,
|
|
||||||
theme,
|
theme,
|
||||||
}: {
|
}: {
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
testID: string;
|
testID: string;
|
||||||
label: string;
|
accessibilityLabel: string;
|
||||||
icon: typeof FolderPlus;
|
icon: typeof FolderPlus;
|
||||||
iconSize?: number;
|
iconSize?: number;
|
||||||
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
|
|
||||||
theme: SidebarTheme;
|
theme: SidebarTheme;
|
||||||
buttonRef?: RefObject<View | null>;
|
buttonRef?: RefObject<View | null>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Tooltip delayDuration={300}>
|
<Pressable
|
||||||
<TooltipTrigger asChild>
|
ref={buttonRef}
|
||||||
<Pressable
|
style={styles.footerIconButton}
|
||||||
ref={buttonRef}
|
testID={testID}
|
||||||
style={styles.footerIconButton}
|
nativeID={testID}
|
||||||
testID={testID}
|
collapsable={false}
|
||||||
nativeID={testID}
|
accessible
|
||||||
collapsable={false}
|
accessibilityLabel={accessibilityLabel}
|
||||||
accessible
|
accessibilityRole="button"
|
||||||
accessibilityLabel={label}
|
onPress={onPress}
|
||||||
accessibilityRole="button"
|
>
|
||||||
onPress={onPress}
|
{({ hovered }) => (
|
||||||
>
|
<Icon
|
||||||
{({ hovered }) => (
|
size={iconSize ?? theme.iconSize.md}
|
||||||
<Icon
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
size={iconSize ?? theme.iconSize.md}
|
/>
|
||||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
)}
|
||||||
/>
|
</Pressable>
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
|
||||||
<IconTooltipContent label={label} shortcutKeys={shortcutKeys} />
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarHostPicker({
|
function SidebarHostPicker({
|
||||||
theme,
|
theme,
|
||||||
label,
|
|
||||||
onAddHost,
|
onAddHost,
|
||||||
onOpenHostSettings,
|
onOpenHostSettings,
|
||||||
}: {
|
}: {
|
||||||
theme: SidebarTheme;
|
theme: SidebarTheme;
|
||||||
label: string;
|
|
||||||
onAddHost: () => void;
|
onAddHost: () => void;
|
||||||
onOpenHostSettings: (serverId: string) => void;
|
onOpenHostSettings: (serverId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -391,7 +380,7 @@ function SidebarHostPicker({
|
|||||||
buttonRef={triggerRef}
|
buttonRef={triggerRef}
|
||||||
onPress={handleOpen}
|
onPress={handleOpen}
|
||||||
testID="sidebar-hosts-trigger"
|
testID="sidebar-hosts-trigger"
|
||||||
label={label}
|
accessibilityLabel="Hosts"
|
||||||
icon={Server}
|
icon={Server}
|
||||||
iconSize={theme.iconSize.sm}
|
iconSize={theme.iconSize.sm}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
@@ -400,7 +389,22 @@ function SidebarHostPicker({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function IconTooltipContent({
|
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({
|
||||||
label,
|
label,
|
||||||
shortcutKeys,
|
shortcutKeys,
|
||||||
}: {
|
}: {
|
||||||
@@ -485,47 +489,50 @@ function SidebarFooter({
|
|||||||
handleSettings: () => void;
|
handleSettings: () => void;
|
||||||
labels: {
|
labels: {
|
||||||
addProject: string;
|
addProject: string;
|
||||||
hosts: string;
|
|
||||||
home: string;
|
home: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
|
switchHost: string;
|
||||||
searchHosts: string;
|
searchHosts: string;
|
||||||
};
|
};
|
||||||
handleAddHost: () => void;
|
handleAddHost: () => void;
|
||||||
handleOpenHostSettings: (serverId: string) => void;
|
handleOpenHostSettings: (serverId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const newAgentKeys = useShortcutKeys("new-agent");
|
const newAgentKeys = useShortcutKeys("new-agent");
|
||||||
const settingsKeys = useShortcutKeys("toggle-settings");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.sidebarFooter}>
|
<View style={styles.sidebarFooter}>
|
||||||
<View style={styles.footerIconRow}>
|
<View style={styles.footerIconRow}>
|
||||||
<SidebarHostPicker
|
<SidebarHostPicker
|
||||||
theme={theme}
|
theme={theme}
|
||||||
label={labels.hosts}
|
|
||||||
onAddHost={handleAddHost}
|
onAddHost={handleAddHost}
|
||||||
onOpenHostSettings={handleOpenHostSettings}
|
onOpenHostSettings={handleOpenHostSettings}
|
||||||
/>
|
/>
|
||||||
<FooterIconButton
|
<Tooltip delayDuration={300}>
|
||||||
onPress={handleOpenProject}
|
<TooltipTrigger asChild>
|
||||||
testID="sidebar-add-project"
|
<FooterIconButton
|
||||||
label={labels.addProject}
|
onPress={handleOpenProject}
|
||||||
icon={FolderPlus}
|
testID="sidebar-add-project"
|
||||||
shortcutKeys={newAgentKeys}
|
accessibilityLabel={labels.addProject}
|
||||||
theme={theme}
|
icon={FolderPlus}
|
||||||
/>
|
theme={theme}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
|
<AddProjectTooltipContent newAgentKeys={newAgentKeys} label={labels.addProject} />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<FooterIconButton
|
<FooterIconButton
|
||||||
onPress={handleHome}
|
onPress={handleHome}
|
||||||
testID="sidebar-home"
|
testID="sidebar-home"
|
||||||
label={labels.home}
|
accessibilityLabel={labels.home}
|
||||||
icon={Home}
|
icon={Home}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
<FooterIconButton
|
<FooterIconButton
|
||||||
onPress={handleSettings}
|
onPress={handleSettings}
|
||||||
testID="sidebar-settings"
|
testID="sidebar-settings"
|
||||||
label={labels.settings}
|
accessibilityLabel={labels.settings}
|
||||||
icon={Settings}
|
icon={Settings}
|
||||||
shortcutKeys={settingsKeys}
|
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -869,7 +876,7 @@ function WorkspacesSectionHeader() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" align="center" offset={8}>
|
<TooltipContent side="bottom" align="center" offset={8}>
|
||||||
<IconTooltipContent label="Search" shortcutKeys={commandCenterKeys} />
|
<HeaderIconTooltipContent label="Search" shortcutKeys={commandCenterKeys} />
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip delayDuration={300}>
|
<Tooltip delayDuration={300}>
|
||||||
@@ -879,7 +886,7 @@ function WorkspacesSectionHeader() {
|
|||||||
</View>
|
</View>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" align="center" offset={8}>
|
<TooltipContent side="bottom" align="center" offset={8}>
|
||||||
<IconTooltipContent label="Display preferences" />
|
<HeaderIconTooltipContent label="Display preferences" />
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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,18 +1,7 @@
|
|||||||
import { useMemo, type ReactNode } from "react";
|
import { useMemo, type ReactNode } from "react";
|
||||||
import {
|
import { View, type StyleProp, type TextProps, type TextStyle, type ViewStyle } from "react-native";
|
||||||
Text,
|
|
||||||
View,
|
|
||||||
type StyleProp,
|
|
||||||
type TextProps,
|
|
||||||
type TextStyle,
|
|
||||||
type ViewStyle,
|
|
||||||
} from "react-native";
|
|
||||||
import { UITextView } from "react-native-uitextview";
|
import { UITextView } from "react-native-uitextview";
|
||||||
import { resolvePlainMarkdownTextStyle } from "@/components/markdown-text-style";
|
import { resolvePlainMarkdownTextStyle } from "@/components/markdown-text-style";
|
||||||
import {
|
|
||||||
iosMarkdownTextIsSelectable,
|
|
||||||
useMarkdownTextSurface,
|
|
||||||
} from "@/components/markdown-text-selection";
|
|
||||||
|
|
||||||
interface MarkdownTextSpanProps {
|
interface MarkdownTextSpanProps {
|
||||||
style?: StyleProp<TextStyle>;
|
style?: StyleProp<TextStyle>;
|
||||||
@@ -40,22 +29,6 @@ export function MarkdownTextSpan({
|
|||||||
accessibilityRole,
|
accessibilityRole,
|
||||||
}: MarkdownTextSpanProps) {
|
}: MarkdownTextSpanProps) {
|
||||||
const plainStyle = useMemo(() => resolvePlainMarkdownTextStyle(style), [style]);
|
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 (
|
return (
|
||||||
<UITextView
|
<UITextView
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|||||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||||
import { MarkdownTableCellText } from "@/components/markdown-text-selection";
|
|
||||||
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
|
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
|
||||||
import { markdownNodeContainsType } from "@/utils/markdown-ast";
|
import { markdownNodeContainsType } from "@/utils/markdown-ast";
|
||||||
import { createCompactMarkdownStyles, createMarkdownStyles } from "@/styles/markdown-styles";
|
import { createCompactMarkdownStyles, createMarkdownStyles } from "@/styles/markdown-styles";
|
||||||
@@ -662,16 +661,6 @@ export function createSharedMarkdownRules(): RenderRules {
|
|||||||
</View>
|
</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: (
|
paragraph: (
|
||||||
node: ASTNode,
|
node: ASTNode,
|
||||||
children: ReactNode[],
|
children: ReactNode[],
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||||
import { MarkdownTableCellText } from "@/components/markdown-text-selection";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
useState,
|
useState,
|
||||||
@@ -1826,16 +1825,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
</View>
|
</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: (
|
paragraph: (
|
||||||
node: ASTNode,
|
node: ASTNode,
|
||||||
children: ReactNode[],
|
children: ReactNode[],
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
resolvePendingModifierDataInput,
|
resolvePendingModifierDataInput,
|
||||||
} from "@/utils/terminal-keys";
|
} from "@/utils/terminal-keys";
|
||||||
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
|
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
|
||||||
import { rememberTerminalViewportSize } from "@/terminal/runtime/terminal-size-cache";
|
|
||||||
import {
|
import {
|
||||||
TerminalStreamController,
|
TerminalStreamController,
|
||||||
type TerminalStreamControllerStatus,
|
type TerminalStreamControllerStatus,
|
||||||
@@ -636,9 +635,6 @@ export function TerminalPane({
|
|||||||
const normalizedCols = Math.floor(cols);
|
const normalizedCols = Math.floor(cols);
|
||||||
const nextSize = { rows: normalizedRows, cols: normalizedCols };
|
const nextSize = { rows: normalizedRows, cols: normalizedCols };
|
||||||
measuredTerminalSizeRef.current = nextSize;
|
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) {
|
if (!input.shouldClaim || !client || !terminalId || !isWorkspaceFocused || !isAppVisible) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,8 +116,6 @@ export interface ComboboxProps {
|
|||||||
desktopFixedHeight?: number;
|
desktopFixedHeight?: number;
|
||||||
/** Content rendered above the scroll area on desktop (sticky header). */
|
/** Content rendered above the scroll area on desktop (sticky header). */
|
||||||
stickyHeader?: ReactNode;
|
stickyHeader?: ReactNode;
|
||||||
/** Content rendered below the scroll area. */
|
|
||||||
footer?: ReactNode;
|
|
||||||
/** When true, selecting an option does not close the picker (multi-select mode). */
|
/** When true, selecting an option does not close the picker (multi-select mode). */
|
||||||
keepOpenOnSelect?: boolean;
|
keepOpenOnSelect?: boolean;
|
||||||
anchorRef: React.RefObject<View | null>;
|
anchorRef: React.RefObject<View | null>;
|
||||||
@@ -930,7 +928,6 @@ interface MobileBodyProps {
|
|||||||
header: SheetHeader | undefined;
|
header: SheetHeader | undefined;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
stickyHeader: ReactNode;
|
stickyHeader: ReactNode;
|
||||||
footer: ReactNode;
|
|
||||||
searchable: boolean;
|
searchable: boolean;
|
||||||
hasChildren: boolean;
|
hasChildren: boolean;
|
||||||
mobileChildrenScrollEnabled: boolean;
|
mobileChildrenScrollEnabled: boolean;
|
||||||
@@ -1029,7 +1026,6 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
|||||||
{body}
|
{body}
|
||||||
</BottomSheetScrollView>
|
</BottomSheetScrollView>
|
||||||
)}
|
)}
|
||||||
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
|
|
||||||
</IsolatedBottomSheetModal>
|
</IsolatedBottomSheetModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1043,7 +1039,6 @@ interface DesktopBodyProps {
|
|||||||
handleDesktopContentLayout: (event: LayoutChangeEvent) => void;
|
handleDesktopContentLayout: (event: LayoutChangeEvent) => void;
|
||||||
header: SheetHeader | undefined;
|
header: SheetHeader | undefined;
|
||||||
stickyHeader: ReactNode;
|
stickyHeader: ReactNode;
|
||||||
footer: ReactNode;
|
|
||||||
searchable: boolean;
|
searchable: boolean;
|
||||||
searchPlaceholder: string;
|
searchPlaceholder: string;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -1197,7 +1192,6 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
|||||||
renderOption={props.renderOption}
|
renderOption={props.renderOption}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
|
|
||||||
</FloatingSurface>
|
</FloatingSurface>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -1230,7 +1224,6 @@ export function Combobox({
|
|||||||
desktopMinWidth,
|
desktopMinWidth,
|
||||||
desktopFixedHeight,
|
desktopFixedHeight,
|
||||||
stickyHeader,
|
stickyHeader,
|
||||||
footer,
|
|
||||||
keepOpenOnSelect = false,
|
keepOpenOnSelect = false,
|
||||||
anchorRef,
|
anchorRef,
|
||||||
children,
|
children,
|
||||||
@@ -1511,7 +1504,6 @@ export function Combobox({
|
|||||||
header={header}
|
header={header}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
stickyHeader={stickyHeader}
|
stickyHeader={stickyHeader}
|
||||||
footer={footer}
|
|
||||||
searchable={searchable}
|
searchable={searchable}
|
||||||
hasChildren={hasChildren}
|
hasChildren={hasChildren}
|
||||||
mobileChildrenScrollEnabled={mobileChildrenScrollEnabled}
|
mobileChildrenScrollEnabled={mobileChildrenScrollEnabled}
|
||||||
@@ -1545,7 +1537,6 @@ export function Combobox({
|
|||||||
handleDesktopContentLayout={handleDesktopContentLayout}
|
handleDesktopContentLayout={handleDesktopContentLayout}
|
||||||
header={header}
|
header={header}
|
||||||
stickyHeader={stickyHeader}
|
stickyHeader={stickyHeader}
|
||||||
footer={footer}
|
|
||||||
searchable={searchable}
|
searchable={searchable}
|
||||||
searchPlaceholder={effectiveSearchPlaceholder}
|
searchPlaceholder={effectiveSearchPlaceholder}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
@@ -1659,10 +1650,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
footer: {
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: theme.colors.border,
|
|
||||||
},
|
|
||||||
bottomSheetHeader: {
|
bottomSheetHeader: {
|
||||||
paddingHorizontal: theme.spacing[6],
|
paddingHorizontal: theme.spacing[6],
|
||||||
paddingBottom: theme.spacing[2],
|
paddingBottom: theme.spacing[2],
|
||||||
|
|||||||
@@ -673,7 +673,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
<AgentStreamView
|
<AgentStreamView
|
||||||
agentId={tabId}
|
agentId={tabId}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
context={draftAgent}
|
agent={draftAgent}
|
||||||
streamItems={optimisticStreamItems}
|
streamItems={optimisticStreamItems}
|
||||||
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
||||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ import {
|
|||||||
applyLegacyDaemonWorkspaceOwnership,
|
applyLegacyDaemonWorkspaceOwnership,
|
||||||
backfillLegacyDaemonWorkspaceDirectoryIfEmpty,
|
backfillLegacyDaemonWorkspaceDirectoryIfEmpty,
|
||||||
} from "@/workspace/legacy-daemon-workspaces";
|
} from "@/workspace/legacy-daemon-workspaces";
|
||||||
import { useProviderSubagentStore } from "@/subagents/provider-store";
|
|
||||||
|
|
||||||
// Re-export types from session-store and draft-store for backward compatibility
|
// Re-export types from session-store and draft-store for backward compatibility
|
||||||
export type { DraftInput } from "@/stores/draft-store";
|
export type { DraftInput } from "@/stores/draft-store";
|
||||||
@@ -1350,11 +1349,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
applyTimelineResponse(message.payload);
|
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) => {
|
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
||||||
if (message.type !== "workspace_update") return;
|
if (message.type !== "workspace_update") return;
|
||||||
if (message.payload.kind === "remove") {
|
if (message.payload.kind === "remove") {
|
||||||
@@ -1756,7 +1750,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
unsubAgentUpdate();
|
unsubAgentUpdate();
|
||||||
unsubAgentStream();
|
unsubAgentStream();
|
||||||
unsubAgentTimeline();
|
unsubAgentTimeline();
|
||||||
unsubProviderSubagentUpdate();
|
|
||||||
unsubWorkspaceUpdate();
|
unsubWorkspaceUpdate();
|
||||||
unsubScriptStatusUpdate();
|
unsubScriptStatusUpdate();
|
||||||
unsubCheckoutStatusUpdate();
|
unsubCheckoutStatusUpdate();
|
||||||
|
|||||||
@@ -68,10 +68,10 @@ const CATALOG_DATA = [
|
|||||||
id: "codebuddy-code",
|
id: "codebuddy-code",
|
||||||
title: "Codebuddy Code",
|
title: "Codebuddy Code",
|
||||||
description: "Tencent Cloud's official intelligent coding tool",
|
description: "Tencent Cloud's official intelligent coding tool",
|
||||||
version: "2.119.3",
|
version: "2.119.2",
|
||||||
iconId: "codebuddy-code",
|
iconId: "codebuddy-code",
|
||||||
installLink: "https://www.codebuddy.cn/cli/",
|
installLink: "https://www.codebuddy.cn/cli/",
|
||||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.119.3", "--acp"],
|
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.119.2", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "codewhale",
|
id: "codewhale",
|
||||||
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
|
|||||||
id: "fast-agent",
|
id: "fast-agent",
|
||||||
title: "fast-agent",
|
title: "fast-agent",
|
||||||
description: "Code and build agents with comprehensive multi-provider support",
|
description: "Code and build agents with comprehensive multi-provider support",
|
||||||
version: "0.9.7",
|
version: "0.9.5",
|
||||||
iconId: "fast-agent",
|
iconId: "fast-agent",
|
||||||
installLink: "https://fast-agent.ai/acp/",
|
installLink: "https://fast-agent.ai/acp/",
|
||||||
command: ["uvx", "--from", "fast-agent-acp==0.9.7", "fast-agent-acp", "-x"],
|
command: ["uvx", "--from", "fast-agent-acp==0.9.5", "fast-agent-acp", "-x"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "gemini",
|
id: "gemini",
|
||||||
|
|||||||
@@ -370,7 +370,6 @@ describe("translation resources", () => {
|
|||||||
expect(en.sidebar.host.switchTitle).toBe("Switch host");
|
expect(en.sidebar.host.switchTitle).toBe("Switch host");
|
||||||
expect(en.sidebar.host.searchPlaceholder).toBe("Search hosts...");
|
expect(en.sidebar.host.searchPlaceholder).toBe("Search hosts...");
|
||||||
expect(en.sidebar.actions.addProject).toBe("Add project");
|
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.home).toBe("Home");
|
||||||
expect(en.sidebar.actions.settings).toBe("Settings");
|
expect(en.sidebar.actions.settings).toBe("Settings");
|
||||||
expect(en.sidebar.actions.closeSidebar).toBe("Close sidebar");
|
expect(en.sidebar.actions.closeSidebar).toBe("Close sidebar");
|
||||||
|
|||||||
@@ -782,7 +782,6 @@ export const ar: TranslationResources = {
|
|||||||
actions: {
|
actions: {
|
||||||
addProject: "إضافة مشروع",
|
addProject: "إضافة مشروع",
|
||||||
newWorkspace: "مساحة عمل جديدة",
|
newWorkspace: "مساحة عمل جديدة",
|
||||||
hosts: "المضيفون",
|
|
||||||
home: "بيت",
|
home: "بيت",
|
||||||
settings: "إعدادات",
|
settings: "إعدادات",
|
||||||
closeSidebar: "إغلاق الشريط الجانبي",
|
closeSidebar: "إغلاق الشريط الجانبي",
|
||||||
|
|||||||
@@ -789,7 +789,6 @@ export const en = {
|
|||||||
actions: {
|
actions: {
|
||||||
addProject: "Add project",
|
addProject: "Add project",
|
||||||
newWorkspace: "New workspace",
|
newWorkspace: "New workspace",
|
||||||
hosts: "Hosts",
|
|
||||||
home: "Home",
|
home: "Home",
|
||||||
settings: "Settings",
|
settings: "Settings",
|
||||||
closeSidebar: "Close sidebar",
|
closeSidebar: "Close sidebar",
|
||||||
|
|||||||
@@ -809,7 +809,6 @@ export const es: TranslationResources = {
|
|||||||
actions: {
|
actions: {
|
||||||
addProject: "Agregar proyecto",
|
addProject: "Agregar proyecto",
|
||||||
newWorkspace: "Nuevo espacio de trabajo",
|
newWorkspace: "Nuevo espacio de trabajo",
|
||||||
hosts: "Hosts",
|
|
||||||
home: "Hogar",
|
home: "Hogar",
|
||||||
settings: "Ajustes",
|
settings: "Ajustes",
|
||||||
closeSidebar: "Cerrar barra lateral",
|
closeSidebar: "Cerrar barra lateral",
|
||||||
|
|||||||
@@ -808,7 +808,6 @@ export const fr: TranslationResources = {
|
|||||||
actions: {
|
actions: {
|
||||||
addProject: "Ajouter un projet",
|
addProject: "Ajouter un projet",
|
||||||
newWorkspace: "Nouvel espace de travail",
|
newWorkspace: "Nouvel espace de travail",
|
||||||
hosts: "Hôtes",
|
|
||||||
home: "Maison",
|
home: "Maison",
|
||||||
settings: "Paramètres",
|
settings: "Paramètres",
|
||||||
closeSidebar: "Fermer la barre latérale",
|
closeSidebar: "Fermer la barre latérale",
|
||||||
|
|||||||
@@ -794,7 +794,6 @@ export const ja: TranslationResources = {
|
|||||||
actions: {
|
actions: {
|
||||||
addProject: "プロジェクトを追加",
|
addProject: "プロジェクトを追加",
|
||||||
newWorkspace: "新しいワークスペース",
|
newWorkspace: "新しいワークスペース",
|
||||||
hosts: "ホスト",
|
|
||||||
home: "ホーム",
|
home: "ホーム",
|
||||||
settings: "設定",
|
settings: "設定",
|
||||||
closeSidebar: "サイドバーを閉じる",
|
closeSidebar: "サイドバーを閉じる",
|
||||||
|
|||||||
@@ -800,7 +800,6 @@ export const ptBR: TranslationResources = {
|
|||||||
actions: {
|
actions: {
|
||||||
addProject: "Adicionar projeto",
|
addProject: "Adicionar projeto",
|
||||||
newWorkspace: "Novo workspace",
|
newWorkspace: "Novo workspace",
|
||||||
hosts: "Hosts",
|
|
||||||
home: "Início",
|
home: "Início",
|
||||||
settings: "Configurações",
|
settings: "Configurações",
|
||||||
closeSidebar: "Fechar barra lateral",
|
closeSidebar: "Fechar barra lateral",
|
||||||
|
|||||||
@@ -801,7 +801,6 @@ export const ru: TranslationResources = {
|
|||||||
actions: {
|
actions: {
|
||||||
addProject: "Добавить проект",
|
addProject: "Добавить проект",
|
||||||
newWorkspace: "Новое рабочее пространство",
|
newWorkspace: "Новое рабочее пространство",
|
||||||
hosts: "Хосты",
|
|
||||||
home: "Дом",
|
home: "Дом",
|
||||||
settings: "Настройки",
|
settings: "Настройки",
|
||||||
closeSidebar: "Закрыть боковую панель",
|
closeSidebar: "Закрыть боковую панель",
|
||||||
|
|||||||
@@ -777,7 +777,6 @@ export const zhCN: TranslationResources = {
|
|||||||
actions: {
|
actions: {
|
||||||
addProject: "添加 project",
|
addProject: "添加 project",
|
||||||
newWorkspace: "新建工作区",
|
newWorkspace: "新建工作区",
|
||||||
hosts: "Hosts",
|
|
||||||
home: "首页",
|
home: "首页",
|
||||||
settings: "设置",
|
settings: "设置",
|
||||||
closeSidebar: "关闭侧边栏",
|
closeSidebar: "关闭侧边栏",
|
||||||
|
|||||||
@@ -1270,7 +1270,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
|||||||
ref={streamViewRef}
|
ref={streamViewRef}
|
||||||
agentId={agent.id}
|
agentId={agent.id}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
context={agent}
|
agent={agent}
|
||||||
streamItems={streamItems}
|
streamItems={streamItems}
|
||||||
pendingPermissions={pendingPermissions}
|
pendingPermissions={pendingPermissions}
|
||||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||||
@@ -1364,7 +1364,7 @@ function ActiveAgentComposer({
|
|||||||
{ initialIsBelow: isCompactFormFactor },
|
{ initialIsBelow: isCompactFormFactor },
|
||||||
);
|
);
|
||||||
const paneContext = usePaneContext();
|
const paneContext = usePaneContext();
|
||||||
const { workspaceId, tabId, retargetCurrentTab, openTab } = paneContext;
|
const { workspaceId, tabId, retargetCurrentTab } = paneContext;
|
||||||
const { archiveAgent } = useArchiveAgent();
|
const { archiveAgent } = useArchiveAgent();
|
||||||
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
||||||
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
|
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
|
||||||
@@ -1382,12 +1382,6 @@ function ActiveAgentComposer({
|
|||||||
},
|
},
|
||||||
[serverId],
|
[serverId],
|
||||||
);
|
);
|
||||||
const handleOpenProviderSubagent = useCallback(
|
|
||||||
(parentAgentId: string, subagentId: string) => {
|
|
||||||
openTab({ kind: "provider_subagent", parentAgentId, subagentId });
|
|
||||||
},
|
|
||||||
[openTab],
|
|
||||||
);
|
|
||||||
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
||||||
const handleDetachSubagent = useDetachSubagent({ serverId });
|
const handleDetachSubagent = useDetachSubagent({ serverId });
|
||||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||||
@@ -1488,7 +1482,6 @@ function ActiveAgentComposer({
|
|||||||
<SubagentsTrack
|
<SubagentsTrack
|
||||||
rows={subagentRows}
|
rows={subagentRows}
|
||||||
onOpenSubagent={handleOpenSubagent}
|
onOpenSubagent={handleOpenSubagent}
|
||||||
onOpenProviderSubagent={handleOpenProviderSubagent}
|
|
||||||
onArchiveSubagent={handleArchiveSubagent}
|
onArchiveSubagent={handleArchiveSubagent}
|
||||||
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
|
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
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,7 +5,6 @@ import { filePanelRegistration } from "@/panels/file-panel";
|
|||||||
import { registerPanel } from "@/panels/panel-registry";
|
import { registerPanel } from "@/panels/panel-registry";
|
||||||
import { setupPanelRegistration } from "@/panels/setup-panel";
|
import { setupPanelRegistration } from "@/panels/setup-panel";
|
||||||
import { terminalPanelRegistration } from "@/panels/terminal-panel";
|
import { terminalPanelRegistration } from "@/panels/terminal-panel";
|
||||||
import { providerSubagentPanelRegistration } from "@/panels/provider-subagent-panel";
|
|
||||||
|
|
||||||
let panelsRegistered = false;
|
let panelsRegistered = false;
|
||||||
|
|
||||||
@@ -15,7 +14,6 @@ export function ensurePanelsRegistered(): void {
|
|||||||
}
|
}
|
||||||
registerPanel(draftPanelRegistration);
|
registerPanel(draftPanelRegistration);
|
||||||
registerPanel(agentPanelRegistration);
|
registerPanel(agentPanelRegistration);
|
||||||
registerPanel(providerSubagentPanelRegistration);
|
|
||||||
registerPanel(setupPanelRegistration);
|
registerPanel(setupPanelRegistration);
|
||||||
registerPanel(terminalPanelRegistration);
|
registerPanel(terminalPanelRegistration);
|
||||||
registerPanel(browserPanelRegistration);
|
registerPanel(browserPanelRegistration);
|
||||||
|
|||||||
@@ -5,19 +5,11 @@ import type { TFunction } from "i18next";
|
|||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
import type { PressableStateCallbackType } from "react-native";
|
import type { PressableStateCallbackType } from "react-native";
|
||||||
import ReanimatedAnimated from "react-native-reanimated";
|
import ReanimatedAnimated from "react-native-reanimated";
|
||||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { createNameId } from "mnemonic-id";
|
import { createNameId } from "mnemonic-id";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import { Check, ChevronDown, Folder, GitBranch, GitPullRequest, X } from "lucide-react-native";
|
||||||
Check,
|
|
||||||
ChevronDown,
|
|
||||||
Folder,
|
|
||||||
FolderPlus,
|
|
||||||
GitBranch,
|
|
||||||
GitPullRequest,
|
|
||||||
X,
|
|
||||||
} from "lucide-react-native";
|
|
||||||
import { Composer } from "@/composer";
|
import { Composer } from "@/composer";
|
||||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||||
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||||
@@ -28,7 +20,6 @@ import { ProjectIconView } from "@/components/project-icon-view";
|
|||||||
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
||||||
import type { ComboboxOption as ComboboxOptionType, ComboboxProps } from "@/components/ui/combobox";
|
import type { ComboboxOption as ComboboxOptionType, ComboboxProps } from "@/components/ui/combobox";
|
||||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||||
@@ -52,7 +43,6 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-
|
|||||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||||
import { generateDraftId } from "@/stores/draft-keys";
|
import { generateDraftId } from "@/stores/draft-keys";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
import { useProjectPickerStore } from "@/stores/project-picker-store";
|
|
||||||
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
|
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
import {
|
import {
|
||||||
useWorkspaceDraftSubmissionStore,
|
useWorkspaceDraftSubmissionStore,
|
||||||
@@ -60,7 +50,6 @@ import {
|
|||||||
} from "@/stores/workspace-draft-submission-store";
|
} from "@/stores/workspace-draft-submission-store";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
|
||||||
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||||
import { generateMessageId } from "@/types/stream";
|
import { generateMessageId } from "@/types/stream";
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
@@ -74,7 +63,6 @@ import {
|
|||||||
type HostProjectListItem,
|
type HostProjectListItem,
|
||||||
} from "@/projects/host-projects";
|
} from "@/projects/host-projects";
|
||||||
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
|
||||||
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
|
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
|
||||||
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||||
import type { MessagePayload } from "@/composer/types";
|
import type { MessagePayload } from "@/composer/types";
|
||||||
@@ -99,12 +87,6 @@ import {
|
|||||||
} from "./new-workspace-initial-context";
|
} from "./new-workspace-initial-context";
|
||||||
import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker";
|
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(
|
function resolveCheckoutRequest(
|
||||||
selectedItem: PickerItem | null,
|
selectedItem: PickerItem | null,
|
||||||
currentBranch: string | null,
|
currentBranch: string | null,
|
||||||
@@ -614,25 +596,6 @@ 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 {
|
function formatPrLabel(item: { number: number; title: string }): string {
|
||||||
return `#${item.number} ${item.title}`;
|
return `#${item.number} ${item.title}`;
|
||||||
}
|
}
|
||||||
@@ -1394,7 +1357,6 @@ interface NewWorkspaceFormStackInput {
|
|||||||
iconDataByProjectKey: Map<string, string | null>;
|
iconDataByProjectKey: Map<string, string | null>;
|
||||||
selectedOptionId: string;
|
selectedOptionId: string;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onAddProject: () => void;
|
|
||||||
renderOption: RefPickerRenderOption;
|
renderOption: RefPickerRenderOption;
|
||||||
};
|
};
|
||||||
host: FormPickerControl & {
|
host: FormPickerControl & {
|
||||||
@@ -1432,10 +1394,6 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
|||||||
host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host";
|
host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host";
|
||||||
const showHostControl = host.allHosts.length > 1;
|
const showHostControl = host.allHosts.length > 1;
|
||||||
const isolationTriggerLabel = isolationLabel(t, isolation.effectiveIsolation);
|
const isolationTriggerLabel = isolationLabel(t, isolation.effectiveIsolation);
|
||||||
const addProjectAction = useMemo(
|
|
||||||
() => <AddProjectPickerAction onPress={project.onAddProject} />,
|
|
||||||
[project.onAddProject],
|
|
||||||
);
|
|
||||||
|
|
||||||
const badgePressableStyle = useCallback(
|
const badgePressableStyle = useCallback(
|
||||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
@@ -1452,7 +1410,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
|||||||
<ProjectPickerTrigger
|
<ProjectPickerTrigger
|
||||||
pickerAnchorRef={project.anchorRef}
|
pickerAnchorRef={project.anchorRef}
|
||||||
onPress={project.open}
|
onPress={project.open}
|
||||||
disabled={isPending}
|
disabled={isPending || project.options.length === 0}
|
||||||
badgePressableStyle={badgePressableStyle}
|
badgePressableStyle={badgePressableStyle}
|
||||||
label={project.triggerLabel}
|
label={project.triggerLabel}
|
||||||
projectKey={project.selectedProject?.projectKey ?? null}
|
projectKey={project.selectedProject?.projectKey ?? null}
|
||||||
@@ -1477,7 +1435,6 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
|||||||
anchorRef={project.anchorRef}
|
anchorRef={project.anchorRef}
|
||||||
emptyText="No projects available."
|
emptyText="No projects available."
|
||||||
renderOption={project.renderOption}
|
renderOption={project.renderOption}
|
||||||
footer={addProjectAction}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -1632,7 +1589,6 @@ export function NewWorkspaceScreen({
|
|||||||
const [manualPickerSelection, setManualPickerSelection] = useState<PickerSelection | null>(null);
|
const [manualPickerSelection, setManualPickerSelection] = useState<PickerSelection | null>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
|
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
|
||||||
const openAddProjectPicker = useProjectPickerStore((state) => state.open);
|
|
||||||
const [isolationPickerOpen, setIsolationPickerOpen] = useState(false);
|
const [isolationPickerOpen, setIsolationPickerOpen] = useState(false);
|
||||||
const [pickerSearchQuery, setPickerSearchQuery] = useState("");
|
const [pickerSearchQuery, setPickerSearchQuery] = useState("");
|
||||||
const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState("");
|
const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState("");
|
||||||
@@ -1667,6 +1623,7 @@ export function NewWorkspaceScreen({
|
|||||||
lastActiveProject,
|
lastActiveProject,
|
||||||
allowAllProjects: supportsWorkspaceMultiplicity,
|
allowAllProjects: supportsWorkspaceMultiplicity,
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectIconTargets = useMemo(
|
const projectIconTargets = useMemo(
|
||||||
() =>
|
() =>
|
||||||
projects.flatMap((project) => {
|
projects.flatMap((project) => {
|
||||||
@@ -1840,11 +1797,6 @@ export function NewWorkspaceScreen({
|
|||||||
[selectProjectOption],
|
[selectProjectOption],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddProject = useCallback(() => {
|
|
||||||
setProjectPickerOpen(false);
|
|
||||||
openAddProjectPicker(selectedServerId);
|
|
||||||
}, [openAddProjectPicker, selectedServerId]);
|
|
||||||
|
|
||||||
const checkoutHintPrAttachment = useMemo(
|
const checkoutHintPrAttachment = useMemo(
|
||||||
() =>
|
() =>
|
||||||
findCheckoutHintPrAttachment({
|
findCheckoutHintPrAttachment({
|
||||||
@@ -2147,7 +2099,6 @@ export function NewWorkspaceScreen({
|
|||||||
iconDataByProjectKey: projectIconDataByProjectKey,
|
iconDataByProjectKey: projectIconDataByProjectKey,
|
||||||
selectedOptionId: selectedProjectOptionId,
|
selectedOptionId: selectedProjectOptionId,
|
||||||
onSelect: handleSelectProjectOption,
|
onSelect: handleSelectProjectOption,
|
||||||
onAddProject: handleAddProject,
|
|
||||||
openState: projectPickerOpen,
|
openState: projectPickerOpen,
|
||||||
onOpenChange: handleProjectPickerOpenChange,
|
onOpenChange: handleProjectPickerOpenChange,
|
||||||
renderOption: renderProjectOption,
|
renderOption: renderProjectOption,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type { WorkspaceDescriptor } from "@/stores/session-store";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useReplicaQuery } from "@/data/query";
|
import { useReplicaQuery } from "@/data/query";
|
||||||
import { workspaceTerminalsPushRoute } from "@/data/push-router";
|
import { workspaceTerminalsPushRoute } from "@/data/push-router";
|
||||||
import { estimateTerminalViewportSize } from "@/terminal/runtime/terminal-size-cache";
|
|
||||||
import {
|
import {
|
||||||
buildTerminalsQueryKey,
|
buildTerminalsQueryKey,
|
||||||
canCreateWorkspaceTerminal,
|
canCreateWorkspaceTerminal,
|
||||||
@@ -136,20 +135,14 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
|
|||||||
if (!client || !workspaceDirectory) {
|
if (!client || !workspaceDirectory) {
|
||||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
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
|
const payload = _input?.profile
|
||||||
? await client.createTerminal(workspaceDirectory, _input.profile.name, undefined, {
|
? await client.createTerminal(workspaceDirectory, _input.profile.name, undefined, {
|
||||||
command: _input.profile.command,
|
command: _input.profile.command,
|
||||||
args: _input.profile.args,
|
args: _input.profile.args,
|
||||||
workspaceId: normalizedWorkspaceId || undefined,
|
workspaceId: normalizedWorkspaceId || undefined,
|
||||||
size: estimatedSize,
|
|
||||||
})
|
})
|
||||||
: await client.createTerminal(workspaceDirectory, undefined, undefined, {
|
: await client.createTerminal(workspaceDirectory, undefined, undefined, {
|
||||||
workspaceId: normalizedWorkspaceId || undefined,
|
workspaceId: normalizedWorkspaceId || undefined,
|
||||||
size: estimatedSize,
|
|
||||||
});
|
});
|
||||||
// The daemon reports a failed spawn (e.g. a profile command that isn't
|
// 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
|
// installed) via payload.error with a null terminal. Surface it instead
|
||||||
|
|||||||
@@ -377,9 +377,6 @@ function getFallbackTabOptionDescription(
|
|||||||
if (tab.target.kind === "browser") {
|
if (tab.target.kind === "browser") {
|
||||||
return labels.browser;
|
return labels.browser;
|
||||||
}
|
}
|
||||||
if (tab.target.kind === "provider_subagent") {
|
|
||||||
return labels.agent;
|
|
||||||
}
|
|
||||||
return tab.target.path;
|
return tab.target.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,9 +138,6 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
|||||||
if (tab.target.kind === "setup") {
|
if (tab.target.kind === "setup") {
|
||||||
return `workspace-setup-close-${encodeWorkspaceIdForPathSegment(tab.target.workspaceId)}`;
|
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)}`;
|
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export interface WorkspaceDraftTabSetup {
|
|||||||
export type WorkspaceTabTarget =
|
export type WorkspaceTabTarget =
|
||||||
| { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup }
|
| { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup }
|
||||||
| { kind: "agent"; agentId: string }
|
| { kind: "agent"; agentId: string }
|
||||||
| { kind: "provider_subagent"; parentAgentId: string; subagentId: string }
|
|
||||||
| { kind: "terminal"; terminalId: string }
|
| { kind: "terminal"; terminalId: string }
|
||||||
| { kind: "browser"; browserId: string }
|
| { kind: "browser"; browserId: string }
|
||||||
| WorkspaceFileTabTarget
|
| WorkspaceFileTabTarget
|
||||||
@@ -509,17 +508,6 @@ function coerceWorkspaceTabTarget(raw: Record<string, unknown>): WorkspaceTabTar
|
|||||||
if (kind === "agent" && typeof raw.agentId === "string") {
|
if (kind === "agent" && typeof raw.agentId === "string") {
|
||||||
return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId });
|
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") {
|
if (kind === "terminal" && typeof raw.terminalId === "string") {
|
||||||
return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId });
|
return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,362 +0,0 @@
|
|||||||
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." }),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
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,7 +1,6 @@
|
|||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { selectProviderSubagentsForParent, selectSubagentsForParent } from "./select";
|
import { selectSubagentsForParent } from "./select";
|
||||||
import { useProviderSubagentStore } from "./provider-store";
|
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
|
|
||||||
const SERVER_ID = "server-1";
|
const SERVER_ID = "server-1";
|
||||||
@@ -59,37 +58,9 @@ function setAgents(agents: Agent[]): void {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
useSessionStore.getState().clearSession(SERVER_ID);
|
useSessionStore.getState().clearSession(SERVER_ID);
|
||||||
useProviderSubagentStore.setState({ descriptors: new Map(), timelines: new Map() });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("selectSubagentsForParent", () => {
|
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", () => {
|
it("returns only non-archived children for the requested parent", () => {
|
||||||
setAgents([
|
setAgents([
|
||||||
makeAgent({ id: "parent-a" }),
|
makeAgent({ id: "parent-a" }),
|
||||||
@@ -223,7 +194,6 @@ describe("selectSubagentsForParent", () => {
|
|||||||
|
|
||||||
expect(rows).toEqual([
|
expect(rows).toEqual([
|
||||||
{
|
{
|
||||||
kind: "paseo",
|
|
||||||
id: "child",
|
id: "child",
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
title: "Review child",
|
title: "Review child",
|
||||||
@@ -235,7 +205,6 @@ describe("selectSubagentsForParent", () => {
|
|||||||
expect(Object.keys(rows[0] ?? {}).sort()).toEqual([
|
expect(Object.keys(rows[0] ?? {}).sort()).toEqual([
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"id",
|
"id",
|
||||||
"kind",
|
|
||||||
"provider",
|
"provider",
|
||||||
"requiresAttention",
|
"requiresAttention",
|
||||||
"status",
|
"status",
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { usePendingArchiveAgentIds } from "@/hooks/use-archive-agent";
|
import { usePendingArchiveAgentIds } from "@/hooks/use-archive-agent";
|
||||||
import equal from "fast-deep-equal";
|
import equal from "fast-deep-equal";
|
||||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
import { refreshProviderSubagents, useProviderSubagentStore } from "./provider-store";
|
|
||||||
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
|
|
||||||
|
|
||||||
export interface PaseoSubagentRow {
|
export interface SubagentRow {
|
||||||
kind: "paseo";
|
|
||||||
id: Agent["id"];
|
id: Agent["id"];
|
||||||
provider: Agent["provider"];
|
provider: Agent["provider"];
|
||||||
title: Agent["title"];
|
title: Agent["title"];
|
||||||
@@ -16,21 +12,7 @@ export interface PaseoSubagentRow {
|
|||||||
createdAt: Agent["createdAt"];
|
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 SessionStoreSnapshot = ReturnType<typeof useSessionStore.getState>;
|
||||||
type ProviderSubagentStoreSnapshot = ReturnType<typeof useProviderSubagentStore.getState>;
|
|
||||||
|
|
||||||
interface SelectSubagentsParams {
|
interface SelectSubagentsParams {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -38,11 +20,9 @@ interface SelectSubagentsParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_SUBAGENT_ROWS: SubagentRow[] = [];
|
const EMPTY_SUBAGENT_ROWS: SubagentRow[] = [];
|
||||||
const EMPTY_PROVIDER_SUBAGENT_ROWS: ProviderSubagentRow[] = [];
|
|
||||||
|
|
||||||
function toSubagentRow(agent: Agent): SubagentRow {
|
function toSubagentRow(agent: Agent): SubagentRow {
|
||||||
return {
|
return {
|
||||||
kind: "paseo",
|
|
||||||
id: agent.id,
|
id: agent.id,
|
||||||
provider: agent.provider,
|
provider: agent.provider,
|
||||||
title: agent.title,
|
title: agent.title,
|
||||||
@@ -82,59 +62,11 @@ export function selectSubagentsForParent(
|
|||||||
return rows;
|
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[] {
|
export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] {
|
||||||
const pendingArchiveIds = usePendingArchiveAgentIds(params.serverId);
|
const pendingArchiveIds = usePendingArchiveAgentIds(params.serverId);
|
||||||
const paseoRows = useStoreWithEqualityFn(
|
return useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
(state) => selectSubagentsForParent(state, params, pendingArchiveIds),
|
(state) => selectSubagentsForParent(state, params, pendingArchiveIds),
|
||||||
equal,
|
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,16 +1,13 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { PaseoSubagentRow, SubagentRow } from "./select";
|
import type { SubagentRow } from "./select";
|
||||||
import {
|
import {
|
||||||
buildSubagentRowPresentationData,
|
buildSubagentRowPresentationData,
|
||||||
formatHeaderLabel,
|
formatHeaderLabel,
|
||||||
resolveRowLabel,
|
resolveRowLabel,
|
||||||
} from "./track-presentation";
|
} from "./track-presentation";
|
||||||
|
|
||||||
function row(
|
function row(overrides: Partial<SubagentRow> & Pick<SubagentRow, "id">): SubagentRow {
|
||||||
overrides: Partial<PaseoSubagentRow> & Pick<PaseoSubagentRow, "id">,
|
|
||||||
): PaseoSubagentRow {
|
|
||||||
return {
|
return {
|
||||||
kind: "paseo",
|
|
||||||
id: overrides.id,
|
id: overrides.id,
|
||||||
provider: overrides.provider ?? "codex",
|
provider: overrides.provider ?? "codex",
|
||||||
title: overrides.title ?? `Agent ${overrides.id}`,
|
title: overrides.title ?? `Agent ${overrides.id}`,
|
||||||
@@ -94,9 +91,7 @@ describe("resolveRowLabel", () => {
|
|||||||
|
|
||||||
describe("buildSubagentRowPresentationData", () => {
|
describe("buildSubagentRowPresentationData", () => {
|
||||||
it("namespaces the key with a subagent prefix", () => {
|
it("namespaces the key with a subagent prefix", () => {
|
||||||
expect(buildSubagentRowPresentationData(row({ id: "child-a" })).key).toBe(
|
expect(buildSubagentRowPresentationData(row({ id: "child-a" })).key).toBe("subagent_child-a");
|
||||||
"paseo_subagent_child-a",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks the row ready when the title resolves to a real label", () => {
|
it("marks the row ready when the title resolves to a real label", () => {
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
import type { SubagentRow } from "./select";
|
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 {
|
export interface SubagentRowPresentationData {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -19,15 +13,14 @@ export interface SubagentRowPresentationData {
|
|||||||
|
|
||||||
export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowPresentationData {
|
export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowPresentationData {
|
||||||
const label = resolveRowLabel(row.title);
|
const label = resolveRowLabel(row.title);
|
||||||
const status = presentationStatus(row);
|
|
||||||
return {
|
return {
|
||||||
key: `${row.kind}_subagent_${row.id}`,
|
key: `subagent_${row.id}`,
|
||||||
kind: "agent",
|
kind: "agent",
|
||||||
label: label ?? "",
|
label: label ?? "",
|
||||||
subtitle: "",
|
subtitle: "",
|
||||||
titleState: label ? "ready" : "loading",
|
titleState: label ? "ready" : "loading",
|
||||||
statusBucket: deriveSidebarStateBucket({
|
statusBucket: deriveSidebarStateBucket({
|
||||||
status,
|
status: row.status,
|
||||||
requiresAttention: false,
|
requiresAttention: false,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
|||||||
export interface SubagentsTrackProps {
|
export interface SubagentsTrackProps {
|
||||||
rows: SubagentRow[];
|
rows: SubagentRow[];
|
||||||
onOpenSubagent: (id: string) => void;
|
onOpenSubagent: (id: string) => void;
|
||||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
|
||||||
onArchiveSubagent: (id: string) => void;
|
onArchiveSubagent: (id: string) => void;
|
||||||
onDetachSubagent?: (id: string) => void;
|
onDetachSubagent?: (id: string) => void;
|
||||||
}
|
}
|
||||||
@@ -45,7 +44,6 @@ function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation {
|
|||||||
export function SubagentsTrack({
|
export function SubagentsTrack({
|
||||||
rows,
|
rows,
|
||||||
onOpenSubagent,
|
onOpenSubagent,
|
||||||
onOpenProviderSubagent,
|
|
||||||
onArchiveSubagent,
|
onArchiveSubagent,
|
||||||
onDetachSubagent,
|
onDetachSubagent,
|
||||||
}: SubagentsTrackProps): ReactElement | null {
|
}: SubagentsTrackProps): ReactElement | null {
|
||||||
@@ -107,7 +105,6 @@ export function SubagentsTrack({
|
|||||||
key={row.id}
|
key={row.id}
|
||||||
row={row}
|
row={row}
|
||||||
onOpenSubagent={onOpenSubagent}
|
onOpenSubagent={onOpenSubagent}
|
||||||
onOpenProviderSubagent={onOpenProviderSubagent}
|
|
||||||
onArchiveSubagent={onArchiveSubagent}
|
onArchiveSubagent={onArchiveSubagent}
|
||||||
onDetachSubagent={onDetachSubagent}
|
onDetachSubagent={onDetachSubagent}
|
||||||
/>
|
/>
|
||||||
@@ -123,7 +120,6 @@ export function SubagentsTrack({
|
|||||||
interface SubagentsTrackRowProps {
|
interface SubagentsTrackRowProps {
|
||||||
row: SubagentRow;
|
row: SubagentRow;
|
||||||
onOpenSubagent: (id: string) => void;
|
onOpenSubagent: (id: string) => void;
|
||||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
|
||||||
onArchiveSubagent: (id: string) => void;
|
onArchiveSubagent: (id: string) => void;
|
||||||
onDetachSubagent?: (id: string) => void;
|
onDetachSubagent?: (id: string) => void;
|
||||||
}
|
}
|
||||||
@@ -131,7 +127,6 @@ interface SubagentsTrackRowProps {
|
|||||||
function SubagentsTrackRow({
|
function SubagentsTrackRow({
|
||||||
row,
|
row,
|
||||||
onOpenSubagent,
|
onOpenSubagent,
|
||||||
onOpenProviderSubagent,
|
|
||||||
onArchiveSubagent,
|
onArchiveSubagent,
|
||||||
onDetachSubagent,
|
onDetachSubagent,
|
||||||
}: SubagentsTrackRowProps): ReactElement {
|
}: SubagentsTrackRowProps): ReactElement {
|
||||||
@@ -142,12 +137,8 @@ function SubagentsTrackRow({
|
|||||||
const displayLabel =
|
const displayLabel =
|
||||||
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
|
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
if (row.kind === "provider") {
|
onOpenSubagent(row.id);
|
||||||
onOpenProviderSubagent(row.parentAgentId, row.id);
|
}, [onOpenSubagent, row.id]);
|
||||||
} else {
|
|
||||||
onOpenSubagent(row.id);
|
|
||||||
}
|
|
||||||
}, [onOpenProviderSubagent, onOpenSubagent, row]);
|
|
||||||
const handleArchivePress = useCallback(() => {
|
const handleArchivePress = useCallback(() => {
|
||||||
onArchiveSubagent(row.id);
|
onArchiveSubagent(row.id);
|
||||||
}, [onArchiveSubagent, row.id]);
|
}, [onArchiveSubagent, row.id]);
|
||||||
@@ -176,15 +167,13 @@ function SubagentsTrackRow({
|
|||||||
<Text style={styles.rowLabel} numberOfLines={1}>
|
<Text style={styles.rowLabel} numberOfLines={1}>
|
||||||
{displayLabel}
|
{displayLabel}
|
||||||
</Text>
|
</Text>
|
||||||
{row.kind === "paseo" ? (
|
<SubagentRowActions
|
||||||
<SubagentRowActions
|
rowId={row.id}
|
||||||
rowId={row.id}
|
displayLabel={displayLabel}
|
||||||
displayLabel={displayLabel}
|
visible={actionsVisible}
|
||||||
visible={actionsVisible}
|
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
|
||||||
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
|
onArchivePress={handleArchivePress}
|
||||||
onArchivePress={handleArchivePress}
|
/>
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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,13 +21,6 @@ export function normalizeWorkspaceTabTarget(
|
|||||||
const agentId = trimNonEmpty(value.agentId);
|
const agentId = trimNonEmpty(value.agentId);
|
||||||
return agentId ? { kind: "agent", agentId } : null;
|
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") {
|
if (value.kind === "terminal") {
|
||||||
const terminalId = trimNonEmpty(value.terminalId);
|
const terminalId = trimNonEmpty(value.terminalId);
|
||||||
return terminalId ? { kind: "terminal", terminalId } : null;
|
return terminalId ? { kind: "terminal", terminalId } : null;
|
||||||
@@ -83,9 +76,6 @@ export function workspaceTabTargetsEqual(
|
|||||||
if (left.kind === "agent" && right.kind === "agent") {
|
if (left.kind === "agent" && right.kind === "agent") {
|
||||||
return left.agentId === right.agentId;
|
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") {
|
if (left.kind === "terminal" && right.kind === "terminal") {
|
||||||
return left.terminalId === right.terminalId;
|
return left.terminalId === right.terminalId;
|
||||||
}
|
}
|
||||||
@@ -141,9 +131,6 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
|
|||||||
if (target.kind === "agent") {
|
if (target.kind === "agent") {
|
||||||
return `agent_${target.agentId}`;
|
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") {
|
if (target.kind === "terminal") {
|
||||||
return `terminal_${target.terminalId}`;
|
return `terminal_${target.terminalId}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||||
"bin": {
|
"bin": {
|
||||||
"paseo": "bin/paseo"
|
"paseo": "bin/paseo"
|
||||||
@@ -27,9 +27,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.1.107",
|
"@getpaseo/client": "0.1.106",
|
||||||
"@getpaseo/protocol": "0.1.107",
|
"@getpaseo/protocol": "0.1.106",
|
||||||
"@getpaseo/server": "0.1.107",
|
"@getpaseo/server": "0.1.106",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"description": "Paseo client SDK package",
|
"description": "Paseo client SDK package",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -35,8 +35,8 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.1.107",
|
"@getpaseo/protocol": "0.1.106",
|
||||||
"@getpaseo/relay": "0.1.107",
|
"@getpaseo/relay": "0.1.106",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -555,7 +555,6 @@ test("advertises client capabilities in hello", async () => {
|
|||||||
protocolVersion: 1,
|
protocolVersion: 1,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
custom_mode_icons: true,
|
custom_mode_icons: true,
|
||||||
provider_subagents: true,
|
|
||||||
reasoning_merge_enum: true,
|
reasoning_merge_enum: true,
|
||||||
terminal_reflowable_snapshot: true,
|
terminal_reflowable_snapshot: true,
|
||||||
browser_host: {
|
browser_host: {
|
||||||
|
|||||||
@@ -490,22 +490,6 @@ export interface FetchAgentTimelineOptions {
|
|||||||
timeout?: number;
|
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
|
// COMPAT(daemon-client-object-options): added in v0.1.102; remove after
|
||||||
// 2026-12-29 once SDK callers have migrated to object parameters.
|
// 2026-12-29 once SDK callers have migrated to object parameters.
|
||||||
function normalizeFetchAgentOptions(
|
function normalizeFetchAgentOptions(
|
||||||
@@ -2445,65 +2429,6 @@ export class DaemonClient {
|
|||||||
return payload;
|
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(
|
async buildAgentForkContext(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
options: AgentForkContextOptions = {},
|
options: AgentForkContextOptions = {},
|
||||||
@@ -4275,13 +4200,7 @@ export class DaemonClient {
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
name?: string,
|
name?: string,
|
||||||
requestId?: string,
|
requestId?: string,
|
||||||
options?: {
|
options?: { agentId?: string; command?: string; args?: string[]; workspaceId?: string },
|
||||||
agentId?: string;
|
|
||||||
command?: string;
|
|
||||||
args?: string[];
|
|
||||||
workspaceId?: string;
|
|
||||||
size?: { rows: number; cols: number };
|
|
||||||
},
|
|
||||||
): Promise<CreateTerminalPayload> {
|
): Promise<CreateTerminalPayload> {
|
||||||
const resolvedRequestId = this.createRequestId(requestId);
|
const resolvedRequestId = this.createRequestId(requestId);
|
||||||
const message = SessionInboundMessageSchema.parse({
|
const message = SessionInboundMessageSchema.parse({
|
||||||
@@ -4292,7 +4211,6 @@ export class DaemonClient {
|
|||||||
command: options?.command,
|
command: options?.command,
|
||||||
args: options?.args,
|
args: options?.args,
|
||||||
...(options?.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}),
|
...(options?.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}),
|
||||||
...(options?.size !== undefined ? { size: options.size } : {}),
|
|
||||||
requestId: resolvedRequestId,
|
requestId: resolvedRequestId,
|
||||||
});
|
});
|
||||||
return this.sendCorrelatedRequest({
|
return this.sendCorrelatedRequest({
|
||||||
@@ -4754,7 +4672,6 @@ export class DaemonClient {
|
|||||||
[CLIENT_CAPS.customModeIcons]: true,
|
[CLIENT_CAPS.customModeIcons]: true,
|
||||||
[CLIENT_CAPS.reasoningMergeEnum]: true,
|
[CLIENT_CAPS.reasoningMergeEnum]: true,
|
||||||
[CLIENT_CAPS.terminalReflowableSnapshot]: true,
|
[CLIENT_CAPS.terminalReflowableSnapshot]: true,
|
||||||
[CLIENT_CAPS.providerSubagents]: true,
|
|
||||||
...this.config.capabilities,
|
...this.config.capabilities,
|
||||||
},
|
},
|
||||||
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
|
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo desktop app (Electron wrapper)",
|
"description": "Paseo desktop app (Electron wrapper)",
|
||||||
"homepage": "https://paseo.sh",
|
"homepage": "https://paseo.sh",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"description": "Native module for two way audio streaming",
|
"description": "Native module for two way audio streaming",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ExpoTwoWayAudio",
|
"ExpoTwoWayAudio",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"!dist/**/*.map"
|
"!dist/**/*.map"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"description": "Paseo shared protocol schemas and wire types",
|
"description": "Paseo shared protocol schemas and wire types",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ export const CLIENT_CAPS = {
|
|||||||
// Old clients use a strict TerminalState schema and would reject the extra fields.
|
// 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.
|
// Drop the gate (always send the flags) when floor >= v0.1.88.
|
||||||
terminalReflowableSnapshot: "terminal_reflowable_snapshot",
|
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",
|
browserHost: "browser_host",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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,22 +1295,6 @@ export const FetchAgentTimelineRequestMessageSchema = z.object({
|
|||||||
projection: z.enum(["projected", "canonical"]).optional(),
|
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({
|
export const AgentForkContextRequestMessageSchema = z.object({
|
||||||
type: z.literal("agent.fork_context.request"),
|
type: z.literal("agent.fork_context.request"),
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
@@ -1984,16 +1968,6 @@ export const CreateTerminalRequestSchema = z.object({
|
|||||||
agentId: z.string().optional(),
|
agentId: z.string().optional(),
|
||||||
command: z.string().optional(),
|
command: z.string().optional(),
|
||||||
args: z.array(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(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2115,8 +2089,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
RestartServerRequestMessageSchema,
|
RestartServerRequestMessageSchema,
|
||||||
DaemonUpdateRequestMessageSchema,
|
DaemonUpdateRequestMessageSchema,
|
||||||
FetchAgentTimelineRequestMessageSchema,
|
FetchAgentTimelineRequestMessageSchema,
|
||||||
ProviderSubagentListRequestMessageSchema,
|
|
||||||
ProviderSubagentTimelineRequestMessageSchema,
|
|
||||||
AgentForkContextRequestMessageSchema,
|
AgentForkContextRequestMessageSchema,
|
||||||
SetAgentModeRequestMessageSchema,
|
SetAgentModeRequestMessageSchema,
|
||||||
SetAgentModelRequestMessageSchema,
|
SetAgentModelRequestMessageSchema,
|
||||||
@@ -2395,8 +2367,6 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
daemonSelfUpdate: z.boolean().optional(),
|
daemonSelfUpdate: z.boolean().optional(),
|
||||||
// COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
|
// COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
|
||||||
agentForkContext: z.boolean().optional(),
|
agentForkContext: z.boolean().optional(),
|
||||||
// COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12.
|
|
||||||
providerSubagents: z.boolean().optional(),
|
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
@@ -2988,88 +2958,6 @@ 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({
|
export const AgentForkContextResponseMessageSchema = z.object({
|
||||||
type: z.literal("agent.fork_context.response"),
|
type: z.literal("agent.fork_context.response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -4319,9 +4207,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ArchiveWorkspaceResponseMessageSchema,
|
ArchiveWorkspaceResponseMessageSchema,
|
||||||
FetchAgentResponseMessageSchema,
|
FetchAgentResponseMessageSchema,
|
||||||
FetchAgentTimelineResponseMessageSchema,
|
FetchAgentTimelineResponseMessageSchema,
|
||||||
ProviderSubagentListResponseMessageSchema,
|
|
||||||
ProviderSubagentTimelineResponseMessageSchema,
|
|
||||||
ProviderSubagentUpdateMessageSchema,
|
|
||||||
AgentForkContextResponseMessageSchema,
|
AgentForkContextResponseMessageSchema,
|
||||||
CancelAgentResponseMessageSchema,
|
CancelAgentResponseMessageSchema,
|
||||||
ClearAgentAttentionResponseMessageSchema,
|
ClearAgentAttentionResponseMessageSchema,
|
||||||
@@ -4794,7 +4679,6 @@ export const WSHelloMessageSchema = z.object({
|
|||||||
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
|
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
|
||||||
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
|
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
|
||||||
[CLIENT_CAPS.terminalReflowableSnapshot]: z.boolean().optional(),
|
[CLIENT_CAPS.terminalReflowableSnapshot]: z.boolean().optional(),
|
||||||
[CLIENT_CAPS.providerSubagents]: z.boolean().optional(),
|
|
||||||
[CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(),
|
[CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(),
|
||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"description": "Paseo relay for bridging daemon and client connections",
|
"description": "Paseo relay for bridging daemon and client connections",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.107",
|
"version": "0.1.106",
|
||||||
"description": "Paseo backend server",
|
"description": "Paseo backend server",
|
||||||
"files": [
|
"files": [
|
||||||
"dist/server",
|
"dist/server",
|
||||||
@@ -67,10 +67,10 @@
|
|||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.1.107",
|
"@getpaseo/client": "0.1.106",
|
||||||
"@getpaseo/highlight": "0.1.107",
|
"@getpaseo/highlight": "0.1.106",
|
||||||
"@getpaseo/protocol": "0.1.107",
|
"@getpaseo/protocol": "0.1.106",
|
||||||
"@getpaseo/relay": "0.1.107",
|
"@getpaseo/relay": "0.1.106",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import type {
|
|||||||
|
|
||||||
const COALESCE_WINDOW_MS = AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS;
|
const COALESCE_WINDOW_MS = AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS;
|
||||||
const BEFORE_COALESCE_WINDOW_MS = Math.max(COALESCE_WINDOW_MS - 1, 0);
|
const BEFORE_COALESCE_WINDOW_MS = Math.max(COALESCE_WINDOW_MS - 1, 0);
|
||||||
const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024;
|
const TOOL_CALL_CONTENT_MAX_BYTES = 64 * 1024;
|
||||||
|
|
||||||
const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
supportsStreaming: false,
|
supportsStreaming: false,
|
||||||
@@ -385,7 +385,7 @@ describe("target coalesced behavior", () => {
|
|||||||
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||||
const expectedItem = toolCall({
|
const expectedItem = toolCall({
|
||||||
status: "completed",
|
status: "completed",
|
||||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
|
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||||
});
|
});
|
||||||
|
|
||||||
session.pushEvent(timelineEvent(toolCall({ status: "completed", output })));
|
session.pushEvent(timelineEvent(toolCall({ status: "completed", output })));
|
||||||
@@ -403,6 +403,27 @@ 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 () => {
|
test("bounds appended tool output before persisting and streaming it", async () => {
|
||||||
const harness = createHarness();
|
const harness = createHarness();
|
||||||
try {
|
try {
|
||||||
@@ -410,7 +431,7 @@ describe("target coalesced behavior", () => {
|
|||||||
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||||
const expectedItem = toolCall({
|
const expectedItem = toolCall({
|
||||||
status: "completed",
|
status: "completed",
|
||||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
|
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||||
});
|
});
|
||||||
|
|
||||||
await harness.manager.appendTimelineItem(agentId, toolCall({ status: "completed", output }));
|
await harness.manager.appendTimelineItem(agentId, toolCall({ status: "completed", output }));
|
||||||
@@ -434,7 +455,7 @@ describe("target coalesced behavior", () => {
|
|||||||
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||||
const expectedItem = toolCall({
|
const expectedItem = toolCall({
|
||||||
status: "completed",
|
status: "completed",
|
||||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
|
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||||
});
|
});
|
||||||
session.setHistory([timelineEvent(toolCall({ status: "completed", output }))]);
|
session.setHistory([timelineEvent(toolCall({ status: "completed", output }))]);
|
||||||
|
|
||||||
@@ -455,7 +476,7 @@ describe("target coalesced behavior", () => {
|
|||||||
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||||
const expectedItem = toolCall({
|
const expectedItem = toolCall({
|
||||||
status: "completed",
|
status: "completed",
|
||||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
|
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||||
});
|
});
|
||||||
|
|
||||||
await harness.manager.emitLiveTimelineItem(
|
await harness.manager.emitLiveTimelineItem(
|
||||||
@@ -473,17 +494,22 @@ describe("target coalesced behavior", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("bounds failed shell output carried in the error", async () => {
|
test("bounds failed shell output carried in error content and message", async () => {
|
||||||
const harness = createHarness();
|
const harness = createHarness();
|
||||||
try {
|
try {
|
||||||
const { agentId, session } = await createManagedSession(harness);
|
const { agentId, session } = await createManagedSession(harness);
|
||||||
const content = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
const content = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||||
const expectedItem = toolCall({
|
const expectedItem = toolCall({
|
||||||
status: "failed",
|
status: "failed",
|
||||||
error: { content: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH) },
|
error: {
|
||||||
|
content: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||||
|
message: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
session.pushEvent(timelineEvent(toolCall({ status: "failed", error: { content } })));
|
session.pushEvent(
|
||||||
|
timelineEvent(toolCall({ status: "failed", error: { content, message: content } })),
|
||||||
|
);
|
||||||
await waitForSessionEventQueue();
|
await waitForSessionEventQueue();
|
||||||
|
|
||||||
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
|
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
|
||||||
|
|||||||
@@ -2331,27 +2331,6 @@ test("importProviderSession imports the selected session without listing and pub
|
|||||||
timestamp: "2026-01-02T00:00:02.000Z",
|
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" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2394,13 +2373,7 @@ test("importProviderSession imports the selected session without listing and pub
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(manager.listProviderSubagents(imported.id)).toEqual([
|
expect(events).toHaveLength(1);
|
||||||
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({
|
expect(events[0]).toMatchObject({
|
||||||
type: "agent_state",
|
type: "agent_state",
|
||||||
agent: {
|
agent: {
|
||||||
@@ -2580,150 +2553,6 @@ test("reloadAgentSession preserves timeline and does not force history replay",
|
|||||||
expect(afterHydrate).toEqual(beforeReload);
|
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 () => {
|
test("reloadAgentSession preserves current title when config title is unset", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-title-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-title-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
@@ -5177,67 +5006,6 @@ test("subscribe does not emit state events for internal agents to global subscri
|
|||||||
expect(receivedEvents.filter((id) => id === generatedAgentIds[1]).length).toBe(0);
|
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 () => {
|
test("subscribe emits state events for internal agents when subscribed by agentId", async () => {
|
||||||
const internalAgentId = "00000000-0000-4000-8000-000000000110";
|
const internalAgentId = "00000000-0000-4000-8000-000000000110";
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
|
|||||||
@@ -66,11 +66,6 @@ import { isSystemInjectedEnvelope } from "./agent-prompt.js";
|
|||||||
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
|
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
|
||||||
import { resolveCreateAgentTitles } from "./create-agent-title.js";
|
import { resolveCreateAgentTitles } from "./create-agent-title.js";
|
||||||
import type { PaseoToolCatalogFactory } from "./tools/types.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 RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
|
||||||
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
|
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
|
||||||
@@ -150,7 +145,6 @@ export type {
|
|||||||
|
|
||||||
export type AgentManagerEvent =
|
export type AgentManagerEvent =
|
||||||
| { type: "agent_state"; agent: ManagedAgent }
|
| { type: "agent_state"; agent: ManagedAgent }
|
||||||
| { type: "provider_subagent"; event: ProviderSubagentStoreEvent }
|
|
||||||
| {
|
| {
|
||||||
type: "agent_stream";
|
type: "agent_stream";
|
||||||
agentId: string;
|
agentId: string;
|
||||||
@@ -540,7 +534,6 @@ export class AgentManager {
|
|||||||
private readonly providerEnabled = new Map<AgentProvider, boolean>();
|
private readonly providerEnabled = new Map<AgentProvider, boolean>();
|
||||||
private readonly agents = new Map<string, LiveManagedAgent>();
|
private readonly agents = new Map<string, LiveManagedAgent>();
|
||||||
private readonly timelineStore = new InMemoryAgentTimelineStore();
|
private readonly timelineStore = new InMemoryAgentTimelineStore();
|
||||||
private readonly providerSubagents = new ProviderSubagentStore();
|
|
||||||
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
|
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
|
||||||
private readonly sessionEventTails = new Map<string, Promise<void>>();
|
private readonly sessionEventTails = new Map<string, Promise<void>>();
|
||||||
private readonly foregroundRuns = new ForegroundRunState();
|
private readonly foregroundRuns = new ForegroundRunState();
|
||||||
@@ -943,28 +936,6 @@ export class AgentManager {
|
|||||||
return this.timelineStore.fetch(id, options);
|
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(
|
createAgent(
|
||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
agentId: string | undefined,
|
agentId: string | undefined,
|
||||||
@@ -1115,7 +1086,7 @@ export class AgentManager {
|
|||||||
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
|
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
|
||||||
|
|
||||||
handedToRegistration = true;
|
handedToRegistration = true;
|
||||||
const agent = await this.registerSession(imported.session, importedConfig, resolvedAgentId, {
|
return this.registerSession(imported.session, importedConfig, resolvedAgentId, {
|
||||||
labels: input.labels,
|
labels: input.labels,
|
||||||
workspaceId: input.workspaceId,
|
workspaceId: input.workspaceId,
|
||||||
timelineRows,
|
timelineRows,
|
||||||
@@ -1125,11 +1096,6 @@ export class AgentManager {
|
|||||||
initialTitle,
|
initialTitle,
|
||||||
publishWhenReady: true,
|
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 {
|
} finally {
|
||||||
if (!handedToRegistration) {
|
if (!handedToRegistration) {
|
||||||
await this.closeUnregisteredSession(imported.session);
|
await this.closeUnregisteredSession(imported.session);
|
||||||
@@ -1202,9 +1168,6 @@ export class AgentManager {
|
|||||||
// provider history into an empty timeline.
|
// provider history into an empty timeline.
|
||||||
await this.deleteCommittedTimeline(agentId);
|
await this.deleteCommittedTimeline(agentId);
|
||||||
this.timelineStore.delete(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.
|
// Preserve existing labels and timeline during reload.
|
||||||
@@ -1298,9 +1261,6 @@ export class AgentManager {
|
|||||||
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
|
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
|
||||||
await agent.session.close();
|
await agent.session.close();
|
||||||
this.timelineStore.delete(agentId);
|
this.timelineStore.delete(agentId);
|
||||||
for (const event of this.providerSubagents.deleteParent(agentId)) {
|
|
||||||
this.dispatch({ type: "provider_subagent", event });
|
|
||||||
}
|
|
||||||
await this.persistSnapshot(closedAgent);
|
await this.persistSnapshot(closedAgent);
|
||||||
this.emitClosedAgent(closedAgent, { persist: false });
|
this.emitClosedAgent(closedAgent, { persist: false });
|
||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
@@ -2877,11 +2837,6 @@ export class AgentManager {
|
|||||||
agent: ActiveManagedAgent,
|
agent: ActiveManagedAgent,
|
||||||
event: AgentStreamEvent,
|
event: AgentStreamEvent,
|
||||||
): Promise<void> {
|
): 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 turnId = getAgentStreamEventTurnId(event);
|
||||||
const matchingWaiters = this.foregroundRuns.getMatchingWaiters(agent, turnId);
|
const matchingWaiters = this.foregroundRuns.getMatchingWaiters(agent, turnId);
|
||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
@@ -3020,79 +2975,49 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options?.force) {
|
if (options?.force) {
|
||||||
await this.forceHydrateTimelineFromLegacyProviderHistory(agent, options.broadcast === true);
|
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);
|
||||||
return;
|
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;
|
agent.historyPrimed = true;
|
||||||
try {
|
try {
|
||||||
for await (const event of agent.session.streamHistory()) {
|
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") {
|
if (event.type !== "timeline") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -3868,35 +3793,22 @@ export class AgentManager {
|
|||||||
) {
|
) {
|
||||||
continue;
|
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)
|
// Skip internal agents for global subscribers (those without a specific agentId)
|
||||||
if (!subscriber.agentId && this.eventBelongsToInternalAgent(event)) {
|
if (!subscriber.agentId) {
|
||||||
continue;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
subscriber.callback(event);
|
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(
|
private async normalizeConfig(
|
||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
options: NormalizeConfigOptions = {},
|
options: NormalizeConfigOptions = {},
|
||||||
@@ -4119,14 +4031,6 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
return agent;
|
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 {
|
export function commandMayHaveChangedExternalState(command: string): boolean {
|
||||||
|
|||||||
@@ -426,11 +426,6 @@ export type AgentStreamEvent =
|
|||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
reason: "finished" | "error" | "permission";
|
reason: "finished" | "error" | "permission";
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "provider_subagent";
|
|
||||||
provider: AgentProvider;
|
|
||||||
event: import("./provider-subagents/store.js").ProviderSubagentInputEvent;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getAgentStreamEventTurnId(event: AgentStreamEvent): string | undefined {
|
export function getAgentStreamEventTurnId(event: AgentStreamEvent): string | undefined {
|
||||||
@@ -546,7 +541,6 @@ export interface ImportedProviderSession {
|
|||||||
config: AgentSessionConfig;
|
config: AgentSessionConfig;
|
||||||
persistence: AgentPersistenceHandle;
|
persistence: AgentPersistenceHandle;
|
||||||
timeline: ImportedTimelineEntry[];
|
timeline: ImportedTimelineEntry[];
|
||||||
providerSubagentEvents?: Extract<AgentStreamEvent, { type: "provider_subagent" }>[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentSessionConfig {
|
export interface AgentSessionConfig {
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
|
import { StringDecoder } from "node:string_decoder";
|
||||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||||
|
|
||||||
const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
|
function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
|
||||||
if (
|
if (
|
||||||
@@ -8,19 +17,30 @@ function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
|
|||||||
item.detail.type !== "shell" ||
|
item.detail.type !== "shell" ||
|
||||||
item.status !== "failed" ||
|
item.status !== "failed" ||
|
||||||
typeof item.error !== "object" ||
|
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;
|
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 {
|
return {
|
||||||
...item,
|
...item,
|
||||||
error: {
|
error,
|
||||||
...item.error,
|
|
||||||
content: item.error.content.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,14 +53,15 @@ export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTim
|
|||||||
) {
|
) {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
if (item.detail.output.length <= TOOL_CALL_CONTENT_MAX_LENGTH) {
|
const output = limitTextContent(item.detail.output);
|
||||||
|
if (output === item.detail.output) {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
detail: {
|
detail: {
|
||||||
...item.detail,
|
...item.detail,
|
||||||
output: item.detail.output.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH),
|
output,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,32 +96,6 @@ 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", () => {
|
it("passes through an explicit mode when the target provider's modes are unknown", () => {
|
||||||
const resolved = resolveAndValidateCreateAgentMode({
|
const resolved = resolveAndValidateCreateAgentMode({
|
||||||
requestedMode: "default",
|
requestedMode: "default",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export interface ResolveCreateAgentModeInput {
|
|||||||
unattended: boolean;
|
unattended: boolean;
|
||||||
// `undefined` = target provider's modes unknown: explicit modes pass through
|
// `undefined` = target provider's modes unknown: explicit modes pass through
|
||||||
// unvalidated, but cross-provider inheritance is still refused.
|
// unvalidated, but cross-provider inheritance is still refused.
|
||||||
// `[]` = target provider explicitly has no modes: use its default behavior.
|
|
||||||
availableModes: string[] | undefined;
|
availableModes: string[] | undefined;
|
||||||
// Target provider's own unattended mode id, if it has one. Used to bridge
|
// Target provider's own unattended mode id, if it has one. Used to bridge
|
||||||
// unattended parents into unattended children across providers.
|
// unattended parents into unattended children across providers.
|
||||||
@@ -72,10 +71,6 @@ export function resolveAndValidateCreateAgentMode(
|
|||||||
return input.targetUnattendedMode;
|
return input.targetUnattendedMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (availableModes?.length === 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
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)}`,
|
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,14 +33,13 @@ export async function importSessionFromPersistence(input: {
|
|||||||
const persistence =
|
const persistence =
|
||||||
input.persistence ?? buildImportPersistenceHandle(input.provider, input.request, storedConfig);
|
input.persistence ?? buildImportPersistenceHandle(input.provider, input.request, storedConfig);
|
||||||
const session = await input.resumeSession(persistence, config, input.context.launchContext);
|
const session = await input.resumeSession(persistence, config, input.context.launchContext);
|
||||||
const history = await collectImportedHistory(session.streamHistory());
|
const timeline = await collectImportedTimeline(session.streamHistory());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
config: storedConfig,
|
config: storedConfig,
|
||||||
persistence,
|
persistence,
|
||||||
timeline: history.timeline,
|
timeline,
|
||||||
providerSubagentEvents: history.providerSubagentEvents,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,17 +60,11 @@ function buildImportPersistenceHandle(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectImportedHistory(events: AsyncGenerator<AgentStreamEvent>): Promise<{
|
async function collectImportedTimeline(
|
||||||
timeline: ImportedTimelineEntry[];
|
events: AsyncGenerator<AgentStreamEvent>,
|
||||||
providerSubagentEvents: Extract<AgentStreamEvent, { type: "provider_subagent" }>[];
|
): Promise<ImportedTimelineEntry[]> {
|
||||||
}> {
|
|
||||||
const timeline: ImportedTimelineEntry[] = [];
|
const timeline: ImportedTimelineEntry[] = [];
|
||||||
const providerSubagentEvents: Extract<AgentStreamEvent, { type: "provider_subagent" }>[] = [];
|
|
||||||
for await (const event of events) {
|
for await (const event of events) {
|
||||||
if (event.type === "provider_subagent") {
|
|
||||||
providerSubagentEvents.push(event);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (event.type !== "timeline") {
|
if (event.type !== "timeline") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -80,5 +73,5 @@ async function collectImportedHistory(events: AsyncGenerator<AgentStreamEvent>):
|
|||||||
...(event.timestamp ? { timestamp: event.timestamp } : {}),
|
...(event.timestamp ? { timestamp: event.timestamp } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { timeline, providerSubagentEvents };
|
return timeline;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
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,7 +20,6 @@ import {
|
|||||||
ACPAgentSession,
|
ACPAgentSession,
|
||||||
type SpawnedACPProcess,
|
type SpawnedACPProcess,
|
||||||
type SessionStateResponse,
|
type SessionStateResponse,
|
||||||
buildACPClientCapabilities,
|
|
||||||
createLoggedNdJsonStream,
|
createLoggedNdJsonStream,
|
||||||
deriveModelDefinitionsFromACP,
|
deriveModelDefinitionsFromACP,
|
||||||
deriveModesFromACP,
|
deriveModesFromACP,
|
||||||
@@ -51,39 +50,6 @@ import { buildStringCommandShellInvocation } from "../../../utils/string-command
|
|||||||
import { asInternals } from "../../test-utils/class-mocks.js";
|
import { asInternals } from "../../test-utils/class-mocks.js";
|
||||||
import * as spawnUtils from "../../../utils/spawn.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 {
|
interface ACPSessionInternals {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
connection: { prompt: (...args: unknown[]) => Promise<PromptResponse> };
|
connection: { prompt: (...args: unknown[]) => Promise<PromptResponse> };
|
||||||
@@ -2090,16 +2056,11 @@ describe("ACPAgentSession", () => {
|
|||||||
|
|
||||||
test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => {
|
test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
const events: Array<{
|
const events: Array<{ type: string; item?: { type: string; text?: string } }> = [];
|
||||||
type: string;
|
|
||||||
item?: { type: string; text?: string; messageId?: string };
|
|
||||||
}> = [];
|
|
||||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||||
|
|
||||||
session.subscribe((event) => {
|
session.subscribe((event) => {
|
||||||
events.push(
|
events.push(event as { type: string; item?: { type: string; text?: string } });
|
||||||
event as { type: string; item?: { type: string; text?: string; messageId?: string } },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await session.sessionUpdate({
|
await session.sessionUpdate({
|
||||||
@@ -2157,8 +2118,8 @@ describe("ACPAgentSession", () => {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
expect(timeline).toEqual([
|
expect(timeline).toEqual([
|
||||||
{ type: "assistant_message", text: "Hey!", messageId: "assistant-1" },
|
{ type: "assistant_message", text: "Hey!" },
|
||||||
{ type: "assistant_message", text: " How are you?", messageId: "assistant-1" },
|
{ type: "assistant_message", text: " How are you?" },
|
||||||
{ type: "reasoning", text: "Thinking" },
|
{ type: "reasoning", text: "Thinking" },
|
||||||
{ type: "reasoning", text: " more" },
|
{ type: "reasoning", text: " more" },
|
||||||
{ type: "user_message", text: "hel", messageId: "user-1" },
|
{ type: "user_message", text: "hel", messageId: "user-1" },
|
||||||
@@ -2166,48 +2127,6 @@ 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 () => {
|
test("startTurn returns before the ACP prompt settles and completes later via subscribers", async () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
const events: Array<{ type: string; turnId?: string }> = [];
|
const events: Array<{ type: string; turnId?: string }> = [];
|
||||||
@@ -2774,103 +2693,6 @@ 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 () => {
|
test("loadSession is always called with mcpServers even when supportsMcpServers is false", async () => {
|
||||||
const { session, loadSession } = makeTestSession({
|
const { session, loadSession } = makeTestSession({
|
||||||
capabilities: { loadSession: true, supportsMcpServers: false },
|
capabilities: { loadSession: true, supportsMcpServers: false },
|
||||||
|
|||||||
@@ -231,27 +231,22 @@ export const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
|
|
||||||
const BASE_ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
|
const BASE_ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
|
||||||
fs: {
|
fs: {
|
||||||
readTextFile: false,
|
readTextFile: true,
|
||||||
writeTextFile: false,
|
writeTextFile: true,
|
||||||
},
|
},
|
||||||
terminal: false,
|
terminal: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ACPClientCapabilityMeta = Record<string, unknown>;
|
export type ACPClientCapabilityMeta = Record<string, unknown>;
|
||||||
|
|
||||||
export function buildACPClientCapabilities(
|
export function buildACPClientCapabilities(meta?: ACPClientCapabilityMeta): ACPClientCapabilities {
|
||||||
meta?: ACPClientCapabilityMeta,
|
if (!meta || Object.keys(meta).length === 0) {
|
||||||
override?: ACPClientCapabilities,
|
return BASE_ACP_CLIENT_CAPABILITIES;
|
||||||
): ACPClientCapabilities {
|
}
|
||||||
const capabilities: ACPClientCapabilities = {
|
return {
|
||||||
...BASE_ACP_CLIENT_CAPABILITIES,
|
...BASE_ACP_CLIENT_CAPABILITIES,
|
||||||
...override,
|
_meta: meta,
|
||||||
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
|
// Suppress interactive auth side-effects (e.g. Gemini CLI opening a Google
|
||||||
@@ -376,7 +371,6 @@ interface ACPAgentClientOptions {
|
|||||||
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
|
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
|
||||||
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
|
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
|
||||||
configFeatureOptions?: ACPConfigFeatureOption[];
|
configFeatureOptions?: ACPConfigFeatureOption[];
|
||||||
clientCapabilities?: ACPClientCapabilities;
|
|
||||||
clientCapabilityMeta?: ACPClientCapabilityMeta;
|
clientCapabilityMeta?: ACPClientCapabilityMeta;
|
||||||
modeIdTransformer?: (modeId: string) => string | null;
|
modeIdTransformer?: (modeId: string) => string | null;
|
||||||
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||||
@@ -406,7 +400,6 @@ interface ACPAgentSessionOptions {
|
|||||||
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
|
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
|
||||||
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
|
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
|
||||||
configFeatureOptions?: ACPConfigFeatureOption[];
|
configFeatureOptions?: ACPConfigFeatureOption[];
|
||||||
clientCapabilities?: ACPClientCapabilities;
|
|
||||||
clientCapabilityMeta?: ACPClientCapabilityMeta;
|
clientCapabilityMeta?: ACPClientCapabilityMeta;
|
||||||
modeIdTransformer?: (modeId: string) => string | null;
|
modeIdTransformer?: (modeId: string) => string | null;
|
||||||
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||||
@@ -715,7 +708,6 @@ export class ACPAgentClient implements AgentClient {
|
|||||||
configOptions: SessionConfigOption[],
|
configOptions: SessionConfigOption[],
|
||||||
) => SessionConfigOption[];
|
) => SessionConfigOption[];
|
||||||
private readonly configFeatureOptions: ACPConfigFeatureOption[];
|
private readonly configFeatureOptions: ACPConfigFeatureOption[];
|
||||||
private readonly clientCapabilities?: ACPClientCapabilities;
|
|
||||||
private readonly clientCapabilityMeta?: ACPClientCapabilityMeta;
|
private readonly clientCapabilityMeta?: ACPClientCapabilityMeta;
|
||||||
private readonly modeIdTransformer?: (modeId: string) => string | null;
|
private readonly modeIdTransformer?: (modeId: string) => string | null;
|
||||||
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||||
@@ -750,7 +742,6 @@ export class ACPAgentClient implements AgentClient {
|
|||||||
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
||||||
this.configOptionsTransformer = options.configOptionsTransformer;
|
this.configOptionsTransformer = options.configOptionsTransformer;
|
||||||
this.configFeatureOptions = options.configFeatureOptions ?? [];
|
this.configFeatureOptions = options.configFeatureOptions ?? [];
|
||||||
this.clientCapabilities = options.clientCapabilities;
|
|
||||||
this.clientCapabilityMeta = options.clientCapabilityMeta;
|
this.clientCapabilityMeta = options.clientCapabilityMeta;
|
||||||
this.modeIdTransformer = options.modeIdTransformer;
|
this.modeIdTransformer = options.modeIdTransformer;
|
||||||
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
||||||
@@ -779,7 +770,6 @@ export class ACPAgentClient implements AgentClient {
|
|||||||
sessionResponseTransformer: this.sessionResponseTransformer,
|
sessionResponseTransformer: this.sessionResponseTransformer,
|
||||||
configOptionsTransformer: this.configOptionsTransformer,
|
configOptionsTransformer: this.configOptionsTransformer,
|
||||||
configFeatureOptions: this.configFeatureOptions,
|
configFeatureOptions: this.configFeatureOptions,
|
||||||
clientCapabilities: this.clientCapabilities,
|
|
||||||
clientCapabilityMeta: this.clientCapabilityMeta,
|
clientCapabilityMeta: this.clientCapabilityMeta,
|
||||||
modeIdTransformer: this.modeIdTransformer,
|
modeIdTransformer: this.modeIdTransformer,
|
||||||
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
||||||
@@ -829,7 +819,6 @@ export class ACPAgentClient implements AgentClient {
|
|||||||
sessionResponseTransformer: this.sessionResponseTransformer,
|
sessionResponseTransformer: this.sessionResponseTransformer,
|
||||||
configOptionsTransformer: this.configOptionsTransformer,
|
configOptionsTransformer: this.configOptionsTransformer,
|
||||||
configFeatureOptions: this.configFeatureOptions,
|
configFeatureOptions: this.configFeatureOptions,
|
||||||
clientCapabilities: this.clientCapabilities,
|
|
||||||
clientCapabilityMeta: this.clientCapabilityMeta,
|
clientCapabilityMeta: this.clientCapabilityMeta,
|
||||||
modeIdTransformer: this.modeIdTransformer,
|
modeIdTransformer: this.modeIdTransformer,
|
||||||
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
||||||
@@ -1068,10 +1057,7 @@ export class ACPAgentClient implements AgentClient {
|
|||||||
Promise.race([
|
Promise.race([
|
||||||
transport.connection.initialize({
|
transport.connection.initialize({
|
||||||
protocolVersion: PROTOCOL_VERSION,
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
clientCapabilities: buildACPClientCapabilities(
|
clientCapabilities: buildACPClientCapabilities(this.clientCapabilityMeta),
|
||||||
this.clientCapabilityMeta,
|
|
||||||
this.clientCapabilities,
|
|
||||||
),
|
|
||||||
clientInfo: { name: "Paseo", version: "dev" },
|
clientInfo: { name: "Paseo", version: "dev" },
|
||||||
}),
|
}),
|
||||||
transport.spawnError,
|
transport.spawnError,
|
||||||
@@ -1283,7 +1269,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
configOptions: SessionConfigOption[],
|
configOptions: SessionConfigOption[],
|
||||||
) => SessionConfigOption[];
|
) => SessionConfigOption[];
|
||||||
private readonly configFeatureOptions: ACPConfigFeatureOption[];
|
private readonly configFeatureOptions: ACPConfigFeatureOption[];
|
||||||
private readonly clientCapabilities?: ACPClientCapabilities;
|
|
||||||
private readonly clientCapabilityMeta?: ACPClientCapabilityMeta;
|
private readonly clientCapabilityMeta?: ACPClientCapabilityMeta;
|
||||||
private readonly modeIdTransformer?: (modeId: string) => string | null;
|
private readonly modeIdTransformer?: (modeId: string) => string | null;
|
||||||
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||||
@@ -1331,7 +1316,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
private readonly extensionCommandsParser?: ACPExtensionCommandsParser;
|
private readonly extensionCommandsParser?: ACPExtensionCommandsParser;
|
||||||
private currentTurnUsage: AgentUsage | undefined;
|
private currentTurnUsage: AgentUsage | undefined;
|
||||||
private activeForegroundTurnId: string | null = null;
|
private activeForegroundTurnId: string | null = null;
|
||||||
private fallbackAssistantMessageId: string | null = null;
|
|
||||||
private closed = false;
|
private closed = false;
|
||||||
private historyPending = false;
|
private historyPending = false;
|
||||||
private replayingHistory = false;
|
private replayingHistory = false;
|
||||||
@@ -1350,7 +1334,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
||||||
this.configOptionsTransformer = options.configOptionsTransformer;
|
this.configOptionsTransformer = options.configOptionsTransformer;
|
||||||
this.configFeatureOptions = options.configFeatureOptions ?? [];
|
this.configFeatureOptions = options.configFeatureOptions ?? [];
|
||||||
this.clientCapabilities = options.clientCapabilities;
|
|
||||||
this.clientCapabilityMeta = options.clientCapabilityMeta;
|
this.clientCapabilityMeta = options.clientCapabilityMeta;
|
||||||
this.modeIdTransformer = options.modeIdTransformer;
|
this.modeIdTransformer = options.modeIdTransformer;
|
||||||
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
||||||
@@ -1476,7 +1459,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
const turnId = randomUUID();
|
const turnId = randomUUID();
|
||||||
const messageId = options?.messageId ?? randomUUID();
|
const messageId = options?.messageId ?? randomUUID();
|
||||||
this.activeForegroundTurnId = turnId;
|
this.activeForegroundTurnId = turnId;
|
||||||
this.fallbackAssistantMessageId = null;
|
|
||||||
this.activeSubmittedUserMessage = null;
|
this.activeSubmittedUserMessage = null;
|
||||||
this.emitBootstrapThreadEvent();
|
this.emitBootstrapThreadEvent();
|
||||||
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||||
@@ -2338,10 +2320,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
const initialize = await this.runACPRequest(() =>
|
const initialize = await this.runACPRequest(() =>
|
||||||
connection.initialize({
|
connection.initialize({
|
||||||
protocolVersion: PROTOCOL_VERSION,
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
clientCapabilities: buildACPClientCapabilities(
|
clientCapabilities: buildACPClientCapabilities(this.clientCapabilityMeta),
|
||||||
this.clientCapabilityMeta,
|
|
||||||
this.clientCapabilities,
|
|
||||||
),
|
|
||||||
clientInfo: { name: "Paseo", version: "dev" },
|
clientInfo: { name: "Paseo", version: "dev" },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -2445,7 +2424,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
private translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[] {
|
private translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[] {
|
||||||
switch (update.sessionUpdate) {
|
switch (update.sessionUpdate) {
|
||||||
case "user_message_chunk": {
|
case "user_message_chunk": {
|
||||||
this.fallbackAssistantMessageId = null;
|
|
||||||
const item = this.createMessageTimelineItem("user_message", update);
|
const item = this.createMessageTimelineItem("user_message", update);
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return [];
|
return [];
|
||||||
@@ -2463,12 +2441,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
return item ? [this.wrapTimeline(item)] : [];
|
return item ? [this.wrapTimeline(item)] : [];
|
||||||
}
|
}
|
||||||
case "agent_thought_chunk": {
|
case "agent_thought_chunk": {
|
||||||
this.fallbackAssistantMessageId = null;
|
|
||||||
const item = this.createMessageTimelineItem("reasoning", update);
|
const item = this.createMessageTimelineItem("reasoning", update);
|
||||||
return item ? [this.wrapTimeline(item)] : [];
|
return item ? [this.wrapTimeline(item)] : [];
|
||||||
}
|
}
|
||||||
case "tool_call":
|
case "tool_call":
|
||||||
this.fallbackAssistantMessageId = null;
|
|
||||||
return this.handleToolCallUpdate(update.toolCallId, update, undefined);
|
return this.handleToolCallUpdate(update.toolCallId, update, undefined);
|
||||||
case "tool_call_update":
|
case "tool_call_update":
|
||||||
return this.handleToolCallUpdate(
|
return this.handleToolCallUpdate(
|
||||||
@@ -2477,7 +2453,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
this.toolCalls.get(update.toolCallId),
|
this.toolCalls.get(update.toolCallId),
|
||||||
);
|
);
|
||||||
case "plan":
|
case "plan":
|
||||||
this.fallbackAssistantMessageId = null;
|
|
||||||
return [this.wrapTimeline(mapPlanToTimeline(update))];
|
return [this.wrapTimeline(mapPlanToTimeline(update))];
|
||||||
case "current_mode_update":
|
case "current_mode_update":
|
||||||
this.handleCurrentModeUpdate(update);
|
this.handleCurrentModeUpdate(update);
|
||||||
@@ -2532,7 +2507,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
>,
|
>,
|
||||||
):
|
):
|
||||||
| { type: "user_message"; text: string; messageId?: string }
|
| { type: "user_message"; text: string; messageId?: string }
|
||||||
| { type: "assistant_message"; text: string; messageId: string }
|
| { type: "assistant_message"; text: string }
|
||||||
| { type: "reasoning"; text: string }
|
| { type: "reasoning"; text: string }
|
||||||
| null {
|
| null {
|
||||||
const chunkText = contentBlockToText(update.content);
|
const chunkText = contentBlockToText(update.content);
|
||||||
@@ -2548,24 +2523,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
return { type: "user_message", text: state.text, messageId: update.messageId ?? undefined };
|
return { type: "user_message", text: state.text, messageId: update.messageId ?? undefined };
|
||||||
}
|
}
|
||||||
if (type === "assistant_message") {
|
if (type === "assistant_message") {
|
||||||
return {
|
return { type: "assistant_message", text: chunkText };
|
||||||
type: "assistant_message",
|
|
||||||
text: chunkText,
|
|
||||||
messageId: this.resolveAssistantMessageId(update.messageId),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return { type: "reasoning", text: chunkText };
|
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(
|
private messageAssemblyKey(
|
||||||
type: "user_message" | "assistant_message" | "reasoning",
|
type: "user_message" | "assistant_message" | "reasoning",
|
||||||
messageId: string | null | undefined,
|
messageId: string | null | undefined,
|
||||||
@@ -2720,7 +2682,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
|
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
|
||||||
): void {
|
): void {
|
||||||
this.activeForegroundTurnId = null;
|
this.activeForegroundTurnId = null;
|
||||||
this.fallbackAssistantMessageId = null;
|
|
||||||
if (this.activeSubmittedUserMessage?.turnId === event.turnId) {
|
if (this.activeSubmittedUserMessage?.turnId === event.turnId) {
|
||||||
this.activeSubmittedUserMessage = null;
|
this.activeSubmittedUserMessage = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,21 +211,6 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
|||||||
parent_tool_use_id: "task-call-1",
|
parent_tool_use_id: "task-call-1",
|
||||||
elapsed_time_seconds: 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",
|
type: "assistant",
|
||||||
parent_tool_use_id: "task-call-1",
|
parent_tool_use_id: "task-call-1",
|
||||||
@@ -322,44 +307,6 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(projectedTaskCalls).toHaveLength(1);
|
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 () => {
|
test("keeps sidechain assistant text out of the parent transcript", async () => {
|
||||||
@@ -402,46 +349,6 @@ 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 () => {
|
test("tails sub-agent actions instead of dropping latest entries at cap", async () => {
|
||||||
queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
|
queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
|
||||||
|
|
||||||
|
|||||||
@@ -1897,10 +1897,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null,
|
getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null,
|
||||||
});
|
});
|
||||||
private persistedHistory: PersistedTimelineEntry[] = [];
|
private persistedHistory: PersistedTimelineEntry[] = [];
|
||||||
private persistedProviderSubagentEvents: Extract<
|
|
||||||
AgentStreamEvent,
|
|
||||||
{ type: "provider_subagent" }
|
|
||||||
>[] = [];
|
|
||||||
private historyPending = false;
|
private historyPending = false;
|
||||||
private turnState: TurnState = "idle";
|
private turnState: TurnState = "idle";
|
||||||
private nextTurnOrdinal = 1;
|
private nextTurnOrdinal = 1;
|
||||||
@@ -2121,16 +2117,11 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||||
if (
|
if (!this.historyPending || this.persistedHistory.length === 0) {
|
||||||
!this.historyPending ||
|
|
||||||
(this.persistedHistory.length === 0 && this.persistedProviderSubagentEvents.length === 0)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const history = this.persistedHistory;
|
const history = this.persistedHistory;
|
||||||
const providerSubagentEvents = this.persistedProviderSubagentEvents;
|
|
||||||
this.persistedHistory = [];
|
this.persistedHistory = [];
|
||||||
this.persistedProviderSubagentEvents = [];
|
|
||||||
this.historyPending = false;
|
this.historyPending = false;
|
||||||
for (const entry of history) {
|
for (const entry of history) {
|
||||||
yield {
|
yield {
|
||||||
@@ -2140,7 +2131,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
timestamp: entry.timestamp,
|
timestamp: entry.timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
yield* providerSubagentEvents;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAvailableModes(): Promise<AgentMode[]> {
|
async getAvailableModes(): Promise<AgentMode[]> {
|
||||||
@@ -2623,7 +2613,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.cachedRuntimeInfo = null;
|
this.cachedRuntimeInfo = null;
|
||||||
this.queryRestartNeeded = true;
|
this.queryRestartNeeded = true;
|
||||||
this.persistedHistory = [];
|
this.persistedHistory = [];
|
||||||
this.persistedProviderSubagentEvents = [];
|
|
||||||
this.historyPending = false;
|
this.historyPending = false;
|
||||||
this.userMessageIds = [];
|
this.userMessageIds = [];
|
||||||
this.emittedUserMessageIds.clear();
|
this.emittedUserMessageIds.clear();
|
||||||
@@ -2653,7 +2642,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.cachedRuntimeInfo = null;
|
this.cachedRuntimeInfo = null;
|
||||||
this.queryRestartNeeded = true;
|
this.queryRestartNeeded = true;
|
||||||
this.persistedHistory = [];
|
this.persistedHistory = [];
|
||||||
this.persistedProviderSubagentEvents = [];
|
|
||||||
this.historyPending = false;
|
this.historyPending = false;
|
||||||
this.userMessageIds = [];
|
this.userMessageIds = [];
|
||||||
this.emittedUserMessageIds.clear();
|
this.emittedUserMessageIds.clear();
|
||||||
@@ -3551,7 +3539,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
this.persistence = null;
|
this.persistence = null;
|
||||||
this.persistedHistory = [];
|
this.persistedHistory = [];
|
||||||
this.persistedProviderSubagentEvents = [];
|
|
||||||
this.historyPending = false;
|
this.historyPending = false;
|
||||||
this.cachedRuntimeInfo = null;
|
this.cachedRuntimeInfo = null;
|
||||||
this.queryRestartNeeded = false;
|
this.queryRestartNeeded = false;
|
||||||
@@ -3623,7 +3610,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
break;
|
break;
|
||||||
case "user":
|
case "user":
|
||||||
this.appendUserMessageEvents(message, events);
|
this.appendUserMessageEvents(message, events);
|
||||||
this.appendSidechainResultEvents(message, events);
|
|
||||||
break;
|
break;
|
||||||
case "assistant": {
|
case "assistant": {
|
||||||
const timelineItems = this.mapBlocksToTimeline(message.message.content, {
|
const timelineItems = this.mapBlocksToTimeline(message.message.content, {
|
||||||
@@ -3633,7 +3619,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
for (const item of timelineItems) {
|
for (const item of timelineItems) {
|
||||||
events.push({ type: "timeline", item, provider: "claude" });
|
events.push({ type: "timeline", item, provider: "claude" });
|
||||||
}
|
}
|
||||||
this.appendSidechainResultEvents(message, events);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "stream_event":
|
case "stream_event":
|
||||||
@@ -3649,18 +3634,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
return events;
|
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(
|
private emitSubmittedUserMessage(
|
||||||
message: Extract<SDKMessage, { type: "user" }>,
|
message: Extract<SDKMessage, { type: "user" }>,
|
||||||
turnId: string,
|
turnId: string,
|
||||||
@@ -3857,7 +3830,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
): void {
|
): void {
|
||||||
const usage = this.convertUsage(message, message.modelUsage);
|
const usage = this.convertUsage(message, message.modelUsage);
|
||||||
if (message.subtype === "success") {
|
if (message.subtype === "success") {
|
||||||
events.push(...this.sidechainTracker.finishAll("completed"));
|
|
||||||
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
|
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
|
||||||
// run client-side in the Claude CLI with no model turn — output_tokens
|
// 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
|
// is 0 and the user-visible text is carried in `result`. Surface it only
|
||||||
@@ -3883,7 +3855,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
"errors" in message && Array.isArray(message.errors) && message.errors.length > 0
|
"errors" in message && Array.isArray(message.errors) && message.errors.length > 0
|
||||||
? message.errors.join("\n")
|
? message.errors.join("\n")
|
||||||
: "Claude run failed";
|
: "Claude run failed";
|
||||||
events.push(...this.sidechainTracker.finishAll("failed"));
|
|
||||||
events.push(this.buildTurnFailedEvent(errorMessage));
|
events.push(this.buildTurnFailedEvent(errorMessage));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4195,9 +4166,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
if (!historyPath || !fs.existsSync(historyPath)) {
|
if (!historyPath || !fs.existsSync(historyPath)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = fs.readFileSync(historyPath, "utf8");
|
this.ingestPersistedHistory(fs.readFileSync(historyPath, "utf8"));
|
||||||
this.ingestPersistedHistory(content);
|
|
||||||
this.ingestPersistedSidechains(content, readClaudeSidechainHistory(historyPath));
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore history load failures
|
// ignore history load failures
|
||||||
}
|
}
|
||||||
@@ -4219,24 +4188,6 @@ 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 {
|
private ingestPersistedHistoryLine(line: string, timeline: PersistedTimelineEntry[]): void {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -4514,6 +4465,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
|
|
||||||
if (typeof block.tool_use_id === "string") {
|
if (typeof block.tool_use_id === "string") {
|
||||||
this.toolUseCache.delete(block.tool_use_id);
|
this.toolUseCache.delete(block.tool_use_id);
|
||||||
|
this.sidechainTracker.delete(block.tool_use_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4995,194 +4947,11 @@ function normalizeHistoryBlocks(content: unknown): ClaudeContentChunk[] | null {
|
|||||||
return 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 {
|
interface ClaudeHistoryEntry {
|
||||||
type?: unknown;
|
type?: unknown;
|
||||||
subtype?: unknown;
|
subtype?: unknown;
|
||||||
isCompactSummary?: unknown;
|
isCompactSummary?: unknown;
|
||||||
isSidechain?: unknown;
|
isSidechain?: unknown;
|
||||||
agentId?: unknown;
|
|
||||||
timestamp?: unknown;
|
|
||||||
uuid?: unknown;
|
uuid?: unknown;
|
||||||
message?: { content?: unknown; [key: string]: unknown };
|
message?: { content?: unknown; [key: string]: unknown };
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|||||||
-95
@@ -15,7 +15,6 @@ let lastQuery: ReturnType<typeof buildSdkQueryMock> | null = null;
|
|||||||
const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER";
|
const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER";
|
||||||
const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER";
|
const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER";
|
||||||
const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER";
|
const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER";
|
||||||
const HISTORY_SIDECHAIN_MARKER = "HISTORY_ONLY_SIDECHAIN_MARKER";
|
|
||||||
|
|
||||||
function buildSdkQueryMock() {
|
function buildSdkQueryMock() {
|
||||||
const events = [
|
const events = [
|
||||||
@@ -127,52 +126,6 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
content: HISTORY_ASSISTANT_MARKER,
|
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"),
|
].join("\n"),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
@@ -266,54 +219,6 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
expect(timelineText).toContain(HISTORY_ASSISTANT_MARKER);
|
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 () => {
|
test("listCommands includes rewind command", async () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
|
||||||
import {
|
import { mapClaudeRunningToolCall } from "./tool-call-mapper.js";
|
||||||
mapClaudeCompletedToolCall,
|
|
||||||
mapClaudeFailedToolCall,
|
|
||||||
mapClaudeRunningToolCall,
|
|
||||||
} from "./tool-call-mapper.js";
|
|
||||||
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
|
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
|
||||||
|
|
||||||
import type { AgentMetadata, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
|
import type { AgentMetadata, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
|
||||||
@@ -17,7 +13,6 @@ interface ClaudeContentChunk {
|
|||||||
interface SubAgentActionEntry {
|
interface SubAgentActionEntry {
|
||||||
index: number;
|
index: number;
|
||||||
toolName: string;
|
toolName: string;
|
||||||
input: unknown;
|
|
||||||
summary?: string;
|
summary?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +23,6 @@ interface SubAgentActivityState {
|
|||||||
actionKeys: string[];
|
actionKeys: string[];
|
||||||
nextActionIndex: number;
|
nextActionIndex: number;
|
||||||
actionIndexByKey: Map<string, number>;
|
actionIndexByKey: Map<string, number>;
|
||||||
completedActionKeys: Set<string>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SubAgentActionCandidate {
|
interface SubAgentActionCandidate {
|
||||||
@@ -70,34 +64,19 @@ export class ClaudeSidechainTracker {
|
|||||||
actionKeys: [],
|
actionKeys: [],
|
||||||
nextActionIndex: 1,
|
nextActionIndex: 1,
|
||||||
actionIndexByKey: new Map<string, number>(),
|
actionIndexByKey: new Map<string, number>(),
|
||||||
completedActionKeys: new Set<string>(),
|
|
||||||
} satisfies SubAgentActivityState);
|
} satisfies SubAgentActivityState);
|
||||||
this.activeSidechains.set(parentToolUseId, state);
|
this.activeSidechains.set(parentToolUseId, state);
|
||||||
|
|
||||||
const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId);
|
const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId);
|
||||||
const actionCandidates = this.extractSubAgentActionCandidates(message);
|
const actionCandidates = this.extractSubAgentActionCandidates(message);
|
||||||
const childTimelineItems = [
|
|
||||||
...this.extractSubAgentTimelineItems(message),
|
|
||||||
...this.extractSubAgentToolResults(message, state),
|
|
||||||
];
|
|
||||||
let actionUpdated = false;
|
let actionUpdated = false;
|
||||||
for (const action of actionCandidates) {
|
for (const action of actionCandidates) {
|
||||||
if (state.completedActionKeys.has(action.key)) continue;
|
|
||||||
if (this.appendSubAgentAction(state, action)) {
|
if (this.appendSubAgentAction(state, action)) {
|
||||||
actionUpdated = true;
|
actionUpdated = true;
|
||||||
const toolCall = mapClaudeRunningToolCall({
|
|
||||||
name: action.toolName,
|
|
||||||
callId: action.key,
|
|
||||||
input: action.input,
|
|
||||||
output: null,
|
|
||||||
});
|
|
||||||
if (toolCall) {
|
|
||||||
childTimelineItems.push(toolCall);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!contextUpdated && !actionUpdated && childTimelineItems.length === 0) {
|
if (!contextUpdated && !actionUpdated) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,25 +103,6 @@ export class ClaudeSidechainTracker {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return [
|
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",
|
type: "timeline",
|
||||||
item: {
|
item: {
|
||||||
@@ -154,46 +114,6 @@ 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 {
|
delete(toolUseId: string): void {
|
||||||
this.activeSidechains.delete(toolUseId);
|
this.activeSidechains.delete(toolUseId);
|
||||||
}
|
}
|
||||||
@@ -202,66 +122,6 @@ export class ClaudeSidechainTracker {
|
|||||||
this.activeSidechains.clear();
|
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(
|
private updateSubAgentContextFromTaskInput(
|
||||||
state: SubAgentActivityState,
|
state: SubAgentActivityState,
|
||||||
parentToolUseId: string,
|
parentToolUseId: string,
|
||||||
@@ -399,7 +259,6 @@ export class ClaudeSidechainTracker {
|
|||||||
state.actions[existingIndex] = {
|
state.actions[existingIndex] = {
|
||||||
...existing,
|
...existing,
|
||||||
toolName: normalizedToolName,
|
toolName: normalizedToolName,
|
||||||
input: existing.input ?? candidate.input,
|
|
||||||
...(nextSummary ? { summary: nextSummary } : {}),
|
...(nextSummary ? { summary: nextSummary } : {}),
|
||||||
};
|
};
|
||||||
return true;
|
return true;
|
||||||
@@ -408,7 +267,6 @@ export class ClaudeSidechainTracker {
|
|||||||
state.actions.push({
|
state.actions.push({
|
||||||
index: state.nextActionIndex,
|
index: state.nextActionIndex,
|
||||||
toolName: normalizedToolName,
|
toolName: normalizedToolName,
|
||||||
input: candidate.input,
|
|
||||||
...(summary ? { summary } : {}),
|
...(summary ? { summary } : {}),
|
||||||
});
|
});
|
||||||
state.nextActionIndex += 1;
|
state.nextActionIndex += 1;
|
||||||
@@ -421,8 +279,7 @@ export class ClaudeSidechainTracker {
|
|||||||
private trimSubAgentTail(state: SubAgentActivityState): void {
|
private trimSubAgentTail(state: SubAgentActivityState): void {
|
||||||
while (state.actions.length > MAX_SUB_AGENT_LOG_ENTRIES) {
|
while (state.actions.length > MAX_SUB_AGENT_LOG_ENTRIES) {
|
||||||
state.actions.shift();
|
state.actions.shift();
|
||||||
const removedKey = state.actionKeys.shift();
|
state.actionKeys.shift();
|
||||||
if (removedKey) state.completedActionKeys.delete(removedKey);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1567,7 +1567,7 @@ describe("Codex app-server provider", () => {
|
|||||||
asInternals(session).handleNotification("item/agentMessage/delta", {
|
asInternals(session).handleNotification("item/agentMessage/delta", {
|
||||||
threadId: "child-thread-1",
|
threadId: "child-thread-1",
|
||||||
itemId: "child-message-1",
|
itemId: "child-message-1",
|
||||||
delta: "Found",
|
delta: "Found the path.",
|
||||||
});
|
});
|
||||||
asInternals(session).handleNotification("item/completed", {
|
asInternals(session).handleNotification("item/completed", {
|
||||||
threadId: "child-thread-1",
|
threadId: "child-thread-1",
|
||||||
@@ -1599,128 +1599,6 @@ describe("Codex app-server provider", () => {
|
|||||||
actions: [],
|
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 () => {
|
test("keeps the parent running when a MultiAgentV2 sub-agent finishes", async () => {
|
||||||
@@ -2217,20 +2095,6 @@ describe("Codex app-server provider", () => {
|
|||||||
event.item.callId === "child-command",
|
event.item.callId === "child-command",
|
||||||
),
|
),
|
||||||
).toBe(false);
|
).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.filter((event) => event.type === "turn_completed")).toHaveLength(0);
|
||||||
expect(events.at(-1)).toMatchObject({
|
expect(events.at(-1)).toMatchObject({
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
@@ -2479,28 +2343,10 @@ describe("Codex app-server provider", () => {
|
|||||||
test("loads mixed legacy and MultiAgentV2 sub-agent history", async () => {
|
test("loads mixed legacy and MultiAgentV2 sub-agent history", async () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
session.client = {
|
session.client = {
|
||||||
request: vi.fn(async (method: string, params: unknown) => {
|
request: vi.fn(async (method: string) => {
|
||||||
if (method !== "thread/read") {
|
if (method !== "thread/read") {
|
||||||
return {};
|
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 {
|
return {
|
||||||
thread: {
|
thread: {
|
||||||
turns: [
|
turns: [
|
||||||
@@ -2536,38 +2382,6 @@ describe("Codex app-server provider", () => {
|
|||||||
for await (const event of session.streamHistory()) {
|
for await (const event of session.streamHistory()) {
|
||||||
history.push(event);
|
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(
|
expect(
|
||||||
history
|
history
|
||||||
.filter((event) => event.type === "timeline" && event.item.type === "tool_call")
|
.filter((event) => event.type === "timeline" && event.item.type === "tool_call")
|
||||||
@@ -2633,13 +2447,10 @@ describe("Codex app-server provider", () => {
|
|||||||
test("coalesces persisted MultiAgentV2 activity for one child into one terminal card", async () => {
|
test("coalesces persisted MultiAgentV2 activity for one child into one terminal card", async () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
session.client = {
|
session.client = {
|
||||||
request: vi.fn(async (method: string, params: unknown) => {
|
request: vi.fn(async (method: string) => {
|
||||||
if (method !== "thread/read") {
|
if (method !== "thread/read") {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
if ((params as { threadId?: string }).threadId !== "test-thread") {
|
|
||||||
return { thread: { turns: [] } };
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
thread: {
|
thread: {
|
||||||
turns: [
|
turns: [
|
||||||
@@ -2684,15 +2495,6 @@ describe("Codex app-server provider", () => {
|
|||||||
history.push(event);
|
history.push(event);
|
||||||
}
|
}
|
||||||
expect(history).toEqual([
|
expect(history).toEqual([
|
||||||
{
|
|
||||||
type: "provider_subagent",
|
|
||||||
provider: "codex",
|
|
||||||
event: expect.objectContaining({
|
|
||||||
type: "upsert",
|
|
||||||
id: "history-child-thread",
|
|
||||||
status: "canceled",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
|
|||||||
@@ -3061,7 +3061,6 @@ interface CodexSubAgentCallState {
|
|||||||
pendingFileChangeOutputDeltas: Map<string, string[]>;
|
pendingFileChangeOutputDeltas: Map<string, string[]>;
|
||||||
childItemOrder: string[];
|
childItemOrder: string[];
|
||||||
childItems: Map<string, AgentTimelineItem>;
|
childItems: Map<string, AgentTimelineItem>;
|
||||||
childThreadIds: Set<string>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CodexAppServerAgentSession implements AgentSession {
|
export class CodexAppServerAgentSession implements AgentSession {
|
||||||
@@ -3082,8 +3081,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private planModeEnabled = false;
|
private planModeEnabled = false;
|
||||||
private historyPending = false;
|
private historyPending = false;
|
||||||
private persistedHistory: PersistedTimelineEntry[] = [];
|
private persistedHistory: PersistedTimelineEntry[] = [];
|
||||||
private loadingPersistedHistory = false;
|
|
||||||
private persistedProviderSubagentEvents: AgentStreamEvent[] = [];
|
|
||||||
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||||
private mcpElicitationPermissionIds = new Map<number, string>();
|
private mcpElicitationPermissionIds = new Map<number, string>();
|
||||||
private pendingPermissionHandlers = new Map<
|
private pendingPermissionHandlers = new Map<
|
||||||
@@ -3108,7 +3105,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private emittedExecCommandCompletedCallIds = new Set<string>();
|
private emittedExecCommandCompletedCallIds = new Set<string>();
|
||||||
private emittedItemStartedIds = new Set<string>();
|
private emittedItemStartedIds = new Set<string>();
|
||||||
private emittedItemCompletedIds = new Set<string>();
|
private emittedItemCompletedIds = new Set<string>();
|
||||||
private emittedProviderSubagentUserMessageKeys = new Set<string>();
|
|
||||||
private subAgentCallsByCallId = new Map<string, CodexSubAgentCallState>();
|
private subAgentCallsByCallId = new Map<string, CodexSubAgentCallState>();
|
||||||
private subAgentCallIdByChildThreadId = new Map<string, string>();
|
private subAgentCallIdByChildThreadId = new Map<string, string>();
|
||||||
private pendingSubAgentNotificationsByThreadId = new Map<string, ParsedCodexNotification[]>();
|
private pendingSubAgentNotificationsByThreadId = new Map<string, ParsedCodexNotification[]>();
|
||||||
@@ -3458,12 +3454,12 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.subAgentCallsByCallId.clear();
|
this.subAgentCallsByCallId.clear();
|
||||||
this.subAgentCallIdByChildThreadId.clear();
|
this.subAgentCallIdByChildThreadId.clear();
|
||||||
this.pendingSubAgentNotificationsByThreadId.clear();
|
this.pendingSubAgentNotificationsByThreadId.clear();
|
||||||
this.persistedProviderSubagentEvents = [];
|
for (const route of subAgentRoutes) {
|
||||||
this.loadingPersistedHistory = true;
|
this.registerSubAgentToolCall({
|
||||||
try {
|
timelineItem: route.toolCall,
|
||||||
await this.loadPersistedSubAgentHistories(client, subAgentRoutes);
|
rawItem: { agentThreadId: route.childThreadId },
|
||||||
} finally {
|
parentCallId: null,
|
||||||
this.loadingPersistedHistory = false;
|
});
|
||||||
}
|
}
|
||||||
this.resetCodexUserMessageTurns();
|
this.resetCodexUserMessageTurns();
|
||||||
for (const entry of timeline) {
|
for (const entry of timeline) {
|
||||||
@@ -3475,44 +3471,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.historyPending = timeline.length > 0;
|
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> {
|
private async ensureThreadLoaded(): Promise<void> {
|
||||||
if (!this.client || !this.currentThreadId) return;
|
if (!this.client || !this.currentThreadId) return;
|
||||||
try {
|
try {
|
||||||
@@ -3860,20 +3818,12 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||||
if (
|
if (!this.historyPending || this.persistedHistory.length === 0) {
|
||||||
(!this.historyPending || this.persistedHistory.length === 0) &&
|
|
||||||
this.persistedProviderSubagentEvents.length === 0
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const history = this.persistedHistory;
|
const history = this.persistedHistory;
|
||||||
const providerSubagents = this.persistedProviderSubagentEvents;
|
|
||||||
this.persistedHistory = [];
|
this.persistedHistory = [];
|
||||||
this.persistedProviderSubagentEvents = [];
|
|
||||||
this.historyPending = false;
|
this.historyPending = false;
|
||||||
for (const event of providerSubagents) {
|
|
||||||
yield event;
|
|
||||||
}
|
|
||||||
for (const entry of history) {
|
for (const entry of history) {
|
||||||
yield {
|
yield {
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
@@ -4504,10 +4454,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private emitEvent(event: AgentStreamEvent): void {
|
private emitEvent(event: AgentStreamEvent): void {
|
||||||
if (this.loadingPersistedHistory && event.type === "provider_subagent") {
|
|
||||||
this.persistedProviderSubagentEvents.push(event);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.notifySubscribers(event);
|
this.notifySubscribers(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4778,7 +4724,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
pendingFileChangeOutputDeltas: new Map<string, string[]>(),
|
pendingFileChangeOutputDeltas: new Map<string, string[]>(),
|
||||||
childItemOrder: [],
|
childItemOrder: [],
|
||||||
childItems: new Map<string, AgentTimelineItem>(),
|
childItems: new Map<string, AgentTimelineItem>(),
|
||||||
childThreadIds: new Set<string>(),
|
|
||||||
} satisfies CodexSubAgentCallState);
|
} satisfies CodexSubAgentCallState);
|
||||||
|
|
||||||
state.toolCall = {
|
state.toolCall = {
|
||||||
@@ -4809,8 +4754,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
);
|
);
|
||||||
for (const receiverThreadId of childThreadIds) {
|
for (const receiverThreadId of childThreadIds) {
|
||||||
this.subAgentCallIdByChildThreadId.set(receiverThreadId, timelineItem.callId);
|
this.subAgentCallIdByChildThreadId.set(receiverThreadId, timelineItem.callId);
|
||||||
state.childThreadIds.add(receiverThreadId);
|
|
||||||
this.emitProviderSubagentUpsert(receiverThreadId, state, timelineItem.status);
|
|
||||||
}
|
}
|
||||||
return childThreadIds;
|
return childThreadIds;
|
||||||
}
|
}
|
||||||
@@ -4892,20 +4835,12 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private emitCodexToolTimelineItem(
|
private emitCodexToolTimelineItem(
|
||||||
timelineItem: ToolCallTimelineItem,
|
timelineItem: ToolCallTimelineItem,
|
||||||
subAgentCallId: string | null,
|
subAgentCallId: string | null,
|
||||||
childThreadId?: string | null,
|
|
||||||
): void {
|
): void {
|
||||||
if (!subAgentCallId) {
|
if (!subAgentCallId) {
|
||||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.upsertSubAgentChildItem(subAgentCallId, timelineItem.callId, timelineItem);
|
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(
|
this.emitSubAgentActivityUpdate(
|
||||||
subAgentCallId,
|
subAgentCallId,
|
||||||
timelineItem.status === "running" ? "running" : undefined,
|
timelineItem.status === "running" ? "running" : undefined,
|
||||||
@@ -4932,9 +4867,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
? curateAgentActivity(childTimeline, { labelAssistantMessages: true })
|
? curateAgentActivity(childTimeline, { labelAssistantMessages: true })
|
||||||
: "";
|
: "";
|
||||||
const resolvedStatus = status ?? state.toolCall.status;
|
const resolvedStatus = status ?? state.toolCall.status;
|
||||||
for (const childThreadId of state.childThreadIds) {
|
|
||||||
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
|
|
||||||
}
|
|
||||||
const baseToolCall = {
|
const baseToolCall = {
|
||||||
...state.toolCall,
|
...state.toolCall,
|
||||||
detail: {
|
detail: {
|
||||||
@@ -4963,100 +4895,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: nextToolCall });
|
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(
|
private handleSubAgentChildItemCompleted(
|
||||||
callId: string,
|
callId: string,
|
||||||
itemId: string | undefined,
|
itemId: string | undefined,
|
||||||
@@ -5111,13 +4949,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.pendingAgentMessages.set(parsed.itemId, text);
|
this.pendingAgentMessages.set(parsed.itemId, text);
|
||||||
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
||||||
if (subAgentCallId) {
|
if (subAgentCallId) {
|
||||||
if (parsed.threadId) {
|
|
||||||
this.emitProviderSubagentTimeline(parsed.threadId, {
|
|
||||||
type: "assistant_message",
|
|
||||||
messageId: parsed.itemId,
|
|
||||||
text: parsed.delta,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, {
|
this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, {
|
||||||
type: "assistant_message",
|
type: "assistant_message",
|
||||||
messageId: parsed.itemId,
|
messageId: parsed.itemId,
|
||||||
@@ -5150,12 +4981,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.pendingReasoning.set(parsed.itemId, prev);
|
this.pendingReasoning.set(parsed.itemId, prev);
|
||||||
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
||||||
if (subAgentCallId) {
|
if (subAgentCallId) {
|
||||||
if (parsed.threadId) {
|
|
||||||
this.emitProviderSubagentTimeline(parsed.threadId, {
|
|
||||||
type: "reasoning",
|
|
||||||
text: parsed.delta,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, {
|
this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, {
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
text: prev.join(""),
|
text: prev.join(""),
|
||||||
@@ -5255,7 +5080,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.latestPlanResult = null;
|
this.latestPlanResult = null;
|
||||||
this.emittedItemStartedIds.clear();
|
this.emittedItemStartedIds.clear();
|
||||||
this.emittedItemCompletedIds.clear();
|
this.emittedItemCompletedIds.clear();
|
||||||
this.emittedProviderSubagentUserMessageKeys.clear();
|
|
||||||
this.emittedExecCommandStartedCallIds.clear();
|
this.emittedExecCommandStartedCallIds.clear();
|
||||||
this.emittedExecCommandCompletedCallIds.clear();
|
this.emittedExecCommandCompletedCallIds.clear();
|
||||||
this.pendingAgentMessages.clear();
|
this.pendingAgentMessages.clear();
|
||||||
@@ -5400,7 +5224,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
running: true,
|
running: true,
|
||||||
});
|
});
|
||||||
if (timelineItem) {
|
if (timelineItem) {
|
||||||
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId);
|
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5433,7 +5257,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
if (!subAgentCallId) {
|
if (!subAgentCallId) {
|
||||||
this.emittedExecCommandCompletedCallIds.add(timelineItem.callId);
|
this.emittedExecCommandCompletedCallIds.add(timelineItem.callId);
|
||||||
}
|
}
|
||||||
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId);
|
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5482,7 +5306,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
callId: parsed.callId,
|
callId: parsed.callId,
|
||||||
changes: parsed.changes,
|
changes: parsed.changes,
|
||||||
});
|
});
|
||||||
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId);
|
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5512,7 +5336,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
changes: parsed.changes,
|
changes: parsed.changes,
|
||||||
stdout: parsed.stdout,
|
stdout: parsed.stdout,
|
||||||
});
|
});
|
||||||
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId);
|
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5548,11 +5372,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
parentCallId: childSubAgentCallId,
|
parentCallId: childSubAgentCallId,
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
const imageItems = mcpToolResultImagesToTimeline(parsed.item);
|
|
||||||
if (childSubAgentCallId) {
|
if (childSubAgentCallId) {
|
||||||
this.emitCompletedProviderSubagentItem(parsed, timelineItem);
|
|
||||||
this.handleSubAgentChildItemCompleted(childSubAgentCallId, parsed.item.id, timelineItem);
|
this.handleSubAgentChildItemCompleted(childSubAgentCallId, parsed.item.id, timelineItem);
|
||||||
this.emitProviderSubagentTimelineItems(parsed.threadId, imageItems);
|
|
||||||
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5587,6 +5408,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
|
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
|
||||||
}
|
}
|
||||||
|
const imageItems = mcpToolResultImagesToTimeline(parsed.item);
|
||||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||||
if (timelineItem.type === "assistant_message") {
|
if (timelineItem.type === "assistant_message") {
|
||||||
this.pendingAssistantMessageBoundary = true;
|
this.pendingAssistantMessageBoundary = true;
|
||||||
@@ -5630,24 +5452,26 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
timelineItem: Extract<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>,
|
timelineItem: Extract<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>,
|
||||||
streamedText: string,
|
streamedText: string,
|
||||||
): void {
|
): void {
|
||||||
const item = this.buildMissingFinalTextSuffix(timelineItem, streamedText);
|
if (!timelineItem.text.startsWith(streamedText)) {
|
||||||
if (item) this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item });
|
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
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);
|
const suffix = timelineItem.text.slice(streamedText.length);
|
||||||
if (!suffix) return null;
|
if (!suffix) {
|
||||||
return timelineItem.type === "assistant_message"
|
return;
|
||||||
? {
|
}
|
||||||
type: timelineItem.type,
|
this.emitEvent({
|
||||||
text: suffix,
|
type: "timeline",
|
||||||
...(timelineItem.messageId ? { messageId: timelineItem.messageId } : {}),
|
provider: CODEX_PROVIDER,
|
||||||
}
|
item:
|
||||||
: { type: timelineItem.type, text: suffix };
|
timelineItem.type === "assistant_message"
|
||||||
|
? {
|
||||||
|
type: timelineItem.type,
|
||||||
|
text: suffix,
|
||||||
|
...(timelineItem.messageId ? { messageId: timelineItem.messageId } : {}),
|
||||||
|
}
|
||||||
|
: { type: timelineItem.type, text: suffix },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyBufferedDeltaTextToTimelineItem(
|
private applyBufferedDeltaTextToTimelineItem(
|
||||||
@@ -5660,15 +5484,14 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
if (timelineItem.type === "assistant_message") {
|
if (timelineItem.type === "assistant_message") {
|
||||||
const buffered = this.pendingAgentMessages.get(itemId);
|
const buffered = this.pendingAgentMessages.get(itemId);
|
||||||
if (buffered && buffered.length > 0) {
|
if (buffered && buffered.length > 0) {
|
||||||
if (!timelineItem.text.startsWith(buffered)) timelineItem.text = buffered;
|
timelineItem.text = buffered;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (timelineItem.type === "reasoning") {
|
if (timelineItem.type === "reasoning") {
|
||||||
const buffered = this.pendingReasoning.get(itemId);
|
const buffered = this.pendingReasoning.get(itemId);
|
||||||
if (buffered && buffered.length > 0) {
|
if (buffered && buffered.length > 0) {
|
||||||
const streamedText = buffered.join("");
|
timelineItem.text = buffered.join("");
|
||||||
if (!timelineItem.text.startsWith(streamedText)) timelineItem.text = streamedText;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5714,7 +5537,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
parentCallId: childSubAgentCallId,
|
parentCallId: childSubAgentCallId,
|
||||||
});
|
});
|
||||||
if (childSubAgentCallId) {
|
if (childSubAgentCallId) {
|
||||||
this.emitStartedProviderSubagentItem(parsed.threadId, timelineItem);
|
|
||||||
if (parsed.item.id) {
|
if (parsed.item.id) {
|
||||||
this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem);
|
this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem);
|
||||||
}
|
}
|
||||||
@@ -5758,18 +5580,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
||||||
if (childSubAgentCallId) {
|
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) {
|
if (itemId) {
|
||||||
this.upsertSubAgentChildItem(childSubAgentCallId, itemId, timelineItem);
|
this.upsertSubAgentChildItem(childSubAgentCallId, itemId, timelineItem);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,26 +51,23 @@ describe("codex tool-call mapper", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['/bin/zsh -lc "echo hello"', '/usr/bin/zsh -lc "echo hello"'])(
|
it("unwraps shell wrapper strings for commandExecution", () => {
|
||||||
"unwraps zsh wrapper strings for commandExecution: %s",
|
const item = expectMapped(
|
||||||
(command) => {
|
mapCodexToolCallFromThreadItem({
|
||||||
const item = expectMapped(
|
type: "commandExecution",
|
||||||
mapCodexToolCallFromThreadItem({
|
id: "codex-call-wrapper-string",
|
||||||
type: "commandExecution",
|
status: "running",
|
||||||
id: "codex-call-wrapper-string",
|
command: '/bin/zsh -lc "echo hello"',
|
||||||
status: "running",
|
|
||||||
command,
|
|
||||||
cwd: "/tmp/repo",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(item.detail).toEqual({
|
|
||||||
type: "shell",
|
|
||||||
command: "echo hello",
|
|
||||||
cwd: "/tmp/repo",
|
cwd: "/tmp/repo",
|
||||||
});
|
}),
|
||||||
},
|
);
|
||||||
);
|
|
||||||
|
expect(item.detail).toEqual({
|
||||||
|
type: "shell",
|
||||||
|
command: "echo hello",
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("unwraps pwsh wrapper strings for commandExecution on Windows", () => {
|
it("unwraps pwsh wrapper strings for commandExecution on Windows", () => {
|
||||||
const item = expectMapped(
|
const item = expectMapped(
|
||||||
|
|||||||
@@ -221,9 +221,7 @@ const CodexThreadItemSchema = z.discriminatedUnion("type", [
|
|||||||
|
|
||||||
function maybeUnwrapShellWrapperCommand(command: string): string {
|
function maybeUnwrapShellWrapperCommand(command: string): string {
|
||||||
const trimmed = command.trim();
|
const trimmed = command.trim();
|
||||||
const unixWrapperMatch = trimmed.match(
|
const unixWrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/);
|
||||||
/^(?:(?:\/[^/\s]+)*\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/,
|
|
||||||
);
|
|
||||||
if (unixWrapperMatch) {
|
if (unixWrapperMatch) {
|
||||||
const candidate = unixWrapperMatch[1]?.trim() ?? "";
|
const candidate = unixWrapperMatch[1]?.trim() ?? "";
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
|
|||||||
@@ -9,13 +9,6 @@ import { buildVersionProbeCommand, GenericACPAgentClient } from "./generic-acp-a
|
|||||||
|
|
||||||
const TEST_ACP_TIMEOUT_MS = 1_000;
|
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", () => {
|
describe("GenericACPAgentClient diagnostics", () => {
|
||||||
test("probes npx-backed agent packages instead of npx itself", () => {
|
test("probes npx-backed agent packages instead of npx itself", () => {
|
||||||
expect(buildVersionProbeCommand(["npx", "-y", "@google/gemini-cli@0.41.1", "--acp"])).toEqual({
|
expect(buildVersionProbeCommand(["npx", "-y", "@google/gemini-cli@0.41.1", "--acp"])).toEqual({
|
||||||
@@ -99,53 +92,6 @@ 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 () => {
|
test("reports a missing launcher without dropping the rest of the diagnostic", async () => {
|
||||||
await withTempDir("paseo-missing-acp-agent-", async (testDir) => {
|
await withTempDir("paseo-missing-acp-agent-", async (testDir) => {
|
||||||
const missingCommand = path.join(testDir, "missing-acp-agent");
|
const missingCommand = path.join(testDir, "missing-acp-agent");
|
||||||
@@ -221,7 +167,6 @@ const readline = require("node:readline");
|
|||||||
|
|
||||||
const mode = process.argv[2];
|
const mode = process.argv[2];
|
||||||
const pidPath = process.argv[3];
|
const pidPath = process.argv[3];
|
||||||
const initializeTracePath = process.argv[4];
|
|
||||||
if (pidPath) {
|
if (pidPath) {
|
||||||
fs.writeFileSync(pidPath, String(process.pid));
|
fs.writeFileSync(pidPath, String(process.pid));
|
||||||
}
|
}
|
||||||
@@ -234,12 +179,6 @@ function send(id, result) {
|
|||||||
rl.on("line", (line) => {
|
rl.on("line", (line) => {
|
||||||
const message = JSON.parse(line);
|
const message = JSON.parse(line);
|
||||||
if (message.method === "initialize") {
|
if (message.method === "initialize") {
|
||||||
if (initializeTracePath) {
|
|
||||||
fs.appendFileSync(
|
|
||||||
initializeTracePath,
|
|
||||||
JSON.stringify({ clientCapabilities: message.params?.clientCapabilities }) + "\\n",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
send(message.id, {
|
send(message.id, {
|
||||||
protocolVersion: message.params?.protocolVersion ?? 1,
|
protocolVersion: message.params?.protocolVersion ?? 1,
|
||||||
agentCapabilities: {},
|
agentCapabilities: {},
|
||||||
|
|||||||
@@ -20,17 +20,6 @@ import {
|
|||||||
export const GenericACPProviderParamsSchema = z
|
export const GenericACPProviderParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
supportsMcpServers: z.boolean().optional(),
|
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();
|
.passthrough();
|
||||||
|
|
||||||
@@ -58,7 +47,6 @@ export class GenericACPAgentClient extends ACPAgentClient {
|
|||||||
private readonly diagnosticPhaseTimeoutMs?: number;
|
private readonly diagnosticPhaseTimeoutMs?: number;
|
||||||
|
|
||||||
constructor(options: GenericACPAgentClientOptions) {
|
constructor(options: GenericACPAgentClientOptions) {
|
||||||
const providerParams = parseGenericACPProviderParams(options.providerParams);
|
|
||||||
super({
|
super({
|
||||||
provider: "acp",
|
provider: "acp",
|
||||||
logger: options.logger,
|
logger: options.logger,
|
||||||
@@ -66,10 +54,9 @@ export class GenericACPAgentClient extends ACPAgentClient {
|
|||||||
env: options.env,
|
env: options.env,
|
||||||
},
|
},
|
||||||
defaultCommand: options.command,
|
defaultCommand: options.command,
|
||||||
capabilities: buildGenericACPCapabilities(providerParams),
|
capabilities: buildGenericACPCapabilities(options),
|
||||||
waitForInitialCommands: options.waitForInitialCommands,
|
waitForInitialCommands: options.waitForInitialCommands,
|
||||||
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
|
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
|
||||||
clientCapabilities: providerParams.clientCapabilities,
|
|
||||||
clientCapabilityMeta: options.clientCapabilityMeta,
|
clientCapabilityMeta: options.clientCapabilityMeta,
|
||||||
configFeatureOptions: options.configFeatureOptions,
|
configFeatureOptions: options.configFeatureOptions,
|
||||||
extensionCommandsParser: options.extensionCommandsParser,
|
extensionCommandsParser: options.extensionCommandsParser,
|
||||||
@@ -158,7 +145,8 @@ export class GenericACPAgentClient extends ACPAgentClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildGenericACPCapabilities(params: GenericACPProviderParams): AgentCapabilityFlags {
|
function buildGenericACPCapabilities(options: GenericACPAgentClientOptions): AgentCapabilityFlags {
|
||||||
|
const params = parseGenericACPProviderParams(options.providerParams);
|
||||||
return {
|
return {
|
||||||
...DEFAULT_ACP_CAPABILITIES,
|
...DEFAULT_ACP_CAPABILITIES,
|
||||||
supportsMcpServers: params.supportsMcpServers ?? DEFAULT_ACP_CAPABILITIES.supportsMcpServers,
|
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,34 +164,6 @@ describe("OpenCodeServerManager generations", () => {
|
|||||||
expect(runtime.terminatedPorts).toEqual([4474]);
|
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 () => {
|
test("repeated rotations leave zero unreferenced retired servers", async () => {
|
||||||
const { manager, runtime } = createTestManager([4501, 4502, 4503]);
|
const { manager, runtime } = createTestManager([4501, 4502, 4503]);
|
||||||
|
|
||||||
|
|||||||
@@ -124,11 +124,7 @@ describe("translateOpenCodeEvent", () => {
|
|||||||
{
|
{
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
item: {
|
item: { type: "assistant_message", text: "hey! what can I help with?" },
|
||||||
type: "assistant_message",
|
|
||||||
text: "hey! what can I help with?",
|
|
||||||
messageId: "message-1",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -171,7 +167,7 @@ describe("translateOpenCodeEvent", () => {
|
|||||||
{
|
{
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
item: { type: "assistant_message", text: "final text", messageId: "message-2" },
|
item: { type: "assistant_message", text: "final text" },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -273,66 +269,16 @@ describe("translateOpenCodeEvent", () => {
|
|||||||
{
|
{
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
item: { type: "assistant_message", text: "hey! ", messageId: "msg-d1" },
|
item: { type: "assistant_message", text: "hey! " },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
item: { type: "assistant_message", text: "what's up?", messageId: "msg-d1" },
|
item: { type: "assistant_message", text: "what's up?" },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
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", () => {
|
it("humanizes permission requests and includes shell detail when command metadata exists", () => {
|
||||||
const state = createState();
|
const state = createState();
|
||||||
|
|
||||||
@@ -1127,7 +1073,7 @@ describe("translateOpenCodeEvent", () => {
|
|||||||
{
|
{
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
item: { type: "assistant_message", text: "hello there", messageId: "msg-dd1" },
|
item: { type: "assistant_message", text: "hello there" },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -1328,11 +1274,7 @@ describe("translateOpenCodeEvent", () => {
|
|||||||
{
|
{
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
item: {
|
item: { type: "assistant_message", text: '{"summary":"hello"}' },
|
||||||
type: "assistant_message",
|
|
||||||
text: '{"summary":"hello"}',
|
|
||||||
messageId: "message-structured-1",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(second).toEqual([]);
|
expect(second).toEqual([]);
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export interface OpenCodeServerManagerLike {
|
|||||||
acquireCurrent(): Promise<OpenCodeServerAcquisition>;
|
acquireCurrent(): Promise<OpenCodeServerAcquisition>;
|
||||||
acquireNew(): Promise<OpenCodeServerAcquisition>;
|
acquireNew(): Promise<OpenCodeServerAcquisition>;
|
||||||
acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition>;
|
acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition>;
|
||||||
acquireExisting(url: string): OpenCodeServerAcquisition | null;
|
|
||||||
shutdown(): Promise<void>;
|
shutdown(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,27 +164,6 @@ 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 {
|
private acquireServer(server: OpenCodeServerGeneration): OpenCodeServerAcquisition {
|
||||||
server.refCount += 1;
|
server.refCount += 1;
|
||||||
let released = false;
|
let released = false;
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "./server-manager.js";
|
import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "./server-manager.js";
|
||||||
|
|
||||||
export interface TestOpenCodeServerAcquisition {
|
export interface TestOpenCodeServerAcquisition {
|
||||||
kind: "current" | "new" | "dedicated" | "existing";
|
kind: "current" | "new" | "dedicated";
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
url?: string;
|
|
||||||
released: boolean;
|
released: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,20 +28,14 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
|
|||||||
return this.recordAcquisition({ kind: "dedicated", env });
|
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: {
|
private recordAcquisition(input: {
|
||||||
kind: TestOpenCodeServerAcquisition["kind"];
|
kind: TestOpenCodeServerAcquisition["kind"];
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
url?: string;
|
|
||||||
}): OpenCodeServerAcquisition {
|
}): OpenCodeServerAcquisition {
|
||||||
const acquisition: TestOpenCodeServerAcquisition = {
|
const acquisition: TestOpenCodeServerAcquisition = {
|
||||||
kind: input.kind,
|
kind: input.kind,
|
||||||
released: false,
|
released: false,
|
||||||
...(input.env ? { env: input.env } : {}),
|
...(input.env ? { env: input.env } : {}),
|
||||||
...(input.url ? { url: input.url } : {}),
|
|
||||||
};
|
};
|
||||||
this.acquisitions.push(acquisition);
|
this.acquisitions.push(acquisition);
|
||||||
return {
|
return {
|
||||||
|
|||||||
+2
-19
@@ -9,9 +9,8 @@ interface OpenCodeResponse {
|
|||||||
|
|
||||||
export class TestOpenCodeHarness implements OpenCodeServerManagerLike {
|
export class TestOpenCodeHarness implements OpenCodeServerManagerLike {
|
||||||
readonly acquisitions: Array<{
|
readonly acquisitions: Array<{
|
||||||
kind: "current" | "new" | "dedicated" | "existing";
|
kind: "current" | "new" | "dedicated";
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
url?: string;
|
|
||||||
releaseCount: number;
|
releaseCount: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
readonly clientCreations: Array<{ baseUrl: string; directory: string }> = [];
|
readonly clientCreations: Array<{ baseUrl: string; directory: string }> = [];
|
||||||
@@ -35,20 +34,14 @@ export class TestOpenCodeHarness implements OpenCodeServerManagerLike {
|
|||||||
return this.recordAcquisition({ kind: "dedicated", env });
|
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: {
|
private recordAcquisition(input: {
|
||||||
kind: "current" | "new" | "dedicated" | "existing";
|
kind: "current" | "new" | "dedicated";
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
url?: string;
|
|
||||||
}): OpenCodeServerAcquisition {
|
}): OpenCodeServerAcquisition {
|
||||||
const acquisition = {
|
const acquisition = {
|
||||||
kind: input.kind,
|
kind: input.kind,
|
||||||
releaseCount: 0,
|
releaseCount: 0,
|
||||||
...(input.env ? { env: input.env } : {}),
|
...(input.env ? { env: input.env } : {}),
|
||||||
...(input.url ? { url: input.url } : {}),
|
|
||||||
};
|
};
|
||||||
this.acquisitions.push(acquisition);
|
this.acquisitions.push(acquisition);
|
||||||
return {
|
return {
|
||||||
@@ -89,7 +82,6 @@ export class TestOpenCodeClient {
|
|||||||
sessionCommand: [] as unknown[],
|
sessionCommand: [] as unknown[],
|
||||||
sessionCreate: [] as unknown[],
|
sessionCreate: [] as unknown[],
|
||||||
sessionDelete: [] as unknown[],
|
sessionDelete: [] as unknown[],
|
||||||
sessionChildren: [] as unknown[],
|
|
||||||
sessionGet: [] as unknown[],
|
sessionGet: [] as unknown[],
|
||||||
sessionMessages: [] as unknown[],
|
sessionMessages: [] as unknown[],
|
||||||
sessionPromptAsync: [] as unknown[],
|
sessionPromptAsync: [] as unknown[],
|
||||||
@@ -114,8 +106,6 @@ export class TestOpenCodeClient {
|
|||||||
sessionCommandResponse: OpenCodeResponse = {};
|
sessionCommandResponse: OpenCodeResponse = {};
|
||||||
sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } };
|
sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } };
|
||||||
sessionDeleteResponse: OpenCodeResponse = {};
|
sessionDeleteResponse: OpenCodeResponse = {};
|
||||||
sessionChildrenResponses: OpenCodeResponse[] = [];
|
|
||||||
sessionChildrenImplementation: ((parameters: unknown) => Promise<OpenCodeResponse>) | null = null;
|
|
||||||
sessionGetResponse: OpenCodeResponse = {
|
sessionGetResponse: OpenCodeResponse = {
|
||||||
data: { id: "session-1", directory: "/workspace/repo", title: null },
|
data: { id: "session-1", directory: "/workspace/repo", title: null },
|
||||||
};
|
};
|
||||||
@@ -229,13 +219,6 @@ export class TestOpenCodeClient {
|
|||||||
this.calls.sessionDelete.push(parameters);
|
this.calls.sessionDelete.push(parameters);
|
||||||
return this.sessionDeleteResponse;
|
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) => {
|
get: async (parameters: unknown) => {
|
||||||
this.calls.sessionGet.push(parameters);
|
this.calls.sessionGet.push(parameters);
|
||||||
return this.sessionGetResponse;
|
return this.sessionGetResponse;
|
||||||
|
|||||||
@@ -82,29 +82,6 @@ test("forwards launch-context env to the Pi process launch", async () => {
|
|||||||
await session.close();
|
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 {
|
class SessionEvents {
|
||||||
private readonly events: AgentStreamEvent[] = [];
|
private readonly events: AgentStreamEvent[] = [];
|
||||||
private readonly waiters: Array<{
|
private readonly waiters: Array<{
|
||||||
@@ -161,13 +138,6 @@ 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" }>> {
|
nextPermissionRequest(): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> {
|
||||||
return this.nextEvent(
|
return this.nextEvent(
|
||||||
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||||
@@ -449,19 +419,10 @@ describe("PiRpcAgentSession", () => {
|
|||||||
const fakeSession = pi.latestSession();
|
const fakeSession = pi.latestSession();
|
||||||
|
|
||||||
await session.startTurn("hello");
|
await session.startTurn("hello");
|
||||||
fakeSession.emit({
|
|
||||||
type: "message_start",
|
|
||||||
message: { role: "assistant", content: [], responseId: "response-1" },
|
|
||||||
});
|
|
||||||
fakeSession.emit({
|
fakeSession.emit({
|
||||||
type: "message_update",
|
type: "message_update",
|
||||||
message: { role: "assistant", content: [], responseId: "response-1" },
|
message: { role: "assistant", content: [] },
|
||||||
assistantMessageEvent: { type: "text_delta", delta: "hel" },
|
assistantMessageEvent: { type: "text_delta", delta: "hello" },
|
||||||
});
|
|
||||||
fakeSession.emit({
|
|
||||||
type: "message_update",
|
|
||||||
message: { role: "assistant", content: [], responseId: "response-1" },
|
|
||||||
assistantMessageEvent: { type: "text_delta", delta: "lo" },
|
|
||||||
});
|
});
|
||||||
fakeSession.emit({
|
fakeSession.emit({
|
||||||
type: "message_update",
|
type: "message_update",
|
||||||
@@ -486,8 +447,7 @@ describe("PiRpcAgentSession", () => {
|
|||||||
await events.nextTurnCompletion();
|
await events.nextTurnCompletion();
|
||||||
|
|
||||||
expect(events.timelineItems()).toEqual([
|
expect(events.timelineItems()).toEqual([
|
||||||
{ type: "assistant_message", text: "hel", messageId: "response-1" },
|
{ type: "assistant_message", text: "hello" },
|
||||||
{ type: "assistant_message", text: "lo", messageId: "response-1" },
|
|
||||||
{ type: "reasoning", text: "thinking" },
|
{ type: "reasoning", text: "thinking" },
|
||||||
{
|
{
|
||||||
type: "tool_call",
|
type: "tool_call",
|
||||||
@@ -508,60 +468,6 @@ 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 () => {
|
test("emits live user messages with captured Pi tree entry ids", async () => {
|
||||||
const { pi, session, events } = await createSession();
|
const { pi, session, events } = await createSession();
|
||||||
const fakeSession = pi.latestSession();
|
const fakeSession = pi.latestSession();
|
||||||
@@ -602,38 +508,6 @@ 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 () => {
|
test("adds Pi assistant context to generic provider finish errors", async () => {
|
||||||
const { pi, session, events } = await createSession();
|
const { pi, session, events } = await createSession();
|
||||||
|
|
||||||
|
|||||||
@@ -1064,7 +1064,6 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
private activeAskUserDialog: ActiveAskUserDialog | null = null;
|
private activeAskUserDialog: ActiveAskUserDialog | null = null;
|
||||||
private pendingCombinedAskUserResponse: PendingCombinedAskUserResponse | null = null;
|
private pendingCombinedAskUserResponse: PendingCombinedAskUserResponse | null = null;
|
||||||
private activeTurnId: string | null = null;
|
private activeTurnId: string | null = null;
|
||||||
private activeAssistantMessageId: string | null = null;
|
|
||||||
private lastKnownThinkingOptionId: string | null;
|
private lastKnownThinkingOptionId: string | null;
|
||||||
currentLeafOverrideId: string | null | undefined;
|
currentLeafOverrideId: string | null | undefined;
|
||||||
private readonly capturedUserEntries: PiCapturedEntry[] = [];
|
private readonly capturedUserEntries: PiCapturedEntry[] = [];
|
||||||
@@ -1124,18 +1123,15 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
const payload = convertPromptInput(prompt, { model: this.state.model });
|
const payload = convertPromptInput(prompt, { model: this.state.model });
|
||||||
const turnId = randomUUID();
|
const turnId = randomUUID();
|
||||||
this.activeTurnId = turnId;
|
this.activeTurnId = turnId;
|
||||||
this.activeAssistantMessageId = null;
|
|
||||||
|
|
||||||
void this.runtimeSession.prompt(payload.text, payload.images).catch((error) => {
|
void this.runtimeSession.prompt(payload.text, payload.images).catch((error) => {
|
||||||
if (this.activeTurnId !== turnId) {
|
const failedTurnId = this.activeTurnId ?? turnId;
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.activeTurnId = null;
|
this.activeTurnId = null;
|
||||||
if (isPiRequestAbortError(error)) {
|
if (isPiRequestAbortError(error)) {
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "turn_canceled",
|
type: "turn_canceled",
|
||||||
provider: PI_PROVIDER,
|
provider: PI_PROVIDER,
|
||||||
turnId,
|
turnId: failedTurnId,
|
||||||
reason: toDiagnosticErrorMessage(error),
|
reason: toDiagnosticErrorMessage(error),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -1143,7 +1139,7 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
this.emit({
|
this.emit({
|
||||||
type: "turn_failed",
|
type: "turn_failed",
|
||||||
provider: PI_PROVIDER,
|
provider: PI_PROVIDER,
|
||||||
turnId,
|
turnId: failedTurnId,
|
||||||
error: toDiagnosticErrorMessage(error),
|
error: toDiagnosticErrorMessage(error),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1238,12 +1234,7 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async interrupt(): Promise<void> {
|
async interrupt(): Promise<void> {
|
||||||
const turnId = this.activeTurnId;
|
|
||||||
await this.runtimeSession.abort();
|
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> {
|
async revertConversation(input: { messageId: string }): Promise<void> {
|
||||||
@@ -1713,7 +1704,6 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
case "message_start":
|
case "message_start":
|
||||||
this.handleMessageStart(event);
|
|
||||||
return;
|
return;
|
||||||
case "message_end":
|
case "message_end":
|
||||||
this.handleMessageEnd(event, turnId);
|
this.handleMessageEnd(event, turnId);
|
||||||
@@ -1821,8 +1811,6 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.assistantMessageEvent.type === "text_delta") {
|
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({
|
this.emit({
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: PI_PROVIDER,
|
provider: PI_PROVIDER,
|
||||||
@@ -1830,7 +1818,6 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
item: {
|
item: {
|
||||||
type: "assistant_message",
|
type: "assistant_message",
|
||||||
text: event.assistantMessageEvent.delta ?? "",
|
text: event.assistantMessageEvent.delta ?? "",
|
||||||
messageId: this.activeAssistantMessageId,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -1848,20 +1835,10 @@ 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(
|
private handleMessageEnd(
|
||||||
event: Extract<PiAgentSessionEvent, { type: "message_end" }>,
|
event: Extract<PiAgentSessionEvent, { type: "message_end" }>,
|
||||||
turnId: string | undefined,
|
turnId: string | undefined,
|
||||||
): void {
|
): void {
|
||||||
if (event.message.role === "assistant") {
|
|
||||||
this.activeAssistantMessageId = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.message.role === "custom") {
|
if (event.message.role === "custom") {
|
||||||
const text = getUserMessageText(event.message.content);
|
const text = getUserMessageText(event.message.content);
|
||||||
if (text) {
|
if (text) {
|
||||||
@@ -1922,7 +1899,6 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
|
|
||||||
private completeTurn(turnId: string | undefined, messages: PiAgentMessage[]): void {
|
private completeTurn(turnId: string | undefined, messages: PiAgentMessage[]): void {
|
||||||
this.activeTurnId = null;
|
this.activeTurnId = null;
|
||||||
this.activeAssistantMessageId = null;
|
|
||||||
const errorMessage = latestPiErrorMessage(messages);
|
const errorMessage = latestPiErrorMessage(messages);
|
||||||
if (typeof errorMessage === "string" && errorMessage.length > 0) {
|
if (typeof errorMessage === "string" && errorMessage.length > 0) {
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -1996,7 +1972,6 @@ export class PiRpcAgentClient implements AgentClient {
|
|||||||
model: config.model,
|
model: config.model,
|
||||||
thinkingOptionId:
|
thinkingOptionId:
|
||||||
normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL,
|
normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL,
|
||||||
noSession: config.internal === true,
|
|
||||||
systemPrompt: composeSystemPromptParts(
|
systemPrompt: composeSystemPromptParts(
|
||||||
config.systemPrompt,
|
config.systemPrompt,
|
||||||
config.daemonAppendSystemPrompt,
|
config.daemonAppendSystemPrompt,
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ describe("Pi history mapper", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
responseId: "response-1",
|
|
||||||
content: [
|
content: [
|
||||||
{ type: "thinking", thinking: "checking file" },
|
{ type: "thinking", thinking: "checking file" },
|
||||||
{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "note.txt" } },
|
{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "note.txt" } },
|
||||||
@@ -78,7 +77,7 @@ describe("Pi history mapper", () => {
|
|||||||
{
|
{
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: "pi",
|
provider: "pi",
|
||||||
item: { type: "assistant_message", text: "done", messageId: "response-1" },
|
item: { type: "assistant_message", text: "done" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
@@ -154,11 +153,7 @@ describe("Pi history mapper", () => {
|
|||||||
{
|
{
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: "pi",
|
provider: "pi",
|
||||||
item: {
|
item: { type: "assistant_message", text: "first answer" },
|
||||||
type: "assistant_message",
|
|
||||||
text: "first answer",
|
|
||||||
messageId: "pi-history-assistant-1",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export async function* streamPiHistory(
|
|||||||
): AsyncGenerator<AgentStreamEvent> {
|
): AsyncGenerator<AgentStreamEvent> {
|
||||||
const pendingToolCalls = new Map<string, PiTrackedToolCall>();
|
const pendingToolCalls = new Map<string, PiTrackedToolCall>();
|
||||||
let userIndex = 0;
|
let userIndex = 0;
|
||||||
let assistantIndex = 0;
|
|
||||||
|
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
@@ -66,14 +65,12 @@ export async function* streamPiHistory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.role === "assistant") {
|
if (message.role === "assistant") {
|
||||||
assistantIndex += 1;
|
|
||||||
const messageId = message.responseId || `${provider}-history-assistant-${assistantIndex}`;
|
|
||||||
for (const content of message.content) {
|
for (const content of message.content) {
|
||||||
if (content.type === "text" && content.text) {
|
if (content.type === "text" && content.text) {
|
||||||
yield {
|
yield {
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider,
|
provider,
|
||||||
item: { type: "assistant_message", text: content.text, messageId },
|
item: { type: "assistant_message", text: content.text },
|
||||||
};
|
};
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export interface PiRuntimeLaunch {
|
|||||||
model?: string;
|
model?: string;
|
||||||
thinkingOptionId?: string;
|
thinkingOptionId?: string;
|
||||||
session?: string;
|
session?: string;
|
||||||
noSession?: boolean;
|
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
mcpConfigPath?: string;
|
mcpConfigPath?: string;
|
||||||
extensionPaths?: string[];
|
extensionPaths?: string[];
|
||||||
@@ -27,7 +26,6 @@ export interface PiStartSessionInput {
|
|||||||
model?: string;
|
model?: string;
|
||||||
thinkingOptionId?: string;
|
thinkingOptionId?: string;
|
||||||
session?: string;
|
session?: string;
|
||||||
noSession?: boolean;
|
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
mcpConfigPath?: string;
|
mcpConfigPath?: string;
|
||||||
extensionPaths?: string[];
|
extensionPaths?: string[];
|
||||||
@@ -81,9 +79,7 @@ export function buildPiLaunch(input: {
|
|||||||
if (input.session.thinkingOptionId) {
|
if (input.session.thinkingOptionId) {
|
||||||
argv.push("--thinking", input.session.thinkingOptionId);
|
argv.push("--thinking", input.session.thinkingOptionId);
|
||||||
}
|
}
|
||||||
if (input.session.noSession) {
|
if (input.session.session) {
|
||||||
argv.push("--no-session");
|
|
||||||
} else if (input.session.session) {
|
|
||||||
argv.push("--session", input.session.session);
|
argv.push("--session", input.session.session);
|
||||||
}
|
}
|
||||||
const systemPrompt = input.session.systemPrompt?.trim();
|
const systemPrompt = input.session.systemPrompt?.trim();
|
||||||
@@ -110,7 +106,6 @@ export function buildPiLaunch(input: {
|
|||||||
model: input.session.model,
|
model: input.session.model,
|
||||||
thinkingOptionId: input.session.thinkingOptionId,
|
thinkingOptionId: input.session.thinkingOptionId,
|
||||||
session: input.session.session,
|
session: input.session.session,
|
||||||
noSession: input.session.noSession,
|
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
mcpConfigPath: input.session.mcpConfigPath,
|
mcpConfigPath: input.session.mcpConfigPath,
|
||||||
extensionPaths: input.session.extensionPaths,
|
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