From b080201af3fe1104b6a697f1e0eb901907b7d663 Mon Sep 17 00:00:00 2001 From: thomas Date: Sun, 26 Jul 2026 20:57:00 +0800 Subject: [PATCH 1/2] feat(desktop): add prevent-sleep settings toggle (#93) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Issue for this PR Closes # ### Type of change - [ ] Bug fix - [x] New feature - [ ] Refactor / code improvement - [ ] Documentation ### What does this PR do? The desktop app calls `powerSaveBlocker.start("prevent-app-suspension")` unconditionally at launch, so the system never idle-sleeps while the app is running, and there is no user-facing way to opt out. This PR adds a "Prevent system sleep" switch under a new Power section in General settings (desktop only): - **Main**: the `preventSleep` value is persisted in the settings store. The default stays enabled (only an explicit `false` opts out), so existing installs keep the historical always-on behavior. A `set-prevent-sleep` IPC handler starts/stops the blocker live and broadcasts the change to all windows; startup reads the stored value instead of always starting the blocker. - **Preload / renderer / app**: bridges `get/setPreventSleepEnabled` through the platform context, mirroring the existing `pinchZoom` wiring, and adds the switch to the settings UI. - **i18n**: translates the new section and row across all 18 locales. - The persisted-setting resolution lives in `prevent-sleep.ts`, an Electron-free module (same split as `close-to-tray.ts`), so the default-on semantics are unit-testable. The toggle takes effect immediately; no restart is required. ### How did you verify your code works? - Unit tests for the setting resolution (unset → on, any non-`false` value → on, explicit `false` → off); all 87 desktop package tests pass. - Added `test:prevent-sleep-smoke`, a Playwright-driven dynamic test that launches the real built app, clicks the actual Settings switch, and asserts against macOS `pmset -g assertions`: 1. default launch registers `NoIdleSleepAssertion` 2. disabling the toggle releases the assertion while the app keeps running 3. the disabled state persists across a relaunch (no assertion) 4. re-enabling registers the assertion again - Wired the smoke into CI as a new `desktop-smoke (macos)` job in `test.yml` (kept out of the unit matrix since it needs a window server and a prior desktop build). - Typecheck (desktop + app), lint, and i18n parity all pass. ### Screenshots / recordings _Recorded locally during development; can attach a recording of the toggle flipping `pmset` assertions on/off if helpful._ ### Checklist - [x] I have tested my changes locally - [x] I have not included unrelated changes in this PR --------- Signed-off-by: thomas-yanga --- .github/workflows/test.yml | 32 ++++++ .../src/components/settings-v2/general.tsx | 34 ++++++ packages/app/src/context/platform.tsx | 6 + packages/app/src/i18n/ar.ts | 3 + packages/app/src/i18n/br.ts | 3 + packages/app/src/i18n/bs.ts | 3 + packages/app/src/i18n/da.ts | 3 + packages/app/src/i18n/de.ts | 3 + packages/app/src/i18n/en.ts | 3 + packages/app/src/i18n/es.ts | 3 + packages/app/src/i18n/fr.ts | 3 + packages/app/src/i18n/ja.ts | 3 + packages/app/src/i18n/ko.ts | 3 + packages/app/src/i18n/no.ts | 3 + packages/app/src/i18n/pl.ts | 3 + packages/app/src/i18n/ru.ts | 3 + packages/app/src/i18n/th.ts | 3 + packages/app/src/i18n/tr.ts | 3 + packages/app/src/i18n/uk.ts | 3 + packages/app/src/i18n/zh.ts | 3 + packages/app/src/i18n/zht.ts | 3 + packages/desktop/package.json | 1 + .../desktop/scripts/prevent-sleep-smoke.ts | 106 ++++++++++++++++++ packages/desktop/src/main/index.ts | 7 +- packages/desktop/src/main/ipc.ts | 17 +++ packages/desktop/src/main/power.test.ts | 21 ++++ packages/desktop/src/main/power.ts | 28 +++++ packages/desktop/src/main/prevent-sleep.ts | 6 + packages/desktop/src/main/store-keys.ts | 1 + packages/desktop/src/preload/index.ts | 7 ++ packages/desktop/src/preload/types.ts | 3 + packages/desktop/src/renderer/index.tsx | 4 + 32 files changed, 324 insertions(+), 3 deletions(-) create mode 100644 packages/desktop/scripts/prevent-sleep-smoke.ts create mode 100644 packages/desktop/src/main/power.test.ts create mode 100644 packages/desktop/src/main/prevent-sleep.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e4849069..3c55bd09 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -96,6 +96,38 @@ jobs: retention-days: 7 path: packages/*/.artifacts/unit/junit.xml + desktop-smoke: + name: desktop-smoke (macos) + # The prevent-sleep smoke drives the real packaged main bundle through Playwright and asserts + # against pmset, so it needs a macOS runner with a window server; keep it out of the unit matrix. + runs-on: macos-14 + defaults: + run: + shell: bash + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "24" + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + - name: Build desktop main bundle + working-directory: packages/desktop + run: bun run build + timeout-minutes: 15 + + - name: Run prevent-sleep smoke + working-directory: packages/desktop + run: bun run test:prevent-sleep-smoke + timeout-minutes: 10 + e2e: name: e2e (${{ matrix.settings.name }}) strategy: diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 4594c5fc..e447a6c4 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -132,6 +132,12 @@ export const SettingsGeneralV2: Component = () => { { initialValue: false }, ) + const [preventSleep, { mutate: setPreventSleep }] = createResource( + () => (desktop() && platform.getPreventSleepEnabled ? true : false), + () => Promise.resolve(platform.getPreventSleepEnabled?.() ?? true).catch(() => true), + { initialValue: true }, + ) + onMount(() => { void theme.loadThemes() }) @@ -252,6 +258,13 @@ export const SettingsGeneralV2: Component = () => { void update.catch(() => setPinchZoom(!checked)) } + const onPreventSleepChange = (checked: boolean) => { + setPreventSleep(checked) + const update = platform.setPreventSleepEnabled?.(checked) + if (!update) return + void update.catch(() => setPreventSleep(!checked)) + } + const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ { value: "system", label: language.t("theme.scheme.system") }, { value: "light", label: language.t("theme.scheme.light") }, @@ -915,6 +928,25 @@ export const SettingsGeneralV2: Component = () => { ) + const PowerSection = () => ( + +
+

{language.t("settings.general.section.power")}

+ + + +
+ +
+
+
+
+
+ ) + return ( <>
@@ -940,6 +972,8 @@ export const SettingsGeneralV2: Component = () => { + +
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index d2806fda..98d8548f 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -122,6 +122,12 @@ type PlatformBase = { /** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */ setPinchZoomEnabled?(enabled: boolean): Promise | void + /** Get whether the app keeps the system awake while running (desktop only) */ + getPreventSleepEnabled?(): Promise | boolean + + /** Keep the system awake while the app is running (desktop only) */ + setPreventSleepEnabled?(enabled: boolean): Promise | void + /** Run a desktop-only menu action from the app chrome */ runDesktopMenuAction?(action: DesktopMenuAction): Promise | void diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 181b543c..f94a0fe2 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -560,6 +560,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "تفعيل لجنة الخبراء افتراضيًا", "settings.general.row.expertPanelDefault.description": "بدء المحادثات الجديدة مع تفعيل لجنة الخبراء، بحيث يبدأ زرها مراجعة السياق الحالي عند الطلب.", "settings.general.section.display": "شاشة العرض", + "settings.general.section.power": "الطاقة", "settings.general.row.language.title": "اللغة", "settings.general.row.language.description": "تغيير لغة العرض لـ DeepAgent Code", "settings.general.row.appearance.title": "المظهر", @@ -930,6 +931,8 @@ export const dict = { "تفعيل التخطيط والصفحة الرئيسية والمحرر وواجهة الجلسة المعاد تصميمها", "settings.general.row.pinchZoom.title": "التكبير بالقرص", "settings.general.row.pinchZoom.description": "السماح بإيماءات قرص لوحة اللمس و Ctrl-تمرير للتكبير", + "settings.general.row.preventSleep.title": "منع سكون النظام", + "settings.general.row.preventSleep.description": "إبقاء النظام مستيقظًا أثناء تشغيل التطبيق؛ ويمكن أن تنطفئ الشاشة", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "جار التنزيل...", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index b2ff7bef..31699b30 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -567,6 +567,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Ativar painel de especialistas por padrão", "settings.general.row.expertPanelDefault.description": "Iniciar novas conversas com o painel de especialistas ativado, para que seu botão inicie uma revisão do contexto atual sob demanda.", "settings.general.section.display": "Tela", + "settings.general.section.power": "Energia", "settings.general.row.language.title": "Idioma", "settings.general.row.language.description": "Alterar o idioma de exibição do DeepAgent Code", "settings.general.row.appearance.title": "Aparência", @@ -954,6 +955,8 @@ export const dict = { "settings.general.row.pinchZoom.title": "Pinçar para zoom", "settings.general.row.pinchZoom.description": "Permitir gestos de pellizco en trackpad y Ctrl-desplazamiento para ampliar", + "settings.general.row.preventSleep.title": "Impedir suspensão do sistema", + "settings.general.row.preventSleep.description": "Manter o sistema ativo enquanto o app está em execução; a tela ainda pode desligar", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "Baixando...", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index cc9f9115..d97be89f 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -632,6 +632,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Zadano uključi ekspertni panel", "settings.general.row.expertPanelDefault.description": "Pokreni nove razgovore s uključenim ekspertnim panelom, tako da njegovo dugme po potrebi pokreće pregled trenutnog konteksta.", "settings.general.section.display": "Prikaz", + "settings.general.section.power": "Napajanje", "settings.general.row.language.title": "Jezik", "settings.general.row.language.description": "Promijeni jezik prikaza u DeepAgent Code-u", @@ -1027,6 +1028,8 @@ export const dict = { "settings.general.row.pinchZoom.title": "Powiększanie gestem szczypania", "settings.general.row.pinchZoom.description": "Zezwól na powiększanie gestem szczypania na trackpadzie i Ctrl-przewijaniem", + "settings.general.row.preventSleep.title": "Spriječi mirovanje sistema", + "settings.general.row.preventSleep.description": "Zadržava sistem budnim dok je aplikacija pokrenuta; ekran se i dalje može ugasiti", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "Preuzimanje...", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 6af26897..b725d224 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -627,6 +627,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Aktivér ekspertpanel som standard", "settings.general.row.expertPanelDefault.description": "Start nye samtaler med ekspertpanelet aktiveret, så knappen kan gennemgå den aktuelle kontekst efter behov.", "settings.general.section.display": "Skærm", + "settings.general.section.power": "Strøm", "settings.general.row.language.title": "Sprog", "settings.general.row.language.description": "Ændr visningssproget for DeepAgent Code", @@ -1016,6 +1017,8 @@ export const dict = { "settings.general.row.newLayoutDesigns.description": "Aktivér det redesignede layout, hjem, composer og sessions-UI", "settings.general.row.pinchZoom.title": "Knib for at zoome", "settings.general.row.pinchZoom.description": "Tillad trackpad-knib og Ctrl-scroll til zoom", + "settings.general.row.preventSleep.title": "Forhindr dvale", + "settings.general.row.preventSleep.description": "Hold systemet vågent, mens appen kører; skærmen kan stadig slukkes", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "Downloader...", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index e682c164..f1972566 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -576,6 +576,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Expertengremium standardmäßig aktivieren", "settings.general.row.expertPanelDefault.description": "Neue Unterhaltungen mit aktiviertem Expertengremium starten, damit dessen Schaltfläche den aktuellen Kontext bei Bedarf prüft.", "settings.general.section.display": "Anzeige", + "settings.general.section.power": "Energie", "settings.general.row.language.title": "Sprache", "settings.general.row.language.description": "Die Anzeigesprache für DeepAgent Code ändern", "settings.general.row.appearance.title": "Erscheinungsbild", @@ -961,6 +962,8 @@ export const dict = { "Überarbeitetes Layout, Startseite, Composer und Sitzungs-UI aktivieren", "settings.general.row.pinchZoom.title": "Zum Zoomen kneifen", "settings.general.row.pinchZoom.description": "Trackpad-Kneifen und Strg-Scrollen zum Zoomen erlauben", + "settings.general.row.preventSleep.title": "Ruhezustand des Systems verhindern", + "settings.general.row.preventSleep.description": "Hält das System wach, solange die App läuft; der Bildschirm kann sich weiterhin ausschalten", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "Wird heruntergeladen...", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index b385af78..bcd0e3c5 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -1204,6 +1204,7 @@ export const dict = { "settings.general.section.sounds": "Sound effects", "settings.general.section.feed": "Feed", "settings.general.section.display": "Display", + "settings.general.section.power": "Power", "settings.general.section.sharing": "Sharing", "settings.general.row.language.title": "Language", @@ -1367,6 +1368,8 @@ export const dict = { "settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI", "settings.general.row.pinchZoom.title": "Pinch to zoom", "settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom", + "settings.general.row.preventSleep.title": "Prevent system sleep", + "settings.general.row.preventSleep.description": "Keep the system awake while the app is running; the display can still turn off", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 5ec07a63..0d6b9085 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -635,6 +635,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Activar el panel de expertos por defecto", "settings.general.row.expertPanelDefault.description": "Iniciar nuevas conversaciones con el panel de expertos activado, para que su botón revise el contexto actual cuando se solicite.", "settings.general.section.display": "Pantalla", + "settings.general.section.power": "Energía", "settings.general.row.language.title": "Idioma", "settings.general.row.language.description": "Cambiar el idioma de visualización para DeepAgent Code", @@ -1037,6 +1038,8 @@ export const dict = { "settings.general.row.pinchZoom.title": "Pellizcar para ampliar", "settings.general.row.pinchZoom.description": "Permitir gestos de pellizco en trackpad y Ctrl-desplazamiento para ampliar", + "settings.general.row.preventSleep.title": "Evitar la suspensión del sistema", + "settings.general.row.preventSleep.description": "Mantener el sistema activo mientras la app está en ejecución; la pantalla aún puede apagarse", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "Descargando...", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 9e01f181..93df50ca 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -574,6 +574,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Activer le panel d'experts par défaut", "settings.general.row.expertPanelDefault.description": "Démarrer les nouvelles conversations avec le panel d'experts activé, afin que son bouton examine le contexte actuel à la demande.", "settings.general.section.display": "Affichage", + "settings.general.section.power": "Alimentation", "settings.general.row.language.title": "Langue", "settings.general.row.language.description": "Changer la langue d'affichage pour DeepAgent Code", "settings.general.row.appearance.title": "Apparence", @@ -962,6 +963,8 @@ export const dict = { "Activer la mise en page, l’accueil, le compositeur et l’interface de session repensés", "settings.general.row.pinchZoom.title": "Pincer pour zoomer", "settings.general.row.pinchZoom.description": "Autoriser le pincement du trackpad et Ctrl-défilement pour zoomer", + "settings.general.row.preventSleep.title": "Empêcher la mise en veille du système", + "settings.general.row.preventSleep.description": "Garde le système actif pendant que l'app est en cours d'exécution ; l'écran peut toujours s'éteindre", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "Téléchargement...", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index ad5e45cb..c79ca0f3 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -566,6 +566,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "エキスパートパネルをデフォルトで有効化", "settings.general.row.expertPanelDefault.description": "新しい会話をエキスパートパネル有効の状態で開始し、ボタンで現在のコンテキストをいつでも会議レビューできるようにします。", "settings.general.section.display": "ディスプレイ", + "settings.general.section.power": "電源", "settings.general.row.language.title": "言語", "settings.general.row.language.description": "DeepAgent Codeの表示言語を変更します", "settings.general.row.appearance.title": "外観", @@ -943,6 +944,8 @@ export const dict = { "再設計されたレイアウト、ホーム、コンポーザー、セッションUIを有効にします", "settings.general.row.pinchZoom.title": "ピンチでズーム", "settings.general.row.pinchZoom.description": "トラックパッドのピンチとCtrlスクロールでズームを許可します", + "settings.general.row.preventSleep.title": "システムのスリープを防止", + "settings.general.row.preventSleep.description": "アプリの実行中はシステムをスリープさせません。ディスプレイはオフにできます", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "ダウンロード中...", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index d8f0e408..1b742aee 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -562,6 +562,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "전문가 패널을 기본으로 활성화", "settings.general.row.expertPanelDefault.description": "새 대화를 전문가 패널이 활성화된 상태로 시작하여 버튼으로 현재 컨텍스트를 필요할 때 검토할 수 있게 합니다.", "settings.general.section.display": "디스플레이", + "settings.general.section.power": "전원", "settings.general.row.language.title": "언어", "settings.general.row.language.description": "DeepAgent Code 표시 언어 변경", "settings.general.row.appearance.title": "모양", @@ -934,6 +935,8 @@ export const dict = { "settings.general.row.newLayoutDesigns.description": "재설계된 레이아웃, 홈, 작성기 및 세션 UI를 활성화합니다", "settings.general.row.pinchZoom.title": "핀치로 확대/축소", "settings.general.row.pinchZoom.description": "트랙패드 핀치와 Ctrl-스크롤 제스처로 확대/축소를 허용합니다", + "settings.general.row.preventSleep.title": "시스템 절전 방지", + "settings.general.row.preventSleep.description": "앱이 실행되는 동안 시스템을 절전 상태로 전환하지 않습니다. 디스플레이는 꺼질 수 있습니다", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "다운로드 중...", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 2d21e9a8..d0a87222 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -635,6 +635,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Aktiver ekspertpanel som standard", "settings.general.row.expertPanelDefault.description": "Start nye samtaler med ekspertpanelet aktivert, slik at knappen kan gjennomgå gjeldende kontekst ved behov.", "settings.general.section.display": "Skjerm", + "settings.general.section.power": "Strøm", "settings.general.row.language.title": "Språk", "settings.general.row.language.description": "Endre visningsspråket for DeepAgent Code", @@ -1023,6 +1024,8 @@ export const dict = { "settings.general.row.newLayoutDesigns.description": "Aktivér det redesignede layout, hjem, composer og sessions-UI", "settings.general.row.pinchZoom.title": "Knib for at zoome", "settings.general.row.pinchZoom.description": "Tillad trackpad-knib og Ctrl-scroll til zoom", + "settings.general.row.preventSleep.title": "Forhindre hvilemodus", + "settings.general.row.preventSleep.description": "Hold systemet våkent mens appen kjører; skjermen kan fortsatt slås av", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "Laster ned...", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 8ade8e49..ea9daff2 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -566,6 +566,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Domyślnie włącz panel ekspertów", "settings.general.row.expertPanelDefault.description": "Rozpoczynaj nowe rozmowy z włączonym panelem ekspertów, aby jego przycisk mógł na żądanie przeglądać bieżący kontekst.", "settings.general.section.display": "Ekran", + "settings.general.section.power": "Zasilanie", "settings.general.row.language.title": "Język", "settings.general.row.language.description": "Zmień język wyświetlania dla DeepAgent Code", "settings.general.row.appearance.title": "Wygląd", @@ -949,6 +950,8 @@ export const dict = { "settings.general.row.pinchZoom.title": "Powiększanie gestem szczypania", "settings.general.row.pinchZoom.description": "Zezwól na powiększanie gestem szczypania na trackpadzie i Ctrl-przewijaniem", + "settings.general.row.preventSleep.title": "Zapobiegaj uśpieniu systemu", + "settings.general.row.preventSleep.description": "Utrzymuj system aktywny, gdy aplikacja jest uruchomiona; ekran nadal może się wyłączyć", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "Pobieranie...", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index c88ca046..7e599757 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -631,6 +631,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Включить экспертную панель по умолчанию", "settings.general.row.expertPanelDefault.description": "Начинать новые беседы с включённой экспертной панелью, чтобы её кнопка по запросу проверяла текущий контекст.", "settings.general.section.display": "Дисплей", + "settings.general.section.power": "Питание", "settings.general.row.language.title": "Язык", "settings.general.row.language.description": "Изменить язык отображения DeepAgent Code", @@ -1028,6 +1029,8 @@ export const dict = { "Включить обновленный макет, главную страницу, композер и интерфейс сессии", "settings.general.row.pinchZoom.title": "Масштабирование щипком", "settings.general.row.pinchZoom.description": "Разрешить масштабирование щипком на трекпаде и Ctrl-прокруткой", + "settings.general.row.preventSleep.title": "Запретить сон системы", + "settings.general.row.preventSleep.description": "Не давать системе засыпать, пока приложение запущено; экран по-прежнему может гаснуть", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "Загрузка...", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index d9822fb2..8cea2edd 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -626,6 +626,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "เปิดใช้คณะผู้เชี่ยวชาญโดยค่าเริ่มต้น", "settings.general.row.expertPanelDefault.description": "เริ่มการสนทนาใหม่โดยเปิดใช้คณะผู้เชี่ยวชาญ เพื่อให้ปุ่มสามารถตรวจทานบริบทปัจจุบันได้ตามต้องการ", "settings.general.section.display": "การแสดงผล", + "settings.general.section.power": "พลังงาน", "settings.general.row.language.title": "ภาษา", "settings.general.row.language.description": "เปลี่ยนภาษาที่แสดงสำหรับ DeepAgent Code", @@ -1011,6 +1012,8 @@ export const dict = { "settings.general.row.newLayoutDesigns.description": "เปิดใช้เลย์เอาต์ หน้าแรก ตัวเขียน และ UI เซสชันที่ออกแบบใหม่", "settings.general.row.pinchZoom.title": "จีบนิ้วเพื่อซูม", "settings.general.row.pinchZoom.description": "อนุญาตท่าทางจีบนิ้วบนแทร็กแพดและ Ctrl-เลื่อนเพื่อซูม", + "settings.general.row.preventSleep.title": "ป้องกันการสลีปของระบบ", + "settings.general.row.preventSleep.description": "ทำให้ระบบทำงานต่อเนื่องขณะแอปกำลังทำงาน หน้าจอยังคงดับได้", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "กำลังดาวน์โหลด...", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index ce03a0a0..e4abc007 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -637,6 +637,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Uzman panelini varsayılan olarak etkinleştir", "settings.general.row.expertPanelDefault.description": "Yeni sohbetleri uzman paneli etkin olarak başlatın; böylece düğmesi istendiğinde mevcut bağlamı inceler.", "settings.general.section.display": "Ekran", + "settings.general.section.power": "Güç", "settings.general.row.language.title": "Dil", "settings.general.row.language.description": "DeepAgent Code'un görünüm dilini değiştirin", @@ -1036,6 +1037,8 @@ export const dict = { "settings.general.row.pinchZoom.title": "Sıkıştırarak yakınlaştır", "settings.general.row.pinchZoom.description": "Trackpad sıkıştırma ve Ctrl-kaydırma hareketleriyle yakınlaştırmaya izin ver", + "settings.general.row.preventSleep.title": "Sistem uykusunu engelle", + "settings.general.row.preventSleep.description": "Uygulama çalışırken sistemi uyanık tutar; ekran yine de kapanabilir", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "İndiriliyor...", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index 931f58d4..7a82b2a5 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -735,6 +735,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Типово вмикати експертну панель", "settings.general.row.expertPanelDefault.description": "Починати нові розмови з увімкненою експертною панеллю, щоб її кнопка за потреби переглядала поточний контекст.", "settings.general.section.display": "Дисплей", + "settings.general.section.power": "Живлення", "settings.general.row.language.title": "Мова", "settings.general.row.language.description": "Змінити мову інтерфейсу DeepAgent Code", @@ -1044,6 +1045,8 @@ export const dict = { "Включить обновленный макет, главную страницу, композер и интерфейс сессии", "settings.general.row.pinchZoom.title": "Масштабування щипком", "settings.general.row.pinchZoom.description": "Разрешить масштабирование щипком на трекпаде и Ctrl-прокруткой", + "settings.general.row.preventSleep.title": "Запобігати сну системи", + "settings.general.row.preventSleep.description": "Не давати системі засинати, доки застосунок працює; екран все одно може вимикатися", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "Завантаження...", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index f9c26a02..fba9bd37 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -995,6 +995,7 @@ export const dict = { "settings.general.section.sounds": "音效", "settings.general.section.feed": "动态", "settings.general.section.display": "显示", + "settings.general.section.power": "电源", "settings.general.section.sharing": "分享", "settings.general.row.shareUrl.title": "分享服务器地址", "settings.general.row.shareUrl.description": "分享会话时使用的服务器基础地址。留空则使用默认值。", @@ -1442,6 +1443,8 @@ export const dict = { "settings.general.row.newLayoutDesigns.description": "启用重新设计的布局、首页、输入器和会话界面", "settings.general.row.pinchZoom.title": "双指缩放", "settings.general.row.pinchZoom.description": "允许触控板双指和 Ctrl 滚动手势缩放", + "settings.general.row.preventSleep.title": "阻止系统休眠", + "settings.general.row.preventSleep.description": "应用运行时保持系统唤醒,显示器仍可关闭", "settings.general.row.zoom.title": "缩放比例", "settings.general.row.zoom.description": "设置界面缩放比例", "settings.updates.action.downloading": "正在下载...", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index fb825dae..eb1a5734 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -781,6 +781,7 @@ export const dict = { "settings.general.section.sounds": "音效", "settings.general.section.feed": "資訊流", "settings.general.section.display": "顯示", + "settings.general.section.power": "電源", "settings.general.section.sharing": "分享", "settings.general.row.shareUrl.title": "分享伺服器網址", "settings.general.row.shareUrl.description": "分享工作階段時使用的伺服器基礎網址。留空則使用預設值。", @@ -1164,6 +1165,8 @@ export const dict = { "settings.general.row.newLayoutDesigns.description": "啟用重新設計的布局、首頁、輸入器和會話介面", "settings.general.row.pinchZoom.title": "雙指縮放", "settings.general.row.pinchZoom.description": "允許觸控板雙指和 Ctrl 捲動手勢縮放", + "settings.general.row.preventSleep.title": "防止系統休眠", + "settings.general.row.preventSleep.description": "應用程式執行時保持系統喚醒,顯示器仍可關閉", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "正在下載...", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index ed67945e..7af9f0b6 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -19,6 +19,7 @@ "test": "bun test ./src", "test:ci": "mkdir -p .artifacts/unit && bun test ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "test:subagents-smoke": "node --experimental-strip-types ./scripts/subagents-smoke.ts", + "test:prevent-sleep-smoke": "node --experimental-strip-types ./scripts/prevent-sleep-smoke.ts", "test:subagents-sourcemap": "bun ./scripts/verify-subagents-sourcemap.ts", "package": "bun ./scripts/package.ts", "package:mac": "bun ./scripts/package.ts --mac", diff --git a/packages/desktop/scripts/prevent-sleep-smoke.ts b/packages/desktop/scripts/prevent-sleep-smoke.ts new file mode 100644 index 00000000..34d42ccd --- /dev/null +++ b/packages/desktop/scripts/prevent-sleep-smoke.ts @@ -0,0 +1,106 @@ +#!/usr/bin/env bun +// Dynamic smoke test for the "Prevent system sleep" settings toggle (macOS only). +// +// Drives the real packaged main bundle through Playwright and asserts against the OS: +// 1. default launch registers a NoIdleSleepAssertion (historical always-on default) +// 2. toggling the switch off in Settings removes the assertion while the app keeps running +// 3. the disabled state persists across a relaunch (no assertion after restart) +// 4. toggling back on re-registers the assertion +// +// Requires a prior `bun run build` (uses out/main/index.js), same as subagents-smoke. +import { strict as assert } from "node:assert" +import { execFileSync } from "node:child_process" +import { mkdtemp, realpath } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" +import { _electron as electron, type ElectronApplication, type Page } from "@playwright/test" + +if (process.platform !== "darwin") { + console.log("prevent-sleep smoke is macOS-only (pmset assertions), skipping") + process.exit(0) +} + +const root = await realpath(await mkdtemp(join(tmpdir(), "deepagent-code-prevent-sleep-smoke-"))) +const main = resolve("out/main/index.js") + +const env = Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), +) +env.DEEPAGENT_CODE_TEST_ONBOARDING = "1" +env.DEEPAGENT_CODE_TEST_ROOT = root +env.DEEPAGENT_CODE_DB = join(root, "deepagent.sqlite") +env.DEEPAGENT_CODE_DISABLE_CHANNEL_DB = "1" + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +function idleSleepAssertionPids(): number[] { + const output = execFileSync("pmset", ["-g", "assertions"], { encoding: "utf8" }) + return [...output.matchAll(/pid (\d+)\([^)]*\): \[[^\]]*\] [\d:]+ NoIdleSleepAssertion/g)].map((match) => + Number(match[1]), + ) +} + +async function waitForAssertion(pid: number, expected: boolean, timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (idleSleepAssertionPids().includes(pid) === expected) return + await sleep(500) + } + throw new Error(`timed out waiting for NoIdleSleepAssertion of pid ${pid} to become ${expected}`) +} + +async function launch() { + const app = await electron.launch({ args: [main], env, timeout: 30_000 }) + const page = await app.firstWindow({ timeout: 30_000 }) + await page.waitForFunction(() => Boolean((window as { api?: unknown }).api)) + await page.evaluate(() => + (window as unknown as { api: { awaitInitialization(): Promise } }).api.awaitInitialization(), + ) + const pid = app.process().pid + assert.ok(pid, "main process pid") + return { app, page, pid } +} + +const switchSelector = '[data-action="settings-prevent-sleep"] [data-component="switch"]' +const switchInputSelector = `${switchSelector} input[data-slot="switch-input"]` + +async function setPreventSleepViaSettings(page: Page, enabled: boolean) { + await page.keyboard.press("Meta+,") + const control = page.locator(`${switchSelector} [data-slot="switch-control"]`) + await control.waitFor({ state: "visible", timeout: 15_000 }) + // Wait until the switch reflects the opposite state before clicking, so the + // click deterministically lands on the desired state even if the resource is slow. + const before = enabled ? "false" : "true" + await page.waitForFunction( + ({ selector, before }) => document.querySelector(selector)?.getAttribute("aria-checked") === before, + { selector: switchInputSelector, before }, + { timeout: 15_000 }, + ) + await control.click() + await page.keyboard.press("Escape") +} + +// 1. Fresh launch: prevention defaults on. +const first = await launch() +await waitForAssertion(first.pid, true) +console.log("ok 1 - default launch registers NoIdleSleepAssertion") + +// 2. Turning the Settings toggle off releases the assertion without quitting. +await setPreventSleepViaSettings(first.page, false) +await waitForAssertion(first.pid, false) +console.log("ok 2 - disabling the toggle releases the assertion while running") +await first.app.close() + +// 3. Relaunch: the persisted disabled state keeps the assertion off. +const second = await launch() +await sleep(3_000) +assert.ok(!idleSleepAssertionPids().includes(second.pid), "assertion must stay off after relaunch with setting disabled") +console.log("ok 3 - disabled state persists across relaunch") + +// 4. Re-enabling registers the assertion again. +await setPreventSleepViaSettings(second.page, true) +await waitForAssertion(second.pid, true) +console.log("ok 4 - re-enabling the toggle registers the assertion again") +await second.app.close() + +console.log("prevent-sleep smoke passed") diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 777d3e41..034b9dbb 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -40,7 +40,7 @@ import { createWslServersController } from "./wsl/servers" import { registerWslIpcHandlers } from "./wsl/ipc" import { spawnWslSidecar } from "./wsl/sidecar" import { migrate } from "./migrate" -import { startPowerSaveBlocker, stopPowerSaveBlocker } from "./power" +import { initPowerSaveBlocker, stopPowerSaveBlocker } from "./power" import { createTray, destroyTray } from "./tray" const APP_NAMES: Record = { @@ -303,11 +303,12 @@ const main = Effect.gen(function* () { }) } - // Keep the app running (prevent idle sleep/hibernate) while it is active. The tray icon enables + // Keep the app running (prevent idle sleep/hibernate) while it is active, unless the user opted + // out in settings. The tray icon enables // close-to-tray: closing the window hides it to the tray with a right-click “Quit” to fully exit. // On platforms without tray support (e.g. Linux GNOME default), tray creation fails silently and // close-to-tray stays disabled so closing the window quits normally — avoiding a stranded window. - startPowerSaveBlocker() + initPowerSaveBlocker() const trayCreated = createTray(() => mainWindow) setCloseToTrayEnabled(trayCreated) diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 9d8b4ce4..d3fca7f3 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -12,6 +12,8 @@ import { archivePath, copyPath, extractPath, guardFileOpCall, movePath, removePa import { fileLog, isTracked } from "./git" import { getStore } from "./store" import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows" +import { getPreventSleepEnabled, setPreventSleepEnabled, syncPowerSaveBlocker } from "./power" +import { PREVENT_SLEEP_KEY, SETTINGS_STORE } from "./store-keys" import { browserView } from "./browser-view" import type { UpdaterController } from "./updater-controller" import { createUpdaterSubscriptions } from "./updater-subscriptions" @@ -92,13 +94,21 @@ export function registerIpcHandlers(deps: Deps) { } }) ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => { + // preventSleep drives a live powerSaveBlocker side effect; routing writes through the + // dedicated set-prevent-sleep IPC keeps the blocker in sync with the persisted value. + if (name === SETTINGS_STORE && key === PREVENT_SLEEP_KEY) { + throw new Error(`"${PREVENT_SLEEP_KEY}" must be written via the set-prevent-sleep IPC`) + } getStore(name).set(key, value) }) ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => { getStore(name).delete(key) + // Deleting the key resets it to the default-on behavior; re-sync so the blocker matches. + if (name === SETTINGS_STORE && key === PREVENT_SLEEP_KEY) syncPowerSaveBlocker() }) ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => { getStore(name).clear() + if (name === SETTINGS_STORE) syncPowerSaveBlocker() }) ipcMain.handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => { const store = getStore(name) @@ -246,6 +256,13 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("set-pinch-zoom-enabled", (_event: IpcMainInvokeEvent, enabled: boolean) => { setPinchZoomEnabled(enabled) }) + ipcMain.handle("get-prevent-sleep", () => getPreventSleepEnabled()) + ipcMain.handle("set-prevent-sleep", (_event: IpcMainInvokeEvent, enabled: boolean) => { + setPreventSleepEnabled(enabled) + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send("prevent-sleep-changed", enabled) + } + }) ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => { const win = BrowserWindow.fromWebContents(event.sender) if (!win) return diff --git a/packages/desktop/src/main/power.test.ts b/packages/desktop/src/main/power.test.ts new file mode 100644 index 00000000..16aa5518 --- /dev/null +++ b/packages/desktop/src/main/power.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test" +import { resolvePreventSleepEnabled } from "./prevent-sleep" + +// Covers the persisted-setting semantics of the sleep-prevention toggle without the Electron +// runtime. The same resolution runs in power.ts on startup and on every settings change. + +describe("resolvePreventSleepEnabled", () => { + test("defaults to enabled when the setting was never stored (historical always-on behavior)", () => { + expect(resolvePreventSleepEnabled(undefined)).toBe(true) + }) + + test("treats any value other than explicit false as enabled", () => { + expect(resolvePreventSleepEnabled(true)).toBe(true) + expect(resolvePreventSleepEnabled(null)).toBe(true) + expect(resolvePreventSleepEnabled("false")).toBe(true) + }) + + test("disables only on explicit false", () => { + expect(resolvePreventSleepEnabled(false)).toBe(false) + }) +}) diff --git a/packages/desktop/src/main/power.ts b/packages/desktop/src/main/power.ts index aa8c6121..bd7c2db7 100644 --- a/packages/desktop/src/main/power.ts +++ b/packages/desktop/src/main/power.ts @@ -1,9 +1,37 @@ import { powerSaveBlocker } from "electron" +import { resolvePreventSleepEnabled } from "./prevent-sleep" +import { getStore } from "./store" +import { PREVENT_SLEEP_KEY } from "./store-keys" + // prevent-app-suspension keeps the system/app from idling to sleep while allowing the display to // turn off (go dark) and the lid to close — i.e. it blocks idle sleep & hibernate, not screen blank. let blockerId: number | null = null +export function getPreventSleepEnabled(): boolean { + return resolvePreventSleepEnabled(getStore().get(PREVENT_SLEEP_KEY)) +} + +export function setPreventSleepEnabled(enabled: boolean): void { + getStore().set(PREVENT_SLEEP_KEY, enabled) + syncPowerSaveBlocker() +} + +// Reconciles the running blocker with the persisted setting. Must be called after any write path +// that can change the stored value outside setPreventSleepEnabled (generic store delete/clear). +export function syncPowerSaveBlocker(): void { + if (getPreventSleepEnabled()) { + startPowerSaveBlocker() + return + } + stopPowerSaveBlocker() +} + +export function initPowerSaveBlocker(): void { + if (!getPreventSleepEnabled()) return + startPowerSaveBlocker() +} + export function startPowerSaveBlocker(): void { if (blockerId !== null) return blockerId = powerSaveBlocker.start("prevent-app-suspension") diff --git a/packages/desktop/src/main/prevent-sleep.ts b/packages/desktop/src/main/prevent-sleep.ts new file mode 100644 index 00000000..d90a830d --- /dev/null +++ b/packages/desktop/src/main/prevent-sleep.ts @@ -0,0 +1,6 @@ +// Sleep prevention predates the settings toggle, so an unset key must keep the historical +// always-on behavior; only an explicit `false` opts out. Kept Electron-free so the decision +// can be unit-tested without the runtime (same split as close-to-tray.ts). +export function resolvePreventSleepEnabled(stored: unknown): boolean { + return stored !== false +} diff --git a/packages/desktop/src/main/store-keys.ts b/packages/desktop/src/main/store-keys.ts index beb18b8b..85ae29c0 100644 --- a/packages/desktop/src/main/store-keys.ts +++ b/packages/desktop/src/main/store-keys.ts @@ -2,3 +2,4 @@ export const SETTINGS_STORE = "deepagent-code.settings" export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl" export const WSL_SERVERS_KEY = "wslServers" export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled" +export const PREVENT_SLEEP_KEY = "preventSleep" diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 1c83db5a..108f4953 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -122,6 +122,13 @@ const api: ElectronAPI = { ipcRenderer.on("pinch-zoom-enabled-changed", handler) return () => ipcRenderer.removeListener("pinch-zoom-enabled-changed", handler) }, + getPreventSleepEnabled: () => ipcRenderer.invoke("get-prevent-sleep"), + setPreventSleepEnabled: (enabled) => ipcRenderer.invoke("set-prevent-sleep", enabled), + onPreventSleepChanged: (cb) => { + const handler = (_: unknown, enabled: boolean) => cb(enabled) + ipcRenderer.on("prevent-sleep-changed", handler) + return () => ipcRenderer.removeListener("prevent-sleep-changed", handler) + }, onZoomFactorChanged: (cb) => { const handler = (_: unknown, factor: number) => cb(factor) ipcRenderer.on("zoom-factor-changed", handler) diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 3d882a6b..db0b741c 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -123,6 +123,9 @@ export type ElectronAPI = { getPinchZoomEnabled: () => Promise setPinchZoomEnabled: (enabled: boolean) => Promise onPinchZoomEnabledChanged: (cb: (enabled: boolean) => void) => () => void + getPreventSleepEnabled: () => Promise + setPreventSleepEnabled: (enabled: boolean) => Promise + onPreventSleepChanged: (cb: (enabled: boolean) => void) => () => void onZoomFactorChanged: (cb: (factor: number) => void) => () => void setTitlebar: (theme: TitlebarTheme) => Promise runDesktopMenuAction: (action: DesktopMenuAction) => Promise diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 33b95d1d..9f4b56cc 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -265,6 +265,10 @@ const createPlatform = (): Platform => { setPinchZoomEnabled, + getPreventSleepEnabled: () => window.api.getPreventSleepEnabled(), + + setPreventSleepEnabled: (enabled: boolean) => window.api.setPreventSleepEnabled(enabled), + runDesktopMenuAction, checkAppExists: async (appName: string) => { From add655ccfb9e8f9744b7c18ad6d565ea6b7bdb38 Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Mon, 27 Jul 2026 16:02:57 +0800 Subject: [PATCH 2/2] audit review clean (#95) ### Issue for this PR Closes # ### Type of change - [ ] Bug fix - [ ] New feature - [ ] Refactor / code improvement - [ ] Documentation ### What does this PR do? Please provide a description of the issue, the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR. **If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!** ### How did you verify your code works? ### Screenshots / recordings _If this is a UI change, please include a screenshot or recording._ ### Checklist - [ ] I have tested my changes locally - [ ] I have not included unrelated changes in this PR _If you do not follow this template your PR will be automatically rejected._ --- .../components/dialog-custom-provider-form.ts | 61 +-- .../components/dialog-custom-provider.test.ts | 53 +++ .../src/components/dialog-custom-provider.tsx | 20 +- .../components/provider-model-refresh.test.ts | 5 + .../src/components/provider-model-refresh.ts | 1 + packages/app/src/i18n/en.ts | 2 + packages/app/src/i18n/zh.ts | 2 + packages/app/src/i18n/zht.ts | 2 + packages/core/src/deepagent/orchestration.ts | 2 +- packages/core/src/deepagent/prompt-policy.ts | 48 +-- packages/core/src/deepagent/session-state.ts | 13 + packages/core/src/skill/discovery.ts | 3 +- .../core/test/deepagent/orchestration.test.ts | 2 +- .../test/deepagent/plan-controller.test.ts | 2 +- .../test/deepagent/plan-gate-loop.test.ts | 6 +- .../core/test/deepagent/prompt-policy.test.ts | 18 + .../session-state-plan-latch.test.ts | 7 + packages/deepagent-code/script/build-node.ts | 11 +- .../src/deepagent/git-groundtruth.ts | 15 +- .../src/deepagent/validation-exec.ts | 12 +- .../deepagent-code/src/mcp/secret-store.ts | 14 +- .../src/provider/discovery-cache.ts | 32 +- .../src/provider/model-discovery.ts | 69 +++- .../deepagent-code/src/provider/provider.ts | 260 ++++++++---- .../deepagent-code/src/provider/transform.ts | 7 +- .../instance/httpapi/handlers/provider.ts | 90 +++-- .../src/session/agent-worktree.ts | 17 +- .../src/session/deepagent-multiround.ts | 23 +- .../deepagent-code/src/session/llm/request.ts | 74 ++-- .../deepagent-code/src/session/message-v2.ts | 48 ++- packages/deepagent-code/src/session/prompt.ts | 6 +- packages/deepagent-code/src/session/tools.ts | 60 ++- packages/deepagent-code/src/tool/internal.ts | 13 + packages/deepagent-code/src/tool/shell.ts | 3 +- packages/deepagent-code/src/tool/task-run.ts | 3 +- .../test/deepagent/multiround.test.ts | 35 ++ .../test/deepagent/plan-status-cache.test.ts | 132 +++---- .../test/deepagent/request-prep.test.ts | 94 ++++- .../test/provider/discovery-cache.test.ts | 26 +- .../test/provider/model-discovery.test.ts | 40 +- .../test/provider/provider.test.ts | 88 ++++- .../test/provider/transform.test.ts | 37 ++ .../test/session/plan-gate-note.test.ts | 38 -- .../deepagent-code/test/session/retry.test.ts | 20 + .../test/session/structured-output.test.ts | 2 + .../deepagent-code/test/tool/task-run.test.ts | 1 + packages/desktop/package.json | 3 +- packages/desktop/scripts/subagents-live.ts | 369 ++++++++++++++++++ 48 files changed, 1455 insertions(+), 434 deletions(-) create mode 100644 packages/deepagent-code/src/tool/internal.ts delete mode 100644 packages/deepagent-code/test/session/plan-gate-note.test.ts create mode 100644 packages/desktop/scripts/subagents-live.ts diff --git a/packages/app/src/components/dialog-custom-provider-form.ts b/packages/app/src/components/dialog-custom-provider-form.ts index cac792ea..ddd685e9 100644 --- a/packages/app/src/components/dialog-custom-provider-form.ts +++ b/packages/app/src/components/dialog-custom-provider-form.ts @@ -34,6 +34,16 @@ export type CustomProviderConfig = { const npmForProtocol = (kind: ProviderProtocol | undefined) => (kind === "anthropic" ? ANTHROPIC : OPENAI_COMPATIBLE) +export function isAmbiguousProtocolError(error: unknown) { + const message = + error instanceof Error + ? error.message + : error && typeof error === "object" && "message" in error && typeof error.message === "string" + ? error.message + : "" + return message.includes("Provider protocol is ambiguous") +} + // Leading host labels that are generic service prefixes and make a poor provider id, so we skip past // them to reach the brand label (api.deepseek.com -> "deepseek", not "api"). Kept deliberately small: // only unambiguous service prefixes, never anything that could be a brand. @@ -183,11 +193,14 @@ export function validateCustomProvider(input: ValidateArgs) { // Zero-config path: when the user leaves id/name blank we derive them from the URL, so those // fields are no longer required. Derivation needs a usable URL — if the URL itself is invalid we // skip it and let urlError drive the failure instead of emitting a spurious id/name error. - const derived = !urlError && (!typedID || !typedName) ? deriveProviderIdentity({ - baseURL, - existingProviderIDs: input.existingProviderIDs, - disabledProviders: input.disabledProviders, - }) : undefined + const derived = + !urlError && (!typedID || !typedName) + ? deriveProviderIdentity({ + baseURL, + existingProviderIDs: input.existingProviderIDs, + disabledProviders: input.disabledProviders, + }) + : undefined const providerID = typedID || derived?.providerID || "" const name = typedName || derived?.name || "" @@ -232,8 +245,7 @@ export function validateCustomProvider(input: ValidateArgs) { const contextError = ctx && !/^\d+$/.test(ctx) ? input.t("provider.custom.error.context") : undefined return { id: idError, name: nameError, context: contextError } }) - const modelsValid = - (discoveryMode || models.every((m) => !m.id && !m.name)) && models.every((m) => !m.context) + const modelsValid = (discoveryMode || models.every((m) => !m.id && !m.name)) && models.every((m) => !m.context) const modelConfig = Object.fromEntries( input.form.models.map((m) => { const ctx = m.context.trim() @@ -309,10 +321,9 @@ export function validateCustomProvider(input: ValidateArgs) { } } -// Build the dialog form state for editing an existing custom provider. Fields (URL/key/headers/name) -// come from the raw config entry; model rows are seeded from the RESOLVED provider so the user sees the -// actual context/reasoning/temperature values (a discovery provider has no models in config — its -// specs only exist post-resolve). Each row is pre-filled so edits override just those fields. +// Discovery results are runtime state, not durable config. In discovery mode only configured model +// overrides become editable rows; otherwise merely opening and saving the dialog would snapshot every +// cached model into config and keep revoked/removed models alive indefinitely. export function formStateFromProvider(input: { config: ProviderConfig resolved: ResolvedProvider | undefined @@ -321,20 +332,24 @@ export function formStateFromProvider(input: { const headers = config.options?.headers const headerRows = headers && typeof headers === "object" && Object.keys(headers).length - ? Object.entries(headers as Record).map(([key, value]) => - headerRow2(String(key), String(value)), - ) + ? Object.entries(headers as Record).map(([key, value]) => headerRow2(String(key), String(value))) : [headerRow()] - const resolvedModels = resolved?.models ?? {} - const modelRows = Object.entries(resolvedModels).map(([id, m]) => - modelRow({ - id, - name: m.name || id, - context: m.limit?.context ? String(m.limit.context) : "", - reasoning: !!m.capabilities?.reasoning, - temperature: !!m.capabilities?.temperature, - }), + const modelRows = Object.entries(config.discovery ? (config.models ?? {}) : (resolved?.models ?? {})).map( + ([id, configured]) => { + const model = resolved?.models[id] + return modelRow({ + id, + name: configured.name || model?.name || id, + context: configured.limit?.context + ? String(configured.limit.context) + : model?.limit?.context + ? String(model.limit.context) + : "", + reasoning: configured.reasoning ?? !!model?.capabilities?.reasoning, + temperature: configured.temperature ?? !!model?.capabilities?.temperature, + }) + }, ) return { diff --git a/packages/app/src/components/dialog-custom-provider.test.ts b/packages/app/src/components/dialog-custom-provider.test.ts index 4e0ff95e..4eb5805d 100644 --- a/packages/app/src/components/dialog-custom-provider.test.ts +++ b/packages/app/src/components/dialog-custom-provider.test.ts @@ -2,12 +2,20 @@ import { describe, expect, test } from "bun:test" import { deriveProviderIdentity, formStateFromProvider, + isAmbiguousProtocolError, modelRow, validateCustomProvider, } from "./dialog-custom-provider-form" const t = (key: string) => key +test("recognizes protocol ambiguity from SDK and Error response shapes", () => { + const message = "Provider protocol is ambiguous; select OpenAI-compatible or Anthropic explicitly" + expect(isAmbiguousProtocolError(new Error(message))).toBe(true) + expect(isAmbiguousProtocolError({ message })).toBe(true) + expect(isAmbiguousProtocolError(new Error("HTTP 404"))).toBe(false) +}) + describe("validateCustomProvider", () => { test("builds trimmed config payload", () => { const result = validateCustomProvider({ @@ -290,6 +298,7 @@ describe("formStateFromProvider", () => { npm: "@ai-sdk/openai-compatible", discovery: true, options: { baseURL: "https://relay.example.com", apiKey: "secret", headers: { "X-Env": "prod" } }, + models: { "gpt-4o": { name: "GPT-4o" } }, }, resolved: { id: "relay", @@ -337,6 +346,50 @@ describe("formStateFromProvider", () => { temperature: true, }) }) + + test("does not turn discovered runtime models into durable overrides", () => { + const form = formStateFromProvider({ + config: { + name: "My Relay", + npm: "@ai-sdk/openai-compatible", + discovery: true, + options: { baseURL: "https://relay.example.com", apiKey: "secret" }, + }, + resolved: { + id: "relay", + name: "My Relay", + source: "custom", + env: [], + options: {}, + models: { + stale: { + id: "stale", + providerID: "relay", + api: { id: "stale", url: "", npm: "@ai-sdk/openai-compatible" }, + name: "Stale", + capabilities: { + temperature: false, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 0, output: 0 }, + status: "active", + options: {}, + headers: {}, + release_date: "", + }, + }, + }, + }) + + expect(form.models).toHaveLength(1) + expect(form.models[0]).toMatchObject({ id: "", name: "" }) + }) }) describe("deriveProviderIdentity", () => { diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 1b4a0054..952846d4 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -18,6 +18,7 @@ import { deriveProviderIdentity, formStateFromProvider, headerRow, + isAmbiguousProtocolError, modelRow, validateCustomProvider, } from "./dialog-custom-provider-form" @@ -244,8 +245,10 @@ export function DialogCustomProvider(props: Props) { const choice = protocolChoice() // Defensive client-side timeout: the backend already caps its /models fetch, but guard the // whole round-trip too so a stalled request can never leave the submit button hung. On - // timeout (or any error) we fall through to manual/validation handling instead of blocking. - const res = await Promise.race([ + // A timeout or ordinary discovery failure falls through to manual validation. Protocol + // ambiguity is different: continuing would either guess the wrong SDK or fail with no useful + // feedback, so require an explicit protocol choice. + const discovery = await Promise.race([ serverSDK.client.provider.models .discover( { @@ -259,7 +262,18 @@ export function DialogCustomProvider(props: Props) { ) .then((res) => res.data), new Promise((resolve) => setTimeout(() => resolve(undefined), 20_000)), - ]).catch(() => undefined) + ]).then( + (data) => ({ data, error: undefined }), + (error: unknown) => ({ data: undefined, error }), + ) + if (choice === "auto" && isAmbiguousProtocolError(discovery.error)) { + showToast({ + title: language.t("common.requestFailed"), + description: language.t("provider.custom.error.protocol.ambiguous"), + }) + return + } + const res = discovery.data if (res?.kind) setDetectedProtocol(res.kind) const discovered = res?.models ?? [] if (discovered.length > 0 && !hasManualModels) { diff --git a/packages/app/src/components/provider-model-refresh.test.ts b/packages/app/src/components/provider-model-refresh.test.ts index 20fb6526..588b0e4e 100644 --- a/packages/app/src/components/provider-model-refresh.test.ts +++ b/packages/app/src/components/provider-model-refresh.test.ts @@ -5,6 +5,11 @@ describe("provider model refresh", () => { test("allows official, discovery, and legacy imported providers", () => { expect(canRefreshProviderModels("openai", undefined)).toBe(true) expect(canRefreshProviderModels("custom", { discovery: true })).toBe(true) + expect( + canRefreshProviderModels("grouped", { + groups: { anthropic: { npm: "@ai-sdk/anthropic", discovery: true } }, + }), + ).toBe(true) expect( canRefreshProviderModels("mistral", { options: { baseURL: "https://api.mistral.ai/v1" }, diff --git a/packages/app/src/components/provider-model-refresh.ts b/packages/app/src/components/provider-model-refresh.ts index 90786742..a7b81ae7 100644 --- a/packages/app/src/components/provider-model-refresh.ts +++ b/packages/app/src/components/provider-model-refresh.ts @@ -4,6 +4,7 @@ import type { ProviderConfig } from "@deepagent-code/sdk/v2" export function canRefreshProviderModels(providerID: string, config: ProviderConfig | undefined) { if (isOfficialProvider(providerID)) return true if (config?.discovery === true) return true + if (Object.values(config?.groups ?? {}).some((group) => group.discovery === true)) return true return ( config?.npm === undefined && typeof config?.options?.baseURL === "string" && diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index bcd0e3c5..c00ee08c 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -247,6 +247,8 @@ export const dict = { "provider.custom.error.required": "Required", "provider.custom.error.duplicate": "Duplicate", "provider.custom.error.context": "Enter a positive whole number, or leave blank", + "provider.custom.error.protocol.ambiguous": + "This endpoint supports multiple authentication styles. Select OpenAI-compatible or Anthropic explicitly.", "provider.disconnect.toast.disconnected.title": "{{provider}} disconnected", "provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index fba9bd37..3b22bd5e 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -261,6 +261,8 @@ export const dict = { "provider.custom.error.required": "必填", "provider.custom.error.duplicate": "重复", "provider.custom.error.context": "请输入正整数,或留空", + "provider.custom.error.protocol.ambiguous": + "该端点同时支持多种认证方式,无法安全地自动判断协议。请明确选择 OpenAI-compatible 或 Anthropic。", "provider.disconnect.toast.disconnected.title": "{{provider}} 已断开连接", "provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index eb1a5734..a54da664 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -208,6 +208,8 @@ export const dict = { "provider.custom.error.required": "必填", "provider.custom.error.duplicate": "重複", "provider.custom.error.context": "請輸入正整數,或留空", + "provider.custom.error.protocol.ambiguous": + "此端點同時支援多種驗證方式,無法安全地自動判斷協定。請明確選擇 OpenAI-compatible 或 Anthropic。", "provider.disconnect.toast.disconnected.title": "{{provider}} 已中斷連線", "provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。", diff --git a/packages/core/src/deepagent/orchestration.ts b/packages/core/src/deepagent/orchestration.ts index 36dc7c63..02a32664 100644 --- a/packages/core/src/deepagent/orchestration.ts +++ b/packages/core/src/deepagent/orchestration.ts @@ -306,7 +306,7 @@ export const buildOrchestrationSection = (mode: AgentMode): string | null => { "抑制信号(命中则本体做,禁止过度编排):单文件;机制已明确;纯机械改动(改名/typo/格式);用户要求快速/直接。", "", "关键判定(reviewer 的 verdict、研究结果的合并)走结构化结果:调 `task` 时传 `output_schema`(reviewer→ReviewResult,researcher→ResearchResult),不要依赖散文解析。", - `扇出规模自控(宽松上限,非硬性):单次编排子 agent 总数控制在 ${DEFAULT_MAX_FANOUT} 个以内,单轮并行不超过 ${DEFAULT_MAX_CONCURRENCY} 个;确有必要可分多轮,但不要一次性发起远超此规模的 task。本轮的具体扇出建议数见对话末尾 。`, + `扇出规模自控(宽松上限,非硬性):单次编排子 agent 总数控制在 ${DEFAULT_MAX_FANOUT} 个以内,单轮并行不超过 ${DEFAULT_MAX_CONCURRENCY} 个;确有必要可分多轮,但不要一次性发起远超此规模的 task。本轮的具体扇出建议数由系统通过 提供。`, ] if (mode === "ultra") { lines.push("当前为 ultra:默认倾向编排并可多轮迭代。") diff --git a/packages/core/src/deepagent/prompt-policy.ts b/packages/core/src/deepagent/prompt-policy.ts index bd126020..3af5dfd2 100644 --- a/packages/core/src/deepagent/prompt-policy.ts +++ b/packages/core/src/deepagent/prompt-policy.ts @@ -112,9 +112,9 @@ export type PreviousResults = { // applyCaching). Anything that changes per-round (round number, previous-round results, token // budget, the concrete fan-out verdict) is a cache-buster: it invalidates the whole prefix AND all // history after it on every intra-turn call. All such volatile state lives in -// `buildVolatileRoundContext` instead, which the caller appends to the LAST user message (after the -// breakpoint) so the model still sees it without churning the prefix. When editing, ask: "does this -// value differ between two turns of the same session?" If yes, it belongs in the volatile context. +// `buildVolatileRoundContext` instead, which the caller sends as a separate system message. This may +// reduce cross-round history cache reuse, but runtime control state must never masquerade as a new +// user request. export const buildSystemPrompt = (ctx: PromptContext): string => { const sections: string[] = [] @@ -130,7 +130,7 @@ export const buildSystemPrompt = (ctx: PromptContext): string => { // L2 (v3.8.0 §L2): orchestration guidance, injected after the tools/task section per the design. // Tier-gated by mode; buildOrchestrationSection returns null when there is nothing to add. The // per-turn fan-out DECISION (concrete counts) is intentionally NOT passed here — it is volatile and - // rendered by buildVolatileRoundContext; the system block keeps only the stable generic guidance. + // rendered by buildVolatileRoundContext; this block keeps only the stable generic guidance. const orchestration = buildOrchestrationSection(ctx.mode) if (orchestration) sections.push(orchestration) @@ -146,8 +146,7 @@ export const buildSystemPrompt = (ctx: PromptContext): string => { // null (no section), and a later retrieval-enabled round returns non-null, which USED to insert the // section into the cached prefix mid-session and bust the cache for the rest of the session. Mirror // the T4.4 fan-out guard: knowledge is per-session-volatile w.r.t. WHEN it first appears, so it does - // NOT belong in the byte-stable prefix. It is rendered in buildVolatileRoundContext instead (tail, - // after the cache breakpoint), so the model still sees it without ever churning the prefix. + // NOT belong in the byte-stable base prompt. It is rendered in buildVolatileRoundContext instead. sections.push(constraintsSection(ctx.mode)) @@ -158,11 +157,9 @@ export const buildSystemPrompt = (ctx: PromptContext): string => { return sections.filter(Boolean).join("\n\n") } -// Volatile per-turn state that must NOT enter the cached system prefix. Rendered into a single -// `` block that the caller appends to the last user message (after the -// cache breakpoint). Returns "" when there is nothing round-specific to surface (e.g. first turn, -// no previous results) so the caller can skip injection entirely. Keep the ORDER and wording here -// free to change per turn — that is the whole point; only buildSystemPrompt must stay stable. +// Volatile per-turn state that must NOT enter the cached base system prompt. Rendered into a single +// `` block that the caller sends as a separate system message before user +// history. Keep the order and wording here free to change; only buildSystemPrompt must stay stable. export const buildVolatileRoundContext = (ctx: PromptContext): string => { const sections: string[] = [] @@ -172,8 +169,7 @@ export const buildVolatileRoundContext = (ctx: PromptContext): string => { sections.push(["# 本轮状态 (round context)", "", roundLine].join("\n")) // MEDIUM cache-buster fix: the current date was moved OUT of environmentSection (cached prefix) — - // it advances at midnight and would bust the whole prefix once per day. Render it here in the tail - // so the model still knows "today" without churning the cache. + // it advances at midnight and would bust the base prompt once per day. Render it here instead. if (ctx.environment.date) { sections.push(["# 日期 (date)", "", `- Date: ${ctx.environment.date}`].join("\n")) } @@ -185,7 +181,7 @@ export const buildVolatileRoundContext = (ctx: PromptContext): string => { } // Task objective + goals/criteria: the current target can be re-seeded on a continue round (the - // supervisor advances the goal), so it is round-derived. Keep it in the tail. + // supervisor advances the goal), so it is round-derived. Keep it in the runtime update. if (ctx.task.userRequest || ctx.task.goals.length > 0) { sections.push(taskSection(ctx.task)) } @@ -196,9 +192,8 @@ export const buildVolatileRoundContext = (ctx: PromptContext): string => { // BUG #5 (prompt-cache): knowledge is retrieved LAZILY (null on a fresh store round 1, non-null on a // later retrieval-enabled round). It used to live in the cached system prefix, so its late appearance - // busted the prefix mid-session (~10× cost). It is advisory, round-derived context — render it here in - // the volatile tail (after the cache breakpoint) so the model still sees it without ever churning the - // prefix. The `knowledgeEnabled(mode)` gate is preserved from the original prefix placement. + // busted the base prompt mid-session (~10× cost). It is advisory, round-derived context and belongs + // in this separate runtime update. The `knowledgeEnabled(mode)` gate is preserved. if (ctx.knowledge && knowledgeEnabled(ctx.mode)) { sections.push(knowledgeSection(ctx.knowledge)) } @@ -243,15 +238,15 @@ const identitySection = (mode: AgentMode): string => { "2. 验证通过则完成,失败则诊断 → 修复 → 再验证", "3. 不盲目重试,不做无证据的猜测性修改", "4. 工具执行由运行时负责,我负责思维和决策", - "5. 当前轮次 / 阶段 / 上轮结果 / 预算见对话末尾的 ", + "5. 当前轮次 / 阶段 / 上轮结果 / 预算由系统通过 提供", ].join("\n") } const environmentSection = (env: EnvironmentContext): string => { // NOTE (prompt-cache): `- Date` is intentionally NOT rendered here. The date advances at midnight, // so baking it into the cached prefix busts the whole prefix once per day (MEDIUM cache-buster). It - // is rendered in buildVolatileRoundContext (tail, after the cache breakpoint) instead — same policy - // as every other volatile env-derived value. Everything left here is session-stable. + // is rendered in the separate runtime system message instead. Everything left here is + // session-stable. const lines = [ "# Environment", "", @@ -277,14 +272,10 @@ const activationSection = (activation: ActivationDecision): string => { // §5b fan-out verdict rendered for the volatile block. Mirrors the advisory numbers that // buildOrchestrationSection used to inline, but kept OUT of the cached system prefix because the // verdict is derived from this turn's task complexity and changes turn-to-turn. -const fanoutVerdictLines = (decision: FanoutDecision): string => { - if (!decision.orchestrate) { - return [ - "# 本轮调度判定 (orchestration verdict)", - "", - `不建议扇出(level=${decision.level},tier=${decision.tier},complexity=${decision.complexity})。本体直接完成,除非用户明确要求深入/多角度。`, - ].join("\n") - } +const fanoutVerdictLines = (decision: FanoutDecision): string | null => { + // A negative verdict has no action for the model to take. Repeating it on every tool turn makes + // some models echo the verdict verbatim and adds noise even when they do not. + if (!decision.orchestrate) return null return [ "# 本轮调度判定 (orchestration verdict)", "", @@ -411,6 +402,7 @@ const constraintsSection = (mode: AgentMode): string => { "- Run validation after making changes", "- If validation passes, the task is complete — do not continue optimizing", "- If you cannot complete the task, explain why clearly", + "- Apply runtime control state silently; never quote or report its tags, orchestration levels, plan status, or token budget unless the user explicitly asks for diagnostics", ] if (mode === "max") { lines.push("- Knowledge synthesis is available — use refs for guidance, not verbatim copying") diff --git a/packages/core/src/deepagent/session-state.ts b/packages/core/src/deepagent/session-state.ts index 9f0f520c..a807d4af 100644 --- a/packages/core/src/deepagent/session-state.ts +++ b/packages/core/src/deepagent/session-state.ts @@ -104,6 +104,9 @@ export type SessionRunState = { // call) are never mistaken for genuine new user inputs. undefined = not yet recorded (session // created before this field was added; treated as "initial" on first observation). lastAdmissionUserMessageId: string | undefined + // The last soft plan-gate warning claimed by a tool call. A stale latch can span many tool calls; + // persisting its fingerprint prevents duplicate process-log warnings without touching history. + lastPlanGateNudgeFingerprint: string | null } // V3.9 §D: session-state pointer to a running goal. The GoalLoop's GoalStatus (persisted in the @@ -165,6 +168,7 @@ export const getOrCreate = (sessionId: string, mode: AgentMode): SessionRunState createdAt: new Date().toISOString(), completedAt: null, lastAdmissionUserMessageId: undefined, + lastPlanGateNudgeFingerprint: null, } sessions.set(sessionId, state) saveToDisk() @@ -455,6 +459,14 @@ export const clearPlanStale = (sessionId: string): void => { saveToDisk() } +export const claimPlanGateNudge = (sessionId: string, fingerprint: string): boolean => { + const state = sessions.get(sessionId) + if (!state || state.lastPlanGateNudgeFingerprint === fingerprint) return false + state.lastPlanGateNudgeFingerprint = fingerprint + saveToDisk() + return true +} + // U1 anti-deadlock: record that the plan gate just blocked a mutating tool on a stale plan. Advances // the runtime grace counter so shouldGraceRelease can fire without the model cooperating. export const recordPlanGateBlock = (sessionId: string): void => { @@ -615,6 +627,7 @@ function normalizeState(state: SessionRunState): SessionRunState { // Backfill: sessions persisted before V4.0 have no panelRounds → null (defaults to "single"). panelRounds: state.panelRounds ?? null, activeGoal: state.activeGoal ?? null, + lastPlanGateNudgeFingerprint: state.lastPlanGateNudgeFingerprint ?? null, } // PR-4 migration cleanup: the legacy `suppressedFingerprints: string[]` field is carried through by // the `...state` spread above. Once its contents are migrated into `suppressedValidations`, drop the diff --git a/packages/core/src/skill/discovery.ts b/packages/core/src/skill/discovery.ts index e20f8a18..54a7c244 100644 --- a/packages/core/src/skill/discovery.ts +++ b/packages/core/src/skill/discovery.ts @@ -8,6 +8,7 @@ import { httpClient } from "../effect/app-node-platform" import { FSUtil } from "../fs-util" import { Global } from "../global" import { AbsolutePath } from "../schema" +import { Hash } from "../util/hash" import * as Log from "../util/log" const skillConcurrency = 4 @@ -109,7 +110,7 @@ export const layer = Layer.effect( ) if (!data) return [] - const sourceRoot = path.resolve(global.cache, "skills", Bun.hash(base).toString(16)) + const sourceRoot = path.resolve(global.cache, "skills", Hash.fast(base)) return yield* Effect.forEach( data.skills.flatMap((skill) => { if (!isSafeSegment(skill.name)) { diff --git a/packages/core/test/deepagent/orchestration.test.ts b/packages/core/test/deepagent/orchestration.test.ts index aa41a1a4..10bb663b 100644 --- a/packages/core/test/deepagent/orchestration.test.ts +++ b/packages/core/test/deepagent/orchestration.test.ts @@ -219,7 +219,7 @@ describe("§5b orchestration section is stable (no per-turn verdict inlined)", ( expect(decideFanout({ mode: "high", signals }).orchestrate).toBe(false) }) - test("section points the model to the tail-appended round context", () => { + test("section points the model to the runtime system context", () => { const section = buildOrchestrationSection("high")! expect(section).toContain("deepagent-round-context") }) diff --git a/packages/core/test/deepagent/plan-controller.test.ts b/packages/core/test/deepagent/plan-controller.test.ts index 0c545572..b993dcc8 100644 --- a/packages/core/test/deepagent/plan-controller.test.ts +++ b/packages/core/test/deepagent/plan-controller.test.ts @@ -134,7 +134,7 @@ describe("planGate (before_tool_use soft gate)", () => { }) // DESIGN (aligned with codex exec_policy): a stale plan ledger NEVER denies tool execution — it - // WARNS (tool runs, reminder attached) so the model is nudged to re-sync without being deadlocked. + // WARNS while the tool runs; dispatch records it outside model-visible tool output. // This holds in every mode, including high+ (the old code hard-blocked here, which caused the // production deadlock). The only remaining hard block is the U9 per-step binding gate, covered below. test("warns (never blocks) on a mutating tool when stale in high+ mode", () => { diff --git a/packages/core/test/deepagent/plan-gate-loop.test.ts b/packages/core/test/deepagent/plan-gate-loop.test.ts index 83dc5291..ec3b5155 100644 --- a/packages/core/test/deepagent/plan-gate-loop.test.ts +++ b/packages/core/test/deepagent/plan-gate-loop.test.ts @@ -8,8 +8,8 @@ import { planGate, HookPolicy } from "../../src/deepagent/hooks" // U1 end-to-end contract (the exact decision the tools.ts chokepoint computes). DESIGN (aligned with // codex exec_policy): plan-ledger state is orthogonal to whether a tool may run, so a stale plan -// NEVER hard-blocks a mutating tool — it WARNS (tool runs, reminder attached) so the model is nudged -// to re-sync without being denied its tools. Read/plan tools always pass. The only hard block that +// NEVER hard-blocks a mutating tool — it WARNS while the tool runs; dispatch keeps that warning out +// of model-visible output and finalization owns plan enforcement. Read/plan tools always pass. The only hard block that // remains is the U9 per-step binding gate (strict hard modes), and even that has a runtime grace // release so it can never permanently deadlock. This mirrors session/tools.ts without booting the // full session loop. @@ -88,7 +88,7 @@ describe("U1 soft-gate loop (chokepoint contract)", () => { test("read-only bash (ls/git status/grep/sed -n/sort) is allowed even while the plan is stale", () => { SessionState.getOrCreate("gate-ro", "xhigh") SessionState.markPlanStale("gate-ro", "validation_failed") - // a mutating bash command warns (runs + reminder), never a hard block… + // a mutating bash command warns but still runs, never a hard block… expect(decideAt("gate-ro", "bash", "xhigh", "rm -rf build").decision).toBe("warn") // …and read-only shell inspections pass outright (isMutating=false → allow). expect(decideAt("gate-ro", "bash", "xhigh", "ls -la").decision).toBe("allow") diff --git a/packages/core/test/deepagent/prompt-policy.test.ts b/packages/core/test/deepagent/prompt-policy.test.ts index f3bdc5e6..24a7ed27 100644 --- a/packages/core/test/deepagent/prompt-policy.test.ts +++ b/packages/core/test/deepagent/prompt-policy.test.ts @@ -186,4 +186,22 @@ describe("buildVolatileRoundContext", () => { // still non-empty: round/stage are always present expect(vol).toContain("第 1 轮") }) + + test("omits a non-actionable fan-out verdict", () => { + const vol = buildVolatileRoundContext({ + ...ctxAt(1, 100_000), + fanoutDecision: { + orchestrate: false, + level: 0, + tier: 1, + complexity: 0, + researchers: 0, + reviewers: 0, + maxConcurrency: 4, + }, + }) + expect(vol).not.toContain("orchestration verdict") + expect(vol).not.toContain("本体直接完成") + expect(vol).not.toContain("level=0") + }) }) diff --git a/packages/core/test/deepagent/session-state-plan-latch.test.ts b/packages/core/test/deepagent/session-state-plan-latch.test.ts index 219b27da..0cf40403 100644 --- a/packages/core/test/deepagent/session-state-plan-latch.test.ts +++ b/packages/core/test/deepagent/session-state-plan-latch.test.ts @@ -75,6 +75,13 @@ describe("session-state plan latch", () => { expect(reloaded.planLatch.latch).toBe("stale") expect(reloaded.planLatch.stale_reason).toBe("no_progress") }) + + test("a plan-gate nudge fingerprint is claimed only once", () => { + SessionState.getOrCreate("latch-s7", "high") + expect(SessionState.claimPlanGateNudge("latch-s7", "stale:user_appended:0")).toBe(true) + expect(SessionState.claimPlanGateNudge("latch-s7", "stale:user_appended:0")).toBe(false) + expect(SessionState.claimPlanGateNudge("latch-s7", "stale:tool_failed:0")).toBe(true) + }) }) // U10 step-reporting: the mutation-since-report counter and evidence plumbing on the production seam. diff --git a/packages/deepagent-code/script/build-node.ts b/packages/deepagent-code/script/build-node.ts index e605ce37..e5dbf12a 100755 --- a/packages/deepagent-code/script/build-node.ts +++ b/packages/deepagent-code/script/build-node.ts @@ -34,9 +34,18 @@ if (!result.success) throw new AggregateError(result.logs, "Failed to build the // keep local usernames/workspace paths out of release artifacts. const buildRoot = path.resolve(dir, "../..") const bundle = Bun.file("./dist/node/node.js") +const source = await bundle.text() +// The plugin context exposes Bun.$ only behind a `typeof Bun` guard. Every other direct Bun API +// in this Node-targeted bundle is an unreviewed runtime dependency and must fail the desktop build. +const unsupportedBunAPIs = [...new Set(source.match(/\bBun\.[A-Za-z_$][A-Za-z0-9_$]*/g) ?? [])].filter( + (api) => api !== "Bun.$", +) +if (unsupportedBunAPIs.length > 0) { + throw new Error(`Node server bundle contains Bun-only runtime APIs: ${unsupportedBunAPIs.join(", ")}`) +} await Bun.write( bundle, - (await bundle.text()) + source .replaceAll(buildRoot, "/__deepagent_build__") .replaceAll(buildRoot.replaceAll("\\", "/"), "/__deepagent_build__") .replaceAll(buildRoot.replaceAll("\\", "\\\\"), "/__deepagent_build__"), diff --git a/packages/deepagent-code/src/deepagent/git-groundtruth.ts b/packages/deepagent-code/src/deepagent/git-groundtruth.ts index a5a819c5..8f7e5a2e 100644 --- a/packages/deepagent-code/src/deepagent/git-groundtruth.ts +++ b/packages/deepagent-code/src/deepagent/git-groundtruth.ts @@ -1,4 +1,6 @@ import path from "node:path" +import { buffer } from "node:stream/consumers" +import { Process } from "@/util/process" // V3.1 A4 (P0 fix): real runner ground truth for the round report's change-surface dimension. // The round report reconciles MODEL CLAIMS against RUNNER GROUND TRUTH; the changed-file list and @@ -19,7 +21,14 @@ const EMPTY: GitGroundTruth = { changed_files: [], diff_stat: null, repo_root: n const run = async (args: readonly string[], cwd: string, timeoutMs: number): Promise => { try { - const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe", env: { ...process.env } }) + const proc = Process.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + env: { ...process.env }, + }) + const stdout = proc.stdout + if (!stdout) return null // P2-5: race read+exit against a timeout sentinel; on timeout kill and resolve null instead of // awaiting stdout that may never close after kill. let timer: ReturnType | undefined @@ -32,8 +41,8 @@ const run = async (args: readonly string[], cwd: string, timeoutMs: number): Pro }, timeoutMs) }) const completed = (async () => { - const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]) - return { stdout, exitCode } as const + const [output, exitCode] = await Promise.all([buffer(stdout), proc.exited]) + return { stdout: output.toString(), exitCode } as const })() const outcome = await Promise.race([completed, timeout]) if (timer) clearTimeout(timer) diff --git a/packages/deepagent-code/src/deepagent/validation-exec.ts b/packages/deepagent-code/src/deepagent/validation-exec.ts index d69b0b01..a3ef0915 100644 --- a/packages/deepagent-code/src/deepagent/validation-exec.ts +++ b/packages/deepagent-code/src/deepagent/validation-exec.ts @@ -1,4 +1,6 @@ import { AgentGateway } from "@deepagent-code/core/agent-gateway" +import { buffer } from "node:stream/consumers" +import { Process } from "@/util/process" // V3 A3: real validation executor. Runs the workspace validation commands (typecheck / lint / // test, inferred by workspace-context) and maps each to the universal ValidationResult the @@ -15,12 +17,15 @@ export const runValidationCommands = async ( for (const command of commands) { const started = Date.now() try { - const proc = Bun.spawn(["sh", "-c", command], { + const proc = Process.spawn(["sh", "-c", command], { cwd, stdout: "pipe", stderr: "pipe", env: { ...process.env }, }) + const stdout = proc.stdout + const stderr = proc.stderr + if (!stdout || !stderr) throw new Error("Validation process output is unavailable") // P2-5: race the full read+exit against a timeout sentinel. On timeout we kill the process // AND resolve immediately to a failed result, instead of awaiting stdout that may never close // after kill (the previous code could hang on `Response(proc.stdout).text()`). @@ -34,9 +39,8 @@ export const runValidationCommands = async ( }, timeoutMs) }) const completed = (async () => { - const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]) - const exitCode = await proc.exited - return { stdout, stderr, exitCode } as const + const [output, error, exitCode] = await Promise.all([buffer(stdout), buffer(stderr), proc.exited]) + return { stdout: output.toString(), stderr: error.toString(), exitCode } as const })() const outcome = await Promise.race([completed, timeout]) diff --git a/packages/deepagent-code/src/mcp/secret-store.ts b/packages/deepagent-code/src/mcp/secret-store.ts index b76ba5f6..74a00835 100644 --- a/packages/deepagent-code/src/mcp/secret-store.ts +++ b/packages/deepagent-code/src/mcp/secret-store.ts @@ -7,6 +7,7 @@ import { FSUtil } from "@deepagent-code/core/fs-util" import { ConfigMCPV1 } from "@deepagent-code/core/v1/config/mcp" import { McpCatalog } from "./catalog" import * as Log from "@deepagent-code/core/util/log" +import { buffer } from "node:stream/consumers" const log = Log.create({ service: "mcp.secret" }) @@ -232,16 +233,15 @@ export namespace SecretStore { put: async (account, secret) => { // secret-tool reads the secret from stdin so it never appears in the process table. const label = `${KEYCHAIN_SERVICE}:${account}` - const proc = Bun.spawn( + const proc = Process.spawn( ["secret-tool", "store", "--label", label, "service", KEYCHAIN_SERVICE, "account", account], - { stdin: "pipe", stdout: "pipe", stderr: "pipe" }, + { stdin: "pipe", stdout: "ignore", stderr: "pipe" }, ) - proc.stdin.write(secret) - proc.stdin.end() - const code = await proc.exited + if (!proc.stdin || !proc.stderr) throw new Error("secret-tool process pipes are unavailable") + proc.stdin.end(secret) + const [code, stderr] = await Promise.all([proc.exited, buffer(proc.stderr)]) if (code !== 0) { - const err = await new Response(proc.stderr).text() - throw new Error(`secret-tool store failed (code ${code}): ${err.trim()}`) + throw new Error(`secret-tool store failed (code ${code}): ${stderr.toString().trim()}`) } }, get: async (account) => { diff --git a/packages/deepagent-code/src/provider/discovery-cache.ts b/packages/deepagent-code/src/provider/discovery-cache.ts index 0a5384c2..786c53a9 100644 --- a/packages/deepagent-code/src/provider/discovery-cache.ts +++ b/packages/deepagent-code/src/provider/discovery-cache.ts @@ -8,11 +8,14 @@ import type { EffectFlock } from "@deepagent-code/core/util/effect-flock" import { discoverProviderModels, isChatModel, + isProviderDiscoveryAuthError, type DiscoveredModel, type ProviderDiscoveryKind, } from "./model-discovery" const log = Log.create({ service: "provider-discovery-cache" }) +const AUTH_FAILURE_RETRY_MS = 60_000 +const authFailures = new Map() // Bounds on what an untrusted /models endpoint can inject into the provider model map. `id` becomes a // map key and `api.id` (flows into request payloads); `name` is display-only. Cap both count and @@ -35,7 +38,11 @@ const sanitizeModels = (models: DiscoveredModel[]): DiscoveredModel[] => { if (seen.has(id)) continue seen.add(id) const rawName = typeof model.name === "string" && model.name ? model.name : id - out.push({ id, name: rawName.slice(0, MAX_MODEL_NAME_LENGTH) }) + out.push({ + id, + name: rawName.slice(0, MAX_MODEL_NAME_LENGTH), + ...(model.protocols ? { protocols: model.protocols } : {}), + }) if (out.length >= MAX_DISCOVERED_MODELS) break } return out @@ -44,8 +51,8 @@ const sanitizeModels = (models: DiscoveredModel[]): DiscoveredModel[] => { // Runtime model discovery for third-party providers that opt in with `discovery: true`. // The live /models list is cached to disk (mtime + TTL, cross-process flock) exactly like the // models.dev catalog cache, so a fresh instance start reuses the list and only refetches after the -// TTL lapses. Discovery is best-effort: a failed fetch falls back to the last good disk copy, and a -// total miss returns [] so the provider load never blocks on the network. +// TTL lapses. Transient failures may use the last good copy, but authentication failures never do: +// keeping revoked credentials looking healthy leaves models selectable even though every request fails. export const DEFAULT_DISCOVERY_TTL = Duration.hours(6) @@ -85,6 +92,9 @@ export const discoverModelsCached = Effect.fn("ProviderDiscovery.cached")(functi ) { const ttl = input.ttl ?? DEFAULT_DISCOVERY_TTL const filepath = cacheFile(input) + const blocked = authFailures.get(filepath) + if (!force && blocked && blocked.retryAt > Date.now()) return yield* Effect.fail(blocked.error) + if (blocked) authFailures.delete(filepath) // Re-sanitize on read too: a cache file written by an older build (looser rules) is normalized to // the current bounds before use. @@ -106,7 +116,13 @@ export const discoverModelsCached = Effect.fn("ProviderDiscovery.cached")(functi } const fetchAndWrite = Effect.gen(function* () { - const models = sanitizeModels(yield* Effect.tryPromise(() => fetch(input))) + const models = sanitizeModels( + yield* Effect.tryPromise({ + try: () => fetch(input), + catch: (error) => error, + }), + ) + authFailures.delete(filepath) // Never cache an empty result: a provider that's still provisioning (200 + `{data:[]}`) or whose // models are all filtered out would otherwise pin an empty list for the whole TTL and hide models // that come online later. Don't overwrite the cache; prefer a prior good (if stale) copy over @@ -123,7 +139,8 @@ export const discoverModelsCached = Effect.fn("ProviderDiscovery.cached")(functi }) // Refetch under a cross-process lock; re-check freshness inside in case a peer refreshed while we - // waited on the lock. Any failure (lock, network, write) falls back to a stale disk copy, then []. + // waited on the lock. Transient failures fall back to stale disk; 401/403 are propagated and held + // briefly in memory so independently created provider instances cannot hammer a revoked credential. return yield* flock .withLock( Effect.gen(function* () { @@ -138,6 +155,11 @@ export const discoverModelsCached = Effect.fn("ProviderDiscovery.cached")(functi .pipe( Effect.catch((error) => Effect.gen(function* () { + if (isProviderDiscoveryAuthError(error)) { + authFailures.set(filepath, { error, retryAt: Date.now() + AUTH_FAILURE_RETRY_MS }) + log.warn("model discovery authentication failed", { providerID: input.providerID, error }) + return yield* Effect.fail(error) + } log.warn("model discovery failed, falling back to cache", { providerID: input.providerID, error }) const stale = yield* readDisk return stale ?? [] diff --git a/packages/deepagent-code/src/provider/model-discovery.ts b/packages/deepagent-code/src/provider/model-discovery.ts index a7a7151d..f178ffa5 100644 --- a/packages/deepagent-code/src/provider/model-discovery.ts +++ b/packages/deepagent-code/src/provider/model-discovery.ts @@ -3,8 +3,23 @@ export type ProviderDiscoveryKind = "openai-compatible" | "anthropic" export type DiscoveredModel = { id: string name: string + protocols?: ProviderDiscoveryKind[] } +export class ProviderDiscoveryError extends Error { + override readonly name = "ProviderDiscoveryError" + + constructor( + readonly providerID: string, + readonly status: number, + ) { + super(`${providerID} model discovery failed: HTTP ${status}`) + } +} + +export const isProviderDiscoveryAuthError = (error: unknown) => + error instanceof ProviderDiscoveryError && (error.status === 401 || error.status === 403) + export function normalizeBaseURL(input: string) { const parsed = new URL(input) parsed.hash = "" @@ -27,7 +42,20 @@ function parseModelList(body: { data?: unknown[] }): DiscoveredModel[] { return (body.data ?? []) .map((item) => { if (!item || typeof item !== "object" || !("id" in item) || typeof item.id !== "string") return - return { id: item.id, name: modelName(item, item.id) } + const protocols = + "supported_endpoint_types" in item && Array.isArray(item.supported_endpoint_types) + ? [ + ...new Set( + item.supported_endpoint_types.flatMap((value): ProviderDiscoveryKind[] => { + if (typeof value !== "string") return [] + if (value === "anthropic" || value === "anthropic-messages") return ["anthropic"] + if (value === "openai" || value === "openai-compatible") return ["openai-compatible"] + return [] + }), + ), + ] + : [] + return { id: item.id, name: modelName(item, item.id), ...(protocols.length > 0 ? { protocols } : {}) } }) .filter((item): item is DiscoveredModel => Boolean(item)) } @@ -37,10 +65,9 @@ export type ProtocolDiscoveryResult = { models: DiscoveredModel[] } -// Resolve the provider protocol and its model list in one pass. When `kind` is given we probe only -// that protocol; otherwise we try openai-compatible first (the common case), then anthropic, and -// return whichever yields models. The last error message is surfaced when nothing succeeds so the -// caller can report a useful failure. `probe` is injectable for testing. +// A successful GET /models only proves that the credential can list models. Gateways commonly accept +// more than one authentication header on that route, so protocol detection must use endpoint metadata +// or an unambiguous single successful probe instead of silently preferring OpenAI compatibility. export async function discoverWithProtocol( input: { baseURL: string @@ -53,17 +80,27 @@ export async function discoverWithProtocol( discoverProviderModels({ ...input, kind }), ): Promise { const candidates: ProviderDiscoveryKind[] = input.kind ? [input.kind] : ["openai-compatible", "anthropic"] - let lastError: unknown - for (const kind of candidates) { - try { - const models = await probe(kind) - if (models.length > 0) return { kind, models } - lastError = new Error("No provider models were returned") - } catch (error) { - lastError = error - } + const results = await Promise.all( + candidates.map((kind) => + probe(kind).then( + (models) => ({ kind, models, error: undefined }), + (error: unknown) => ({ kind, models: [] as DiscoveredModel[], error }), + ), + ), + ) + const successful = results.filter((result) => result.models.length > 0) + const declared = [...new Set(successful.flatMap((result) => result.models.flatMap((model) => model.protocols ?? [])))] + if (declared.length === 1) { + const kind = declared[0] + const result = successful.find((item) => item.models.some((model) => model.protocols?.includes(kind))) + if (result) return { kind, models: result.models } + } + if (successful.length === 1) return { kind: successful[0].kind, models: successful[0].models } + if (successful.length > 1) { + throw new Error("Provider protocol is ambiguous; select OpenAI-compatible or Anthropic explicitly") } - throw lastError instanceof Error ? lastError : new Error("No provider models were returned") + const error = results.findLast((result) => result.error)?.error + throw error instanceof Error ? error : new Error("No provider models were returned") } // Discovery must never hang forever. An unreachable or silent /models endpoint (wrong URL, a host @@ -108,7 +145,7 @@ export async function discoverProviderModels(input: { } throw error }) - if (!response.ok) throw new Error(`${input.providerID} model discovery failed: HTTP ${response.status}`) + if (!response.ok) throw new ProviderDiscoveryError(input.providerID, response.status) const body = (await response.json()) as { data?: unknown[] } const seen = new Set() diff --git a/packages/deepagent-code/src/provider/provider.ts b/packages/deepagent-code/src/provider/provider.ts index f12e5caa..05e01bd3 100644 --- a/packages/deepagent-code/src/provider/provider.ts +++ b/packages/deepagent-code/src/provider/provider.ts @@ -1129,6 +1129,7 @@ interface State { catalog: Record errors: ConfigError[] sdk: Map + transportOptions: Map> modelLoaders: Record varsLoaders: Record } @@ -1345,6 +1346,7 @@ export const layer = Layer.effect( const providers: Record = {} as Record const languages = new Map() + const transportOptions = new Map>() const modelLoaders: { [providerID: string]: CustomModelLoader } = {} @@ -1437,13 +1439,14 @@ export const layer = Layer.effect( } } - // Runtime model discovery (opt-in via `provider..discovery: true`). For each third-party - // provider that opts in, fetch its live /models list (disk-cached, TTL + flock) and expose it - // as config-model entries so the loop below runs them through the same parse/inference path as - // hand-listed models. This is what lets a URL+Key-only provider surface models at runtime - // instead of freezing them into config at save time. Best-effort: failures yield no entries. + // Discover provider-level and group-level model lists independently. A group is a complete + // transport boundary: its protocol, key, URL and headers inherit from the provider only when + // omitted, and one group's failure must not suppress another group's models. type ConfigModels = NonNullable<(typeof configProviders)[number][1]["models"]> const discoveredModels: Record = {} + const discoveredGroupModels: Record> = {} + const failedDiscoveryProviders = new Set() + const failedDiscoveryGroups = new Set() // Track providers that opted into discovery so the zero-model deletion below can tell a // discovery provider that came up empty (worth an error) apart from a normal empty provider. const discoveryProviders = new Set() @@ -1484,42 +1487,79 @@ export const layer = Layer.effect( discoveredModels[providerID] = Object.fromEntries( models.map((m: DiscoveredModel) => [m.id, { name: m.name || m.id }]), ) - - // NEW (v4.0.6): run discovery for each group that declares discovery:true. - // Uses group.npm to determine the protocol kind; group.options overrides baseURL/apiKey. - for (const [groupId, group] of Object.entries(provider.groups ?? {})) { - if (!group.discovery) continue - const gBaseURL = group.options?.baseURL ?? baseURL - const gApiKey = group.options?.apiKey ?? apiKey - const gHeaders = { ...headers, ...(group.options?.headers as Record | undefined) } - if (!gBaseURL || (!gApiKey && !Object.keys(gHeaders).length)) continue - const gKind: ProviderDiscoveryKind = - group.npm === "@ai-sdk/anthropic" ? "anthropic" : "openai-compatible" - const groupCacheKey = `${providerID}:${groupId}` - const gModels = yield* discoverModelsCached(fs, flock, { - providerID: groupCacheKey, - baseURL: gBaseURL, - apiKey: gApiKey, - kind: gKind, - headers: gHeaders, - }) - if (!gModels.length) continue - // Merge into the group's models map (hand-listed entries win over discovered) - group.models = { - ...Object.fromEntries(gModels.map((m: DiscoveredModel) => [m.id, { name: m.name || m.id }])), - ...(group.models ?? {}), - } - } }).pipe( - // Discovery is best-effort and must never take down the whole provider-state build. The - // cache already handles ordinary failures; this also swallows defects (e.g. a flock - // release die on a corrupt lock file) so one provider's discovery fault can't abort all. + Effect.catch((error) => + Effect.sync(() => { + failedDiscoveryProviders.add(providerID) + errors.push(providerConfigError(providerID, error instanceof Error ? error.message : String(error))) + }), + ), Effect.catchDefect((defect) => Effect.sync(() => log.warn("discovery pre-pass defect", { providerID, defect })), ), ), { concurrency: 5 }, ) + yield* Effect.forEach( + configProviders + .filter( + ([providerID]) => + providerID !== "deepagent-code" && + providerID !== "deepagent" && + !isOfficialProviderID(providerID) && + isProviderAllowed(ProviderV2.ID.make(providerID)), + ) + .flatMap(([providerID, provider]) => + Object.entries(provider.groups ?? {}) + .filter(([, group]) => group.discovery === true) + .map(([groupID, group]) => ({ providerID, provider, groupID, group })), + ), + ({ providerID, provider, groupID, group }) => + Effect.gen(function* () { + discoveryProviders.add(providerID) + const baseURL = group.options?.baseURL ?? provider.options?.baseURL + if (!baseURL) return + const envKey = provider.env ? providerEnvKey(provider.env, envs) : undefined + const apiKey = group.options?.apiKey ?? provider.options?.apiKey ?? (envKey ? envs[envKey] : undefined) + const headers = { + ...(provider.options?.headers as Record | undefined), + ...(group.options?.headers as Record | undefined), + } + if (!apiKey && Object.keys(headers).length === 0) return + const kind: ProviderDiscoveryKind = + (group.npm ?? provider.npm ?? modelsDev[providerID]?.npm) === "@ai-sdk/anthropic" + ? "anthropic" + : "openai-compatible" + const models = yield* discoverModelsCached(fs, flock, { + providerID: `${providerID}:${groupID}`, + baseURL, + apiKey, + kind, + headers, + }) + if (!models.length) return + discoveredGroupModels[providerID] ??= {} + discoveredGroupModels[providerID][groupID] = Object.fromEntries( + models.map((model: DiscoveredModel) => [model.id, { name: model.name || model.id }]), + ) + }).pipe( + Effect.catch((error) => + Effect.sync(() => { + failedDiscoveryGroups.add(`${providerID}/${groupID}`) + errors.push( + providerConfigError( + `${providerID}.groups.${groupID}`, + error instanceof Error ? error.message : String(error), + ), + ) + }), + ), + Effect.catchDefect((defect) => + Effect.sync(() => log.warn("group discovery pre-pass defect", { providerID, groupID, defect })), + ), + ), + { concurrency: 5 }, + ) // Configured providers are always third-party providers. They must not share ids with // official catalog providers; otherwise a custom endpoint can silently hijack official @@ -1548,8 +1588,9 @@ export const layer = Layer.effect( // Discovered models seed the source map; hand-listed `provider.models` override them so an // explicit config entry always wins over the runtime-discovered version of the same id. - const modelSource = - legacyDiscoveryProviders.has(providerID) && discoveredModels[providerID] + const modelSource = failedDiscoveryProviders.has(providerID) + ? {} + : legacyDiscoveryProviders.has(providerID) && discoveredModels[providerID] ? discoveredModels[providerID] : { ...(discoveredModels[providerID] ?? {}), ...(provider.models ?? {}) } for (const [modelID, model] of Object.entries(modelSource)) { @@ -1582,13 +1623,15 @@ export const layer = Layer.effect( name, providerID: ProviderV2.ID.make(providerID), capabilities: { - temperature: model.temperature ?? existingModel?.capabilities.temperature ?? catalogModel?.temperature ?? false, + temperature: + model.temperature ?? existingModel?.capabilities.temperature ?? catalogModel?.temperature ?? false, reasoning: model.reasoning ?? (existingModel?.capabilities.reasoning || catalogModel?.reasoning || inferredReasoning(providerID, apiID, modelID)), - attachment: model.attachment ?? existingModel?.capabilities.attachment ?? catalogModel?.attachment ?? false, + attachment: + model.attachment ?? existingModel?.capabilities.attachment ?? catalogModel?.attachment ?? false, toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? catalogModel?.tool_call ?? true, input: { text: @@ -1656,8 +1699,13 @@ export const layer = Layer.effect( input: model?.cost?.input ?? existingModel?.cost?.input ?? catalogModel?.cost?.input ?? 0, output: model?.cost?.output ?? existingModel?.cost?.output ?? catalogModel?.cost?.output ?? 0, cache: { - read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? catalogModel?.cost?.cache_read ?? 0, - write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? catalogModel?.cost?.cache_write ?? 0, + read: + model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? catalogModel?.cost?.cache_read ?? 0, + write: + model?.cost?.cache_write ?? + existingModel?.cost?.cache.write ?? + catalogModel?.cost?.cache_write ?? + 0, }, }, options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}), @@ -1679,23 +1727,29 @@ export const layer = Layer.effect( parsed.models[modelID] = parsedModel } - // NEW (v4.0.6): process model groups — each group carries its own protocol (npm) and/or - // API key, overriding the provider-level defaults for its models. The model-building logic - // mirrors the modelSource loop above; group.npm > provider.npm and group.options.apiKey > - // provider.options.apiKey for every model inside the group. + // Each group carries its own transport. Discovered entries are ephemeral and configured + // entries win, so refresh cannot rewrite protocol, credentials, or durable model overrides. + const groupModelOwners = new Map() for (const [groupId, group] of Object.entries(provider.groups ?? {})) { const groupNpm = group.npm ?? provider.npm - const groupApiKey = group.options?.apiKey - const groupBaseURL = group.options?.baseURL - // Merge group options onto provider options — group-level wins for apiKey/baseURL, rest inherited - const groupOptions = { - ...parsed.options, - ...(group.options ?? {}), - ...(groupApiKey ? { apiKey: groupApiKey } : {}), - ...(groupBaseURL ? { baseURL: groupBaseURL } : {}), - } - const groupModelSource = group.models ?? {} + const groupModelSource = failedDiscoveryGroups.has(`${providerID}/${groupId}`) + ? {} + : { + ...(discoveredGroupModels[providerID]?.[groupId] ?? {}), + ...(group.models ?? {}), + } for (const [modelID, model] of Object.entries(groupModelSource)) { + const owner = groupModelOwners.get(modelID) + if (owner) { + errors.push( + providerConfigError( + `${providerID}.groups.${groupId}`, + `Model ${modelID} is already assigned to group ${owner}`, + ), + ) + continue + } + groupModelOwners.set(modelID, groupId) const existingModel = parsed.models[model.id ?? modelID] const apiID = model.id ?? existingModel?.api.id ?? modelID const catalogModel = existingModel ? undefined : catalogSpecFor(apiID, modelID, catalogIndex) @@ -1716,47 +1770,97 @@ export const layer = Layer.effect( api: { id: apiID, npm: apiNpm, - url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "", + url: + model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "", }, status: model.status ?? existingModel?.status ?? "active", name, providerID: ProviderV2.ID.make(providerID), capabilities: { - temperature: model.temperature ?? existingModel?.capabilities.temperature ?? catalogModel?.temperature ?? false, + temperature: + model.temperature ?? existingModel?.capabilities.temperature ?? catalogModel?.temperature ?? false, reasoning: model.reasoning ?? - (existingModel?.capabilities.reasoning || catalogModel?.reasoning || inferredReasoning(providerID, apiID, modelID)), - attachment: model.attachment ?? existingModel?.capabilities.attachment ?? catalogModel?.attachment ?? false, + (existingModel?.capabilities.reasoning || + catalogModel?.reasoning || + inferredReasoning(providerID, apiID, modelID)), + attachment: + model.attachment ?? existingModel?.capabilities.attachment ?? catalogModel?.attachment ?? false, toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? catalogModel?.tool_call ?? true, interleaved: model.interleaved ?? existingModel?.capabilities.interleaved ?? false, input: { - text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? catalogModel?.modalities?.input?.includes("text") ?? true, - audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? catalogModel?.modalities?.input?.includes("audio") ?? false, - image: model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? catalogModel?.modalities?.input?.includes("image") ?? false, - video: model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? catalogModel?.modalities?.input?.includes("video") ?? false, - pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? catalogModel?.modalities?.input?.includes("pdf") ?? false, + text: + model.modalities?.input?.includes("text") ?? + existingModel?.capabilities.input.text ?? + catalogModel?.modalities?.input?.includes("text") ?? + true, + audio: + model.modalities?.input?.includes("audio") ?? + existingModel?.capabilities.input.audio ?? + catalogModel?.modalities?.input?.includes("audio") ?? + false, + image: + model.modalities?.input?.includes("image") ?? + existingModel?.capabilities.input.image ?? + catalogModel?.modalities?.input?.includes("image") ?? + false, + video: + model.modalities?.input?.includes("video") ?? + existingModel?.capabilities.input.video ?? + catalogModel?.modalities?.input?.includes("video") ?? + false, + pdf: + model.modalities?.input?.includes("pdf") ?? + existingModel?.capabilities.input.pdf ?? + catalogModel?.modalities?.input?.includes("pdf") ?? + false, }, output: { - text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? catalogModel?.modalities?.output?.includes("text") ?? true, - audio: model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? catalogModel?.modalities?.output?.includes("audio") ?? false, - image: model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? catalogModel?.modalities?.output?.includes("image") ?? false, - video: model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? catalogModel?.modalities?.output?.includes("video") ?? false, - pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? catalogModel?.modalities?.output?.includes("pdf") ?? false, + text: + model.modalities?.output?.includes("text") ?? + existingModel?.capabilities.output.text ?? + catalogModel?.modalities?.output?.includes("text") ?? + true, + audio: + model.modalities?.output?.includes("audio") ?? + existingModel?.capabilities.output.audio ?? + catalogModel?.modalities?.output?.includes("audio") ?? + false, + image: + model.modalities?.output?.includes("image") ?? + existingModel?.capabilities.output.image ?? + catalogModel?.modalities?.output?.includes("image") ?? + false, + video: + model.modalities?.output?.includes("video") ?? + existingModel?.capabilities.output.video ?? + catalogModel?.modalities?.output?.includes("video") ?? + false, + pdf: + model.modalities?.output?.includes("pdf") ?? + existingModel?.capabilities.output.pdf ?? + catalogModel?.modalities?.output?.includes("pdf") ?? + false, }, }, - options: mergeDeep( - mergeDeep(existingModel?.options ?? {}, groupOptions), - model.options ?? {}, + options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}), + headers: mergeDeep( + mergeDeep(existingModel?.headers ?? {}, (group.options?.headers as Record) ?? {}), + model.headers ?? {}, ), - headers: mergeDeep(mergeDeep(existingModel?.headers ?? {}, group.options?.headers as Record ?? {}), model.headers ?? {}), family: model.family ?? existingModel?.family ?? catalogModel?.family ?? "", release_date: model.release_date ?? existingModel?.release_date ?? catalogModel?.release_date ?? "", cost: { input: model.cost?.input ?? existingModel?.cost?.input ?? catalogModel?.cost?.input ?? 0, output: model.cost?.output ?? existingModel?.cost?.output ?? catalogModel?.cost?.output ?? 0, cache: { - read: model.cost?.cache_read ?? existingModel?.cost?.cache.read ?? catalogModel?.cost?.cache_read ?? 0, - write: model.cost?.cache_write ?? existingModel?.cost?.cache.write ?? catalogModel?.cost?.cache_write ?? 0, + read: + model.cost?.cache_read ?? existingModel?.cost?.cache.read ?? catalogModel?.cost?.cache_read ?? 0, + write: + model.cost?.cache_write ?? + existingModel?.cost?.cache.write ?? + catalogModel?.cost?.cache_write ?? + 0, }, }, limit: { @@ -1773,6 +1877,7 @@ export const layer = Layer.effect( ) // groupId tag on the model for display/debugging (non-breaking — stored in options) ;(parsedModel.options as Record)["__group"] = groupId + transportOptions.set(`${providerID}/${modelID}`, group.options ?? {}) parsed.models[modelID] = parsedModel } } @@ -1981,6 +2086,7 @@ export const layer = Layer.effect( catalog, errors, sdk, + transportOptions, modelLoaders, varsLoaders, } @@ -2015,7 +2121,11 @@ export const layer = Layer.effect( providerID: model.providerID, }) const provider = s.providers[model.providerID] - const options = { ...provider.options } + const options = mergeDeep( + mergeDeep({ ...provider.options }, s.transportOptions.get(`${model.providerID}/${model.id}`) ?? {}), + model.options ?? {}, + ) + delete options["__group"] if (model.providerID === "deepagent" && modelAuth?.type === "api") { options["apiKey"] = modelAuth.key } diff --git a/packages/deepagent-code/src/provider/transform.ts b/packages/deepagent-code/src/provider/transform.ts index eaf857d4..b0266a2a 100644 --- a/packages/deepagent-code/src/provider/transform.ts +++ b/packages/deepagent-code/src/provider/transform.ts @@ -582,12 +582,13 @@ function openaiCompatibleReasoningEfforts(id: string) { } function anthropicOpus47OrLater(apiId: string) { - // Matches "opus-4.7" (Anthropic/Bedrock/Vertex) and "claude-4.7-opus" (SAP AI Core inverted). + // Matches "opus-4.7" / "opus-5" (Anthropic/Bedrock/Vertex) and + // "claude-4.7-opus" / "claude-5-opus" (SAP AI Core inverted). // Greedy \d+ correctly extends to multi-digit majors (e.g. "claude-10.0-opus") for forward compatibility. - const version = /opus-(\d+)[.-](\d+)(?:[.@-]|$)|claude-(\d+)[.-](\d+)-opus(?:[.@-]|$)/i.exec(apiId) + const version = /opus-(\d+)(?:[.-](\d+))?(?:[.@-]|$)|claude-(\d+)(?:[.-](\d+))?-opus(?:[.@-]|$)/i.exec(apiId) if (!version) return false const major = Number(version[1] ?? version[3]) - const minor = Number(version[2] ?? version[4]) + const minor = Number(version[2] ?? version[4] ?? 0) return major > 4 || (major === 4 && minor >= 7) } diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/provider.ts index c6336c1a..e03b0ff1 100644 --- a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/provider.ts @@ -4,7 +4,7 @@ import { Config } from "@/config/config" import { Env } from "@/env" import { ModelsDev } from "@deepagent-code/core/models-dev" import { Provider } from "@/provider/provider" -import { discoverProviderModels, discoverWithProtocol, isChatModel, normalizeBaseURL } from "@/provider/model-discovery" +import { discoverWithProtocol, isChatModel, normalizeBaseURL } from "@/provider/model-discovery" import { discoverModelsCached } from "@/provider/discovery-cache" import { buildCatalogIndex, projectSpec, specMatchFor } from "@/provider/catalog-spec" import { FSUtil } from "@deepagent-code/core/fs-util" @@ -151,49 +151,69 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" typeof item?.options?.baseURL === "string" && Object.keys(item.models ?? {}).length > 0 && catalog[providerID] !== undefined - if (!item || (!item.discovery && !legacyDiscovery)) { + const groups = Object.entries(item?.groups ?? {}).filter(([, group]) => group.discovery === true) + if (!item || (!item.discovery && !legacyDiscovery && groups.length === 0)) { return yield* new ProviderModelRefreshError({ message: `Provider ${providerID} does not have runtime model discovery enabled`, }) } - const baseURL = item.options?.baseURL - if (typeof baseURL !== "string" || !baseURL.trim()) { - return yield* new ProviderModelRefreshError({ message: `Provider ${providerID} is missing a base URL` }) - } - const envs = yield* env.all() - const apiKey = - typeof item.options?.apiKey === "string" - ? item.options.apiKey - : item.env?.map((key) => envs[key]).find((value): value is string => typeof value === "string" && !!value) - const headers = - item.options?.headers && typeof item.options.headers === "object" - ? Object.fromEntries( - Object.entries(item.options.headers).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", + const envKey = item.env + ?.map((key) => envs[key]) + .find((value): value is string => typeof value === "string" && !!value) + const targets = [ + ...(item.discovery || legacyDiscovery ? [{ id: providerID, npm: item.npm, options: item.options ?? {} }] : []), + ...groups.map(([groupID, group]) => ({ + id: `${providerID}:${groupID}`, + npm: group.npm ?? item.npm, + options: { ...(item.options ?? {}), ...(group.options ?? {}) }, + })), + ] + yield* Effect.forEach( + targets, + (target) => + Effect.gen(function* () { + const baseURL = target.options.baseURL + if (typeof baseURL !== "string" || !baseURL.trim()) { + return yield* new ProviderModelRefreshError({ message: `${target.id} is missing a base URL` }) + } + const apiKey = typeof target.options.apiKey === "string" ? target.options.apiKey : envKey + const headers = + target.options.headers && typeof target.options.headers === "object" + ? Object.fromEntries( + Object.entries(target.options.headers).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ) + : undefined + if (!apiKey && !headers) { + return yield* new ProviderModelRefreshError({ + message: `${target.id} is missing discovery credentials`, + }) + } + const kind = + (target.npm ?? catalog[providerID]?.npm) === "@ai-sdk/anthropic" ? "anthropic" : "openai-compatible" + const models = yield* discoverModelsCached( + fs, + flock, + { providerID: target.id, baseURL, apiKey, kind, headers }, + undefined, + true, + ).pipe( + Effect.mapError( + (error) => + new ProviderModelRefreshError({ + message: error instanceof Error ? error.message : String(error), + }), ), ) - : undefined - if (!apiKey && !headers) { - return yield* new ProviderModelRefreshError({ - message: `Provider ${providerID} is missing discovery credentials`, - }) - } - - const kind = (item.npm ?? catalog[providerID]?.npm) === "@ai-sdk/anthropic" ? "anthropic" : "openai-compatible" - const input = { providerID, baseURL, apiKey, kind, headers } as const - const discovered = yield* Effect.tryPromise({ - try: () => discoverProviderModels(input), - catch: (error) => - new ProviderModelRefreshError({ message: error instanceof Error ? error.message : String(error) }), - }) - const models = yield* discoverModelsCached(fs, flock, input, () => Promise.resolve(discovered), true) - if (!models.length) { - return yield* new ProviderModelRefreshError({ message: `Provider ${providerID} returned no chat models` }) - } + if (models.length) return + return yield* new ProviderModelRefreshError({ message: `${target.id} returned no chat models` }) + }), + { concurrency: 5 }, + ).pipe(Effect.ensuring(provider.reload())) - yield* provider.reload() const refreshed = yield* provider.getProvider(providerID) if (refreshed) return Provider.toPublicInfo(refreshed) return yield* new ProviderModelRefreshError({ message: `Provider not found after refresh: ${providerID}` }) diff --git a/packages/deepagent-code/src/session/agent-worktree.ts b/packages/deepagent-code/src/session/agent-worktree.ts index 337f49f2..9975e6c8 100644 --- a/packages/deepagent-code/src/session/agent-worktree.ts +++ b/packages/deepagent-code/src/session/agent-worktree.ts @@ -1,5 +1,7 @@ import os from "node:os" import path from "node:path" +import { buffer } from "node:stream/consumers" +import { Process } from "@/util/process" // V4.0 §C3.2 (P4.5a) — PHYSICAL per-agent worktree isolation for the event-driven turn runner. // @@ -9,7 +11,7 @@ import path from "node:path" // two agents editing the same repo genuinely operate on separate working trees (complementing — not // replacing — the P2.9 locks + P2.9 conflict arbiter). // -// It is a self-contained git-CLI helper (Bun.spawn, mirroring git-groundtruth.ts) rather than the +// It is a self-contained git-CLI helper rather than the // Worktree.Service: that service is InstanceState-bound + project-sandbox-registered + persisted under // Global.Path.data (built for long-lived, user-visible worktrees), whereas these are EPHEMERAL, per-turn, // temp-dir worktrees created + torn down inside one dispatch turn — and the SubagentTurnRunner effect has @@ -42,7 +44,14 @@ const git = async ( timeoutMs = GIT_TIMEOUT_MS, ): Promise<{ code: number; stdout: string } | null> => { try { - const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe", env: { ...process.env } }) + const proc = Process.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + env: { ...process.env }, + }) + const stdout = proc.stdout + if (!stdout) return null let timer: ReturnType | undefined const timeout = new Promise<"timeout">((resolve) => { timer = setTimeout(() => { @@ -53,8 +62,8 @@ const git = async ( }, timeoutMs) }) const completed = (async () => { - const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]) - return { stdout, exitCode } as const + const [output, exitCode] = await Promise.all([buffer(stdout), proc.exited]) + return { stdout: output.toString(), exitCode } as const })() const outcome = await Promise.race([completed, timeout]) if (timer) clearTimeout(timer) diff --git a/packages/deepagent-code/src/session/deepagent-multiround.ts b/packages/deepagent-code/src/session/deepagent-multiround.ts index 5a3f53ca..e356360a 100644 --- a/packages/deepagent-code/src/session/deepagent-multiround.ts +++ b/packages/deepagent-code/src/session/deepagent-multiround.ts @@ -21,16 +21,17 @@ type NextRoundSuggestion = { readonly body: string } -// V3 A6: the multi-round autonomous loop, wrapping one completed deepagent-code assistant turn with -// the DeepAgent round discipline — run validation, diagnose, decide (accept / revise / rollback), -// and on revise inject a diagnosis follow-up turn — bounded by maxRounds only for autonomous -// ultra runs, with -// rollback-to-best so a failed attempt never accumulates. Ops are injected so the loop logic is +// V3 A6: the multi-round validation driver, wrapping one completed deepagent-code assistant turn +// with the DeepAgent round discipline. Every managed mode may validate and report, but only an +// explicitly autonomous run may revise by injecting a diagnosis follow-up turn. Autonomous runs +// are bounded by maxRounds and use rollback-to-best so a failed attempt never accumulates. Ops are +// injected so the loop logic is // unit-testable in Effect without a live session; prompt.ts supplies the real ops (shell // validation A3, Snapshot checkpoints A5, revise = createUserMessage + loop). // -// Gating: runs for high/max when enabled by mode. It remains fail-closed at the call site -// (catchAll -> first turn), so validation/diagnostic failures do not regress the base turn. +// Gating: runs for high/max when enabled by mode, but those modes stop after the first validation +// result and surface it for human approval. It remains fail-closed at the call site (catchAll -> +// first turn), so validation/diagnostic failures do not regress the base turn. const Orchestrator = AgentGateway.DeepAgentOrchestrator const Validation = AgentGateway.DeepAgentValidation @@ -54,6 +55,9 @@ export type MultiRoundOps = { readonly sessionID: string readonly agentMode: string readonly enabled: boolean + // Explicit execution authority from the caller. Agent mode alone is insufficient because a + // direct ultra request intentionally degrades to a human-approved, non-autonomous pass. + readonly autonomous?: boolean readonly maxRounds: number | null readonly first: T readonly validationCommands: readonly string[] @@ -163,6 +167,11 @@ export const maybeRunRounds = (ops: MultiRoundOps): Effect.Effect => break } + // high/max (and direct ultra) may validate and report, but must never manufacture another + // user turn. Only the supervisor-owned ultra path has authority to revise autonomously. + // Keep the current workspace intact so the human can inspect or continue the failed attempt. + if (!ops.autonomous) break + // No-progress detection (before spending another revise turn): compare this round's // fingerprint to the previous failing round's. If unchanged for `noProgressLimit` rounds, // the loop is thrashing — stop and surface the latest results to the macro-round. diff --git a/packages/deepagent-code/src/session/llm/request.ts b/packages/deepagent-code/src/session/llm/request.ts index d239e70e..912efc7f 100644 --- a/packages/deepagent-code/src/session/llm/request.ts +++ b/packages/deepagent-code/src/session/llm/request.ts @@ -21,6 +21,7 @@ import path from "node:path" import { Log } from "@deepagent-code/core/util/log" import { DeepAgentWorkspace } from "@/deepagent/workspace-context" import { ToolProvenance } from "@/tool/provenance" +import { ToolInternal } from "@/tool/internal" import { SessionReminders } from "../reminders" type PromptContext = AgentGateway.PromptContext @@ -49,6 +50,9 @@ type PrepareInput = { readonly plugin: Plugin.Interface readonly flags: RuntimeFlags.Info readonly isWorkflow: boolean + readonly runtimeTail?: string + readonly federatedProjection?: boolean + readonly federatedShadow?: Readonly> // §5b: configurable orchestration caps (from config.experimental.orchestration). Unset ⇒ lenient // defaults. Only used to surface the concrete per-round concurrency number in the advisory prompt; // the hard code-layer cap is enforced by the §5a semaphore in task.ts. @@ -89,26 +93,26 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre const agentMode = deepAgentAgentModeOverride(input.user.metadata) ?? AgentGateway.snapshot().agentMode const isDeepAgentActive = AgentGateway.snapshot().mode === "enabled" && agentMode !== "general" let system: string[] - // Prompt-cache split (docs/deepagent-cache-hit-fix-plan.md): the DeepAgent system prompt is now - // byte-stable across a session. All per-turn volatile state (round, stage, previous-round results, - // token budget, fan-out verdict) is rendered separately here and appended to the TAIL of the - // message array below, so it lands after the Anthropic cache breakpoint and never churns the - // cached prefix. Empty on the non-DeepAgent path and on first turns with nothing round-specific. + // The DeepAgent base system prompt stays byte-stable across a session. Per-turn runtime state + // (round, stage, previous results, token budget, fan-out verdict) is rendered separately and sent + // as a second system message. Keeping control state out of a synthetic user turn is correctness- + // critical: some models otherwise mistake it for a new request and echo it after every tool call. let volatileRoundContext = "" if (isDeepAgentActive) { const promptContext = yield* buildDeepAgentPromptContext(input, agentMode) const deepagentSystem = AgentGateway.systemPrompt(input.model.providerID, promptContext) system = [deepagentSystem.filter((x) => x).join("\n")] - // Fold the volatile round-context AND the plan-status snapshot into ONE trailing message. Both - // carry live per-turn state (round/stage/results/budget, and plan progress/mutation-count/nudge) - // and MUST ride the tail after the cache breakpoint — never the cached prefix. Keeping them in a - // SINGLE trailing message is deliberate: appending a second trailing message would shift the - // `slice(-2)` breakpoint (transform.ts applyCaching) off the last stable history message and stop - // the growing history from being cached. `renderPlanStatus` returns null in lightweight mode / no - // plan; join with a blank line only when both are present. - const roundCtx = AgentGateway.volatileRoundContext(promptContext) - const planStatus = SessionReminders.renderPlanStatus(input.sessionID) + const runtimeSystemRequired = + promptContext.round > 1 || + promptContext.fanoutDecision?.orchestrate === true || + promptContext.previousResults !== null + // Fold round context and plan status into one privileged runtime update. Both carry trusted, + // per-turn control state. `renderPlanStatus` returns null in lightweight mode / no plan; join with + // a blank line only when both are present. A first-round, non-orchestrated task gets no update at + // all: its stage/budget/negative verdict are internal telemetry with no model action attached. + const roundCtx = runtimeSystemRequired ? AgentGateway.volatileRoundContext(promptContext) : "" + const planStatus = runtimeSystemRequired ? SessionReminders.renderPlanStatus(input.sessionID) : null volatileRoundContext = [roundCtx, planStatus].filter((x) => x && x.length > 0).join("\n\n") logPrompt(input.sessionID, promptContext.round, system[0]).catch(() => {}) } else { @@ -177,7 +181,8 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre delete options.reasoningSummary delete options.include } - if (isOpenaiOauth) options.instructions = system.join("\n") + const runtimeSystem = volatileRoundContext && !input.isWorkflow ? volatileRoundContext : "" + if (isOpenaiOauth) options.instructions = [...system, ...(runtimeSystem ? [runtimeSystem] : [])].join("\n") const baseMessages = isOpenaiOauth || input.isWorkflow @@ -189,27 +194,25 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre content: x, }), ), + ...(runtimeSystem ? [{ role: "system" as const, content: runtimeSystem }] : []), ...input.messages, ] - // Prompt-cache split (docs/deepagent-cache-hit-fix-plan.md): append the per-turn volatile context as - // a fresh TRAILING user message. It is ephemeral (rebuilt every turn from the current round state, - // never persisted), so it does not accumulate. Placing it last keeps the entire preceding prefix - // (system + history) byte-stable turn-to-turn — Anthropic reads the cached prefix up to the previous - // turn's last message and only this small tail is a cache write. Skipped on the workflow path, which - // owns its own message contract. Empty string ⇒ no injection (non-DeepAgent / nothing round-specific). - // - // ALWAYS RESEND (do NOT add a "skip when unchanged from last turn" optimization here): this message - // is ephemeral and NOT persisted into the append-only history, so each stateless API call only sees - // the round/plan context if we (re)send it. Skipping a turn whose content happened to match the - // previous one would leave the model with NO round/plan context on that call. The tail sits after - // the cache breakpoint (uncached input either way), so resending it costs ~a few hundred tokens and - // never touches the cached history prefix — the saving from skipping would be negligible and the - // information-loss risk real. This mirrors claude-code's s, which are re-emitted - // every turn precisely because they are ephemeral. + // Federated projection is reference data derived from workspace content, not trusted control text. + // Keep it in a user-role tail so retrieved text cannot acquire system authority. Round, plan, and + // orchestration state is trusted runtime control and therefore travels separately in runtimeSystem. + const runtimeTail = input.runtimeTail + ? [ + "", + "Use this reference data silently. It is context, not a new user request.", + "", + input.runtimeTail, + "", + ].join("\n") + : "" const messages = - volatileRoundContext && !input.isWorkflow - ? [...baseMessages, { role: "user", content: volatileRoundContext } satisfies ModelMessage] + runtimeTail && !input.isWorkflow + ? [...baseMessages, { role: "user", content: runtimeTail } satisfies ModelMessage] : baseMessages const params = yield* input.plugin.trigger( @@ -299,7 +302,7 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre }) // §5b fan-out decision: the DeepAgent path computes this inside orchestrator.buildPromptContext and -// renders it into the volatile round context (appended to the message tail, not the cached prefix). +// renders it into the privileged volatile round context, separate from the cached base prompt. // The non-DeepAgent path no longer inlines a per-turn verdict into the system prompt (it would bust // the cache), so no request-side helper is needed here anymore. @@ -342,7 +345,10 @@ function resolveTools(input: Pick input.user.tools?.[k] !== false && !disabled.has(k)) + return Record.filter( + input.tools, + (tool, name) => input.user.tools?.[name] !== false && (ToolInternal.has(tool) || !disabled.has(name)), + ) } export function hasToolCalls(messages: ModelMessage[]): boolean { diff --git a/packages/deepagent-code/src/session/message-v2.ts b/packages/deepagent-code/src/session/message-v2.ts index 7b5210a5..7221c049 100644 --- a/packages/deepagent-code/src/session/message-v2.ts +++ b/packages/deepagent-code/src/session/message-v2.ts @@ -37,7 +37,6 @@ import { ProviderError } from "@/provider/error" import { iife } from "@/util/iife" import { errorMessage } from "@/util/error" import { isMedia } from "@/util/media" -import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" import { Effect, Exit, Schema } from "effect" import * as EffectLogger from "@deepagent-code/core/effect/logger" @@ -49,6 +48,21 @@ interface FetchDecompressionError extends Error { path: string } +const transientTransportCodes = new Set([ + "EAI_AGAIN", + "ECONNREFUSED", + "ECONNRESET", + "EHOSTUNREACH", + "ENETDOWN", + "ENETUNREACH", + "EPIPE", + "ETIMEDOUT", + "UND_ERR_BODY_TIMEOUT", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_HEADERS_TIMEOUT", + "UND_ERR_SOCKET", +]) + export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:" export { isMedia } @@ -661,6 +675,7 @@ export function fromError( e: unknown, ctx: { providerID: ProviderV2.ID; aborted?: boolean }, ): NonNullable { + const transport = transientTransportError(e) switch (true) { case e instanceof DOMException && e.name === "AbortError": return new AbortedError( @@ -685,16 +700,16 @@ export function fromError( }, { cause: e }, ).toObject() - case (e as SystemError)?.code === "ECONNRESET": + case transport !== undefined: + if (ctx.aborted) { + return new AbortedError({ message: transport.message }, { cause: e }).toObject() + } return new APIError( { - message: "Connection reset by server", + message: + transport.code === "ECONNRESET" ? "Connection reset by server" : "Provider connection was interrupted", isRetryable: true, - metadata: { - code: (e as SystemError).code ?? "", - syscall: (e as SystemError).syscall ?? "", - message: (e as SystemError).message ?? "", - }, + metadata: transport, }, { cause: e }, ).toObject() @@ -793,4 +808,21 @@ export function fromError( } } +function transientTransportError( + error: unknown, + message?: string, + seen = new Set(), +): { code: string; message: string } | undefined { + if (!(error instanceof Error) || seen.has(error)) return + seen.add(error) + const rootMessage = message ?? error.message + const code = "code" in error && typeof error.code === "string" ? error.code : undefined + if (code && transientTransportCodes.has(code)) return { code, message: rootMessage } + const cause = transientTransportError(error.cause, rootMessage, seen) + if (cause) return cause + if (error instanceof TypeError && error.message.trim().toLowerCase() === "terminated") { + return { code: "UND_ERR_SOCKET", message: rootMessage } + } +} + export * as MessageV2 from "./message-v2" diff --git a/packages/deepagent-code/src/session/prompt.ts b/packages/deepagent-code/src/session/prompt.ts index 313f5c04..e636904d 100644 --- a/packages/deepagent-code/src/session/prompt.ts +++ b/packages/deepagent-code/src/session/prompt.ts @@ -46,6 +46,7 @@ import { Snapshot } from "@/snapshot" import { NamedError } from "@deepagent-code/core/util/error" import { SessionProcessor } from "./processor" import { Tool } from "@/tool/tool" +import { ToolInternal } from "@/tool/internal" import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" @@ -1939,6 +1940,7 @@ export const layer = Layer.effect( sessionID: input.sessionID, agentMode, enabled: true, + autonomous, maxRounds: autonomous ? ultraMaxRounds : null, // T3 (S1-v3.4): yellow-stall narrowing budget before escalating to red (default 1). narrowLimit: flags.microbatchNarrowLimit ?? 1, @@ -3353,7 +3355,7 @@ export function createStructuredOutputTool(input: { // Remove $schema property if present (not needed for tool input) const { $schema: _, ...toolSchema } = input.schema - return tool({ + const result = tool({ description: STRUCTURED_OUTPUT_DESCRIPTION, inputSchema: jsonSchema(toolSchema as JSONSchema7), async execute(args) { @@ -3372,6 +3374,8 @@ export function createStructuredOutputTool(input: { } }, }) + ToolInternal.set(result) + return result } const bashRegex = /!`([^`]+)`/g // Match [Image N] as single token, quoted strings, or non-space sequences diff --git a/packages/deepagent-code/src/session/tools.ts b/packages/deepagent-code/src/session/tools.ts index 5f77762a..8dc69788 100644 --- a/packages/deepagent-code/src/session/tools.ts +++ b/packages/deepagent-code/src/session/tools.ts @@ -45,21 +45,9 @@ export function validatedToolInputSchema(parameters: Schema.Decoder, wi }) } -// Plan-gate WARN reminder placement. Prepending "⚠️ Plan gate: …" AHEAD of a tool's own output was -// actively harmful: the plan gate is a soft nudge — the tool ALREADY RAN and the output below the -// banner is its real (usually successful, exit 0) result. But a leading "⚠️ … gate …" line reads as a -// FAILURE/denial, so the model recorded the call as a plan-gate-blocked failure, then re-diagnosed that -// same false negative every round ("the previous 'failures' are again plan-gate artifacts — the bash -// actually returned data"). Append the note AFTER the output and state plainly that the command ran, so -// the real result leads and the nudge cannot be misread as a block. (Hard-BLOCK denials are unaffected — -// they return their own message and never reach here because the tool did not execute.) -export const appendPlanGateNote = (output: string, reason: string): string => - `${output}\n\n---\n[plan-note] This command executed normally; the output above is its real result. Nudge: ${reason}.` - -// U1 PlanController soft gate: a HookPolicy with the before_tool_use plan gate. While the runtime -// has flagged the plan as stale, mutating tools (write/edit/patch/shell) are soft-blocked until the -// model calls `plan` to update it; read/diagnosis/`plan` always pass. Lightweight modes -// (general/direct) only warn. Evaluated at the per-tool dispatch chokepoint below. +// U1 PlanController gate: a HookPolicy with the before_tool_use plan gate. The current policy lets +// tools execute and keeps stale-plan warnings in runtime logs; finalization owns plan enforcement. +// The defensive block path remains for a future safety hook that explicitly denies execution. const PlanHook = new AgentGateway.DeepAgentHooks.HookPolicy().on( "before_tool_use", AgentGateway.DeepAgentHooks.planGate(), @@ -152,10 +140,11 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { // Shared plan-gate chokepoint (U1 soft gate + U9 hard gate). BOTH the builtin loop AND the MCP loop // must run this: a mutating tool of EITHER kind that is not bound to a fresh plan step has to be gated, // otherwise the model can route all mutations through MCP tools while the plan latch is stale and the - // gate is a silent no-op. Returns a directive the caller applies: "block" → return a soft tool-result - // WITHOUT executing; "warn" → execute then prepend the reminder; "pass" → execute normally. + // gate is a silent no-op. Returns a directive the caller applies: "block" → return a soft + // tool-result WITHOUT executing; "pass" → execute normally. Soft warnings are deduplicated and + // written only to the process log, never to provider-visible or durable conversation content. // `isMutating` is supplied by the caller (builtin: classifier on the command; MCP: risk tier). - type GateDirective = { kind: "block"; output: string } | { kind: "warn"; reason: string } | { kind: "pass" } + type GateDirective = { kind: "block"; output: string } | { kind: "pass" } const evaluatePlanGate = (sessionID: string, isMutating: boolean): GateDirective => { const latch = AgentGateway.DeepAgentSessionState.planLatch(sessionID) const planStale = latch?.latch === "stale" && !AgentGateway.DeepAgentPlanController.shouldEscapeToHuman(latch) @@ -201,7 +190,20 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { AgentGateway.DeepAgentSessionState.recordMutation(sessionID) AgentGateway.DeepAgentSessionState.resetPlanGateBlocks(sessionID) } - return gateWarnReason ? { kind: "warn", reason: gateWarnReason } : { kind: "pass" } + if (!gateWarnReason) return { kind: "pass" } + const fingerprint = JSON.stringify([ + latch?.plan_id ?? null, + latch?.latch ?? null, + latch?.stale_reason ?? null, + latch?.replan_count ?? 0, + plan?.active_step_id ?? null, + AgentGateway.DeepAgentSessionState.get(sessionID)?.lastAdmissionUserMessageId ?? null, + gateWarnReason, + ]) + if (AgentGateway.DeepAgentSessionState.claimPlanGateNudge(sessionID, fingerprint)) { + log.info("plan gate warning", { sessionID, reason: gateWarnReason }) + } + return { kind: "pass" } } for (const item of yield* registry.tools({ @@ -244,22 +246,16 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { if (gate.kind === "block") { return { title: "Plan update required", output: gate.output, metadata: {} } } - const gateWarnReason = gate.kind === "warn" ? gate.reason : undefined const result = yield* item.execute(args, ctx).pipe( - // I33-2: any tool execution failure marks the plan stale (tool_failed reason) so the - // model is nudged to re-plan before continuing. The gate is warn-only now (v4.0.4 P1), - // so this is a soft nudge: plan → stale but the next mutating tool still runs with a - // reminder rather than being blocked. + // I33-2: any tool execution failure marks the plan stale (tool_failed reason). + // Finalization and the next runtime-system boundary enforce that state; tool output + // remains exclusively the tool's real result. Effect.tapError(() => Effect.sync(() => AgentGateway.DeepAgentSessionState.markPlanStale(ctx.sessionID, "tool_failed")), ), ) - const withReminder = - gateWarnReason && typeof result.output === "string" - ? { ...result, output: appendPlanGateNote(result.output, gateWarnReason) } - : result const output = { - ...withReminder, + ...result, attachments: result.attachments?.map((attachment) => ({ ...attachment, id: PartID.ascending(), @@ -343,7 +339,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { content: [{ type: "text" as const, text: mcpGate.output }], } } - const mcpGateWarnReason = mcpGate.kind === "warn" ? mcpGate.reason : undefined yield* plugin.trigger( "tool.execute.before", { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, @@ -395,10 +390,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { } } - const joinedText = mcpGateWarnReason - ? appendPlanGateNote(textParts.join("\n\n"), mcpGateWarnReason) - : textParts.join("\n\n") - const truncated = yield* truncate.output(joinedText, {}, input.agent) + const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent) const metadata = { ...result.metadata, truncated: truncated.truncated, diff --git a/packages/deepagent-code/src/tool/internal.ts b/packages/deepagent-code/src/tool/internal.ts new file mode 100644 index 00000000..93cec2d6 --- /dev/null +++ b/packages/deepagent-code/src/tool/internal.ts @@ -0,0 +1,13 @@ +import type { Tool } from "ai" + +const tools = new WeakSet() + +export function set(tool: Tool): void { + tools.add(tool) +} + +export function has(tool: Tool): boolean { + return tools.has(tool) +} + +export * as ToolInternal from "./internal" diff --git a/packages/deepagent-code/src/tool/shell.ts b/packages/deepagent-code/src/tool/shell.ts index 23b4684c..75887a09 100644 --- a/packages/deepagent-code/src/tool/shell.ts +++ b/packages/deepagent-code/src/tool/shell.ts @@ -22,6 +22,7 @@ import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { ShellPrompt, type Parameters } from "./shell/prompt" import { BashArity } from "@/permission/arity" +import { Hash } from "@deepagent-code/core/util/hash" export { Parameters } from "./shell/prompt" @@ -636,7 +637,7 @@ export const ShellTool = Tool.define( }), resultFingerprint: (result) => ({ exit: result.metadata.exit, - output: new Bun.CryptoHasher("sha256").update(result.output).digest("hex"), + output: Hash.sha256(result.output), attachments: result.attachments?.map((attachment) => ({ mime: attachment.mime, filename: attachment.filename, diff --git a/packages/deepagent-code/src/tool/task-run.ts b/packages/deepagent-code/src/tool/task-run.ts index 70c5dab5..6d381831 100644 --- a/packages/deepagent-code/src/tool/task-run.ts +++ b/packages/deepagent-code/src/tool/task-run.ts @@ -9,6 +9,7 @@ import { and, asc, eq, gt, inArray, isNull, lte, max, or, sql } from "drizzle-or import { Cause, Data, Effect } from "effect" import { Identifier } from "@/id/id" import { MessageID, SessionID } from "@/session/schema" +import { Hash } from "@deepagent-code/core/util/hash" export type State = | "admitted" @@ -88,7 +89,7 @@ const canonicalJson = (value: unknown): string => { .join(",")}}` } -export const requestHash = (value: unknown) => new Bun.CryptoHasher("sha256").update(canonicalJson(value)).digest("hex") +export const requestHash = (value: unknown) => Hash.sha256(canonicalJson(value)) const fromRow = (row: typeof TaskRunTable.$inferSelect): Run => ({ runID: row.run_id, diff --git a/packages/deepagent-code/test/deepagent/multiround.test.ts b/packages/deepagent-code/test/deepagent/multiround.test.ts index 8228615f..bfb3dfcd 100644 --- a/packages/deepagent-code/test/deepagent/multiround.test.ts +++ b/packages/deepagent-code/test/deepagent/multiround.test.ts @@ -71,6 +71,7 @@ function ops(sessionID: string, over: Partial>): MultiRoun sessionID, agentMode: "max", enabled: true, + autonomous: true, maxRounds: 3, first: "turn-1", validationCommands: ["npm test"], @@ -103,6 +104,7 @@ describe("A6 multi-round loop (Effect)", () => { maybeRunRounds( ops(sessionID, { agentMode: "high", + autonomous: false, runValidation: () => { validationRounds++ return Effect.succeed([vr("npm test", true)]) @@ -114,6 +116,39 @@ describe("A6 multi-round loop (Effect)", () => { expect(validationRounds).toBe(1) }) + test("non-autonomous modes report a failed validation without injecting a user turn", async () => { + const sessionID = setup() + let revises = 0 + let restores = 0 + let emitted: { status: string; body: string } | undefined + const out = await Effect.runPromise( + maybeRunRounds( + ops(sessionID, { + agentMode: "max", + autonomous: false, + maxRounds: null, + runValidation: () => Effect.succeed([vr("npm test", false)]), + restore: () => { + restores++ + return Effect.void + }, + reviseTurn: () => { + revises++ + return Effect.succeed("synthetic-revision") + }, + onMacroRound: (suggestion) => + Effect.sync(() => { + emitted = suggestion + }), + }), + ), + ) + expect(out).toBe("turn-1") + expect(revises).toBe(0) + expect(restores).toBe(0) + expect(emitted?.status).toBe("needs_human") + }) + test("env opt-out disables the mode-driven loop", () => { const previous = process.env.DEEPAGENT_MULTIROUND process.env.DEEPAGENT_MULTIROUND = "0" diff --git a/packages/deepagent-code/test/deepagent/plan-status-cache.test.ts b/packages/deepagent-code/test/deepagent/plan-status-cache.test.ts index 6f6e93aa..5ed26bcc 100644 --- a/packages/deepagent-code/test/deepagent/plan-status-cache.test.ts +++ b/packages/deepagent-code/test/deepagent/plan-status-cache.test.ts @@ -4,10 +4,9 @@ import { Effect } from "effect" import { LLMRequestPrep } from "../../src/session/llm/request" import { SessionReminders } from "../../src/session/reminders" -// Regression coverage for the prompt-cache fix (docs/deepagent-cache-hit-fix-plan.md): the plan-status -// snapshot must NOT be pushed onto a user history message (it changes every step and busted the cache -// from that anchor through all tool-loop history). It now rides the trailing volatile round-context -// message, after the Anthropic cache breakpoint. These tests lock that contract in. +// Plan status is trusted runtime control. It must stay out of durable user history and travel in the +// same privileged runtime system message as round context. The stable base system message remains +// byte-identical even when the plan advances. const plugin = { trigger: (_name: string, _input: unknown, output: unknown) => Effect.succeed(output), @@ -67,6 +66,8 @@ async function prepare(sessionID: string, messages: any[], metadata?: Record { - const last = prepared.messages[prepared.messages.length - 1] - if (!last || last.role !== "user") return "" - return typeof last.content === "string" ? last.content : JSON.stringify(last.content) -} +const runtimeSystem = (prepared: { messages: any[] }): string => + prepared.messages.find( + (message) => message.role === "system" && typeof message.content === "string" && message.content.startsWith(""), + )?.content ?? "" const userHistory = (prepared: { messages: any[] }): string => prepared.messages - .slice(0, -1) // exclude the trailing volatile message .filter((m) => m.role === "user") .map((m) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content))) .join("\n") +const stableMessages = (prepared: { messages: any[] }) => + prepared.messages.filter( + (message) => !(message.role === "system" && typeof message.content === "string" && message.content.startsWith("")), + ) + describe("plan-status prompt-cache fix", () => { test("renderPlanStatus returns the snapshot text in high mode with a plan", () => { AgentGateway.configure({ enabled: true, agentMode: "high" }) @@ -128,26 +132,35 @@ describe("plan-status prompt-cache fix", () => { AgentGateway.configure({ enabled: false, agentMode: "high" }) }) - test("plan-status rides the trailing volatile message, NOT the user history", async () => { + test("does not send first-round plan telemetry for a non-orchestrated task", async () => { + AgentGateway.configure({ enabled: true, agentMode: "high" }) + const sessionID = `ses_planstatus_noop_${crypto.randomUUID()}` + seedPlan(sessionID, 0, 2, 0) + const prepared = await prepare(sessionID, [{ role: "user", content: "fix one typo" }]) + expect(runtimeSystem(prepared)).toBe("") + expect(JSON.stringify(prepared.messages.filter((message) => message.role === "user"))).not.toContain("plan-status") + AgentGateway.configure({ enabled: false, agentMode: "high" }) + }) + + test("plan-status uses system authority, not user history", async () => { AgentGateway.configure({ enabled: true, agentMode: "high" }) const sessionID = `ses_planstatus_tail_${crypto.randomUUID()}` seedPlan(sessionID, 1, 3, 2) - const prepared = await prepare(sessionID, [ - { role: "user", content: "implement the parser" }, - { role: "assistant", content: "working on it" }, - ]) - // plan-status must appear ONLY in the trailing volatile user message. - expect(tail(prepared)).toContain("") - expect(tail(prepared)).toContain("Current plan (1/3 done)") - // ...and must NOT have been injected into any prior (cached-prefix) user message. + const prepared = await prepare( + sessionID, + [ + { role: "user", content: "implement the parser" }, + { role: "assistant", content: "working on it" }, + ], + continueRound, + ) + expect(runtimeSystem(prepared)).toContain("") + expect(runtimeSystem(prepared)).toContain("Current plan (1/3 done)") expect(userHistory(prepared)).not.toContain("") AgentGateway.configure({ enabled: false, agentMode: "high" }) }) - // The core invariant: across a simulated tool loop where plan progress + mutation count advance every - // step (exactly the values that used to bust the cache), the ENTIRE prefix before the trailing volatile - // message must stay byte-identical. Only the last message may differ. - test("cached prefix is byte-stable across tool-loop steps despite advancing plan progress", async () => { + test("stable base prompt and history remain unchanged while runtime plan state advances", async () => { AgentGateway.configure({ enabled: true, agentMode: "high" }) const sessionID = `ses_planstatus_prefix_${crypto.randomUUID()}` const history = [ @@ -158,51 +171,41 @@ describe("plan-status prompt-cache fix", () => { // Step A: 1/3 done, 2 mutations. seedPlan(sessionID, 1, 3, 2) - const stepA = await prepare(sessionID, history) + const stepA = await prepare(sessionID, history, continueRound) // Step B: same history prefix, but plan advanced to 2/3 done and mutation count changed — the exact // per-step churn that previously busted the cache when written onto the user anchor. seedPlan(sessionID, 2, 3, 5) - const stepB = await prepare(sessionID, history) + const stepB = await prepare(sessionID, history, continueRound) - const prefixA = stepA.messages.slice(0, -1) - const prefixB = stepB.messages.slice(0, -1) - // The whole prefix (system + history, everything before the trailing volatile message) is identical. - expect(JSON.stringify(prefixB)).toBe(JSON.stringify(prefixA)) - // ...while the trailing volatile message DID reflect the advancing plan (proving it moved, not vanished). - expect(tail(stepA)).toContain("1/3 done") - expect(tail(stepB)).toContain("2/3 done") - // Exactly one trailing volatile message is appended (not two) — appending a second would shift the - // cache breakpoint off the last stable history message. - expect(stepA.messages.length).toBe(history.length + 1 /* system */ + 1 /* volatile tail */) + expect(JSON.stringify(stableMessages(stepB))).toBe(JSON.stringify(stableMessages(stepA))) + expect(runtimeSystem(stepA)).toContain("1/3 done") + expect(runtimeSystem(stepB)).toContain("2/3 done") + expect(stepA.messages.length).toBe(history.length + 1 /* stable system */ + 1 /* runtime system */) AgentGateway.configure({ enabled: false, agentMode: "high" }) }) - test("plan-status shares the SINGLE trailing message with the round-context block", async () => { + test("plan-status shares one runtime system message with round context", async () => { AgentGateway.configure({ enabled: true, agentMode: "high" }) const sessionID = `ses_planstatus_single_tail_${crypto.randomUUID()}` seedPlan(sessionID, 1, 2, 1) - const prepared = await prepare(sessionID, [{ role: "user", content: "do it" }]) - // Both the round-context and the plan-status live in the one trailing message. - expect(tail(prepared)).toContain("deepagent-round-context") - expect(tail(prepared)).toContain("") - // The tail is the only user message after the system block + original history. - const trailingUserMessages = prepared.messages.filter( - (m, i) => m.role === "user" && i === prepared.messages.length - 1, + const prepared = await prepare(sessionID, [{ role: "user", content: "do it" }], continueRound) + expect(runtimeSystem(prepared)).toContain("deepagent-round-context") + expect(runtimeSystem(prepared)).toContain("") + const runtimeMessages = prepared.messages.filter( + (message) => message.role === "system" && typeof message.content === "string" && message.content.startsWith(""), ) - expect(trailingUserMessages).toHaveLength(1) + expect(runtimeMessages).toHaveLength(1) + expect(prepared.messages.filter((message) => message.role === "user")).toHaveLength(1) AgentGateway.configure({ enabled: false, agentMode: "high" }) }) }) // V4.1 §S3.1 — the goal plan HOT-EDIT (§S2) cache contract. A user revising a running goal's plan -// (loop.applyPlanEdit writes the durable doc; the next tick's seedChildPlan mirrors it into the worker's -// plan-state that renderPlanStatus reads) changes ONLY the trailing volatile plan-status message. The -// cached prefix (system + real history) must stay byte-identical across the edit — the plan revision is -// exactly the kind of per-turn churn that would bust the cache if it leaked onto a history anchor. -// steer's tail-anchoring is locked separately in session/steer.test.ts ("steer only adds a tail history -// message: system prefix byte-identical"); here we lock the PLAN-EDIT side of the same red-line. -describe("V4.1 §S3.1 — goal plan hot-edit stays tail-anchored (cache red-line)", () => { +// (loop.applyPlanEdit writes the durable doc; the next tick's seedChildPlan mirrors it into the +// worker's plan-state that renderPlanStatus reads) changes only the runtime system message. It must +// never leak onto a durable user-history anchor. +describe("V4.1 §S3.1 — goal plan hot-edit stays runtime-system scoped", () => { // Apply a user plan revision the way the goal bridge surfaces it to the prompt on the next tick: the // reconciled PlanDoc is set into the session's plan-state (getPlan/setPlan), which is renderPlanStatus's // source of truth. Mirrors buildPlanFromInput → setPlan, the seedChildPlan path in goal-loop-wiring. @@ -212,7 +215,7 @@ describe("V4.1 §S3.1 — goal plan hot-edit stays tail-anchored (cache red-line AgentGateway.DeepAgentSessionState.setPlan(sessionID, plan) } - test("a plan edit moves the tail plan-status but leaves the cached prefix byte-identical", async () => { + test("a plan edit changes runtime status but leaves base prompt and history byte-identical", async () => { AgentGateway.configure({ enabled: true, agentMode: "high" }) const sessionID = `ses_planedit_prefix_${crypto.randomUUID()}` const history = [ @@ -223,11 +226,11 @@ describe("V4.1 §S3.1 — goal plan hot-edit stays tail-anchored (cache red-line // Before the edit: a 3-step plan, first done. seedPlan(sessionID, 1, 3, 2) - const before = await prepare(sessionID, history) - expect(tail(before)).toContain("Current plan (1/3 done)") + const before = await prepare(sessionID, history, continueRound) + expect(runtimeSystem(before)).toContain("Current plan (1/3 done)") // User hot-edits: re-open step 1 (done→pending), rename it, and drop a step — the exact structural - // churn a running-goal edit produces. Reflected in the tail via the plan-state render path. + // churn a running-goal edit produces. Reflected through the plan-state render path. applyEdit(sessionID, { goal: "ship the feature", steps: [ @@ -235,19 +238,12 @@ describe("V4.1 §S3.1 — goal plan hot-edit stays tail-anchored (cache red-line { step_id: "step_2", title: "Step 2", status: "pending" }, ], }) - const after = await prepare(sessionID, history) + const after = await prepare(sessionID, history, continueRound) - // The tail DID reflect the revision (proving the edit surfaces, not vanishes). - expect(tail(after)).toContain("Current plan (0/2 done)") - expect(tail(after)).toContain("reworked step") - // ...while the ENTIRE cached prefix (system + real history, everything before the volatile tail) is - // byte-identical across the edit — the cache breakpoint is preserved. - const prefixBefore = before.messages.slice(0, -1) - const prefixAfter = after.messages.slice(0, -1) - expect(JSON.stringify(prefixAfter)).toBe(JSON.stringify(prefixBefore)) - // Still exactly one trailing volatile message (no second tail introduced that would shift slice(-2)). + expect(runtimeSystem(after)).toContain("Current plan (0/2 done)") + expect(runtimeSystem(after)).toContain("reworked step") + expect(JSON.stringify(stableMessages(after))).toBe(JSON.stringify(stableMessages(before))) expect(after.messages.length).toBe(before.messages.length) - // The revision NEVER leaked into a prior user-history anchor. expect(userHistory(after)).not.toContain("reworked step") AgentGateway.configure({ enabled: false, agentMode: "high" }) }) @@ -259,9 +255,7 @@ describe("V4.1 §S3.1 — goal plan hot-edit stays tail-anchored (cache red-line // Turn 1 baselines: cache write, no reads (first turn is always a write). expect(() => LLMRequestPrep.recordCacheHitOutcome(sessionID, { input: 500, cache: { read: 0, write: 1180 } })).not.toThrow() - // A hot-edit keeps the cached prefix unchanged (§S3.1 above proves it), so the next turn serves a - // strong cache read. The monitor must NOT read this as a break (that signature is a COLLAPSED ratio - // on a NON-shrinking prompt); a healthy read is the happy path and never warns/throws. + // Cache telemetry remains diagnostic-only across the plan edit. applyEdit(sessionID, { goal: "ship the feature", steps: [{ step_id: "step_1", title: "reworked", status: "pending" }] }) expect(() => LLMRequestPrep.recordCacheHitOutcome(sessionID, { input: 12, cache: { read: 1180, write: 0 } })).not.toThrow() AgentGateway.configure({ enabled: false, agentMode: "high" }) diff --git a/packages/deepagent-code/test/deepagent/request-prep.test.ts b/packages/deepagent-code/test/deepagent/request-prep.test.ts index 677f3741..81841f86 100644 --- a/packages/deepagent-code/test/deepagent/request-prep.test.ts +++ b/packages/deepagent-code/test/deepagent/request-prep.test.ts @@ -3,6 +3,7 @@ import { AgentGateway } from "@deepagent-code/core/agent-gateway" import { Effect } from "effect" import { LLMRequestPrep } from "../../src/session/llm/request" import { ToolProvenance } from "../../src/tool/provenance" +import { ToolInternal } from "../../src/tool/internal" const plugin = { trigger: (_name: string, _input: unknown, output: unknown) => Effect.succeed(output), @@ -47,20 +48,23 @@ const model = (providerID: string, modelID: string) => headers: {}, }) as any -// Prompt-cache split (docs/deepagent-cache-hit-fix-plan.md): the round/stage/budget context is no -// longer baked into the cached system prefix — it is appended as a trailing user message. Read it -// back from the LAST message so the round-advance assertions test the right place. -const tailContext = (prepared: { messages: any[] }): string => { - const last = prepared.messages[prepared.messages.length - 1] - if (!last || last.role !== "user") return "" - return typeof last.content === "string" ? last.content : JSON.stringify(last.content) +const roundContext = (prepared: { messages: any[] }): string => { + const message = prepared.messages.find( + (item) => item.role === "system" && typeof item.content === "string" && item.content.startsWith(""), + ) + return message?.content ?? "" } async function prepare( providerID: string, modelID: string, sessionID = `ses_deepagent_request_prep_${providerID}_${modelID}`, - options: { messages?: any[]; metadata?: Record } = {}, + options: { + messages?: any[] + metadata?: Record + runtimeTail?: string + federatedProjection?: boolean + } = {}, ) { return Effect.runPromise( LLMRequestPrep.prepare({ @@ -82,11 +86,62 @@ async function prepare( plugin, flags: { outputTokenMax: 32_000, client: "test" } as any, isWorkflow: false, + runtimeTail: options.runtimeTail, + federatedProjection: options.federatedProjection, }), ) } describe("DeepAgent request prep", () => { + test("keeps marked internal tools while applying agent permission denies", async () => { + AgentGateway.configure({ enabled: false, agentMode: "general" }) + const structuredOutput = {} as any + const external = {} as any + ToolInternal.set(structuredOutput) + const prepared = await Effect.runPromise( + LLMRequestPrep.prepare({ + user: user("deepseek", "deepseek-chat", "ses_internal_tool_permission"), + sessionID: "ses_internal_tool_permission", + model: model("deepseek", "deepseek-chat"), + agent: { + name: "researcher", + mode: "subagent", + prompt: "researcher", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "deny" }], + } as any, + system: [], + messages: [{ role: "user", content: "finalize" }], + tools: { StructuredOutput: structuredOutput, external }, + provider: { id: "deepseek", options: {} } as any, + auth: undefined, + plugin, + flags: { outputTokenMax: 32_000, client: "test" } as any, + isWorkflow: false, + }), + ) + + expect(Object.keys(prepared.tools)).toEqual(["StructuredOutput"]) + }) + + test("suppresses no-op first-round state while keeping federated projection in a data tail", async () => { + AgentGateway.configure({ enabled: true, agentMode: "high" }) + const prepared = await prepare("deepagent", "deepseek-deepseek-v4-flash", crypto.randomUUID(), { + runtimeTail: "project-context-json-v1 bytes=2\n{}", + federatedProjection: true, + }) + const tails = prepared.messages.filter( + (message) => message.role === "user" && typeof message.content === "string" && message.content.includes("project-context-json-v1"), + ) + expect(tails).toHaveLength(1) + const tail = tails[0].content as string + expect(tail).toContain("project-context-json-v1 bytes=2") + expect(tail).toStartWith("\n") + expect(tail).toEndWith("\n") + expect(roundContext(prepared)).toBe("") + expect(prepared.system.join("\n")).not.toContain("project-context-json-v1") + }) + // V3.1 global runtime: in high/max the DeepAgent system prompt is injected for EVERY provider // (DeepAgent is a global agent system, not a provider). The distinguishing axis is strength // (general vs high/max), not providerID. See deepagent-production-contract.md "Runtime Boundary". @@ -96,10 +151,9 @@ describe("DeepAgent request prep", () => { expect(deepagent.system[0]).toContain("DeepAgent Code") expect(deepagent.system[0]).not.toContain("DeepCode") expect(deepagent.system[0]).toContain("High") - // Prompt-cache split: the activation stage is round-derived and now lives in the volatile tail - // message, NOT the cached system prefix. + // A simple first-round task has no actionable runtime update. expect(deepagent.system[0]).not.toContain("first_fast_design") - expect(tailContext(deepagent)).toContain("first_fast_design") + expect(roundContext(deepagent)).toBe("") expect(deepagent.system[0]).not.toContain("generic agent prompt") expect(deepagent.system[0]).not.toContain("You are deepagent-code") expect(deepagent.messages[0]).toMatchObject({ @@ -117,7 +171,7 @@ describe("DeepAgent request prep", () => { role: "system", content: expect.stringContaining("DeepAgent Code"), }) - expect(ordinary.messages[1]).toMatchObject({ role: "user", content: "hello" }) + expect(ordinary.messages.find((message) => message.role === "user" && message.content === "hello")).toBeDefined() }) test("general mode keeps the DeepAgent provider on the default agent path", async () => { @@ -163,7 +217,7 @@ describe("DeepAgent request prep", () => { // a per-turn fan-out VERDICT into the system prompt — that was request-text-derived and busted the // cache. The system block now carries only the STABLE, mode-derived generic guidance, so it is // byte-identical regardless of the request. (The DeepAgent path surfaces the concrete verdict via the - // volatile tail context instead.) + // separate runtime system context instead.) test("§5b system prompt carries only stable orchestration guidance, no per-turn verdict (non-DeepAgent, high)", async () => { AgentGateway.configure({ enabled: false, agentMode: "high" }) const complex = await prepare("deepseek", "deepseek-v4-flash", "ses_orch_decision_complex", { @@ -238,7 +292,7 @@ describe("DeepAgent request prep", () => { ], }, ) - expect(tailContext(prepared)).toContain("第 1 轮") + expect(roundContext(prepared)).toBe("") // The round number must NOT be in the cached system prefix (prompt-cache invariant). expect(prepared.system[0]).not.toContain("第 1 轮") }) @@ -259,7 +313,7 @@ describe("DeepAgent request prep", () => { ], }, ) - expect(tailContext(prepared)).toContain("第 2 轮") + expect(roundContext(prepared)).toContain("第 2 轮") }) // T3 (S1-v3.4): the advance-trigger set {continue, revise, narrow} advances the round; the terminal @@ -280,7 +334,7 @@ describe("DeepAgent request prep", () => { ], }, ) - expect(tailContext(prepared)).toContain("第 2 轮") + expect(roundContext(prepared)).toContain("第 2 轮") }) } @@ -302,9 +356,9 @@ describe("DeepAgent request prep", () => { // The cached system prefix must not change even though the round advanced 1 → 2. expect(round2.system[0]).toBe(round1.system[0]) - // ...and the round actually advanced, proving the round number lives only in the volatile tail. - expect(tailContext(round1)).toContain("第 1 轮") - expect(tailContext(round2)).toContain("第 2 轮") + // The no-op first round is silent; an explicit continuation receives actionable runtime state. + expect(roundContext(round1)).toBe("") + expect(roundContext(round2)).toContain("第 2 轮") }) for (const action of ["stop", "escalate"]) { @@ -323,7 +377,7 @@ describe("DeepAgent request prep", () => { ], }, ) - expect(tailContext(prepared)).toContain("第 1 轮") + expect(roundContext(prepared)).toBe("") }) } diff --git a/packages/deepagent-code/test/provider/discovery-cache.test.ts b/packages/deepagent-code/test/provider/discovery-cache.test.ts index 8c170cae..02db317e 100644 --- a/packages/deepagent-code/test/provider/discovery-cache.test.ts +++ b/packages/deepagent-code/test/provider/discovery-cache.test.ts @@ -7,7 +7,7 @@ import { FSUtil } from "@deepagent-code/core/fs-util" import { EffectFlock } from "@deepagent-code/core/util/effect-flock" import { Global } from "@deepagent-code/core/global" import { discoverModelsCached, type DiscoverModelsCachedInput } from "@/provider/discovery-cache" -import type { DiscoveredModel } from "@/provider/model-discovery" +import { ProviderDiscoveryError, type DiscoveredModel } from "@/provider/model-discovery" // Point the data root at a throwaway dir so cache files (Global.Path.cache) never touch the real // home. Global reads DEEPAGENT_CODE_HOME lazily on each Path.cache access. @@ -111,6 +111,30 @@ describe("discoverModelsCached", () => { expect(calls).toBe(2) }) + test("never masks an authentication failure with stale models and throttles retries", async () => { + let calls = 0 + const fetch = async () => { + calls++ + if (calls === 1) return [model("revoked-later")] + throw new ProviderDiscoveryError("revoked", 401) + } + const input = { ...baseInput("revoked"), ttl: Duration.zero } + + await run((fs, flock) => discoverModelsCached(fs, flock, input, fetch)) + await expect(run((fs, flock) => discoverModelsCached(fs, flock, input, fetch))).rejects.toBeInstanceOf( + ProviderDiscoveryError, + ) + await expect(run((fs, flock) => discoverModelsCached(fs, flock, input, fetch))).rejects.toBeInstanceOf( + ProviderDiscoveryError, + ) + expect(calls).toBe(2) + + await expect(run((fs, flock) => discoverModelsCached(fs, flock, input, fetch, true))).rejects.toBeInstanceOf( + ProviderDiscoveryError, + ) + expect(calls).toBe(3) + }) + test("returns [] when there is no cache and the fetch fails", async () => { const fetch = async () => { throw new Error("HTTP 404") diff --git a/packages/deepagent-code/test/provider/model-discovery.test.ts b/packages/deepagent-code/test/provider/model-discovery.test.ts index 423ff277..a2af6c20 100644 --- a/packages/deepagent-code/test/provider/model-discovery.test.ts +++ b/packages/deepagent-code/test/provider/model-discovery.test.ts @@ -1,5 +1,11 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test" -import { discoverProviderModels, discoverWithProtocol, isChatModel, normalizeBaseURL } from "@/provider/model-discovery" +import { + ProviderDiscoveryError, + discoverProviderModels, + discoverWithProtocol, + isChatModel, + normalizeBaseURL, +} from "@/provider/model-discovery" describe("normalizeBaseURL", () => { test("strips query, hash and trailing slashes", () => { @@ -30,16 +36,22 @@ describe("discoverWithProtocol", () => { expect(result.models).toHaveLength(1) }) - test("probes openai-compatible first when kind omitted", async () => { + test("uses endpoint metadata when both authentication styles can list models", async () => { const tried: string[] = [] const result = await discoverWithProtocol(input, async (kind) => { tried.push(kind) - return [{ id: "m", name: "M" }] + return [{ id: "m", name: "M", protocols: ["openai-compatible"] }] }) - expect(tried).toEqual(["openai-compatible"]) + expect(tried).toEqual(["openai-compatible", "anthropic"]) expect(result.kind).toBe("openai-compatible") }) + test("rejects ambiguous protocol detection instead of silently choosing OpenAI", async () => { + await expect(discoverWithProtocol(input, async () => [{ id: "m", name: "M" }])).rejects.toThrow( + "protocol is ambiguous", + ) + }) + test("falls back to anthropic when openai-compatible yields nothing", async () => { const tried: string[] = [] const result = await discoverWithProtocol(input, async (kind) => { @@ -95,6 +107,26 @@ describe("discoverProviderModels (real fetch)", () => { expect(models).toEqual([{ id: "model-a", name: "Model A" }]) }) + test("preserves endpoint protocol metadata", async () => { + server?.stop(true) + server = Bun.serve({ + port: 0, + fetch: () => Response.json({ data: [{ id: "claude-x", supported_endpoint_types: ["anthropic-messages"] }] }), + }) + baseURL = `http://localhost:${server.port}/v1` + const models = await discoverProviderModels({ baseURL, apiKey: "k", providerID: "relay", kind: "anthropic" }) + expect(models).toEqual([{ id: "claude-x", name: "claude-x", protocols: ["anthropic"] }]) + }) + + test("returns a typed HTTP error for authentication failures", async () => { + server?.stop(true) + server = Bun.serve({ port: 0, fetch: () => new Response("unauthorized", { status: 401 }) }) + baseURL = `http://localhost:${server.port}/v1` + await expect(discoverProviderModels({ baseURL, apiKey: "bad", providerID: "relay" })).rejects.toBeInstanceOf( + ProviderDiscoveryError, + ) + }) + test("rejects (does not hang) when the host is unreachable", async () => { // Port 1 is a reserved port nothing listens on → connection refused resolves fast, proving the // call surfaces an error instead of blocking the caller forever. diff --git a/packages/deepagent-code/test/provider/provider.test.ts b/packages/deepagent-code/test/provider/provider.test.ts index 4326a738..a09f96db 100644 --- a/packages/deepagent-code/test/provider/provider.test.ts +++ b/packages/deepagent-code/test/provider/provider.test.ts @@ -124,6 +124,7 @@ beforeAll(() => { fetch(req) { const url = new URL(req.url) if (url.pathname.endsWith("/models")) { + if (url.pathname.startsWith("/unauthorized")) return new Response("unauthorized", { status: 401 }) // `/empty/models` returns a 200 with no models (provisioning / not-implemented shape); every // other path returns two chat models plus one embedding model that runtime filtering must drop. if (url.pathname.startsWith("/empty")) return Response.json({ data: [] }) @@ -315,6 +316,30 @@ it.instance( }, ) +it.instance( + "discovery authentication failure suppresses configured model snapshots", + Effect.gen(function* () { + const providerID = ProviderV2.ID.make("runtime-revoked") + expect((yield* list)[providerID]).toBeUndefined() + expect( + (yield* Provider.use.errors()).find((error) => error.source === `provider.${providerID}`)?.message, + ).toContain("HTTP 401") + }), + { + config: () => ({ + provider: { + "runtime-revoked": { + name: "Runtime Revoked", + npm: "@ai-sdk/openai-compatible", + discovery: true, + options: { apiKey: "revoked", baseURL: `${discoveryServerURL}/../unauthorized` }, + models: { "stale-snapshot": { name: "Stale Snapshot" } }, + }, + }, + }), + }, +) + it.instance( "third-party model with a bare well-known id inherits specs from the models.dev catalog", Effect.gen(function* () { @@ -2286,7 +2311,11 @@ it.effect("provider groups: models in a group use the group npm (protocol overri npm: "@ai-sdk/anthropic", options: { apiKey: "claude-key" }, models: { - "claude-3-5-sonnet": { name: "Claude 3.5 Sonnet", tool_call: true, limit: { context: 200000, output: 8096 } }, + "claude-3-5-sonnet": { + name: "Claude 3.5 Sonnet", + tool_call: true, + limit: { context: 200000, output: 8096 }, + }, }, }, }, @@ -2301,6 +2330,63 @@ it.effect("provider groups: models in a group use the group npm (protocol overri expect(gpt4o?.api.npm).toBe("@ai-sdk/openai-compatible") const claude = mygateway.models[ModelV2.ID.make("claude-3-5-sonnet")] expect(claude?.api.npm).toBe("@ai-sdk/anthropic") + expect(claude?.options.apiKey).toBeUndefined() + const language = yield* Provider.use.getLanguage(claude).pipe(provideInstanceEffect(dir)) + const config = ( + language as unknown as { + config: { baseURL: string; headers: () => Promise> } + } + ).config + expect(config.baseURL).toBe("https://gateway.example.com/v1") + expect((yield* Effect.promise(() => Promise.resolve(config.headers())))["x-api-key"]).toBe("claude-key") + }).pipe(provideMultiInstance), +) + +it.effect("provider groups: discovery isolates one group's authentication failure", () => + Effect.gen(function* () { + const server = Bun.serve({ + port: 0, + fetch: (request) => { + if (request.headers.get("authorization") !== "Bearer group-key") return new Response("bad key", { status: 401 }) + return Response.json({ data: [{ id: "group-model", name: "Group Model" }] }) + }, + }) + yield* Effect.addFinalizer(() => Effect.sync(() => server.stop(true))) + const dir = yield* tmpdirScoped({ + config: { + provider: { + groupedgateway: { + name: "Grouped Gateway", + options: { baseURL: `http://localhost:${server.port}/v1`, apiKey: "top-key" }, + groups: { + openai: { + npm: "@ai-sdk/openai-compatible", + options: { apiKey: "group-key" }, + discovery: true, + }, + anthropic: { + npm: "@ai-sdk/anthropic", + options: { apiKey: "revoked-group-key" }, + discovery: true, + }, + }, + }, + }, + }, + }) + const providers = yield* Provider.use.list().pipe(provideInstanceEffect(dir)).pipe(provideMultiInstance) + const model = providers[ProviderV2.ID.make("groupedgateway")].models[ModelV2.ID.make("group-model")] + expect(model?.api.npm).toBe("@ai-sdk/openai-compatible") + expect((model?.options as Record).apiKey).toBeUndefined() + const language = yield* Provider.use.getLanguage(model).pipe(provideInstanceEffect(dir)) + const headers = (language as unknown as { config: { headers: () => Promise> } }).config + .headers + expect((yield* Effect.promise(() => Promise.resolve(headers()))).authorization).toBe("Bearer group-key") + expect( + (yield* Provider.use.errors().pipe(provideInstanceEffect(dir))).find( + (error) => error.source === "provider.groupedgateway.groups.anthropic", + )?.message, + ).toContain("HTTP 401") }).pipe(provideMultiInstance), ) diff --git a/packages/deepagent-code/test/provider/transform.test.ts b/packages/deepagent-code/test/provider/transform.test.ts index d27457a3..55a3fd54 100644 --- a/packages/deepagent-code/test/provider/transform.test.ts +++ b/packages/deepagent-code/test/provider/transform.test.ts @@ -2568,6 +2568,22 @@ describe("ProviderTransform.variants", () => { expect(result.xhigh).toEqual({ reasoningEffort: "xhigh" }) }) + test("openai-compatible claude-opus-5 forwards max adaptive effort", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "gateway/claude-opus-5", + providerID: "gateway", + api: { + id: "claude-opus-5", + url: "https://api.gateway.ai/v1", + npm: "@ai-sdk/openai-compatible", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.max).toEqual({ reasoningEffort: "max" }) + }) + test("openai-compatible non-gpt5 model still gets only low/medium/high", () => { const model = createMockModel({ id: "third-party/some-model", @@ -3470,6 +3486,27 @@ describe("ProviderTransform.variants", () => { } } + for (const apiId of ["claude-opus-5", "claude-opus-5-20260720"]) { + test(`opus 5 ${apiId} uses max adaptive effort`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: `anthropic/${apiId}`, + providerID: "anthropic", + api: { + id: apiId, + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.max).toEqual({ + thinking: { type: "adaptive", display: "summarized" }, + effort: "max", + }) + }) + } + test("github copilot opus 4.7 returns only medium reasoning effort", () => { const model = createMockModel({ id: "claude-opus-4.7", diff --git a/packages/deepagent-code/test/session/plan-gate-note.test.ts b/packages/deepagent-code/test/session/plan-gate-note.test.ts deleted file mode 100644 index 38164209..00000000 --- a/packages/deepagent-code/test/session/plan-gate-note.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { SessionTools } from "../../src/session/tools" - -// Regression guard for the plan-gate WARN reminder placement. The bug: "⚠️ Plan gate: …" was -// PREPENDED to a successful tool's own output, so the model read the leading gate banner as a -// FAILURE/denial and recorded the (exit-0) call as plan-gate-blocked. That false negative was then -// re-surfaced as "previous validation failed" every round, so the model re-diagnosed the same -// non-failure verbatim on every subsequent turn ("the previous 'failures' are again plan-gate -// artifacts — the bash actually returned data"). The fix appends the note AFTER the real output and -// states the command ran, so the result leads and the nudge can't be misread as a block. -describe("appendPlanGateNote (plan-gate false-negative regression)", () => { - const reason = "the plan is stale (reality changed); review it and update the `plan` tool to resync" - const realOutput = "compat.cuh\nexit code: 0\nBuild OK" - - test("the real tool output comes FIRST (not behind a gate banner)", () => { - const noted = SessionTools.appendPlanGateNote(realOutput, reason) - expect(noted.startsWith(realOutput)).toBe(true) - // The note must not lead with a warning/gate banner that reads as a failure. - expect(noted.startsWith("⚠️")).toBe(false) - expect(noted.startsWith("⚠️ Plan gate")).toBe(false) - }) - - test("states plainly that the command executed (so it is not read as a block)", () => { - const noted = SessionTools.appendPlanGateNote(realOutput, reason) - expect(noted).toContain("executed normally") - expect(noted).toContain("real result") - // The nudge reason is preserved so the model can still re-sync the plan. - expect(noted).toContain(reason) - }) - - test("preserves the output verbatim and appends the note at the tail", () => { - const noted = SessionTools.appendPlanGateNote(realOutput, reason) - const idxOutput = noted.indexOf(realOutput) - const idxNote = noted.indexOf("[plan-note]") - expect(idxOutput).toBe(0) - expect(idxNote).toBeGreaterThan(idxOutput + realOutput.length) - }) -}) diff --git a/packages/deepagent-code/test/session/retry.test.ts b/packages/deepagent-code/test/session/retry.test.ts index da726124..43113ab1 100644 --- a/packages/deepagent-code/test/session/retry.test.ts +++ b/packages/deepagent-code/test/session/retry.test.ts @@ -181,6 +181,26 @@ describe("session.retry.retryable", () => { }) }) + test("retries terminated Undici streams", () => { + const cause = Object.assign(new Error("other side closed"), { code: "UND_ERR_SOCKET" }) + const request = MessageV2.fromError(new TypeError("terminated", { cause }), { providerID }) + + expect(SessionV1.APIError.isInstance(request)).toBe(true) + if (!SessionV1.APIError.isInstance(request)) throw new Error("expected APIError") + expect(request.data.isRetryable).toBe(true) + expect(request.data.metadata).toEqual({ code: "UND_ERR_SOCKET", message: "terminated" }) + expect(SessionRetry.retryable(request, retryProvider)).toEqual({ + message: "Provider connection was interrupted", + }) + }) + + test("does not retry a terminated stream after user interruption", () => { + const request = MessageV2.fromError(new TypeError("terminated"), { providerID, aborted: true }) + + expect(SessionV1.AbortedError.isInstance(request)).toBe(true) + expect(SessionRetry.retryable(request, retryProvider)).toBeUndefined() + }) + test("does not retry context overflow errors", () => { const error = new SessionV1.ContextOverflowError({ message: "Input exceeds context window of this model", diff --git a/packages/deepagent-code/test/session/structured-output.test.ts b/packages/deepagent-code/test/session/structured-output.test.ts index 563ab530..3ad0de4e 100644 --- a/packages/deepagent-code/test/session/structured-output.test.ts +++ b/packages/deepagent-code/test/session/structured-output.test.ts @@ -4,6 +4,7 @@ import { Exit, Schema } from "effect" import { MessageV2 } from "../../src/session/message-v2" import { SessionPrompt } from "../../src/session/prompt" import { SessionID, MessageID } from "../../src/session/schema" +import { ToolInternal } from "../../src/tool/internal" const decodeFormat = Schema.decodeUnknownExit(SessionV1.Format) const decodeUser = Schema.decodeUnknownExit(SessionV1.User) @@ -254,6 +255,7 @@ describe("structured-output.createStructuredOutputTool", () => { }) expect(tool.description).toContain("structured format") + expect(ToolInternal.has(tool)).toBe(true) }) test("creates tool with schema as inputSchema", () => { diff --git a/packages/deepagent-code/test/tool/task-run.test.ts b/packages/deepagent-code/test/tool/task-run.test.ts index 0cf81108..aab62779 100644 --- a/packages/deepagent-code/test/tool/task-run.test.ts +++ b/packages/deepagent-code/test/tool/task-run.test.ts @@ -80,6 +80,7 @@ describe("TaskRun durable store", () => { expect(requestHash({ b: [2, { y: true, x: null }], a: 1 })).toBe( requestHash({ a: 1, b: [2, { x: null, y: true }] }), ) + expect(requestHash({ prompt: "a" })).toBe("732acbb3e402e912437dbe53442ca54368b45e73c284acbf4c0530fac53cca74") expect(requestHash({ prompt: "a" })).not.toBe(requestHash({ prompt: "b" })) }), ) diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 7af9f0b6..d3b26c76 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -18,7 +18,8 @@ "preview": "electron-vite preview", "test": "bun test ./src", "test:ci": "mkdir -p .artifacts/unit && bun test ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", - "test:subagents-smoke": "node --experimental-strip-types ./scripts/subagents-smoke.ts", + "test:subagents-cold-start": "node --experimental-strip-types ./scripts/subagents-smoke.ts", + "test:subagents-smoke": "node --experimental-strip-types ./scripts/subagents-live.ts", "test:prevent-sleep-smoke": "node --experimental-strip-types ./scripts/prevent-sleep-smoke.ts", "test:subagents-sourcemap": "bun ./scripts/verify-subagents-sourcemap.ts", "package": "bun ./scripts/package.ts", diff --git a/packages/desktop/scripts/subagents-live.ts b/packages/desktop/scripts/subagents-live.ts new file mode 100644 index 00000000..f70940b3 --- /dev/null +++ b/packages/desktop/scripts/subagents-live.ts @@ -0,0 +1,369 @@ +#!/usr/bin/env node +import { strict as assert } from "node:assert" +import { randomUUID } from "node:crypto" +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises" +import { homedir, tmpdir } from "node:os" +import { join, resolve } from "node:path" +import { _electron as electron, type ElectronApplication, type Page } from "@playwright/test" + +const MODEL = { providerID: "deepseek", modelID: "deepseek-chat" } as const +const main = resolve("out/main/index.js") +const authFile = process.env.DEEPAGENT_CODE_LIVE_AUTH_FILE ?? join(homedir(), ".deepagent", "code", "auth.json") +const savedAuth = JSON.parse(await readFile(authFile, "utf8")) as Record +const deepseekAuth = savedAuth.deepseek +assert.equal(isRecord(deepseekAuth) && deepseekAuth.type === "api" && typeof deepseekAuth.key === "string", true) + +type Server = { url: string; username: string; password: string } +type Session = { + id: string + parentID?: string + agent?: string + model?: { id: string; providerID: string } + metadata?: Record +} +type Status = { type: "idle" | "busy" | "retry" } +type Message = { + info: { + id: string + role: "user" | "assistant" + providerID?: string + modelID?: string + finish?: string + error?: unknown + time: { completed?: number } + } + parts: Array< + | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } + | { + type: "tool" + tool: string + state: + | { status: "pending" | "running" } + | { status: "completed"; output: string; metadata: Record } + | { status: "error"; error: string } + } + | { type: string } + > +} +type ToolPart = Extract +type Runtime = { + app: ElectronApplication + root: string + workspace: string + server: Server +} +type PromptRun = { before: Set; messages: Message[] } + +const activeApps = new Set() + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function basic(server: Server) { + return `Basic ${Buffer.from(`${server.username}:${server.password}`).toString("base64")}` +} + +function subagentState(session: Session) { + const deepagent = isRecord(session.metadata?.deepagent) ? session.metadata.deepagent : undefined + const subagent = isRecord(deepagent?.subagent) ? deepagent.subagent : undefined + return { + state: typeof subagent?.state === "string" ? subagent.state : undefined, + reason: typeof subagent?.reason === "string" ? subagent.reason : undefined, + } +} + +async function request(runtime: Runtime, pathname: string, init?: RequestInit) { + const url = new URL(pathname, runtime.server.url) + url.searchParams.set("directory", runtime.workspace) + const response = await fetch(url, { + ...init, + headers: { + authorization: basic(runtime.server), + ...(init?.body ? { "content-type": "application/json" } : {}), + ...init?.headers, + }, + }) + if (!response.ok) throw new Error(`${init?.method ?? "GET"} ${url.pathname} failed with HTTP ${response.status}`) + if (response.status === 204) return undefined as T + return (await response.json()) as T +} + +async function launch(name: string) { + const root = await realpath(await mkdtemp(join(tmpdir(), `deepagent-code-subagents-live-${name}-`))) + const workspace = join(root, "workspace") + await mkdir(workspace, { recursive: true }) + const env = Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ) + env.DEEPAGENT_CODE_TEST_ONBOARDING = "1" + env.DEEPAGENT_CODE_TEST_ROOT = root + env.DEEPAGENT_CODE_DB = join(root, "deepagent.sqlite") + env.DEEPAGENT_CODE_DISABLE_CHANNEL_DB = "1" + env.DEEPAGENT_CODE_DISABLE_EXTERNAL_SKILLS = "1" + env.DEEPAGENT_CODE_DISABLE_LSP_DOWNLOAD = "1" + env.DEEPAGENT_CODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS = "true" + env.DEEPAGENT_CODE_AUTH_CONTENT = JSON.stringify({ deepseek: deepseekAuth }) + env.DEEPAGENT_CODE_CONFIG_CONTENT = JSON.stringify({ + model: `${MODEL.providerID}/${MODEL.modelID}`, + permission: { task: "allow", bash: "allow", read: "allow" }, + }) + + const app = await electron.launch({ args: [main], env, timeout: 30_000 }) + activeApps.add(app) + const page = await app.firstWindow({ timeout: 30_000 }) + await page.waitForFunction(() => Boolean((window as unknown as { api?: DesktopAPI }).api)) + const server = await page.evaluate(() => (window as unknown as { api: DesktopAPI }).api.awaitInitialization()) + return { app, root, workspace, server } satisfies Runtime +} + +type DesktopAPI = { + awaitInitialization(): Promise +} + +async function close(runtime: Runtime) { + await runtime.app.close() + activeApps.delete(runtime.app) + if (process.env.DEEPAGENT_CODE_KEEP_LIVE_SMOKE !== "1") { + await rm(runtime.root, { recursive: true, force: true }) + } +} + +async function waitFor(check: () => Promise, label: string, timeout = 300_000): Promise { + const deadline = Date.now() + timeout + while (Date.now() < deadline) { + const value = await check() + if (value !== undefined) return value + await new Promise((resolve) => setTimeout(resolve, 250)) + } + throw new Error(`Timed out waiting for ${label}`) +} + +async function createParent(runtime: Runtime, title: string) { + return request(runtime, "/session", { + method: "POST", + body: JSON.stringify({ + title, + agent: "auto", + model: { id: MODEL.modelID, providerID: MODEL.providerID }, + }), + }) +} + +function messages(runtime: Runtime, sessionID: string) { + return request(runtime, `/session/${sessionID}/message`) +} + +function children(runtime: Runtime, sessionID: string) { + return request(runtime, `/session/${sessionID}/children`) +} + +async function startPrompt(runtime: Runtime, sessionID: string, text: string) { + const before = new Set((await messages(runtime, sessionID)).map((message) => message.info.id)) + await request(runtime, `/session/${sessionID}/prompt_async`, { + method: "POST", + body: JSON.stringify({ + model: MODEL, + agent: "auto", + system: + "You are controlling a live subagent E2E test. Follow the requested tool sequence exactly. Do not simulate tool output.", + parts: [{ type: "text", text }], + }), + }) + return before +} + +async function finishPrompt(runtime: Runtime, sessionID: string, before: Set, label: string) { + const fresh = await waitFor(async () => { + const [all, statuses] = await Promise.all([ + messages(runtime, sessionID), + request>(runtime, "/session/status"), + ]) + const next = all.filter((message) => !before.has(message.info.id)) + const active = statuses[sessionID]?.type + const completed = next.some( + (message) => message.info.role === "assistant" && message.info.time.completed !== undefined, + ) + if (completed && active !== "busy" && active !== "retry") return next + }, label) + return { before, messages: fresh } satisfies PromptRun +} + +async function runPrompt(runtime: Runtime, sessionID: string, text: string, label: string) { + return finishPrompt(runtime, sessionID, await startPrompt(runtime, sessionID, text), label) +} + +function toolParts(run: PromptRun, name: string) { + return run.messages.flatMap((message) => + message.parts.filter((part): part is ToolPart => part.type === "tool" && part.tool === name), + ) +} + +function completedTool(run: PromptRun, name: string) { + const parts = toolParts(run, name) + const completed = parts.findLast((part) => part.state.status === "completed") + if (completed?.state.status === "completed") return completed.state + const failed = parts.findLast((part) => part.state.status === "error") + throw new Error(`${name} did not complete${failed?.state.status === "error" ? `: ${failed.state.error}` : ""}`) +} + +function text(run: PromptRun) { + return run.messages + .flatMap((message) => + message.parts + .filter( + (part): part is Extract => + part.type === "text" && !part.synthetic && !part.ignored, + ) + .map((part) => part.text), + ) + .join("\n") +} + +function assertDeepSeek(messages: Message[]) { + assert.equal( + messages.some( + (message) => + message.info.role === "assistant" && + message.info.providerID === MODEL.providerID && + message.info.modelID === MODEL.modelID, + ), + true, + ) +} + +async function successScenario() { + const runtime = await launch("success") + try { + const marker = `LIVE_SUCCESS_${randomUUID().replaceAll("-", "")}` + const fixture = join(runtime.workspace, "success-fixture.txt") + await writeFile(fixture, `${marker}\n`, "utf8") + const parent = await createParent(runtime, "Live DeepSeek subagent success") + const taskRun = await runPrompt( + runtime, + parent.id, + [ + "Call the task tool exactly once and wait for it to finish.", + 'Use subagent_type="researcher" and description="live success fixture".', + `The child prompt must require reading ${fixture} and returning its exact content ${marker} in the final research result.`, + "Do not read the file yourself and do not call task_status or task_read yet.", + `After task returns, include ${marker} in your final response.`, + ].join("\n"), + "successful parent task", + ) + assertDeepSeek(taskRun.messages) + assert.match(completedTool(taskRun, "task").output, new RegExp(marker)) + assert.match(text(taskRun), new RegExp(marker)) + + const child = await waitFor(async () => { + const items = await children(runtime, parent.id) + const item = items.find((session) => subagentState(session).state === "completed") + return item + }, "completed child session") + const childMessages = await messages(runtime, child.id) + assertDeepSeek(childMessages) + assert.match(JSON.stringify(childMessages), new RegExp(marker)) + + const audit = await runPrompt( + runtime, + parent.id, + [ + `The completed child task id is ${child.id}.`, + "Call task_status exactly once, then call task_read exactly once with that task id.", + "Do not call task again and do not infer the transcript.", + `Your final response must include ${marker} and say that the child state is completed.`, + ].join("\n"), + "successful task audit", + ) + const status = completedTool(audit, "task_status").output + const transcript = completedTool(audit, "task_read").output + assert.match(status, /\[completed\]/) + assert.match(status, new RegExp(child.id)) + assert.match(transcript, /state="completed"/) + assert.match(transcript, new RegExp(marker)) + assert.match(text(audit), new RegExp(marker)) + console.log("Live success scenario passed", { parent: parent.id, child: child.id, state: "completed" }) + } finally { + await close(runtime) + } +} + +async function interruptedScenario() { + const runtime = await launch("interrupted") + try { + const marker = `LIVE_PARTIAL_${randomUUID().replaceAll("-", "")}` + const fixture = join(runtime.workspace, "partial-fixture.txt") + await writeFile(fixture, `${marker}\n`, "utf8") + const parent = await createParent(runtime, "Live DeepSeek subagent interruption") + const before = await startPrompt( + runtime, + parent.id, + [ + "Call the task tool exactly once in foreground mode and wait for it.", + 'Use subagent_type="researcher" and description="live interruption fixture".', + "Give the child these strict instructions:", + `1. Read ${fixture} with the read tool so its completed tool output contains ${marker}.`, + "2. After the read completes, run `sleep 120` with the bash tool.", + "3. Do not return a final answer before the sleep completes.", + "Do not call task_status or task_read yet.", + ].join("\n"), + ) + const child = await waitFor(async () => (await children(runtime, parent.id))[0], "interruption child session") + await waitFor(async () => { + const items = await messages(runtime, child.id) + const recovered = items.some((message) => + message.parts.some( + (part) => + part.type === "tool" && + part.tool === "read" && + part.state.status === "completed" && + part.state.output.includes(marker), + ), + ) + return recovered ? true : undefined + }, "recoverable child output") + await request(runtime, `/session/${child.id}/abort`, { method: "POST" }) + const taskRun = await finishPrompt(runtime, parent.id, before, "interrupted parent task") + assertDeepSeek(taskRun.messages) + assert.equal( + toolParts(taskRun, "task").some((part) => part.state.status === "error"), + true, + ) + + const settled = await waitFor(async () => { + const item = (await children(runtime, parent.id)).find((session) => session.id === child.id) + return item && subagentState(item).state ? item : undefined + }, "interrupted child settlement") + assert.equal(subagentState(settled).state, "interrupted") + + const audit = await runPrompt( + runtime, + parent.id, + [ + `The interrupted child task id is ${child.id}.`, + "Call task_status exactly once, then call task_read exactly once with that task id.", + "Do not call task again. Recover the partial work only from the tool transcript.", + `Your final response must include ${marker} and say that the child state is interrupted.`, + ].join("\n"), + "interrupted task audit", + ) + const status = completedTool(audit, "task_status").output + const transcript = completedTool(audit, "task_read").output + assert.match(status, /\[interrupted\]/) + assert.match(status, /partial work preserved/) + assert.match(transcript, /state="interrupted"/) + assert.match(transcript, new RegExp(marker)) + assert.match(text(audit), new RegExp(marker)) + console.log("Live interruption scenario passed", { parent: parent.id, child: child.id, state: "interrupted" }) + } finally { + await close(runtime) + } +} + +try { + await successScenario() + await interruptedScenario() + console.log("Live DeepSeek subagent E2E smoke passed") +} finally { + await Promise.all([...activeApps].map((app) => app.close().catch(() => undefined))) +}