use std::collections::HashSet;
use anyhow::{anyhow, Context, Result};
use crate::memory::poisoning::scan_generated_surfaces;
use crate::memory::session_label::{normalize_topic, SessionIntent};
use super::RollupRange;
#[derive(Debug, Clone)]
pub(super) struct RollupOutput {
pub(super) summary_text: String,
pub(super) structured_fields: RollupStructuredFields,
pub(super) segments: Vec<ParsedTopicSegment>,
}
#[derive(Debug, Clone, Default)]
pub(super) struct RollupStructuredFields {
pub(super) request: Option<String>,
pub(super) session_intent: Option<String>,
pub(super) session_topic: Option<String>,
pub(super) decisions: Option<String>,
pub(super) learned: Option<String>,
pub(super) next_steps: Option<String>,
pub(super) preferences: Option<String>,
}
#[derive(Debug, Clone)]
pub(super) struct ParsedTopicSegment {
pub(super) topic_key: String,
pub(super) title: String,
pub(super) summary: String,
pub(super) status: String,
pub(super) segment_index: i64,
pub(super) covered_from_event_id: i64,
pub(super) covered_to_event_id: i64,
pub(super) evidence_event_ids: Vec<i64>,
pub(super) files: Vec<String>,
pub(super) confidence: f64,
}
pub(super) fn parse_rollup_response(text: &str, range: &RollupRange) -> Result<RollupOutput> {
let summary_text = extract_top_level_summary(text)
.map(|summary| summary.trim().to_string())
.filter(|summary| !summary.is_empty())
.ok_or_else(|| anyhow!("session_rollup response missing non-empty <summary>"))?;
let structured_fields = parse_structured_fields(text)?;
let segments_xml = extract_tag(text, "segments")
.ok_or_else(|| anyhow!("session_rollup response missing <segments>"))?;
if segments_xml.trim().is_empty() {
return Ok(RollupOutput {
summary_text,
structured_fields,
segments: Vec::new(),
});
}
let mut segments = Vec::new();
for (index, raw_segment) in iter_segment_blocks(&segments_xml)?.into_iter().enumerate() {
let segment = parse_segment(index as i64, &raw_segment, range)
.map_err(|error| anyhow!("parse topic segment index {index}: {error}"))?;
segments.push(segment);
}
if segments.is_empty() {
return Err(anyhow!(
"session_rollup response <segments> contains no <segment> blocks"
));
}
Ok(RollupOutput {
summary_text,
structured_fields,
segments,
})
}
fn iter_segment_blocks(text: &str) -> Result<Vec<String>> {
let mut blocks = Vec::new();
let mut rest = text;
while let Some(start_rel) = rest.find("<segment") {
let after_start = &rest[start_rel..];
let open_end_rel = after_start
.find('>')
.context("segment missing opening tag terminator")?;
let body_start = start_rel + open_end_rel + 1;
let close_rel = rest[body_start..]
.find("</segment>")
.context("segment missing closing tag")?;
let close_end = body_start + close_rel + "</segment>".len();
blocks.push(rest[start_rel..close_end].to_string());
rest = &rest[close_end..];
}
Ok(blocks)
}
fn extract_top_level_summary(text: &str) -> Option<String> {
let segments_start = text.find("<segments").unwrap_or(text.len());
extract_tag(&text[..segments_start], "summary")
}
fn parse_structured_fields(text: &str) -> Result<RollupStructuredFields> {
let body = extract_tag(text, "structured_fields")
.ok_or_else(|| anyhow!("session_rollup response missing <structured_fields>"))?;
Ok(RollupStructuredFields {
request: optional_structured_tag(&body, "request")?,
session_intent: extract_tag(&body, "session_intent")
.as_deref()
.and_then(SessionIntent::parse)
.map(|intent| intent.as_str().to_string()),
session_topic: extract_tag(&body, "session_topic")
.as_deref()
.and_then(normalize_model_topic),
decisions: optional_structured_tag(&body, "decisions")?,
learned: optional_structured_tag(&body, "learned")?,
next_steps: optional_structured_tag(&body, "next_steps")?,
preferences: optional_structured_tag(&body, "preferences")?,
})
}
fn normalize_model_topic(raw: &str) -> Option<String> {
if scan_generated_surfaces(&[("session_topic", Some(raw))]).is_some() {
return None;
}
let redacted = crate::db::redact_capture_content(raw);
if redacted != raw {
return None;
}
normalize_topic(&redacted)
}
fn optional_structured_tag(body: &str, tag: &str) -> Result<Option<String>> {
let raw = extract_tag(body, tag)
.ok_or_else(|| anyhow!("session_rollup response missing <structured_fields><{tag}>"))?;
let value = raw.trim().to_string();
Ok((!value.is_empty()).then_some(value))
}
fn parse_segment(
segment_index: i64,
raw_segment: &str,
range: &RollupRange,
) -> Result<ParsedTopicSegment> {
let open_end = raw_segment
.find('>')
.context("segment missing opening tag terminator")?;
let open_tag = &raw_segment[..=open_end];
let body = &raw_segment[open_end + 1..raw_segment.len() - "</segment>".len()];
let raw_topic_key = extract_attr(open_tag, "topic_key")
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.context("segment missing topic_key")?;
let topic_key = if is_legacy_topic_key(&raw_topic_key) {
raw_topic_key.clone()
} else {
crate::memory::slugify_for_topic(&raw_topic_key, 96)
};
if topic_key.is_empty() {
return Err(anyhow!("invalid topic_key '{raw_topic_key}'"));
}
let status = extract_attr(open_tag, "status").unwrap_or_else(|| "open".to_string());
if !matches!(status.as_str(), "open" | "resolved" | "superseded") {
return Err(anyhow!("invalid segment status '{status}'"));
}
let title = required_tag(body, "title")?;
let summary = required_tag(body, "summary")?;
let explicit_from = required_i64(body, "from_event_id")?;
let explicit_to = required_i64(body, "to_event_id")?;
if explicit_to < explicit_from {
return Err(anyhow!(
"to_event_id {} is before from_event_id {}",
explicit_to,
explicit_from
));
}
let mut evidence_event_ids = extract_tag(body, "evidence_event_ids")
.map(|raw| parse_event_ids(&raw))
.transpose()?
.unwrap_or_else(|| vec![explicit_from, explicit_to]);
evidence_event_ids.sort_unstable();
evidence_event_ids.dedup();
if evidence_event_ids.is_empty() {
return Err(anyhow!("segment has empty evidence_event_ids"));
}
let loaded_event_ids = range
.events
.iter()
.map(|event| event.id)
.collect::<HashSet<_>>();
let missing_event_ids = evidence_event_ids
.iter()
.copied()
.filter(|event_id| !loaded_event_ids.contains(event_id))
.collect::<Vec<_>>();
if !missing_event_ids.is_empty() {
return Err(anyhow!(
"evidence_event_ids absent from loaded rollup events: {:?}",
missing_event_ids
));
}
let covered_from_event_id = evidence_event_ids[0];
let covered_to_event_id = evidence_event_ids[evidence_event_ids.len() - 1];
let files = extract_tag(body, "files")
.map(|raw| parse_files(&raw))
.transpose()?
.unwrap_or_default();
let confidence = extract_attr(open_tag, "confidence")
.as_deref()
.map(str::parse::<f64>)
.transpose()
.context("parse segment confidence")?
.unwrap_or(0.75);
Ok(ParsedTopicSegment {
topic_key,
title,
summary,
status,
segment_index,
covered_from_event_id,
covered_to_event_id,
evidence_event_ids,
files,
confidence,
})
}
fn is_legacy_topic_key(value: &str) -> bool {
value.bytes().any(|byte| byte.is_ascii_alphanumeric())
&& value.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_')
})
}
fn required_tag(body: &str, tag: &str) -> Result<String> {
extract_tag(body, tag)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow!("segment missing non-empty <{tag}>"))
}
fn required_i64(body: &str, tag: &str) -> Result<i64> {
required_tag(body, tag)?
.parse::<i64>()
.with_context(|| format!("parse <{tag}>"))
}
fn extract_tag(text: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = text.find(&open)? + open.len();
let end = text[start..].find(&close)? + start;
Some(xml_unescape_text(text[start..end].trim()))
}
fn extract_attr(open_tag: &str, attr: &str) -> Option<String> {
let needle = format!("{attr}=\"");
let start = open_tag.find(&needle)? + needle.len();
let end = open_tag[start..].find('"')? + start;
Some(xml_unescape_text(&open_tag[start..end]))
}
fn parse_event_ids(raw: &str) -> Result<Vec<i64>> {
if let Ok(ids) = serde_json::from_str::<Vec<i64>>(raw.trim()) {
return Ok(ids);
}
raw.split(|ch: char| ch == ',' || ch.is_whitespace())
.map(str::trim)
.filter(|part| !part.is_empty())
.map(|part| {
part.parse::<i64>()
.with_context(|| format!("parse evidence event id '{part}'"))
})
.collect()
}
fn parse_files(raw: &str) -> Result<Vec<String>> {
if raw.trim().is_empty() {
return Ok(Vec::new());
}
if let Ok(files) = serde_json::from_str::<Vec<String>>(raw.trim()) {
return Ok(files);
}
let mut files = raw
.split([',', '\n'])
.map(str::trim)
.filter(|part| !part.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
files.sort();
files.dedup();
Ok(files)
}
fn xml_unescape_text(raw: &str) -> String {
raw.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("&", "&")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session_rollup::{RollupEvent, RollupRange};
fn event(id: i64) -> RollupEvent {
RollupEvent {
id,
event_type: "tool_result".to_string(),
role: None,
tool_name: None,
content: format!("content {id}"),
token_estimate: 1,
created_at_epoch: 100 + id,
turn_id: None,
}
}
fn range() -> RollupRange {
RollupRange {
from_event_id: 10,
to_event_id: 20,
events: vec![event(10), event(11), event(14), event(20)],
}
}
#[test]
fn parses_segments_with_overlapping_event_ranges() -> Result<()> {
let parsed = parse_rollup_response(
r#"<summary>done</summary>
<structured_fields>
<request></request>
<decisions></decisions>
<learned></learned>
<next_steps></next_steps>
<preferences></preferences>
</structured_fields>
<segments>
<segment topic_key="anti-bot-research" status="resolved">
<title>Anti-bot research</title>
<summary>Investigated blocking.</summary>
<evidence_event_ids>10,14,20</evidence_event_ids>
<from_event_id>10</from_event_id>
<to_event_id>20</to_event_id>
<files>src/a.rs,src/b.rs</files>
</segment>
<segment topic_key="kexue-scraping" status="open">
<title>Kexue scraping</title>
<summary>Implemented scraper.</summary>
<evidence_event_ids>[11, 14]</evidence_event_ids>
<from_event_id>11</from_event_id>
<to_event_id>14</to_event_id>
</segment>
</segments>"#,
&range(),
)?;
assert_eq!(parsed.summary_text, "done");
assert_eq!(parsed.segments.len(), 2);
assert_eq!(parsed.segments[0].covered_from_event_id, 10);
assert_eq!(parsed.segments[0].covered_to_event_id, 20);
assert_eq!(parsed.segments[1].evidence_event_ids, vec![11, 14]);
Ok(())
}
#[test]
fn normalizes_version_punctuation_in_topic_key() -> Result<()> {
let parsed = parse_rollup_response(
r#"<summary>release audit</summary>
<structured_fields>
<request></request>
<decisions></decisions>
<learned></learned>
<next_steps></next_steps>
<preferences></preferences>
</structured_fields>
<segments>
<segment topic_key="v0.2-release-audit" status="resolved">
<title>v0.2 release audit</title>
<summary>Audited the v0.2 release.</summary>
<evidence_event_ids>10,20</evidence_event_ids>
<from_event_id>10</from_event_id>
<to_event_id>20</to_event_id>
</segment>
</segments>"#,
&range(),
)?;
assert_eq!(parsed.segments[0].topic_key, "v0-2-release-audit");
Ok(())
}
#[test]
fn preserves_existing_snake_case_topic_key() -> Result<()> {
let snake = parse_segment(
0,
r#"<segment topic_key="existing_topic_2" status="open">
<title>Existing topic</title><summary>Stable identity.</summary>
<evidence_event_ids>10,20</evidence_event_ids>
<from_event_id>10</from_event_id><to_event_id>20</to_event_id>
</segment>"#,
&range(),
)?;
let kebab = parse_segment(
1,
r#"<segment topic_key="existing-topic-2" status="open">
<title>Existing topic</title><summary>Stable identity.</summary>
<evidence_event_ids>10,20</evidence_event_ids>
<from_event_id>10</from_event_id><to_event_id>20</to_event_id>
</segment>"#,
&range(),
)?;
assert_eq!(snake.topic_key, "existing_topic_2");
assert_eq!(kebab.topic_key, "existing-topic-2");
Ok(())
}
#[test]
fn rejects_topic_key_that_normalizes_to_empty() {
let err = parse_segment(
0,
r#"<segment topic_key="..." status="resolved">
<title>Invalid topic</title>
<summary>Invalid topic key.</summary>
<evidence_event_ids>10,20</evidence_event_ids>
<from_event_id>10</from_event_id>
<to_event_id>20</to_event_id>
</segment>"#,
&range(),
)
.expect_err("punctuation-only topic key should fail closed");
assert!(err.to_string().contains("invalid topic_key '...'"));
}
#[test]
fn rejects_punctuation_only_topic_key() {
let err = parse_segment(
0,
r#"<segment topic_key="---" status="resolved">
<title>Invalid topic</title><summary>Invalid topic key.</summary>
<evidence_event_ids>10,20</evidence_event_ids>
<from_event_id>10</from_event_id><to_event_id>20</to_event_id>
</segment>"#,
&range(),
)
.expect_err("punctuation-only legacy grammar must fail closed");
assert!(err.to_string().contains("invalid topic_key '---'"));
}
#[test]
fn rejects_segment_with_evidence_event_absent_from_loaded_events() {
let err = parse_rollup_response(
r#"<summary>done</summary>
<structured_fields>
<request></request>
<decisions></decisions>
<learned></learned>
<next_steps></next_steps>
<preferences></preferences>
</structured_fields>
<segments>
<segment topic_key="interleaved-session" status="open">
<title>Interleaved session</title>
<summary>Should not attach unrelated evidence.</summary>
<evidence_event_ids>10,15,20</evidence_event_ids>
<from_event_id>10</from_event_id>
<to_event_id>20</to_event_id>
</segment>
</segments>"#,
&range(),
)
.expect_err("out-of-range evidence should fail the rollup parse");
assert!(err
.to_string()
.contains("evidence_event_ids absent from loaded rollup events"));
}
#[test]
fn missing_segments_tag_fails_entire_rollup_parse() {
let err = parse_rollup_response(
r#"<summary>done</summary>
<structured_fields>
<request></request>
<decisions></decisions>
<learned></learned>
<next_steps></next_steps>
<preferences></preferences>
</structured_fields>"#,
&range(),
)
.expect_err("missing segments should fail");
assert!(err.to_string().contains("missing <segments>"));
}
#[test]
fn explicit_empty_segments_is_accepted() -> Result<()> {
let parsed = parse_rollup_response(
r#"<summary>done</summary>
<structured_fields>
<request></request>
<decisions></decisions>
<learned></learned>
<next_steps></next_steps>
<preferences></preferences>
</structured_fields>
<segments></segments>"#,
&range(),
)?;
assert_eq!(parsed.summary_text, "done");
assert!(parsed.segments.is_empty());
Ok(())
}
#[test]
fn missing_structured_fields_tag_fails_entire_rollup_parse() {
let err = parse_rollup_response("<summary>done</summary><segments></segments>", &range())
.expect_err("missing structured fields should fail");
assert!(err.to_string().contains("missing <structured_fields>"));
}
#[test]
fn parses_optional_structured_summary_fields() -> Result<()> {
let parsed = parse_rollup_response(
r#"<summary>overall</summary>
<structured_fields>
<request>Compare rollup and summary writers</request>
<decisions>Keep session_summaries as the shared table.</decisions>
<learned>Rollup owns range identity.</learned>
<next_steps>Port structured fields before Summary retirement.</next_steps>
<preferences>Do not silently drop summary preferences.</preferences>
</structured_fields>
<segments></segments>"#,
&range(),
)?;
assert_eq!(parsed.summary_text, "overall");
assert_eq!(
parsed.structured_fields.request.as_deref(),
Some("Compare rollup and summary writers")
);
assert_eq!(
parsed.structured_fields.decisions.as_deref(),
Some("Keep session_summaries as the shared table.")
);
assert_eq!(
parsed.structured_fields.learned.as_deref(),
Some("Rollup owns range identity.")
);
assert_eq!(
parsed.structured_fields.next_steps.as_deref(),
Some("Port structured fields before Summary retirement.")
);
assert_eq!(
parsed.structured_fields.preferences.as_deref(),
Some("Do not silently drop summary preferences.")
);
Ok(())
}
#[test]
fn missing_summary_fails_entire_rollup_parse() {
let err = parse_rollup_response("<segments></segments>", &range())
.expect_err("missing summary should fail");
assert!(err.to_string().contains("missing non-empty <summary>"));
}
#[test]
fn segment_summary_does_not_replace_top_level_summary() {
let err = parse_rollup_response(
r#"<segments>
<segment topic_key="nested-summary" status="open">
<title>Nested summary</title>
<summary>This is not the session summary.</summary>
<evidence_event_ids>10,11</evidence_event_ids>
<from_event_id>10</from_event_id>
<to_event_id>11</to_event_id>
</segment>
</segments>"#,
&range(),
)
.expect_err("nested segment summary must not satisfy top-level summary");
assert!(err.to_string().contains("missing non-empty <summary>"));
}
}