diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index 8791c304..c5d518a7 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -1395,7 +1395,12 @@ impl Pod { .filter(|i| i.is_user_message()) .count(); - // Build new history: [summary, ...auto-read, task snapshot, TaskList result, references, ...retained]. + // Build new history: [summary, ...auto-read, references, ...retained, task snapshot, TaskList synthetic call/result]. + // The TaskStore snapshot trails the retained items so that, on resume, + // `replay_history` walks any pre-compact Task* calls preserved verbatim + // in retained_items first and the trailing snapshot's `replace_with` + // is the final word — pre-compact `TaskCreate` calls cannot leak as + // duplicate entries. let mut new_history = Vec::with_capacity( 1 + auto_read_messages.len() + 3 @@ -1406,6 +1411,10 @@ impl Pod { "[Compacted context summary]\n\n{summary_text}" ))); new_history.extend(auto_read_messages); + if let Some(msg) = reference_message { + new_history.push(msg); + } + new_history.extend(retained_items); new_history.push(Item::system_message(format!( "[Session TaskStore snapshot]\n\n{task_snapshot_text}\n\n\ This is the complete session task list preserved across compaction. \ @@ -1417,10 +1426,6 @@ impl Pod { tools::task::snapshot_overview(&self.task_store.list()), task_snapshot_text.clone(), )); - if let Some(msg) = reference_message { - new_history.push(msg); - } - new_history.extend(retained_items); // Persist as a new compacted session. let old_session_id = self.session_id; diff --git a/crates/tools/src/task.rs b/crates/tools/src/task.rs index 9261d396..133cc051 100644 --- a/crates/tools/src/task.rs +++ b/crates/tools/src/task.rs @@ -609,4 +609,37 @@ mod tests { assert_eq!(tasks[1].taskid, 2); assert_eq!(tasks[1].subject, "new"); } + + #[test] + fn trailing_snapshot_supersedes_pre_compact_taskcreates_in_retained() { + // Mirrors the post-compact layout: pre-compact `TaskCreate` calls are + // preserved verbatim in retained_items, and the snapshot trails them. + // The trailing snapshot must reset the store to the captured state so + // pre-compact `TaskCreate`s do not surface as duplicates. + let snapshot = "[Session TaskStore snapshot]\n\nTaskStore: 2 task(s) (pending: 0, inprogress: 1, completed: 1, deleted: 0)\n\n- taskid: 1\n status: completed\n subject: A\n description: A-desc\n\n- taskid: 2\n status: inprogress\n subject: B\n description: B-desc\n"; + let history = vec![ + Item::tool_call("c1", "TaskCreate", r#"{"subject":"A","description":"A-desc"}"#), + Item::tool_call("u1", "TaskUpdate", r#"{"taskid":1,"status":"completed"}"#), + Item::tool_call("c2", "TaskCreate", r#"{"subject":"B","description":"B-desc"}"#), + Item::tool_call("u2", "TaskUpdate", r#"{"taskid":2,"status":"inprogress"}"#), + Item::system_message(snapshot), + Item::tool_call("compact-tasklist", "TaskList", "{}"), + Item::tool_call( + "c3", + "TaskCreate", + r#"{"subject":"C","description":"after compact"}"#, + ), + ]; + let store = TaskStore::from_history(&history); + let tasks = store.list(); + assert_eq!(tasks.len(), 3); + assert_eq!(tasks[0].taskid, 1); + assert_eq!(tasks[0].subject, "A"); + assert_eq!(tasks[0].status, TaskStatus::Completed); + assert_eq!(tasks[1].taskid, 2); + assert_eq!(tasks[1].subject, "B"); + assert_eq!(tasks[1].status, TaskStatus::Inprogress); + assert_eq!(tasks[2].taskid, 3); + assert_eq!(tasks[2].subject, "C"); + } }