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:
7 files changed
+473
-33
No files matched your search
@@ -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__done`: Mark a task done by id. Call this immediately after completing each step.
|
||||||
- `todo__list`: Show the current todo list.
|
- `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__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:
|
RULES:
|
||||||
- Always create a todo list before starting work.
|
- Always create a todo list before starting work.
|
||||||
- Mark each task done as soon as you finish it; do not batch.
|
- 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 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.
|
- 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."
|
- If you stop with incomplete tasks, the system will automatically prompt you to continue."
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -611,20 +611,26 @@ impl RequestContext {
|
|||||||
pub fn init_todo_list(&mut self, goal: &str) {
|
pub fn init_todo_list(&mut self, goal: &str) {
|
||||||
self.todo_list = TodoList::new(goal);
|
self.todo_list = TodoList::new(goal);
|
||||||
self.auto_continue_paused = None;
|
self.auto_continue_paused = None;
|
||||||
|
self.sync_todo_state_to_session();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_todo(&mut self, task: &str) -> usize {
|
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 {
|
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) {
|
pub fn clear_todo_list(&mut self) {
|
||||||
self.todo_list.clear();
|
self.todo_list.clear();
|
||||||
self.auto_continue_count = 0;
|
self.auto_continue_count = 0;
|
||||||
self.auto_continue_paused = None;
|
self.auto_continue_paused = None;
|
||||||
|
self.sync_todo_state_to_session();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn increment_auto_continue_count(&mut self) {
|
pub fn increment_auto_continue_count(&mut self) {
|
||||||
@@ -633,10 +639,21 @@ impl RequestContext {
|
|||||||
|
|
||||||
pub fn pause_auto_continue(&mut self, reason: &str) {
|
pub fn pause_auto_continue(&mut self, reason: &str) {
|
||||||
self.auto_continue_paused = Some(reason.to_string());
|
self.auto_continue_paused = Some(reason.to_string());
|
||||||
|
self.sync_todo_state_to_session();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resume_auto_continue(&mut self) {
|
pub fn resume_auto_continue(&mut self) {
|
||||||
self.auto_continue_paused = None;
|
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) {
|
pub fn reset_continuation_count(&mut self) {
|
||||||
@@ -1024,6 +1041,11 @@ impl RequestContext {
|
|||||||
let sessions_dir = self.sessions_dir();
|
let sessions_dir = self.sessions_dir();
|
||||||
session.exit(&sessions_dir, self.working_mode.is_repl())?;
|
session.exit(&sessions_dir, self.working_mode.is_repl())?;
|
||||||
self.discontinuous_last_message();
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -2604,6 +2626,16 @@ impl RequestContext {
|
|||||||
functions.extend(self.select_enabled_mcp_servers(role));
|
functions.extend(self.select_enabled_mcp_servers(role));
|
||||||
self.apply_job_tool_visibility(&mut functions);
|
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() {
|
if functions.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -4626,6 +4658,20 @@ impl RequestContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.session = session;
|
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.refresh_mcp_tool_filters();
|
||||||
self.init_agent_session_variables(new_session)?;
|
self.init_agent_session_variables(new_session)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -4762,6 +4808,9 @@ impl RequestContext {
|
|||||||
self.auto_continue_count = 0;
|
self.auto_continue_count = 0;
|
||||||
self.todo_list = TodoList::default();
|
self.todo_list = TodoList::default();
|
||||||
self.auto_continue_paused = None;
|
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() {
|
if let Some(session_name) = session_name.as_deref() {
|
||||||
self.use_session(app, Some(session_name), abort_signal)
|
self.use_session(app, Some(session_name), abort_signal)
|
||||||
@@ -6684,6 +6733,24 @@ mod tests {
|
|||||||
assert!(!names.contains(&"todo__done"));
|
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]
|
#[test]
|
||||||
fn select_functions_re_adds_skill_tools_when_role_skills_enabled_unset() {
|
fn select_functions_re_adds_skill_tools_when_role_skills_enabled_unset() {
|
||||||
let mut ctx = create_test_ctx();
|
let mut ctx = create_test_ctx();
|
||||||
@@ -7790,6 +7857,153 @@ mod tests {
|
|||||||
assert!(ctx.session.is_none());
|
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]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn use_role_obj_and_exit_role_full_cycle() {
|
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> {
|
fn first_file(dir: &Path) -> Option<PathBuf> {
|
||||||
for entry in read_dir(dir).ok()?.flatten() {
|
for entry in read_dir(dir).ok()?.flatten() {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
@@ -9702,13 +9956,17 @@ mod tests {
|
|||||||
pid_file.display()
|
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| {
|
let alive = |pid: &str| {
|
||||||
Command::new("kill")
|
let out = Command::new("ps")
|
||||||
.args(["-0", pid])
|
.args(["-o", "stat=", "-p", pid])
|
||||||
.output()
|
.output()
|
||||||
.unwrap()
|
.unwrap();
|
||||||
.status
|
out.status.success()
|
||||||
.success()
|
&& !String::from_utf8_lossy(&out.stdout)
|
||||||
|
.trim_start()
|
||||||
|
.starts_with('Z')
|
||||||
};
|
};
|
||||||
let mut polls = 0;
|
let mut polls = 0;
|
||||||
let mut still_alive = alive(&pid);
|
let mut still_alive = alive(&pid);
|
||||||
|
|||||||
+91
-1
@@ -95,6 +95,10 @@ pub struct Session {
|
|||||||
messages: Vec<Message>,
|
messages: Vec<Message>,
|
||||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
data_urls: HashMap<String, String>,
|
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")]
|
#[serde(default, skip_serializing_if = "TokenUsage::is_empty")]
|
||||||
token_usage: TokenUsage,
|
token_usage: TokenUsage,
|
||||||
|
|
||||||
@@ -143,10 +147,15 @@ impl Session {
|
|||||||
let mut session = Self {
|
let mut session = Self {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
save_session: app.save_session,
|
save_session: app.save_session,
|
||||||
|
todo_list: ctx.todo_list.clone(),
|
||||||
|
auto_continue_paused: ctx.auto_continue_paused.clone(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
session.set_role(role);
|
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)
|
Ok(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +213,25 @@ impl Session {
|
|||||||
&self.compressed_messages
|
&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 {
|
pub fn name(&self) -> &str {
|
||||||
&self.name
|
&self.name
|
||||||
}
|
}
|
||||||
@@ -1203,6 +1231,54 @@ mod tests {
|
|||||||
assert!(!exported.contains("cache_creation_tokens"));
|
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() {
|
fn session_mcp_tools_survives_yaml_round_trip() {
|
||||||
let mut session = Session::default();
|
let mut session = Session::default();
|
||||||
let mut mcp_tools = IndexMap::new();
|
let mut mcp_tools = IndexMap::new();
|
||||||
@@ -1301,6 +1377,20 @@ mod tests {
|
|||||||
assert!(!session.dirty());
|
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]
|
#[test]
|
||||||
fn session_set_role_captures_role_info() {
|
fn session_set_role_captures_role_info() {
|
||||||
let mut session = Session::default();
|
let mut session = Session::default();
|
||||||
|
|||||||
+9
-2
@@ -16,7 +16,7 @@ impl TodoStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct TodoItem {
|
pub struct TodoItem {
|
||||||
pub id: usize,
|
pub id: usize,
|
||||||
#[serde(alias = "description")]
|
#[serde(alias = "description")]
|
||||||
@@ -24,7 +24,7 @@ pub struct TodoItem {
|
|||||||
pub done: bool,
|
pub done: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct TodoList {
|
pub struct TodoList {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub goal: String,
|
pub goal: String,
|
||||||
@@ -67,6 +67,13 @@ impl TodoList {
|
|||||||
self.todos.is_empty()
|
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) {
|
pub fn clear(&mut self) {
|
||||||
self.goal.clear();
|
self.goal.clear();
|
||||||
self.todos.clear();
|
self.todos.clear();
|
||||||
|
|||||||
+5
-1
@@ -2681,7 +2681,7 @@ mod tests {
|
|||||||
FIXTURE_ANNOTATED_TEXT, FIXTURE_ANNOTATED_URI, FIXTURE_BLOB_BYTES, FIXTURE_BLOB_URI,
|
FIXTURE_ANNOTATED_TEXT, FIXTURE_ANNOTATED_URI, FIXTURE_BLOB_BYTES, FIXTURE_BLOB_URI,
|
||||||
FIXTURE_LOG_TEXT, FIXTURE_LOG_URI, FixtureServer, fixture_runtime,
|
FIXTURE_LOG_TEXT, FIXTURE_LOG_URI, FixtureServer, fixture_runtime,
|
||||||
};
|
};
|
||||||
use crate::config::{Agent, AgentConfig, AppConfig, AppState, WorkingMode};
|
use crate::config::{Agent, AgentConfig, AppConfig, AppState, Session, WorkingMode};
|
||||||
use crate::supervisor::escalation::{EscalationQueue, EscalationRequest};
|
use crate::supervisor::escalation::{EscalationQueue, EscalationRequest};
|
||||||
use crate::supervisor::mailbox::Inbox;
|
use crate::supervisor::mailbox::Inbox;
|
||||||
use crate::supervisor::notification::{agent_notification, job_notification};
|
use crate::supervisor::notification::{agent_notification, job_notification};
|
||||||
@@ -5081,6 +5081,9 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd);
|
let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd);
|
||||||
ctx.tool_scope.functions.append_todo_functions();
|
ctx.tool_scope.functions.append_todo_functions();
|
||||||
|
// Pausing requires an active session (there is no cross-turn history
|
||||||
|
// to resume from otherwise), so give the ctx one.
|
||||||
|
ctx.session = Some(Session::default());
|
||||||
|
|
||||||
// A blank reason is rejected and does not pause.
|
// A blank reason is rejected and does not pause.
|
||||||
let out = run_async(call_with_args("todo__pause", json!({"reason": " "})).eval(&mut ctx))
|
let out = run_async(call_with_args("todo__pause", json!({"reason": " "})).eval(&mut ctx))
|
||||||
@@ -5130,6 +5133,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd);
|
let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd);
|
||||||
ctx.tool_scope.functions.append_todo_functions();
|
ctx.tool_scope.functions.append_todo_functions();
|
||||||
|
// session is deliberately None: the subagent reject must win over the session reject.
|
||||||
ctx.self_agent_id = Some("agent_sisyphus_abc123".to_string());
|
ctx.self_agent_id = Some("agent_sisyphus_abc123".to_string());
|
||||||
|
|
||||||
let out = run_async(
|
let out = run_async(
|
||||||
|
|||||||
+38
-3
@@ -92,9 +92,11 @@ pub fn todo_function_declarations() -> Vec<FunctionDeclaration> {
|
|||||||
incomplete todos intact. Use when you cannot proceed without user input \
|
incomplete todos intact. Use when you cannot proceed without user input \
|
||||||
or a user-side action. NEVER mark unfinished todos as done just to stop \
|
or a user-side action. NEVER mark unfinished todos as done just to stop \
|
||||||
auto-continuation. Auto-continue resumes automatically on the user's \
|
auto-continuation. Auto-continue resumes automatically on the user's \
|
||||||
next message. Unavailable to subagents: they are driven to completion \
|
next message. Requires an active session (the pause/resume cycle needs \
|
||||||
autonomously — a subagent needing input must use a user__* tool, which \
|
cross-turn history). Unavailable to subagents: they are driven to \
|
||||||
escalates to the root agent mid-turn and blocks until answered."
|
completion autonomously — a subagent needing input must use a user__* \
|
||||||
|
tool, which escalates to the root agent mid-turn and blocks until \
|
||||||
|
answered."
|
||||||
.to_string(),
|
.to_string(),
|
||||||
parameters: JsonSchema {
|
parameters: JsonSchema {
|
||||||
type_value: Some("object".to_string()),
|
type_value: Some("object".to_string()),
|
||||||
@@ -189,6 +191,15 @@ pub fn handle_todo_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value)
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ctx.session.is_none() {
|
||||||
|
return Ok(json!({
|
||||||
|
"error": "todo__pause requires an active session: without one there is no \
|
||||||
|
cross-turn history, so the user's answer would arrive with no \
|
||||||
|
context to resume from. Ask the user your question directly in \
|
||||||
|
your reply instead, then end your turn."
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
let reason = args
|
let reason = args
|
||||||
.get("reason")
|
.get("reason")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
@@ -211,3 +222,27 @@ pub fn handle_todo_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value)
|
|||||||
_ => bail!("Unknown todo action: {action}"),
|
_ => bail!("Unknown todo action: {action}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::{AppConfig, AppState, WorkingMode};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pause_without_session_returns_teaching_error() {
|
||||||
|
let mut app_state = AppState::test_default();
|
||||||
|
app_state.config = Arc::new(AppConfig {
|
||||||
|
auto_continue: true,
|
||||||
|
..AppConfig::default()
|
||||||
|
});
|
||||||
|
let mut ctx = RequestContext::new(Arc::new(app_state), WorkingMode::Cmd);
|
||||||
|
|
||||||
|
let result =
|
||||||
|
handle_todo_tool(&mut ctx, "todo__pause", &json!({"reason": "need input"})).unwrap();
|
||||||
|
|
||||||
|
let error = result["error"].as_str().unwrap();
|
||||||
|
assert!(error.contains("requires an active session"));
|
||||||
|
assert!(ctx.auto_continue_paused.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
-17
@@ -52,7 +52,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
|
|||||||
1. BEFORE marking a todo done: verify the work compiles/works. No premature completion.
|
1. BEFORE marking a todo done: verify the work compiles/works. No premature completion.
|
||||||
2. If a todo is broad (e.g. \"implement X and implement Y\"): break it into specific subtasks FIRST using todo__add, then work on those.\n\
|
2. If a todo is broad (e.g. \"implement X and implement Y\"): break it into specific subtasks FIRST using todo__add, then work on those.\n\
|
||||||
3. Each todo should be atomic and be \"single responsibility\" - completable in one focused action.
|
3. Each todo should be atomic and be \"single responsibility\" - completable in one focused action.
|
||||||
4. If you are blocked on user input or a user-side action: call todo__pause with the reason, ask the user, and end your turn. NEVER mark unfinished todos done just to stop this reminder.
|
4. If you are blocked on user input or a user-side action: call todo__pause with the reason (if available); otherwise ask the user directly in your reply and end your turn. NEVER mark unfinished todos done just to stop this reminder.
|
||||||
5. Otherwise, continue with the next pending item now. Call tools immediately."
|
5. Otherwise, continue with the next pending item now. Call tools immediately."
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -433,6 +433,8 @@ Type ".help" for additional help.
|
|||||||
// OSC 11 / DA1 responses) so they don't get injected into the prompt.
|
// OSC 11 / DA1 responses) so they don't get injected into the prompt.
|
||||||
drain_stale_tty_input();
|
drain_stale_tty_input();
|
||||||
|
|
||||||
|
print_pause_banner(&self.ctx.read());
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if self.abort_signal.aborted_ctrld() {
|
if self.abort_signal.aborted_ctrld() {
|
||||||
break;
|
break;
|
||||||
@@ -927,6 +929,7 @@ pub async fn run_repl_command(
|
|||||||
let (compressed, active) = replay::snapshot(session);
|
let (compressed, active) = replay::snapshot(session);
|
||||||
replay::render(app.as_ref(), &compressed, &active)?;
|
replay::render(app.as_ref(), &compressed, &active)?;
|
||||||
}
|
}
|
||||||
|
print_pause_banner(ctx);
|
||||||
}
|
}
|
||||||
".install" => match parse_repl_install(args) {
|
".install" => match parse_repl_install(args) {
|
||||||
ReplInstallDispatch::Builtins(category) => config::install_assets(category)?,
|
ReplInstallDispatch::Builtins(category) => config::install_assets(category)?,
|
||||||
@@ -983,6 +986,7 @@ pub async fn run_repl_command(
|
|||||||
if let Some(session) = &ctx.session {
|
if let Some(session) = &ctx.session {
|
||||||
let (compressed, active) = replay::snapshot(session);
|
let (compressed, active) = replay::snapshot(session);
|
||||||
replay::render(app.as_ref(), &compressed, &active)?;
|
replay::render(app.as_ref(), &compressed, &active)?;
|
||||||
|
print_pause_banner(ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -1538,22 +1542,7 @@ async fn ask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
reset_continuation(ctx);
|
reset_continuation(ctx);
|
||||||
if let Some(reason) = ctx.auto_continue_paused.clone()
|
print_pause_banner(ctx);
|
||||||
&& ctx.todo_list.has_incomplete()
|
|
||||||
{
|
|
||||||
let remaining = ctx.todo_list.incomplete_count();
|
|
||||||
let color = if app.light_theme() {
|
|
||||||
nu_ansi_term::Color::LightGray
|
|
||||||
} else {
|
|
||||||
nu_ansi_term::Color::DarkGray
|
|
||||||
};
|
|
||||||
eprintln!(
|
|
||||||
"\n⏸ {}",
|
|
||||||
color.italic().paint(format!(
|
|
||||||
"Auto-continue paused, awaiting user ({remaining} incomplete todo(s) preserved): {reason}"
|
|
||||||
))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if ctx.maybe_autoname_session() {
|
if ctx.maybe_autoname_session() {
|
||||||
let color = if app.light_theme() {
|
let color = if app.light_theme() {
|
||||||
nu_ansi_term::Color::LightGray
|
nu_ansi_term::Color::LightGray
|
||||||
@@ -1656,6 +1645,28 @@ fn reset_continuation(ctx: &mut RequestContext) {
|
|||||||
ctx.reset_continuation_count();
|
ctx.reset_continuation_count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pause_banner_text(ctx: &RequestContext) -> Option<String> {
|
||||||
|
let reason = ctx.auto_continue_paused.as_deref()?;
|
||||||
|
if !ctx.todo_list.has_incomplete() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let remaining = ctx.todo_list.incomplete_count();
|
||||||
|
Some(format!(
|
||||||
|
"Auto-continue paused, awaiting user ({remaining} incomplete todo(s) preserved): {reason}"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_pause_banner(ctx: &RequestContext) {
|
||||||
|
if let Some(text) = pause_banner_text(ctx) {
|
||||||
|
let color = if ctx.app.config.light_theme() {
|
||||||
|
nu_ansi_term::Color::LightGray
|
||||||
|
} else {
|
||||||
|
nu_ansi_term::Color::DarkGray
|
||||||
|
};
|
||||||
|
eprintln!("\n⏸ {}", color.italic().paint(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn unknown_command() -> Result<()> {
|
fn unknown_command() -> Result<()> {
|
||||||
bail!(r#"Unknown command. Type ".help" for additional help."#);
|
bail!(r#"Unknown command. Type ".help" for additional help."#);
|
||||||
}
|
}
|
||||||
@@ -2468,4 +2479,39 @@ mod tests {
|
|||||||
let result = MULTILINE_RE.captures(input).unwrap();
|
let result = MULTILINE_RE.captures(input).unwrap();
|
||||||
assert!(result.is_none());
|
assert!(result.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn banner_ctx() -> RequestContext {
|
||||||
|
use crate::config::{AppState, WorkingMode};
|
||||||
|
RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pause_banner_text_paused_with_incomplete_todos() {
|
||||||
|
let mut ctx = banner_ctx();
|
||||||
|
ctx.add_todo("write code");
|
||||||
|
ctx.add_todo("run tests");
|
||||||
|
ctx.pause_auto_continue("Which database?");
|
||||||
|
|
||||||
|
let text = pause_banner_text(&ctx).unwrap();
|
||||||
|
assert!(text.contains("Which database?"), "{text}");
|
||||||
|
assert!(text.contains("2 incomplete todo(s) preserved"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pause_banner_text_none_when_all_todos_complete() {
|
||||||
|
let mut ctx = banner_ctx();
|
||||||
|
let id = ctx.add_todo("write code");
|
||||||
|
ctx.mark_todo_done(id);
|
||||||
|
ctx.pause_auto_continue("Which database?");
|
||||||
|
|
||||||
|
assert_eq!(pause_banner_text(&ctx), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pause_banner_text_none_when_unpaused() {
|
||||||
|
let mut ctx = banner_ctx();
|
||||||
|
ctx.add_todo("write code");
|
||||||
|
|
||||||
|
assert_eq!(pause_banner_text(&ctx), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user