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:
CodeWhale Bot
2026-08-06 20:27:42 -07:00
parent e733c00892
commit ec5747f7d7
9 changed files with 133 additions and 48 deletions
+43 -10
View File
@@ -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}"
+3 -3
View File
@@ -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(),
);
}
+1 -1
View File
@@ -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);
+9 -9
View File
@@ -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,
+4 -6
View File
@@ -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
+21 -13
View File
@@ -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",
+27 -3
View File
@@ -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()
+22
View File
@@ -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);
}
+3 -3
View File
@@ -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!(