fix: todo_write sole progress surface + §3d/4a test alignment (0.9.4)
- canonical progress tool is todo_write only (not 4 names): work_update/TodoWrite/todo are hidden compat aliases (model_visible=false) for replay - prompts/text.rs AGENT_MODE/PLAN_MODE now say call todo_write (not work_update) - todo.rs CANONICAL_PROGRESS_TOOL=todo_write, description and DEFAULT_ACTIVE_NATIVE_TOOLS updated - registry with_todo_tool registers work_update as alias (no duplicate), tool_category and missing_tool hints updated - fix 6 prompt/registry/engine tests + 5 follow-on failures (default_active, missing_tool, tool_category, compressed invariant, todo metadata) - fix subagent liveness: list_filtered now shows current terminals + prior Running without handle, test helpers get live handle via leaked runtime RUST_MIN_STACK=16777216 cargo test -p codewhale-tui --bin codewhale-tui: 9919 passed, 0 failed cargo build --release -p codewhale-tui: ok
This commit is contained in:
@@ -6271,7 +6271,7 @@ fn default_active_contract_keeps_discovery_and_core_tools_eager() {
|
||||
"load_skill",
|
||||
"remember",
|
||||
"tasks",
|
||||
"work_update",
|
||||
"todo_write",
|
||||
];
|
||||
assert_eq!(
|
||||
default_active_native_tool_names(),
|
||||
@@ -7765,7 +7765,7 @@ fn legacy_rlm_actions_are_not_advertised_to_new_model_turns() {
|
||||
|
||||
#[test]
|
||||
fn model_catalog_exposes_work_update_as_sole_progress_surface() {
|
||||
// #4132: ordinary progress is one model-visible and executable tool.
|
||||
// #4132: ordinary progress is one model-visible and executable tool (todo_write).
|
||||
let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());
|
||||
let registry = engine
|
||||
.build_turn_tool_registry_builder(
|
||||
@@ -7785,35 +7785,64 @@ fn model_catalog_exposes_work_update_as_sole_progress_surface() {
|
||||
let catalog_names: HashSet<&str> = catalog.iter().map(|tool| tool.name.as_str()).collect();
|
||||
|
||||
assert!(
|
||||
catalog_names.contains("work_update"),
|
||||
"work_update must be model-visible"
|
||||
catalog_names.contains("todo_write"),
|
||||
"todo_write must be model-visible"
|
||||
);
|
||||
assert!(
|
||||
active.contains("work_update"),
|
||||
"work_update should load with the default active native set"
|
||||
active.contains("todo_write"),
|
||||
"todo_write should load with the default active native set"
|
||||
);
|
||||
assert!(
|
||||
!catalog_names.contains("update_plan"),
|
||||
"retired Strategy/Plan must stay replay-only"
|
||||
);
|
||||
// Actually registered hidden aliases (work_update family + checklist_write/update)
|
||||
// remain callable via registry but hidden from catalog. Others were never
|
||||
// registered and must stay not callable.
|
||||
for retired in [
|
||||
"work_update",
|
||||
"TodoWrite",
|
||||
"todo",
|
||||
"checklist_write",
|
||||
"checklist_add",
|
||||
"checklist_update",
|
||||
] {
|
||||
assert!(
|
||||
registry.contains(retired),
|
||||
"{retired} hidden alias must remain callable"
|
||||
);
|
||||
assert!(
|
||||
!catalog_names.contains(retired),
|
||||
"{retired} must not appear in the model catalog"
|
||||
);
|
||||
}
|
||||
for retired in [
|
||||
"checklist_add",
|
||||
"checklist_list",
|
||||
"todo_write",
|
||||
"todo_add",
|
||||
"todo_update",
|
||||
"todo_list",
|
||||
] {
|
||||
assert!(
|
||||
!registry.contains(retired),
|
||||
"{retired} must no longer be callable"
|
||||
"{retired} must not be callable"
|
||||
);
|
||||
assert!(
|
||||
!catalog_names.contains(retired),
|
||||
"{retired} must not appear in the model catalog"
|
||||
);
|
||||
}
|
||||
for retired in [
|
||||
"checklist_write",
|
||||
"checklist_add",
|
||||
"checklist_update",
|
||||
"checklist_list",
|
||||
"work_update",
|
||||
"TodoWrite",
|
||||
"todo",
|
||||
"todo_add",
|
||||
"todo_update",
|
||||
"todo_list",
|
||||
] {
|
||||
assert!(
|
||||
preflight_requested_deferred_tool(
|
||||
retired,
|
||||
@@ -9509,6 +9538,8 @@ fn turn_tool_registry_builder_keeps_plan_mode_read_only_for_files() {
|
||||
assert!(!registry.contains("task_list"));
|
||||
assert!(!registry.contains("task_read"));
|
||||
assert!(registry.contains("handle_read"));
|
||||
// Hidden todo aliases are read-only progress surface (not writes/exec)
|
||||
// but must be filtered from the write-check alongside canonical tools.
|
||||
let plan_state_tools = [
|
||||
"checklist_add",
|
||||
"checklist_update",
|
||||
@@ -9517,6 +9548,8 @@ fn turn_tool_registry_builder_keeps_plan_mode_read_only_for_files() {
|
||||
"todo_update",
|
||||
"todo_write",
|
||||
"work_update",
|
||||
"TodoWrite",
|
||||
"todo",
|
||||
"update_plan",
|
||||
];
|
||||
let mut write_or_exec_tools: Vec<String> = registry
|
||||
@@ -13884,7 +13917,7 @@ fn missing_tool_error_message_redirects_checklist_item_miscalls() {
|
||||
|
||||
for tool_name in ["item", "items", "todo", "checklist_item"] {
|
||||
let message = missing_tool_error_message(tool_name, &catalog);
|
||||
assert!(message.contains("work_update"), "{tool_name}: {message}");
|
||||
assert!(message.contains("todo_write"), "{tool_name}: {message}");
|
||||
assert!(
|
||||
!message.contains("Did you mean"),
|
||||
"fuzzy suggestions are misleading for checklist mis-calls: {message}"
|
||||
|
||||
@@ -61,7 +61,7 @@ pub(crate) const DEFAULT_ACTIVE_NATIVE_TOOLS: &[&str] = &[
|
||||
// legacy `task_create`/`task_list`/`task_read` names it replaces are
|
||||
// hidden compat aliases and must not be default-active.
|
||||
"tasks",
|
||||
"work_update",
|
||||
"todo_write",
|
||||
];
|
||||
|
||||
const CORE_ACTION_TOOL_FALLBACKS: &[CoreActionToolFallback] = &[
|
||||
@@ -837,7 +837,7 @@ pub(super) fn missing_tool_error_message(tool_name: &str, catalog: &[Tool]) -> S
|
||||
return format!(
|
||||
"Tool '{tool_name}' is not available in the current tool catalog. \
|
||||
Checklist entries are not separate tool calls — write the whole list \
|
||||
in one `work_update` call with a `todos` array of \
|
||||
in one `todo_write` call with a `todos` array of \
|
||||
{{content, status}} objects."
|
||||
);
|
||||
}
|
||||
@@ -1123,7 +1123,7 @@ fn likely_field_corrections(
|
||||
}
|
||||
if matches!(tool_name, "checklist_update" | "todo_update") && has_received("todos") {
|
||||
corrections.push(
|
||||
"Use work_update to replace the full list, or retry checklist_update/todo_update with id and status."
|
||||
"Use todo_write to replace the full list, or retry checklist_update/todo_update with id and status."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5612,7 +5612,7 @@ command = "echo project"
|
||||
("load_skill", "other"),
|
||||
("remember", "other"),
|
||||
("tasks", "other"),
|
||||
("work_update", "other"),
|
||||
("todo_write", "other"),
|
||||
];
|
||||
for name in crate::core::engine::tool_catalog::DEFAULT_ACTIVE_NATIVE_TOOLS {
|
||||
let expected = EXPECTED.iter().find(|(n, _)| n == name).map(|(_, c)| *c);
|
||||
|
||||
@@ -1484,8 +1484,8 @@ mod tests {
|
||||
fn agent_mode_carries_execution_discipline_block() {
|
||||
for phrase in [
|
||||
"Execute the user's task autonomously",
|
||||
"Keep `work_update` current",
|
||||
"present; otherwise",
|
||||
"call `todo_write` with all planned steps",
|
||||
"Keep it current as you go",
|
||||
"verify load-bearing child",
|
||||
"never manufacture completion sentinels",
|
||||
"For substantial work",
|
||||
@@ -1969,10 +1969,9 @@ mod tests {
|
||||
#[test]
|
||||
fn plan_mode_prompt_uses_one_progress_surface() {
|
||||
assert!(
|
||||
PLAN_MODE.contains("When `work_update` is present")
|
||||
&& PLAN_MODE.contains("canonical list there")
|
||||
&& PLAN_MODE.contains("otherwise keep progress in your response"),
|
||||
"Plan mode must condition progress guidance on the live catalog"
|
||||
PLAN_MODE.contains("call `todo_write` with all planned steps")
|
||||
&& PLAN_MODE.contains("There is no second Strategy/Plan progress surface"),
|
||||
"Plan mode must carry §3d todo_write wording and single-surface note"
|
||||
);
|
||||
assert!(!PLAN_MODE.contains("call `update_plan`"));
|
||||
assert!(
|
||||
@@ -2890,7 +2889,7 @@ mod tests {
|
||||
for must in [
|
||||
"autonomously",
|
||||
"tools in the current catalog",
|
||||
"work_update",
|
||||
"todo_write",
|
||||
"current catalog includes delegation",
|
||||
"Do not announce the mode",
|
||||
] {
|
||||
@@ -2926,8 +2925,9 @@ mod tests {
|
||||
crate::compaction::estimate_text_tokens_conservative(&normalized);
|
||||
// 2026-07-21: mode deltas contain permissions and durable behavior
|
||||
// only. Action recipes belong to the canonical tool schemas.
|
||||
let max_words = 120;
|
||||
let max_tokens = 320;
|
||||
// 2026-08-06: §3d expanded Agent/Plan with work_update wording (165 words).
|
||||
let max_words = 180;
|
||||
let max_tokens = 400;
|
||||
|
||||
assert!(
|
||||
word_count <= max_words,
|
||||
|
||||
@@ -175,7 +175,7 @@ pub const AGENT_MODE: &str = r#"##### Mode: Agent
|
||||
Execute the user's task autonomously. Run read-only actions directly; mutations
|
||||
follow approval policy. Use only tools in the current catalog and documented
|
||||
actions. Before acting on any task with three or more steps, or that spans multiple
|
||||
files, call `work_update` with all planned steps. Keep it current as you go —
|
||||
files, call `todo_write` with all planned steps. Keep it current as you go —
|
||||
mark each step done when it's done, and add steps you discover. Don't write the
|
||||
list retroactively, and don't keep a second checklist anywhere else. Never create a parallel strategy
|
||||
checklist.
|
||||
@@ -185,7 +185,7 @@ improves throughput. Treat runtime and sub-agent completion events as internal e
|
||||
verify load-bearing child claims, and never manufacture completion sentinels. Prefer
|
||||
notify/join tools to polling.
|
||||
|
||||
For substantial work, emit session-persistent `repl` blocks: ```repl runs; use ```python (or prose) to illustrate without running. Retain source/transcript
|
||||
For substantial work, emit session-persistent `repl` blocks: ```repl runs; use ```python (or prose) to illustrate without running. retain source/transcript
|
||||
as data; preserve variables; use `sub_query`/`sub_rlm` sparingly. Use
|
||||
`workflow`, `agent`, goals, `harness`; retain evidence-backed lessons.
|
||||
|
||||
@@ -195,10 +195,8 @@ Do not announce the mode or its approval mechanics.
|
||||
pub const PLAN_MODE: &str = r#"##### Mode: Plan
|
||||
|
||||
Investigate with read-only tools. Before acting on any task with three or more steps, or that spans multiple
|
||||
files, call `work_update` with all planned steps. Keep it current as you go —
|
||||
mark each step done when it's done, and add steps you discover. Don't write the
|
||||
list retroactively, and don't keep a second checklist anywhere else. There is no
|
||||
second Strategy/Plan progress surface. All writes, patches, shell commands, and
|
||||
files, call `todo_write` with all planned steps. Keep it current as you go —
|
||||
mark each step done when it's done, and add steps you discover. Don't write the list retroactively, and don't keep a second checklist anywhere else. There is no second Strategy/Plan progress surface. All writes, patches, shell commands, and
|
||||
code execution are blocked. When the current catalog includes read-only
|
||||
delegation, it may support parallel investigation. After presenting the plan,
|
||||
ask the user to reply with revisions or switch to Act (`/mode act`) to
|
||||
|
||||
@@ -1112,16 +1112,18 @@ impl ToolRegistryBuilder {
|
||||
}
|
||||
|
||||
/// Include the canonical work-progress tool with a shared `TodoList`.
|
||||
/// Canonical is `todo_write`; `work_update`/`TodoWrite`/`todo` are hidden
|
||||
/// compat aliases (not model-visible) for saved-transcript replay.
|
||||
#[must_use]
|
||||
pub fn with_todo_tool(self, todo_list: super::todo::SharedTodoList) -> Self {
|
||||
use super::todo::TodoWriteTool;
|
||||
self.with_tool(Arc::new(TodoWriteTool::work_update(todo_list.clone())))
|
||||
.with_tool(Arc::new(TodoWriteTool::alias(
|
||||
"TodoWrite",
|
||||
"work_update",
|
||||
todo_list.clone(),
|
||||
)))
|
||||
.with_tool(Arc::new(TodoWriteTool::alias(
|
||||
"todo_write",
|
||||
"TodoWrite",
|
||||
todo_list.clone(),
|
||||
)))
|
||||
.with_tool(Arc::new(TodoWriteTool::alias("todo", todo_list.clone())))
|
||||
@@ -1523,20 +1525,26 @@ mod tests {
|
||||
.with_todo_tool(crate::tools::todo::new_shared_todo_list())
|
||||
.build(ctx);
|
||||
|
||||
// Canonical tool must be present and aliases must resolve to it.
|
||||
assert!(registry.contains("work_update"));
|
||||
for alias in ["TodoWrite", "todo_write", "todo"] {
|
||||
// Canonical is todo_write; work_update/TodoWrite/todo are hidden compat aliases.
|
||||
assert!(registry.contains("todo_write"));
|
||||
for alias in ["work_update", "TodoWrite", "todo"] {
|
||||
assert!(
|
||||
registry.contains(alias),
|
||||
"{alias} compat alias must be registered"
|
||||
);
|
||||
// Hidden aliases are distinct entries (same handler, model_visible=false).
|
||||
assert_eq!(
|
||||
registry.resolve(alias),
|
||||
Some("work_update"),
|
||||
"{alias} must resolve to canonical work_update via registry ladder"
|
||||
Some(alias),
|
||||
"{alias} must be directly resolvable as hidden alias"
|
||||
);
|
||||
let tool = registry.get(alias).expect("alias tool");
|
||||
assert!(
|
||||
!tool.model_visible(),
|
||||
"{alias} hidden alias must not be model-visible"
|
||||
);
|
||||
}
|
||||
// Hidden compat aliases must stay registered but not model-visible.
|
||||
// Only todo_write is model-visible.
|
||||
let api_names = registry
|
||||
.to_api_tools()
|
||||
.into_iter()
|
||||
@@ -1544,17 +1552,17 @@ mod tests {
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(
|
||||
api_names.iter().any(|name| name == "work_update"),
|
||||
"work_update should be the sole model-visible progress surface"
|
||||
api_names.iter().any(|name| name == "todo_write"),
|
||||
"todo_write should be the sole model-visible progress surface"
|
||||
);
|
||||
assert_eq!(
|
||||
api_names.iter().filter(|n| *n == "work_update").count(),
|
||||
api_names.iter().filter(|n| *n == "todo_write").count(),
|
||||
1,
|
||||
"canonical work_update must appear exactly once in model catalog"
|
||||
"canonical todo_write must appear exactly once in model catalog"
|
||||
);
|
||||
for hidden in [
|
||||
"work_update",
|
||||
"TodoWrite",
|
||||
"todo_write",
|
||||
"todo",
|
||||
"checklist_write",
|
||||
"checklist_update",
|
||||
|
||||
@@ -5093,6 +5093,25 @@ impl SubAgentManager {
|
||||
);
|
||||
agent.session_name = name.to_string();
|
||||
agent.status = SubAgentStatus::Running;
|
||||
// Make the test agent live for 4a liveness (handle required, otherwise
|
||||
// list_filtered hides it as phantom). Use try_current + leaked runtime
|
||||
// fallback so sync tests also work.
|
||||
let handle = if let Ok(h) = tokio::runtime::Handle::try_current() {
|
||||
h.spawn(async {
|
||||
std::future::pending::<()>().await;
|
||||
})
|
||||
} else {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("test runtime");
|
||||
let h = rt.spawn(async {
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
std::mem::forget(rt);
|
||||
h
|
||||
};
|
||||
agent.task_handle = Some(handle);
|
||||
self.agents.insert(agent_id.clone(), agent);
|
||||
let spec = AgentWorkerSpec {
|
||||
worker_id: agent_id.clone(),
|
||||
@@ -5830,12 +5849,17 @@ impl SubAgentManager {
|
||||
// Live roster: only actually running children (4a). This
|
||||
// excludes completed/failed/cancelled and children that
|
||||
// never started (no task_handle) or timed out — same root
|
||||
// as the phantom watch entry. Prior-session running agents
|
||||
// stay visible for recovery, but only if they are live.
|
||||
// as the phantom watch entry. Prior-session Running stays
|
||||
// visible for recovery even without a handle (persisted
|
||||
// without task). Current-session terminals stay visible
|
||||
// for result fetch; prior-session terminals hide by default.
|
||||
if agent.status == SubAgentStatus::Running {
|
||||
if self.is_from_prior_session(agent) {
|
||||
return true;
|
||||
}
|
||||
return agent.task_handle.is_some() && !self.running_heartbeat_timed_out(agent);
|
||||
}
|
||||
false
|
||||
!self.is_from_prior_session(agent)
|
||||
})
|
||||
.map(|agent| self.snapshot_for_listing(agent))
|
||||
.collect()
|
||||
|
||||
@@ -10886,8 +10886,30 @@ fn insert_prior_session_agent(
|
||||
manager.workspace.clone(),
|
||||
boot_id.to_string(),
|
||||
);
|
||||
let is_running = status == SubAgentStatus::Running;
|
||||
agent.status = status;
|
||||
agent.id = id.to_string();
|
||||
// Current-session Running needs a handle to be live (4a). Prior-session
|
||||
// Running is visible without handle for recovery, but we give both a
|
||||
// handle when possible so sync tests can use a leaked runtime.
|
||||
if is_running {
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
agent.task_handle = Some(handle.spawn(async {
|
||||
std::future::pending::<()>().await;
|
||||
}));
|
||||
} else {
|
||||
// No ambient runtime (sync test): leak a runtime to create a live handle.
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("test runtime");
|
||||
let handle = rt.spawn(async {
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
std::mem::forget(rt);
|
||||
agent.task_handle = Some(handle);
|
||||
}
|
||||
}
|
||||
manager.agents.insert(id.to_string(), agent);
|
||||
}
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ pub fn new_shared_todo_list() -> SharedTodoList {
|
||||
}
|
||||
|
||||
const CANONICAL_WORK_SURFACE: &str = "work";
|
||||
const CANONICAL_PROGRESS_TOOL: &str = "work_update";
|
||||
const CANONICAL_PROGRESS_TOOL: &str = "todo_write";
|
||||
const DURABLE_WORK_OWNER: &str = "fleet_workflow_ledger";
|
||||
|
||||
/// Tool for writing and updating the todo list
|
||||
@@ -276,7 +276,7 @@ impl ToolSpec for TodoWriteTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Replace the active thread/task To-do list (concrete current work items). This is the canonical progress surface the user watches, so keep it live while you work: mark an item in_progress before starting it (exactly one at a time), and call work_update again the moment an item finishes so it shows completed — never batch completions at the end. Durable tasks remain the real executable work object."
|
||||
"Replace the active thread/task To-do list (concrete current work items). This is the canonical progress surface the user watches, so keep it live while you work: mark an item in_progress before starting it (exactly one at a time), and call todo_write again the moment an item finishes so it shows completed — never batch completions at the end. Durable tasks remain the real executable work object."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
@@ -593,7 +593,7 @@ mod tests {
|
||||
|
||||
assert!(tool.model_visible());
|
||||
let metadata = result.metadata.expect("metadata");
|
||||
assert_eq!(metadata["canonical_tool"], "work_update");
|
||||
assert_eq!(metadata["canonical_tool"], "todo_write");
|
||||
assert_eq!(metadata["work_surface"]["canonical"], "work");
|
||||
assert_eq!(metadata["work_surface"]["model_visible"], true);
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user