Compare commits

...
12 Commits
Author SHA1 Message Date
Mohamed Boudra c5236c00d8 chore(release): cut 0.10.1 2026-09-29 00:06:23 +02:00
Mohamed Boudra d1443064db docs(changelog): add 0.10.1 release notes 2026-09-29 00:03:24 +02:00
Mohamed Boudra 9f387873b0 fix(opencode): clear unsupported thinking when changing models (#5587)
Resolve retained variants against the destination model before applying a model change. Publish the effective thinking choice and refresh the provider resume handle immediately so a reload cannot restore the old selection.

(cherry picked from commit 940dbfd242)
2026-09-28 23:37:09 +02:00
Qi 12cd5345d2 feat(claude): add Sonnet 5.5 with its model capabilities (#5583)
Claude Code 2.1.284 introduces Sonnet 5.5 with a native 1M context
window, medium default effort, and adaptive thinking that cannot be
turned off, so it takes the same shape as Opus 5.5 in the manifest.

Sonnet 5.5 rejects `thinking: {"type": "disabled"}` with a 400, so its
entry omits supportsThinkingDisabled and its thinking options have no
off. It also takes the Sonnet family tagline, which moves Sonnet 5 to
Previous release.

The catalog gates the entry on Claude Code 2.1.284, the version that
recognizes the model, so older installs keep their current catalog.
The existing major-minor runtime matching already resolves dashed
gateway IDs such as us.anthropic.claude-sonnet-5-5-20260928-v1:0 to
the new entry without collapsing them onto Sonnet 5.

(cherry picked from commit 4340607093)
2026-09-28 23:36:50 +02:00
paseo-bot[bot] 2c6d4e5283 fix(app): keep line breaks in streamed replies when a chunk ends in indentation (#5577)
While a reply streams, presentation reparses only the growing last Markdown
block: the cached block text plus the new chunk. splitMarkdownBlocks drops a
trailing whitespace-only line, and only trailing "\n"s were added back, so a
reply that paused on "]\n   " cached the block as "]". The next chunk " B -->"
then continued the previous line, joining two lines of a code block. Codex
streams a line break and the next line's indent as separate deltas, so its
Mermaid diagrams hit this and failed to render. History reparses the whole
text, which is why a reload showed the reply correctly.

Keep every trailing blank line the split drops on the last block.

Closes #5456

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
(cherry picked from commit f4ba16a0b3)
2026-09-28 23:36:50 +02:00
paseo-bot[bot] 006bae2897 Archive a custom Codex provider's session when its agent is archived (#5572)
A provider that extends Codex runs through the registry's client wrapper,
which forwarded session listing and import but not native archive or
unarchive. Archiving such an agent left its Codex thread unarchived, so
the session stayed in Import session and could be imported again. The
wrapper now forwards both, the same as stock Codex.

Closes #5454

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
(cherry picked from commit 9d010211a2)
2026-09-28 23:36:50 +02:00
Mohamed Boudra 437aef1a7b Show Windows file links relative to their workspace (#1987)
* fix(app): show Windows file links relative to workspace

Tooltip formatting only handled matching POSIX separators, so Windows paths could remain absolute. Reuse the workspace file-path resolver for normalized relative paths.

* fix(app): preserve workspace-root link tooltips

Workspace file resolution deliberately rejects the root as an openable file, so tooltip formatting preserves its display-only dot form separately.

* fix(app): share workspace path rules for file link tooltips

(cherry picked from commit cf4509631b)
2026-09-28 23:36:50 +02:00
paseo-bot[bot] b44e7b0b90 Open a subagent in the split pane it was opened from (#5451)
* fix(app): open a subagent in the pane it was opened from

Opening a subagent from an agent's composer placed the new tab in the
focused pane. Clicking the subagent row does not focus its pane, so in a
split view the subagent landed in whichever pane last had focus.

Place an implicit main-location open that has a parent tab in that tab's
pane.

Refs #5433

* test(app): cover subagent split-pane placement in browser

* test(app): use browser repro for subagent pane placement

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
(cherry picked from commit 7bb7d4ed08)
2026-09-28 23:36:50 +02:00
paseo-bot[bot] 09f87df1b2 Report a background send_agent_prompt's started turn as running (#5386)
* Report a background send_agent_prompt's started turn as running

The background branch read the target's status right after dispatch,
before the provider acknowledged the turn, so an idle target read as
idle. Wait for the run to start first, as create_agent and the app's
send path already do.

* Check the caller still gets the finish notification after a background send

* Acknowledge the held turn start explicitly in the send_agent_prompt test

* Cover immediate completion after background turn start

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
(cherry picked from commit 8de52a3c99)
2026-09-28 23:36:50 +02:00
paseo-bot[bot] b013e0d428 fix(codex): read custom prompts from the provider's own CODEX_HOME (#5450)
A provider that extends codex and sets CODEX_HOME in its env runs Codex
against that home, but the daemon listed, expanded, and fell back to
skills from its own CODEX_HOME or ~/.codex. The / menu offered another
home's prompts and never the provider's own, and invoking one of the
provider's prompts sent the raw slash text to Codex.

Resolve the Codex home from the same environment the app-server is
launched with, and give it to the session.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
(cherry picked from commit dffde6ae8b)
2026-09-28 23:36:50 +02:00
paseo-bot[bot] c938e1cf44 Show the Cursor icon for terminal profiles that run cursor-agent (#5379)
* Show the Cursor icon for terminal profiles that run cursor-agent

The Cursor CLI binary is cursor-agent, so a profile created in settings
with that command matched no known icon and fell back to the generic
terminal icon. Map cursor-agent to the cursor icon while keeping
command matching exact.

Closes #4702

* Verify Cursor terminal profile icons in real browser

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
(cherry picked from commit a43c8d888f)
2026-09-28 23:36:50 +02:00
paseo-bot[bot] 0c18144967 Keep a custom Codex provider and Paseo tools after rewinding
Rewound Codex forks receive the same provider and runtime MCP config as thread start and resume. Covers paginated and legacy rewind paths.\n\nCloses #4542\nCloses #3205\n\nCo-authored-by: zbaibg <zbai29@wisc.edu>

(cherry picked from commit 827178df90)
2026-09-28 23:36:50 +02:00
50 changed files with 1488 additions and 141 deletions
+18
View File
@@ -1,5 +1,23 @@
# 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
### 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
+27 -27
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.10.0",
"version": "0.10.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.10.0",
"version": "0.10.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36410,7 +36410,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.10.0",
"version": "0.10.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37476,12 +37476,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.10.0",
"version": "0.10.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/server": "0.10.0",
"@getpaseo/client": "0.10.1",
"@getpaseo/protocol": "0.10.1",
"@getpaseo/server": "0.10.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37728,10 +37728,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.10.0",
"version": "0.10.1",
"dependencies": {
"@getpaseo/protocol": "0.10.0",
"@getpaseo/relay": "0.10.0",
"@getpaseo/protocol": "0.10.1",
"@getpaseo/relay": "0.10.1",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37742,7 +37742,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.10.0",
"version": "0.10.1",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37986,7 +37986,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.10.0",
"version": "0.10.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38882,7 +38882,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.10.0",
"version": "0.10.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38911,14 +38911,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.10.0",
"version": "0.10.1",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/client": "0.10.1",
"@getpaseo/protocol": "0.10.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38928,8 +38928,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/client": "0.10.1",
"@getpaseo/protocol": "0.10.1",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38951,7 +38951,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.10.0",
"version": "0.10.1",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38978,7 +38978,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.10.0",
"version": "0.10.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39196,16 +39196,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.10.0",
"version": "0.10.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.10.0",
"@getpaseo/highlight": "0.10.0",
"@getpaseo/plugin": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/relay": "0.10.0",
"@getpaseo/client": "0.10.1",
"@getpaseo/highlight": "0.10.1",
"@getpaseo/plugin": "0.10.1",
"@getpaseo/protocol": "0.10.1",
"@getpaseo/relay": "0.10.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -40110,7 +40110,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.10.0",
"version": "0.10.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -7,7 +7,11 @@ import {
expectFileTabOpen,
} from "../support/helpers/file-explorer";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { gotoAppShell } from "../support/helpers/app";
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";
@@ -92,6 +96,40 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
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 ({
page,
}) => {
@@ -250,6 +250,52 @@ test.describe("Tab creation", () => {
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 }) => {
await gotoWorkspace(page, workspace.workspaceId);
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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -210,6 +210,17 @@ describe("stream presentation through installed plugins", () => {
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,
// 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
@@ -112,8 +112,10 @@ export function createStreamPresentation() {
const index = prefix.length + offset;
let blockText = text;
if (offset === textBlocks.length - 1) {
const trailingNewlines = /\n+$/.exec(item.text)?.[0] ?? "";
blockText += trailingNewlines;
// The split drops trailing blank lines, including a line break followed only
// 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 id = `${item.id}:block:${index}`;
+2 -33
View File
@@ -10,6 +10,7 @@ import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { markdownCopyDataSet } from "@/assistant-selection-copy/markup";
import { useAssistantFileLinkResolverContext } from "./provider";
import type { AssistantFileLinkSource } from "./resolver";
import { formatFileLinkTooltipPath } from "./tooltip-path";
import { useFileLink } from "./use-file-link";
interface AssistantMarkdownLinkProps {
@@ -34,7 +35,7 @@ export function AssistantMarkdownLink({
const { configRef } = useAssistantFileLinkResolverContext();
const workspaceRoot = configRef.current.workspaceRoot;
const tooltipPath = useMemo(
() => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
() => (target ? formatFileLinkTooltipPath({ target, workspaceRoot }) : null),
[target, workspaceRoot],
);
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 {
content: string;
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,
DEFAULT_PANE_ID,
findPaneById,
findPaneContainingTab,
useWorkspaceLayoutStore,
type WorkspaceTabPlacement,
} from "@/stores/workspace-layout-store";
@@ -109,6 +110,10 @@ export function openWorkspaceTargetAtLocation(
if (shouldOpenBeside && !targetAlreadyExists) {
const paneId = store.ensureSidePane(input.workspaceKey);
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({
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", () => {
expect(
resolveWorkspaceFilePaths({ path: "~/notes.md", workspaceRoot: "/Users/me/repo" }),
+13 -2
View File
@@ -129,6 +129,17 @@ function startsWithPath(value: string, prefix: string): boolean {
: 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 {
/** Absolute path on the host, suitable for opening in an editor / file manager. */
absolutePath: string;
@@ -159,7 +170,7 @@ export function resolveWorkspaceFilePaths(input: {
if (pathsEqual(normalizedFile, workspaceRoot)) {
return null;
}
const prefix = `${workspaceRoot}/`;
const prefix = pathWithinRoot(workspaceRoot, "");
const relativePath = startsWithPath(normalizedFile, prefix)
? normalizedFile.slice(prefix.length)
: null;
@@ -174,5 +185,5 @@ export function resolveWorkspaceFilePaths(input: {
if (!relativePath) {
return null;
}
return { absolutePath: `${workspaceRoot}/${relativePath}`, relativePath };
return { absolutePath: pathWithinRoot(workspaceRoot, relativePath), relativePath };
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.10.0",
"version": "0.10.1",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/server": "0.10.0",
"@getpaseo/client": "0.10.1",
"@getpaseo/protocol": "0.10.1",
"@getpaseo/server": "0.10.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+7 -2
View File
@@ -82,10 +82,15 @@ const EXPECTED_CLAUDE_MODELS = [
model: "Opus 4.8",
descriptionFragment: "Previous release",
},
{
id: "claude-sonnet-5-5",
model: "Sonnet 5.5",
descriptionFragment: "Best for everyday tasks",
},
{
id: "claude-sonnet-5",
model: "Sonnet 5",
descriptionFragment: "Best for everyday tasks",
descriptionFragment: "Previous release",
},
{
id: "claude-opus-4-7[1m]",
@@ -430,7 +435,7 @@ try {
"--quiet should print the same ordered model IDs returned by --json",
);
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",
);
console.log("✓ provider models --quiet outputs model IDs only\n");
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.10.0",
"version": "0.10.1",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.10.0",
"@getpaseo/relay": "0.10.0",
"@getpaseo/protocol": "0.10.1",
"@getpaseo/relay": "0.10.1",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.10.0",
"version": "0.10.1",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.10.0",
"version": "0.10.1",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.10.0",
"version": "0.10.1",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -65,8 +65,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/client": "0.10.1",
"@getpaseo/protocol": "0.10.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -76,8 +76,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/client": "0.10.1",
"@getpaseo/protocol": "0.10.1",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.10.0",
"version": "0.10.1",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
@@ -196,11 +196,13 @@ describe("guessTerminalProfileIcon", () => {
["/usr/local/bin/gemini", "gemini"],
["agy", "agy"],
["/usr/local/bin/agy", "agy"],
["cursor-agent", "cursor"],
["/usr/local/bin/cursor-agent", "cursor"],
])("guesses %s -> %s", (command, 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",
(command) => {
expect(guessTerminalProfileIcon(command)).toBeUndefined();
@@ -219,6 +221,26 @@ describe("getTerminalProfileIcon", () => {
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", () => {
const profile = { id: "1", name: "Foo", command: "zsh" };
expect(getTerminalProfileIcon(profile)).toBeUndefined();
+8 -1
View File
@@ -105,7 +105,14 @@ export function resolveTerminalProfileLaunch(
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 {
const lastSlash = command.lastIndexOf("/");
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.10.0",
"version": "0.10.1",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.10.0",
"version": "0.10.1",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.10.0",
"@getpaseo/highlight": "0.10.0",
"@getpaseo/plugin": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/relay": "0.10.0",
"@getpaseo/client": "0.10.1",
"@getpaseo/highlight": "0.10.1",
"@getpaseo/plugin": "0.10.1",
"@getpaseo/protocol": "0.10.1",
"@getpaseo/relay": "0.10.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@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");
});
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 () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-config-mutation-order-"));
class ConfigMutationSession extends TestAgentSession {
@@ -1943,6 +1943,7 @@ export class AgentManager {
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
}
this.refreshSessionPersistence(agent);
this.touchUpdatedAt(agent);
this.emitState(agent);
}
@@ -3704,18 +3704,29 @@ class HeldTurnAgentSession implements AgentSession {
readonly prompts: string[] = [];
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private activeTurnId: string | null = null;
private turnStartGate: Promise<void> | null = null;
constructor(
readonly provider: AgentProvider,
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> {
return { sessionId: this.id, finalText: "", timeline: [] };
}
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
this.prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt));
await this.turnStartGate;
const turnId = randomUUID();
this.activeTurnId = turnId;
setTimeout(() => {
@@ -4085,6 +4096,127 @@ describe("send_agent_prompt MCP tool", () => {
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", () => {
@@ -60,6 +60,7 @@ const mockState = vi.hoisted(() => {
isCommandAvailable: vi.fn(async (_command: string) => false),
runtimeModels: new Map<string, AgentModelDefinition[]>(),
cursorListFeaturesConfigs: [] as AgentSessionConfig[],
codexNativeArchiveCalls: [] as Array<{ state: "archive" | "restore"; handle: unknown }>,
reset() {
this.constructorArgs.claude = [];
this.constructorArgs.codex = [];
@@ -73,6 +74,7 @@ const mockState = vi.hoisted(() => {
this.isCommandAvailable.mockImplementation(async (_command: string) => false);
this.runtimeModels.clear();
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> {
const command: { mode?: string; argv?: string[] } | undefined =
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");
});
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 () => {
const omp = new FakeOmp(["custom-omp"]);
const registry = buildProviderRegistry(logger, {
@@ -487,6 +487,8 @@ function wrapClientProvider(
const listImportableSessions = inner.listImportableSessions?.bind(inner);
const importSession = inner.importSession?.bind(inner);
const listFeatures = inner.listFeatures?.bind(inner);
const archiveNativeSession = inner.archiveNativeSession?.bind(inner);
const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner);
return {
provider,
@@ -575,6 +577,12 @@ function wrapClientProvider(
};
}
: 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),
isAvailable: (signal, options) => inner.isAvailable(signal, options),
getDiagnostic: inner.getDiagnostic?.bind(inner),
@@ -496,7 +496,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
const client = new ClaudeAgentClient({
logger,
resolveBinary: async () => "/test/claude/bin",
resolveVersion: async () => "2.1.219",
resolveVersion: async () => "2.1.284",
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
});
const { models } = await client.fetchCatalog({
@@ -515,6 +515,8 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode");
expect(getThinkingIds("claude-sonnet-5")).toContain("xhigh");
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")).toContain("ultracode");
expect(getThinkingIds("claude-sonnet-4-6")).not.toContain("ultracode");
@@ -1238,6 +1240,26 @@ describe("ClaudeAgentSession features", () => {
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 () => {
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
@@ -93,10 +93,19 @@ export const CLAUDE_MODEL_MANIFEST = [
supportsThinkingDisabled: 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",
label: "Sonnet 5",
description: "Sonnet 5 · Best for everyday tasks",
description: "Sonnet 5 · Previous release",
contextWindowMaxTokens: 200_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsThinkingDisabled: true,
@@ -39,7 +39,7 @@ async function createClaudeConfigDirWithRawSettings(settings: string): Promise<s
return configDir;
}
function createCatalogClient(claudeCodeVersion = "2.1.280"): ClaudeAgentClient {
function createCatalogClient(claudeCodeVersion = "2.1.284"): ClaudeAgentClient {
return new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => claudeCodeVersion,
@@ -57,6 +57,7 @@ describe("getClaudeModels", () => {
"claude-fable-5[1m]",
"claude-opus-4-8[1m]",
"claude-opus-4-8",
"claude-sonnet-5-5",
"claude-sonnet-5",
"claude-sonnet-5[1m]",
"claude-opus-4-7[1m]",
@@ -90,6 +91,7 @@ describe("getClaudeModels", () => {
["claude-fable-5[1m]", 1_000_000],
["claude-opus-4-8[1m]", 1_000_000],
["claude-opus-4-8", 200_000],
["claude-sonnet-5-5", 1_000_000],
["claude-sonnet-5", 200_000],
["claude-sonnet-5[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.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.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", () => {
@@ -178,6 +183,8 @@ describe("getClaudeModels", () => {
it.each([
["claude-opus-5-5", 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-20260724", true, "high"],
["claude-sonnet-5", true, "high"],
@@ -467,6 +474,16 @@ describe("normalizeClaudeRuntimeModelId", () => {
"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", () => {
@@ -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", () => {
it("keeps fast mode strict to first-party manifest model IDs", () => {
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", () => {
expect(claudeManifestModelSupportsFastMode("claude-opus-5-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-fable-5")).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 {
const threadStart = records.find((record) => record.method === "thread/start");
const params = threadStart?.params as Record<string, unknown> | undefined;
@@ -1833,6 +1938,79 @@ describe("Codex app-server provider", () => {
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 () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
@@ -1989,7 +2167,9 @@ describe("Codex app-server provider", () => {
};
try {
await expect(listCodexSkills(cwd, workspaceGitService)).resolves.toContainEqual({
await expect(
listCodexSkills(cwd, path.join(tempDir, "codex-home"), workspaceGitService),
).resolves.toContainEqual({
name: "shipper",
description: "Ship changes carefully.",
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 () => {
const commands = await listCommandsFromFakeCodex([
{
@@ -260,6 +260,8 @@ interface CodexAppServerAgentDeps {
extends: string;
};
customCodexConfig?: CodexCustomProviderConfig | null;
// The CODEX_HOME the session's app-server runs with; prompts and skills are read from it.
codexHome?: string;
_createCodexClient?: (
child: ChildProcessWithoutNullStreams,
logger: Logger,
@@ -545,8 +547,8 @@ async function checkCodexLaunchAvailable(launch: ResolvedProviderLaunch) {
});
}
function resolveCodexHomeDir(): string {
return process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
function resolveCodexHomeDir(env: NodeJS.ProcessEnv): string {
return env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
}
function decodeEscapedChar(next: string): string {
@@ -656,8 +658,7 @@ function parseFrontMatter(markdown: string): {
return { frontMatter, body };
}
async function listCodexCustomPrompts(): Promise<AgentSlashCommand[]> {
const codexHome = resolveCodexHomeDir();
async function listCodexCustomPrompts(codexHome: string): Promise<AgentSlashCommand[]> {
const promptsDir = path.join(codexHome, "prompts");
let entries: Dirent[];
try {
@@ -699,6 +700,7 @@ async function listCodexCustomPrompts(): Promise<AgentSlashCommand[]> {
export async function listCodexSkills(
cwd: string,
codexHome: string,
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">,
): Promise<AgentSlashCommand[]> {
const candidates: string[] = [];
@@ -712,7 +714,7 @@ export async function listCodexSkills(
candidates.push(path.join(repoRoot, ".codex", "skills"));
}
candidates.push(path.join(resolveCodexHomeDir(), "skills"));
candidates.push(path.join(codexHome, "skills"));
const candidateReads = await Promise.all(
candidates.map(async (dir) => {
@@ -3354,6 +3356,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private readonly logger: Logger;
private readonly config: AgentSessionConfig;
private readonly asyncQuestions: CodexAsyncQuestions;
private readonly codexHome: string;
private currentMode: string;
private hasWorkflowModeOverride: boolean;
private readonly providerOptions: CodexProviderOptions;
@@ -3467,6 +3470,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.providerOptions = CodexProviderOptionsSchema.parse(config.providerOptions ?? {});
this.config = config;
this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions);
this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env);
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
if (this.config.featureValues?.fast_mode && codexModelSupportsFastMode(this.config.model)) {
this.serviceTier = "fast";
@@ -4037,8 +4041,7 @@ export class CodexAppServerAgentSession implements AgentSession {
): Promise<CodexPromptInput> {
if (commandName.startsWith("prompts:")) {
const promptName = commandName.slice("prompts:".length);
const codexHome = resolveCodexHomeDir();
const promptPath = path.join(codexHome, "prompts", `${promptName}.md`);
const promptPath = path.join(this.codexHome, "prompts", `${promptName}.md`);
const raw = await fs.readFile(promptPath, "utf8");
const parsed = parseFrontMatter(raw);
return expandCodexCustomPrompt(parsed.body, args);
@@ -4834,6 +4837,7 @@ export class CodexAppServerAgentSession implements AgentSession {
cwd: this.config.cwd ?? null,
model: this.config.model ?? null,
serviceTier: this.serviceTier,
config: this.buildCodexInnerConfig(),
userMessageTurns: this.codexUserMessageTurns(),
setThreadId: async (threadId) => {
this.currentThreadId = threadId;
@@ -4941,7 +4945,7 @@ export class CodexAppServerAgentSession implements AgentSession {
}
async listCommands(): Promise<AgentSlashCommand[]> {
const prompts = await listCodexCustomPrompts();
const prompts = await listCodexCustomPrompts(this.codexHome);
if (this.connectionState === "disconnected") {
await this.connect();
} else {
@@ -4955,7 +4959,7 @@ export class CodexAppServerAgentSession implements AgentSession {
}));
const fallbackSkills =
this.cachedSkills === null
? await listCodexSkills(this.config.cwd, this.deps.workspaceGitService)
? await listCodexSkills(this.config.cwd, this.codexHome, this.deps.workspaceGitService)
: [];
const builtin: AgentSlashCommand[] = [
{
@@ -7024,9 +7028,10 @@ export class CodexAppServerAgentClient implements AgentClient {
private readonly deps: CodexAppServerAgentDeps = {},
) {}
private sessionDeps(): CodexAppServerAgentDeps {
private sessionDeps(launchEnv: Record<string, string> | undefined): CodexAppServerAgentDeps {
return {
...this.deps,
codexHome: resolveCodexHomeDir(buildCodexAppServerEnv(this.runtimeSettings, launchEnv)),
customCodexConfig: this.customProviderConfig(),
};
}
@@ -7138,7 +7143,7 @@ export class CodexAppServerAgentClient implements AgentClient {
this.logger,
() =>
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
this.sessionDeps(),
this.sessionDeps(launchContext?.env),
options?.persistSession === false,
goalsEnabled,
autoReviewEnabled,
@@ -7169,7 +7174,7 @@ export class CodexAppServerAgentClient implements AgentClient {
this.logger,
() =>
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
this.sessionDeps(),
this.sessionDeps(launchContext?.env),
false,
goalsEnabled,
autoReviewEnabled,
@@ -67,6 +67,7 @@ export async function revertCodexConversation(input: {
cwd?: string | null;
model?: string | null;
serviceTier?: string | null;
config?: Record<string, unknown> | null;
userMessageTurns: CodexUserMessageTurnIndex;
setThreadId: (threadId: string) => 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`);
}
// 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);
if (historyMode === "paginated") {
if (!targetTurn.turnId) {
throw new Error(`Codex could not find the turn containing user message ${input.messageId}`);
}
const forked = await forkCodexThread(input.client, {
threadId: input.threadId,
...forkParams,
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);
return;
@@ -105,14 +113,7 @@ export async function revertCodexConversation(input: {
// 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.
const forked = await forkCodexThread(input.client, {
threadId: input.threadId,
cwd: input.cwd ?? null,
model: input.model ?? null,
serviceTier: input.serviceTier ?? null,
excludeTurns: false,
persistExtendedHistory: true,
});
const forked = await forkCodexThread(input.client, forkParams);
const forkedThreadId = forked.thread.id;
// 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,
});
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 () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
@@ -3549,16 +3549,37 @@ class OpenCodeAgentSession implements AgentSession {
sessionId: this.sessionId,
model: this.config.model ?? null,
modeId: this.currentMode,
thinkingOptionId: this.config.thinkingOptionId ?? null,
};
}
async setModel(modelId: string | null): Promise<void> {
await this.reconnectIfServerExited();
const normalizedModelId =
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.thinkingOptionId = variant;
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
this.config.model,
);
this.notifySubscribers({
type: "thinking_option_changed",
provider: "opencode",
thinkingOptionId: variant ?? null,
});
}
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 { createTestLogger } from "../../../../../test-utils/test-logger.js";
@@ -7,6 +7,89 @@ import { OpenCodeV2AgentClient } from "./agent.js";
import { V2Harness } from "../test-utils/v2-harness.js";
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 () => {
const first = new V2Harness();
const second = new V2Harness();
@@ -276,25 +276,48 @@ export class OpenCodeV2Session implements AgentSession {
}
async setModel(model: string | null) {
await this.reconnectIfExited();
const selected = model
? modelRef(model, this.config.thinkingOptionId)
: (await this.client.model.default({ location: { directory: this.config.cwd } })).data;
const location = { directory: this.config.cwd };
const selected = model ? modelRef(model) : (await this.client.model.default({ location })).data;
if (!selected) throw new Error("OpenCode has no default model");
await this.client.session.switchModel({
sessionID: this.id,
model: {
id: selected.id,
providerID: selected.providerID,
variant: this.config.thinkingOptionId,
},
});
const catalog = await this.client.model.list({ location });
const target = catalog.data.find(
(entry) =>
entry.enabled && entry.providerID === selected.providerID && entry.id === selected.id,
);
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.thinkingOptionId = variant;
this.emit({
type: "thinking_option_changed",
provider: "opencode",
thinkingOptionId: variant ?? null,
});
this.emit({
type: "model_changed",
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) {
await this.reconnectIfExited();
const model = this.info.model;
@@ -61,7 +61,11 @@ import {
toScheduleSummary,
waitForAgentWithTimeout,
} 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 {
archiveAgentCommand,
@@ -1924,7 +1928,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
return true;
}
await sendPromptToAgent({
const { disposition } = await sendPromptToAgent({
agentManager,
agentStorage,
agentId,
@@ -1963,8 +1967,10 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
const notifying = armFinishNotification();
// Return immediately if background=true
// Re-fetch snapshot since the state may have changed
// Return once the provider has accepted the turn, so the status reports it running.
if (disposition === "turn_started") {
await waitForAgentRunStartWithTimeout(agentManager, agentId);
}
const currentSnapshot = agentManager.getAgent(agentId);
const responseData = {
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"type": "module",
"scripts": {