Skip to main content

agent_engine/runtime/
chat_capture.rs

1//! Typed, terminal-only continuous-memory chat capture (task C1).
2//!
3//! The builder accepts [`TerminalTurnHistory`], not streaming state. It rejects
4//! foreign-project provenance before inspecting content, filters forbidden
5//! classes, bounds every retained body, and derives a deterministic source
6//! digest / capture id from canonical source data.
7
8use 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/// Host classification of one canonical history item. Only the first three
34/// variants can produce captured content; all other variants are forbidden.
35#[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/// One item from canonical completed history, already classified by the host.
74#[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    /// Required for [`CaptureContentClass::ToolSummary`], ignored otherwise.
82    pub tool_name: Option<String>,
83}
84
85/// Canonical history after the engine has produced exactly one terminal
86/// [`TurnOutcome`]. Partial stream accumulators are a different type and cannot
87/// be passed to [`build_chat_turn_capture`].
88#[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/// First-class provenance for a compaction summary linked to this turn.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct CompactionSource {
198    pub schema: CompactionSchemaVersion,
199    pub project_id: ProjectId,
200    pub source_session_id: SessionId,
201    /// Inclusive source range. Compaction of legacy histories uses canonical
202    /// message positions because those messages have no durable turn ordinal;
203    /// the accompanying digest binds the exact ordered content.
204    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/// First-class compaction memory. It links to (and never substitutes for) the
215/// source range whose full provenance remains on the compacted session.
216#[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
242/// Build the bounded first-class memory emitted after a persisted compaction
243/// transition. The source digest and session are copied from the transition's
244/// typed provenance rather than recomputed from the post-compaction history.
245pub 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/// Exact disclosure classes present after filtering, in deterministic order.
292#[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
344/// Build one bounded capture from a typed terminal history snapshot.
345///
346/// Foreign provenance rejects the entire capture, even when attached to an
347/// otherwise forbidden item. Forbidden content is never copied into the
348/// capture. Secret sensitivity and `never_persist` disclosure fail closed.
349pub 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}