Skip to main content

agent_context_contract/
lib.rs

1//! HTTP API contract for the Context service.
2//!
3//! # Why this service exists
4//!
5//! Conversation history is durable, cross-turn state. A Runtime must be able to
6//! read and append it without owning a database and without holding a platform
7//! service secret. Giving history its own service does for history what
8//! `agent-workspace-infra` does for sandboxes: one owner, one HTTP contract, one
9//! deployable.
10//!
11//! # Layering
12//!
13//! ```text
14//! agent-context-contract   paths + DTOs        (this crate)
15//! agent-context-runtime    store implementation
16//! agent-context-server     router + auth + bin
17//! ```
18//!
19//! See [`api.md`](../api.md) for the full HTTP reference.
20
21use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24
25mod records;
26pub use records::*;
27
28pub const SERVICE_NAME: &str = "agent-context-infra";
29pub const DEFAULT_PORT: u16 = 5120;
30pub const AUDIENCE: &str = "agent-context-infra";
31pub const MAX_RESOURCE_ID_CHARS: usize = 191;
32
33/// Optional static service token. When unset, the server runs in open mode.
34pub const SERVICE_TOKEN_ENV: &str = "AGENT_CONTEXT_SERVICE_TOKEN";
35
36pub const HEALTH_PATH: &str = "/health";
37pub const MESSAGES_APPEND_PATH: &str = "/internal/v1/context/messages/batch";
38pub const MESSAGES_RECENT_PATH: &str = "/internal/v1/context/messages/recent";
39pub const MESSAGES_SEARCH_PATH: &str = "/internal/v1/context/messages/search";
40pub const MESSAGES_PAGE_PATH: &str = "/internal/v1/context/messages";
41pub const CONVERSATION_DELETE_PATH: &str = "/internal/v1/context/conversations/delete";
42pub const RETENTION_PATH: &str = "/internal/v1/context/conversations/retention";
43pub const CONTEXT_OPERATIONS_PATH: &str = "/internal/v1/context/operations";
44pub const MAINTENANCE_STATUS_PATH: &str = "/internal/v1/context/maintenance/status";
45pub const CONTEXT_INSIGHTS_PATH: &str = "/internal/v1/context/entries";
46pub const CONTEXT_INSIGHT_GET_PATH: &str = "/internal/v1/context/entries/get";
47pub const CONTEXT_RECALL_PATH: &str = "/internal/v1/context/recall";
48pub const CONTEXT_MERGE_PATH: &str = "/internal/v1/context/entries/merge";
49pub const CONTEXT_CORRECT_PATH: &str = "/internal/v1/context/entries/correct";
50pub const CONTEXT_EXPIRE_PATH: &str = "/internal/v1/context/entries/expire";
51pub const CONTEXT_EXPORT_PATH: &str = "/internal/v1/context/exports";
52pub const CONTEXT_IMPORT_PATH: &str = "/internal/v1/context/imports";
53pub const VECTOR_REBUILD_PATH: &str = "/internal/v1/context/projections/vector/rebuild";
54pub const CONTEXT_ADVANCED_OPERATIONS_PATH: &str = "/internal/v1/context/advanced-operations";
55pub const CONTEXT_EXPORT_ARTIFACT_PATH: &str = "/internal/v1/context/export-artifacts/get";
56pub const CONTEXT_EXPORT_ARTIFACT_CHUNK_PATH: &str = "/internal/v1/context/export-artifacts/chunk";
57
58// Trace events are NOT served here. They belong to `agent-trace` (Trace Infra service)
59// (`/internal/v1/trace-infra/events/batch`).
60//
61// Two services accepting the same records would mean two write paths for one
62// dataset, and a run's trace would split depending on which endpoint a client
63// happened to call. Trace also needs different guarantees: bounded retention,
64// server-side credential redaction, and a hard rule that it is never replayed
65// into a model prompt.
66
67/// Identifies the conversation a record belongs to.
68///
69/// `conversation_id` is the only required field: it is the partition key. The
70/// rest are optional provenance used for filtering and auditing.
71#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct ConversationScope {
74    pub conversation_id: String,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub user_id: Option<String>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub agent_id: Option<String>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub agent_space_id: Option<String>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub runtime_type: Option<String>,
83    /// Many-to-many domain associations. Legacy singular provenance fields
84    /// above remain readable but are not the canonical relation model.
85    #[serde(default, skip_serializing_if = "relations_are_empty")]
86    pub relations: agent_registry_contract::ResourceRelations,
87}
88
89/// Scope derived from a verified gateway-issued internal credential.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase", deny_unknown_fields)]
92pub struct AuthorizedScope {
93    pub tenant_id: String,
94    pub project_id: String,
95    pub subject: String,
96}
97
98impl ConversationScope {
99    pub fn conversation(conversation_id: impl Into<String>) -> Self {
100        Self {
101            conversation_id: conversation_id.into(),
102            relations: Default::default(),
103            ..Default::default()
104        }
105    }
106}
107
108fn relations_are_empty(relations: &agent_registry_contract::ResourceRelations) -> bool {
109    relations.agent_ids.is_empty()
110        && relations.runtime_ids.is_empty()
111        && relations.user_ids.is_empty()
112}
113
114/// One durable conversation message.
115///
116/// `content` is a `Value` rather than a `String` because assistant turns carry
117/// structured tool calls. Storing the real shape avoids a lossy round-trip
118/// through a stringified envelope.
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120#[serde(rename_all = "camelCase")]
121pub struct ConversationRecord {
122    /// Caller supplied id. Used for idempotent append: re-sending the same id is
123    /// a no-op, so a retry after a timeout cannot duplicate a turn.
124    pub id: String,
125    pub scope: ConversationScope,
126    /// `user`, `assistant`, `tool`, `system`, or `event`.
127    pub role: String,
128    pub content: Value,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub name: Option<String>,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub tool_call_id: Option<String>,
133    /// Monotonic order within a conversation. This value is assigned by the
134    /// service; caller-provided values are ignored on the normal append path.
135    #[serde(default)]
136    pub sequence: i64,
137    #[serde(default)]
138    pub created_at_ms: i64,
139    #[serde(default, skip_serializing_if = "Value::is_null")]
140    pub metadata: Value,
141}
142
143#[derive(Debug, Clone, Default, Serialize, Deserialize)]
144#[serde(rename_all = "camelCase")]
145pub struct AppendMessagesRequest {
146    pub messages: Vec<ConversationRecord>,
147}
148
149/// Result of an append. `appended` excludes ids that already existed, so a
150/// caller can tell a fresh write from a deduplicated retry.
151#[derive(Debug, Clone, Default, Serialize, Deserialize)]
152#[serde(rename_all = "camelCase")]
153pub struct AppendResponse {
154    pub appended: usize,
155    pub skipped: usize,
156}
157
158#[derive(Debug, Clone, Default, Serialize, Deserialize)]
159#[serde(rename_all = "camelCase")]
160pub struct RecentMessagesRequest {
161    pub scope: ConversationScope,
162    /// Maximum records to return. The server clamps this to [`MAX_PAGE_SIZE`].
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub limit: Option<usize>,
165    /// When set, only these roles are returned. Empty means all roles.
166    #[serde(default, skip_serializing_if = "Vec::is_empty")]
167    pub roles: Vec<String>,
168}
169
170/// Records in chronological order, oldest first.
171///
172/// The order is part of the contract. A client that has to re-sort is a client
173/// that will eventually get it wrong.
174#[derive(Debug, Clone, Default, Serialize, Deserialize)]
175#[serde(rename_all = "camelCase")]
176pub struct MessagesResponse {
177    pub items: Vec<ConversationRecord>,
178}
179
180/// Stable opaque cursor position for `(sequence ASC, id ASC)` ordering.
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase", deny_unknown_fields)]
183pub struct MessageCursor {
184    pub sequence: i64,
185    pub id: String,
186}
187
188impl MessageCursor {
189    pub fn encode(&self) -> Result<String, serde_json::Error> {
190        serde_json::to_vec(self).map(|bytes| URL_SAFE_NO_PAD.encode(bytes))
191    }
192
193    pub fn decode(value: &str) -> Result<Self, String> {
194        let bytes = URL_SAFE_NO_PAD
195            .decode(value)
196            .map_err(|_| "invalid message cursor encoding".to_string())?;
197        serde_json::from_slice(&bytes).map_err(|_| "invalid message cursor payload".to_string())
198    }
199}
200
201#[derive(Debug, Clone, Default, Serialize, Deserialize)]
202#[serde(rename_all = "camelCase")]
203pub struct PageMessagesRequest {
204    pub scope: ConversationScope,
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub cursor: Option<String>,
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub limit: Option<usize>,
209    #[serde(default, skip_serializing_if = "Vec::is_empty")]
210    pub roles: Vec<String>,
211}
212
213#[derive(Debug, Clone, Default, Serialize, Deserialize)]
214#[serde(rename_all = "camelCase")]
215pub struct MessagePage {
216    pub items: Vec<ConversationRecord>,
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub next_cursor: Option<String>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
222#[serde(rename_all = "camelCase", deny_unknown_fields)]
223pub struct RetentionPolicy {
224    pub max_age_days: Option<u32>,
225    pub max_messages: Option<u32>,
226}
227
228impl Default for RetentionPolicy {
229    fn default() -> Self {
230        Self {
231            max_age_days: Some(90),
232            max_messages: Some(100_000),
233        }
234    }
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238#[serde(rename_all = "camelCase", deny_unknown_fields)]
239pub struct RetentionRequest {
240    pub scope: ConversationScope,
241    #[serde(default)]
242    pub policy: RetentionPolicy,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
246#[serde(rename_all = "camelCase", deny_unknown_fields)]
247pub struct DeleteConversationRequest {
248    pub scope: ConversationScope,
249    #[serde(default)]
250    pub reason: String,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(rename_all = "camelCase")]
255pub struct DeletionOperation {
256    pub id: String,
257    pub state: String,
258    pub deleted_messages: u64,
259    pub created_at_ms: i64,
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub next_poll_after_ms: Option<u64>,
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub error_code: Option<String>,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase", deny_unknown_fields)]
268pub struct GetDeletionOperationRequest {
269    pub operation_id: String,
270}
271
272#[derive(Debug, Clone, Default, Serialize, Deserialize)]
273#[serde(rename_all = "camelCase")]
274pub struct ContextMaintenanceStatus {
275    pub projector_last_run_ms: Option<i64>,
276    pub projector_processed: u64,
277    pub retention_last_run_ms: Option<i64>,
278    pub retention_processed: u64,
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub index_watermark: Option<IndexWatermark>,
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284#[serde(rename_all = "snake_case")]
285pub enum ContextInsightStatus {
286    Active,
287    Corrected,
288    Merged,
289    Expired,
290}
291
292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "snake_case")]
294pub enum ContextInsightKind {
295    Fact,
296    Preference,
297    Decision,
298    Task,
299    Summary,
300    Custom,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
304#[serde(rename_all = "camelCase", deny_unknown_fields)]
305pub struct ContextProvenance {
306    pub conversation_id: String,
307    #[serde(default)]
308    pub message_ids: Vec<String>,
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub extractor_ref: Option<String>,
311    #[serde(default)]
312    pub observed_at_ms: i64,
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize)]
316#[serde(rename_all = "camelCase")]
317pub struct ContextInsight {
318    pub id: String,
319    pub kind: ContextInsightKind,
320    pub content: Value,
321    #[serde(default)]
322    pub attributes: Value,
323    pub version: u64,
324    pub status: ContextInsightStatus,
325    pub provenance: ContextProvenance,
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub superseded_by: Option<String>,
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub expires_at_ms: Option<i64>,
330    pub created_at_ms: i64,
331    pub updated_at_ms: i64,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
335#[serde(rename_all = "camelCase", deny_unknown_fields)]
336pub struct PutContextInsightRequest {
337    pub entry: ContextInsight,
338    #[serde(default)]
339    pub expected_version: u64,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
343#[serde(rename_all = "camelCase", deny_unknown_fields)]
344pub struct GetContextInsightRequest {
345    pub entry_id: String,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
349#[serde(rename_all = "camelCase", deny_unknown_fields)]
350pub struct CorrectContextInsightRequest {
351    pub entry_id: String,
352    pub expected_version: u64,
353    pub content: Value,
354    #[serde(default)]
355    pub reason: String,
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize)]
359#[serde(rename_all = "camelCase", deny_unknown_fields)]
360pub struct ExpireContextInsightRequest {
361    pub entry_id: String,
362    pub expected_version: u64,
363    #[serde(default)]
364    pub reason: String,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
368#[serde(rename_all = "camelCase", deny_unknown_fields)]
369pub struct MergeContextInsightsRequest {
370    pub source_entry_ids: Vec<String>,
371    pub merged_entry: ContextInsight,
372    #[serde(default)]
373    pub expected_versions: std::collections::BTreeMap<String, u64>,
374}
375
376#[derive(Debug, Clone, Serialize, Deserialize)]
377#[serde(rename_all = "camelCase", deny_unknown_fields)]
378pub struct RecallContextRequest {
379    pub query: String,
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub conversation_id: Option<String>,
382    #[serde(default = "default_recall_limit")]
383    pub limit: usize,
384    #[serde(default = "default_lexical_weight")]
385    pub lexical_weight: f64,
386    #[serde(default = "default_vector_weight")]
387    pub vector_weight: f64,
388}
389
390fn default_recall_limit() -> usize {
391    20
392}
393fn default_lexical_weight() -> f64 {
394    0.35
395}
396fn default_vector_weight() -> f64 {
397    0.65
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
401#[serde(rename_all = "camelCase")]
402pub struct RecallScoreBreakdown {
403    pub lexical: f64,
404    pub vector: f64,
405    pub recency: f64,
406    pub final_score: f64,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize)]
410#[serde(rename_all = "camelCase")]
411pub struct RecalledContext {
412    pub entry: ContextInsight,
413    pub score: RecallScoreBreakdown,
414}
415
416#[derive(Debug, Clone, Default, Serialize, Deserialize)]
417#[serde(rename_all = "camelCase")]
418pub struct IndexWatermark {
419    pub projected_version: u64,
420    pub source_version: u64,
421    pub lag: u64,
422    pub updated_at_ms: i64,
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize)]
426#[serde(rename_all = "camelCase")]
427pub struct RecallContextResponse {
428    pub items: Vec<RecalledContext>,
429    pub index_watermark: IndexWatermark,
430}
431
432#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
433#[serde(rename_all = "snake_case")]
434pub enum ContextOperationKind {
435    Delete,
436    Export,
437    Import,
438    RebuildVector,
439}
440
441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
442#[serde(rename_all = "snake_case")]
443pub enum ContextOperationState {
444    Queued,
445    Running,
446    Succeeded,
447    Failed,
448}
449
450#[derive(Debug, Clone, Serialize, Deserialize)]
451#[serde(rename_all = "camelCase")]
452pub struct ContextOperation {
453    pub id: String,
454    pub kind: ContextOperationKind,
455    pub state: ContextOperationState,
456    pub phase: String,
457    pub processed: u64,
458    pub total: u64,
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub artifact_ref: Option<String>,
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub result: Option<Value>,
463    #[serde(default, skip_serializing_if = "Option::is_none")]
464    pub error: Option<String>,
465    /// Stable machine-readable terminal/retry classification. Provider and DB
466    /// messages remain in `error` and are never reused as protocol codes.
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    pub error_code: Option<String>,
469    pub created_at_ms: i64,
470    pub updated_at_ms: i64,
471    pub version: u64,
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub next_poll_after_ms: Option<u64>,
474}
475
476#[derive(Debug, Clone, Serialize, Deserialize)]
477#[serde(rename_all = "camelCase", deny_unknown_fields)]
478pub struct GetContextOperationRequest {
479    pub operation_id: String,
480}
481
482#[derive(Debug, Clone, Default, Serialize, Deserialize)]
483#[serde(rename_all = "camelCase", deny_unknown_fields)]
484pub struct ExportContextRequest {
485    #[serde(default)]
486    pub include_expired: bool,
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize)]
490#[serde(rename_all = "camelCase", deny_unknown_fields)]
491pub struct ImportContextRequest {
492    /// Preferred durable import source. When set, inline vectors must be
493    /// empty and the worker parses the tenant-scoped artifact incrementally.
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub artifact_ref: Option<String>,
496    #[serde(default)]
497    pub messages: Vec<ConversationRecord>,
498    #[serde(default)]
499    pub entries: Vec<ContextInsight>,
500    pub idempotency_key: String,
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
504#[serde(rename_all = "camelCase")]
505pub struct ContextExportArtifact {
506    pub id: String,
507    pub schema_version: u32,
508    pub messages: Vec<ConversationRecord>,
509    pub entries: Vec<ContextInsight>,
510    pub created_at_ms: i64,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize)]
514#[serde(rename_all = "camelCase", deny_unknown_fields)]
515pub struct GetContextExportArtifactRequest {
516    pub artifact_id: String,
517}
518
519#[derive(Debug, Clone, Serialize, Deserialize)]
520#[serde(rename_all = "camelCase", deny_unknown_fields)]
521pub struct GetContextExportArtifactChunkRequest {
522    pub artifact_id: String,
523    #[serde(default)]
524    pub offset: u64,
525    #[serde(default = "default_artifact_chunk_bytes")]
526    pub limit: usize,
527}
528
529fn default_artifact_chunk_bytes() -> usize {
530    1024 * 1024
531}
532
533#[derive(Debug, Clone, Serialize, Deserialize)]
534#[serde(rename_all = "camelCase")]
535pub struct ContextExportArtifactChunk {
536    pub artifact_id: String,
537    pub offset: u64,
538    pub data_base64: String,
539    pub next_offset: u64,
540    pub eof: bool,
541    pub digest: String,
542}
543
544#[derive(Debug, Clone, Default, Serialize, Deserialize)]
545#[serde(rename_all = "camelCase", deny_unknown_fields)]
546pub struct RebuildVectorProjectionRequest {
547    #[serde(default)]
548    pub force: bool,
549}
550
551#[derive(Debug, Clone, Default, Serialize, Deserialize)]
552#[serde(rename_all = "camelCase")]
553pub struct SearchMessagesRequest {
554    pub scope: ConversationScope,
555    /// Case-insensitive substring match against textual content.
556    #[serde(default, skip_serializing_if = "Option::is_none")]
557    pub query: Option<String>,
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    pub role: Option<String>,
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    pub limit: Option<usize>,
562}
563
564/// Upper bound the server enforces on any page size.
565///
566/// An unbounded read is a denial-of-service waiting to happen once a
567/// conversation grows, so the limit lives in the contract where both sides see
568/// it.
569pub const MAX_PAGE_SIZE: usize = 500;
570
571/// Default page size when a request omits `limit`.
572pub const DEFAULT_PAGE_SIZE: usize = 50;
573
574/// Maximum number of records accepted by one atomic append.
575pub const MAX_APPEND_MESSAGES: usize = 500;
576
577/// Maximum serialized payload bytes accepted by one atomic append.
578pub const MAX_APPEND_BYTES: usize = 8 * 1024 * 1024;
579pub const MAX_SEARCH_QUERY_BYTES: usize = 4 * 1024;
580
581/// Error body shared by every non-2xx response.
582#[derive(Debug, Clone, Serialize, Deserialize)]
583pub struct ApiError {
584    pub status: u16,
585    pub message: String,
586}
587
588/// Extract plain text from a message `content` value.
589///
590/// Content is either a string or an OpenAI-style array of parts. Callers that
591/// need text (prompt assembly, substring search) must agree on one reading, so
592/// it lives in the contract instead of being reimplemented per crate.
593pub fn content_text(content: &Value) -> String {
594    match content {
595        Value::String(value) => value.clone(),
596        Value::Array(parts) => parts
597            .iter()
598            .filter_map(|part| {
599                part.get("text")
600                    .and_then(Value::as_str)
601                    .or_else(|| part.as_str())
602            })
603            .collect::<Vec<_>>()
604            .join("\n"),
605        Value::Null => String::new(),
606        other => other.to_string(),
607    }
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use serde_json::json;
614
615    #[test]
616    fn scope_serialises_camel_case_and_omits_absent_provenance() {
617        let value =
618            serde_json::to_value(ConversationScope::conversation("conv-1")).expect("serialize");
619        assert_eq!(value, json!({"conversationId": "conv-1"}));
620    }
621
622    #[test]
623    fn content_text_reads_strings_and_part_arrays() {
624        assert_eq!(content_text(&json!("hello")), "hello");
625        assert_eq!(
626            content_text(&json!([{"type": "text", "text": "a"}, {"type": "text", "text": "b"}])),
627            "a\nb"
628        );
629        assert_eq!(content_text(&Value::Null), "");
630    }
631
632    #[test]
633    fn a_message_round_trips_through_json() {
634        let record = ConversationRecord {
635            id: "m-1".into(),
636            scope: ConversationScope::conversation("conv-1"),
637            role: "assistant".into(),
638            content: json!("done"),
639            name: None,
640            tool_call_id: None,
641            sequence: 7,
642            created_at_ms: 1_700_000_000_000,
643            metadata: Value::Null,
644        };
645        let encoded = serde_json::to_value(&record).expect("serialize");
646        assert_eq!(encoded["createdAtMs"], 1_700_000_000_000_i64);
647        let decoded: ConversationRecord = serde_json::from_value(encoded).expect("deserialize");
648        assert_eq!(decoded, record);
649    }
650}