1use std::sync::Arc;
10
11use chrono::SecondsFormat;
12use serde::{Deserialize, Serialize};
13
14use super::{Capability, CapabilityLocalization, ModelViewContext, ModelViewProvider};
15use crate::message::{ContentPart, Message, MessageRole};
16
17pub const MESSAGE_METADATA_CAPABILITY_ID: &str = "message_metadata";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum MessageMetadataField {
27 Timestamp,
31}
32
33impl MessageMetadataField {
34 fn render(&self, msg: &Message) -> Option<String> {
35 match self {
36 Self::Timestamp => Some(format!(
37 "[time {}]",
38 msg.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)
39 )),
40 }
41 }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct MessageMetadataConfig {
51 #[serde(default = "default_fields")]
53 pub fields: Vec<MessageMetadataField>,
54}
55
56impl Default for MessageMetadataConfig {
57 fn default() -> Self {
58 Self {
59 fields: default_fields(),
60 }
61 }
62}
63
64fn default_fields() -> Vec<MessageMetadataField> {
65 vec![MessageMetadataField::Timestamp]
66}
67
68impl MessageMetadataConfig {
69 pub fn from_json(value: &serde_json::Value) -> Self {
71 serde_json::from_value(value.clone()).unwrap_or_default()
72 }
73}
74
75pub struct MessageMetadataCapability;
78
79impl Capability for MessageMetadataCapability {
80 fn id(&self) -> &str {
81 MESSAGE_METADATA_CAPABILITY_ID
82 }
83
84 fn name(&self) -> &str {
85 "Message Metadata"
86 }
87
88 fn description(&self) -> &str {
89 "Annotates user and agent messages with metadata (message timestamp, UTC) when building the LLM request, so the model can reason about timing and gaps between messages. Stored messages are unchanged."
90 }
91
92 fn filter_response_text(&self, text: String, _config: &serde_json::Value) -> String {
93 strip_leading_timestamp_annotations(&text)
94 }
95
96 fn icon(&self) -> Option<&str> {
97 Some("clock")
98 }
99
100 fn category(&self) -> Option<&str> {
101 Some("Core")
102 }
103
104 fn system_prompt_addition(&self) -> Option<&str> {
105 Some(
106 "Conversation messages carry a bracketed annotation added by the system, e.g. `[time 2026-06-11T09:15:42Z]` — the message's timestamp (UTC). Use it to reason about timing and gaps between messages. It is not part of what the author wrote; never emit such annotations in your replies.",
107 )
108 }
109
110 fn config_schema(&self) -> Option<serde_json::Value> {
111 Some(serde_json::json!({
112 "type": "object",
113 "properties": {
114 "fields": {
115 "type": "array",
116 "items": {
117 "type": "string",
118 "title": "Metadata field",
119 "description": "Metadata field rendered as a bracketed prefix on each message.",
120 "oneOf": [
121 { "const": "timestamp", "title": "Timestamp" }
122 ]
123 },
124 "default": ["timestamp"],
125 "title": "Metadata fields to annotate",
126 "description": "Which metadata fields are annotated onto user and agent messages, in render order. An empty list disables annotations."
127 }
128 },
129 "additionalProperties": false
130 }))
131 }
132
133 fn validate_config(&self, config: &serde_json::Value) -> Result<(), String> {
134 if config.is_null() {
135 return Ok(());
136 }
137 serde_json::from_value::<MessageMetadataConfig>(config.clone())
138 .map(|_| ())
139 .map_err(|e| format!("invalid message_metadata config: {e}"))
140 }
141
142 fn model_view_provider(&self) -> Option<Arc<dyn ModelViewProvider>> {
143 Some(Arc::new(MessageMetadataModelViewProvider))
144 }
145
146 fn localizations(&self) -> Vec<CapabilityLocalization> {
147 vec![
148 CapabilityLocalization {
149 locale: "en",
150 name: None,
151 description: None,
152 config_description: Some(
153 "Choose which metadata fields are annotated onto messages sent to the LLM.",
154 ),
155 config_overlay: None,
156 },
157 CapabilityLocalization {
158 locale: "uk",
159 name: Some("Метадані повідомлень"),
160 description: Some(
161 "Додає до повідомлень користувача й агента метадані (часову позначку, UTC) \
162 під час формування запиту до LLM, щоб модель могла враховувати час і паузи \
163 між повідомленнями. Збережені повідомлення не змінюються.",
164 ),
165 config_description: Some(
166 "Визначає, які поля метаданих додаються до повідомлень, що надсилаються LLM.",
167 ),
168 config_overlay: Some(serde_json::json!({
169 "properties": {
170 "fields": {
171 "title": "Поля метаданих",
172 "description": "Які поля метаданих додаються до повідомлень користувача й агента, у порядку відображення. Порожній список вимикає анотації.",
173 "items": {
174 "title": "Поле метаданих",
175 "description": "Поле метаданих, що відображається як префікс у дужках для кожного повідомлення.",
176 "enum_labels": {
177 "timestamp": "Часова позначка"
178 }
179 }
180 }
181 }
182 })),
183 },
184 ]
185 }
186}
187
188struct MessageMetadataModelViewProvider;
189
190impl ModelViewProvider for MessageMetadataModelViewProvider {
191 fn apply_model_view(
192 &self,
193 mut messages: Vec<Message>,
194 config: &serde_json::Value,
195 _context: &ModelViewContext<'_>,
196 ) -> Vec<Message> {
197 let config = MessageMetadataConfig::from_json(config);
198 for msg in &mut messages {
199 if matches!(msg.role, MessageRole::User | MessageRole::Agent) {
200 annotate_message(msg, &config.fields);
201 }
202 }
203 messages
204 }
205
206 fn priority(&self) -> i32 {
208 100
209 }
210}
211
212pub fn strip_leading_timestamp_annotations(text: &str) -> String {
226 let mut rest = text;
227 while let Some(after) = strip_one_timestamp_annotation(rest) {
228 rest = after;
229 }
230 rest.to_string()
231}
232
233fn strip_one_timestamp_annotation(text: &str) -> Option<&str> {
236 const PREFIX: &str = "[time ";
237 let rest = text.strip_prefix(PREFIX)?;
238 let close = rest.find(']')?;
239 chrono::DateTime::parse_from_rfc3339(&rest[..close]).ok()?;
242 let after = &rest[close + 1..];
243 Some(after.strip_prefix(' ').unwrap_or(after))
244}
245
246pub fn render_annotation(msg: &Message, fields: &[MessageMetadataField]) -> Option<String> {
249 let segments: Vec<String> = fields.iter().filter_map(|f| f.render(msg)).collect();
250 if segments.is_empty() {
251 None
252 } else {
253 Some(segments.join(" "))
254 }
255}
256
257fn annotate_message(msg: &mut Message, fields: &[MessageMetadataField]) {
258 let Some(annotation) = render_annotation(msg, fields) else {
259 return;
260 };
261 if let Some(ContentPart::Text(t)) = msg
262 .content
263 .iter_mut()
264 .find(|p| matches!(p, ContentPart::Text(_)))
265 {
266 t.text = if t.text.is_empty() {
267 annotation
268 } else {
269 format!("{annotation} {}", t.text)
270 };
271 } else {
272 msg.content.insert(0, ContentPart::text(annotation));
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use crate::message::ToolCallContentPart;
282 use crate::typed_id::SessionId;
283
284 fn ctx() -> ModelViewContext<'static> {
285 ModelViewContext {
286 session_id: SessionId::new(),
287 prior_usage: None,
288 }
289 }
290
291 fn apply(messages: Vec<Message>, config: serde_json::Value) -> Vec<Message> {
292 MessageMetadataModelViewProvider.apply_model_view(messages, &config, &ctx())
293 }
294
295 fn time_annotation(msg: &Message) -> String {
296 render_annotation(msg, &[MessageMetadataField::Timestamp]).unwrap()
297 }
298
299 #[test]
302 fn test_capability_in_registry() {
303 let registry = crate::portable_capability_registry().unwrap();
304 let cap = registry.get(MESSAGE_METADATA_CAPABILITY_ID).unwrap();
305 assert!(cap.model_view_provider().is_some());
306 }
307
308 #[test]
309 fn test_annotates_user_and_agent_messages() {
310 let user = Message::user("hello");
311 let agent = Message::assistant("hi there");
312 let expected_user = time_annotation(&user);
313 let expected_agent = time_annotation(&agent);
314
315 let out = apply(vec![user, agent], serde_json::json!({}));
316
317 assert_eq!(
318 out[0].text().unwrap(),
319 format!("{expected_user} hello"),
320 "user message gets timestamp prefix"
321 );
322 assert_eq!(out[1].text().unwrap(), format!("{expected_agent} hi there"));
323 }
324
325 #[test]
326 fn test_skips_system_and_tool_result_messages() {
327 let system = Message::system("you are a bot");
328 let tool = Message::tool_result("call_1", Some(serde_json::json!({"ok": true})), None);
329
330 let out = apply(vec![system, tool], serde_json::json!({}));
331
332 assert_eq!(out[0].text().unwrap(), "you are a bot");
333 assert!(out[1].text().is_none());
334 }
335
336 #[test]
337 fn test_explicit_fields_config() {
338 let user = Message::user("hello");
339 let expected = time_annotation(&user);
340 let out = apply(vec![user], serde_json::json!({"fields": ["timestamp"]}));
341 assert_eq!(out[0].text().unwrap(), format!("{expected} hello"));
342 }
343
344 #[test]
345 fn test_empty_fields_disable_annotations() {
346 let user = Message::user("hello");
347 let out = apply(vec![user], serde_json::json!({"fields": []}));
348 assert_eq!(out[0].text().unwrap(), "hello");
349 assert_eq!(out[0].content.len(), 1);
350 }
351
352 #[test]
353 fn test_tool_call_only_agent_message_gets_text_part() {
354 let mut agent = Message::assistant("");
355 agent.content = vec![ContentPart::ToolCall(ToolCallContentPart::new(
356 "call_1",
357 "get_weather",
358 serde_json::json!({}),
359 ))];
360 let expected = time_annotation(&agent);
361
362 let out = apply(vec![agent], serde_json::json!({}));
363
364 assert_eq!(out[0].content.len(), 2);
365 assert_eq!(out[0].text().unwrap(), expected);
366 assert!(matches!(out[0].content[1], ContentPart::ToolCall(_)));
367 }
368
369 #[test]
370 fn test_empty_text_part_gets_annotation_without_trailing_space() {
371 let agent = Message::assistant("");
372 let expected = time_annotation(&agent);
373
374 let out = apply(vec![agent], serde_json::json!({}));
375
376 assert_eq!(out[0].text().unwrap(), expected);
377 }
378
379 #[test]
380 fn test_annotation_format_is_rfc3339_utc() {
381 let user = Message::user("hello");
382 let out = apply(vec![user], serde_json::json!({}));
383 let text = out[0].text().unwrap();
384 assert!(text.starts_with("[time 2"), "got: {text}");
385 assert!(text.contains("Z] hello"), "got: {text}");
386 }
387
388 #[test]
391 fn strip_removes_single_leading_annotation() {
392 assert_eq!(
393 strip_leading_timestamp_annotations("[time 2026-07-10T05:38:28Z] cobalt"),
394 "cobalt"
395 );
396 }
397
398 #[test]
400 fn strip_removes_repeated_leading_annotations() {
401 assert_eq!(
402 strip_leading_timestamp_annotations(
403 "[time 2026-07-10T05:38:21Z] [time 2026-07-10T05:38:21Z] Understood"
404 ),
405 "Understood"
406 );
407 }
408
409 #[test]
411 fn strip_handles_annotation_without_trailing_space() {
412 assert_eq!(
413 strip_leading_timestamp_annotations("[time 2026-07-10T05:38:28Z]hi"),
414 "hi"
415 );
416 }
417
418 #[test]
421 fn strip_preserves_non_timestamp_bracket_text() {
422 assert_eq!(
423 strip_leading_timestamp_annotations("[time to go] home"),
424 "[time to go] home"
425 );
426 assert_eq!(
427 strip_leading_timestamp_annotations("hello world"),
428 "hello world"
429 );
430 }
431
432 #[test]
435 fn strip_only_touches_leading_annotation() {
436 assert_eq!(
437 strip_leading_timestamp_annotations("see [time 2026-07-10T05:38:28Z]"),
438 "see [time 2026-07-10T05:38:28Z]"
439 );
440 }
441
442 #[test]
446 fn strip_inverts_render_annotation() {
447 let agent = Message::assistant("the answer");
448 let annotated = format!("{} the answer", time_annotation(&agent));
449 assert_eq!(
450 strip_leading_timestamp_annotations(&annotated),
451 "the answer"
452 );
453 }
454
455 #[test]
456 fn test_validate_config() {
457 let cap = MessageMetadataCapability;
458 assert!(cap.validate_config(&serde_json::Value::Null).is_ok());
459 assert!(cap.validate_config(&serde_json::json!({})).is_ok());
460 assert!(
461 cap.validate_config(&serde_json::json!({"fields": ["timestamp"]}))
462 .is_ok()
463 );
464 assert!(
465 cap.validate_config(&serde_json::json!({"fields": []}))
466 .is_ok()
467 );
468 assert!(
469 cap.validate_config(&serde_json::json!({"fields": ["model"]}))
470 .is_err(),
471 "unknown metadata fields are rejected until implemented"
472 );
473 assert!(
474 cap.validate_config(&serde_json::json!({"fields": "timestamp"}))
475 .is_err(),
476 "fields must be an array"
477 );
478 assert!(
479 cap.validate_config(&serde_json::json!({"user_messages": false}))
480 .is_err(),
481 "role toggles were removed; user/agent messages are always annotated"
482 );
483 assert!(
484 cap.validate_config(&serde_json::json!({"unknown": true}))
485 .is_err()
486 );
487 }
488
489 #[test]
492 fn test_config_schema_matches_config_shape() {
493 let cap = MessageMetadataCapability;
494 let schema = cap.config_schema().expect("capability exposes a schema");
495
496 assert_eq!(schema["type"], "object");
497 assert_eq!(
498 schema["additionalProperties"], false,
499 "schema must reject unknown keys like validate_config does"
500 );
501
502 let schema_keys: std::collections::BTreeSet<&str> = schema["properties"]
504 .as_object()
505 .expect("properties object")
506 .keys()
507 .map(String::as_str)
508 .collect();
509 let config_value = serde_json::to_value(MessageMetadataConfig::default()).unwrap();
510 let config_keys: std::collections::BTreeSet<&str> = config_value
511 .as_object()
512 .expect("config serializes to object")
513 .keys()
514 .map(String::as_str)
515 .collect();
516 assert_eq!(schema_keys, config_keys);
517
518 let enum_values: Vec<serde_json::Value> = schema["properties"]["fields"]["items"]["oneOf"]
522 .as_array()
523 .expect("fields oneOf")
524 .iter()
525 .map(|option| option["const"].clone())
526 .collect();
527 for value in &enum_values {
528 assert!(
529 serde_json::from_value::<MessageMetadataField>(value.clone()).is_ok(),
530 "schema oneOf const {value} is not a known MessageMetadataField"
531 );
532 }
533 assert_eq!(
534 enum_values.len(),
535 1,
536 "add new MessageMetadataField variants to the schema oneOf"
537 );
538 let schema_default = serde_json::json!({
539 "fields": schema["properties"]["fields"]["default"]
540 });
541 assert!(cap.validate_config(&schema_default).is_ok());
542 }
543}