802 lines
25 KiB
Rust
802 lines
25 KiB
Rust
//! Workspace- and Memory-independent exploration of an immutable ordered session capture.
|
|
//!
|
|
//! Hosts construct a capture from committed session items. The capture excludes reasoning,
|
|
//! assigns append-stable `SessionEntryRef` values, and provides sparse overview, bounded
|
|
//! range/search, read, and generic evidence projections without granting mutation authority.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use llm_engine::{Item, Role};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
const DEFAULT_SEARCH_LIMIT: usize = 20;
|
|
const MAX_SEARCH_LIMIT: usize = 50;
|
|
const DEFAULT_READ_MAX_ITEMS: usize = 40;
|
|
const MAX_READ_MAX_ITEMS: usize = 80;
|
|
const DEFAULT_READ_MAX_BYTES: usize = 32 * 1024;
|
|
const OVERVIEW_ANCHOR_STRIDE: usize = 8;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(transparent)]
|
|
pub(crate) struct SessionEntryRef(String);
|
|
|
|
impl SessionEntryRef {
|
|
pub(crate) fn new(source_index: usize) -> Self {
|
|
Self(format!("E{source_index:08}"))
|
|
}
|
|
|
|
pub(crate) fn parse(value: &str) -> Option<Self> {
|
|
let reference = Self(value.to_string());
|
|
reference.source_index()?;
|
|
Some(reference)
|
|
}
|
|
|
|
pub(crate) fn as_str(&self) -> &str {
|
|
&self.0
|
|
}
|
|
|
|
pub(crate) fn source_index(&self) -> Option<u64> {
|
|
self.0.strip_prefix('E')?.parse().ok()
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for SessionEntryRef {
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
formatter.write_str(&self.0)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum ReferenceKind {
|
|
User,
|
|
Assistant,
|
|
Tool,
|
|
}
|
|
|
|
impl ReferenceKind {
|
|
pub(crate) fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::User => "user",
|
|
Self::Assistant => "assistant",
|
|
Self::Tool => "tool",
|
|
}
|
|
}
|
|
|
|
pub(crate) fn parse(value: &str) -> Option<Self> {
|
|
match value {
|
|
"user" => Some(Self::User),
|
|
"assistant" | "agent" => Some(Self::Assistant),
|
|
"tool" => Some(Self::Tool),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum ToolPart {
|
|
Input,
|
|
Output,
|
|
Both,
|
|
}
|
|
|
|
impl ToolPart {
|
|
pub(crate) fn parse(value: &str) -> Option<Self> {
|
|
match value {
|
|
"input" => Some(Self::Input),
|
|
"output" => Some(Self::Output),
|
|
"both" => Some(Self::Both),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn matches(self, actual: ToolPart) -> bool {
|
|
matches!(self, ToolPart::Both) || self == actual
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct OverviewItem {
|
|
pub id: SessionEntryRef,
|
|
pub entry_range: [u64; 2],
|
|
pub kind: ReferenceKind,
|
|
pub label: String,
|
|
pub text: String,
|
|
pub intervening_entries: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct ReferenceEntry {
|
|
pub id: SessionEntryRef,
|
|
pub entry_range: [u64; 2],
|
|
pub kind: ReferenceKind,
|
|
pub tool_part: Option<ToolPart>,
|
|
pub tool_name: Option<String>,
|
|
pub label: String,
|
|
pub summary: String,
|
|
search_text: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub(crate) struct SearchOptions {
|
|
pub query: String,
|
|
pub kind: Option<ReferenceKind>,
|
|
pub tool_part: Option<ToolPart>,
|
|
pub tool_name: Option<String>,
|
|
pub limit: Option<usize>,
|
|
pub min_entry_index: Option<u64>,
|
|
pub from: Option<SessionEntryRef>,
|
|
pub through: Option<SessionEntryRef>,
|
|
pub offset: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct SearchHit {
|
|
pub id: SessionEntryRef,
|
|
pub kind: ReferenceKind,
|
|
pub tool_part: Option<ToolPart>,
|
|
pub tool_name: Option<String>,
|
|
pub entry_range: [u64; 2],
|
|
pub label: String,
|
|
pub summary: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub(crate) enum ReadSelector<'a> {
|
|
Id(&'a str),
|
|
EntryRange([u64; 2]),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum ReadDetail {
|
|
Compact,
|
|
Full,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct ReadOptions {
|
|
pub include_tools: bool,
|
|
pub tool_part: ToolPart,
|
|
pub detail: ReadDetail,
|
|
pub max_items: usize,
|
|
pub max_bytes: usize,
|
|
}
|
|
|
|
impl Default for ReadOptions {
|
|
fn default() -> Self {
|
|
Self {
|
|
include_tools: true,
|
|
tool_part: ToolPart::Both,
|
|
detail: ReadDetail::Compact,
|
|
max_items: DEFAULT_READ_MAX_ITEMS,
|
|
max_bytes: DEFAULT_READ_MAX_BYTES,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct ReadEntry {
|
|
pub id: SessionEntryRef,
|
|
pub kind: ReferenceKind,
|
|
pub tool_part: Option<ToolPart>,
|
|
pub tool_name: Option<String>,
|
|
pub entry_range: [u64; 2],
|
|
pub label: String,
|
|
pub text: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct ReadResult {
|
|
pub entries: Vec<ReadEntry>,
|
|
pub truncated: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct SessionEntryEvidence {
|
|
pub segment_id: String,
|
|
pub entry_ref: SessionEntryRef,
|
|
pub entry_range: [u64; 2],
|
|
pub kind: ReferenceKind,
|
|
pub tool_part: Option<ToolPart>,
|
|
pub label: String,
|
|
pub summary: String,
|
|
pub excerpt: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct SessionCapture {
|
|
segment_id: String,
|
|
items: Arc<Vec<Item>>,
|
|
overview: Vec<OverviewItem>,
|
|
index: Vec<ReferenceEntry>,
|
|
}
|
|
|
|
impl SessionCapture {
|
|
pub(crate) fn new(segment_id: impl Into<String>, items: Vec<Item>) -> Self {
|
|
let segment_id = segment_id.into();
|
|
let items = Arc::new(items);
|
|
let mut overview = Vec::new();
|
|
let mut index = Vec::new();
|
|
|
|
for (idx, item) in items.iter().enumerate() {
|
|
let entry_range = [idx as u64, idx as u64];
|
|
match item {
|
|
Item::Message { role, content, .. } => {
|
|
let kind = match role {
|
|
Role::User => ReferenceKind::User,
|
|
Role::Assistant => ReferenceKind::Assistant,
|
|
Role::System => continue,
|
|
};
|
|
let text = content
|
|
.iter()
|
|
.map(|p| p.as_text())
|
|
.collect::<Vec<_>>()
|
|
.join("");
|
|
let label = format!("{} message", kind.as_str());
|
|
let summary = truncate_chars(&text, 240);
|
|
let id = SessionEntryRef::new(idx);
|
|
index.push(ReferenceEntry {
|
|
id: id.clone(),
|
|
entry_range,
|
|
kind,
|
|
tool_part: None,
|
|
tool_name: None,
|
|
label: label.clone(),
|
|
summary: summary.clone(),
|
|
search_text: text.clone(),
|
|
});
|
|
if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) {
|
|
overview.push(OverviewItem {
|
|
id: id.clone(),
|
|
entry_range,
|
|
kind,
|
|
label,
|
|
text,
|
|
intervening_entries: 0,
|
|
});
|
|
}
|
|
}
|
|
Item::ToolCall {
|
|
name, arguments, ..
|
|
} => {
|
|
let text = format!("{name}\n{arguments}");
|
|
index.push(ReferenceEntry {
|
|
id: SessionEntryRef::new(idx),
|
|
entry_range,
|
|
kind: ReferenceKind::Tool,
|
|
tool_part: Some(ToolPart::Input),
|
|
tool_name: Some(name.clone()),
|
|
label: format!("tool input: {name}"),
|
|
summary: format!("Tool call {name}"),
|
|
search_text: text,
|
|
});
|
|
}
|
|
Item::ToolResult {
|
|
summary,
|
|
content,
|
|
attachments,
|
|
..
|
|
} => {
|
|
let attachment_marker = if attachments.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!("\n[{} image attachment(s)]", attachments.len())
|
|
};
|
|
let text = format!(
|
|
"{summary}\n{}{attachment_marker}",
|
|
content.as_deref().unwrap_or_default(),
|
|
);
|
|
index.push(ReferenceEntry {
|
|
id: SessionEntryRef::new(idx),
|
|
entry_range,
|
|
kind: ReferenceKind::Tool,
|
|
tool_part: Some(ToolPart::Output),
|
|
tool_name: None,
|
|
label: "tool output".to_string(),
|
|
summary: truncate_chars(summary, 240),
|
|
search_text: text,
|
|
});
|
|
}
|
|
Item::Reasoning { .. } => {}
|
|
}
|
|
}
|
|
|
|
if overview.len() > 2 {
|
|
let last = overview.len() - 1;
|
|
overview = overview
|
|
.into_iter()
|
|
.enumerate()
|
|
.filter_map(|(index, entry)| {
|
|
(index == 0 || index == last || index % OVERVIEW_ANCHOR_STRIDE == 0)
|
|
.then_some(entry)
|
|
})
|
|
.collect();
|
|
}
|
|
|
|
for overview_index in 0..overview.len().saturating_sub(1) {
|
|
let current_entry = overview[overview_index].entry_range[0];
|
|
let next_entry = overview[overview_index + 1].entry_range[0];
|
|
overview[overview_index].intervening_entries = index
|
|
.iter()
|
|
.filter(|entry| {
|
|
let entry_index = entry.entry_range[0];
|
|
entry_index > current_entry && entry_index < next_entry
|
|
})
|
|
.count();
|
|
}
|
|
|
|
Self {
|
|
segment_id,
|
|
items,
|
|
overview,
|
|
index,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn overview(&self) -> &[OverviewItem] {
|
|
&self.overview
|
|
}
|
|
|
|
pub(crate) fn search(&self, options: &SearchOptions) -> Vec<SearchHit> {
|
|
let query = options.query.trim().to_lowercase();
|
|
let limit = options
|
|
.limit
|
|
.unwrap_or(DEFAULT_SEARCH_LIMIT)
|
|
.clamp(1, MAX_SEARCH_LIMIT);
|
|
let tool_name = options.tool_name.as_deref();
|
|
let min_entry_index = options
|
|
.from
|
|
.as_ref()
|
|
.and_then(SessionEntryRef::source_index)
|
|
.unwrap_or_else(|| options.min_entry_index.unwrap_or(0));
|
|
let max_entry_index = options
|
|
.through
|
|
.as_ref()
|
|
.and_then(SessionEntryRef::source_index)
|
|
.unwrap_or(u64::MAX);
|
|
let mut skipped = 0usize;
|
|
let mut hits = Vec::new();
|
|
|
|
for entry in &self.index {
|
|
if entry.entry_range[0] < min_entry_index || entry.entry_range[0] > max_entry_index {
|
|
continue;
|
|
}
|
|
if let Some(kind) = options.kind {
|
|
if entry.kind != kind {
|
|
continue;
|
|
}
|
|
}
|
|
if let Some(part) = options.tool_part {
|
|
if entry.kind != ReferenceKind::Tool {
|
|
continue;
|
|
}
|
|
let Some(actual) = entry.tool_part else {
|
|
continue;
|
|
};
|
|
if !part.matches(actual) {
|
|
continue;
|
|
}
|
|
}
|
|
if let Some(name) = tool_name {
|
|
if entry.tool_name.as_deref() != Some(name) {
|
|
continue;
|
|
}
|
|
}
|
|
if !query.is_empty() && !entry.search_text.to_lowercase().contains(&query) {
|
|
continue;
|
|
}
|
|
if skipped < options.offset {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
hits.push(SearchHit {
|
|
id: entry.id.clone(),
|
|
kind: entry.kind,
|
|
tool_part: entry.tool_part,
|
|
tool_name: entry.tool_name.clone(),
|
|
entry_range: entry.entry_range,
|
|
label: entry.label.clone(),
|
|
summary: entry.summary.clone(),
|
|
});
|
|
if hits.len() >= limit {
|
|
break;
|
|
}
|
|
}
|
|
|
|
hits
|
|
}
|
|
|
|
pub(crate) fn read(&self, selector: ReadSelector<'_>, options: ReadOptions) -> ReadResult {
|
|
let max_items = options.max_items.clamp(1, MAX_READ_MAX_ITEMS);
|
|
let max_bytes = options.max_bytes.max(1);
|
|
let mut entries = Vec::new();
|
|
let mut bytes = 0usize;
|
|
let mut truncated = false;
|
|
|
|
let selected: Vec<&ReferenceEntry> = match selector {
|
|
ReadSelector::Id(id) => self
|
|
.index
|
|
.iter()
|
|
.filter(|entry| entry.id.as_str() == id)
|
|
.collect(),
|
|
ReadSelector::EntryRange([start, end]) => self
|
|
.index
|
|
.iter()
|
|
.filter(|entry| entry.entry_range[0] >= start && entry.entry_range[0] <= end)
|
|
.collect(),
|
|
};
|
|
|
|
for entry in selected {
|
|
if entries.len() >= max_items {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
if entry.kind == ReferenceKind::Tool {
|
|
if !options.include_tools {
|
|
continue;
|
|
}
|
|
if let Some(actual) = entry.tool_part {
|
|
if !options.tool_part.matches(actual) {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
let Some(item) = self.items.get(entry.entry_range[0] as usize) else {
|
|
continue;
|
|
};
|
|
let text = render_item(item, entry, options.detail, max_bytes.saturating_sub(bytes));
|
|
bytes = bytes.saturating_add(text.len());
|
|
entries.push(ReadEntry {
|
|
id: entry.id.clone(),
|
|
kind: entry.kind,
|
|
tool_part: entry.tool_part,
|
|
tool_name: entry.tool_name.clone(),
|
|
entry_range: entry.entry_range,
|
|
label: entry.label.clone(),
|
|
text,
|
|
});
|
|
if bytes >= max_bytes {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
ReadResult { entries, truncated }
|
|
}
|
|
|
|
pub(crate) fn evidence_for(&self, id: &str) -> Option<SessionEntryEvidence> {
|
|
let entry = self.index.iter().find(|entry| entry.id.as_str() == id)?;
|
|
let excerpt = self
|
|
.read(
|
|
ReadSelector::Id(id),
|
|
ReadOptions {
|
|
include_tools: true,
|
|
tool_part: ToolPart::Both,
|
|
detail: ReadDetail::Compact,
|
|
max_items: 1,
|
|
max_bytes: 2 * 1024,
|
|
},
|
|
)
|
|
.entries
|
|
.first()
|
|
.map(|entry| entry.text.clone())
|
|
.unwrap_or_else(|| entry.summary.clone());
|
|
Some(SessionEntryEvidence {
|
|
segment_id: self.segment_id.clone(),
|
|
entry_ref: entry.id.clone(),
|
|
entry_range: entry.entry_range,
|
|
kind: entry.kind,
|
|
tool_part: entry.tool_part,
|
|
label: entry.label.clone(),
|
|
summary: entry.summary.clone(),
|
|
excerpt,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn render_item(
|
|
item: &Item,
|
|
entry: &ReferenceEntry,
|
|
detail: ReadDetail,
|
|
max_bytes: usize,
|
|
) -> String {
|
|
let text = match item {
|
|
Item::Message { role, content, .. } => {
|
|
let text = content
|
|
.iter()
|
|
.map(|p| p.as_text())
|
|
.collect::<Vec<_>>()
|
|
.join("");
|
|
format!("[{} {:?}] {text}", entry.id, role)
|
|
}
|
|
Item::ToolCall {
|
|
name, arguments, ..
|
|
} => match detail {
|
|
ReadDetail::Compact => format!("[{} ToolInput {name}] (arguments omitted)", entry.id),
|
|
ReadDetail::Full => format!("[{} ToolInput {name}]\narguments: {arguments}", entry.id),
|
|
},
|
|
Item::ToolResult {
|
|
summary,
|
|
content,
|
|
attachments,
|
|
is_error,
|
|
..
|
|
} => {
|
|
let attachment_line = if attachments.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!("\nattachments: {} image(s)", attachments.len())
|
|
};
|
|
match detail {
|
|
ReadDetail::Compact => format!(
|
|
"[{} ToolOutput{}]\nsummary: {summary}\ncontent: (omitted){attachment_line}",
|
|
entry.id,
|
|
if *is_error { " error" } else { "" },
|
|
),
|
|
ReadDetail::Full => format!(
|
|
"[{} ToolOutput{}]\nsummary: {summary}\ncontent: {}{attachment_line}",
|
|
entry.id,
|
|
if *is_error { " error" } else { "" },
|
|
content.as_deref().unwrap_or_default(),
|
|
),
|
|
}
|
|
}
|
|
Item::Reasoning { .. } => format!("[{} Reasoning omitted]", entry.id),
|
|
};
|
|
truncate_chars(&text, max_bytes)
|
|
}
|
|
|
|
fn truncate_chars(text: &str, max_chars: usize) -> String {
|
|
if max_chars == 0 {
|
|
return "… [truncated]".to_string();
|
|
}
|
|
if text.chars().count() <= max_chars {
|
|
return text.to_string();
|
|
}
|
|
let mut out = text.chars().take(max_chars).collect::<String>();
|
|
out.push_str("… [truncated]");
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn overview_contains_user_and_assistant_only() {
|
|
let view = SessionCapture::new(
|
|
"segment-1",
|
|
vec![
|
|
Item::system_message("sys"),
|
|
Item::user_message("hello"),
|
|
Item::assistant_message("progress"),
|
|
Item::tool_call("c1", "Read", "{\"file\":\"x\"}"),
|
|
Item::tool_result("c1", "read ok"),
|
|
],
|
|
);
|
|
|
|
let overview = view.overview();
|
|
assert_eq!(overview.len(), 2);
|
|
assert_eq!(overview[0].kind, ReferenceKind::User);
|
|
assert_eq!(overview[1].kind, ReferenceKind::Assistant);
|
|
assert!(overview[1].text.contains("progress"));
|
|
}
|
|
|
|
#[test]
|
|
fn search_filters_tool_input_and_output() {
|
|
let view = SessionCapture::new(
|
|
"segment-1",
|
|
vec![
|
|
Item::tool_call("c1", "Read", "{\"file\":\"Cargo.toml\"}"),
|
|
Item::tool_result_with_content("c1", "read ok", "package metadata"),
|
|
],
|
|
);
|
|
|
|
let input_hits = view.search(&SearchOptions {
|
|
query: "Cargo.toml".into(),
|
|
kind: Some(ReferenceKind::Tool),
|
|
tool_part: Some(ToolPart::Input),
|
|
tool_name: Some("Read".into()),
|
|
limit: None,
|
|
min_entry_index: None,
|
|
from: None,
|
|
through: None,
|
|
offset: 0,
|
|
});
|
|
assert_eq!(input_hits.len(), 1);
|
|
assert_eq!(input_hits[0].tool_part, Some(ToolPart::Input));
|
|
|
|
let output_hits = view.search(&SearchOptions {
|
|
query: "package metadata".into(),
|
|
kind: Some(ReferenceKind::Tool),
|
|
tool_part: Some(ToolPart::Output),
|
|
tool_name: None,
|
|
limit: None,
|
|
min_entry_index: None,
|
|
from: None,
|
|
through: None,
|
|
offset: 0,
|
|
});
|
|
assert_eq!(output_hits.len(), 1);
|
|
assert_eq!(output_hits[0].tool_part, Some(ToolPart::Output));
|
|
}
|
|
|
|
#[test]
|
|
fn read_by_entry_range_is_bounded_and_can_skip_tools() {
|
|
let view = SessionCapture::new(
|
|
"segment-1",
|
|
vec![
|
|
Item::user_message("one"),
|
|
Item::tool_call("c1", "Read", "{}"),
|
|
Item::tool_result_with_content("c1", "ok", "large-content".repeat(100)),
|
|
Item::assistant_message("two"),
|
|
],
|
|
);
|
|
|
|
let result = view.read(
|
|
ReadSelector::EntryRange([0, 3]),
|
|
ReadOptions {
|
|
include_tools: false,
|
|
detail: ReadDetail::Compact,
|
|
max_items: 10,
|
|
max_bytes: 1_000,
|
|
..ReadOptions::default()
|
|
},
|
|
);
|
|
|
|
assert_eq!(result.entries.len(), 2);
|
|
assert!(
|
|
result
|
|
.entries
|
|
.iter()
|
|
.all(|entry| entry.kind != ReferenceKind::Tool)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_image_projection_exposes_only_bounded_metadata() {
|
|
let view = SessionCapture::new(
|
|
"segment-1",
|
|
vec![Item::tool_result_item_with_attachments(
|
|
"c1",
|
|
"attached",
|
|
None,
|
|
false,
|
|
vec![llm_engine::tool::Attachment::Image(
|
|
llm_engine::tool::ImageAttachment::new(
|
|
"image/png",
|
|
b"private-image-body".to_vec(),
|
|
),
|
|
)],
|
|
)],
|
|
);
|
|
|
|
let result = view.read(
|
|
ReadSelector::Id("E00000000"),
|
|
ReadOptions {
|
|
include_tools: true,
|
|
detail: ReadDetail::Full,
|
|
..ReadOptions::default()
|
|
},
|
|
);
|
|
assert_eq!(result.entries.len(), 1);
|
|
assert!(result.entries[0].text.contains("attachments: 1 image(s)"));
|
|
assert!(!result.entries[0].text.contains("private-image-body"));
|
|
assert!(!result.entries[0].text.contains("cHJpdmF0ZS"));
|
|
}
|
|
|
|
#[test]
|
|
fn system_prompt_and_reasoning_are_excluded_from_every_projection() {
|
|
let view = SessionCapture::new(
|
|
"segment-1",
|
|
vec![
|
|
Item::system_message("raw secret system prompt"),
|
|
Item::reasoning("private chain of thought"),
|
|
Item::user_message("visible user entry"),
|
|
],
|
|
);
|
|
let hits = view.search(&SearchOptions {
|
|
query: String::new(),
|
|
kind: None,
|
|
tool_part: None,
|
|
tool_name: None,
|
|
limit: None,
|
|
min_entry_index: None,
|
|
from: None,
|
|
through: None,
|
|
offset: 0,
|
|
});
|
|
assert_eq!(hits.len(), 1);
|
|
assert_eq!(hits[0].id.as_str(), "E00000002");
|
|
assert!(!hits[0].summary.contains("secret"));
|
|
assert!(!hits[0].summary.contains("chain of thought"));
|
|
assert!(
|
|
view.read(ReadSelector::Id("E00000000"), ReadOptions::default())
|
|
.entries
|
|
.is_empty()
|
|
);
|
|
assert!(view.evidence_for("E00000000").is_none());
|
|
assert_eq!(view.overview().len(), 1);
|
|
assert_eq!(view.overview()[0].id.as_str(), "E00000002");
|
|
}
|
|
|
|
#[test]
|
|
fn overview_is_sparse_and_reports_intervening_non_reasoning_entries() {
|
|
let items = (0..20)
|
|
.map(|index| Item::user_message(format!("message-{index}")))
|
|
.collect::<Vec<_>>();
|
|
let view = SessionCapture::new("segment-1", items);
|
|
let refs = view
|
|
.overview()
|
|
.iter()
|
|
.map(|entry| entry.id.as_str())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
refs,
|
|
vec!["E00000000", "E00000008", "E00000016", "E00000019"]
|
|
);
|
|
assert_eq!(view.overview()[0].intervening_entries, 7);
|
|
assert_eq!(view.overview()[1].intervening_entries, 7);
|
|
assert_eq!(view.overview()[2].intervening_entries, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn append_preserves_existing_session_entry_refs() {
|
|
let first = SessionCapture::new(
|
|
"segment-1",
|
|
vec![
|
|
Item::user_message("first"),
|
|
Item::assistant_message("second"),
|
|
],
|
|
);
|
|
let appended = SessionCapture::new(
|
|
"segment-1",
|
|
vec![
|
|
Item::user_message("first"),
|
|
Item::assistant_message("second"),
|
|
Item::user_message("third"),
|
|
],
|
|
);
|
|
let first_refs = first
|
|
.search(&SearchOptions {
|
|
query: String::new(),
|
|
kind: None,
|
|
tool_part: None,
|
|
tool_name: None,
|
|
limit: None,
|
|
min_entry_index: None,
|
|
from: None,
|
|
through: None,
|
|
offset: 0,
|
|
})
|
|
.into_iter()
|
|
.map(|entry| entry.id)
|
|
.collect::<Vec<_>>();
|
|
let appended_refs = appended
|
|
.search(&SearchOptions {
|
|
query: String::new(),
|
|
kind: None,
|
|
tool_part: None,
|
|
tool_name: None,
|
|
limit: None,
|
|
min_entry_index: None,
|
|
from: None,
|
|
through: None,
|
|
offset: 0,
|
|
})
|
|
.into_iter()
|
|
.take(2)
|
|
.map(|entry| entry.id)
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(first_refs, appended_refs);
|
|
}
|
|
|
|
#[test]
|
|
fn evidence_projection_uses_entry_range_and_session_entry_ref() {
|
|
let view = SessionCapture::new("segment-1", vec![Item::user_message("hello")]);
|
|
let source = view.evidence_for("E00000000").unwrap();
|
|
assert_eq!(source.segment_id, "segment-1");
|
|
assert_eq!(source.entry_range, [0, 0]);
|
|
assert_eq!(source.entry_ref.as_str(), "E00000000");
|
|
}
|
|
}
|