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