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
41 changes: 40 additions & 1 deletion desktop/src-tauri/src/managed_agents/agent_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ pub const PNG_CHUNK_KEYWORD: &str = "buzz_agent_snapshot";
/// this are stored as a URL reference instead.
const MAX_AVATAR_INLINE_BYTES: usize = 2 * 1024 * 1024; // 2 MB

/// Maximum edge (px) for the PNG image body. The body is only a card
/// thumbnail — the manifest keeps the full-resolution source reference — so a
/// large avatar is downscaled here to keep the encoded snapshot well under
/// `MAX_SNAPSHOT_PNG_BYTES`. Mirrors the frontend SVG rasterizer's 512×512 cap
/// in `snapshotAvatarPng.ts`.
const MAX_PNG_BODY_EDGE: u32 = 512;

/// Format discriminator — used for sniffing and validation.
pub const FORMAT_DISCRIMINATOR: &str = "buzz-agent-snapshot";

Expand Down Expand Up @@ -328,7 +335,7 @@ pub(crate) fn encode_chunk_payload_png(
// 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") {
let encoded_avatar = if bytes.starts_with(b"\x89PNG") && png_within_body_cap(bytes) {
inject_text_chunk(bytes, PNG_CHUNK_KEYWORD, &chunk_text).or_else(|_| {
transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text)
})
Expand Down Expand Up @@ -440,20 +447,52 @@ pub(crate) fn make_png_with_text(keyword: &str, text: &str) -> Result<Vec<u8>, S
}

/// Transcode a decodable avatar to PNG and add the snapshot manifest chunk.
///
/// The decoded image is downscaled so its longest edge is at most
/// `MAX_PNG_BODY_EDGE` before PNG re-encoding. The body is only a card
/// thumbnail — this keeps a large source avatar (e.g. a 4K webp) from
/// producing a PNG that blows `MAX_SNAPSHOT_PNG_BYTES`.
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 image = downscale_to_body_cap(image);
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)
}

/// Downscale so the longest edge is at most `MAX_PNG_BODY_EDGE`, preserving
/// aspect ratio. Images already within the cap are returned untouched.
fn downscale_to_body_cap(image: image::DynamicImage) -> image::DynamicImage {
if image.width() <= MAX_PNG_BODY_EDGE && image.height() <= MAX_PNG_BODY_EDGE {
return image;
}
image.resize(
MAX_PNG_BODY_EDGE,
MAX_PNG_BODY_EDGE,
image::imageops::FilterType::Lanczos3,
)
}

/// Whether an already-PNG avatar is within the body dimension cap and can be
/// carried as-is (via a cheap tEXt-chunk injection) instead of being decoded
/// and downscaled. Undecodable headers fall through to the transcode path.
fn png_within_body_cap(png_bytes: &[u8]) -> bool {
Decoder::new(Cursor::new(png_bytes))
.read_info()
.map(|reader| {
let info = reader.info();
info.width <= MAX_PNG_BODY_EDGE && info.height <= MAX_PNG_BODY_EDGE
})
.unwrap_or(false)
}

/// 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
Expand Down
55 changes: 54 additions & 1 deletion desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,60 @@ fn png_snapshot_transcodes_jpeg_avatar_into_image_body() {
assert_eq!((reader.info().width, reader.info().height), (3, 2));
}

// ── PNG memory parity ─────────────────────────────────────────────────────
#[test]
fn png_snapshot_downscales_oversize_avatar_under_cap() {
// A large avatar (mirrors Gurney's 2764×4096 image that encoded to ~26 MB)
// must be downscaled for the PNG body so the snapshot stays under the
// 10 MiB cap — while the manifest keeps the untouched source reference.
// An already-PNG oversize avatar exercises the `png_within_body_cap` guard
// that routes it through the downscaling transcode path.
let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(2764, 4096, |x, y| {
image::Rgb([(x % 256) as u8, (y % 256) as u8, ((x + y) % 256) as u8])
}));
let mut source_bytes = Vec::new();
avatar
.write_to(&mut Cursor::new(&mut source_bytes), image::ImageFormat::Png)
.unwrap();

let snapshot = build_snapshot(
&minimal_record(),
MemoryLevel::None,
vec![],
Some(&source_bytes),
);
let png_bytes = encode_snapshot_png(&snapshot, Some(&source_bytes)).unwrap();

assert!(
png_bytes.len()
<= super::MAX_PNG_BODY_EDGE as usize * super::MAX_PNG_BODY_EDGE as usize * 4,
"downscaled snapshot ({} bytes) must be far under the 10 MiB cap",
png_bytes.len()
);

let reader = Decoder::new(Cursor::new(png_bytes)).read_info().unwrap();
let (width, height) = (reader.info().width, reader.info().height);
assert!(
width <= 512 && height <= 512,
"body dimensions {width}×{height} must fit the 512px cap"
);
// Aspect ratio preserved: the longest edge (height) is clamped to the cap.
assert_eq!(height, 512, "longest edge should hit the 512px cap");

// The manifest keeps the untouched full-resolution source reference — only
// the PNG body is downscaled. The oversize source bytes exceed the inline
// cap, so the manifest falls back to the record's `avatar_url`.
let manifest =
decode_snapshot_png(&encode_snapshot_png(&snapshot, Some(&source_bytes)).unwrap()).unwrap();
assert_eq!(
manifest.profile.avatar_url.as_deref(),
Some("https://example.com/avatar.png"),
"manifest must preserve the untouched source avatar reference"
);
assert!(
manifest.profile.avatar_data_url.is_none(),
"oversize source bytes must not be inlined into the manifest"
);
}

#[test]
fn png_round_trip_with_core_memory() {
Expand Down
117 changes: 116 additions & 1 deletion desktop/src-tauri/src/managed_agents/config_bridge/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,21 @@ pub enum ConfigOrigin {
}

/// How a config field can be written back to the runtime.
///
/// `rename_all_fields` is load-bearing, not decoration: on an internally
/// tagged enum `rename_all` renames the *variants*, never the variants'
/// fields, so without it `RespawnWithEnvVar` serializes as
/// `{"type":"respawnWithEnvVar","env_key":"…"}` while
/// `desktop/src/shared/api/types.ts` declares `envKey`. `invokeTauri<T>` is an
/// unchecked cast, so `tsc` cannot see the mismatch — the reader just gets
/// `undefined`. `wire_format_matches_typescript_contract` below pins the exact
/// bytes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum ConfigWriteMechanism {
/// Update record env vars, save, stop + restart agent.
RespawnWithEnvVar { env_key: String },
Expand Down Expand Up @@ -244,3 +257,105 @@ pub struct AcpModelEntry {
pub name: Option<String>,
pub description: Option<String>,
}

#[cfg(test)]
mod wire_format_tests {
use super::*;
use serde_json::json;

/// Every `ConfigWriteMechanism` variant, as `desktop/src/shared/api/types.ts`
/// declares it. Whole-value comparison, not a key-set check: a key-set
/// assertion still passes if the variant *name* regresses, and the `type`
/// discriminant is what every `switch (writeVia.type)` reads. Compared as
/// `serde_json::Value` rather than as text, because JSON object order is
/// not semantic and the contract is the keys and values, not the encoder's
/// field order.
#[test]
fn wire_format_matches_typescript_contract() {
let cases = [
(
ConfigWriteMechanism::RespawnWithEnvVar {
env_key: "GOOSE_MODE".into(),
},
json!({"type": "respawnWithEnvVar", "envKey": "GOOSE_MODE"}),
),
(
ConfigWriteMechanism::AcpSetConfigOption {
config_id: "model".into(),
},
json!({"type": "acpSetConfigOption", "configId": "model"}),
),
(
ConfigWriteMechanism::AcpSetSessionModel,
json!({"type": "acpSetSessionModel"}),
),
(
ConfigWriteMechanism::GooseNativeConfigWrite {
config_key: "goose.model".into(),
},
json!({"type": "gooseNativeConfigWrite", "configKey": "goose.model"}),
),
(ConfigWriteMechanism::ReadOnly, json!({"type": "readOnly"})),
];
for (mechanism, expected) in cases {
assert_eq!(
serde_json::to_value(&mechanism).expect("serialize"),
expected
);
}
}

/// The renderer never sees a bare mechanism — it arrives nested inside
/// `NormalizedField`, which is where the mismatch used to hide: the
/// enclosing struct's `writeVia` / `overriddenValue` / `isRequired` all
/// renamed correctly, so only the variant's own field was snake_case.
#[test]
fn nested_field_is_camel_case_all_the_way_down() {
let field = NormalizedField {
value: Some("v".into()),
origin: ConfigOrigin::EnvVar,
write_via: ConfigWriteMechanism::RespawnWithEnvVar {
env_key: "GOOSE_MODE".into(),
},
overridden_value: Some("o".into()),
overridden_origin: Some(ConfigOrigin::ConfigFile),
is_required: true,
};
assert_eq!(
serde_json::to_value(&field).expect("serialize"),
json!({
"value": "v",
"origin": "envVar",
"writeVia": {"type": "respawnWithEnvVar", "envKey": "GOOSE_MODE"},
"overriddenValue": "o",
"overriddenOrigin": "configFile",
"isRequired": true,
})
);
}

/// The contract is singular: the shape the renderer sends back round-trips,
/// and the old snake_case spelling is no longer accepted. Without the
/// second half, a future revert would still deserialize and the read path
/// would look healthy.
#[test]
fn camel_case_round_trips_and_snake_case_is_rejected() {
let parsed: ConfigWriteMechanism =
serde_json::from_str(r#"{"type":"respawnWithEnvVar","envKey":"GOOSE_MODE"}"#)
.expect("the TypeScript shape must deserialize");
assert_eq!(
parsed,
ConfigWriteMechanism::RespawnWithEnvVar {
env_key: "GOOSE_MODE".into(),
}
);

assert!(
serde_json::from_str::<ConfigWriteMechanism>(
r#"{"type":"respawnWithEnvVar","env_key":"GOOSE_MODE"}"#
)
.is_err(),
"the pre-fix snake_case spelling must not be accepted"
);
}
}
1 change: 1 addition & 0 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,7 @@ export function AppShell() {
/>
) : null}
<AppShellChannelSurface
hasCommunityRail={hasCommunityRail}
isHuddleRoom={isHuddleRoom}
isHuddleRoomStarting={isHuddleRoomStarting}
mainInsetRef={mainInsetRef}
Expand Down
17 changes: 16 additions & 1 deletion desktop/src/app/AppShellChannelSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { HuddleRoomHeader, HuddleStartingView } from "@/features/huddle";
import { MainInsetProvider } from "@/shared/layout/MainInsetContext";
import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
import { SidebarInset } from "@/shared/ui/sidebar";
import { SidebarInset, useSidebar } from "@/shared/ui/sidebar";

type AppShellChannelSurfaceProps = {
children: React.ReactNode;
hasCommunityRail: boolean;
isHuddleRoom: boolean;
isHuddleRoomStarting: boolean;
mainInsetRef: React.RefObject<HTMLElement | null>;
Expand All @@ -16,25 +17,39 @@ type AppShellChannelSurfaceProps = {

export function AppShellChannelSurface({
children,
hasCommunityRail,
isHuddleRoom,
isHuddleRoomStarting,
mainInsetRef,
terminal,
}: AppShellChannelSurfaceProps) {
const { isMobile, openMobile, state: sidebarState } = useSidebar();
const hasCollapsedSidebarGutter =
!isHuddleRoom &&
!hasCommunityRail &&
(isMobile ? !openMobile : sidebarState === "collapsed");

return (
<MainInsetProvider mainInsetRef={mainInsetRef}>
<SidebarInset
ref={mainInsetRef}
className={cn(
"isolate z-0 min-h-0 min-w-0 overflow-hidden",
isHuddleRoom ? "bg-background" : "bg-sidebar",
hasCollapsedSidebarGutter && "pl-2",
)}
data-buzz-content-surface={isHuddleRoom ? true : undefined}
data-buzz-content-unframed={isHuddleRoom ? true : undefined}
data-buzz-glass-inset
data-buzz-shadow-viewport
style={chromeCssVarDefaults as React.CSSProperties}
>
{hasCollapsedSidebarGutter ? (
<div
className="absolute inset-y-0 left-0 w-2 bg-sidebar"
data-collapsed-content-gutter
/>
) : null}
{isHuddleRoom && !isHuddleRoomStarting ? <HuddleRoomHeader /> : null}
<BuzzTheme.ContentSurface terminal={terminal} unframed={isHuddleRoom}>
{isHuddleRoomStarting ? <HuddleStartingView /> : children}
Expand Down
5 changes: 4 additions & 1 deletion desktop/src/features/agents/ui/AgentCardViewerDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,10 @@ function AgentCardViewerContent({
toast.success(`Sent ${agentName}'s card.`);
closeCardViewer();
} else if (sent === false) {
toast.error("Couldn’t send the card. Try again.");
toast.error(
sendController.getCurrentError() ??
"Couldn’t send the card. Try again.",
);
}
}

Expand Down
5 changes: 4 additions & 1 deletion desktop/src/features/agents/ui/PersonaShareDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,10 @@ export function SnapshotShareDialog({
toast.success(`Sent a copy of ${displayName}`);
onOpenChange(false);
} else if (sent === false) {
toast.error(`Couldn’t send ${itemLabel}. Try again.`);
toast.error(
snapshotSendController.getCurrentError() ??
`Couldn’t send ${itemLabel}. Try again.`,
);
}
}

Expand Down
Loading
Loading