mod auto_clear;
mod auto_hide;
mod cadence;
mod config;
mod menu;
mod paths;
mod sort;
mod store;
mod subagent;
mod tools;
mod types;
mod widget;
use crate::{
core::{
extensions::{
self, BoundaryOutcome, DockLine, Extension, ExtensionCommand, ExtensionHook,
ExtensionMode, ExtensionSetting, ExtensionTool, ExtensionUiRequest, ForkProjectInfo,
SubcommandDef, ToolAnnotations, ToolExecCtx, ToolExposure, UiNotifyLevel,
},
provider::AgentMessage,
session_manager::{Session, session_parent_id},
settings_manager::agent_dir,
tools::{ToolError, ToolExecutionMode, ToolResult},
},
error::Result,
extensions::{
EXTENSION_FACTORIES, ExtensionFactory, PRIORITY_TASKS, command_arg,
util::{SPINNER_TICK_MS, notify_text, push_context, spinner_frame_index},
},
modes::interactive::{app::App, handlers::register_slash_command},
utils::{glyphs::DEF_SPINNER, time::now_ms},
};
use auto_clear::AutoClearManager;
use auto_hide::AutoHideManager;
use cadence::CadenceState;
use config::{AutoHideDelay, TaskScope, TasksConfig};
use futures_util::future::BoxFuture;
use menu::MenuState;
use serde_json::{Value, json};
use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex, MutexGuard, OnceLock},
};
use store::TaskStore;
use subagent::RpcState;
use types::{Task, TaskStatus};
use widget::WidgetState;
pub(super) const EXT: &str = "tasks";
const CMD: &str = "tasks";
const ENV_TASKS: &str = "PRUX_TASKS";
const AUTO_CLEAR_DELAY: u64 = 4;
const REMINDER_INTERVAL: u64 = 4;
const ACTIVE_REMINDER_INTERVAL: u64 = 2;
const SUBCOMMANDS: &[SubcommandDef] = &[
SubcommandDef {
name: "create",
description: "Create a task directly: /tasks create <subject> [:: <description>]",
},
SubcommandDef {
name: "run",
description: "Start an agent turn to finish tasks: /tasks run [<id>,<id>...]",
},
];
#[linkme::distributed_slice(EXTENSION_FACTORIES)]
static TASKS_FACTORY: ExtensionFactory = ExtensionFactory {
priority: PRIORITY_TASKS,
make: || -> Arc<dyn Extension> { Arc::new(Tasks) },
};
struct State {
config: TasksConfig,
store: TaskStore,
store_key: String,
cwd: Option<String>,
session_id: Option<String>,
menu: MenuState,
widget: WidgetState,
auto_clear: AutoClearManager,
auto_hide: AutoHideManager,
rpc: RpcState,
cadence: CadenceState,
persisted_shown: bool,
}
impl Default for State {
fn default() -> Self {
State {
config: TasksConfig::default(),
store: TaskStore::new(None),
store_key: String::new(),
cwd: None,
session_id: None,
menu: MenuState::default(),
widget: WidgetState::default(),
auto_clear: AutoClearManager::new(AUTO_CLEAR_DELAY),
auto_hide: AutoHideManager::default(),
rpc: RpcState::default(),
cadence: CadenceState::default(),
persisted_shown: false,
}
}
}
fn state() -> &'static Mutex<State> {
static S: OnceLock<Mutex<State>> = OnceLock::new();
S.get_or_init(|| Mutex::new(State::default()))
}
fn lock_state() -> MutexGuard<'static, State> {
state().lock().unwrap_or_else(|e| e.into_inner())
}
fn is_task_tool(name: &str) -> bool {
matches!(
name,
"TaskCreate"
| "TaskList"
| "TaskGet"
| "TaskUpdate"
| "TaskOutput"
| "TaskStop"
| "TaskExecute"
)
}
pub struct Tasks;
fn resolve_store_target(
cwd: Option<&str>,
session_id: Option<&str>,
scope: TaskScope,
) -> (String, Option<PathBuf>) {
let env = std::env::var(ENV_TASKS)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(env) = env {
if env == "off" {
return ("memory:env".to_string(), None);
}
if env.starts_with('/') {
let p = PathBuf::from(&env);
return (format!("path:{}", p.display()), Some(p));
}
if env.starts_with('.') {
return match cwd {
Some(cwd) => {
let p = Path::new(cwd).join(&env);
(format!("path:{}", p.display()), Some(p))
}
None => ("pending:relative".to_string(), None),
};
}
let p = agent_dir().join("tasks").join(format!("{env}.json"));
return (format!("path:{}", p.display()), Some(p));
}
if scope == TaskScope::Memory {
return ("memory:config".to_string(), None);
}
let Some(cwd) = cwd else {
return ("pending:workspace".to_string(), None);
};
if scope.is_session() {
return match session_id {
Some(sid) => {
let p = paths::session_task_file(cwd, sid, scope);
(format!("path:{}", p.display()), Some(p))
}
None => ("pending:session".to_string(), None),
};
}
let p = paths::project_task_file(cwd);
(format!("path:{}", p.display()), Some(p))
}
fn sync_store(st: &mut State, reload_config: bool) {
if reload_config && let Some(cwd) = st.cwd.clone() {
st.config = config::load_tasks_config(&cwd);
}
let scope = st.config.task_scope();
let (key, path) = resolve_store_target(st.cwd.as_deref(), st.session_id.as_deref(), scope);
if key != st.store_key {
st.store = TaskStore::new(path);
st.store_key = key; reset_session_state(st);
}
}
fn reset_session_state(st: &mut State) {
st.menu = MenuState::default();
st.widget.reset();
st.auto_clear.reset();
st.auto_hide.reset();
st.cadence = CadenceState::default();
st.rpc.agent_task_map.clear();
st.persisted_shown = false;
}
fn session_key_from_path(path: &Path) -> Option<String> {
path.file_stem()
.and_then(|s| s.to_str())
.map(str::to_string)
}
fn cleanup_session_file(store: &mut TaskStore, scope: TaskScope, cwd: Option<&str>) {
if !scope.is_session() {
return;
}
if std::env::var(ENV_TASKS)
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
{
return;
}
if store.delete_file_if_empty()
&& scope == TaskScope::SessionGlobal
&& let Some(cwd) = cwd
{
paths::reclaim_global_session_tasks_dir(cwd);
}
}
fn seed_forked_tasks(st: &mut State, parent_session_id: &str) {
if std::env::var(ENV_TASKS)
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
{
return;
}
let scope = st.config.task_scope();
if !scope.is_session() {
return;
}
let Some(cwd) = st.cwd.clone() else {
return;
};
let parent_path = paths::session_task_file(&cwd, parent_session_id, scope);
if !parent_path.exists() {
return;
}
let mut parent = TaskStore::new(Some(parent_path));
let data = parent.snapshot();
if data.tasks.is_empty() {
return;
}
_ = st.store.seed(data);
}
fn after_session_sync(st: &mut State, is_resume: bool) {
if st.persisted_shown {
return;
}
st.persisted_shown = true;
let tasks = st.store.list(None);
if tasks.is_empty() {
return;
}
if is_resume {
if tasks.iter().all(|t| t.status == TaskStatus::Completed) {
st.auto_hide
.hide_now(&tasks, now_ms(), st.config.hide_completed_after_ms());
} else {
extensions::request_show_dock();
}
return;
}
if tasks.iter().all(|t| t.status == TaskStatus::Completed) {
_ = st.store.clear_completed();
let scope = st.config.task_scope();
let cwd = st.cwd.clone();
cleanup_session_file(&mut st.store, scope, cwd.as_deref());
}
}
fn apply_update_side_effects(st: &mut State, task_id: &str, status: &str) {
match status {
"in_progress" => {
st.widget.set_active(task_id, true);
st.auto_clear.reset_batch_countdown();
}
"pending" => {
st.widget.set_active(task_id, false);
st.auto_clear.reset_batch_countdown();
}
"completed" => {
st.widget.set_active(task_id, false);
let turn = st.cadence.current_turn;
let mode = st.config.auto_clear_completed();
st.auto_clear
.track_completion(&mut st.store, task_id, turn, mode);
}
"deleted" => st.widget.set_active(task_id, false),
_ => {}
}
}
impl Extension for Tasks {
fn name(&self) -> &str {
EXT
}
fn description(&self) -> &str {
"Claude Code-style task tracking: TaskCreate / TaskList / TaskGet / TaskUpdate / TaskOutput / TaskStop / TaskExecute, a task widget, and /tasks."
}
fn fork_project(&self) -> Option<ForkProjectInfo> {
Some(ForkProjectInfo {
plugin_name: "pi-tasks".to_string(),
plugin_version: "0.9.0".to_string(),
url: "https://github.com/tintinweb/pi-tasks".to_string(),
})
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Dev, ExtensionMode::Creator]
}
fn default_enabled(&self) -> bool {
false
}
fn tools(&self) -> Vec<ExtensionTool> {
tools_schema()
}
fn commands(&self) -> Vec<ExtensionCommand> {
vec![ExtensionCommand {
name: CMD.to_string(),
description: "Manage tasks — view, create, run, clear completed, settings".to_string(),
busy_safe: true, subcommands: SUBCOMMANDS.to_vec(),
}]
}
fn hooks(&self) -> Vec<ExtensionHook> {
vec![
ExtensionHook::Dock,
ExtensionHook::AgentEvent,
ExtensionHook::Boundary,
ExtensionHook::ExtensionEvent,
ExtensionHook::AfterToolCall,
ExtensionHook::ContextWithSystem,
]
}
fn dock_lines(&self) -> Vec<DockLine> {
let mut guard = lock_state();
let State {
store,
config,
widget,
auto_hide,
..
} = &mut *guard;
widget.prune(store);
let frame = spinner_frame_index(DEF_SPINNER.len(), SPINNER_TICK_MS);
widget::build_lines(store, config, widget, auto_hide, frame)
}
fn wants_redraw(&self) -> bool {
let mut guard = lock_state();
if guard.widget.has_active() {
return true;
}
if guard.config.hide_completed_after() == AutoHideDelay::Never {
return false;
}
let State {
store,
config,
auto_hide,
..
} = &mut *guard;
let tasks = store.list_cached(config.sort_order());
let just_hid = auto_hide.tick(&tasks, now_ms(), config.hide_completed_after_ms());
just_hid || auto_hide.is_pending()
}
fn on_agent_event(&self, event: &Value) {
let ty = event.get("type").and_then(Value::as_str);
if ty == Some("agent_end") && event.get("reason").and_then(Value::as_str) == Some("aborted")
{
let agents = subagent::abort_running();
if !agents.is_empty() {
extensions::request_show_dock();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
for agent_id in agents {
subagent::stop_agent(&agent_id).await;
}
});
}
}
return;
}
if ty != Some("turn_start") {
return;
}
let mut guard = lock_state();
let st = &mut *guard;
st.cadence.on_turn_start();
let turn = st.cadence.current_turn;
let mode = st.config.auto_clear_completed();
let scope = st.config.task_scope();
let State {
store,
widget,
auto_clear,
cwd,
..
} = st;
_ = store.list(None);
widget.prune(store);
if auto_clear.on_turn_start(store, turn, mode) {
cleanup_session_file(store, scope, cwd.as_deref());
}
}
fn on_boundary(&self, event: &Value) -> Option<BoundaryOutcome> {
let main_scope = event.get("agentScope").and_then(Value::as_str) == Some("main");
match event.get("type").and_then(Value::as_str) {
Some("turn_end") => {
if main_scope {
let input = event
.pointer("/message/usage/input")
.and_then(Value::as_u64)
.unwrap_or(0);
let output = event
.pointer("/message/usage/output")
.and_then(Value::as_u64)
.unwrap_or(0);
let mut guard = lock_state();
let State {
widget,
cadence,
store,
..
} = &mut *guard;
if input > 0 || output > 0 {
widget.add_usage(input, output);
}
let gap = cadence
.current_turn
.saturating_sub(cadence.last_task_tool_use_turn);
if gap >= ACTIVE_REMINDER_INTERVAL {
let has_in_progress = store
.list_cached(None)
.iter()
.any(|t| t.status == TaskStatus::InProgress);
cadence.note_stale_if_active(has_in_progress, ACTIVE_REMINDER_INTERVAL);
}
}
None
}
Some("agent_before_settle") => {
if main_scope {
lock_state().auto_clear.on_run_ended();
}
None
}
_ => None,
}
}
fn after_tool_call(
&self,
name: &str,
_args: &Value,
result: &str,
) -> std::result::Result<String, ToolError> {
let mut guard = lock_state();
let State { cadence, store, .. } = &mut *guard;
if is_task_tool(name) {
cadence.note_task_tool();
return Ok(result.to_string());
}
if cadence.reminder_injected_this_cycle {
return Ok(result.to_string());
}
if cadence
.current_turn
.saturating_sub(cadence.last_task_tool_use_turn)
< ACTIVE_REMINDER_INTERVAL
{
return Ok(result.to_string());
}
let tasks = cadence::tasks_snapshot(store);
let interval = cadence::interval_for(&tasks, REMINDER_INTERVAL, ACTIVE_REMINDER_INTERVAL);
cadence.maybe_mark_due(!tasks.is_empty(), interval);
Ok(result.to_string())
}
fn transform_context_with_system(&self, messages: &mut Vec<AgentMessage>) -> Result<()> {
let due = lock_state().cadence.drain_reminder_for_context();
if !due {
return Ok(());
}
let tasks = lock_state().store.list_cached(None);
push_context(messages, &cadence::build_system_reminder(&tasks));
Ok(())
}
fn settings(&self) -> Vec<ExtensionSetting> {
config::panel_settings(&lock_state().config)
}
fn apply_setting(&self, key: &str, value: &str) -> std::result::Result<(), String> {
let mut st = lock_state();
config::apply_setting(&mut st.config, key, value)?;
if let Some(cwd) = st.cwd.clone() {
config::save_tasks_config(&st.config, &cwd);
}
let scope = st.config.task_scope();
let (k, p) = resolve_store_target(st.cwd.as_deref(), st.session_id.as_deref(), scope);
if k != st.store_key {
st.store = TaskStore::new(p);
st.store_key = k;
st.menu = MenuState::default();
}
Ok(())
}
fn on_registered(&self) {
register_slash_command(EXT, CMD, command_tasks);
}
fn on_session_start(&self, cwd: &str, messages: &[AgentMessage], session: Option<&Session>) {
let is_resume = !messages.is_empty();
let session_id = session
.and_then(|s| s.get_session_file())
.and_then(session_key_from_path);
{
let mut st = lock_state();
st.cwd = Some(cwd.to_string());
st.session_id = session_id;
sync_store(&mut st, true);
after_session_sync(&mut st, is_resume);
}
subagent::reattach(); }
fn on_session_switched(&self, session_path: Option<&str>, messages: &[AgentMessage]) {
let session_id = session_path
.filter(|p| !p.is_empty())
.and_then(|p| session_key_from_path(Path::new(p)));
let fork_parent = session_path
.filter(|p| !p.is_empty())
.and_then(|p| session_parent_id(Path::new(p)));
{
let mut st = lock_state();
let inherits_from_current = fork_parent.is_some() && fork_parent == st.session_id;
let is_new = messages.is_empty() && !inherits_from_current;
st.session_id = session_id;
reset_session_state(&mut st);
sync_store(&mut st, false);
if is_new && st.config.task_scope() == TaskScope::Memory {
_ = st.store.clear_all();
}
if inherits_from_current && let Some(parent) = fork_parent {
seed_forked_tasks(&mut st, &parent);
}
after_session_sync(&mut st, !is_new);
}
subagent::reattach();
}
fn on_exec_ctx(&self, ctx: &ToolExecCtx) {
{
let mut st = lock_state();
if st.cwd.as_deref() != Some(ctx.cwd.as_str()) {
st.cwd = Some(ctx.cwd.clone());
sync_store(&mut st, true);
}
}
subagent::ping(); }
fn on_extension_event(&self, name: &str, payload: &Value) {
subagent::on_event(name, payload);
}
fn on_ui_choice(&self, id: u64, choice: Option<String>) -> Option<String> {
let mut guard = lock_state();
let st = &mut *guard;
let scope = st.config.task_scope();
let State {
store, menu, cwd, ..
} = st;
let changed = menu::on_choice(store, menu, id, choice);
if changed {
cleanup_session_file(store, scope, cwd.as_deref());
drop(guard);
extensions::request_show_dock();
}
None
}
fn execute_tool_async(
&self,
name: String,
args: Value,
_ctx: ToolExecCtx,
) -> BoxFuture<'static, std::result::Result<ToolResult, ToolError>> {
Box::pin(async move {
match name.as_str() {
"TaskExecute" => return subagent::execute(&args).await,
"TaskOutput" => return subagent::output(&args).await,
"TaskStop" => return subagent::stop(&args).await,
_ => {}
}
let mut show_dock = false;
let result = {
let mut guard = lock_state();
let st = &mut *guard;
match name.as_str() {
"TaskCreate" => {
let mode = st.config.auto_clear_completed();
st.auto_clear.start_new_batch(&mut st.store, mode);
show_dock = true;
tools::task_create(&args, &mut st.store)
}
"TaskList" => tools::task_list(&args, &mut st.store),
"TaskGet" => tools::task_get(&args, &mut st.store),
"TaskUpdate" => {
let r = tools::task_update(&args, &mut st.store);
let task_id = args
.get("taskId")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let status = args.get("status").and_then(Value::as_str).unwrap_or("");
apply_update_side_effects(st, &task_id, status);
show_dock = true;
r
}
other => Err(ToolError(format!("extension {EXT}: unknown tool: {other}"))),
}
};
if show_dock && result.is_ok() {
extensions::request_show_dock();
}
result
})
}
}
fn command_tasks(st: &mut App, raw: &str) -> bool {
let arg = command_arg(raw).trim().to_string();
{
let mut state = lock_state();
if state.cwd.is_none()
&& let Ok(cwd) = std::env::current_dir()
{
state.cwd = Some(cwd.to_string_lossy().to_string());
}
sync_store(&mut state, false);
}
let (sub, rest) = match arg.split_once(char::is_whitespace) {
Some((a, b)) => (a, b.trim()),
None => (arg.as_str(), ""),
};
match sub {
"create" => create_task_from_command(rest),
"run" => run_tasks_from_command(st, rest),
_ => {
let mut state = lock_state();
let State { store, menu, .. } = &mut *state;
menu::open_main(store, menu);
}
}
false
}
fn parse_create_args(rest: &str) -> Option<(String, String)> {
let rest = rest.trim();
if rest.is_empty() {
return None;
}
match rest.split_once("::") {
Some((subject, description)) if !subject.trim().is_empty() => {
Some((subject.trim().to_string(), description.trim().to_string()))
}
_ => Some((rest.to_string(), rest.to_string())),
}
}
fn create_task_from_command(rest: &str) {
let Some((subject, description)) = parse_create_args(rest) else {
notify_text(
"usage: /tasks create <subject> [:: <description>]",
UiNotifyLevel::Warning,
);
return;
};
let created = {
let mut st = lock_state();
let mode = st.config.auto_clear_completed();
let State {
store, auto_clear, ..
} = &mut *st;
auto_clear.start_new_batch(store, mode);
store.create(subject, description, None, None)
};
match created {
Ok(task) => {
extensions::request_show_dock();
notify_text(
&format!("Created task #{}: {}", task.id, task.subject),
UiNotifyLevel::Info,
);
}
Err(e) => notify_text(&format!("Failed to create task: {e}"), UiNotifyLevel::Error),
}
}
fn parse_run_ids(rest: &str) -> Vec<String> {
let mut ids: Vec<String> = Vec::new();
for token in rest.split(|c: char| c.is_whitespace() || c == ',') {
let id = token.trim().trim_start_matches('#');
if id.is_empty() || ids.iter().any(|x| x == id) {
continue;
}
ids.push(id.to_string());
}
ids
}
fn run_prompt(tasks: &[Task]) -> String {
let listed = tasks
.iter()
.map(|t| format!("#{}, {}", t.id, t.subject))
.collect::<Vec<_>>()
.join("\n");
format!(
"Continue working until the following tasks are completed. Work on these tasks \
only:\n\
{listed}\n\
\n\
Use TaskGet for details on each target. Do not start, modify, or complete any task that is not \
in this list, even if TaskList shows other pending tasks. Mark each listed task in_progress \
with TaskUpdate before starting it, and completed as soon as it is fully done. Among the listed \
tasks, respect blockedBy dependencies (finish blockers first) and work in ID order. Do not \
stop until every task in this list is completed."
)
}
fn dispatch_run_prompt(app: &mut App, prompt: String) {
if app.busy {
app.runtime_steer_inbox
.lock()
.unwrap()
.push_back(AgentMessage::user_text(&prompt));
} else {
extensions::request_ui(ExtensionUiRequest::Continuation {
message: Box::new(AgentMessage::user_text(&prompt)),
});
}
}
fn run_tasks_from_command(app: &mut App, rest: &str) {
let ids = parse_run_ids(rest);
let targets: Vec<Task> = {
let mut st = lock_state();
st.store
.list(None)
.into_iter()
.filter(|t| t.status != TaskStatus::Completed)
.filter(|t| ids.is_empty() || ids.iter().any(|id| id == &t.id))
.collect()
};
if targets.is_empty() {
notify_text(
if ids.is_empty() {
"No pending tasks to run."
} else {
"No matching pending tasks to run."
},
UiNotifyLevel::Warning,
);
return;
}
let count = targets.len();
dispatch_run_prompt(app, run_prompt(&targets));
extensions::request_show_dock();
notify_text(&format!("Running {count} task(s)..."), UiNotifyLevel::Info);
}
fn tools_schema() -> Vec<ExtensionTool> {
vec![
ExtensionTool {
exposure: ToolExposure::Direct,
namespace: None,
annotations: ToolAnnotations::default(),
output_schema: None,
name: "TaskCreate".to_string(),
description: TASK_CREATE_DESC.to_string(),
label: Some("Create task".to_string()),
parameters: json!({
"type": "object",
"properties": {
"subject": { "type": "string", "description": "A brief title for the task" },
"description": { "type": "string", "description": "A detailed description of what needs to be done" },
"activeForm": { "type": "string", "description": "Present continuous form shown in spinner when in_progress (e.g., 'Running tests')" },
"agentType": { "type": "string", "description": "Agent type for subagent execution (e.g., 'general-purpose', 'Explore'). Tasks with agentType can be started via TaskExecute." },
"metadata": { "type": "object", "description": "Arbitrary metadata to attach to the task" }
},
"required": ["subject", "description"],
"additionalProperties": false
}),
snippet: "Create a structured task for the current session".to_string(),
prompt_guidelines: vec![
"When working on complex multi-step tasks, use TaskCreate to track progress and TaskUpdate to update status.".to_string(),
"Mark tasks as in_progress before starting work and completed when done.".to_string(),
"Use TaskList to check for available work after completing a task.".to_string(),
],
constrained_sampling: false,
render_shell: None,
execution_mode: Some(ToolExecutionMode::Parallel),
prepare_arguments: None,
grammar_sampling: None,
},
ExtensionTool {
exposure: ToolExposure::Direct,
namespace: None,
annotations: ToolAnnotations::default(),
output_schema: None,
name: "TaskList".to_string(),
description: TASK_LIST_DESC.to_string(),
label: Some("List tasks".to_string()),
parameters: json!({ "type": "object", "properties": {}, "additionalProperties": false }),
snippet: "List all tasks with status, owner, and blocked-by info".to_string(),
prompt_guidelines: Vec::new(),
constrained_sampling: false,
render_shell: None,
execution_mode: None,
prepare_arguments: None,
grammar_sampling: None,
},
ExtensionTool {
exposure: ToolExposure::Direct,
namespace: None,
annotations: ToolAnnotations::default(),
output_schema: None,
name: "TaskGet".to_string(),
description: TASK_GET_DESC.to_string(),
label: Some("Get task".to_string()),
parameters: json!({
"type": "object",
"properties": {
"taskId": { "type": "string", "description": "The ID of the task to retrieve" }
},
"required": ["taskId"],
"additionalProperties": false
}),
snippet: "Get full details for a specific task".to_string(),
prompt_guidelines: Vec::new(),
constrained_sampling: false,
render_shell: None,
execution_mode: None,
prepare_arguments: None,
grammar_sampling: None,
},
ExtensionTool {
exposure: ToolExposure::Direct,
namespace: None,
annotations: ToolAnnotations::default(),
output_schema: None,
name: "TaskUpdate".to_string(),
description: TASK_UPDATE_DESC.to_string(),
label: Some("Update task".to_string()),
parameters: json!({
"type": "object",
"properties": {
"taskId": { "type": "string", "description": "The ID of the task to update" },
"status": { "type": "string", "enum": ["pending", "in_progress", "completed", "deleted"], "description": "New status for the task" },
"subject": { "type": "string", "description": "New subject for the task" },
"description": { "type": "string", "description": "New description for the task" },
"activeForm": { "type": "string", "description": "Present continuous form shown in spinner when in_progress" },
"owner": { "type": "string", "description": "New owner for the task" },
"metadata": { "type": "object", "description": "Metadata keys to merge into the task. Set a key to null to delete it." },
"addBlocks": { "type": "array", "items": { "type": "string" }, "description": "Task IDs that this task blocks" },
"addBlockedBy": { "type": "array", "items": { "type": "string" }, "description": "Task IDs that block this task" }
},
"required": ["taskId"],
"additionalProperties": false
}),
snippet: "Update a task's fields, status, metadata, and dependencies".to_string(),
prompt_guidelines: Vec::new(),
constrained_sampling: false,
render_shell: None,
execution_mode: None,
prepare_arguments: None,
grammar_sampling: None,
},
ExtensionTool {
exposure: ToolExposure::Direct,
namespace: None,
annotations: ToolAnnotations::default(),
output_schema: None,
name: "TaskOutput".to_string(),
description: TASK_OUTPUT_DESC.to_string(),
label: Some("Task output".to_string()),
parameters: json!({
"type": "object",
"properties": {
"task_id": { "type": "string", "description": "The task ID to get output from" },
"block": { "type": "boolean", "description": "Whether to wait for completion", "default": true },
"timeout": { "type": "number", "description": "Max wait time in ms", "default": 30000, "minimum": 0, "maximum": 600000 }
},
"required": ["task_id"],
"additionalProperties": false
}),
snippet: "Retrieve output from a running or completed task".to_string(),
prompt_guidelines: Vec::new(),
constrained_sampling: false,
render_shell: None,
execution_mode: None,
prepare_arguments: None,
grammar_sampling: None,
},
ExtensionTool {
exposure: ToolExposure::Direct,
namespace: None,
annotations: ToolAnnotations::default(),
output_schema: None,
name: "TaskStop".to_string(),
description: TASK_STOP_DESC.to_string(),
label: Some("Stop task".to_string()),
parameters: json!({
"type": "object",
"properties": {
"task_id": { "type": "string", "description": "The ID of the background task to stop" },
"shell_id": { "type": "string", "description": "Deprecated: use task_id instead" }
},
"additionalProperties": false
}),
snippet: "Stop a running background task by its ID".to_string(),
prompt_guidelines: Vec::new(),
constrained_sampling: false,
render_shell: None,
execution_mode: None,
prepare_arguments: None,
grammar_sampling: None,
},
ExtensionTool {
exposure: ToolExposure::Direct,
namespace: None,
annotations: ToolAnnotations::default(),
output_schema: None,
name: "TaskExecute".to_string(),
description: TASK_EXECUTE_DESC.to_string(),
label: Some("Execute tasks".to_string()),
parameters: json!({
"type": "object",
"properties": {
"task_ids": { "type": "array", "items": { "type": "string" }, "description": "Task IDs to execute as subagents" },
"additional_context": { "type": "string", "description": "Extra context for agent prompts" },
"model": { "type": "string", "description": "Model override for agents" },
"max_turns": { "type": "number", "description": "Max turns per agent", "minimum": 1 }
},
"required": ["task_ids"],
"additionalProperties": false
}),
snippet: "Execute one or more tasks as background subagents".to_string(),
prompt_guidelines: vec![
"Never use the Agent tool for tasks launched via TaskExecute — agents are already running.".to_string(),
],
constrained_sampling: false,
render_shell: None,
execution_mode: None,
prepare_arguments: None,
grammar_sampling: None,
},
]
}
const TASK_CREATE_DESC: &str = r#"Use this tool to create a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.
It also helps the user understand the progress of the task and overall progress of their requests.
## When to Use This Tool
Use this tool proactively in these scenarios:
- Complex multi-step tasks - When a task requires 3 or more distinct steps or actions
- Non-trivial and complex tasks - Tasks that require careful planning or multiple operations
- Plan mode - When using plan mode, create a task list to track the work
- User explicitly requests todo list - When the user directly asks you to use the todo list
- User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated). Create them all in one response with one TaskCreate call per task
- After receiving new instructions - Immediately capture user requirements as tasks
- When you start working on a task - Mark it as in_progress BEFORE beginning work
- After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation
## When NOT to Use This Tool
Skip using this tool when:
- There is only a single, straightforward task
- The task is trivial and tracking it provides no organizational benefit
- The task can be completed in less than 3 trivial steps
- The task is purely conversational or informational
NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly.
## Task Fields
- **subject**: A brief, actionable title in imperative form (e.g., "Fix authentication bug in login flow")
- **description**: Detailed description of what needs to be done, including context and acceptance criteria
- **activeForm** (optional): Present continuous form shown in the spinner when the task is in_progress (e.g., "Fixing authentication bug"). If omitted, the spinner shows the subject instead.
All tasks are created with status `pending`.
## Tips
- Create tasks with clear, specific subjects that describe the outcome
- Include enough detail in the description for another agent to understand and complete the task
- After creating tasks, use TaskUpdate to set up dependencies (blocks/blockedBy) if needed
- Check TaskList first to avoid creating duplicate tasks
- Include `agentType` (e.g., "general-purpose", "Explore") to mark tasks for subagent execution via TaskExecute
- To create several tasks at once, call TaskCreate multiple times in a single response — independent tool calls run in parallel, so the whole batch is created in one turn (one task per call)."#;
const TASK_LIST_DESC: &str = r#"Use this tool to list all tasks in the task list.
## When to Use This Tool
- To see what tasks are available to work on (status: 'pending', no owner, not blocked)
- To check overall progress on the project
- To find tasks that are blocked and need dependencies resolved
- After completing a task, to check for newly unblocked work or claim the next available task
- **Prefer working on tasks in ID order** (lowest ID first) when multiple tasks are available, as earlier tasks often set up context for later ones
## Output
Returns a summary of each task:
- **id**: Task identifier (use with TaskGet, TaskUpdate)
- **subject**: Brief description of the task
- **status**: 'pending', 'in_progress', or 'completed'
- **owner**: Agent ID if assigned, empty if available
- **blockedBy**: List of open task IDs that must be resolved first (tasks with blockedBy cannot be claimed until dependencies resolve)
Use TaskGet with a specific task ID to view full details including description and comments."#;
const TASK_GET_DESC: &str = r#"Use this tool to retrieve a task by its ID from the task list.
## When to Use This Tool
- When you need the full description and context before starting work on a task
- To understand task dependencies (what it blocks, what blocks it)
- After being assigned a task, to get complete requirements
## Output
Returns full task details:
- **subject**: Task title
- **description**: Detailed requirements and context
- **status**: 'pending', 'in_progress', or 'completed'
- **blocks**: Tasks waiting on this one to complete
- **blockedBy**: Tasks that must complete before this one can start
## Tips
- After fetching a task, verify its blockedBy list is empty before beginning work.
- Use TaskList to see all tasks in summary form."#;
const TASK_OUTPUT_DESC: &str = r#"- Retrieves output from a running or completed task (background shell, agent, or remote session)
- Takes a task_id parameter identifying the task
- Returns the task output along with status information
- Use block=true (default) to wait for task completion
- Use block=false for non-blocking check of current status
- Task IDs can be found using the /tasks command
- Works with all task types: background shells, async agents, and remote sessions"#;
const TASK_STOP_DESC: &str = r#"
- Stops a running background task by its ID
- Takes a task_id parameter identifying the task to stop
- Returns a success or failure status
- Use this tool when you need to terminate a long-running task"#;
const TASK_EXECUTE_DESC: &str = r#"Execute one or more tasks as subagents.
## When to Use This Tool
- To start execution of tasks that have `agentType` set (created via TaskCreate with agentType parameter)
- Tasks must be `pending` with all blockedBy dependencies `completed`
- Each task runs as an independent background subagent
## Parameters
- **task_ids**: Array of task IDs to execute
- **additional_context**: Extra context appended to each agent's prompt
- **model**: Model override for agents (e.g., "sonnet", "haiku")
- **max_turns**: Maximum turns per agent"#;
const TASK_UPDATE_DESC: &str = r#"Use this tool to update a task in the task list.
## When to Use This Tool
**Before starting work on a task:**
- Mark it in_progress BEFORE beginning — do not start work without updating status first
- After resolving, call TaskList to find your next task
**Mark tasks as resolved:**
- When you have completed the work described in a task
- When a task is no longer needed or has been superseded
- IMPORTANT: Always mark your assigned tasks as resolved when you finish them
- After resolving, call TaskList to find your next task
- ONLY mark a task as completed when you have FULLY accomplished it
- If you encounter errors, blockers, or cannot finish, keep the task as in_progress
- When blocked, create a new task describing what needs to be resolved
- Never mark a task as completed if:
- Tests are failing
- Implementation is partial
- You encountered unresolved errors
- You couldn't find necessary files or dependencies
**Delete tasks:**
- When a task is no longer relevant or was created in error
- Setting status to `deleted` permanently removes the task
**Update task details:**
- When requirements change or become clearer
- When establishing dependencies between tasks
## Fields You Can Update
- **status**: The task status (see Status Workflow below)
- **subject**: Change the task title (imperative form, e.g., "Run tests")
- **description**: Change the task description
- **activeForm**: Present continuous form shown in spinner when in_progress (e.g., "Running tests")
- **owner**: Change the task owner (agent name)
- **metadata**: Merge metadata keys into the task (set a key to null to delete it)
- **addBlocks**: Mark tasks that cannot start until this one completes
- **addBlockedBy**: Mark tasks that must complete before this one can start
## Status Workflow
Status progresses: `pending` → `in_progress` → `completed`
Use `deleted` to permanently remove a task.
## Staleness
Make sure to read a task's latest state using `TaskGet` before updating it.
## Examples
Mark task as in progress when starting work:
```json
{"taskId": "1", "status": "in_progress"}
```
Mark task as completed after finishing work:
```json
{"taskId": "1", "status": "completed"}
```
Delete a task:
```json
{"taskId": "1", "status": "deleted"}
```
Claim a task by setting owner:
```json
{"taskId": "1", "owner": "my-name"}
```
Set up task dependencies:
```json
{"taskId": "2", "addBlockedBy": ["1"]}
```"#;
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::AgentDirGuard;
#[test]
fn store_target_env_off_is_memory() {
let (key, path) =
resolve_store_target(Some("/tmp/x"), Some("s1"), config::TaskScope::Memory);
assert_eq!(key, "memory:config");
assert!(path.is_none());
}
#[test]
fn store_target_session_and_project() {
let _g = AgentDirGuard::temp();
let (key, path) =
resolve_store_target(Some("/tmp/proj"), Some("s1"), config::TaskScope::Session);
assert!(key.starts_with("path:"));
assert!(path.unwrap().to_string_lossy().contains("tasks-s1.json"));
let (key, _) =
resolve_store_target(Some("/tmp/proj"), Some("s1"), config::TaskScope::Project);
assert!(
std::path::Path::new(key.trim_start_matches("path:"))
.ends_with(std::path::Path::new("tasks").join("tasks.json")),
"{key}"
);
}
#[test]
fn tools_schema_is_stable() {
let names: Vec<String> = tools_schema().into_iter().map(|t| t.name).collect();
assert_eq!(
names,
[
"TaskCreate",
"TaskList",
"TaskGet",
"TaskUpdate",
"TaskOutput",
"TaskStop",
"TaskExecute"
]
);
}
#[test]
fn parse_create_args_forms() {
assert_eq!(parse_create_args(""), None);
assert_eq!(parse_create_args(" "), None);
assert_eq!(
parse_create_args("Fix login"),
Some(("Fix login".into(), "Fix login".into()))
);
assert_eq!(
parse_create_args("Fix login :: Handle OAuth redirect"),
Some(("Fix login".into(), "Handle OAuth redirect".into()))
);
assert_eq!(
parse_create_args(":: only desc"),
Some((":: only desc".into(), ":: only desc".into()))
);
}
#[test]
fn subcommands_match_parser() {
let ext = Tasks;
let subs: Vec<&str> = ext.commands()[0]
.subcommands
.iter()
.map(|s| s.name)
.collect();
assert_eq!(subs, ["create", "run"]);
assert!(parse_create_args("x").is_some());
assert_eq!(parse_run_ids("1"), ["1"]);
}
#[test]
fn parse_run_ids_forms() {
assert!(parse_run_ids("").is_empty());
assert!(parse_run_ids(" ").is_empty());
assert_eq!(parse_run_ids("1 2"), ["1", "2"]);
assert_eq!(parse_run_ids("#1, #2 ,3"), ["1", "2", "3"]);
assert_eq!(parse_run_ids("2 1 2"), ["2", "1"]);
}
#[test]
fn run_prompt_lists_targets_and_tool_guidance() {
let tasks = vec![
Task {
id: "1".into(),
subject: "Fix login".into(),
description: "d".into(),
status: TaskStatus::Pending,
active_form: None,
owner: None,
metadata: Default::default(),
blocks: Vec::new(),
blocked_by: Vec::new(),
created_at: 0,
updated_at: 0,
},
Task {
id: "3".into(),
subject: "Write tests".into(),
description: "d".into(),
status: TaskStatus::InProgress,
active_form: None,
owner: None,
metadata: Default::default(),
blocks: Vec::new(),
blocked_by: Vec::new(),
created_at: 0,
updated_at: 0,
},
];
let prompt = run_prompt(&tasks);
assert!(prompt.contains("#1, Fix login"), "{prompt}");
assert!(prompt.contains("#3, Write tests"), "{prompt}");
assert!(prompt.contains("TaskUpdate"), "{prompt}");
assert!(prompt.contains("blockedBy"), "{prompt}");
assert!(prompt.contains("Work on these tasks only"), "{prompt}");
assert!(
prompt.contains("Do not start, modify, or complete any task that is not in this list"),
"{prompt}"
);
}
#[test]
fn command_run_queues_continuation_for_pending_tasks() {
let _g = AgentDirGuard::temp();
let _lock = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
while crate::core::extensions::take_pending_ui().is_some() {}
{
let mut st = lock_state();
st.cwd = Some("/tmp/tasks-run-cmd".to_string());
st.session_id = None;
st.config = TasksConfig {
task_scope: Some(TaskScope::Memory),
..Default::default()
};
st.store = TaskStore::new(None);
st.store_key = "memory:config".to_string();
st.store.create("A".into(), "d".into(), None, None).unwrap();
st.store.create("B".into(), "d".into(), None, None).unwrap();
st.store
.update(
"2",
super::types::TaskUpdateFields {
status: Some(super::types::TaskStatusOrDeleted::Status(
TaskStatus::Completed,
)),
..Default::default()
},
)
.unwrap();
}
let mut app = App::new();
assert!(!command_tasks(&mut app, "tasks run"));
let mut continuation = None;
while let Some(req) = crate::core::extensions::take_pending_ui() {
if let ExtensionUiRequest::Continuation { message } = req {
continuation = Some(message.text());
}
}
let text = continuation.expect("空闲态应投递一条 Continuation");
assert!(text.contains("#1, A"), "{text}");
assert!(text.contains("TaskUpdate"), "{text}");
assert!(!text.contains("#2, B"), "已完成任务不该再列出: {text}");
}
#[test]
fn command_run_filters_by_id_and_noops_without_targets() {
let _g = AgentDirGuard::temp();
let _lock = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
while crate::core::extensions::take_pending_ui().is_some() {}
{
let mut st = lock_state();
st.cwd = Some("/tmp/tasks-run-ids".to_string());
st.session_id = None;
st.config = TasksConfig {
task_scope: Some(TaskScope::Memory),
..Default::default()
};
st.store = TaskStore::new(None);
st.store_key = "memory:config".to_string();
st.store.create("A".into(), "d".into(), None, None).unwrap();
st.store.create("B".into(), "d".into(), None, None).unwrap();
}
let mut app = App::new();
assert!(!command_tasks(&mut app, "tasks run #2"));
let mut text = None;
while let Some(req) = crate::core::extensions::take_pending_ui() {
if let ExtensionUiRequest::Continuation { message } = req {
text = Some(message.text());
}
}
let text = text.expect("指定 id 有未完成任务时应续跑");
assert!(text.contains("#2, B"), "{text}");
assert!(!text.contains("#1, A"), "未选中的任务不该出现: {text}");
assert!(!command_tasks(&mut app, "tasks run 999"));
let mut saw_continuation = false;
while let Some(req) = crate::core::extensions::take_pending_ui() {
if matches!(req, ExtensionUiRequest::Continuation { .. }) {
saw_continuation = true;
}
}
assert!(!saw_continuation, "无目标时不得续跑");
}
#[test]
fn command_run_steers_when_busy() {
let _g = AgentDirGuard::temp();
let _lock = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
while crate::core::extensions::take_pending_ui().is_some() {}
{
let mut st = lock_state();
st.cwd = Some("/tmp/tasks-run-busy".to_string());
st.session_id = None;
st.config = TasksConfig {
task_scope: Some(TaskScope::Memory),
..Default::default()
};
st.store = TaskStore::new(None);
st.store_key = "memory:config".to_string();
st.store.create("A".into(), "d".into(), None, None).unwrap();
}
let mut app = App::new();
app.busy = true;
app.runtime_steer_inbox.lock().unwrap().clear();
assert!(!command_tasks(&mut app, "tasks run"));
assert!(
!matches!(
crate::core::extensions::take_pending_ui(),
Some(ExtensionUiRequest::Continuation { .. })
),
"忙碌态不应投 Continuation"
);
let inbox = app.runtime_steer_inbox.lock().unwrap();
assert_eq!(inbox.len(), 1, "忙碌态应把续跑消息作为 steer 注入");
assert!(inbox[0].text().contains("#1, A"));
}
#[test]
fn fork_seed_inherits_parent_tasks() {
if std::env::var(ENV_TASKS)
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
{
return; }
let _g = AgentDirGuard::temp();
let _lock = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().to_string_lossy().to_string();
let parent_path = paths::session_task_file(&cwd, "parent", TaskScope::Session);
let mut parent = TaskStore::new(Some(parent_path));
parent.create("A".into(), "d".into(), None, None).unwrap();
parent.create("B".into(), "d".into(), None, None).unwrap();
let child_path = paths::session_task_file(&cwd, "child", TaskScope::Session);
let mut st = State {
cwd: Some(cwd.clone()),
store: TaskStore::new(Some(child_path)),
store_key: "test".into(),
..Default::default()
};
seed_forked_tasks(&mut st, "parent");
let tasks = st.store.list(None);
assert_eq!(tasks.len(), 2, "继承父会话任务");
assert_eq!(tasks[0].subject, "A");
seed_forked_tasks(&mut st, "parent");
assert_eq!(st.store.list(None).len(), 2);
}
#[test]
fn fork_seed_skips_non_session_scopes() {
if std::env::var(ENV_TASKS)
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
{
return;
}
let _g = AgentDirGuard::temp();
let _lock = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().to_string_lossy().to_string();
let parent_path = paths::session_task_file(&cwd, "parent", TaskScope::Session);
let mut parent = TaskStore::new(Some(parent_path));
parent.create("A".into(), "d".into(), None, None).unwrap();
let mut st = State {
cwd: Some(cwd.clone()),
config: TasksConfig {
task_scope: Some(TaskScope::Project),
..Default::default()
},
store: TaskStore::new(None),
..Default::default()
};
seed_forked_tasks(&mut st, "parent");
assert!(st.store.list(None).is_empty(), "project 作用域不继承");
}
#[test]
fn command_create_builds_task_with_description() {
let _g = AgentDirGuard::temp();
let _lock = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
{
let mut st = lock_state();
st.cwd = Some(dir.path().to_string_lossy().to_string());
st.session_id = None; st.store = TaskStore::new(None);
st.store_key = "memory:test".into();
st.config = TasksConfig::default();
}
let mut app = App::new();
assert!(!command_tasks(
&mut app,
"tasks create Fix login :: Handle OAuth"
));
let st = lock_state();
let tasks = st.store.list_cached(None);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].subject, "Fix login");
assert_eq!(tasks[0].description, "Handle OAuth");
}
#[test]
fn new_session_clears_memory_scope_dock() {
if std::env::var(ENV_TASKS)
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
{
return;
}
let _lock = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
while crate::core::extensions::take_pending_ui().is_some() {}
{
let mut st = lock_state();
st.cwd = Some("/tmp/tasks-new-session".to_string());
st.session_id = Some("old".to_string());
st.config = TasksConfig {
task_scope: Some(TaskScope::Memory),
..Default::default()
};
st.store = TaskStore::new(None);
st.store_key = "memory:config".to_string();
st.store
.create("stale".into(), "d".into(), None, None)
.unwrap();
st.widget.set_active("1", true);
}
Tasks.on_session_switched(None, &[]);
{
let st = lock_state();
assert!(
st.store.list_cached(None).is_empty(),
"新会话不应带着上一个会话的任务"
);
assert!(!st.widget.has_active(), "切会话清掉活跃任务指针");
}
assert!(
Tasks.dock_lines().is_empty(),
"dock 内容清空(面板随之自动收起)"
);
}
#[test]
fn session_switch_only_reopens_the_panel_on_resume() {
if std::env::var(ENV_TASKS)
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
{
return;
}
let _g = AgentDirGuard::temp();
let _lock = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
while crate::core::extensions::take_pending_ui().is_some() {}
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().to_string_lossy().to_string();
let path = paths::project_task_file(&cwd);
{
let mut st = lock_state();
st.cwd = Some(cwd);
st.session_id = Some("old".to_string());
st.config = TasksConfig {
task_scope: Some(TaskScope::Project),
..Default::default()
};
st.store = TaskStore::new(Some(path.clone()));
st.store_key = format!("path:{}", path.display());
st.store
.create("shared".into(), "d".into(), None, None)
.unwrap();
}
Tasks.on_session_switched(None, &[]);
assert!(
crate::core::extensions::take_pending_ui().is_none(),
"新会话不应主动打开停靠面板"
);
{
let mut st = lock_state();
assert_eq!(st.store.list(None).len(), 1, "project 列表跨会话保留");
}
Tasks.on_session_switched(None, &[AgentMessage::user_text("hi")]);
let mut saw_dock = false;
while let Some(req) = crate::core::extensions::take_pending_ui() {
if matches!(req, crate::core::extensions::ExtensionUiRequest::ShowDock) {
saw_dock = true;
}
}
assert!(saw_dock, "恢复会话应请求打开停靠面板");
}
#[test]
fn resume_with_all_completed_keeps_dock_clean() {
if std::env::var(ENV_TASKS)
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
{
return;
}
let _lock = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
while crate::core::extensions::take_pending_ui().is_some() {}
{
let mut st = lock_state();
st.cwd = Some("/tmp/tasks-resume-all-done".to_string());
st.session_id = None;
st.config = TasksConfig {
task_scope: Some(TaskScope::Memory),
..Default::default()
};
st.store = TaskStore::new(None);
st.store_key = "memory:config".to_string();
st.persisted_shown = false;
st.auto_hide.reset();
st.store
.create("done".into(), "d".into(), None, None)
.unwrap();
st.store
.update(
"1",
super::types::TaskUpdateFields {
status: Some(super::types::TaskStatusOrDeleted::Status(
TaskStatus::Completed,
)),
..Default::default()
},
)
.unwrap();
after_session_sync(&mut st, true);
let tasks = st.store.list(None);
assert!(
st.auto_hide.hides(&tasks[0]),
"全完成列表恢复会话应直接隐藏"
);
}
assert!(
crate::core::extensions::take_pending_ui().is_none(),
"全完成列表恢复会话不应请求打开停靠面板"
);
assert!(
Tasks.dock_lines().is_empty(),
"全完成列表恢复后 dock 不应出现任务行"
);
}
#[test]
fn reminder_injection_appends_without_mutating_transcript() {
let _lock = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
{
let mut st = lock_state();
st.store = TaskStore::new(None);
st.store
.create("Report bug".into(), "d".into(), None, None)
.unwrap();
st.cadence = CadenceState::default();
st.cadence.reminder_due = true;
}
let snapshot = |m: &AgentMessage| (m.role.clone(), m.text());
let mut system = AgentMessage::user_text("# sys");
system.role = "system".to_string();
let user = AgentMessage::user_text("hello");
let before = vec![snapshot(&system), snapshot(&user)];
let mut full = vec![system, user];
Tasks.transform_context_with_system(&mut full).unwrap();
assert_eq!(full.len(), before.len() + 1, "只 append 一条提醒");
let after: Vec<_> = full.iter().map(snapshot).collect();
assert_eq!(
&after[..before.len()],
before.as_slice(),
"既有消息一字不动"
);
let reminder = full.last().unwrap();
assert_eq!(reminder.role, "user");
let text = reminder.text();
assert!(text.starts_with("<system-reminder>"), "{text}");
assert!(text.trim_end().ends_with("</system-reminder>"), "{text}");
assert!(text.contains("Report bug"), "含任务回显: {text}");
Tasks.transform_context_with_system(&mut full).unwrap();
assert_eq!(full.len(), before.len() + 1);
}
}