feat: Support persisting paused todo lists to sessions so users can exit and resume paused todo sessions; Also fixed a bug so todo list pausing is only supported when being used in a session since it typically requires multi-turn communication

This commit is contained in:
Dark-Alex-17 committed 2026-09-14 13:57:08 -06:00
1 parent 5d59adadbc
commit 77da48a40f
7 files changed
+473 -33

No files matched your search

+91 -1
View File
@@ -95,6 +95,10 @@ pub struct Session {
messages: Vec<Message>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
data_urls: HashMap<String, String>,
#[serde(default, skip_serializing_if = "TodoList::is_default")]
todo_list: TodoList,
#[serde(default, skip_serializing_if = "Option::is_none")]
auto_continue_paused: Option<String>,
#[serde(default, skip_serializing_if = "TokenUsage::is_empty")]
token_usage: TokenUsage,
@@ -143,10 +147,15 @@ impl Session {
let mut session = Self {
name: name.to_string(),
save_session: app.save_session,
todo_list: ctx.todo_list.clone(),
auto_continue_paused: ctx.auto_continue_paused.clone(),
..Default::default()
};
session.set_role(role);
session.dirty = false;
// Undo set_role's dirtying, but stay dirty when carrying in-flight
// todo/pause state: `Session::exit` only saves dirty sessions, and a
// create-then-exit must not destroy the sole copy of that state.
session.dirty = !ctx.todo_list.is_default() || ctx.auto_continue_paused.is_some();
Ok(session)
}
@@ -204,6 +213,25 @@ impl Session {
&self.compressed_messages
}
pub fn todo_list(&self) -> &TodoList {
&self.todo_list
}
pub fn auto_continue_paused(&self) -> Option<&str> {
self.auto_continue_paused.as_deref()
}
pub fn sync_todo_state(&mut self, todo_list: &TodoList, auto_continue_paused: Option<&str>) {
if self.todo_list == *todo_list
&& self.auto_continue_paused.as_deref() == auto_continue_paused
{
return;
}
self.todo_list = todo_list.clone();
self.auto_continue_paused = auto_continue_paused.map(str::to_string);
self.dirty = true;
}
pub fn name(&self) -> &str {
&self.name
}
@@ -1203,6 +1231,54 @@ mod tests {
assert!(!exported.contains("cache_creation_tokens"));
}
#[test]
fn session_todo_state_survives_yaml_round_trip() {
let mut session = Session::default();
let mut todo_list = TodoList::new("Ship feature");
todo_list.add("Write code");
todo_list.add("Write tests");
todo_list.mark_done(1);
session.sync_todo_state(&todo_list, Some("Which database should tests target?"));
assert!(session.dirty());
let yaml = serde_yaml::to_string(&session).unwrap();
let reloaded: Session = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(reloaded.todo_list, todo_list);
assert_eq!(
reloaded.auto_continue_paused.as_deref(),
Some("Which database should tests target?")
);
}
#[test]
fn session_goal_only_todo_list_survives_yaml_round_trip() {
// `todo__init` sets a goal before any items exist; the serde skip
// predicate must not treat that as an empty list.
let mut session = Session::default();
session.sync_todo_state(&TodoList::new("Ship feature"), None);
assert!(session.dirty());
let yaml = serde_yaml::to_string(&session).unwrap();
let reloaded: Session = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(reloaded.todo_list.goal, "Ship feature");
assert!(reloaded.todo_list.todos.is_empty());
}
#[test]
fn session_without_todo_fields_loads_defaults() {
let session: Session = serde_yaml::from_str("model: provider:test\nmessages: []").unwrap();
assert!(session.todo_list.is_empty());
assert_eq!(session.auto_continue_paused, None);
let yaml = serde_yaml::to_string(&session).unwrap();
assert!(!yaml.contains("todo_list"));
assert!(!yaml.contains("auto_continue_paused"));
}
#[test]
fn session_mcp_tools_survives_yaml_round_trip() {
let mut session = Session::default();
let mut mcp_tools = IndexMap::new();
@@ -1301,6 +1377,20 @@ mod tests {
assert!(!session.dirty());
}
#[test]
fn session_new_from_ctx_with_in_flight_todo_state_is_dirty() {
let app_config = Arc::new(AppConfig::default());
let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
ctx.init_todo_list("Ship feature");
let session = Session::new_from_ctx(&ctx, &app_config, "mid-task").unwrap();
assert!(
session.dirty(),
"carried-in todo state must mark the session dirty, or a create-then-exit skips the only save of it"
);
}
#[test]
fn session_set_role_captures_role_info() {
let mut session = Session::default();