1use super::memory_context::{ProjectId, RetentionClass, SessionId, TurnId};
9use agent_core::core::disclosure::DisclosureClass;
10use agent_core::{BoundedText, BudgetDimension, TurnOutcome};
11use sha2::{Digest, Sha256};
12use std::fmt;
13use std::time::{SystemTime, UNIX_EPOCH};
14
15pub const CAPTURE_SEGMENT_MAX_BYTES: usize = 4096;
16pub const SUMMARY_CAPTURE_MAX_BYTES: usize = 12_000;
17pub const TOOL_SUMMARY_MAX_BYTES: usize = 1024;
18pub const TOOL_NAME_MAX_BYTES: usize = 128;
19pub const MAX_TOOL_SUMMARIES: usize = 16;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum CaptureSchemaVersion {
23 V1,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
27pub enum Sensitivity {
28 Normal,
29 Sensitive,
30 Secret,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum CaptureContentClass {
37 UserMessage,
38 AssistantFinal,
39 ToolSummary,
40 SystemPrompt,
41 DeveloperPrompt,
42 PrivateReasoning,
43 Secret,
44 NeverPersist,
45 RawBinary,
46 RawToolOutput,
47}
48
49impl CaptureContentClass {
50 fn digest_tag(self) -> u8 {
51 match self {
52 Self::UserMessage => 0,
53 Self::AssistantFinal => 1,
54 Self::ToolSummary => 2,
55 Self::SystemPrompt => 3,
56 Self::DeveloperPrompt => 4,
57 Self::PrivateReasoning => 5,
58 Self::Secret => 6,
59 Self::NeverPersist => 7,
60 Self::RawBinary => 8,
61 Self::RawToolOutput => 9,
62 }
63 }
64
65 fn is_capture_content(self) -> bool {
66 matches!(
67 self,
68 Self::UserMessage | Self::AssistantFinal | Self::ToolSummary
69 )
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct CanonicalCaptureItem {
76 pub project_id: ProjectId,
77 pub class: CaptureContentClass,
78 pub disclosure: DisclosureClass,
79 pub sensitivity: Sensitivity,
80 pub text: String,
81 pub tool_name: Option<String>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct TerminalTurnHistory {
90 pub project_id: ProjectId,
91 pub session_id: SessionId,
92 pub turn_id: TurnId,
93 pub turn_ordinal: u64,
94 pub started_at: SystemTime,
95 pub completed_at: SystemTime,
96 pub outcome: TurnOutcome,
97 pub items: Vec<CanonicalCaptureItem>,
98 pub compaction: Option<CompactionSource>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct CaptureSegment {
103 pub content: BoundedText,
104 pub disclosure: DisclosureClass,
105 pub sensitivity: Sensitivity,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct ToolCaptureSummary {
110 pub tool_name: BoundedText,
111 pub summary: BoundedText,
112 pub disclosure: DisclosureClass,
113 pub sensitivity: Sensitivity,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
117pub struct MessageRangeDigest([u8; 32]);
118
119impl MessageRangeDigest {
120 pub fn from_bytes(bytes: [u8; 32]) -> Self {
121 Self(bytes)
122 }
123
124 pub fn from_hex(hex: &str) -> Option<Self> {
125 decode_hex_32(hex).map(Self)
126 }
127
128 pub fn as_bytes(&self) -> &[u8; 32] {
129 &self.0
130 }
131
132 pub fn to_hex(self) -> String {
133 hex_bytes(&self.0)
134 }
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
138pub struct PromptStackDigest([u8; 32]);
139
140impl PromptStackDigest {
141 pub fn from_bytes(bytes: [u8; 32]) -> Self {
142 Self(bytes)
143 }
144
145 pub fn from_hex(hex: &str) -> Option<Self> {
146 decode_hex_32(hex).map(Self)
147 }
148
149 pub fn as_bytes(&self) -> &[u8; 32] {
150 &self.0
151 }
152
153 pub fn to_hex(self) -> String {
154 hex_bytes(&self.0)
155 }
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
159pub struct CaptureId([u8; 32]);
160
161impl CaptureId {
162 pub(crate) fn from_bytes(bytes: [u8; 32]) -> Self {
163 Self(bytes)
164 }
165
166 pub fn as_bytes(&self) -> &[u8; 32] {
167 &self.0
168 }
169
170 pub fn to_hex(self) -> String {
171 hex_bytes(&self.0)
172 }
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum CompactionSchemaVersion {
177 V1,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub enum CompactionSummaryOrigin {
182 LocalOnly,
183 Provider {
184 provider_id: String,
185 model_id: String,
186 },
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum RedactionPolicy {
191 None,
192 HostRedacted,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct CompactionSource {
198 pub schema: CompactionSchemaVersion,
199 pub project_id: ProjectId,
200 pub source_session_id: SessionId,
201 pub first_turn_ordinal: u64,
205 pub last_turn_ordinal: u64,
206 pub source_digest: MessageRangeDigest,
207 pub summary_origin: CompactionSummaryOrigin,
208 pub prompt_stack_digest: PromptStackDigest,
209 pub redaction: RedactionPolicy,
210 pub content_classes: Vec<agent_core::compaction::ContentClass>,
211 pub summarized_at: SystemTime,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct ConversationSummaryCapture {
218 pub schema: CompactionSchemaVersion,
219 pub capture_id: CaptureId,
220 pub project_id: ProjectId,
221 pub source_session_id: SessionId,
222 pub source_message_count: usize,
223 pub first_turn_ordinal: u64,
224 pub last_turn_ordinal: u64,
225 pub source_turn_range_digest: MessageRangeDigest,
226 pub summary: BoundedText,
227 pub summary_origin: CompactionSummaryOrigin,
228 pub prompt_stack_digest: PromptStackDigest,
229 pub redaction_policy: agent_core::compaction::RedactionPolicy,
230 pub content_classes: Vec<agent_core::compaction::ContentClass>,
231 pub summarized_at: SystemTime,
232 pub retention: RetentionClass,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum SummaryCaptureBuildError {
237 ForeignProject,
238 EmptySourceRange,
239 InvalidTurnRange,
240}
241
242pub fn build_conversation_summary_capture(
246 expected_project: &ProjectId,
247 source: CompactionSource,
248 source_message_count: usize,
249 summary_text: &str,
250 redaction_policy: agent_core::compaction::RedactionPolicy,
251 retention: RetentionClass,
252) -> Result<ConversationSummaryCapture, SummaryCaptureBuildError> {
253 if &source.project_id != expected_project {
254 return Err(SummaryCaptureBuildError::ForeignProject);
255 }
256 if source_message_count == 0 {
257 return Err(SummaryCaptureBuildError::EmptySourceRange);
258 }
259 if source.first_turn_ordinal > source.last_turn_ordinal {
260 return Err(SummaryCaptureBuildError::InvalidTurnRange);
261 }
262
263 let mut digest = Sha256::new();
264 digest.update(b"synaps.conversation-summary-capture-id.v1\0");
265 digest_text(&mut digest, expected_project.as_str());
266 digest_text(&mut digest, source.source_session_id.as_str());
267 digest_u64(&mut digest, source.first_turn_ordinal);
268 digest_u64(&mut digest, source.last_turn_ordinal);
269 digest.update(source.source_digest.as_bytes());
270 let capture_id = CaptureId(digest.finalize().into());
271
272 Ok(ConversationSummaryCapture {
273 schema: source.schema,
274 capture_id,
275 project_id: source.project_id,
276 source_session_id: source.source_session_id,
277 source_message_count,
278 first_turn_ordinal: source.first_turn_ordinal,
279 last_turn_ordinal: source.last_turn_ordinal,
280 source_turn_range_digest: source.source_digest,
281 summary: BoundedText::new(summary_text, SUMMARY_CAPTURE_MAX_BYTES),
282 summary_origin: source.summary_origin,
283 prompt_stack_digest: source.prompt_stack_digest,
284 redaction_policy,
285 content_classes: source.content_classes,
286 summarized_at: source.summarized_at,
287 retention,
288 })
289}
290
291#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct CaptureDisclosure {
294 pub classes: Vec<DisclosureClass>,
295}
296
297#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct ChatTurnCapture {
299 pub schema: CaptureSchemaVersion,
300 pub capture_id: CaptureId,
301 pub project_id: ProjectId,
302 pub session_id: SessionId,
303 pub turn_id: TurnId,
304 pub turn_ordinal: u64,
305 pub started_at: SystemTime,
306 pub completed_at: SystemTime,
307 pub user: CaptureSegment,
308 pub assistant: CaptureSegment,
309 pub tools: Vec<ToolCaptureSummary>,
310 pub outcome: TurnOutcome,
311 pub compaction: Option<CompactionSource>,
312 pub source_digest: MessageRangeDigest,
313 pub disclosure: CaptureDisclosure,
314 pub sensitivity: Sensitivity,
315 pub retention: RetentionClass,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq)]
319pub enum CaptureBuildError {
320 ForeignProject,
321 InvalidTimes,
322 InvalidCompactionRange,
323 MissingUser,
324 MissingFinalAssistant,
325 MissingToolName,
326}
327
328impl fmt::Display for CaptureBuildError {
329 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330 let message = match self {
331 Self::ForeignProject => "capture contains foreign-project provenance",
332 Self::InvalidTimes => "turn completion precedes turn start",
333 Self::InvalidCompactionRange => "compaction source turn range is invalid",
334 Self::MissingUser => "capture has no persistable canonical user message",
335 Self::MissingFinalAssistant => "capture has no persistable final assistant message",
336 Self::MissingToolName => "tool capture summary has no tool name",
337 };
338 f.write_str(message)
339 }
340}
341
342impl std::error::Error for CaptureBuildError {}
343
344pub fn build_chat_turn_capture(
350 expected_project: &ProjectId,
351 history: TerminalTurnHistory,
352 retention: RetentionClass,
353) -> Result<ChatTurnCapture, CaptureBuildError> {
354 validate_provenance(expected_project, &history)?;
355
356 let mut user = None;
357 let mut assistant = None;
358 let mut tools = Vec::new();
359 let mut disclosure_classes = Vec::new();
360 let mut sensitivity = Sensitivity::Normal;
361
362 for item in &history.items {
363 if !is_persistable_capture_item(item) {
364 continue;
365 }
366
367 match item.class {
368 CaptureContentClass::UserMessage => {
369 sensitivity = sensitivity.max(item.sensitivity);
370 insert_disclosure(&mut disclosure_classes, item.disclosure);
371 user = Some(CaptureSegment {
372 content: BoundedText::new(&item.text, CAPTURE_SEGMENT_MAX_BYTES),
373 disclosure: item.disclosure,
374 sensitivity: item.sensitivity,
375 });
376 }
377 CaptureContentClass::AssistantFinal => {
378 sensitivity = sensitivity.max(item.sensitivity);
379 insert_disclosure(&mut disclosure_classes, item.disclosure);
380 assistant = Some(CaptureSegment {
381 content: BoundedText::new(&item.text, CAPTURE_SEGMENT_MAX_BYTES),
382 disclosure: item.disclosure,
383 sensitivity: item.sensitivity,
384 });
385 }
386 CaptureContentClass::ToolSummary => {
387 let name = item
388 .tool_name
389 .as_deref()
390 .filter(|name| !name.trim().is_empty())
391 .ok_or(CaptureBuildError::MissingToolName)?;
392 if tools.len() < MAX_TOOL_SUMMARIES {
393 sensitivity = sensitivity.max(item.sensitivity);
394 insert_disclosure(&mut disclosure_classes, item.disclosure);
395 tools.push(ToolCaptureSummary {
396 tool_name: BoundedText::new(name, TOOL_NAME_MAX_BYTES),
397 summary: BoundedText::new(&item.text, TOOL_SUMMARY_MAX_BYTES),
398 disclosure: item.disclosure,
399 sensitivity: item.sensitivity,
400 });
401 }
402 }
403 CaptureContentClass::SystemPrompt
404 | CaptureContentClass::DeveloperPrompt
405 | CaptureContentClass::PrivateReasoning
406 | CaptureContentClass::Secret
407 | CaptureContentClass::NeverPersist
408 | CaptureContentClass::RawBinary
409 | CaptureContentClass::RawToolOutput => unreachable!("forbidden item passed filter"),
410 }
411 }
412
413 let user = user.ok_or(CaptureBuildError::MissingUser)?;
414 let assistant = assistant.ok_or(CaptureBuildError::MissingFinalAssistant)?;
415 let source_digest = source_digest(&history);
416 let capture_id = capture_id(&history, source_digest);
417
418 Ok(ChatTurnCapture {
419 schema: CaptureSchemaVersion::V1,
420 capture_id,
421 project_id: history.project_id,
422 session_id: history.session_id,
423 turn_id: history.turn_id,
424 turn_ordinal: history.turn_ordinal,
425 started_at: history.started_at,
426 completed_at: history.completed_at,
427 user,
428 assistant,
429 tools,
430 outcome: history.outcome,
431 compaction: history.compaction,
432 source_digest,
433 disclosure: CaptureDisclosure {
434 classes: disclosure_classes,
435 },
436 sensitivity,
437 retention,
438 })
439}
440
441fn validate_provenance(
442 expected_project: &ProjectId,
443 history: &TerminalTurnHistory,
444) -> Result<(), CaptureBuildError> {
445 if &history.project_id != expected_project
446 || history
447 .items
448 .iter()
449 .any(|item| &item.project_id != expected_project)
450 || history
451 .compaction
452 .as_ref()
453 .is_some_and(|source| &source.project_id != expected_project)
454 {
455 return Err(CaptureBuildError::ForeignProject);
456 }
457 if history.completed_at < history.started_at {
458 return Err(CaptureBuildError::InvalidTimes);
459 }
460 if history
461 .compaction
462 .as_ref()
463 .is_some_and(|source| source.first_turn_ordinal > source.last_turn_ordinal)
464 {
465 return Err(CaptureBuildError::InvalidCompactionRange);
466 }
467 Ok(())
468}
469
470fn is_persistable_capture_item(item: &CanonicalCaptureItem) -> bool {
471 item.class.is_capture_content()
472 && item.class != CaptureContentClass::NeverPersist
473 && item.sensitivity != Sensitivity::Secret
474 && agent_core::core::disclosure::may_persist(item.disclosure)
475}
476
477fn insert_disclosure(classes: &mut Vec<DisclosureClass>, class: DisclosureClass) {
478 if !classes.contains(&class) {
479 classes.push(class);
480 classes.sort_by_key(|class| disclosure_tag(*class));
481 }
482}
483
484fn source_digest(history: &TerminalTurnHistory) -> MessageRangeDigest {
485 let mut digest = Sha256::new();
486 digest.update(b"synaps.chat-turn-source.v1\0");
487 digest_text(&mut digest, history.project_id.as_str());
488 digest_text(&mut digest, history.session_id.as_str());
489 digest_text(&mut digest, history.turn_id.as_str());
490 digest_u64(&mut digest, history.turn_ordinal);
491 digest_time(&mut digest, history.started_at);
492 digest_time(&mut digest, history.completed_at);
493 digest_outcome(&mut digest, &history.outcome);
494
495 for item in &history.items {
496 if !is_persistable_capture_item(item) {
497 continue;
498 }
499 digest.update([item.class.digest_tag()]);
500 digest.update([disclosure_tag(item.disclosure)]);
501 digest.update([sensitivity_tag(item.sensitivity)]);
502 digest_text(&mut digest, item.tool_name.as_deref().unwrap_or(""));
503 digest_text(&mut digest, &item.text);
504 }
505
506 match &history.compaction {
507 None => digest.update([0]),
508 Some(source) => {
509 digest.update([1]);
510 digest_compaction(&mut digest, source);
511 }
512 }
513
514 MessageRangeDigest(digest.finalize().into())
515}
516
517fn capture_id(history: &TerminalTurnHistory, source: MessageRangeDigest) -> CaptureId {
518 let mut digest = Sha256::new();
519 digest.update(b"synaps.chat-turn-capture-id.v1\0");
520 digest_text(&mut digest, history.project_id.as_str());
521 digest_text(&mut digest, history.session_id.as_str());
522 digest_text(&mut digest, history.turn_id.as_str());
523 digest_u64(&mut digest, history.turn_ordinal);
524 digest.update(source.as_bytes());
525 CaptureId(digest.finalize().into())
526}
527
528fn digest_compaction(digest: &mut Sha256, source: &CompactionSource) {
529 digest.update([match source.schema {
530 CompactionSchemaVersion::V1 => 1,
531 }]);
532 digest_text(digest, source.project_id.as_str());
533 digest_text(digest, source.source_session_id.as_str());
534 digest_u64(digest, source.first_turn_ordinal);
535 digest_u64(digest, source.last_turn_ordinal);
536 digest.update(source.source_digest.as_bytes());
537 match &source.summary_origin {
538 CompactionSummaryOrigin::LocalOnly => digest.update([0]),
539 CompactionSummaryOrigin::Provider {
540 provider_id,
541 model_id,
542 } => {
543 digest.update([1]);
544 digest_text(digest, provider_id);
545 digest_text(digest, model_id);
546 }
547 }
548 digest.update(source.prompt_stack_digest.as_bytes());
549 digest.update([match source.redaction {
550 RedactionPolicy::None => 0,
551 RedactionPolicy::HostRedacted => 1,
552 }]);
553 digest_u64(digest, source.content_classes.len() as u64);
554 for class in &source.content_classes {
555 digest_text(digest, class.as_str());
556 }
557 digest_time(digest, source.summarized_at);
558}
559
560fn digest_outcome(digest: &mut Sha256, outcome: &TurnOutcome) {
561 match outcome {
562 TurnOutcome::Completed => digest.update([0]),
563 TurnOutcome::Canceled => digest.update([1]),
564 TurnOutcome::ProviderFailed {
565 code,
566 correlation_id,
567 } => {
568 digest.update([2]);
569 digest_text(digest, code);
570 digest_text(digest, correlation_id);
571 }
572 TurnOutcome::ToolFailed {
573 tool_id,
574 correlation_id,
575 } => {
576 digest.update([3]);
577 digest_text(digest, tool_id);
578 digest_text(digest, correlation_id);
579 }
580 TurnOutcome::BudgetExceeded { dimension } => {
581 digest.update([4]);
582 digest.update([budget_dimension_tag(*dimension)]);
583 }
584 TurnOutcome::InterruptedAfterSideEffect { call_id } => {
585 digest.update([5]);
586 digest_text(digest, call_id);
587 }
588 }
589}
590
591fn budget_dimension_tag(dimension: BudgetDimension) -> u8 {
592 match dimension {
593 BudgetDimension::InputTokens => 0,
594 BudgetDimension::OutputTokens => 1,
595 BudgetDimension::ToolCalls => 2,
596 BudgetDimension::WallClock => 3,
597 BudgetDimension::ProviderRounds => 4,
598 BudgetDimension::ToolResultBytes => 5,
599 BudgetDimension::CostUsd => 6,
600 }
601}
602
603fn disclosure_tag(class: DisclosureClass) -> u8 {
604 match class {
605 DisclosureClass::ModelVisible => 0,
606 DisclosureClass::LocalOnly => 1,
607 DisclosureClass::ModelVisibleAfterRedaction => 2,
608 DisclosureClass::ModelVisibleAfterConsent => 3,
609 DisclosureClass::PersistNeverTransmit => 4,
610 DisclosureClass::NeverPersist => 5,
611 }
612}
613
614fn sensitivity_tag(sensitivity: Sensitivity) -> u8 {
615 match sensitivity {
616 Sensitivity::Normal => 0,
617 Sensitivity::Sensitive => 1,
618 Sensitivity::Secret => 2,
619 }
620}
621
622fn digest_text(digest: &mut Sha256, text: &str) {
623 digest_u64(digest, text.len() as u64);
624 digest.update(text.as_bytes());
625}
626
627fn digest_u64(digest: &mut Sha256, value: u64) {
628 digest.update(value.to_be_bytes());
629}
630
631fn digest_time(digest: &mut Sha256, time: SystemTime) {
632 match time.duration_since(UNIX_EPOCH) {
633 Ok(duration) => {
634 digest.update([0]);
635 digest_u64(digest, duration.as_secs());
636 digest.update(duration.subsec_nanos().to_be_bytes());
637 }
638 Err(error) => {
639 digest.update([1]);
640 digest_u64(digest, error.duration().as_secs());
641 digest.update(error.duration().subsec_nanos().to_be_bytes());
642 }
643 }
644}
645
646fn decode_hex_32(hex: &str) -> Option<[u8; 32]> {
647 if hex.len() != 64 {
648 return None;
649 }
650 let mut bytes = [0; 32];
651 for (index, pair) in hex.as_bytes().chunks_exact(2).enumerate() {
652 let high = (pair[0] as char).to_digit(16)? as u8;
653 let low = (pair[1] as char).to_digit(16)? as u8;
654 bytes[index] = (high << 4) | low;
655 }
656 Some(bytes)
657}
658
659fn hex_bytes(bytes: &[u8]) -> String {
660 const HEX: &[u8; 16] = b"0123456789abcdef";
661 let mut output = String::with_capacity(bytes.len() * 2);
662 for byte in bytes {
663 output.push(HEX[(byte >> 4) as usize] as char);
664 output.push(HEX[(byte & 0x0f) as usize] as char);
665 }
666 output
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use std::time::Duration;
673
674 const FORBIDDEN: &str = "FORBIDDEN-CONTENT-SENTINEL";
675
676 fn project(raw: &str) -> ProjectId {
677 ProjectId::parse(raw).unwrap()
678 }
679
680 fn item(class: CaptureContentClass, text: &str) -> CanonicalCaptureItem {
681 CanonicalCaptureItem {
682 project_id: project("project-a"),
683 class,
684 disclosure: DisclosureClass::ModelVisible,
685 sensitivity: Sensitivity::Normal,
686 text: text.to_string(),
687 tool_name: None,
688 }
689 }
690
691 fn tool(text: &str) -> CanonicalCaptureItem {
692 CanonicalCaptureItem {
693 tool_name: Some("bash".to_string()),
694 ..item(CaptureContentClass::ToolSummary, text)
695 }
696 }
697
698 fn terminal(items: Vec<CanonicalCaptureItem>) -> TerminalTurnHistory {
699 TerminalTurnHistory {
700 project_id: project("project-a"),
701 session_id: SessionId::parse("session-1").unwrap(),
702 turn_id: TurnId::parse("turn-7").unwrap(),
703 turn_ordinal: 7,
704 started_at: UNIX_EPOCH + Duration::from_secs(10),
705 completed_at: UNIX_EPOCH + Duration::from_secs(20),
706 outcome: TurnOutcome::Completed,
707 items,
708 compaction: None,
709 }
710 }
711
712 fn required_items() -> Vec<CanonicalCaptureItem> {
713 vec![
714 item(CaptureContentClass::UserMessage, "user"),
715 item(CaptureContentClass::AssistantFinal, "assistant"),
716 ]
717 }
718
719 fn build(history: TerminalTurnHistory) -> Result<ChatTurnCapture, CaptureBuildError> {
720 build_chat_turn_capture(&project("project-a"), history, RetentionClass::Standard)
721 }
722
723 #[test]
724 fn bounds_user_assistant_and_tool_summaries() {
725 let mut items = vec![
726 item(CaptureContentClass::UserMessage, &"u".repeat(5000)),
727 item(CaptureContentClass::AssistantFinal, &"a".repeat(5000)),
728 ];
729 items.extend((0..MAX_TOOL_SUMMARIES + 3).map(|_| tool(&"t".repeat(2000))));
730
731 let capture = build(terminal(items)).unwrap();
732
733 assert_eq!(
734 capture.user.content.retained_bytes,
735 CAPTURE_SEGMENT_MAX_BYTES
736 );
737 assert!(capture.user.content.truncated);
738 assert_eq!(
739 capture.assistant.content.retained_bytes,
740 CAPTURE_SEGMENT_MAX_BYTES
741 );
742 assert!(capture.assistant.content.truncated);
743 assert_eq!(capture.tools.len(), MAX_TOOL_SUMMARIES);
744 assert!(capture
745 .tools
746 .iter()
747 .all(|tool| tool.summary.retained_bytes == TOOL_SUMMARY_MAX_BYTES
748 && tool.summary.truncated));
749 }
750
751 #[test]
752 fn filters_every_forbidden_class_and_never_persist_or_secret_content() {
753 let mut items = required_items();
754 for class in [
755 CaptureContentClass::SystemPrompt,
756 CaptureContentClass::DeveloperPrompt,
757 CaptureContentClass::PrivateReasoning,
758 CaptureContentClass::Secret,
759 CaptureContentClass::NeverPersist,
760 CaptureContentClass::RawBinary,
761 CaptureContentClass::RawToolOutput,
762 ] {
763 items.push(item(class, FORBIDDEN));
764 }
765 let mut never_persist = tool(FORBIDDEN);
766 never_persist.disclosure = DisclosureClass::NeverPersist;
767 items.push(never_persist);
768 let mut secret = tool(FORBIDDEN);
769 secret.sensitivity = Sensitivity::Secret;
770 items.push(secret);
771
772 let capture = build(terminal(items)).unwrap();
773 let rendered = format!("{capture:?}");
774
775 assert!(!rendered.contains(FORBIDDEN));
776 assert!(capture.tools.is_empty());
777 assert!(!capture
778 .disclosure
779 .classes
780 .contains(&DisclosureClass::NeverPersist));
781 assert_ne!(capture.sensitivity, Sensitivity::Secret);
782 }
783
784 #[test]
785 fn rejects_foreign_project_even_when_item_content_would_be_filtered() {
786 let mut items = required_items();
787 let mut foreign = item(CaptureContentClass::SystemPrompt, FORBIDDEN);
788 foreign.project_id = project("project-b");
789 items.push(foreign);
790
791 assert_eq!(
792 build(terminal(items)),
793 Err(CaptureBuildError::ForeignProject)
794 );
795 }
796
797 #[test]
798 fn source_digest_and_capture_id_are_deterministic_and_content_sensitive() {
799 let history = terminal(required_items());
800 let first = build(history.clone()).unwrap();
801 let second = build(history.clone()).unwrap();
802 let mut changed = history;
803 changed.items[1].text.push('!');
804 let changed = build(changed).unwrap();
805
806 assert_eq!(first.source_digest, second.source_digest);
807 assert_eq!(first.capture_id, second.capture_id);
808 assert_eq!(first.capture_id.to_hex().len(), 64);
809 assert_ne!(first.source_digest, changed.source_digest);
810 assert_ne!(first.capture_id, changed.capture_id);
811 }
812
813 #[test]
814 fn interrupted_non_idempotent_outcome_is_never_clean_success() {
815 let mut history = terminal(required_items());
816 history.outcome = TurnOutcome::InterruptedAfterSideEffect {
817 call_id: "call-9".to_string(),
818 };
819
820 let capture = build(history).unwrap();
821
822 assert_eq!(
823 capture.outcome,
824 TurnOutcome::InterruptedAfterSideEffect {
825 call_id: "call-9".to_string()
826 }
827 );
828 assert_ne!(capture.outcome, TurnOutcome::Completed);
829 }
830
831 #[test]
832 fn conversation_summary_builder_is_bounded_and_digest_linked() {
833 let source_digest = MessageRangeDigest::from_bytes([7; 32]);
834 let source = CompactionSource {
835 schema: CompactionSchemaVersion::V1,
836 project_id: project("project-a"),
837 source_session_id: SessionId::parse("session-previous").unwrap(),
838 first_turn_ordinal: 2,
839 last_turn_ordinal: 5,
840 source_digest,
841 summary_origin: CompactionSummaryOrigin::Provider {
842 provider_id: "anthropic".into(),
843 model_id: "claude-sonnet-4-6".into(),
844 },
845 prompt_stack_digest: PromptStackDigest::from_bytes([8; 32]),
846 redaction: RedactionPolicy::HostRedacted,
847 content_classes: vec![agent_core::compaction::ContentClass::UserText],
848 summarized_at: UNIX_EPOCH + Duration::from_secs(9),
849 };
850
851 let summary = build_conversation_summary_capture(
852 &project("project-a"),
853 source,
854 4,
855 &"s".repeat(SUMMARY_CAPTURE_MAX_BYTES + 1),
856 agent_core::compaction::RedactionPolicy::PolicyExclusions,
857 RetentionClass::Standard,
858 )
859 .unwrap();
860
861 assert_eq!(summary.source_session_id.as_str(), "session-previous");
862 assert_eq!(summary.source_turn_range_digest, source_digest);
863 assert_eq!(summary.first_turn_ordinal, 2);
864 assert_eq!(summary.last_turn_ordinal, 5);
865 assert_eq!(
866 summary.redaction_policy,
867 agent_core::compaction::RedactionPolicy::PolicyExclusions
868 );
869 assert_eq!(summary.summary.retained_bytes, SUMMARY_CAPTURE_MAX_BYTES);
870 assert!(summary.summary.truncated);
871 }
872
873 #[test]
874 fn carries_times_ordinal_disclosure_retention_sensitivity_and_compaction() {
875 let mut items = required_items();
876 items[0].disclosure = DisclosureClass::LocalOnly;
877 items[1].sensitivity = Sensitivity::Sensitive;
878 let mut history = terminal(items);
879 let compaction_digest = MessageRangeDigest([7; 32]);
880 history.compaction = Some(CompactionSource {
881 schema: CompactionSchemaVersion::V1,
882 project_id: project("project-a"),
883 source_session_id: SessionId::parse("session-previous").unwrap(),
884 first_turn_ordinal: 1,
885 last_turn_ordinal: 6,
886 source_digest: compaction_digest,
887 summary_origin: CompactionSummaryOrigin::LocalOnly,
888 prompt_stack_digest: PromptStackDigest::from_bytes([8; 32]),
889 redaction: RedactionPolicy::HostRedacted,
890 content_classes: vec![agent_core::compaction::ContentClass::UserText],
891 summarized_at: UNIX_EPOCH + Duration::from_secs(9),
892 });
893
894 let capture = build(history).unwrap();
895
896 assert_eq!(capture.turn_ordinal, 7);
897 assert_eq!(capture.started_at, UNIX_EPOCH + Duration::from_secs(10));
898 assert_eq!(capture.completed_at, UNIX_EPOCH + Duration::from_secs(20));
899 assert_eq!(capture.sensitivity, Sensitivity::Sensitive);
900 assert_eq!(capture.retention, RetentionClass::Standard);
901 assert_eq!(
902 capture.disclosure.classes,
903 vec![DisclosureClass::ModelVisible, DisclosureClass::LocalOnly]
904 );
905 assert_eq!(capture.compaction.unwrap().source_digest, compaction_digest);
906 }
907}