diff --git a/src/renderer/components/providers/providerManifest.test.ts b/src/renderer/components/providers/providerManifest.test.ts index ad643f7a..ba45eb2b 100644 --- a/src/renderer/components/providers/providerManifest.test.ts +++ b/src/renderer/components/providers/providerManifest.test.ts @@ -21,6 +21,7 @@ const EXPECTED_PROVIDER_ORDER = [ "codex", "gemini", "qwen", + "qoder", "grok", "kimi", "antigravity", diff --git a/src/renderer/components/providers/qoder/QoderIcon.tsx b/src/renderer/components/providers/qoder/QoderIcon.tsx new file mode 100644 index 00000000..9aa0cf93 --- /dev/null +++ b/src/renderer/components/providers/qoder/QoderIcon.tsx @@ -0,0 +1,11 @@ +import { createProviderIcon } from "../common/createProviderIcon"; + +// Official Qoder mark (ACP agent registry icon). +const QODER_PATH = + "M8 1C4.134 1 1 4.134 1 8s3.134 7 7 7c1.5 0 2.9-.47 4.05-1.28l1.12 1.12a.75.75 0 1 0 1.06-1.06l-1.12-1.12A6.97 6.97 0 0 0 15 8c0-3.866-3.134-7-7-7Zm0 2c2.761 0 5 2.239 5 5s-2.239 5-5 5-5-2.239-5-5 2.239-5 5-5Z"; + +export const QoderIcon = createProviderIcon({ + cssPrefix: "poracode-qoder-icon", + path: QODER_PATH, + viewBox: "0 0 16 16", +}); diff --git a/src/renderer/components/providers/qoder/index.test.tsx b/src/renderer/components/providers/qoder/index.test.tsx new file mode 100644 index 00000000..24896607 --- /dev/null +++ b/src/renderer/components/providers/qoder/index.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from "vitest"; +import type { ComposerControl } from "@/renderer/components/thread/ThreadComposer"; +import type { AgentCapability, ThreadConfig } from "@/shared/contracts"; +import { getComposerControls } from "../providerComposer"; +import "./index"; + +const capabilities: AgentCapability = { + models: [], + efforts: [], + modelEfforts: {}, + modes: ["agent", "plan"], + approvalPolicies: [ + { id: "default", label: "Default" }, + { id: "acceptEdits", label: "Accept Edits" }, + { id: "bypassPermissions", label: "Bypass Permissions" }, + ], + sandboxModes: [], + supportsResume: true, + supportsDirectInput: true, + liveInputMode: "terminal", + presentationMode: "terminal", + presentationModes: ["terminal", "gui"], + defaultApprovalPolicy: "default", + settingDefs: [], +}; + +function isMenuControl( + control: ComposerControl, +): control is Extract { + return control.kind === undefined || control.kind === "menu"; +} + +function isModeControl(control: ComposerControl): boolean { + return "iconKind" in control && control.iconKind === "mode"; +} + +describe("Qoder composer controls", () => { + it("shows Work mode with Default permissions by default", () => { + const onConfigChange = vi.fn<(patch: Partial) => void>(); + const controls = getComposerControls("qoder")?.({ + capabilities, + config: { model: "auto", mode: "agent", approvalPolicy: "default" }, + isDisabled: false, + onConfigChange, + presentationMode: "gui", + }); + + expect(controls?.find(isModeControl)).toMatchObject({ + label: "Work", + isSelected: false, + }); + expect( + controls?.find((control) => isMenuControl(control) && control.iconKind === "permission"), + ).toMatchObject({ value: "default" }); + }); + + it("keeps Plan as the alternate mode", () => { + const controls = getComposerControls("qoder")?.({ + capabilities, + config: { model: "auto", mode: "plan", approvalPolicy: "default" }, + isDisabled: false, + onConfigChange: vi.fn<(patch: Partial) => void>(), + presentationMode: "gui", + }); + + expect(controls?.find(isModeControl)).toMatchObject({ + label: "Plan", + isSelected: true, + }); + }); +}); diff --git a/src/renderer/components/providers/qoder/index.tsx b/src/renderer/components/providers/qoder/index.tsx new file mode 100644 index 00000000..4a740813 --- /dev/null +++ b/src/renderer/components/providers/qoder/index.tsx @@ -0,0 +1,25 @@ +export * from "./QoderIcon"; + +import { QoderIcon } from "./QoderIcon"; +import providerManifest from "./manifest"; +import { standardPlanApprovalControls } from "../composerControlBuilders"; +import { registerProviderIcon } from "../ProviderIcon"; +import { registerComposerControls } from "../providerComposer"; +import { registerCommitGenDefaults } from "../commitGen"; +import { registerConflictResolverDefaults } from "../conflictResolver"; +import { registerTitleGenDefaults } from "../titleGen"; + +const PROVIDER_KIND = providerManifest.kind; +const QODER_UTILITY_DEFAULTS = { + label: "Qoder", + hint: "Auto", + model: "auto", + effort: "", +}; + +registerProviderIcon(PROVIDER_KIND, QoderIcon); +registerCommitGenDefaults(PROVIDER_KIND, QODER_UTILITY_DEFAULTS); +registerTitleGenDefaults(PROVIDER_KIND, QODER_UTILITY_DEFAULTS); +registerConflictResolverDefaults(PROVIDER_KIND, QODER_UTILITY_DEFAULTS); + +registerComposerControls(PROVIDER_KIND, (input) => standardPlanApprovalControls(input)); diff --git a/src/renderer/components/providers/qoder/manifest.ts b/src/renderer/components/providers/qoder/manifest.ts new file mode 100644 index 00000000..cc327814 --- /dev/null +++ b/src/renderer/components/providers/qoder/manifest.ts @@ -0,0 +1,8 @@ +import { msg } from "@lingui/core/macro"; +import type { RendererProviderManifest } from "../providerManifest"; + +export default { + kind: "qoder", + label: msg`Qoder CLI`, + order: 37, +} satisfies RendererProviderManifest; diff --git a/src/renderer/locales/de/messages.po b/src/renderer/locales/de/messages.po index 7ec16e35..7bdfd014 100644 --- a/src/renderer/locales/de/messages.po +++ b/src/renderer/locales/de/messages.po @@ -4183,6 +4183,10 @@ msgstr "Erstklassige OpenCode-Integration mithilfe der nativen SDK-Laufzeitumgeb msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." msgstr "Erstklassige Pi-Integration mit einem echten Terminal und Pis nativer SDK-Laufzeit." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Qoder CLI integration using Poracode's native terminal and ACP runtimes." +msgstr "Erstklassige Qoder CLI-Integration mit Poracodes nativen Terminal- und ACP-Laufzeiten." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Erstklassige Qwen Code-Integration mit Poracodes nativen Terminal- und ACP-Laufzeiten." @@ -7504,6 +7508,10 @@ msgstr "Push fehlgeschlagen: {detail}" msgid "Push updates to paired mobile devices when threads finish or need you, even when the app is closed." msgstr "Sende Aktualisierungen an gekoppelte Mobilgeräte, wenn Threads fertig sind oder dich brauchen, auch wenn die App geschlossen ist." +#: src/renderer/components/providers/qoder/manifest.ts +msgid "Qoder CLI" +msgstr "Qoder CLI" + #: src/renderer/components/thread/ThreadRuntimeRequestPanel/parts/QuestionSwitcher.tsx msgid "Questions" msgstr "Fragen" diff --git a/src/renderer/locales/en/messages.po b/src/renderer/locales/en/messages.po index 495088e7..81761fd8 100644 --- a/src/renderer/locales/en/messages.po +++ b/src/renderer/locales/en/messages.po @@ -4183,6 +4183,10 @@ msgstr "First-class OpenCode integration using Poracode's native SDK runtime." msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." msgstr "First-class Pi integration using a real terminal and Pi's native SDK runtime." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Qoder CLI integration using Poracode's native terminal and ACP runtimes." +msgstr "First-class Qoder CLI integration using Poracode's native terminal and ACP runtimes." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." @@ -7504,6 +7508,10 @@ msgstr "Push failed: {detail}" msgid "Push updates to paired mobile devices when threads finish or need you, even when the app is closed." msgstr "Push updates to paired mobile devices when threads finish or need you, even when the app is closed." +#: src/renderer/components/providers/qoder/manifest.ts +msgid "Qoder CLI" +msgstr "Qoder CLI" + #: src/renderer/components/thread/ThreadRuntimeRequestPanel/parts/QuestionSwitcher.tsx msgid "Questions" msgstr "Questions" diff --git a/src/renderer/locales/es/messages.po b/src/renderer/locales/es/messages.po index 00fb6217..5044e7ef 100644 --- a/src/renderer/locales/es/messages.po +++ b/src/renderer/locales/es/messages.po @@ -4183,6 +4183,10 @@ msgstr "Integración de primera clase de OpenCode usando el runtime nativo del S msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." msgstr "Integración Pi de primera clase con un terminal real y el entorno de ejecución SDK nativo de Pi." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Qoder CLI integration using Poracode's native terminal and ACP runtimes." +msgstr "Integración de primera clase con Qoder CLI mediante los entornos de ejecución nativos de terminal y ACP de Poracode." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Integración de primera clase con Qwen Code mediante los entornos de ejecución nativos de terminal y ACP de Poracode." @@ -7504,6 +7508,10 @@ msgstr "Error en el push: {detail}" msgid "Push updates to paired mobile devices when threads finish or need you, even when the app is closed." msgstr "Envía novedades a los dispositivos móviles emparejados cuando los hilos terminan o necesitan tu atención, incluso con la app cerrada." +#: src/renderer/components/providers/qoder/manifest.ts +msgid "Qoder CLI" +msgstr "Qoder CLI" + #: src/renderer/components/thread/ThreadRuntimeRequestPanel/parts/QuestionSwitcher.tsx msgid "Questions" msgstr "Preguntas" diff --git a/src/renderer/locales/fr/messages.po b/src/renderer/locales/fr/messages.po index e40d57d0..4ae84cc4 100644 --- a/src/renderer/locales/fr/messages.po +++ b/src/renderer/locales/fr/messages.po @@ -4183,6 +4183,10 @@ msgstr "Intégration OpenCode de première classe à l'aide du runtime SDK natif msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." msgstr "Intégration Pi de premier ordre avec un véritable terminal et l’environnement d’exécution SDK natif de Pi." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Qoder CLI integration using Poracode's native terminal and ACP runtimes." +msgstr "Intégration de premier ordre de Qoder CLI utilisant les environnements d’exécution natifs de terminal et ACP de Poracode." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Intégration de premier ordre de Qwen Code utilisant les environnements d’exécution natifs de terminal et ACP de Poracode." @@ -7503,6 +7507,10 @@ msgstr "Échec du push : {detail}" msgid "Push updates to paired mobile devices when threads finish or need you, even when the app is closed." msgstr "Envoyez des mises à jour aux appareils mobiles appairés lorsque les fils de discussion se terminent ou ont besoin de vous, même lorsque l’application est fermée." +#: src/renderer/components/providers/qoder/manifest.ts +msgid "Qoder CLI" +msgstr "Qoder CLI" + #: src/renderer/components/thread/ThreadRuntimeRequestPanel/parts/QuestionSwitcher.tsx msgid "Questions" msgstr "Questions" diff --git a/src/renderer/locales/ja/messages.po b/src/renderer/locales/ja/messages.po index 11482bec..8b5a5e4a 100644 --- a/src/renderer/locales/ja/messages.po +++ b/src/renderer/locales/ja/messages.po @@ -4182,6 +4182,10 @@ msgstr "Poracode のネイティブ SDK ランタイムを使用したファー msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." msgstr "実際のターミナルと Pi のネイティブ SDK ランタイムを使用する、完全な Pi 統合です。" +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Qoder CLI integration using Poracode's native terminal and ACP runtimes." +msgstr "Poracode のネイティブなターミナルおよび ACP ランタイムを使用する、Qoder CLI のファーストクラス統合です。" + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Poracode のネイティブなターミナルおよび ACP ランタイムを使用する、Qwen Code のファーストクラス統合です。" @@ -7502,6 +7506,10 @@ msgstr "プッシュに失敗しました:{detail}" msgid "Push updates to paired mobile devices when threads finish or need you, even when the app is closed." msgstr "スレッドが完了したり対応が必要になったりしたときに、アプリが閉じていてもペアリング済みのモバイルデバイスに更新をプッシュします。" +#: src/renderer/components/providers/qoder/manifest.ts +msgid "Qoder CLI" +msgstr "Qoder CLI" + #: src/renderer/components/thread/ThreadRuntimeRequestPanel/parts/QuestionSwitcher.tsx msgid "Questions" msgstr "質問" diff --git a/src/renderer/locales/ko/messages.po b/src/renderer/locales/ko/messages.po index 94e69fbf..a9a146eb 100644 --- a/src/renderer/locales/ko/messages.po +++ b/src/renderer/locales/ko/messages.po @@ -4183,6 +4183,10 @@ msgstr "Poracode의 기본 SDK 런타임을 사용한 최고 수준의 OpenCode msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." msgstr "실제 터미널과 Pi의 네이티브 SDK 런타임을 사용하는 완전한 Pi 통합입니다." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Qoder CLI integration using Poracode's native terminal and ACP runtimes." +msgstr "Poracode의 네이티브 터미널 및 ACP 런타임을 사용하는 Qoder CLI의 일급 통합입니다." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Poracode의 네이티브 터미널 및 ACP 런타임을 사용하는 Qwen Code의 일급 통합입니다." @@ -7504,6 +7508,10 @@ msgstr "푸시 실패: {detail}" msgid "Push updates to paired mobile devices when threads finish or need you, even when the app is closed." msgstr "스레드가 완료되거나 응답이 필요할 때 앱이 닫혀 있어도 페어링된 모바일 기기로 업데이트를 푸시합니다." +#: src/renderer/components/providers/qoder/manifest.ts +msgid "Qoder CLI" +msgstr "Qoder CLI" + #: src/renderer/components/thread/ThreadRuntimeRequestPanel/parts/QuestionSwitcher.tsx msgid "Questions" msgstr "질문" diff --git a/src/renderer/locales/pl/messages.po b/src/renderer/locales/pl/messages.po index fde7d191..cc017f0e 100644 --- a/src/renderer/locales/pl/messages.po +++ b/src/renderer/locales/pl/messages.po @@ -4183,6 +4183,10 @@ msgstr "Pierwszorzędna integracja OpenCode z wykorzystaniem natywnego środowis msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." msgstr "Pełna integracja Pi korzystająca z prawdziwego terminala i natywnego środowiska uruchomieniowego SDK Pi." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Qoder CLI integration using Poracode's native terminal and ACP runtimes." +msgstr "Pierwszorzędna integracja z Qoder CLI korzystająca z natywnych środowisk uruchomieniowych terminala i ACP w Poracode." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Pierwszorzędna integracja z Qwen Code korzystająca z natywnych środowisk uruchomieniowych terminala i ACP w Poracode." @@ -7504,6 +7508,10 @@ msgstr "Push nie powiódł się: {detail}" msgid "Push updates to paired mobile devices when threads finish or need you, even when the app is closed." msgstr "Wysyłaj aktualizacje na sparowane urządzenia mobilne, gdy wątki się kończą lub wymagają Twojej uwagi, nawet gdy aplikacja jest zamknięta." +#: src/renderer/components/providers/qoder/manifest.ts +msgid "Qoder CLI" +msgstr "Qoder CLI" + #: src/renderer/components/thread/ThreadRuntimeRequestPanel/parts/QuestionSwitcher.tsx msgid "Questions" msgstr "Pytania" diff --git a/src/renderer/locales/pt-BR/messages.po b/src/renderer/locales/pt-BR/messages.po index 42a40409..4954420f 100644 --- a/src/renderer/locales/pt-BR/messages.po +++ b/src/renderer/locales/pt-BR/messages.po @@ -4183,6 +4183,10 @@ msgstr "Integração OpenCode de primeira classe usando o tempo de execução SD msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." msgstr "Integração Pi de primeira classe com um terminal real e o ambiente de execução SDK nativo do Pi." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Qoder CLI integration using Poracode's native terminal and ACP runtimes." +msgstr "Integração nativa com o Qoder CLI usando os runtimes de terminal e ACP do Poracode." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Integração nativa com o Qwen Code usando os runtimes de terminal e ACP do Poracode." @@ -7504,6 +7508,10 @@ msgstr "Falha no envio: {detail}" msgid "Push updates to paired mobile devices when threads finish or need you, even when the app is closed." msgstr "Envie atualizações para os dispositivos móveis pareados quando os tópicos terminarem ou precisarem de você, mesmo com o app fechado." +#: src/renderer/components/providers/qoder/manifest.ts +msgid "Qoder CLI" +msgstr "Qoder CLI" + #: src/renderer/components/thread/ThreadRuntimeRequestPanel/parts/QuestionSwitcher.tsx msgid "Questions" msgstr "Perguntas" diff --git a/src/renderer/locales/ru/messages.po b/src/renderer/locales/ru/messages.po index dbc267fc..9717be6b 100644 --- a/src/renderer/locales/ru/messages.po +++ b/src/renderer/locales/ru/messages.po @@ -4183,6 +4183,10 @@ msgstr "Первоклассная интеграция OpenCode с исполь msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." msgstr "Полноценная интеграция Pi с настоящим терминалом и нативной средой выполнения SDK Pi." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Qoder CLI integration using Poracode's native terminal and ACP runtimes." +msgstr "Первоклассная интеграция Qoder CLI с использованием встроенных сред терминала и ACP в Poracode." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Первоклассная интеграция Qwen Code с использованием встроенных сред терминала и ACP в Poracode." @@ -7504,6 +7508,10 @@ msgstr "Сбой push: {detail}" msgid "Push updates to paired mobile devices when threads finish or need you, even when the app is closed." msgstr "Отправляйте обновления на сопряжённые мобильные устройства, когда треды завершаются или требуют вашего внимания, даже когда приложение закрыто." +#: src/renderer/components/providers/qoder/manifest.ts +msgid "Qoder CLI" +msgstr "Qoder CLI" + #: src/renderer/components/thread/ThreadRuntimeRequestPanel/parts/QuestionSwitcher.tsx msgid "Questions" msgstr "Вопросы" diff --git a/src/renderer/locales/tr/messages.po b/src/renderer/locales/tr/messages.po index e958dbde..baba0ea3 100644 --- a/src/renderer/locales/tr/messages.po +++ b/src/renderer/locales/tr/messages.po @@ -4183,6 +4183,10 @@ msgstr "Poracode'un yerel SDK çalışma zamanını kullanan birinci sınıf Ope msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." msgstr "Gerçek bir terminal ve Pi'nin yerel SDK çalışma zamanı ile birinci sınıf Pi entegrasyonu." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Qoder CLI integration using Poracode's native terminal and ACP runtimes." +msgstr "Poracode'un yerel terminal ve ACP çalışma zamanlarını kullanan birinci sınıf Qoder CLI entegrasyonu." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Poracode'un yerel terminal ve ACP çalışma zamanlarını kullanan birinci sınıf Qwen Code entegrasyonu." @@ -7504,6 +7508,10 @@ msgstr "Push başarısız oldu: {detail}" msgid "Push updates to paired mobile devices when threads finish or need you, even when the app is closed." msgstr "Konular tamamlandığında veya ilginizi gerektirdiğinde, uygulama kapalıyken bile eşleştirilmiş mobil cihazlara güncellemeler gönderin." +#: src/renderer/components/providers/qoder/manifest.ts +msgid "Qoder CLI" +msgstr "Qoder CLI" + #: src/renderer/components/thread/ThreadRuntimeRequestPanel/parts/QuestionSwitcher.tsx msgid "Questions" msgstr "Sorular" diff --git a/src/renderer/locales/uk/messages.po b/src/renderer/locales/uk/messages.po index 8a2a28ba..f72ba3df 100644 --- a/src/renderer/locales/uk/messages.po +++ b/src/renderer/locales/uk/messages.po @@ -4183,6 +4183,10 @@ msgstr "Першокласна інтеграція OpenCode з використ msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." msgstr "Повноцінна інтеграція Pi зі справжнім терміналом і нативним середовищем виконання SDK Pi." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Qoder CLI integration using Poracode's native terminal and ACP runtimes." +msgstr "Повноцінна інтеграція Qoder CLI із використанням вбудованих середовищ термінала та ACP у Poracode." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Повноцінна інтеграція Qwen Code із використанням вбудованих середовищ термінала та ACP у Poracode." @@ -7504,6 +7508,10 @@ msgstr "Збій push: {detail}" msgid "Push updates to paired mobile devices when threads finish or need you, even when the app is closed." msgstr "Надсилайте оновлення на спарені мобільні пристрої, коли треди завершуються або потребують вашої уваги, навіть коли застосунок закрито." +#: src/renderer/components/providers/qoder/manifest.ts +msgid "Qoder CLI" +msgstr "Qoder CLI" + #: src/renderer/components/thread/ThreadRuntimeRequestPanel/parts/QuestionSwitcher.tsx msgid "Questions" msgstr "Питання" diff --git a/src/renderer/locales/vi/messages.po b/src/renderer/locales/vi/messages.po index 4503125b..80e1899c 100644 --- a/src/renderer/locales/vi/messages.po +++ b/src/renderer/locales/vi/messages.po @@ -4183,6 +4183,10 @@ msgstr "Tích hợp OpenCode hạng nhất bằng thời gian chạy SDK gốc c msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." msgstr "Tích hợp Pi đầy đủ bằng thiết bị đầu cuối thực và môi trường chạy SDK gốc của Pi." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Qoder CLI integration using Poracode's native terminal and ACP runtimes." +msgstr "Tích hợp Qoder CLI hạng nhất bằng các môi trường chạy terminal và ACP gốc của Poracode." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "Tích hợp Qwen Code hạng nhất bằng các môi trường chạy terminal và ACP gốc của Poracode." @@ -7504,6 +7508,10 @@ msgstr "Push không thành công: {detail}" msgid "Push updates to paired mobile devices when threads finish or need you, even when the app is closed." msgstr "Đẩy các cập nhật đến những thiết bị di động đã ghép nối khi luồng hoàn tất hoặc cần bạn, ngay cả khi ứng dụng đã đóng." +#: src/renderer/components/providers/qoder/manifest.ts +msgid "Qoder CLI" +msgstr "Qoder CLI" + #: src/renderer/components/thread/ThreadRuntimeRequestPanel/parts/QuestionSwitcher.tsx msgid "Questions" msgstr "Câu hỏi" diff --git a/src/renderer/locales/zh-CN/messages.po b/src/renderer/locales/zh-CN/messages.po index dd8786ad..540e0381 100644 --- a/src/renderer/locales/zh-CN/messages.po +++ b/src/renderer/locales/zh-CN/messages.po @@ -4183,6 +4183,10 @@ msgstr "使用 Poracode 的本机 SDK 运行时进行一流的 OpenCode 集成 msgid "First-class Pi integration using a real terminal and Pi's native SDK runtime." msgstr "使用真实终端和 Pi 原生 SDK 运行时的完整 Pi 集成。" +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Qoder CLI integration using Poracode's native terminal and ACP runtimes." +msgstr "使用 Poracode 原生终端和 ACP 运行时的一流 Qoder CLI 集成。" + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class Qwen Code integration using Poracode's native terminal and ACP runtimes." msgstr "使用 Poracode 原生终端和 ACP 运行时的一流 Qwen Code 集成。" @@ -7503,6 +7507,10 @@ msgstr "推送失败:{detail}" msgid "Push updates to paired mobile devices when threads finish or need you, even when the app is closed." msgstr "当线程完成或需要你处理时,向已配对的移动设备推送更新,即使应用已关闭。" +#: src/renderer/components/providers/qoder/manifest.ts +msgid "Qoder CLI" +msgstr "Qoder CLI" + #: src/renderer/components/thread/ThreadRuntimeRequestPanel/parts/QuestionSwitcher.tsx msgid "Questions" msgstr "问题" diff --git a/src/renderer/views/SettingsOverlay/parts/AcpRegistrySettings.test.tsx b/src/renderer/views/SettingsOverlay/parts/AcpRegistrySettings.test.tsx index 73407100..4d3640b0 100644 --- a/src/renderer/views/SettingsOverlay/parts/AcpRegistrySettings.test.tsx +++ b/src/renderer/views/SettingsOverlay/parts/AcpRegistrySettings.test.tsx @@ -127,6 +127,7 @@ describe("native ACP registry aliases", () => { "grok-build": "grok", opencode: "opencode", "pi-acp": "pi", + qoder: "qoder", }); }); @@ -143,6 +144,7 @@ describe("native ACP registry aliases", () => { "gemini", "github-copilot", "github-copilot-cli", + "qoder", ]); expect( [...APP_SUPPORTED_ACP_AGENT_IDS].every((id) => KNOWN_NATIVE_FAMILY_ACP_AGENT_IDS.has(id)), diff --git a/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts b/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts index 33274d99..db816af9 100644 --- a/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +++ b/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts @@ -299,6 +299,21 @@ export const NATIVE_AGENT_REGISTRY_ENTRIES: NativeAgentRegistryEntry[] = [ "if (Get-Command irm -ErrorAction SilentlyContinue) { irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex } elseif (Get-Command npm -ErrorAction SilentlyContinue) { npm install -g @qwen-code/qwen-code@latest } else { Write-Host 'No supported installer found. Install PowerShell Invoke-RestMethod or Node.js/npm first, then refresh detected agents.' }", }), }, + { + id: "qoder", + acpRegistryAliases: [{ id: "qoder", nativeSupport: true }], + description: msg`First-class Qoder CLI integration using Poracode's native terminal and ACP runtimes.`, + docsUrl: "https://docs.qoder.com/en/cli/quick-start", + installCommand: (project) => + posixOrWindows( + project, + "if command -v curl >/dev/null 2>&1; then curl -fsSL https://qoder.com/install | bash; " + + "elif command -v npm >/dev/null 2>&1; then npm install -g @qoder-ai/qodercli@latest; else " + + POSIX_MISSING_CURL_NPM_MESSAGE + + "; fi", + "if (Get-Command irm -ErrorAction SilentlyContinue) { irm https://qoder.com/install.ps1 | iex } elseif (Get-Command npm -ErrorAction SilentlyContinue) { npm install -g @qoder-ai/qodercli@latest } else { Write-Host 'No supported installer found. Install PowerShell Invoke-RestMethod or Node.js/npm first, then refresh detected agents.' }", + ), + }, { id: "copilot", acpRegistryAliases: [ diff --git a/src/supervisor/agents/acp/canonicalMapping.test.ts b/src/supervisor/agents/acp/canonicalMapping.test.ts index b7da208d..28db16de 100644 --- a/src/supervisor/agents/acp/canonicalMapping.test.ts +++ b/src/supervisor/agents/acp/canonicalMapping.test.ts @@ -3,6 +3,7 @@ import type { SessionNotification } from "@agentclientprotocol/sdk"; import { closeOpenTurnItems, createAcpMapperState, + mapAcpGoalSlashCommand, mapAcpPermissionRequest, mapAcpSessionUpdate, PORACODE_ACP_GOAL_META_KEY, @@ -564,6 +565,46 @@ describe("mapAcpSessionUpdate", () => { }); }); + it("preserves Qoder ACP MCP tool calls and their results", () => { + const state = createAcpMapperState("t-qoder-mcp"); + const started = mapAcpSessionUpdate( + note({ + sessionUpdate: "tool_call", + toolCallId: "tc-qoder-mcp", + title: "echo_marker (poracode_smoke MCP Server)", + kind: "other", + status: "in_progress", + rawInput: { text: "MCP_QODER_OK" }, + } as Parameters[0]["update"]), + state, + ); + expect(started[0]).toMatchObject({ + type: "item.started", + itemType: "tool_call", + payload: { + name: "echo_marker (poracode_smoke MCP Server)", + args: { text: "MCP_QODER_OK" }, + status: "running", + }, + }); + + const completed = mapAcpSessionUpdate( + note({ + sessionUpdate: "tool_call_update", + toolCallId: "tc-qoder-mcp", + status: "completed", + rawOutput: "MCP_QODER_OK", + } as Parameters[0]["update"]), + state, + ); + expect(completed).toContainEqual( + expect.objectContaining({ + type: "item.completed", + payload: expect.objectContaining({ status: "success", result: "MCP_QODER_OK" }), + }), + ); + }); + it("preserves inline image content from a tool result onto payload.images", () => { const state = createAcpMapperState("t-image"); mapAcpSessionUpdate( @@ -1193,6 +1234,179 @@ describe("mapAcpSessionUpdate", () => { ]); }); + it("infers Qoder Agent tool calls as subagents and nests child output", () => { + const state = createAcpMapperState("t-qoder-subagent"); + const started = mapAcpSessionUpdate( + note({ + sessionUpdate: "tool_call", + toolCallId: "tc-qoder-agent", + title: "Agent", + kind: "think", + status: "in_progress", + rawInput: { + description: "Use poracode-marker-agent", + prompt: "Please run your deterministic marker response.", + subagent_type: "poracode-marker-agent", + }, + } as Parameters[0]["update"]), + state, + ); + const parentItemId = (started[0] as { itemId: string }).itemId; + expect(started[0]).toMatchObject({ + type: "item.started", + itemType: "tool_call", + payload: { + name: "Agent", + isSubAgent: true, + args: { subagent_type: "poracode-marker-agent" }, + }, + }); + + const child = mapAcpSessionUpdate( + note({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "SUBAGENT_CHILD_OK" }, + }), + state, + ); + expect(child).toContainEqual( + expect.objectContaining({ + type: "item.started", + itemType: "assistant_message", + parentItemId, + }), + ); + }); + + it("keeps consecutive ACP subagent launches as parallel siblings", () => { + const state = createAcpMapperState("t-parallel-subagents"); + const first = mapAcpSessionUpdate( + note({ + sessionUpdate: "tool_call", + toolCallId: "tc-parallel-first", + title: "First agent", + status: "in_progress", + rawInput: { description: "First", subagent_type: "worker", prompt: "First task" }, + } as Parameters[0]["update"]), + state, + ); + const firstItemId = (first[0] as { itemId: string }).itemId; + + const second = mapAcpSessionUpdate( + note({ + sessionUpdate: "tool_call", + toolCallId: "tc-parallel-second", + title: "Second agent", + status: "in_progress", + rawInput: { description: "Second", subagent_type: "worker", prompt: "Second task" }, + } as Parameters[0]["update"]), + state, + ); + const secondStart = second.find((event) => event.type === "item.started"); + expect(secondStart).not.toHaveProperty("parentItemId"); + const secondItemId = secondStart!.itemId; + + const firstFinished = mapAcpSessionUpdate( + note({ + sessionUpdate: "tool_call_update", + toolCallId: "tc-parallel-first", + status: "completed", + rawOutput: { text: "First agent result" }, + } as Parameters[0]["update"]), + state, + ); + expect(firstFinished).toContainEqual( + expect.objectContaining({ + type: "item.started", + itemType: "assistant_message", + parentItemId: firstItemId, + }), + ); + + const secondChild = mapAcpSessionUpdate( + note({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Second agent result" }, + }), + state, + ); + expect(secondChild).toContainEqual( + expect.objectContaining({ + type: "item.started", + itemType: "assistant_message", + parentItemId: secondItemId, + }), + ); + }); + + it("matches concurrent subagent child tools by their distinct input identity", () => { + const state = createAcpMapperState("t-parallel-child-tools"); + const readmeAgent = mapAcpSessionUpdate( + note({ + sessionUpdate: "tool_call", + toolCallId: "tc-readme-agent", + title: "Inspect README.md", + status: "in_progress", + rawInput: { + description: "Inspect README.md", + subagent_type: "Explore", + prompt: "Read README.md without modifying it", + }, + } as Parameters[0]["update"]), + state, + ); + const readmeAgentId = (readmeAgent[0] as { itemId: string }).itemId; + const helloAgent = mapAcpSessionUpdate( + note({ + sessionUpdate: "tool_call", + toolCallId: "tc-hello-agent", + title: "Inspect hello.txt", + status: "in_progress", + rawInput: { + description: "Inspect hello.txt", + subagent_type: "Explore", + prompt: "Read hello.txt without modifying it", + }, + } as Parameters[0]["update"]), + state, + ); + const helloAgentId = (helloAgent[0] as { itemId: string }).itemId; + + const readmeTool = mapAcpSessionUpdate( + note({ + sessionUpdate: "tool_call", + toolCallId: "tc-readme-tool", + title: "Read /fixture/README.md", + kind: "read", + status: "in_progress", + rawInput: { file_path: "/fixture/README.md" }, + locations: [{ path: "/fixture/README.md" }], + } as Parameters[0]["update"]), + state, + ); + expect(readmeTool.find((event) => event.type === "item.started")).toHaveProperty( + "parentItemId", + readmeAgentId, + ); + + const helloTool = mapAcpSessionUpdate( + note({ + sessionUpdate: "tool_call", + toolCallId: "tc-hello-tool", + title: "Read /fixture/hello.txt", + kind: "read", + status: "in_progress", + rawInput: { file_path: "/fixture/hello.txt" }, + locations: [{ path: "/fixture/hello.txt" }], + } as Parameters[0]["update"]), + state, + ); + expect(helloTool.find((event) => event.type === "item.started")).toHaveProperty( + "parentItemId", + helloAgentId, + ); + }); + it("surfaces ACP subagent tool_call_update progress as title metadata and child markdown", () => { const state = createAcpMapperState("t-subagent-progress"); const started = mapAcpSessionUpdate( @@ -1286,6 +1500,14 @@ describe("mapAcpSessionUpdate", () => { ); const outerItemId = (outer[0] as { itemId: string }).itemId; + mapAcpSessionUpdate( + note({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Outer agent is delegating its critique." }, + }), + state, + ); + const inner = mapAcpSessionUpdate( note({ sessionUpdate: "tool_call", @@ -1301,7 +1523,10 @@ describe("mapAcpSessionUpdate", () => { } as Parameters[0]["update"]), state, ); - const innerStart = inner[0] as { itemId: string; parentItemId?: string }; + const innerStart = inner.find( + (event): event is Extract<(typeof inner)[number], { type: "item.started" }> => + event.type === "item.started", + )!; expect(innerStart.parentItemId).toBe(outerItemId); const innerItemId = innerStart.itemId; @@ -1401,6 +1626,64 @@ describe("mapAcpSessionUpdate", () => { expect(nextTurn[0]).not.toHaveProperty("parentItemId"); }); + it("maps opt-in ACP goal commands and normalized completion onto one goal item", () => { + const state = createAcpMapperState("t-acp-goal"); + const started = mapAcpGoalSlashCommand("/goal set Verify Qoder rendering", state); + const goalItemId = (started[0] as { itemId: string }).itemId; + expect(started[0]).toMatchObject({ + type: "item.started", + itemType: "goal", + payload: { action: "set", objective: "Verify Qoder rendering", status: "active" }, + }); + + expect(mapAcpGoalSlashCommand("/goal pause", state)[0]).toMatchObject({ + type: "item.updated", + itemId: goalItemId, + payload: { action: "updated", objective: "Verify Qoder rendering", status: "paused" }, + }); + expect(mapAcpGoalSlashCommand("/goal status", state)[0]).toMatchObject({ + type: "item.updated", + itemId: goalItemId, + payload: { action: "viewed", status: "paused" }, + }); + expect(mapAcpGoalSlashCommand("/goal resume", state)[0]).toMatchObject({ + type: "item.updated", + itemId: goalItemId, + payload: { action: "updated", status: "active" }, + }); + + const completed = mapAcpSessionUpdate( + note({ + sessionUpdate: "tool_call", + toolCallId: "tc-goal-complete", + title: "Edit file", + kind: "edit", + status: "in_progress", + rawInput: { + status: "complete", + _poracodeCanonicalGoal: { action: "updated", status: "complete" }, + }, + locations: [{ path: "file" }], + } as Parameters[0]["update"]), + state, + ); + expect(completed[0]).toMatchObject({ + type: "item.updated", + itemId: goalItemId, + payload: { action: "updated", objective: "Verify Qoder rendering", status: "complete" }, + }); + expect( + completed.some((event) => event.type === "item.started" && event.itemType === "file_change"), + ).toBe(false); + + expect(mapAcpGoalSlashCommand("/goal cancel", state)).toEqual([]); + expect(mapAcpGoalSlashCommand("/goal clear", state)[0]).toMatchObject({ + type: "item.updated", + itemId: goalItemId, + payload: { action: "cleared" }, + }); + }); + it("preserves detached subagents across a foreground turn boundary", () => { const state = createAcpMapperState("t-detached-subagent"); const started = mapAcpSessionUpdate( @@ -1444,7 +1727,9 @@ describe("mapAcpSessionUpdate", () => { expect(closeOpenTurnItems(state)).toEqual([]); expect(state.toolCallItems.get("tc-detached")?.itemId).toBe(parentItemId); - expect(state.activeSubAgents).toEqual([{ toolCallId: "tc-detached", itemId: parentItemId }]); + expect(state.activeSubAgents).toEqual([ + { toolCallId: "tc-detached", itemId: parentItemId, hasChildActivity: true }, + ]); const nextForegroundTurn = mapAcpSessionUpdate( note({ diff --git a/src/supervisor/agents/acp/canonicalMapping.ts b/src/supervisor/agents/acp/canonicalMapping.ts index 1a52b6dd..0e222f85 100644 --- a/src/supervisor/agents/acp/canonicalMapping.ts +++ b/src/supervisor/agents/acp/canonicalMapping.ts @@ -30,4 +30,5 @@ export { PORACODE_ACP_TOP_LEVEL_TOOL_CALL_META_KEY, } from "./canonicalMapping/subagents"; export { mapAcpSessionUpdate } from "./canonicalMapping/dispatch"; +export { mapAcpGoalSlashCommand } from "./canonicalMapping/goal"; export { mapAcpElicitationRequest, mapAcpPermissionRequest } from "./canonicalMapping/permissions"; diff --git a/src/supervisor/agents/acp/canonicalMapping/dispatch.ts b/src/supervisor/agents/acp/canonicalMapping/dispatch.ts index cc7ebedf..069e6f16 100644 --- a/src/supervisor/agents/acp/canonicalMapping/dispatch.ts +++ b/src/supervisor/agents/acp/canonicalMapping/dispatch.ts @@ -32,10 +32,15 @@ import { PORACODE_ACP_DETACHED_SUBAGENT_META_KEY, PORACODE_ACP_NEW_ASSISTANT_ITEM_META_KEY, removeActiveSubAgent, + selectActiveSubAgentForToolCall, tagSubAgentChildStarts, } from "./subagents"; import { closeOpenContentItems, newItemId } from "./state"; import type { ActiveAcpSubAgent, AcpMapperState } from "./state"; +import { + mapAcpCanonicalGoalUpdate as mapAcpCommandGoalUpdate, + readAcpCanonicalGoalUpdate, +} from "./goal"; import { mapAcpCanonicalGoalUpdate } from "./goals"; function acpContentBlockToCanonical(block: ContentBlock): CanonicalContentBlock | undefined { @@ -67,7 +72,7 @@ export function mapAcpSessionUpdate( const events: RuntimeEvent[] = []; const { threadId } = state; events.push(...mapAcpCanonicalGoalUpdate(update, state)); - const activeSubAgent = getActiveSubAgentForNotification(state, update); + let activeSubAgent = getActiveSubAgentForNotification(state, update); let pendingSubAgent: ActiveAcpSubAgent | undefined; switch (update.sessionUpdate) { @@ -237,6 +242,12 @@ export function mapAcpSessionUpdate( } break; } + const goalUpdate = readAcpCanonicalGoalUpdate(toolCall.rawInput); + if (goalUpdate) { + state.suppressedToolCallIds.add(toolCall.toolCallId); + events.push(...mapAcpCommandGoalUpdate(state, goalUpdate)); + break; + } const itemId = newItemId("tool"); const status = toolCall.status === "completed" @@ -246,6 +257,9 @@ export function mapAcpSessionUpdate( : "running"; const itemType = classifyToolCallItemType(toolCall.kind, toolCall.title, toolCall.locations); const isSubAgent = isAcpSubAgentToolCall(toolCall); + if (!isSubAgent) { + activeSubAgent = selectActiveSubAgentForToolCall(state, toolCall); + } const rawInput = toolCall.rawInput && typeof toolCall.rawInput === "object" && @@ -302,7 +316,7 @@ export function mapAcpSessionUpdate( state.toolCallItems.delete(toolCall.toolCallId); } if (isSubAgent && toolCall.status !== "completed" && toolCall.status !== "failed") { - pendingSubAgent = { toolCallId: toolCall.toolCallId, itemId }; + pendingSubAgent = { toolCallId: toolCall.toolCallId, itemId, hasChildActivity: false }; } break; } @@ -485,9 +499,11 @@ export function mapAcpSessionUpdate( break; } - const nestingSubAgent = activeSubAgent ?? pendingSubAgent; - if (nestingSubAgent) { - tagSubAgentChildStarts(events, nestingSubAgent, state); + // Consecutive sub-agent starts are ambiguous in ACP: the protocol carries no + // parent id. Treat them as parallel siblings until the active agent has + // emitted real child activity; only then is a later launch safely nested. + if (activeSubAgent && (!pendingSubAgent || activeSubAgent.hasChildActivity)) { + tagSubAgentChildStarts(events, activeSubAgent, state); } if (pendingSubAgent) { state.activeSubAgents.push(pendingSubAgent); diff --git a/src/supervisor/agents/acp/canonicalMapping/goal.ts b/src/supervisor/agents/acp/canonicalMapping/goal.ts new file mode 100644 index 00000000..95a58682 --- /dev/null +++ b/src/supervisor/agents/acp/canonicalMapping/goal.ts @@ -0,0 +1,93 @@ +import type { GoalItemPayload, RuntimeEvent } from "@/shared/contracts"; +import { startGoalItemEvents, updateGoalItemEvents } from "../../goalRuntime"; +import type { AcpMapperState } from "./state"; +import { newItemId } from "./state"; + +export const ACP_CANONICAL_GOAL_INPUT_KEY = "_poracodeCanonicalGoal"; + +export interface AcpCanonicalGoalUpdate { + action: "updated"; + status: NonNullable; +} + +export function readAcpCanonicalGoalUpdate(rawInput: unknown): AcpCanonicalGoalUpdate | undefined { + if (!isRecord(rawInput)) return undefined; + const marker = rawInput[ACP_CANONICAL_GOAL_INPUT_KEY]; + if (!isRecord(marker) || marker.action !== "updated") return undefined; + if (!isGoalStatus(marker.status)) return undefined; + return { action: "updated", status: marker.status }; +} + +export function mapAcpCanonicalGoalUpdate( + state: AcpMapperState, + update: AcpCanonicalGoalUpdate, +): RuntimeEvent[] { + const payload: GoalItemPayload = { + action: update.action, + status: update.status, + ...(state.activeGoalObjective ? { objective: state.activeGoalObjective } : {}), + }; + state.activeGoalStatus = update.status; + if (!state.activeGoalItemId) { + state.activeGoalItemId = newItemId("goal"); + return startGoalItemEvents(state.threadId, state.activeGoalItemId, payload); + } + return updateGoalItemEvents(state.threadId, state.activeGoalItemId, payload); +} + +export function mapAcpGoalSlashCommand(prompt: string, state: AcpMapperState): RuntimeEvent[] { + const match = /^\/goal(?:\s+([\s\S]*))?$/iu.exec(prompt.trim()); + if (!match) return []; + const args = match[1]?.trim() ?? ""; + const setMatch = /^set(?:\s+([\s\S]+))?$/iu.exec(args); + + if (setMatch) { + const objective = setMatch[1]?.trim(); + if (!objective) return []; + state.activeGoalItemId = newItemId("goal"); + state.activeGoalObjective = objective; + state.activeGoalStatus = "active"; + return startGoalItemEvents(state.threadId, state.activeGoalItemId, { + action: "set", + objective, + status: "active", + }); + } + + if (args.toLowerCase() === "clear") { + const itemId = state.activeGoalItemId ?? newItemId("goal"); + const events = state.activeGoalItemId + ? updateGoalItemEvents(state.threadId, itemId, { action: "cleared" }) + : startGoalItemEvents(state.threadId, itemId, { action: "cleared" }); + delete state.activeGoalItemId; + delete state.activeGoalObjective; + delete state.activeGoalStatus; + return events; + } + + const status = args.toLowerCase(); + if (status !== "pause" && status !== "resume" && status !== "status" && args !== "") return []; + if (!state.activeGoalItemId || !state.activeGoalObjective) return []; + const nextStatus = + status === "pause" + ? "paused" + : status === "resume" + ? "active" + : (state.activeGoalStatus ?? "active"); + state.activeGoalStatus = nextStatus; + return updateGoalItemEvents(state.threadId, state.activeGoalItemId, { + action: status === "status" || args === "" ? "viewed" : "updated", + objective: state.activeGoalObjective, + status: nextStatus, + }); +} + +function isGoalStatus(value: unknown): value is NonNullable { + return ( + value === "active" || value === "paused" || value === "budget_limited" || value === "complete" + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/supervisor/agents/acp/canonicalMapping/state.ts b/src/supervisor/agents/acp/canonicalMapping/state.ts index 793fe10b..901e4572 100644 --- a/src/supervisor/agents/acp/canonicalMapping/state.ts +++ b/src/supervisor/agents/acp/canonicalMapping/state.ts @@ -3,7 +3,7 @@ * mapper. Tracks open items so streamed deltas land on the right item id. */ -import type { CanonicalItemType, RuntimeEvent } from "@/shared/contracts"; +import type { CanonicalItemType, GoalItemPayload, RuntimeEvent } from "@/shared/contracts"; export interface AcpToolCallItemState { itemId: string; @@ -26,6 +26,8 @@ export interface AcpToolCallItemState { export interface ActiveAcpSubAgent { toolCallId: string; itemId: string; + /** Whether this agent has emitted at least one inferred or explicit child. */ + hasChildActivity: boolean; } /** Per-session state — tracks open items so deltas land on the right item id. */ @@ -44,6 +46,12 @@ export interface AcpMapperState { * we conservatively infer nesting from active sub-agent tool-call lifetimes. */ activeSubAgents: ActiveAcpSubAgent[]; + /** Stable canonical item for an ACP provider's persistent goal lifecycle. */ + activeGoalItemId?: string; + /** Objective retained across `/goal pause`, resume, status, and completion. */ + activeGoalObjective?: string; + /** Most recently observed provider goal status. */ + activeGoalStatus?: NonNullable; /** Item id of the most recent plan, if open. */ openPlanItemId?: string; /** Last plan steps emitted for the open plan item. */ diff --git a/src/supervisor/agents/acp/canonicalMapping/subagents.ts b/src/supervisor/agents/acp/canonicalMapping/subagents.ts index bf6c3430..bc9001dd 100644 --- a/src/supervisor/agents/acp/canonicalMapping/subagents.ts +++ b/src/supervisor/agents/acp/canonicalMapping/subagents.ts @@ -61,6 +61,7 @@ export function buildSubAgentProgressEvents( threadId: state.threadId, itemId: item.subAgentProgressItemId, itemType: "assistant_message", + parentItemId: item.itemId, }); } const previous = item.subAgentProgressText ?? ""; @@ -159,6 +160,56 @@ export function getDetachedSubAgentToolCallIdForNotification( : undefined; } +/** + * When sibling ACP subagents run concurrently, a child tool call can arrive + * while both lifetimes are active. ACP has no parent id, but providers usually + * repeat the relevant file, command, or subject from the launch prompt in the + * child tool input. Prefer the one sibling with uniquely matching terms; + * retain stack order when the payload carries no useful identity. + */ +export function selectActiveSubAgentForToolCall( + state: AcpMapperState, + toolCall: { + title?: string | null; + rawInput?: unknown; + _meta?: unknown; + locations?: Array<{ path?: string | null }> | null; + }, +): ActiveAcpSubAgent | undefined { + const fallback = getActiveSubAgentForNotification(state, toolCall); + if (!fallback) return undefined; + const meta = + toolCall._meta && typeof toolCall._meta === "object" && !Array.isArray(toolCall._meta) + ? (toolCall._meta as Record) + : undefined; + if (typeof meta?.[PORACODE_ACP_PARENT_TOOL_CALL_ID_META_KEY] === "string") return fallback; + const candidates = state.activeSubAgents.filter( + (active) => state.toolCallItems.get(active.toolCallId)?.detached !== true, + ); + if (candidates.length < 2) return fallback; + const childTerms = extractIdentityTerms({ + title: toolCall.title, + rawInput: toolCall.rawInput, + locations: toolCall.locations, + }); + if (childTerms.size === 0) return fallback; + + const rankedCandidates = candidates.map((active) => ({ + active, + terms: extractIdentityTerms(state.toolCallItems.get(active.toolCallId)?.payload.args), + score: 0, + })); + for (const term of childTerms) { + const matches = rankedCandidates.filter((candidate) => candidate.terms.has(term)); + if (matches.length === 1) matches[0]!.score += 1; + } + const ranked = rankedCandidates.toSorted((left, right) => right.score - left.score); + const best = ranked[0]; + const runnerUp = ranked[1]; + if (!best || best.score === 0 || best.score === runnerUp?.score) return fallback; + return best.active; +} + export function removeActiveSubAgent(state: AcpMapperState, toolCallId: string): void { for (let index = state.activeSubAgents.length - 1; index >= 0; index -= 1) { if (state.activeSubAgents[index]?.toolCallId !== toolCallId) continue; @@ -172,27 +223,36 @@ export function tagSubAgentChildStarts( parent: ActiveAcpSubAgent, state: AcpMapperState, ): void { - let taggedStarts = 0; + const childStartsByParent = new Map(); for (let index = 0; index < events.length; index += 1) { const event = events[index]; if (!event || event.type !== "item.started") continue; // A newly classified subagent and its first progress item can be emitted // by the same ACP update. Never make the parent tool its own child. if (event.itemId === parent.itemId) continue; - if ("parentItemId" in event && typeof event.parentItemId === "string") continue; - events[index] = { ...event, parentItemId: parent.itemId }; - taggedStarts += 1; + const explicitParent = "parentItemId" in event ? event.parentItemId : undefined; + const parentItemId = typeof explicitParent === "string" ? explicitParent : parent.itemId; + if (typeof explicitParent !== "string") { + events[index] = { ...event, parentItemId }; + } + childStartsByParent.set(parentItemId, (childStartsByParent.get(parentItemId) ?? 0) + 1); + } + for (const [parentItemId, taggedStarts] of childStartsByParent) { + const activeParent = state.activeSubAgents.find( + (candidate) => candidate.itemId === parentItemId, + ); + if (!activeParent) continue; + activeParent.hasChildActivity = true; + const parentTool = state.toolCallItems.get(activeParent.toolCallId); + if (!parentTool) continue; + parentTool.payload = withBumpedSubAgentStepCount(parentTool.payload, taggedStarts); + events.push({ + type: "item.updated", + threadId: state.threadId, + itemId: parentItemId, + payload: parentTool.payload, + }); } - if (taggedStarts === 0) return; - const parentTool = state.toolCallItems.get(parent.toolCallId); - if (!parentTool) return; - parentTool.payload = withBumpedSubAgentStepCount(parentTool.payload, taggedStarts); - events.push({ - type: "item.updated", - threadId: state.threadId, - itemId: parent.itemId, - payload: parentTool.payload, - }); } function withBumpedSubAgentStepCount( @@ -211,6 +271,34 @@ function withBumpedSubAgentStepCount( return { ...payload, status: "running", progress }; } +const SUBAGENT_IDENTITY_STOP_WORDS = new Set([ + "agent", + "current", + "directory", + "file", + "files", + "inspect", + "modify", + "read", + "return", + "task", + "tool", +]); + +function extractIdentityTerms(value: unknown): Set { + let text: string; + try { + text = typeof value === "string" ? value : JSON.stringify(value); + } catch { + return new Set(); + } + return new Set( + (text.toLowerCase().match(/[a-z0-9][a-z0-9._-]{2,}/gu) ?? []).filter( + (term) => !SUBAGENT_IDENTITY_STOP_WORDS.has(term), + ), + ); +} + /** * Gemini's `update_topic` tool re-titles the active conversation topic for UI * grouping. ACP carries it with `kind: "think"` and `title` set to either the diff --git a/src/supervisor/agents/acp/fixtures/fake-acp-agent.mjs b/src/supervisor/agents/acp/fixtures/fake-acp-agent.mjs new file mode 100644 index 00000000..362ff105 --- /dev/null +++ b/src/supervisor/agents/acp/fixtures/fake-acp-agent.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node +/** + * Minimal fake ACP agent used by `probe.stress.test.ts`. + * + * Speaks just enough newline-delimited JSON-RPC over stdio to drive + * `probeAcpCapabilities` through its handshake + per-model thought-level + * probing, with timing faults injected via environment variables so each + * stress scenario is deterministic: + * + * FAKE_MODELS comma-separated model ids for the "model" selector + * FAKE_EFFORTS comma-separated reasoning-effort values (default "low,high") + * FAKE_REASONING_EFFORT "1" → advertise a {category:"model",id:"reasoning_effort"} selector + * FAKE_SLASH_BATCHES JSON array of {delayMs, commands:[{name,description}]} — each + * entry schedules one available_commands_update notification + * FAKE_SET_CONFIG_DELAY_MS delay before answering session/set_config_option + * FAKE_HANG_SET_CONFIG "1" → never answer session/set_config_option (simulates a wedged agent) + * FAKE_CRASH_AFTER_NEW_SESSION "1" → exit(0) immediately after answering session/new + * FAKE_SELF_DESTRUCT_MS exit(0) after N ms regardless (test cleanup guard) + */ +import { createInterface } from "node:readline"; + +const env = process.env; +const SESSION_ID = "fake-session-1"; + +const models = (env.FAKE_MODELS ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); +const efforts = (env.FAKE_EFFORTS ?? "low,high") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); +const slashBatches = JSON.parse(env.FAKE_SLASH_BATCHES ?? "[]"); +const setConfigDelayMs = Number(env.FAKE_SET_CONFIG_DELAY_MS ?? 0); +const hangSetConfig = env.FAKE_HANG_SET_CONFIG === "1"; +const crashAfterNewSession = env.FAKE_CRASH_AFTER_NEW_SESSION === "1"; +const selfDestructMs = Number(env.FAKE_SELF_DESTRUCT_MS ?? 0); +const includeReasoningEffort = env.FAKE_REASONING_EFFORT === "1"; + +if (selfDestructMs > 0) { + const timer = setTimeout(() => process.exit(0), selfDestructMs); + timer.unref?.(); +} + +let currentModel = models[0]; + +function send(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function respond(id, result) { + send({ jsonrpc: "2.0", id, result }); +} + +function notifySessionUpdate(update) { + send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: SESSION_ID, update } }); +} + +function modelConfigOption() { + return { + type: "select", + id: "model", + name: "Model", + category: "model", + currentValue: currentModel, + options: models.map((value) => ({ value, name: value })), + }; +} + +function reasoningEffortOption() { + return { + type: "select", + id: "reasoning_effort", + name: "Reasoning Effort", + category: "model", + currentValue: efforts[0] ?? "low", + options: efforts.map((value) => ({ value, name: value })), + }; +} + +function configOptions() { + const options = []; + if (models.length > 0) options.push(modelConfigOption()); + if (includeReasoningEffort) options.push(reasoningEffortOption()); + return options; +} + +const rl = createInterface({ input: process.stdin }); + +rl.on("line", (line) => { + const text = line.trim(); + if (!text) return; + let message; + try { + message = JSON.parse(text); + } catch { + return; + } + const { id, method, params } = message; + + switch (method) { + case "initialize": + respond(id, { + protocolVersion: 1, + agentCapabilities: { promptCapabilities: {}, sessionCapabilities: {} }, + agentInfo: { name: "fake-acp-agent", version: "0.0.0" }, + }); + return; + + case "authenticate": + respond(id, {}); + return; + + case "session/new": + respond(id, { + sessionId: SESSION_ID, + modes: { + currentModeId: "default", + availableModes: [{ id: "default", name: "Default" }], + }, + configOptions: configOptions(), + }); + for (const batch of slashBatches) { + setTimeout( + () => { + notifySessionUpdate({ + sessionUpdate: "available_commands_update", + availableCommands: batch.commands, + }); + }, + Number(batch.delayMs ?? 0), + ); + } + if (crashAfterNewSession) { + // Give the session/new response time to flush before dying like a + // crashed agent (process.exit() can truncate pending stdout writes). + setTimeout(() => process.exit(0), 20); + } + return; + + case "session/set_config_option": { + if (hangSetConfig) return; // wedged agent: never answer + const value = params?.value; + if (params?.configId === "model" && typeof value === "string") { + currentModel = value; + } + const answer = () => respond(id, { configOptions: configOptions() }); + if (setConfigDelayMs > 0) setTimeout(answer, setConfigDelayMs); + else answer(); + return; + } + + case "session/prompt": + respond(id, { stopReason: "end_turn" }); + return; + + case "session/cancel": + return; // notification — no response + + default: + if (id !== undefined) respond(id, {}); + return; + } +}); + +rl.on("close", () => process.exit(0)); diff --git a/src/supervisor/agents/acp/probe.stress.test.ts b/src/supervisor/agents/acp/probe.stress.test.ts new file mode 100644 index 00000000..35afe1d7 --- /dev/null +++ b/src/supervisor/agents/acp/probe.stress.test.ts @@ -0,0 +1,116 @@ +/** + * Stress tests for the Qoder-relevant `probeAcpCapabilities` timing paths. + * + * These drive the real probe against a fake ACP agent process + * (`fixtures/fake-acp-agent.mjs`) that injects deterministic timing faults. + * + * Each test asserts the correct behavior for timing and process-failure paths + * that are difficult to exercise reliably against a live provider. + */ +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { probeAcpCapabilities, type AcpProbeResult } from "./probe"; + +const FIXTURE = fileURLToPath(new URL("./fixtures/fake-acp-agent.mjs", import.meta.url)); + +function probeWith( + env: Record, + timeoutMs: number, +): Promise { + return probeAcpCapabilities(process.execPath, [FIXTURE], process.cwd(), { + env, + timeoutMs, + label: "stress", + }); +} + +describe("probeAcpCapabilities stress (Qoder timing paths)", () => { + it("captures slash commands delivered in a later batch within the 2s grace window", async () => { + // Qoder can deliver its initial command list after newSession resolves and + // may append skills after its built-ins have already been published. + const result = await probeWith( + { + FAKE_SLASH_BATCHES: JSON.stringify([ + { delayMs: 300, commands: [{ name: "quest", description: "workflow orchestrator" }] }, + { + delayMs: 700, + commands: [ + { name: "quest", description: "workflow orchestrator" }, + { name: "status", description: "show status" }, + ], + }, + ]), + }, + 5_000, + ); + + const ids = result?.slashCommands?.map((command) => command.id) ?? []; + expect(ids).toContain("quest"); + expect(ids).toContain("status"); + }); + + it("bounds the whole probe by timeoutMs, not just the handshake", async () => { + // The slash-command grace and per-model config RPCs must share the single + // caller-supplied budget rather than extending it. + const started = Date.now(); + await probeWith( + { + FAKE_MODELS: "auto,ultimate,fast", + FAKE_REASONING_EFFORT: "1", + FAKE_SLASH_BATCHES: JSON.stringify([ + { delayMs: 50, commands: [{ name: "quest", description: "workflow" }] }, + ]), + FAKE_SET_CONFIG_DELAY_MS: "800", + }, + 1_000, + ); + const elapsed = Date.now() - started; + + expect(elapsed).toBeLessThan(1_100); + }); + + it("cuts off a wedged session/set_config_option instead of hanging", async () => { + // The budget is deliberately large enough that per-model probing IS reached + // (slash grace ~2s first), so the wedged config RPC actually fires. It must + // be cut off by MODEL_THOUGHT_LEVEL_PROBE_TIMEOUT_MS (300ms) and the global + // budget — before the fix this RPC had no timeout and hung the probe forever. + const probePromise = probeWith( + { + FAKE_MODELS: "auto,ultimate", + FAKE_HANG_SET_CONFIG: "1", + // Safety net so the fake agent is reaped even if the probe regresses. + FAKE_SELF_DESTRUCT_MS: "8000", + }, + 3_000, + ); + + const watchdog = new Promise<"timeout">((resolve) => { + setTimeout(() => resolve("timeout"), 4_000); + }); + + try { + const outcome = await Promise.race([probePromise.then(() => "resolved" as const), watchdog]); + // ~2s slash grace + one wedged model RPC cut off at ~300ms ≈ 2.3s, well + // inside the 3s budget. A regression to the untimed RPC never resolves. + expect(outcome).toBe("resolved"); + } finally { + await probePromise.catch(() => undefined); + } + }); + + it("aborts promptly when the agent crashes right after session/new", async () => { + // Process exit must end the slash grace and prevent per-model retries. + const started = Date.now(); + await probeWith( + { + FAKE_MODELS: "auto,m1,m2,m3", + FAKE_REASONING_EFFORT: "1", + FAKE_CRASH_AFTER_NEW_SESSION: "1", + }, + 5_000, + ); + const elapsed = Date.now() - started; + + expect(elapsed).toBeLessThan(1_000); + }); +}); diff --git a/src/supervisor/agents/acp/probe.test.ts b/src/supervisor/agents/acp/probe.test.ts index 49d1d29a..6dfd1da1 100644 --- a/src/supervisor/agents/acp/probe.test.ts +++ b/src/supervisor/agents/acp/probe.test.ts @@ -324,6 +324,37 @@ describe("mapAcpThoughtLevels", () => { }); }); + it("extracts efforts from a reasoning_effort selector filed under the model category", () => { + // Qoder files its effort selector as { category: "model", id: "reasoning_effort" }. + const result = mapAcpThoughtLevels([ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "auto", + options: [{ value: "auto", name: "Auto" }], + }, + { + id: "reasoning_effort", + name: "Reasoning Effort", + category: "model", + type: "select", + currentValue: "xhigh", + options: [ + { value: "xhigh", name: "Extra High" }, + { value: "high", name: "High" }, + { value: "low", name: "Low" }, + ], + }, + ]); + + expect(result).toEqual({ + efforts: ["xhigh", "high", "low"], + defaultEffort: "xhigh", + }); + }); + it("returns empty efforts when no thought_level config exists", () => { expect( mapAcpThoughtLevels([ diff --git a/src/supervisor/agents/acp/probe.ts b/src/supervisor/agents/acp/probe.ts index 28c81cab..6e6fa5e4 100644 --- a/src/supervisor/agents/acp/probe.ts +++ b/src/supervisor/agents/acp/probe.ts @@ -22,6 +22,8 @@ import { } from "@agentclientprotocol/sdk"; import type { AgentSlashCommand, AuthState, ThreadMode } from "@/shared/contracts"; import { terminateChildProcessTree } from "@/shared/processTree"; +import { findThoughtLevelConfigOption } from "./thoughtLevel"; +import { filterAcpStdoutNonJsonLines } from "./sessionStreamFilter"; import { readUnstableSessionModels, type UnstableModelInfo } from "./unstableModelCompat"; const ACP_AUTH_REQUIRED_ERROR = RequestError.authRequired(); @@ -103,6 +105,12 @@ type AcpAvailableCommandLike = { const MODEL_THOUGHT_LEVEL_PROBE_TIMEOUT_MS = 300; const MAX_MODEL_THOUGHT_LEVEL_PROBES = 40; +/** + * Grace period for the agent to push `available_commands_update` after + * `newSession` resolves. Some agents (qoder) deliver the initial command list + * a few hundred ms after the response instead of before it. + */ +const INITIAL_SLASH_COMMANDS_TIMEOUT_MS = 2_000; // ── Mode mapping ───────────────────────────────────────────────── @@ -279,7 +287,7 @@ export function mapAcpThoughtLevels(configOptions: unknown): { efforts: string[]; defaultEffort?: string; } { - const option = findSelectConfigOption(configOptions, "thought_level"); + const option = findThoughtLevelConfigOption(configOptions); if (!option) { return { efforts: [] }; @@ -331,19 +339,29 @@ function readConfigOptions(value: unknown): unknown[] | undefined { function nextConfigOptionsUpdate( waiters: Array<(configOptions: unknown[] | undefined) => void>, timeoutMs: number, -): Promise { - return new Promise((resolve) => { - const waiter = (configOptions: unknown[] | undefined) => { - clearTimeout(timer); +): { promise: Promise; cancel: () => void } { + let waiter: ((configOptions: unknown[] | undefined) => void) | undefined; + let timer: ReturnType | undefined; + const promise = new Promise((resolve) => { + waiter = (configOptions: unknown[] | undefined) => { + if (timer) clearTimeout(timer); resolve(configOptions); }; - const timer = setTimeout(() => { - const index = waiters.indexOf(waiter); + timer = setTimeout(() => { + const index = waiter ? waiters.indexOf(waiter) : -1; if (index >= 0) waiters.splice(index, 1); resolve(undefined); }, timeoutMs); waiters.push(waiter); }); + return { + promise, + cancel: () => { + if (timer) clearTimeout(timer); + const index = waiter ? waiters.indexOf(waiter) : -1; + if (index >= 0) waiters.splice(index, 1); + }, + }; } // ── Probe ──────────────────────────────────────────────────────── @@ -375,6 +393,7 @@ export async function probeAcpCapabilities( }, ): Promise { const timeoutMs = options?.timeoutMs ?? 15_000; + const deadline = Date.now() + timeoutMs; const tag = options?.label ? `[acp-probe:${options.label}]` : "[acp-probe]"; let child: ReturnType | undefined; const probeResult: AcpProbeResult = {}; @@ -382,19 +401,8 @@ export async function probeAcpCapabilities( try { const configOptionsWaiters: Array<(configOptions: unknown[] | undefined) => void> = []; let latestSlashCommands: AgentSlashCommand[] | undefined; - let resolveInitialSlashCommands: - | ((commands: AgentSlashCommand[] | undefined) => void) - | undefined; - let initialSlashCommandsResolved = false; - const initialSlashCommands = new Promise((resolve) => { - resolveInitialSlashCommands = resolve; - }); const rememberSlashCommands = (commands: AgentSlashCommand[] | undefined) => { latestSlashCommands = commands; - if (!initialSlashCommandsResolved) { - initialSlashCommandsResolved = true; - resolveInitialSlashCommands?.(commands); - } }; child = spawn(command, args, { @@ -405,6 +413,51 @@ export async function probeAcpCapabilities( windowsHide: true, }); + let childExited = false; + const childClosed = new Promise((resolve) => { + const markClosed = () => { + childExited = true; + resolve(); + }; + child!.once("error", markClosed); + child!.once("exit", markClosed); + }); + const remainingBudgetMs = () => Math.max(0, deadline - Date.now()); + const waitForProbeWindow = async (maxMs: number): Promise => { + const waitMs = Math.min(maxMs, remainingBudgetMs()); + if (waitMs <= 0 || childExited) return; + let timer: ReturnType | undefined; + try { + await Promise.race([ + new Promise((resolve) => { + timer = setTimeout(resolve, waitMs); + }), + childClosed, + ]); + } finally { + if (timer) clearTimeout(timer); + } + }; + const runWithinProbeBudget = async (operation: Promise, maxMs?: number): Promise => { + const budgetMs = Math.min(maxMs ?? Number.POSITIVE_INFINITY, remainingBudgetMs()); + if (budgetMs <= 0) throw new Error("ACP probe timed out"); + if (childExited) throw new Error("ACP agent exited during capability probe"); + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("ACP probe timed out")), budgetMs); + }), + childClosed.then(() => { + throw new Error("ACP agent exited during capability probe"); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + }; + // Bail early if the process fails to start const spawnError = await new Promise((resolve) => { child!.once("error", (err) => resolve(err)); @@ -418,7 +471,7 @@ export async function probeAcpCapabilities( const toAgent = Writable.toWeb(child.stdin!) as WritableStream; const fromAgent = Readable.toWeb(child.stdout!) as ReadableStream; - const stream = ndJsonStream(toAgent, fromAgent); + const stream = ndJsonStream(toAgent, filterAcpStdoutNonJsonLines(fromAgent)); const connection = new ClientSideConnection( () => ({ @@ -441,7 +494,7 @@ export async function probeAcpCapabilities( stream, ); - const result = await Promise.race([ + const result = await runWithinProbeBudget( (async () => { const initResult = await connection.initialize({ protocolVersion: PROTOCOL_VERSION, @@ -503,19 +556,13 @@ export async function probeAcpCapabilities( throw err; } })(), - new Promise((_, reject) => - setTimeout(() => reject(new Error("ACP probe timed out")), timeoutMs), - ), - ]); - const initialCommandUpdate = await Promise.race([ - initialSlashCommands, - new Promise((resolve) => { - setTimeout(() => resolve(undefined), 250); - }), - ]); - const resolvedSlashCommands = initialCommandUpdate ?? latestSlashCommands; - if (resolvedSlashCommands !== undefined) { - probeResult.slashCommands = resolvedSlashCommands; + ); + // An available-commands update is a full snapshot. Keep the latest one + // throughout the grace window because agents may publish built-ins first + // and append skills after their async discovery completes. + await waitForProbeWindow(INITIAL_SLASH_COMMANDS_TIMEOUT_MS); + if (latestSlashCommands !== undefined) { + probeResult.slashCommands = latestSlashCommands; } probeResult.sessionEstablished = true; @@ -550,50 +597,83 @@ export async function probeAcpCapabilities( if (thoughtLevels.defaultEffort) { probeResult.defaultEffort = thoughtLevels.defaultEffort; } - if (probeResult.models?.length && probeResult.efforts?.length && result.sessionId) { - const modelConfig = findSelectConfigOption(result.configOptions, "model"); + const modelConfig = findSelectConfigOption(result.configOptions, "model"); + // Probe per-model thought levels even when the default model exposes + // none — some agents (qoder) only advertise a reasoning-effort selector + // after switching to a reasoning-capable model. + if (probeResult.models?.length && result.sessionId && modelConfig?.id) { const currentModel = typeof modelConfig?.currentValue === "string" ? modelConfig.currentValue : undefined; - if (modelConfig?.id) { - const modelEfforts: Record = {}; - if (currentModel) { - rememberModelThoughtLevels( - currentModel, - result.configOptions, - probeResult.efforts, - modelEfforts, - ); - } - const modelIds = probeResult.models - .map((model) => model.id) - .filter((modelId) => modelId !== currentModel) - .slice(0, MAX_MODEL_THOUGHT_LEVEL_PROBES); - for (const modelId of modelIds) { - const configOptionsUpdate = nextConfigOptionsUpdate( - configOptionsWaiters, - MODEL_THOUGHT_LEVEL_PROBE_TIMEOUT_MS, - ); - let returnedConfigOptions: unknown[] | undefined; - try { - const setResult = await connection.setSessionConfigOption({ + const modelEfforts: Record = {}; + if (currentModel) { + rememberModelThoughtLevels( + currentModel, + result.configOptions, + probeResult.efforts ?? [], + modelEfforts, + ); + } + const modelIds = probeResult.models + .map((model) => model.id) + .filter((modelId) => modelId !== currentModel) + .slice(0, MAX_MODEL_THOUGHT_LEVEL_PROBES); + for (const modelId of modelIds) { + if (remainingBudgetMs() <= 0 || childExited) break; + const configOptionsUpdate = nextConfigOptionsUpdate( + configOptionsWaiters, + Math.min(MODEL_THOUGHT_LEVEL_PROBE_TIMEOUT_MS, remainingBudgetMs()), + ); + let returnedConfigOptions: unknown[] | undefined; + try { + const setResult = await runWithinProbeBudget( + connection.setSessionConfigOption({ sessionId: result.sessionId, configId: modelConfig.id, value: modelId, - }); - returnedConfigOptions = readConfigOptions(setResult); - } catch { - await configOptionsUpdate; - continue; + }), + MODEL_THOUGHT_LEVEL_PROBE_TIMEOUT_MS, + ); + returnedConfigOptions = readConfigOptions(setResult); + } catch { + if (remainingBudgetMs() <= 0 || childExited) { + configOptionsUpdate.cancel(); + break; } - const configOptions = returnedConfigOptions ?? (await configOptionsUpdate); - if (!configOptions) { + const notifiedConfigOptions = await configOptionsUpdate.promise; + if (notifiedConfigOptions) { + returnedConfigOptions = notifiedConfigOptions; + } + // No baseline yet means the method is likely unsupported — retrying + // per model would just stall the probe. + if (!returnedConfigOptions && !probeResult.efforts?.length) { break; } - rememberModelThoughtLevels(modelId, configOptions, probeResult.efforts, modelEfforts); + if (!returnedConfigOptions) continue; + } + const configOptions = returnedConfigOptions ?? (await configOptionsUpdate.promise); + configOptionsUpdate.cancel(); + if (!configOptions) { + break; } - if (Object.keys(modelEfforts).length > 0) { - probeResult.modelEfforts = modelEfforts; + // Adopt the first discovered thought-level selector as the baseline. + if (!probeResult.efforts?.length) { + const discovered = mapAcpThoughtLevels(configOptions); + if (discovered.efforts.length > 0) { + probeResult.efforts = discovered.efforts; + if (discovered.defaultEffort) { + probeResult.defaultEffort = discovered.defaultEffort; + } + } } + rememberModelThoughtLevels( + modelId, + configOptions, + probeResult.efforts ?? [], + modelEfforts, + ); + } + if (Object.keys(modelEfforts).length > 0) { + probeResult.modelEfforts = modelEfforts; } } } @@ -673,7 +753,7 @@ export async function authenticateAcpAgent( return Promise.resolve(); }, }), - ndJsonStream(toAgent, fromAgent), + ndJsonStream(toAgent, filterAcpStdoutNonJsonLines(fromAgent)), ); const initResult = await connection.initialize({ protocolVersion: PROTOCOL_VERSION, @@ -750,7 +830,7 @@ export async function logoutAcpAgent( return Promise.resolve(); }, }), - ndJsonStream(toAgent, fromAgent), + ndJsonStream(toAgent, filterAcpStdoutNonJsonLines(fromAgent)), ); const initResult = await connection.initialize({ protocolVersion: PROTOCOL_VERSION, diff --git a/src/supervisor/agents/acp/session.ts b/src/supervisor/agents/acp/session.ts index 90e10ea9..2eb0fb96 100644 --- a/src/supervisor/agents/acp/session.ts +++ b/src/supervisor/agents/acp/session.ts @@ -61,6 +61,7 @@ import { closeOpenTurnItems, createAcpMapperState, getDetachedSubAgentToolCallIdForNotification, + mapAcpGoalSlashCommand, mapAcpSessionUpdate, type AcpMapperState, } from "./canonicalMapping"; @@ -93,7 +94,11 @@ import { export { resolveAcpReadableHostFsPath, resolveAcpResourcePath, toAcpResourceUri }; import { segmentsToContentBlocks } from "./sessionContentBlocks"; -import { filterAcpInboundNoise, looksLikeAcpSessionNotification } from "./sessionStreamFilter"; +import { + filterAcpInboundNoise, + filterAcpStdoutNonJsonLines, + looksLikeAcpSessionNotification, +} from "./sessionStreamFilter"; import { maybeCaptureAcpUpdate } from "./sessionDiagnostics"; import { AcpTerminalManager } from "./terminalManager"; import { @@ -128,6 +133,8 @@ export interface AcpStructuredSessionOptions { * provider-agnostic. */ sessionUpdateTransform?: (notification: SessionNotification) => SessionNotification; + /** Paint canonical state for this provider's `/goal` command family. */ + goalCommands?: boolean; extensionSessionUpdateTransform?: import("../base/types").AcpExtensionSessionUpdateTransform; /** * Vendor ACP extension notifications (e.g. Cursor `cursor/task`) that are @@ -148,6 +155,8 @@ export class AcpStructuredSession implements StructuredSessionHandle { private sessionUpdateTransform?: (notification: SessionNotification) => SessionNotification; private extensionSessionUpdateTransform?: import("../base/types").AcpExtensionSessionUpdateTransform; + private readonly goalCommands: boolean; + private extensionNotificationHandler?: import("../base/types").AcpExtensionNotificationHandler; private readonly acpToolCallIdToItemId = new Map(); @@ -288,6 +297,7 @@ export class AcpStructuredSession implements StructuredSessionHandle { if (options?.sessionUpdateTransform) { this.sessionUpdateTransform = options.sessionUpdateTransform; } + this.goalCommands = options?.goalCommands === true; if (options?.extensionSessionUpdateTransform) { this.extensionSessionUpdateTransform = options.extensionSessionUpdateTransform; } @@ -416,7 +426,9 @@ export class AcpStructuredSession implements StructuredSessionHandle { // tsgo's strict generics require explicit casts. const toAgent = Writable.toWeb(child.stdin!) as WritableStream; const fromAgent = Readable.toWeb(child.stdout!) as ReadableStream; - const stream = filterAcpInboundNoise(ndJsonStream(toAgent, fromAgent)); + const stream = filterAcpInboundNoise( + ndJsonStream(toAgent, filterAcpStdoutNonJsonLines(fromAgent)), + ); let session: AcpStructuredSession; @@ -708,6 +720,10 @@ export class AcpStructuredSession implements StructuredSessionHandle { }, { type: "item.completed", threadId: this.threadId, itemId: userItemId }, ]); + if (this.goalCommands) { + const goalEvents = mapAcpGoalSlashCommand(prompt, this.ensureMapperState()); + if (goalEvents.length > 0) this.emitRuntimeEvents(goalEvents); + } // Signal working state immediately this.emitListenerUpdate({ status: "working", attention: "working" }); diff --git a/src/supervisor/agents/acp/sessionConfig.ts b/src/supervisor/agents/acp/sessionConfig.ts index 5cc18ccf..6b3b5d84 100644 --- a/src/supervisor/agents/acp/sessionConfig.ts +++ b/src/supervisor/agents/acp/sessionConfig.ts @@ -1,5 +1,6 @@ import type { ThreadConfig } from "@/shared/contracts"; import { normalizeAcpModeId } from "./probe"; +import { findThoughtLevelConfigOption } from "./thoughtLevel"; /** * Resolve the ACP mode ID from Poracode's ThreadConfig. @@ -88,7 +89,7 @@ export function findSelectConfigOption( } export function findThoughtLevelConfig(configOptions: unknown): AcpConfigOptionLike | undefined { - return findSelectConfigOption(configOptions, "thought_level"); + return findThoughtLevelConfigOption(configOptions); } function isSelectOption(value: unknown): value is AcpConfigSelectOptionLike { diff --git a/src/supervisor/agents/acp/sessionConfigSync.test.ts b/src/supervisor/agents/acp/sessionConfigSync.test.ts index 6fb2a348..4712f6eb 100644 --- a/src/supervisor/agents/acp/sessionConfigSync.test.ts +++ b/src/supervisor/agents/acp/sessionConfigSync.test.ts @@ -583,4 +583,63 @@ describe("AcpSessionConfigSync", () => { value: "high", }); }); + + // Qoder files its effort selector as { category: "model", id: "reasoning_effort" } + // and only advertises it for reasoning-capable models, so a model switch can + // make the selector appear or disappear. These pin both transitions. + function qoderEffortOption(currentValue = "xhigh") { + return { + id: "reasoning_effort", + name: "Reasoning Effort", + category: "model", + type: "select", + currentValue, + options: [ + { value: "xhigh", name: "Extra High" }, + { value: "high", name: "High" }, + ], + }; + } + + it("skips the effort update when the reasoning_effort selector disappears after a model change", async () => { + const initialOptions = [modelSelectOption(), qoderEffortOption()]; + // model-b is not a reasoning model — its config options drop the selector. + const afterModelOptions = [modelSelectOption("model-b")]; + const { connection, sync } = makeConfigSync({ configOptions: initialOptions }); + connection.setSessionConfigOption.mockResolvedValueOnce({ configOptions: afterModelOptions }); + + await sync.applyTurnConfig( + "session-1", + { ...previousConfig, model: "model-b", effort: "high" }, + previousConfig, + ); + + // Only the model update is sent; the effort update is skipped rather than + // firing at a now-nonexistent "reasoning_effort" configId. + expect(connection.setSessionConfigOption.mock.calls.map(([call]) => call.configId)).toEqual([ + "model", + ]); + }); + + it("applies effort through the reasoning_effort selector that appears after a model change", async () => { + // The initial model exposes no effort selector... + const initialOptions = [modelSelectOption()]; + // ...switching to model-b reveals Qoder's reasoning-effort selector. + const afterModelOptions = [modelSelectOption("model-b"), qoderEffortOption("xhigh")]; + const { connection, sync } = makeConfigSync({ configOptions: initialOptions }); + connection.setSessionConfigOption.mockResolvedValueOnce({ configOptions: afterModelOptions }); + + await sync.applyTurnConfig( + "session-1", + { ...previousConfig, model: "model-b", effort: "high" }, + { ...previousConfig, model: "model-a" }, + ); + + expect( + connection.setSessionConfigOption.mock.calls.map(([call]) => [call.configId, call.value]), + ).toEqual([ + ["model", "model-b"], + ["reasoning_effort", "high"], + ]); + }); }); diff --git a/src/supervisor/agents/acp/sessionFactory.ts b/src/supervisor/agents/acp/sessionFactory.ts index bff3e51b..e2ea7146 100644 --- a/src/supervisor/agents/acp/sessionFactory.ts +++ b/src/supervisor/agents/acp/sessionFactory.ts @@ -52,6 +52,7 @@ export function createAcpStructuredSession( ...(input.acpSessionUpdateTransform ? { sessionUpdateTransform: input.acpSessionUpdateTransform } : {}), + ...(input.acpGoalCommands ? { goalCommands: true } : {}), ...(input.acpExtensionSessionUpdateTransform ? { extensionSessionUpdateTransform: input.acpExtensionSessionUpdateTransform } : {}), diff --git a/src/supervisor/agents/acp/sessionStreamFilter.test.ts b/src/supervisor/agents/acp/sessionStreamFilter.test.ts new file mode 100644 index 00000000..3485e2c1 --- /dev/null +++ b/src/supervisor/agents/acp/sessionStreamFilter.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { filterAcpStdoutNonJsonLines } from "./sessionStreamFilter"; + +async function readUtf8(stream: ReadableStream): Promise { + const decoder = new TextDecoder(); + const reader = stream.getReader(); + let output = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) return output + decoder.decode(); + output += decoder.decode(value, { stream: true }); + } +} + +describe("filterAcpStdoutNonJsonLines", () => { + it("drops plain-text startup diagnostics split across chunks", async () => { + const encoder = new TextEncoder(); + const input = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('{"jsonrpc":"2.0","id":1,"result":{}}\nSkill comm')); + controller.enqueue( + encoder.encode( + "and '/release-notes' was renamed because it conflicts with a built-in command.\n" + + '{"jsonrpc":"2.0","method":"session/update","params":{}}\r\n', + ), + ); + controller.close(); + }, + }); + + await expect(readUtf8(filterAcpStdoutNonJsonLines(input))).resolves.toBe( + '{"jsonrpc":"2.0","id":1,"result":{}}\n' + + '{"jsonrpc":"2.0","method":"session/update","params":{}}\n', + ); + }); + + it("preserves an object-shaped final line without a trailing newline", async () => { + const encoder = new TextEncoder(); + const input = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('notice\n {"jsonrpc":"2.0","id":2,"result":{}}')); + controller.close(); + }, + }); + + await expect(readUtf8(filterAcpStdoutNonJsonLines(input))).resolves.toBe( + ' {"jsonrpc":"2.0","id":2,"result":{}}', + ); + }); +}); diff --git a/src/supervisor/agents/acp/sessionStreamFilter.ts b/src/supervisor/agents/acp/sessionStreamFilter.ts index e2ae7aa5..1e2f7e28 100644 --- a/src/supervisor/agents/acp/sessionStreamFilter.ts +++ b/src/supervisor/agents/acp/sessionStreamFilter.ts @@ -4,6 +4,49 @@ */ import { ndJsonStream, type SessionNotification } from "@agentclientprotocol/sdk"; +/** + * ACP stdio is newline-delimited JSON, but some agents occasionally print a + * human-readable diagnostic to stdout during startup. Drop lines that cannot + * possibly be JSON-RPC objects before the SDK parser sees them; object-shaped + * malformed JSON still reaches the SDK and retains its normal diagnostics. + */ +export function filterAcpStdoutNonJsonLines( + input: ReadableStream, +): ReadableStream { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let pending = ""; + + const emitLine = ( + line: string, + hasNewline: boolean, + controller: TransformStreamDefaultController, + ) => { + if (line.trimStart().startsWith("{")) { + controller.enqueue(encoder.encode(hasNewline ? `${line}\n` : line)); + } + }; + + return input.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + pending += decoder.decode(chunk, { stream: true }); + let newlineIndex = pending.indexOf("\n"); + while (newlineIndex >= 0) { + const line = pending.slice(0, newlineIndex).replace(/\r$/u, ""); + pending = pending.slice(newlineIndex + 1); + emitLine(line, true, controller); + newlineIndex = pending.indexOf("\n"); + } + }, + flush(controller) { + pending += decoder.decode(); + if (pending.length > 0) emitLine(pending.replace(/\r$/u, ""), false, controller); + }, + }), + ); +} + export function looksLikeAcpSessionNotification(params: unknown): params is SessionNotification { if (!params || typeof params !== "object") return false; const p = params as { sessionId?: unknown; update?: unknown }; diff --git a/src/supervisor/agents/acp/thoughtLevel.test.ts b/src/supervisor/agents/acp/thoughtLevel.test.ts new file mode 100644 index 00000000..c13ff672 --- /dev/null +++ b/src/supervisor/agents/acp/thoughtLevel.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { THOUGHT_LEVEL_CONFIG_OPTION_IDS, findThoughtLevelConfigOption } from "./thoughtLevel"; + +describe("findThoughtLevelConfigOption", () => { + it("returns undefined for non-array input", () => { + expect(findThoughtLevelConfigOption(undefined)).toBeUndefined(); + expect(findThoughtLevelConfigOption(null)).toBeUndefined(); + expect(findThoughtLevelConfigOption("thought_level")).toBeUndefined(); + expect(findThoughtLevelConfigOption({ id: "thought_level" })).toBeUndefined(); + expect(findThoughtLevelConfigOption(42)).toBeUndefined(); + }); + + it("returns undefined for an empty array", () => { + expect(findThoughtLevelConfigOption([])).toBeUndefined(); + }); + + it("matches the spec-shaped thought_level category first", () => { + const options = [ + { id: "reasoning_effort", category: "model", type: "select", options: ["low", "high"] }, + { id: "thought_level", category: "thought_level", type: "select", options: ["low", "high"] }, + ]; + const result = findThoughtLevelConfigOption(options); + expect(result?.category).toBe("thought_level"); + }); + + it("falls back to known option ids when no thought_level category exists", () => { + const options = [ + { id: "model", category: "model", type: "select", options: ["gpt-4", "gpt-3"] }, + { id: "reasoning_effort", category: "model", type: "select", options: ["low", "high"] }, + ]; + const result = findThoughtLevelConfigOption(options); + expect(result?.id).toBe("reasoning_effort"); + }); + + it("never matches the model selector even though it shares category model", () => { + const options = [ + { id: "model", category: "model", type: "select", options: ["gpt-4", "gpt-3"] }, + ]; + expect(findThoughtLevelConfigOption(options)).toBeUndefined(); + }); + + it("ignores non-select options even with a matching id", () => { + const options = [ + { id: "thought_level", category: "thought_level", type: "text" }, + { id: "reasoning_effort", category: "model", type: "toggle" }, + ]; + expect(findThoughtLevelConfigOption(options)).toBeUndefined(); + }); + + it("ignores null and non-object entries", () => { + const options = [ + null, + undefined, + 42, + "thought_level", + { id: "thought_level", type: "select", category: "thought_level" }, + ]; + const result = findThoughtLevelConfigOption(options); + expect(result?.id).toBe("thought_level"); + }); + + it("handles options with missing id gracefully", () => { + const options = [{ category: "model", type: "select" }, { type: "select" }]; + expect(findThoughtLevelConfigOption(options)).toBeUndefined(); + }); + + it("matches thought_level category regardless of id", () => { + const options = [ + { id: "custom_name", category: "thought_level", type: "select", options: ["low"] }, + ]; + const result = findThoughtLevelConfigOption(options); + expect(result?.id).toBe("custom_name"); + expect(result?.category).toBe("thought_level"); + }); + + it("exposes the known ids for external consumers", () => { + expect(THOUGHT_LEVEL_CONFIG_OPTION_IDS).toContain("thought_level"); + expect(THOUGHT_LEVEL_CONFIG_OPTION_IDS).toContain("reasoning_effort"); + }); +}); diff --git a/src/supervisor/agents/acp/thoughtLevel.ts b/src/supervisor/agents/acp/thoughtLevel.ts new file mode 100644 index 00000000..dfe285d7 --- /dev/null +++ b/src/supervisor/agents/acp/thoughtLevel.ts @@ -0,0 +1,40 @@ +/** + * Locates the ACP "reasoning effort" select config option. + * + * Agents advertise effort under different shapes: + * Gemini: { category: "thought_level", id: "thought_level" } + * Qoder: { category: "model", id: "reasoning_effort" } + * + * Match the spec-shaped category first, then known option ids, so a + * `category: "model"` effort selector is never confused with the model + * selector (which carries `id: "model"`). + */ +export const THOUGHT_LEVEL_CONFIG_OPTION_IDS = ["thought_level", "reasoning_effort"] as const; + +export type ThoughtLevelConfigOptionLike = { + id?: string; + category?: string | null; + type?: string; + currentValue?: string; + options?: unknown; +}; + +export function findThoughtLevelConfigOption( + configOptions: unknown, +): ThoughtLevelConfigOptionLike | undefined { + if (!Array.isArray(configOptions)) { + return undefined; + } + const selectable = configOptions.filter( + (candidate): candidate is ThoughtLevelConfigOptionLike => + typeof candidate === "object" && + candidate !== null && + (candidate as ThoughtLevelConfigOptionLike).type === "select", + ); + return ( + selectable.find((option) => option.category === "thought_level") ?? + selectable.find((option) => + (THOUGHT_LEVEL_CONFIG_OPTION_IDS as readonly string[]).includes(option.id ?? ""), + ) + ); +} diff --git a/src/supervisor/agents/base/types.ts b/src/supervisor/agents/base/types.ts index 5f2d83ce..4adda23e 100644 --- a/src/supervisor/agents/base/types.ts +++ b/src/supervisor/agents/base/types.ts @@ -167,6 +167,12 @@ export interface CreateStructuredSessionInput { * the shared mapper must remain provider-agnostic. */ acpSessionUpdateTransform?: AcpSessionUpdateTransform; + /** + * Enable canonical goal lifecycle events for providers whose ACP server + * implements the `/goal` command family. Kept opt-in so unsupported ACP + * providers do not paint an optimistic goal dock for an unknown command. + */ + acpGoalCommands?: boolean; /** * Translate a vendor ACP extension notification into a standard * `session/update` before canonical mapping. This is for providers that put diff --git a/src/supervisor/agents/plugin/prepareAgentPlugins.test.ts b/src/supervisor/agents/plugin/prepareAgentPlugins.test.ts index 88b5008d..35bc2332 100644 --- a/src/supervisor/agents/plugin/prepareAgentPlugins.test.ts +++ b/src/supervisor/agents/plugin/prepareAgentPlugins.test.ts @@ -25,6 +25,7 @@ const EXPECTED_PLUGIN_KINDS = [ "gemini", "grok", "opencode", + "qoder", ] as const; const repoRoot = resolve(import.meta.dirname, "../../../.."); diff --git a/src/supervisor/agents/qoder/acpTransform.test.ts b/src/supervisor/agents/qoder/acpTransform.test.ts new file mode 100644 index 00000000..48cfaa9b --- /dev/null +++ b/src/supervisor/agents/qoder/acpTransform.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import type { SessionNotification } from "@agentclientprotocol/sdk"; +import { transformQoderAcpSessionUpdate } from "./acpTransform"; + +function note(update: SessionNotification["update"]): SessionNotification { + return { sessionId: "qoder-session", update }; +} + +describe("transformQoderAcpSessionUpdate", () => { + it("marks Qoder's synthetic UpdateGoal edit as a canonical goal update", () => { + const transformed = transformQoderAcpSessionUpdate( + note({ + sessionUpdate: "tool_call", + toolCallId: "goal-tool", + title: "Edit file", + kind: "edit", + status: "in_progress", + rawInput: { status: "complete" }, + locations: [{ path: "file" }], + } as SessionNotification["update"]), + ); + expect((transformed.update as { rawInput: unknown }).rawInput).toEqual({ + status: "complete", + _poracodeCanonicalGoal: { action: "updated", status: "complete" }, + }); + }); + + it("leaves real edits and unrelated status-shaped tools untouched", () => { + const realEdit = note({ + sessionUpdate: "tool_call", + toolCallId: "real-edit", + title: "Edit file", + kind: "edit", + status: "in_progress", + rawInput: { status: "complete", path: "src/app.ts" }, + locations: [{ path: "src/app.ts" }], + } as SessionNotification["update"]); + expect(transformQoderAcpSessionUpdate(realEdit)).toBe(realEdit); + }); +}); diff --git a/src/supervisor/agents/qoder/acpTransform.ts b/src/supervisor/agents/qoder/acpTransform.ts new file mode 100644 index 00000000..42e18063 --- /dev/null +++ b/src/supervisor/agents/qoder/acpTransform.ts @@ -0,0 +1,66 @@ +/** + * Qoder-specific ACP normalization. + * + * Qoder 1.1.x projects its internal `UpdateGoal` tool as a synthetic file edit + * (`title: "Edit file"`, `kind: "edit"`, location `file`). The shared mapper + * cannot distinguish that from a real edit, so this provider boundary marks + * the exact wire signature as a canonical goal update. The marker is consumed + * by the provider-agnostic ACP mapper and never reaches the renderer. + */ + +import type { SessionNotification } from "@agentclientprotocol/sdk"; +import { ACP_CANONICAL_GOAL_INPUT_KEY } from "../acp/canonicalMapping/goal"; + +export function transformQoderAcpSessionUpdate( + notification: SessionNotification, +): SessionNotification { + const update = notification.update; + if (update.sessionUpdate !== "tool_call") return notification; + const tool = update as { + title?: unknown; + kind?: unknown; + rawInput?: unknown; + locations?: unknown; + }; + if (tool.title !== "Edit file" || tool.kind !== "edit" || !isRecord(tool.rawInput)) { + return notification; + } + const inputKeys = Object.keys(tool.rawInput); + if (inputKeys.length !== 1 || inputKeys[0] !== "status") return notification; + const status = normalizeGoalStatus(tool.rawInput.status); + if (!status || !hasSyntheticGoalLocation(tool.locations)) return notification; + return { + ...notification, + update: { + ...update, + rawInput: { + ...tool.rawInput, + [ACP_CANONICAL_GOAL_INPUT_KEY]: { action: "updated", status }, + }, + } as SessionNotification["update"], + }; +} + +function normalizeGoalStatus( + value: unknown, +): "active" | "paused" | "budget_limited" | "complete" | undefined { + if ( + value === "active" || + value === "paused" || + value === "budget_limited" || + value === "complete" + ) { + return value; + } + return undefined; +} + +function hasSyntheticGoalLocation(value: unknown): boolean { + return ( + Array.isArray(value) && value.some((location) => isRecord(location) && location.path === "file") + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/supervisor/agents/qoder/argv.ts b/src/supervisor/agents/qoder/argv.ts new file mode 100644 index 00000000..8871b485 --- /dev/null +++ b/src/supervisor/agents/qoder/argv.ts @@ -0,0 +1,51 @@ +import type { ThreadConfig } from "@/shared/contracts"; + +export const QODER_DEFAULT_MODEL_ID = "auto"; + +/** + * Map Poracode config to qodercli `--permission-mode` values. The CLI accepts + * default | accept_edits | bypass_permissions | dont_ask | auto | plan, and + * the ACP surface advertises the matching Default / Accept Edits / Bypass + * Permissions / Plan modes. Approval-policy aliases cover configs carried + * over from threads created under other providers. + */ +function permissionModeFor(config: ThreadConfig): string { + if (config.mode === "plan") return "plan"; + switch (config.approvalPolicy) { + case "acceptEdits": + case "auto_edit": + case "auto-edit": + return "accept_edits"; + case "bypassPermissions": + case "never": + case "yolo": + return "bypass_permissions"; + case "auto": + return "auto"; + case "dont_ask": + return "dont_ask"; + default: + return "default"; + } +} + +export function buildQoderArgs( + config: ThreadConfig, + prompt: string, + resumeSessionId?: string, + assignedSessionId?: string, +): string[] { + const args: string[] = []; + if (resumeSessionId) { + args.push("--resume", resumeSessionId); + } else if (assignedSessionId) { + args.push("--session-id", assignedSessionId); + } + args.push("--model", config.model || QODER_DEFAULT_MODEL_ID); + args.push("--permission-mode", permissionModeFor(config)); + if (config.effort) { + args.push("--reasoning-effort", config.effort); + } + if (prompt.trim().length > 0) args.push("--prompt-interactive", prompt); + return args; +} diff --git a/src/supervisor/agents/qoder/detection.ts b/src/supervisor/agents/qoder/detection.ts new file mode 100644 index 00000000..54970498 --- /dev/null +++ b/src/supervisor/agents/qoder/detection.ts @@ -0,0 +1,126 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { AgentCapability, AgentTerminalAuthMethod, ProjectLocation } from "@/shared/contracts"; +import { probeAcpCapabilities, type AcpProbeResult } from "../acp"; +import { + batchWslCommandsAsync, + buildAgentCommand, + envVarAuthProbe, + type AuthProbe, + type CapabilitiesProbeResult, + type DetectionSpec, +} from "../base"; +import { getAgentProbeCwd, resolveProbeSpawnCwd } from "../probeCwd"; +import { QODER_DEFAULT_MODEL_ID } from "./argv"; + +export const qoderDefaultCapabilities: AgentCapability = { + models: [{ id: QODER_DEFAULT_MODEL_ID, label: "Auto" }], + efforts: ["none", "low", "medium", "high", "xhigh", "max"], + modelEfforts: {}, + modes: ["agent", "plan"], + approvalPolicies: [ + { id: "default", label: "Default" }, + { id: "acceptEdits", label: "Accept Edits" }, + { id: "bypassPermissions", label: "Bypass Permissions" }, + ], + sandboxModes: [], + supportsResume: true, + supportsOneShot: true, + supportsDirectInput: true, + liveInputMode: "terminal", + presentationMode: "terminal", + presentationModes: ["terminal", "gui"], + defaultApprovalPolicy: "default", + bypassPermissions: { approvalPolicy: "bypassPermissions" }, + mcpScope: { terminal: "none", gui: "launch" }, + settingDefs: [], +}; + +export function buildQoderCommand( + location: ProjectLocation, + args: string[], + executablePath?: string, +) { + return buildAgentCommand(location, "qodercli", args, executablePath); +} + +const terminalAuthMethod: AgentTerminalAuthMethod = { + id: "qoder-terminal-login", + name: "Login", + type: "terminal", +}; + +export const QODER_AUTH_ENV_KEYS = ["QODER_PERSONAL_ACCESS_TOKEN"] as const; + +/** + * qodercli persists account sign-in to `~/.qoder/.auth/user`. The config root + * itself is created on first run (before any login), so key off the + * credential file, not the directory. + */ +const credentialFileAuthProbe: AuthProbe = async (ctx) => { + if (ctx.location.kind !== "wsl") { + return existsSync(join(homedir(), ".qoder", ".auth", "user")) ? "authenticated" : "unknown"; + } + const [result] = await batchWslCommandsAsync(ctx.location.distro, [ + "test -f ~/.qoder/.auth/user && echo yes", + ]); + return result?.ok && result.stdout.trim() === "yes" ? "authenticated" : "unknown"; +}; + +export function buildQoderProbeCapabilities( + probe: AcpProbeResult | undefined, +): CapabilitiesProbeResult { + return { + ...qoderDefaultCapabilities, + ...(probe?.models?.length ? { models: probe.models } : {}), + ...(probe?.efforts?.length ? { efforts: probe.efforts } : {}), + ...(probe?.defaultEffort ? { defaultEffort: probe.defaultEffort } : {}), + ...(probe?.modelEfforts ? { modelEfforts: probe.modelEfforts } : {}), + modes: [...new Set([...qoderDefaultCapabilities.modes, ...(probe?.modes ?? [])])], + ...(probe?.approvalPolicies?.length ? { approvalPolicies: probe.approvalPolicies } : {}), + ...(probe?.slashCommands?.length ? { slashCommands: probe.slashCommands } : {}), + authMethods: [terminalAuthMethod], + preferTerminalLogin: true, + // NB: qodercli's `session/new` succeeds without sign-in (prompts fail + // later), so the probe's session-derived authState is meaningless for + // Qoder; the env / credential-file probes carry the real state. + }; +} + +async function probeCapabilities( + location: ProjectLocation, + executablePath: string, +): Promise { + const command = buildQoderCommand(location, ["--acp"], executablePath); + const processCwd = resolveProbeSpawnCwd(location, command.cwd); + const probe = await probeAcpCapabilities( + command.command, + command.args, + getAgentProbeCwd(location), + { + ...(processCwd ? { processCwd } : {}), + ...(command.env ? { env: command.env } : {}), + timeoutMs: 20_000, + label: location.kind === "wsl" ? `qoder:wsl:${location.distro}` : `qoder:${location.kind}`, + }, + ); + return buildQoderProbeCapabilities(probe); +} + +export const qoderDetectionSpec: DetectionSpec = { + kind: "qoder", + label: "Qoder", + binary: "qodercli", + loginCommand: "qodercli login", + capabilities: qoderDefaultCapabilities, + update: { + builtIn: { binary: "qodercli", args: ["update"] }, + npm: "@qoder-ai/qodercli", + }, + authProbes: [envVarAuthProbe([...QODER_AUTH_ENV_KEYS]), credentialFileAuthProbe], + async capabilitiesProbe(ctx) { + if (!ctx.executablePath) return undefined; + return probeCapabilities(ctx.location, ctx.executablePath); + }, +}; diff --git a/src/supervisor/agents/qoder/index.ts b/src/supervisor/agents/qoder/index.ts new file mode 100644 index 00000000..6ae4ef1c --- /dev/null +++ b/src/supervisor/agents/qoder/index.ts @@ -0,0 +1,193 @@ +import { randomUUID } from "node:crypto"; +import type { PromptSegment } from "@/shared/contracts"; +import { inlinePromptSegmentText } from "@/shared/promptContent"; +import { EXTRACTION_PROMPT } from "@/supervisor/contextExtractor"; +import { createAcpStructuredSession } from "../acp"; +import { + createKnownSessionRef, + detectAgentInstall, + detectProbeLocation, + type AgentAdapter, + type AgentEnvContext, + type CreateStructuredSessionInput, +} from "../base"; +import { resolveAgentBinaryPath } from "../binaryResolver"; +import { resolveInstallNodePath, warnIfPluginManifestMissing } from "../plugin/installerBase"; +import { buildQoderArgs, QODER_DEFAULT_MODEL_ID } from "./argv"; +import { buildQoderCommand, qoderDefaultCapabilities, qoderDetectionSpec } from "./detection"; +import { + getQoderPluginPaths, + installQoderPlugin, + isQoderPluginInstalled, + readBundledQoderPluginVersion, + uninstallQoderPlugin, +} from "./plugin/install"; +import { detectQoderInvalidSessionRef } from "./session"; +import { transformQoderAcpSessionUpdate } from "./acpTransform"; + +export { detectQoderInvalidSessionRef } from "./session"; + +const QODER_PLUGIN_VERSION = readBundledQoderPluginVersion(); + +warnIfPluginManifestMissing("qoder", QODER_PLUGIN_VERSION); + +export function createQoderAdapter(): AgentAdapter { + let capabilities = qoderDefaultCapabilities; + + return { + kind: qoderDetectionSpec.kind, + label: qoderDetectionSpec.label, + binary: qoderDetectionSpec.binary, + skillSupport: { + roots: [ + { + id: "qoder", + label: qoderDetectionSpec.label, + globalPath: ".qoder/skills", + projectPath: ".qoder/skills", + }, + { + id: "agents", + label: "Shared agent skills", + globalPath: ".agents/skills", + projectPath: ".agents/skills", + }, + ], + invocation: "slash", + precedence: { + global: ["qoder", "agents"], + project: ["qoder", "agents"], + }, + }, + ...(qoderDetectionSpec.update ? { update: qoderDetectionSpec.update } : {}), + get capabilities() { + return capabilities; + }, + spawnEnv: { wsl: { BROWSER: "/bin/true" } }, + + // ── CLI hook plugin support ────────────────────────────────────────── + pluginId: "poracode-status@qoder", + pluginVersion: QODER_PLUGIN_VERSION, + minProtocolVersion: 1, + async isPluginSupported(ctx) { + // Native: forward.mjs runs under Electron-as-Node via a generated + // wrapper script — always supported. + // WSL: hooks always supported; the runtime resolver probes the distro + // for an existing node and falls back to installing the pinned LTS if + // none is available. The actual install happens in `installPlugin`. + void ctx; + return true; + }, + async isPluginInstalled(ctx) { + return isQoderPluginInstalled(ctx); + }, + async installPlugin(ctx) { + const node = await resolveInstallNodePath(ctx); + if (!node.ok) return node; + const result = installQoderPlugin(ctx, { resolvedNodePath: node.nodePath }); + if (!result.ok) return result; + return { ok: true, version: result.version }; + }, + async uninstallPlugin(ctx) { + uninstallQoderPlugin(ctx); + }, + async pluginLaunchExtras(ctx) { + const paths = getQoderPluginPaths(ctx); + return { args: ["--settings", paths.settingsPath] }; + }, + + async detectInstall(ctx) { + const status = await detectAgentInstall(ctx, qoderDetectionSpec); + capabilities = status.capabilities; + return status; + }, + + buildLaunchArgv(_location, config, prompt) { + const sessionId = randomUUID(); + return { + binary: "qodercli", + args: buildQoderArgs(config, prompt, undefined, sessionId), + sessionRef: createKnownSessionRef(sessionId), + }; + }, + + buildResumeArgv(_location, config, prompt, sessionRef) { + return { + binary: "qodercli", + args: buildQoderArgs(config, prompt, sessionRef.providerSessionId), + }; + }, + + async createStructuredSession(input: CreateStructuredSessionInput) { + const command = buildQoderCommand( + input.projectLocation, + ["--acp"], + resolveAgentBinaryPath(input.projectLocation, "qodercli"), + ); + return createAcpStructuredSession(command, { + ...input, + acpGoalCommands: true, + acpSessionUpdateTransform: transformQoderAcpSessionUpdate, + }); + }, + + async buildAcpAuthCommand(ctx?: AgentEnvContext) { + const location = detectProbeLocation(ctx); + return buildQoderCommand(location, ["--acp"], resolveAgentBinaryPath(location, "qodercli")); + }, + + createInitialSessionRef() { + return undefined; + }, + + buildDirectInput(prompt) { + return [prompt, "@wait:40", "\r"]; + }, + + formatPromptSegments(segments: PromptSegment[]) { + const attachments = segments.filter((segment) => segment.kind === "attachment"); + const rest = segments.filter((segment) => segment.kind !== "attachment"); + const attachmentLines = attachments.map((segment) => `@${segment.path}`).join(" "); + const restText = rest.map(inlinePromptSegmentText).join(""); + return attachmentLines ? `${restText}\n\n${attachmentLines} ` : restText; + }, + + optimisticWorkingOnSubmit: true, + detectInvalidSessionRef: detectQoderInvalidSessionRef, + + defaultOneShotModel: QODER_DEFAULT_MODEL_ID, + + buildOneShotCommand(model, _effort, prompt) { + if (!prompt) return undefined; + return { + command: "qodercli", + args: [ + "-p", + prompt, + "--model", + model || QODER_DEFAULT_MODEL_ID, + "--permission-mode", + "plan", + ], + stdin: "", + }; + }, + + buildContextExtractionCommand(sessionRef, _location, model) { + return { + command: "qodercli", + args: [ + "-p", + EXTRACTION_PROMPT, + "--resume", + sessionRef.providerSessionId, + "--model", + model || QODER_DEFAULT_MODEL_ID, + "--permission-mode", + "plan", + ], + stdin: "", + }; + }, + }; +} diff --git a/src/supervisor/agents/qoder/plugin/forward.mjs b/src/supervisor/agents/qoder/plugin/forward.mjs new file mode 100644 index 00000000..305844b5 --- /dev/null +++ b/src/supervisor/agents/qoder/plugin/forward.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +/** + * Qoder CLI lifecycle hook forwarder for Poracode. + * + * Invoked by Qoder on each subscribed hook event with: + * argv[2] = hook event name (e.g. "UserPromptSubmit") + * stdin = JSON payload from Qoder + * + * Reads `PORACODE_HOOK_URL`, `PORACODE_HOOK_SECRET`, etc. from env, builds + * the universal Poracode envelope, and POSTs it. Emits NOTHING on stdout — + * Qoder relays hook stdout into the model's context for some events. + * + * Generic plumbing lives in the shared `poracode-hook-runtime.mjs` sibling. + */ + +import { + copyStringExtra, + readPluginVersionFromManifest, + runForwarder, +} from "./poracode-hook-runtime.mjs"; + +const PLUGIN_VERSION = readPluginVersionFromManifest(import.meta.url); + +function intentFor(eventName, payload) { + switch (eventName) { + case "SessionStart": + return "session.started"; + case "UserPromptSubmit": + return "session.turn_started"; + case "PermissionRequest": + return "session.needs_approval"; + // Tool finished (approve path) — exit `needs_approval`, still mid-turn. + case "PostToolUse": + return "session.turn_started"; + // Tool execution failed; Qoder recovers and `Stop` will close the turn. + case "PostToolUseFailure": + return "session.turn_started"; + case "ElicitationResult": { + const a = payload?.action; + if (a === "cancel" || a === "decline") { + return "session.turn_finished"; + } + return undefined; + } + case "Notification": + // Only `idle_prompt` (assistant idle waiting on the human) maps to + // `needs_reply`; permission / auth / elicitation notifications don't. + return payload?.notification_type === "idle_prompt" ? "session.needs_reply" : undefined; + case "Stop": + return "session.turn_finished"; + case "StopFailure": + return "session.turn_errored"; + default: + return undefined; + } +} + +function buildExtra(eventName, payload) { + const extra = { agentNativeEvent: eventName }; + if (payload && typeof payload === "object") { + copyStringExtra(extra, payload, "matcher", "matcher"); + copyStringExtra(extra, payload, "tool_name", "tool"); + copyStringExtra(extra, payload, "notification_type", "notificationType"); + copyStringExtra(extra, payload, "message", "message"); + } + return extra; +} + +function pickSessionId(payload) { + return typeof payload?.session_id === "string" ? payload.session_id : undefined; +} + +await runForwarder({ + agentKind: "qoder", + pluginVersion: PLUGIN_VERSION, + intentFor, + buildExtra, + pickSessionId, +}); diff --git a/src/supervisor/agents/qoder/plugin/install.test.ts b/src/supervisor/agents/qoder/plugin/install.test.ts new file mode 100644 index 00000000..b5bbd2c2 --- /dev/null +++ b/src/supervisor/agents/qoder/plugin/install.test.ts @@ -0,0 +1,47 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { installQoderPlugin, isQoderPluginInstalled } from "./install"; + +describe("installQoderPlugin", () => { + const baseDir = mkdtempSync(join(tmpdir(), "qoder-plugin-test-")); + + afterAll(() => { + rmSync(baseDir, { recursive: true, force: true }); + }); + + it("stages assets and renders qoder hook settings", () => { + const result = installQoderPlugin({ envKind: "posix", baseDir }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(existsSync(join(result.paths.pluginDir, "plugin.json"))).toBe(true); + expect(existsSync(join(result.paths.pluginDir, "forward.mjs"))).toBe(true); + expect(existsSync(join(result.paths.pluginDir, "poracode-hook-runtime.mjs"))).toBe(true); + + const settings = JSON.parse(readFileSync(result.paths.settingsPath, "utf8")) as { + hooks: Record }>>; + }; + expect(Object.keys(settings.hooks)).toEqual( + expect.arrayContaining([ + "SessionStart", + "UserPromptSubmit", + "PermissionRequest", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "Stop", + "StopFailure", + ]), + ); + const submit = settings.hooks["UserPromptSubmit"]?.[0]?.hooks[0]; + expect(submit?.type).toBe("command"); + expect(submit?.command.endsWith("UserPromptSubmit")).toBe(true); + + expect(isQoderPluginInstalled({ envKind: "posix", baseDir })).toEqual({ + installed: true, + version: result.version, + }); + }); +}); diff --git a/src/supervisor/agents/qoder/plugin/install.ts b/src/supervisor/agents/qoder/plugin/install.ts new file mode 100644 index 00000000..4eac4352 --- /dev/null +++ b/src/supervisor/agents/qoder/plugin/install.ts @@ -0,0 +1,319 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { toWslUncPath } from "@/shared/wsl"; +import type { AgentEnvContext } from "../../base"; +import { + FORWARD_RUNTIME_FILE, + buildNativeHookCommandHeads, + buildWslHookCommandHead, + copyForwardRuntimeFile, + copyPluginAssetsIfStale, + createPluginSourceResolver, + ctxCacheKey, + getNativeHookWrapperFilename, + getNativePluginBaseDir, + getWslPluginBaseDirs, + hasNativeHookWrapper, + isWslPluginContext, + memoByCtx, + readBundledPluginVersion, + readPluginManifest, + removeStagedPluginDir, + stagePluginAssetsToWsl, + writeNativeHookWrapper, + type PluginManifest, +} from "../../plugin/installerBase"; + +/** + * Qoder CLI plugin installer. + * + * "Install" here just means: stage the plugin assets at a stable location + * outside the Electron asar/source tree so that: + * 1. Qoder CLI can read `forward.mjs` as a regular file (asar reads fail + * from child Node processes), and + * 2. We can render a Qoder `--settings ` JSON file that points + * `command` at the staged forwarder. + * + * The flow is idempotent: every call copies `plugin.json` + `forward.mjs` + * from source and regenerates `settings.json`. That keeps the staging dir in + * sync with the current build even if a previous version left stale files + * behind. + * + * For WSL projects the plugin must live INSIDE the distro because Qoder runs + * there and can't read `\\wsl.localhost\` paths reliably from inside a login + * shell. We reuse the shared `deployFilesToWslHome` primitive (the same one + * the bridge uses for `bridge.mjs`) and emit a settings file with Linux-side + * paths. + */ + +export interface QoderPluginPaths { + /** + * Directory containing forward.mjs, plugin.json. For WSL contexts this is a + * Linux path inside the distro (e.g. `/home/user/.poracode/agent-plugins/qoder`); + * the caller must NOT pass it to native fs APIs. + */ + pluginDir: string; + /** Path to the generated Qoder settings file (passed via `--settings`). */ + settingsPath: string; + /** Plugin semver from plugin.json. */ + version: string; +} + +const callerDir = + typeof __dirname !== "undefined" + ? __dirname + : dirname(fileURLToPath(import.meta.url ?? "file://")); + +const resolveSourceDir = createPluginSourceResolver({ + kind: "qoder", + sourceEnvVar: "PORACODE_QODER_PLUGIN_SOURCE", + callerDir, +}); + +/** + * Single source of truth for the plugin semver: `plugin.json` next to this + * package in the repo / resources tree. Used by the Qoder adapter for install + * cache keys; `forward.mjs` reads the same file from disk next to itself at + * runtime after staging. + */ +export function readBundledQoderPluginVersion(): string { + return readBundledPluginVersion(resolveSourceDir); +} + +function computeQoderPluginPaths(ctx?: AgentEnvContext): QoderPluginPaths { + if (isWslPluginContext(ctx)) { + const wsl = getWslPluginBaseDirs(ctx.wslDistro, "qoder"); + if (!wsl) return { pluginDir: "", settingsPath: "", version: "0.0.0" }; + let version = "0.0.0"; + try { + version = readPluginManifest(wsl.uncBase).version; + } catch { + // staged manifest missing or distro unreachable. + } + return { + pluginDir: wsl.linuxBase, + settingsPath: `${wsl.linuxBase}/settings.json`, + version, + }; + } + const pluginDir = getNativePluginBaseDir("qoder", ctx?.baseDir); + let version = "0.0.0"; + try { + version = readPluginManifest(pluginDir).version; + } catch { + // staged manifest missing; caller should run installQoderPlugin first. + } + return { + pluginDir, + settingsPath: join(pluginDir, "settings.json"), + version, + }; +} + +const qoderPluginPathsMemo = memoByCtx(computeQoderPluginPaths, ctxCacheKey); + +/** + * Compute the plugin staging dir without performing any install work. + * Result is memoized per (envKind, wslDistro, baseDir) for the supervisor + * lifetime — all inputs are stable across spawns. After `installQoderPlugin` + * runs, the manifest version on disk is the same the memo would have read, + * so re-installs don't require invalidation in practice. + */ +export function getQoderPluginPaths(ctx?: AgentEnvContext): QoderPluginPaths { + return qoderPluginPathsMemo.call(ctx); +} + +/** + * Stage the Qoder plugin assets and write a `settings.json` that wires + * Qoder's hook system to invoke the staged `forward.mjs`. Idempotent — safe + * to call from every supervisor boot. For WSL contexts, assets are staged + * into the distro's `~/.poracode/agent-plugins/qoder/` via the shared + * `deployFilesToWslHome` helper. + */ +export interface InstallQoderPluginOptions { + /** + * Absolute path to the Node binary the staged hook command should use. + * + * - **WSL contexts:** required. Comes from `resolveNodeForDistro`. + * - **Native contexts:** optional. When provided (preferred), the wrapper + * exec's the bare Node binary directly; otherwise it falls back to + * `ELECTRON_RUN_AS_NODE=1` against the bundled Electron binary. + */ + resolvedNodePath?: string | undefined; +} + +export function installQoderPlugin( + ctx?: AgentEnvContext, + options?: InstallQoderPluginOptions, +): { ok: true; paths: QoderPluginPaths; version: string } | { ok: false; reason: string } { + let sourceDir: string; + try { + sourceDir = resolveSourceDir(); + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : String(error) }; + } + + let manifest: PluginManifest; + try { + manifest = readPluginManifest(sourceDir); + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : String(error) }; + } + + if (isWslPluginContext(ctx)) { + if (!options?.resolvedNodePath) { + return { + ok: false, + reason: + "WSL Qoder plugin install requires a resolved node path; the adapter must call resolveNodeForDistro before installing.", + }; + } + return installQoderPluginWsl(ctx.wslDistro, sourceDir, manifest, options.resolvedNodePath); + } + + const pluginDir = getNativePluginBaseDir("qoder", ctx?.baseDir); + mkdirSync(pluginDir, { recursive: true }); + copyPluginAssetsIfStale(sourceDir, pluginDir); + copyForwardRuntimeFile(pluginDir); + const wrapperPath = writeNativeHookWrapper(pluginDir, { + ...(options?.resolvedNodePath ? { nodePath: options.resolvedNodePath } : {}), + }); + + const settingsPath = join(pluginDir, "settings.json"); + const nativeCommands = buildNativeHookCommandHeads(wrapperPath); + const settings = renderQoderSettings(nativeCommands.command); + writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf8"); + + console.log( + `[supervisor] Qoder hook plugin staged v${manifest.version} at ${pluginDir} (forward.mjs, ${getNativeHookWrapperFilename()}, settings.json)`, + ); + + return { + ok: true, + version: manifest.version, + paths: { pluginDir, settingsPath, version: manifest.version }, + }; +} + +function installQoderPluginWsl( + distro: string, + sourceDir: string, + manifest: PluginManifest, + resolvedNodePath: string, +): { ok: true; paths: QoderPluginPaths; version: string } | { ok: false; reason: string } { + const staged = stagePluginAssetsToWsl(distro, sourceDir, "qoder", { + includeForwardRuntime: true, + }); + if (!staged.ok) return staged; + + const linuxPluginDir = staged.linuxPluginDir; + const linuxSettingsPath = `${linuxPluginDir}/settings.json`; + const linuxForwardPath = `${linuxPluginDir}/forward.mjs`; + const headExpression = buildWslHookCommandHead(resolvedNodePath, linuxForwardPath); + + const uncSettingsPath = toWslUncPath(distro, linuxSettingsPath); + try { + mkdirSync(dirname(uncSettingsPath), { recursive: true }); + const settings = renderQoderSettings(headExpression); + writeFileSync(uncSettingsPath, JSON.stringify(settings, null, 2), "utf8"); + } catch (error) { + return { + ok: false, + reason: `failed to write Qoder settings.json in wsl distro ${distro}: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + + console.log( + `[supervisor] Qoder hook plugin staged v${manifest.version} in WSL distro ${distro} at ${linuxPluginDir} (forward.mjs, settings.json) using node=${resolvedNodePath}`, + ); + + return { + ok: true, + version: manifest.version, + paths: { + pluginDir: linuxPluginDir, + settingsPath: linuxSettingsPath, + version: manifest.version, + }, + }; +} + +/** + * Read whether the plugin is already installed at the canonical staging path + * for the given environment. + */ +export function isQoderPluginInstalled(ctx?: AgentEnvContext): { + installed: boolean; + version?: string; +} { + if (isWslPluginContext(ctx)) { + const wsl = getWslPluginBaseDirs(ctx.wslDistro, "qoder"); + if (!wsl) return { installed: false }; + return verifyQoderInstallAt(wsl.uncBase, "wsl"); + } + return verifyQoderInstallAt(getNativePluginBaseDir("qoder", ctx?.baseDir), "native"); +} + +export function uninstallQoderPlugin(ctx?: AgentEnvContext): void { + removeStagedPluginDir("qoder", ctx); +} + +function verifyQoderInstallAt( + readableDir: string, + target: "native" | "wsl", +): { installed: boolean; version?: string } { + if (!existsSync(join(readableDir, "plugin.json"))) return { installed: false }; + if (!existsSync(join(readableDir, "forward.mjs"))) return { installed: false }; + if (!existsSync(join(readableDir, FORWARD_RUNTIME_FILE))) return { installed: false }; + if (!existsSync(join(readableDir, "settings.json"))) return { installed: false }; + if (!hasNativeHookWrapper(readableDir, target)) return { installed: false }; + try { + const version = readPluginManifest(readableDir).version; + return { installed: true, version }; + } catch { + return { installed: false }; + } +} + +interface QoderHookEntry { + hooks: Array<{ type: "command"; command: string }>; +} + +interface QoderSettings { + hooks: Record; +} + +/** + * Default hooks: intents we forward for sidebar status detection. + * Qoder fires `Stop` when the main agent finishes responding; user interrupts + * surface through `StopFailure` / the absence of `Stop`, mirroring Claude. + * A matcher is omitted everywhere — Qoder matches all when it's absent. + */ +const QODER_HOOK_EVENTS: readonly string[] = [ + "SessionStart", + "UserPromptSubmit", + "PermissionRequest", + "PostToolUse", + "PostToolUseFailure", + "ElicitationResult", + "Notification", + "Stop", + "StopFailure", +]; + +/** + * Build the Qoder `--settings` document. `headExpression` is the fully + * quoted command prefix to which we append ` ` per hook — caller + * decides whether that's a native wrapper path or a WSL ` ` + * pair. + */ +function renderQoderSettings(headExpression: string): QoderSettings { + const hooks: Record = {}; + for (const event of QODER_HOOK_EVENTS) { + hooks[event] = [{ hooks: [{ type: "command", command: `${headExpression} ${event}` }] }]; + } + return { hooks }; +} diff --git a/src/supervisor/agents/qoder/plugin/intentMap.test.ts b/src/supervisor/agents/qoder/plugin/intentMap.test.ts new file mode 100644 index 00000000..955f0a2b --- /dev/null +++ b/src/supervisor/agents/qoder/plugin/intentMap.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { qoderIntentFor } from "./intentMap"; + +describe("qoderIntentFor", () => { + it("maps turn lifecycle events to working and finished states", () => { + expect(qoderIntentFor("UserPromptSubmit", undefined)).toBe("session.turn_started"); + expect(qoderIntentFor("Stop", undefined)).toBe("session.turn_finished"); + expect(qoderIntentFor("StopFailure", undefined)).toBe("session.turn_errored"); + }); + + it("maps permission and tool events to approval-aware states", () => { + expect(qoderIntentFor("PermissionRequest", undefined)).toBe("session.needs_approval"); + expect(qoderIntentFor("PostToolUse", undefined)).toBe("session.turn_started"); + expect(qoderIntentFor("PostToolUseFailure", undefined)).toBe("session.turn_started"); + }); + + it("maps only idle_prompt notifications to needs_reply", () => { + expect(qoderIntentFor("Notification", { notification_type: "idle_prompt" })).toBe( + "session.needs_reply", + ); + expect( + qoderIntentFor("Notification", { notification_type: "permission_prompt" }), + ).toBeUndefined(); + expect(qoderIntentFor("Notification", undefined)).toBeUndefined(); + }); + + it("maps declined or cancelled elicitations to turn_finished", () => { + expect(qoderIntentFor("ElicitationResult", { action: "decline" })).toBe( + "session.turn_finished", + ); + expect(qoderIntentFor("ElicitationResult", { action: "cancel" })).toBe("session.turn_finished"); + expect(qoderIntentFor("ElicitationResult", { action: "accept" })).toBeUndefined(); + }); + + it("maps session start and ignores unknown events", () => { + expect(qoderIntentFor("SessionStart", undefined)).toBe("session.started"); + expect(qoderIntentFor("PreCompact", undefined)).toBeUndefined(); + }); +}); diff --git a/src/supervisor/agents/qoder/plugin/intentMap.ts b/src/supervisor/agents/qoder/plugin/intentMap.ts new file mode 100644 index 00000000..5b67f03d --- /dev/null +++ b/src/supervisor/agents/qoder/plugin/intentMap.ts @@ -0,0 +1,61 @@ +import type { AgentEventIntent } from "@/shared/contracts"; + +/** + * Translate a Qoder CLI hook event name + raw payload into the universal + * Poracode `intent` vocabulary. Any event we don't care about returns + * `undefined` and the forwarder simply exits 0 without POSTing. + * + * This file is the only Qoder-specific surface in the plugin pipeline: + * the supervisor-side handler is provider-agnostic, and `forward.mjs` only + * formats the universal envelope. + * + * Kept dependency-free so the same source can be transpiled to ESM and + * re-exported as `intentMap.mjs` next to `forward.mjs` at install time. + */ +export function qoderIntentFor( + eventName: string, + payload: + | { + hook_event_name?: string; + notification_type?: string; + action?: string; + } + | undefined, +): AgentEventIntent | undefined { + switch (eventName) { + case "SessionStart": + return "session.started"; + case "UserPromptSubmit": + return "session.turn_started"; + case "PermissionRequest": + return "session.needs_approval"; + // Tool finished (approve path) — exit `needs_approval`, still mid-turn. + case "PostToolUse": + return "session.turn_started"; + // Tool execution failed; Qoder recovers and `Stop` will close the turn. + case "PostToolUseFailure": + return "session.turn_started"; + case "ElicitationResult": { + const a = payload?.action; + if (a === "cancel" || a === "decline") { + return "session.turn_finished"; + } + return undefined; + } + case "Notification": { + // Qoder's `Notification` fires for permission prompts, auth, and + // elicitation too; only `idle_prompt` (assistant idle waiting on the + // human) maps to `needs_reply`. + if (payload?.notification_type === "idle_prompt") { + return "session.needs_reply"; + } + return undefined; + } + case "Stop": + return "session.turn_finished"; + case "StopFailure": + return "session.turn_errored"; + default: + return undefined; + } +} diff --git a/src/supervisor/agents/qoder/plugin/plugin.json b/src/supervisor/agents/qoder/plugin/plugin.json new file mode 100644 index 00000000..272b5c0d --- /dev/null +++ b/src/supervisor/agents/qoder/plugin/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "poracode-status", + "version": "1.0.0", + "description": "Forward Qoder CLI lifecycle events to Poracode for sidebar status detection.", + "author": "Poracode", + "license": "Apache-2.0" +} diff --git a/src/supervisor/agents/qoder/qoder.test.ts b/src/supervisor/agents/qoder/qoder.test.ts new file mode 100644 index 00000000..4c41b917 --- /dev/null +++ b/src/supervisor/agents/qoder/qoder.test.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ProjectLocation, ThreadConfig } from "@/shared/contracts"; +import { createQoderAdapter } from "."; +import { buildQoderArgs, QODER_DEFAULT_MODEL_ID } from "./argv"; +import { buildQoderProbeCapabilities, QODER_AUTH_ENV_KEYS, qoderDetectionSpec } from "./detection"; +import { detectQoderInvalidSessionRef } from "./session"; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("buildQoderArgs", () => { + it("builds an interactive plan-mode launch with a preassigned session", () => { + expect( + buildQoderArgs( + { model: QODER_DEFAULT_MODEL_ID, mode: "plan" }, + "hello", + undefined, + "session-id", + ), + ).toEqual([ + "--session-id", + "session-id", + "--model", + QODER_DEFAULT_MODEL_ID, + "--permission-mode", + "plan", + "--prompt-interactive", + "hello", + ]); + }); + + it.each([ + ["acceptEdits", "accept_edits"], + ["auto_edit", "accept_edits"], + ["auto", "auto"], + ["dont_ask", "dont_ask"], + ["default", "default"], + ["never", "bypass_permissions"], + ["bypassPermissions", "bypass_permissions"], + [undefined, "default"], + ] as const)("maps approval policy %s to %s", (approvalPolicy, expected) => { + const config: ThreadConfig = { + model: QODER_DEFAULT_MODEL_ID, + ...(approvalPolicy ? { approvalPolicy } : {}), + }; + const args = buildQoderArgs(config, ""); + expect(args.slice(-2)).toEqual(["--permission-mode", expected]); + }); + + it("passes the configured reasoning effort through", () => { + const args = buildQoderArgs({ model: "ultimate", effort: "high" }, ""); + expect(args).toContain("--reasoning-effort"); + expect(args[args.indexOf("--reasoning-effort") + 1]).toBe("high"); + }); +}); + +describe("createQoderAdapter", () => { + const project: ProjectLocation = { kind: "windows", path: "C:\\demo" }; + const config: ThreadConfig = { model: QODER_DEFAULT_MODEL_ID }; + + it("preassigns a stable session ID and resumes that exact ID", () => { + const adapter = createQoderAdapter(); + const launch = adapter.buildLaunchArgv(project, config, "hello"); + const sessionIndex = launch.args.indexOf("--session-id"); + const sessionId = launch.args[sessionIndex + 1]; + + expect(launch.binary).toBe("qodercli"); + expect(sessionId).toMatch(UUID_RE); + expect(launch.sessionRef?.providerSessionId).toBe(sessionId); + + const resume = adapter.buildResumeArgv(project, config, "again", launch.sessionRef!); + expect(resume.args).toContain("--resume"); + expect(resume.args).toContain(sessionId); + expect(resume.args).not.toContain("--session-id"); + }); + + it("exposes terminal and ACP runtimes, updater metadata, and Qoder skill roots", () => { + const adapter = createQoderAdapter(); + expect(adapter.capabilities.modes).toEqual(["agent", "plan"]); + expect(adapter.capabilities.defaultApprovalPolicy).toBe("default"); + expect(adapter.capabilities.bypassPermissions).toEqual({ + approvalPolicy: "bypassPermissions", + }); + expect(adapter.capabilities.presentationModes).toEqual(["terminal", "gui"]); + expect(adapter.capabilities.mcpScope).toEqual({ terminal: "none", gui: "launch" }); + expect(adapter.createStructuredSession).toBeTypeOf("function"); + expect(adapter.update).toEqual({ + builtIn: { binary: "qodercli", args: ["update"] }, + npm: "@qoder-ai/qodercli", + }); + expect(adapter.skillSupport).toMatchObject({ + roots: [ + { id: "qoder", globalPath: ".qoder/skills", projectPath: ".qoder/skills" }, + { id: "agents", globalPath: ".agents/skills", projectPath: ".agents/skills" }, + ], + invocation: "slash", + precedence: { + global: ["qoder", "agents"], + project: ["qoder", "agents"], + }, + }); + }); + + it("uses read-only plan mode for one-shot utility prompts", () => { + expect(createQoderAdapter().buildOneShotCommand?.("", "", "title")).toEqual({ + command: "qodercli", + args: ["-p", "title", "--model", QODER_DEFAULT_MODEL_ID, "--permission-mode", "plan"], + stdin: "", + }); + }); +}); + +describe("buildQoderProbeCapabilities", () => { + it("overlays probed models, efforts, modes, policies, and slash commands", () => { + const capabilities = buildQoderProbeCapabilities({ + models: [ + { id: "auto", label: "Auto (default)" }, + { id: "ultimate", label: "Ultimate" }, + ], + efforts: ["xhigh", "high", "low", "max", "medium", "none"], + defaultEffort: "xhigh", + modes: ["agent", "plan"], + approvalPolicies: [ + { id: "default", label: "Default" }, + { id: "acceptEdits", label: "Accept Edits" }, + { id: "bypassPermissions", label: "Bypass Permissions" }, + ], + slashCommands: [{ id: "quest", label: "quest — workflow orchestrator" }], + }); + + expect(capabilities.models?.map((model) => model.id)).toEqual(["auto", "ultimate"]); + expect(capabilities.efforts).toEqual(["xhigh", "high", "low", "max", "medium", "none"]); + expect(capabilities.defaultEffort).toBe("xhigh"); + expect(capabilities.modes).toEqual(["agent", "plan"]); + expect(capabilities.approvalPolicies).toHaveLength(3); + expect(capabilities.slashCommands).toHaveLength(1); + expect(capabilities.preferTerminalLogin).toBe(true); + }); + + it("keeps static defaults and never forwards a session-derived auth state", () => { + const capabilities = buildQoderProbeCapabilities({ + authState: "authenticated", + }); + + expect(capabilities.models).toEqual(qoderDetectionSpec.capabilities.models); + expect(capabilities.efforts).toEqual(qoderDetectionSpec.capabilities.efforts); + expect(capabilities.approvalPolicies).toEqual(qoderDetectionSpec.capabilities.approvalPolicies); + expect(capabilities.authState).toBeUndefined(); + }); +}); + +describe("Qoder authentication detection", () => { + it("recognizes the personal access token variable", async () => { + vi.stubEnv("QODER_PERSONAL_ACCESS_TOKEN", "qoder-token"); + + await expect( + qoderDetectionSpec.authProbes?.[0]?.({ + location: { kind: "windows", path: "C:\\demo" }, + executablePath: "qodercli", + }), + ).resolves.toBe("authenticated"); + }); + + it("does not treat unrelated Qwen env keys as Qoder auth", async () => { + for (const key of QODER_AUTH_ENV_KEYS) vi.stubEnv(key, ""); + vi.stubEnv("DASHSCOPE_API_KEY", "dashscope-key"); + + await expect( + qoderDetectionSpec.authProbes?.[0]?.({ + location: { kind: "windows", path: "C:\\demo" }, + executablePath: "qodercli", + }), + ).resolves.toBe("unknown"); + }); +}); + +describe("buildQoderArgs edge cases", () => { + it("omits --prompt-interactive for whitespace-only prompts", () => { + const args = buildQoderArgs({ model: "auto" }, " "); + expect(args).not.toContain("--prompt-interactive"); + }); + + it("omits --prompt-interactive for empty prompts", () => { + const args = buildQoderArgs({ model: "auto" }, ""); + expect(args).not.toContain("--prompt-interactive"); + }); + + it("prefers --resume over --session-id when both are provided", () => { + const args = buildQoderArgs({ model: "auto" }, "hi", "resume-id", "assigned-id"); + expect(args).toContain("--resume"); + expect(args).toContain("resume-id"); + expect(args).not.toContain("--session-id"); + expect(args).not.toContain("assigned-id"); + }); + + it("falls back to the default model when config.model is empty", () => { + const args = buildQoderArgs({ model: "" }, "hi"); + const modelIndex = args.indexOf("--model"); + expect(args[modelIndex + 1]).toBe(QODER_DEFAULT_MODEL_ID); + }); + + it("maps the yolo alias to bypass_permissions", () => { + const args = buildQoderArgs({ model: "auto", approvalPolicy: "yolo" }, ""); + expect(args.slice(-2)).toEqual(["--permission-mode", "bypass_permissions"]); + }); + + it("maps the auto-edit hyphenated alias to accept_edits", () => { + const args = buildQoderArgs({ model: "auto", approvalPolicy: "auto-edit" }, ""); + expect(args.slice(-2)).toEqual(["--permission-mode", "accept_edits"]); + }); + + it("omits --reasoning-effort when effort is undefined", () => { + const args = buildQoderArgs({ model: "auto" }, ""); + expect(args).not.toContain("--reasoning-effort"); + }); +}); + +describe("detectQoderInvalidSessionRef", () => { + it("detects Qoder resume failures", () => { + expect(detectQoderInvalidSessionRef('Invalid session identifier "missing".')).toBe(true); + expect(detectQoderInvalidSessionRef("Error resuming session: missing")).toBe(true); + expect(detectQoderInvalidSessionRef("Qoder ready")).toBe(false); + }); + + it("is case-insensitive", () => { + expect(detectQoderInvalidSessionRef("INVALID SESSION IDENTIFIER")).toBe(true); + expect(detectQoderInvalidSessionRef("error resuming session")).toBe(true); + }); + + it("detects the error embedded in longer output", () => { + expect( + detectQoderInvalidSessionRef("Loading config...\nError resuming session: abc-123\nExiting."), + ).toBe(true); + }); + + it("does not false-positive on partial matches", () => { + expect(detectQoderInvalidSessionRef("Invalid session")).toBe(false); + expect(detectQoderInvalidSessionRef("Error resuming")).toBe(false); + expect(detectQoderInvalidSessionRef("session identifier")).toBe(false); + }); +}); diff --git a/src/supervisor/agents/qoder/session.ts b/src/supervisor/agents/qoder/session.ts new file mode 100644 index 00000000..f28d3020 --- /dev/null +++ b/src/supervisor/agents/qoder/session.ts @@ -0,0 +1,5 @@ +const INVALID_SESSION_RE = /(?:Invalid session identifier|Error resuming session)/iu; + +export function detectQoderInvalidSessionRef(output: string): boolean { + return INVALID_SESSION_RE.test(output); +} diff --git a/src/supervisor/agents/registry.test.ts b/src/supervisor/agents/registry.test.ts index d19469f5..60000383 100644 --- a/src/supervisor/agents/registry.test.ts +++ b/src/supervisor/agents/registry.test.ts @@ -10,6 +10,7 @@ const EXPECTED_BUILT_IN_ORDER = [ "codex", "gemini", "qwen", + "qoder", "grok", "kimi", "antigravity", @@ -27,6 +28,7 @@ const EXPECTED_SUBAGENT_APPROVAL_POLICY: Record<(typeof EXPECTED_BUILT_IN_ORDER) codex: "never", gemini: "never", qwen: "never", + qoder: "bypassPermissions", grok: "bypassPermissions", kimi: "yolo", antigravity: "yolo", diff --git a/src/supervisor/agents/registry.ts b/src/supervisor/agents/registry.ts index a26b8b39..9d9ce75b 100644 --- a/src/supervisor/agents/registry.ts +++ b/src/supervisor/agents/registry.ts @@ -26,6 +26,7 @@ import { createGrokAdapter } from "./grok"; import { createKimiAdapter } from "./kimi"; import { createOpenCodeAdapter } from "./opencode"; import { createPiAdapter } from "./pi"; +import { createQoderAdapter } from "./qoder"; import { createQwenAdapter } from "./qwen"; export function createAgentRegistry(): AgentAdapter[] { @@ -44,6 +45,7 @@ export function buildAgentRegistry(userInstances: AgentInstanceConfig[]): AgentA createCodexAdapter(), createGeminiAdapter(), createQwenAdapter(), + createQoderAdapter(), createGrokAdapter(), createKimiAdapter(), createAntigravityAdapter(), diff --git a/src/supervisor/skills/SkillsService.ts b/src/supervisor/skills/SkillsService.ts index 209f197d..19142048 100644 --- a/src/supervisor/skills/SkillsService.ts +++ b/src/supervisor/skills/SkillsService.ts @@ -1294,10 +1294,16 @@ export class SkillsService { enabled: boolean, ): Promise { const moves: LinkedImportMove[] = []; - // macOS commonly exposes the temporary directory through `/var` while - // `realpath()` canonicalizes it to `/private/var`. Compare canonical paths - // on both sides so linked imports still follow their provider source. - const resolvedSourcePath = await realpath(sourcePath).catch(() => sourcePath); + // Compare canonical paths on hosts such as macOS where a caller-visible + // path (`/var/...`) can resolve through a system symlink (`/private/var/...`). + // Comparing the link target to the unresolved source path leaves the + // managed import behind as a broken symlink when the source is disabled. + let canonicalSourcePath: string; + try { + canonicalSourcePath = await realpath(sourcePath); + } catch { + return moves; + } for (const availability of ["shared", "poracode"] as const) { const managedRoot = this.managedRoot(environment, "global", availability); const sourceRoot = enabled ? disabledRoot(managedRoot.fsPath) : managedRoot.fsPath; @@ -1318,7 +1324,7 @@ export class SkillsService { } catch { continue; } - if (normalizePath(targetPath) !== normalizePath(resolvedSourcePath)) continue; + if (normalizePath(targetPath) !== normalizePath(canonicalSourcePath)) continue; const destinationPath = join(destinationRoot, linkName); if (await pathExists(destinationPath)) { throw new Error(`A linked skill named ${linkName} already exists in the destination.`); diff --git a/tests/integration/providers-lifecycle.integration.test.ts b/tests/integration/providers-lifecycle.integration.test.ts index 5fbe2800..8860aa54 100644 --- a/tests/integration/providers-lifecycle.integration.test.ts +++ b/tests/integration/providers-lifecycle.integration.test.ts @@ -42,6 +42,7 @@ const PREFERRED_MODEL: Record = { opencode: "opencode/big-pickle", kimi: "kimi-code/kimi-for-coding", qwen: "qwen3.8-max-preview", + qoder: "lite", }; const CHEAP_NAME_HINTS = ["haiku", "mini", "flash-lite", "flash", "lite", "small", "fast", "nano"];