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__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."
}; };
+265 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
} }