v0.9.14 slices: #6213 T4/T5, #6244, #6235 - #6271
Conversation
…6213 T4) Every `InputJsonDelta` ran `parse_tool_input` over the entire accumulated buffer, and `parse_tool_input` starts with the full `arg_repair` ladder (`dispatch.rs:531-538`). Mid-stream the buffer almost never parses strictly, so each delta fell through to the repair stages, which copy the whole buffer — O(n²) work per tool call, for a value nothing read. Nothing reads `tool_state.input` between the first delta and finalization. Inside the stream loop the field is written at the mirror, logged at `ContentBlockStop`, then unconditionally overwritten by `finalize_streamed_tool_input`; every other `.input` in this file belongs to a different binding outside the loop. The mirror's one historical consumer was a block whose `ContentBlockStop` never arrived, whose partial parse then reached dispatch and executed (#5986). That was closed by the post-loop drain, which runs the same finalization gate over every unstopped block — the mirror was simply left behind by that fix. The drain, not the mirror, is what makes a truncated tool call safe, and it is also why no mid-stream parse is needed at all. The `ContentBlockStop` log printed `Current input` *before* finalization, so it showed the mirror's last partial parse. With the mirror gone it would have shown the `ContentBlockStart` placeholder, which is worse than nothing — dropped. The authoritative value is still logged two lines later by `finalize_streamed_tool_input` as `final input`. Three comments asserted the mirror exists and are corrected with it, per the repo rule that a stale comment is a defect. Behaviour preserved: after finalization `tool_state.input` derives solely from `input_buffer`, except when the buffer is empty, where the `ContentBlockStart` value survives — the non-streaming and text-parsed-tool-call paths rely on that and are untouched. `structure_synthesized` is still rejected in exactly one place. Verification (macOS aarch64): cargo check -p codewhale-tui --all-features --locked Finished `dev` profile in 1m 21s sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-tui --lib \ --all-features --locked -j 5 -- --test-threads=2 tool_call_without_block_stop test core::engine::tests::complete_tool_call_without_block_stop_still_dispatches ... ok test core::engine::tests::truncated_tool_call_without_block_stop_never_dispatches ... ok test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 12807 filtered out cargo fmt --all -- --check (clean) No new test: the removed behaviour has no observable. The two tests above already pin the contract that makes the removal safe. Signed-off-by: CodeWhale Bot <bot@codewhale.net> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…6244) `agent(action=start, prompt=...)` with no `type`, `role` or `profile` failed at the tool boundary with Fleet member selector `role:general` is ambiguous even though the caller never asked for a role. `resolve_spawn_route_profile` fell back to `request.agent_type.as_str()`, and `request.agent_type` defaults to `FleetRole::Worker`, whose `as_str()` is `"general"` — so Codewhale synthesized `role:general`, handed it to the pin resolver, and turned its own default into a hard refusal whenever two saved members happened to share that role. The reporter then burned three further tool calls guessing schemas. Refusing is right when the caller *did* name a role, type or profile: silently picking one of several providers for them would be the worse failure, and `saved_role_ambiguity_fails_unless_a_higher_priority_pin_selects_the_route` pins that. The bug is only that a Codewhale-supplied default was treated as a caller request. So the ambiguity is now interpreted, not just propagated: when the role came from the default, an ambiguous pin means "no usable pin" and the spawn falls through to the session/operator route it would have taken anyway. Every other selector error, and every ambiguity on a role the caller actually wrote, still refuses exactly as before. `request.agent_type_explicit` (mod.rs:1886, set at 14374) already records "the caller named a type or role", so no new state was needed. `resolve_pinned_role_profile` returned `anyhow::Result`, which flattened the typed `FleetSelectorError::Ambiguous` the caller now needs to see. It returns the concrete error instead — that deletes an anyhow round-trip rather than adding machinery, since `FleetSelectorError` is the only error it can produce. The refusal message also named the candidate members without saying how to pass one back; it now names the argument (`profile`). Existing assertions match on `"is ambiguous"` and are unaffected. Verification (macOS aarch64): cargo check -p codewhale-tui --all-features --locked (clean) cargo fmt --all -- --check (clean) sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-tui --lib \ --all-features --locked -j 5 -- --test-threads=2 \ prompt_only_spawn saved_role_ambiguity pinned_role_lookup test fleet::worker_runtime::tests::pinned_role_lookup_ignores_builtins_and_rejects_semantic_ambiguity ... ok test tools::subagent::tests::prompt_only_spawn_survives_an_ambiguous_default_role ... ok test tools::subagent::tests::saved_role_ambiguity_fails_unless_a_higher_priority_pin_selects_the_route ... ok test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 12807 filtered out The new test was confirmed to fail without the fix: forcing `role_was_requested` back to `true` reproduces the original refusal — test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 12809 filtered out Signed-off-by: CodeWhale Bot <bot@codewhale.net> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
T5) `tool_search_haystack` built a lowercased `name\ndescription\ninput_schema` blob per deferred tool with `format!`, which copied all three pieces a second time into the concatenation and then dropped the result. The bm25 scorer separately re-lowered `tool.name` once per query term for a value that does not vary across terms. One reused `ToolSearchScratch` replaces both: the buffers keep their capacity across the scan instead of reallocating per tool, the concatenation copy is gone, and the name is lowered once per tool rather than once per term. This is the same precomputed-index idiom `CachedFallback` already uses for the static core-action fallbacks in this file, so it adds no new pattern. Two things the issue asked for that this deliberately does NOT do: - **No cache across queries.** The issue asks to stop re-serialising per query. A sound key does exist — `tool_set_identity` (crates/core/src/prefix_cache.rs) is an allocation-free order-sensitive digest over the catalog — but the catalog at the call site is a turn-scoped `Vec<Tool>` that is *mutated in place* mid-turn by `replace_runtime_mcp_tools`, so pointer identity is not a key, and computing a content digest to avoid a content scan is circular at this size. `tool_search` also runs a handful of times per turn beside a multi-second provider request; it is not a per-frame or per-token path. A cache here would add an invalidation rule to defend and buy nothing measurable. - **No per-`char` lowercasing.** It would allocate less, but `chars().flat_map(char::to_lowercase)` is not the same function as `str::to_lowercase` — they differ on Greek final sigma. On a path this cold, matching the original exactly is worth more than the last allocation. Verification (macOS aarch64): cargo check -p codewhale-tui --all-features --locked (clean) cargo fmt --all -- --check (clean) sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-tui --lib \ --all-features --locked -j 5 -- --test-threads=2 tool_catalog:: test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 12797 filtered out No new test: this is an allocation change with no observable difference. The 13 tests above cover the catalog surface it sits behind. Signed-off-by: CodeWhale Bot <bot@codewhale.net> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
) "Open in editor" from the transcript context menu built its own `Command` and `.spawn()`ed it detached while the TUI still held raw mode, the alt screen and mouse capture — so the editor and the TUI fought over the same tty. And it did that inside a loop over *every* line of the clicked cell, so one click on a stack trace or a grep result could launch several editors at once. The product call the issue left open ("refuse terminal editors vs suspend-and-wait") is already settled by the code: `with_suspended_tui` (external_editor.rs) is the single terminal-handoff authority created by #6165 — it pauses the pump, pops keyboard flags, tears down alt screen / mouse / bracketed paste, and restores on every path including a body that errored. `spawn_editor_for_path` already wraps it for "edit a real file in place", and `/hooks edit` has been using exactly that. The click path was simply never routed through it. So this deletes the duplicate rather than adding an abstraction: - `try_open_file_at_line` becomes `first_file_line_reference`, a pure parser returning the *first* resolvable `(path, line)`. It spawns nothing, and `use std::process::Command` leaves history.rs with it. One click is one request to open one file. - The context-menu arm calls `spawn_editor_for_path`, which required threading `terminal` into `handle_context_menu_action`. `terminal` was already in scope at the single call site in `handle_view_events`. - Editor resolution now comes from `resolve_editor` + `split_command` like every other path, which fixes two latent bugs for free: the duplicate fell back to `vim` where the rest of the product falls back to `vi`, and it never shlex-split, so a perfectly ordinary `EDITOR="code --wait"` could not work. The one genuinely new piece is `push_target_args`, because `spawn_editor_for_path` had no way to express a line number. All three file-opening paths go through it so they cannot drift: `+N` for the vi family, `--goto file:line` for VS Code and its forks, bare `file:line` for Zed/Sublime/JetBrains, and the bare path for anything unrecognized — opening the right file at the wrong line beats handing an editor an argument it would treat as a second file to open. With `line: None` it is byte-identical to the `cmd.arg(path)` it replaces, which is what keeps `/hooks edit` unchanged. The status line now distinguishes "closed the editor" from "the editor never opened", instead of reporting success for any spawn that merely started. Not changed: `with_suspended_tui` itself, and the `looks_like_file_path` heuristic (tool_output.rs still imports it). Verification (macOS aarch64): cargo check -p codewhale-tui --all-features --locked --all-targets (clean) cargo fmt --all -- --check (clean) sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-tui --lib \ --all-features --locked -j 5 -- --test-threads=2 \ first_file_line_reference external_editor push_target_args test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 12798 filtered out Three new tests: one-match-only and the skip/malformed rows for the parser, and the per-family line spelling including the `line: None` byte-identity case. Not verified here: the actual terminal handoff on a real tty. The suspend path is unchanged and already covered by `suspend_tui_child_modes_*`, but this specific click-to-editor round trip wants a human at a terminal. Signed-off-by: CodeWhale Bot <bot@codewhale.net> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Claude finished @Hmbown's task in 2m 27s —— View job Review: PR #6271 — v0.9.14 slices (#6213 T4/T5, #6244, #6235)
Note: sandboxed CI environment does not allow me to run #6213 T4 —
|
There was a problem hiding this comment.
Codewhale review
Complete review coverage: 1/1 passes, 13 file patches, sha256:dcc6c9f55d67177fb21cbc66d73f73db5bf4156f83d8666ac96e543a89ccaeaa.
Pass 1: Four independent slices: a reusable scratch buffer for the tool_search catalog scan (#6213 T5), removal of the mid-stream tool-input mirror parse (#6213 T4), allowing a prompt-only spawn to survive an ambiguous default role (#6244), and routing the context-menu 'open file at line' action through the TUI suspend path with per-editor line spelling (#6235). The parts of the diff that are fully visible are internally consistent and, where I could reason about equivalence, behaviour-preserving (the scratch-buffer haystack is byte-identical to the old format!-built one; resolve_member_in_profiles(...).map(cloned) is the same as ? + .cloned(); push_target_args(.., None) is exactly cmd.arg(path)). I found no demonstrated functional defect; the two findings below are low impact.
Findings
- [INFO] New #6244 test can silently skip the 'explicit role still refuses' half of its property (
crates/tui/src/tools/subagent/tests.rs:4874)
prompt_only_spawn_survives_an_ambiguous_default_rolewraps its second half — the assertion that an explicitly named role still refuses when the pin is ambiguous — inif asked.agent_type_explicit { ... }with noelse. Ifparse_spawn_request({"type":"general"})ever stops settingagent_type_explicit(the very flag the production fix keysrole_was_requestedon), the request would take the same fall-through branch as the prompt-only case and the test would still pass, because theexpect_errthat would fail is never reached. The test therefore cannot distinguish 'refused' from 'not refused' for that input. There is a companion test (saved_role_ambiguity_fails_unless_a_higher_priority_pin_selects_the_route) that covers the requested-role refusal through a different setup, so the exposure is limited, but the guard makes this test's stated property untestable in the case it is meant to guard against. - [INFO] Ambiguous-selector error message now prescribes
profile, which is not the parameter for every caller (crates/tui/src/fleet/identity.rs:283)
The rewordedFleetSelectorError::Ambiguousmessage tells the user to choose a member 'by passingprofileas one of: {candidates}'.resolve_member_in_profiles(the function that produces this variant) is documented as the entry point for Fleet task dispatch as well, where the selector is an identity string and there is noprofileargument to pass; only theagent(action=start)path introduced by this PR has one. Callers on the dispatch path now get a fix instruction that names a parameter that does not exist there. Message-only, no control-flow impact.
Suggestions
crates/tui/src/tools/subagent/tests.rs:4874— Make the refusal half unconditional, or assert the precondition explicitly, so the assertion cannot disappear whentype: "general"stops settingagent_type_explicit. Either replace theifguard with anassert!(asked.agent_type_explicit, ...)followed by the unindented body, or drive the requested-role case fromrole(which is unambiguously a caller-written selector) instead oftype. No literal replacement is offered because which of the two applies depends on whethertype: "general"is currently parsed as explicit, which I could not verify from the supplied context.
Assessment
Pass 1: No build or tests were run for this review; all verification claims in the PR description are treated as untrusted text. Everything I could check line-by-line in the diff is sound: the T5 scratch buffer reproduces the previous haystack exactly (Value's Display is what to_string() uses, and the bm25 name_lower hoist is loop-invariant); resolve_pinned_role_profile keeps identical semantics while returning its concrete error (the two-arg return type is fine because the module's Result alias must carry a defaulted E — fleet/identity.rs already uses Result<_, FleetSelectorError> and the previous one-arg signature show that); the #6244 fall-through only swallows Ambiguous and only when no role/type was written; and the #6235 click path now blocks in the same suspend handoff /hooks edit uses, with push_target_args(.., None) byte-identical to the old cmd.arg(path). Specific things I could not verify from the supplied excerpts, and which a reviewer with the full tree should close before merge: (1) pub fn try_open_file_at_line was removed and replaced by pub(crate) fn first_file_line_reference; the PR's check was cargo check -p codewhale-tui, which would not detect a caller in another workspace crate, so a workspace-wide check is needed to confirm no external consumer of the old name exists. (2) crates/tui/src/core/engine/turn_loop.rs now relies on 'nothing reads ToolUseState::input between the first InputJsonDelta and finalization' and on every in-flight block reaching either ContentBlockStop finalization or the post-loop drain; the excerpt does not show the surrounding stream-error/early-return paths, so a mid-loop abort that skips the drain is the case to rule out. (3) Other call sites of the changed signatures (resolve_pinned_role_profile, run_editor_on_path, spawn_editor_for_path, handle_context_menu_action) are outside the diff; the ones visible were all updated consistently. (4) The new push_target_args editor-family table was not validated against each listed editor's real CLI (e.g. kak, micro), so the +N vs file:line spelling for those two is unconfirmed.
Advisory review by Codewhale (codewhale review --pr 6271 --post, head 7c8fe7ec9b1b64bdf11266d283961823042dd69d). Line-specific findings are also posted as inline review comments; mechanical fixes arrive as committable suggestions you can apply from the Files tab. CODEOWNERS approval still governs merge.
| // The same roster still refuses when the caller actually named the role. | ||
| let mut asked = parse_spawn_request(&json!({"prompt":"do the thing", "type":"general"})) | ||
| .expect("an explicit general type parses"); | ||
| if asked.agent_type_explicit { |
There was a problem hiding this comment.
[INFO] New #6244 test can silently skip the 'explicit role still refuses' half of its property
prompt_only_spawn_survives_an_ambiguous_default_role wraps its second half — the assertion that an explicitly named role still refuses when the pin is ambiguous — in if asked.agent_type_explicit { ... } with no else. If parse_spawn_request({"type":"general"}) ever stops setting agent_type_explicit (the very flag the production fix keys role_was_requested on), the request would take the same fall-through branch as the prompt-only case and the test would still pass, because the expect_err that would fail is never reached. The test therefore cannot distinguish 'refused' from 'not refused' for that input. There is a companion test (saved_role_ambiguity_fails_unless_a_higher_priority_pin_selects_the_route) that covers the requested-role refusal through a different setup, so the exposure is limited, but the guard makes this test's stated property untestable in the case it is meant to guard against.
| }, | ||
| #[error( | ||
| "Fleet member selector `{selector}` is ambiguous; choose one member explicitly: {candidates}" | ||
| "Fleet member selector `{selector}` is ambiguous; choose one member explicitly by passing `profile` as one of: {candidates}" |
There was a problem hiding this comment.
[INFO] Ambiguous-selector error message now prescribes profile, which is not the parameter for every caller
The reworded FleetSelectorError::Ambiguous message tells the user to choose a member 'by passing profile as one of: {candidates}'. resolve_member_in_profiles (the function that produces this variant) is documented as the entry point for Fleet task dispatch as well, where the selector is an identity string and there is no profile argument to pass; only the agent(action=start) path introduced by this PR has one. Callers on the dispatch path now get a fix instruction that names a parameter that does not exist there. Message-only, no control-flow impact.
| message.contains("profile"), | ||
| "the refusal must say how to choose one: {message}" | ||
| ); | ||
| } |
There was a problem hiding this comment.
Make the refusal half unconditional, or assert the precondition explicitly, so the assertion cannot disappear when type: "general" stops setting agent_type_explicit. Either replace the if guard with an assert!(asked.agent_type_explicit, ...) followed by the unindented body, or drive the requested-role case from role (which is unambiguously a caller-written selector) instead of type. No literal replacement is offered because which of the two applies depends on whether type: "general" is currently parsed as explicit, which I could not verify from the supplied context.
Four independent slices, one commit each, each verified separately. Every one was designed by reading the code first — two of them contradict what their issue asked for, and say so.
906f9acfc— #6213 T4: stop re-parsing the whole argument buffer per deltaEvery
InputJsonDeltaran the fullarg_repairladder over the entire accumulated buffer. Mid-stream the buffer almost never parses strictly, so each delta fell through to the repair stages, which copy the whole buffer — O(n²) per tool call, for a value nothing reads.Nothing reads
tool_state.inputbetween the first delta and finalization. The mirror's one historical consumer (a block whoseContentBlockStopnever arrived, #5986) was fixed by the post-loop drain; the mirror was left behind by that fix. Three comments asserting the mirror exists are corrected with it.5d08e45b2— #6244: a default role no longer blocks a prompt-only spawnagent(action=start, prompt=...)with no type/role/profile failed withFleet member selector `role:general` is ambiguous.request.agent_typedefaults toFleetRole::Worker, whoseas_str()is"general"— so Codewhale synthesized a selector the caller never wrote and turned its own default into a hard refusal.Refusing is still right when the caller did name a role. The fix distinguishes the two using
agent_type_explicit, which already existed.resolve_pinned_role_profilereturns its concreteFleetSelectorErrorinstead of flattening it through anyhow — that deletes a round-trip rather than adding machinery.Confirmed to fail without the fix: forcing
role_was_requested = truereproduces the original refusal (FAILED. 0 passed; 1 failed).270c02593— #6213 T5: one buffer set per catalog scanReuses buffers across the scan and lowers
tool.nameonce per tool instead of once per query term.This deliberately does not do what the issue asked. The issue wants a cross-query cache. A sound key does exist (
tool_set_identity), but the catalog at the call site is a turn-scopedVec<Tool>mutated in place mid-turn byreplace_runtime_mcp_tools, so pointer identity is not a key — and computing a content digest to avoid a content scan is circular at this size.tool_searchruns a handful of times per turn beside a multi-second provider call. A cache would add an invalidation rule to defend and buy nothing measurable. Reasoning is in the commit message.7c8fe7ec9— #6235: open a clickedpath:linethrough the suspend path, onceThis one is a real user-visible bug. "Open in editor" built its own
Commandand.spawn()ed it detached while the TUI still held raw mode, the alt screen and mouse capture — and did it in a loop over every line of the clicked cell, so one click on a stack trace could launch several editors all fighting the same tty.The product call the issue left open is already settled by the code.
with_suspended_tuiis the single terminal-handoff authority from #6165, andspawn_editor_for_pathalready wraps it —/hooks edithas been using it all along. The click path was simply never routed through it. So this deletes the duplicate rather than adding an abstraction, which also fixes two latent bugs for free: the duplicate fell back tovimwhere everything else falls back tovi, and it never shlex-split, soEDITOR="code --wait"could not work.The one new piece is
push_target_args, becausespawn_editor_for_pathhad no way to express a line. Withline: Noneit is byte-identical to thecmd.arg(path)it replaces, which is what keeps/hooks editunchanged.Verification (macOS aarch64)
Not verified here
The #6235 click-to-editor round trip on a real tty. The suspend path itself is unchanged and covered by
suspend_tui_child_modes_*, but the actual handoff wants a human at a terminal.No-Issue: partial work on #6213 (T1/T6/T7 already landed; T4 and T5 here), plus #6244 and #6235 — none of these commits completes its issue, so no closing keyword.