diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx index 4f390f518..7fd55808f 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialog.tsx @@ -443,6 +443,10 @@ export function CreateAgentDialog({ runtime: selectedRuntimeId !== "custom" ? selectedRuntimeId : undefined, model: useMesh ? meshModelId.trim() || undefined : undefined, + // Carry the backend provider when the agent runs on one ("Run on" is + // a provider, not local). Mirrors what the agent input persists in + // `backend.id`; local agents leave it unset. + provider: isProviderMode ? runOn : undefined, envVars, }; try { diff --git a/desktop/src/features/agents/ui/personaDialogState.test.mjs b/desktop/src/features/agents/ui/personaDialogState.test.mjs index b4969aea0..fc3050a21 100644 --- a/desktop/src/features/agents/ui/personaDialogState.test.mjs +++ b/desktop/src/features/agents/ui/personaDialogState.test.mjs @@ -259,6 +259,7 @@ function makeAgent(overrides = {}) { lastStoppedAt: null, lastExitCode: null, lastError: null, + backend: { type: "local" }, ...overrides, }; } @@ -316,12 +317,26 @@ test("saveAsPersonaTemplateDialogState carries name, prompt, model, and envVars" assert.deepEqual(state.initialValues.namePool, []); }); -test("saveAsPersonaTemplateDialogState omits provider (no top-level field on ManagedAgent)", () => { - // A ManagedAgent has no top-level provider, so there is nothing lossless to - // carry — the builder must not invent one. - const state = saveAsPersonaTemplateDialogState(makeAgent(), []); +test("saveAsPersonaTemplateDialogState carries the provider id from a provider backend", () => { + // A databricks/anthropic agent must promote with its provider, not lose it. + // ManagedAgent has no top-level provider on main; it lives in backend.id. + const state = saveAsPersonaTemplateDialogState( + makeAgent({ backend: { type: "provider", id: "databricks", config: {} } }), + [], + ); - assert.equal("provider" in state.initialValues, false); + assert.equal(state.initialValues.provider, "databricks"); +}); + +test("saveAsPersonaTemplateDialogState leaves provider unset for a local backend", () => { + // A local backend has no provider; the persona's provider is optional, so it + // carries as undefined (auto-detect / provider-locked runtime). + const state = saveAsPersonaTemplateDialogState( + makeAgent({ backend: { type: "local" } }), + [], + ); + + assert.equal(state.initialValues.provider, undefined); }); test("saveAsPersonaTemplateDialogState tolerates null systemPrompt and model", () => { diff --git a/desktop/src/features/agents/ui/personaDialogState.ts b/desktop/src/features/agents/ui/personaDialogState.ts index 92764bba0..3e048b23a 100644 --- a/desktop/src/features/agents/ui/personaDialogState.ts +++ b/desktop/src/features/agents/ui/personaDialogState.ts @@ -76,21 +76,34 @@ function runtimeIdForAgentCommand( return match?.id; } +/** + * Extract the LLM provider id from a managed agent's backend so it can carry + * into the persona template (a databricks/anthropic agent should promote with + * its provider, not lose it). On `main` the provider is NOT a top-level field + * on `ManagedAgent`; it lives in the backend union: a `"provider"` backend + * carries the provider id, while a `"local"` backend has none. Returns + * `undefined` for local backends — the persona's `provider` is optional, so an + * absent provider just carries as unset (auto-detect / provider-locked runtime). + */ +function providerForAgentBackend( + backend: ManagedAgent["backend"], +): string | undefined { + return backend.type === "provider" ? backend.id : undefined; +} + /** * Dialog state for the opt-in "Save as persona template" action on an existing * agent. Prefills the persona editor from the agent so the user reviews and * confirms before a persona template is created — nothing is minted silently. * * Near-lossless promote: name, system prompt, model, and env vars copy - * straight across; the harness command reverse-maps to a runtime ID. - * `namePool` is persona-only and starts empty — the user can fill it in the - * same dialog (it's how a template bulk-adds bots later). + * straight across; the harness command reverse-maps to a runtime ID and the + * backend provider id carries into the persona's provider. `namePool` is + * persona-only and starts empty — the user can fill it in the same dialog + * (it's how a template bulk-adds bots later). * * Note: "persona template" is the UI name for what the backend calls a * `persona` (kind:30175). This builder produces a backend `CreatePersonaInput`. - * `provider` is intentionally not carried: a `ManagedAgent` does not expose a - * top-level provider field (it lives in backend/provider config), so there is - * nothing lossless to copy here. */ export function saveAsPersonaTemplateDialogState( agent: ManagedAgent, @@ -106,6 +119,9 @@ export function saveAsPersonaTemplateDialogState( systemPrompt: agent.systemPrompt ?? "", runtime: runtimeIdForAgentCommand(agent.agentCommand, runtimes), model: agent.model ?? undefined, + // Carry the provider from a `"provider"` backend; `"local"` backends + // have none and leave it unset (see providerForAgentBackend). + provider: providerForAgentBackend(agent.backend), // namePool is persona-only; start empty so the user fills it here. namePool: [], envVars: agent.envVars ?? {},