Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] df081a06ed test(opencode): retry temp cleanup while Windows holds the ripgrep download 2026-09-29 08:34:57 +02:00
paseo-bot[bot]and3ae3ae c6a4ae81c9 fix(codex): start sessions on the project's configured model and effort
A session created without a model or reasoning effort read Codex defaults
without its cwd, so a trusted project's .codex/config.toml was ignored and
the global values were sent as explicit overrides. Read the effective config
for the session cwd first and keep getUserSavedConfig as the fallback for
older Codex builds.

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
2026-09-29 08:09:46 +02:00
13 changed files with 71 additions and 157 deletions
-10
View File
@@ -39240,7 +39240,6 @@
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"typescript": "^5.9.3",
"undici": "^7.30.0",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
@@ -39971,15 +39970,6 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"packages/server/node_modules/undici": {
"version": "7.30.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz",
"integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"packages/server/node_modules/vitest": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
@@ -14,7 +14,6 @@ import {
expectConflictPreservesDraft,
discardTitle,
returnAndReopenSettings,
returnToPluginsPage,
expectSettingsUnavailable,
} from "../support/helpers/plugin-settings";
@@ -44,11 +43,6 @@ test("another client's save preserves a conflicting draft", async ({ page }) =>
await discardTitle(page);
});
test("Back to plugins returns from a plugin settings screen", async ({ page }) => {
await openDisplaySettings(page);
await returnToPluginsPage(page);
});
test.describe("compact plugin settings", () => {
test.use({ viewport: { width: 390, height: 844 } });
@@ -3,8 +3,6 @@ import { expect, test as base, type Page } from "../fixtures";
import { gotoAppShell, openSettings } from "./app";
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
import { getServerId } from "./server-id";
import { expectAppRoute } from "./route-assertions";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
@@ -107,13 +105,6 @@ export async function returnAndReopenSettings(page: Page) {
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
}
export async function returnToPluginsPage(page: Page) {
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
await expectAppRoute(page, buildSettingsHostSectionRoute(getServerId(), "plugins"));
await expect(
page.getByRole("button", { name: "Actions for settings-example", exact: true }),
).toBeVisible();
}
export async function expectSettingsUnavailable(page: Page) {
await expect(
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
-1
View File
@@ -2071,7 +2071,6 @@ export const en = {
offline: "Connect to this host to open plugin settings.",
update: "Update this host to use plugin settings.",
unavailable: "This plugin settings screen is unavailable.",
backToPlugins: "Back to plugins",
},
trustedTitle: "Plugins are trusted code",
trustedDescription:
@@ -18,7 +18,6 @@ export const pluginSettings = {
offline: "اتصل بهذا المضيف لفتح إعدادات الإضافة.",
update: "حدّث هذا المضيف لاستخدام إعدادات الإضافة.",
unavailable: "شاشة إعدادات الإضافة هذه غير متاحة.",
backToPlugins: "العودة إلى الإضافات",
},
title: "الإضافات",
trustedTitle: "الإضافات تعليمات برمجية موثوقة",
@@ -73,7 +72,6 @@ export const pluginSettings = {
offline: "Conéctate a este host para abrir los ajustes del plugin.",
update: "Actualiza este host para usar los ajustes del plugin.",
unavailable: "Esta pantalla de ajustes del plugin no está disponible.",
backToPlugins: "Volver a plugins",
},
title: "Plugins",
trustedTitle: "Los plugins son código de confianza",
@@ -128,7 +126,6 @@ export const pluginSettings = {
offline: "Connectez-vous à cet hôte pour ouvrir les paramètres du plugin.",
update: "Mettez à jour cet hôte pour utiliser les paramètres du plugin.",
unavailable: "Cet écran de paramètres du plugin est indisponible.",
backToPlugins: "Retour aux plugins",
},
title: "Plugins",
trustedTitle: "Les plugins sont du code de confiance",
@@ -183,7 +180,6 @@ export const pluginSettings = {
offline: "このホストに接続してプラグイン設定を開いてください。",
update: "プラグイン設定を使用するには、このホストを更新してください。",
unavailable: "このプラグイン設定画面は利用できません。",
backToPlugins: "プラグインに戻る",
},
title: "プラグイン",
trustedTitle: "プラグインは信頼済みコードです",
@@ -238,7 +234,6 @@ export const pluginSettings = {
offline: "플러그인 설정을 열려면 이 호스트에 연결하세요.",
update: "플러그인 설정을 사용하려면 이 호스트를 업데이트하세요.",
unavailable: "이 플러그인 설정 화면을 사용할 수 없습니다.",
backToPlugins: "플러그인으로 돌아가기",
},
title: "플러그인",
trustedTitle: "플러그인은 신뢰하는 코드입니다",
@@ -292,7 +287,6 @@ export const pluginSettings = {
offline: "Conecte-se a este host para abrir as configurações do plugin.",
update: "Atualize este host para usar as configurações do plugin.",
unavailable: "Esta tela de configurações do plugin está indisponível.",
backToPlugins: "Voltar para plugins",
},
title: "Plugins",
trustedTitle: "Plugins são código confiável",
@@ -347,7 +341,6 @@ export const pluginSettings = {
offline: "Подключитесь к этому хосту, чтобы открыть настройки плагина.",
update: "Обновите этот хост, чтобы использовать настройки плагина.",
unavailable: "Этот экран настроек плагина недоступен.",
backToPlugins: "Вернуться к плагинам",
},
title: "Плагины",
trustedTitle: "Плагины — доверенный код",
@@ -402,7 +395,6 @@ export const pluginSettings = {
offline: "连接到此主机以打开插件设置。",
update: "更新此主机以使用插件设置。",
unavailable: "此插件设置页面不可用。",
backToPlugins: "返回插件",
},
title: "插件",
trustedTitle: "插件是受信任的代码",
+3 -27
View File
@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
import { Platform, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { router } from "expo-router";
import { ArrowLeft } from "lucide-react-native";
import type { PluginHostProps } from "@getpaseo/plugin/client";
import { Button } from "@/components/ui/button";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
@@ -128,30 +127,7 @@ function SettingsContent({
}
const ThemedSettingsContent = withUnistyles(SettingsContent);
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
export function PluginSettingsContent({
onBackToPlugins,
showBackToPlugins,
...identity
}: SettingsIdentity & { onBackToPlugins: () => void; showBackToPlugins: boolean }) {
const { t } = useTranslation();
return (
<View>
{showBackToPlugins ? (
<Button
onPress={onBackToPlugins}
variant="ghost"
size="sm"
leftIcon={ArrowLeft}
style={styles.backButton}
>
{t("settings.plugins.screens.backToPlugins")}
</Button>
) : null}
<ThemedSettingsContent {...identity} uniProps={themeMapping} />
</View>
);
export function PluginSettingsContent(props: SettingsIdentity) {
return <ThemedSettingsContent {...props} uniProps={themeMapping} />;
}
const styles = StyleSheet.create((theme) => ({
message: { color: theme.colors.foregroundMuted },
backButton: { alignSelf: "flex-start", paddingHorizontal: 0, marginBottom: theme.spacing[4] },
}));
const styles = StyleSheet.create((theme) => ({ message: { color: theme.colors.foregroundMuted } }));
@@ -1333,8 +1333,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
serverId={view.serverId}
pluginId={view.pluginId}
screenId={view.screenId}
onBackToPlugins={handleBackFromDetail}
showBackToPlugins={!isCompactLayout}
/>
);
if (view.kind === "host") {
-1
View File
@@ -141,7 +141,6 @@
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"typescript": "^5.9.3",
"undici": "^7.30.0",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
@@ -1117,6 +1117,42 @@ describe("Codex app-server provider", () => {
});
});
test("starts a session without a model on the project's Codex model and effort", async () => {
const cwd = "/workspace/project";
const appServer = createFakeCodexAppServer({
"config/read": (params) => ({
config:
(params as { cwd?: unknown }).cwd === cwd
? { model: "project-model", model_reasoning_effort: "high" }
: { model: "global-model", model_reasoning_effort: "low" },
}),
getUserSavedConfig: () => ({
config: { model: "global-model", modelReasoningEffort: "low" },
}),
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd, model: undefined }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.startTurn("use project defaults");
await expect(appServer.waitForRequest("thread/start")).resolves.toMatchObject({
model: "project-model",
});
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
model: "project-model",
effort: "high",
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("preserves cwd-resolved Codex writable roots under an explicit workflow mode", async () => {
const appServer = createFakeCodexAppServer({
"config/read": () => ({
@@ -3100,10 +3100,32 @@ const CodexNotificationSchema = z.union([
),
]);
// config/read resolves the effective layered config, including a trusted project's
// .codex/config.toml when given its cwd; getUserSavedConfig predates it.
async function readCodexConfiguredDefaults(
client: CodexAppServerClient,
logger: Logger,
cwd: string | null,
): Promise<CodexConfiguredDefaults> {
let configReadDefaults: CodexConfiguredDefaults = {};
try {
const response = toObjectRecord(await client.request("config/read", { cwd }));
const config = toObjectRecord(response?.config);
const modelValue = typeof config?.model === "string" ? config.model : undefined;
const thinkingOptionValue =
typeof config?.model_reasoning_effort === "string" ? config.model_reasoning_effort : null;
configReadDefaults = {
model: normalizeCodexModelId(modelValue),
thinkingOptionId: normalizeCodexThinkingOptionId(thinkingOptionValue),
};
} catch (error) {
logger.debug({ error }, "Failed to read Codex config defaults");
}
if (configReadDefaults.model && configReadDefaults.thinkingOptionId) {
return configReadDefaults;
}
let savedConfigDefaults: CodexConfiguredDefaults = {};
try {
const response = toObjectRecord(await client.request("getUserSavedConfig", {}));
@@ -3119,26 +3141,7 @@ async function readCodexConfiguredDefaults(
logger.debug({ error }, "Failed to read Codex saved config defaults");
}
if (savedConfigDefaults.model && savedConfigDefaults.thinkingOptionId) {
return savedConfigDefaults;
}
let configReadDefaults: CodexConfiguredDefaults = {};
try {
const response = toObjectRecord(await client.request("config/read", {}));
const config = toObjectRecord(response?.config);
const modelValue = typeof config?.model === "string" ? config.model : undefined;
const thinkingOptionValue =
typeof config?.model_reasoning_effort === "string" ? config.model_reasoning_effort : null;
configReadDefaults = {
model: normalizeCodexModelId(modelValue),
thinkingOptionId: normalizeCodexThinkingOptionId(thinkingOptionValue),
};
} catch (error) {
logger.debug({ error }, "Failed to read Codex config defaults");
}
return mergeCodexConfiguredDefaults(savedConfigDefaults, configReadDefaults);
return mergeCodexConfiguredDefaults(configReadDefaults, savedConfigDefaults);
}
interface CodexSkillPromptBlock {
@@ -5119,7 +5122,11 @@ export class CodexAppServerAgentSession implements AgentSession {
let model = this.config.model;
let thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
if (!model || !thinkingOptionId) {
configuredDefaults = await readCodexConfiguredDefaults(this.client, this.logger);
configuredDefaults = await readCodexConfiguredDefaults(
this.client,
this.logger,
this.config.cwd ?? null,
);
}
if (!model) {
model = configuredDefaults.model;
@@ -7323,7 +7330,7 @@ export class CodexAppServerAgentClient implements AgentClient {
const parsedResponse = CodexModelListResponseSchema.safeParse(rawResponse);
const models = parsedResponse.success ? (parsedResponse.data.data ?? []) : [];
const configuredDefaults = await runProviderRefreshActivity(context, "config/read", () =>
readCodexConfiguredDefaults(client!, this.logger),
readCodexConfiguredDefaults(client!, this.logger, null),
);
const configuredDefaultModelId = configuredDefaults.model;
const configuredDefaultThinkingOptionId = configuredDefaults.thinkingOptionId;
@@ -355,7 +355,8 @@ test.each([
await resumed?.close();
await original?.close();
await client.shutdown();
await rm(root, { recursive: true, force: true });
// OpenCode can still hold its ripgrep download open on Windows right after shutdown.
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
}
},
240_000,
@@ -1,62 +0,0 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Agent, getGlobalDispatcher, setGlobalDispatcher } from "undici";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
import { V2Runtime } from "./runtime.js";
// Answers every request at once except session.wait, which it holds for WAIT_MS before answering,
// the way OpenCode holds the wait response until the session's agent loop is idle.
const FAKE_HELPER = `
import http from "node:http";
const server = http.createServer((request, response) => {
if (request.url.endsWith("/wait")) {
setTimeout(() => { response.statusCode = 204; response.end(); }, Number(process.env.WAIT_MS));
return;
}
response.setHeader("content-type", "application/json");
response.end("{}");
});
server.listen(0, "127.0.0.1", () => {
console.log("server listening on http://127.0.0.1:" + server.address().port);
});
`;
let root: string;
const defaultDispatcher = getGlobalDispatcher();
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-runtime-"));
vi.stubEnv("PASEO_HOME", path.join(root, "home"));
});
afterEach(async () => {
setGlobalDispatcher(defaultDispatcher);
vi.unstubAllEnvs();
await rm(root, { recursive: true, force: true });
});
test("session.wait outlasts the fetch header deadline until the helper reports the session idle", async () => {
// Stands in for undici's five-minute default, which ended every longer turn.
setGlobalDispatcher(new Agent({ headersTimeout: 200 }));
const helper = path.join(root, "fake-opencode.mjs");
await writeFile(helper, FAKE_HELPER);
const runtime = new V2Runtime({
logger: createTestLogger(),
settings: {
command: { mode: "replace", argv: [process.execPath, helper] },
env: { WAIT_MS: "2500" },
},
decorateEnv: async (env) => env,
});
try {
const connection = await runtime.acquire();
await expect(connection.client.session.wait({ sessionID: "ses_long" })).resolves.toBe(
undefined,
);
} finally {
await runtime.shutdown();
}
});
@@ -5,7 +5,6 @@ import type { V2Api } from "./api.js";
import { randomBytes } from "node:crypto";
import { mkdir } from "node:fs/promises";
import type { Logger } from "pino";
import { Agent } from "undici";
import { spawnProcess } from "../../../../../utils/spawn.js";
import { terminateWithTreeKill } from "../../../../../utils/tree-kill.js";
import {
@@ -175,9 +174,6 @@ export class V2Runtime {
}),
});
const processAbort = new AbortController();
// session.wait answers only once the session is idle, so a header deadline would fail any
// turn that outlasts it. Requests end when the helper exits or the caller aborts instead.
const dispatcher = new Agent({ headersTimeout: 0 });
const exited = new Promise<Error>((resolve) =>
process.once("exit", (code) => {
const error = new Error(`OpenCode helper server exited (${code})`);
@@ -203,7 +199,6 @@ export class V2Runtime {
const stop = () => {
stopped ??= (async () => {
await terminateWithTreeKill(process, { gracefulTimeoutMs: 5_000, forceTimeoutMs: 1_000 });
await dispatcher.destroy();
const entry = await record;
if (entry) await managedProcesses?.remove(entry.id);
})();
@@ -248,9 +243,7 @@ export class V2Runtime {
const signal = init?.signal
? AbortSignal.any([init.signal, processAbort.signal])
: processAbort.signal;
// Node's fetch accepts an undici dispatcher, which the DOM RequestInit type omits.
const requestInit: RequestInit & { dispatcher: Agent } = { ...init, signal, dispatcher };
const response = await fetch(request, requestInit);
const response = await fetch(request, { ...init, signal });
const html = response.headers.get("content-type")?.includes("text/html") ?? false;
if (!response.ok || html) {
const requestUrl = request instanceof Request ? request.url : String(request);