From 420f74edc668281e3f4c87e794ab99f09b5b3311 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 3 May 2026 21:33:50 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20TaskStore=20snapshot=20=E3=82=92=20JSON?= =?UTF-8?q?=20=E3=83=96=E3=83=AD=E3=83=83=E3=82=AF=E5=8C=96=20+=20?= =?UTF-8?q?=E6=A7=8B=E9=80=A0=E3=83=A9=E3=82=A6=E3=83=B3=E3=83=89=E3=83=88?= =?UTF-8?q?=E3=83=AA=E3=83=83=E3=83=97=E3=83=86=E3=82=B9=E3=83=88=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tools/src/task.rs | 187 ++++++++++++++++++++++++--------------- 1 file changed, 115 insertions(+), 72 deletions(-) diff --git a/crates/tools/src/task.rs b/crates/tools/src/task.rs index 133cc051..e683fe49 100644 --- a/crates/tools/src/task.rs +++ b/crates/tools/src/task.rs @@ -4,8 +4,6 @@ //! is reconstructed on resume by replaying TaskCreate / TaskUpdate tool-call //! arguments from persisted history. -use std::collections::HashMap; -use std::fmt::Write as _; use std::sync::{Arc, Mutex}; use async_trait::async_trait; @@ -375,77 +373,25 @@ pub fn snapshot_overview(tasks: &[TaskEntry]) -> String { } pub fn render_snapshot(tasks: &[TaskEntry]) -> String { - if tasks.is_empty() { - return "TaskStore is empty.".to_string(); - } - let mut out = String::new(); - let _ = writeln!(&mut out, "{}", snapshot_overview(tasks)); - for task in tasks { - let _ = writeln!( - &mut out, - "\n- taskid: {}\n status: {}\n subject: {}\n description: {}", - task.taskid, task.status, task.subject, task.description - ); - } - out + let snapshot = TaskSnapshot { + tasks: tasks.to_vec(), + }; + let json = serde_json::to_string_pretty(&snapshot) + .unwrap_or_else(|_| String::from("{\"tasks\":[]}")); + format!("{}\n\n```json\n{}\n```\n", snapshot_overview(tasks), json) } fn parse_compact_snapshot_text(text: &str) -> Option> { if !text.starts_with("[Session TaskStore snapshot]") { return None; } - let body = text.split_once("\n\n")?.1; - parse_rendered_snapshot(body).or_else(|| { - if body.contains("TaskStore is empty.") { - Some(Vec::new()) - } else { - None - } - }) -} - -fn parse_rendered_snapshot(text: &str) -> Option> { - if text.contains("TaskStore is empty.") { - return Some(Vec::new()); - } - let mut tasks = Vec::new(); - let mut current: HashMap<&str, String> = HashMap::new(); - for line in text.lines() { - let line = line.trim_start(); - if let Some(value) = line.strip_prefix("- taskid: ") { - if !current.is_empty() { - tasks.push(task_from_fields(¤t)?); - current.clear(); - } - current.insert("taskid", value.to_string()); - } else if let Some(value) = line.strip_prefix("status: ") { - current.insert("status", value.to_string()); - } else if let Some(value) = line.strip_prefix("subject: ") { - current.insert("subject", value.to_string()); - } else if let Some(value) = line.strip_prefix("description: ") { - current.insert("description", value.to_string()); - } - } - if !current.is_empty() { - tasks.push(task_from_fields(¤t)?); - } - Some(tasks) -} - -fn task_from_fields(fields: &HashMap<&str, String>) -> Option { - let status = match fields.get("status")?.as_str() { - "pending" => TaskStatus::Pending, - "inprogress" => TaskStatus::Inprogress, - "completed" => TaskStatus::Completed, - "deleted" => TaskStatus::Deleted, - _ => return None, - }; - Some(TaskEntry { - taskid: fields.get("taskid")?.parse().ok()?, - status, - subject: fields.get("subject")?.clone(), - description: fields.get("description")?.clone(), - }) + let start_marker = "```json\n"; + let end_marker = "\n```"; + let start = text.find(start_marker)? + start_marker.len(); + let rest = &text[start..]; + let end = rest.find(end_marker)?; + let snapshot: TaskSnapshot = serde_json::from_str(&rest[..end]).ok()?; + Some(snapshot.tasks) } fn task_create_tool(store: TaskStore) -> ToolDefinition { @@ -552,7 +498,9 @@ mod tests { let out = list.execute("{}").await.unwrap(); assert!(out.summary.contains("1 task(s)")); - assert!(out.content.unwrap().contains("taskid: 1")); + let content = out.content.unwrap(); + assert!(content.contains("\"taskid\": 1")); + assert!(content.contains("```json")); } #[tokio::test] @@ -589,11 +537,23 @@ mod tests { assert_eq!(tasks[1].status, TaskStatus::Completed); } + /// Wrap snapshot text the way `Pod::try_post_run_compact` does, so tests + /// exercise the exact format that goes through the session log. + fn wrap_snapshot_system_message(snapshot: &str) -> String { + format!( + "[Session TaskStore snapshot]\n\n{snapshot}\n\n\ + This is the complete session task list preserved across compaction. \ + The following TaskList tool result presents the same state through the tool lane." + ) + } + #[test] fn replay_history_uses_compact_snapshot_and_continues_updates() { - let snapshot = "[Session TaskStore snapshot]\n\nTaskStore: 1 task(s) (pending: 0, inprogress: 1, completed: 0, deleted: 0)\n\n- taskid: 1\n status: inprogress\n subject: kept\n description: from compact\n"; + let pre = TaskStore::new(); + pre.create("kept".into(), "from compact".into()); + pre.update(1, Some(TaskStatus::Inprogress), None, None).unwrap(); let history = vec![ - Item::system_message(snapshot), + Item::system_message(wrap_snapshot_system_message(&pre.snapshot_text())), Item::tool_call("u1", "TaskUpdate", r#"{"taskid":1,"status":"completed"}"#), Item::tool_call( "c2", @@ -616,13 +576,17 @@ mod tests { // 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 pre = TaskStore::new(); + pre.create("A".into(), "A-desc".into()); + pre.update(1, Some(TaskStatus::Completed), None, None).unwrap(); + pre.create("B".into(), "B-desc".into()); + pre.update(2, Some(TaskStatus::Inprogress), None, None).unwrap(); 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::system_message(wrap_snapshot_system_message(&pre.snapshot_text())), Item::tool_call("compact-tasklist", "TaskList", "{}"), Item::tool_call( "c3", @@ -642,4 +606,83 @@ mod tests { assert_eq!(tasks[2].taskid, 3); assert_eq!(tasks[2].subject, "C"); } + + #[test] + fn snapshot_round_trips_multiline_subject_and_description() { + // Subject / description with embedded newlines and shape-breaking + // characters must survive snapshot serialization unchanged. + let pre = TaskStore::new(); + pre.create( + "subject with\nembedded newline\n- bullet".into(), + "desc:\n status: not-actually-a-field\n ```code fence```".into(), + ); + pre.update(1, Some(TaskStatus::Inprogress), None, None).unwrap(); + + let history = vec![Item::system_message(wrap_snapshot_system_message( + &pre.snapshot_text(), + ))]; + let store = TaskStore::from_history(&history); + let tasks = store.list(); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].subject, "subject with\nembedded newline\n- bullet"); + assert_eq!( + tasks[0].description, + "desc:\n status: not-actually-a-field\n ```code fence```" + ); + assert_eq!(tasks[0].status, TaskStatus::Inprogress); + } + + #[test] + fn synthetic_compact_tasklist_pair_is_well_formed() { + // Mirrors `Pod::try_post_run_compact`'s synthetic insertion: + // a system snapshot message followed by a TaskList tool_call/tool_result + // pair sharing the `compact-tasklist` id. Verify the structural + // contract every provider request builder relies on (matched call_id, + // tool name, content recoverable to the same TaskStore state). + let pre = TaskStore::new(); + pre.create("plan".into(), "do A then B".into()); + + let snapshot_text = pre.snapshot_text(); + let system = Item::system_message(wrap_snapshot_system_message(&snapshot_text)); + let call = Item::tool_call("compact-tasklist", "TaskList", "{}"); + let result = Item::tool_result_with_content( + "compact-tasklist", + snapshot_overview(&pre.list()), + snapshot_text.clone(), + ); + + // The system message embeds a parseable snapshot. + let extracted = system + .as_text() + .and_then(parse_compact_snapshot_text) + .expect("system message should parse as snapshot"); + assert_eq!(extracted, pre.list()); + + // The synthetic call/result pair shares one call_id and carries the + // expected tool name + detailed content. + match (&call, &result) { + ( + Item::ToolCall { + call_id: c_id, + name, + .. + }, + Item::ToolResult { + call_id: r_id, + content, + .. + }, + ) => { + assert_eq!(c_id.as_str(), r_id.as_str()); + assert_eq!(c_id.as_str(), "compact-tasklist"); + assert_eq!(name, "TaskList"); + assert_eq!(content.as_deref(), Some(snapshot_text.as_str())); + } + other => panic!("unexpected synthetic pair shape: {other:?}"), + } + + // Replaying the full triple reconstructs the same TaskStore. + let store = TaskStore::from_history(&[system, call, result]); + assert_eq!(store.list(), pre.list()); + } }