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

+2 -2
View File
@@ -63,14 +63,14 @@ pub(in crate::config) const DEFAULT_TODO_INSTRUCTIONS: &str = indoc! {"
- `todo__done`: Mark a task done by id. Call this immediately after completing each step.
- `todo__list`: Show the current todo list.
- `todo__clear`: Clear the entire todo list and reset the goal. Use when the user cancels or changes direction.
- `todo__pause`: Pause auto-continue and yield the turn to the user, keeping incomplete todos intact. Use when you are blocked on user input or a user-side action. Auto-continue resumes on the user's next message.
- `todo__pause`: Pause auto-continue and yield the turn to the user, keeping incomplete todos intact. Use when you are blocked on user input or a user-side action. Auto-continue resumes on the user's next message. Requires an active session.
RULES:
- Always create a todo list before starting work.
- Mark each task done as soon as you finish it; do not batch.
- If the user cancels the current task or changes direction, call `todo__clear` immediately.
- If you need user input (or the user must do something on their end) before you can continue, call `todo__pause` with the reason, ask the user, and end your turn. NEVER mark unfinished todos done just to stop the auto-continue prompts.
- `todo__pause` is for top-level sessions only. Subagents are driven to completion autonomously and cannot pause. If you are a subagent and need input, use a `user__*` tool (it escalates to the root agent mid-turn and blocks until answered, so you keep working without ending your turn).
- `todo__pause` requires an active session and is rejected without one — there is no cross-turn history to resume from, so ask your question directly in your reply instead. Subagents are driven to completion autonomously and cannot pause. If you are a subagent and need input, use a `user__*` tool (it escalates to the root agent mid-turn and blocks until answered, so you keep working without ending your turn).
- If you stop with incomplete tasks, the system will automatically prompt you to continue."
};
+265 -7
View File
@@ -611,20 +611,26 @@ impl RequestContext {
pub fn init_todo_list(&mut self, goal: &str) {
self.todo_list = TodoList::new(goal);
self.auto_continue_paused = None;
self.sync_todo_state_to_session();
}
pub fn add_todo(&mut self, task: &str) -> usize {
self.todo_list.add(task)
let id = self.todo_list.add(task);
self.sync_todo_state_to_session();
id
}
pub fn mark_todo_done(&mut self, id: usize) -> bool {
self.todo_list.mark_done(id)
let marked = self.todo_list.mark_done(id);
self.sync_todo_state_to_session();
marked
}
pub fn clear_todo_list(&mut self) {
self.todo_list.clear();
self.auto_continue_count = 0;
self.auto_continue_paused = None;
self.sync_todo_state_to_session();
}
pub fn increment_auto_continue_count(&mut self) {
@@ -633,10 +639,21 @@ impl RequestContext {
pub fn pause_auto_continue(&mut self, reason: &str) {
self.auto_continue_paused = Some(reason.to_string());
self.sync_todo_state_to_session();
}
pub fn resume_auto_continue(&mut self) {
self.auto_continue_paused = None;
self.sync_todo_state_to_session();
}
/// Eagerly mirrors todo state into the session copy so every save path
/// (`.save session`, exit-time dirty save, flush) serializes the current
/// state without each needing its own sync step.
fn sync_todo_state_to_session(&mut self) {
if let Some(session) = self.session.as_mut() {
session.sync_todo_state(&self.todo_list, self.auto_continue_paused.as_deref());
}
}
pub fn reset_continuation_count(&mut self) {
@@ -1024,6 +1041,11 @@ impl RequestContext {
let sessions_dir = self.sessions_dir();
session.exit(&sessions_dir, self.working_mode.is_repl())?;
self.discontinuous_last_message();
// Todo state is session-scoped: it was mirrored into the session
// just saved, and a stale pause left behind would suppress
// continuation in the now-sessionless context.
self.todo_list = TodoList::default();
self.auto_continue_paused = None;
}
Ok(())
}
@@ -2604,6 +2626,16 @@ impl RequestContext {
functions.extend(self.select_enabled_mcp_servers(role));
self.apply_job_tool_visibility(&mut functions);
// Pausing only makes sense with cross-turn history to resume from;
// without a session the user's answer would arrive with no context,
// so the declaration is withheld (handle_todo_tool also rejects).
// Macro contexts are deliberately sessionless, so pause is stripped
// inside isolated macros by design.
if self.session.is_none() {
let pause_name = format!("{TODO_FUNCTION_PREFIX}pause");
functions.retain(|f| f.name != pause_name);
}
if functions.is_empty() {
None
} else {
@@ -4626,6 +4658,20 @@ impl RequestContext {
}
}
self.session = session;
// Adopt the session's todo state wholesale: a session without any todo
// state resets ctx to defaults, so nothing leaks across a session switch.
match &self.session {
Some(session) => {
self.todo_list = session.todo_list().clone();
self.auto_continue_paused = session.auto_continue_paused().map(str::to_string);
}
None => {
// Structurally unreachable today (both arms above assign Some);
// kept so the no-leakage reset stays explicit.
self.todo_list = TodoList::default();
self.auto_continue_paused = None;
}
}
self.refresh_mcp_tool_filters();
self.init_agent_session_variables(new_session)?;
Ok(())
@@ -4762,6 +4808,9 @@ impl RequestContext {
self.auto_continue_count = 0;
self.todo_list = TodoList::default();
self.auto_continue_paused = None;
// Defensive: the already-in-a-session bail above guarantees no session
// is attached here; this sync only matters if that bail is relaxed.
self.sync_todo_state_to_session();
if let Some(session_name) = session_name.as_deref() {
self.use_session(app, Some(session_name), abort_signal)
@@ -6684,6 +6733,24 @@ mod tests {
assert!(!names.contains(&"todo__done"));
}
#[test]
fn select_functions_strips_todo_pause_without_session() {
let mut ctx = create_test_ctx();
ctx.tool_scope.functions.append_todo_functions();
let mut role = Role::new("r", "p");
role.set_enabled_tools(Some(vec!["all".to_string()]));
let fns = ctx.select_functions(&role).unwrap();
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
assert!(!names.contains(&"todo__pause"));
assert!(names.contains(&"todo__init"));
ctx.session = Some(Session::default());
let fns = ctx.select_functions(&role).unwrap();
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"todo__pause"));
}
#[test]
fn select_functions_re_adds_skill_tools_when_role_skills_enabled_unset() {
let mut ctx = create_test_ctx();
@@ -7790,6 +7857,153 @@ mod tests {
assert!(ctx.session.is_none());
}
fn write_paused_todo_session(ctx: &RequestContext, name: &str) {
let session_path = ctx.session_file(name);
ensure_parent_exists(&session_path).unwrap();
write(
&session_path,
"model: test-seeded:test-chat\nmessages: []\ntodo_list:\n goal: Ship feature\n todos:\n - id: 1\n desc: Write code\n done: false\nauto_continue_paused: Which database?\n",
)
.unwrap();
}
#[test]
fn todo_mutators_eagerly_sync_to_session() {
let mut ctx = create_test_ctx();
ctx.session = Some(Session::default());
ctx.init_todo_list("Ship feature");
let id = ctx.add_todo("Write code");
{
let session = ctx.session.as_ref().unwrap();
assert_eq!(session.todo_list().goal, "Ship feature");
assert_eq!(session.todo_list().todos.len(), 1);
assert!(session.dirty());
}
ctx.mark_todo_done(id);
assert!(ctx.session.as_ref().unwrap().todo_list().todos[0].done);
ctx.pause_auto_continue("Need credentials");
assert_eq!(
ctx.session.as_ref().unwrap().auto_continue_paused(),
Some("Need credentials")
);
ctx.resume_auto_continue();
assert!(
ctx.session
.as_ref()
.unwrap()
.auto_continue_paused()
.is_none()
);
ctx.clear_todo_list();
assert!(ctx.session.as_ref().unwrap().todo_list().is_default());
}
#[test]
#[serial]
fn use_session_adopts_persisted_todo_state() {
let _guard = TestConfigDirGuard::new();
let mut ctx = create_test_ctx();
ctx.init_todo_list("stale goal");
ctx.pause_auto_continue("stale question");
write_paused_todo_session(&ctx, "with-todos");
let app = ctx.app.config.clone();
run_async(ctx.use_session(&app, Some("with-todos"), utils::create_abort_signal())).unwrap();
assert_eq!(ctx.todo_list.goal, "Ship feature");
assert_eq!(ctx.todo_list.todos.len(), 1);
assert_eq!(ctx.auto_continue_paused.as_deref(), Some("Which database?"));
}
#[test]
#[serial]
fn use_session_without_todo_state_resets_ctx() {
let _guard = TestConfigDirGuard::new();
let mut ctx = create_test_ctx();
ctx.init_todo_list("stale goal");
ctx.add_todo("stale task");
ctx.pause_auto_continue("stale question");
let session_path = ctx.session_file("plain");
ensure_parent_exists(&session_path).unwrap();
write(
&session_path,
"model: test-seeded:test-chat\nmessages: []\n",
)
.unwrap();
let app = ctx.app.config.clone();
run_async(ctx.use_session(&app, Some("plain"), utils::create_abort_signal())).unwrap();
assert_eq!(ctx.todo_list, TodoList::default());
assert!(ctx.auto_continue_paused.is_none());
}
#[test]
#[serial]
fn use_session_new_session_carries_in_flight_todo_state() {
let _guard = TestConfigDirGuard::new();
let mut ctx = create_test_ctx();
ctx.init_todo_list("Ship feature");
ctx.add_todo("Write code");
ctx.pause_auto_continue("Which database?");
let app = ctx.app.config.clone();
run_async(ctx.use_session(&app, Some("mid-task"), utils::create_abort_signal())).unwrap();
assert_eq!(ctx.todo_list.goal, "Ship feature");
assert_eq!(ctx.auto_continue_paused.as_deref(), Some("Which database?"));
let session = ctx.session.as_ref().unwrap();
assert_eq!(session.todo_list().goal, "Ship feature");
assert_eq!(session.auto_continue_paused(), Some("Which database?"));
}
#[test]
#[serial]
fn restored_pause_resumes_in_ctx_and_session_mirror() {
let _guard = TestConfigDirGuard::new();
let mut ctx = create_test_ctx();
write_paused_todo_session(&ctx, "paused");
let app = ctx.app.config.clone();
run_async(ctx.use_session(&app, Some("paused"), utils::create_abort_signal())).unwrap();
assert_eq!(ctx.auto_continue_paused.as_deref(), Some("Which database?"));
assert!(ctx.todo_list.has_incomplete());
ctx.resume_auto_continue();
assert!(ctx.auto_continue_paused.is_none());
assert!(
ctx.session
.as_ref()
.unwrap()
.auto_continue_paused()
.is_none(),
"a subsequent save must not resurrect the pause"
);
}
#[test]
#[serial]
fn exit_session_resets_todo_and_pause_state() {
let _guard = TestConfigDirGuard::new();
let mut ctx = create_test_ctx();
write_paused_todo_session(&ctx, "paused-exit");
let app = ctx.app.config.clone();
run_async(ctx.use_session(&app, Some("paused-exit"), utils::create_abort_signal()))
.unwrap();
assert!(ctx.auto_continue_paused.is_some());
ctx.exit_session().unwrap();
assert!(ctx.session.is_none());
assert_eq!(ctx.todo_list, TodoList::default());
assert!(ctx.auto_continue_paused.is_none());
}
#[test]
#[serial]
fn use_role_obj_and_exit_role_full_cycle() {
@@ -8093,6 +8307,46 @@ mod tests {
);
}
#[test]
#[serial]
fn use_agent_resets_pre_agent_todo_state() {
let _guard = TestConfigDirGuard::new();
let mut ctx = create_test_ctx();
// No session attached: use_agent bails on an existing one, so the
// pre-agent state only ever lives on the bare ctx.
ctx.init_todo_list("pre-agent goal");
ctx.add_todo("pre-agent task");
ctx.pause_auto_continue("pre-agent question");
let app = ctx.app.config.clone();
let agent_name = format!(
"test_agent_{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let agent_dir = paths::agent_data_dir(&agent_name);
create_dir_all(&agent_dir).unwrap();
write(
agent_dir.join("config.yaml"),
format!("name: {agent_name}\ninstructions: hi\n"),
)
.unwrap();
let abort = utils::create_abort_signal();
run_async(ctx.use_agent(&app, &agent_name, Some("agent_session"), abort)).unwrap();
assert_eq!(ctx.todo_list, TodoList::default());
assert!(ctx.auto_continue_paused.is_none());
let session = ctx.session.as_ref().expect("agent session should engage");
assert!(
session.todo_list().is_default(),
"pre-agent todo state must not leak into the agent session"
);
assert!(session.auto_continue_paused().is_none());
}
fn first_file(dir: &Path) -> Option<PathBuf> {
for entry in read_dir(dir).ok()?.flatten() {
let path = entry.path();
@@ -9702,13 +9956,17 @@ mod tests {
pid_file.display()
);
// `kill -0` also succeeds on zombies, and in sandboxed environments
// orphaned zombies may never get reaped — a Z state counts as dead.
let alive = |pid: &str| {
Command::new("kill")
.args(["-0", pid])
let out = Command::new("ps")
.args(["-o", "stat=", "-p", pid])
.output()
.unwrap()
.status
.success()
.unwrap();
out.status.success()
&& !String::from_utf8_lossy(&out.stdout)
.trim_start()
.starts_with('Z')
};
let mut polls = 0;
let mut still_alive = alive(&pid);
+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();
+9 -2
View File
@@ -16,7 +16,7 @@ impl TodoStatus {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TodoItem {
pub id: usize,
#[serde(alias = "description")]
@@ -24,7 +24,7 @@ pub struct TodoItem {
pub done: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct TodoList {
#[serde(default)]
pub goal: String,
@@ -67,6 +67,13 @@ impl TodoList {
self.todos.is_empty()
}
/// Serde skip predicate for session persistence. Unlike `is_empty` (items
/// only), the goal counts: a goal-only list right after `todo__init` must
/// still serialize.
pub fn is_default(&self) -> bool {
*self == Self::default()
}
pub fn clear(&mut self) {
self.goal.clear();
self.todos.clear();