fix(tui): keep progress and shell work reachable

This commit is contained in:
CodeWhale Bot
2026-08-11 18:20:27 -07:00
parent 4d1fe43116
commit bf6def00d7
12 changed files with 167 additions and 84 deletions
+11 -5
View File
@@ -94,11 +94,12 @@ runs against Pi 0.8.41 and by dogfooding repeated manual compaction.
- `todo_write` is an optional progress surface rather than required model
ceremony.
- New turns use one small, stable toolbox: `read`, `write`, `edit`, `bash`,
`agent`, and `tool_search`. Specialized native, Web, MCP, plugin, memory,
task, and verification tools are policy-filtered and searchable; activated
schemas stay in a bounded per-conversation cache. Every sub-agent keeps its
own search and cache, including policy-allowed Web research, while forked
context and parent activations remain warm starts rather than allowlists.
`agent`, `todo_write`, and `tool_search`. The optional progress tool stays
visible as familiar working memory; specialized native, Web, MCP, plugin,
memory, task, and verification tools are policy-filtered and searchable;
activated schemas stay in a bounded per-conversation cache. Every sub-agent
keeps its own search and cache, including policy-allowed Web research, while
forked context and parent activations remain warm starts rather than allowlists.
- The direct file and shell schemas follow Pi's deliberately small contract:
bounded complete-line reads, hash-free writes, unambiguous multi-edit with
BOM/CRLF preservation and conservative fuzzy matching, and one foreground
@@ -122,6 +123,11 @@ runs against Pi 0.8.41 and by dogfooding repeated manual compaction.
### Fixed
- Sending more context while a lowercase `bash` command is running now moves
the command to `/jobs` and returns a successful running receipt instead of
falsely reporting `Command exited with code -1`; the process keeps running
and its completion still arrives through the normal runtime event.
- First-run usage disclosure now opens as a native Codewhale modal instead of a
shell questionnaire before application startup. Telemetry remains unarmed
until the native choice is made, and an in-memory Disable choice governs the
+13 -14
View File
@@ -4961,24 +4961,23 @@ mod tests {
async fn assert_kimi_code_captures_exact_general_child_catalog() {
let tools = crate::tools::subagent::kimi_general_child_request_tools_fixture();
let source_len = tools.len();
// The deliberate lowercase contract: the child wire catalog is the
// fixed six-tool surface, with specialized tools discoverable through
// tool_search rather than eager.
// Specialized tools remain discoverable beyond the fixed eager head.
assert_eq!(
source_len, 6,
"expected the six-tool General child catalog: {tools:?}"
source_len,
crate::core::engine::default_active_native_tool_names().len() + 1,
"expected the seven-tool General child catalog: {tools:?}"
);
let source_names = tools
.iter()
.map(|tool| tool.name.as_str())
.map(|tool| tool.name.clone())
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
source_names,
["agent", "bash", "edit", "read", "tool_search", "write"]
.into_iter()
.collect(),
"the child wire catalog must be exactly the six-tool surface"
);
let expected_names = crate::core::engine::default_active_native_tool_names()
.iter()
.copied()
.chain([crate::core::engine::tool_catalog::TOOL_SEARCH_NAME])
.map(str::to_string)
.collect();
assert_eq!(source_names, expected_names);
// Name the offending first-party tool in test-only diagnostics while
// production errors remain fixed and non-secret.
@@ -4999,7 +4998,7 @@ mod tests {
let captured = body["tools"].as_array().expect("captured tool catalog");
assert_eq!(captured.len(), source_len);
for required in ["read", "write", "edit", "bash", "agent", "tool_search"] {
for required in &source_names {
assert!(
captured_function(&body, required).is_object(),
"{required} must reach the Kimi Code wire"
+15 -7
View File
@@ -6669,9 +6669,9 @@ fn approval_stamp_preserves_existing_metadata() {
}
#[test]
fn only_pi_core_native_tools_default_to_eager() {
fn core_primitives_and_todo_write_default_to_eager() {
let always_load = HashSet::new();
for core in ["read", "write", "edit", "bash", "agent"] {
for core in ["read", "write", "edit", "bash", "agent", "todo_write"] {
assert!(!should_default_defer_tool(core, &always_load));
}
for searchable in ["File", "Bash", "Git", "Run", "tasks", "git_blame"] {
@@ -6681,7 +6681,7 @@ fn only_pi_core_native_tools_default_to_eager() {
#[test]
fn default_active_contract_keeps_discovery_and_core_tools_eager() {
const EXPECTED_NATIVE: [&str; 5] = ["read", "write", "edit", "bash", "agent"];
const EXPECTED_NATIVE: [&str; 6] = ["read", "write", "edit", "bash", "agent", "todo_write"];
assert_eq!(
default_active_native_tool_names(),
EXPECTED_NATIVE.as_slice()
@@ -6715,7 +6715,7 @@ fn default_active_contract_keeps_discovery_and_core_tools_eager() {
#[test]
fn non_yolo_mode_retains_default_defer_policy() {
let always_load = HashSet::new();
for core in ["read", "write", "edit", "bash", "agent"] {
for core in ["read", "write", "edit", "bash", "agent", "todo_write"] {
assert!(!should_default_defer_tool(core, &always_load));
}
for searchable in [
@@ -7191,7 +7191,15 @@ fn metric_tool_names<'a>(
#[allow(clippy::await_holding_lock)]
async fn runtime_contract_tool_metric_uses_canonical_mode_surfaces() {
let payload = measure_production_mode_tool_catalogs().await;
let expected_active = HashSet::from(["agent", "bash", "edit", "read", "tool_search", "write"]);
let expected_active = HashSet::from([
"agent",
"bash",
"edit",
"read",
"todo_write",
"tool_search",
"write",
]);
for mode in ["plan", "act", "operate"] {
let full = metric_tool_names(&payload, mode, "full");
@@ -8020,8 +8028,8 @@ fn model_catalog_exposes_work_update_as_sole_progress_surface() {
"todo_write must be model-visible"
);
assert!(
!active.contains("todo_write"),
"todo_write is searchable/lazy"
active.contains("todo_write"),
"todo_write must be available without a discovery turn"
);
assert!(
!catalog_names.contains("update_plan"),
+4 -7
View File
@@ -43,14 +43,11 @@ pub(crate) fn is_tool_search_tool(name: &str) -> bool {
)
}
// Crate-visible rather than `pub(super)` so the hook gate's classifier test can
// assert it still recognises every default-active name. Without that anchor the
// test pins hardcoded strings and stays green through a tool rename, while the
// gate silently reclassifies the renamed tool as unknown.
// Crate-visible so the hook gate tests the real eager names instead of a copy.
#[rustfmt::skip]
pub(crate) const DEFAULT_ACTIVE_NATIVE_TOOLS: &[&str] = &[
// A deliberately small, stable router surface. Specialized native, MCP,
// plugin, and durable-work tools remain discoverable through tool_search.
"read", "write", "edit", "bash", "agent",
// Specialized native, MCP, plugin, and durable-work tools stay searchable.
"read", "write", "edit", "bash", "agent", "todo_write",
];
const CORE_ACTION_TOOL_FALLBACKS: &[CoreActionToolFallback] = &[
@@ -72,12 +72,20 @@ fn published_synthetic_names_agree_with_the_synthetic_predicate() {
fn first_turn_surface_is_stable_across_plan_work_and_full_access() {
assert_eq!(
DEFAULT_ACTIVE_NATIVE_TOOLS,
&["read", "write", "edit", "bash", "agent"]
&["read", "write", "edit", "bash", "agent", "todo_write"]
);
let expected = ["agent", "bash", "edit", "read", "tool_search", "write"]
.into_iter()
.map(str::to_string)
.collect::<BTreeSet<_>>();
let expected = [
"agent",
"bash",
"edit",
"read",
"todo_write",
"tool_search",
"write",
]
.into_iter()
.map(str::to_string)
.collect::<BTreeSet<_>>();
for mode in [AppMode::Plan, AppMode::Agent, AppMode::Yolo] {
let mut catalog = [
"read",
@@ -85,6 +93,7 @@ fn first_turn_surface_is_stable_across_plan_work_and_full_access() {
"edit",
"bash",
"agent",
"todo_write",
"Git",
"Run",
"tasks",
@@ -244,7 +253,7 @@ fn unknown_and_wildcard_allowlists_keep_mcp_startup() {
#[test]
fn compact_surface_keeps_the_exact_eager_agent_head() {
let catalog = build_model_tool_catalog_with_surface(
["read", "write", "edit", "bash", "agent"]
["read", "write", "edit", "bash", "agent", "todo_write"]
.into_iter()
.map(tool)
.collect(),
+2 -1
View File
@@ -2450,7 +2450,7 @@ fn tool_category_for(tool_name: &str, tool_args: Option<&str>) -> &'static str {
// `shell.rs` still stamps it for the `shell_env` hook event.
"bash" | "Bash" | "exec_shell" => "shell",
// The lowercase primitives ship without an action envelope.
"read" => "safe",
"read" | "todo_write" => "safe",
"write" | "edit" => "file_write",
"File" | "file" => match action.as_deref() {
Some("read" | "list" | "search_name" | "search_content") => "safe",
@@ -5608,6 +5608,7 @@ command = "echo project"
("bash", "shell"),
// The router itself touches nothing a hook needs to gate.
("agent", "other"),
("todo_write", "safe"),
];
for name in crate::core::engine::tool_catalog::DEFAULT_ACTIVE_NATIVE_TOOLS {
let expected = EXPECTED.iter().find(|(n, _)| n == name).map(|(_, c)| *c);
+17 -11
View File
@@ -145,7 +145,7 @@ fn validate_shell_working_dir(path: &Path, inherited_session_workspace: bool) ->
Ok(())
}
/// Status of a shell process
/// Status of a shell process.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ShellStatus {
Running,
@@ -155,9 +155,8 @@ pub enum ShellStatus {
TimedOut,
}
/// Result from a shell command execution
/// Result from a shell command execution.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShellResult {
pub task_id: Option<String>,
pub status: ShellStatus,
@@ -3517,6 +3516,20 @@ fn finish_pi_bash_result(
) -> Result<ToolResult, ToolError> {
let mut output = result.stdout.clone();
output.push_str(&result.stderr);
let metadata = json!({
"evidence_routing": "inline", "exit_code": result.exit_code,
"status": format!("{:?}", result.status), "duration_ms": result.duration_ms,
"sandboxed": result.sandboxed, "sandbox_type": result.sandbox_type,
"task_id": result.task_id, "backgrounded": result.status == ShellStatus::Running,
});
if result.status == ShellStatus::Running {
let task_id = result.task_id.as_deref().unwrap_or("unknown");
let partial = (!output.is_empty()).then(|| format!("\n\nOutput so far:\n{output}"));
return Ok(ToolResult::success(format!(
"Foreground shell wait moved to /jobs: {task_id}{}\n\nThe command is still running; completion will appear as a runtime event.",
partial.as_deref().unwrap_or_default()
)).with_metadata(metadata));
}
if result.status != ShellStatus::Completed {
let status = pi_bash_error_status(&result, timeout_ms);
return Err(ToolError::execution_failed(if output.is_empty() {
@@ -3531,14 +3544,7 @@ fn finish_pi_bash_result(
} else {
output
})
.with_metadata(json!({
"evidence_routing": "inline",
"exit_code": result.exit_code,
"status": "Completed",
"duration_ms": result.duration_ms,
"sandboxed": result.sandboxed,
"sandbox_type": result.sandbox_type,
})))
.with_metadata(metadata))
}
/// Small foreground-only shell surface shown to new model turns.
+44
View File
@@ -2269,6 +2269,50 @@ async fn test_exec_shell_foreground_can_move_to_background() {
assert_eq!(killed.status, ShellStatus::Killed);
}
#[tokio::test]
async fn lowercase_bash_foreground_detach_is_a_successful_running_receipt() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path());
let shell_manager = ctx.shell_manager.clone();
let command = sleep_command(30);
let task_ctx = ctx.clone();
let task = tokio::spawn(async move {
PiBashTool
.execute(json!({"command": command}), &task_ctx)
.await
.expect("execute")
});
tokio::time::sleep(Duration::from_millis(150)).await;
shell_manager
.lock()
.expect("shell manager lock")
.request_foreground_background();
let result = tokio::time::timeout(Duration::from_secs(5), task)
.await
.expect("foreground shell should detach")
.expect("task should not panic");
assert!(result.success, "{}", result.content);
assert!(
result.content.contains("moved to /jobs"),
"{}",
result.content
);
assert!(!result.content.contains("code -1"), "{}", result.content);
let metadata = result.metadata.expect("metadata");
assert_eq!(metadata["status"], "Running");
assert_eq!(metadata["backgrounded"], true);
let task_id = metadata["task_id"].as_str().expect("task id");
let mut manager = shell_manager.lock().expect("shell manager lock");
let job = manager.inspect_job(task_id).expect("inspect job");
assert_eq!(job.snapshot.status, ShellStatus::Running);
manager.kill(task_id).expect("kill test job");
}
#[tokio::test]
async fn test_exec_shell_wait_cancel_leaves_background_process_running() {
let tmp = tempdir().expect("tempdir");
+13 -5
View File
@@ -6225,10 +6225,18 @@ fn small_surface_starts_with_only_pi_head_and_search() {
assert_eq!(
model_tool_names(model_request_tools(&mut surface)),
["agent", "bash", "edit", "read", "tool_search", "write"]
.into_iter()
.map(str::to_string)
.collect()
[
"agent",
"bash",
"edit",
"read",
"todo_write",
"tool_search",
"write",
]
.into_iter()
.map(str::to_string)
.collect()
);
let strict = surface.request_tools(surface.catalog.clone(), true);
assert_eq!(
@@ -6474,7 +6482,7 @@ fn small_surface_depth_cap_removes_only_agent() {
);
assert_eq!(
model_tool_names(model_request_tools(&mut surface)),
["bash", "edit", "read", "tool_search", "write"]
["bash", "edit", "read", "todo_write", "tool_search", "write"]
.into_iter()
.map(str::to_string)
.collect()
+4 -4
View File
@@ -292,10 +292,10 @@ second catalog snapshot.
The default diet removes `exec_wait` and `exec_interact` from the active head
(they become hidden-compat; their canonical twins `exec_shell_wait` /
`exec_shell_interact` stay). `tts` and `todo_*` are *already not* in the active
set, so they do not change the active budget in this diet. The net effect of
this specific diet is to remove two duplicate active aliases from whatever
default active head is current after the surrounding v0.8.53 PR batch.
`exec_shell_interact` stay). `tts` and the legacy `todo_*` aliases remain out
of the active set. The canonical `todo_write` tool became eager in v0.9.6 as an
explicit budget decision so ordinary progress tracking never requires a
discovery turn.
### Per mode (Plan / Agent / YOLO)
+8 -6
View File
@@ -17,16 +17,17 @@ Implementation sources:
## Default-active contract
New turns start with exactly six model-facing names:
New turns start with exactly seven model-facing names:
1. `read`
2. `write`
3. `edit`
4. `bash`
5. `agent`
6. `tool_search`
6. `todo_write`
7. `tool_search`
The first five are `DEFAULT_ACTIVE_NATIVE_TOOLS` in
The first six are `DEFAULT_ACTIVE_NATIVE_TOOLS` in
`crates/tui/src/core/engine/tool_catalog.rs`. `tool_search` is synthetic and is
always active. An authority boundary may remove `agent` at the maximum child
depth, but route size alone must not change this core vocabulary.
@@ -40,6 +41,7 @@ The direct schemas deliberately stay small:
| `edit` | `path`, `edits` | Apply one or more unambiguous text replacements against one original snapshot. |
| `bash` | `command`, optional `timeout` | Run one cancellable foreground shell command and return a bounded tail. |
| `agent` | delegated task and optional scope/context controls | Start or inspect focused child work. |
| `todo_write` | complete replacement list of `{content, status}` items | Keep optional, agent-owned progress notes for genuinely multi-step work. |
| `tool_search` | `query`, optional matching controls | Discover policy-allowed deferred tools and add selected schemas to this conversation's toolbox. |
Mode is an authority decision, not a synonym system. Plan, Work, and Operate
@@ -49,8 +51,8 @@ trusted-path, repository-law, and managed-policy gates. Full Access changes
ordinary approval behavior but does not bypass hard safety or repository law.
`update_plan` remains registered only for saved-artifact compatibility and is
not model-visible. `todo_write`, `tasks`, `Git`, `Run`, `Web`, `remember`, and
other specialized capabilities are searchable rather than first-turn ceremony.
not model-visible. `tasks`, `Git`, `Run`, `Web`, `remember`, and other
specialized capabilities are searchable rather than first-turn ceremony.
## Deferred and dynamic tools
@@ -253,7 +255,7 @@ exits 0 with "0 passed; N filtered out" when a filter matches nothing, so a
misspelled filter is indistinguishable from a pass. (Three filters printed here
before v0.9.4 named tests that did not exist.)
The provider-free receipt must report the six default-active names listed
The provider-free receipt must report the seven default-active names listed
above. A separate repository-wide tool count may include deferred, dynamic,
feature-gated, and compatibility-only registrations; it is not the number of
tools placed in the first-turn model catalog.
+21 -18
View File
@@ -85,23 +85,24 @@
"modes": {
"act": {
"active": {
"bytes": 12786,
"identity_sha256": "34a00b1aa3688c88b5b3b57b292b0fbc01ced97e7d5ae00ecbeefee4cfb62671",
"tokens_est": 3197,
"bytes": 13435,
"identity_sha256": "8411bddfc0d7fce72ec53dfeef341f28e6a3cfe37b98a721dc05b17d53b0a13e",
"tokens_est": 3359,
"tool_names": [
"agent",
"bash",
"edit",
"read",
"todo_write",
"tool_search",
"write"
],
"tools": 6
"tools": 7
},
"full": {
"bytes": 68409,
"bytes": 68377,
"identity_sha256": "8f3b51d6221804baac42de911d89f772b89c38a1320da7ebb6d3bdc96c4efb79",
"tokens_est": 17103,
"tokens_est": 17095,
"tool_names": [
"Git",
"Run",
@@ -164,23 +165,24 @@
},
"operate": {
"active": {
"bytes": 12786,
"identity_sha256": "34a00b1aa3688c88b5b3b57b292b0fbc01ced97e7d5ae00ecbeefee4cfb62671",
"tokens_est": 3197,
"bytes": 13435,
"identity_sha256": "8411bddfc0d7fce72ec53dfeef341f28e6a3cfe37b98a721dc05b17d53b0a13e",
"tokens_est": 3359,
"tool_names": [
"agent",
"bash",
"edit",
"read",
"todo_write",
"tool_search",
"write"
],
"tools": 6
"tools": 7
},
"full": {
"bytes": 68409,
"bytes": 68377,
"identity_sha256": "8f3b51d6221804baac42de911d89f772b89c38a1320da7ebb6d3bdc96c4efb79",
"tokens_est": 17103,
"tokens_est": 17095,
"tool_names": [
"Git",
"Run",
@@ -243,23 +245,24 @@
},
"plan": {
"active": {
"bytes": 12786,
"identity_sha256": "34a00b1aa3688c88b5b3b57b292b0fbc01ced97e7d5ae00ecbeefee4cfb62671",
"tokens_est": 3197,
"bytes": 13435,
"identity_sha256": "8411bddfc0d7fce72ec53dfeef341f28e6a3cfe37b98a721dc05b17d53b0a13e",
"tokens_est": 3359,
"tool_names": [
"agent",
"bash",
"edit",
"read",
"todo_write",
"tool_search",
"write"
],
"tools": 6
"tools": 7
},
"full": {
"bytes": 41403,
"bytes": 41371,
"identity_sha256": "2cf3e5b6810e7840ee2d2df2b6dd81a24058e3e916acdb1199aff3abae916a41",
"tokens_est": 10351,
"tokens_est": 10343,
"tool_names": [
"Git",
"Web",