mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(snapshot): send Buzz shares as PNG avatar cards (#1811)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
d41b4c3905
commit
a6cfd65511
@@ -281,7 +281,7 @@ async fn fetch_blob_bytes_with_cap(
|
||||
pub(crate) enum SnapshotFileKind {
|
||||
/// `.agent.json` — plaintext JSON; accepts memory; 5 MiB cap.
|
||||
AgentJson,
|
||||
/// `.agent.png` — PNG with embedded metadata; no memory; 10 MiB cap.
|
||||
/// `.agent.png` — PNG with embedded metadata; 10 MiB cap.
|
||||
AgentPng,
|
||||
/// `.team.json` — team template; 25 MiB cap.
|
||||
TeamJson,
|
||||
@@ -443,9 +443,8 @@ pub async fn fetch_snapshot_bytes(
|
||||
return Err("hash mismatch: fetched bytes do not match the declared SHA-256".to_string());
|
||||
}
|
||||
|
||||
// 3. Byte magic must match the expected kind (filename → format).
|
||||
// This prevents .agent.png delivering JSON bytes (including memory-bearing
|
||||
// JSON) and .agent.json delivering PNG bytes.
|
||||
// 3. Byte magic must match the expected kind (filename → format), so a
|
||||
// filename cannot select a JSON or PNG parser for the other format.
|
||||
ensure_bytes_match_kind(&bytes, kind)?;
|
||||
|
||||
// 4. Bytes must parse as the snapshot type selected by the filename.
|
||||
|
||||
@@ -173,7 +173,6 @@ fn parse_format_is_png(s: &str) -> Result<bool, String> {
|
||||
///
|
||||
/// **Invariants preserved (identical for both callers):**
|
||||
/// - JSON/PNG format selection from the parsed `is_png` flag (magic-byte sniffing is import-only)
|
||||
/// - PNG + memory hard rejection
|
||||
/// - Memory-source pubkey validation
|
||||
/// - Secret exclusion (env_vars never enter the manifest via `build_snapshot`)
|
||||
/// - Output filename derived from the agent display name
|
||||
@@ -182,18 +181,10 @@ pub(crate) async fn materialize_snapshot_bytes(
|
||||
memory_source_pubkey: Option<String>,
|
||||
memory_level: MemoryLevel,
|
||||
is_png: bool,
|
||||
avatar_png_data_url: Option<String>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SnapshotPayload, String> {
|
||||
// Eagerly reject PNG + memory — avoid an unnecessary relay round-trip.
|
||||
if is_png && memory_level != MemoryLevel::None {
|
||||
return Err(
|
||||
"Cannot export memory to .agent.png — use JSON format for memory-bearing \
|
||||
snapshots."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Load definition record and memory-source instance under lock ─────────
|
||||
let (record, memory_pubkey) = {
|
||||
let _store_guard = state
|
||||
@@ -274,7 +265,9 @@ pub(crate) async fn materialize_snapshot_bytes(
|
||||
let slug = crate::util::slugify(&display_name, "agent", 50);
|
||||
|
||||
if is_png {
|
||||
let png_bytes = encode_snapshot_png(&snapshot, avatar_bytes.as_deref())
|
||||
let png_body_avatar_bytes =
|
||||
resolve_png_body_avatar_bytes(avatar_png_data_url.as_deref(), avatar_bytes);
|
||||
let png_bytes = encode_snapshot_png(&snapshot, png_body_avatar_bytes.as_deref())
|
||||
.map_err(|e| format!("Failed to encode .agent.png: {e}"))?;
|
||||
validate_snapshot_encode_size(png_bytes.len(), true)?;
|
||||
Ok(SnapshotPayload {
|
||||
@@ -292,6 +285,17 @@ pub(crate) async fn materialize_snapshot_bytes(
|
||||
}
|
||||
}
|
||||
|
||||
/// Choose bytes for the PNG image body without changing the source avatar the
|
||||
/// manifest preserves for import.
|
||||
fn resolve_png_body_avatar_bytes(
|
||||
avatar_png_data_url: Option<&str>,
|
||||
store_avatar_bytes: Option<Vec<u8>>,
|
||||
) -> Option<Vec<u8>> {
|
||||
avatar_png_data_url
|
||||
.and_then(crate::managed_agents::agent_snapshot::decode_avatar_data_url)
|
||||
.or(store_avatar_bytes)
|
||||
}
|
||||
|
||||
/// Export an agent definition as a `buzz-agent-snapshot v1` file.
|
||||
///
|
||||
/// `id` is a definition slug or a keyed-instance pubkey.
|
||||
@@ -309,6 +313,7 @@ pub async fn export_agent_snapshot(
|
||||
memory_source_pubkey: Option<String>,
|
||||
memory_level: String,
|
||||
format: String,
|
||||
avatar_png_data_url: Option<String>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
@@ -320,6 +325,7 @@ pub async fn export_agent_snapshot(
|
||||
memory_source_pubkey,
|
||||
memory_level,
|
||||
is_png,
|
||||
avatar_png_data_url,
|
||||
app.clone(),
|
||||
state,
|
||||
)
|
||||
@@ -370,15 +376,23 @@ pub async fn encode_agent_snapshot_for_send(
|
||||
memory_source_pubkey: Option<String>,
|
||||
memory_level: String,
|
||||
format: String,
|
||||
avatar_png_data_url: Option<String>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<EncodedSnapshotPayload, String> {
|
||||
let memory_level = parse_memory_level(&memory_level)?;
|
||||
let is_png = parse_format_is_png(&format)?;
|
||||
|
||||
let payload =
|
||||
materialize_snapshot_bytes(id, memory_source_pubkey, memory_level, is_png, app, state)
|
||||
.await?;
|
||||
let payload = materialize_snapshot_bytes(
|
||||
id,
|
||||
memory_source_pubkey,
|
||||
memory_level,
|
||||
is_png,
|
||||
avatar_png_data_url,
|
||||
app,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(EncodedSnapshotPayload {
|
||||
file_bytes: payload.bytes,
|
||||
@@ -388,3 +402,66 @@ pub async fn encode_agent_snapshot_for_send(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod png_body_tests {
|
||||
use super::*;
|
||||
use base64::Engine as _;
|
||||
use png::Decoder;
|
||||
|
||||
#[test]
|
||||
fn frontend_raster_becomes_png_body_without_replacing_manifest_source() {
|
||||
let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
|
||||
3,
|
||||
2,
|
||||
image::Rgb([0x12, 0x34, 0x56]),
|
||||
));
|
||||
let mut source_png = Vec::new();
|
||||
avatar
|
||||
.write_to(
|
||||
&mut std::io::Cursor::new(&mut source_png),
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.unwrap();
|
||||
let avatar_data_url = format!(
|
||||
"data:image/png;base64,{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(&source_png)
|
||||
);
|
||||
let snapshot = crate::managed_agents::agent_snapshot::AgentSnapshot {
|
||||
format: crate::managed_agents::agent_snapshot::FORMAT_DISCRIMINATOR.to_string(),
|
||||
version: crate::managed_agents::agent_snapshot::FORMAT_VERSION,
|
||||
definition: crate::managed_agents::agent_snapshot::AgentSnapshotDefinition {
|
||||
name: "Agent".to_string(),
|
||||
system_prompt: None,
|
||||
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: crate::managed_agents::agent_snapshot::AgentSnapshotProfile {
|
||||
display_name: "Agent".to_string(),
|
||||
about: None,
|
||||
avatar_data_url: None,
|
||||
avatar_url: Some("https://relay.example/media/avatar.png".to_string()),
|
||||
},
|
||||
memory: crate::managed_agents::agent_snapshot::AgentSnapshotMemory {
|
||||
level: MemoryLevel::None,
|
||||
entries: vec![],
|
||||
},
|
||||
};
|
||||
let body_bytes = resolve_png_body_avatar_bytes(Some(&avatar_data_url), None);
|
||||
let png_bytes = encode_snapshot_png(&snapshot, body_bytes.as_deref()).unwrap();
|
||||
let reader = Decoder::new(std::io::Cursor::new(&png_bytes))
|
||||
.read_info()
|
||||
.unwrap();
|
||||
let decoded = import::decode_snapshot_from_bytes(&png_bytes).unwrap();
|
||||
|
||||
assert_eq!((reader.info().width, reader.info().height), (3, 2));
|
||||
assert_eq!(decoded.profile.avatar_url, snapshot.profile.avatar_url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,9 +197,9 @@ const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4e, 0x47];
|
||||
/// Fails closed on malformed content, wrong format, or unsupported version.
|
||||
/// Never trusts the file extension — only the bytes.
|
||||
///
|
||||
/// **PNG memory policy:** any PNG manifest whose `memory.level` is not `None`,
|
||||
/// or whose `memory.entries` is non-empty despite `level == None`, is rejected
|
||||
/// before any write. Plaintext memory is accepted only from `.agent.json`.
|
||||
/// **Memory consistency:** any manifest whose `memory.entries` is non-empty
|
||||
/// despite `memory.level == None` is rejected before any write, regardless of
|
||||
/// the enclosing format.
|
||||
///
|
||||
/// **Size cap:** PNG inputs over 10 MiB and JSON inputs over 5 MiB are rejected
|
||||
/// before allocation to avoid avoidable large-input work.
|
||||
@@ -214,18 +214,9 @@ pub(crate) fn decode_snapshot_from_bytes(
|
||||
));
|
||||
}
|
||||
let snapshot = decode_snapshot_png(file_bytes)?;
|
||||
// Hard reject: PNG must never carry memory.
|
||||
if snapshot.memory.level != crate::managed_agents::agent_snapshot::MemoryLevel::None {
|
||||
if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() {
|
||||
return Err(
|
||||
"Cannot import a memory-bearing .agent.png — use .agent.json for \
|
||||
snapshots that include memory."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if !snapshot.memory.entries.is_empty() {
|
||||
return Err(
|
||||
"Snapshot is malformed: .agent.png carries memory entries despite \
|
||||
memory.level being 'none'."
|
||||
"Snapshot is malformed: memory.level is 'none' but entries are present."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -368,80 +368,71 @@ fn import_avatar_url_fallback_is_used_when_no_data_url() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Import: PNG memory policy ─────────────────────────────────────────────
|
||||
// ── Import: PNG memory parity ─────────────────────────────────────────────
|
||||
|
||||
/// PNG with memory level `core` is rejected before any write.
|
||||
/// PNG with core memory round-trips through the production import decoder.
|
||||
#[test]
|
||||
fn import_png_with_core_memory_is_rejected() {
|
||||
// encode_snapshot_png refuses to encode memory-bearing snapshots —
|
||||
// that guard on the EXPORT side is correct. On the IMPORT side,
|
||||
// decode_snapshot_from_bytes adds a matching guard so that a
|
||||
// foreign-built PNG carrying a core/everything memory level is also
|
||||
// rejected. We verify the guard condition here.
|
||||
let level = MemoryLevel::Core;
|
||||
let entries = vec![AgentSnapshotMemoryEntry {
|
||||
slug: "core".to_string(),
|
||||
body: "Secret.".to_string(),
|
||||
}];
|
||||
// Simulate what decode_snapshot_from_bytes checks after PNG decode:
|
||||
let would_reject = level != MemoryLevel::None;
|
||||
assert!(
|
||||
would_reject,
|
||||
"PNG with core memory level must be rejected by the import guard"
|
||||
);
|
||||
// Also verify that the encoder itself refuses this on the export side.
|
||||
fn import_png_with_core_memory_preserves_entries() {
|
||||
use crate::managed_agents::agent_snapshot::encode_snapshot_png;
|
||||
let snapshot = make_snapshot(MemoryLevel::Core, entries);
|
||||
assert!(
|
||||
encode_snapshot_png(&snapshot, None).is_err(),
|
||||
"encode_snapshot_png must also refuse memory-bearing snapshots"
|
||||
let snapshot = make_snapshot(
|
||||
MemoryLevel::Core,
|
||||
vec![AgentSnapshotMemoryEntry {
|
||||
slug: "core".to_string(),
|
||||
body: "Remember this.".to_string(),
|
||||
}],
|
||||
);
|
||||
|
||||
let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();
|
||||
let decoded = decode_snapshot_from_bytes(&png_bytes).unwrap();
|
||||
|
||||
assert_eq!(decoded.memory, snapshot.memory);
|
||||
}
|
||||
|
||||
/// PNG with memory level `everything` is rejected before any write.
|
||||
/// PNG with all memory round-trips through the production import decoder.
|
||||
#[test]
|
||||
fn import_png_with_everything_memory_is_rejected() {
|
||||
// Same as core: both the encoder guard and the decoder guard fire.
|
||||
let level = MemoryLevel::Everything;
|
||||
let would_reject = level != MemoryLevel::None;
|
||||
assert!(
|
||||
would_reject,
|
||||
"PNG with everything memory level must be rejected by the import guard"
|
||||
);
|
||||
fn import_png_with_everything_memory_preserves_entries() {
|
||||
use crate::managed_agents::agent_snapshot::encode_snapshot_png;
|
||||
let snapshot = make_snapshot(
|
||||
MemoryLevel::Everything,
|
||||
vec![AgentSnapshotMemoryEntry {
|
||||
slug: "mem/research".to_string(),
|
||||
body: "Notes.".to_string(),
|
||||
}],
|
||||
);
|
||||
assert!(
|
||||
encode_snapshot_png(&snapshot, None).is_err(),
|
||||
"encode_snapshot_png must also refuse memory-bearing snapshots"
|
||||
vec![
|
||||
AgentSnapshotMemoryEntry {
|
||||
slug: "core".to_string(),
|
||||
body: "Remember this.".to_string(),
|
||||
},
|
||||
AgentSnapshotMemoryEntry {
|
||||
slug: "mem/research".to_string(),
|
||||
body: "Notes.".to_string(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();
|
||||
let decoded = decode_snapshot_from_bytes(&png_bytes).unwrap();
|
||||
|
||||
assert_eq!(decoded.memory, snapshot.memory);
|
||||
}
|
||||
|
||||
/// PNG with `none` level but non-empty entries is rejected (malformed).
|
||||
/// We test this by verifying the guard condition directly, since
|
||||
/// encode_snapshot_png correctly refuses to produce such a PNG.
|
||||
/// PNG with `none` level but non-empty entries is rejected as malformed.
|
||||
#[test]
|
||||
fn import_png_with_none_level_and_entries_is_rejected() {
|
||||
// This guard is enforced in decode_snapshot_from_bytes after the PNG
|
||||
// path resolves. A PNG with none + entries cannot be produced by
|
||||
// encode_snapshot_png (it already guards that), but could arrive from
|
||||
// a foreign tool. We verify the guard condition matches the spec:
|
||||
let level = MemoryLevel::None;
|
||||
let entries = [AgentSnapshotMemoryEntry {
|
||||
slug: "core".to_string(),
|
||||
body: "Leak.".to_string(),
|
||||
}];
|
||||
// The guard in decode_snapshot_from_bytes:
|
||||
let would_reject = !entries.is_empty() && level == MemoryLevel::None;
|
||||
assert!(
|
||||
would_reject,
|
||||
"none level + non-empty entries must trigger the guard"
|
||||
use crate::managed_agents::agent_snapshot::{
|
||||
encode_snapshot_json, encode_snapshot_png, make_png_with_text, PNG_CHUNK_KEYWORD,
|
||||
};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
|
||||
let snapshot = make_snapshot(
|
||||
MemoryLevel::None,
|
||||
vec![AgentSnapshotMemoryEntry {
|
||||
slug: "core".to_string(),
|
||||
body: "Leak.".to_string(),
|
||||
}],
|
||||
);
|
||||
let json = encode_snapshot_json(&snapshot).unwrap();
|
||||
let png_bytes = make_png_with_text(PNG_CHUNK_KEYWORD, &STANDARD.encode(json)).unwrap();
|
||||
|
||||
assert!(encode_snapshot_png(&snapshot, None).is_err());
|
||||
let error = decode_snapshot_from_bytes(&png_bytes).unwrap_err();
|
||||
assert!(error.contains("'none' but entries are present"));
|
||||
}
|
||||
|
||||
/// PNG with `none` level and no entries imports normally.
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
//! - **memory** — optional, owner-decrypted engrams at one of three levels
|
||||
//!
|
||||
//! Two encodings are supported:
|
||||
//! - `.agent.json` — canonical, may carry memory at any level
|
||||
//! - `.agent.json` — canonical snapshot manifest
|
||||
//! - `.agent.png` — avatar image with manifest in a `buzz_agent_snapshot`
|
||||
//! tEXt chunk; memory MUST be `None` (PNG files are casually shared and
|
||||
//! would leak plaintext memory into chat uploads)
|
||||
//! tEXt chunk
|
||||
//!
|
||||
//! Both formats may carry memory at any level. Memory entries are plaintext,
|
||||
//! so callers must require an explicit opt-in before exporting them.
|
||||
//!
|
||||
//! **Zip is NOT in v1** — deferred to v2 for skills bundling.
|
||||
//!
|
||||
@@ -284,17 +286,13 @@ pub fn decode_snapshot_json(bytes: &[u8]) -> Result<AgentSnapshot, String> {
|
||||
|
||||
/// Encode a snapshot into a `.agent.png` — avatar as the image body, manifest
|
||||
/// in the `buzz_agent_snapshot` tEXt chunk.
|
||||
///
|
||||
/// **Rejects** any snapshot whose `memory.level != None` — memory in a PNG
|
||||
/// file is a security hazard (images are casually shared, pasted into chats).
|
||||
pub fn encode_snapshot_png(
|
||||
snapshot: &AgentSnapshot,
|
||||
avatar_bytes: Option<&[u8]>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
if snapshot.memory.level != MemoryLevel::None || !snapshot.memory.entries.is_empty() {
|
||||
if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() {
|
||||
return Err(
|
||||
"Cannot write memory to a .agent.png file — use .agent.json for memory-bearing \
|
||||
snapshots. PNG images are casually shared and would expose memory as plaintext."
|
||||
"Cannot write a snapshot with memory.level 'none' and non-empty memory entries."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
@@ -303,18 +301,23 @@ pub fn encode_snapshot_png(
|
||||
let json_bytes = encode_snapshot_json(snapshot)?;
|
||||
let chunk_text = STANDARD.encode(&json_bytes);
|
||||
|
||||
// Use the avatar bytes as the PNG image when available; otherwise produce
|
||||
// a minimal 1×1 transparent placeholder.
|
||||
let png_bytes = match avatar_bytes.filter(|b| !b.is_empty()) {
|
||||
Some(bytes) if bytes.starts_with(b"\x89PNG") => {
|
||||
// Already a PNG — inject the tEXt chunk.
|
||||
inject_text_chunk(bytes, PNG_CHUNK_KEYWORD, &chunk_text)?
|
||||
}
|
||||
Some(_bytes) => {
|
||||
// Non-PNG avatar (JPEG, etc.) — re-encode as 1×1 placeholder and
|
||||
// carry the avatar via the manifest's `profile.avatar_data_url`.
|
||||
// This keeps the PNG valid while preserving the avatar in the JSON.
|
||||
make_png_with_text(PNG_CHUNK_KEYWORD, &chunk_text)?
|
||||
// Use the avatar as the PNG image body, transcoding decodable non-PNG
|
||||
// avatars. Fall back to a minimal 1×1 transparent placeholder only when
|
||||
// there is no avatar or it cannot be decoded.
|
||||
let png_bytes = match avatar_bytes.filter(|bytes| !bytes.is_empty()) {
|
||||
Some(bytes) => {
|
||||
let encoded_avatar = if bytes.starts_with(b"\x89PNG") {
|
||||
inject_text_chunk(bytes, PNG_CHUNK_KEYWORD, &chunk_text).or_else(|_| {
|
||||
transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text)
|
||||
})
|
||||
} else {
|
||||
transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text)
|
||||
};
|
||||
|
||||
match encoded_avatar {
|
||||
Ok(png_bytes) => png_bytes,
|
||||
Err(_) => make_png_with_text(PNG_CHUNK_KEYWORD, &chunk_text)?,
|
||||
}
|
||||
}
|
||||
None => make_png_with_text(PNG_CHUNK_KEYWORD, &chunk_text)?,
|
||||
};
|
||||
@@ -403,6 +406,21 @@ pub(crate) fn make_png_with_text(keyword: &str, text: &str) -> Result<Vec<u8>, S
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Transcode a decodable avatar to PNG and add the snapshot manifest chunk.
|
||||
fn transcode_avatar_to_png_with_text(
|
||||
avatar_bytes: &[u8],
|
||||
keyword: &str,
|
||||
text: &str,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let image = image::load_from_memory(avatar_bytes)
|
||||
.map_err(|e| format!("Failed to decode avatar image: {e}"))?;
|
||||
let mut png_bytes = Vec::new();
|
||||
image
|
||||
.write_to(&mut Cursor::new(&mut png_bytes), image::ImageFormat::Png)
|
||||
.map_err(|e| format!("Failed to encode avatar as PNG: {e}"))?;
|
||||
inject_text_chunk(&png_bytes, keyword, text)
|
||||
}
|
||||
|
||||
/// Inject a tEXt chunk into an existing PNG by re-encoding it.
|
||||
///
|
||||
/// Re-decodes the image data via the `png` crate and writes a fresh PNG with
|
||||
@@ -576,35 +594,67 @@ mod tests {
|
||||
assert_eq!(parsed.definition.name, snapshot.definition.name);
|
||||
}
|
||||
|
||||
// ── PNG memory guard ──────────────────────────────────────────────────────
|
||||
#[test]
|
||||
fn png_snapshot_transcodes_jpeg_avatar_into_image_body() {
|
||||
let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
|
||||
3,
|
||||
2,
|
||||
image::Rgb([0x12, 0x34, 0x56]),
|
||||
));
|
||||
let mut jpeg_bytes = Vec::new();
|
||||
avatar
|
||||
.write_to(&mut Cursor::new(&mut jpeg_bytes), image::ImageFormat::Jpeg)
|
||||
.unwrap();
|
||||
|
||||
let snapshot = build_snapshot(
|
||||
&minimal_record(),
|
||||
MemoryLevel::None,
|
||||
vec![],
|
||||
Some(&jpeg_bytes),
|
||||
);
|
||||
let png_bytes = encode_snapshot_png(&snapshot, Some(&jpeg_bytes)).unwrap();
|
||||
let decoder = Decoder::new(Cursor::new(png_bytes));
|
||||
let reader = decoder.read_info().unwrap();
|
||||
|
||||
assert_eq!((reader.info().width, reader.info().height), (3, 2));
|
||||
}
|
||||
|
||||
// ── PNG memory parity ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn png_export_with_core_memory_is_rejected() {
|
||||
fn png_round_trip_with_core_memory() {
|
||||
let record = minimal_record();
|
||||
let entries = vec![AgentSnapshotMemoryEntry {
|
||||
slug: "core".to_string(),
|
||||
body: "secret memory".to_string(),
|
||||
body: "remember this".to_string(),
|
||||
}];
|
||||
let snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None);
|
||||
let result = encode_snapshot_png(&snapshot, None);
|
||||
assert!(result.is_err(), "Expected error for PNG with memory");
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.contains("Cannot write memory to a .agent.png"),
|
||||
"Error message should explain the PNG memory restriction, got: {err}"
|
||||
);
|
||||
|
||||
let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();
|
||||
let parsed = decode_snapshot_png(&png_bytes).unwrap();
|
||||
|
||||
assert_eq!(parsed.memory, snapshot.memory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_export_with_everything_memory_is_rejected() {
|
||||
fn png_round_trip_with_everything_memory() {
|
||||
let record = minimal_record();
|
||||
let entries = vec![AgentSnapshotMemoryEntry {
|
||||
slug: "mem/notes".to_string(),
|
||||
body: "private notes".to_string(),
|
||||
}];
|
||||
let entries = vec![
|
||||
AgentSnapshotMemoryEntry {
|
||||
slug: "core".to_string(),
|
||||
body: "remember this".to_string(),
|
||||
},
|
||||
AgentSnapshotMemoryEntry {
|
||||
slug: "mem/notes".to_string(),
|
||||
body: "private notes".to_string(),
|
||||
},
|
||||
];
|
||||
let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None);
|
||||
let result = encode_snapshot_png(&snapshot, None);
|
||||
assert!(result.is_err());
|
||||
|
||||
let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();
|
||||
let parsed = decode_snapshot_png(&png_bytes).unwrap();
|
||||
|
||||
assert_eq!(parsed.memory, snapshot.memory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -632,8 +682,10 @@ mod tests {
|
||||
"PNG encoder must reject level=None with non-empty entries"
|
||||
);
|
||||
assert!(
|
||||
result.unwrap_err().contains("Cannot write memory"),
|
||||
"Error must explain the PNG memory restriction"
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("memory.level 'none' and non-empty memory entries"),
|
||||
"Error must explain the malformed memory state"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ensureChannelAgentPresetInChannel,
|
||||
} from "@/features/agents/channelAgents";
|
||||
import { channelsQueryKey } from "@/features/channels/hooks";
|
||||
import { resolveSnapshotAvatarPng } from "@/features/agents/ui/snapshotAvatarPng";
|
||||
import { evictUsersBatchEntries } from "@/features/profile/hooks";
|
||||
import {
|
||||
createManagedAgent,
|
||||
@@ -597,17 +598,31 @@ export function useCreateChannelManagedAgentsMutation(
|
||||
|
||||
export function useExportAgentSnapshotMutation() {
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
memoryLevel,
|
||||
format,
|
||||
memorySourcePubkey,
|
||||
avatarUrl,
|
||||
}: {
|
||||
id: string;
|
||||
memoryLevel: SnapshotMemoryLevel;
|
||||
format: SnapshotFormat;
|
||||
memorySourcePubkey?: string | null;
|
||||
}) => exportAgentSnapshot(id, memoryLevel, format, memorySourcePubkey),
|
||||
avatarUrl?: string | null;
|
||||
}) => {
|
||||
const avatarPngDataUrl =
|
||||
format === "png"
|
||||
? await resolveSnapshotAvatarPng(avatarUrl)
|
||||
: undefined;
|
||||
return exportAgentSnapshot(
|
||||
id,
|
||||
memoryLevel,
|
||||
format,
|
||||
memorySourcePubkey,
|
||||
avatarPngDataUrl,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -618,13 +633,21 @@ export function useEncodeAgentSnapshotForSendMutation() {
|
||||
memoryLevel,
|
||||
format,
|
||||
memorySourcePubkey,
|
||||
avatarPngDataUrl,
|
||||
}: {
|
||||
id: string;
|
||||
memoryLevel: SnapshotMemoryLevel;
|
||||
format: SnapshotFormat;
|
||||
memorySourcePubkey?: string | null;
|
||||
avatarPngDataUrl?: string;
|
||||
}) =>
|
||||
encodeAgentSnapshotForSend(id, memoryLevel, format, memorySourcePubkey),
|
||||
encodeAgentSnapshotForSend(
|
||||
id,
|
||||
memoryLevel,
|
||||
format,
|
||||
memorySourcePubkey,
|
||||
avatarPngDataUrl,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -69,17 +69,6 @@ export function AgentSnapshotExportDialog({
|
||||
|
||||
const hasLinkedAgent = linkedAgentPubkey !== null;
|
||||
const showMemoryWarning = memoryLevel !== "none";
|
||||
// PNG format is disabled when memory is selected (backend guards this too,
|
||||
// but we disable the option proactively to avoid a confusing error).
|
||||
const pngDisabled = memoryLevel !== "none";
|
||||
|
||||
React.useEffect(() => {
|
||||
// If the user switches to a memory level, force JSON format since PNG
|
||||
// cannot carry memory.
|
||||
if (pngDisabled && format === "png") {
|
||||
setFormat("json");
|
||||
}
|
||||
}, [pngDisabled, format]);
|
||||
|
||||
// Reset state when the dialog opens for a fresh export.
|
||||
React.useEffect(() => {
|
||||
@@ -226,23 +215,21 @@ export function AgentSnapshotExportDialog({
|
||||
/>
|
||||
<span className="text-sm">.agent.json</span>
|
||||
</label>
|
||||
<label
|
||||
className={`flex items-center gap-2 ${pngDisabled ? "cursor-not-allowed opacity-40" : "cursor-pointer"}`}
|
||||
>
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
checked={format === "png"}
|
||||
disabled={pngDisabled}
|
||||
name="snapshot-format"
|
||||
onChange={() => !pngDisabled && setFormat("png")}
|
||||
onChange={() => setFormat("png")}
|
||||
type="radio"
|
||||
value="png"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
.agent.png
|
||||
{pngDisabled ? " (unavailable with memory)" : ""}
|
||||
</span>
|
||||
<span className="text-sm">.agent.png</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Applies to saved files; snapshots shared in Buzz always use
|
||||
.agent.png. PNG exports include memory when selected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -251,7 +238,6 @@ export function AgentSnapshotExportDialog({
|
||||
{/* Send-in-Buzz destination picker — opened as a secondary dialog */}
|
||||
{sendOpen ? (
|
||||
<AgentSnapshotSendDialog
|
||||
format={format}
|
||||
linkedAgentPubkey={linkedAgentPubkey}
|
||||
memoryLevel={memoryLevel}
|
||||
open={sendOpen}
|
||||
|
||||
@@ -2,10 +2,7 @@ import * as React from "react";
|
||||
import { AlertCircle, Check, Search, Send } from "lucide-react";
|
||||
|
||||
import type { AgentPersona } from "@/shared/api/types";
|
||||
import type {
|
||||
SnapshotFormat,
|
||||
SnapshotMemoryLevel,
|
||||
} from "@/shared/api/tauriPersonas";
|
||||
import type { SnapshotMemoryLevel } from "@/shared/api/tauriPersonas";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -17,6 +14,7 @@ import {
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
import { useEncodeAgentSnapshotForSendMutation } from "@/features/agents/hooks";
|
||||
import { useTimeoutState } from "@/features/moderation/lib/timeoutStore";
|
||||
import { resolveSnapshotAvatarPng } from "./snapshotAvatarPng";
|
||||
import {
|
||||
useSnapshotSendController,
|
||||
type SendPhase,
|
||||
@@ -30,7 +28,6 @@ type AgentSnapshotSendDialogProps = {
|
||||
persona: AgentPersona;
|
||||
linkedAgentPubkey: string | null;
|
||||
memoryLevel: SnapshotMemoryLevel;
|
||||
format: SnapshotFormat;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Called when the snapshot was successfully sent. */
|
||||
onSent: () => void;
|
||||
@@ -53,7 +50,6 @@ export function AgentSnapshotSendDialog({
|
||||
persona,
|
||||
linkedAgentPubkey,
|
||||
memoryLevel,
|
||||
format,
|
||||
onOpenChange,
|
||||
onSent,
|
||||
}: AgentSnapshotSendDialogProps) {
|
||||
@@ -163,12 +159,15 @@ export function AgentSnapshotSendDialog({
|
||||
// This closes the race between this pre-flight check and the moment encode
|
||||
// or upload actually starts.
|
||||
await controller.beginSend(
|
||||
() =>
|
||||
async () =>
|
||||
encodeMutation.mutateAsync({
|
||||
id: persona.id,
|
||||
memoryLevel,
|
||||
format,
|
||||
// PNG is the avatar card image and retains the snapshot contents.
|
||||
// JSON has no relay-valid thumbnail.
|
||||
format: "png",
|
||||
memorySourcePubkey: linkedAgentPubkey,
|
||||
avatarPngDataUrl: await resolveSnapshotAvatarPng(persona.avatarUrl),
|
||||
}),
|
||||
destination.id,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resolveSnapshotAvatarPng } from "./snapshotAvatarPng.ts";
|
||||
|
||||
const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
|
||||
test("resolveSnapshotAvatarPng: relay media URL becomes PNG data URL", async () => {
|
||||
const result = await resolveSnapshotAvatarPng(
|
||||
"https://relay.example/media/avatar.png",
|
||||
{
|
||||
fetchBytes: async (url) => {
|
||||
assert.equal(url, "https://relay.example/media/avatar.png");
|
||||
return PNG_BYTES;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, "data:image/png;base64,iVBORw==");
|
||||
});
|
||||
|
||||
test("resolveSnapshotAvatarPng: emoji SVG is rasterized onto a canvas", async () => {
|
||||
const draws = [];
|
||||
const image = {
|
||||
src: "",
|
||||
decode: async () => {},
|
||||
};
|
||||
const canvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => ({
|
||||
drawImage: (...args) => draws.push(args),
|
||||
}),
|
||||
toDataURL: (type) => {
|
||||
assert.equal(type, "image/png");
|
||||
return "data:image/png;base64,cmFzdGVyaXplZA==";
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveSnapshotAvatarPng(
|
||||
"data:image/svg+xml,%3Csvg%2F%3E",
|
||||
{
|
||||
createCanvas: () => canvas,
|
||||
createImage: () => image,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, "data:image/png;base64,cmFzdGVyaXplZA==");
|
||||
assert.equal(image.src, "data:image/svg+xml,%3Csvg%2F%3E");
|
||||
assert.equal(canvas.width, 512);
|
||||
assert.equal(canvas.height, 512);
|
||||
assert.deepEqual(draws, [[image, 0, 0, 512, 512]]);
|
||||
});
|
||||
|
||||
test("resolveSnapshotAvatarPng: failed media fetches and malformed URLs return undefined", async () => {
|
||||
let fetchCalled = false;
|
||||
const dependencies = {
|
||||
fetchBytes: async () => {
|
||||
fetchCalled = true;
|
||||
throw new Error("external URL rejected by Rust validation");
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
await resolveSnapshotAvatarPng(
|
||||
"https://external.example/avatar.png",
|
||||
dependencies,
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSnapshotAvatarPng("not a URL", dependencies),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(fetchCalled, true);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { fetchMediaBytes } from "@/shared/api/tauriMedia";
|
||||
|
||||
type SnapshotAvatarPngDependencies = {
|
||||
fetchBytes?: (url: string) => Promise<Uint8Array>;
|
||||
createCanvas?: () => HTMLCanvasElement;
|
||||
createImage?: () => HTMLImageElement;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve an avatar to PNG data for the image body of a snapshot PNG.
|
||||
*
|
||||
* The original avatar URL remains in the manifest so imports preserve the
|
||||
* editable source; this only supplies a renderable card thumbnail. Relay
|
||||
* media fetches are validated by Rust, which rejects external origins.
|
||||
*/
|
||||
export async function resolveSnapshotAvatarPng(
|
||||
avatarUrl: string | null | undefined,
|
||||
dependencies: SnapshotAvatarPngDependencies = {},
|
||||
): Promise<string | undefined> {
|
||||
const url = avatarUrl?.trim();
|
||||
if (!url) return undefined;
|
||||
|
||||
if (isSvgDataUrl(url)) {
|
||||
return rasterizeSvg(url, dependencies);
|
||||
}
|
||||
|
||||
if (!isHttpsUrl(url)) return undefined;
|
||||
|
||||
try {
|
||||
// Rust validates same-relay `/media/` URLs before fetching; other origins
|
||||
// fail there rather than being fetched by the webview.
|
||||
const bytes = await (dependencies.fetchBytes ?? fetchMediaBytes)(url);
|
||||
return `data:image/png;base64,${bytesToBase64(bytes)}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isSvgDataUrl(url: string) {
|
||||
return /^data:image\/svg\+xml(?:;[^,]*)?,/i.test(url);
|
||||
}
|
||||
|
||||
function isHttpsUrl(url: string) {
|
||||
try {
|
||||
return new URL(url).protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function rasterizeSvg(
|
||||
svgDataUrl: string,
|
||||
dependencies: SnapshotAvatarPngDependencies,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const image = (dependencies.createImage ?? (() => new Image()))();
|
||||
image.src = svgDataUrl;
|
||||
await image.decode();
|
||||
|
||||
const canvas = (
|
||||
dependencies.createCanvas ?? (() => document.createElement("canvas"))
|
||||
)();
|
||||
canvas.width = 512;
|
||||
canvas.height = 512;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return undefined;
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL("image/png");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
const chunkSize = 0x8000;
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
binary += String.fromCharCode(
|
||||
...bytes.subarray(offset, offset + chunkSize),
|
||||
);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
@@ -423,6 +423,7 @@ export function usePersonaActions() {
|
||||
memoryLevel,
|
||||
format,
|
||||
memorySourcePubkey: linkedAgentPubkey,
|
||||
avatarUrl: persona.avatarUrl,
|
||||
},
|
||||
{
|
||||
onSuccess: (saved) => {
|
||||
|
||||
@@ -117,6 +117,17 @@ test("formatImetaMediaLine: spoilered image mime → wrapped ![image] line", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("formatImetaMediaLine: agent snapshot PNG → filename link", () => {
|
||||
assert.equal(
|
||||
formatImetaMediaLine({
|
||||
url: "https://b/analyst.png",
|
||||
type: "image/png",
|
||||
filename: "analyst.agent.png",
|
||||
}),
|
||||
"\n[analyst.agent.png](https://b/analyst.png)",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildImetaTags keeps media filenames in imeta", () => {
|
||||
// Filenames are included for every MIME type — the video review dialog
|
||||
// and file cards use them as display titles.
|
||||
@@ -404,6 +415,20 @@ test("buildOutgoingMessage: appends media markdown line per attachment, in order
|
||||
);
|
||||
});
|
||||
|
||||
test("buildOutgoingMessage: agent snapshot PNG uses the snapshot-card link path", () => {
|
||||
const out = buildOutgoingMessage("", [
|
||||
{
|
||||
url: "https://b/analyst.png",
|
||||
type: "image/png",
|
||||
sha256: "x",
|
||||
size: 1,
|
||||
uploaded: 0,
|
||||
filename: "analyst.agent.png",
|
||||
},
|
||||
]);
|
||||
assert.equal(out.content, "\n[analyst.agent.png](https://b/analyst.png)");
|
||||
});
|
||||
|
||||
test("buildOutgoingMessage: wraps spoilered image and video attachments", () => {
|
||||
const out = buildOutgoingMessage(
|
||||
"hi",
|
||||
|
||||
@@ -235,17 +235,21 @@ export function findSpoileredImetaMediaUrls(
|
||||
* Images and video use `` so the renderer draws them inline.
|
||||
* Generic files use a plain `[filename](url)` link — the renderer recognises the
|
||||
* href as a local media blob with a non-media MIME and upgrades it to a file
|
||||
* card. Mime-driven so the form is correct regardless of URL suffix.
|
||||
* card. Agent snapshot PNGs deliberately use the file-link form despite their
|
||||
* image MIME so the renderer can upgrade them to the import/download card.
|
||||
*/
|
||||
export function formatImetaMediaLine(
|
||||
{ url, type, filename }: ImetaMedia,
|
||||
options: { spoiler?: boolean } = {},
|
||||
): string {
|
||||
// A PNG snapshot is image/png on the wire, but it is an importable file, not
|
||||
// inline media. Keep it on the anchor renderer's snapshot-card path.
|
||||
const isAgentSnapshot = filename?.toLowerCase().endsWith(".agent.png");
|
||||
if (type.startsWith("video/")) {
|
||||
const line = ``;
|
||||
return options.spoiler ? `\n||${line}||` : `\n${line}`;
|
||||
}
|
||||
if (type.startsWith("image/")) {
|
||||
if (type.startsWith("image/") && !isAgentSnapshot) {
|
||||
const line = ``;
|
||||
return options.spoiler ? `\n||${line}||` : `\n${line}`;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export function UserProfileSnapshotExportDialog({
|
||||
memoryLevel,
|
||||
format,
|
||||
memorySourcePubkey: linkedAgentPubkey,
|
||||
avatarUrl: persona.avatarUrl,
|
||||
},
|
||||
{
|
||||
onSuccess: (saved) => {
|
||||
|
||||
@@ -124,12 +124,14 @@ export async function exportAgentSnapshot(
|
||||
memoryLevel: SnapshotMemoryLevel,
|
||||
format: SnapshotFormat,
|
||||
memorySourcePubkey?: string | null,
|
||||
avatarPngDataUrl?: string,
|
||||
): Promise<boolean> {
|
||||
return invokeTauri<boolean>("export_agent_snapshot", {
|
||||
id,
|
||||
memorySourcePubkey: memorySourcePubkey ?? null,
|
||||
memoryLevel,
|
||||
format,
|
||||
avatarPngDataUrl: avatarPngDataUrl ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,12 +156,14 @@ export async function encodeAgentSnapshotForSend(
|
||||
memoryLevel: SnapshotMemoryLevel,
|
||||
format: SnapshotFormat,
|
||||
memorySourcePubkey?: string | null,
|
||||
avatarPngDataUrl?: string,
|
||||
): Promise<EncodedSnapshotPayload> {
|
||||
return invokeTauri<EncodedSnapshotPayload>("encode_agent_snapshot_for_send", {
|
||||
id,
|
||||
memorySourcePubkey: memorySourcePubkey ?? null,
|
||||
memoryLevel,
|
||||
format,
|
||||
avatarPngDataUrl: avatarPngDataUrl ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -33,10 +33,9 @@ export type ResolvedSnapshotCard = {
|
||||
/** Discriminant for the snapshot kind — currently only "agent". */
|
||||
snapshotKind: "agent";
|
||||
/**
|
||||
* Optional thumbnail URL for the card icon. For `.agent.json` attachments
|
||||
* this comes from the imeta `thumb` field set by the sender. For
|
||||
* `.agent.png` attachments it falls back to the attachment URL itself
|
||||
* (the PNG is the avatar card image).
|
||||
* Optional thumbnail URL for the card icon. PNG snapshots use the
|
||||
* attachment URL because the PNG body is the avatar card image. JSON
|
||||
* snapshots have no thumbnail and use the generic icon.
|
||||
*/
|
||||
thumb?: string;
|
||||
};
|
||||
|
||||
@@ -8902,8 +8902,8 @@ export function maybeInstallE2eTauriMocks() {
|
||||
// Specs assert invocation via __BUZZ_E2E_COMMANDS__.
|
||||
return true;
|
||||
case "encode_agent_snapshot_for_send": {
|
||||
// Return a minimal valid `.agent.json` payload so the send flow can
|
||||
// proceed through upload_media_bytes without a real Rust encode step.
|
||||
// Return a minimal PNG-shaped payload so the send flow can proceed
|
||||
// through upload_media_bytes without a real Rust encode step.
|
||||
// Optional encodeDelayMs lets specs observe the "preparing" phase before
|
||||
// the upload begins.
|
||||
const encodeDelayMs = activeConfig?.mock?.encodeDelayMs ?? 0;
|
||||
@@ -8912,20 +8912,9 @@ export function maybeInstallE2eTauriMocks() {
|
||||
window.setTimeout(resolve, encodeDelayMs),
|
||||
);
|
||||
}
|
||||
const jsonBytes = Array.from(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
format: "buzz-agent-snapshot",
|
||||
version: 1,
|
||||
definition: { system_prompt: null },
|
||||
profile: { display_name: "E2E Agent" },
|
||||
memory: { level: "none", entries: [] },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return {
|
||||
fileBytes: jsonBytes,
|
||||
fileName: "e2e-agent.agent.json",
|
||||
fileBytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
|
||||
fileName: "e2e-agent.agent.png",
|
||||
};
|
||||
}
|
||||
case "preview_agent_snapshot_import": {
|
||||
|
||||
@@ -21,6 +21,39 @@ async function readCommandLog(page: import("@playwright/test").Page) {
|
||||
);
|
||||
}
|
||||
|
||||
async function invokeMockCommand(
|
||||
page: import("@playwright/test").Page,
|
||||
command: string,
|
||||
payload: Record<string, unknown>,
|
||||
) {
|
||||
// The app installs its dynamically imported bridge during bootstrap. Wait for
|
||||
// that installation after navigation instead of racing the first invoke.
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
typeof (
|
||||
window as Window & {
|
||||
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
|
||||
}
|
||||
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
|
||||
null,
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
return page.evaluate(
|
||||
async ({ command: cmd, payload: request }) => {
|
||||
const invoke = (
|
||||
window as Window & {
|
||||
__BUZZ_E2E_INVOKE_MOCK_COMMAND__: (
|
||||
command: string,
|
||||
payload: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||||
return invoke(cmd, request);
|
||||
},
|
||||
{ command, payload },
|
||||
);
|
||||
}
|
||||
|
||||
const ANALYST_PERSONA_ID = "test-analyst";
|
||||
const ANALYST_PUBKEY =
|
||||
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";
|
||||
@@ -63,23 +96,24 @@ async function seedAndSendSnapshot(
|
||||
: {}),
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
|
||||
// Open the send dialog and send to #general.
|
||||
await page.getByLabel("Open actions for Analyst").click();
|
||||
await page.getByRole("menuitem", { name: "Export snapshot" }).click();
|
||||
const sendBtn = page.getByRole("button", { name: "Send in Buzz" });
|
||||
if (await sendBtn.isVisible()) await sendBtn.click();
|
||||
await expect(page.getByTestId("agent-snapshot-send-dialog")).toBeVisible();
|
||||
await page
|
||||
.getByTestId("agent-snapshot-send-channel-list")
|
||||
.getByText("general")
|
||||
.click();
|
||||
await page.getByTestId("agent-snapshot-send-confirm").click();
|
||||
await expect(page.getByTestId("agent-snapshot-send-done")).toBeVisible({
|
||||
timeout: 8000,
|
||||
// JSON snapshots can still arrive from saved files or older clients, even
|
||||
// though new in-app shares always encode PNG. Seed one directly so this
|
||||
// recipient suite retains JSON-card coverage independent of send behavior.
|
||||
await invokeMockCommand(page, "send_channel_message", {
|
||||
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
||||
content: `[${SNAPSHOT_UPLOAD_DESCRIPTOR.filename}](${SNAPSHOT_UPLOAD_DESCRIPTOR.url})`,
|
||||
mediaTags: [
|
||||
[
|
||||
"imeta",
|
||||
`url ${SNAPSHOT_UPLOAD_DESCRIPTOR.url}`,
|
||||
`m ${SNAPSHOT_UPLOAD_DESCRIPTOR.type}`,
|
||||
`x ${SNAPSHOT_UPLOAD_DESCRIPTOR.sha256}`,
|
||||
`size ${SNAPSHOT_UPLOAD_DESCRIPTOR.size}`,
|
||||
`filename ${SNAPSHOT_UPLOAD_DESCRIPTOR.filename}`,
|
||||
],
|
||||
],
|
||||
});
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
|
||||
// Navigate to #general.
|
||||
await page.getByTestId("channel-general").click();
|
||||
@@ -194,48 +228,9 @@ test("recipient_import_confirm_calls_confirm_once_and_shows_result", async ({
|
||||
test("recipient_fetch_error_shows_error_and_download_remains", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
personas: [
|
||||
{
|
||||
id: ANALYST_PERSONA_ID,
|
||||
displayName: "Analyst",
|
||||
systemPrompt: "You are an analyst.",
|
||||
},
|
||||
],
|
||||
managedAgents: [
|
||||
{
|
||||
pubkey: ANALYST_PUBKEY,
|
||||
name: "Analyst",
|
||||
personaId: ANALYST_PERSONA_ID,
|
||||
},
|
||||
],
|
||||
uploadDescriptors: [
|
||||
{
|
||||
...SNAPSHOT_UPLOAD_DESCRIPTOR,
|
||||
filename: "bad.agent.json",
|
||||
},
|
||||
],
|
||||
await seedAndSendSnapshot(page, {
|
||||
snapshotFetchError: "hash mismatch: fetched bytes do not match",
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
|
||||
// Send to #general.
|
||||
await page.getByLabel("Open actions for Analyst").click();
|
||||
await page.getByRole("menuitem", { name: "Export snapshot" }).click();
|
||||
const sendBtn = page.getByRole("button", { name: "Send in Buzz" });
|
||||
if (await sendBtn.isVisible()) await sendBtn.click();
|
||||
await expect(page.getByTestId("agent-snapshot-send-dialog")).toBeVisible();
|
||||
await page
|
||||
.getByTestId("agent-snapshot-send-channel-list")
|
||||
.getByText("general")
|
||||
.click();
|
||||
await page.getByTestId("agent-snapshot-send-confirm").click();
|
||||
await expect(page.getByTestId("agent-snapshot-send-done")).toBeVisible({
|
||||
timeout: 8000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await page.getByTestId("channel-general").click();
|
||||
|
||||
const card = page.getByTestId("agent-snapshot-card").last();
|
||||
await expect(card).toBeVisible({ timeout: 5000 });
|
||||
|
||||
@@ -32,12 +32,12 @@ const ANALYST_PUBKEY =
|
||||
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";
|
||||
|
||||
const MOCK_UPLOAD_DESCRIPTOR = {
|
||||
url: `https://mock.relay/media/${"a".repeat(64)}.json`,
|
||||
url: `https://mock.relay/media/${"a".repeat(64)}.png`,
|
||||
sha256: "a".repeat(64),
|
||||
size: 1234,
|
||||
type: "application/json",
|
||||
type: "image/png",
|
||||
uploaded: Math.floor(Date.now() / 1000),
|
||||
filename: "analyst.agent.json",
|
||||
filename: "analyst.agent.png",
|
||||
};
|
||||
|
||||
// ── Destination picker: channel/DM visibility ─────────────────────────────────
|
||||
@@ -210,6 +210,7 @@ test("snapshot_send_config_only_calls_encode_upload_send_in_order", async ({
|
||||
{
|
||||
id: ANALYST_PERSONA_ID,
|
||||
displayName: "Analyst",
|
||||
avatarUrl: "https://mock.relay/media/avatar.png",
|
||||
systemPrompt: "You are an analyst.",
|
||||
},
|
||||
],
|
||||
@@ -222,6 +223,13 @@ test("snapshot_send_config_only_calls_encode_upload_send_in_order", async ({
|
||||
],
|
||||
uploadDescriptors: [MOCK_UPLOAD_DESCRIPTOR],
|
||||
});
|
||||
await page.route("https://mock.relay/media/avatar.png", (route) =>
|
||||
route.fulfill({
|
||||
body: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
|
||||
contentType: "image/png",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
}),
|
||||
);
|
||||
await gotoAgentsPage(page);
|
||||
|
||||
await page.getByLabel("Open actions for Analyst").click();
|
||||
@@ -279,22 +287,30 @@ test("snapshot_send_config_only_calls_encode_upload_send_in_order", async ({
|
||||
const imeta = sendPayload?.mediaTags?.[0];
|
||||
expect(imeta).toBeDefined();
|
||||
const sha = "a".repeat(64);
|
||||
const expectedUrl = `https://mock.relay/media/${sha}.json`;
|
||||
const expectedUrl = `https://mock.relay/media/${sha}.png`;
|
||||
expect(imeta).toContain(`url ${expectedUrl}`);
|
||||
expect(imeta).toContain("m application/json");
|
||||
expect(imeta).toContain("m image/png");
|
||||
expect(imeta).toContain(`x ${sha}`);
|
||||
expect(imeta).toContain("size 1234");
|
||||
// The filename in the imeta comes from the encode payload's fileName — the
|
||||
// controller sets descriptorWithFilename.filename = fileName (the file produced
|
||||
// by encode_agent_snapshot_for_send), which the bridge hardcodes as
|
||||
// "e2e-agent.agent.json".
|
||||
expect(imeta).toContain("filename e2e-agent.agent.json");
|
||||
// "e2e-agent.agent.png".
|
||||
expect(imeta).toContain("filename e2e-agent.agent.png");
|
||||
|
||||
// The encode command itself produces "e2e-agent.agent.json" (bridge fixture).
|
||||
// Send-in-Buzz always encodes PNG so the attachment itself provides the
|
||||
// avatar thumbnail.
|
||||
const encodeEntry = log.find(
|
||||
(e) => e.command === "encode_agent_snapshot_for_send",
|
||||
);
|
||||
expect(encodeEntry).toBeTruthy();
|
||||
const encodePayload = encodeEntry?.payload as
|
||||
| { format?: string; avatarPngDataUrl?: string }
|
||||
| undefined;
|
||||
expect(encodePayload?.format).toBe("png");
|
||||
expect(encodePayload?.avatarPngDataUrl).toEqual(
|
||||
expect.stringMatching(/^data:image\/png;base64,/),
|
||||
);
|
||||
|
||||
// Close the dialog and navigate to #general to verify the AgentSnapshotCard renders.
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
@@ -304,7 +320,7 @@ test("snapshot_send_config_only_calls_encode_upload_send_in_order", async ({
|
||||
// FileCard) with the exact filename that the encode step produced.
|
||||
const snapshotCard = page.getByTestId("agent-snapshot-card").last();
|
||||
await expect(snapshotCard).toBeVisible({ timeout: 5000 });
|
||||
await expect(snapshotCard).toContainText("e2e-agent.agent.json");
|
||||
await expect(snapshotCard).toContainText("e2e-agent.agent.png");
|
||||
});
|
||||
|
||||
// ── Memory-bearing flow: gate stops before encode/upload/send ─────────────────
|
||||
|
||||
Reference in New Issue
Block a user