diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index baffe0ced..cbd2cd4d9 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -30,7 +30,7 @@ const rules = [ // Exceptions should stay rare and temporary. Prefer splitting files instead. const overrides = new Map([ - ["src-tauri/src/managed_agents/persona_card.rs", 700], // PNG/ZIP persona card codec + 21 unit tests (~300 lines of tests) + ["src-tauri/src/managed_agents/persona_card.rs", 760], // PNG/ZIP persona card codec + provider/model fields + 27 unit tests (~350 lines of tests) ["src/app/AppShell.tsx", 775], ["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation ["src/features/channels/ui/ChannelManagementSheet.tsx", 800], @@ -42,9 +42,10 @@ const overrides = new Map([ ["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions ["src-tauri/src/commands/agents.rs", 820], // remote agent lifecycle routing (local + provider branches) + scope enforcement ["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests - ["src/features/agents/ui/AgentsView.tsx", 775], // remote agent stop/delete + channel UUID resolution + presence-aware delete guard + persona/team import + ["src/features/agents/ui/AgentsView.tsx", 790], // remote agent stop/delete + channel UUID resolution + presence-aware delete guard + persona/team import + provider/model fields ["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes - ["src/features/channels/ui/AddChannelBotDialog.tsx", 600], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement + ["src/features/channels/ui/AddChannelBotDialog.tsx", 640], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display + ["src/shared/api/types.ts", 515], // persona provider/model fields + forum types ]); async function walkFiles(directory) { diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs index 05465b15c..0e3a35029 100644 --- a/desktop/src-tauri/src/commands/personas.rs +++ b/desktop/src-tauri/src/commands/personas.rs @@ -45,6 +45,8 @@ pub fn create_persona( let display_name = trim_required(&input.display_name, "Display name")?; let system_prompt = trim_required(&input.system_prompt, "System prompt")?; let avatar_url = trim_optional(input.avatar_url); + let provider = trim_optional(input.provider); + let model = trim_optional(input.model); let now = now_iso(); let _store_guard = state @@ -57,6 +59,8 @@ pub fn create_persona( display_name, avatar_url, system_prompt, + provider, + model, is_builtin: false, created_at: now.clone(), updated_at: now, @@ -75,6 +79,8 @@ pub fn update_persona( let display_name = trim_required(&input.display_name, "Display name")?; let system_prompt = trim_required(&input.system_prompt, "System prompt")?; let avatar_url = trim_optional(input.avatar_url); + let provider = trim_optional(input.provider); + let model = trim_optional(input.model); let _store_guard = state .managed_agents_store_lock @@ -93,6 +99,8 @@ pub fn update_persona( persona.display_name = display_name; persona.avatar_url = avatar_url; persona.system_prompt = system_prompt; + persona.provider = provider; + persona.model = model; persona.updated_at = now_iso(); save_personas(&app, &personas)?; @@ -215,7 +223,7 @@ pub async fn export_persona_to_json( state: State<'_, AppState>, ) -> Result { // Load persona data under lock, then drop lock before dialog. - let (display_name, system_prompt, avatar_url) = { + let (display_name, system_prompt, avatar_url, provider, model) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -229,11 +237,18 @@ pub async fn export_persona_to_json( persona.display_name.clone(), persona.system_prompt.clone(), persona.avatar_url.clone(), + persona.provider.clone(), + persona.model.clone(), ) }; - let json_bytes = - encode_persona_json(&display_name, &system_prompt, avatar_url.as_deref())?; + let json_bytes = encode_persona_json( + &display_name, + &system_prompt, + avatar_url.as_deref(), + provider.as_deref(), + model.as_deref(), + )?; let slug = crate::util::slugify(&display_name, "persona", 50); let filename = format!("{slug}.persona.json"); diff --git a/desktop/src-tauri/src/managed_agents/persona_card.rs b/desktop/src-tauri/src/managed_agents/persona_card.rs index 5f2ee9beb..5b6a01b10 100644 --- a/desktop/src-tauri/src/managed_agents/persona_card.rs +++ b/desktop/src-tauri/src/managed_agents/persona_card.rs @@ -13,6 +13,8 @@ pub struct ParsedPersonaPreview { pub display_name: String, pub system_prompt: String, pub avatar_data_url: Option, + pub provider: Option, + pub model: Option, pub source_file: String, } @@ -55,7 +57,7 @@ pub fn parse_png_persona(png_bytes: &[u8]) -> Result Result Result { serde_json::from_slice(&bytes).map_err(|e| format!("Invalid JSON: {e}")) } -/// Extract and validate `displayName` + `systemPrompt` from a Sprout persona -/// JSON value (shared by both the PNG tEXt-chunk path and the standalone JSON path). -fn extract_sprout_fields(v: &Value) -> Result<(String, String), String> { +/// Extracted fields from a Sprout persona JSON payload. +struct SproutPersonaFields { + display_name: String, + system_prompt: String, + avatar_url: Option, + provider: Option, + model: Option, +} + +/// Extract and validate fields from a Sprout persona JSON value +/// (shared by both the PNG tEXt-chunk path and the standalone JSON path). +fn extract_sprout_fields(v: &Value) -> Result { let version = v.get("version").and_then(|v| v.as_u64()).unwrap_or(0); if version != 1 { return Err(format!("Unsupported persona version: {version}")); @@ -105,15 +120,39 @@ fn extract_sprout_fields(v: &Value) -> Result<(String, String), String> { if prompt.is_empty() { return Err("systemPrompt is empty".to_string()); } - Ok((name, prompt)) + let avatar_url = v + .get("avatarUrl") + .and_then(|v| v.as_str()) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + let provider = v + .get("provider") + .and_then(|v| v.as_str()) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + let model = v + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + Ok(SproutPersonaFields { + display_name: name, + system_prompt: prompt, + avatar_url, + provider, + model, + }) } -fn parse_sprout_payload(b64: &str) -> Result<(String, String), String> { +fn parse_sprout_payload(b64: &str) -> Result { let v = decode_b64_json(b64)?; extract_sprout_fields(&v) } -fn parse_chara_payload(b64: &str) -> Result<(String, String), String> { +fn parse_chara_payload(b64: &str) -> Result { let v = decode_b64_json(b64)?; let data = v.get("data").ok_or("Missing 'data' in chara payload")?; let name = data @@ -142,7 +181,13 @@ fn parse_chara_payload(b64: &str) -> Result<(String, String), String> { if prompt.is_empty() { return Err("Chara card has no system_prompt or description".to_string()); } - Ok((name, prompt)) + Ok(SproutPersonaFields { + display_name: name, + system_prompt: prompt, + avatar_url: None, + provider: None, + model: None, + }) } // --------------------------------------------------------------------------- @@ -152,19 +197,14 @@ fn parse_chara_payload(b64: &str) -> Result<(String, String), String> { pub fn parse_json_persona(json_bytes: &[u8]) -> Result { let v: Value = serde_json::from_slice(json_bytes).map_err(|e| format!("Invalid JSON: {e}"))?; - let (name, prompt) = extract_sprout_fields(&v)?; - - let avatar_data_url = v - .get("avatarUrl") - .and_then(|v| v.as_str()) - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); + let fields = extract_sprout_fields(&v)?; Ok(ParsedPersonaPreview { - display_name: name, - system_prompt: prompt, - avatar_data_url, + display_name: fields.display_name, + system_prompt: fields.system_prompt, + avatar_data_url: fields.avatar_url, + provider: fields.provider, + model: fields.model, source_file: String::new(), }) } @@ -173,6 +213,8 @@ pub fn encode_persona_json( display_name: &str, system_prompt: &str, avatar_url: Option<&str>, + provider: Option<&str>, + model: Option<&str>, ) -> Result, String> { let mut map = serde_json::Map::new(); map.insert("version".to_string(), serde_json::json!(1)); @@ -187,6 +229,12 @@ pub fn encode_persona_json( if let Some(url) = avatar_url { map.insert("avatarUrl".to_string(), serde_json::json!(url)); } + if let Some(p) = provider { + map.insert("provider".to_string(), serde_json::json!(p)); + } + if let Some(m) = model { + map.insert("model".to_string(), serde_json::json!(m)); + } serde_json::to_vec_pretty(&map).map_err(|e| format!("Failed to serialize JSON: {e}")) } @@ -550,7 +598,7 @@ mod tests { #[test] fn parse_json_round_trip() { let bytes = - encode_persona_json("Ada Lovelace", "You are Ada.", Some("https://example.com/ada.png")) + encode_persona_json("Ada Lovelace", "You are Ada.", Some("https://example.com/ada.png"), None, None) .unwrap(); let result = parse_json_persona(&bytes).unwrap(); assert_eq!(result.display_name, "Ada Lovelace"); @@ -564,7 +612,7 @@ mod tests { #[test] fn parse_json_round_trip_no_avatar() { - let bytes = encode_persona_json("Bob", "You are Bob.", None).unwrap(); + let bytes = encode_persona_json("Bob", "You are Bob.", None, None, None).unwrap(); let result = parse_json_persona(&bytes).unwrap(); assert_eq!(result.display_name, "Bob"); assert_eq!(result.system_prompt, "You are Bob."); @@ -574,12 +622,55 @@ mod tests { #[test] fn parse_json_round_trip_data_uri_avatar() { let data_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=="; - let bytes = encode_persona_json("Carol", "You are Carol.", Some(data_uri)).unwrap(); + let bytes = encode_persona_json("Carol", "You are Carol.", Some(data_uri), None, None).unwrap(); let result = parse_json_persona(&bytes).unwrap(); assert_eq!(result.display_name, "Carol"); assert_eq!(result.avatar_data_url.as_deref(), Some(data_uri)); } + #[test] + fn parse_json_round_trip_with_provider_and_model() { + let bytes = encode_persona_json( + "Agent Smith", + "You are an agent.", + None, + Some("goose"), + Some("claude-sonnet-4"), + ) + .unwrap(); + let result = parse_json_persona(&bytes).unwrap(); + assert_eq!(result.display_name, "Agent Smith"); + assert_eq!(result.system_prompt, "You are an agent."); + assert!(result.avatar_data_url.is_none()); + assert_eq!(result.provider.as_deref(), Some("goose")); + assert_eq!(result.model.as_deref(), Some("claude-sonnet-4")); + } + + #[test] + fn parse_json_round_trip_without_provider_and_model() { + let bytes = encode_persona_json("Bob", "You are Bob.", None, None, None).unwrap(); + let result = parse_json_persona(&bytes).unwrap(); + assert_eq!(result.display_name, "Bob"); + assert!(result.provider.is_none()); + assert!(result.model.is_none()); + } + + #[test] + fn parse_json_backward_compat_no_provider_model_fields() { + // Simulate a legacy persona JSON without provider/model fields + let json = serde_json::json!({ + "version": 1, + "displayName": "Legacy Persona", + "systemPrompt": "Old school prompt" + }); + let bytes = serde_json::to_vec(&json).unwrap(); + let result = parse_json_persona(&bytes).unwrap(); + assert_eq!(result.display_name, "Legacy Persona"); + assert_eq!(result.system_prompt, "Old school prompt"); + assert!(result.provider.is_none()); + assert!(result.model.is_none()); + } + #[test] fn parse_json_invalid_version() { let json = serde_json::json!({ @@ -620,8 +711,8 @@ mod tests { #[test] fn parse_zip_with_json() { - let j1 = encode_persona_json("Alice", "Prompt A", None).unwrap(); - let j2 = encode_persona_json("Bob", "Prompt B", None).unwrap(); + let j1 = encode_persona_json("Alice", "Prompt A", None, None, None).unwrap(); + let j2 = encode_persona_json("Bob", "Prompt B", None, None, None).unwrap(); let zip = make_test_zip(&[("alice.persona.json", &j1), ("bob.persona.json", &j2)]); let result = parse_zip_personas(&zip).unwrap(); assert_eq!(result.personas.len(), 2); @@ -633,7 +724,7 @@ mod tests { #[test] fn parse_zip_mixed_png_and_json() { let png = make_test_persona_png("PngPersona", "PNG prompt"); - let json = encode_persona_json("JsonPersona", "JSON prompt", None).unwrap(); + let json = encode_persona_json("JsonPersona", "JSON prompt", None, None, None).unwrap(); let zip = make_test_zip(&[ ("persona.png", &png), ("persona.json", &json), @@ -648,8 +739,8 @@ mod tests { #[test] fn parse_zip_ignores_macos_resource_forks() { - let j1 = encode_persona_json("Frank", "You are Frank.", None).unwrap(); - let j2 = encode_persona_json("Jackie", "You are Jackie.", None).unwrap(); + let j1 = encode_persona_json("Frank", "You are Frank.", None, None, None).unwrap(); + let j2 = encode_persona_json("Jackie", "You are Jackie.", None, None, None).unwrap(); let zip = make_test_zip(&[ ("frank-costanza.persona.json", &j1), ("jackie-chiles.persona.json", &j2), diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index f7a8fb548..0518d7546 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -58,6 +58,8 @@ fn built_in_persona_records(now: &str) -> Vec { display_name: persona.display_name.to_string(), avatar_url: None, system_prompt: persona.system_prompt.to_string(), + provider: None, + model: None, is_builtin: true, created_at: now.to_string(), updated_at: now.to_string(), @@ -99,6 +101,8 @@ fn merge_personas(mut stored: Vec, now: &str) -> (Vec, pub system_prompt: String, + /// Preferred ACP provider ID (e.g. "goose", "claude", "codex"). + /// When deploying an agent from this persona, this provider is pre-selected. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// Preferred model ID (e.g. "gpt-4o", "claude-sonnet-4-20250514"). + /// Passed to the agent at creation time when deploying from this persona. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, #[serde(default)] pub is_builtin: bool, pub created_at: String, @@ -160,6 +168,10 @@ pub struct CreatePersonaRequest { pub display_name: String, pub avatar_url: Option, pub system_prompt: String, + #[serde(default)] + pub provider: Option, + #[serde(default)] + pub model: Option, } #[derive(Debug, Deserialize)] @@ -169,6 +181,10 @@ pub struct UpdatePersonaRequest { pub display_name: String, pub avatar_url: Option, pub system_prompt: String, + #[serde(default)] + pub provider: Option, + #[serde(default)] + pub model: Option, } #[derive(Debug, Deserialize)] diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 02a3603f5..18c16fc4d 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -52,6 +52,8 @@ export type CreateChannelManagedAgentInput = { systemPrompt?: string; avatarUrl?: string; personaId?: string | null; + /** Preferred model ID from the persona. Passed to createManagedAgent. */ + model?: string; role?: Exclude; ensureRunning?: boolean; backend?: ManagedAgentBackend; @@ -308,6 +310,7 @@ export async function createChannelManagedAgent( personaId: input.personaId ?? undefined, systemPrompt: input.systemPrompt?.trim() || undefined, avatarUrl: resolvedAvatarUrl, + model: input.model?.trim() || undefined, spawnAfterCreate: isProviderMode, startOnAppLaunch: isProviderMode ? false : undefined, backend: input.backend, diff --git a/desktop/src/features/agents/lib/resolvePersonaProvider.ts b/desktop/src/features/agents/lib/resolvePersonaProvider.ts new file mode 100644 index 000000000..346265eb5 --- /dev/null +++ b/desktop/src/features/agents/lib/resolvePersonaProvider.ts @@ -0,0 +1,91 @@ +import type { AcpProvider } from "@/shared/api/types"; + +/** + * Result of resolving a persona's preferred provider against the set of + * currently-available ACP providers. + * + * `provider` is the provider that should be used for deployment. + * `warnings` contains user-visible messages when the resolved provider + * differs from what the persona requested (e.g. the configured runtime + * was uninstalled) or when no provider is available at all. + */ +export type ResolvePersonaProviderResult = { + provider: AcpProvider | null; + warnings: string[]; +}; + +/** + * Resolve which ACP provider to use when deploying an agent from a persona. + * + * Resolution order: + * 1. If the persona has no `providerId` → use `defaultProvider`, no warnings. + * 2. If the persona's `providerId` matches an available provider → use it. + * 3. If the persona's `providerId` is set but not found in `providers` → + * fall back to `defaultProvider` and emit a warning. + * 4. If there is no `defaultProvider` either → return `null` with an error + * warning so the UI can block deployment. + */ +export function resolvePersonaProvider( + personaProviderId: string | undefined | null, + providers: readonly AcpProvider[], + defaultProvider: AcpProvider | null, +): ResolvePersonaProviderResult { + // Case 1: Persona has no provider preference — use the default. + if (!personaProviderId) { + return { + provider: defaultProvider, + warnings: defaultProvider + ? [] + : [ + "No agent runtimes are available. Install a runtime (e.g. Goose) to deploy agents.", + ], + }; + } + + // Case 2: Persona's preferred provider is available. + const matched = providers.find((p) => p.id === personaProviderId); + if (matched) { + return { provider: matched, warnings: [] }; + } + + // Case 3 & 4: Persona's provider is not available — fall back. + if (defaultProvider) { + return { + provider: defaultProvider, + warnings: [ + `Persona is configured for runtime "${personaProviderId}" but it is not available. Using ${defaultProvider.label} instead.`, + ], + }; + } + + return { + provider: null, + warnings: [ + `Persona is configured for runtime "${personaProviderId}" but it is not available, and no other runtimes were found.`, + ], + }; +} + +/** + * Collect provider-resolution warnings for a list of personas. + * + * Used by deploy dialogs to surface inline alerts when one or more + * personas reference a runtime that isn't currently available. + */ +export function collectProviderWarnings( + personas: readonly { provider: string | null }[], + providers: readonly AcpProvider[], + fallbackProvider: AcpProvider | null, +): string[] { + if (!fallbackProvider) return []; + const warnings: string[] = []; + for (const persona of personas) { + const { warnings: w } = resolvePersonaProvider( + persona.provider, + providers, + fallbackProvider, + ); + warnings.push(...w); + } + return warnings; +} diff --git a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx index 3cffe4ccb..64c59bbe8 100644 --- a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx @@ -1,3 +1,4 @@ +import { AlertTriangle } from "lucide-react"; import * as React from "react"; import { @@ -5,6 +6,10 @@ import { useCreateChannelManagedAgentsMutation, } from "@/features/agents/hooks"; import type { CreateChannelManagedAgentsResult } from "@/features/agents/channelAgents"; +import { + collectProviderWarnings, + resolvePersonaProvider, +} from "@/features/agents/lib/resolvePersonaProvider"; import { useChannelsQuery } from "@/features/channels/hooks"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { @@ -70,6 +75,14 @@ export function AddTeamToChannelDialog({ const resolved = team ? resolvePersonas(team.personaIds, personas) : []; + // Surface warnings when a persona's preferred provider is unavailable. + // This dialog has no provider selector, so the fallback is always + // `defaultProvider` (the first available runtime). + const providerWarnings = React.useMemo( + () => collectProviderWarnings(resolved, providers, defaultProvider), + [resolved, providers, defaultProvider], + ); + function reset() { setChannelId(""); setRole("bot"); @@ -101,19 +114,32 @@ export function AddTeamToChannelDialog({ } try { - const inputs = resolved.map((persona) => ({ - provider: { - id: defaultProvider.id, - label: defaultProvider.label, - command: defaultProvider.command, - defaultArgs: defaultProvider.defaultArgs, - }, - name: persona.displayName, - systemPrompt: persona.systemPrompt, - avatarUrl: persona.avatarUrl ?? undefined, - personaId: persona.id, - role, - })); + // Resolve each persona's preferred provider. This dialog has no + // provider selector, so the fallback is `defaultProvider` (first + // available runtime). Warnings are computed separately via the + // `providerWarnings` memo and rendered as inline alerts above. + const inputs = resolved.map((persona) => { + const { provider: personaProvider } = resolvePersonaProvider( + persona.provider, + providers, + defaultProvider, + ); + const providerToUse = personaProvider ?? defaultProvider; + return { + provider: { + id: providerToUse.id, + label: providerToUse.label, + command: providerToUse.command, + defaultArgs: providerToUse.defaultArgs, + }, + name: persona.displayName, + systemPrompt: persona.systemPrompt, + avatarUrl: persona.avatarUrl ?? undefined, + model: persona.model ?? undefined, + personaId: persona.id, + role, + }; + }); const result = await deployMutation.mutateAsync(inputs); onDeployed(selectedChannel, result); @@ -214,6 +240,20 @@ export function AddTeamToChannelDialog({

) : null} + {providerWarnings.length > 0 + ? providerWarnings.map((warning) => ( +
+ +

+ {warning} +

+
+ )) + : null} + {channelsQuery.error instanceof Error ? (

{channelsQuery.error.message} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 1404b0d0f..81466f6bb 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { type AttachManagedAgentToChannelResult, personasQueryKey, + useAcpProvidersQuery, useCreatePersonaMutation, useDeletePersonaMutation, useDeleteManagedAgentMutation, @@ -66,6 +67,7 @@ export function AgentsView() { const managedAgentsQuery = useManagedAgentsQuery(); const channelsQuery = useChannelsQuery(); const personasQuery = usePersonasQuery(); + const acpProvidersQuery = useAcpProvidersQuery(); const startMutation = useStartManagedAgentMutation(); const stopMutation = useStopManagedAgentMutation(); const startOnLaunchMutation = useSetManagedAgentStartOnAppLaunchMutation(); @@ -412,6 +414,8 @@ export function AgentsView() { displayName: p.displayName, avatarUrl: p.avatarDataUrl ?? "", systemPrompt: p.systemPrompt, + provider: p.provider ?? undefined, + model: p.model ?? undefined, }, }); } else if (result.personas.length > 0) { @@ -471,6 +475,8 @@ export function AgentsView() { displayName: "", avatarUrl: "", systemPrompt: "", + provider: undefined, + model: undefined, }, }); }} @@ -487,6 +493,8 @@ export function AgentsView() { displayName: `${persona.displayName} copy`, avatarUrl: persona.avatarUrl ?? "", systemPrompt: persona.systemPrompt, + provider: persona.provider ?? undefined, + model: persona.model ?? undefined, }, }); }} @@ -503,6 +511,8 @@ export function AgentsView() { displayName: persona.displayName, avatarUrl: persona.avatarUrl ?? "", systemPrompt: persona.systemPrompt, + provider: persona.provider ?? undefined, + model: persona.model ?? undefined, }, }); }} @@ -665,6 +675,8 @@ export function AgentsView() { isPending={ createPersonaMutation.isPending || updatePersonaMutation.isPending } + providers={acpProvidersQuery.data ?? []} + providersLoading={acpProvidersQuery.isLoading} onOpenChange={(open) => { if (!open) { setPersonaDialogState(null); diff --git a/desktop/src/features/agents/ui/BatchImportDialog.tsx b/desktop/src/features/agents/ui/BatchImportDialog.tsx index 43bd7239b..a481698da 100644 --- a/desktop/src/features/agents/ui/BatchImportDialog.tsx +++ b/desktop/src/features/agents/ui/BatchImportDialog.tsx @@ -95,6 +95,8 @@ export function BatchImportDialog({ displayName: persona.displayName, avatarUrl: persona.avatarDataUrl ?? undefined, systemPrompt: persona.systemPrompt, + provider: persona.provider ?? undefined, + model: persona.model ?? undefined, }); completed += 1; setImportedCount(completed); diff --git a/desktop/src/features/agents/ui/PersonaDialog.tsx b/desktop/src/features/agents/ui/PersonaDialog.tsx index cc55d32e6..89d8c7976 100644 --- a/desktop/src/features/agents/ui/PersonaDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaDialog.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import type { + AcpProvider, CreatePersonaInput, UpdatePersonaInput, } from "@/shared/api/types"; @@ -23,6 +24,8 @@ type PersonaDialogProps = { initialValues: CreatePersonaInput | UpdatePersonaInput | null; error: Error | null; isPending: boolean; + providers: AcpProvider[]; + providersLoading?: boolean; onOpenChange: (open: boolean) => void; onSubmit: (input: CreatePersonaInput | UpdatePersonaInput) => Promise; }; @@ -35,12 +38,16 @@ export function PersonaDialog({ initialValues, error, isPending, + providers, + providersLoading = false, onOpenChange, onSubmit, }: PersonaDialogProps) { const [displayName, setDisplayName] = React.useState(""); const [avatarUrl, setAvatarUrl] = React.useState(""); const [systemPrompt, setSystemPrompt] = React.useState(""); + const [provider, setProvider] = React.useState(""); + const [model, setModel] = React.useState(""); React.useEffect(() => { if (!open || !initialValues) { @@ -50,6 +57,8 @@ export function PersonaDialog({ setDisplayName(initialValues.displayName); setAvatarUrl(initialValues.avatarUrl ?? ""); setSystemPrompt(initialValues.systemPrompt); + setProvider(initialValues.provider ?? ""); + setModel(initialValues.model ?? ""); }, [initialValues, open]); function handleOpenChange(next: boolean) { @@ -57,6 +66,8 @@ export function PersonaDialog({ setDisplayName(""); setAvatarUrl(""); setSystemPrompt(""); + setProvider(""); + setModel(""); } onOpenChange(next); @@ -71,6 +82,8 @@ export function PersonaDialog({ displayName, avatarUrl: avatarUrl.trim() || undefined, systemPrompt, + provider: provider.trim() || undefined, + model: model.trim() || undefined, }; if ("id" in initialValues) { @@ -93,7 +106,7 @@ export function PersonaDialog({ {description} -

+
+
+ + +

+ Optional. When deploying this persona, the selected runtime will + be pre-selected. Falls back to the default if unavailable. +

+
+ +
+ + setModel(event.target.value)} + placeholder="e.g. gpt-4o, claude-sonnet-4-20250514" + spellCheck={false} + value={model} + /> +

+ Optional. Passed to the agent at creation time. Leave blank to + use the runtime default. +

+
+ {error ? (

{error.message} diff --git a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx index 91c9e23ef..69213afec 100644 --- a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx @@ -37,6 +37,10 @@ import { coerceConfigValues, ProviderConfigFields, } from "@/features/agents/ui/ProviderConfigFields"; +import { + collectProviderWarnings, + resolvePersonaProvider, +} from "@/features/agents/lib/resolvePersonaProvider"; type AddChannelBotDialogProps = { backendProviders?: BackendProviderCandidate[]; @@ -145,6 +149,18 @@ export function AddChannelBotDialog({ ); const selectedCount = selectedPersonas.length + (includeGeneric ? 1 : 0); + // Surface warnings when a persona's preferred provider differs from the + // user-selected provider. In this dialog the user explicitly picks a + // runtime via the dropdown, so the fallback is `selectedProvider` (their + // choice), NOT `providers[0]`. This differs intentionally from + // AddTeamToChannelDialog which has no provider selector and falls back + // to the first available runtime. + const providerWarnings = React.useMemo( + () => + collectProviderWarnings(selectedPersonas, providers, selectedProvider), + [selectedPersonas, providers, selectedProvider], + ); + const isProviderMode = runOn !== "local"; const selectedBackendProvider = React.useMemo( () => resolvedBackendProviders.find((p) => p.id === runOn) ?? null, @@ -300,15 +316,23 @@ export function AddChannelBotDialog({ }, ] : []), - ...selectedPersonas.map((persona) => ({ - provider: selectedProvider, - name: persona.displayName, - personaId: persona.id, - systemPrompt: persona.systemPrompt, - avatarUrl: persona.avatarUrl ?? undefined, - role: "bot" as const, - backend, - })), + ...selectedPersonas.map((persona) => { + const resolved = resolvePersonaProvider( + persona.provider, + providers, + selectedProvider, + ); + return { + provider: resolved.provider ?? selectedProvider, + name: persona.displayName, + personaId: persona.id, + systemPrompt: persona.systemPrompt, + avatarUrl: persona.avatarUrl ?? undefined, + model: persona.model ?? undefined, + role: "bot" as const, + backend, + }; + }), ]; setSubmissionNotice(null); @@ -530,6 +554,20 @@ export function AddChannelBotDialog({

) : null} + {providerWarnings.length > 0 + ? providerWarnings.map((warning) => ( +
+ +

+ {warning} +

+
+ )) + : null} + {personasQuery.error instanceof Error ? (

{personasQuery.error.message} diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts index 10493cbb5..aaa06658a 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -10,6 +10,8 @@ type RawParsedPersonaPreview = { display_name: string; system_prompt: string; avatar_data_url: string | null; + provider: string | null; + model: string | null; source_file: string; }; @@ -28,6 +30,8 @@ export type ParsedPersonaPreview = { displayName: string; systemPrompt: string; avatarDataUrl: string | null; + provider: string | null; + model: string | null; sourceFile: string; }; @@ -46,6 +50,8 @@ type RawPersona = { display_name: string; avatar_url: string | null; system_prompt: string; + provider?: string | null; + model?: string | null; is_builtin: boolean; created_at: string; updated_at: string; @@ -57,6 +63,8 @@ function fromRawPersona(persona: RawPersona): AgentPersona { displayName: persona.display_name, avatarUrl: persona.avatar_url, systemPrompt: persona.system_prompt, + provider: persona.provider ?? null, + model: persona.model ?? null, isBuiltIn: persona.is_builtin, createdAt: persona.created_at, updatedAt: persona.updated_at, @@ -76,6 +84,8 @@ export async function createPersona( displayName: input.displayName, avatarUrl: input.avatarUrl, systemPrompt: input.systemPrompt, + provider: input.provider, + model: input.model, }, }), ); @@ -91,6 +101,8 @@ export async function updatePersona( displayName: input.displayName, avatarUrl: input.avatarUrl, systemPrompt: input.systemPrompt, + provider: input.provider, + model: input.model, }, }), ); @@ -113,6 +125,8 @@ export async function parsePersonaFiles( displayName: p.display_name, systemPrompt: p.system_prompt, avatarDataUrl: p.avatar_data_url, + provider: p.provider, + model: p.model, sourceFile: p.source_file, })), skipped: raw.skipped.map((s) => ({ diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 840e4191e..6504ba5b5 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -410,6 +410,10 @@ export type AgentPersona = { displayName: string; avatarUrl: string | null; systemPrompt: string; + /** Preferred ACP provider ID (e.g. "goose", "claude"). */ + provider: string | null; + /** Preferred model ID (e.g. "gpt-4o", "claude-sonnet-4-20250514"). */ + model: string | null; isBuiltIn: boolean; createdAt: string; updatedAt: string; @@ -419,6 +423,8 @@ export type CreatePersonaInput = { displayName: string; avatarUrl?: string; systemPrompt: string; + provider?: string; + model?: string; }; export type UpdatePersonaInput = { @@ -426,6 +432,8 @@ export type UpdatePersonaInput = { displayName: string; avatarUrl?: string; systemPrompt: string; + provider?: string; + model?: string; }; // ── Team types ────────────────────────────────────────────────────────────────