From 940608f50c4a842033a87328ff7dfa96ebd6a92d Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 16 Jun 2026 19:42:16 -0400 Subject: [PATCH] feat(config-bridge): resolve persona values at call site and replace badges with provenance sentences Phase 1: Resolve all three persona fields (prompt, model, provider) at the get_agent_config_surface call site before calling the reader. Inject resolved values into the record where absent, then post-process the surface to re-tag injected fields from BuzzExplicit to PersonaDefault. Reader stays untouched (pure tier-merge function). Phase 2: Add AgentConfigPanel to ProfileSummaryView in the profile pop-out, gated on isBot && isOwner && managedAgent defined. Phase 3: Remove SourcesFooter and colored OriginBadge pills. Replace with gray inline provenance sentences below each value ("Set in Buzz", "Inherited from persona", "From environment variable", etc). No action clauses. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src-tauri/src/commands/agent_config.rs | 88 ++++++++-- .../managed_agents/config_bridge/reader.rs | 117 +++++++++++++ .../src/managed_agents/config_bridge/types.rs | 5 +- .../features/agents/ui/AgentConfigPanel.tsx | 159 ++++-------------- .../profile/ui/UserProfilePanelSections.tsx | 19 +++ 5 files changed, 253 insertions(+), 135 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 554a53672..0d3f69a2f 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -6,13 +6,15 @@ use crate::{ config_bridge::{ reader::read_config_surface, types::{ - AcpConfigOptionEntry, AcpConfigOptionValue, AcpModelEntry, ConfigWriteMechanism, - RuntimeConfigSurface, SessionConfigCache, WriteConfigFieldRequest, - WriteConfigResult, WriteConfigTarget, + AcpConfigOptionEntry, AcpConfigOptionValue, AcpModelEntry, ConfigOrigin, + ConfigWriteMechanism, RuntimeConfigSurface, SessionConfigCache, + WriteConfigFieldRequest, WriteConfigResult, WriteConfigTarget, }, writer::plan_config_write, }, - known_acp_runtime, load_managed_agents, save_managed_agents, sync_managed_agent_processes, + known_acp_runtime, load_managed_agents, load_personas, + resolve_effective_prompt_model_provider, save_managed_agents, + sync_managed_agent_processes, }, }; @@ -20,13 +22,15 @@ use crate::{ /// /// Returns normalized + advanced config from all available tiers. /// Pre-spawn agents show config file values with ACP tiers marked as pending. +/// Persona-sourced values are resolved here (call-site) and re-tagged as +/// `PersonaDefault` after the reader produces the surface. #[tauri::command] pub async fn get_agent_config_surface( pubkey: String, app: AppHandle, state: State<'_, AppState>, ) -> Result { - let record = { + let mut record = { let _store_guard = state .managed_agents_store_lock .lock() @@ -45,14 +49,74 @@ pub async fn get_agent_config_surface( .ok_or_else(|| format!("agent {pubkey} not found"))? }; + // Resolve persona values at the call site (not inside the reader). + // Track which fields were absent on the record so we can re-tag them + // as PersonaDefault after the reader produces the surface. + let personas = load_personas(&app).unwrap_or_default(); + let had_prompt = record.system_prompt.is_some() + || record.env_vars.contains_key("BUZZ_ACP_SYSTEM_PROMPT"); + let had_model = record.model.is_some(); + let runtime_meta = known_acp_runtime(&record.agent_command); + let provider_env_key = runtime_meta + .and_then(|m| m.provider_env_var) + .unwrap_or(""); + let had_provider = record.env_vars.contains_key(provider_env_key); + + let (persona_prompt, persona_model, persona_provider) = + resolve_effective_prompt_model_provider( + record.persona_id.as_deref(), + &personas, + record.system_prompt.clone(), + record.model.clone(), + ); + + // Inject resolved persona values into the record where absent. + if !had_prompt { + if let Some(ref p) = persona_prompt { + record + .env_vars + .insert("BUZZ_ACP_SYSTEM_PROMPT".to_string(), p.clone()); + } + } + if !had_model { + record.model = persona_model; + } + if !had_provider && !provider_env_key.is_empty() { + if let Some(ref prov) = persona_provider { + record + .env_vars + .insert(provider_env_key.to_string(), prov.clone()); + } + } + let session_cache = state.get_session_cache(&pubkey); + let mut surface = read_config_surface(&record, runtime_meta, session_cache.as_ref()); + + // Re-tag persona-sourced fields from BuzzExplicit to PersonaDefault. + if !had_prompt { + if let Some(ref mut field) = surface.normalized.system_prompt { + if field.origin == ConfigOrigin::BuzzExplicit { + field.origin = ConfigOrigin::PersonaDefault; + } + } + } + if !had_model { + if let Some(ref mut field) = surface.normalized.model { + if field.origin == ConfigOrigin::BuzzExplicit { + field.origin = ConfigOrigin::PersonaDefault; + } + } + } + if !had_provider { + if let Some(ref mut field) = surface.normalized.provider { + if field.origin == ConfigOrigin::BuzzExplicit { + field.origin = ConfigOrigin::PersonaDefault; + } + } + } - Ok(read_config_surface( - &record, - runtime_meta, - session_cache.as_ref(), - )) + Ok(surface) } /// Write a config field value for a managed agent. @@ -60,6 +124,10 @@ pub async fn get_agent_config_surface( /// Plans the write mechanism based on the current config surface, then /// executes: either updating the record (for env var respawn) or returning /// the mechanism for the frontend to send via observer control (for ACP writes). +/// +// TODO: When inline editing lands, this function needs the same persona +// injection as get_agent_config_surface — without it, plan_config_write +// won't see persona-sourced fields and will return "field not available". #[tauri::command] pub async fn write_agent_config_field( request: WriteConfigFieldRequest, diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index 46d57c7e9..8e0a949b6 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -579,4 +579,121 @@ mod tests { // the actual file in a unit test, just verify the override fields are populated // when we manually construct the scenario via build_model_field. } + + // ── Persona resolution integration tests ──────────────────────────── + // + // These simulate the call-site pattern in agent_config.rs: + // 1. Inject persona-resolved values into the record (as if absent) + // 2. Call read_config_surface (reader tags them BuzzExplicit) + // 3. Re-tag injected fields to PersonaDefault + // + // This exercises the same logic path as get_agent_config_surface without + // requiring Tauri AppHandle/State infrastructure. + + #[test] + fn persona_model_injection_produces_persona_default_origin() { + let mut record = test_record(); + // Simulate: record has no model, persona provides one. + // The call-site injects it before calling the reader. + record.model = Some("persona-model".to_string()); + let runtime = test_runtime(); + + let mut surface = read_config_surface(&record, Some(runtime), None); + + // Reader sees injected model as BuzzExplicit. + let model = surface.normalized.model.as_ref().unwrap(); + assert_eq!(model.value.as_deref(), Some("persona-model")); + assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); + + // Call-site re-tags (simulating had_model == false). + if let Some(ref mut field) = surface.normalized.model { + if field.origin == ConfigOrigin::BuzzExplicit { + field.origin = ConfigOrigin::PersonaDefault; + } + } + + let model = surface.normalized.model.unwrap(); + assert_eq!(model.value.as_deref(), Some("persona-model")); + assert_eq!(model.origin, ConfigOrigin::PersonaDefault); + } + + #[test] + fn persona_provider_injection_produces_persona_default_origin() { + let mut record = test_record(); + // Simulate: record has no provider env var, persona provides one. + // The call-site injects it as GOOSE_PROVIDER before calling the reader. + record + .env_vars + .insert("GOOSE_PROVIDER".to_string(), "anthropic".to_string()); + let runtime = test_runtime(); + + let mut surface = read_config_surface(&record, Some(runtime), None); + + // Reader sees injected provider as BuzzExplicit. + let provider = surface.normalized.provider.as_ref().unwrap(); + assert_eq!(provider.value.as_deref(), Some("anthropic")); + assert_eq!(provider.origin, ConfigOrigin::BuzzExplicit); + + // Call-site re-tags (simulating had_provider == false). + if let Some(ref mut field) = surface.normalized.provider { + if field.origin == ConfigOrigin::BuzzExplicit { + field.origin = ConfigOrigin::PersonaDefault; + } + } + + let provider = surface.normalized.provider.unwrap(); + assert_eq!(provider.value.as_deref(), Some("anthropic")); + assert_eq!(provider.origin, ConfigOrigin::PersonaDefault); + } + + #[test] + fn persona_system_prompt_injection_produces_persona_default_origin() { + let mut record = test_record(); + // Simulate: record has no system_prompt, persona provides one via env var. + // The call-site injects it as BUZZ_ACP_SYSTEM_PROMPT before calling the reader. + record.env_vars.insert( + "BUZZ_ACP_SYSTEM_PROMPT".to_string(), + "You are a helpful assistant.".to_string(), + ); + let runtime = test_runtime(); + + let mut surface = read_config_surface(&record, Some(runtime), None); + + // Reader sees injected prompt as BuzzExplicit. + let prompt = surface.normalized.system_prompt.as_ref().unwrap(); + assert_eq!( + prompt.value.as_deref(), + Some("You are a helpful assistant.") + ); + assert_eq!(prompt.origin, ConfigOrigin::BuzzExplicit); + + // Call-site re-tags (simulating had_prompt == false). + if let Some(ref mut field) = surface.normalized.system_prompt { + if field.origin == ConfigOrigin::BuzzExplicit { + field.origin = ConfigOrigin::PersonaDefault; + } + } + + let prompt = surface.normalized.system_prompt.unwrap(); + assert_eq!( + prompt.value.as_deref(), + Some("You are a helpful assistant.") + ); + assert_eq!(prompt.origin, ConfigOrigin::PersonaDefault); + } + + #[test] + fn explicit_record_model_not_retagged_when_already_present() { + let mut record = test_record(); + // Record already has its own model — persona resolution should NOT re-tag. + record.model = Some("explicit-model".to_string()); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None); + + // had_model == true, so no re-tagging occurs. Origin stays BuzzExplicit. + let model = surface.normalized.model.unwrap(); + assert_eq!(model.value.as_deref(), Some("explicit-model")); + assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); + } } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs index 5324eec43..b176f109c 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs @@ -17,8 +17,9 @@ pub enum ConfigOrigin { /// Read from harness config file on disk (tier 2b, lowest precedence). ConfigFile, /// Value inherited from persona defaults. - /// Forward slot — not yet populated by any reader. Will be wired when - /// persona pack config resolution is added to `read_config_surface`. + /// Populated by the `get_agent_config_surface` call site: persona values are + /// resolved before calling the reader, then the surface is post-processed to + /// re-tag injected fields from `BuzzExplicit` to `PersonaDefault`. PersonaDefault, } diff --git a/desktop/src/features/agents/ui/AgentConfigPanel.tsx b/desktop/src/features/agents/ui/AgentConfigPanel.tsx index decf3896d..636247b77 100644 --- a/desktop/src/features/agents/ui/AgentConfigPanel.tsx +++ b/desktop/src/features/agents/ui/AgentConfigPanel.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { ChevronDown, ChevronRight, Info } from "lucide-react"; +import { ChevronDown, ChevronRight } from "lucide-react"; import { useAgentConfigSurface } from "../hooks"; import { cn } from "@/shared/lib/cn"; @@ -7,9 +7,9 @@ import { Spinner } from "@/shared/ui/spinner"; import type { ConfigField, ConfigOrigin, + ConfigWriteMechanism, NormalizedConfig, NormalizedField, - ConfigSourceReport, } from "@/shared/api/types"; type Props = { @@ -17,68 +17,34 @@ type Props = { isRunning: boolean; }; -// ── Origin badge ───────────────────────────────────────────────────────────── +// ── Provenance sentence ────────────────────────────────────────────────────── -function originLabel( +function provenanceSentence( origin: ConfigOrigin, + writeVia: ConfigWriteMechanism, configFilePath: string | null, ): string { switch (origin) { case "buzzExplicit": - return "Buzz"; - case "acpConfigOption": - return "ACP"; - case "acpNativeRead": - return "ACP"; - case "envVar": - return "Env"; - case "configFile": { - if (configFilePath) { - const parts = configFilePath.split(/[/\\]/); - return parts[parts.length - 1] ?? configFilePath; + return "Set in Buzz"; + case "personaDefault": + return "Inherited from persona"; + case "envVar": { + if (writeVia.type === "respawnWithEnvVar") { + return `From environment variable (${writeVia.envKey})`; } - return "Config"; + return "From environment variable"; } - case "personaDefault": - return "Persona"; - } -} - -function originColorClass(origin: ConfigOrigin): string { - switch (origin) { - case "buzzExplicit": - return "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"; + case "configFile": + return configFilePath + ? `From config file (${configFilePath})` + : "From config file"; case "acpConfigOption": case "acpNativeRead": - return "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300"; - case "configFile": - case "personaDefault": - return "bg-muted text-muted-foreground"; - case "envVar": - return "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"; + return "From ACP session"; } } -function OriginBadge({ - origin, - configFilePath, -}: { - origin: ConfigOrigin; - configFilePath: string | null; -}) { - return ( - - {originLabel(origin, configFilePath)} - - ); -} - // ── Normalized row ──────────────────────────────────────────────────────────── const NORMALIZED_LABELS: Record = { @@ -107,19 +73,13 @@ function NormalizedRow({ field.origin === "acpNativeRead" || field.origin === "acpConfigOption"; return ( -
- - {label} - - - {/* Value area: effective value + strikethrough overridden value */} - +
+
{label}
+
{isPreSpawn && isAcpOnly ? ( - + Available after agent starts - - ) : isPreSpawn && field.origin === "configFile" && !field.value ? ( - + ) : ( <> {field.value ?? } @@ -130,25 +90,11 @@ function NormalizedRow({ )} )} - - - {/* Badge area: effective badge + strikethrough overridden badge */} - - - {field.overriddenOrigin && ( - - - - )} - - - {!field.isWritable && ( - - - +
+ {field.value && ( +
+ {provenanceSentence(field.origin, field.writeVia, configFilePath)} +
)}
); @@ -164,53 +110,22 @@ function AdvancedRow({ configFilePath: string | null; }) { return ( -
- - {field.label} - - +
+
{field.label}
+
{field.value ?? ( - + )} - - - {!field.isWritable && ( - - - +
+ {field.value && ( +
+ {provenanceSentence(field.origin, field.writeVia, configFilePath)} +
)}
); } -// ── Sources footer ──────────────────────────────────────────────────────────── - -const STATUS_ICON: Record = { - available: "✓", - pending: "⏳", - notApplicable: "—", -}; - -function SourcesFooter({ sources }: { sources: ConfigSourceReport }) { - const tiers = [ - { label: "Config file", status: sources.configFile }, - { label: "ACP native", status: sources.acpNative }, - { label: "ACP config", status: sources.acpConfigOptions }, - { label: "Env vars", status: sources.envVars }, - ] as const; - - return ( -

- {tiers.map((tier, i) => ( - - {i > 0 && |} - {tier.label} {STATUS_ICON[tier.status] ?? tier.status} - - ))} -

- ); -} - // ── Main component ──────────────────────────────────────────────────────────── export function AgentConfigPanel({ pubkey, isRunning: _isRunning }: Props) { @@ -302,8 +217,6 @@ export function AgentConfigPanel({ pubkey, isRunning: _isRunning }: Props) { )}
)} - -
); } diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 0d35e909b..3aa9ef12b 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -14,6 +14,7 @@ import { MessageSquare, Pencil, Server, + Settings, Terminal, UserMinus, UserPlus, @@ -22,6 +23,7 @@ import { import { toast } from "sonner"; import { MemorySection } from "@/features/agent-memory/ui/MemorySection"; +import { AgentConfigPanel } from "@/features/agents/ui/AgentConfigPanel"; import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; @@ -231,6 +233,23 @@ export function ProfileSummaryView({ {metadataFields.length > 0 ? ( ) : null} + + {isBot && isOwner === true && managedAgent !== undefined ? ( +
+
+ +

+ Configuration +

+
+
+ +
+
+ ) : null} ); }