Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andBae Seongmin 2291f54dc5 fix(app): paste copied text and its image together
Pasting from Word put only an image of the text in the composer, because
the paste handler cancelled the paste whenever the clipboard held an
image. Let the text paste normally when the clipboard has text, and still
attach the images, so content copied with both (Word, or the desktop
browser's element screenshot) keeps both.

Fixes #3851

Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com>
2026-10-05 11:15:59 +02:00
Bae Seongmin 5eaf6d5464 Merge branch 'main' into fix/paste-text-with-image-fallback 2026-09-29 23:25:10 +09:00
3ae3ae 6dc17b2f1b Merge main into fix/paste-text-with-image-fallback 2026-09-29 16:05:21 +09:00
3ae3ae 052974a308 fix(app): preserve text when the clipboard also contains an image 2026-09-29 15:43:16 +09:00
41 changed files with 298 additions and 1382 deletions
@@ -1,122 +0,0 @@
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();
}
});
}
@@ -18,6 +18,7 @@ import {
expectAttachButtonDisabled,
fillComposerDraft,
dropFileOnComposer,
pasteClipboardIntoComposer,
sendDraftToQueue,
expectQueuedMessageButton,
startRunningMockAgent,
@@ -53,6 +54,43 @@ const TEST_JSON = {
};
test.describe("Composer attachments", () => {
test("pasting copied document text keeps the text and attaches its image", async ({
page,
context,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "paste-document-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
const text = "Copied paragraph with 한글.\nSecond line.";
await pasteClipboardIntoComposer(page, context, {
text,
html: "<p>Copied paragraph with 한글.</p><p>Second line.</p>",
png: MINIMAL_PNG,
});
await expectComposerDraft(page, text);
await expectAttachmentPill(page, "composer-image-attachment-pill");
});
test("pasting an image without text attaches it and leaves the draft empty", async ({
page,
context,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "paste-image-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await pasteClipboardIntoComposer(page, context, { png: MINIMAL_PNG });
await expectAttachmentPill(page, "composer-image-attachment-pill");
await expectComposerDraft(page, "");
});
test("selected file shows a loading attachment until upload is acknowledged", async ({
page,
withWorkspace,
@@ -1,7 +1,6 @@
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { existsSync, writeFileSync } from "node:fs";
import path from "node:path";
import {
restoreArchivedWorkspace,
@@ -355,88 +354,6 @@ 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`;
@@ -505,12 +422,6 @@ 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" });
@@ -1,88 +0,0 @@
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 } });
}
});
}
+25 -1
View File
@@ -1,4 +1,4 @@
import { expect, type Page } from "@playwright/test";
import { expect, type BrowserContext, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { createTempGitRepo } from "./workspace";
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
@@ -172,6 +172,30 @@ export async function dropFileOnComposer(
await dataTransfer.dispose();
}
/** Puts the given formats on the system clipboard together, then pastes them into the composer with the keyboard shortcut. */
export async function pasteClipboardIntoComposer(
page: Page,
context: BrowserContext,
clipboard: { text?: string; html?: string; png?: Buffer },
): Promise<void> {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.evaluate(
async ({ text, html, pngBase64 }) => {
const formats: Record<string, Blob> = {};
if (text !== undefined) formats["text/plain"] = new Blob([text], { type: "text/plain" });
if (html !== undefined) formats["text/html"] = new Blob([html], { type: "text/html" });
if (pngBase64 !== undefined) {
const bytes = Uint8Array.from(atob(pngBase64), (char) => char.charCodeAt(0));
formats["image/png"] = new Blob([bytes], { type: "image/png" });
}
await navigator.clipboard.write([new ClipboardItem(formats)]);
},
{ text: clipboard.text, html: clipboard.html, pngBase64: clipboard.png?.toString("base64") },
);
await composerInput(page).click();
await page.keyboard.press("ControlOrMeta+v");
}
/** Hover to reveal the X button (hidden until hover on desktop web), then click by accessible label. */
export async function removeAttachmentPill(
page: Page,
@@ -1357,10 +1357,6 @@ 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}>
@@ -1371,16 +1367,15 @@ function DesktopFeatureItem({
surface="toolbar"
label={feature.label}
value={selectedOption?.label ?? feature.label}
showToolbarLabel={!iconOnly}
open={openSelector === featureSelector}
disabled={disabled}
onPress={handleSelectPress}
accessibilityLabel={tooltip}
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
/>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{tooltip}</Text>
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
</TooltipContent>
</Tooltip>
<Combobox
+3 -1
View File
@@ -475,7 +475,9 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
const imageFiles = collectImageFilesFromClipboardData(event.clipboardData);
if (imageFiles.length === 0) return;
event.preventDefault();
// Copied text often carries an image of itself, as from Word. Let the
// text paste normally and attach the images alongside it.
if (!event.clipboardData?.getData("text/plain")) event.preventDefault();
void filesToImageAttachments(imageFiles)
.then((pastedAttachments) => {
+1 -3
View File
@@ -439,9 +439,7 @@ export const ar: TranslationResources = {
recovery: {
archivedTitle: "مساحة العمل مؤرشفة",
restoreDescription:
"استعد {{workspaceName}} للعودة إلى وكلائها. ستستخدم شجرة العمل الفرع {{branch}}.",
restoreWithoutBranchDescription:
"استعد {{workspaceName}} للعودة إلى وكلائها. سيبدأ فرع جديد من الفرع الأساسي المحفوظ أو الفرع الافتراضي للمستودع.",
"تمت أرشفة {{workspaceName}} وإزالة شجرة العمل الخاصة بها. استعد الفرع {{branch}} لفتحها مجددًا.",
unarchiveDescription: "{{workspaceName}} مؤرشفة. ألغِ أرشفتها لفتحها مجددًا.",
restoreAction: "استعادة",
unarchiveAction: "إلغاء الأرشفة",
+1 -3
View File
@@ -436,9 +436,7 @@ export const en = {
recovery: {
archivedTitle: "Workspace archived",
restoreDescription:
"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.",
"{{workspaceName}} was archived and its worktree was removed. Restore branch {{branch}} to open it again.",
unarchiveDescription: "{{workspaceName}} is archived. Unarchive it to open it again.",
restoreAction: "Restore",
unarchiveAction: "Unarchive",
+1 -3
View File
@@ -444,9 +444,7 @@ export const es: TranslationResources = {
recovery: {
archivedTitle: "Espacio de trabajo archivado",
restoreDescription:
"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.",
"{{workspaceName}} se archivó y se eliminó su worktree. Restaura la rama {{branch}} para volver a abrirlo.",
unarchiveDescription:
"{{workspaceName}} está archivado. Desarchívalo para volver a abrirlo.",
restoreAction: "Restaurar",
+1 -3
View File
@@ -445,9 +445,7 @@ export const fr: TranslationResources = {
recovery: {
archivedTitle: "Espace de travail archivé",
restoreDescription:
"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.",
"{{workspaceName}} a été archivé et son worktree supprimé. Restaurez la branche {{branch}} pour le rouvrir.",
unarchiveDescription: "{{workspaceName}} est archivé. Désarchivez-le pour le rouvrir.",
restoreAction: "Restaurer",
unarchiveAction: "Désarchiver",
+1 -3
View File
@@ -444,9 +444,7 @@ export const ja: TranslationResources = {
recovery: {
archivedTitle: "ワークスペースはアーカイブ済みです",
restoreDescription:
"{{workspaceName}} を復元してエージェントに戻ります。worktree ではブランチ {{branch}} を使用します。",
restoreWithoutBranchDescription:
"{{workspaceName}} を復元してエージェントに戻ります。保存されたベース、またはリポジトリのデフォルトブランチから新しいブランチを作成します。",
"{{workspaceName}} はアーカイブされ、worktree が削除されました。ブランチ {{branch}} を復元して再度開きます。",
unarchiveDescription:
"{{workspaceName}} はアーカイブされています。再度開くにはアーカイブを解除してください。",
restoreAction: "復元",
+1 -3
View File
@@ -440,9 +440,7 @@ export const ko: TranslationResources = {
recovery: {
archivedTitle: "워크스페이스가 보관되었습니다",
restoreDescription:
"{{workspaceName}}를 복원하여 에이전트로 돌아갑니다. 워크트리는 {{branch}} 브랜치를 사용합니다.",
restoreWithoutBranchDescription:
"{{workspaceName}}를 복원하여 에이전트로 돌아갑니다. 저장된 기반 브랜치 또는 저장소 기본 브랜치에서 새 브랜치를 만듭니다.",
"{{workspaceName}}가 보관되고 워크트리가 제거되었습니다. 다시 열려면 {{branch}} 브랜치를 복원하세요.",
unarchiveDescription: "{{workspaceName}}가 보관되었습니다. 다시 열려면 보관을 취소하세요.",
restoreAction: "복원",
unarchiveAction: "보관 취소",
+1 -3
View File
@@ -443,9 +443,7 @@ export const ptBR: TranslationResources = {
recovery: {
archivedTitle: "Workspace arquivado",
restoreDescription:
"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.",
"{{workspaceName}} foi arquivado e sua worktree foi removida. Restaure a branch {{branch}} para abri-lo novamente.",
unarchiveDescription:
"{{workspaceName}} está arquivado. Desarquive-o para abri-lo novamente.",
restoreAction: "Restaurar",
+1 -3
View File
@@ -443,9 +443,7 @@ export const ru: TranslationResources = {
recovery: {
archivedTitle: "Рабочая область в архиве",
restoreDescription:
"Восстановите «{{workspaceName}}», чтобы вернуться к его агентам. Worktree будет использовать ветку {{branch}}.",
restoreWithoutBranchDescription:
"Восстановите «{{workspaceName}}», чтобы вернуться к его агентам. Новая ветка будет создана от сохранённой базовой или ветки репозитория по умолчанию.",
"Рабочее пространство «{{workspaceName}}» было архивировано, а его worktree удалён. Восстановите ветку {{branch}}, чтобы снова открыть рабочее пространство.",
unarchiveDescription:
"Рабочее пространство «{{workspaceName}}» находится в архиве. Разархивируйте его, чтобы снова открыть.",
restoreAction: "Восстановить",
+1 -3
View File
@@ -439,9 +439,7 @@ export const zhCN: TranslationResources = {
recovery: {
archivedTitle: "Workspace 已归档",
restoreDescription:
"恢复 {{workspaceName}} 以返回其中的代理。worktree 将使用分支 {{branch}}。",
restoreWithoutBranchDescription:
"恢复 {{workspaceName}} 以返回其中的代理。新分支将从保存的基准分支或仓库默认分支创建。",
"{{workspaceName}} 已归档,其 worktree 已移除。恢复分支 {{branch}} 以重新打开。",
unarchiveDescription: "{{workspaceName}} 已归档。取消归档以重新打开。",
restoreAction: "恢复",
unarchiveAction: "取消归档",
@@ -126,21 +126,15 @@ function ArchivedWorkspaceRecovery({
if (recovery.phase === "failed") {
actionLabel = t("common.actions.retry");
}
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,
});
}
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,
});
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, getZoomFactor: () => 1, capturePage });
const { service } = harness({ isDestroyed: () => false, capturePage });
await expect(
service.capture({
@@ -41,25 +41,9 @@ 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, getZoomFactor: () => 1, capturePage });
const { service } = harness({ isDestroyed: () => false, capturePage });
await expect(
service.capture({ browserId: "browser-1", hostWebContentsId: 42, rect: { width: 0 } }),
@@ -5,7 +5,6 @@ export interface BrowserCaptureImage {
export interface BrowserCaptureGuest<TImage extends BrowserCaptureImage = BrowserCaptureImage> {
isDestroyed(): boolean;
getZoomFactor(): number;
capturePage(rect: BrowserCaptureRect): Promise<TImage>;
}
@@ -41,9 +40,7 @@ export interface BrowserCaptureService {
copy(payload: unknown): Promise<boolean>;
}
// 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 {
function captureRect(value: unknown): 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];
@@ -54,9 +51,7 @@ function captureRect(value: unknown, zoomFactor: number): BrowserCaptureRect | n
) {
return null;
}
const [x, y, width, height] = (coordinates as number[]).map(
(coordinate) => coordinate * zoomFactor,
);
const [x, y, width, height] = coordinates as number[];
if (width <= 0 || height <= 0) return null;
return {
x: Math.max(0, Math.round(x)),
@@ -85,9 +80,8 @@ 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);
if (!guest || guest.isDestroyed()) return null;
const bounds = captureRect(rect, guest.getZoomFactor());
if (!bounds) return null;
const bounds = captureRect(rect);
if (!guest || guest.isDestroyed() || !bounds) return null;
try {
const image = await guest.capturePage(bounds);
return image.isEmpty() ? null : image.toDataURL();
@@ -1,9 +1,7 @@
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;
@@ -12,23 +10,6 @@ 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[] = [];
@@ -39,7 +20,20 @@ describe("editor target runtime", () => {
ELECTRON_RUN_AS_NODE: "1",
},
pathExists: (targetPath) => targetPath === "C:/Program Files/Editors & Tools/bin/code.cmd",
spawn: recordSpawns(records),
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;
},
});
const command = runtime.resolveCommand(["missing", "code"]);
@@ -63,32 +57,4 @@ 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,28 +56,10 @@ 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: {
searchDirectories: readonly string[];
env: NodeJS.ProcessEnv;
pathExists: (path: string) => boolean;
platform: NodeJS.Platform;
},
@@ -87,7 +69,10 @@ function resolveExecutable(
return command;
}
for (const directory of input.searchDirectories) {
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;
const candidate = `${directory}/${command}`;
if (input.platform !== "win32") {
if (input.pathExists(candidate)) return candidate;
@@ -155,12 +140,7 @@ export function createEditorTargetRuntime(
env,
pathExists,
isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform),
resolveCommand: (commands) =>
resolveExecutable(commands, {
searchDirectories: commandSearchDirectories({ env, platform, homeDirectory }),
pathExists,
platform,
}),
resolveCommand: (commands) => resolveExecutable(commands, { env, 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", "dev.zed.Zed"] as const;
const COMMANDS = ["zed", "zeditor"] as const;
function location(input: EditorTargetLaunchInput): string {
if (!input.line) return input.filePath!;
@@ -49,22 +49,6 @@ 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,8 +145,6 @@ 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,7 +301,6 @@ 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,7 +1,4 @@
import type {
AgentFeature,
AgentFeatureSelect,
AgentFeatureToggle,
AgentProviderNotice,
AgentTaskItem,
JsonValue,
@@ -11,13 +8,7 @@ import type {
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { PaseoToolCatalog } from "./tools/types.js";
export type {
AgentFeature,
AgentFeatureSelect,
AgentFeatureToggle,
AgentProviderNotice,
AgentTaskItem,
};
export type { AgentProviderNotice, AgentTaskItem };
export type AgentProvider = string;
@@ -165,6 +156,29 @@ 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,41 +19,6 @@ 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",
@@ -100,7 +65,7 @@ function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSession
function createSessionHarness(
configOverrides: Partial<AgentSessionConfig> = {},
options: { logger?: pino.Logger; models?: unknown[] } = {},
options: { logger?: pino.Logger } = {},
): {
session: CodexFeaturesTestSession;
appServer: FakeCodexAppServer;
@@ -108,7 +73,6 @@ 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 },
@@ -121,7 +85,7 @@ function createSessionHarness(
async function createConnectedSession(
configOverrides: Partial<AgentSessionConfig> = {},
options: { logger?: pino.Logger; models?: unknown[] } = {},
options: { logger?: pino.Logger } = {},
): Promise<{
session: CodexFeaturesTestSession;
appServer: FakeCodexAppServer;
@@ -133,169 +97,8 @@ 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",
@@ -309,15 +112,15 @@ describe("Codex app-server provider features", () => {
try {
expect(session.features).toContainEqual(
expect.objectContaining({
id: "service_tier",
value: "default",
id: "fast_mode",
value: false,
}),
);
await session.setFeature?.("service_tier", "priority");
await session.setFeature?.("fast_mode", true);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
model,
serviceTier: "priority",
serviceTier: "fast",
});
} finally {
await session.close();
@@ -346,8 +149,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.toMatchObject({
serviceTier: "default",
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
serviceTier: expect.anything(),
});
} finally {
await session.close();
@@ -362,8 +165,8 @@ describe("Codex app-server provider features", () => {
try {
expect(session.features).toContainEqual(
expect.objectContaining({
id: "service_tier",
value: "priority",
id: "fast_mode",
value: true,
}),
);
await session.setModel("gpt-5.6-sol");
@@ -371,18 +174,26 @@ describe("Codex app-server provider features", () => {
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
model: "gpt-6-astra",
serviceTier: "priority",
serviceTier: "fast",
});
} finally {
await session.close();
}
});
test("features returns speed selector and plan toggle when supported", async () => {
test("features returns fast and plan toggles when supported", async () => {
const { session } = await createConnectedSession();
expect(session.features).toEqual([
speedFeature("default"),
{
type: "toggle",
id: "fast_mode",
label: "Fast",
description: "Priority inference at increased usage",
tooltip: "Toggle fast mode",
icon: "zap",
value: false,
},
{
type: "toggle",
id: "plan_mode",
@@ -394,11 +205,19 @@ describe("Codex app-server provider features", () => {
},
]);
await session.setFeature?.("service_tier", "priority");
await session.setFeature?.("fast_mode", true);
await session.setFeature?.("plan_mode", true);
expect(session.features).toEqual([
speedFeature("priority"),
{
type: "toggle",
id: "fast_mode",
label: "Fast",
description: "Priority inference at increased usage",
tooltip: "Toggle fast mode",
icon: "zap",
value: true,
},
{
type: "toggle",
id: "plan_mode",
@@ -446,23 +265,23 @@ describe("Codex app-server provider features", () => {
]);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "default",
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
serviceTier: expect.anything(),
});
});
test("selecting Fast sends the catalog service tier", async () => {
test("setFeature('fast_mode', true) sets serviceTier to fast", async () => {
const { session, appServer } = await createConnectedSession();
await session.setFeature?.("service_tier", "priority");
await session.setFeature?.("fast_mode", true);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "priority",
serviceTier: "fast",
});
});
test("legacy Fast off explicitly selects Normal", async () => {
test("setFeature('fast_mode', false) clears serviceTier to null", async () => {
const { session, appServer } = await createConnectedSession({
featureValues: { fast_mode: true },
});
@@ -470,8 +289,8 @@ describe("Codex app-server provider features", () => {
await session.setFeature?.("fast_mode", false);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "default",
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
serviceTier: expect.anything(),
});
});
@@ -511,7 +330,15 @@ describe("Codex app-server provider features", () => {
});
expect(session.features).toEqual([
speedFeature("priority"),
{
type: "toggle",
id: "fast_mode",
label: "Fast",
description: "Priority inference at increased usage",
tooltip: "Toggle fast mode",
icon: "zap",
value: true,
},
{
type: "toggle",
id: "plan_mode",
@@ -525,7 +352,7 @@ describe("Codex app-server provider features", () => {
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "priority",
serviceTier: "fast",
collaborationMode: expect.objectContaining({
mode: "plan",
}),
@@ -535,11 +362,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?.("service_tier", "priority");
await session.setFeature?.("fast_mode", true);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "priority",
serviceTier: "fast",
});
});
@@ -561,7 +388,7 @@ describe("Codex app-server provider features", () => {
msg: "Starting Codex app-server turn",
model: "gpt-5.4",
modeId: "auto",
serviceTier: "priority",
serviceTier: "fast",
cwd: "/tmp/codex-fast-mode-test",
});
expect(JSON.stringify(entry)).not.toContain(prompt);
@@ -570,7 +397,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?.("service_tier", "priority");
await session.setFeature?.("fast_mode", true);
await session.setModel("gpt-3.5-turbo");
expect(session.features).toEqual([
@@ -586,8 +413,8 @@ describe("Codex app-server provider features", () => {
]);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "default",
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
serviceTier: expect.anything(),
});
});
@@ -804,7 +804,6 @@ 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)};
@@ -1171,36 +1170,6 @@ 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,
@@ -1965,66 +1934,6 @@ 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": () => ({
@@ -2290,12 +2199,7 @@ describe("Codex app-server provider", () => {
throw new Error(`resumeSession timed out; thread requests: ${threadRequests.join(", ")}`);
}
expect(threadRequests).toEqual([
"model/list",
"config/read",
"thread/loaded/list",
"thread/resume",
]);
expect(threadRequests).toEqual(["config/read", "thread/loaded/list", "thread/resume"]);
expect(outcome).toBe("rejected");
appServer.assertNoErrors();
});
@@ -2469,71 +2373,6 @@ 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,12 +73,7 @@ import {
import { createPathEquivalenceMatcher } from "../../../utils/path.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import {
buildCodexFeatures,
CodexServiceTierSchema,
readCodexServiceTier,
type CodexServiceTier,
} from "./codex-feature-definitions.js";
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
import {
CodexAppServerClient,
CodexAppServerRpcError,
@@ -174,9 +169,6 @@ 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+)/);
@@ -197,15 +189,6 @@ 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" }
@@ -382,67 +365,19 @@ function isObjectSchemaNode(schema: Record<string, unknown>): boolean {
);
}
// 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 {
function normalizeCodexOutputSchemaNode(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> = { ...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}`,
);
}
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schema)) {
normalized[key] = normalizeCodexOutputSchemaNode(value, `${schemaPath}.${key}`);
}
if (!isObjectSchemaNode(normalized)) {
@@ -946,7 +881,6 @@ interface CodexModel {
model?: string;
defaultReasoningEffort?: string;
supportedReasoningEfforts?: CodexReasoningEffortEntry[];
serviceTiers?: CodexServiceTier[];
}
const CodexModelListResponseSchema = z.object({
@@ -954,7 +888,6 @@ 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(),
@@ -3440,14 +3373,12 @@ 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: string | null = null;
private speedModels: CodexModel[] = [];
private serviceTier: "fast" | null = null;
private planModeEnabled = false;
private historyPending = false;
private persistedHistory: PersistedTimelineEntry[] = [];
@@ -3538,7 +3469,9 @@ 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);
this.serviceTier = readCodexServiceTier(this.config.featureValues);
if (this.config.featureValues?.fast_mode && codexModelSupportsFastMode(this.config.model)) {
this.serviceTier = "fast";
}
if (this.config.featureValues?.plan_mode) {
this.planModeEnabled = true;
}
@@ -3566,8 +3499,8 @@ export class CodexAppServerAgentSession implements AgentSession {
get features(): AgentFeature[] {
return buildCodexFeatures({
serviceTiers: this.currentServiceTiers(),
serviceTier: this.serviceTier ?? "default",
modelId: this.config.model,
fastModeEnabled: this.serviceTier === "fast",
planModeEnabled: this.planModeEnabled,
planModeAvailable: this.hasPlanCollaborationMode(),
});
@@ -3620,15 +3553,9 @@ export class CodexAppServerAgentSession implements AgentSession {
this.registerRequestHandlers();
try {
const initialized = toObjectRecord(
await client.request("initialize", buildCodexAppServerInitializeParams()),
);
this.threadRollbackAvailable = codexServerHasThreadRollback(initialized?.userAgent);
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
this.speedModels =
CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? [];
this.reconcileServiceTier();
await this.loadResolvedWorkspaceWrite();
await this.loadCollaborationModes();
await this.loadSkills();
@@ -3636,7 +3563,6 @@ export class CodexAppServerAgentSession implements AgentSession {
if (this.currentThreadId) {
await this.ensureThreadLoaded();
await this.loadPersistedHistory(this.client);
await this.applyDefaultModelAndThinking();
}
if (this.closed) {
@@ -3833,30 +3759,18 @@ export class CodexAppServerAgentSession implements AgentSession {
this.resolvedCollaborationMode = this.resolveCollaborationMode();
}
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 ?? [];
}
private applyFeatureValue(featureId: "fast_mode" | "plan_mode", value: boolean): void {
this.config.featureValues = {
...this.config.featureValues,
[featureId]: value,
};
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 (featureId === "fast_mode") {
this.serviceTier = value ? "fast" : null;
this.cachedRuntimeInfo = null;
return;
}
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;
@@ -4187,7 +4101,9 @@ export class CodexAppServerAgentSession implements AgentSession {
if (thinkingOptionId) {
params.effort = thinkingOptionId;
}
params.serviceTier = this.serviceTier ?? "default";
if (this.serviceTier) {
params.serviceTier = this.serviceTier;
}
if (this.resolvedCollaborationMode) {
params.collaborationMode = {
mode: this.resolvedCollaborationMode.mode,
@@ -4605,7 +4521,9 @@ export class CodexAppServerAgentSession implements AgentSession {
async setModel(modelId: string | null): Promise<void> {
this.config.model = modelId ?? undefined;
this.reconcileServiceTier();
if (!codexModelSupportsFastMode(this.config.model)) {
this.serviceTier = null;
}
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
}
@@ -4620,27 +4538,13 @@ 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") {
const fast = this.currentServiceTiers().find((tier) => tier.name.toLowerCase() === "fast");
if (value && !fast)
if (Boolean(value) && !codexModelSupportsFastMode(this.config.model)) {
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.serviceTier = value;
this.reconcileServiceTier();
this.cachedRuntimeInfo = null;
this.applyFeatureValue("fast_mode", Boolean(value));
return;
}
if (featureId === "plan_mode") {
@@ -4943,7 +4847,6 @@ 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;
@@ -5257,21 +5160,13 @@ 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 = await this.applyDefaultModelAndThinking();
const { model, thinkingOptionId } = await this.resolveModelAndThinking();
this.config.model = model;
this.config.thinkingOptionId = thinkingOptionId;
const { params, approvalPolicy, sandbox } = this.buildThreadStartRequest(model);
const rawResponse = await this.client.request("thread/start", params);
@@ -1,33 +1,27 @@
import type { AgentFeature, AgentFeatureToggle } from "../agent-sdk-types.js";
import { z } from "zod";
// 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",
]);
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_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 CODEX_PLAN_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
type: "toggle",
@@ -38,22 +32,40 @@ 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: {
serviceTiers: CodexServiceTier[];
serviceTier: string;
modelId: string | null | undefined;
fastModeEnabled: boolean;
planModeEnabled: boolean;
planModeAvailable?: boolean;
}): AgentFeature[] {
const features = buildCodexSpeedFeature(input.serviceTiers, input.serviceTier);
if (input.planModeAvailable !== false) {
features.push({ ...CODEX_PLAN_MODE_FEATURE, value: input.planModeEnabled });
const features: AgentFeature[] = [];
if (codexModelSupportsFastMode(input.modelId)) {
features.push({
...CODEX_FAST_MODE_FEATURE,
value: input.fastModeEnabled,
});
}
if (input.planModeAvailable !== false) {
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,7 +96,6 @@ describe("Codex Rewind", () => {
model: "gpt-5.4-mini",
serviceTier: null,
userMessageTurns,
threadRollbackAvailable: true,
setThreadId: (threadId) => {
reboundThreadId = threadId;
},
@@ -132,7 +131,6 @@ describe("Codex Rewind", () => {
threadId: "source-thread",
messageId: "codex-second",
userMessageTurns,
threadRollbackAvailable: true,
setThreadId: (threadId) => {
reboundThreadId = threadId;
},
@@ -177,7 +175,6 @@ describe("Codex Rewind", () => {
model: "gpt-5.4-mini",
serviceTier: null,
userMessageTurns,
threadRollbackAvailable: true,
setThreadId: (threadId) => {
reboundThreadId = threadId;
},
@@ -221,7 +218,6 @@ describe("Codex Rewind", () => {
model: "gpt-5.4-mini",
serviceTier: null,
userMessageTurns,
threadRollbackAvailable: true,
setThreadId: (threadId) => {
reboundThreadId = threadId;
},
@@ -243,7 +239,6 @@ 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,7 +69,6 @@ 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) {
@@ -99,10 +98,8 @@ export async function revertCodexConversation(input: {
persistExtendedHistory: true,
};
if (
!input.threadRollbackAvailable ||
(await readCodexThreadHistoryMode(input.client, input.threadId)) === "paginated"
) {
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}`);
}
@@ -114,8 +111,7 @@ export async function revertCodexConversation(input: {
return;
}
// 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
// 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,10 +312,6 @@ 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,34 +387,6 @@ 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,7 +35,6 @@ 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";
@@ -46,7 +45,6 @@ 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;
@@ -103,17 +101,6 @@ 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;
@@ -478,7 +465,6 @@ export class OpenCodeV2Session implements AgentSession {
return;
}
this.permissions.observe(event);
this.usage.observe(event);
this.turns.observe(event);
this.scheduleReconcile();
}
@@ -1,100 +0,0 @@
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();
}
});
});
@@ -1,61 +0,0 @@
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,10 +1,7 @@
import type { ForgeService, PullRequestCheckoutTarget } from "../services/forge-service.js";
import type { WorktreeSource } from "../utils/worktree.js";
export type WorktreeCreationIntent = Exclude<
WorktreeSource,
{ kind: "restore" | "restore-from-base" }
>;
export type WorktreeCreationIntent = Exclude<WorktreeSource, { kind: "restore" }>;
export interface ResolveWorktreeCreationIntentInput {
worktreeSlug?: string;
@@ -357,67 +357,6 @@ 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,7 +1,5 @@
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 {
@@ -9,7 +7,6 @@ import {
isPaseoOwnedWorktreeCwd,
mapWorkspaceCwdToWorktree,
rollbackCreatedPaseoWorktree,
type WorktreeSource,
} from "../../../utils/worktree.js";
import { WorktreeRequestError, toWorktreeRequestError } from "../../worktree-errors.js";
import {
@@ -112,6 +109,15 @@ 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;
@@ -151,13 +157,13 @@ export function createWorkspaceRecoveryService(deps: {
workspace: PersistedWorkspaceRecord,
sourceRepoRoot: string,
): Promise<void> {
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),
};
const branch = workspace.branch;
if (!branch) {
throw new WorktreeRequestError({
code: "unknown",
message: `Workspace ${workspace.workspaceId} has no branch to restore`,
});
}
try {
await runGitCommand(["worktree", "prune"], { cwd: sourceRepoRoot, timeout: 30_000 });
@@ -183,7 +189,7 @@ export function createWorkspaceRecoveryService(deps: {
const result = await createWorktree({
cwd: sourceRepoRoot,
worktreeSlug: basename(previousWorktreePath),
source,
source: { kind: "restore", branchName: branch, baseRef: workspace.baseBranch },
runSetup: false,
paseoHome: deps.paseoHome,
worktreesRoot: deps.worktreesRoot,
@@ -228,26 +234,6 @@ 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,7 +186,6 @@ 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;
@@ -1342,21 +1341,6 @@ 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" }>,
@@ -1393,8 +1377,6 @@ 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);