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
36 changes: 36 additions & 0 deletions desktop/src-tauri/src/commands/personas/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,33 @@ fn parse_format_is_png(s: &str) -> Result<bool, String> {
}
}

fn materialize_portable_runtime_defaults(
record: &mut ManagedAgentRecord,
global: &crate::managed_agents::GlobalAgentConfig,
) {
if record
.model
.as_deref()
.is_none_or(|value| value.trim().is_empty())
{
record.model = global.model.clone();
}
if record
.provider
.as_deref()
.is_none_or(|value| value.trim().is_empty())
{
record.provider = global.provider.clone();
}
if record
.runtime
.as_deref()
.is_none_or(|value| value.trim().is_empty())
{
record.runtime = global.preferred_runtime.clone();
}
}

/// Shared production encoding path.
///
/// Resolves the agent definition, validates inputs, fetches optional memory,
Expand Down Expand Up @@ -196,6 +223,13 @@ pub(crate) async fn materialize_snapshot_bytes(
let definitions = load_agent_definitions(&app)?;
let (def_record, is_definition) = resolve_from_lists(&id, &instances, &definitions)
.map(|(r, is_def)| (r.clone(), is_def))?;
let mut def_record = def_record;
// A snapshot is a verbatim portable copy of the effective runtime,
// provider, and model configuration, not a pointer to the sender's
// machine-wide defaults. This does not translate or substitute values
// for a different recipient setup.
let global = crate::managed_agents::load_global_agent_config(&app).unwrap_or_default();
materialize_portable_runtime_defaults(&mut def_record, &global);

let memory_pubkey = if memory_level != MemoryLevel::None {
let mpk = memory_source_pubkey.as_deref().unwrap_or("");
Expand Down Expand Up @@ -400,6 +434,8 @@ pub async fn encode_agent_snapshot_for_send(
})
}

#[cfg(test)]
mod fidelity_tests;
#[cfg(test)]
mod tests;

Expand Down
210 changes: 210 additions & 0 deletions desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
use super::import::decode_snapshot_from_bytes;
use super::*;
use crate::managed_agents::{
agent_snapshot::{
AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, AgentSnapshotProfile,
FORMAT_DISCRIMINATOR, FORMAT_VERSION,
},
BackendKind, ManagedAgentRecord, RespondTo,
};
use std::collections::BTreeMap;

fn make_definition(slug: &str) -> ManagedAgentRecord {
ManagedAgentRecord {
pubkey: String::new(),
slug: Some(slug.to_string()),
name: slug.to_string(),
display_name: None,
persona_id: None,
private_key_nsec: String::new(),
auth_tag: None,
relay_url: String::new(),
avatar_url: None,
acp_command: String::new(),
agent_command: String::new(),
agent_command_override: None,
agent_args: vec![],
mcp_command: String::new(),
turn_timeout_seconds: 0,
idle_timeout_seconds: None,
max_turn_duration_seconds: None,
parallelism: 1,
system_prompt: None,
model: None,
provider: None,
persona_source_version: None,
env_vars: BTreeMap::new(),
start_on_app_launch: false,
auto_restart_on_config_change: false,
runtime_pid: None,
backend: BackendKind::Local,
backend_agent_id: None,
provider_binary_path: None,
team_id: None,
persona_team_dir: None,
persona_name_in_team: None,
created_at: String::new(),
updated_at: String::new(),
last_started_at: None,
last_stopped_at: None,
last_exit_code: None,
last_error: None,
last_error_code: None,
respond_to: RespondTo::default(),
respond_to_allowlist: vec![],
runtime: None,
name_pool: vec![],
is_builtin: false,
is_active: false,
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
definition_respond_to: None,
definition_respond_to_allowlist: vec![],
definition_parallelism: None,
relay_mesh: None,
}
}

/// Build a minimal valid AgentSnapshot for import tests.
fn make_snapshot(
memory_level: MemoryLevel,
entries: Vec<AgentSnapshotMemoryEntry>,
) -> AgentSnapshot {
AgentSnapshot {
format: FORMAT_DISCRIMINATOR.to_string(),
version: FORMAT_VERSION,
definition: AgentSnapshotDefinition {
name: "Test Agent".to_string(),
source_is_builtin: false,
system_prompt: Some("You are helpful.".to_string()),
runtime: None,
model: None,
provider: None,
parallelism: None,
respond_to: None,
respond_to_allowlist: vec![],
name_pool: vec![],
idle_timeout_seconds: None,
max_turn_duration_seconds: None,
},
profile: AgentSnapshotProfile {
display_name: "Test Agent".to_string(),
about: None,
avatar_data_url: None,
avatar_url: None,
},
memory: AgentSnapshotMemory {
level: memory_level,
entries,
},
}
}

// ── Portable effective configuration ─────────────────────────────────────

#[test]
fn inherited_runtime_provider_and_model_are_materialized_for_export() {
let mut record = make_definition("wren");
let global = crate::managed_agents::GlobalAgentConfig {
preferred_runtime: Some("goose".to_string()),
provider: Some("databricks_v2".to_string()),
model: Some("databricks-gpt-5-6-sol".to_string()),
..Default::default()
};

materialize_portable_runtime_defaults(&mut record, &global);

assert_eq!(record.runtime.as_deref(), Some("goose"));
assert_eq!(record.provider.as_deref(), Some("databricks_v2"));
assert_eq!(record.model.as_deref(), Some("databricks-gpt-5-6-sol"));
}

#[test]
fn explicit_runtime_provider_and_model_win_over_global_defaults() {
let mut record = make_definition("wren");
record.runtime = Some("claude".to_string());
record.provider = Some("anthropic".to_string());
record.model = Some("claude-opus-5".to_string());
let global = crate::managed_agents::GlobalAgentConfig {
preferred_runtime: Some("goose".to_string()),
provider: Some("databricks_v2".to_string()),
model: Some("databricks-gpt-5-6-sol".to_string()),
..Default::default()
};

materialize_portable_runtime_defaults(&mut record, &global);

assert_eq!(record.runtime.as_deref(), Some("claude"));
assert_eq!(record.provider.as_deref(), Some("anthropic"));
assert_eq!(record.model.as_deref(), Some("claude-opus-5"));
}

/// PNG image-body avatar overrides manifest avatar fields and all definition
/// config survives the exact production decoder.
#[test]
fn import_png_body_avatar_and_full_model_round_trip() {
use crate::managed_agents::agent_snapshot::{decode_avatar_data_url, encode_snapshot_png};

let mut snapshot = make_snapshot(MemoryLevel::None, vec![]);
snapshot.definition.runtime = Some("goose".to_string());
snapshot.definition.model = Some("databricks-gpt-5-6-sol".to_string());
snapshot.definition.provider = Some("databricks_v2".to_string());
snapshot.profile.avatar_data_url = None;
snapshot.profile.avatar_url = Some("https://sender.invalid/avatar.png".to_string());

let avatar = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
4,
3,
image::Rgba([23, 91, 177, 255]),
));
let mut avatar_png = std::io::Cursor::new(Vec::new());
avatar
.write_to(&mut avatar_png, image::ImageFormat::Png)
.unwrap();
let png_bytes = encode_snapshot_png(&snapshot, Some(avatar_png.get_ref())).unwrap();

let decoded = decode_snapshot_from_bytes(&png_bytes).unwrap();
assert_eq!(decoded.definition.runtime.as_deref(), Some("goose"));
assert_eq!(
decoded.definition.model.as_deref(),
Some("databricks-gpt-5-6-sol")
);
assert_eq!(
decoded.definition.provider.as_deref(),
Some("databricks_v2")
);
assert_eq!(
decoded.profile.avatar_url.as_deref(),
Some("https://sender.invalid/avatar.png")
);

let avatar_data_url = decoded
.profile
.avatar_data_url
.as_deref()
.expect("PNG image body must become the effective portable avatar");
let avatar_bytes = decode_avatar_data_url(avatar_data_url).unwrap();
let imported_avatar = image::load_from_memory(&avatar_bytes).unwrap();
assert_eq!((imported_avatar.width(), imported_avatar.height()), (4, 3));
assert_eq!(
imported_avatar.to_rgba8().get_pixel(0, 0).0,
[23, 91, 177, 255]
);
}

/// The transparent 1×1 no-avatar card must not override a manifest fallback.
#[test]
fn import_png_placeholder_keeps_manifest_avatar_fallback() {
use crate::managed_agents::agent_snapshot::encode_snapshot_png;

let mut snapshot = make_snapshot(MemoryLevel::None, vec![]);
snapshot.profile.avatar_data_url = None;
snapshot.profile.avatar_url = Some("https://example.com/avatar.png".to_string());
let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();

let decoded = decode_snapshot_from_bytes(&png_bytes).unwrap();
assert!(decoded.profile.avatar_data_url.is_none());
assert_eq!(decoded.profile.avatar_url, snapshot.profile.avatar_url);
}
10 changes: 9 additions & 1 deletion desktop/src-tauri/src/commands/personas/snapshot/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,15 @@ pub(crate) fn decode_snapshot_from_bytes(
file_bytes.len() / (1024 * 1024)
));
}
let snapshot = decode_snapshot_png(file_bytes)?;
let mut snapshot = decode_snapshot_png(file_bytes)?;
// The PNG image body is the portable avatar. It deliberately wins over
// manifest avatar fields, whose URL may only be reachable by the
// sender. A 1×1 export placeholder leaves the manifest fallback intact.
if let Some(avatar_data_url) =
crate::managed_agents::snapshot_avatar::snapshot_png_avatar_data_url(file_bytes)?
{
snapshot.profile.avatar_data_url = Some(avatar_data_url);
}
if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() {
return Err(
"Snapshot is malformed: memory.level is 'none' but entries are present."
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/managed_agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub mod retention;
mod runtime;
mod runtime_commands;
mod runtime_types;
pub(crate) mod snapshot_avatar;
pub(crate) mod spawn_hash;
pub(crate) mod storage;
pub(crate) mod team_events;
Expand Down
42 changes: 42 additions & 0 deletions desktop/src-tauri/src/managed_agents/snapshot_avatar.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
use base64::{engine::general_purpose::STANDARD, Engine as _};
use image::ImageDecoder;
use std::io::Cursor;

const MAX_AVATAR_INLINE_BYTES: usize = 2 * 1024 * 1024;
const MAX_AVATAR_DIMENSION: u32 = 2048;
const MAX_AVATAR_DECODE_ALLOC: u64 = 32 * 1024 * 1024;

/// Materialize a snapshot PNG's visible pixels as a bounded portable avatar.
/// The exact transparent 1×1 no-avatar placeholder and images that cannot fit
/// the persisted inline-avatar budget leave the manifest fallback intact.
pub(crate) fn snapshot_png_avatar_data_url(png_bytes: &[u8]) -> Result<Option<String>, String> {
let reader = image::ImageReader::with_format(Cursor::new(png_bytes), image::ImageFormat::Png);
let mut decoder = reader
.into_decoder()
.map_err(|e| format!("Failed to decode snapshot avatar: {e}"))?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_AVATAR_DIMENSION);
limits.max_image_height = Some(MAX_AVATAR_DIMENSION);
limits.max_alloc = Some(MAX_AVATAR_DECODE_ALLOC);
decoder
.set_limits(limits)
.map_err(|e| format!("Snapshot avatar exceeds safe decoding limits: {e}"))?;
let (width, height) = decoder.dimensions();
let image = image::DynamicImage::from_decoder(decoder)
.map_err(|e| format!("Failed to decode snapshot avatar: {e}"))?;
if width == 1 && height == 1 && image.to_rgba8().get_pixel(0, 0).0 == [0, 0, 0, 0] {
return Ok(None);
}

let mut clean_png = Vec::new();
image
.write_to(&mut Cursor::new(&mut clean_png), image::ImageFormat::Png)
.map_err(|e| format!("Failed to encode snapshot avatar: {e}"))?;
if clean_png.len() > MAX_AVATAR_INLINE_BYTES {
return Ok(None);
}
Ok(Some(format!(
"data:image/png;base64,{}",
STANDARD.encode(clean_png)
)))
}
2 changes: 2 additions & 0 deletions desktop/src/features/agents/ui/AgentsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ export function AgentsView() {
)}
isPending={personas.isPending}
linkedAgentPubkey={personas.personaToShare.linkedAgentPubkey}
effectiveAvatarUrl={personas.personaToShare.effectiveAvatarUrl}
onCatalogShareLevelChange={(shareLevel) => {
const shareTarget = personas.personaToShare;
if (!shareTarget) return;
Expand Down Expand Up @@ -392,6 +393,7 @@ export function AgentsView() {
personas.handleExportSnapshot(
personas.personaToExportSnapshot.persona,
personas.personaToExportSnapshot.linkedAgentPubkey,
personas.personaToExportSnapshot.effectiveAvatarUrl,
memoryLevel,
format,
);
Expand Down
6 changes: 4 additions & 2 deletions desktop/src/features/agents/ui/PersonaShareDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type PersonaShareDialogProps = {
catalogShareLevel: CatalogPersonaShareLevel;
isPending: boolean;
linkedAgentPubkey: string | null;
effectiveAvatarUrl: string | null;
onCatalogShareLevelChange: (shareLevel: CatalogPersonaShareLevel) => void;
onExport: () => void;
onOpenChange: (open: boolean) => void;
Expand Down Expand Up @@ -694,6 +695,7 @@ export function PersonaShareDialog({
catalogShareLevel,
isPending,
linkedAgentPubkey,
effectiveAvatarUrl,
onCatalogShareLevelChange,
onExport,
onOpenChange,
Expand All @@ -715,12 +717,12 @@ export function PersonaShareDialog({
memoryLevel: linkedAgentPubkey ? memoryLevel : "none",
format: "png",
memorySourcePubkey: linkedAgentPubkey,
avatarPngDataUrl: await resolveSnapshotAvatarPng(persona.avatarUrl),
avatarPngDataUrl: await resolveSnapshotAvatarPng(effectiveAvatarUrl),
}),
[
encodeSnapshotMutation.mutateAsync,
effectiveAvatarUrl,
linkedAgentPubkey,
persona.avatarUrl,
persona.id,
],
);
Expand Down
Loading
Loading