From 89a147cc6a4b3c8c360181fa76467e1d416b2fc6 Mon Sep 17 00:00:00 2001 From: Jian Gong Date: Thu, 24 Sep 2026 13:10:54 +0800 Subject: [PATCH 01/15] fix(desktop): reopen the dashboard from the macOS app icon (cherry picked from commit 920c0527eef08acf4c6d07a170e0ea42d311b10c) Co-authored-by: Jian Gong --- desktop/src-tauri/src/lib.rs | 5 +++++ docs-site/src/content/docs/fr/guides/desktop-app.md | 2 ++ docs-site/src/content/docs/guides/desktop-app.md | 2 ++ docs-site/src/content/docs/ja/guides/desktop-app.md | 2 ++ docs-site/src/content/docs/ko/guides/desktop-app.md | 2 ++ docs-site/src/content/docs/ru/guides/desktop-app.md | 2 ++ docs-site/src/content/docs/tr/guides/desktop-app.md | 2 ++ .../src/content/docs/zh-cn/guides/desktop-app.md | 2 ++ .../src/content/docs/zh-tw/guides/desktop-app.md | 2 ++ structure/desktop-shell.md | 6 ++++++ tests/clients/desktop-exit-ownership.test.ts | 12 ++++++++++++ 11 files changed, 39 insertions(+) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e35224af54..227a2a82e8 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -260,6 +260,11 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while building OpenCodex desktop shell") .run(|app, event| { + // Dock/Finder reopening an existing macOS app does not launch a second instance. + #[cfg(target_os = "macos")] + if let tauri::RunEvent::Reopen { .. } = event { + show_dashboard(app.clone()); + } // Window close and the platform quit gesture arrive here as an exit request, and until // this handler existed they went straight through to a SIGKILL of the runtime. D2 makes // them hide; only the tray's Quit, and an update's coordinated restart, get past. diff --git a/docs-site/src/content/docs/fr/guides/desktop-app.md b/docs-site/src/content/docs/fr/guides/desktop-app.md index ee633d9205..e5e42513de 100644 --- a/docs-site/src/content/docs/fr/guides/desktop-app.md +++ b/docs-site/src/content/docs/fr/guides/desktop-app.md @@ -44,6 +44,8 @@ L’application demande à son CLI intégré d’exécuter `ocx resolve --json` Utilisez l’action **Open dashboard** ou **Open in browser** de la zone de notification pour passer du tableau de bord intégré à votre navigateur habituel. Le menu permet aussi de rechercher les mises à jour. +Sur macOS, fermer le tableau de bord laisse l’application active dans la barre des menus. Ouvrez à nouveau OpenCodex depuis le Dock ou le Finder pour réafficher le tableau de bord sans redémarrer le proxy. + ## Utilisation dans la zone de notification Sur macOS et Windows, cliquez sur l’icône pour ouvrir un panneau compact d’utilisation. L’action **Show usage** l’ouvre également, notamment sous Linux lorsque la zone de notification ne transmet pas les clics. Sous Linux, le tableau de bord s’ouvre au démarrage, même si l’environnement de bureau n’affiche pas d’icône. diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index df6541e21f..31ee4150d8 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -59,6 +59,8 @@ it from the tray or launch the app again. Use the tray's **Open dashboard** or **Open in browser** action to move between the embedded dashboard and your normal browser. The tray also provides update checks. +On macOS, closing the dashboard keeps the app running in the menu bar. Open OpenCodex again from Dock or Finder to restore the dashboard without restarting the proxy. + ## Usage in the tray On macOS and Windows, click the tray icon to open a compact usage window. The tray's diff --git a/docs-site/src/content/docs/ja/guides/desktop-app.md b/docs-site/src/content/docs/ja/guides/desktop-app.md index c9ceaba268..ee829b46ed 100644 --- a/docs-site/src/content/docs/ja/guides/desktop-app.md +++ b/docs-site/src/content/docs/ja/guides/desktop-app.md @@ -44,6 +44,8 @@ sudo apt install ./OpenCodex--linux-amd64.deb トレイの **Open dashboard** または **Open in browser** で、埋め込みダッシュボードと通常のブラウザを切り替えられます。トレイから更新の確認もできます。 +macOS では、ダッシュボードを閉じてもアプリはメニューバーで動作し続けます。Dock または Finder から OpenCodex を再度開くと、プロキシを再起動せずにダッシュボードが再表示されます。 + ## トレイでの使用量表示 macOS と Windows ではトレイアイコンをクリックするとコンパクトな使用量ウィンドウが開きます。トレイの **Show usage** 操作でも開けます。これはトレイのクリックイベントを転送しない Linux デスクトップでも使えます。Linux では、デスクトップ環境にトレイアイコンが表示されなくても起動時にダッシュボードが開きます。 diff --git a/docs-site/src/content/docs/ko/guides/desktop-app.md b/docs-site/src/content/docs/ko/guides/desktop-app.md index d910373bb8..ee2c150da0 100644 --- a/docs-site/src/content/docs/ko/guides/desktop-app.md +++ b/docs-site/src/content/docs/ko/guides/desktop-app.md @@ -44,6 +44,8 @@ sudo apt install ./OpenCodex--linux-amd64.deb 트레이의 **Open dashboard** 또는 **Open in browser**를 사용하면 내장 대시보드와 일반 브라우저를 오갈 수 있습니다. 트레이에서는 업데이트도 확인할 수 있습니다. +macOS에서는 대시보드를 닫아도 앱이 메뉴 막대에서 계속 실행됩니다. Dock 또는 Finder에서 OpenCodex를 다시 열면 프록시를 재시작하지 않고 대시보드가 다시 표시됩니다. + ## 트레이에서 사용량 보기 macOS와 Windows에서는 트레이 아이콘을 클릭하면 작은 사용량 창이 열립니다. 트레이의 **Show usage**로도 열 수 있으며, 트레이 클릭 이벤트를 전달하지 않는 Linux 데스크톱에서도 사용할 수 있습니다. Linux에서는 트레이 아이콘이 표시되지 않는 환경을 포함해 시작할 때 대시보드가 열립니다. diff --git a/docs-site/src/content/docs/ru/guides/desktop-app.md b/docs-site/src/content/docs/ru/guides/desktop-app.md index d52917226c..ce4f17b5c7 100644 --- a/docs-site/src/content/docs/ru/guides/desktop-app.md +++ b/docs-site/src/content/docs/ru/guides/desktop-app.md @@ -60,6 +60,8 @@ CLI подтвердил отсутствие прокси; неопределё Используйте действия **Open dashboard** или **Open in browser** в системной панели, чтобы переключаться между встроенным дашбордом и обычным браузером. Там же доступны проверки обновлений. +В macOS после закрытия дашборда приложение продолжает работать в строке меню. Откройте OpenCodex снова через Dock или Finder, чтобы вернуть дашборд без перезапуска прокси. + ## Использование в системной панели На macOS и Windows нажмите значок в системной панели, чтобы открыть компактное окно использования. diff --git a/docs-site/src/content/docs/tr/guides/desktop-app.md b/docs-site/src/content/docs/tr/guides/desktop-app.md index d0c8c59320..b0b635909f 100644 --- a/docs-site/src/content/docs/tr/guides/desktop-app.md +++ b/docs-site/src/content/docs/tr/guides/desktop-app.md @@ -44,6 +44,8 @@ Uygulama, paketindeki CLI'dan `ocx resolve --json` çalıştırmasını ister ve Gömülü kontrol paneli ile normal tarayıcınız arasında geçmek için tepsideki **Open dashboard** veya **Open in browser** eylemini kullanın. Tepsi, güncelleme denetimlerini de sunar. +macOS’te kontrol panelini kapattığınızda uygulama menü çubuğunda çalışmaya devam eder. Proxy’yi yeniden başlatmadan kontrol panelini geri getirmek için OpenCodex’i Dock veya Finder üzerinden yeniden açın. + ## Tepside kullanım macOS ve Windows'ta küçük kullanım penceresini açmak için tepsi simgesine tıklayın. Tepsideki **Show usage** eylemi de pencereyi açar; tıklama olaylarını iletmeyen Linux tepsilerinde de çalışır. Linux'ta masaüstü ortamı tepsi simgesi göstermese bile kontrol paneli başlangıçta açılır. diff --git a/docs-site/src/content/docs/zh-cn/guides/desktop-app.md b/docs-site/src/content/docs/zh-cn/guides/desktop-app.md index 2e94868ce1..0342194eb0 100644 --- a/docs-site/src/content/docs/zh-cn/guides/desktop-app.md +++ b/docs-site/src/content/docs/zh-cn/guides/desktop-app.md @@ -44,6 +44,8 @@ sudo apt install ./OpenCodex--linux-amd64.deb 使用托盘中的 **Open dashboard** 或 **Open in browser**,可在内嵌仪表盘与常用浏览器之间切换。托盘也提供更新检查。 +在 macOS 上,关闭仪表盘后,应用会继续在菜单栏中运行。从 Dock 或 Finder 再次打开 OpenCodex 即可恢复仪表盘,无需重启代理。 + ## 在托盘中查看用量 在 macOS 和 Windows 上,点击托盘图标即可打开紧凑的用量窗口。托盘中的 **Show usage** 也能打开它,包括在不转发点击事件的 Linux 桌面上。在 Linux 上,仪表盘会在启动时打开,即使桌面环境没有显示托盘图标也是如此。 diff --git a/docs-site/src/content/docs/zh-tw/guides/desktop-app.md b/docs-site/src/content/docs/zh-tw/guides/desktop-app.md index 111f96f84e..94febd97fa 100644 --- a/docs-site/src/content/docs/zh-tw/guides/desktop-app.md +++ b/docs-site/src/content/docs/zh-tw/guides/desktop-app.md @@ -44,6 +44,8 @@ sudo apt install ./OpenCodex--linux-amd64.deb 透過系統匣的 **Open dashboard** 或 **Open in browser**,可以在內嵌儀表板與一般瀏覽器間切換。系統匣也提供更新檢查。 +在 macOS 上,關閉儀表板後,應用程式會繼續在選單列中執行。從 Dock 或 Finder 再次開啟 OpenCodex 即可恢復儀表板,無須重新啟動代理。 + ## 系統匣中的用量資訊 在 macOS 與 Windows 上,點擊系統匣圖示可開啟精簡用量視窗。系統匣的 **Show usage** 也能開啟它,包括不會轉送點擊事件的 Linux 桌面環境。Linux 會在啟動時開啟儀表板,即使桌面環境不顯示系統匣圖示也一樣。 diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 53d21f71f7..282c2d812e 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -99,6 +99,12 @@ is the quit. macOS needs one thing beyond the event loop: Tauri's default menu c Quit wired to Cocoa's `terminate:` and the pinned tao raises no cancellable event for it, so `desktop/src-tauri/src/menu.rs` rebuilds that menu with an ordinary item on the same accelerator. +On macOS, the event loop in `desktop/src-tauri/src/lib.rs` handles `RunEvent::Reopen` through the +existing dashboard entry point. Opening the running app from Dock or Finder restores its main +window, closes the usage popup if it is open, and loads the dashboard if a hidden launch deferred +it. This is separate from the single-instance callback, which handles a second process notifying +the existing one. + Every ending drains first, and so does the tray's Stop, which is not an ending: all of them take the same phase, so Stop pressed twice, Stop then Quit, and Stop during an update are one execution over one child rather than several racing. Ownership is re-established at the start of each drain rather diff --git a/tests/clients/desktop-exit-ownership.test.ts b/tests/clients/desktop-exit-ownership.test.ts index 27cc075f3f..3578d7c5ee 100644 --- a/tests/clients/desktop-exit-ownership.test.ts +++ b/tests/clients/desktop-exit-ownership.test.ts @@ -42,6 +42,18 @@ function shellSources(directory: string = SRC): string[] { } describe("desktop exit ownership", () => { + test("macOS reopen reaches the existing dashboard entry point", () => { + // Wiring guard only; the native close/reopen gesture needs a macOS app session. + const lib = code(LIB); + const run = lib.slice(lib.indexOf(".run(|app, event|")); + expect(run).toMatch( + /#\[cfg\(target_os = "macos"\)\]\s*if let tauri::RunEvent::Reopen \{ \.\. \} = event \{\s*show_dashboard\(app\.clone\(\)\);\s*\}/, + ); + const show = lib.slice(lib.indexOf("fn show_dashboard("), lib.indexOf("fn hide_dashboard(")); + expect(show).toContain("popup::hide(&app)"); + expect(show).toContain("startup::open_dashboard(&app)"); + }); + test("the event loop intercepts the exit request instead of letting it through", () => { const lib = code(LIB); expect(lib).toContain("RunEvent::ExitRequested"); From 2e3b917b6e1951bc4c05e450600d52a9080b246e Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 16:13:16 +0900 Subject: [PATCH 02/15] fix(desktop,gui): pause dashboard polling while the desktop window is hidden WebView2 does not flip document.visibilityState when the Tauri window hides to the tray, so the dashboard kept polling. The shell now publishes window.__OPENCODEX_HOST_VISIBLE__ and an opencodex:host-visibility event for the main window on show, hide and every page load, and visibility-poll, client-resource and Combos read one deduped predicate from gui/src/host-visibility.ts. macOS WKWebView was measured to flip visibilityState already. Refs #5493 --- desktop/src-tauri/src/lib.rs | 13 ++ desktop/src-tauri/src/window.rs | 23 ++ gui/src/client-resource.ts | 27 ++- gui/src/host-visibility.ts | 71 +++++++ gui/src/pages/Combos.tsx | 7 +- gui/src/visibility-poll.ts | 19 +- gui/tests/host-visibility.test.ts | 196 ++++++++++++++++++ scripts/test-layout/layout.json | 1 + structure/desktop-shell.md | 13 ++ tests/clients/desktop-host-visibility.test.ts | 82 ++++++++ tests/fixtures/test-layout-expected.json | 1 + 11 files changed, 427 insertions(+), 26 deletions(-) create mode 100644 gui/src/host-visibility.ts create mode 100644 gui/tests/host-visibility.test.ts create mode 100644 tests/clients/desktop-host-visibility.test.ts diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 227a2a82e8..5b34a44613 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -242,6 +242,19 @@ pub fn run() { // to the loopback dashboard by `capabilities/dashboard-zoom.json`. .zoom_hotkeys_enabled(true) .on_navigation(window::navigation_allowed(app.handle().clone())) + // A hidden window still loads pages: wry builds this one with WebView2 + // IsVisible=false, and the bootstrap page navigates to the dashboard URL + // afterwards, so the eval that a later show or hide would rely on has nowhere + // to land during a reload. Re-sending the current state here is what keeps the + // GUI's answer correct across navigation. + .on_page_load(|window, payload| { + if matches!(payload.event(), tauri::webview::PageLoadEvent::Finished) { + window::report_visibility( + &window, + window.is_visible().unwrap_or(false), + ); + } + }) .build()?; window::configure(&window); if startup::LaunchOrigin::detect() == startup::LaunchOrigin::User { diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs index a886fb0776..c61887b06f 100644 --- a/desktop/src-tauri/src/window.rs +++ b/desktop/src-tauri/src/window.rs @@ -79,14 +79,37 @@ fn is_app_origin(url: &Url) -> bool { pub fn show(window: &WebviewWindow) { let _ = window.show(); let _ = window.set_focus(); + report_visibility(window, true); apply_tray_policy(window.app_handle(), true); } pub fn hide(window: &WebviewWindow) { let _ = window.hide(); + report_visibility(window, false); apply_tray_policy(window.app_handle(), false); } +/// Tell the main window's page whether its host window is visible. +/// +/// Windows WebView2 does not flip `document.visibilityState` when the host window is hidden +/// (tauri issues #10592 and #6864), so the dashboard's pollers keep running while the app sits in +/// the tray; macOS WKWebView does flip it. Publishing the host's own answer gives the GUI one +/// signal on every platform instead of one that is correct on only some of them. +/// +/// Only the `main` window publishes: `exit::hide_windows` hides every window through `hide`, +/// and the tray popup carries its own equivalent bridge, so an unguarded report would claim the +/// dashboard was hidden because a popup was. A page that has not loaded yet simply misses the eval; +/// the page-load hook re-sends the current state. +pub fn report_visibility(window: &WebviewWindow, visible: bool) { + if window.label() != "main" { + return; + } + let script = format!( + "window.__OPENCODEX_HOST_VISIBLE__ = {visible}; window.dispatchEvent(new CustomEvent('opencodex:host-visibility', {{detail: {visible}}}));" + ); + let _ = window.eval(script); +} + #[cfg(target_os = "macos")] fn apply_tray_policy(app: &AppHandle, visible: bool) { let policy = if visible { diff --git a/gui/src/client-resource.ts b/gui/src/client-resource.ts index 1ec40fc28f..bed565430d 100644 --- a/gui/src/client-resource.ts +++ b/gui/src/client-resource.ts @@ -1,4 +1,5 @@ import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react"; +import { hostDocumentHidden, onHostVisibilityChange } from "./host-visibility"; export type ResourceSnapshot = { data: T | undefined; @@ -196,7 +197,7 @@ function joinPollBucket(store: Store, intervalMs: number) { /** True when the document is currently hidden. Safe on non-browser runtimes. */ function documentIsHidden(): boolean { - return typeof document !== "undefined" && document.visibilityState === "hidden"; + return hostDocumentHidden(); } /** @@ -274,11 +275,14 @@ function recomputePoll(store: Store) { * * `replaceInflight: false` keeps this from cancelling work a visible-again mount just * started; if something is already loading, that request is the fresh answer. + * + * The subscription is host-visibility's deduped one, so a single hide (both the + * document event and the desktop host event on macOS) sweeps once, not twice. */ -let moduleVisibilityListener: (() => void) | null = null; +let moduleVisibilityUnsubscribe: (() => void) | null = null; function ensureVisibilityListener(_store: Store) { - if (typeof document === "undefined" || moduleVisibilityListener) return; + if (typeof document === "undefined" || moduleVisibilityUnsubscribe) return; const onVisibility = () => { syncAllBuckets(); if (documentIsHidden()) return; @@ -291,18 +295,15 @@ function ensureVisibilityListener(_store: Store) { } } }; - document.addEventListener("visibilitychange", onVisibility); - moduleVisibilityListener = onVisibility; + moduleVisibilityUnsubscribe = onHostVisibilityChange(onVisibility); } /** Drop the shared listener once nothing polls at all. */ function removeVisibilityListener(_store: Store) { - if (!moduleVisibilityListener) return; + if (!moduleVisibilityUnsubscribe) return; if (pollBuckets.size > 0) return; - if (typeof document !== "undefined") { - document.removeEventListener("visibilitychange", moduleVisibilityListener); - } - moduleVisibilityListener = null; + moduleVisibilityUnsubscribe(); + moduleVisibilityUnsubscribe = null; } async function runFetch( @@ -675,10 +676,8 @@ export function clearClientResourceStoresForTests(): void { } // The shared listener outlives individual stores, so the reset must drop it too or // a later suite's document would keep a handler bound to the previous one. - if (moduleVisibilityListener && typeof document !== "undefined") { - document.removeEventListener("visibilitychange", moduleVisibilityListener); - } - moduleVisibilityListener = null; + moduleVisibilityUnsubscribe?.(); + moduleVisibilityUnsubscribe = null; } /** diff --git a/gui/src/host-visibility.ts b/gui/src/host-visibility.ts new file mode 100644 index 0000000000..b8264b33e5 --- /dev/null +++ b/gui/src/host-visibility.ts @@ -0,0 +1,71 @@ +/** + * The single answer to "is the dashboard hidden right now?". + * + * A plain browser is answered by `document.visibilityState`. The desktop shell is not + * always: WebView2 on Windows is reported to keep "visible" while the Tauri window sits + * hidden in the tray (tauri issues #10592, #6864), so every dashboard poller went on + * fetching for a window nobody could see. macOS WKWebView does flip it (measured). + * The native side closes that gap by pushing the truth into the page — + * `window.__OPENCODEX_HOST_VISIBLE__` plus an `opencodex:host-visibility` event, on + * every show/hide and again after each page load — and this module folds both signals + * into one predicate. + * + * Consumers read {@link hostDocumentHidden} and subscribe through + * {@link onHostVisibilityChange} instead of touching `document.visibilityState`; + * the tray popup keeps its own equivalent bridge (`opencodex:tray-visibility`). + */ + +declare global { + interface Window { + /** + * Pushed by the desktop shell: `false` while the main dashboard window is hidden + * to the tray, `true` when it is shown. Absent in a browser, where the flag has no + * meaning and `undefined !== false` keeps the document the only signal. + */ + __OPENCODEX_HOST_VISIBLE__?: boolean; + } +} + +/** True when the dashboard is hidden — by the browser tab or by the desktop host. */ +export function hostDocumentHidden(): boolean { + if (typeof document !== "undefined" && document.visibilityState === "hidden") return true; + return typeof window !== "undefined" && window.__OPENCODEX_HOST_VISIBLE__ === false; +} + +/** + * Call `callback` on every real host-visibility transition, and return the unsubscribe. + * + * Both signals are watched: `visibilitychange` for browsers and macOS, the custom host + * event for the Windows case the standard event cannot see. On macOS both arrive for a + * single hide, and a consumer's visible-again path is a make-up fetch, so the + * transition is deduped against the last computed value per subscription — one hide is + * one callback, one show is one callback, and a duplicate signal costs nothing. + */ +export function onHostVisibilityChange(callback: () => void): () => void { + let last = hostDocumentHidden(); + + const notify = () => { + const next = hostDocumentHidden(); + if (next === last) return; + last = next; + callback(); + }; + + const onHostEvent = (event: Event) => { + const detail = (event as CustomEvent).detail; + // The flag must land before the reading below, or the event would evaluate against + // the previous state and the transition would be deduped away. + if (typeof window !== "undefined" && typeof detail === "boolean") { + window.__OPENCODEX_HOST_VISIBLE__ = detail; + } + notify(); + }; + + if (typeof document !== "undefined") document.addEventListener("visibilitychange", notify); + if (typeof window !== "undefined") window.addEventListener("opencodex:host-visibility", onHostEvent); + + return () => { + if (typeof document !== "undefined") document.removeEventListener("visibilitychange", notify); + if (typeof window !== "undefined") window.removeEventListener("opencodex:host-visibility", onHostEvent); + }; +} diff --git a/gui/src/pages/Combos.tsx b/gui/src/pages/Combos.tsx index 18b997a4b5..6d299c6c99 100644 --- a/gui/src/pages/Combos.tsx +++ b/gui/src/pages/Combos.tsx @@ -8,6 +8,7 @@ import { nextProviderQuotaStateExpiration, toPutBody, } from "../combo-workspace-data"; +import { hostDocumentHidden, onHostVisibilityChange } from "../host-visibility"; import { hideRedundantChatGptForwardProviders } from "../provider-workspace/catalog"; import { readSessionListCacheEntry, writeSessionListCacheEntry } from "../session-list-cache"; import { Notice } from "../ui"; @@ -252,11 +253,11 @@ export default function Combos({ // A new snapshot may be newer than this clock, so unknown state also gets one immediate check. const timer = window.setTimeout(recheck, quotaExpiry === undefined ? 0 : Math.max(0, quotaExpiry - Date.now())); - const onVisible = () => { if (document.visibilityState === "visible") recheck(); }; - document.addEventListener("visibilitychange", onVisible); + const onVisible = () => { if (!hostDocumentHidden()) recheck(); }; + const unsubscribeVisibility = onHostVisibilityChange(onVisible); return () => { window.clearTimeout(timer); - document.removeEventListener("visibilitychange", onVisible); + unsubscribeVisibility(); }; }, [active, apiBase, quotaResource.data, quotaResource.lastAttemptOk, quotaExpiry]); diff --git a/gui/src/visibility-poll.ts b/gui/src/visibility-poll.ts index 40eed778c3..6e74947968 100644 --- a/gui/src/visibility-poll.ts +++ b/gui/src/visibility-poll.ts @@ -5,10 +5,15 @@ * setInterval pollers (log viewers, settings cards, OAuth status) hand-rolled the * same pattern nine different ways — most without any visibility handling, so a * background tab kept paying full poll cost. This helper is the one place that - * owns the rule: while document.hidden there is no interval and no callback; on - * visible-again one make-up tick fires immediately, then the cadence resumes. + * owns the rule: while the dashboard is hidden there is no interval and no callback; + * on visible-again one make-up tick fires immediately, then the cadence resumes. + * + * "Hidden" comes from host-visibility.ts rather than `document.visibilityState`, which + * on the Windows desktop shell stays "visible" while the window sits in the tray. */ +import { hostDocumentHidden, onHostVisibilityChange } from "./host-visibility"; + export type VisibilityPollOptions = { /** * Default true: hidden tabs neither tick nor hold a timer. Set false only for @@ -21,7 +26,7 @@ export type VisibilityPollOptions = { }; function hiddenNow(): boolean { - return typeof document !== "undefined" && document.visibilityState === "hidden"; + return hostDocumentHidden(); } /** @@ -95,16 +100,12 @@ export function startVisibilityPoll( } else { arm(); } - if (pauseWhenHidden && typeof document !== "undefined") { - document.addEventListener("visibilitychange", onVisibility); - } + const unsubscribeVisibility = pauseWhenHidden ? onHostVisibilityChange(onVisibility) : null; if (options?.immediate) tick(); return () => { stopped = true; disarm(); - if (pauseWhenHidden && typeof document !== "undefined") { - document.removeEventListener("visibilitychange", onVisibility); - } + unsubscribeVisibility?.(); }; } diff --git a/gui/tests/host-visibility.test.ts b/gui/tests/host-visibility.test.ts new file mode 100644 index 0000000000..3604f95bb2 --- /dev/null +++ b/gui/tests/host-visibility.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, expect, test as bunTest } from "bun:test"; +import { Window } from "happy-dom"; +import { act, createElement } from "react"; +import type { Root } from "react-dom/client"; +import { + clearClientResourceStoresForTests, + hasPollTimerForTests, + useClientResource, +} from "../src/client-resource"; +import { hostDocumentHidden, onHostVisibilityChange } from "../src/host-visibility"; +import { startVisibilityPoll } from "../src/visibility-poll"; + +// Same setup as visibility-poll.test.ts / client-resource-poll.test.tsx: a real +// happy-dom window installed as the globals the modules read, plus Bun's own clock. +function test(name: string, fn: () => void | Promise): void { + bunTest(name, fn, { timeout: 30_000 }); +} + +const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; + +beforeEach(() => { + clearClientResourceStoresForTests(); + previousGlobals = Object.fromEntries(globals.map((key) => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + // The flag is per page, never per test: a leftover `false` would hide every case after it. + Reflect.deleteProperty(testWindow, "__OPENCODEX_HOST_VISIBLE__"); +}); + +afterEach(() => { + clearClientResourceStoresForTests(); + Reflect.deleteProperty(testWindow, "__OPENCODEX_HOST_VISIBLE__"); + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +/** happy-dom derives visibilityState from internals, so drive it directly. */ +function setVisibility(state: "visible" | "hidden"): void { + Object.defineProperty(testWindow.document, "visibilityState", { + configurable: true, + get: () => state, + }); + testWindow.document.dispatchEvent(new testWindow.Event("visibilitychange")); +} + +function hostWindow(): { __OPENCODEX_HOST_VISIBLE__?: boolean } { + return testWindow as unknown as { __OPENCODEX_HOST_VISIBLE__?: boolean }; +} + +function hostFlag(): boolean | undefined { + return hostWindow().__OPENCODEX_HOST_VISIBLE__; +} + +/** The desktop shell's bridge: the side effect first, then the event. */ +function dispatchHostVisibility(visible: boolean): void { + hostWindow().__OPENCODEX_HOST_VISIBLE__ = visible; + dispatchHostEvent(visible); +} + +/** Event only, so the module itself has to take the state from `detail`. */ +function dispatchHostEvent(visible: boolean): void { + testWindow.dispatchEvent(new testWindow.CustomEvent("opencodex:host-visibility", { detail: visible })); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Same busy-runner ceiling as client-resource-poll.test.tsx: the wait is about whether. */ +async function waitFor(predicate: () => boolean, timeoutMs = 15_000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out"); + await act(async () => { + await new Promise((resolve) => testWindow.setTimeout(resolve, 10)); + }); + } +} + +test("the document and the host flag each hide the page; a browser with no flag is visible", () => { + expect(hostDocumentHidden()).toBe(false); + setVisibility("hidden"); + expect(hostDocumentHidden()).toBe(true); + setVisibility("visible"); + expect(hostDocumentHidden()).toBe(false); + + // An explicit true must not hide anything, and the document still wins when both speak. + hostWindow().__OPENCODEX_HOST_VISIBLE__ = true; + expect(hostDocumentHidden()).toBe(false); + hostWindow().__OPENCODEX_HOST_VISIBLE__ = false; + expect(hostDocumentHidden()).toBe(true); + setVisibility("hidden"); + expect(hostDocumentHidden()).toBe(true); +}); + +// The Windows WebView2 case: the document stays "visible" forever, so only the host +// event can suspend a poller. +test("the host event suspends a poller the document never reports hidden, then resumes it", async () => { + let calls = 0; + const stop = startVisibilityPoll(() => { calls += 1; }, 30); + await sleep(75); + const atStart = calls; + expect(atStart).toBeGreaterThanOrEqual(1); + expect(testWindow.document.visibilityState).toBe("visible"); // nothing else changed + + dispatchHostEvent(false); + expect(hostFlag()).toBe(false); // the module wrote the flag from the event detail + expect(hostDocumentHidden()).toBe(true); + await sleep(160); // >5 intervals: zero calls without the suspension + expect(calls).toBe(atStart); + + dispatchHostEvent(true); + await sleep(10); + expect(calls).toBe(atStart + 1); // exactly the make-up tick + await sleep(90); + expect(calls).toBeGreaterThanOrEqual(atStart + 2); // cadence resumed + stop(); +}); + +// On macOS both signals arrive for one hide. Consumers fetch on visible-again, so a +// second callback for the same transition would double-fetch. +test("two signals reporting one transition call back once", () => { + const seen: boolean[] = []; + const unsubscribe = onHostVisibilityChange(() => seen.push(hostDocumentHidden())); + + setVisibility("hidden"); + dispatchHostVisibility(false); + expect(seen).toEqual([true]); + + setVisibility("visible"); + dispatchHostVisibility(true); + expect(seen).toEqual([true, false]); + + // A repeated signal for a state already known is silent too. + dispatchHostVisibility(true); + setVisibility("visible"); + expect(seen).toEqual([true, false]); + + unsubscribe(); + setVisibility("hidden"); + expect(seen).toEqual([true, false]); // no listener left behind +}); + +test("a polling subscriber skips its interval for a host-hidden window and makes up once", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + const KEY = `host-visibility-poll-${Date.now()}`; + let fetches = 0; + + function Page() { + useClientResource(KEY, async () => { fetches += 1; return `v${fetches}`; }, { pollMs: 150 }); + return null; + } + + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(createElement(Page)); + }); + await waitFor(() => fetches >= 1); + expect(hasPollTimerForTests(KEY)).toBe(true); + + await act(async () => { + dispatchHostVisibility(false); + await Promise.resolve(); + }); + expect(hasPollTimerForTests(KEY)).toBe(false); // suspended: the timer is gone + + // An in-flight tick may still settle; take the count once the store is quiet. + await act(async () => { await new Promise((resolve) => testWindow.setTimeout(resolve, 60)); }); + const atHidden = fetches; + await act(async () => { await new Promise((resolve) => testWindow.setTimeout(resolve, 320)); }); + expect(fetches).toBe(atHidden); // >2 intervals, no fetch + + await act(async () => { + dispatchHostVisibility(true); + await Promise.resolve(); + }); + await waitFor(() => fetches === atHidden + 1); + await act(async () => { await new Promise((resolve) => testWindow.setTimeout(resolve, 50)); }); + expect(fetches).toBe(atHidden + 1); // one make-up fetch, not a burst + await waitFor(() => fetches >= atHidden + 2); // then the cadence + + await act(async () => { root.unmount(); }); + container.remove(); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index eddd75946f..2b740cd1ca 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -776,6 +776,7 @@ "desktop-app-restart.test.ts": "clients", "desktop-cli-contracts.test.ts": "clients", "desktop-exit-ownership.test.ts": "clients", + "desktop-host-visibility.test.ts": "clients", "desktop-install-identity.test.ts": "clients", "desktop-profile.test.ts": "clients", "desktop-proxy-direct-transport.test.ts": "clients", diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 282c2d812e..e82d89df53 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -105,6 +105,19 @@ window, closes the usage popup if it is open, and loads the dashboard if a hidde it. This is separate from the single-instance callback, which handles a second process notifying the existing one. +The host window also answers whether the dashboard is visible at all. Windows WebView2 is reported +to keep `document.visibilityState === "visible"` while the Tauri window sits hidden in the tray +(tauri issues #10592 and #6864; macOS WKWebView does flip it, measured), so a hidden dashboard went +on polling for nobody. `desktop/src-tauri/src/window.rs` therefore publishes the shell's own +answer — the page global `window.__OPENCODEX_HOST_VISIBLE__` and an `opencodex:host-visibility` +CustomEvent — from `show` and `hide`, with a label guard so only `main` reports while +`exit::hide_windows` hides every window through the same `hide`; the main window's builder in +`lib.rs` re-sends the current state on every `PageLoadEvent::Finished`, which covers a reload or +the bootstrap page's later navigation to the dashboard URL. The GUI folds both the standard event +and this one into a single predicate in `gui/src/host-visibility.ts`, which +`gui/src/visibility-poll.ts` and `gui/src/client-resource.ts` read in place of +`document.visibilityState`. The tray popup keeps its own equivalent bridge. + Every ending drains first, and so does the tray's Stop, which is not an ending: all of them take the same phase, so Stop pressed twice, Stop then Quit, and Stop during an update are one execution over one child rather than several racing. Ownership is re-established at the start of each drain rather diff --git a/tests/clients/desktop-host-visibility.test.ts b/tests/clients/desktop-host-visibility.test.ts new file mode 100644 index 0000000000..db62bfb3b9 --- /dev/null +++ b/tests/clients/desktop-host-visibility.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * The main window's host-visibility bridge. + * + * Windows WebView2 does not flip `document.visibilityState` when the host window is hidden to the + * tray (tauri issues #10592 and #6864), so on Windows a hidden dashboard keeps polling while macOS + * WKWebView (measured) stops. The shell therefore publishes its own answer: the page global + * `window.__OPENCODEX_HOST_VISIBLE__` plus an `opencodex:host-visibility` CustomEvent, sent when + * the window is shown or hidden by the shell and re-sent after every page load of the main window. + * + * The wiring is what carries that contract, and no CI job can observe it: the shell is built + * against a placeholder sidecar and there is no session to hide a real window in. It is read out of + * the Rust source, the way the exit ownership and start-at-login orderings already are. + */ +const SRC = "desktop/src-tauri/src"; +const WINDOW = repoPath(`${SRC}/window.rs`); +const LIB = repoPath(`${SRC}/lib.rs`); +const POPUP = repoPath(`${SRC}/popup.rs`); + +function code(path: string): string { + return readFileSync(path, "utf8").replace(/\/\/[^\n]*/g, ""); +} + +/** The body of one function, from its signature to the first line that closes it. */ +function body(source: string, signature: string): string { + const start = source.indexOf(signature); + expect(start).toBeGreaterThan(-1); + const rest = source.slice(start); + return rest.slice(0, rest.indexOf("\n}")); +} + +describe("desktop host visibility bridge", () => { + test("only the main window publishes its visibility", () => { + const report = body(code(WINDOW), "pub fn report_visibility("); + // exit.rs hides every window through window::hide, and the tray popup has its own bridge, so + // an unguarded report would answer for the dashboard from a popup. + expect(report).toContain('if window.label() != "main"'); + expect(report).toContain("return;"); + expect(report).toContain("window.__OPENCODEX_HOST_VISIBLE__ = {visible}"); + expect(report).toContain("new CustomEvent('opencodex:host-visibility'"); + expect(report).toContain("window.eval("); + }); + + test("show and hide report the state they just applied", () => { + const window = code(WINDOW); + const show = body(window, "pub fn show("); + expect(show).toContain("report_visibility(window, true)"); + expect(show.indexOf("report_visibility(window, true)")).toBeGreaterThan( + show.indexOf("window.show()"), + ); + const hide = body(window, "pub fn hide("); + expect(hide).toContain("report_visibility(window, false)"); + expect(hide.indexOf("report_visibility(window, false)")).toBeGreaterThan( + hide.indexOf("window.hide()"), + ); + }); + + test("the main window re-reports its visibility after every page load", () => { + const lib = code(LIB); + const builder = lib.slice(lib.indexOf('WebviewWindowBuilder::new(app, "main"')); + expect(builder.length).toBeGreaterThan(0); + const built = builder.slice(0, builder.indexOf(".build()?")); + // A hidden window still navigates: the bootstrap page hands off to the dashboard URL, and a + // reload would otherwise leave the page's answer stale until the next show or hide. + expect(built).toContain(".on_page_load("); + expect(built).toContain("tauri::webview::PageLoadEvent::Finished"); + expect(built).toContain("window::report_visibility("); + expect(built).toContain("window.is_visible().unwrap_or(false)"); + }); + + test("the tray popup's bridge is not the main window's", () => { + const popup = code(POPUP); + expect(popup).toContain("__OPENCODEX_TRAY_VISIBLE__"); + expect(popup).toContain("opencodex:tray-visibility"); + // Its window is not the dashboard, so its visibility must not answer for it. + expect(popup).not.toContain("__OPENCODEX_HOST_VISIBLE__"); + expect(popup).not.toContain("opencodex:host-visibility"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index ce99be13d8..6fb1dfef20 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -603,6 +603,7 @@ "desktop-app-restart.test.ts": "clients", "desktop-cli-contracts.test.ts": "clients", "desktop-exit-ownership.test.ts": "clients", + "desktop-host-visibility.test.ts": "clients", "desktop-install-identity.test.ts": "clients", "desktop-profile.test.ts": "clients", "desktop-proxy-direct-transport.test.ts": "clients", From 82907bfbf8a0fc81d579be492df12a04e8308cf0 Mon Sep 17 00:00:00 2001 From: Jian Gong Date: Thu, 24 Sep 2026 06:50:35 +0800 Subject: [PATCH 03/15] fix(hermes): generate and safely adopt dynamic session affinity (cherry picked from commit fa097ba515cf7864f16fcde8eeed8d216bb3b7b0) Closes #5710 Co-authored-by: Jian Gong --- .../content/docs/fr/guides/integrations.md | 2 + .../src/content/docs/guides/integrations.md | 22 +++ .../content/docs/ja/guides/integrations.md | 2 + .../content/docs/ko/guides/integrations.md | 2 + .../content/docs/ru/guides/integrations.md | 2 + .../content/docs/tr/guides/integrations.md | 2 + .../content/docs/zh-cn/guides/integrations.md | 2 + .../content/docs/zh-tw/guides/integrations.md | 2 + scripts/test-layout/layout.json | 1 + src/clients/config-export.ts | 3 + src/integrations/owned-refresh.ts | 2 +- src/integrations/ownership-policy.ts | 33 +++- src/integrations/state.ts | 4 +- src/integrations/writer.ts | 9 + structure/clients/integrations.md | 20 +++ .../integrations-hermes-affinity.test.ts | 161 ++++++++++++++++++ .../client-config-export-new-clients.test.ts | 1 + tests/fixtures/test-layout-expected.json | 1 + 18 files changed, 268 insertions(+), 3 deletions(-) create mode 100644 tests/clients/integrations-hermes-affinity.test.ts diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index 1f3e04ada5..fa50e57a3d 100644 --- a/docs-site/src/content/docs/fr/guides/integrations.md +++ b/docs-site/src/content/docs/fr/guides/integrations.md @@ -134,6 +134,8 @@ Kimi Code, gjc, MiniMax Code et Raycast — documents YAML, JSON5 et TOML rééc d'opencodex ont été modifiées, le commutateur se verrouille et la désactivation est refusée plutôt que de deviner quelles modifications vous appartiennent. +Exception pour Hermes : l'ajout de `session_affinity_header: session-id` seul dans un bloc déjà géré peut être adopté via **Apply** ; toute autre modification d'un champ géré reste un conflit. Jusqu'à cette application, l'actualisation automatique de la liste des modèles est également suspendue. Le réglage concerne tous les modèles du provider et nécessite une version de Hermes qui le prend en charge ; il ne garantit aucun taux de succès du cache. Voir le [guide de mise à niveau en anglais](/guides/integrations/#hermes-session-affinity). + ## Prévisualiser et confirmer les modifications Appliquer, Remplacer, Désactiver et Restaurer commencent désormais par un aperçu. La boîte de dialogue diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index ee28e6e7ad..71549cf66e 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -200,6 +200,28 @@ undoable. The switch itself stays locked, because the switch cannot know which e you meant to keep — only you can say so. Nothing else is relaxed: a file we cannot parse, or one whose structure we cannot reason about, still refuses. +## Hermes session affinity + +The generated `providers.opencodex` block includes `session_affinity_header: session-id` for all +models. This names a header; Hermes supplies its dynamic conversation identifier. OpenCodex does +not write a shared static identifier or change `api_mode` to enable affinity. + +Use a Hermes version supporting [per-provider request options](https://hermes-agent.nousresearch.com/docs/user-guide/configuring-models#per-provider-request-options). +Older versions may ignore or discard the option; a valid configuration alone does not prove that +Hermes sends the header. Conversation isolation, compaction lineage and auxiliary/child requests +follow Hermes' affinity semantics. This setting does not guarantee a particular cache-hit rate. + +For an existing managed integration, open **Integrations → Hermes**, review **Apply**, and confirm +the update. Until then, it shows **Update needed** and implicit catalog refresh leaves it unchanged, +including its model list. Reading the page does not upgrade the configuration. After Apply, normal +catalog refresh resumes and retains the setting; **Replace** also includes it. + +If you already added exactly `session_affinity_header: session-id` inside the managed block, Apply +can adopt it when all other managed settings still match the ownership record. This is the narrow +exception to the conflict rule above: other edits, a different header name, or a block without a +matching ownership record still require conflict resolution. Unrelated YAML settings and comments +remain untouched, and the existing snapshot and Restore workflow applies to the upgrade. + ## Preview and confirm changes Apply, Replace, Disable, and Restore now begin with a preview. The dialog shows exactly which diff --git a/docs-site/src/content/docs/ja/guides/integrations.md b/docs-site/src/content/docs/ja/guides/integrations.md index 5fa241bf77..43ccd49d70 100644 --- a/docs-site/src/content/docs/ja/guides/integrations.md +++ b/docs-site/src/content/docs/ja/guides/integrations.md @@ -85,6 +85,8 @@ Disable は opencodex が自身のものとして記録した項目だけを削 ロックされても操作不能ではありません。競合したクライアントには、概要カードとクライアントページの両方で、スイッチの横に **Replace** が表示されます。管理対象設定が置かれている内容を opencodex のブロックで置き換える操作で、先に確認を求めます。ダイアログにはファイル名、失われる内容、元に戻すためのスナップショットが示されます。スイッチ自体はロックされたままです。どの編集を維持するか判断できるのは利用者だけだからです。それ以外の制約は緩めません。解析できないファイルや、構造を安全に判断できないファイルは引き続き拒否されます。 +Hermes のセッション識別設定には例外があります。管理対象の設定に `session_affinity_header: session-id` だけを追加した場合、**Apply** で取り込めます。他の管理対象フィールドの変更は引き続き競合になります。適用するまでバックグラウンドのモデル一覧更新も保留されます。この設定は provider 内の全モデルに適用され、対応する Hermes バージョンが必要です。キャッシュヒット率は保証されません。[英語のアップグレード説明](/guides/integrations/#hermes-session-affinity)を参照してください。 + ## 変更内容を確認して確定する Apply、Replace、Disable、Restore はプレビューから始まります。ダイアログには、変更対象の管理設定が、範囲を限定した変更パスと値の追加・更新・削除の区別とともに表示されます。確定前に内容を確認してください。 diff --git a/docs-site/src/content/docs/ko/guides/integrations.md b/docs-site/src/content/docs/ko/guides/integrations.md index a2377a3968..5d27c27ba6 100644 --- a/docs-site/src/content/docs/ko/guides/integrations.md +++ b/docs-site/src/content/docs/ko/guides/integrations.md @@ -85,6 +85,8 @@ Disable은 opencodex가 소유한다고 기록한 항목만 제거합니다. 이 잠겨도 해결 방법이 있습니다. 충돌한 클라이언트는 개요 카드와 클라이언트 페이지의 스위치 옆에 **Replace**를 표시합니다. opencodex 설정을 담은 부분을 새 블록으로 교체하기 전에 확인을 요청합니다. 대화 상자에 파일 이름, 잃게 될 내용, 되돌릴 수 있게 해 주는 스냅샷이 나옵니다. 스위치는 어떤 편집을 유지할지 판단할 수 없으므로 잠긴 채로 둡니다. 그 결정은 사용자가 해야 합니다. 그 밖의 거부 조건은 완화하지 않습니다. 파싱할 수 없거나 구조를 판단할 수 없는 파일은 여전히 거부합니다. +Hermes 세션 식별 설정에는 예외가 있습니다. 기존 관리 설정에 `session_affinity_header: session-id`만 추가했다면 **Apply**로 수용할 수 있습니다. 다른 관리 필드의 수정은 계속 충돌로 처리됩니다. 적용 전에는 백그라운드 모델 목록 갱신도 보류됩니다. 이 설정은 provider의 모든 모델에 적용되며 해당 기능을 지원하는 Hermes 버전이 필요합니다. 캐시 적중률은 보장하지 않습니다. [영문 업그레이드 안내](/guides/integrations/#hermes-session-affinity)를 참조하세요. + ## 변경 미리 보기와 확인 Apply, Replace, Disable, Restore는 미리 보기로 시작합니다. 대화 상자는 제한된 변경 경로와 각 값의 추가·갱신·삭제 여부를 포함해 어떤 관리 설정이 바뀔지 정확히 보여줍니다. 확인하기 전에 계획을 검토하세요. diff --git a/docs-site/src/content/docs/ru/guides/integrations.md b/docs-site/src/content/docs/ru/guides/integrations.md index e63b5ba93f..e496ae9b70 100644 --- a/docs-site/src/content/docs/ru/guides/integrations.md +++ b/docs-site/src/content/docs/ru/guides/integrations.md @@ -231,6 +231,8 @@ JSON5 и TOML при записи всего документа либо обы это можете решить только вы. Другие ограничения не ослаблены: файл, который нельзя разобрать или безопасно понять, по-прежнему отклоняется. +Исключение для Hermes: если в управляемый блок добавлено только `session_affinity_header: session-id`, изменение можно принять через **Apply**. Другие изменения управляемых полей остаются конфликтами. До применения обновления фоновое обновление списка моделей также приостановлено. Настройка действует для всех моделей provider и требует версии Hermes с поддержкой этой функции; доля попаданий в кеш не гарантируется. См. [инструкцию по обновлению на английском](/guides/integrations/#hermes-session-affinity). + ## Предпросмотр и подтверждение изменений Apply, Replace, Disable и Restore теперь начинаются с предпросмотра. Диалог diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index 298930cd40..1c5496f4b4 100644 --- a/docs-site/src/content/docs/tr/guides/integrations.md +++ b/docs-site/src/content/docs/tr/guides/integrations.md @@ -155,6 +155,8 @@ Kimi Code, gjc, MiniMax Code, Raycast — bütün belge olarak yazılan YAML, JS kendi girdilerimiz düzenlenmişse, anahtar kilitlenir ve hangi düzenlemelerin size ait olduğunu tahmin etmek yerine devre dışı bırakmayı reddeder. +Hermes istisnası: yönetilen bloğa yalnızca `session_affinity_header: session-id` eklenmişse **Apply** ile benimsenebilir; diğer yönetilen alan değişiklikleri çakışma olarak kalır. Uygulanana kadar arka plandaki model listesi güncellemeleri de bekletilir. Ayar provider içindeki tüm modeller için geçerlidir ve bu özelliği destekleyen bir Hermes sürümü gerektirir; önbellek isabet oranı garanti edilmez. [İngilizce yükseltme açıklamasına](/guides/integrations/#hermes-session-affinity) bakın. + ## Değişiklikleri önizleyin ve onaylayın Uygula, Değiştir, Devre dışı bırak ve Geri yükle işlemleri artık bir önizlemeyle başlar. İletişim diff --git a/docs-site/src/content/docs/zh-cn/guides/integrations.md b/docs-site/src/content/docs/zh-cn/guides/integrations.md index 3ebc705dc3..2d7a03b7f7 100644 --- a/docs-site/src/content/docs/zh-cn/guides/integrations.md +++ b/docs-site/src/content/docs/zh-cn/guides/integrations.md @@ -85,6 +85,8 @@ Disable 只移除 opencodex 记录为自己管理的条目。如果文件在写 锁定状态并非无解。有冲突的客户端会在概览卡片和自身页面的开关旁显示 **Replace**。它会将占据我们设置位置的内容替换为 opencodex 将写入的配置块,并事先询问:对话框会显示文件名、说明会丢失什么,并指向可用于撤销的快照。开关本身仍锁定,因为它无法知道你希望保留哪些编辑;只有你能决定。其他限制没有放宽:无法解析或无法可靠理解结构的文件仍会拒绝处理。 +Hermes 的会话标识升级是上述冲突规则的特例:已有受管配置仅新增 `session_affinity_header: session-id` 时,可通过 **Apply** 接纳;其他受管字段的修改仍会冲突。升级前,后台刷新会同时暂停该集成的模型列表更新。此设置适用于该 provider 的所有模型,需要支持该能力的 Hermes 版本,且不保证缓存命中率。详见[英文升级说明](/guides/integrations/#hermes-session-affinity)。 + ## 预览并确认变更 Apply、Replace、Disable 和 Restore 都先显示预览。对话框准确列出会变化的托管设置,包括有界的变更路径及每项变更是添加、更新还是移除。确认前请检查计划。 diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index 83f5cc7cf6..12dc51aff5 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -84,6 +84,8 @@ opencodex 從自己的環境讀取這些變數。如果你的 gateway 以 profil 停用只移除 opencodex 記錄為自己寫入的條目。如果你的檔案在我們寫入之後有變更,後續行為取決於我們自己的條目是否完好,以及檔案的格式。對於嚴格 JSON 設定檔(OpenCode、Pi),在我們的區塊**旁邊**進行的編輯——例如新增 MCP 伺服器或你自己的 provider——會顯示為**需要更新**:重新整理會在保留你的條目的前提下合併寫入,但格式可能會被正規化。例外情況是 JSON 無法精確重寫的內容——例如 `1e999` 這類非有限數字、重寫會被四捨五入的數字(極大的整數,或小到會塌縮成零的數字)、`-0`、同一個物件裡重複出現的鍵,或巢狀層數超過 1000 層——此時開關會鎖定,確保沒有任何值被悄悄改動或刪除。**OMP、DSH 與 Hermes** 同樣不受旁邊編輯影響,但原因不同:它們的 writer 只逐位元組修補自己的 `providers.opencodex` 範圍,檔案其餘部分從不會被重寫。至於其餘可以包含註解的格式(OpenClaw、Kimi Code、gjc、MiniMax Code、Raycast——以整份文件寫出的 YAML、JSON5 與 TOML),或當我們自己的條目被編輯過時,開關會鎖定,停用會拒絕執行,而不是猜測哪些編輯是你的。 +Hermes 的會話標識升級是上述衝突規則的特例:既有受管設定僅新增 `session_affinity_header: session-id` 時,可透過 **Apply** 接納;其他受管欄位的修改仍會衝突。升級前,背景重新整理也會暫停此整合的模型清單更新。此設定適用於該 provider 的所有模型,需要支援此能力的 Hermes 版本,且不保證快取命中率。詳見[英文升級說明](/guides/integrations/#hermes-session-affinity)。 + ## 預覽並確認變更 套用、取代、停用與回復現在都會先顯示預覽。對話框會明確列出哪些受管理的設定將會變更, diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2b740cd1ca..52324e59d5 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -931,6 +931,7 @@ "install-scripts.test.ts": "ci-workflows", "installed-gate-drivers.test.ts": "ci-workflows", "integrations-current-store.test.ts": "clients", + "integrations-hermes-affinity.test.ts": "clients", "integrations-invariants.test.ts": "gui", "integrations-journal.test.ts": "clients", "integrations-merge.test.ts": "clients", diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index b419d99e67..b282d08aad 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -863,6 +863,8 @@ export interface HermesProviderBlock { api: string; api_key: string; api_mode: "chat_completions"; + /** Header name only; Hermes supplies a dynamic per-conversation value. */ + session_affinity_header: "session-id"; /** We supply the list, so skip their live `/models` probe. */ discover_models: false; models: Record; @@ -1051,6 +1053,7 @@ function buildHermesClientConfig(ctx: ExportContext): HermesGeneratedConfig { api: ctx.baseUrl, api_key: HERMES_API_KEY_ENV_REF, api_mode: "chat_completions", + session_affinity_header: "session-id", discover_models: false, models, ...(headers ? { extra_headers: headers } : {}), diff --git a/src/integrations/owned-refresh.ts b/src/integrations/owned-refresh.ts index 37621412e6..7f9e848e9a 100644 --- a/src/integrations/owned-refresh.ts +++ b/src/integrations/owned-refresh.ts @@ -77,7 +77,7 @@ export async function refreshOwnedIntegration( client: input.clientId, ok: true, changed: result.changed, - ...(result.state === "absent" ? { reason: result.message } : {}), + ...(result.state !== "current" ? { reason: result.message } : {}), } : { client: input.clientId, ok: false, reason: result.message, refusalReason: result.reason, state: result.state, ...(result.snapshotPath ? { snapshotPath: result.snapshotPath } : {}), diff --git a/src/integrations/ownership-policy.ts b/src/integrations/ownership-policy.ts index a0cf52bf61..842a6e1cd1 100644 --- a/src/integrations/ownership-policy.ts +++ b/src/integrations/ownership-policy.ts @@ -12,7 +12,8 @@ import { type ManagedContribution, type ManagedFragment, } from "../clients/config-export"; -import { canonicalContribution, fingerprint, semanticContribution } from "./ownership"; +import { canonicalContribution, fingerprint, semanticContribution, type OwnershipRecord } from "./ownership"; +import { readPath } from "./merge"; type JsonObject = Record; @@ -20,6 +21,36 @@ function isObject(value: unknown): value is JsonObject { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** + * One-way Hermes upgrade: the old owned block is unchanged, or has only gained + * the supported dynamic affinity setting. Once applied, that field is protected + * like every other field; this is not a permanent refreshable-path exemption. + * Callers must first establish the record's client and config-path ownership. + */ +export function isHermesAffinityUpgrade( + doc: unknown, + record: OwnershipRecord, + desired: ManagedContribution, +): boolean { + if (record.clientId !== "hermes" || desired.clientId !== "hermes") return false; + const path = ["providers", OPENCODE_PROVIDER_ID]; + const matchesPath = (candidate: readonly string[]) => ( + candidate.length === path.length && candidate.every((key, index) => key === path[index]) + ); + if (record.fragmentPaths.length !== 1 || !matchesPath(record.fragmentPaths[0]!)) return false; + const fragment = desired.fragments.find(item => matchesPath(item.path)); + if (!isObject(fragment?.value) || fragment.value.session_affinity_header !== "session-id") return false; + const observed = readPath(doc, path); + if (!isObject(observed)) return false; + if (Object.hasOwn(observed, "session_affinity_header") && observed.session_affinity_header !== "session-id") return false; + const value = { ...observed }; + delete value.session_affinity_header; + const predecessor: ManagedContribution = { clientId: "hermes", fragments: [{ path, value }] }; + return fingerprint(canonicalContribution(predecessor)) === record.blockFingerprint + || (typeof record.semanticBlockFingerprint === "string" + && fingerprint(semanticContribution(predecessor)) === record.semanticBlockFingerprint); +} + function pathStartsWith(path: readonly string[], prefix: readonly string[]): boolean { return prefix.length <= path.length && prefix.every((part, index) => path[index] === part); } diff --git a/src/integrations/state.ts b/src/integrations/state.ts index 0b76efb9af..d41444615b 100644 --- a/src/integrations/state.ts +++ b/src/integrations/state.ts @@ -25,6 +25,7 @@ import { } from "./merge"; import { canonicalContribution, fingerprint, semanticContribution, type OwnershipRecord } from "./ownership"; import { + isHermesAffinityUpgrade, protectedContributionFingerprint, refreshablePathsOf, semanticProtectedContributionFingerprint, @@ -385,7 +386,8 @@ export function classifyIntegration(input: { * conflict no matter what the rest of the file looks like, so the sibling- * edit exemption below can never mask it. */ - if (!recordedBlockIsOwned(input.parsed, input.record, input.contribution)) { + if (!recordedBlockIsOwned(input.parsed, input.record, input.contribution) + && !isHermesAffinityUpgrade(input.parsed, input.record, input.contribution)) { return { state: "conflict", reason: "foreign-edit" }; } if (!INTEGRATION_CLIENTS[clientId].sourcePreservingYaml diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 262b7a235a..10419149cc 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -27,6 +27,7 @@ import { type OwnershipRecord, } from "./ownership"; import { + isHermesAffinityUpgrade, protectedContributionFingerprint, refreshablePathsOf, semanticProtectedContributionFingerprint, @@ -341,6 +342,14 @@ function applyOrRefreshIntegration( if (classified.state === "current") { return { ok: true, changed: false, state: "current", clientId, message: "already applied" }; } + // Catalog refresh must not opt an existing Hermes integration into affinity. + // Explicit Apply (or Replace) records the new contract before refresh resumes. + if (!allowAbsent && record && isHermesAffinityUpgrade(parsed, record, contribution)) { + return { + ok: true, changed: false, state: "stale", clientId, + message: "Hermes session affinity requires Apply in Integrations; refresh left the configuration unchanged", + }; + } // A stale refresh drops what the PREVIOUS record owned before merging: a // model that left the catalog would otherwise stay behind as an orphan the diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 465a19aa68..4ed33f6013 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -175,6 +175,26 @@ because its authoritative input-modality vocabulary currently has no video value > Decision record: [ADR-0090](../decisions/ADR-0090-hermes-model-capabilities.md) +## Hermes session affinity + +Hermes exports include `session_affinity_header: session-id` on the entire OpenCodex provider, +independent of the model roster. This is a header name: Hermes generates the conversation-scoped +value. No static session identifier or protocol switch is emitted, and upstream header-forwarding +rules remain unchanged. Hermes must support the documented per-provider affinity option to use it. + +`src/integrations/ownership-policy.ts` recognizes exactly one predecessor: an owned Hermes block +without this setting. The block may remain unchanged or have gained only the supported value; after +removing that field, its exact or recorded semantic fingerprint must match the previous record. +Client, config path and fragment-path ownership still apply. Other edits remain conflicts, and +after adoption the affinity field is fully protected, including against deletion. + +Legacy blocks report `stale` until an explicit Apply records the new contribution. Implicit refresh +leaves both their configuration and ownership unchanged, reporting that Apply is needed; this also +defers catalog updates until Apply. Once upgraded, normal refresh resumes and preserves affinity. +Replace emits the setting too. The existing source-preserving YAML, snapshot and restore paths +remain authoritative. `tests/clients/integrations-hermes-affinity.test.ts` exercises the real writer +against temporary client homes, including exact-workaround adoption, refusal, refresh and undo. + ## Ownership Axes `fileFingerprint` records the exact whole-file result for restore and for serializers that may lose diff --git a/tests/clients/integrations-hermes-affinity.test.ts b/tests/clients/integrations-hermes-affinity.test.ts new file mode 100644 index 0000000000..a5ce713240 --- /dev/null +++ b/tests/clients/integrations-hermes-affinity.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { HERMES_API_KEY_ENV_REF, type ManagedContribution } from "../../src/clients/config-export"; +import { previewIntegration } from "../../src/integrations/mutation-plan"; +import { refreshOwnedIntegration } from "../../src/integrations/owned-refresh"; +import { canonicalContribution, fingerprint, semanticContribution } from "../../src/integrations/ownership"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { readIntegrationState } from "../../src/integrations/state"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { + applyIntegration, disableIntegration, overwriteIntegration, refreshIntegration, restoreIntegration, + type IntegrationWriteInput, +} from "../../src/integrations/writer"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let root: string; +let path: string; +let input: IntegrationWriteInput; +const prefix = "# User-owned settings\nmodel:\n default: user-choice\nproviders:\n other:\n api: http://localhost:9000/v1 # keep this comment\n"; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-hermes-affinity-")); + const home = join(root, "home"); + path = INTEGRATION_CLIENTS.hermes.configPath({}, home); + mkdirSync(dirname(path), { recursive: true }); + input = { + clientId: "hermes", home, env: {}, port: 10100, + models: [{ namespaced: "mock/example", provider: "mock", id: "example" }], + config: { + port: 10100, hostname: "127.0.0.1", defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, + } as unknown as OcxConfig, + store: createIntegrationStateStore(join(root, "integrations")), + }; + writeFileSync(path, prefix); +}); + +afterEach(() => removeTreeWithRetry(root)); + +function provider(): Record { + return (Bun.YAML.parse(readFileSync(path, "utf8")) as { + providers: { opencodex: Record }; + }).providers.opencodex; +} + +function writeProvider(block: Record): void { + const yaml = Bun.YAML.stringify(block).trimEnd().split("\n").map(line => ` ${line}`).join("\n"); + writeFileSync(path, `${prefix} opencodex:\n${yaml}\n`); +} + +/** A pre-fix contribution, independent of the generator under test. */ +function seedLegacy(manual = false, semantic = true): void { + const block = { + api: "http://127.0.0.1:10100/v1", api_key: HERMES_API_KEY_ENV_REF, + api_mode: "chat_completions", discover_models: false, models: { "mock/example": {} }, + }; + writeProvider(block); + const contribution: ManagedContribution = { + clientId: "hermes", fragments: [{ path: ["providers", "opencodex"], value: block }], + }; + input.store!.putRecord({ + clientId: "hermes", configPath: path, fragmentPaths: [["providers", "opencodex"]], + fileFingerprint: fingerprint(readFileSync(path, "utf8")), + blockFingerprint: fingerprint(canonicalContribution(contribution)), + ...(semantic ? { semanticBlockFingerprint: fingerprint(semanticContribution(contribution)) } : {}), + createdContainers: [], appliedAt: "2026-09-01T00:00:00.000Z", opId: "legacy-apply", + }); + if (manual) writeProvider({ ...block, session_affinity_header: "session-id" }); +} + +test("new Hermes integrations enable dynamic affinity even with only third-party models", () => { + expect(applyIntegration(input)).toMatchObject({ ok: true, state: "current" }); + expect(provider()).toMatchObject({ session_affinity_header: "session-id", api_mode: "chat_completions" }); + expect(provider().extra_headers).toBeUndefined(); + expect(readFileSync(path, "utf8").startsWith(prefix)).toBe(true); +}); + +test.each([ + [false, false], [false, true], [true, false], [true, true], +])("legacy manual=%s semantic=%s waits for Apply, then supports refresh and undo", async (manual, semantic) => { + seedLegacy(manual, semantic); + const before = readFileSync(path, "utf8"); + const record = input.store!.readRecords().hermes; + expect(readIntegrationState(input).state).toBe("stale"); + expect(previewIntegration(input, { operation: "apply" })).toMatchObject({ canApply: true, willChange: true }); + expect(refreshIntegration(input)).toMatchObject({ ok: true, changed: false, state: "stale" }); + expect(await refreshOwnedIntegration(input)).toMatchObject({ ok: true, changed: false, reason: expect.stringContaining("Apply") }); + expect(readFileSync(path, "utf8")).toBe(before); + expect(input.store!.readRecords().hermes).toEqual(record); + expect(input.store!.listOperations("hermes")).toEqual([]); + + const applied = applyIntegration(input); + expect(applied).toMatchObject({ ok: true, changed: true, state: "current" }); + expect(provider().session_affinity_header).toBe("session-id"); + expect(readIntegrationState(input).state).toBe("current"); + expect(applyIntegration(input)).toMatchObject({ ok: true, changed: false }); + if (!applied.ok || !applied.opId) throw new Error("missing apply operation"); + expect(restoreIntegration({ ...input, opId: applied.opId })).toMatchObject({ ok: true }); + expect(readFileSync(path, "utf8")).toBe(before); + expect(readIntegrationState(input).state).toBe("stale"); + + expect(applyIntegration(input).ok).toBe(true); + const changed = { ...input, models: [...input.models, { namespaced: "mock/next", provider: "mock", id: "next" }] }; + expect(await refreshOwnedIntegration(changed)).toMatchObject({ ok: true, changed: true }); + expect(provider()).toMatchObject({ session_affinity_header: "session-id", models: { "mock/next": {} } }); + expect(readFileSync(path, "utf8").startsWith(prefix)).toBe(true); + expect(disableIntegration(changed).ok).toBe(true); + expect(readFileSync(path, "utf8")).toBe(prefix); +}); + +test("a manual affinity addition can be adopted despite catalog drift and YAML key reordering", () => { + seedLegacy(true); + writeProvider(Object.fromEntries(Object.entries(provider()).reverse())); + const changed = { ...input, models: [{ namespaced: "mock/new", provider: "mock", id: "new" }] }; + expect(readIntegrationState(changed).state).toBe("stale"); + expect(applyIntegration(changed).ok).toBe(true); + expect(provider()).toMatchObject({ session_affinity_header: "session-id", models: { "mock/new": {} } }); + expect(provider().models).not.toHaveProperty("mock/example"); +}); + +test.each([ + ["api", "http://localhost:9900/v1"], ["api_key", "user-env-reference"], + ["api_mode", "codex_responses"], ["discover_models", true], + ["models", { "mock/own": {} }], ["extra_headers", { "x-user": "keep" }], + ["user_option", true], ["session_affinity_header", "thread-id"], +])("the supported workaround does not excuse a protected %s edit", (key, value) => { + seedLegacy(true); + writeProvider({ ...provider(), [key as string]: value }); + const before = readFileSync(path, "utf8"); + expect(readIntegrationState(input)).toMatchObject({ state: "conflict", reason: "foreign-edit" }); + for (const action of [applyIntegration, refreshIntegration, disableIntegration]) { + expect(action(input)).toMatchObject({ ok: false, reason: "conflict" }); + } + expect(readFileSync(path, "utf8")).toBe(before); + expect(input.store!.listOperations("hermes")).toEqual([]); +}); + +test.each([undefined, null, "thread-id"])("after adoption the affinity field is protected against %s", value => { + expect(applyIntegration(input).ok).toBe(true); + const block = provider(); + if (value === undefined) delete block.session_affinity_header; + else block.session_affinity_header = value; + writeProvider(block); + expect(readIntegrationState(input)).toMatchObject({ state: "conflict", reason: "foreign-edit" }); + expect(refreshIntegration(input)).toMatchObject({ ok: false, reason: "conflict" }); + expect(overwriteIntegration(input)).toMatchObject({ ok: true, state: "current" }); + expect(provider().session_affinity_header).toBe("session-id"); +}); + +test.each(["missing", "different-path", "different-client"])("adoption requires ownership: %s", kind => { + seedLegacy(true); + const record = input.store!.readRecords().hermes!; + if (kind === "missing") input.store!.dropRecord("hermes"); + else if (kind === "different-path") input.store!.putRecord({ ...record, configPath: join(root, "unowned.yaml") }); + else input.store = { ...input.store!, readRecords: () => ({ hermes: { ...record, clientId: "pi" } }) }; + expect(readIntegrationState(input).state).toBe("conflict"); + expect(applyIntegration(input)).toMatchObject({ ok: false, reason: "conflict" }); +}); diff --git a/tests/config/client-config-export-new-clients.test.ts b/tests/config/client-config-export-new-clients.test.ts index 03000ae38b..f4b3a64bcb 100644 --- a/tests/config/client-config-export-new-clients.test.ts +++ b/tests/config/client-config-export-new-clients.test.ts @@ -97,6 +97,7 @@ describe("hermes", () => { const block = doc.providers[OPENCODE_PROVIDER_ID]!; expect(block.api_key).toBe(HERMES_API_KEY_ENV_REF); expect(block.api_mode).toBe("chat_completions"); + expect(block.session_affinity_header).toBe("session-id"); expect(block.discover_models).toBe(false); expect(block.models).toEqual({ "anthropic/claude-opus-4-8": { supports_vision: true }, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6fb1dfef20..f647f63f64 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -758,6 +758,7 @@ "install-scripts.test.ts": "ci-workflows", "installed-gate-drivers.test.ts": "ci-workflows", "integrations-current-store.test.ts": "clients", + "integrations-hermes-affinity.test.ts": "clients", "integrations-invariants.test.ts": "gui", "integrations-journal.test.ts": "clients", "integrations-merge.test.ts": "clients", From 873428eaa52e04939a9648e9608ae06da6536c27 Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:12:03 +0200 Subject: [PATCH 04/15] feat(sidecar): make Off selectable in the web-search card and switch Codex's web_search off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web-search sidecar could only be turned off by editing `config.json`. That is not enough when an MCP search server should be the only search path: Codex keeps declaring its native hosted `web_search` tool until its own root `web_search` mode says otherwise, and the tool a client advertises is the tool the model reaches for — so the model kept calling the native tool instead of the MCP one. Off is now the first row of the Dashboard's web-search model picker (i18n `dash.webSearchOff`, all ten locales) and `ocx agent sidecar web --enabled off` accepts the same switch. Both go through the existing `PUT /api/sidecar-settings`, which persists `webSearchSidecar.enabled` and — only when the switch actually MOVES — re-runs the Codex config injection, so the sidecar state and Codex's client-side key follow each other immediately instead of at the next `ocx sync`. The response carries the Codex-side write as `codexWebSearch` (`applied`/`reason`/`retryable`), the same report the Desktop switches use. Ownership follows the routing keys: while the sidecar is off the injection owns root `web_search` and writes `web_search = "disabled"` — the only mode that removes the native tool. A user-owned root line is replaced in that state because two root keys of the same name are invalid TOML; the journal snapshot returns it on `ocx restore`. Switching the sidecar back on removes only the marker-owned pair, so a re-enabled sidecar cannot be left with nothing to intercept. (cherry picked from commit d2ec419a02e7904aa59e7ada793dd3694914c79c) --- docs-site/src/content/docs/guides/sidecars.md | 18 +++ .../src/content/docs/reference/cli/agents.md | 8 + .../docs/reference/configuration/server.md | 2 +- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/vi.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/dashboard-overview-sections.tsx | 19 ++- gui/src/pages/dashboard-shared.ts | 11 +- scripts/test-layout/layout.json | 2 + src/cli/agent.ts | 18 ++- src/cli/runtime-api.ts | 17 +++ src/cli/system-command.ts | 10 +- src/codex/desktop-switches.ts | 10 +- src/codex/inject/config-toml.ts | 67 +++++++++ src/codex/inject/plan.ts | 5 + src/codex/inject/remove.ts | 2 + src/server/management/config-routes.ts | 29 +++- structure/config.md | 8 + tests/cli/cli-headless-parity.test.ts | 55 +++++++ .../codex-web-search-switch.test.ts | 138 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 + .../sidecar-settings-web-search-off.test.ts | 138 ++++++++++++++++++ 29 files changed, 548 insertions(+), 21 deletions(-) create mode 100644 tests/codex-integration/codex-web-search-switch.test.ts create mode 100644 tests/vision/sidecar-settings-web-search-off.test.ts diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index 1f80f4fbc9..e1a83bb650 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -207,6 +207,24 @@ timeout, and limit. omitted keys unchanged. `timeoutMs` uses the runtime integer bounds (1–2147483647 ms). +The web-search sidecar card carries the same control shape: the model picker's first row is +**Off**. Off does two things, and the second one is the reason the row exists. OpenCodex stops +intercepting `web_search`, and the Codex integration writes Codex's own +`web_search = "disabled"` mode into `~/.codex/config.toml` — because Codex keeps declaring its +native hosted `web_search` tool until its own mode says otherwise, and the tool a client +advertises is the one the model reaches for. An operator who wants an MCP search server to be +the only search path needs both halves; otherwise the model keeps calling the native tool. + +`web_search` is Codex's key with its own value space (`disabled`, `cached`, `indexed`, `live`). +OpenCodex only ever writes `disabled` while the sidecar is off, and removes its marker-owned line +again once the sidecar is back on — a re-enabled sidecar whose client still had the native tool +switched off would have nothing to intercept. The write needs a managed `~/.codex/config.toml` +(`ocx sync`); the management response reports it as `codexWebSearch`, and +`ocx agent sidecar web --enabled off` prints whether it happened. A root `web_search` line the +operator set by hand is replaced while the sidecar is off, since two root keys of the same name +are not valid TOML; `ocx restore` replays the journal snapshot and brings that value back, like +every other line the injection rewrites. + You can still set `enabled: false` in `config.json` if you prefer to edit the file directly. Anthropic-OAuth search and image description reuse the existing Claude Code OAuth fingerprint precedent, but should be soak-tested with the diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 0f68be9f7a..781424cea4 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -31,8 +31,16 @@ stay writable). ```bash ocx agent sidecar web --list ocx agent sidecar web --model gpt-5.6-luna +ocx agent sidecar web --enabled off ``` +`--enabled off` is the same switch as the Dashboard's Off row: OpenCodex stops running the +sidecar and the Codex integration writes `web_search = "disabled"` into `~/.codex/config.toml`, +which is what lets an MCP search server be the only search path. `--enabled on` removes that +marker-owned line again. The command reports the Codex-side write (`codexWebSearch` in `--json`, +a trailing `Codex config:` line otherwise) and points at `ocx sync` when it could not happen. +The flag works for `vision` too. + ### `ocx effort [status|set|clear]` Inspect or change main and subagent reasoning-effort caps through the live proxy, or the local diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 0394a17d5f..d3aee62c2c 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -657,7 +657,7 @@ Images API paths and response shape expected by Codex. | Field | Type | Default | Meaning | | --- | --- | --- | --- | -| `enabled?` | `boolean` | on when usable | Master switch. | +| `enabled?` | `boolean` | on when usable | Master switch. When false, OpenCodex stops intercepting `web_search` AND the Codex integration writes `web_search = "disabled"` into `~/.codex/config.toml`. | | `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | Explicit wins; unset always resolves to `openai`. `anthropic` and `xai` run only when explicitly configured; `gemini` and `exa` remain reserved until their executors ship. | | `model?` | `string` | backend-dependent | `gpt-5.6-luna` for OpenAI, `claude-sonnet-5` for Anthropic, or `grok-4.6` for xAI. Legacy explicit `gpt-5.4-mini` migrates on start. | | `exaApiKey?` | `string` | none | Operator key for the `exa` backend. Write-only: management reads never return the stored value. | diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index b50da156f7..2900ff1a46 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -356,6 +356,7 @@ export const de: Record = { "dash.visionModelHint": "Modell zur Beschreibung von Bildern für nur-Text-Routen. Erfordert ChatGPT-Login.", "dash.webSearchSidecar": "Websuche-Sidecar", "dash.webSearchSidecarHint": "Backend und Modell für die Websuche gerouteter Modelle auswählen.", + "dash.webSearchOff": "Aus", "dash.webSearchStream": "Antworten live streamen", "dash.webSearchStreamHint": "Führenden Text und Reasoning live streamen, bis das Modell über einen Tool-Aufruf entscheidet; der Rest bleibt für das Abfangen der Suche gepuffert. Text vor einer Suche kann sich teilweise wiederholen.", "dash.visionSidecar": "Vision-Sidecar", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 199e6e9abb..8a19ef6f14 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -368,6 +368,7 @@ export const en = { "dash.visionModelHint": "Model used to describe images for text-only routed models. Requires ChatGPT login.", "dash.webSearchSidecar": "Web search sidecar", "dash.webSearchSidecarHint": "Choose the backend and model used for web search on routed models.", + "dash.webSearchOff": "Off", "dash.webSearchStream": "Stream answers live", "dash.webSearchStreamHint": "Stream the model’s leading text and reasoning live until it decides on a tool call; the rest of the turn stays buffered for search interception. Text written before a search may partially repeat.", "dash.visionSidecar": "Vision sidecar", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4b87fe1474..83c11349c3 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -358,6 +358,7 @@ export const fr: Record = { "dash.visionModelHint": "Modèle utilisé pour décrire les images aux modèles routés en mode texte uniquement. Nécessite une connexion à ChatGPT.", "dash.webSearchSidecar": "Service auxiliaire de recherche Web", "dash.webSearchSidecarHint": "Choisissez le moteur et le modèle utilisés pour la recherche Web sur les modèles routés.", + "dash.webSearchOff": "Désactivé", "dash.webSearchStream": "Diffuser les réponses en direct", "dash.webSearchStreamHint": "Diffuse en direct le texte initial et le raisonnement du modèle jusqu’à ce qu’il décide d’appeler un outil ; le reste du tour demeure en mémoire tampon pour intercepter la recherche. Le texte produit avant une recherche peut être partiellement répété.", "dash.visionSidecar": "Service auxiliaire de vision", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 29a825d332..d43a414e9d 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -365,6 +365,7 @@ export const ja: Record = { "dash.visionModelHint": "テキスト専用ルーティングモデルで画像を説明するために使うモデル。ChatGPT ログインが必要です。", "dash.webSearchSidecar": "ウェブ検索サイドカー", "dash.webSearchSidecarHint": "ルーティングモデルでウェブ検索に使うバックエンドとモデルを選択します。", + "dash.webSearchOff": "オフ", "dash.webSearchStream": "回答をライブ配信", "dash.webSearchStreamHint": "モデルがツール呼び出しを決定するまで、先頭のテキストと推論をライブ配信します。以降は検索インターセプトのためバッファされます。検索前のテキストは一部繰り返される場合があります。", "dash.visionSidecar": "ビジョンサイドカー", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 2e87cd5b5f..d5391464b1 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -360,6 +360,7 @@ export const ko: Record = { "dash.visionModelHint": "텍스트 전용 라우팅 모델에 이미지를 설명하는 데 사용되는 모델입니다. ChatGPT 로그인 필요.", "dash.webSearchSidecar": "웹 검색 사이드카", "dash.webSearchSidecarHint": "라우팅 모델의 웹 검색에 쓸 백엔드와 모델을 고릅니다.", + "dash.webSearchOff": "끔", "dash.webSearchStream": "응답 실시간 스트리밍", "dash.webSearchStreamHint": "모델이 도구 호출을 결정할 때까지 앞부분 텍스트와 추론을 실시간 스트리밍합니다. 이후는 검색 가로채기를 위해 버퍼링됩니다. 검색 전 텍스트가 일부 반복될 수 있습니다.", "dash.visionSidecar": "비전 사이드카", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 1279c60b09..2e48a026bb 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -365,6 +365,7 @@ export const ru: Record = { "dash.visionModelHint": "Модель, которая описывает изображения для маршрутизируемых моделей, работающих только с текстом. Требуется вход в аккаунт ChatGPT.", "dash.webSearchSidecar": "Сайдкар веб-поиска", "dash.webSearchSidecarHint": "Выберите бэкенд и модель, используемые для веб-поиска на маршрутизируемых моделях.", + "dash.webSearchOff": "Выкл", "dash.webSearchStream": "Стримить ответы вживую", "dash.webSearchStreamHint": "Транслировать начальный текст и рассуждения вживую, пока модель не решит вызвать инструмент; остальное буферизуется для перехвата поиска. Текст до поиска может частично повторяться.", "dash.visionSidecar": "Сайдкар для изображений", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index cef6fa0703..670fed3a5b 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -366,6 +366,7 @@ export const tr: Record = { "dash.visionModelHint": "Salt metin yönlendirilen modeller için görselleri tanımlamakta kullanılan model. ChatGPT girişi gerektirir.", "dash.webSearchSidecar": "Web arama yan aracı (sidecar)", "dash.webSearchSidecarHint": "Yönlendirilen modellerde web araması için kullanılan arka ucu ve modeli seçin.", + "dash.webSearchOff": "Kapalı", "dash.webSearchStream": "Yanıtları canlı akıt", "dash.webSearchStreamHint": "Model bir araç çağrısına karar verene kadar baştaki metni ve akıl yürütmeyi canlı akıtır; kalanı arama yakalama için arabelleğe alınır. Aramadan önce yazılan metin kısmen tekrarlanabilir.", "dash.visionSidecar": "Görsel yan aracı (sidecar)", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 099ce7cafb..36e32e12f1 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -358,6 +358,7 @@ export const vi: Record = { "dash.visionModelHint": "Model được sử dụng để mô tả hình ảnh cho các model định tuyến chỉ hỗ trợ văn bản. Yêu cầu đăng nhập ChatGPT.", "dash.webSearchSidecar": "Web search sidecar", "dash.webSearchSidecarHint": "Chọn backend và model được sử dụng cho tìm kiếm web trên các models định tuyến.", + "dash.webSearchOff": "Tắt", "dash.webSearchStream": "Phát trực tuyến (Stream) các câu trả lời trực tiếp", "dash.webSearchStreamHint": "Phát trực tuyến các văn bản dẫn dắt và quá trình lý luận của model cho đến khi nó quyết định gọi một công cụ; phần còn lại của lượt chạy sẽ được lưu đệm (buffered) để can thiệp tìm kiếm. Văn bản được viết trước một tìm kiếm có thể lặp lại một phần.", "dash.visionSidecar": "Vision sidecar", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 1c8d62080b..24134a0fb8 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -255,6 +255,7 @@ export const zhTW: Record = { "dash.visionModelHint": "為純文字路由模型描述圖像的模型。需要 ChatGPT 登入。", "dash.webSearchSidecar": "網頁搜尋附屬服務", "dash.webSearchSidecarHint": "選擇路由模型進行網頁搜尋時使用的後端和模型。", + "dash.webSearchOff": "關閉", "dash.webSearchStream": "即時串流輸出回答", "dash.webSearchStreamHint": "即時串流輸出開頭的文字和推理,直到模型決定呼叫工具;其餘部分為攔截搜尋而保持緩衝。搜尋前的文字可能會部分重複。", "dash.visionSidecar": "視覺附屬服務", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 6d05e3854a..4fa559bafb 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -360,6 +360,7 @@ export const zh: Record = { "dash.visionModelHint": "为纯文本路由模型描述图像的模型。需要 ChatGPT 登录。", "dash.webSearchSidecar": "网页搜索附属服务", "dash.webSearchSidecarHint": "选择路由模型进行网页搜索时使用的后端和模型。", + "dash.webSearchOff": "关闭", "dash.webSearchStream": "实时流式输出回答", "dash.webSearchStreamHint": "实时流式输出开头的文本和推理,直到模型决定调用工具;其余部分为拦截搜索而保持缓冲。搜索前的文本可能会部分重复。", "dash.visionSidecar": "视觉附属服务", diff --git a/gui/src/pages/dashboard-overview-sections.tsx b/gui/src/pages/dashboard-overview-sections.tsx index 2aa28acb10..f514ea61ff 100644 --- a/gui/src/pages/dashboard-overview-sections.tsx +++ b/gui/src/pages/dashboard-overview-sections.tsx @@ -17,6 +17,7 @@ import { shadowCallModelOptions, webSearchSidecarSelectionForModel, updateJobLabel, + webSearchEnabledPatch, visionEnabledPatch, visionMaxDescriptionsPatch, visionReasoningLadder, @@ -445,6 +446,9 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) { } = d; const visionEnabled = sidecar?.vision?.enabled !== false; const visionModel = visionEnabled ? (sidecar?.vision?.model ?? "gpt-5.6-luna") : ""; + const webSearchEnabled = sidecar?.webSearch?.enabled !== false; + // Same shape as the Vision card: Off is a row in the picker, and choosing a model is the way back. + const webSearchModel = webSearchEnabled ? (sidecar?.webSearch?.model ?? "gpt-5.6-luna") : ""; const persistedVisionReasoning = sidecar?.vision?.reasoning ?? "low"; const visionLadder = visionReasoningLadder(models, visionModel); const visionReasoning = clampVisionReasoningToLadder(visionLadder, persistedVisionReasoning); @@ -561,10 +565,17 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) {