@@ -6,20 +6,100 @@ use crate::{
66 config_bridge:: {
77 reader:: read_config_surface,
88 types:: {
9- AcpConfigOptionEntry , AcpConfigOptionValue , AcpModelEntry , ConfigWriteMechanism ,
10- RuntimeConfigSurface , SessionConfigCache , WriteConfigFieldRequest ,
11- WriteConfigResult , WriteConfigTarget ,
9+ AcpConfigOptionEntry , AcpConfigOptionValue , AcpModelEntry , ConfigOrigin ,
10+ ConfigWriteMechanism , NormalizedField , RuntimeConfigSurface , SessionConfigCache ,
11+ WriteConfigFieldRequest , WriteConfigResult , WriteConfigTarget ,
1212 } ,
1313 writer:: plan_config_write,
1414 } ,
15- known_acp_runtime, load_managed_agents, save_managed_agents, sync_managed_agent_processes,
15+ known_acp_runtime, load_managed_agents, load_personas,
16+ resolve_effective_prompt_model_provider, save_managed_agents, sync_managed_agent_processes,
17+ KnownAcpRuntime , ManagedAgentRecord , PersonaRecord ,
1618 } ,
1719} ;
1820
21+ /// Resolve the config surface with persona values applied.
22+ ///
23+ /// Both the read path (`get_agent_config_surface`) and the write path
24+ /// (`write_agent_config_field`) must see the same surface, so this is the
25+ /// single place persona resolution happens. The pipeline: resolve the linked
26+ /// persona's prompt/model/provider, inject each into the record only where the
27+ /// record lacks its own value, let `read_config_surface` tag those injected
28+ /// fields `BuzzExplicit`, then re-tag exactly the injected fields to
29+ /// `PersonaDefault`.
30+ ///
31+ /// The re-tag is triple-gated — a field is re-tagged only when (a) the record
32+ /// did not already have it (`!had_*`), (b) the surface produced the field, and
33+ /// (c) the reader tagged it `BuzzExplicit`. A value the user set explicitly in
34+ /// Buzz keeps `had_* == true` and is never re-tagged.
35+ fn resolve_config_surface (
36+ mut record : ManagedAgentRecord ,
37+ personas : & [ PersonaRecord ] ,
38+ runtime_meta : Option < & KnownAcpRuntime > ,
39+ session_cache : Option < & SessionConfigCache > ,
40+ ) -> RuntimeConfigSurface {
41+ let had_prompt =
42+ record. system_prompt . is_some ( ) || record. env_vars . contains_key ( "BUZZ_ACP_SYSTEM_PROMPT" ) ;
43+ let had_model = record. model . is_some ( ) ;
44+
45+ let provider_env_key = runtime_meta. and_then ( |m| m. provider_env_var ) . unwrap_or ( "" ) ;
46+ let had_provider = record. env_vars . contains_key ( provider_env_key) ;
47+
48+ let ( persona_prompt, persona_model, persona_provider) = resolve_effective_prompt_model_provider (
49+ record. persona_id . as_deref ( ) ,
50+ personas,
51+ record. system_prompt . clone ( ) ,
52+ record. model . clone ( ) ,
53+ ) ;
54+
55+ // Inject resolved persona values into the record where absent.
56+ if !had_prompt {
57+ if let Some ( p) = persona_prompt {
58+ record
59+ . env_vars
60+ . insert ( "BUZZ_ACP_SYSTEM_PROMPT" . to_string ( ) , p) ;
61+ }
62+ }
63+ if !had_model {
64+ record. model = persona_model;
65+ }
66+ if !had_provider && !provider_env_key. is_empty ( ) {
67+ if let Some ( prov) = persona_provider {
68+ record. env_vars . insert ( provider_env_key. to_string ( ) , prov) ;
69+ }
70+ }
71+
72+ let mut surface = read_config_surface ( & record, runtime_meta, session_cache) ;
73+
74+ // Re-tag persona-sourced fields from BuzzExplicit to PersonaDefault.
75+ if !had_prompt {
76+ retag_persona_default ( & mut surface. normalized . system_prompt ) ;
77+ }
78+ if !had_model {
79+ retag_persona_default ( & mut surface. normalized . model ) ;
80+ }
81+ if !had_provider && !provider_env_key. is_empty ( ) {
82+ retag_persona_default ( & mut surface. normalized . provider ) ;
83+ }
84+
85+ surface
86+ }
87+
88+ /// Re-tag a field's origin from `BuzzExplicit` to `PersonaDefault`, leaving any
89+ /// other origin untouched. No-op when the field is absent.
90+ fn retag_persona_default ( field : & mut Option < NormalizedField > ) {
91+ if let Some ( field) = field {
92+ if field. origin == ConfigOrigin :: BuzzExplicit {
93+ field. origin = ConfigOrigin :: PersonaDefault ;
94+ }
95+ }
96+ }
97+
1998/// Get the full config surface for a managed agent.
2099///
21100/// Returns normalized + advanced config from all available tiers.
22101/// Pre-spawn agents show config file values with ACP tiers marked as pending.
102+ /// Persona-sourced values are resolved by `resolve_config_surface`.
23103#[ tauri:: command]
24104pub async fn get_agent_config_surface (
25105 pubkey : String ,
@@ -45,11 +125,13 @@ pub async fn get_agent_config_surface(
45125 . ok_or_else ( || format ! ( "agent {pubkey} not found" ) ) ?
46126 } ;
47127
128+ let personas = load_personas ( & app) . unwrap_or_default ( ) ;
48129 let runtime_meta = known_acp_runtime ( & record. agent_command ) ;
49130 let session_cache = state. get_session_cache ( & pubkey) ;
50131
51- Ok ( read_config_surface (
52- & record,
132+ Ok ( resolve_config_surface (
133+ record,
134+ & personas,
53135 runtime_meta,
54136 session_cache. as_ref ( ) ,
55137 ) )
@@ -60,6 +142,10 @@ pub async fn get_agent_config_surface(
60142/// Plans the write mechanism based on the current config surface, then
61143/// executes: either updating the record (for env var respawn) or returning
62144/// the mechanism for the frontend to send via observer control (for ACP writes).
145+ ///
146+ /// Uses the same persona-resolved surface as `get_agent_config_surface` so
147+ /// `plan_config_write` sees persona-sourced fields and never returns
148+ /// "field not available" for a value inherited from the linked persona.
63149#[ tauri:: command]
64150pub async fn write_agent_config_field (
65151 request : WriteConfigFieldRequest ,
@@ -78,9 +164,10 @@ pub async fn write_agent_config_field(
78164 . cloned ( )
79165 . ok_or_else ( || format ! ( "agent {} not found" , request. pubkey) ) ?;
80166
167+ let personas = load_personas ( & app) . unwrap_or_default ( ) ;
81168 let runtime_meta = known_acp_runtime ( & record. agent_command ) ;
82169 let session_cache = state. get_session_cache ( & request. pubkey ) ;
83- let surface = read_config_surface ( & record, runtime_meta, session_cache. as_ref ( ) ) ;
170+ let surface = resolve_config_surface ( record, & personas , runtime_meta, session_cache. as_ref ( ) ) ;
84171
85172 let mut result = plan_config_write ( & surface, & request. field ) ;
86173
@@ -293,3 +380,136 @@ fn parse_models(raw: Option<&serde_json::Value>) -> (Vec<AcpModelEntry>, Option<
293380 . collect ( ) ;
294381 ( models, current_model)
295382}
383+
384+ #[ cfg( test) ]
385+ mod tests {
386+ use std:: collections:: BTreeMap ;
387+
388+ use super :: * ;
389+ use crate :: managed_agents:: { BackendKind , RespondTo } ;
390+
391+ fn goose_runtime ( ) -> & ' static KnownAcpRuntime {
392+ & KnownAcpRuntime {
393+ id : "goose" ,
394+ label : "Goose" ,
395+ commands : & [ "goose" ] ,
396+ aliases : & [ ] ,
397+ avatar_url : "" ,
398+ mcp_command : None ,
399+ mcp_hooks : false ,
400+ underlying_cli : None ,
401+ cli_install_commands : & [ ] ,
402+ adapter_install_commands : & [ ] ,
403+ install_instructions_url : "" ,
404+ cli_install_hint : "" ,
405+ adapter_install_hint : "" ,
406+ skill_dir : None ,
407+ supports_acp_model_switching : false ,
408+ model_env_var : Some ( "GOOSE_MODEL" ) ,
409+ provider_env_var : Some ( "GOOSE_PROVIDER" ) ,
410+ provider_locked : false ,
411+ default_env : & [ ] ,
412+ config_file_path : Some ( "~/.config/goose/config.yaml" ) ,
413+ config_file_format : Some ( "yaml" ) ,
414+ supports_acp_native_config : true ,
415+ thinking_env_var : Some ( "GOOSE_THINKING_EFFORT" ) ,
416+ }
417+ }
418+
419+ fn agent_record ( ) -> ManagedAgentRecord {
420+ ManagedAgentRecord {
421+ pubkey : "agent" . to_string ( ) ,
422+ name : "Agent" . to_string ( ) ,
423+ persona_id : Some ( "persona-1" . to_string ( ) ) ,
424+ private_key_nsec : "" . to_string ( ) ,
425+ auth_tag : None ,
426+ relay_url : "ws://localhost:3000" . to_string ( ) ,
427+ avatar_url : None ,
428+ acp_command : "buzz-acp" . to_string ( ) ,
429+ agent_command : "goose" . to_string ( ) ,
430+ agent_args : vec ! [ ] ,
431+ mcp_command : "" . to_string ( ) ,
432+ turn_timeout_seconds : 300 ,
433+ idle_timeout_seconds : None ,
434+ max_turn_duration_seconds : None ,
435+ parallelism : 1 ,
436+ system_prompt : None ,
437+ model : None ,
438+ mcp_toolsets : None ,
439+ env_vars : BTreeMap :: new ( ) ,
440+ start_on_app_launch : false ,
441+ runtime_pid : None ,
442+ backend : BackendKind :: Local ,
443+ backend_agent_id : None ,
444+ provider_binary_path : None ,
445+ persona_team_dir : None ,
446+ persona_name_in_team : None ,
447+ created_at : "" . to_string ( ) ,
448+ updated_at : "" . to_string ( ) ,
449+ last_started_at : None ,
450+ last_stopped_at : None ,
451+ last_exit_code : None ,
452+ last_error : None ,
453+ respond_to : RespondTo :: OwnerOnly ,
454+ respond_to_allowlist : vec ! [ ] ,
455+ relay_mesh : None ,
456+ }
457+ }
458+
459+ fn persona_with_model ( model : & str ) -> PersonaRecord {
460+ PersonaRecord {
461+ id : "persona-1" . to_string ( ) ,
462+ display_name : "Persona" . to_string ( ) ,
463+ avatar_url : None ,
464+ system_prompt : "You are a persona." . to_string ( ) ,
465+ runtime : None ,
466+ model : Some ( model. to_string ( ) ) ,
467+ provider : None ,
468+ name_pool : Vec :: new ( ) ,
469+ is_builtin : false ,
470+ is_active : true ,
471+ source_team : None ,
472+ source_team_persona_slug : None ,
473+ env_vars : BTreeMap :: new ( ) ,
474+ created_at : "" . to_string ( ) ,
475+ updated_at : "" . to_string ( ) ,
476+ }
477+ }
478+
479+ /// The write path must see a persona-inherited model. Without persona
480+ /// resolution `surface.normalized.model` would be `None` and
481+ /// `plan_config_write` would return "field not available for this runtime".
482+ #[ test]
483+ fn write_path_sees_persona_sourced_model_field ( ) {
484+ let record = agent_record ( ) ;
485+ let personas = vec ! [ persona_with_model( "persona-model" ) ] ;
486+
487+ let surface = resolve_config_surface ( record, & personas, Some ( goose_runtime ( ) ) , None ) ;
488+
489+ let model = surface. normalized . model . as_ref ( ) . expect ( "model resolved" ) ;
490+ assert_eq ! ( model. value. as_deref( ) , Some ( "persona-model" ) ) ;
491+ assert_eq ! ( model. origin, ConfigOrigin :: PersonaDefault ) ;
492+
493+ let result = plan_config_write ( & surface, & WriteConfigTarget :: Model ) ;
494+ assert ! ( result. success, "write plan failed: {:?}" , result. error) ;
495+ assert ! ( matches!(
496+ result. mechanism_used,
497+ ConfigWriteMechanism :: RespawnWithEnvVar { .. }
498+ ) ) ;
499+ }
500+
501+ /// A model the user set explicitly in Buzz must never be re-tagged to
502+ /// `PersonaDefault`, even when the linked persona also has a model.
503+ #[ test]
504+ fn explicit_record_model_outranks_persona_and_keeps_buzz_explicit_origin ( ) {
505+ let mut record = agent_record ( ) ;
506+ record. model = Some ( "explicit-model" . to_string ( ) ) ;
507+ let personas = vec ! [ persona_with_model( "persona-model" ) ] ;
508+
509+ let surface = resolve_config_surface ( record, & personas, Some ( goose_runtime ( ) ) , None ) ;
510+
511+ let model = surface. normalized . model . as_ref ( ) . expect ( "model resolved" ) ;
512+ assert_eq ! ( model. value. as_deref( ) , Some ( "explicit-model" ) ) ;
513+ assert_eq ! ( model. origin, ConfigOrigin :: BuzzExplicit ) ;
514+ }
515+ }
0 commit comments