diff --git a/devlog/_plan/260724_bugfix_train/061_root_suite_investigation.md b/devlog/_plan/260724_bugfix_train/061_root_suite_investigation.md index 5f6df052b..56641f05e 100644 --- a/devlog/_plan/260724_bugfix_train/061_root_suite_investigation.md +++ b/devlog/_plan/260724_bugfix_train/061_root_suite_investigation.md @@ -110,3 +110,31 @@ Cycle 6 may update the contributor branch only when: - two consecutive post-change `bun run test` executions pass; - the focused release-helper run is supporting evidence, not sole closure; and - required Cross-platform CI is green on the exact pushed head. + +## 2026-07-24 — Cycle 6 investigation result + +- Live contributor head at takeover: `5f84301252865dc6f792d3272c2fe0db6d09eb0e`. +- `CYCLE6_BASE_SHA`: `c63589ccfe9e053d92acc029f55be0a809fb6fca` (`origin/dev` after the cycle-start fetch). +- Rebased pre-repair head: `8feb0980`; `git range-diff` mapped both contributor commits exactly (`=`) to `458b3a39` and `8feb0980`. +- Environment: macOS arm64 (`Darwin 25.6.0`), Bun `1.3.14`. +- Evidence directory: `devlog/_plan/260724_bugfix_train/evidence/cycle6-pr337-root/`. +- The contributor's retained public report contains only the aggregate `3780 passed, 4 skipped, 4 failed` and says the four failures were in this five-test file. It does not retain the four failing names, assertion text, stderr, or first-failure line. The original four failure identities therefore remain unresolved. + +### Inferred current candidates — not recovered failures + +The four success-path cases below are investigation candidates only because they are the four cases in the current five-test file that require a zero release-helper exit. They are not recovered names for the originally reported failures and cannot receive per-failure classifications without the missing original reporter evidence. + +| Inferred current candidate | Original failing log/line | Current rerun evidence | Classification | Causal evidence | Owner path | Disposition | +|---|---|---|---|---|---|---| +| `preflight runs typecheck, test suite, and privacy scan before version bump on main dry-runs` | Unavailable; current source line 216 is not an original failure line | PR: standalone 3/3 + full 3/3 pass; base: standalone 1/1 + full 2/2 pass | Unresolved — original failure identity unavailable | This current case passed in every recorded PR/base order; it does not identify an original failure | `tests/release-helper.test.ts` | No per-failure disposition; suite-level unresolved | +| `preview branch still defaults to preview tag and dry-run dispatch` | Unavailable; current source line 250 is not an original failure line | PR: standalone 3/3 + full 3/3 pass; base: standalone 1/1 + full 2/2 pass | Unresolved — original failure identity unavailable | This current case passed in every recorded PR/base order; it does not identify an original failure | `tests/release-helper.test.ts` | No per-failure disposition; suite-level unresolved | +| `dispatch pins the audited release SHA via expected-sha` | Unavailable; current source line 263 is not an original failure line | PR: standalone 3/3 + full 3/3 pass; base: standalone 1/1 + full 2/2 pass | Unresolved — original failure identity unavailable | This current case passed in every recorded PR/base order; it does not identify an original failure | `tests/release-helper.test.ts` | No per-failure disposition; suite-level unresolved | +| `aborts before dispatch when the remote branch moved during the CI wait` | Unavailable; current source line 275 is not an original failure line | PR: standalone 3/3 + full 3/3 pass; base: standalone 1/1 + full 2/2 pass | Unresolved — original failure identity unavailable | This current case passed in every recorded PR/base order; it does not identify an original failure | `tests/release-helper.test.ts` | No per-failure disposition; suite-level unresolved | + +### Current green rerun evidence + +- PR initial/repeats: `pr337-root-initial.log`, `pr337-root-repeat-1.log`, `pr337-root-repeat-2.log` — each `3861 pass / 0 fail`. +- PR standalone: `release-helper-1.log`, `release-helper-2.log`, `release-helper-3.log` — each `5 pass / 0 fail`. +- Exact-base comparison: `baseline-release-helper.log` (`5 pass / 0 fail`), `baseline-root-1.log`, `baseline-root-2.log` (each `3861 pass / 0 fail`). + +The current reruns do not provide evidence for a release-helper source change in this scoped PR: the investigated current cases and full suites were green at the recorded PR and base heads, and no causal PR diff path was found. They do not reconstruct or resolve the original four failures. Their names, assertions, stderr, and first failing lines remain unresolved unless the original CI or test-reporter metadata is recovered. Accordingly, the historical suite-level disposition remains unresolved, and the final exact-head root gates are still required without changing release code or tests. diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 3d7675904..23ff93dfc 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -34,7 +34,7 @@ bun run dev:gui | **Codex autostart** | Allow an already-installed Codex launcher shim to run `ocx ensure`. This toggle does not install a shim or background service. | | **Providers** | Add, edit, enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. | | **Add provider** | Search registry-backed presets for account login, API-key services, local servers, or a custom endpoint. | -| **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, and configure quota auto-switch and transient-failure failover. | +| **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, enable or disable quota auto-switch, set its 1–100% threshold, and configure transient-failure failover. | | **Subagents** | Feature up to five bare native or namespaced routed models in the `spawn_agent` override list. | | **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose v1/base/v2, and configure the v2 thread limit. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. | | **Logs** | Auto-refresh recent requests with tokens, requested effort, resolved model, provider, status, request id, duration, and error details. | diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 8fabb8d99..c4052a2fb 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -34,7 +34,7 @@ bun run dev:gui | **Codex 自動起動** | インストール済み Codex launcher shim に `ocx ensure` の実行を許可します。このトグルは shim やバックグラウンドサービスをインストールしません。 | | **プロバイダー** | プロバイダーを追加、編集、有効化/無効化、削除し、対応する OAuth アカウントプールと API キープールを管理します。 | | **プロバイダー追加** | レジストリベースのプリセットからアカウントログイン、API キーサービス、ローカルサーバー、custom エンドポイントを検索します。 | -| **Codex 認証** | ChatGPT/Codex プールアカウントを追加し、次回セッションアカウントを選び、5 時間 / 週間 / 30 日クォータを更新し、クォータ自動切り替えと一時的失敗フェイルオーバーを設定します。 | +| **Codex 認証** | ChatGPT/Codex プールアカウントを追加し、次回セッションアカウントを選び、5 時間 / 週間 / 30 日クォータを更新し、クォータ自動切り替えのオン/オフと 1~100% のしきい値、一時的失敗フェイルオーバーを設定します。 | | **サブエージェント** | `spawn_agent` オーバーライド一覧にネイティブまたはルーティングモデルを最大 5 つまで優先公開します。 | | **モデル** | ネイティブ GPT とルーティングモデルをオン/オフし、プロバイダー許可リストとコンテキスト上限、v1/base/v2、v2 スレッド数を設定します。 | | **ログ** | トークン、リクエスト強度、実際のモデル、プロバイダー、状態、リクエスト ID、所要時間、エラー詳細を含む最近のリクエストを自動更新します。 | @@ -69,7 +69,7 @@ bun run dev:gui - アカウントを直接選ぶと次の新規 Codex セッションから変わります。すでにアカウントが紐付いた thread はこの手動切り替えだけでは 途中で移動しません。 - Thread affinity がリクエストごとにアカウントが揺れるのを防ぎます。クォータ自動切り替えがオンなら長く - 実行される thread も定期的に再評価します。関連使用量がしきい値を超え、使用量が確実により低い + 実行される thread も定期的に再評価します。関連使用量がしきい値以上で、使用量が確実により低い 健全アカウントがあればそのアカウントに再紐付けできます。 - 新規セッションは使用量が最も低い健全アカウントを選べます。有料プランは既知の 5 時間、週間、30 日 枠のうち最も高い使用率でスコア付けし、Go/Free プランは 30 日枠のみ使います。 diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index 2ce065e9d..e665d6d0a 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -34,7 +34,7 @@ bun run dev:gui | **Codex 자동 시작** | 이미 설치된 Codex launcher shim이 `ocx ensure`를 실행하도록 허용합니다. 이 토글은 shim이나 백그라운드 서비스를 설치하지 않습니다. | | **Providers** | 프로바이더를 추가, 편집, 활성화/비활성화, 제거하고, 지원되는 OAuth 계정 풀과 API key 풀을 관리합니다. | | **Add provider** | 레지스트리 기반 프리셋에서 계정 로그인, API key 서비스, 로컬 서버, custom endpoint를 검색합니다. | -| **Codex Auth** | ChatGPT/Codex 풀 계정을 추가하고, 다음 세션 계정을 선택하고, 5시간 / 주간 / 30일 할당량을 갱신하며, 할당량 자동 전환과 일시적 실패 failover를 설정합니다. | +| **Codex Auth** | ChatGPT/Codex 풀 계정을 추가하고, 다음 세션 계정을 선택하고, 5시간 / 주간 / 30일 할당량을 갱신하며, 할당량 자동 전환을 켜거나 끄고 1~100% 임계값과 일시적 실패 failover를 설정합니다. | | **Subagents** | `spawn_agent` override 목록에 네이티브 또는 라우팅 모델을 최대 5개까지 우선 노출합니다. | | **Models** | 네이티브 GPT와 라우팅 모델을 켜고 끄고, 프로바이더 allowlist와 컨텍스트 상한, v1/base/v2, v2 thread 수를 설정합니다. | | **Logs** | 토큰, 요청 강도, 실제 모델, 프로바이더, 상태, 요청 id, 소요 시간, 오류 상세가 포함된 최근 요청을 자동 갱신합니다. | @@ -69,7 +69,7 @@ Dashboard의 **Sub-agent delegation** 선택기는 `injectionModel`과 선택적 - 계정을 직접 고르면 다음 새 Codex 세션부터 바뀝니다. 이미 계정이 묶인 thread는 이 수동 전환만으로 중간에 이동하지 않습니다. - Thread affinity가 요청마다 계정이 흔들리는 일을 막습니다. 할당량 자동 전환이 켜져 있으면 오래 - 실행되는 thread도 주기적으로 다시 평가합니다. 관련 사용량이 임계값을 넘고 사용량이 확실히 더 낮은 + 실행되는 thread도 주기적으로 다시 평가합니다. 관련 사용량이 임계값 이상이고 사용량이 확실히 더 낮은 정상 계정이 있으면 그 계정으로 다시 묶일 수 있습니다. - 새 세션은 사용량이 가장 낮은 정상 계정을 고를 수 있습니다. 유료 플랜은 알려진 5시간, 주간, 30일 창 중 가장 높은 사용률로 점수를 매기고, Go/Free 플랜은 30일 창만 사용합니다. diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index f42a34e28..98a43ce45 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -34,7 +34,7 @@ bun run dev:gui | **Автозапуск Codex** | Разрешает уже установленному launcher shim Codex выполнять `ocx ensure`. Переключатель не устанавливает shim или фоновую службу. | | **Providers** | Добавление, редактирование, включение/отключение и удаление провайдеров; управление пулами OAuth-аккаунтов и пулами API-ключей там, где они поддерживаются. | | **Add provider** | Поиск по пресетам из реестра: вход по аккаунту, сервисы с API-ключом, локальные серверы или пользовательская конечная точка. | -| **Codex Auth** | Добавление аккаунтов пула ChatGPT/Codex, выбор аккаунта для следующей сессии, обновление квот 5 ч / недельных / 30-дневных, настройка автопереключения по квоте и failover при временных сбоях. | +| **Codex Auth** | Добавление аккаунтов пула ChatGPT/Codex, выбор аккаунта для следующей сессии, обновление квот 5 ч / недельных / 30-дневных, включение или отключение автопереключения, настройка его порога 1–100% и failover при временных сбоях. | | **Subagents** | Выделение до пяти «голых» нативных или маршрутизируемых моделей с пространством имён в списке переопределений `spawn_agent`. | | **Models** | Включение и отключение нативных GPT и маршрутизируемых моделей, настройка списков разрешённых провайдеров и лимитов контекста, выбор v1/base/v2 и настройка лимита потоков v2. | | **Logs** | Автообновляемый список недавних запросов: токены, запрошенный уровень рассуждений, фактическая модель, провайдер, статус, id запроса, длительность и подробности ошибок. | diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index 200f7af81..65dc53e27 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -33,7 +33,7 @@ bun run dev:gui | **Codex 自动启动** | 允许已安装的 Codex launcher shim 运行 `ocx ensure`。此开关不会安装 shim 或后台服务。 | | **Providers** | 添加、编辑、启用/禁用、删除 provider,并在支持时管理 OAuth 账号池和 API key 池。 | | **Add provider** | 搜索 registry preset,选择账号登录、API key 服务、本地服务器或自定义 endpoint。 | -| **Codex Auth** | 添加 ChatGPT/Codex 池账号,选择下一 session 的账号,刷新 5h / 每周 / 30d 配额,并设置配额自动切换和临时故障 failover。 | +| **Codex Auth** | 添加 ChatGPT/Codex 池账号,选择下一 session 的账号,刷新 5h / 每周 / 30d 配额,启用或停用配额自动切换,设置其 1–100% 阈值和临时故障 failover。 | | **Subagents** | 在 `spawn_agent` override 列表中置顶最多五个原生或路由模型。 | | **Models** | 开关原生 GPT 与路由模型,配置 provider allowlist、上下文上限、v1/base/v2 以及 v2 thread 数量。 | | **Logs** | 自动刷新近期请求,显示 token、请求强度、实际模型、provider、状态、request id、耗时和错误详情。 | diff --git a/gui/src/codex-auto-switch.ts b/gui/src/codex-auto-switch.ts new file mode 100644 index 000000000..288d2f5cf --- /dev/null +++ b/gui/src/codex-auto-switch.ts @@ -0,0 +1,84 @@ +export const DEFAULT_AUTO_SWITCH_THRESHOLD = 80; + +const AUTO_SWITCH_PUT_TIMEOUT_MS = 10_000; + +export type AutoSwitchFetch = (input: string, init: RequestInit) => Promise; + +export function normalizeAutoSwitchThreshold(value: unknown): number { + return typeof value === "number" + && Number.isInteger(value) + && value >= 0 + && value <= 100 + ? value + : DEFAULT_AUTO_SWITCH_THRESHOLD; +} + +export function parseEnabledAutoSwitchThreshold(value: string): number | null { + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) return null; + const threshold = Number(trimmed); + return threshold >= 1 && threshold <= 100 ? threshold : null; +} + +export function nextAutoSwitchThreshold(current: number, lastEnabled: number): number { + if (current > 0) return 0; + return Number.isInteger(lastEnabled) && lastEnabled >= 1 && lastEnabled <= 100 + ? lastEnabled + : DEFAULT_AUTO_SWITCH_THRESHOLD; +} + +export type AutoSwitchThresholdReadDisposition = "apply" | "defer" | "ignore"; + +export function autoSwitchThresholdReadDisposition( + editing: boolean, + saving: boolean, + startedRevision: number, + currentRevision: number, +): AutoSwitchThresholdReadDisposition { + if (startedRevision !== currentRevision) return "ignore"; + return editing || saving ? "defer" : "apply"; +} + +export interface AutoSwitchTogglePlan { + threshold: number; + lastEnabled: number; +} + +/** + * Disabling is one server write. A valid dirty draft becomes the page-lifetime + * restore value, so re-enabling can persist it without a partial two-write + * failure state. + */ +export function planAutoSwitchToggleWrite( + current: number, + draft: string, + lastEnabled: number, +): AutoSwitchTogglePlan { + if (current <= 0) { + const threshold = nextAutoSwitchThreshold(current, lastEnabled); + return { threshold, lastEnabled: threshold }; + } + const restoreThreshold = parseEnabledAutoSwitchThreshold(draft) + ?? nextAutoSwitchThreshold(0, lastEnabled); + return { threshold: 0, lastEnabled: restoreThreshold }; +} + +export async function putAutoSwitchThreshold( + apiBase: string, + threshold: number, + fetchImpl: AutoSwitchFetch = (input, init) => fetch(input, init), + timeoutMs = AUTO_SWITCH_PUT_TIMEOUT_MS, +): Promise { + if (!Number.isInteger(threshold) || threshold < 0 || threshold > 100) return false; + try { + const response = await fetchImpl(`${apiBase}/api/codex-auth/auto-switch`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ threshold }), + signal: AbortSignal.timeout(timeoutMs), + }); + return response.ok; + } catch { + return false; + } +} diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index b3d8a3a42..697ad7ac9 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -7,6 +7,8 @@ import type { AccountQuota } from "../codex-quota-utils"; import QuotaBars from "./QuotaBars"; import type { ReactNode } from "react"; import type { CodexAccountModeState } from "../codex-multi-state"; +import CodexAutoSwitchSetting from "./CodexAutoSwitchSetting"; +import { useCodexAutoSwitch } from "../hooks/useCodexAutoSwitch"; export interface CodexAccountEntry { id: string; alias?: string; email: string; plan?: string; isMain: boolean; @@ -29,9 +31,14 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban onActiveNeedsReauthChange?: (needs: boolean) => void; }) { const t = useT(); + const autoSwitch = useCodexAutoSwitch(apiBase, { + updated: t("codexAuth.autoSwitchUpdated"), + updateFailed: t("codexAuth.autoSwitchUpdateFailed"), + invalid: t("codexAuth.autoSwitchThresholdInvalid"), + }); + const { beginServerRead, acceptServerRead, rejectServerRead } = autoSwitch; const [accounts, setAccounts] = useState([]); const [activeId, setActiveId] = useState(null); - const [autoThreshold, setAutoThreshold] = useState(80); const [confirm, setConfirm] = useState(null); const [showAdd, setShowAdd] = useState(false); const [reauthId, setReauthId] = useState(null); @@ -49,25 +56,39 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const load = useCallback(async (refreshQuota = false) => { const generation = ++loadGenerationRef.current; + const autoSwitchReadRevision = beginServerRead(); if (!refreshQuota) setLoadState("loading"); - try { - const [accountsResponse, activeResponse] = await Promise.all([ - fetch(`${apiBase}/api/codex-auth/accounts${refreshQuota ? "?refresh=1" : ""}`), - fetch(`${apiBase}/api/codex-auth/active`), - ]); - if (!accountsResponse.ok || !activeResponse.ok) throw new Error("account load failed"); - const [accts, active] = await Promise.all([accountsResponse.json(), activeResponse.json()]); - if (loadGenerationRef.current !== generation) return false; - setAccounts(accts.accounts ?? []); - setActiveId(active.activeCodexAccountId ?? null); - setAutoThreshold(active.autoSwitchThreshold ?? 80); - setLoadState("ready"); - return true; - } catch { - if (loadGenerationRef.current === generation) setLoadState("error"); - return false; - } - }, [apiBase]); + const accountsTask = (async (): Promise => { + try { + const response = await fetch(`${apiBase}/api/codex-auth/accounts${refreshQuota ? "?refresh=1" : ""}`); + if (!response.ok) throw new Error("account load failed"); + const accts = await response.json(); + if (loadGenerationRef.current === generation) setAccounts(accts.accounts ?? []); + return true; + } catch { + return false; + } + })(); + const activeTask = (async (): Promise => { + try { + const response = await fetch(`${apiBase}/api/codex-auth/active`); + if (!response.ok) throw new Error("active account load failed"); + const active = await response.json(); + if (loadGenerationRef.current === generation) { + setActiveId(active.activeCodexAccountId ?? null); + acceptServerRead(active.autoSwitchThreshold, autoSwitchReadRevision); + } + return true; + } catch { + if (loadGenerationRef.current === generation) rejectServerRead(); + return false; + } + })(); + const [accountsOk, activeOk] = await Promise.all([accountsTask, activeTask]); + if (loadGenerationRef.current !== generation) return false; + setLoadState(accountsOk && activeOk ? "ready" : "error"); + return accountsOk && activeOk; + }, [acceptServerRead, apiBase, beginServerRead, rejectServerRead]); useEffect(() => { const timeout = window.setTimeout(() => { void load(); @@ -179,15 +200,6 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban } }; - const toggleAuto = async () => { - const next = autoThreshold > 0 ? 0 : 80; - await fetch(`${apiBase}/api/codex-auth/auto-switch`, { - method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ threshold: next }), - }); - setAutoThreshold(next); - }; - const refreshQuotas = async () => { setRefreshingQuota(true); try { @@ -312,7 +324,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
{main?.email ?? "Codex App login"}{main?.plan ? ` · ${main.plan}` : ""}
{main?.needsReauth ?
{t("codexAuth.mainTokenExpired")}
- : main?.quota && } + : main?.quota && }
@@ -374,20 +386,26 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
{a.email}{a.plan ? ` · ${a.plan}` : ""} · {t("prov.accountId")}: {a.id}
{a.needsReauth ?
{t("codexAuth.tokenExpired")}
- : } + : }
))} -
-
- {t("codexAuth.autoSwitch")} -
{t("codexAuth.autoSwitchDesc")}
-
- -
+ { + autoSwitch.retry(); + void load(); + }} + /> {confirm && (
setConfirm(null)}> diff --git a/gui/src/components/CodexAutoSwitchSetting.tsx b/gui/src/components/CodexAutoSwitchSetting.tsx new file mode 100644 index 000000000..62fa886bf --- /dev/null +++ b/gui/src/components/CodexAutoSwitchSetting.tsx @@ -0,0 +1,154 @@ +import { useRef } from "react"; +import { useT } from "../i18n"; + +export type AutoSwitchFeedback = { tone: "ok" | "err"; message: string } | null; + +export interface CodexAutoSwitchSettingProps { + threshold: number | null; + draft: string; + saving: boolean; + loadError: boolean; + feedback: AutoSwitchFeedback; + onDraftChange(value: string): void; + onEditingChange(editing: boolean): void; + onCommit(): Promise; + onCancel(): void; + onToggle(): Promise; + onRetry(): void; +} + +export function CodexAutoSwitchSetting({ + threshold, + draft, + saving, + loadError, + feedback, + onDraftChange, + onEditingChange, + onCommit, + onCancel, + onToggle, + onRetry, +}: CodexAutoSwitchSettingProps) { + const t = useT(); + const togglePointerIntentRef = useRef(false); + const ready = threshold !== null; + const enabled = ready && threshold > 0; + const feedbackMessage = saving ? t("common.saving") : feedback?.message ?? ""; + const feedbackTone = saving ? "pending" : feedback?.tone; + const describedBy = feedbackMessage + ? "codex-auto-switch-desc codex-auto-switch-feedback" + : "codex-auto-switch-desc"; + return ( +
+
+ {t("codexAuth.autoSwitch")} +
+ {!ready + ? loadError ? t("codexAuth.autoSwitchLoadFailed") : t("common.loading") + : enabled + ? t("codexAuth.autoSwitchDesc", { threshold }) + : t("codexAuth.autoSwitchOffDesc")} +
+
+ {ready ? ( +
{ + if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; + onEditingChange(false); + if (togglePointerIntentRef.current) { + togglePointerIntentRef.current = false; + return; + } + if (enabled && !saving) void onCommit(); + }} + > + {enabled && ( + + )} + +
+ ) : loadError ? ( +
+ +
+ ) : null} + {feedbackMessage && ( +
+ {feedbackMessage} +
+ )} +
+ ); +} + +export default CodexAutoSwitchSetting; diff --git a/gui/src/hooks/useCodexAutoSwitch.ts b/gui/src/hooks/useCodexAutoSwitch.ts new file mode 100644 index 000000000..52b7618dd --- /dev/null +++ b/gui/src/hooks/useCodexAutoSwitch.ts @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + DEFAULT_AUTO_SWITCH_THRESHOLD, + autoSwitchThresholdReadDisposition, + normalizeAutoSwitchThreshold, + parseEnabledAutoSwitchThreshold, + planAutoSwitchToggleWrite, + putAutoSwitchThreshold, +} from "../codex-auto-switch"; +import type { AutoSwitchFeedback } from "../components/CodexAutoSwitchSetting"; + +export interface CodexAutoSwitchController { + threshold: number | null; + draft: string; + saving: boolean; + loadError: boolean; + feedback: AutoSwitchFeedback; + beginServerRead(): number; + acceptServerRead(value: unknown, startedRevision: number): void; + rejectServerRead(): void; + setDraft(value: string): void; + setEditing(editing: boolean): void; + commit(): Promise; + cancel(): void; + toggle(): Promise; + retry(): void; +} + +export function useCodexAutoSwitch( + apiBase: string, + messages: { + updated: string; + updateFailed: string; + invalid: string; + }, +): CodexAutoSwitchController { + const [threshold, setThreshold] = useState(null); + const [draft, setDraftState] = useState(""); + const [loadError, setLoadError] = useState(false); + const [saving, setSaving] = useState(false); + const [feedback, setFeedback] = useState(null); + const thresholdRef = useRef(null); + const lastEnabledRef = useRef(DEFAULT_AUTO_SWITCH_THRESHOLD); + const editingRef = useRef(false); + const savingRef = useRef(false); + const cancelledDraftRef = useRef(false); + const revisionRef = useRef(0); + const deferredServerValueRef = useRef(null); + const feedbackTimeoutRef = useRef(null); + + const apply = useCallback((next: number) => { + thresholdRef.current = next; + setThreshold(next); + if (next > 0) lastEnabledRef.current = next; + setDraftState(String(next > 0 ? next : lastEnabledRef.current)); + }, []); + + const queueOrApply = useCallback((next: number) => { + if (editingRef.current || savingRef.current) { + deferredServerValueRef.current = next; + return; + } + deferredServerValueRef.current = null; + apply(next); + }, [apply]); + + const reconcileDeferred = useCallback((): boolean => { + const deferred = deferredServerValueRef.current; + if (deferred === null) return false; + deferredServerValueRef.current = null; + apply(deferred); + return true; + }, [apply]); + + const clearFeedback = useCallback(() => { + if (feedbackTimeoutRef.current !== null) { + window.clearTimeout(feedbackTimeoutRef.current); + feedbackTimeoutRef.current = null; + } + setFeedback(null); + }, []); + + const showFeedback = useCallback((message: string, error: boolean) => { + if (feedbackTimeoutRef.current !== null) { + window.clearTimeout(feedbackTimeoutRef.current); + } + setFeedback({ tone: error ? "err" : "ok", message }); + feedbackTimeoutRef.current = window.setTimeout(() => { + setFeedback(null); + feedbackTimeoutRef.current = null; + }, 5000); + }, []); + + useEffect(() => () => { + if (feedbackTimeoutRef.current !== null) { + window.clearTimeout(feedbackTimeoutRef.current); + } + }, []); + + const beginServerRead = useCallback((): number => { + if (thresholdRef.current === null) setLoadError(false); + return revisionRef.current; + }, []); + + const acceptServerRead = useCallback((value: unknown, startedRevision: number) => { + setLoadError(false); + const disposition = autoSwitchThresholdReadDisposition( + editingRef.current, + savingRef.current, + startedRevision, + revisionRef.current, + ); + if (disposition === "defer") { + deferredServerValueRef.current = normalizeAutoSwitchThreshold(value); + } else if (disposition === "apply") { + queueOrApply(normalizeAutoSwitchThreshold(value)); + } + }, [queueOrApply]); + + const rejectServerRead = useCallback(() => { + if (thresholdRef.current === null) setLoadError(true); + }, []); + + const save = useCallback(async ( + next: number, + previous: number, + showSuccess = true, + ): Promise => { + if (savingRef.current) return false; + savingRef.current = true; + editingRef.current = false; + clearFeedback(); + setSaving(true); + revisionRef.current += 1; + const ok = await putAutoSwitchThreshold(apiBase, next); + revisionRef.current += 1; + savingRef.current = false; + if (ok) { + deferredServerValueRef.current = null; + apply(next); + if (showSuccess) showFeedback(messages.updated, false); + } else { + if (!reconcileDeferred()) apply(previous); + showFeedback(messages.updateFailed, true); + } + setSaving(false); + return ok; + }, [apiBase, apply, clearFeedback, messages.updateFailed, messages.updated, reconcileDeferred, showFeedback]); + + const rejectDraft = useCallback(() => { + editingRef.current = false; + const current = thresholdRef.current; + if (!reconcileDeferred() && current !== null) { + setDraftState(String(current > 0 ? current : lastEnabledRef.current)); + } + showFeedback(messages.invalid, true); + }, [messages.invalid, reconcileDeferred, showFeedback]); + + const cancel = useCallback(() => { + editingRef.current = false; + cancelledDraftRef.current = true; + clearFeedback(); + const current = thresholdRef.current; + if (!reconcileDeferred() && current !== null) { + setDraftState(String(current > 0 ? current : lastEnabledRef.current)); + } + }, [clearFeedback, reconcileDeferred]); + + const commit = useCallback(async (): Promise => { + if (cancelledDraftRef.current) { + cancelledDraftRef.current = false; + return true; + } + const current = thresholdRef.current; + if (current === null || savingRef.current) return false; + editingRef.current = false; + const next = parseEnabledAutoSwitchThreshold(draft); + if (next === null) { + rejectDraft(); + return false; + } + if (next === current) { + if (!reconcileDeferred()) setDraftState(String(next)); + return true; + } + return save(next, current); + }, [draft, reconcileDeferred, rejectDraft, save]); + + const toggle = useCallback(async (): Promise => { + const current = thresholdRef.current; + if (current === null || savingRef.current) return false; + editingRef.current = false; + const plan = planAutoSwitchToggleWrite(current, draft, lastEnabledRef.current); + const ok = await save(plan.threshold, current); + if (!ok) return false; + lastEnabledRef.current = plan.lastEnabled; + if (plan.threshold === 0) setDraftState(String(plan.lastEnabled)); + return ok; + }, [draft, save]); + + const setDraft = useCallback((value: string) => { + editingRef.current = true; + cancelledDraftRef.current = false; + clearFeedback(); + setDraftState(value); + }, [clearFeedback]); + + const setEditing = useCallback((editing: boolean) => { + editingRef.current = editing; + }, []); + + const retry = useCallback(() => { + setLoadError(false); + clearFeedback(); + }, [clearFeedback]); + + return { + threshold, + draft, + saving, + loadError, + feedback, + beginServerRead, + acceptServerRead, + rejectServerRead, + setDraft, + setEditing, + commit, + cancel, + toggle, + retry, + }; +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 6cade5be0..76c24b18b 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -605,10 +605,17 @@ export const de = { "codexAuth.cancel": "Abbrechen", "codexAuth.switchBack": "Zurück zum Hauptkonto?", "codexAuth.switchBackDesc": "Die nächste Codex-Sitzung verwendet wieder dein App-Login-Konto.", - "codexAuth.autoSwitch": "Auto-Wechsel bei 80%", - "codexAuth.autoSwitchDesc": "Neue Sitzungen wechseln, wenn das entsprechende Kontingent-Fenster den Schwellenwert erreicht; Go/Free nutzen nur 30d", + "codexAuth.autoSwitch": "Automatischer Kontowechsel", + "codexAuth.autoSwitchDesc": "Wechselt ab {threshold} % Auslastung, wenn ein verfügbares Konto eine niedrigere Auslastung hat; Go/Free nutzen nur das 30-Tage-Fenster", + "codexAuth.autoSwitchOffDesc": "Der automatische Kontowechsel ist ausgeschaltet", + "codexAuth.autoSwitchThreshold": "Wechselschwelle", + "codexAuth.autoSwitchThresholdAria": "Wechselschwelle in Prozent", + "codexAuth.autoSwitchLoadFailed": "Die Einstellung für den automatischen Kontowechsel konnte nicht geladen werden.", + "codexAuth.autoSwitchThresholdInvalid": "Gib eine ganze Zahl von 1 bis 100 ein", + "codexAuth.autoSwitchUpdated": "Der automatische Kontowechsel wurde aktualisiert", + "codexAuth.autoSwitchUpdateFailed": "Die Aktualisierung konnte nicht bestätigt werden. Der zuletzt bestätigte Wert wird angezeigt.", "codexAuth.switched": "{email} wird ab der nächsten Sitzung verwendet", - "codexAuth.loadFailed": "Konten konnten nicht geladen werden.", + "codexAuth.loadFailed": "Die Codex-Kontoeinstellungen konnten nicht geladen werden.", "codexAuth.switchFailed": "Das Konto konnte nicht gewechselt werden. Die vorherige Auswahl bleibt erhalten.", "codexAuth.removeConfirm": "{id} entfernen?", "codexAuth.removeFailed": "Das Konto konnte nicht entfernt werden. Es wurde nichts geändert.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 81bc40d28..b42f850d1 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -906,10 +906,17 @@ export const en = { "codexAuth.cancel": "Cancel", "codexAuth.switchBack": "Switch back to Main?", "codexAuth.switchBackDesc": "Next Codex session will use your App login account again.", - "codexAuth.autoSwitch": "Auto-switch at 80%", - "codexAuth.autoSwitchDesc": "New sessions switch when the relevant quota window reaches the threshold; Go/Free use 30d only", + "codexAuth.autoSwitch": "Automatic account switching", + "codexAuth.autoSwitchDesc": "Switches at {threshold}% usage or above when an eligible account has lower usage; Go/Free use 30d only", + "codexAuth.autoSwitchOffDesc": "Automatic account switching is off", + "codexAuth.autoSwitchThreshold": "Switch threshold", + "codexAuth.autoSwitchThresholdAria": "Switch threshold, percent", + "codexAuth.autoSwitchLoadFailed": "Automatic switching setting could not be loaded.", + "codexAuth.autoSwitchThresholdInvalid": "Enter a whole number from 1 to 100", + "codexAuth.autoSwitchUpdated": "Automatic account switching updated", + "codexAuth.autoSwitchUpdateFailed": "The update could not be confirmed. The last confirmed value is shown.", "codexAuth.switched": "{email} will be used from next session", - "codexAuth.loadFailed": "Accounts could not be loaded.", + "codexAuth.loadFailed": "Codex account settings could not be loaded.", "codexAuth.switchFailed": "The account could not be switched. Your previous selection is unchanged.", "codexAuth.removeConfirm": "Remove {id}?", "codexAuth.removeFailed": "The account could not be removed. Nothing was changed.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e7dfd58c1..c3c062242 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -861,10 +861,17 @@ export const ja: Record = { "codexAuth.cancel": "キャンセル", "codexAuth.switchBack": "メインに戻しますか?", "codexAuth.switchBackDesc": "次回の Codex セッションはアプリログインアカウントを再び使用します。", - "codexAuth.autoSwitch": "80% で自動切り替え", - "codexAuth.autoSwitchDesc": "関連するクォータ枠がしきい値に達すると新規セッションが切り替わります; Go/Free は 30日のみ", + "codexAuth.autoSwitch": "アカウントの自動切り替え", + "codexAuth.autoSwitchDesc": "使用率が {threshold}% 以上で、より使用率の低い利用可能なアカウントがある場合に切り替えます。Go/Free は 30 日枠のみを使用します", + "codexAuth.autoSwitchOffDesc": "アカウントの自動切り替えはオフです", + "codexAuth.autoSwitchThreshold": "切り替えしきい値", + "codexAuth.autoSwitchThresholdAria": "切り替えしきい値(パーセント)", + "codexAuth.autoSwitchLoadFailed": "アカウントの自動切り替え設定を読み込めませんでした。", + "codexAuth.autoSwitchThresholdInvalid": "1 から 100 までの整数を入力してください", + "codexAuth.autoSwitchUpdated": "アカウントの自動切り替え設定を更新しました", + "codexAuth.autoSwitchUpdateFailed": "更新を確認できませんでした。最後に確認された値を表示しています。", "codexAuth.switched": "{email} は次回セッションから使用されます", - "codexAuth.loadFailed": "アカウントを読み込めませんでした。", + "codexAuth.loadFailed": "Codex アカウント設定を読み込めませんでした。", "codexAuth.switchFailed": "アカウントを切り替えられませんでした。以前の選択はそのままです。", "codexAuth.removeConfirm": "{id} を削除しますか?", "codexAuth.removeFailed": "アカウントを削除できませんでした。何も変更されていません。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index df1797959..81fcb3758 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -624,10 +624,17 @@ export const ko: Record = { "codexAuth.cancel": "취소", "codexAuth.switchBack": "메인 계정으로 돌아가시겠습니까?", "codexAuth.switchBackDesc": "다음 Codex 세션에서 앱 로그인 계정을 사용합니다.", - "codexAuth.autoSwitch": "80% 초과 시 자동 전환", - "codexAuth.autoSwitchDesc": "새 세션은 해당 플랜의 기준 창이 임계값에 도달하면 전환합니다. Go/Free는 30일만 봅니다", + "codexAuth.autoSwitch": "자동 계정 전환", + "codexAuth.autoSwitchDesc": "사용량이 {threshold}% 이상이고 사용량이 더 낮은 정상 계정이 있으면 전환합니다. Go/Free는 30일만 봅니다", + "codexAuth.autoSwitchOffDesc": "자동 계정 전환이 꺼져 있습니다", + "codexAuth.autoSwitchThreshold": "전환 임계값", + "codexAuth.autoSwitchThresholdAria": "전환 임계값(퍼센트)", + "codexAuth.autoSwitchLoadFailed": "자동 계정 전환 설정을 불러오지 못했습니다.", + "codexAuth.autoSwitchThresholdInvalid": "1~100 사이의 정수를 입력하세요", + "codexAuth.autoSwitchUpdated": "자동 계정 전환 설정을 저장했습니다", + "codexAuth.autoSwitchUpdateFailed": "자동 계정 전환 설정 변경을 확인하지 못했습니다. 마지막으로 확인된 값을 표시합니다.", "codexAuth.switched": "다음 세션부터 {email}을(를) 사용합니다", - "codexAuth.loadFailed": "계정을 불러오지 못했습니다.", + "codexAuth.loadFailed": "Codex 계정 설정을 불러오지 못했습니다.", "codexAuth.switchFailed": "계정을 전환하지 못했습니다. 이전 선택은 그대로 유지됩니다.", "codexAuth.removeConfirm": "{id}을(를) 삭제하시겠습니까?", "codexAuth.removeFailed": "계정을 제거하지 못했습니다. 변경된 내용은 없습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 148af25f6..4346cbb5b 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -107,8 +107,8 @@ export const ru: Record = { "startup.copied": "Скопировано", "startup.recommended": "Рекомендуемое исправление: {cmd}", "startup.navRisk": "Защита запуска требует внимания", - "startup.codexRuntime.clampHidden": "Некоторые уровни reasoning effort скрыты, потому что OpenCodex использовал Codex {version}.", - "startup.codexRuntime.clampHiddenWithEfforts": "Некоторые уровни reasoning effort скрыты, потому что OpenCodex использовал Codex {version} (удалены: {efforts}).", + "startup.codexRuntime.clampHidden": "Некоторые уровни рассуждений скрыты, потому что OpenCodex использует Codex {version}.", + "startup.codexRuntime.clampHiddenWithEfforts": "Некоторые уровни рассуждений скрыты, потому что OpenCodex использует Codex {version} (удалены: {efforts}).", "startup.codexRuntime.olderBinary": "OpenCodex использует более старый бинарник Codex ({version}). Доступна более новая установка.", // dashboard @@ -906,10 +906,17 @@ export const ru: Record = { "codexAuth.cancel": "Отмена", "codexAuth.switchBack": "Вернуться на основной аккаунт?", "codexAuth.switchBackDesc": "Следующая сессия Codex снова будет использовать ваш аккаунт входа через приложение.", - "codexAuth.autoSwitch": "Автопереключение при 80%", - "codexAuth.autoSwitchDesc": "Новые сессии переключаются, когда соответствующее окно квоты достигает порога; тарифы Go/Free используют только окно 30 дней", + "codexAuth.autoSwitch": "Автоматическое переключение аккаунтов", + "codexAuth.autoSwitchDesc": "Переключает при использовании {threshold}% или выше, если доступен аккаунт с меньшим использованием; тарифы Go/Free используют только окно 30 дней", + "codexAuth.autoSwitchOffDesc": "Автоматическое переключение аккаунтов выключено", + "codexAuth.autoSwitchThreshold": "Порог переключения", + "codexAuth.autoSwitchThresholdAria": "Порог переключения в процентах", + "codexAuth.autoSwitchLoadFailed": "Не удалось загрузить настройку автоматического переключения аккаунтов.", + "codexAuth.autoSwitchThresholdInvalid": "Введите целое число от 1 до 100", + "codexAuth.autoSwitchUpdated": "Настройки автоматического переключения обновлены", + "codexAuth.autoSwitchUpdateFailed": "Не удалось подтвердить обновление. Показано последнее подтверждённое значение.", "codexAuth.switched": "{email} будет использоваться со следующей сессии", - "codexAuth.loadFailed": "Не удалось загрузить аккаунты.", + "codexAuth.loadFailed": "Не удалось загрузить настройки аккаунтов Codex.", "codexAuth.switchFailed": "Не удалось переключить аккаунт. Ваш предыдущий выбор не изменён.", "codexAuth.removeConfirm": "Удалить {id}?", "codexAuth.removeFailed": "Не удалось удалить аккаунт. Ничего не изменено.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index e6414158f..d46b3ad13 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -102,8 +102,8 @@ export const zh: Record = { "startup.copied": "已复制", "startup.recommended": "推荐修复:{cmd}", "startup.navRisk": "启动保护需要处理", - "startup.codexRuntime.clampHidden": "部分推理强度选项已隐藏,因为 OpenCodex 使用了 Codex {version}。", - "startup.codexRuntime.clampHiddenWithEfforts": "部分推理强度选项已隐藏,因为 OpenCodex 使用了 Codex {version}(已移除:{efforts})。", + "startup.codexRuntime.clampHidden": "部分推理强度选项已隐藏,因为 OpenCodex 正在使用 Codex {version}。", + "startup.codexRuntime.clampHiddenWithEfforts": "部分推理强度选项已隐藏,因为 OpenCodex 正在使用 Codex {version}(已移除:{efforts})。", "startup.codexRuntime.olderBinary": "OpenCodex 正在使用较旧的 Codex 二进制文件({version})。检测到可用的较新安装。", // dashboard @@ -624,10 +624,17 @@ export const zh: Record = { "codexAuth.cancel": "取消", "codexAuth.switchBack": "切换回主账号?", "codexAuth.switchBackDesc": "下次 Codex 会话将使用应用登录账号。", - "codexAuth.autoSwitch": "超过 80% 时自动切换", - "codexAuth.autoSwitchDesc": "新会话会在相关额度窗口达到阈值时切换;Go/Free 只使用 30天", + "codexAuth.autoSwitch": "自动切换账号", + "codexAuth.autoSwitchDesc": "使用率达到或超过 {threshold}% 且存在使用率更低的可用账号时切换;Go/Free 仅使用 30 天窗口", + "codexAuth.autoSwitchOffDesc": "自动切换账号已关闭", + "codexAuth.autoSwitchThreshold": "切换阈值", + "codexAuth.autoSwitchThresholdAria": "切换阈值(百分比)", + "codexAuth.autoSwitchLoadFailed": "无法加载自动切换账号设置。", + "codexAuth.autoSwitchThresholdInvalid": "请输入 1 到 100 之间的整数", + "codexAuth.autoSwitchUpdated": "自动切换账号设置已更新", + "codexAuth.autoSwitchUpdateFailed": "无法确认更新。当前显示最后一次确认的值。", "codexAuth.switched": "下次会话将使用 {email}", - "codexAuth.loadFailed": "无法加载账户。", + "codexAuth.loadFailed": "无法加载 Codex 账号设置。", "codexAuth.switchFailed": "无法切换账户。之前的选择保持不变。", "codexAuth.removeConfirm": "删除 {id}?", "codexAuth.removeFailed": "无法移除账户。未进行任何更改。", diff --git a/gui/src/styles.css b/gui/src/styles.css index b814f947d..fb5471895 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -764,6 +764,20 @@ dialog.modal-overlay::backdrop { .toggle.on { background: var(--toggle-on-bg); border-color: var(--toggle-on-bg); } .toggle-knob { width: 16px; height: 16px; background: light-dark(#ffffff, #ececec); border-radius: var(--radius-round); position: absolute; top: 1px; left: 1px; transition: left var(--motion-normal), background var(--motion-normal); } .toggle.on .toggle-knob { left: 17px; background: var(--toggle-dot-color); } +.toggle:disabled { cursor: not-allowed; opacity: 0.55; } +.codex-auto-switch-card { gap: 16px; flex-wrap: wrap; } +.codex-auto-switch-copy { flex: 1 1 auto; min-width: 0; } +.codex-auto-switch-copy .card-sub { padding: 2px 0 0; } +.codex-auto-switch-controls { display: flex; align-items: flex-end; gap: 12px; flex: 0 0 auto; margin-left: auto; } +.codex-auto-switch-controls > .toggle { margin-left: auto; } +.codex-auto-switch-threshold { display: flex; flex-direction: column; align-items: flex-start; margin: 0; } +.codex-auto-switch-threshold .field-label { margin-bottom: 4px; white-space: nowrap; } +.codex-auto-switch-input-wrap { display: flex; align-items: center; gap: 6px; } +.codex-auto-switch-input { width: 84px; text-align: right; font-variant-numeric: tabular-nums; } +.codex-auto-switch-input[readonly] { cursor: progress; opacity: 0.7; } +.codex-auto-switch-unit { color: var(--muted); font-size: var(--text-control); } +.codex-auto-switch-feedback { flex: 1 0 100%; margin-top: -8px; color: var(--muted); font-size: var(--text-label); line-height: var(--leading-body); text-align: right; } +.codex-auto-switch-feedback.is-error { color: var(--red); } .notice-warn { font-size: var(--text-label); line-height: var(--leading-body); padding: 8px 10px; border-radius: var(--radius-sm); background: var(--amber-soft); color: var(--amber); display: flex; align-items: center; gap: 6px; margin-bottom: 12px; } .notice-warn svg { width: 14px; height: 14px; flex-shrink: 0; } @@ -996,6 +1010,9 @@ button.prov-account-row.active { cursor: default; } .setting-row > .select-sm { width: 100%; } .api-form-row { align-items: stretch; flex-direction: column; } .api-form-row .btn { width: 100%; min-height: 40px; } + .codex-auto-switch-card { align-items: stretch; flex-direction: column; } + .codex-auto-switch-controls { align-items: flex-end; justify-content: space-between; width: 100%; } + .codex-auto-switch-feedback { margin-top: -6px; text-align: left; } .stat-row > .stat { min-width: 100px; } .tbl { min-width: 460px; } /* keep columns readable; .tbl-wrap scrolls horizontally */ .usage-cards { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } diff --git a/gui/tests/codex-account-auto-switch.test.tsx b/gui/tests/codex-account-auto-switch.test.tsx new file mode 100644 index 000000000..771bfdb68 --- /dev/null +++ b/gui/tests/codex-account-auto-switch.test.tsx @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { + DEFAULT_AUTO_SWITCH_THRESHOLD, + autoSwitchThresholdReadDisposition, + nextAutoSwitchThreshold, + normalizeAutoSwitchThreshold, + parseEnabledAutoSwitchThreshold, + planAutoSwitchToggleWrite, + putAutoSwitchThreshold, + type AutoSwitchFetch, +} from "../src/codex-auto-switch"; +import { CodexAutoSwitchSetting as AutoSwitchSetting } from "../src/components/CodexAutoSwitchSetting"; +import { LanguageProvider } from "../src/i18n/provider"; + +let previousLanguage: unknown; + +beforeEach(() => { + previousLanguage = (globalThis.navigator as { language?: unknown } | undefined)?.language; + Object.defineProperty(globalThis.navigator, "language", { + configurable: true, + value: "en-US", + }); +}); + +afterEach(() => { + Object.defineProperty(globalThis.navigator, "language", { + configurable: true, + value: previousLanguage, + }); +}); + +function renderSetting( + threshold: number | null, + draft = threshold === null ? "" : String(threshold), + saving = false, + loadError = false, + feedback: { tone: "ok" | "err"; message: string } | null = null, +): string { + return renderToStaticMarkup( + + {}} + onEditingChange={() => {}} + onCommit={async () => true} + onCancel={() => {}} + onToggle={async () => true} + onRetry={() => {}} + /> + , + ); +} + +describe("Codex account auto-switch threshold", () => { + test("accepts enabled integer boundaries and a custom value", () => { + expect(parseEnabledAutoSwitchThreshold("1")).toBe(1); + expect(parseEnabledAutoSwitchThreshold("95")).toBe(95); + expect(parseEnabledAutoSwitchThreshold("100")).toBe(100); + }); + + test("rejects empty, disabled, fractional, negative, and out-of-range drafts", () => { + for (const invalid of ["", "0", "50.5", "-1", "101", "abc"]) { + expect(parseEnabledAutoSwitchThreshold(invalid)).toBeNull(); + } + }); + + test("normalizes malformed server values to the existing default", () => { + expect(normalizeAutoSwitchThreshold(0)).toBe(0); + expect(normalizeAutoSwitchThreshold(95)).toBe(95); + expect(normalizeAutoSwitchThreshold(undefined)).toBe(DEFAULT_AUTO_SWITCH_THRESHOLD); + expect(normalizeAutoSwitchThreshold(101)).toBe(DEFAULT_AUTO_SWITCH_THRESHOLD); + }); + + test("toggle disables with zero and restores the last enabled value", () => { + expect(nextAutoSwitchThreshold(95, 95)).toBe(0); + expect(nextAutoSwitchThreshold(0, 95)).toBe(95); + expect(nextAutoSwitchThreshold(0, 0)).toBe(DEFAULT_AUTO_SWITCH_THRESHOLD); + }); + + test("toggle keeps a changed valid draft as the page restore value", () => { + expect(planAutoSwitchToggleWrite(90, "95", 90)).toEqual({ threshold: 0, lastEnabled: 95 }); + expect(planAutoSwitchToggleWrite(90, "90", 90)).toEqual({ threshold: 0, lastEnabled: 90 }); + expect(planAutoSwitchToggleWrite(0, "90", 95)).toEqual({ threshold: 95, lastEnabled: 95 }); + }); + + test("toggle disables despite an invalid draft and preserves a safe restore value", () => { + expect(planAutoSwitchToggleWrite(90, "", 90)).toEqual({ threshold: 0, lastEnabled: 90 }); + expect(planAutoSwitchToggleWrite(90, "50.5", 90)).toEqual({ threshold: 0, lastEnabled: 90 }); + expect(planAutoSwitchToggleWrite(90, "abc", 0)).toEqual({ + threshold: 0, + lastEnabled: DEFAULT_AUTO_SWITCH_THRESHOLD, + }); + expect(planAutoSwitchToggleWrite(90, "101", 101)).toEqual({ + threshold: 0, + lastEnabled: DEFAULT_AUTO_SWITCH_THRESHOLD, + }); + }); + + test("applies, defers, or ignores threshold reads by edit and revision state", () => { + expect(autoSwitchThresholdReadDisposition(false, false, 2, 2)).toBe("apply"); + expect(autoSwitchThresholdReadDisposition(true, false, 2, 2)).toBe("defer"); + expect(autoSwitchThresholdReadDisposition(false, true, 2, 2)).toBe("defer"); + expect(autoSwitchThresholdReadDisposition(false, false, 1, 2)).toBe("ignore"); + }); + + test("renders the persisted custom threshold and inclusive condition", () => { + const html = renderSetting(95); + expect(html).toContain('value="95"'); + expect(html).toContain('min="1"'); + expect(html).toContain('max="100"'); + expect(html).toContain('aria-label="Switch threshold, percent"'); + expect(html).toContain("95% usage or above"); + expect(html).toContain('aria-pressed="true"'); + }); + + test("renders both enabled boundary values", () => { + for (const threshold of [1, 100]) { + const html = renderSetting(threshold); + expect(html).toContain(`value="${threshold}"`); + expect(html).toContain(`${threshold}% usage or above`); + } + }); + + test("renders an explicit off state without an editable threshold", () => { + const html = renderSetting(0, "80"); + expect(html).toContain("Automatic account switching is off"); + expect(html).toContain('aria-pressed="false"'); + expect(html).not.toContain('type="number"'); + }); + + test("does not expose an actionable placeholder before hydration", () => { + const loading = renderSetting(null); + expect(loading).toContain("Loading…"); + expect(loading).toContain('aria-busy="true"'); + expect(loading).not.toContain('type="number"'); + expect(loading).not.toContain('aria-pressed='); + + const failed = renderSetting(null, "", false, true); + expect(failed).toContain("Automatic switching setting could not be loaded."); + expect(failed).toContain("Retry"); + expect(failed).not.toContain('type="number"'); + }); + + test("keeps the focused input present but read-only while a write is pending", () => { + const html = renderSetting(80, "80", true); + expect(html).toContain('aria-busy="true"'); + expect(html).toContain('readOnly=""'); + expect(html).toContain("Saving…"); + expect(html).toContain('disabled=""'); + }); + + test("renders contextual success and error feedback", () => { + const success = renderSetting(80, "80", false, false, { tone: "ok", message: "Saved here" }); + expect(success).toContain('role="status"'); + expect(success).toContain("Saved here"); + expect(success).toContain("codex-auto-switch-feedback"); + + const failure = renderSetting(80, "80", false, false, { tone: "err", message: "Fix this value" }); + expect(failure).toContain('role="alert"'); + expect(failure).toContain("Fix this value"); + expect(failure).toContain("is-error"); + }); + + test("writes the requested threshold through the existing management endpoint", async () => { + let request: { input: string; init: RequestInit } | null = null; + const fetchImpl: AutoSwitchFetch = async (input, init) => { + request = { input, init }; + return new Response(null, { status: 204 }); + }; + + expect(await putAutoSwitchThreshold("http://localhost:10100", 95, fetchImpl)).toBe(true); + expect(request?.input).toBe("http://localhost:10100/api/codex-auth/auto-switch"); + expect(request?.init.method).toBe("PUT"); + expect(request?.init.body).toBe(JSON.stringify({ threshold: 95 })); + }); + + test("reports HTTP and network failures without accepting the write", async () => { + const httpFailure: AutoSwitchFetch = async () => new Response(null, { status: 500 }); + const networkFailure: AutoSwitchFetch = async () => { throw new Error("offline"); }; + expect(await putAutoSwitchThreshold("", 95, httpFailure)).toBe(false); + expect(await putAutoSwitchThreshold("", 95, networkFailure)).toBe(false); + }); + + test("aborts a write that exceeds the browser-side saving timeout", async () => { + let calls = 0; + let receivedSignal: AbortSignal | null = null; + const fetchImpl: AutoSwitchFetch = async (_input, init) => { + calls += 1; + const signal = init.signal; + if (!signal) throw new Error("expected an abort signal"); + receivedSignal = signal; + return await new Promise((_resolve, reject) => { + const guard = setTimeout(() => reject(new Error("abort signal did not fire")), 100); + const rejectOnAbort = () => { + clearTimeout(guard); + reject(signal.reason ?? new Error("aborted")); + }; + if (signal.aborted) { + rejectOnAbort(); + return; + } + signal.addEventListener("abort", rejectOnAbort, { once: true }); + }); + }; + + expect(await putAutoSwitchThreshold("", 95, fetchImpl, 5)).toBe(false); + expect(calls).toBe(1); + expect(receivedSignal).not.toBeNull(); + expect(receivedSignal?.aborted).toBe(true); + }); + + test("does not send an invalid threshold", async () => { + let calls = 0; + const fetchImpl: AutoSwitchFetch = async () => { + calls += 1; + return new Response(null, { status: 204 }); + }; + expect(await putAutoSwitchThreshold("", 101, fetchImpl)).toBe(false); + expect(calls).toBe(0); + }); +}); diff --git a/gui/tests/codex-auto-switch-controller.test.tsx b/gui/tests/codex-auto-switch-controller.test.tsx new file mode 100644 index 000000000..e6cae7fdd --- /dev/null +++ b/gui/tests/codex-auto-switch-controller.test.tsx @@ -0,0 +1,362 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import CodexAccountPool from "../src/components/CodexAccountPool"; +import { LanguageProvider } from "../src/i18n/provider"; + +const globals = [ + "document", + "window", + "navigator", + "localStorage", + "fetch", + "IS_REACT_ACT_ENVIRONMENT", +] as const; + +interface Deferred { + promise: Promise; + resolve(value: T): void; + reject(reason?: unknown): void; +} + +interface Harness { + container: HTMLDivElement; + outsideButton: HTMLButtonElement; + root: Root; + input: HTMLInputElement; + writes: number[]; + currentInput(): HTMLInputElement | null; + currentToggle(): HTMLButtonElement; + enqueueActive(response: Promise | Response): void; + enqueuePut(response: Promise | Response): void; + refresh(): void; +} + +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let mountedRoot: Root | null; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function flush(): Promise { + await Promise.resolve(); + await new Promise((resolve) => testWindow.setTimeout(resolve, 0)); + await Promise.resolve(); +} + +function setInputValue(input: HTMLInputElement, value: string): void { + Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")! + .set!.call(input, value); + input.dispatchEvent(new testWindow.Event("input", { bubbles: true })); +} + +function keyDown(input: HTMLInputElement, key: string): void { + input.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key, bubbles: true })); +} + +function pointerToggleAfterNullTargetBlur( + input: HTMLInputElement, + toggle: HTMLButtonElement, +): void { + toggle.dispatchEvent(new testWindow.Event("pointerdown", { bubbles: true })); + input.dispatchEvent(new testWindow.FocusEvent("focusout", { + bubbles: true, + relatedTarget: null, + })); + toggle.dispatchEvent(new testWindow.Event("pointerup", { bubbles: true })); + toggle.dispatchEvent(new testWindow.MouseEvent("click", { bubbles: true })); +} + +beforeEach(() => { + previousGlobals = Object.fromEntries( + globals.map((key) => [key, Reflect.get(globalThis, key)]), + ) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + mountedRoot = null; +}); + +afterEach(async () => { + if (mountedRoot) { + await act(async () => { + mountedRoot?.unmount(); + }); + } + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function mountHarness(): Promise { + const activeResponses: Array | Response> = [ + Response.json({ activeCodexAccountId: null, autoSwitchThreshold: 80 }), + ]; + const putResponses: Array | Response> = []; + const writes: number[] = []; + let refreshCallback: (() => void) | null = null; + + Object.defineProperty(testWindow, "setInterval", { + configurable: true, + value: (callback: () => void, delay?: number) => { + if (delay === 30_000) refreshCallback = callback; + return 1; + }, + }); + Object.defineProperty(testWindow, "clearInterval", { + configurable: true, + value: () => {}, + }); + + const fetchRouter = async (input: string | URL | Request, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + if (url.endsWith("/api/codex-auth/accounts") && method === "GET") { + return Response.json({ accounts: [] }); + } + if (url.endsWith("/api/codex-auth/active") && method === "GET") { + const response = activeResponses.shift(); + if (!response) throw new Error("unexpected active-account read"); + return await response; + } + if (url.endsWith("/api/codex-auth/auto-switch") && method === "PUT") { + const body = JSON.parse(String(init?.body)) as { threshold: number }; + writes.push(body.threshold); + const response = putResponses.shift(); + if (!response) throw new Error("unexpected auto-switch write"); + return await response; + } + throw new Error(`unexpected fetch: ${method} ${url}`); + }; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: fetchRouter }); + + const container = document.createElement("div"); + const outsideButton = document.createElement("button"); + outsideButton.textContent = "Outside"; + document.body.append(container, outsideButton); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(container); + mountedRoot = root; + await act(async () => { + root.render( + + + , + ); + await flush(); + }); + await act(flush); + + const input = container.querySelector('input[aria-label="Switch threshold, percent"]'); + expect(input).not.toBeNull(); + expect(input?.value).toBe("80"); + expect(input?.readOnly).toBe(false); + expect(refreshCallback).not.toBeNull(); + + const currentInput = (): HTMLInputElement | null => ( + container.querySelector('input[aria-label="Switch threshold, percent"]') + ); + const currentToggle = (): HTMLButtonElement => { + const toggle = container.querySelector("button.toggle[aria-pressed]"); + if (!toggle) throw new Error("auto-switch toggle was not rendered"); + return toggle; + }; + + return { + container, + outsideButton, + root, + input: input!, + writes, + currentInput, + currentToggle, + enqueueActive(response) { + activeResponses.push(response); + }, + enqueuePut(response) { + putResponses.push(response); + }, + refresh() { + if (!refreshCallback) throw new Error("refresh interval was not registered"); + refreshCallback(); + }, + }; +} + +describe("Codex auto-switch controller interactions", () => { + test("Enter then blur issues exactly one write", async () => { + const harness = await mountHarness(); + const write = deferred(); + harness.enqueuePut(write.promise); + + await act(async () => { + harness.input.focus(); + setInputValue(harness.input, "95"); + keyDown(harness.input, "Enter"); + harness.outsideButton.focus(); + await Promise.resolve(); + }); + + expect(harness.writes).toEqual([95]); + await act(async () => { + write.resolve(new Response(null, { status: 204 })); + await flush(); + }); + expect(harness.input.value).toBe("95"); + expect(harness.container.querySelector('[role="status"]')?.textContent).toContain("updated"); + expect(harness.writes).toEqual([95]); + }); + + test("stale 30-second refresh cannot overwrite a successful edit", async () => { + const harness = await mountHarness(); + const staleRead = deferred(); + const write = deferred(); + harness.enqueueActive(staleRead.promise); + harness.enqueuePut(write.promise); + + await act(async () => { + harness.refresh(); + await Promise.resolve(); + harness.input.focus(); + setInputValue(harness.input, "95"); + keyDown(harness.input, "Enter"); + await Promise.resolve(); + }); + expect(harness.writes).toEqual([95]); + + await act(async () => { + write.resolve(new Response(null, { status: 204 })); + await flush(); + staleRead.resolve(Response.json({ activeCodexAccountId: null, autoSwitchThreshold: 80 })); + await flush(); + }); + + expect(harness.input.value).toBe("95"); + expect(harness.container.textContent).toContain("95% usage or above"); + expect(harness.writes).toEqual([95]); + }); + + test("failed write restores the last confirmed value", async () => { + const harness = await mountHarness(); + harness.enqueuePut(new Response(null, { status: 500 })); + + await act(async () => { + harness.input.focus(); + setInputValue(harness.input, "95"); + keyDown(harness.input, "Enter"); + await flush(); + }); + + expect(harness.input.value).toBe("80"); + expect(harness.container.textContent).toContain("80% usage or above"); + expect(harness.container.querySelector('[role="alert"]')?.textContent).toContain("could not be confirmed"); + expect(harness.writes).toEqual([95]); + }); + + test("Escape cancels without writing", async () => { + const harness = await mountHarness(); + + await act(async () => { + harness.input.focus(); + setInputValue(harness.input, "95"); + keyDown(harness.input, "Escape"); + harness.outsideButton.focus(); + await flush(); + }); + + expect(harness.input.value).toBe("80"); + expect(harness.writes).toEqual([]); + expect(harness.container.querySelector('[role="status"]')).toBeNull(); + }); + + test("pointer toggle disables a dirty valid draft before blur can commit it", async () => { + const harness = await mountHarness(); + harness.enqueuePut(new Response(null, { status: 204 })); + harness.enqueuePut(new Response(null, { status: 204 })); + + await act(async () => { + harness.input.focus(); + setInputValue(harness.input, "95"); + await flush(); + }); + + const dirtyInput = harness.currentInput(); + expect(dirtyInput).not.toBeNull(); + expect(dirtyInput?.value).toBe("95"); + + await act(async () => { + pointerToggleAfterNullTargetBlur(dirtyInput!, harness.currentToggle()); + await flush(); + }); + + expect(harness.writes).toEqual([0]); + expect(harness.currentInput()).toBeNull(); + expect(harness.currentToggle().getAttribute("aria-pressed")).toBe("false"); + expect(harness.container.textContent).toContain("Automatic account switching is off"); + expect(harness.container.querySelector('[role="alert"]')).toBeNull(); + + await act(async () => { + harness.currentToggle().click(); + await flush(); + }); + + expect(harness.writes).toEqual([0, 95]); + expect(harness.currentInput()?.value).toBe("95"); + expect(harness.currentToggle().getAttribute("aria-pressed")).toBe("true"); + expect(harness.container.textContent).toContain("95% usage or above"); + }); + + test("pointer toggle disables an empty draft and restores the default", async () => { + const harness = await mountHarness(); + harness.enqueuePut(new Response(null, { status: 204 })); + harness.enqueuePut(new Response(null, { status: 204 })); + + await act(async () => { + harness.input.focus(); + setInputValue(harness.input, ""); + await flush(); + }); + + const emptyInput = harness.currentInput(); + expect(emptyInput).not.toBeNull(); + expect(emptyInput?.value).toBe(""); + + await act(async () => { + pointerToggleAfterNullTargetBlur(emptyInput!, harness.currentToggle()); + await flush(); + }); + + expect(harness.writes).toEqual([0]); + expect(harness.currentInput()).toBeNull(); + expect(harness.currentToggle().getAttribute("aria-pressed")).toBe("false"); + expect(harness.container.textContent).toContain("Automatic account switching is off"); + expect(harness.container.querySelector('[role="alert"]')).toBeNull(); + + await act(async () => { + harness.currentToggle().click(); + await flush(); + }); + + expect(harness.writes).toEqual([0, 80]); + expect(harness.currentInput()?.value).toBe("80"); + expect(harness.currentToggle().getAttribute("aria-pressed")).toBe("true"); + expect(harness.container.textContent).toContain("80% usage or above"); + }); +}); diff --git a/tests/cli-provider.test.ts b/tests/cli-provider.test.ts index a2429b4eb..2e24a2d8b 100644 --- a/tests/cli-provider.test.ts +++ b/tests/cli-provider.test.ts @@ -388,7 +388,7 @@ describe("ocx provider add --sync", () => { } finally { rmSync(dir, { recursive: true, force: true }); } - }); + }, 15_000); test("provider add --sync --json reports needsSync false", () => { const { dir } = freshConfig(); diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 1cb9dd4ee..5d6e4b374 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -55,6 +55,8 @@ async function providerDiscoveryDto(provider: string): Promise { } finally { warn.mockRestore(); } - }); + }, 15_000); test("exact combo slugs come only from current config", () => { expect(exactComboCatalogSlugs({ combos: { diff --git a/tests/codex-v2-gate.test.ts b/tests/codex-v2-gate.test.ts index 38ba4773d..dbe3bd659 100644 --- a/tests/codex-v2-gate.test.ts +++ b/tests/codex-v2-gate.test.ts @@ -475,7 +475,7 @@ describe("cli surface", () => { if (oldCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = oldCodexHome; if (oldOcxHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = oldOcxHome; } - }); + }, 15_000); }); describe("mock-max wire clamp (nativeEffortClamp)", () => { diff --git a/tests/combo-management-api.test.ts b/tests/combo-management-api.test.ts index 72825d652..5bbf5a0af 100644 --- a/tests/combo-management-api.test.ts +++ b/tests/combo-management-api.test.ts @@ -492,7 +492,7 @@ describe("combo management API", () => { const disabledResponse = await comboApi(config, "GET", "/api/subagent-models"); const disabledBody = await disabledResponse!.json() as { available: string[] }; expect(disabledBody.available).not.toContain("deepseek-v4-flash"); - }); + }, 15_000); test("GET models round-trips a disabled combo alias for the Models GUI", async () => { const config = baseConfig({ @@ -707,7 +707,7 @@ describe("combo management API", () => { else process.env.CODEX_HOME = previousCodexHome; } }); - }); + }, 15_000); test("provider deletion is guarded by sorted combo dependencies until cleanup", async () => { await withTempHome(async () => { diff --git a/tests/cursor-native-exec.test.ts b/tests/cursor-native-exec.test.ts index e7943296b..72237abfa 100644 --- a/tests/cursor-native-exec.test.ts +++ b/tests/cursor-native-exec.test.ts @@ -287,7 +287,10 @@ describe("Cursor native exec bridge", () => { const dir = mkdtempSync(join(tmpdir(), "ocx-cursor-shell-")); const shell = decode((await handleCursorNativeExec(execMessage({ case: "shellArgs", - value: create(ShellArgsSchema, { command: "printf cursor-ok", workingDirectory: dir }), + value: create(ShellArgsSchema, { + command: "node -e \"process.stdout.write('cursor-ok')\"", + workingDirectory: dir, + }), }), { unsafeAllowNativeLocalExec: true }))[0]); expect(shell.message.case).toBe("shellResult"); @@ -301,7 +304,10 @@ describe("Cursor native exec bridge", () => { const dir = mkdtempSync(join(tmpdir(), "ocx-cursor-stream-")); const replies = await handleCursorNativeExec(execMessage({ case: "shellStreamArgs", - value: create(ShellArgsSchema, { command: "printf stream-ok", workingDirectory: dir }), + value: create(ShellArgsSchema, { + command: "node -e \"process.stdout.write('stream-ok')\"", + workingDirectory: dir, + }), }), { unsafeAllowNativeLocalExec: true }); const decodedAll = replies.map(reply => fromBinary(AgentClientMessageSchema, reply)); const execFrames = decodedAll diff --git a/tests/injection-model-api.test.ts b/tests/injection-model-api.test.ts index 36ba6d74e..b3bc8713d 100644 --- a/tests/injection-model-api.test.ts +++ b/tests/injection-model-api.test.ts @@ -145,7 +145,7 @@ describe("/api/injection-model reasoning effort", () => { ); data = await response!.json() as typeof data; expect(data.available.some(model => model.namespaced === alias)).toBe(false); - }); + }, 15_000); }); describe("/api/injection-model guidance kill switch + partial update", () => { diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index d70ca2084..f48666318 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -34,7 +34,7 @@ import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isol // Full-suite Windows load: startServer + multi-step provider PATCH/GET flows exceed the // default 5s per-test budget (same flake class as 810fa115 / claude-management-api). -setDefaultTimeout(30_000); +setDefaultTimeout(60_000); const previousApiToken = process.env.OPENCODEX_API_AUTH_TOKEN; const previousOpencodexHome = process.env.OPENCODEX_HOME; @@ -124,6 +124,8 @@ describe("provider management validation", () => { new Request(requestUrl), requestUrl, { + port: 10100, + defaultProvider: "auth-broken", providers: { "auth-broken": { adapter: "openai-chat", diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 2b4522f71..743f2e255 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -471,7 +471,7 @@ describe("server local API auth", () => { } finally { await server.stop(true); } - }); + }, 15_000); test("safeConfigDTO exposes the freeTier badge flag (WP040)", async () => { const { safeConfigDTO } = await import("../src/server/auth-cors"); diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index b85a94c19..5032e5267 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -510,7 +510,7 @@ describe("server combo failover 030 activation matrix", () => { } finally { await server.stop(true); } - }); + }, 60_000); test("ordinary /v1/models preserves raw nested selectors while an exact combo alias wins", async () => { const config = comboConfig({