Skip to main content

rightkit_agent_engine_protocol/
references.rs

1use serde::{Deserialize, Serialize};
2
3/// Persisted (metadata-only) reference attached to a user turn. Mirrors the
4/// TypeScript `ReferenceMeta` shape used by the desktop composer. **No image
5/// bytes live here** — the desktop keeps the bytes in-memory only; this
6/// record is the transcript-visible card (filename, size, kind, trust) that
7/// the UI persists under `cr.references.v1` and re-attaches on restart.
8///
9/// Wire compatibility: all fields use `#[serde(default)]` and
10/// `skip_serializing_if` so legacy rows (pre-references) deserialize cleanly.
11/// ReferenceMeta is exposed as a standalone type — the desktop owns the
12/// transcript mutation surface in the user-message variant, and Agent 4's
13/// integration pass binds this type into the `PostMessage` body when the
14/// composer forwards references to the engine.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "camelCase")]
17pub struct ReferenceMeta {
18    /// Stable client-generated id (uuid v4 on the desktop). Reused by the
19    /// transcript's chip strip and the inline resolver; never recast on
20    /// restart so the chip identity survives a page reload.
21    pub id: String,
22    /// `"external_link" | "workspace_file" | "generated_document" | "image"
23    ///  | "artifact" | "change_set"`. Free-form on the wire so a future
24    /// kind can be introduced without breaking older clients; the desktop
25    /// renders unknown kinds as a generic chip.
26    pub kind: String,
27    /// Display target: URL, workspace-relative path, doc id, artifact id, or
28    /// change-set id. Used by `openExternal` / `revealInFolder` / file
29    /// resolver.
30    pub destination: String,
31    /// Optional MIME hint — `"image/png"`, `"text/markdown"`,
32    /// `"application/octet-stream"`, etc.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub mime: Option<String>,
35    /// Display filename shown on the chip when `kind` is `image`,
36    /// `workspace_file`, or `generated_document`. Optional for
37    /// `external_link` and `change_set` where the destination is the label.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub filename: Option<String>,
40    /// Best-effort byte count for the original artifact. `None` for
41    /// `external_link` and `change_set` where the size is not knowable up
42    /// front.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub size_bytes: Option<i64>,
45    /// Provenance + trust. `source` is the producer string
46    /// (`"composer.paste"`, `"workspace_resolver"`, etc.), `createdAt` is the
47    /// ISO timestamp the user added the reference, `trust` is the
48    /// `ContentTrust` label, `contentHash` (when known) is the SHA-256 of the
49    /// original bytes — present for `image` / `workspace_file` /
50    /// `generated_document` once the desktop has read the file.
51    pub provenance: ReferenceProvenance,
52    /// Action affordances the chip exposes. Free-form on the wire; the
53    /// desktop renders the intersection with its known action set so a
54    /// future `download` action sent from a newer sender doesn't break an
55    /// older renderer.
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub actions: Vec<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61#[serde(rename_all = "camelCase")]
62pub struct ReferenceProvenance {
63    pub source: String,
64    pub created_at: String,
65    pub trust: String,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub content_hash: Option<String>,
68}
69
70impl ReferenceMeta {
71    /// Wire-friendly default: an empty record with no actions / no MIME / no
72    /// size. Useful for tests that need a placeholder.
73    pub fn empty(id: &str, kind: &str, destination: &str) -> Self {
74        Self {
75            id: id.to_string(),
76            kind: kind.to_string(),
77            destination: destination.to_string(),
78            mime: None,
79            filename: None,
80            size_bytes: None,
81            provenance: ReferenceProvenance {
82                source: "composer".to_string(),
83                created_at: "1970-01-01T00:00:00Z".to_string(),
84                trust: "user".to_string(),
85                content_hash: None,
86            },
87            actions: Vec::new(),
88        }
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn round_trip_minimal_record() {
98        let meta = ReferenceMeta::empty("r1", "external_link", "https://example.com");
99        let json = serde_json::to_string(&meta).unwrap();
100        assert!(json.contains(r#""id":"r1""#), "got: {json}");
101        assert!(json.contains(r#""kind":"external_link""#), "got: {json}");
102        assert!(
103            json.contains(r#""destination":"https://example.com""#),
104            "got: {json}"
105        );
106        assert!(
107            !json.contains("\"mime\""),
108            "absent mime must skip: got: {json}"
109        );
110        assert!(
111            !json.contains("\"actions\""),
112            "empty actions must skip: got: {json}"
113        );
114        let rt: ReferenceMeta = serde_json::from_str(&json).unwrap();
115        assert_eq!(rt, meta);
116    }
117
118    #[test]
119    fn legacy_attachment_record_without_provenance_is_rejected() {
120        // The wire contract requires `provenance`; legacy rows never had a
121        // reference system, so there is no legacy shape to migrate — the only
122        // back-compat surface is "no `attachments` field at all", which is
123        // owned by the *enclosing* TranscriptMessage / ProtocolEvent tests.
124        let legacy = r#"{"id":"r1","kind":"image","destination":"x.png"}"#;
125        let result: Result<ReferenceMeta, _> = serde_json::from_str(legacy);
126        assert!(
127            result.is_err(),
128            "ReferenceMeta without provenance must fail to parse"
129        );
130    }
131
132    #[test]
133    fn camel_case_field_names_match_typescript() {
134        let meta = ReferenceMeta {
135            id: "r1".into(),
136            kind: "image".into(),
137            destination: "x.png".into(),
138            mime: Some("image/png".into()),
139            filename: Some("x.png".into()),
140            size_bytes: Some(2048),
141            provenance: ReferenceProvenance {
142                source: "composer.paste".into(),
143                created_at: "2026-07-20T12:00:00Z".into(),
144                trust: "user".into(),
145                content_hash: Some("sha256:abc".into()),
146            },
147            actions: vec!["open".into(), "remove".into()],
148        };
149        let json = serde_json::to_string(&meta).unwrap();
150        assert!(json.contains(r#""sizeBytes":2048"#), "got: {json}");
151        assert!(
152            json.contains(r#""contentHash":"sha256:abc""#),
153            "got: {json}"
154        );
155        assert!(
156            json.contains(r#""createdAt":"2026-07-20T12:00:00Z""#),
157            "got: {json}"
158        );
159        let rt: ReferenceMeta = serde_json::from_str(&json).unwrap();
160        assert_eq!(rt, meta);
161    }
162}