mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5236c00d8 | ||
|
|
d1443064db | ||
|
|
9f387873b0 | ||
|
|
12cd5345d2 | ||
|
|
2c6d4e5283 | ||
|
|
006bae2897 | ||
|
|
437aef1a7b | ||
|
|
b44e7b0b90 | ||
|
|
09f87df1b2 | ||
|
|
b013e0d428 | ||
|
|
c938e1cf44 | ||
|
|
0c18144967 |
@@ -1,5 +1,23 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.10.1 - 2026-09-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer ([#5583](https://github.com/getpaseo/paseo/pull/5583) by [@yangqi](https://github.com/yangqi))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level ([#5587](https://github.com/getpaseo/paseo/pull/5587))
|
||||||
|
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools ([#5345](https://github.com/getpaseo/paseo/pull/5345) by [@zbaibg](https://github.com/zbaibg))
|
||||||
|
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads ([#5577](https://github.com/getpaseo/paseo/pull/5577))
|
||||||
|
- Fixed a subagent opened from a split pane opening in a different pane ([#5451](https://github.com/getpaseo/paseo/pull/5451))
|
||||||
|
- Fixed archiving a custom Codex provider's agent leaving its session in Import session ([#5572](https://github.com/getpaseo/paseo/pull/5572))
|
||||||
|
- Fixed the `/` menu of a custom Codex provider with its own `CODEX_HOME` listing the daemon's prompts instead of its own ([#5450](https://github.com/getpaseo/paseo/pull/5450))
|
||||||
|
- Fixed a background `send_agent_prompt` returning `idle` for a prompt the agent accepted ([#5386](https://github.com/getpaseo/paseo/pull/5386))
|
||||||
|
- Fixed Windows file-link tooltips showing the full path for files inside the workspace ([#1987](https://github.com/getpaseo/paseo/pull/1987))
|
||||||
|
- Fixed terminal profiles that run `cursor-agent` showing the generic terminal icon ([#5379](https://github.com/getpaseo/paseo/pull/5379))
|
||||||
|
|
||||||
## 0.10.0 - 2026-09-28
|
## 0.10.0 - 2026-09-28
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Added
|
||||||
|
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level
|
||||||
|
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools
|
||||||
|
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads
|
||||||
|
- Fixed a subagent opened from a split pane opening in a different pane
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Added
|
||||||
|
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level
|
||||||
|
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools
|
||||||
|
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads
|
||||||
|
- Fixed a subagent opened from a split pane opening in a different pane
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Added
|
||||||
|
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level
|
||||||
|
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools
|
||||||
|
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads
|
||||||
|
- Fixed a subagent opened from a split pane opening in a different pane
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Added
|
||||||
|
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer
|
||||||
|
|
||||||
|
Fixed
|
||||||
|
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level
|
||||||
|
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools
|
||||||
|
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads
|
||||||
|
- Fixed a subagent opened from a split pane opening in a different pane
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
Generated
+27
-27
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -36410,7 +36410,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "6.10.4",
|
"@codemirror/commands": "6.10.4",
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
@@ -37476,12 +37476,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.10.0",
|
"@getpaseo/client": "0.10.1",
|
||||||
"@getpaseo/protocol": "0.10.0",
|
"@getpaseo/protocol": "0.10.1",
|
||||||
"@getpaseo/server": "0.10.0",
|
"@getpaseo/server": "0.10.1",
|
||||||
"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",
|
||||||
@@ -37728,10 +37728,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.10.0",
|
"@getpaseo/protocol": "0.10.1",
|
||||||
"@getpaseo/relay": "0.10.0",
|
"@getpaseo/relay": "0.10.1",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -37742,7 +37742,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -37986,7 +37986,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.10.0",
|
"version": "0.10.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -38882,7 +38882,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -38911,14 +38911,14 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@getpaseo/plugin",
|
"name": "@getpaseo/plugin",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^1.4.0",
|
"@agentclientprotocol/sdk": "^1.4.0",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@getpaseo/client": "0.10.0",
|
"@getpaseo/client": "0.10.1",
|
||||||
"@getpaseo/protocol": "0.10.0",
|
"@getpaseo/protocol": "0.10.1",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -38928,8 +38928,8 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@getpaseo/client": "0.10.0",
|
"@getpaseo/client": "0.10.1",
|
||||||
"@getpaseo/protocol": "0.10.0",
|
"@getpaseo/protocol": "0.10.1",
|
||||||
"react": "~19.1.0",
|
"react": "~19.1.0",
|
||||||
"react-native": ">=0.81.5",
|
"react-native": ">=0.81.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -38951,7 +38951,7 @@
|
|||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"semver": "^7.8.5",
|
"semver": "^7.8.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -38978,7 +38978,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -39196,16 +39196,16 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.10.0",
|
"@getpaseo/client": "0.10.1",
|
||||||
"@getpaseo/highlight": "0.10.0",
|
"@getpaseo/highlight": "0.10.1",
|
||||||
"@getpaseo/plugin": "0.10.0",
|
"@getpaseo/plugin": "0.10.1",
|
||||||
"@getpaseo/protocol": "0.10.0",
|
"@getpaseo/protocol": "0.10.1",
|
||||||
"@getpaseo/relay": "0.10.0",
|
"@getpaseo/relay": "0.10.1",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
@@ -40110,7 +40110,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"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.10.0",
|
"version": "0.10.1",
|
||||||
"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": [
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import {
|
|||||||
expectFileTabOpen,
|
expectFileTabOpen,
|
||||||
} from "../support/helpers/file-explorer";
|
} from "../support/helpers/file-explorer";
|
||||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||||
|
import { gotoAppShell } from "../support/helpers/app";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import { getServerId } from "../support/helpers/server-id";
|
||||||
|
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||||
|
|
||||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||||
|
|
||||||
@@ -92,6 +96,40 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("CodeMirror workspace file editing", () => {
|
test.describe("CodeMirror workspace file editing", () => {
|
||||||
|
test("shows an absolute POSIX assistant file link relative to the workspace on hover", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "file-link-tooltip-" });
|
||||||
|
const filePath = path.join(workspace.repoPath, "src", "target.ts");
|
||||||
|
try {
|
||||||
|
const agent = await workspace.client.createAgent({
|
||||||
|
provider: "mock",
|
||||||
|
cwd: workspace.repoPath,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
title: "Absolute file link tooltip",
|
||||||
|
modeId: "load-test",
|
||||||
|
model: "e2e-fast-stream",
|
||||||
|
initialPrompt: "Show the file link",
|
||||||
|
featureValues: { mockAssistantResponse: `[Open target](${filePath}:42-45)` },
|
||||||
|
});
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
const workspaceRow = page.getByTestId(
|
||||||
|
`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`,
|
||||||
|
);
|
||||||
|
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
await workspaceRow.click();
|
||||||
|
await page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).click();
|
||||||
|
|
||||||
|
const fileLink = page.getByRole("link", { name: "Open target" }).first();
|
||||||
|
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||||
|
await fileLink.hover();
|
||||||
|
await expect(page.getByText("src/target.ts:42-45", { exact: true })).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
|
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -250,6 +250,52 @@ test.describe("Tab creation", () => {
|
|||||||
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
|
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Cursor CLI profile uses the Cursor icon in New tab and host settings", async ({ page }) => {
|
||||||
|
const guessed: TerminalProfile = {
|
||||||
|
id: "e2e-cursor-guessed",
|
||||||
|
name: "Cursor guessed",
|
||||||
|
command: "cursor-agent",
|
||||||
|
args: ["{{{prompt}}}"],
|
||||||
|
};
|
||||||
|
const explicit: TerminalProfile = {
|
||||||
|
...guessed,
|
||||||
|
id: "e2e-cursor-explicit",
|
||||||
|
name: "Cursor explicit",
|
||||||
|
icon: "cursor",
|
||||||
|
};
|
||||||
|
const profileSeed = await seedTerminalProfiles([guessed, explicit]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||||
|
|
||||||
|
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||||
|
const menuIconPath = (name: string) =>
|
||||||
|
menu
|
||||||
|
.getByRole("menuitem", { name })
|
||||||
|
.evaluate((element) => element.querySelector("svg path")?.getAttribute("d") ?? null);
|
||||||
|
const guessedMenuPath = await menuIconPath(guessed.name);
|
||||||
|
const explicitMenuPath = await menuIconPath(explicit.name);
|
||||||
|
|
||||||
|
await menu.getByTestId("workspace-new-tab-menu-edit-terminal-profiles").click();
|
||||||
|
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
|
||||||
|
|
||||||
|
const settingsIconPath = (id: string) =>
|
||||||
|
page
|
||||||
|
.getByTestId(`terminal-profile-row-${id}`)
|
||||||
|
.evaluate((element) => element.querySelector("svg path")?.getAttribute("d") ?? null);
|
||||||
|
const guessedSettingsPath = await settingsIconPath(guessed.id);
|
||||||
|
const explicitSettingsPath = await settingsIconPath(explicit.id);
|
||||||
|
|
||||||
|
expect(explicitMenuPath).not.toBeNull();
|
||||||
|
expect(explicitSettingsPath).not.toBeNull();
|
||||||
|
expect.soft(guessedMenuPath, "New tab Cursor profile icon").toBe(explicitMenuPath);
|
||||||
|
expect.soft(guessedSettingsPath, "Settings Cursor profile icon").toBe(explicitSettingsPath);
|
||||||
|
} finally {
|
||||||
|
await profileSeed.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("tab bar shows action buttons per pane", async ({ page }) => {
|
test("tab bar shows action buttons per pane", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
await assertSingleNewTabButton(page);
|
await assertSingleNewTabButton(page);
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { test, expect, type Page } from "../support/fixtures";
|
||||||
|
import { launchAgent, cleanupRewindFlow, type AgentHandle } from "../support/helpers/rewind-flow";
|
||||||
|
import { submitMessage } from "../support/helpers/composer";
|
||||||
|
|
||||||
|
const binary = process.env.OPENCODE_TEST_BINARY ?? "opencode";
|
||||||
|
test.use({
|
||||||
|
e2eDaemonConfig: {
|
||||||
|
version: 1,
|
||||||
|
agents: { providers: { opencode: { enabled: true, command: [binary] } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function selectMedium(page: Page) {
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /^Select thinking option/ })
|
||||||
|
.filter({ visible: true })
|
||||||
|
.click();
|
||||||
|
await page.getByText("Medium", { exact: true }).filter({ visible: true }).click();
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByRole("button", { name: "Select thinking option (Medium)", exact: true })
|
||||||
|
.filter({ visible: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
async function selectLongcat(page: Page) {
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /^Select model \(/ })
|
||||||
|
.filter({ visible: true })
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByText("LongCat 2.5 Preview Free", { exact: true })
|
||||||
|
.filter({ visible: true })
|
||||||
|
.click();
|
||||||
|
}
|
||||||
|
async function askForReply(handle: AgentHandle) {
|
||||||
|
await submitMessage(handle.page, "Reply exactly OK. Do not use tools.");
|
||||||
|
return handle.client.waitForFinish(handle.agentId, 120_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("switching an existing OpenCode session clears unsupported thinking", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.setTimeout(240_000);
|
||||||
|
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-variant-ui-"));
|
||||||
|
let handle: AgentHandle | undefined;
|
||||||
|
try {
|
||||||
|
handle = await launchAgent({
|
||||||
|
page,
|
||||||
|
cwd,
|
||||||
|
provider: "opencode",
|
||||||
|
mode: "full-access",
|
||||||
|
providerConfig: { model: "opencode/space-bunny-free" },
|
||||||
|
});
|
||||||
|
await selectMedium(page);
|
||||||
|
expect((await askForReply(handle)).status).toBe("idle");
|
||||||
|
await selectLongcat(page);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: /^Select thinking option/ }).filter({ visible: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
const finish = await askForReply(handle);
|
||||||
|
const screenshot = testInfo.outputPath("model-switch.png");
|
||||||
|
await page.screenshot({ path: screenshot });
|
||||||
|
await testInfo.attach("After switching to Longcat", {
|
||||||
|
path: screenshot,
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
expect(finish.status).toBe("idle");
|
||||||
|
expect(finish.final?.lastError).toBeFalsy();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("assistant-message").filter({ visible: true }).last(),
|
||||||
|
).toContainText("OK");
|
||||||
|
} finally {
|
||||||
|
await cleanupRewindFlow({ handle, cwd });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
|
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||||
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents";
|
||||||
|
|
||||||
|
const SETTINGS_KEY = "@paseo:app-settings";
|
||||||
|
|
||||||
|
async function openParentInRightPane(page: Page, parentId: string, workspaceId: string) {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await openAgentRoute(page, { workspaceId, agentId: parentId });
|
||||||
|
await expect(page.getByTestId(`workspace-tab-agent_${parentId}`)).toBeVisible();
|
||||||
|
await page.getByTestId("workspace-pane-main").getByTestId("workspace-new-tab-button").click();
|
||||||
|
await page.getByTestId("workspace-new-tab-menu-agent").click();
|
||||||
|
await page.getByTestId(`workspace-tab-agent_${parentId}`).click();
|
||||||
|
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||||
|
await page
|
||||||
|
.getByTestId("workspace-pane-main")
|
||||||
|
.getByTestId(`workspace-tab-agent_${parentId}`)
|
||||||
|
.click();
|
||||||
|
await page.keyboard.press("Meta+Alt+Shift+ArrowRight");
|
||||||
|
|
||||||
|
const left = page.getByTestId("workspace-pane-main");
|
||||||
|
const right = page
|
||||||
|
.locator('[data-testid^="workspace-pane-"]')
|
||||||
|
.filter({ has: page.getByTestId(`workspace-tab-agent_${parentId}`) });
|
||||||
|
await expect(left).toBeVisible();
|
||||||
|
await expect(right).toBeVisible();
|
||||||
|
await expect(right).not.toHaveAttribute("data-testid", "workspace-pane-main");
|
||||||
|
await left.locator('[data-testid^="workspace-tab-draft_"]').click();
|
||||||
|
return { left, right };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a subagent opened from a right-pane parent stays with its parent when left has focus", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "subagent-origin-pane-" });
|
||||||
|
try {
|
||||||
|
const pair = await seedParentWithSubagent(workspace, {
|
||||||
|
parentTitle: "Right parent",
|
||||||
|
childTitle: "Child agent",
|
||||||
|
});
|
||||||
|
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
|
||||||
|
await openSubagentsTrack(page);
|
||||||
|
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||||
|
|
||||||
|
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||||
|
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the side preference opens a right-pane parent's subagent in the side pane", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript((key) => {
|
||||||
|
localStorage.setItem(key, JSON.stringify({ openInSidePane: { subagents: true } }));
|
||||||
|
}, SETTINGS_KEY);
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "subagent-side-preference-" });
|
||||||
|
try {
|
||||||
|
const pair = await seedParentWithSubagent(workspace, {
|
||||||
|
parentTitle: "Right parent",
|
||||||
|
childTitle: "Child agent",
|
||||||
|
});
|
||||||
|
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
|
||||||
|
await openSubagentsTrack(page);
|
||||||
|
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||||
|
|
||||||
|
const childPane = page
|
||||||
|
.locator('[data-testid^="workspace-pane-"]')
|
||||||
|
.filter({ has: page.getByTestId(`workspace-tab-agent_${pair.child.id}`) });
|
||||||
|
await expect(childPane).toBeVisible();
|
||||||
|
expect(await childPane.getAttribute("data-testid")).not.toBe(
|
||||||
|
await right.getAttribute("data-testid"),
|
||||||
|
);
|
||||||
|
await expect(childPane).not.toHaveAttribute("data-testid", "workspace-pane-main");
|
||||||
|
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opening an already-tabbed subagent reveals its left tab without a duplicate", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "subagent-existing-tab-" });
|
||||||
|
try {
|
||||||
|
const pair = await seedParentWithSubagent(workspace, {
|
||||||
|
parentTitle: "Right parent",
|
||||||
|
childTitle: "Child agent",
|
||||||
|
});
|
||||||
|
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
|
||||||
|
await openSubagentsTrack(page);
|
||||||
|
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||||
|
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||||
|
await page.keyboard.press("Meta+Alt+Shift+ArrowLeft");
|
||||||
|
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||||
|
await right.getByTestId(`workspace-tab-agent_${pair.parent.id}`).click();
|
||||||
|
await openSubagentsTrack(page);
|
||||||
|
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||||
|
|
||||||
|
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||||
|
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(1);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a preferred chat file link still opens beside its right-pane agent", async ({ page }) => {
|
||||||
|
const target = "linked.ts:3";
|
||||||
|
const workspace = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "chat-file-origin-pane-",
|
||||||
|
title: "File link parent",
|
||||||
|
initialPrompt: [
|
||||||
|
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
|
||||||
|
"Return JSON only with fields 'title' and 'branch'.",
|
||||||
|
"",
|
||||||
|
"<user-prompt>",
|
||||||
|
`Open \`${target}\` now`,
|
||||||
|
"</user-prompt>",
|
||||||
|
].join("\n"),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await writeFile(path.join(workspace.cwd, "linked.ts"), "one\ntwo\nthree\n", "utf8");
|
||||||
|
const { left, right } = await openParentInRightPane(
|
||||||
|
page,
|
||||||
|
workspace.agentId,
|
||||||
|
workspace.workspaceId,
|
||||||
|
);
|
||||||
|
const fileLink = right.getByText(target, { exact: true });
|
||||||
|
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||||
|
await fileLink.click();
|
||||||
|
|
||||||
|
await expect(right.getByTestId("workspace-tab-file_linked.ts")).toBeVisible();
|
||||||
|
await expect(left.getByTestId("workspace-tab-file_linked.ts")).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -210,6 +210,17 @@ describe("stream presentation through installed plugins", () => {
|
|||||||
expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]);
|
expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Codex streams a line break and the next line's indent as their own chunks.
|
||||||
|
it("keeps a line break when a chunk ends in the next line's indent", () => {
|
||||||
|
const harness = streamHarness();
|
||||||
|
harness.send(assistant("```mermaid\nflowchart TD\n A[Start] --> B[Review]\n"));
|
||||||
|
harness.send(assistant(" "));
|
||||||
|
const result = harness.send(assistant(" B --> C[Finish]\n```"));
|
||||||
|
expect(result.head).toMatchObject([
|
||||||
|
{ text: "```mermaid\nflowchart TD\n A[Start] --> B[Review]\n B --> C[Finish]\n```" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
// One rendering path: a fetched message is the same block group as a streamed one,
|
// One rendering path: a fetched message is the same block group as a streamed one,
|
||||||
// which is what lets find, scroll-to-message and history reveal address it by id.
|
// which is what lets find, scroll-to-message and history reveal address it by id.
|
||||||
// A link reference definition stays with the paragraph that uses it, so the split
|
// A link reference definition stays with the paragraph that uses it, so the split
|
||||||
|
|||||||
@@ -112,8 +112,10 @@ export function createStreamPresentation() {
|
|||||||
const index = prefix.length + offset;
|
const index = prefix.length + offset;
|
||||||
let blockText = text;
|
let blockText = text;
|
||||||
if (offset === textBlocks.length - 1) {
|
if (offset === textBlocks.length - 1) {
|
||||||
const trailingNewlines = /\n+$/.exec(item.text)?.[0] ?? "";
|
// The split drops trailing blank lines, including a line break followed only
|
||||||
blockText += trailingNewlines;
|
// by the next line's indent. Keep them so the next append continues that line.
|
||||||
|
const trailingBlankLines = /\n\s*$/.exec(item.text)?.[0] ?? "";
|
||||||
|
blockText += trailingBlankLines;
|
||||||
}
|
}
|
||||||
const existing = previous?.[index];
|
const existing = previous?.[index];
|
||||||
const id = `${item.id}:block:${index}`;
|
const id = `${item.id}:block:${index}`;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
|||||||
import { markdownCopyDataSet } from "@/assistant-selection-copy/markup";
|
import { markdownCopyDataSet } from "@/assistant-selection-copy/markup";
|
||||||
import { useAssistantFileLinkResolverContext } from "./provider";
|
import { useAssistantFileLinkResolverContext } from "./provider";
|
||||||
import type { AssistantFileLinkSource } from "./resolver";
|
import type { AssistantFileLinkSource } from "./resolver";
|
||||||
|
import { formatFileLinkTooltipPath } from "./tooltip-path";
|
||||||
import { useFileLink } from "./use-file-link";
|
import { useFileLink } from "./use-file-link";
|
||||||
|
|
||||||
interface AssistantMarkdownLinkProps {
|
interface AssistantMarkdownLinkProps {
|
||||||
@@ -34,7 +35,7 @@ export function AssistantMarkdownLink({
|
|||||||
const { configRef } = useAssistantFileLinkResolverContext();
|
const { configRef } = useAssistantFileLinkResolverContext();
|
||||||
const workspaceRoot = configRef.current.workspaceRoot;
|
const workspaceRoot = configRef.current.workspaceRoot;
|
||||||
const tooltipPath = useMemo(
|
const tooltipPath = useMemo(
|
||||||
() => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
|
() => (target ? formatFileLinkTooltipPath({ target, workspaceRoot }) : null),
|
||||||
[target, workspaceRoot],
|
[target, workspaceRoot],
|
||||||
);
|
);
|
||||||
const linkPress = useMemo<AssistantLinkPress>(
|
const linkPress = useMemo<AssistantLinkPress>(
|
||||||
@@ -127,38 +128,6 @@ export function AssistantMarkdownCodeLink({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatInlinePathTargetForTooltip(
|
|
||||||
target: { path: string; lineStart?: number; lineEnd?: number },
|
|
||||||
workspaceRoot: string | undefined,
|
|
||||||
): string {
|
|
||||||
let result = relativizePathToWorkspace(target.path, workspaceRoot);
|
|
||||||
if (target.lineStart) {
|
|
||||||
result += `:${target.lineStart}`;
|
|
||||||
if (target.lineEnd && target.lineEnd !== target.lineStart) {
|
|
||||||
result += `-${target.lineEnd}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function relativizePathToWorkspace(filePath: string, workspaceRoot: string | undefined): string {
|
|
||||||
if (!workspaceRoot) {
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
const root = workspaceRoot.replace(/\/+$/, "");
|
|
||||||
if (!root) {
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
if (filePath === root) {
|
|
||||||
return ".";
|
|
||||||
}
|
|
||||||
const prefix = `${root}/`;
|
|
||||||
if (filePath.startsWith(prefix)) {
|
|
||||||
return filePath.slice(prefix.length);
|
|
||||||
}
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AssistantInlineCodePathLinkProps {
|
interface AssistantInlineCodePathLinkProps {
|
||||||
content: string;
|
content: string;
|
||||||
inheritedStyles: TextStyle;
|
inheritedStyles: TextStyle;
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { formatFileLinkTooltipPath } from "./tooltip-path";
|
||||||
|
|
||||||
|
describe("formatFileLinkTooltipPath", () => {
|
||||||
|
it("shows a Windows file path relative to its workspace regardless of separators", () => {
|
||||||
|
expect(
|
||||||
|
formatFileLinkTooltipPath({
|
||||||
|
target: {
|
||||||
|
path: "C:/Users/me/repo/src/app.ts",
|
||||||
|
lineStart: 12,
|
||||||
|
lineEnd: 20,
|
||||||
|
},
|
||||||
|
workspaceRoot: "C:\\Users\\me\\repo",
|
||||||
|
}),
|
||||||
|
).toBe("src/app.ts:12-20");
|
||||||
|
expect(
|
||||||
|
formatFileLinkTooltipPath({
|
||||||
|
target: { path: "C:/src/app.ts", lineStart: 12 },
|
||||||
|
workspaceRoot: "C:\\",
|
||||||
|
}),
|
||||||
|
).toBe("src/app.ts:12");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the workspace root as a dot", () => {
|
||||||
|
expect(
|
||||||
|
formatFileLinkTooltipPath({
|
||||||
|
target: { path: "/Users/me/repo" },
|
||||||
|
workspaceRoot: "/Users/me/repo",
|
||||||
|
}),
|
||||||
|
).toBe(".");
|
||||||
|
expect(
|
||||||
|
formatFileLinkTooltipPath({
|
||||||
|
target: { path: "C:/Users/Me/Repo/", lineStart: 4 },
|
||||||
|
workspaceRoot: "c:\\users\\me\\repo",
|
||||||
|
}),
|
||||||
|
).toBe(".:4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an absolute path outside the workspace", () => {
|
||||||
|
expect(
|
||||||
|
formatFileLinkTooltipPath({
|
||||||
|
target: { path: "/Users/me/notes.md" },
|
||||||
|
workspaceRoot: "/Users/me/repo",
|
||||||
|
}),
|
||||||
|
).toBe("/Users/me/notes.md");
|
||||||
|
expect(
|
||||||
|
formatFileLinkTooltipPath({
|
||||||
|
target: { path: "C:/Users/me/notes.md", lineStart: 8 },
|
||||||
|
workspaceRoot: "C:\\Users\\me\\repo",
|
||||||
|
}),
|
||||||
|
).toBe("C:/Users/me/notes.md:8");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the target path when the workspace root is unavailable", () => {
|
||||||
|
expect(formatFileLinkTooltipPath({ target: { path: "src/app.ts", lineStart: 12 } })).toBe(
|
||||||
|
"src/app.ts:12",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
isWorkspaceRootPath,
|
||||||
|
resolveWorkspaceFilePaths,
|
||||||
|
type WorkspaceFileLocation,
|
||||||
|
} from "@/workspace/file-open";
|
||||||
|
|
||||||
|
interface FormatFileLinkTooltipPathInput {
|
||||||
|
target: WorkspaceFileLocation;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFileLinkTooltipPath({
|
||||||
|
target,
|
||||||
|
workspaceRoot,
|
||||||
|
}: FormatFileLinkTooltipPathInput): string {
|
||||||
|
const resolvedPaths = workspaceRoot
|
||||||
|
? resolveWorkspaceFilePaths({ path: target.path, workspaceRoot })
|
||||||
|
: null;
|
||||||
|
const isWorkspaceRoot = workspaceRoot
|
||||||
|
? isWorkspaceRootPath({ path: target.path, workspaceRoot })
|
||||||
|
: false;
|
||||||
|
let result = isWorkspaceRoot ? "." : (resolvedPaths?.relativePath ?? target.path);
|
||||||
|
if (target.lineStart) {
|
||||||
|
result += `:${target.lineStart}`;
|
||||||
|
if (target.lineEnd && target.lineEnd !== target.lineStart) {
|
||||||
|
result += `-${target.lineEnd}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
collectAllPanes,
|
collectAllPanes,
|
||||||
DEFAULT_PANE_ID,
|
DEFAULT_PANE_ID,
|
||||||
findPaneById,
|
findPaneById,
|
||||||
|
findPaneContainingTab,
|
||||||
useWorkspaceLayoutStore,
|
useWorkspaceLayoutStore,
|
||||||
type WorkspaceTabPlacement,
|
type WorkspaceTabPlacement,
|
||||||
} from "@/stores/workspace-layout-store";
|
} from "@/stores/workspace-layout-store";
|
||||||
@@ -109,6 +110,10 @@ export function openWorkspaceTargetAtLocation(
|
|||||||
if (shouldOpenBeside && !targetAlreadyExists) {
|
if (shouldOpenBeside && !targetAlreadyExists) {
|
||||||
const paneId = store.ensureSidePane(input.workspaceKey);
|
const paneId = store.ensureSidePane(input.workspaceKey);
|
||||||
placement = paneId ? { mode: "prefer", paneId } : undefined;
|
placement = paneId ? { mode: "prefer", paneId } : undefined;
|
||||||
|
} else if (!shouldOpenBeside && layout && input.parentTabId) {
|
||||||
|
// A target opened from a tab belongs in that tab's pane, whichever pane has focus.
|
||||||
|
const parentPane = findPaneContainingTab(layout.root, input.parentTabId);
|
||||||
|
placement = parentPane ? { mode: "prefer", paneId: parentPane.id } : undefined;
|
||||||
}
|
}
|
||||||
return store.openTab({
|
return store.openTab({
|
||||||
workspaceKey: input.workspaceKey,
|
workspaceKey: input.workspaceKey,
|
||||||
|
|||||||
@@ -136,6 +136,24 @@ describe("resolveWorkspaceFilePaths", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves files under a drive-root workspace without doubling the separator", () => {
|
||||||
|
expect(resolveWorkspaceFilePaths({ path: "C:/src/app.ts", workspaceRoot: "C:\\" })).toEqual({
|
||||||
|
absolutePath: "C:/src/app.ts",
|
||||||
|
relativePath: "src/app.ts",
|
||||||
|
});
|
||||||
|
expect(resolveWorkspaceFilePaths({ path: "src/app.ts", workspaceRoot: "C:\\" })).toEqual({
|
||||||
|
absolutePath: "C:/src/app.ts",
|
||||||
|
relativePath: "src/app.ts",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves files under the POSIX root workspace", () => {
|
||||||
|
expect(resolveWorkspaceFilePaths({ path: "/src/app.ts", workspaceRoot: "/" })).toEqual({
|
||||||
|
absolutePath: "/src/app.ts",
|
||||||
|
relativePath: "src/app.ts",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("returns null for home-relative paths that cannot be anchored", () => {
|
it("returns null for home-relative paths that cannot be anchored", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveWorkspaceFilePaths({ path: "~/notes.md", workspaceRoot: "/Users/me/repo" }),
|
resolveWorkspaceFilePaths({ path: "~/notes.md", workspaceRoot: "/Users/me/repo" }),
|
||||||
|
|||||||
@@ -129,6 +129,17 @@ function startsWithPath(value: string, prefix: string): boolean {
|
|||||||
: value.startsWith(prefix);
|
: value.startsWith(prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pathWithinRoot(root: string, relativePath: string): string {
|
||||||
|
return root.endsWith("/") ? `${root}${relativePath}` : `${root}/${relativePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an absolute path names the workspace directory itself. */
|
||||||
|
export function isWorkspaceRootPath(input: { path: string; workspaceRoot: string }): boolean {
|
||||||
|
const path = normalizeAbsolutePath(input.path.trim().replace(/\\/g, "/"));
|
||||||
|
const root = normalizeAbsolutePath(input.workspaceRoot.trim().replace(/\\/g, "/"));
|
||||||
|
return path !== null && root !== null && pathsEqual(path, root);
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResolvedWorkspaceFilePaths {
|
export interface ResolvedWorkspaceFilePaths {
|
||||||
/** Absolute path on the host, suitable for opening in an editor / file manager. */
|
/** Absolute path on the host, suitable for opening in an editor / file manager. */
|
||||||
absolutePath: string;
|
absolutePath: string;
|
||||||
@@ -159,7 +170,7 @@ export function resolveWorkspaceFilePaths(input: {
|
|||||||
if (pathsEqual(normalizedFile, workspaceRoot)) {
|
if (pathsEqual(normalizedFile, workspaceRoot)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const prefix = `${workspaceRoot}/`;
|
const prefix = pathWithinRoot(workspaceRoot, "");
|
||||||
const relativePath = startsWithPath(normalizedFile, prefix)
|
const relativePath = startsWithPath(normalizedFile, prefix)
|
||||||
? normalizedFile.slice(prefix.length)
|
? normalizedFile.slice(prefix.length)
|
||||||
: null;
|
: null;
|
||||||
@@ -174,5 +185,5 @@ export function resolveWorkspaceFilePaths(input: {
|
|||||||
if (!relativePath) {
|
if (!relativePath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { absolutePath: `${workspaceRoot}/${relativePath}`, relativePath };
|
return { absolutePath: pathWithinRoot(workspaceRoot, relativePath), relativePath };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"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"
|
||||||
@@ -28,9 +28,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.10.0",
|
"@getpaseo/client": "0.10.1",
|
||||||
"@getpaseo/protocol": "0.10.0",
|
"@getpaseo/protocol": "0.10.1",
|
||||||
"@getpaseo/server": "0.10.0",
|
"@getpaseo/server": "0.10.1",
|
||||||
"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",
|
||||||
|
|||||||
@@ -82,10 +82,15 @@ const EXPECTED_CLAUDE_MODELS = [
|
|||||||
model: "Opus 4.8",
|
model: "Opus 4.8",
|
||||||
descriptionFragment: "Previous release",
|
descriptionFragment: "Previous release",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "claude-sonnet-5-5",
|
||||||
|
model: "Sonnet 5.5",
|
||||||
|
descriptionFragment: "Best for everyday tasks",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "claude-sonnet-5",
|
id: "claude-sonnet-5",
|
||||||
model: "Sonnet 5",
|
model: "Sonnet 5",
|
||||||
descriptionFragment: "Best for everyday tasks",
|
descriptionFragment: "Previous release",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "claude-opus-4-7[1m]",
|
id: "claude-opus-4-7[1m]",
|
||||||
@@ -430,7 +435,7 @@ try {
|
|||||||
"--quiet should print the same ordered model IDs returned by --json",
|
"--quiet should print the same ordered model IDs returned by --json",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5"),
|
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5-5"),
|
||||||
"captured --json output should include the current Claude everyday model id",
|
"captured --json output should include the current Claude everyday model id",
|
||||||
);
|
);
|
||||||
console.log("✓ provider models --quiet outputs model IDs only\n");
|
console.log("✓ provider models --quiet outputs model IDs only\n");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"description": "Paseo client SDK package",
|
"description": "Paseo client SDK package",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -39,8 +39,8 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.10.0",
|
"@getpaseo/protocol": "0.10.1",
|
||||||
"@getpaseo/relay": "0.10.0",
|
"@getpaseo/relay": "0.10.1",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"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.10.0",
|
"version": "0.10.1",
|
||||||
"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.10.0",
|
"version": "0.10.1",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"!dist/**/*.map",
|
"!dist/**/*.map",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/plugin",
|
"name": "@getpaseo/plugin",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"description": "Paseo plugin SDK package",
|
"description": "Paseo plugin SDK package",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -65,8 +65,8 @@
|
|||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@getpaseo/client": "0.10.0",
|
"@getpaseo/client": "0.10.1",
|
||||||
"@getpaseo/protocol": "0.10.0",
|
"@getpaseo/protocol": "0.10.1",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -76,8 +76,8 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@getpaseo/client": "0.10.0",
|
"@getpaseo/client": "0.10.1",
|
||||||
"@getpaseo/protocol": "0.10.0",
|
"@getpaseo/protocol": "0.10.1",
|
||||||
"react": "~19.1.0",
|
"react": "~19.1.0",
|
||||||
"react-native": ">=0.81.5",
|
"react-native": ">=0.81.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"description": "Paseo shared protocol schemas and wire types",
|
"description": "Paseo shared protocol schemas and wire types",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -196,11 +196,13 @@ describe("guessTerminalProfileIcon", () => {
|
|||||||
["/usr/local/bin/gemini", "gemini"],
|
["/usr/local/bin/gemini", "gemini"],
|
||||||
["agy", "agy"],
|
["agy", "agy"],
|
||||||
["/usr/local/bin/agy", "agy"],
|
["/usr/local/bin/agy", "agy"],
|
||||||
|
["cursor-agent", "cursor"],
|
||||||
|
["/usr/local/bin/cursor-agent", "cursor"],
|
||||||
])("guesses %s -> %s", (command, expected) => {
|
])("guesses %s -> %s", (command, expected) => {
|
||||||
expect(guessTerminalProfileIcon(command)).toBe(expected);
|
expect(guessTerminalProfileIcon(command)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([["zsh"], ["bash"], ["fish"], [""], ["/usr/bin/foo"], ["cursor-agent"]])(
|
it.each([["zsh"], ["bash"], ["fish"], [""], ["/usr/bin/foo"], ["cursor-tool"]])(
|
||||||
"returns undefined for unknown command %s",
|
"returns undefined for unknown command %s",
|
||||||
(command) => {
|
(command) => {
|
||||||
expect(guessTerminalProfileIcon(command)).toBeUndefined();
|
expect(guessTerminalProfileIcon(command)).toBeUndefined();
|
||||||
@@ -219,6 +221,26 @@ describe("getTerminalProfileIcon", () => {
|
|||||||
expect(getTerminalProfileIcon(profile)).toBe("claude");
|
expect(getTerminalProfileIcon(profile)).toBe("claude");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows the Cursor icon for a profile created in settings with the Cursor CLI", () => {
|
||||||
|
const profile = {
|
||||||
|
id: "profile_1",
|
||||||
|
name: "Cursor",
|
||||||
|
command: "cursor-agent",
|
||||||
|
args: [PROMPT_SENTINEL],
|
||||||
|
};
|
||||||
|
expect(getTerminalProfileIcon(profile)).toBe("cursor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an explicit icon over the Cursor CLI guess", () => {
|
||||||
|
const profile = {
|
||||||
|
id: "profile_1",
|
||||||
|
name: "Cursor",
|
||||||
|
command: "cursor-agent",
|
||||||
|
icon: "claude",
|
||||||
|
};
|
||||||
|
expect(getTerminalProfileIcon(profile)).toBe("claude");
|
||||||
|
});
|
||||||
|
|
||||||
it("returns undefined when no icon is set and command is unknown", () => {
|
it("returns undefined when no icon is set and command is unknown", () => {
|
||||||
const profile = { id: "1", name: "Foo", command: "zsh" };
|
const profile = { id: "1", name: "Foo", command: "zsh" };
|
||||||
expect(getTerminalProfileIcon(profile)).toBeUndefined();
|
expect(getTerminalProfileIcon(profile)).toBeUndefined();
|
||||||
|
|||||||
@@ -105,7 +105,14 @@ export function resolveTerminalProfileLaunch(
|
|||||||
return { name: profile.name, ...substitutePrompt(profile, prompt) };
|
return { name: profile.name, ...substitutePrompt(profile, prompt) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const WELL_KNOWN_COMMAND_ICONS = new Map(KNOWN_PROVIDER_ICON_NAMES.map((name) => [name, name]));
|
// CLIs whose binary is not named after their icon. Matching stays exact, so a
|
||||||
|
// command only gets an icon when its whole base name is listed.
|
||||||
|
const COMMAND_ICON_ALIASES: ReadonlyArray<[string, string]> = [["cursor-agent", "cursor"]];
|
||||||
|
|
||||||
|
const WELL_KNOWN_COMMAND_ICONS = new Map([
|
||||||
|
...KNOWN_PROVIDER_ICON_NAMES.map((name): [string, string] => [name, name]),
|
||||||
|
...COMMAND_ICON_ALIASES,
|
||||||
|
]);
|
||||||
|
|
||||||
function getCommandBaseName(command: string): string {
|
function getCommandBaseName(command: string): string {
|
||||||
const lastSlash = command.lastIndexOf("/");
|
const lastSlash = command.lastIndexOf("/");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"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.10.0",
|
"version": "0.10.1",
|
||||||
"description": "Paseo backend server",
|
"description": "Paseo backend server",
|
||||||
"files": [
|
"files": [
|
||||||
"dist/server",
|
"dist/server",
|
||||||
@@ -107,11 +107,11 @@
|
|||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.10.0",
|
"@getpaseo/client": "0.10.1",
|
||||||
"@getpaseo/highlight": "0.10.0",
|
"@getpaseo/highlight": "0.10.1",
|
||||||
"@getpaseo/plugin": "0.10.0",
|
"@getpaseo/plugin": "0.10.1",
|
||||||
"@getpaseo/protocol": "0.10.0",
|
"@getpaseo/protocol": "0.10.1",
|
||||||
"@getpaseo/relay": "0.10.0",
|
"@getpaseo/relay": "0.10.1",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
|
|||||||
@@ -4851,6 +4851,53 @@ test("persists live mode, model, and thinking changes without an external snapsh
|
|||||||
expect(persisted?.runtimeInfo?.model).toBe("gpt-5.4");
|
expect(persisted?.runtimeInfo?.model).toBe("gpt-5.4");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("model changes persist the resolved thinking and fresh provider handle before another turn", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-model-selection-"));
|
||||||
|
class ModelSession extends TestAgentSession {
|
||||||
|
selection = { model: "source", thinkingOptionId: "medium" as string | undefined };
|
||||||
|
async setModel(model: string | null) {
|
||||||
|
this.selection = { model: model ?? "default", thinkingOptionId: undefined };
|
||||||
|
this.pushEvent({
|
||||||
|
type: "thinking_option_changed",
|
||||||
|
provider: "codex",
|
||||||
|
thinkingOptionId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
describePersistence() {
|
||||||
|
return { provider: "codex" as const, sessionId: this.id, metadata: { ...this.selection } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class ModelClient extends TestAgentClient {
|
||||||
|
async createSession(config: AgentSessionConfig) {
|
||||||
|
return new ModelSession(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: { codex: new ModelClient() },
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const agent = await manager.createAgent(
|
||||||
|
{ provider: "codex", cwd: workdir, model: "source", thinkingOptionId: "medium" },
|
||||||
|
undefined,
|
||||||
|
{ workspaceId: undefined },
|
||||||
|
);
|
||||||
|
await manager.setAgentModel(agent.id, "target");
|
||||||
|
await manager.flush();
|
||||||
|
const stored = await storage.get(agent.id);
|
||||||
|
expect(stored?.config?.model).toBe("target");
|
||||||
|
expect(stored?.config?.thinkingOptionId).toBeUndefined();
|
||||||
|
expect(stored?.persistence?.metadata?.model).toBe("target");
|
||||||
|
expect(stored?.persistence?.metadata?.thinkingOptionId).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
for (const agent of manager.listAgents()) await manager.closeAgent(agent.id);
|
||||||
|
await manager.flush();
|
||||||
|
rmSync(workdir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("later explicit config mutations win over events emitted by earlier mutations", async () => {
|
test("later explicit config mutations win over events emitted by earlier mutations", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-config-mutation-order-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-config-mutation-order-"));
|
||||||
class ConfigMutationSession extends TestAgentSession {
|
class ConfigMutationSession extends TestAgentSession {
|
||||||
|
|||||||
@@ -1943,6 +1943,7 @@ export class AgentManager {
|
|||||||
if (agent.runtimeInfo) {
|
if (agent.runtimeInfo) {
|
||||||
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
|
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
|
||||||
}
|
}
|
||||||
|
this.refreshSessionPersistence(agent);
|
||||||
this.touchUpdatedAt(agent);
|
this.touchUpdatedAt(agent);
|
||||||
this.emitState(agent);
|
this.emitState(agent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3704,18 +3704,29 @@ class HeldTurnAgentSession implements AgentSession {
|
|||||||
readonly prompts: string[] = [];
|
readonly prompts: string[] = [];
|
||||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||||
private activeTurnId: string | null = null;
|
private activeTurnId: string | null = null;
|
||||||
|
private turnStartGate: Promise<void> | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly provider: AgentProvider,
|
readonly provider: AgentProvider,
|
||||||
private readonly holdTurns: boolean,
|
private readonly holdTurns: boolean,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** Hold the next turn's acknowledgment, as a provider awaiting its turn-start request does. */
|
||||||
|
holdTurnStart(): () => void {
|
||||||
|
let release!: () => void;
|
||||||
|
this.turnStartGate = new Promise((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
return release;
|
||||||
|
}
|
||||||
|
|
||||||
async run(): Promise<AgentRunResult> {
|
async run(): Promise<AgentRunResult> {
|
||||||
return { sessionId: this.id, finalText: "", timeline: [] };
|
return { sessionId: this.id, finalText: "", timeline: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||||
this.prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt));
|
this.prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt));
|
||||||
|
await this.turnStartGate;
|
||||||
const turnId = randomUUID();
|
const turnId = randomUUID();
|
||||||
this.activeTurnId = turnId;
|
this.activeTurnId = turnId;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -4085,6 +4096,127 @@ describe("send_agent_prompt MCP tool", () => {
|
|||||||
await removeAgentStateDir(agentManager, storage, workdir);
|
await removeAgentStateDir(agentManager, storage, workdir);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports a background prompt's accepted turn as running", async () => {
|
||||||
|
const workdir = await mkdtemp(join(tmpdir(), "mcp-background-send-status-"));
|
||||||
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||||
|
const parentClient = new HeldTurnAgentClient("claude", false);
|
||||||
|
const childClient = new HeldTurnAgentClient("codex", true);
|
||||||
|
const agentManager = new AgentManager({
|
||||||
|
clients: { claude: parentClient, codex: childClient },
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parent = await agentManager.createAgent(
|
||||||
|
{ provider: "claude", cwd: existingCwd },
|
||||||
|
undefined,
|
||||||
|
{ workspaceId: "wks_parent" },
|
||||||
|
);
|
||||||
|
const child = await agentManager.createAgent(
|
||||||
|
{ provider: "codex", cwd: existingCwd },
|
||||||
|
undefined,
|
||||||
|
{ workspaceId: "wks_parent" },
|
||||||
|
);
|
||||||
|
const server = await createAgentMcpServer({
|
||||||
|
agentManager,
|
||||||
|
agentStorage: storage,
|
||||||
|
callerAgentId: parent.id,
|
||||||
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const tool = registeredTool(server, "send_agent_prompt");
|
||||||
|
const childSession = childClient.sessions[0]!;
|
||||||
|
const acknowledgeTurnStart = childSession.holdTurnStart();
|
||||||
|
|
||||||
|
const pending = invokeToolWithParsedInput(tool, {
|
||||||
|
agentId: child.id,
|
||||||
|
prompt: "Follow up",
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(childSession.prompts).toEqual(["Follow up"]));
|
||||||
|
acknowledgeTurnStart();
|
||||||
|
const response = await pending;
|
||||||
|
|
||||||
|
expect(response.structuredContent).toMatchObject({ success: true, status: "running" });
|
||||||
|
expect(agentManager.getAgent(child.id)?.lifecycle).toBe("running");
|
||||||
|
|
||||||
|
childSession.finishTurn();
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const parentPrompts = parentClient.sessions[0]!.prompts;
|
||||||
|
expect(parentPrompts).toHaveLength(1);
|
||||||
|
expect(parentPrompts[0]).toContain(child.id);
|
||||||
|
expect(parentPrompts[0]).toContain("finished");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await removeAgentStateDir(agentManager, storage, workdir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a background turn completed as the manager publishes its start", async () => {
|
||||||
|
const workdir = await mkdtemp(join(tmpdir(), "mcp-fast-background-send-"));
|
||||||
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||||
|
const parentClient = new HeldTurnAgentClient("claude", false);
|
||||||
|
const childClient = new HeldTurnAgentClient("codex", true);
|
||||||
|
const agentManager = new AgentManager({
|
||||||
|
clients: { claude: parentClient, codex: childClient },
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parent = await agentManager.createAgent(
|
||||||
|
{ provider: "claude", cwd: existingCwd },
|
||||||
|
undefined,
|
||||||
|
{ workspaceId: "wks_parent" },
|
||||||
|
);
|
||||||
|
const child = await agentManager.createAgent(
|
||||||
|
{ provider: "codex", cwd: existingCwd },
|
||||||
|
undefined,
|
||||||
|
{ workspaceId: "wks_parent" },
|
||||||
|
);
|
||||||
|
const server = await createAgentMcpServer({
|
||||||
|
agentManager,
|
||||||
|
agentStorage: storage,
|
||||||
|
callerAgentId: parent.id,
|
||||||
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
let finished = false;
|
||||||
|
const unsubscribe = agentManager.subscribe(
|
||||||
|
(event) => {
|
||||||
|
if (
|
||||||
|
!finished &&
|
||||||
|
event.type === "agent_state" &&
|
||||||
|
event.agent.id === child.id &&
|
||||||
|
event.agent.lifecycle === "running"
|
||||||
|
) {
|
||||||
|
finished = true;
|
||||||
|
childClient.sessions[0]!.finishTurn();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ agentId: child.id, replayState: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await invokeToolWithParsedInput(
|
||||||
|
registeredTool(server, "send_agent_prompt"),
|
||||||
|
{
|
||||||
|
agentId: child.id,
|
||||||
|
prompt: "Fast reply",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
unsubscribe();
|
||||||
|
expect(response.structuredContent).toMatchObject({ success: true, status: "idle" });
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const parentPrompts = parentClient.sessions[0]!.prompts;
|
||||||
|
expect(parentPrompts).toHaveLength(1);
|
||||||
|
expect(parentPrompts[0]).toContain(child.id);
|
||||||
|
expect(parentPrompts[0]).toContain("finished");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await removeAgentStateDir(agentManager, storage, workdir);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("update_agent MCP tool", () => {
|
describe("update_agent MCP tool", () => {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const mockState = vi.hoisted(() => {
|
|||||||
isCommandAvailable: vi.fn(async (_command: string) => false),
|
isCommandAvailable: vi.fn(async (_command: string) => false),
|
||||||
runtimeModels: new Map<string, AgentModelDefinition[]>(),
|
runtimeModels: new Map<string, AgentModelDefinition[]>(),
|
||||||
cursorListFeaturesConfigs: [] as AgentSessionConfig[],
|
cursorListFeaturesConfigs: [] as AgentSessionConfig[],
|
||||||
|
codexNativeArchiveCalls: [] as Array<{ state: "archive" | "restore"; handle: unknown }>,
|
||||||
reset() {
|
reset() {
|
||||||
this.constructorArgs.claude = [];
|
this.constructorArgs.claude = [];
|
||||||
this.constructorArgs.codex = [];
|
this.constructorArgs.codex = [];
|
||||||
@@ -73,6 +74,7 @@ const mockState = vi.hoisted(() => {
|
|||||||
this.isCommandAvailable.mockImplementation(async (_command: string) => false);
|
this.isCommandAvailable.mockImplementation(async (_command: string) => false);
|
||||||
this.runtimeModels.clear();
|
this.runtimeModels.clear();
|
||||||
this.cursorListFeaturesConfigs = [];
|
this.cursorListFeaturesConfigs = [];
|
||||||
|
this.codexNativeArchiveCalls = [];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -171,6 +173,14 @@ vi.mock("./providers/codex-app-server-agent.js", () => ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async archiveNativeSession(handle: unknown): Promise<void> {
|
||||||
|
mockState.codexNativeArchiveCalls.push({ state: "archive", handle });
|
||||||
|
}
|
||||||
|
|
||||||
|
async unarchiveNativeSession(handle: unknown): Promise<void> {
|
||||||
|
mockState.codexNativeArchiveCalls.push({ state: "restore", handle });
|
||||||
|
}
|
||||||
|
|
||||||
async isAvailable(): Promise<boolean> {
|
async isAvailable(): Promise<boolean> {
|
||||||
const command: { mode?: string; argv?: string[] } | undefined =
|
const command: { mode?: string; argv?: string[] } | undefined =
|
||||||
typeof this.runtimeSettings === "object" && this.runtimeSettings !== null
|
typeof this.runtimeSettings === "object" && this.runtimeSettings !== null
|
||||||
@@ -651,6 +661,22 @@ test("new provider extending claude appears in registry", () => {
|
|||||||
expect(registry.zai.createClient(logger).provider).toBe("zai");
|
expect(registry.zai.createClient(logger).provider).toBe("zai");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("new provider extending codex archives and unarchives its native sessions", async () => {
|
||||||
|
const registry = buildProviderRegistry(logger, {
|
||||||
|
providerOverrides: { "my-codex": { extends: "codex", label: "My Codex" } },
|
||||||
|
});
|
||||||
|
const client = registry["my-codex"].createClient(logger);
|
||||||
|
const handle = { provider: "my-codex", sessionId: "thread-1", nativeHandle: "thread-1" };
|
||||||
|
|
||||||
|
await client.archiveNativeSession?.(handle);
|
||||||
|
await client.unarchiveNativeSession?.(handle);
|
||||||
|
|
||||||
|
expect(mockState.codexNativeArchiveCalls).toEqual([
|
||||||
|
{ state: "archive", handle: { ...handle, provider: "codex" } },
|
||||||
|
{ state: "restore", handle: { ...handle, provider: "codex" } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("built-in OMP override keeps the real OMP adapter enabled and launchable", async () => {
|
test("built-in OMP override keeps the real OMP adapter enabled and launchable", async () => {
|
||||||
const omp = new FakeOmp(["custom-omp"]);
|
const omp = new FakeOmp(["custom-omp"]);
|
||||||
const registry = buildProviderRegistry(logger, {
|
const registry = buildProviderRegistry(logger, {
|
||||||
|
|||||||
@@ -487,6 +487,8 @@ function wrapClientProvider(
|
|||||||
const listImportableSessions = inner.listImportableSessions?.bind(inner);
|
const listImportableSessions = inner.listImportableSessions?.bind(inner);
|
||||||
const importSession = inner.importSession?.bind(inner);
|
const importSession = inner.importSession?.bind(inner);
|
||||||
const listFeatures = inner.listFeatures?.bind(inner);
|
const listFeatures = inner.listFeatures?.bind(inner);
|
||||||
|
const archiveNativeSession = inner.archiveNativeSession?.bind(inner);
|
||||||
|
const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider,
|
provider,
|
||||||
@@ -575,6 +577,12 @@ function wrapClientProvider(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
archiveNativeSession: archiveNativeSession
|
||||||
|
? async (handle) => await archiveNativeSession({ ...handle, provider: inner.provider })
|
||||||
|
: undefined,
|
||||||
|
unarchiveNativeSession: unarchiveNativeSession
|
||||||
|
? async (handle) => await unarchiveNativeSession({ ...handle, provider: inner.provider })
|
||||||
|
: undefined,
|
||||||
getCatalogCacheKey: inner.getCatalogCacheKey?.bind(inner),
|
getCatalogCacheKey: inner.getCatalogCacheKey?.bind(inner),
|
||||||
isAvailable: (signal, options) => inner.isAvailable(signal, options),
|
isAvailable: (signal, options) => inner.isAvailable(signal, options),
|
||||||
getDiagnostic: inner.getDiagnostic?.bind(inner),
|
getDiagnostic: inner.getDiagnostic?.bind(inner),
|
||||||
|
|||||||
@@ -496,7 +496,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
|||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({
|
||||||
logger,
|
logger,
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
resolveBinary: async () => "/test/claude/bin",
|
||||||
resolveVersion: async () => "2.1.219",
|
resolveVersion: async () => "2.1.284",
|
||||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
|
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
|
||||||
});
|
});
|
||||||
const { models } = await client.fetchCatalog({
|
const { models } = await client.fetchCatalog({
|
||||||
@@ -515,6 +515,8 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
|||||||
expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode");
|
expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode");
|
||||||
expect(getThinkingIds("claude-sonnet-5")).toContain("xhigh");
|
expect(getThinkingIds("claude-sonnet-5")).toContain("xhigh");
|
||||||
expect(getThinkingIds("claude-sonnet-5")).toContain("ultracode");
|
expect(getThinkingIds("claude-sonnet-5")).toContain("ultracode");
|
||||||
|
expect(getThinkingIds("claude-sonnet-5-5")).toContain("xhigh");
|
||||||
|
expect(getThinkingIds("claude-sonnet-5-5")).not.toContain("off");
|
||||||
expect(getThinkingIds("claude-opus-4-7[1m]")).toContain("ultracode");
|
expect(getThinkingIds("claude-opus-4-7[1m]")).toContain("ultracode");
|
||||||
expect(getThinkingIds("claude-opus-4-7")).toContain("ultracode");
|
expect(getThinkingIds("claude-opus-4-7")).toContain("ultracode");
|
||||||
expect(getThinkingIds("claude-sonnet-4-6")).not.toContain("ultracode");
|
expect(getThinkingIds("claude-sonnet-4-6")).not.toContain("ultracode");
|
||||||
@@ -1238,6 +1240,26 @@ describe("ClaudeAgentSession features", () => {
|
|||||||
await session.close();
|
await session.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rejects disabled thinking on Sonnet 5.5, which only runs with adaptive thinking", async () => {
|
||||||
|
const { queryFactory } = createQueryMock();
|
||||||
|
const client = new ClaudeAgentClient({
|
||||||
|
logger,
|
||||||
|
queryFactory,
|
||||||
|
resolveBinary: async () => "/test/claude/bin",
|
||||||
|
});
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "claude",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
model: "claude-sonnet-5-5",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(session.setThinkingOption?.("off")).rejects.toThrow(
|
||||||
|
"Thinking option 'off' is not available for model 'claude-sonnet-5-5'",
|
||||||
|
);
|
||||||
|
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects an initial disabled-thinking config for an unsupported model", async () => {
|
test("rejects an initial disabled-thinking config for an unsupported model", async () => {
|
||||||
const { queryFactory } = createQueryMock();
|
const { queryFactory } = createQueryMock();
|
||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({
|
||||||
|
|||||||
@@ -93,10 +93,19 @@ export const CLAUDE_MODEL_MANIFEST = [
|
|||||||
supportsThinkingDisabled: true,
|
supportsThinkingDisabled: true,
|
||||||
supportsFastMode: true,
|
supportsFastMode: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "claude-sonnet-5-5",
|
||||||
|
label: "Sonnet 5.5",
|
||||||
|
description: "Sonnet 5.5 · Best for everyday tasks",
|
||||||
|
minimumClaudeCodeVersion: "2.1.284",
|
||||||
|
defaultThinkingOptionId: "medium",
|
||||||
|
contextWindowMaxTokens: 1_000_000,
|
||||||
|
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "claude-sonnet-5",
|
id: "claude-sonnet-5",
|
||||||
label: "Sonnet 5",
|
label: "Sonnet 5",
|
||||||
description: "Sonnet 5 · Best for everyday tasks",
|
description: "Sonnet 5 · Previous release",
|
||||||
contextWindowMaxTokens: 200_000,
|
contextWindowMaxTokens: 200_000,
|
||||||
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
||||||
supportsThinkingDisabled: true,
|
supportsThinkingDisabled: true,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ async function createClaudeConfigDirWithRawSettings(settings: string): Promise<s
|
|||||||
return configDir;
|
return configDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCatalogClient(claudeCodeVersion = "2.1.280"): ClaudeAgentClient {
|
function createCatalogClient(claudeCodeVersion = "2.1.284"): ClaudeAgentClient {
|
||||||
return new ClaudeAgentClient({
|
return new ClaudeAgentClient({
|
||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
resolveVersion: async () => claudeCodeVersion,
|
resolveVersion: async () => claudeCodeVersion,
|
||||||
@@ -57,6 +57,7 @@ describe("getClaudeModels", () => {
|
|||||||
"claude-fable-5[1m]",
|
"claude-fable-5[1m]",
|
||||||
"claude-opus-4-8[1m]",
|
"claude-opus-4-8[1m]",
|
||||||
"claude-opus-4-8",
|
"claude-opus-4-8",
|
||||||
|
"claude-sonnet-5-5",
|
||||||
"claude-sonnet-5",
|
"claude-sonnet-5",
|
||||||
"claude-sonnet-5[1m]",
|
"claude-sonnet-5[1m]",
|
||||||
"claude-opus-4-7[1m]",
|
"claude-opus-4-7[1m]",
|
||||||
@@ -90,6 +91,7 @@ describe("getClaudeModels", () => {
|
|||||||
["claude-fable-5[1m]", 1_000_000],
|
["claude-fable-5[1m]", 1_000_000],
|
||||||
["claude-opus-4-8[1m]", 1_000_000],
|
["claude-opus-4-8[1m]", 1_000_000],
|
||||||
["claude-opus-4-8", 200_000],
|
["claude-opus-4-8", 200_000],
|
||||||
|
["claude-sonnet-5-5", 1_000_000],
|
||||||
["claude-sonnet-5", 200_000],
|
["claude-sonnet-5", 200_000],
|
||||||
["claude-sonnet-5[1m]", 1_000_000],
|
["claude-sonnet-5[1m]", 1_000_000],
|
||||||
["claude-opus-4-7[1m]", 1_000_000],
|
["claude-opus-4-7[1m]", 1_000_000],
|
||||||
@@ -116,6 +118,9 @@ describe("getClaudeModels", () => {
|
|||||||
expect(getClaudeModels("2.1.279").find((model) => model.isDefault)?.id).toBe("claude-opus-5");
|
expect(getClaudeModels("2.1.279").find((model) => model.isDefault)?.id).toBe("claude-opus-5");
|
||||||
expect(getClaudeModels("2.1.280").map((model) => model.id)).toContain("claude-opus-5-5");
|
expect(getClaudeModels("2.1.280").map((model) => model.id)).toContain("claude-opus-5-5");
|
||||||
expect(getClaudeModels("2.1.280").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5");
|
expect(getClaudeModels("2.1.280").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5");
|
||||||
|
|
||||||
|
expect(getClaudeModels("2.1.283").map((model) => model.id)).not.toContain("claude-sonnet-5-5");
|
||||||
|
expect(getClaudeModels("2.1.284").map((model) => model.id)).toContain("claude-sonnet-5-5");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("derives thinking options from model effort capabilities", () => {
|
it("derives thinking options from model effort capabilities", () => {
|
||||||
@@ -178,6 +183,8 @@ describe("getClaudeModels", () => {
|
|||||||
it.each([
|
it.each([
|
||||||
["claude-opus-5-5", false, "medium"],
|
["claude-opus-5-5", false, "medium"],
|
||||||
["claude-opus-5-5-20260401", false, "medium"],
|
["claude-opus-5-5-20260401", false, "medium"],
|
||||||
|
["claude-sonnet-5-5", false, "medium"],
|
||||||
|
["claude-sonnet-5-5-20260928", false, "medium"],
|
||||||
["claude-opus-5", true, "high"],
|
["claude-opus-5", true, "high"],
|
||||||
["claude-opus-5-20260724", true, "high"],
|
["claude-opus-5-20260724", true, "high"],
|
||||||
["claude-sonnet-5", true, "high"],
|
["claude-sonnet-5", true, "high"],
|
||||||
@@ -467,6 +474,16 @@ describe("normalizeClaudeRuntimeModelId", () => {
|
|||||||
"claude-opus-5",
|
"claude-opus-5",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not collapse a prefixed Sonnet 5.5 onto Sonnet 5", () => {
|
||||||
|
expect(normalizeClaudeRuntimeModelId("anthropic/claude-sonnet-5-5")).toBe("claude-sonnet-5-5");
|
||||||
|
expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-sonnet-5-5-20260928-v1:0")).toBe(
|
||||||
|
"claude-sonnet-5-5",
|
||||||
|
);
|
||||||
|
expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-sonnet-5-20260101-v1:0")).toBe(
|
||||||
|
"claude-sonnet-5",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseClaudeCodeVersion", () => {
|
describe("parseClaudeCodeVersion", () => {
|
||||||
@@ -600,6 +617,42 @@ describe("Claude Opus 5.5 catalog", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Claude Sonnet 5.5 catalog", () => {
|
||||||
|
it("offers one Sonnet 5.5 entry with a 1M context window", () => {
|
||||||
|
const sonnet55Models = getClaudeModels()
|
||||||
|
.filter((model) => model.id.startsWith("claude-sonnet-5-5"))
|
||||||
|
.map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens }));
|
||||||
|
|
||||||
|
expect(sonnet55Models).toEqual([
|
||||||
|
{ id: "claude-sonnet-5-5", label: "Sonnet 5.5", contextWindowMaxTokens: 1_000_000 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers every effort level except off, because Sonnet 5.5 cannot disable thinking", () => {
|
||||||
|
const sonnet55 = getClaudeModels().find((model) => model.id === "claude-sonnet-5-5");
|
||||||
|
|
||||||
|
expect(sonnet55?.thinkingOptions?.map((option) => option.id)).toEqual([
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high",
|
||||||
|
"xhigh",
|
||||||
|
"max",
|
||||||
|
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
|
||||||
|
]);
|
||||||
|
expect(sonnet55?.defaultThinkingOptionId).toBe("medium");
|
||||||
|
expect(
|
||||||
|
sonnet55?.thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id),
|
||||||
|
).toEqual(["medium"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves suffixed and dated Sonnet 5.5 IDs to the single catalog entry", () => {
|
||||||
|
expect(findClaudeModel("claude-sonnet-5-5[1m]")?.id).toBe("claude-sonnet-5-5");
|
||||||
|
expect(findClaudeModel("claude-sonnet-5-5-20260928")?.id).toBe("claude-sonnet-5-5");
|
||||||
|
expect(findClaudeModel("claude-sonnet-5-5-20260928[1m]")?.id).toBe("claude-sonnet-5-5");
|
||||||
|
expect(findClaudeModel("claude-sonnet-5-5[1m]")?.contextWindowMaxTokens).toBe(1_000_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("claudeManifestModelSupportsFastMode", () => {
|
describe("claudeManifestModelSupportsFastMode", () => {
|
||||||
it("keeps fast mode strict to first-party manifest model IDs", () => {
|
it("keeps fast mode strict to first-party manifest model IDs", () => {
|
||||||
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();
|
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();
|
||||||
@@ -610,6 +663,7 @@ describe("claudeManifestModelSupportsFastMode", () => {
|
|||||||
it("supports fast mode on Opus 5 but not on other Claude 5 models", () => {
|
it("supports fast mode on Opus 5 but not on other Claude 5 models", () => {
|
||||||
expect(claudeManifestModelSupportsFastMode("claude-opus-5-5")).toBe(true);
|
expect(claudeManifestModelSupportsFastMode("claude-opus-5-5")).toBe(true);
|
||||||
expect(claudeManifestModelSupportsFastMode("claude-opus-5")).toBe(true);
|
expect(claudeManifestModelSupportsFastMode("claude-opus-5")).toBe(true);
|
||||||
|
expect(claudeManifestModelSupportsFastMode("claude-sonnet-5-5")).toBe(false);
|
||||||
expect(claudeManifestModelSupportsFastMode("claude-sonnet-5")).toBe(false);
|
expect(claudeManifestModelSupportsFastMode("claude-sonnet-5")).toBe(false);
|
||||||
expect(claudeManifestModelSupportsFastMode("claude-fable-5")).toBe(false);
|
expect(claudeManifestModelSupportsFastMode("claude-fable-5")).toBe(false);
|
||||||
expect(claudeManifestModelSupportsFastMode("claude-fable-5-1")).toBe(false);
|
expect(claudeManifestModelSupportsFastMode("claude-fable-5-1")).toBe(false);
|
||||||
|
|||||||
@@ -617,6 +617,111 @@ process.stdin.on("data", (chunk) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withCustomCodexProviderHome<T>(
|
||||||
|
run: (input: {
|
||||||
|
session: AgentSession;
|
||||||
|
readCaptured: () => CapturedFakeCodexRecord[];
|
||||||
|
}) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-provider-home-"));
|
||||||
|
const daemonCodexHome = path.join(tempDir, "daemon-codex-home");
|
||||||
|
const providerCodexHome = path.join(tempDir, "provider-codex-home");
|
||||||
|
const fakeAppServerPath = path.join(tempDir, "fake-codex-app-server.cjs");
|
||||||
|
const capturedRequestsPath = path.join(tempDir, "requests.jsonl");
|
||||||
|
mkdirSync(path.join(daemonCodexHome, "prompts"), { recursive: true });
|
||||||
|
mkdirSync(path.join(providerCodexHome, "prompts"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
path.join(daemonCodexHome, "prompts", "probe-default.md"),
|
||||||
|
"---\ndescription: Daemon home prompt\n---\nfrom the daemon home\n",
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
path.join(providerCodexHome, "prompts", "probe-profile.md"),
|
||||||
|
"---\ndescription: Provider home prompt\n---\nfrom the provider home\n",
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
fakeAppServerPath,
|
||||||
|
`
|
||||||
|
const fs = require("node:fs");
|
||||||
|
|
||||||
|
const capturePath = process.env.PASEO_FAKE_CODEX_CAPTURE;
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
fs.appendFileSync(capturePath, JSON.stringify({ kind: "env", CODEX_HOME: process.env.CODEX_HOME }) + "\\n");
|
||||||
|
|
||||||
|
function resultFor(method) {
|
||||||
|
if (method === "collaborationMode/list") return { data: [] };
|
||||||
|
if (method === "skills/list") return { data: [] };
|
||||||
|
if (method === "model/list") return { data: [{ id: "profile-model", isDefault: true }] };
|
||||||
|
if (method === "thread/start") return { thread: { id: "thread-1" } };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdin.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
for (;;) {
|
||||||
|
const newlineIndex = buffer.indexOf("\\n");
|
||||||
|
if (newlineIndex === -1) break;
|
||||||
|
const line = buffer.slice(0, newlineIndex).trim();
|
||||||
|
buffer = buffer.slice(newlineIndex + 1);
|
||||||
|
if (!line) continue;
|
||||||
|
const message = JSON.parse(line);
|
||||||
|
fs.appendFileSync(capturePath, JSON.stringify({ kind: "request", method: message.method, params: message.params }) + "\\n");
|
||||||
|
process.stdout.write(JSON.stringify({ id: message.id, result: resultFor(message.method) }) + "\\n");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.stubEnv("CODEX_HOME", daemonCodexHome);
|
||||||
|
const registry = buildProviderRegistry(createTestLogger(), {
|
||||||
|
providerOverrides: {
|
||||||
|
"profile-codex": {
|
||||||
|
extends: "codex",
|
||||||
|
label: "Profile Codex",
|
||||||
|
command: [process.execPath, fakeAppServerPath],
|
||||||
|
env: {
|
||||||
|
CODEX_HOME: providerCodexHome,
|
||||||
|
PASEO_FAKE_CODEX_CAPTURE: capturedRequestsPath,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const session = await registry["profile-codex"].createClient(createTestLogger()).createSession({
|
||||||
|
provider: "profile-codex",
|
||||||
|
cwd: tempDir,
|
||||||
|
modeId: "auto",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await run({
|
||||||
|
session,
|
||||||
|
readCaptured: () =>
|
||||||
|
readFileSync(capturedRequestsPath, "utf8")
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => JSON.parse(line) as CapturedFakeCodexRecord),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listPromptCommandsFromCustomCodexHome(): Promise<string[]> {
|
||||||
|
return withCustomCodexProviderHome(async ({ session }) => {
|
||||||
|
const commands = await session.listCommands!();
|
||||||
|
return commands.map((command) => command.name).filter((name) => name.startsWith("prompts:"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPromptFromCustomCodexHome(prompt: string): Promise<CapturedFakeCodexRecord[]> {
|
||||||
|
return withCustomCodexProviderHome(async ({ session, readCaptured }) => {
|
||||||
|
await session.startTurn(prompt);
|
||||||
|
return readCaptured();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function capturedThreadStartConfig(records: CapturedFakeCodexRecord[]): unknown {
|
function capturedThreadStartConfig(records: CapturedFakeCodexRecord[]): unknown {
|
||||||
const threadStart = records.find((record) => record.method === "thread/start");
|
const threadStart = records.find((record) => record.method === "thread/start");
|
||||||
const params = threadStart?.params as Record<string, unknown> | undefined;
|
const params = threadStart?.params as Record<string, unknown> | undefined;
|
||||||
@@ -1833,6 +1938,79 @@ describe("Codex app-server provider", () => {
|
|||||||
await session.close();
|
await session.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.each(["legacy", "paginated"] as const)(
|
||||||
|
"rewinds a %s thread onto a fork that keeps the custom provider and runtime MCP servers",
|
||||||
|
async (historyMode) => {
|
||||||
|
const appServer = createFakeCodexAppServer(
|
||||||
|
historyMode === "paginated"
|
||||||
|
? {
|
||||||
|
"thread/read": () => ({
|
||||||
|
thread: { id: "thread-1", historyMode: "paginated", turns: [] },
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
const customCodexConfig = {
|
||||||
|
model_provider: "codex-custom",
|
||||||
|
model_providers: {
|
||||||
|
"codex-custom": {
|
||||||
|
name: "Custom Codex",
|
||||||
|
base_url: "https://custom-relay.example.com/v1",
|
||||||
|
env_key: "OPENAI_API_KEY",
|
||||||
|
requires_openai_auth: false,
|
||||||
|
wire_api: "responses",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const session = new CodexAppServerAgentSession(
|
||||||
|
createConfig({
|
||||||
|
cwd: "/workspace/project",
|
||||||
|
mcpServers: {
|
||||||
|
paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
createTestLogger(),
|
||||||
|
async () => appServer.child,
|
||||||
|
{ customCodexConfig },
|
||||||
|
);
|
||||||
|
|
||||||
|
await session.startTurn("remember first");
|
||||||
|
emitCodexUserMessage(appServer, {
|
||||||
|
id: "codex-first",
|
||||||
|
text: "remember first",
|
||||||
|
turnId: "turn-first",
|
||||||
|
});
|
||||||
|
appServer.completeTurn();
|
||||||
|
await session.startTurn("remember second");
|
||||||
|
emitCodexUserMessage(appServer, {
|
||||||
|
id: "codex-second",
|
||||||
|
text: "remember second",
|
||||||
|
turnId: "turn-second",
|
||||||
|
});
|
||||||
|
appServer.completeTurn();
|
||||||
|
|
||||||
|
await session.revertConversation({ messageId: "codex-first" });
|
||||||
|
|
||||||
|
const forkConfigs = appServer
|
||||||
|
.requests()
|
||||||
|
.filter((request) => request.method === "thread/fork")
|
||||||
|
.map((request) => (request.params as { config?: unknown }).config);
|
||||||
|
expect(forkConfigs).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
...customCodexConfig,
|
||||||
|
mcp_servers: {
|
||||||
|
paseo: expect.objectContaining({
|
||||||
|
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
appServer.assertNoErrors();
|
||||||
|
await session.close();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test("correlates a Codex user message with the submitting client message", async () => {
|
test("correlates a Codex user message with the submitting client message", async () => {
|
||||||
const appServer = createFakeCodexAppServer();
|
const appServer = createFakeCodexAppServer();
|
||||||
const session = new CodexAppServerAgentSession(
|
const session = new CodexAppServerAgentSession(
|
||||||
@@ -1989,7 +2167,9 @@ describe("Codex app-server provider", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await expect(listCodexSkills(cwd, workspaceGitService)).resolves.toContainEqual({
|
await expect(
|
||||||
|
listCodexSkills(cwd, path.join(tempDir, "codex-home"), workspaceGitService),
|
||||||
|
).resolves.toContainEqual({
|
||||||
name: "shipper",
|
name: "shipper",
|
||||||
description: "Ship changes carefully.",
|
description: "Ship changes carefully.",
|
||||||
argumentHint: "",
|
argumentHint: "",
|
||||||
@@ -2250,6 +2430,22 @@ describe("Codex app-server provider", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("lists custom prompts from the CODEX_HOME a custom provider runs Codex with", async () => {
|
||||||
|
await expect(listPromptCommandsFromCustomCodexHome()).resolves.toEqual([
|
||||||
|
"prompts:probe-profile",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runs a custom prompt from the CODEX_HOME a custom provider runs Codex with", async () => {
|
||||||
|
const records = await runPromptFromCustomCodexHome("/prompts:probe-profile");
|
||||||
|
|
||||||
|
expect(records.find((record) => record.kind === "env")?.CODEX_HOME).toMatch(
|
||||||
|
/provider-codex-home$/,
|
||||||
|
);
|
||||||
|
const turnStart = records.find((record) => record.method === "turn/start");
|
||||||
|
expect(JSON.stringify(turnStart?.params)).toContain("from the provider home");
|
||||||
|
});
|
||||||
|
|
||||||
test("deduplicates Codex skill slash commands returned from multiple skill roots", async () => {
|
test("deduplicates Codex skill slash commands returned from multiple skill roots", async () => {
|
||||||
const commands = await listCommandsFromFakeCodex([
|
const commands = await listCommandsFromFakeCodex([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -260,6 +260,8 @@ interface CodexAppServerAgentDeps {
|
|||||||
extends: string;
|
extends: string;
|
||||||
};
|
};
|
||||||
customCodexConfig?: CodexCustomProviderConfig | null;
|
customCodexConfig?: CodexCustomProviderConfig | null;
|
||||||
|
// The CODEX_HOME the session's app-server runs with; prompts and skills are read from it.
|
||||||
|
codexHome?: string;
|
||||||
_createCodexClient?: (
|
_createCodexClient?: (
|
||||||
child: ChildProcessWithoutNullStreams,
|
child: ChildProcessWithoutNullStreams,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
@@ -545,8 +547,8 @@ async function checkCodexLaunchAvailable(launch: ResolvedProviderLaunch) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveCodexHomeDir(): string {
|
function resolveCodexHomeDir(env: NodeJS.ProcessEnv): string {
|
||||||
return process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
return env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeEscapedChar(next: string): string {
|
function decodeEscapedChar(next: string): string {
|
||||||
@@ -656,8 +658,7 @@ function parseFrontMatter(markdown: string): {
|
|||||||
return { frontMatter, body };
|
return { frontMatter, body };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listCodexCustomPrompts(): Promise<AgentSlashCommand[]> {
|
async function listCodexCustomPrompts(codexHome: string): Promise<AgentSlashCommand[]> {
|
||||||
const codexHome = resolveCodexHomeDir();
|
|
||||||
const promptsDir = path.join(codexHome, "prompts");
|
const promptsDir = path.join(codexHome, "prompts");
|
||||||
let entries: Dirent[];
|
let entries: Dirent[];
|
||||||
try {
|
try {
|
||||||
@@ -699,6 +700,7 @@ async function listCodexCustomPrompts(): Promise<AgentSlashCommand[]> {
|
|||||||
|
|
||||||
export async function listCodexSkills(
|
export async function listCodexSkills(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
|
codexHome: string,
|
||||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">,
|
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">,
|
||||||
): Promise<AgentSlashCommand[]> {
|
): Promise<AgentSlashCommand[]> {
|
||||||
const candidates: string[] = [];
|
const candidates: string[] = [];
|
||||||
@@ -712,7 +714,7 @@ export async function listCodexSkills(
|
|||||||
candidates.push(path.join(repoRoot, ".codex", "skills"));
|
candidates.push(path.join(repoRoot, ".codex", "skills"));
|
||||||
}
|
}
|
||||||
|
|
||||||
candidates.push(path.join(resolveCodexHomeDir(), "skills"));
|
candidates.push(path.join(codexHome, "skills"));
|
||||||
|
|
||||||
const candidateReads = await Promise.all(
|
const candidateReads = await Promise.all(
|
||||||
candidates.map(async (dir) => {
|
candidates.map(async (dir) => {
|
||||||
@@ -3354,6 +3356,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
private readonly config: AgentSessionConfig;
|
private readonly config: AgentSessionConfig;
|
||||||
private readonly asyncQuestions: CodexAsyncQuestions;
|
private readonly asyncQuestions: CodexAsyncQuestions;
|
||||||
|
private readonly codexHome: string;
|
||||||
private currentMode: string;
|
private currentMode: string;
|
||||||
private hasWorkflowModeOverride: boolean;
|
private hasWorkflowModeOverride: boolean;
|
||||||
private readonly providerOptions: CodexProviderOptions;
|
private readonly providerOptions: CodexProviderOptions;
|
||||||
@@ -3467,6 +3470,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.providerOptions = CodexProviderOptionsSchema.parse(config.providerOptions ?? {});
|
this.providerOptions = CodexProviderOptionsSchema.parse(config.providerOptions ?? {});
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions);
|
this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions);
|
||||||
|
this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env);
|
||||||
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
||||||
if (this.config.featureValues?.fast_mode && codexModelSupportsFastMode(this.config.model)) {
|
if (this.config.featureValues?.fast_mode && codexModelSupportsFastMode(this.config.model)) {
|
||||||
this.serviceTier = "fast";
|
this.serviceTier = "fast";
|
||||||
@@ -4037,8 +4041,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
): Promise<CodexPromptInput> {
|
): Promise<CodexPromptInput> {
|
||||||
if (commandName.startsWith("prompts:")) {
|
if (commandName.startsWith("prompts:")) {
|
||||||
const promptName = commandName.slice("prompts:".length);
|
const promptName = commandName.slice("prompts:".length);
|
||||||
const codexHome = resolveCodexHomeDir();
|
const promptPath = path.join(this.codexHome, "prompts", `${promptName}.md`);
|
||||||
const promptPath = path.join(codexHome, "prompts", `${promptName}.md`);
|
|
||||||
const raw = await fs.readFile(promptPath, "utf8");
|
const raw = await fs.readFile(promptPath, "utf8");
|
||||||
const parsed = parseFrontMatter(raw);
|
const parsed = parseFrontMatter(raw);
|
||||||
return expandCodexCustomPrompt(parsed.body, args);
|
return expandCodexCustomPrompt(parsed.body, args);
|
||||||
@@ -4834,6 +4837,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
cwd: this.config.cwd ?? null,
|
cwd: this.config.cwd ?? null,
|
||||||
model: this.config.model ?? null,
|
model: this.config.model ?? null,
|
||||||
serviceTier: this.serviceTier,
|
serviceTier: this.serviceTier,
|
||||||
|
config: this.buildCodexInnerConfig(),
|
||||||
userMessageTurns: this.codexUserMessageTurns(),
|
userMessageTurns: this.codexUserMessageTurns(),
|
||||||
setThreadId: async (threadId) => {
|
setThreadId: async (threadId) => {
|
||||||
this.currentThreadId = threadId;
|
this.currentThreadId = threadId;
|
||||||
@@ -4941,7 +4945,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||||
const prompts = await listCodexCustomPrompts();
|
const prompts = await listCodexCustomPrompts(this.codexHome);
|
||||||
if (this.connectionState === "disconnected") {
|
if (this.connectionState === "disconnected") {
|
||||||
await this.connect();
|
await this.connect();
|
||||||
} else {
|
} else {
|
||||||
@@ -4955,7 +4959,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
}));
|
}));
|
||||||
const fallbackSkills =
|
const fallbackSkills =
|
||||||
this.cachedSkills === null
|
this.cachedSkills === null
|
||||||
? await listCodexSkills(this.config.cwd, this.deps.workspaceGitService)
|
? await listCodexSkills(this.config.cwd, this.codexHome, this.deps.workspaceGitService)
|
||||||
: [];
|
: [];
|
||||||
const builtin: AgentSlashCommand[] = [
|
const builtin: AgentSlashCommand[] = [
|
||||||
{
|
{
|
||||||
@@ -7024,9 +7028,10 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
private readonly deps: CodexAppServerAgentDeps = {},
|
private readonly deps: CodexAppServerAgentDeps = {},
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private sessionDeps(): CodexAppServerAgentDeps {
|
private sessionDeps(launchEnv: Record<string, string> | undefined): CodexAppServerAgentDeps {
|
||||||
return {
|
return {
|
||||||
...this.deps,
|
...this.deps,
|
||||||
|
codexHome: resolveCodexHomeDir(buildCodexAppServerEnv(this.runtimeSettings, launchEnv)),
|
||||||
customCodexConfig: this.customProviderConfig(),
|
customCodexConfig: this.customProviderConfig(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -7138,7 +7143,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
this.logger,
|
this.logger,
|
||||||
() =>
|
() =>
|
||||||
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
|
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
|
||||||
this.sessionDeps(),
|
this.sessionDeps(launchContext?.env),
|
||||||
options?.persistSession === false,
|
options?.persistSession === false,
|
||||||
goalsEnabled,
|
goalsEnabled,
|
||||||
autoReviewEnabled,
|
autoReviewEnabled,
|
||||||
@@ -7169,7 +7174,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
this.logger,
|
this.logger,
|
||||||
() =>
|
() =>
|
||||||
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
|
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
|
||||||
this.sessionDeps(),
|
this.sessionDeps(launchContext?.env),
|
||||||
false,
|
false,
|
||||||
goalsEnabled,
|
goalsEnabled,
|
||||||
autoReviewEnabled,
|
autoReviewEnabled,
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export async function revertCodexConversation(input: {
|
|||||||
cwd?: string | null;
|
cwd?: string | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
serviceTier?: string | null;
|
serviceTier?: string | null;
|
||||||
|
config?: Record<string, unknown> | null;
|
||||||
userMessageTurns: CodexUserMessageTurnIndex;
|
userMessageTurns: CodexUserMessageTurnIndex;
|
||||||
setThreadId: (threadId: string) => void | Promise<void>;
|
setThreadId: (threadId: string) => void | Promise<void>;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
@@ -85,19 +86,26 @@ export async function revertCodexConversation(input: {
|
|||||||
throw new Error(`Codex user message ${input.messageId} is outside the current thread`);
|
throw new Error(`Codex user message ${input.messageId} is outside the current thread`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Codex does not carry the parent thread's config into a fork; without it the
|
||||||
|
// forked thread falls back to the default model provider.
|
||||||
|
const forkParams: CodexThreadForkParams = {
|
||||||
|
threadId: input.threadId,
|
||||||
|
cwd: input.cwd ?? null,
|
||||||
|
model: input.model ?? null,
|
||||||
|
serviceTier: input.serviceTier ?? null,
|
||||||
|
...(input.config ? { config: input.config } : {}),
|
||||||
|
excludeTurns: false,
|
||||||
|
persistExtendedHistory: true,
|
||||||
|
};
|
||||||
|
|
||||||
const historyMode = await readCodexThreadHistoryMode(input.client, input.threadId);
|
const historyMode = await readCodexThreadHistoryMode(input.client, input.threadId);
|
||||||
if (historyMode === "paginated") {
|
if (historyMode === "paginated") {
|
||||||
if (!targetTurn.turnId) {
|
if (!targetTurn.turnId) {
|
||||||
throw new Error(`Codex could not find the turn containing user message ${input.messageId}`);
|
throw new Error(`Codex could not find the turn containing user message ${input.messageId}`);
|
||||||
}
|
}
|
||||||
const forked = await forkCodexThread(input.client, {
|
const forked = await forkCodexThread(input.client, {
|
||||||
threadId: input.threadId,
|
...forkParams,
|
||||||
beforeTurnId: targetTurn.turnId,
|
beforeTurnId: targetTurn.turnId,
|
||||||
cwd: input.cwd ?? null,
|
|
||||||
model: input.model ?? null,
|
|
||||||
serviceTier: input.serviceTier ?? null,
|
|
||||||
excludeTurns: false,
|
|
||||||
persistExtendedHistory: true,
|
|
||||||
});
|
});
|
||||||
await input.setThreadId(forked.thread.id);
|
await input.setThreadId(forked.thread.id);
|
||||||
return;
|
return;
|
||||||
@@ -105,14 +113,7 @@ export async function revertCodexConversation(input: {
|
|||||||
|
|
||||||
// Fork is non-destructive: the old thread file stays on disk and remains
|
// Fork is non-destructive: the old thread file stays on disk and remains
|
||||||
// recoverable with `codex resume <old-uuid>` if the rewind target was wrong.
|
// recoverable with `codex resume <old-uuid>` if the rewind target was wrong.
|
||||||
const forked = await forkCodexThread(input.client, {
|
const forked = await forkCodexThread(input.client, forkParams);
|
||||||
threadId: input.threadId,
|
|
||||||
cwd: input.cwd ?? null,
|
|
||||||
model: input.model ?? null,
|
|
||||||
serviceTier: input.serviceTier ?? null,
|
|
||||||
excludeTurns: false,
|
|
||||||
persistExtendedHistory: true,
|
|
||||||
});
|
|
||||||
const forkedThreadId = forked.thread.id;
|
const forkedThreadId = forked.thread.id;
|
||||||
|
|
||||||
// Codex rollback is chat-only by design. File edits from rewound turns stay
|
// Codex rollback is chat-only by design. File edits from rewound turns stay
|
||||||
|
|||||||
@@ -333,6 +333,76 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
|||||||
model: TEST_MODEL,
|
model: TEST_MODEL,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ variants: { medium: {}, custom: {} }, selected: "medium", expected: "medium" },
|
||||||
|
{ variants: { custom: {} }, selected: "custom", expected: "custom" },
|
||||||
|
{ variants: { high: {} }, selected: "medium", expected: undefined },
|
||||||
|
{ variants: {}, selected: "medium", expected: undefined },
|
||||||
|
])(
|
||||||
|
"model switches resolve the retained variant: $selected / $variants",
|
||||||
|
async ({ variants, selected, expected }) => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const runtime = new TestOpenCodeHarness();
|
||||||
|
const upstream = new TestOpenCodeClient();
|
||||||
|
upstream.providerListResponse = {
|
||||||
|
data: {
|
||||||
|
connected: ["test"],
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "test",
|
||||||
|
name: "Test",
|
||||||
|
source: "api",
|
||||||
|
models: { target: { name: "Target", variants } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
runtime.enqueueClient(upstream);
|
||||||
|
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||||
|
serverManager: runtime,
|
||||||
|
createClient: runtime.createClient,
|
||||||
|
});
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "opencode",
|
||||||
|
cwd,
|
||||||
|
model: "test/source",
|
||||||
|
thinkingOptionId: selected,
|
||||||
|
});
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
try {
|
||||||
|
await session.setModel!("test/target");
|
||||||
|
expect((await session.getRuntimeInfo()).thinkingOptionId).toBe(expected ?? null);
|
||||||
|
expect(events).toContainEqual({
|
||||||
|
type: "thinking_option_changed",
|
||||||
|
provider: "opencode",
|
||||||
|
thinkingOptionId: expected ?? null,
|
||||||
|
});
|
||||||
|
upstream.sessionPromptAsyncEvents = [
|
||||||
|
{ type: "session.idle", properties: { sessionID: "session-1" } },
|
||||||
|
];
|
||||||
|
await collectTurnEvents(streamSession(session, "Use the selected model"));
|
||||||
|
expect(upstream.calls.sessionPromptAsync).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
model: { providerID: "test", modelID: "target" },
|
||||||
|
...(expected ? { variant: expected } : {}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (!expected) expect(upstream.calls.sessionPromptAsync[0]).not.toHaveProperty("variant");
|
||||||
|
await session.setThinkingOption!("medium");
|
||||||
|
await expect(session.setModel!("test/missing")).rejects.toThrow(
|
||||||
|
"OpenCode model unavailable",
|
||||||
|
);
|
||||||
|
upstream.providerListResponse = { error: "Catalog unavailable" };
|
||||||
|
await expect(session.setModel!("test/source")).rejects.toThrow("Catalog unavailable");
|
||||||
|
expect((await session.getRuntimeInfo()).model).toBe("test/target");
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test("creates a session with valid id and provider", async () => {
|
test("creates a session with valid id and provider", async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
const runtime = new TestOpenCodeHarness();
|
const runtime = new TestOpenCodeHarness();
|
||||||
|
|||||||
@@ -3549,16 +3549,37 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
model: this.config.model ?? null,
|
model: this.config.model ?? null,
|
||||||
modeId: this.currentMode,
|
modeId: this.currentMode,
|
||||||
|
thinkingOptionId: this.config.thinkingOptionId ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async setModel(modelId: string | null): Promise<void> {
|
async setModel(modelId: string | null): Promise<void> {
|
||||||
|
await this.reconnectIfServerExited();
|
||||||
const normalizedModelId =
|
const normalizedModelId =
|
||||||
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
|
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
|
||||||
|
let variant = this.config.thinkingOptionId;
|
||||||
|
if (!normalizedModelId) {
|
||||||
|
variant = undefined;
|
||||||
|
} else if (variant) {
|
||||||
|
const model = this.parseModel(normalizedModelId);
|
||||||
|
const response = await this.client.provider.list({ directory: this.config.cwd });
|
||||||
|
if (response.error)
|
||||||
|
throw new Error(`Failed to fetch OpenCode providers: ${JSON.stringify(response.error)}`);
|
||||||
|
const provider = response.data?.all.find((entry) => entry.id === model?.providerID);
|
||||||
|
const target = model && provider?.models[model.modelID];
|
||||||
|
if (!target) throw new Error(`OpenCode model unavailable: ${normalizedModelId}`);
|
||||||
|
if (!Object.hasOwn(target.variants ?? {}, variant)) variant = undefined;
|
||||||
|
}
|
||||||
this.config.model = normalizedModelId ?? undefined;
|
this.config.model = normalizedModelId ?? undefined;
|
||||||
|
this.config.thinkingOptionId = variant;
|
||||||
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
|
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
|
||||||
this.config.model,
|
this.config.model,
|
||||||
);
|
);
|
||||||
|
this.notifySubscribers({
|
||||||
|
type: "thinking_option_changed",
|
||||||
|
provider: "opencode",
|
||||||
|
thinkingOptionId: variant ?? null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
|
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { SessionMessageInfo } from "@opencode/client";
|
import type { ModelInfo, SessionMessageInfo } from "@opencode/client";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
|
||||||
@@ -7,6 +7,89 @@ import { OpenCodeV2AgentClient } from "./agent.js";
|
|||||||
import { V2Harness } from "../test-utils/v2-harness.js";
|
import { V2Harness } from "../test-utils/v2-harness.js";
|
||||||
|
|
||||||
describe("OpenCode v2 session lifecycle", () => {
|
describe("OpenCode v2 session lifecycle", () => {
|
||||||
|
test.each([
|
||||||
|
{ variants: ["medium", "custom"], selected: "medium", expected: "medium" },
|
||||||
|
{ variants: ["custom"], selected: "custom", expected: "custom" },
|
||||||
|
{ variants: ["high"], selected: "medium", expected: undefined },
|
||||||
|
{ variants: [], selected: "medium", expected: undefined },
|
||||||
|
{ variants: ["medium"], selected: "default", expected: undefined },
|
||||||
|
])(
|
||||||
|
"model switches retain only supported variants: $selected / $variants",
|
||||||
|
async ({ variants, selected, expected }) => {
|
||||||
|
const harness = new V2Harness();
|
||||||
|
const target: ModelInfo = {
|
||||||
|
id: "target",
|
||||||
|
modelID: "target",
|
||||||
|
providerID: "test",
|
||||||
|
name: "Target",
|
||||||
|
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||||
|
variants: variants.map((id) => ({ id })),
|
||||||
|
time: { released: 1 },
|
||||||
|
cost: [],
|
||||||
|
status: "active",
|
||||||
|
enabled: true,
|
||||||
|
limit: { context: 200000, output: 10000 },
|
||||||
|
};
|
||||||
|
harness.info.model = { providerID: "test", id: "source", variant: selected };
|
||||||
|
harness.api.model.list = async () => ({ location: harness.info.location, data: [target] });
|
||||||
|
harness.api.session.switchModel = async ({ model }) => {
|
||||||
|
harness.info.model = model;
|
||||||
|
};
|
||||||
|
const client = new OpenCodeV2AgentClient({
|
||||||
|
logger: createTestLogger(),
|
||||||
|
runtime: harness.runtime,
|
||||||
|
});
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "opencode",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
model: "test/source",
|
||||||
|
thinkingOptionId: selected,
|
||||||
|
});
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
try {
|
||||||
|
await session.setModel!("test/target");
|
||||||
|
expect(await session.getRuntimeInfo()).toMatchObject({
|
||||||
|
model: "test/target",
|
||||||
|
thinkingOptionId: expected ?? null,
|
||||||
|
});
|
||||||
|
expect(session.describePersistence().metadata?.thinkingOptionId).toBe(expected);
|
||||||
|
expect(events).toContainEqual({
|
||||||
|
type: "thinking_option_changed",
|
||||||
|
provider: "opencode",
|
||||||
|
thinkingOptionId: expected ?? null,
|
||||||
|
});
|
||||||
|
await expect(session.setModel!("test/missing")).rejects.toThrow(
|
||||||
|
"OpenCode model unavailable",
|
||||||
|
);
|
||||||
|
harness.api.session.switchModel = async () => {
|
||||||
|
throw new Error("Switch failed");
|
||||||
|
};
|
||||||
|
await expect(session.setModel!("test/target")).rejects.toThrow("Switch failed");
|
||||||
|
expect(session.describePersistence().metadata?.thinkingOptionId).toBe(expected);
|
||||||
|
expect(harness.info.model).toEqual({
|
||||||
|
providerID: "test",
|
||||||
|
id: "target",
|
||||||
|
...(expected ? { variant: expected } : {}),
|
||||||
|
});
|
||||||
|
harness.api.session.switchModel = async ({ model }) => {
|
||||||
|
harness.info.model = model;
|
||||||
|
};
|
||||||
|
harness.api.model.default = async () => ({ location: harness.info.location, data: target });
|
||||||
|
await session.setThinkingOption!("unsupported");
|
||||||
|
await session.setModel!(null);
|
||||||
|
expect((await session.getRuntimeInfo()).thinkingOptionId).toBeNull();
|
||||||
|
harness.api.model.list = async () => {
|
||||||
|
throw new Error("Catalog unavailable");
|
||||||
|
};
|
||||||
|
await expect(session.setModel!("test/source")).rejects.toThrow("Catalog unavailable");
|
||||||
|
expect((await session.getRuntimeInfo()).model).toBe("test/target");
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test("restores each same-directory agent environment on reconnect and resume", async () => {
|
test("restores each same-directory agent environment on reconnect and resume", async () => {
|
||||||
const first = new V2Harness();
|
const first = new V2Harness();
|
||||||
const second = new V2Harness();
|
const second = new V2Harness();
|
||||||
|
|||||||
@@ -276,25 +276,48 @@ export class OpenCodeV2Session implements AgentSession {
|
|||||||
}
|
}
|
||||||
async setModel(model: string | null) {
|
async setModel(model: string | null) {
|
||||||
await this.reconnectIfExited();
|
await this.reconnectIfExited();
|
||||||
const selected = model
|
const location = { directory: this.config.cwd };
|
||||||
? modelRef(model, this.config.thinkingOptionId)
|
const selected = model ? modelRef(model) : (await this.client.model.default({ location })).data;
|
||||||
: (await this.client.model.default({ location: { directory: this.config.cwd } })).data;
|
|
||||||
if (!selected) throw new Error("OpenCode has no default model");
|
if (!selected) throw new Error("OpenCode has no default model");
|
||||||
await this.client.session.switchModel({
|
const catalog = await this.client.model.list({ location });
|
||||||
sessionID: this.id,
|
const target = catalog.data.find(
|
||||||
model: {
|
(entry) =>
|
||||||
id: selected.id,
|
entry.enabled && entry.providerID === selected.providerID && entry.id === selected.id,
|
||||||
providerID: selected.providerID,
|
);
|
||||||
variant: this.config.thinkingOptionId,
|
if (!target)
|
||||||
},
|
throw new Error(`OpenCode model unavailable: ${selected.providerID}/${selected.id}`);
|
||||||
});
|
const retainedVariant = this.config.thinkingOptionId;
|
||||||
|
const variant =
|
||||||
|
retainedVariant !== "default" && target.variants.some((entry) => entry.id === retainedVariant)
|
||||||
|
? retainedVariant
|
||||||
|
: undefined;
|
||||||
|
const nextModel = {
|
||||||
|
id: selected.id,
|
||||||
|
providerID: selected.providerID,
|
||||||
|
...(variant ? { variant } : {}),
|
||||||
|
};
|
||||||
|
await this.client.session.switchModel({ sessionID: this.id, model: nextModel });
|
||||||
|
this.info.model = nextModel;
|
||||||
this.config.model = model ?? undefined;
|
this.config.model = model ?? undefined;
|
||||||
|
this.config.thinkingOptionId = variant;
|
||||||
|
this.emit({
|
||||||
|
type: "thinking_option_changed",
|
||||||
|
provider: "opencode",
|
||||||
|
thinkingOptionId: variant ?? null,
|
||||||
|
});
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "model_changed",
|
type: "model_changed",
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
runtimeInfo: await this.getRuntimeInfo(),
|
runtimeInfo: {
|
||||||
|
provider: "opencode",
|
||||||
|
sessionId: this.id,
|
||||||
|
model: `${selected.providerID}/${selected.id}`,
|
||||||
|
modeId: this.info.agent ?? null,
|
||||||
|
thinkingOptionId: variant ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async setThinkingOption(variant: string | null) {
|
async setThinkingOption(variant: string | null) {
|
||||||
await this.reconnectIfExited();
|
await this.reconnectIfExited();
|
||||||
const model = this.info.model;
|
const model = this.info.model;
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ import {
|
|||||||
toScheduleSummary,
|
toScheduleSummary,
|
||||||
waitForAgentWithTimeout,
|
waitForAgentWithTimeout,
|
||||||
} from "../mcp-shared.js";
|
} from "../mcp-shared.js";
|
||||||
import { sendPromptToAgent, setupFinishNotification } from "../agent-prompt.js";
|
import {
|
||||||
|
sendPromptToAgent,
|
||||||
|
setupFinishNotification,
|
||||||
|
waitForAgentRunStartWithTimeout,
|
||||||
|
} from "../agent-prompt.js";
|
||||||
import { respondToAgentPermission } from "../permission-response.js";
|
import { respondToAgentPermission } from "../permission-response.js";
|
||||||
import {
|
import {
|
||||||
archiveAgentCommand,
|
archiveAgentCommand,
|
||||||
@@ -1924,7 +1928,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendPromptToAgent({
|
const { disposition } = await sendPromptToAgent({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
agentId,
|
agentId,
|
||||||
@@ -1963,8 +1967,10 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
|||||||
|
|
||||||
const notifying = armFinishNotification();
|
const notifying = armFinishNotification();
|
||||||
|
|
||||||
// Return immediately if background=true
|
// Return once the provider has accepted the turn, so the status reports it running.
|
||||||
// Re-fetch snapshot since the state may have changed
|
if (disposition === "turn_started") {
|
||||||
|
await waitForAgentRunStartWithTimeout(agentManager, agentId);
|
||||||
|
}
|
||||||
const currentSnapshot = agentManager.getAgent(agentId);
|
const currentSnapshot = agentManager.getAgent(agentId);
|
||||||
|
|
||||||
const responseData = {
|
const responseData = {
|
||||||
|
|||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
import { once } from "node:events";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { createServer, type Server } from "node:http";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||||
|
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||||
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
|
|
||||||
|
// Runs the real `codex` binary against a local Responses endpoint standing in
|
||||||
|
// for a custom provider's OPENAI_BASE_URL, so no login is needed.
|
||||||
|
|
||||||
|
function sse(events: Array<Record<string, unknown>>): string {
|
||||||
|
return events
|
||||||
|
.map((event) => `event: ${String(event.type)}\ndata: ${JSON.stringify(event)}\n\n`)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalAnswer(): string {
|
||||||
|
return sse([
|
||||||
|
{ type: "response.created", response: { id: "resp-1" } },
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: {
|
||||||
|
type: "message",
|
||||||
|
role: "assistant",
|
||||||
|
id: "msg-1",
|
||||||
|
content: [{ type: "output_text", text: "done" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.completed",
|
||||||
|
response: {
|
||||||
|
id: "resp-1",
|
||||||
|
usage: {
|
||||||
|
input_tokens: 0,
|
||||||
|
input_tokens_details: null,
|
||||||
|
output_tokens: 0,
|
||||||
|
output_tokens_details: null,
|
||||||
|
total_tokens: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startResponsesEndpoint(): Promise<{
|
||||||
|
baseUrl: string;
|
||||||
|
server: Server;
|
||||||
|
requestCount: () => number;
|
||||||
|
}> {
|
||||||
|
let requests = 0;
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
req.resume();
|
||||||
|
req.on("end", () => {
|
||||||
|
if (req.method !== "POST" || req.url !== "/v1/responses") {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requests += 1;
|
||||||
|
res.setHeader("content-type", "text/event-stream");
|
||||||
|
res.end(finalAnswer());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
server.listen(0, "127.0.0.1");
|
||||||
|
await once(server, "listening");
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("no server address");
|
||||||
|
return {
|
||||||
|
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||||
|
server,
|
||||||
|
requestCount: () => requests,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeServer(server: Server): Promise<void> {
|
||||||
|
server.close();
|
||||||
|
await once(server, "close");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Codex custom provider archive", () => {
|
||||||
|
const cleanups: Array<() => void | Promise<void>> = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const cleanup of cleanups.splice(0).toReversed()) await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("archiving hides the session from Import session and unarchiving lists it again", async () => {
|
||||||
|
const root = mkdtempSync(path.join(os.tmpdir(), "codex-custom-archive-"));
|
||||||
|
cleanups.push(() => rmSync(root, { recursive: true, force: true }));
|
||||||
|
const codexHome = path.join(root, "codex-home");
|
||||||
|
mkdirSync(codexHome);
|
||||||
|
const cwd = path.join(root, "project");
|
||||||
|
mkdirSync(cwd);
|
||||||
|
const { baseUrl, server } = await startResponsesEndpoint();
|
||||||
|
cleanups.push(() => closeServer(server));
|
||||||
|
|
||||||
|
const daemon = await createTestPaseoDaemon({
|
||||||
|
logger: createTestLogger(),
|
||||||
|
pluginsEnabled: false,
|
||||||
|
providerOverrides: {
|
||||||
|
"my-codex": {
|
||||||
|
extends: "codex",
|
||||||
|
label: "My Codex",
|
||||||
|
env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
cleanups.push(() => daemon.close());
|
||||||
|
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||||
|
cleanups.push(() => client.close());
|
||||||
|
await client.connect();
|
||||||
|
await client.fetchAgents({ subscribe: {} });
|
||||||
|
const manager = daemon.daemon.agentManager;
|
||||||
|
|
||||||
|
const agent = await client.createAgent({
|
||||||
|
config: { provider: "my-codex", cwd, model: "mock-model", modeId: "full-access" },
|
||||||
|
});
|
||||||
|
await manager.runAgent(agent.id, "hello from the custom provider");
|
||||||
|
const importable = async () =>
|
||||||
|
(await manager.listImportableSessions({ cwd, providerFilter: new Set(["my-codex"]) }))
|
||||||
|
.sessions;
|
||||||
|
expect(await importable()).toHaveLength(1);
|
||||||
|
|
||||||
|
await client.archiveAgent(agent.id);
|
||||||
|
expect(await importable()).toEqual([]);
|
||||||
|
|
||||||
|
await client.refreshAgent(agent.id);
|
||||||
|
expect(await importable()).toHaveLength(1);
|
||||||
|
}, 120_000);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user