Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] e66773b7d0 fix(desktop): find Zed installed as a Flatpak on Linux
Flatpak puts app launchers in its exports/bin directories but does not
add them to PATH, so Zed installed from Flathub never appeared in the
Open in editor list. Search the user and system Flatpak export
directories after PATH on Linux, and look for Zed's Flathub launcher.

Closes #5458
2026-09-29 23:23:14 +02:00
paseo-bot[bot]andBae Seongmin 767b0e970b Rewind legacy Codex threads on Codex versions without thread/rollback (#5711)
Codex 0.156 removed thread/rollback. Rewinding a legacy-history thread
forked it and then called rollback on the fork, so the rewind failed and
left an unused fork behind. Read the Codex version from the initialize
userAgent and, when rollback is gone, fork before the target turn the
same way paginated threads already do. Older servers keep fork plus
rollback.

Closes #5616

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com>
2026-09-29 22:59:00 +02:00
paseo-bot[bot]andMatt Cowger d3c902e9fa fix(opencode): fill the context meter during v2 turns (#5710)
OpenCode v2 sessions never reported context used or the model's context
limit, so the composer meter was an empty ring during a turn and vanished
when it ended. Each finished step now reports its context against the
selected model's limit.

Closes #5701

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Matt Cowger <matt@cowger.us>
2026-09-29 22:10:27 +02:00
Mohamed Boudra 60aff2346c Support catalog-driven Codex Fast and Ultrafast speeds (#5708)
* feat(codex): discover Fast and Ultrafast speeds from the catalog

Replace the model allowlist with advertised service tiers so new models and speeds need no Paseo catalog update. Normal explicitly overrides the thread tier. Desktop icon-only triggers are provider metadata; mobile selectors remain labeled.

* chore: remove QA screenshots from the change
2026-09-29 20:19:59 +01:00
paseo-bot[bot] 5de45e2086 fix(desktop): capture the annotated element when the app is zoomed (#5703)
The browser pane measures the selected element in page CSS pixels, but
capturePage takes device-independent pixels. Paseo's zoom carries into the
in-app browser, so at any zoom other than 100% the screenshot attached to
an annotation was offset and undersized. Scale the region by the guest's
zoom factor before capturing.

Closes #5690

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 20:08:05 +02:00
paseo-bot[bot] 5764863f63 fix(opencode): show v2 patch edits as edit diffs (#5696)
OpenCode 2 reports GPT models' apply_patch tool calls under the name
patch. The OpenCode tool-call parser only recognized apply_patch, so
these calls fell through to an unknown tool card with the raw patch
text. Map patch payloads the same way as apply_patch.

Closes #5643

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 19:15:39 +02:00
Mohamed Boudra 4b45bc9fc1 fix: restore archived workspaces without recorded branches (#5694)
Create a fresh branch from the recorded base when available, or the repository default, so archived agents remain accessible. Cover the server recovery choices and the browser restore-to-agent flow.
2026-09-29 17:25:21 +01:00
paseo-bot[bot] a8f5d969b8 fix(codex): keep the default effort after a daemon restart (#5686)
A Codex agent created without a thinking option ran on the configured
default effort until the daemon restarted. The resumed session never
resolved the defaults, so it showed thinking auto and sent turns with no
effort. Resolve unset model and effort for a resumed thread the same way
a new thread does.

Closes #5673

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 17:53:43 +02:00
paseo-bot[bot] 23c4404b95 fix(codex): keep output schema fields named like schema keywords (#5684)
The Codex output schema normalizer walked every key as if it held a
schema, so a field named "properties" (or a literal default, enum, or
const value shaped like an object schema) was rewritten with invented
required and additionalProperties entries. Codex then rejected the turn
with invalid_json_schema. Walk only the keywords that hold schemas.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 16:49:03 +02:00
38 changed files with 1379 additions and 231 deletions
@@ -0,0 +1,122 @@
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute } from "../support/helpers/mock-agent";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
// This fixture catalog advertises Ultrafast on Sol; another model offers Fast only.
// The real daemon discovers both through the same Codex model/list boundary.
test.use({
e2eDaemonConfig: {
version: 1,
agents: {
providers: {
codex: {
enabled: true,
command: [process.execPath, path.resolve("e2e/fixtures/catalog-codex.mjs")],
},
claude: { enabled: false },
copilot: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
omp: { enabled: false },
},
},
},
});
async function openSpeedSelector(page: Page): Promise<void> {
const speed = page.getByRole("button", { name: /^(Select speed|Speed: .+)$/ });
const features = page.getByRole("button", { name: "Open agent features", exact: true });
const modelSettings = page.getByRole("button", { name: /^Select model \(/ });
await expect(speed.or(features).or(modelSettings).filter({ visible: true }).first()).toBeVisible({
timeout: 30_000,
});
if (!(await speed.isVisible())) {
if (await features.isVisible()) await features.click();
else await modelSettings.click();
}
await expect(speed).toBeVisible({ timeout: 30_000 });
await speed.click();
await expect(page.getByText("Normal", { exact: true }).last()).toBeVisible();
}
async function selectSpeed(page: Page, label: string): Promise<void> {
await page.getByRole("button", { name: label, exact: true }).last().click();
const toolbarTrigger = page.getByRole("button", { name: `Speed: ${label}`, exact: true });
const sheetTrigger = page.getByRole("button", { name: "Select speed", exact: true });
await expect(toolbarTrigger.or(sheetTrigger)).toBeVisible();
if (await toolbarTrigger.isVisible()) await expect(toolbarTrigger).toHaveText("");
else await expect(sheetTrigger).toContainText(label);
}
async function expectSpeedChoices(page: Page, choices: string[]): Promise<void> {
for (const choice of ["Normal", "Fast", "Ultrafast"]) {
const option = page.getByText(choice, { exact: true }).last();
if (choices.includes(choice)) {
await expect(option).toBeVisible();
await expect(option).toBeInViewport({ ratio: 1 });
} else await expect(option).not.toBeVisible();
}
}
async function switchAgentModel(agentId: string, modelId: string): Promise<void> {
const client = await connectDaemonClient<
Pick<DaemonClient, "setAgentModel" | "close" | "connect">
>({
clientIdPrefix: "codex-speed-model",
});
try {
await client.setAgentModel(agentId, modelId);
} finally {
await client.close();
}
}
for (const viewport of [
{ name: "desktop", width: 1280, height: 850 },
{ name: "compact", width: 390, height: 844 },
]) {
test(`Codex catalog drives speed choices on ${viewport.name}`, async ({ page }, testInfo) => {
test.setTimeout(180_000);
await page.setViewportSize(viewport);
const workspace = await seedWorkspace({ repoPrefix: "codex-speed-" });
try {
const agent = await workspace.client.createAgent({
provider: "codex",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Speed settings",
model: "gpt-6.1-sol",
modeId: "full-access",
initialPrompt: "Get ready to work on this project.",
});
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
if (viewport.name === "desktop") {
const trigger = page.getByRole("button", { name: "Speed: Normal", exact: true });
await expect(trigger).toBeVisible({ timeout: 30_000 });
await expect(trigger).toHaveText("");
await page.screenshot({ path: testInfo.outputPath("desktop-normal-trigger.png") });
}
await openSpeedSelector(page);
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-speed-options.png`) });
await selectSpeed(page, "Ultrafast");
await openSpeedSelector(page);
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
await page.screenshot({
path: testInfo.outputPath(`${viewport.name}-ultrafast-selected.png`),
});
await selectSpeed(page, "Normal");
if (viewport.name === "desktop") {
await switchAgentModel(agent.id, "gpt-6-sol");
await openSpeedSelector(page);
await expectSpeedChoices(page, ["Normal", "Fast"]);
await page.screenshot({ path: testInfo.outputPath("desktop-fast-only.png") });
}
} finally {
await workspace.cleanup();
}
});
}
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
restoreArchivedWorkspace,
@@ -354,6 +355,88 @@ test.describe("Worktree restore", () => {
.toBeNull();
});
test("restores a branchless workspace so its archived agent can be used again", async ({
page,
}) => {
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
const backingWorktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: `branchless-${randomUUID().slice(0, 8)}`,
});
createdProjectIds.add(backingWorktree.projectKey);
createdWorktreeDirectories.add(backingWorktree.workspaceDirectory);
execFileSync("git", ["switch", "--detach", "main"], {
cwd: backingWorktree.workspaceDirectory,
stdio: "pipe",
});
const created = await worktreeClient.createWorkspace({
source: { kind: "directory", path: backingWorktree.workspaceDirectory },
title: "Sample workspace",
});
if (!created.workspace) {
throw new Error(created.error ?? "Could not create the branchless workspace");
}
const workspaceId = created.workspace.id;
const agent = await createIdleAgent(client, {
cwd: backingWorktree.workspaceDirectory,
workspaceId,
title: "Archived agent",
});
await archiveWorkspaceFromDaemon(worktreeClient, backingWorktree.workspaceDirectory, {
scope: "worktree",
});
await expect
.poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).not.toBeNull();
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${agent.id}`).click();
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible();
await expect(
page.getByText(
"Restore Sample workspace to return to its agents. A new branch will start from the saved base or the repository default.",
{ exact: true },
),
).toBeVisible();
await captureBranchlessRestoreStep(page, "01-restore-workspace.png");
await page.getByRole("button", { name: "Restore", exact: true }).click();
await expect
.poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 })
.toBe(true);
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toBeVisible({
timeout: 30_000,
});
await captureBranchlessRestoreStep(page, "02-unarchive-agent.png");
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).toBeNull();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
await captureBranchlessRestoreStep(page, "03-agent-ready.png");
const restoredHead = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: backingWorktree.workspaceDirectory,
encoding: "utf8",
}).trim();
const defaultHead = execFileSync("git", ["rev-parse", "main"], {
cwd: tempRepo.path,
encoding: "utf8",
}).trim();
expect(restoredHead).toBe(defaultHead);
expect(
execFileSync("git", ["branch", "--show-current"], {
cwd: backingWorktree.workspaceDirectory,
encoding: "utf8",
}).trim(),
).toMatch(/^restored\//);
});
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
@@ -422,6 +505,12 @@ test.describe("Worktree restore", () => {
});
});
async function captureBranchlessRestoreStep(page: Page, filename: string): Promise<void> {
const screenshotDirectory = path.join(tmpdir(), "paseo-branchless-restore-evidence");
mkdirSync(screenshotDirectory, { recursive: true });
await page.screenshot({ path: path.join(screenshotDirectory, filename), fullPage: true });
}
function commitRestorableChange(cwd: string): void {
writeFileSync(path.join(cwd, "restored-change.txt"), "preserved after restore\n");
execFileSync("git", ["add", "restored-change.txt"], { cwd, stdio: "pipe" });
@@ -0,0 +1,88 @@
import readline from "node:readline";
import { randomUUID } from "node:crypto";
const fast = { id: "priority", name: "Fast", description: "Faster processing, increased usage" };
const ultra = {
id: "ultrafast",
name: "Ultrafast",
description: "Lowest latency, increased usage",
};
const models = [
{ id: "gpt-6.1-sol", displayName: "GPT-6.1 Sol", isDefault: true, serviceTiers: [fast, ultra] },
{ id: "gpt-6-sol", displayName: "GPT-6 Sol", serviceTiers: [fast] },
].map((model) =>
Object.assign(model, {
model: model.id,
defaultReasoningEffort: "medium",
supportedReasoningEfforts: [{ reasoningEffort: "medium", description: "Medium" }],
}),
);
function send(message) {
process.stdout.write(JSON.stringify(message) + "\n");
}
function respond(method, params) {
switch (method) {
case "initialize":
return {};
case "model/list":
return { data: models };
case "config/read":
return { config: { model: models[0].id, model_reasoning_effort: "medium" } };
case "collaborationMode/list":
return { data: [] };
case "skills/list":
return { data: [] };
case "thread/list":
case "thread/loaded/list":
return { data: [] };
case "thread/start":
return { thread: { id: randomUUID() }, model: params.model };
case "thread/resume":
return { thread: { id: params.threadId } };
case "thread/read":
return { thread: { id: params.threadId, turns: [] } };
case "turn/start": {
const turnId = randomUUID();
setTimeout(() => {
send({
method: "turn/started",
params: { threadId: params.threadId, turn: { id: turnId } },
});
send({
method: "item/completed",
params: {
threadId: params.threadId,
turnId,
item: {
id: randomUUID(),
type: "agentMessage",
text: "Ready to work on this project.",
},
},
});
send({
method: "turn/completed",
params: { threadId: params.threadId, turn: { id: turnId, status: "completed" } },
});
}, 50);
return { turn: { id: turnId } };
}
default:
throw new Error(`Unsupported Codex fixture request: ${method}`);
}
}
if (process.argv.includes("--version")) {
console.log("codex-cli 0.159.0");
} else {
readline.createInterface({ input: process.stdin }).on("line", (line) => {
const request = JSON.parse(line);
if (request.id === undefined) return;
try {
send({ id: request.id, result: respond(request.method, request.params ?? {}) });
} catch (error) {
send({ id: request.id, error: { code: -32601, message: error.message } });
}
});
}
@@ -1357,6 +1357,10 @@ function DesktopFeatureItem({
if (feature.type === "select") {
const FeatureIcon = getAgentFeatureIcon(feature.icon);
const selectedOption = feature.options.find((o) => o.id === feature.value);
const iconOnly = feature.desktopTrigger === "icon";
const tooltip = iconOnly
? `${feature.label}: ${selectedOption?.label ?? feature.label}`
: getFeatureTooltip(feature);
return (
<>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -1367,15 +1371,16 @@ function DesktopFeatureItem({
surface="toolbar"
label={feature.label}
value={selectedOption?.label ?? feature.label}
showToolbarLabel={!iconOnly}
open={openSelector === featureSelector}
disabled={disabled}
onPress={handleSelectPress}
accessibilityLabel={getFeatureTooltip(feature)}
accessibilityLabel={tooltip}
testID={`agent-feature-${feature.id}`}
/>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</Tooltip>
<Combobox
+3 -1
View File
@@ -439,7 +439,9 @@ export const ar: TranslationResources = {
recovery: {
archivedTitle: "مساحة العمل مؤرشفة",
restoreDescription:
"تمت أرشفة {{workspaceName}} وإزالة شجرة العمل الخاصة بها. استعد الفرع {{branch}} لفتحها مجددًا.",
"استعد {{workspaceName}} للعودة إلى وكلائها. ستستخدم شجرة العمل الفرع {{branch}}.",
restoreWithoutBranchDescription:
"استعد {{workspaceName}} للعودة إلى وكلائها. سيبدأ فرع جديد من الفرع الأساسي المحفوظ أو الفرع الافتراضي للمستودع.",
unarchiveDescription: "{{workspaceName}} مؤرشفة. ألغِ أرشفتها لفتحها مجددًا.",
restoreAction: "استعادة",
unarchiveAction: "إلغاء الأرشفة",
+3 -1
View File
@@ -436,7 +436,9 @@ export const en = {
recovery: {
archivedTitle: "Workspace archived",
restoreDescription:
"{{workspaceName}} was archived and its worktree was removed. Restore branch {{branch}} to open it again.",
"Restore {{workspaceName}} to return to its agents. Its worktree will use branch {{branch}}.",
restoreWithoutBranchDescription:
"Restore {{workspaceName}} to return to its agents. A new branch will start from the saved base or the repository default.",
unarchiveDescription: "{{workspaceName}} is archived. Unarchive it to open it again.",
restoreAction: "Restore",
unarchiveAction: "Unarchive",
+3 -1
View File
@@ -444,7 +444,9 @@ export const es: TranslationResources = {
recovery: {
archivedTitle: "Espacio de trabajo archivado",
restoreDescription:
"{{workspaceName}} se archivó y se eliminó su worktree. Restaura la rama {{branch}} para volver a abrirlo.",
"Restaura {{workspaceName}} para volver a sus agentes. Su worktree usará la rama {{branch}}.",
restoreWithoutBranchDescription:
"Restaura {{workspaceName}} para volver a sus agentes. Se creará una rama nueva desde la base guardada o la rama predeterminada del repositorio.",
unarchiveDescription:
"{{workspaceName}} está archivado. Desarchívalo para volver a abrirlo.",
restoreAction: "Restaurar",
+3 -1
View File
@@ -445,7 +445,9 @@ export const fr: TranslationResources = {
recovery: {
archivedTitle: "Espace de travail archivé",
restoreDescription:
"{{workspaceName}} a été archivé et son worktree supprimé. Restaurez la branche {{branch}} pour le rouvrir.",
"Restaurez {{workspaceName}} pour retrouver ses agents. Son worktree utilisera la branche {{branch}}.",
restoreWithoutBranchDescription:
"Restaurez {{workspaceName}} pour retrouver ses agents. Une nouvelle branche partira de la base enregistrée ou de la branche par défaut du dépôt.",
unarchiveDescription: "{{workspaceName}} est archivé. Désarchivez-le pour le rouvrir.",
restoreAction: "Restaurer",
unarchiveAction: "Désarchiver",
+3 -1
View File
@@ -444,7 +444,9 @@ export const ja: TranslationResources = {
recovery: {
archivedTitle: "ワークスペースはアーカイブ済みです",
restoreDescription:
"{{workspaceName}} はアーカイブされ、worktree が削除されました。ブランチ {{branch}} を復元して再度開きます。",
"{{workspaceName}} を復元してエージェントに戻ります。worktree ではブランチ {{branch}} を使用します。",
restoreWithoutBranchDescription:
"{{workspaceName}} を復元してエージェントに戻ります。保存されたベース、またはリポジトリのデフォルトブランチから新しいブランチを作成します。",
unarchiveDescription:
"{{workspaceName}} はアーカイブされています。再度開くにはアーカイブを解除してください。",
restoreAction: "復元",
+3 -1
View File
@@ -440,7 +440,9 @@ export const ko: TranslationResources = {
recovery: {
archivedTitle: "워크스페이스가 보관되었습니다",
restoreDescription:
"{{workspaceName}}가 보관되고 워크트리가 제거되었습니다. 다시 열려면 {{branch}} 브랜치를 복원하세요.",
"{{workspaceName}}를 복원하여 에이전트로 돌아갑니다. 워크트리는 {{branch}} 브랜치를 사용합니다.",
restoreWithoutBranchDescription:
"{{workspaceName}}를 복원하여 에이전트로 돌아갑니다. 저장된 기반 브랜치 또는 저장소 기본 브랜치에서 새 브랜치를 만듭니다.",
unarchiveDescription: "{{workspaceName}}가 보관되었습니다. 다시 열려면 보관을 취소하세요.",
restoreAction: "복원",
unarchiveAction: "보관 취소",
+3 -1
View File
@@ -443,7 +443,9 @@ export const ptBR: TranslationResources = {
recovery: {
archivedTitle: "Workspace arquivado",
restoreDescription:
"{{workspaceName}} foi arquivado e sua worktree foi removida. Restaure a branch {{branch}} para abri-lo novamente.",
"Restaure {{workspaceName}} para voltar aos seus agentes. A worktree usará a branch {{branch}}.",
restoreWithoutBranchDescription:
"Restaure {{workspaceName}} para voltar aos seus agentes. Uma nova branch partirá da base salva ou da branch padrão do repositório.",
unarchiveDescription:
"{{workspaceName}} está arquivado. Desarquive-o para abri-lo novamente.",
restoreAction: "Restaurar",
+3 -1
View File
@@ -443,7 +443,9 @@ export const ru: TranslationResources = {
recovery: {
archivedTitle: "Рабочая область в архиве",
restoreDescription:
"Рабочее пространство «{{workspaceName}}» было архивировано, а его worktree удалён. Восстановите ветку {{branch}}, чтобы снова открыть рабочее пространство.",
"Восстановите «{{workspaceName}}», чтобы вернуться к его агентам. Worktree будет использовать ветку {{branch}}.",
restoreWithoutBranchDescription:
"Восстановите «{{workspaceName}}», чтобы вернуться к его агентам. Новая ветка будет создана от сохранённой базовой или ветки репозитория по умолчанию.",
unarchiveDescription:
"Рабочее пространство «{{workspaceName}}» находится в архиве. Разархивируйте его, чтобы снова открыть.",
restoreAction: "Восстановить",
+3 -1
View File
@@ -439,7 +439,9 @@ export const zhCN: TranslationResources = {
recovery: {
archivedTitle: "Workspace 已归档",
restoreDescription:
"{{workspaceName}} 已归档,其 worktree 已移除。恢复分支 {{branch}} 以重新打开。",
"恢复 {{workspaceName}} 以返回其中的代理。worktree 将使用分支 {{branch}}。",
restoreWithoutBranchDescription:
"恢复 {{workspaceName}} 以返回其中的代理。新分支将从保存的基准分支或仓库默认分支创建。",
unarchiveDescription: "{{workspaceName}} 已归档。取消归档以重新打开。",
restoreAction: "恢复",
unarchiveAction: "取消归档",
@@ -126,15 +126,21 @@ function ArchivedWorkspaceRecovery({
if (recovery.phase === "failed") {
actionLabel = t("common.actions.retry");
}
const description =
recovery.recovery.action === "restore"
? t("workspace.route.recovery.restoreDescription", {
workspaceName: recovery.recovery.workspaceName,
branch: recovery.recovery.branch,
})
: t("workspace.route.recovery.unarchiveDescription", {
workspaceName: recovery.recovery.workspaceName,
});
let description: string;
if (recovery.recovery.action === "restore" && recovery.recovery.branch) {
description = t("workspace.route.recovery.restoreDescription", {
workspaceName: recovery.recovery.workspaceName,
branch: recovery.recovery.branch,
});
} else if (recovery.recovery.action === "restore") {
description = t("workspace.route.recovery.restoreWithoutBranchDescription", {
workspaceName: recovery.recovery.workspaceName,
});
} else {
description = t("workspace.route.recovery.unarchiveDescription", {
workspaceName: recovery.recovery.workspaceName,
});
}
return (
<View style={styles.emptyState}>
@@ -29,7 +29,7 @@ function harness(guest: BrowserCaptureGuest | null = null) {
describe("browser capture service", () => {
it("validates and rounds guest-relative bounds before capture", async () => {
const capturePage = vi.fn(async () => image());
const { service } = harness({ isDestroyed: () => false, capturePage });
const { service } = harness({ isDestroyed: () => false, getZoomFactor: () => 1, capturePage });
await expect(
service.capture({
@@ -41,9 +41,25 @@ describe("browser capture service", () => {
expect(capturePage).toHaveBeenCalledWith({ x: 0, y: 9, width: 20, height: 11 });
});
it("captures the selected element when the page is zoomed", async () => {
const capturePage = vi.fn(async () => image());
const { service } = harness({
isDestroyed: () => false,
getZoomFactor: () => 1.2,
capturePage,
});
await service.capture({
browserId: "browser-1",
hostWebContentsId: 42,
rect: { x: 300, y: 200, width: 120, height: 60 },
});
expect(capturePage).toHaveBeenCalledWith({ x: 360, y: 240, width: 144, height: 72 });
});
it("rejects invalid or unavailable captures without touching the guest", async () => {
const capturePage = vi.fn(async () => image());
const { service } = harness({ isDestroyed: () => false, capturePage });
const { service } = harness({ isDestroyed: () => false, getZoomFactor: () => 1, capturePage });
await expect(
service.capture({ browserId: "browser-1", hostWebContentsId: 42, rect: { width: 0 } }),
@@ -5,6 +5,7 @@ export interface BrowserCaptureImage {
export interface BrowserCaptureGuest<TImage extends BrowserCaptureImage = BrowserCaptureImage> {
isDestroyed(): boolean;
getZoomFactor(): number;
capturePage(rect: BrowserCaptureRect): Promise<TImage>;
}
@@ -40,7 +41,9 @@ export interface BrowserCaptureService {
copy(payload: unknown): Promise<boolean>;
}
function captureRect(value: unknown): BrowserCaptureRect | null {
// The renderer measures the element in page CSS pixels, while capturePage
// takes device-independent pixels, so the page zoom scales the region.
function captureRect(value: unknown, zoomFactor: number): BrowserCaptureRect | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const coordinates = [record.x, record.y, record.width, record.height];
@@ -51,7 +54,9 @@ function captureRect(value: unknown): BrowserCaptureRect | null {
) {
return null;
}
const [x, y, width, height] = coordinates as number[];
const [x, y, width, height] = (coordinates as number[]).map(
(coordinate) => coordinate * zoomFactor,
);
if (width <= 0 || height <= 0) return null;
return {
x: Math.max(0, Math.round(x)),
@@ -80,8 +85,9 @@ export function createBrowserCaptureService<TImage extends BrowserCaptureImage>(
async capture({ browserId, hostWebContentsId, rect }) {
if (typeof browserId !== "string" || browserId.trim().length === 0) return null;
const guest = dependencies.findGuest(browserId, hostWebContentsId);
const bounds = captureRect(rect);
if (!guest || guest.isDestroyed() || !bounds) return null;
if (!guest || guest.isDestroyed()) return null;
const bounds = captureRect(rect, guest.getZoomFactor());
if (!bounds) return null;
try {
const image = await guest.capturePage(bounds);
return image.isEmpty() ? null : image.toDataURL();
@@ -1,7 +1,9 @@
import type { SpawnOptions } from "node:child_process";
import { describe, expect, it } from "vitest";
import { listAvailableEditorTargets, openEditorTarget } from "./registry.js";
import { createEditorTargetRuntime } from "./runtime.js";
import { zedTarget } from "./targets/zed.js";
interface SpawnRecord {
command: string;
@@ -10,6 +12,23 @@ interface SpawnRecord {
unrefed: boolean;
}
function recordSpawns(records: SpawnRecord[]) {
return (command: string, args: string[], options: SpawnOptions) => {
const record = { command, args, options, unrefed: false };
records.push(record);
const child = {
once(event: "error" | "spawn", handler: (error?: Error) => void) {
if (event === "spawn") queueMicrotask(() => handler());
return child;
},
unref() {
record.unrefed = true;
},
};
return child;
};
}
describe("editor target runtime", () => {
it("resolves command aliases and safely launches Windows command scripts", async () => {
const records: SpawnRecord[] = [];
@@ -20,20 +39,7 @@ describe("editor target runtime", () => {
ELECTRON_RUN_AS_NODE: "1",
},
pathExists: (targetPath) => targetPath === "C:/Program Files/Editors & Tools/bin/code.cmd",
spawn: (command, args, options) => {
const record = { command, args, options, unrefed: false };
records.push(record);
const child = {
once(event: "error" | "spawn", handler: (error?: Error) => void) {
if (event === "spawn") queueMicrotask(() => handler());
return child;
},
unref() {
record.unrefed = true;
},
};
return child;
},
spawn: recordSpawns(records),
});
const command = runtime.resolveCommand(["missing", "code"]);
@@ -57,4 +63,32 @@ describe("editor target runtime", () => {
},
]);
});
it.each([
["system", "/var/lib/flatpak/exports/bin/dev.zed.Zed"],
["user", "/home/dev/.local/share/flatpak/exports/bin/dev.zed.Zed"],
])("finds and opens Zed from a %s Flatpak install on Linux", async (_scope, flatpakExport) => {
const records: SpawnRecord[] = [];
const existingPaths = new Set([flatpakExport, "/repo", "/repo/src/app.ts"]);
const runtime = createEditorTargetRuntime({
platform: "linux",
env: { PATH: "/usr/local/bin:/usr/bin:/bin" },
homeDirectory: "/home/dev",
pathExists: (targetPath) => existingPaths.has(targetPath),
spawn: recordSpawns(records),
loadIcon: async (fileName) => ({ kind: "image", dataUrl: fileName }),
});
const targets = await listAvailableEditorTargets(runtime, [zedTarget]);
expect(targets.map((target) => target.id)).toEqual(["zed"]);
await openEditorTarget(
{ editorId: "zed", workspacePath: "/repo", filePath: "/repo/src/app.ts", line: 3 },
runtime,
[zedTarget],
);
expect(records.map(({ command, args }) => ({ command, args }))).toEqual([
{ command: flatpakExport, args: ["/repo", "/repo/src/app.ts:3"] },
]);
});
});
@@ -56,10 +56,28 @@ function isAbsolutePath(value: string, platform: NodeJS.Platform): boolean {
return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value);
}
// Flatpak exports app launchers here, but does not put these directories on PATH.
function flatpakExportDirectories(env: NodeJS.ProcessEnv, homeDirectory: string): string[] {
const userDataDirectory = env.XDG_DATA_HOME || `${homeDirectory}/.local/share`;
return [`${userDataDirectory}/flatpak/exports/bin`, "/var/lib/flatpak/exports/bin"];
}
function commandSearchDirectories(input: {
env: NodeJS.ProcessEnv;
platform: NodeJS.Platform;
homeDirectory: string;
}): string[] {
const pathValue = input.env.PATH ?? input.env.Path ?? input.env.path ?? "";
const pathDelimiter = input.platform === "win32" ? ";" : ":";
const directories = pathValue.split(pathDelimiter).filter(Boolean);
if (input.platform !== "linux") return directories;
return [...directories, ...flatpakExportDirectories(input.env, input.homeDirectory)];
}
function resolveExecutable(
commands: readonly string[],
input: {
env: NodeJS.ProcessEnv;
searchDirectories: readonly string[];
pathExists: (path: string) => boolean;
platform: NodeJS.Platform;
},
@@ -69,10 +87,7 @@ function resolveExecutable(
return command;
}
const pathValue = input.env.PATH ?? input.env.Path ?? input.env.path ?? "";
const pathDelimiter = input.platform === "win32" ? ";" : ":";
for (const directory of pathValue.split(pathDelimiter)) {
if (!directory) continue;
for (const directory of input.searchDirectories) {
const candidate = `${directory}/${command}`;
if (input.platform !== "win32") {
if (input.pathExists(candidate)) return candidate;
@@ -140,7 +155,12 @@ export function createEditorTargetRuntime(
env,
pathExists,
isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform),
resolveCommand: (commands) => resolveExecutable(commands, { env, pathExists, platform }),
resolveCommand: (commands) =>
resolveExecutable(commands, {
searchDirectories: commandSearchDirectories({ env, platform, homeDirectory }),
pathExists,
platform,
}),
async spawnDetached({ command, args }) {
const commandScript = isWindowsCommandScript(command, platform);
const launchCommand = commandScript ? escapeWindowsCmdValue(command) : command;
@@ -1,6 +1,6 @@
import type { EditorTarget, EditorTargetLaunchInput } from "../target.js";
const COMMANDS = ["zed", "zeditor"] as const;
const COMMANDS = ["zed", "zeditor", "dev.zed.Zed"] as const;
function location(input: EditorTargetLaunchInput): string {
if (!input.line) return input.filePath!;
@@ -49,6 +49,22 @@ describe("agent feature schemas", () => {
expect(parsed.value).toBe("flex");
});
it.each(["icon", "label"])(
"preserves the %s desktop trigger for any select feature",
(desktopTrigger) => {
const feature = {
type: "select",
id: "custom-feature",
label: "Custom setting",
icon: "gauge",
value: "first",
options: [{ id: "first", label: "First" }],
desktopTrigger,
};
expect(AgentFeatureSchema.parse(feature)).toEqual(feature);
},
);
it("rejects invalid features", () => {
const invalidDiscriminator = AgentFeatureSchema.safeParse({
type: "slider",
+2
View File
@@ -145,6 +145,8 @@ export interface AgentFeatureSelect {
description?: string;
tooltip?: string;
icon?: string;
/** Desktop toolbar presentation; omitted means labeled. Mobile selectors stay labeled. */
desktopTrigger?: "icon" | "label";
value: string | null;
options: AgentSelectOption[];
}
+1
View File
@@ -301,6 +301,7 @@ export const AgentFeatureSelectSchema = z.object({
description: z.string().optional(),
tooltip: z.string().optional(),
icon: z.string().optional(),
desktopTrigger: z.enum(["icon", "label"]).optional(),
value: z.string().nullable(),
options: z.array(AgentSelectOptionSchema),
});
@@ -1,4 +1,7 @@
import type {
AgentFeature,
AgentFeatureSelect,
AgentFeatureToggle,
AgentProviderNotice,
AgentTaskItem,
JsonValue,
@@ -8,7 +11,13 @@ import type {
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { PaseoToolCatalog } from "./tools/types.js";
export type { AgentProviderNotice, AgentTaskItem };
export type {
AgentFeature,
AgentFeatureSelect,
AgentFeatureToggle,
AgentProviderNotice,
AgentTaskItem,
};
export type AgentProvider = string;
@@ -156,29 +165,6 @@ export interface AgentCreateConfigUnattendedInput {
availableModes: AgentMode[];
}
export interface AgentFeatureToggle {
type: "toggle";
id: string;
label: string;
description?: string;
tooltip?: string;
icon?: string;
value: boolean;
}
export interface AgentFeatureSelect {
type: "select";
id: string;
label: string;
description?: string;
tooltip?: string;
icon?: string;
value: string | null;
options: AgentSelectOption[];
}
export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect;
export interface AgentCapabilityFlags {
[capability: string]: boolean | undefined;
supportsStreaming: boolean;
@@ -19,6 +19,41 @@ interface CollaborationModeRecord {
developer_instructions?: string | null;
}
const TEST_SPEED_MODELS = [
"gpt-6-astra",
"gpt-6.1-sol",
"gpt-6-sol",
"gpt-6-luna",
"gpt-5.6",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5",
"gpt-5.4",
].map((id) => ({
id,
isDefault: id === "gpt-5.4",
defaultReasoningEffort: "medium",
serviceTiers: [{ id: "priority", name: "Fast", description: "Faster processing" }],
}));
function speedFeature(value: string) {
return {
type: "select",
id: "service_tier",
label: "Speed",
description: "Choose processing speed. Faster tiers increase usage.",
tooltip: "Select speed",
icon: "zap",
desktopTrigger: "icon",
value,
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "priority", label: "Fast" },
],
};
}
const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [
{
name: "Code",
@@ -65,7 +100,7 @@ function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSession
function createSessionHarness(
configOverrides: Partial<AgentSessionConfig> = {},
options: { logger?: pino.Logger } = {},
options: { logger?: pino.Logger; models?: unknown[] } = {},
): {
session: CodexFeaturesTestSession;
appServer: FakeCodexAppServer;
@@ -73,6 +108,7 @@ function createSessionHarness(
const config = createConfig(configOverrides);
const appServer = createFakeCodexAppServer({
"collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }),
"model/list": () => ({ data: options.models ?? TEST_SPEED_MODELS }),
});
const session = new CodexAppServerAgentSession(
{ ...config, provider: CODEX_PROVIDER },
@@ -85,7 +121,7 @@ function createSessionHarness(
async function createConnectedSession(
configOverrides: Partial<AgentSessionConfig> = {},
options: { logger?: pino.Logger } = {},
options: { logger?: pino.Logger; models?: unknown[] } = {},
): Promise<{
session: CodexFeaturesTestSession;
appServer: FakeCodexAppServer;
@@ -97,8 +133,169 @@ async function createConnectedSession(
}
describe("Codex app-server provider features", () => {
test("GPT-6.1 Sol offers catalog speed tiers and sends Ultrafast", async () => {
const { session, appServer } = await createConnectedSession(
{ model: "gpt-6.1-sol" },
{
models: [
{
id: "gpt-6.1-sol",
serviceTiers: [
{ id: "priority", name: "Fast", description: "Faster processing" },
{ id: "ultrafast", name: "Ultrafast", description: "Lowest latency" },
],
},
],
},
);
try {
expect(session.features).toContainEqual(
expect.objectContaining({
id: "service_tier",
type: "select",
value: "default",
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "priority", label: "Fast" },
{ id: "ultrafast", label: "Ultrafast" },
],
}),
);
await session.setFeature?.("service_tier", "ultrafast");
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "ultrafast",
});
} finally {
await session.close();
}
});
test("new models and service tiers work without a Paseo catalog update", async () => {
const { session, appServer } = await createConnectedSession(
{ model: "future-codex-model" },
{
models: [
{
id: "future-codex-model",
serviceTiers: [
{ id: "future-tier", name: "Future speed", description: "A newly advertised tier" },
],
},
],
},
);
try {
expect(session.features).toContainEqual(
expect.objectContaining({
id: "service_tier",
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "future-tier", label: "Future speed" },
],
}),
);
await session.setFeature?.("service_tier", "future-tier");
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
model: "future-codex-model",
serviceTier: "future-tier",
});
} finally {
await session.close();
}
});
test.each(["priority", "ultrafast"])(
"restores %s and Normal explicitly overrides an existing thread tier",
async (tier) => {
const { session, appServer } = await createConnectedSession(
{ model: "future-model", featureValues: { service_tier: tier } },
{
models: [
{
id: "future-model",
serviceTiers: [
{ id: "priority", name: "Fast", description: "Fast processing" },
{ id: "ultrafast", name: "Ultrafast", description: "Ultra processing" },
],
},
],
},
);
try {
expect(session.features).toContainEqual(
expect.objectContaining({ id: "service_tier", value: tier }),
);
await session.getRuntimeInfo();
await session.setFeature?.("service_tier", "default");
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "default",
});
} finally {
await session.close();
}
},
);
test("rejects unadvertised tiers and non-string values", async () => {
const { session } = await createConnectedSession();
try {
for (const value of ["ultrafast", "bogus", true, null]) {
await expect(session.setFeature?.("service_tier", value)).rejects.toThrow(
"is not available",
);
}
expect(session.features).toContainEqual(
expect.objectContaining({ id: "service_tier", value: "default" }),
);
} finally {
await session.close();
}
});
test("switching to a model without the selected tier resets speed", async () => {
const { session, appServer } = await createConnectedSession(
{ model: "ultra-model", featureValues: { service_tier: "ultrafast" } },
{
models: [
{
id: "ultra-model",
serviceTiers: [{ id: "ultrafast", name: "Ultrafast", description: "Ultra processing" }],
},
...TEST_SPEED_MODELS,
],
},
);
try {
await session.setModel("gpt-6.1-sol");
expect(session.features).toContainEqual(
expect.objectContaining({ id: "service_tier", value: "default" }),
);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
} finally {
await session.close();
}
});
test("maps legacy Fast commands to the catalog's request id", async () => {
const { session, appServer } = await createConnectedSession();
try {
await session.setFeature?.("fast_mode", true);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "priority",
});
} finally {
await session.close();
}
});
test.each([
"gpt-6-astra",
"gpt-6.1-sol",
"gpt-6-sol",
"gpt-6-luna",
"gpt-5.6",
@@ -112,15 +309,15 @@ describe("Codex app-server provider features", () => {
try {
expect(session.features).toContainEqual(
expect.objectContaining({
id: "fast_mode",
value: false,
id: "service_tier",
value: "default",
}),
);
await session.setFeature?.("fast_mode", true);
await session.setFeature?.("service_tier", "priority");
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
model,
serviceTier: "fast",
serviceTier: "priority",
});
} finally {
await session.close();
@@ -149,8 +346,8 @@ describe("Codex app-server provider features", () => {
`Codex fast mode is not available for model '${model}'`,
);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
serviceTier: expect.anything(),
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "default",
});
} finally {
await session.close();
@@ -165,8 +362,8 @@ describe("Codex app-server provider features", () => {
try {
expect(session.features).toContainEqual(
expect.objectContaining({
id: "fast_mode",
value: true,
id: "service_tier",
value: "priority",
}),
);
await session.setModel("gpt-5.6-sol");
@@ -174,26 +371,18 @@ describe("Codex app-server provider features", () => {
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
model: "gpt-6-astra",
serviceTier: "fast",
serviceTier: "priority",
});
} finally {
await session.close();
}
});
test("features returns fast and plan toggles when supported", async () => {
test("features returns speed selector and plan toggle when supported", async () => {
const { session } = await createConnectedSession();
expect(session.features).toEqual([
{
type: "toggle",
id: "fast_mode",
label: "Fast",
description: "Priority inference at increased usage",
tooltip: "Toggle fast mode",
icon: "zap",
value: false,
},
speedFeature("default"),
{
type: "toggle",
id: "plan_mode",
@@ -205,19 +394,11 @@ describe("Codex app-server provider features", () => {
},
]);
await session.setFeature?.("fast_mode", true);
await session.setFeature?.("service_tier", "priority");
await session.setFeature?.("plan_mode", true);
expect(session.features).toEqual([
{
type: "toggle",
id: "fast_mode",
label: "Fast",
description: "Priority inference at increased usage",
tooltip: "Toggle fast mode",
icon: "zap",
value: true,
},
speedFeature("priority"),
{
type: "toggle",
id: "plan_mode",
@@ -265,23 +446,23 @@ describe("Codex app-server provider features", () => {
]);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
serviceTier: expect.anything(),
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "default",
});
});
test("setFeature('fast_mode', true) sets serviceTier to fast", async () => {
test("selecting Fast sends the catalog service tier", async () => {
const { session, appServer } = await createConnectedSession();
await session.setFeature?.("fast_mode", true);
await session.setFeature?.("service_tier", "priority");
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "fast",
serviceTier: "priority",
});
});
test("setFeature('fast_mode', false) clears serviceTier to null", async () => {
test("legacy Fast off explicitly selects Normal", async () => {
const { session, appServer } = await createConnectedSession({
featureValues: { fast_mode: true },
});
@@ -289,8 +470,8 @@ describe("Codex app-server provider features", () => {
await session.setFeature?.("fast_mode", false);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
serviceTier: expect.anything(),
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "default",
});
});
@@ -330,15 +511,7 @@ describe("Codex app-server provider features", () => {
});
expect(session.features).toEqual([
{
type: "toggle",
id: "fast_mode",
label: "Fast",
description: "Priority inference at increased usage",
tooltip: "Toggle fast mode",
icon: "zap",
value: true,
},
speedFeature("priority"),
{
type: "toggle",
id: "plan_mode",
@@ -352,7 +525,7 @@ describe("Codex app-server provider features", () => {
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "fast",
serviceTier: "priority",
collaborationMode: expect.objectContaining({
mode: "plan",
}),
@@ -362,11 +535,11 @@ describe("Codex app-server provider features", () => {
test("startTurn includes serviceTier when fast mode is enabled", async () => {
const { session, appServer } = await createConnectedSession();
await session.setFeature?.("fast_mode", true);
await session.setFeature?.("service_tier", "priority");
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "fast",
serviceTier: "priority",
});
});
@@ -388,7 +561,7 @@ describe("Codex app-server provider features", () => {
msg: "Starting Codex app-server turn",
model: "gpt-5.4",
modeId: "auto",
serviceTier: "fast",
serviceTier: "priority",
cwd: "/tmp/codex-fast-mode-test",
});
expect(JSON.stringify(entry)).not.toContain(prompt);
@@ -397,7 +570,7 @@ describe("Codex app-server provider features", () => {
test("setModel clears fast mode when switching to an unsupported model", async () => {
const { session, appServer } = await createConnectedSession();
await session.setFeature?.("fast_mode", true);
await session.setFeature?.("service_tier", "priority");
await session.setModel("gpt-3.5-turbo");
expect(session.features).toEqual([
@@ -413,8 +586,8 @@ describe("Codex app-server provider features", () => {
]);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
serviceTier: expect.anything(),
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "default",
});
});
@@ -804,6 +804,7 @@ let buffer = "";
function resultFor(method, params) {
if (method === "initialize") return {};
if (method === "collaborationMode/list") return { data: [] };
if (method === "model/list") return { data: [] };
if (method === "skills/list") {
const cwds = params && params.cwds;
const projectCwd = ${JSON.stringify(projectCwd)};
@@ -1170,6 +1171,36 @@ describe("Codex app-server provider", () => {
}
});
test("keeps the default effort when a session without one is resumed after a restart", async () => {
const beforeRestart = createFakeCodexAppServer();
const firstSession = await createProviderWithFakeAppServer(beforeRestart).createSession(
createConfig({ thinkingOptionId: undefined }),
);
await firstSession.startTurn("first turn");
await expect(beforeRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" });
const handle = firstSession.describePersistence()!;
await firstSession.close();
const afterRestart = createFakeCodexAppServer();
// The daemon resumes with the stored agent config, which never recorded an effort.
const resumed = await createProviderWithFakeAppServer(afterRestart).resumeSession(handle, {
model: "gpt-5.4",
thinkingOptionId: undefined,
});
try {
await expect(resumed.getRuntimeInfo()).resolves.toMatchObject({
thinkingOptionId: "medium",
});
await resumed.startTurn("turn after restart");
await expect(afterRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" });
beforeRestart.assertNoErrors();
afterRestart.assertNoErrors();
} finally {
await resumed.close();
}
});
test("preapproves only granted tools on the injected Codex MCP server", async () => {
const session = createSession({
modeId: undefined,
@@ -1934,6 +1965,66 @@ describe("Codex app-server provider", () => {
await session.close();
});
test("rewinds a legacy conversation with a bounded fork on Codex without thread/rollback", async () => {
const appServer = createFakeCodexAppServer({
initialize: () => ({ userAgent: "paseo/0.159.0 (Ubuntu 26.4.0; x86_64) (paseo; 0)" }),
"thread/read": () => ({
thread: { id: "thread-1", historyMode: "legacy", turns: [] },
}),
"thread/rollback": () => ({
__jsonRpcError: {
code: -32600,
message: "Invalid request: unknown variant `thread/rollback`",
},
}),
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
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-second" });
const forkRequests = appServer
.requests()
.filter((request) => request.method === "thread/fork")
.map((request) => request.params);
expect(forkRequests).toEqual([
{
threadId: "thread-1",
beforeTurnId: "turn-second",
cwd: "/workspace/project",
model: "gpt-5.4",
serviceTier: null,
excludeTurns: false,
persistExtendedHistory: true,
},
]);
expect(appServer.recordedRollbacks).toEqual([]);
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
sessionId: "forked-thread",
});
appServer.assertNoErrors();
await session.close();
});
test("rewinds a paginated conversation through the public session capability", async () => {
const appServer = createFakeCodexAppServer({
"thread/read": () => ({
@@ -2199,7 +2290,12 @@ describe("Codex app-server provider", () => {
throw new Error(`resumeSession timed out; thread requests: ${threadRequests.join(", ")}`);
}
expect(threadRequests).toEqual(["config/read", "thread/loaded/list", "thread/resume"]);
expect(threadRequests).toEqual([
"model/list",
"config/read",
"thread/loaded/list",
"thread/resume",
]);
expect(outcome).toBe("rejected");
appServer.assertNoErrors();
});
@@ -2373,6 +2469,71 @@ describe("Codex app-server provider", () => {
});
});
test("keeps an output field named properties as a field", () => {
const input = {
type: "object",
properties: { properties: { type: "string" } },
required: ["properties"],
additionalProperties: false,
};
expect(normalizeCodexOutputSchema(input)).toEqual(input);
});
test("normalizes schemas under keywords and leaves literal values alone", () => {
const literal = { type: "object", properties: { type: "string" } };
const input = {
type: "object",
properties: {
result: { $ref: "#/$defs/result" },
choice: { anyOf: [{ type: "object", properties: { id: { type: "string" } } }] },
},
$defs: {
properties: { type: "string" },
result: {
type: "object",
properties: { value: { type: "string" } },
default: literal,
examples: [literal],
enum: [literal],
const: literal,
},
},
};
expect(normalizeCodexOutputSchema(input)).toEqual({
type: "object",
properties: {
result: { $ref: "#/$defs/result" },
choice: {
anyOf: [
{
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
additionalProperties: false,
},
],
},
},
required: ["result", "choice"],
additionalProperties: false,
$defs: {
properties: { type: "string" },
result: {
type: "object",
properties: { value: { type: "string" } },
default: literal,
examples: [literal],
enum: [literal],
const: literal,
required: ["value"],
additionalProperties: false,
},
},
});
});
test("passes a normalized output schema to turn/start", async () => {
const session = createSession();
const request = vi.fn(async (method: string) => {
@@ -73,7 +73,12 @@ import {
import { createPathEquivalenceMatcher } from "../../../utils/path.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
import {
buildCodexFeatures,
CodexServiceTierSchema,
readCodexServiceTier,
type CodexServiceTier,
} from "./codex-feature-definitions.js";
import {
CodexAppServerClient,
CodexAppServerRpcError,
@@ -169,6 +174,9 @@ const CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX =
// (and the /goal slash command) when the binary is too old.
const CODEX_GOALS_MIN_VERSION: readonly [number, number, number] = [0, 128, 0];
const CODEX_AUTO_REVIEW_MIN_VERSION: readonly [number, number, number] = [0, 115, 0];
// Codex removed `thread/rollback` in 0.156.0, so rewinding a legacy thread there
// has to fork before the target turn instead.
const CODEX_THREAD_ROLLBACK_REMOVED_VERSION: readonly [number, number, number] = [0, 156, 0];
function parseCodexVersion(versionOutput: string): [number, number, number] | null {
const match = versionOutput.match(/(\d+)\.(\d+)\.(\d+)/);
@@ -189,6 +197,15 @@ function codexVersionAtLeast(
return true;
}
// The initialize userAgent starts with `<client>/<codex version>`. An agent we
// cannot parse is treated as an older server that still has rollback.
function codexServerHasThreadRollback(userAgent: unknown): boolean {
return (
typeof userAgent !== "string" ||
!codexVersionAtLeast(userAgent, CODEX_THREAD_ROLLBACK_REMOVED_VERSION)
);
}
type GoalSubcommand =
| { kind: "set"; objective: string }
| { kind: "pause" }
@@ -365,19 +382,67 @@ function isObjectSchemaNode(schema: Record<string, unknown>): boolean {
);
}
function normalizeCodexOutputSchemaNode(schema: unknown, schemaPath: string): unknown {
// Keywords whose value maps names to schemas.
const SCHEMA_MAP_KEYWORDS = [
"properties",
"patternProperties",
"$defs",
"definitions",
"dependentSchemas",
] as const;
// Keywords whose value is a schema or an array of schemas.
const SUBSCHEMA_KEYWORDS = [
"items",
"prefixItems",
"additionalItems",
"contains",
"additionalProperties",
"unevaluatedProperties",
"unevaluatedItems",
"propertyNames",
"allOf",
"anyOf",
"oneOf",
"not",
"if",
"then",
"else",
] as const;
function normalizeCodexOutputSubschema(schema: unknown, schemaPath: string): unknown {
if (Array.isArray(schema)) {
return schema.map((entry, index) =>
normalizeCodexOutputSchemaNode(entry, `${schemaPath}[${index}]`),
);
}
return normalizeCodexOutputSchemaNode(schema, schemaPath);
}
function normalizeCodexOutputSchemaNode(schema: unknown, schemaPath: string): unknown {
if (!isSchemaRecord(schema)) {
return schema;
}
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schema)) {
normalized[key] = normalizeCodexOutputSchemaNode(value, `${schemaPath}.${key}`);
const normalized: Record<string, unknown> = { ...schema };
for (const keyword of SCHEMA_MAP_KEYWORDS) {
const schemas = schema[keyword];
if (isSchemaRecord(schemas)) {
normalized[keyword] = Object.fromEntries(
Object.entries(schemas).map(([name, child]) => [
name,
normalizeCodexOutputSchemaNode(child, `${schemaPath}.${keyword}.${name}`),
]),
);
}
}
for (const keyword of SUBSCHEMA_KEYWORDS) {
if (keyword in schema) {
normalized[keyword] = normalizeCodexOutputSubschema(
schema[keyword],
`${schemaPath}.${keyword}`,
);
}
}
if (!isObjectSchemaNode(normalized)) {
@@ -881,6 +946,7 @@ interface CodexModel {
model?: string;
defaultReasoningEffort?: string;
supportedReasoningEfforts?: CodexReasoningEffortEntry[];
serviceTiers?: CodexServiceTier[];
}
const CodexModelListResponseSchema = z.object({
@@ -888,6 +954,7 @@ const CodexModelListResponseSchema = z.object({
.array(
z.object({
id: z.string(),
serviceTiers: z.array(CodexServiceTierSchema).optional(),
displayName: z.string().optional(),
description: z.string().optional(),
isDefault: z.boolean().optional(),
@@ -3373,12 +3440,14 @@ export class CodexAppServerAgentSession implements AgentSession {
cancelRequested: boolean;
} | null = null;
private client: CodexAppServerClient | null = null;
private threadRollbackAvailable = true;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
private activeClientMessageId: string | null = null;
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
private serviceTier: "fast" | null = null;
private serviceTier: string | null = null;
private speedModels: CodexModel[] = [];
private planModeEnabled = false;
private historyPending = false;
private persistedHistory: PersistedTimelineEntry[] = [];
@@ -3469,9 +3538,7 @@ export class CodexAppServerAgentSession implements AgentSession {
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";
}
this.serviceTier = readCodexServiceTier(this.config.featureValues);
if (this.config.featureValues?.plan_mode) {
this.planModeEnabled = true;
}
@@ -3499,8 +3566,8 @@ export class CodexAppServerAgentSession implements AgentSession {
get features(): AgentFeature[] {
return buildCodexFeatures({
modelId: this.config.model,
fastModeEnabled: this.serviceTier === "fast",
serviceTiers: this.currentServiceTiers(),
serviceTier: this.serviceTier ?? "default",
planModeEnabled: this.planModeEnabled,
planModeAvailable: this.hasPlanCollaborationMode(),
});
@@ -3553,9 +3620,15 @@ export class CodexAppServerAgentSession implements AgentSession {
this.registerRequestHandlers();
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
const initialized = toObjectRecord(
await client.request("initialize", buildCodexAppServerInitializeParams()),
);
this.threadRollbackAvailable = codexServerHasThreadRollback(initialized?.userAgent);
client.notify("initialized", {});
this.speedModels =
CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? [];
this.reconcileServiceTier();
await this.loadResolvedWorkspaceWrite();
await this.loadCollaborationModes();
await this.loadSkills();
@@ -3563,6 +3636,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (this.currentThreadId) {
await this.ensureThreadLoaded();
await this.loadPersistedHistory(this.client);
await this.applyDefaultModelAndThinking();
}
if (this.closed) {
@@ -3759,18 +3833,30 @@ export class CodexAppServerAgentSession implements AgentSession {
this.resolvedCollaborationMode = this.resolveCollaborationMode();
}
private applyFeatureValue(featureId: "fast_mode" | "plan_mode", value: boolean): void {
this.config.featureValues = {
...this.config.featureValues,
[featureId]: value,
};
private currentServiceTiers(): CodexServiceTier[] {
const selectedModel = this.config.model
? this.speedModels.find(
(model) => model.id === this.config.model || model.model === this.config.model,
)
: (this.speedModels.find((model) => model.isDefault) ?? this.speedModels[0]);
return selectedModel?.serviceTiers ?? [];
}
if (featureId === "fast_mode") {
this.serviceTier = value ? "fast" : null;
this.cachedRuntimeInfo = null;
return;
private reconcileServiceTier(): void {
const tiers = this.currentServiceTiers();
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
if (this.serviceTier === "fast" && !tiers.some((tier) => tier.id === "fast")) {
this.serviceTier = tiers.find((tier) => tier.name.toLowerCase() === "fast")?.id ?? null;
}
if (this.serviceTier !== "default" && !tiers.some((tier) => tier.id === this.serviceTier)) {
this.serviceTier = null;
}
const { fast_mode: _legacyFast, ...values } = this.config.featureValues ?? {};
this.config.featureValues = { ...values, service_tier: this.serviceTier ?? "default" };
}
private applyFeatureValue(featureId: "plan_mode", value: boolean): void {
this.config.featureValues = { ...this.config.featureValues, [featureId]: value };
this.planModeEnabled = value;
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
@@ -4101,9 +4187,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (thinkingOptionId) {
params.effort = thinkingOptionId;
}
if (this.serviceTier) {
params.serviceTier = this.serviceTier;
}
params.serviceTier = this.serviceTier ?? "default";
if (this.resolvedCollaborationMode) {
params.collaborationMode = {
mode: this.resolvedCollaborationMode.mode,
@@ -4521,9 +4605,7 @@ export class CodexAppServerAgentSession implements AgentSession {
async setModel(modelId: string | null): Promise<void> {
this.config.model = modelId ?? undefined;
if (!codexModelSupportsFastMode(this.config.model)) {
this.serviceTier = null;
}
this.reconcileServiceTier();
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
}
@@ -4538,13 +4620,27 @@ export class CodexAppServerAgentSession implements AgentSession {
}
async setFeature(featureId: string, value: unknown): Promise<void> {
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once clients use service_tier.
if (featureId === "fast_mode") {
if (Boolean(value) && !codexModelSupportsFastMode(this.config.model)) {
const fast = this.currentServiceTiers().find((tier) => tier.name.toLowerCase() === "fast");
if (value && !fast)
throw new Error(
`Codex fast mode is not available for model '${this.config.model ?? "default"}'`,
);
return this.setFeature("service_tier", value ? fast!.id : "default");
}
if (featureId === "service_tier") {
if (
typeof value !== "string" ||
(value !== "default" && !this.currentServiceTiers().some((tier) => tier.id === value))
) {
throw new Error(
`Codex speed '${String(value)}' is not available for model '${this.config.model ?? "default"}'`,
);
}
this.applyFeatureValue("fast_mode", Boolean(value));
this.serviceTier = value;
this.reconcileServiceTier();
this.cachedRuntimeInfo = null;
return;
}
if (featureId === "plan_mode") {
@@ -4847,6 +4943,7 @@ export class CodexAppServerAgentSession implements AgentSession {
serviceTier: this.serviceTier,
config: this.buildCodexInnerConfig(),
userMessageTurns: this.codexUserMessageTurns(),
threadRollbackAvailable: this.threadRollbackAvailable,
setThreadId: async (threadId) => {
this.currentThreadId = threadId;
this.cachedRuntimeInfo = null;
@@ -5160,13 +5257,21 @@ export class CodexAppServerAgentSession implements AgentSession {
return { model, thinkingOptionId };
}
// A session without a model or effort runs on Codex's configured defaults, whether its
// thread is new or resumed after a restart.
private async applyDefaultModelAndThinking(): Promise<string> {
const { model, thinkingOptionId } = await this.resolveModelAndThinking();
this.config.model = model;
this.reconcileServiceTier();
this.config.thinkingOptionId = thinkingOptionId;
return model;
}
private async ensureThread(): Promise<void> {
if (!this.client) return;
if (this.currentThreadId) return;
const { model, thinkingOptionId } = await this.resolveModelAndThinking();
this.config.model = model;
this.config.thinkingOptionId = thinkingOptionId;
const model = await this.applyDefaultModelAndThinking();
const { params, approvalPolicy, sandbox } = this.buildThreadStartRequest(model);
const rawResponse = await this.client.request("thread/start", params);
@@ -1,27 +1,33 @@
import type { AgentFeature, AgentFeatureToggle } from "../agent-sdk-types.js";
// Codex Fast is distinct from API Priority processing. Keep model support aligned with
// https://developers.openai.com/codex/speed and https://developers.openai.com/codex/models.
const CODEX_FAST_MODE_SUPPORTED_MODELS = new Set([
"gpt-6-astra",
"gpt-6-sol",
"gpt-6-luna",
"gpt-5.6",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5",
"gpt-5.4",
]);
import { z } from "zod";
export const CODEX_FAST_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
type: "toggle",
id: "fast_mode",
label: "Fast",
description: "Priority inference at increased usage",
tooltip: "Toggle fast mode",
icon: "zap",
};
export const CodexServiceTierSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string(),
});
export type CodexServiceTier = z.infer<typeof CodexServiceTierSchema>;
function buildCodexSpeedFeature(tiers: CodexServiceTier[], value: string): AgentFeature[] {
if (tiers.length === 0) return [];
return [
{
type: "select",
id: "service_tier",
label: "Speed",
description: "Choose processing speed. Faster tiers increase usage.",
tooltip: "Select speed",
icon: "zap",
desktopTrigger: "icon",
value,
options: [
{ id: "default", label: "Normal", isDefault: true },
...tiers.map((tier) => ({ id: tier.id, label: tier.name })),
],
},
];
}
export const CODEX_PLAN_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
type: "toggle",
@@ -32,40 +38,22 @@ export const CODEX_PLAN_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
icon: "list-todo",
};
function normalizeCodexModelId(modelId: string | null | undefined): string | null {
const normalized = typeof modelId === "string" ? modelId.trim() : "";
return normalized.length > 0 ? normalized : null;
}
export function codexModelSupportsFastMode(modelId: string | null | undefined): boolean {
const normalizedModelId = normalizeCodexModelId(modelId);
if (!normalizedModelId) {
return false;
}
return CODEX_FAST_MODE_SUPPORTED_MODELS.has(normalizedModelId);
}
export function buildCodexFeatures(input: {
modelId: string | null | undefined;
fastModeEnabled: boolean;
serviceTiers: CodexServiceTier[];
serviceTier: string;
planModeEnabled: boolean;
planModeAvailable?: boolean;
}): AgentFeature[] {
const features: AgentFeature[] = [];
if (codexModelSupportsFastMode(input.modelId)) {
features.push({
...CODEX_FAST_MODE_FEATURE,
value: input.fastModeEnabled,
});
}
const features = buildCodexSpeedFeature(input.serviceTiers, input.serviceTier);
if (input.planModeAvailable !== false) {
features.push({
...CODEX_PLAN_MODE_FEATURE,
value: input.planModeEnabled,
});
features.push({ ...CODEX_PLAN_MODE_FEATURE, value: input.planModeEnabled });
}
return features;
}
export function readCodexServiceTier(values: Record<string, unknown> | undefined): string | null {
const tier = values?.service_tier;
if (typeof tier === "string") return tier;
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
return values?.fast_mode ? "fast" : null;
}
@@ -96,6 +96,7 @@ describe("Codex Rewind", () => {
model: "gpt-5.4-mini",
serviceTier: null,
userMessageTurns,
threadRollbackAvailable: true,
setThreadId: (threadId) => {
reboundThreadId = threadId;
},
@@ -131,6 +132,7 @@ describe("Codex Rewind", () => {
threadId: "source-thread",
messageId: "codex-second",
userMessageTurns,
threadRollbackAvailable: true,
setThreadId: (threadId) => {
reboundThreadId = threadId;
},
@@ -175,6 +177,7 @@ describe("Codex Rewind", () => {
model: "gpt-5.4-mini",
serviceTier: null,
userMessageTurns,
threadRollbackAvailable: true,
setThreadId: (threadId) => {
reboundThreadId = threadId;
},
@@ -218,6 +221,7 @@ describe("Codex Rewind", () => {
model: "gpt-5.4-mini",
serviceTier: null,
userMessageTurns,
threadRollbackAvailable: true,
setThreadId: (threadId) => {
reboundThreadId = threadId;
},
@@ -239,6 +243,7 @@ describe("Codex Rewind", () => {
threadId: "source-thread",
messageId: "missing-message",
userMessageTurns,
threadRollbackAvailable: true,
setThreadId: () => undefined,
}),
).rejects.toThrow("Codex could not find user message missing-message");
@@ -69,6 +69,7 @@ export async function revertCodexConversation(input: {
serviceTier?: string | null;
config?: Record<string, unknown> | null;
userMessageTurns: CodexUserMessageTurnIndex;
threadRollbackAvailable: boolean;
setThreadId: (threadId: string) => void | Promise<void>;
}): Promise<void> {
if (!input.threadId) {
@@ -98,8 +99,10 @@ export async function revertCodexConversation(input: {
persistExtendedHistory: true,
};
const historyMode = await readCodexThreadHistoryMode(input.client, input.threadId);
if (historyMode === "paginated") {
if (
!input.threadRollbackAvailable ||
(await readCodexThreadHistoryMode(input.client, input.threadId)) === "paginated"
) {
if (!targetTurn.turnId) {
throw new Error(`Codex could not find the turn containing user message ${input.messageId}`);
}
@@ -111,7 +114,8 @@ export async function revertCodexConversation(input: {
return;
}
// Fork is non-destructive: the old thread file stays on disk and remains
// Legacy threads on Codex before 0.156 fork and then roll back. 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, forkParams);
const forkedThreadId = forked.thread.id;
@@ -312,6 +312,10 @@ const OpencodeKnownToolDetailSchema = z.union([
z.unknown(),
(input) => toEditToolDetail(input, null),
),
// OpenCode v2 reports apply_patch as `patch`.
toolDetailBranchByToolName("patch", OpencodeApplyPatchTextInputSchema, z.unknown(), (input) =>
toEditToolDetail(input, null),
),
toolDetailBranchByToolName(
"apply_diff",
OpencodeEditInputSchema,
@@ -387,6 +387,34 @@ describe("opencode tool-call mapper", () => {
});
});
it("maps OpenCode v2 patch payloads from GPT models into edit detail", () => {
const item = expectMapped(
mapOpencodeToolCall({
toolName: "patch",
callId: "opencode-v2-patch-1",
status: "completed",
input: {
patchText:
"*** Begin Patch\n*** Update File: patch.txt\n@@\n-north south\n+NORTH south\n*** End Patch",
},
output: "Success. Updated the following files:\nM patch.txt",
}),
);
expect(item.detail).toEqual({
type: "edit",
filePath: "patch.txt",
unifiedDiff: [
"diff --git a/patch.txt b/patch.txt",
"--- a/patch.txt",
"+++ b/patch.txt",
"@@",
"-north south",
"+NORTH south",
].join("\n"),
});
});
it("maps unknown tools to unknown detail with raw payloads", () => {
const item = expectMapped(
mapOpencodeToolCall({
@@ -35,6 +35,7 @@ import { features } from "./configuration.js";
import { commands } from "./commands.js";
import { messages } from "./history.js";
import { SessionPermissions } from "./permissions.js";
import { SessionUsage } from "./usage.js";
export class OpenCodeV2Session implements AgentSession {
readonly provider = "opencode";
@@ -45,6 +46,7 @@ export class OpenCodeV2Session implements AgentSession {
private readonly permissions: SessionPermissions;
private readonly turns: SessionTurns;
private readonly children: SessionChildren;
private readonly usage: SessionUsage;
private stream: Promise<void> | null = null;
private streamAbort = new AbortController();
private exited = false;
@@ -101,6 +103,17 @@ export class OpenCodeV2Session implements AgentSession {
await this.permissions.respondToPermission(request.id, { behavior: "deny" });
},
});
this.usage = new SessionUsage({
client: () => this.client,
cwd: config.cwd,
info: () => this.info,
emit: (event) => this.emit(event),
reportError: (error) =>
this.logger.warn(
{ error: toDiagnosticErrorMessage(error) },
"OpenCode context usage update failed",
),
});
}
get id() {
return this.info.id;
@@ -465,6 +478,7 @@ export class OpenCodeV2Session implements AgentSession {
return;
}
this.permissions.observe(event);
this.usage.observe(event);
this.turns.observe(event);
this.scheduleReconcile();
}
@@ -0,0 +1,100 @@
import type { ModelInfo, TokenUsageInfo } from "@opencode/client";
import { describe, expect, test } from "vitest";
import { OpenCodeV2AgentClient } from "./agent.js";
import { V2Harness } from "../test-utils/v2-harness.js";
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
import type { AgentUsage } from "../../../agent-sdk-types.js";
function model(context: number): ModelInfo {
return {
id: "model",
modelID: "model",
providerID: "test",
name: "Model",
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [],
time: { released: 1 },
cost: [],
status: "active",
enabled: true,
limit: { context, output: 10_000 },
};
}
function stepEnded(harness: V2Harness, id: string, tokens: TokenUsageInfo) {
harness.push({
id,
created: 2,
type: "session.step.ended",
durable: { aggregateID: "session", seq: 1, version: 1 },
data: {
sessionID: "session",
assistantMessageID: "answer",
finish: "tool-calls",
cost: 0.1,
tokens,
},
});
}
describe("OpenCode v2 context usage", () => {
test("reports each step's context against the model limit, in step order", async () => {
const harness = new V2Harness();
harness.info.model = { providerID: "test", id: "model" };
harness.info.tokens = {
input: 900,
output: 90,
reasoning: 0,
cache: { read: 40, write: 0 },
};
harness.info.cost = 0.5;
// Each lookup answers faster than the one before it, so a lookup per step would
// deliver step 1's usage after step 2's.
const lookupDelays = [50, 0];
harness.api.model.list = async (input) => {
await new Promise((resolve) => setTimeout(resolve, lookupDelays.shift() ?? 0));
return {
location: { directory: input.location.directory },
data: [model(200_000)],
};
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
});
const usage: AgentUsage[] = [];
session.subscribe((event) => {
if (event.type === "usage_updated") usage.push(event.usage);
});
try {
stepEnded(harness, "step-1", {
input: 100,
output: 10,
reasoning: 5,
cache: { read: 1_000, write: 20 },
});
stepEnded(harness, "step-2", {
input: 300,
output: 10,
reasoning: 5,
cache: { read: 1_000, write: 20 },
});
await expect.poll(() => usage.length).toBe(2);
expect(usage.map((entry) => entry.contextWindowUsedTokens)).toEqual([1_135, 1_335]);
expect(usage[1]).toEqual({
inputTokens: 900,
outputTokens: 90,
cachedInputTokens: 40,
totalCostUsd: 0.5,
contextWindowUsedTokens: 1_335,
contextWindowMaxTokens: 200_000,
});
} finally {
await session.close();
}
});
});
@@ -0,0 +1,61 @@
import type { ModelRef, OpenCodeEvent, SessionInfo, TokenUsageInfo } from "@opencode/client";
import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
import type { V2Api } from "./api.js";
import { usageFromV2 } from "./mapping.js";
interface UsageOptions {
client(): V2Api;
cwd: string;
info(): SessionInfo;
emit(event: AgentStreamEvent): void;
reportError(error: unknown): void;
}
// A step is one model call, so its tokens are the context that call used.
function contextWindowUsedTokens(tokens: TokenUsageInfo) {
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write;
}
export class SessionUsage {
private readonly limits = new Map<string, Promise<number | undefined>>();
private reports: Promise<void> = Promise.resolve();
constructor(private readonly options: UsageOptions) {}
observe(event: OpenCodeEvent) {
if (event.type !== "session.step.ended") return;
const { tokens } = event.data;
// Reports stay in step order while a model's context limit is still being looked up.
this.reports = this.reports
.then(() => this.report(tokens))
.catch((error: unknown) => this.options.reportError(error));
}
private async report(tokens: TokenUsageInfo) {
const model = this.options.info().model;
const contextWindowMaxTokens = model ? await this.contextLimit(model) : undefined;
this.options.emit({
type: "usage_updated",
provider: "opencode",
usage: {
...usageFromV2(this.options.info()),
contextWindowUsedTokens: contextWindowUsedTokens(tokens),
...(contextWindowMaxTokens === undefined ? {} : { contextWindowMaxTokens }),
},
});
}
private contextLimit(model: ModelRef) {
const key = `${model.providerID}/${model.id}`;
const cached = this.limits.get(key);
if (cached) return cached;
const limit = this.options
.client()
.model.list({ location: { directory: this.options.cwd } })
.then(
(catalog) =>
catalog.data.find(
(entry) => entry.providerID === model.providerID && entry.id === model.id,
)?.limit.context,
);
this.limits.set(key, limit);
limit.catch(() => this.limits.delete(key));
return limit;
}
}
@@ -1,7 +1,10 @@
import type { ForgeService, PullRequestCheckoutTarget } from "../services/forge-service.js";
import type { WorktreeSource } from "../utils/worktree.js";
export type WorktreeCreationIntent = Exclude<WorktreeSource, { kind: "restore" }>;
export type WorktreeCreationIntent = Exclude<
WorktreeSource,
{ kind: "restore" | "restore-from-base" }
>;
export interface ResolveWorktreeCreationIntentInput {
worktreeSlug?: string;
@@ -357,6 +357,67 @@ test("preserves ordinary checkout behavior when no separate base was recorded",
).toMatchObject({ currentBranch: "feature", baseRef: "feature" });
});
test("restores a branchless workspace from its recorded base so work can continue", async () => {
const fixture = await createBaseRecoveryFixture("refs/heads/release");
execFileSync("git", ["switch", "-c", "release"], { cwd: fixture.repoDir, stdio: "pipe" });
writeFileSync(join(fixture.repoDir, "release.txt"), "release\n");
execFileSync("git", ["add", "release.txt"], { cwd: fixture.repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "release"], { cwd: fixture.repoDir, stdio: "pipe" });
execFileSync("git", ["switch", "main"], { cwd: fixture.repoDir, stdio: "pipe" });
await fixture.registry.update(fixture.workspace.workspaceId, (workspace) => ({
...workspace,
branch: null,
}));
await fixture.archiveAndRemove();
await expect(fixture.service.inspect(fixture.workspace.workspaceId)).resolves.toMatchObject({
kind: "recoverable",
action: "restore",
branch: null,
});
await expect(fixture.service.restore(fixture.workspace.workspaceId)).resolves.toEqual({
workspaceId: fixture.workspace.workspaceId,
action: "restore",
});
const checkout = await getCheckoutStatus(fixture.workspace.cwd, { paseoHome: fixture.paseoHome });
expect(checkout.currentBranch).toBeTruthy();
expect(checkout.currentBranch).not.toBe("feature");
expect(
execFileSync("git", ["rev-parse", "HEAD"], {
cwd: fixture.workspace.cwd,
encoding: "utf8",
}).trim(),
).toBe(
execFileSync("git", ["rev-parse", "release"], {
cwd: fixture.repoDir,
encoding: "utf8",
}).trim(),
);
});
test("restores a branchless workspace from the repository default when its base is missing", async () => {
const fixture = await createBaseRecoveryFixture("refs/heads/deleted-base");
await fixture.registry.update(fixture.workspace.workspaceId, (workspace) => ({
...workspace,
branch: null,
}));
await fixture.archiveAndRemove();
await expect(fixture.service.restore(fixture.workspace.workspaceId)).resolves.toEqual({
workspaceId: fixture.workspace.workspaceId,
action: "restore",
});
expect(
execFileSync("git", ["rev-parse", "HEAD"], {
cwd: fixture.workspace.cwd,
encoding: "utf8",
}).trim(),
).toBe(
execFileSync("git", ["rev-parse", "main"], { cwd: fixture.repoDir, encoding: "utf8" }).trim(),
);
});
test("restores the branch HEAD with a base name when the exact base is missing", async () => {
const fixture = await createBaseRecoveryFixture("refs/remotes/upstream/main");
const headBeforeArchive = execFileSync("git", ["rev-parse", "HEAD"], {
@@ -1,5 +1,7 @@
import { randomUUID } from "node:crypto";
import { basename } from "node:path";
import { resolveRepositoryDefaultBranch } from "../../../utils/checkout-git.js";
import { createRealpathAwarePathMatcher } from "../../../utils/path.js";
import { runGitCommand } from "../../../utils/run-git-command.js";
import {
@@ -7,6 +9,7 @@ import {
isPaseoOwnedWorktreeCwd,
mapWorkspaceCwdToWorktree,
rollbackCreatedPaseoWorktree,
type WorktreeSource,
} from "../../../utils/worktree.js";
import { WorktreeRequestError, toWorktreeRequestError } from "../../worktree-errors.js";
import {
@@ -109,15 +112,6 @@ export function createWorkspaceRecoveryService(deps: {
message: "The archived workspace directory no longer exists and cannot be recreated.",
};
}
if (!workspace.branch) {
return {
kind: "unavailable",
workspaceId,
reason: "worktree_branch_missing",
message: "The archived worktree has no branch recorded, so it cannot be restored.",
};
}
// COMPAT(worktreeRestoreMissingMainRepoRoot): records created before v0.1.110
// lack placement ownership; remove the project-root fallback after 2027-01-17.
const sourceRepoRoot = workspace.mainRepoRoot ?? project.rootPath;
@@ -157,13 +151,13 @@ export function createWorkspaceRecoveryService(deps: {
workspace: PersistedWorkspaceRecord,
sourceRepoRoot: string,
): Promise<void> {
const branch = workspace.branch;
if (!branch) {
throw new WorktreeRequestError({
code: "unknown",
message: `Workspace ${workspace.workspaceId} has no branch to restore`,
});
}
const source: WorktreeSource = workspace.branch
? { kind: "restore", branchName: workspace.branch, baseRef: workspace.baseBranch }
: {
kind: "restore-from-base",
branchName: `restored/${randomUUID().slice(0, 8)}`,
baseRef: await resolveRecoveryBase(sourceRepoRoot, workspace.baseBranch),
};
try {
await runGitCommand(["worktree", "prune"], { cwd: sourceRepoRoot, timeout: 30_000 });
@@ -189,7 +183,7 @@ export function createWorkspaceRecoveryService(deps: {
const result = await createWorktree({
cwd: sourceRepoRoot,
worktreeSlug: basename(previousWorktreePath),
source: { kind: "restore", branchName: branch, baseRef: workspace.baseBranch },
source,
runSetup: false,
paseoHome: deps.paseoHome,
worktreesRoot: deps.worktreesRoot,
@@ -234,6 +228,26 @@ export function createWorkspaceRecoveryService(deps: {
return { inspect, restore };
}
async function resolveRecoveryBase(repoRoot: string, recordedBase: string | null): Promise<string> {
if (recordedBase) {
try {
await runGitCommand(["rev-parse", "--verify", `${recordedBase}^{commit}`], { cwd: repoRoot });
return recordedBase;
} catch {
// The saved base was removed; use the repository default for continued work.
}
}
const defaultBranch = await resolveRepositoryDefaultBranch(repoRoot);
if (!defaultBranch) {
throw new WorktreeRequestError({
code: "unknown",
message:
"The saved base is unavailable and the repository has no default branch to restore from.",
});
}
return defaultBranch;
}
function createRecoveryPlan(
input:
| { action: "unarchive"; workspace: PersistedWorkspaceRecord }
+18
View File
@@ -186,6 +186,7 @@ export type WorktreeSource =
| { kind: "branch-off"; baseBranch: string; branchName: string }
| { kind: "checkout-branch"; branchName: string }
| { kind: "restore"; branchName: string; baseRef: string | null }
| { kind: "restore-from-base"; baseRef: string; branchName: string }
| {
kind: "checkout-change-request";
forge: string;
@@ -1341,6 +1342,21 @@ async function resolveRestoredWorktreeSourcePlan(
};
}
async function resolveRestoredWorktreeFromBasePlan(
cwd: string,
source: Extract<WorktreeSource, { kind: "restore-from-base" }>,
): Promise<WorktreeSourcePlan> {
await validateGitBranchName(cwd, source.branchName);
const baseRef = await resolveBaseBranchForWorktree(cwd, source.baseRef);
const branchName = await resolveUniqueLocalBranchName(cwd, source.branchName);
return {
branchName,
metadataBaseRefName: normalizeRequiredBaseBranch(source.baseRef),
metadataBaseRef: baseRef,
addArguments: ["-b", branchName, "--no-track", baseRef],
};
}
async function resolveBranchOffWorktreeSourcePlan(
cwd: string,
source: Extract<WorktreeSource, { kind: "branch-off" }>,
@@ -1377,6 +1393,8 @@ async function resolveWorktreeSourcePlan({
return resolveBranchOffWorktreeSourcePlan(cwd, source, desiredSlug);
case "restore":
return resolveRestoredWorktreeSourcePlan(cwd, source);
case "restore-from-base":
return resolveRestoredWorktreeFromBasePlan(cwd, source);
case "checkout-branch": {
await validateGitBranchName(cwd, source.branchName);
await ensureLocalBranch(cwd, source.branchName);