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}