fix: preserve original session coordinates through capture selection
This commit is contained in:
@@ -237,6 +237,7 @@ pub struct SessionEntryEvidence {
|
|||||||
pub segment_id: String,
|
pub segment_id: String,
|
||||||
pub entry_ref: SessionEntryRef,
|
pub entry_ref: SessionEntryRef,
|
||||||
pub origin: SessionEntryProvenance,
|
pub origin: SessionEntryProvenance,
|
||||||
|
/// Inclusive original-source positions in `segment_id`, not view/UI order.
|
||||||
pub entry_range: [u64; 2],
|
pub entry_range: [u64; 2],
|
||||||
pub kind: ReferenceKind,
|
pub kind: ReferenceKind,
|
||||||
pub tool_part: Option<ToolPart>,
|
pub tool_part: Option<ToolPart>,
|
||||||
@@ -259,6 +260,7 @@ pub struct SessionCapture {
|
|||||||
session_id: String,
|
session_id: String,
|
||||||
segment_id: String,
|
segment_id: String,
|
||||||
capture_id: String,
|
capture_id: String,
|
||||||
|
view_entry_count: u64,
|
||||||
entries: Arc<Vec<CapturedHistoryEntry>>,
|
entries: Arc<Vec<CapturedHistoryEntry>>,
|
||||||
overview: Vec<OverviewItem>,
|
overview: Vec<OverviewItem>,
|
||||||
index: Vec<ReferenceEntry>,
|
index: Vec<ReferenceEntry>,
|
||||||
@@ -271,6 +273,7 @@ impl SessionCapture {
|
|||||||
snapshot: SessionSnapshot,
|
snapshot: SessionSnapshot,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let segment_id = segment_id.into();
|
let segment_id = segment_id.into();
|
||||||
|
let view_entry_count = snapshot.entries.len() as u64;
|
||||||
let entries = snapshot
|
let entries = snapshot
|
||||||
.entries
|
.entries
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -341,7 +344,7 @@ impl SessionCapture {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Self::from_projected(session_id.into(), segment_id, entries)
|
Self::from_projected(session_id.into(), segment_id, view_entry_count, entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new(
|
pub fn new(
|
||||||
@@ -350,6 +353,7 @@ impl SessionCapture {
|
|||||||
items: Vec<Item>,
|
items: Vec<Item>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let segment_id = segment_id.into();
|
let segment_id = segment_id.into();
|
||||||
|
let view_entry_count = items.len() as u64;
|
||||||
let entries = items
|
let entries = items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
@@ -371,15 +375,19 @@ impl SessionCapture {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Self::from_projected(session_id.into(), segment_id, entries)
|
Self::from_projected(session_id.into(), segment_id, view_entry_count, entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_history_entries(
|
/// Project a complete Host-fixed history whose source positions start at zero.
|
||||||
|
/// Select a suffix with `select_order_range` on the resulting value instead
|
||||||
|
/// of passing a rebased history vector: annotations do not carry source positions.
|
||||||
|
pub fn from_complete_history(
|
||||||
session_id: impl Into<String>,
|
session_id: impl Into<String>,
|
||||||
segment_id: impl Into<String>,
|
segment_id: impl Into<String>,
|
||||||
entries: Vec<HistoryEntry<SessionHistoryMetadata>>,
|
entries: Vec<HistoryEntry<SessionHistoryMetadata>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let segment_id = segment_id.into();
|
let segment_id = segment_id.into();
|
||||||
|
let view_entry_count = entries.len() as u64;
|
||||||
let entries = entries
|
let entries = entries
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
@@ -432,14 +440,20 @@ impl SessionCapture {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Self::from_projected(session_id.into(), segment_id, entries)
|
Self::from_projected(session_id.into(), segment_id, view_entry_count, entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn from_projected(session_id: String, segment_id: String, dtos: Vec<CaptureEntryDto>) -> Self {
|
fn from_projected(
|
||||||
|
session_id: String,
|
||||||
|
segment_id: String,
|
||||||
|
view_entry_count: u64,
|
||||||
|
dtos: Vec<CaptureEntryDto>,
|
||||||
|
) -> Self {
|
||||||
let dto = CaptureDto {
|
let dto = CaptureDto {
|
||||||
version: CAPTURE_VERSION,
|
version: CAPTURE_VERSION,
|
||||||
session_id: session_id.clone(),
|
session_id: session_id.clone(),
|
||||||
segment_id: segment_id.clone(),
|
segment_id: segment_id.clone(),
|
||||||
|
view_entry_count,
|
||||||
entries: dtos,
|
entries: dtos,
|
||||||
};
|
};
|
||||||
let capture_id = dto.fingerprint();
|
let capture_id = dto.fingerprint();
|
||||||
@@ -589,6 +603,7 @@ impl SessionCapture {
|
|||||||
session_id,
|
session_id,
|
||||||
segment_id,
|
segment_id,
|
||||||
capture_id,
|
capture_id,
|
||||||
|
view_entry_count,
|
||||||
entries: Arc::new(entries),
|
entries: Arc::new(entries),
|
||||||
overview,
|
overview,
|
||||||
index,
|
index,
|
||||||
@@ -604,7 +619,8 @@ impl SessionCapture {
|
|||||||
&self.overview
|
&self.overview
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn source_index_for_ref(&self, reference: &SessionEntryRef) -> Option<u64> {
|
/// Capture-local UI position, not the original source position used by evidence.
|
||||||
|
pub fn view_order_for_ref(&self, reference: &SessionEntryRef) -> Option<u64> {
|
||||||
self.index
|
self.index
|
||||||
.iter()
|
.iter()
|
||||||
.find(|entry| entry.id == *reference)
|
.find(|entry| entry.id == *reference)
|
||||||
@@ -624,12 +640,12 @@ impl SessionCapture {
|
|||||||
let min_entry_index = options
|
let min_entry_index = options
|
||||||
.from
|
.from
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|reference| self.source_index_for_ref(reference))
|
.and_then(|reference| self.view_order_for_ref(reference))
|
||||||
.unwrap_or_else(|| options.min_entry_index.unwrap_or(0));
|
.unwrap_or_else(|| options.min_entry_index.unwrap_or(0));
|
||||||
let max_entry_index = options
|
let max_entry_index = options
|
||||||
.through
|
.through
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|reference| self.source_index_for_ref(reference))
|
.and_then(|reference| self.view_order_for_ref(reference))
|
||||||
.unwrap_or(u64::MAX);
|
.unwrap_or(u64::MAX);
|
||||||
let mut skipped = 0usize;
|
let mut skipped = 0usize;
|
||||||
let mut hits = Vec::new();
|
let mut hits = Vec::new();
|
||||||
@@ -787,24 +803,16 @@ impl SessionCapture {
|
|||||||
if entry.use_policy.is_some_and(|p| !p.memory_evidence) {
|
if entry.use_policy.is_some_and(|p| !p.memory_evidence) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let item = &self
|
let captured = self
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
.find(|captured| captured.dto.order == entry.entry_range[0])?
|
.find(|captured| captured.dto.order == entry.entry_range[0])?;
|
||||||
.item;
|
let excerpt = render_item(&captured.item, entry, ReadDetail::Compact, 2 * 1024);
|
||||||
let excerpt = render_item(item, entry, ReadDetail::Compact, 2 * 1024);
|
|
||||||
Some(SessionEntryEvidence {
|
Some(SessionEntryEvidence {
|
||||||
segment_id: self
|
segment_id: captured.dto.source.segment_id.clone(),
|
||||||
.entries
|
|
||||||
.iter()
|
|
||||||
.find(|captured| captured.dto.order == entry.entry_range[0])?
|
|
||||||
.dto
|
|
||||||
.source
|
|
||||||
.segment_id
|
|
||||||
.clone(),
|
|
||||||
entry_ref: entry.id.clone(),
|
entry_ref: entry.id.clone(),
|
||||||
origin: entry.origin.clone(),
|
origin: entry.origin.clone(),
|
||||||
entry_range: entry.entry_range,
|
entry_range: [captured.dto.source.entry_index; 2],
|
||||||
kind: entry.kind,
|
kind: entry.kind,
|
||||||
tool_part: entry.tool_part,
|
tool_part: entry.tool_part,
|
||||||
label: entry.label.clone(),
|
label: entry.label.clone(),
|
||||||
|
|||||||
@@ -307,6 +307,8 @@ pub struct CaptureDto {
|
|||||||
pub version: u32,
|
pub version: u32,
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
pub segment_id: String,
|
pub segment_id: String,
|
||||||
|
/// Extent of view order, including positions omitted by public projection.
|
||||||
|
pub view_entry_count: u64,
|
||||||
pub entries: Vec<CaptureEntryDto>,
|
pub entries: Vec<CaptureEntryDto>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,6 +411,9 @@ impl CaptureDto {
|
|||||||
{
|
{
|
||||||
return Err(CaptureError::Invalid("entry identity"));
|
return Err(CaptureError::Invalid("entry identity"));
|
||||||
}
|
}
|
||||||
|
if entry.order >= self.view_entry_count {
|
||||||
|
return Err(CaptureError::Invalid("entry outside view extent"));
|
||||||
|
}
|
||||||
if previous.is_some_and(|order| order >= entry.order) {
|
if previous.is_some_and(|order| order >= entry.order) {
|
||||||
return Err(CaptureError::Invalid("entry order"));
|
return Err(CaptureError::Invalid("entry order"));
|
||||||
}
|
}
|
||||||
@@ -598,6 +603,7 @@ impl SessionCapture {
|
|||||||
version: CAPTURE_VERSION,
|
version: CAPTURE_VERSION,
|
||||||
session_id: self.session_id.clone(),
|
session_id: self.session_id.clone(),
|
||||||
segment_id: self.segment_id.clone(),
|
segment_id: self.segment_id.clone(),
|
||||||
|
view_entry_count: self.view_entry_count,
|
||||||
entries: self.entries.iter().map(|e| e.dto.clone()).collect(),
|
entries: self.entries.iter().map(|e| e.dto.clone()).collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -607,6 +613,7 @@ impl SessionCapture {
|
|||||||
Ok(Self::from_projected(
|
Ok(Self::from_projected(
|
||||||
dto.session_id,
|
dto.session_id,
|
||||||
dto.segment_id,
|
dto.segment_id,
|
||||||
|
dto.view_entry_count,
|
||||||
dto.entries,
|
dto.entries,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -618,11 +625,48 @@ impl SessionCapture {
|
|||||||
Self::try_from_dto(CaptureDto::decode(bytes, limits)?, limits)
|
Self::try_from_dto(CaptureDto::decode(bytes, limits)?, limits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Select fixed view positions. UI order is rebased, while every original
|
||||||
|
/// source identity/position, body, provenance and policy stays unchanged.
|
||||||
|
pub fn select_order_range(&self, range: std::ops::Range<u64>) -> Result<Self, CaptureError> {
|
||||||
|
if range.start > range.end || range.end > self.view_entry_count {
|
||||||
|
return Err(CaptureError::InvalidRange);
|
||||||
|
}
|
||||||
|
let entries = self
|
||||||
|
.entries()
|
||||||
|
.filter(|entry| range.contains(&entry.order))
|
||||||
|
.map(|entry| {
|
||||||
|
let mut entry = entry.clone();
|
||||||
|
entry.order -= range.start;
|
||||||
|
entry
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Self::from_projected(
|
||||||
|
self.session_id.clone(),
|
||||||
|
self.segment_id.clone(),
|
||||||
|
range.end - range.start,
|
||||||
|
entries,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host-owned target selection may exclude entries without renumbering either
|
||||||
|
/// the fixed view or its original sources. No source I/O or business policy is applied.
|
||||||
|
pub fn retain_entries(&self, mut include: impl FnMut(&CaptureEntryDto) -> bool) -> Self {
|
||||||
|
Self::from_projected(
|
||||||
|
self.session_id.clone(),
|
||||||
|
self.segment_id.clone(),
|
||||||
|
self.view_entry_count,
|
||||||
|
self.entries()
|
||||||
|
.filter(|entry| include(entry))
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn scoped_ref(
|
pub fn scoped_ref(
|
||||||
&self,
|
&self,
|
||||||
reference: &SessionEntryRef,
|
reference: &SessionEntryRef,
|
||||||
) -> Result<ScopedSessionEntryRef, CaptureError> {
|
) -> Result<ScopedSessionEntryRef, CaptureError> {
|
||||||
if self.source_index_for_ref(reference).is_none() {
|
if self.view_order_for_ref(reference).is_none() {
|
||||||
return Err(CaptureError::UnknownReference);
|
return Err(CaptureError::UnknownReference);
|
||||||
}
|
}
|
||||||
Ok(ScopedSessionEntryRef {
|
Ok(ScopedSessionEntryRef {
|
||||||
@@ -679,7 +723,7 @@ impl SessionCapture {
|
|||||||
.from
|
.from
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
self.source_index_for_ref(r)
|
self.view_order_for_ref(r)
|
||||||
.ok_or(CaptureError::UnknownReference)
|
.ok_or(CaptureError::UnknownReference)
|
||||||
})
|
})
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
@@ -687,7 +731,7 @@ impl SessionCapture {
|
|||||||
.through
|
.through
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
self.source_index_for_ref(r)
|
self.view_order_for_ref(r)
|
||||||
.ok_or(CaptureError::UnknownReference)
|
.ok_or(CaptureError::UnknownReference)
|
||||||
})
|
})
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
@@ -710,7 +754,7 @@ impl SessionCapture {
|
|||||||
match selector {
|
match selector {
|
||||||
ReadSelector::Id(id) => {
|
ReadSelector::Id(id) => {
|
||||||
let reference = SessionEntryRef::parse(id).ok_or(CaptureError::UnknownReference)?;
|
let reference = SessionEntryRef::parse(id).ok_or(CaptureError::UnknownReference)?;
|
||||||
self.source_index_for_ref(&reference)
|
self.view_order_for_ref(&reference)
|
||||||
.ok_or(CaptureError::UnknownReference)?;
|
.ok_or(CaptureError::UnknownReference)?;
|
||||||
}
|
}
|
||||||
ReadSelector::EntryRange([start, end]) => {
|
ReadSelector::EntryRange([start, end]) => {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ fn public_consumer_roundtrip_retains_body_original_source_order_origin_and_indep
|
|||||||
Some(HistoryInputMetadata::new(usage(false, false, true), None, None).unwrap()),
|
Some(HistoryInputMetadata::new(usage(false, false, true), None, None).unwrap()),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
let capture = SessionCapture::from_history_entries("synthetic-session", "selected", entries);
|
let capture = SessionCapture::from_complete_history("synthetic-session", "selected", entries);
|
||||||
let mut dto = capture.to_dto();
|
let mut dto = capture.to_dto();
|
||||||
// The checked boundary also accepts entries retained from different original segments.
|
// The checked boundary also accepts entries retained from different original segments.
|
||||||
dto.entries[0].source.segment_id = "original-a".into();
|
dto.entries[0].source.segment_id = "original-a".into();
|
||||||
@@ -115,6 +115,7 @@ fn public_consumer_roundtrip_retains_body_original_source_order_origin_and_indep
|
|||||||
assert!(restored.evidence_for("Erecording").is_none());
|
assert!(restored.evidence_for("Erecording").is_none());
|
||||||
let evidence = restored.evidence_for("Ememory").unwrap();
|
let evidence = restored.evidence_for("Ememory").unwrap();
|
||||||
assert_eq!(evidence.segment_id, "original-b");
|
assert_eq!(evidence.segment_id, "original-b");
|
||||||
|
assert_eq!(evidence.entry_range, [3, 3]);
|
||||||
assert!(evidence.excerpt.contains("memory-only"));
|
assert!(evidence.excerpt.contains("memory-only"));
|
||||||
let recorded = restored
|
let recorded = restored
|
||||||
.slice(CapturePurpose::Recording, None, 10, 16_000)
|
.slice(CapturePurpose::Recording, None, 10, 16_000)
|
||||||
@@ -159,7 +160,7 @@ fn common_data_drops_reasoning_raw_system_and_private_payload_even_when_unindexe
|
|||||||
HistoryInputMetadata::new(usage(true, true, true), Some(payload.clone()), Some(public))
|
HistoryInputMetadata::new(usage(true, true, true), Some(payload.clone()), Some(public))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let private = HistoryInputMetadata::new(usage(true, true, true), Some(payload), None).unwrap();
|
let private = HistoryInputMetadata::new(usage(true, true, true), Some(payload), None).unwrap();
|
||||||
let capture = SessionCapture::from_history_entries(
|
let capture = SessionCapture::from_complete_history(
|
||||||
"s",
|
"s",
|
||||||
"segment",
|
"segment",
|
||||||
vec![
|
vec![
|
||||||
@@ -195,7 +196,7 @@ fn common_data_drops_reasoning_raw_system_and_private_payload_even_when_unindexe
|
|||||||
// Filtered entries do not renumber the public source positions.
|
// Filtered entries do not renumber the public source positions.
|
||||||
assert_eq!(capture.to_dto().entries[0].order, 3);
|
assert_eq!(capture.to_dto().entries[0].order, 3);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
capture.source_index_for_ref(&SessionEntryRef::parse("Eordinary").unwrap()),
|
capture.view_order_for_ref(&SessionEntryRef::parse("Eordinary").unwrap()),
|
||||||
Some(4)
|
Some(4)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -318,6 +319,7 @@ fn malformed_dtos_and_oversized_representations_are_rejected() {
|
|||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
let mut reversed = capture.to_dto();
|
let mut reversed = capture.to_dto();
|
||||||
|
reversed.view_entry_count = 2;
|
||||||
let mut second = reversed.entries[0].clone();
|
let mut second = reversed.entries[0].clone();
|
||||||
second.entry_id = "second".into();
|
second.entry_id = "second".into();
|
||||||
second.source.entry_index = 4;
|
second.source.entry_index = 4;
|
||||||
@@ -325,6 +327,16 @@ fn malformed_dtos_and_oversized_representations_are_rejected() {
|
|||||||
reversed.entries[0].order = 1;
|
reversed.entries[0].order = 1;
|
||||||
reversed.entries.push(second);
|
reversed.entries.push(second);
|
||||||
assert!(SessionCapture::try_from_dto(reversed, CaptureLimits::default()).is_err());
|
assert!(SessionCapture::try_from_dto(reversed, CaptureLimits::default()).is_err());
|
||||||
|
let mut duplicate_source = capture.to_dto();
|
||||||
|
duplicate_source.view_entry_count = 2;
|
||||||
|
let mut second = duplicate_source.entries[0].clone();
|
||||||
|
second.entry_id = "distinct-id".into();
|
||||||
|
second.order = 1;
|
||||||
|
duplicate_source.entries.push(second);
|
||||||
|
assert!(matches!(
|
||||||
|
SessionCapture::try_from_dto(duplicate_source, CaptureLimits::default()),
|
||||||
|
Err(CaptureError::Invalid("duplicate source position"))
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -503,7 +515,7 @@ fn snapshot_immutability_and_scoped_operations_reject_foreign_or_unknown_refs_an
|
|||||||
2
|
2
|
||||||
);
|
);
|
||||||
let unknown = SessionEntryRef::parse("E99999999").unwrap();
|
let unknown = SessionEntryRef::parse("E99999999").unwrap();
|
||||||
assert_eq!(capture.source_index_for_ref(&unknown), None);
|
assert_eq!(capture.view_order_for_ref(&unknown), None);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
capture.scoped_ref(&unknown),
|
capture.scoped_ref(&unknown),
|
||||||
Err(CaptureError::UnknownReference)
|
Err(CaptureError::UnknownReference)
|
||||||
@@ -592,3 +604,108 @@ fn slices_preserve_empty_and_long_result_fields_under_item_and_metadata_budgets(
|
|||||||
Err(CaptureError::BudgetTooSmall)
|
Err(CaptureError::BudgetTooSmall)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn evidence_uses_original_segment_positions_after_roundtrip_and_view_selection() {
|
||||||
|
let full = SessionCapture::new(
|
||||||
|
"s",
|
||||||
|
"selected",
|
||||||
|
vec![
|
||||||
|
Item::user_message("first"),
|
||||||
|
Item::assistant_message("second"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let mut dto = full.to_dto();
|
||||||
|
dto.entries[0].source.segment_id = "original-a".into();
|
||||||
|
dto.entries[0].source.entry_index = 80;
|
||||||
|
dto.entries[1].source.segment_id = "original-b".into();
|
||||||
|
dto.entries[1].source.entry_index = 3;
|
||||||
|
let encoded = dto.encode(CaptureLimits::default()).unwrap();
|
||||||
|
let restored = SessionCapture::decode(&encoded, CaptureLimits::default()).unwrap();
|
||||||
|
for (id, segment, index, order) in [
|
||||||
|
("E00000000", "original-a", 80, 0),
|
||||||
|
("E00000001", "original-b", 3, 1),
|
||||||
|
] {
|
||||||
|
let evidence = restored.evidence_for(id).unwrap();
|
||||||
|
assert_eq!(evidence.segment_id, segment);
|
||||||
|
assert_eq!(evidence.entry_range, [index; 2]);
|
||||||
|
let read = restored.read(ReadSelector::Id(id), ReadOptions::default());
|
||||||
|
assert_eq!(read.entries[0].entry_range, [order; 2]);
|
||||||
|
}
|
||||||
|
let selected = restored.select_order_range(1..2).unwrap();
|
||||||
|
assert_eq!(selected.overview()[0].entry_range, [0, 0]);
|
||||||
|
let evidence = selected.evidence_for("E00000001").unwrap();
|
||||||
|
assert_eq!(evidence.segment_id, "original-b");
|
||||||
|
assert_eq!(evidence.entry_range, [3, 3]);
|
||||||
|
let roundtrip = SessionCapture::decode(
|
||||||
|
&selected.encode(CaptureLimits::default()).unwrap(),
|
||||||
|
CaptureLimits::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(roundtrip.to_dto(), selected.to_dto());
|
||||||
|
assert_eq!(
|
||||||
|
roundtrip.evidence_for("E00000001").unwrap().entry_range,
|
||||||
|
[3, 3]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixed_history_selection_preserves_original_coordinates_and_checks_private_position_bounds() {
|
||||||
|
let history = vec![
|
||||||
|
history("old", Item::user_message("old"), None),
|
||||||
|
history("hidden", Item::reasoning("PRIVATE"), None),
|
||||||
|
history("selected", Item::user_message("selected"), None),
|
||||||
|
history("private-tail", Item::system_message("PRIVATE"), None),
|
||||||
|
];
|
||||||
|
let full = SessionCapture::from_complete_history("s", "seg", history);
|
||||||
|
let suffix = full.select_order_range(1..4).unwrap();
|
||||||
|
let original = full
|
||||||
|
.entries()
|
||||||
|
.find(|entry| entry.entry_id == "selected")
|
||||||
|
.unwrap();
|
||||||
|
let selected = suffix.entries().next().unwrap();
|
||||||
|
assert_eq!(selected.entry_id, original.entry_id);
|
||||||
|
assert_eq!(selected.source, original.source);
|
||||||
|
assert_eq!(selected.body, original.body);
|
||||||
|
assert_eq!(selected.policy, original.policy);
|
||||||
|
assert!(suffix.evidence_for("Eold").is_none());
|
||||||
|
assert_eq!(suffix.search(&SearchOptions::default()).len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
suffix.view_order_for_ref(&SessionEntryRef::parse("Eselected").unwrap()),
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
assert_eq!(selected.source.entry_index, 2);
|
||||||
|
assert_eq!(selected.order, 1);
|
||||||
|
assert_eq!(suffix.to_dto().view_entry_count, 3);
|
||||||
|
assert_eq!(
|
||||||
|
suffix.evidence_for("Eselected").unwrap().entry_range,
|
||||||
|
[2, 2]
|
||||||
|
);
|
||||||
|
let nested = suffix.select_order_range(1..2).unwrap();
|
||||||
|
assert_eq!(nested.entries().next().unwrap().order, 0);
|
||||||
|
assert_eq!(nested.entries().next().unwrap().source, original.source);
|
||||||
|
let restored = SessionCapture::decode(
|
||||||
|
&suffix.encode(CaptureLimits::default()).unwrap(),
|
||||||
|
CaptureLimits::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(restored.to_dto(), suffix.to_dto());
|
||||||
|
assert!(full.select_order_range(4..5).is_err());
|
||||||
|
let reversed = std::ops::Range { start: 3, end: 2 };
|
||||||
|
assert!(full.select_order_range(reversed).is_err());
|
||||||
|
let hidden_only = full.select_order_range(3..4).unwrap();
|
||||||
|
assert_eq!(hidden_only.entries().len(), 0);
|
||||||
|
assert_eq!(hidden_only.to_dto().view_entry_count, 1);
|
||||||
|
let mut invalid = full.to_dto();
|
||||||
|
invalid.view_entry_count = 2; // the selected entry's view order is outside this extent
|
||||||
|
assert!(SessionCapture::try_from_dto(invalid, CaptureLimits::default()).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
full.retain_entries(|entry| entry.entry_id == "selected")
|
||||||
|
.entries()
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.source,
|
||||||
|
original.source
|
||||||
|
);
|
||||||
|
assert_eq!(full.to_dto().view_entry_count, 4);
|
||||||
|
}
|
||||||
|
|||||||
@@ -474,7 +474,7 @@ where
|
|||||||
self.segment_id,
|
self.segment_id,
|
||||||
&entries,
|
&entries,
|
||||||
)?;
|
)?;
|
||||||
Ok(SessionCapture::from_history_entries(
|
Ok(SessionCapture::from_complete_history(
|
||||||
self.session_id.to_string(),
|
self.session_id.to_string(),
|
||||||
self.segment_id.to_string(),
|
self.segment_id.to_string(),
|
||||||
history,
|
history,
|
||||||
|
|||||||
@@ -456,11 +456,13 @@ impl MemoryLifecycleTask {
|
|||||||
.await;
|
.await;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let view = SessionCapture::from_history_entries(
|
let view = SessionCapture::from_complete_history(
|
||||||
capture.session_id.clone(),
|
capture.session_id.clone(),
|
||||||
capture.segment_id.clone(),
|
capture.segment_id.clone(),
|
||||||
capture.history[history_start..history_end].to_vec(),
|
capture.history.clone(),
|
||||||
);
|
)
|
||||||
|
.select_order_range(history_start as u64..history_end as u64)
|
||||||
|
.map_err(hook_internal)?;
|
||||||
let start_entry = pointer
|
let start_entry = pointer
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|pointer| pointer.processed_through_entry + 1)
|
.map(|pointer| pointer.processed_through_entry + 1)
|
||||||
@@ -1557,7 +1559,7 @@ permission = "write"
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn run_committed_background_task_stages_non_empty_extraction_and_commits_pointer() {
|
async fn run_committed_background_task_stages_non_empty_extraction_and_commits_pointer() {
|
||||||
let source = capture(2, 250);
|
let source = capture(2, 250);
|
||||||
let entry_ref = SessionCapture::from_history_entries(
|
let entry_ref = SessionCapture::from_complete_history(
|
||||||
source.session_id.clone(),
|
source.session_id.clone(),
|
||||||
source.segment_id.clone(),
|
source.segment_id.clone(),
|
||||||
source.history.clone(),
|
source.history.clone(),
|
||||||
@@ -1610,10 +1612,95 @@ permission = "write"
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Real background extraction -> common fixed selection -> StageMemoryCandidate
|
||||||
|
// -> typed backend operation and pointer write. Only model/backend transport
|
||||||
|
// are replaced; the second extraction uses the first run's saved pointer.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn second_extraction_preserves_original_evidence_positions_after_nonzero_pointer() {
|
||||||
|
let first = capture(2, 250);
|
||||||
|
let mut second = capture(4, 750);
|
||||||
|
second.history[..2].clone_from_slice(&first.history);
|
||||||
|
second
|
||||||
|
.usage_history
|
||||||
|
.insert(0, first.usage_history[0].clone());
|
||||||
|
let selected_ref =
|
||||||
|
crate::SessionEntryRef::from_history_entry_id(&second.history[2].annotation.entry_id)
|
||||||
|
.to_string();
|
||||||
|
let extension_writes = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let workspace_client = Arc::new(RecordingWorkspaceClient::default());
|
||||||
|
let (event_tx, _) = broadcast::channel(64);
|
||||||
|
run_background_task(test_task(
|
||||||
|
first,
|
||||||
|
Box::new(ScriptClient::new(vec![
|
||||||
|
finish_empty_events("first-empty"),
|
||||||
|
completed_events(),
|
||||||
|
])),
|
||||||
|
extension_writes.clone(),
|
||||||
|
event_tx.clone(),
|
||||||
|
workspace_client.clone(),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
{
|
||||||
|
let writes = extension_writes.lock().unwrap();
|
||||||
|
assert_eq!(writes.len(), 1);
|
||||||
|
let pointer: memory::ExtractPointerPayload =
|
||||||
|
serde_json::from_value(writes[0].2.clone()).unwrap();
|
||||||
|
assert_eq!(pointer.processed_through_history_len, 2);
|
||||||
|
second
|
||||||
|
.extensions
|
||||||
|
.push((writes[0].1.clone(), writes[0].2.clone()));
|
||||||
|
}
|
||||||
|
run_background_task(test_task(
|
||||||
|
second,
|
||||||
|
Box::new(ScriptClient::new(vec![
|
||||||
|
stage_candidate_events("second-stage", &selected_ref),
|
||||||
|
finish_events("second-finish", 1),
|
||||||
|
completed_events(),
|
||||||
|
])),
|
||||||
|
extension_writes.clone(),
|
||||||
|
event_tx,
|
||||||
|
workspace_client.clone(),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
let operations = workspace_client
|
||||||
|
.requests
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|request| {
|
||||||
|
request.body.as_deref().and_then(|body| {
|
||||||
|
serde_json::from_str::<memory::backend::MemoryBackendOperation>(body).ok()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.filter_map(|operation| match operation {
|
||||||
|
memory::backend::MemoryBackendOperation::StageCandidate(operation) => {
|
||||||
|
Some(operation)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(operations.len(), 1);
|
||||||
|
let operation = &operations[0];
|
||||||
|
assert_eq!(operation.source.range, [2, 3]);
|
||||||
|
assert_eq!(operation.evidence[0].id, selected_ref);
|
||||||
|
assert_eq!(operation.evidence[0].entry_range, Some([2, 2]));
|
||||||
|
assert_eq!(
|
||||||
|
operation.source_refs[0].segment_id.as_deref(),
|
||||||
|
Some("segment-1")
|
||||||
|
);
|
||||||
|
assert_eq!(operation.source_refs[0].entry_range, Some([2, 2]));
|
||||||
|
let writes = extension_writes.lock().unwrap();
|
||||||
|
assert_eq!(writes.len(), 2);
|
||||||
|
let pointer: memory::ExtractPointerPayload =
|
||||||
|
serde_json::from_value(writes[1].2.clone()).unwrap();
|
||||||
|
assert_eq!(pointer.processed_through_history_len, 4);
|
||||||
|
assert_eq!(pointer.processed_through_entry, 3);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn subjektiv_extraction_stages_once_and_commits_its_independent_pointer() {
|
async fn subjektiv_extraction_stages_once_and_commits_its_independent_pointer() {
|
||||||
let source = capture(2, 250);
|
let source = capture(2, 250);
|
||||||
let entry_ref = SessionCapture::from_history_entries(
|
let entry_ref = SessionCapture::from_complete_history(
|
||||||
source.session_id.clone(),
|
source.session_id.clone(),
|
||||||
source.segment_id.clone(),
|
source.segment_id.clone(),
|
||||||
source.history.clone(),
|
source.history.clone(),
|
||||||
@@ -1722,7 +1809,7 @@ permission = "write"
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn subjektiv_candidate_remains_staged_when_later_extraction_fails_without_pointer() {
|
async fn subjektiv_candidate_remains_staged_when_later_extraction_fails_without_pointer() {
|
||||||
let source = capture(2, 250);
|
let source = capture(2, 250);
|
||||||
let entry_ref = SessionCapture::from_history_entries(
|
let entry_ref = SessionCapture::from_complete_history(
|
||||||
source.session_id.clone(),
|
source.session_id.clone(),
|
||||||
source.segment_id.clone(),
|
source.segment_id.clone(),
|
||||||
source.history.clone(),
|
source.history.clone(),
|
||||||
|
|||||||
@@ -639,4 +639,53 @@ mod tests {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(format!("{error:?}").contains("unknown SessionEntryRef"));
|
assert!(format!("{error:?}").contains("unknown SessionEntryRef"));
|
||||||
}
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn staging_roundtripped_mixed_sources_uses_original_evidence_ranges_not_view_order() {
|
||||||
|
let client = Arc::new(RecordingSubjektivClient::default());
|
||||||
|
let mut dto = SessionCapture::new(
|
||||||
|
"session",
|
||||||
|
"view",
|
||||||
|
vec![
|
||||||
|
Item::user_message("first"),
|
||||||
|
Item::assistant_message("second"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.to_dto();
|
||||||
|
dto.entries[0].source.segment_id = "original-a".into();
|
||||||
|
dto.entries[0].source.entry_index = 80;
|
||||||
|
dto.entries[1].source.segment_id = "original-b".into();
|
||||||
|
dto.entries[1].source.entry_index = 3;
|
||||||
|
let limits = crate::session_capture::CaptureLimits::default();
|
||||||
|
let view = SessionCapture::decode(&dto.encode(limits).unwrap(), limits).unwrap();
|
||||||
|
let state = MemoryStagingOutputState::new_subjektiv(
|
||||||
|
view,
|
||||||
|
crate::subjektiv::test_connection(client.clone()),
|
||||||
|
SourceRef {
|
||||||
|
segment_id: "view".into(),
|
||||||
|
range: [0, 1],
|
||||||
|
},
|
||||||
|
"run".into(),
|
||||||
|
);
|
||||||
|
StageMemoryCandidateTool { state }.execute(
|
||||||
|
r#"{"kind":"decision","claim":"claim","why_useful":"useful","entry_refs":["E00000000","E00000001"]}"#,
|
||||||
|
agen::tool::ToolExecutionContext::direct(),
|
||||||
|
).await.unwrap();
|
||||||
|
let requests = client.requests.lock().unwrap();
|
||||||
|
let request: server_api::SubjektivStageCandidateRequest =
|
||||||
|
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
|
||||||
|
for (index, segment, position) in [(0, "original-a", 80), (1, "original-b", 3)] {
|
||||||
|
assert_eq!(
|
||||||
|
request.operation.evidence[index].entry_range,
|
||||||
|
Some([position; 2])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request.operation.source_refs[index].segment_id.as_deref(),
|
||||||
|
Some(segment)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request.operation.source_refs[index].entry_range,
|
||||||
|
Some([position; 2])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -571,7 +571,7 @@ impl SubjektivMemoryState {
|
|||||||
.capture()
|
.capture()
|
||||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||||
let session_id = capture.session_id.clone();
|
let session_id = capture.session_id.clone();
|
||||||
let view = SessionCapture::from_history_entries(
|
let view = SessionCapture::from_complete_history(
|
||||||
capture.session_id.clone(),
|
capture.session_id.clone(),
|
||||||
capture.segment_id,
|
capture.segment_id,
|
||||||
capture.history,
|
capture.history,
|
||||||
@@ -680,7 +680,7 @@ fn replay_pending_committed_memory_with_policy(
|
|||||||
.capture
|
.capture
|
||||||
.capture()
|
.capture()
|
||||||
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?;
|
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?;
|
||||||
let view = SessionCapture::from_history_entries(
|
let view = SessionCapture::from_complete_history(
|
||||||
capture.session_id.clone(),
|
capture.session_id.clone(),
|
||||||
capture.segment_id.clone(),
|
capture.segment_id.clone(),
|
||||||
capture.history.clone(),
|
capture.history.clone(),
|
||||||
@@ -815,7 +815,7 @@ fn resolve_entry_refs(
|
|||||||
capture: &crate::feature::session::CommittedSessionCapture,
|
capture: &crate::feature::session::CommittedSessionCapture,
|
||||||
entry_refs: &[String],
|
entry_refs: &[String],
|
||||||
) -> Result<Vec<SessionEntryEvidence>, ToolError> {
|
) -> Result<Vec<SessionEntryEvidence>, ToolError> {
|
||||||
let view = SessionCapture::from_history_entries(
|
let view = SessionCapture::from_complete_history(
|
||||||
capture.session_id.clone(),
|
capture.session_id.clone(),
|
||||||
capture.segment_id.clone(),
|
capture.segment_id.clone(),
|
||||||
capture.history.clone(),
|
capture.history.clone(),
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
let capture = SessionCapture::from_history_entries("session", "segment", entries);
|
let capture = SessionCapture::from_complete_history("session", "segment", entries);
|
||||||
let overview = capture.overview();
|
let overview = capture.overview();
|
||||||
assert_eq!(overview.len(), 1);
|
assert_eq!(overview.len(), 1);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -54,8 +54,9 @@ mod tests {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
let expected_ref = SessionEntryRef::from_history_entry_id(&retained.annotation.entry_id);
|
let expected_ref = SessionEntryRef::from_history_entry_id(&retained.annotation.entry_id);
|
||||||
let before = SessionCapture::from_history_entries("session", "old", vec![retained.clone()]);
|
let before =
|
||||||
let after = SessionCapture::from_history_entries("session", "new", vec![retained]);
|
SessionCapture::from_complete_history("session", "old", vec![retained.clone()]);
|
||||||
|
let after = SessionCapture::from_complete_history("session", "new", vec![retained]);
|
||||||
assert_eq!(before.overview()[0].id, expected_ref);
|
assert_eq!(before.overview()[0].id, expected_ref);
|
||||||
assert_eq!(after.overview()[0].id, expected_ref);
|
assert_eq!(after.overview()[0].id, expected_ref);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -96,7 +97,7 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|entry| SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id))
|
.map(|entry| SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let capture = SessionCapture::from_history_entries("session", "segment", entries);
|
let capture = SessionCapture::from_complete_history("session", "segment", entries);
|
||||||
assert!(capture.overview().is_empty());
|
assert!(capture.overview().is_empty());
|
||||||
assert!(
|
assert!(
|
||||||
capture
|
capture
|
||||||
@@ -235,7 +236,7 @@ mod tests {
|
|||||||
let snapshot: SessionSnapshot =
|
let snapshot: SessionSnapshot =
|
||||||
serde_json::from_value(serde_json::to_value(snapshot).unwrap()).unwrap();
|
serde_json::from_value(serde_json::to_value(snapshot).unwrap()).unwrap();
|
||||||
for capture in [
|
for capture in [
|
||||||
SessionCapture::from_history_entries(
|
SessionCapture::from_complete_history(
|
||||||
session.to_string(),
|
session.to_string(),
|
||||||
segment.to_string(),
|
segment.to_string(),
|
||||||
restored,
|
restored,
|
||||||
@@ -369,7 +370,7 @@ mod tests {
|
|||||||
assert_eq!(entry.item, items[index]);
|
assert_eq!(entry.item, items[index]);
|
||||||
assert_eq!(entry.annotation, history[index].metadata);
|
assert_eq!(entry.annotation, history[index].metadata);
|
||||||
}
|
}
|
||||||
let capture = SessionCapture::from_history_entries(
|
let capture = SessionCapture::from_complete_history(
|
||||||
session.to_string(),
|
session.to_string(),
|
||||||
fork.to_string(),
|
fork.to_string(),
|
||||||
restored,
|
restored,
|
||||||
@@ -757,7 +758,7 @@ mod tests {
|
|||||||
let restored =
|
let restored =
|
||||||
crate::session_history::restore_history_entries(session, first, &log).unwrap();
|
crate::session_history::restore_history_entries(session, first, &log).unwrap();
|
||||||
let fixed =
|
let fixed =
|
||||||
SessionCapture::from_history_entries(session.to_string(), first.to_string(), restored);
|
SessionCapture::from_complete_history(session.to_string(), first.to_string(), restored);
|
||||||
let overview = fixed.overview()[0].clone();
|
let overview = fixed.overview()[0].clone();
|
||||||
let evidence = fixed.evidence_for(overview.id.as_str()).unwrap();
|
let evidence = fixed.evidence_for(overview.id.as_str()).unwrap();
|
||||||
let extract = render_extract_input(&fixed);
|
let extract = render_extract_input(&fixed);
|
||||||
|
|||||||
@@ -7338,6 +7338,20 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
.cloned()
|
.cloned()
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
project_compactor_capture_attachments(&mut entries_to_summarise);
|
project_compactor_capture_attachments(&mut entries_to_summarise);
|
||||||
|
// Project before Host selection so omitted summary/reference inputs never
|
||||||
|
// rebase the original source positions of the retained prefix entries.
|
||||||
|
let mut exploration_entries = history_entries[..retain_from].to_vec();
|
||||||
|
project_compactor_capture_attachments(&mut exploration_entries);
|
||||||
|
let summarised_ids = entries_to_summarise
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.annotation.entry_id.0.as_str())
|
||||||
|
.collect::<std::collections::HashSet<_>>();
|
||||||
|
let exploration_view = crate::session_capture::SessionCapture::from_complete_history(
|
||||||
|
self.session_id().to_string(),
|
||||||
|
self.segment_id().to_string(),
|
||||||
|
exploration_entries,
|
||||||
|
)
|
||||||
|
.retain_entries(|entry| summarised_ids.contains(entry.entry_id.as_str()));
|
||||||
let items_to_summarise = entries_to_summarise
|
let items_to_summarise = entries_to_summarise
|
||||||
.iter()
|
.iter()
|
||||||
.map(|entry| entry.item.clone())
|
.map(|entry| entry.item.clone())
|
||||||
@@ -7500,11 +7514,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
.with_module(
|
.with_module(
|
||||||
crate::feature::builtin::session_explore::SessionExploreFeature::new(
|
crate::feature::builtin::session_explore::SessionExploreFeature::new(
|
||||||
crate::feature::builtin::session_explore::SessionExploreState::new(
|
crate::feature::builtin::session_explore::SessionExploreState::new(
|
||||||
crate::session_capture::SessionCapture::from_history_entries(
|
exploration_view,
|
||||||
self.session_id().to_string(),
|
|
||||||
self.segment_id().to_string(),
|
|
||||||
entries_to_summarise.clone(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -17375,7 +17385,7 @@ permission = "write"
|
|||||||
producer: "arbitrary-context-provider".into()
|
producer: "arbitrary-context-provider".into()
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
let view = crate::session_capture::SessionCapture::from_history_entries(
|
let view = crate::session_capture::SessionCapture::from_complete_history(
|
||||||
"session", "startup", replay,
|
"session", "startup", replay,
|
||||||
);
|
);
|
||||||
let extract =
|
let extract =
|
||||||
|
|||||||
Reference in New Issue
Block a user