Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,38 @@ jobs:
retention-days: 7
path: packages/*/.artifacts/unit/junit.xml

desktop-smoke:
name: desktop-smoke (macos)
# The prevent-sleep smoke drives the real packaged main bundle through Playwright and asserts
# against pmset, so it needs a macOS runner with a window server; keep it out of the unit matrix.
runs-on: macos-14
defaults:
run:
shell: bash
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}

- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "24"

- name: Setup Bun
uses: ./.github/actions/setup-bun

- name: Build desktop main bundle
working-directory: packages/desktop
run: bun run build
timeout-minutes: 15

- name: Run prevent-sleep smoke
working-directory: packages/desktop
run: bun run test:prevent-sleep-smoke
timeout-minutes: 10

e2e:
name: e2e (${{ matrix.settings.name }})
strategy:
Expand Down
61 changes: 38 additions & 23 deletions packages/app/src/components/dialog-custom-provider-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ export type CustomProviderConfig = {

const npmForProtocol = (kind: ProviderProtocol | undefined) => (kind === "anthropic" ? ANTHROPIC : OPENAI_COMPATIBLE)

export function isAmbiguousProtocolError(error: unknown) {
const message =
error instanceof Error
? error.message
: error && typeof error === "object" && "message" in error && typeof error.message === "string"
? error.message
: ""
return message.includes("Provider protocol is ambiguous")
}

// Leading host labels that are generic service prefixes and make a poor provider id, so we skip past
// them to reach the brand label (api.deepseek.com -> "deepseek", not "api"). Kept deliberately small:
// only unambiguous service prefixes, never anything that could be a brand.
Expand Down Expand Up @@ -183,11 +193,14 @@ export function validateCustomProvider(input: ValidateArgs) {
// Zero-config path: when the user leaves id/name blank we derive them from the URL, so those
// fields are no longer required. Derivation needs a usable URL — if the URL itself is invalid we
// skip it and let urlError drive the failure instead of emitting a spurious id/name error.
const derived = !urlError && (!typedID || !typedName) ? deriveProviderIdentity({
baseURL,
existingProviderIDs: input.existingProviderIDs,
disabledProviders: input.disabledProviders,
}) : undefined
const derived =
!urlError && (!typedID || !typedName)
? deriveProviderIdentity({
baseURL,
existingProviderIDs: input.existingProviderIDs,
disabledProviders: input.disabledProviders,
})
: undefined
const providerID = typedID || derived?.providerID || ""
const name = typedName || derived?.name || ""

Expand Down Expand Up @@ -232,8 +245,7 @@ export function validateCustomProvider(input: ValidateArgs) {
const contextError = ctx && !/^\d+$/.test(ctx) ? input.t("provider.custom.error.context") : undefined
return { id: idError, name: nameError, context: contextError }
})
const modelsValid =
(discoveryMode || models.every((m) => !m.id && !m.name)) && models.every((m) => !m.context)
const modelsValid = (discoveryMode || models.every((m) => !m.id && !m.name)) && models.every((m) => !m.context)
const modelConfig = Object.fromEntries(
input.form.models.map((m) => {
const ctx = m.context.trim()
Expand Down Expand Up @@ -309,10 +321,9 @@ export function validateCustomProvider(input: ValidateArgs) {
}
}

// Build the dialog form state for editing an existing custom provider. Fields (URL/key/headers/name)
// come from the raw config entry; model rows are seeded from the RESOLVED provider so the user sees the
// actual context/reasoning/temperature values (a discovery provider has no models in config — its
// specs only exist post-resolve). Each row is pre-filled so edits override just those fields.
// Discovery results are runtime state, not durable config. In discovery mode only configured model
// overrides become editable rows; otherwise merely opening and saving the dialog would snapshot every
// cached model into config and keep revoked/removed models alive indefinitely.
export function formStateFromProvider(input: {
config: ProviderConfig
resolved: ResolvedProvider | undefined
Expand All @@ -321,20 +332,24 @@ export function formStateFromProvider(input: {
const headers = config.options?.headers
const headerRows =
headers && typeof headers === "object" && Object.keys(headers).length
? Object.entries(headers as Record<string, string>).map(([key, value]) =>
headerRow2(String(key), String(value)),
)
? Object.entries(headers as Record<string, string>).map(([key, value]) => headerRow2(String(key), String(value)))
: [headerRow()]

const resolvedModels = resolved?.models ?? {}
const modelRows = Object.entries(resolvedModels).map(([id, m]) =>
modelRow({
id,
name: m.name || id,
context: m.limit?.context ? String(m.limit.context) : "",
reasoning: !!m.capabilities?.reasoning,
temperature: !!m.capabilities?.temperature,
}),
const modelRows = Object.entries(config.discovery ? (config.models ?? {}) : (resolved?.models ?? {})).map(
([id, configured]) => {
const model = resolved?.models[id]
return modelRow({
id,
name: configured.name || model?.name || id,
context: configured.limit?.context
? String(configured.limit.context)
: model?.limit?.context
? String(model.limit.context)
: "",
reasoning: configured.reasoning ?? !!model?.capabilities?.reasoning,
temperature: configured.temperature ?? !!model?.capabilities?.temperature,
})
},
)

return {
Expand Down
53 changes: 53 additions & 0 deletions packages/app/src/components/dialog-custom-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,20 @@ import { describe, expect, test } from "bun:test"
import {
deriveProviderIdentity,
formStateFromProvider,
isAmbiguousProtocolError,
modelRow,
validateCustomProvider,
} from "./dialog-custom-provider-form"

const t = (key: string) => key

test("recognizes protocol ambiguity from SDK and Error response shapes", () => {
const message = "Provider protocol is ambiguous; select OpenAI-compatible or Anthropic explicitly"
expect(isAmbiguousProtocolError(new Error(message))).toBe(true)
expect(isAmbiguousProtocolError({ message })).toBe(true)
expect(isAmbiguousProtocolError(new Error("HTTP 404"))).toBe(false)
})

describe("validateCustomProvider", () => {
test("builds trimmed config payload", () => {
const result = validateCustomProvider({
Expand Down Expand Up @@ -290,6 +298,7 @@ describe("formStateFromProvider", () => {
npm: "@ai-sdk/openai-compatible",
discovery: true,
options: { baseURL: "https://relay.example.com", apiKey: "secret", headers: { "X-Env": "prod" } },
models: { "gpt-4o": { name: "GPT-4o" } },
},
resolved: {
id: "relay",
Expand Down Expand Up @@ -337,6 +346,50 @@ describe("formStateFromProvider", () => {
temperature: true,
})
})

test("does not turn discovered runtime models into durable overrides", () => {
const form = formStateFromProvider({
config: {
name: "My Relay",
npm: "@ai-sdk/openai-compatible",
discovery: true,
options: { baseURL: "https://relay.example.com", apiKey: "secret" },
},
resolved: {
id: "relay",
name: "My Relay",
source: "custom",
env: [],
options: {},
models: {
stale: {
id: "stale",
providerID: "relay",
api: { id: "stale", url: "", npm: "@ai-sdk/openai-compatible" },
name: "Stale",
capabilities: {
temperature: false,
reasoning: false,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 0, output: 0 },
status: "active",
options: {},
headers: {},
release_date: "",
},
},
},
})

expect(form.models).toHaveLength(1)
expect(form.models[0]).toMatchObject({ id: "", name: "" })
})
})

describe("deriveProviderIdentity", () => {
Expand Down
20 changes: 17 additions & 3 deletions packages/app/src/components/dialog-custom-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
deriveProviderIdentity,
formStateFromProvider,
headerRow,
isAmbiguousProtocolError,
modelRow,
validateCustomProvider,
} from "./dialog-custom-provider-form"
Expand Down Expand Up @@ -244,8 +245,10 @@ export function DialogCustomProvider(props: Props) {
const choice = protocolChoice()
// Defensive client-side timeout: the backend already caps its /models fetch, but guard the
// whole round-trip too so a stalled request can never leave the submit button hung. On
// timeout (or any error) we fall through to manual/validation handling instead of blocking.
const res = await Promise.race([
// A timeout or ordinary discovery failure falls through to manual validation. Protocol
// ambiguity is different: continuing would either guess the wrong SDK or fail with no useful
// feedback, so require an explicit protocol choice.
const discovery = await Promise.race([
serverSDK.client.provider.models
.discover(
{
Expand All @@ -259,7 +262,18 @@ export function DialogCustomProvider(props: Props) {
)
.then((res) => res.data),
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), 20_000)),
]).catch(() => undefined)
]).then(
(data) => ({ data, error: undefined }),
(error: unknown) => ({ data: undefined, error }),
)
if (choice === "auto" && isAmbiguousProtocolError(discovery.error)) {
showToast({
title: language.t("common.requestFailed"),
description: language.t("provider.custom.error.protocol.ambiguous"),
})
return
}
const res = discovery.data
if (res?.kind) setDetectedProtocol(res.kind)
const discovered = res?.models ?? []
if (discovered.length > 0 && !hasManualModels) {
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/components/provider-model-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ describe("provider model refresh", () => {
test("allows official, discovery, and legacy imported providers", () => {
expect(canRefreshProviderModels("openai", undefined)).toBe(true)
expect(canRefreshProviderModels("custom", { discovery: true })).toBe(true)
expect(
canRefreshProviderModels("grouped", {
groups: { anthropic: { npm: "@ai-sdk/anthropic", discovery: true } },
}),
).toBe(true)
expect(
canRefreshProviderModels("mistral", {
options: { baseURL: "https://api.mistral.ai/v1" },
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/components/provider-model-refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ProviderConfig } from "@deepagent-code/sdk/v2"
export function canRefreshProviderModels(providerID: string, config: ProviderConfig | undefined) {
if (isOfficialProvider(providerID)) return true
if (config?.discovery === true) return true
if (Object.values(config?.groups ?? {}).some((group) => group.discovery === true)) return true
return (
config?.npm === undefined &&
typeof config?.options?.baseURL === "string" &&
Expand Down
34 changes: 34 additions & 0 deletions packages/app/src/components/settings-v2/general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ export const SettingsGeneralV2: Component = () => {
{ initialValue: false },
)

const [preventSleep, { mutate: setPreventSleep }] = createResource(
() => (desktop() && platform.getPreventSleepEnabled ? true : false),
() => Promise.resolve(platform.getPreventSleepEnabled?.() ?? true).catch(() => true),
{ initialValue: true },
)

onMount(() => {
void theme.loadThemes()
})
Expand Down Expand Up @@ -252,6 +258,13 @@ export const SettingsGeneralV2: Component = () => {
void update.catch(() => setPinchZoom(!checked))
}

const onPreventSleepChange = (checked: boolean) => {
setPreventSleep(checked)
const update = platform.setPreventSleepEnabled?.(checked)
if (!update) return
void update.catch(() => setPreventSleep(!checked))
}

const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") },
{ value: "light", label: language.t("theme.scheme.light") },
Expand Down Expand Up @@ -915,6 +928,25 @@ export const SettingsGeneralV2: Component = () => {
</Show>
)

const PowerSection = () => (
<Show when={desktop()}>
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.power")}</h3>

<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.preventSleep.title")}
description={language.t("settings.general.row.preventSleep.description")}
>
<div data-action="settings-prevent-sleep">
<Switch checked={preventSleep.latest} onChange={onPreventSleepChange} />
</div>
</SettingsRowV2>
</SettingsListV2>
</div>
</Show>
)

return (
<>
<div class="settings-v2-tab-header">
Expand All @@ -940,6 +972,8 @@ export const SettingsGeneralV2: Component = () => {

<DisplaySection />

<PowerSection />

<ImportSection />
</div>
</>
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/context/platform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ type PlatformBase = {
/** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
setPinchZoomEnabled?(enabled: boolean): Promise<void> | void

/** Get whether the app keeps the system awake while running (desktop only) */
getPreventSleepEnabled?(): Promise<boolean> | boolean

/** Keep the system awake while the app is running (desktop only) */
setPreventSleepEnabled?(enabled: boolean): Promise<void> | void

/** Run a desktop-only menu action from the app chrome */
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void

Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,7 @@ export const dict = {
"settings.general.row.expertPanelDefault.title": "تفعيل لجنة الخبراء افتراضيًا",
"settings.general.row.expertPanelDefault.description": "بدء المحادثات الجديدة مع تفعيل لجنة الخبراء، بحيث يبدأ زرها مراجعة السياق الحالي عند الطلب.",
"settings.general.section.display": "شاشة العرض",
"settings.general.section.power": "الطاقة",
"settings.general.row.language.title": "اللغة",
"settings.general.row.language.description": "تغيير لغة العرض لـ DeepAgent Code",
"settings.general.row.appearance.title": "المظهر",
Expand Down Expand Up @@ -930,6 +931,8 @@ export const dict = {
"تفعيل التخطيط والصفحة الرئيسية والمحرر وواجهة الجلسة المعاد تصميمها",
"settings.general.row.pinchZoom.title": "التكبير بالقرص",
"settings.general.row.pinchZoom.description": "السماح بإيماءات قرص لوحة اللمس و Ctrl-تمرير للتكبير",
"settings.general.row.preventSleep.title": "منع سكون النظام",
"settings.general.row.preventSleep.description": "إبقاء النظام مستيقظًا أثناء تشغيل التطبيق؛ ويمكن أن تنطفئ الشاشة",
"settings.general.row.zoom.title": "Zoom level",
"settings.general.row.zoom.description": "Set the interface zoom level",
"settings.updates.action.downloading": "جار التنزيل...",
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/br.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@ export const dict = {
"settings.general.row.expertPanelDefault.title": "Ativar painel de especialistas por padrão",
"settings.general.row.expertPanelDefault.description": "Iniciar novas conversas com o painel de especialistas ativado, para que seu botão inicie uma revisão do contexto atual sob demanda.",
"settings.general.section.display": "Tela",
"settings.general.section.power": "Energia",
"settings.general.row.language.title": "Idioma",
"settings.general.row.language.description": "Alterar o idioma de exibição do DeepAgent Code",
"settings.general.row.appearance.title": "Aparência",
Expand Down Expand Up @@ -954,6 +955,8 @@ export const dict = {
"settings.general.row.pinchZoom.title": "Pinçar para zoom",
"settings.general.row.pinchZoom.description":
"Permitir gestos de pellizco en trackpad y Ctrl-desplazamiento para ampliar",
"settings.general.row.preventSleep.title": "Impedir suspensão do sistema",
"settings.general.row.preventSleep.description": "Manter o sistema ativo enquanto o app está em execução; a tela ainda pode desligar",
"settings.general.row.zoom.title": "Zoom level",
"settings.general.row.zoom.description": "Set the interface zoom level",
"settings.updates.action.downloading": "Baixando...",
Expand Down
Loading
Loading