diff --git a/desktop/src-tauri/src/persona_catalog_tests.rs b/desktop/src-tauri/src/persona_catalog_tests.rs index 0532660b4..d3175ef98 100644 --- a/desktop/src-tauri/src/persona_catalog_tests.rs +++ b/desktop/src-tauri/src/persona_catalog_tests.rs @@ -190,3 +190,46 @@ fn exact_tags_reject_duplicates_and_extra_fields() { assert_eq!(coordinate_tag(&extended, "d").as_deref(), Some("reviewer")); assert_eq!(exact_tag(&extended, "shared"), None); } + +/// Pins the serialized DTO output against the renderer's catalog contract. +/// The Tauri generic is only a TypeScript assertion; serde's bytes are the +/// actual boundary, so populate every optional field and compare the value. +#[test] +fn serialized_catalog_matches_the_typescript_contract() { + let publication = PersonaCatalogPublication { + event_id: "ev1".into(), + owner_pubkey: "owner".into(), + source_persona_id: "persona-1".into(), + created_at: 42, + agent: CatalogAgentProjection { + display_name: "Ada".into(), + avatar_url: Some("https://example.com/a.png".into()), + system_prompt: "be kind".into(), + runtime: Some("acp".into()), + model: Some("m1".into()), + provider: Some("p1".into()), + name_pool: vec!["Ada".into(), "Lin".into()], + respond_to: Some("mentions".into()), + parallelism: Some(2), + }, + }; + let actual = serde_json::to_value(vec![publication]).unwrap(); + let expected = serde_json::json!([{ + "eventId": "ev1", + "ownerPubkey": "owner", + "sourcePersonaId": "persona-1", + "createdAt": 42, + "agent": { + "displayName": "Ada", + "avatarUrl": "https://example.com/a.png", + "systemPrompt": "be kind", + "runtime": "acp", + "model": "m1", + "provider": "p1", + "namePool": ["Ada", "Lin"], + "respondTo": "mentions", + "parallelism": 2, + }, + }]); + assert_eq!(actual, expected); +} diff --git a/desktop/src-tauri/src/unread_catch_up.rs b/desktop/src-tauri/src/unread_catch_up.rs index e08342c06..c7de5dae8 100644 --- a/desktop/src-tauri/src/unread_catch_up.rs +++ b/desktop/src-tauri/src/unread_catch_up.rs @@ -53,7 +53,11 @@ pub(crate) struct UnreadCatchUpResponse { } #[derive(Serialize)] -#[serde(tag = "status", rename_all = "camelCase")] +#[serde( + tag = "status", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] enum ChannelResult { Success { channel_id: String, @@ -271,14 +275,7 @@ fn classify_batch( .channel .read_at .is_some_and(|read_at| event.created_at <= read_at) - || !should_notify( - &event, - &item.channel.id, - &self_pubkey, - request, - &participated, - &authored, - ) + || !should_notify(&event, &self_pubkey, request, &participated, &authored) { continue; } @@ -384,7 +381,6 @@ fn thread_reference(tags: &[Vec]) -> ThreadReference { fn should_notify( event: &EventView, - _channel_id: &str, self_pubkey: &str, request: &UnreadCatchUpRequest, participated: &HashSet, @@ -556,4 +552,96 @@ mod tests { ); assert_eq!(*max_trigger, 12); } + + /// Pins the SERIALIZED wire contract against `tauriUnreadCatchUp.ts`. + /// + /// Asserts on serde's OUTPUT, not on `ChannelResult`: the renderer never + /// sees the Rust type, it sees bytes, through an `invokeTauri` cast + /// that validates nothing. Every other test here inspects the enum before + /// serialization and the e2e bridge hand-writes the intended shape, so + /// without this nothing compares what Rust emits to what TypeScript + /// declares. + /// + /// Whole-value rather than a key list, deliberately: a key-set assertion + /// passes a mutant that drops the variant rename and emits `"Success"`, + /// which the renderer's `status === "error"` branch silently misreads. + /// Failure here means the merge loop throws on the first success row and + /// catch-up yields nothing, silently. + #[test] + fn serialized_response_matches_the_typescript_contract() { + let channels = vec![ + ChannelResult::Success { + channel_id: "ch".into(), + observed_events: vec![ObservedUnreadEvent { + id: "evt".into(), + created_at: 11, + root_id: Some("root".into()), + high_priority: true, + counts_toward_badge: true, + counts_toward_app_badge: false, + }], + max_trigger: 11, + activity_rows: vec![ActivityRow { + id: "evt".into(), + kind: 9, + pubkey: "other".into(), + content: "hi".into(), + created_at: 11, + channel_id: "ch".into(), + channel_name: "Ch".into(), + tags: vec![vec!["h".into(), "ch".into()]], + }], + discovered: DiscoveredRoots { + participated: vec!["root".into()], + authored: Vec::new(), + mentioned: Vec::new(), + }, + }, + ChannelResult::Error { + channel_id: "ch-2".into(), + error: "relay request timed out".into(), + }, + ]; + + let actual = serde_json::to_value(UnreadCatchUpResponse { channels }).unwrap(); + let expected = serde_json::json!({ + "channels": [ + { + "status": "success", + "channelId": "ch", + "observedEvents": [{ + "id": "evt", + "createdAt": 11, + "rootId": "root", + "highPriority": true, + "countsTowardBadge": true, + "countsTowardAppBadge": false, + }], + "maxTrigger": 11, + "activityRows": [{ + "id": "evt", + "kind": 9, + "pubkey": "other", + "content": "hi", + "createdAt": 11, + "channelId": "ch", + "channelName": "Ch", + "tags": [["h", "ch"]], + }], + "discovered": { + "participated": ["root"], + "authored": [], + "mentioned": [], + }, + }, + { + "status": "error", + "channelId": "ch-2", + "error": "relay request timed out", + }, + ] + }); + + assert_eq!(actual, expected); + } } diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index 888ad7051..34b2d9450 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -590,6 +590,10 @@ export function useUnreadChannels( const authoredSizeBefore = authoredRootIdsRef.current.size; const mentionedSizeBefore = mentionedRootIdsRef.current.size; + // Membership remains renderer-owned until E's native observed-unread store, + // so unchanged sets cross IPC on every catch-up. The five 1,000-entry + // stores bound that interim cost at roughly 332 KiB per request. Command + // arguments use a fetch body, so this cost is linear with no size cliff. void unreadCatchUp({ channels: toFetch.map((channelId) => { const channel = channels.find( @@ -621,6 +625,8 @@ export function useUnreadChannels( const allThreadReplies: ThreadActivityItem[] = []; for (const result of results) { if (result.status === "error") { + // The error arm carries only this identity; releasing its claim is + // what lets a failed channel retry on the next effect run. caughtUpChannelsRef.current.delete(result.channelId); continue; } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index ae3ecbd04..8a31d2ad2 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -10,6 +10,7 @@ import { handleDeleteCustomHarness, } from "./e2eBridgeCustomHarnesses.ts"; +import type { UnreadCatchUpChannelResult } from "@/shared/api/tauriUnreadCatchUp"; import { relayClient } from "@/shared/api/relayClient"; import { activateRateLimit } from "@/shared/api/relayRateLimitGate"; import { resolveAgentParallelism } from "@/features/agents/lib/agentParallelism"; @@ -13380,16 +13381,18 @@ export function maybeInstallE2eTauriMocks() { const request = payload as { request: { channels: Array<{ id: string }> }; }; - return { - channels: request.request.channels.map((channel) => ({ + const results: UnreadCatchUpChannelResult[] = + request.request.channels.map((channel) => ({ status: "success", channelId: channel.id, observedEvents: [], maxTrigger: 0, activityRows: [], discovered: { participated: [], authored: [], mentioned: [] }, - })), - }; + })); + // Keep this mock aligned with the complete Rust serde shape pinned by + // `serialized_response_matches_the_typescript_contract`. + return { channels: results }; } case "agent_metric_archive_default_enabled": return activeConfig?.mock?.agentMetricArchiveDefaultEnabled ?? true;