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(¤t).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}