Skip to main content

gate4agent_types/
history.rs

1use crate::{OperationId, WORKING_DIRECTORY_MAX_BYTES};
2use serde::{Deserialize, Serialize};
3use thiserror::Error;
4
5pub const HISTORY_DISCOVERY_LIMIT_MAX: u16 = 256;
6pub const HISTORY_CANDIDATE_ID_MAX_BYTES: usize = 1_024;
7pub const HISTORY_SESSION_ID_MAX_BYTES: usize = 512;
8pub const HISTORY_TITLE_MAX_BYTES: usize = 512;
9pub const HISTORY_MODEL_MAX_BYTES: usize = 512;
10pub const HISTORY_MESSAGES_MAX: usize = 256;
11pub const HISTORY_MESSAGE_MAX_BYTES: usize = 16_384;
12pub const HISTORY_ERROR_MAX_BYTES: usize = 4_096;
13pub const NATIVE_SESSION_CATALOG_LIMIT_MAX: u16 = 64;
14pub const NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX: u16 = 24;
15pub const NATIVE_SESSION_PREVIEW_TEXT_MAX_BYTES: usize = 4_096;
16pub const NATIVE_SESSION_EXTERNAL_GROUP_ID_MAX_BYTES: usize = 128;
17pub const NATIVE_SESSION_EXTERNAL_GROUP_LABEL_MAX_BYTES: usize = 256;
18
19#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
20#[serde(rename_all = "kebab-case")]
21pub enum NativeSessionCatalogScope {
22    Workspace,
23    Unregistered,
24}
25
26#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
27pub struct NativeSessionExternalGroup {
28    pub group_id: String,
29    pub kind: NativeSessionExternalGroupKind,
30    pub display_name: String,
31}
32
33#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
34#[serde(rename_all = "kebab-case")]
35pub enum NativeSessionExternalGroupKind {
36    Project,
37    Global,
38}
39
40impl NativeSessionExternalGroup {
41    pub fn validate(&self) -> Result<(), HistoryValidationError> {
42        if self.group_id.trim().is_empty()
43            || self.group_id.len() > NATIVE_SESSION_EXTERNAL_GROUP_ID_MAX_BYTES
44            || !self
45                .group_id
46                .bytes()
47                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
48        {
49            return Err(HistoryValidationError::InvalidField {
50                field: "native session external group id",
51            });
52        }
53        validate_required_identifier(
54            &self.display_name,
55            NATIVE_SESSION_EXTERNAL_GROUP_LABEL_MAX_BYTES,
56            "native session external group display name",
57        )?;
58        if self.display_name == "."
59            || self.display_name == ".."
60            || self.display_name.contains('/')
61            || self.display_name.contains('\\')
62            || self.display_name.contains(':')
63        {
64            return Err(HistoryValidationError::InvalidField {
65                field: "native session external group display name",
66            });
67        }
68        Ok(())
69    }
70}
71
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct NativeSessionCatalogEntry {
74    pub selection_id: String,
75    pub session_id: String,
76    pub title: Option<String>,
77    pub modified_at_unix_ms: Option<u64>,
78    pub model: Option<String>,
79    pub message_count: u64,
80    pub completed_turn_count: Option<u64>,
81}
82
83#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
84#[serde(rename_all = "kebab-case")]
85pub enum NativeSessionCatalogWindow {
86    Recent,
87    Older,
88}
89
90#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
91pub struct NativeSessionCatalogSummary {
92    pub catalog_revision: u64,
93    pub recent_cutoff_unix_ms: u64,
94    pub recent_total_count: u32,
95    pub older_total_count: u32,
96    pub recent_next_after_selection_id: Option<String>,
97    pub recent_has_more: bool,
98}
99
100impl NativeSessionCatalogSummary {
101    pub fn validate(&self) -> Result<(), HistoryValidationError> {
102        validate_catalog_cursor_presence(
103            &self.recent_next_after_selection_id,
104            self.recent_has_more,
105        )
106    }
107
108    pub fn validate_initial_entries(
109        &self,
110        entry_count: usize,
111    ) -> Result<(), HistoryValidationError> {
112        self.validate()?;
113        let entry_count = u32::try_from(entry_count).map_err(|_| {
114            HistoryValidationError::InvalidField {
115                field: "catalog summary",
116            }
117        })?;
118        if self.recent_total_count < entry_count
119            || self.recent_has_more != (self.recent_total_count > entry_count)
120        {
121            return Err(HistoryValidationError::InvalidField {
122                field: "catalog summary",
123            });
124        }
125        Ok(())
126    }
127}
128
129#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct NativeSessionCatalogPage {
131    pub window: NativeSessionCatalogWindow,
132    pub revision: u64,
133    pub entries: Vec<NativeSessionCatalogEntry>,
134    pub next_after_selection_id: Option<String>,
135    pub remaining_count: u32,
136    pub has_more: bool,
137}
138
139impl NativeSessionCatalogPage {
140    pub fn validate(&self) -> Result<(), HistoryValidationError> {
141        if self.entries.len() > usize::from(NATIVE_SESSION_CATALOG_LIMIT_MAX) {
142            return Err(HistoryValidationError::TooManyMessages);
143        }
144        for (index, entry) in self.entries.iter().enumerate() {
145            entry.validate()?;
146            if self.entries[..index]
147                .iter()
148                .any(|existing| existing.session_id == entry.session_id)
149            {
150                return Err(HistoryValidationError::InvalidField {
151                    field: "catalog entries",
152                });
153            }
154        }
155        validate_catalog_cursor(
156            &self.next_after_selection_id,
157            self.has_more,
158            self.remaining_count,
159        )
160    }
161}
162
163fn validate_catalog_cursor(
164    cursor: &Option<String>,
165    has_more: bool,
166    remaining_count: u32,
167) -> Result<(), HistoryValidationError> {
168    validate_catalog_cursor_presence(cursor, has_more)?;
169    if has_more != (remaining_count > 0) {
170        return Err(HistoryValidationError::InvalidField {
171            field: "catalog cursor",
172        });
173    }
174    Ok(())
175}
176
177fn validate_catalog_cursor_presence(
178    cursor: &Option<String>,
179    has_more: bool,
180) -> Result<(), HistoryValidationError> {
181    if let Some(cursor) = cursor {
182        validate_candidate_id(cursor)?;
183    }
184    if has_more != cursor.is_some() {
185        return Err(HistoryValidationError::InvalidField {
186            field: "catalog cursor",
187        });
188    }
189    Ok(())
190}
191
192impl NativeSessionCatalogEntry {
193    pub fn validate(&self) -> Result<(), HistoryValidationError> {
194        validate_candidate_id(&self.selection_id)?;
195        validate_required_identifier(&self.session_id, HISTORY_SESSION_ID_MAX_BYTES, "session id")?;
196        validate_optional_single_line_text(&self.title, HISTORY_TITLE_MAX_BYTES, "title")?;
197        validate_optional_identifier(&self.model, HISTORY_MODEL_MAX_BYTES, "model")
198    }
199}
200
201#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
202pub struct NativeSessionPreviewMessage {
203    pub role: HistoryMessageRole,
204    pub text: String,
205}
206
207impl NativeSessionPreviewMessage {
208    pub fn validate(&self) -> Result<(), HistoryValidationError> {
209        validate_text(&self.text, NATIVE_SESSION_PREVIEW_TEXT_MAX_BYTES, "preview message")
210    }
211}
212
213#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
214pub struct NativeSessionPreview {
215    pub session_id: String,
216    pub title: Option<String>,
217    pub modified_at_unix_ms: Option<u64>,
218    pub model: Option<String>,
219    pub message_count: u64,
220    #[serde(default)]
221    pub message_count_exact: bool,
222    pub completed_turn_count: Option<u64>,
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub total_tokens: Option<u64>,
225    pub truncated: bool,
226    pub messages: Vec<NativeSessionPreviewMessage>,
227}
228
229#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
230pub struct SessionRecordPreview {
231    pub title: Option<String>,
232    pub modified_at_unix_ms: Option<u64>,
233    pub model: Option<String>,
234    pub message_count: u64,
235    #[serde(default)]
236    pub message_count_exact: bool,
237    pub completed_turn_count: Option<u64>,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub total_tokens: Option<u64>,
240    pub truncated: bool,
241    pub messages: Vec<NativeSessionPreviewMessage>,
242}
243
244impl SessionRecordPreview {
245    pub fn validate(&self) -> Result<(), HistoryValidationError> {
246        validate_optional_single_line_text(&self.title, HISTORY_TITLE_MAX_BYTES, "title")?;
247        validate_optional_identifier(&self.model, HISTORY_MODEL_MAX_BYTES, "model")?;
248        if self.messages.len() > usize::from(NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX) {
249            return Err(HistoryValidationError::TooManyMessages);
250        }
251        for message in &self.messages {
252            message.validate()?;
253        }
254        Ok(())
255    }
256}
257
258impl From<NativeSessionPreview> for SessionRecordPreview {
259    fn from(preview: NativeSessionPreview) -> Self {
260        Self {
261            title: preview.title,
262            modified_at_unix_ms: preview.modified_at_unix_ms,
263            model: preview.model,
264            message_count: preview.message_count,
265            message_count_exact: preview.message_count_exact,
266            completed_turn_count: preview.completed_turn_count,
267            total_tokens: preview.total_tokens,
268            truncated: preview.truncated,
269            messages: preview.messages,
270        }
271    }
272}
273
274impl NativeSessionPreview {
275    pub fn validate(&self) -> Result<(), HistoryValidationError> {
276        validate_required_identifier(&self.session_id, HISTORY_SESSION_ID_MAX_BYTES, "session id")?;
277        validate_optional_single_line_text(&self.title, HISTORY_TITLE_MAX_BYTES, "title")?;
278        validate_optional_identifier(&self.model, HISTORY_MODEL_MAX_BYTES, "model")?;
279        if self.messages.len() > usize::from(NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX) {
280            return Err(HistoryValidationError::TooManyMessages);
281        }
282        for message in &self.messages {
283            message.validate()?;
284        }
285        Ok(())
286    }
287}
288
289#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
290pub struct HistoryQuery {
291    pub working_directory: Option<String>,
292    pub limit: u16,
293}
294
295impl HistoryQuery {
296    pub fn validate(&self) -> Result<(), HistoryValidationError> {
297        if !(1..=HISTORY_DISCOVERY_LIMIT_MAX).contains(&self.limit) {
298            return Err(HistoryValidationError::InvalidLimit);
299        }
300        if let Some(working_directory) = &self.working_directory {
301            if working_directory.trim().is_empty()
302                || working_directory.len() > WORKING_DIRECTORY_MAX_BYTES
303                || working_directory.chars().any(char::is_control)
304            {
305                return Err(HistoryValidationError::InvalidWorkingDirectory);
306            }
307        }
308        Ok(())
309    }
310}
311
312#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
313pub struct HistoryCandidateSummary {
314    pub id: String,
315    pub session_id_hint: String,
316    pub modified_at_unix_ms: Option<u64>,
317}
318
319impl HistoryCandidateSummary {
320    pub fn validate(&self) -> Result<(), HistoryValidationError> {
321        validate_candidate_id(&self.id)?;
322        validate_required_identifier(
323            &self.session_id_hint,
324            HISTORY_SESSION_ID_MAX_BYTES,
325            "session id",
326        )
327    }
328}
329
330#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
331#[serde(rename_all = "kebab-case")]
332pub enum HistoryMessageRole {
333    User,
334    Assistant,
335}
336
337#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
338pub struct HistoryMessageRecord {
339    pub role: HistoryMessageRole,
340    pub text: String,
341}
342
343#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
344pub struct HistorySessionRecord {
345    pub session_id: String,
346    pub title: Option<String>,
347    pub cwd: Option<String>,
348    pub model: Option<String>,
349    pub message_count: u64,
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub completed_turn_count: Option<u64>,
352    pub total_tokens: u64,
353    pub messages: Vec<HistoryMessageRecord>,
354}
355
356impl HistorySessionRecord {
357    pub fn validate(&self) -> Result<(), HistoryValidationError> {
358        validate_required_identifier(&self.session_id, HISTORY_SESSION_ID_MAX_BYTES, "session id")?;
359        validate_optional_text(&self.title, HISTORY_TITLE_MAX_BYTES, "title")?;
360        validate_optional_identifier(&self.cwd, WORKING_DIRECTORY_MAX_BYTES, "working directory")?;
361        validate_optional_identifier(&self.model, HISTORY_MODEL_MAX_BYTES, "model")?;
362        if self.messages.len() > HISTORY_MESSAGES_MAX {
363            return Err(HistoryValidationError::TooManyMessages);
364        }
365        for message in &self.messages {
366            validate_text(&message.text, HISTORY_MESSAGE_MAX_BYTES, "message")?;
367        }
368        Ok(())
369    }
370}
371
372#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
373#[serde(tag = "kind", rename_all = "kebab-case")]
374pub enum HistoryOperation {
375    Discover { query: HistoryQuery },
376    Load { candidate_id: String },
377}
378
379#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
380pub struct PendingHistoryOperation {
381    pub operation_id: OperationId,
382    pub operation: HistoryOperation,
383}
384
385#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
386pub struct HistorySnapshot {
387    pub pending: Option<PendingHistoryOperation>,
388    pub candidates: Vec<HistoryCandidateSummary>,
389    pub loaded_candidate_id: Option<String>,
390    pub loaded: Option<HistorySessionRecord>,
391    pub last_error: Option<String>,
392}
393
394impl HistorySnapshot {
395    pub fn candidate(&self, candidate_id: &str) -> Option<&HistoryCandidateSummary> {
396        self.candidates
397            .iter()
398            .find(|candidate| candidate.id == candidate_id)
399    }
400}
401
402#[derive(Clone, Debug, Error, Eq, PartialEq)]
403pub enum HistoryValidationError {
404    #[error("history discovery limit is outside the supported bounded range")]
405    InvalidLimit,
406    #[error("history working directory is empty, too large, or contains controls")]
407    InvalidWorkingDirectory,
408    #[error("history candidate ID is not a bounded opaque ASCII token")]
409    InvalidCandidateId,
410    #[error("history {field} is empty, too large, or contains controls")]
411    InvalidField { field: &'static str },
412    #[error("history result contains too many retained messages")]
413    TooManyMessages,
414}
415
416pub fn validate_candidate_id(value: &str) -> Result<(), HistoryValidationError> {
417    if value.trim().is_empty()
418        || value.len() > HISTORY_CANDIDATE_ID_MAX_BYTES
419        || !value
420            .bytes()
421            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
422    {
423        return Err(HistoryValidationError::InvalidCandidateId);
424    }
425    Ok(())
426}
427
428pub fn validate_native_session_id(value: &str) -> Result<(), HistoryValidationError> {
429    validate_required_identifier(value, HISTORY_SESSION_ID_MAX_BYTES, "session id")
430}
431
432pub fn validate_history_error(message: &str) -> Result<(), HistoryValidationError> {
433    validate_required_text(message, HISTORY_ERROR_MAX_BYTES, "error")
434}
435
436fn validate_optional_text(
437    value: &Option<String>,
438    max: usize,
439    field: &'static str,
440) -> Result<(), HistoryValidationError> {
441    if let Some(value) = value {
442        validate_text(value, max, field)?;
443    }
444    Ok(())
445}
446
447fn validate_optional_single_line_text(
448    value: &Option<String>,
449    max: usize,
450    field: &'static str,
451) -> Result<(), HistoryValidationError> {
452    if value
453        .as_ref()
454        .is_some_and(|value| value.len() > max || value.chars().any(char::is_control))
455    {
456        return Err(HistoryValidationError::InvalidField { field });
457    }
458    Ok(())
459}
460
461fn validate_optional_identifier(
462    value: &Option<String>,
463    max: usize,
464    field: &'static str,
465) -> Result<(), HistoryValidationError> {
466    if let Some(value) = value {
467        validate_required_identifier(value, max, field)?;
468    }
469    Ok(())
470}
471
472fn validate_required_identifier(
473    value: &str,
474    max: usize,
475    field: &'static str,
476) -> Result<(), HistoryValidationError> {
477    if value.trim().is_empty() || value.len() > max || value.chars().any(char::is_control) {
478        return Err(HistoryValidationError::InvalidField { field });
479    }
480    Ok(())
481}
482
483fn validate_required_text(
484    value: &str,
485    max: usize,
486    field: &'static str,
487) -> Result<(), HistoryValidationError> {
488    if value.trim().is_empty() {
489        return Err(HistoryValidationError::InvalidField { field });
490    }
491    validate_text(value, max, field)
492}
493
494fn validate_text(
495    value: &str,
496    max: usize,
497    field: &'static str,
498) -> Result<(), HistoryValidationError> {
499    if value.len() > max
500        || value
501            .chars()
502            .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
503    {
504        return Err(HistoryValidationError::InvalidField { field });
505    }
506    Ok(())
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn query_candidate_and_result_bounds_are_explicit() {
515        assert_eq!(
516            HistoryQuery {
517                working_directory: None,
518                limit: 0,
519            }
520            .validate(),
521            Err(HistoryValidationError::InvalidLimit)
522        );
523        assert_eq!(
524            validate_candidate_id(r"C:\history\session.jsonl"),
525            Err(HistoryValidationError::InvalidCandidateId)
526        );
527        let record = HistorySessionRecord {
528            session_id: "session-1".to_owned(),
529            title: None,
530            cwd: None,
531            model: None,
532            message_count: 0,
533            completed_turn_count: None,
534            total_tokens: 0,
535            messages: Vec::new(),
536        };
537        assert_eq!(record.validate(), Ok(()));
538
539        let mut record_with_control = record;
540        record_with_control.session_id = "session\n2".to_owned();
541        assert_eq!(
542            record_with_control.validate(),
543            Err(HistoryValidationError::InvalidField {
544                field: "session id"
545            })
546        );
547    }
548
549    #[test]
550    fn completed_turn_count_is_backward_compatible_optional_metadata() {
551        let legacy = serde_json::from_str::<HistorySessionRecord>(
552            r#"{"session_id":"session-1","title":null,"cwd":null,"model":null,"message_count":2,"total_tokens":3,"messages":[]}"#,
553        )
554        .unwrap();
555        assert_eq!(legacy.completed_turn_count, None);
556        assert!(serde_json::to_value(&legacy)
557            .unwrap()
558            .get("completed_turn_count")
559            .is_none());
560
561        let current = HistorySessionRecord {
562            completed_turn_count: Some(1),
563            ..legacy
564        };
565        assert_eq!(
566            serde_json::to_value(&current).unwrap()["completed_turn_count"],
567            1
568        );
569    }
570
571    #[test]
572    fn native_session_catalog_entry_is_bounded_metadata_only() {
573        let entry = NativeSessionCatalogEntry {
574            selection_id: "hist_selection_1".to_owned(),
575            session_id: "session-1".to_owned(),
576            title: Some("Review".to_owned()),
577            modified_at_unix_ms: Some(7),
578            model: Some("model-1".to_owned()),
579            message_count: 4,
580            completed_turn_count: Some(2),
581        };
582        assert_eq!(entry.validate(), Ok(()));
583        let NativeSessionCatalogEntry {
584            selection_id: _,
585            session_id: _,
586            title: _,
587            modified_at_unix_ms: _,
588            model: _,
589            message_count: _,
590            completed_turn_count: _,
591        } = &entry;
592        let injected = NativeSessionCatalogEntry {
593            title: Some("safe\nunsafe".to_owned()),
594            ..entry
595        };
596        assert_eq!(
597            injected.validate(),
598            Err(HistoryValidationError::InvalidField { field: "title" })
599        );
600    }
601
602    #[test]
603    fn preview_token_totals_are_optional_and_backward_compatible() {
604        let legacy_native = serde_json::from_str::<NativeSessionPreview>(
605            r#"{"session_id":"session-1","title":null,"modified_at_unix_ms":null,"model":null,"message_count":0,"message_count_exact":true,"completed_turn_count":null,"truncated":false,"messages":[]}"#,
606        )
607        .unwrap();
608        assert_eq!(legacy_native.total_tokens, None);
609        assert!(serde_json::to_value(&legacy_native)
610            .unwrap()
611            .get("total_tokens")
612            .is_none());
613
614        let legacy_record = serde_json::from_str::<SessionRecordPreview>(
615            r#"{"title":null,"modified_at_unix_ms":null,"model":null,"message_count":0,"message_count_exact":true,"completed_turn_count":null,"truncated":false,"messages":[]}"#,
616        )
617        .unwrap();
618        assert_eq!(legacy_record.total_tokens, None);
619
620        let observed_zero = NativeSessionPreview {
621            total_tokens: Some(0),
622            ..legacy_native
623        };
624        assert_eq!(
625            serde_json::to_value(&observed_zero).unwrap()["total_tokens"],
626            0
627        );
628        assert_eq!(
629            SessionRecordPreview::from(observed_zero).total_tokens,
630            Some(0)
631        );
632    }
633}