use crate::templates::{self, COMPACTION_SUMMARY_TEMPLATE};
use goose_provider_types::json::safely_parse_json;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StructuredSummary {
#[serde(default, deserialize_with = "lenient_string_list")]
pub user_intent: Vec<String>,
#[serde(default, deserialize_with = "lenient_string_list")]
pub technical_concepts: Vec<String>,
#[serde(default, deserialize_with = "lenient_file_list")]
pub files: Vec<FileActivity>,
#[serde(default, deserialize_with = "lenient_string_list")]
pub errors_and_fixes: Vec<String>,
#[serde(default, deserialize_with = "lenient_string_list")]
pub problem_solving: Vec<String>,
#[serde(default, deserialize_with = "lenient_string_list")]
pub user_messages: Vec<String>,
#[serde(default, deserialize_with = "lenient_string_list")]
pub pending_tasks: Vec<String>,
#[serde(default, deserialize_with = "lenient_string_opt")]
pub current_work: Option<String>,
#[serde(default, deserialize_with = "lenient_string_opt")]
pub next_step: Option<String>,
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileActivity {
#[serde(default, deserialize_with = "lenient_string")]
pub path: String,
#[serde(default, deserialize_with = "lenient_string")]
pub summary: String,
#[serde(default, deserialize_with = "lenient_string_opt")]
pub key_code: Option<String>,
}
fn stringify_lenient(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Null => String::new(),
serde_json::Value::Object(map) => map
.iter()
.map(|(k, v)| format!("{k}: {}", stringify_lenient(v)))
.collect::<Vec<_>>()
.join("; "),
serde_json::Value::Array(items) => items
.iter()
.map(stringify_lenient)
.collect::<Vec<_>>()
.join("; "),
other => other.to_string(),
}
}
fn lenient_string_list<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
Ok(match value {
serde_json::Value::Array(items) => items.iter().map(stringify_lenient).collect(),
serde_json::Value::Null => Vec::new(),
other => vec![stringify_lenient(&other)],
})
}
fn lenient_string<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
Ok(stringify_lenient(&value))
}
fn lenient_string_opt<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
Ok(match value {
serde_json::Value::Null => None,
other => Some(stringify_lenient(&other)),
})
}
fn lenient_file_list<'de, D>(deserializer: D) -> Result<Vec<FileActivity>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
let items = match value {
serde_json::Value::Array(items) => items,
serde_json::Value::Null => return Ok(Vec::new()),
other => vec![other],
};
Ok(items
.into_iter()
.filter_map(|item| match item {
serde_json::Value::Object(_) => serde_json::from_value(item).ok(),
other => {
let path = stringify_lenient(&other);
(!path.trim().is_empty()).then_some(FileActivity {
path,
summary: String::new(),
key_code: None,
})
}
})
.collect())
}
impl StructuredSummary {
pub fn parse(response_text: &str) -> Option<Self> {
json_candidates(response_text).into_iter().find_map(|c| {
let value = safely_parse_json(c).ok()?;
let mut summary: Self = serde_json::from_value(value).ok()?;
summary.normalize();
(!summary.is_empty()).then_some(summary)
})
}
pub fn render(&self) -> Result<String, minijinja::Error> {
self.render_with(
&templates::builtin_template(COMPACTION_SUMMARY_TEMPLATE)
.expect("builtin compaction summary template"),
)
}
pub fn render_with(&self, template: &str) -> Result<String, minijinja::Error> {
templates::render(template, self)
}
fn normalize(&mut self) {
fn blank(s: &str) -> bool {
s.trim().is_empty()
}
for list in [
&mut self.user_intent,
&mut self.technical_concepts,
&mut self.errors_and_fixes,
&mut self.problem_solving,
&mut self.user_messages,
&mut self.pending_tasks,
] {
list.retain(|s| !blank(s));
}
for file in &mut self.files {
if file.key_code.as_deref().is_some_and(blank) {
file.key_code = None;
}
}
self.files
.retain(|f| !blank(&f.path) || !blank(&f.summary) || f.key_code.is_some());
if self.current_work.as_deref().is_some_and(blank) {
self.current_work = None;
}
if self.next_step.as_deref().is_some_and(blank) {
self.next_step = None;
}
}
fn is_empty(&self) -> bool {
self.user_intent.is_empty()
&& self.technical_concepts.is_empty()
&& self.files.is_empty()
&& self.errors_and_fixes.is_empty()
&& self.problem_solving.is_empty()
&& self.user_messages.is_empty()
&& self.pending_tasks.is_empty()
&& self.current_work.is_none()
&& self.next_step.is_none()
}
}
#[allow(clippy::string_slice)] fn json_candidates(text: &str) -> Vec<&str> {
const TERMINATOR: &str = "</analysis>";
let mut cuts: Vec<usize> = text
.match_indices(TERMINATOR)
.map(|(idx, _)| idx + TERMINATOR.len())
.collect();
if cuts.is_empty() {
cuts.push(0);
}
let mut candidates: Vec<&str> = Vec::new();
for &cut in cuts.iter().rev() {
let tail = &text[cut..];
let later_terminators = tail.matches(TERMINATOR).count();
candidates.extend(
fenced_json_blocks(tail)
.chain(leading_object(tail))
.filter(|candidate| candidate.matches(TERMINATOR).count() == later_terminators),
);
}
candidates.extend(leading_object(text));
candidates.dedup();
candidates
}
#[allow(clippy::string_slice)] fn fenced_json_blocks(text: &str) -> impl Iterator<Item = &str> {
text.match_indices("```json")
.collect::<Vec<_>>()
.into_iter()
.rev()
.filter_map(|(idx, marker)| leading_object(&text[idx + marker.len()..]))
}
#[allow(clippy::string_slice)] fn leading_object(text: &str) -> Option<&str> {
let text = text.trim_start();
if !text.starts_with('{') {
return None;
}
let mut depth = 0usize;
let mut in_string = false;
let mut escaped = false;
for (idx, ch) in text.char_indices() {
if in_string {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
match ch {
'"' => in_string = true,
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return Some(&text[..=idx]);
}
}
_ => {}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
const FULL_RESPONSE: &str = r#"<analysis>
The user asked to fix a bug in parser.rs. I traced it to an off-by-one
in {brace handling} and patched it.
</analysis>
```json
{
"user_intent": ["Fix the parser bug", "Add a regression test"],
"technical_concepts": ["off-by-one", "tokenizer"],
"files": [
{"path": "src/parser.rs", "summary": "Fixed off-by-one in scan loop", "key_code": "fn scan(&mut self) { .. }"}
],
"errors_and_fixes": ["Panic on empty input, fixed with early return"],
"problem_solving": ["Root-caused via failing unit test"],
"user_messages": ["fix the parser bug", "add a test"],
"pending_tasks": ["Add a regression test"],
"current_work": "Writing the regression test in tests/parser.rs",
"next_step": "Finish the regression test"
}
```"#;
#[test]
fn parses_fenced_json_after_analysis() {
let summary = StructuredSummary::parse(FULL_RESPONSE).expect("should parse");
assert_eq!(
summary.user_intent,
vec!["Fix the parser bug", "Add a regression test"]
);
assert_eq!(summary.files.len(), 1);
assert_eq!(summary.files[0].path, "src/parser.rs");
assert_eq!(
summary.current_work.as_deref(),
Some("Writing the regression test in tests/parser.rs")
);
}
#[test]
fn unusable_responses_fall_back_to_raw_text() {
for text in [
"Here is a summary of the conversation. The user asked about compaction.",
"{}",
r#"{"notes": "unknown fields alone are not a summary"}"#,
r#"{"current_work": ""}"#,
r#"{"files": [{}], "user_intent": [" "]}"#,
"```json\n{\"user_intent\": [\"Fix the bug\"], \"pending_tasks\": [\"Write tests\", \"Update docs",
r#"The session focused on the parser migration. The tracker entry {"current_work": "migrate parser"} is unchanged, and tests still need porting."#,
"<analysis>reviewing</analysis>\nA prose recap: the config was set to {\"user_intent\": [\"quoted example\"]} per the docs, then the run passed.",
"<analysis>\nThe target shape is:\n```json\n{\"user_intent\": [\"example only\"]}\n```\nNow let me review the conversation.\n</analysis>\nSorry, I ran out of room and could not produce the summary document.",
"<analysis>\nThe prompt ends with </analysis> and shows the shape:\n```json\n{\"user_intent\": [\"example only\"]}\n```\nNow let me review the conversation.\n</analysis>\nSorry, I ran out of room and could not produce the summary document.",
] {
assert!(
StructuredSummary::parse(text).is_none(),
"should fall back to raw text for: {text}"
);
}
}
#[test]
fn embedded_fences_in_string_values_do_not_break_extraction() {
let text = "```json\n{\"user_intent\": [\"Document the build\"], \"files\": [{\"path\": \"README.md\", \"summary\": \"Added build docs\", \"key_code\": \"```bash\\ncargo build\\n```\"}], \"pending_tasks\": [\"Publish the docs\"]}\n```";
let summary = StructuredSummary::parse(text).expect("should parse");
assert_eq!(
summary.files[0].key_code.as_deref(),
Some("```bash\ncargo build\n```")
);
assert_eq!(summary.pending_tasks, vec!["Publish the docs"]);
let quoted_json_fence = "```json\n{\"user_intent\": [\"Document the config\"], \"files\": [{\"path\": \"docs/config.md\", \"summary\": \"Added config examples\", \"key_code\": \"```json\\n{\\\"retries\\\": 3}\\n```\"}]}\n```";
let summary =
StructuredSummary::parse(quoted_json_fence).expect("should parse via the outer fence");
assert_eq!(
summary.files[0].key_code.as_deref(),
Some("```json\n{\"retries\": 3}\n```")
);
}
#[test]
fn quoted_terminator_inside_summary_json_does_not_hide_it() {
let text = "<analysis>\nThe session edited the compaction prompt itself.\n</analysis>\n```json\n{\"user_intent\": [\"Rework the scratchpad prompt\"], \"files\": [{\"path\": \"prompts/compact.md\", \"summary\": \"Tightened the <analysis>...</analysis> instructions\"}]}\n```";
let summary = StructuredSummary::parse(text).expect("should parse");
assert_eq!(summary.user_intent, vec!["Rework the scratchpad prompt"]);
assert_eq!(
summary.files[0].summary,
"Tightened the <analysis>...</analysis> instructions"
);
}
#[test]
fn retries_next_candidate_when_fenced_extraction_fails() {
let text = "<analysis>the model was told to emit ```json with {braces</analysis>\n{\"user_intent\": [\"Real goal\"]}";
let summary = StructuredSummary::parse(text).expect("should parse");
assert_eq!(summary.user_intent, vec!["Real goal"]);
}
#[test]
fn lenient_shapes_are_stringified_not_rejected() {
let text = r#"{
"user_intent": "fix the flaky test",
"errors_and_fixes": [
{"error": "cursor drifted after replay batch 34", "fix": "bounded mpsc channel"},
"plain string entry",
null
],
"pending_tasks": [42],
"current_work": {"task": "regression test", "status": "in progress"}
}"#;
let summary = StructuredSummary::parse(text).expect("should parse leniently");
assert_eq!(summary.user_intent, vec!["fix the flaky test"]);
assert_eq!(
summary.errors_and_fixes,
vec![
"error: cursor drifted after replay batch 34; fix: bounded mpsc channel",
"plain string entry",
]
);
assert_eq!(summary.pending_tasks, vec!["42"]);
assert_eq!(
summary.current_work.as_deref(),
Some("task: regression test; status: in progress")
);
}
#[test]
fn file_entries_parse_leniently() {
let text = r#"{"files": [
"src/parser.rs",
{"path": "tests/parser.rs", "summary": "Added regression test"},
{"path": "src/scan.rs", "summary": 42, "key_code": ["fn a() {}", "fn b() {}"]},
""
]}"#;
let summary = StructuredSummary::parse(text).expect("should parse");
assert_eq!(summary.files.len(), 3);
assert_eq!(summary.files[0].path, "src/parser.rs");
assert_eq!(summary.files[0].summary, "");
assert_eq!(summary.files[1].summary, "Added regression test");
assert_eq!(summary.files[2].summary, "42");
assert_eq!(
summary.files[2].key_code.as_deref(),
Some("fn a() {}; fn b() {}")
);
}
#[test]
fn drops_blank_entries_but_keeps_content() {
let text = r#"{"user_intent": ["", "Fix the bug"], "files": [{"path": "a.rs", "summary": "Patched", "key_code": " "}], "next_step": " "}"#;
let summary = StructuredSummary::parse(text).expect("should parse");
assert_eq!(summary.user_intent, vec!["Fix the bug"]);
assert_eq!(summary.files[0].key_code, None);
assert_eq!(summary.next_step, None);
}
#[test]
fn renders_markdown_sections() {
let summary = StructuredSummary::parse(FULL_RESPONSE).unwrap();
let rendered = summary.render().expect("should render");
assert!(rendered.contains("## User Intent"));
assert!(rendered.contains("- Fix the parser bug"));
assert!(rendered.contains("### src/parser.rs"));
assert!(rendered.contains("fn scan(&mut self) { .. }"));
assert!(rendered.contains("## Next Step"));
}
#[test]
fn render_fences_exceed_backtick_runs_in_key_code() {
let summary = StructuredSummary {
files: vec![FileActivity {
path: "docs/build.md".to_string(),
summary: "Documented the build".to_string(),
key_code: Some(
"```bash\ncargo build\n```\n````\nnested fence docs\n````".to_string(),
),
}],
errors_and_fixes: vec!["None".to_string()],
..Default::default()
};
let rendered = summary.render().expect("should render");
assert_eq!(rendered.matches("\n`````\n").count(), 2);
let errors_heading = rendered.find("## Errors + Fixes").unwrap();
let closing_fence = rendered.rfind("\n`````\n").unwrap();
assert!(errors_heading > closing_fence);
}
}