From 3b3eb7d47df4313adb090946d99b9c121f11a2a1 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Wed, 4 Nov 2026 18:45:09 +0800 Subject: [PATCH 1/4] fix(fleet): the test role states its real contract Third slice of the #126 split: the `test` role's honest surface. - The roster description, the type schema, VERIFIER_AGENT_INTRO, and both SUBAGENTS role tables state the real contract: built-in verification gates with test-selection arguments, plus the classifier-bounded read-only shell for evidence gathering; custom commands stay refused. - The Verifier joins `role_requires_read_only_shell` and `allows_bounded_readonly_bash` (the latter now reads that one predicate), gaining the same bounded probe lane as the inspection roles; the carve-out is Verifier-only and keys on a recorded parent-shell grant, so a read-only-parent's child never regains authority through the role name alone (chain pins cover the sub-subagent path). - Shell authority (`holds_shell_authority`) keys on the held policy plus the grant sentinel; fleet receipts report the verifier's effective shell as `read_only`; the worker catalog omits bash from envelopes without read-only shell authority and pins the refusal copy's load-bearing claims. Tests: verifier contract/authority/carve-out pins incl. the sub-subagent chain, catalog and receipts labels; the deleted no-shell verifier catalog test is replaced by the probe-lane variant. Targeted: `tools::subagent::` + `tools::registry::` + `fleet::` suites green; tree of this commit equals the reviewed Pinvou/CodeWhale#126 (stacks on fix/live-test-findings-subagent-shell after fix/shell-stdin-eof); full content preserved on archive/p126-pre-split-8705ccd85. Signed-off-by: asto18089 --- crates/tui/src/fleet/executor.rs | 5 +- crates/tui/src/fleet/manager.rs | 8 +- crates/tui/src/fleet/role.rs | 37 +- crates/tui/src/fleet/worker_runtime.rs | 7 +- crates/tui/src/tools/registry.rs | 11 +- crates/tui/src/tools/registry/tests.rs | 56 ++ crates/tui/src/tools/subagent/mod.rs | 100 ++- crates/tui/src/tools/subagent/tests.rs | 958 ++++++++++++++++++++++++- docs/SUBAGENTS.md | 25 +- docs/zh_hans/FLEET.md | 2 +- docs/zh_hans/SUBAGENTS.md | 8 +- 11 files changed, 1151 insertions(+), 66 deletions(-) diff --git a/crates/tui/src/fleet/executor.rs b/crates/tui/src/fleet/executor.rs index a508e8545e..a82ee4eacc 100644 --- a/crates/tui/src/fleet/executor.rs +++ b/crates/tui/src/fleet/executor.rs @@ -243,7 +243,10 @@ pub(crate) fn authority_envelope_for_worker( && spec.runtime_profile.shell.allows_shell() { // Scout, Reviewer, and Planner get one classifier-bounded - // foreground shell. Verifiers keep the dedicated Run surface, + // foreground shell. On this executor path the Verifier keeps only + // the dedicated Run surface below (the in-process registry + // additionally admits it to the classifier-bounded `bash` evidence + // lane — see `SubAgentToolRegistry::allows_bounded_readonly_bash`), // consultants remain shell-less, and writers retain the historical // subprocess contract. ToolShellAuthority::ReadOnly diff --git a/crates/tui/src/fleet/manager.rs b/crates/tui/src/fleet/manager.rs index ec3257002e..35a3ae2f23 100644 --- a/crates/tui/src/fleet/manager.rs +++ b/crates/tui/src/fleet/manager.rs @@ -4884,7 +4884,13 @@ esac !permissions.write, "verifier receipt {key} must stay read-only" ); - assert_eq!(permissions.shell, "full"); + // The verifier joined the read-only-shell roles: its bash + // is the classifier-bounded probe lane, and the receipt + // records that effective posture (the old `full` label + // overclaimed a raw shell the clamp always denied). The + // built-in verification surface stays keyed on the shell + // ceiling, not on this label. + assert_eq!(permissions.shell, "read_only"); } role => panic!("unexpected receipt role for {key}: {role:?}"), } diff --git a/crates/tui/src/fleet/role.rs b/crates/tui/src/fleet/role.rs index fe729af6b7..79e3bd47d4 100644 --- a/crates/tui/src/fleet/role.rs +++ b/crates/tui/src/fleet/role.rs @@ -193,7 +193,7 @@ impl FleetRole { "Lands focused code changes cleanly with the minimum surrounding edit." } Self::Verifier => { - "Runs the test suite and validation gates, reports pass/fail with evidence." + "Runs the built-in verification gates with test-selection arguments, plus — for in-session delegations — a bounded read-only shell for evidence gathering; cannot execute custom commands — use a write-capable role for those." } Self::Consultant => { "Read-only high-reasoning counsel for judgement calls and design critique." @@ -350,9 +350,11 @@ pub(crate) const MUTATING_TOOL_DENYLIST: &[&str] = &[ /// receipt says `write=false`. Denying the raw shell entries and leaving the /// bounded verification surface (`Run` / `run_tests` / `run_verifiers`) intact /// keeps the verifier able to do its job under a contract that is true. -/// Scout/reviewer read-only inspection selectively removes only canonical `Bash` from this -/// deny list after the role is known; its input-specific read-only classifier -/// remains the authority for that narrow exception. +/// Scout/reviewer/planner read-only inspection selectively removes only canonical `Bash` from this +/// deny list after the role is known (`from_runtime_role`'s retain); a verifier's +/// classifier-bounded `bash` is admitted separately by the subagent registry's +/// probe lane (`allows_bounded_readonly_bash`), not by that retain; the +/// input-specific read-only classifier remains the authority for both narrow exceptions. /// /// That surface is bounded only in its **default** form, and the distinction is /// load-bearing: `run_verifiers` accepts a `commands` array of arbitrary @@ -514,8 +516,10 @@ impl ChildAuthority { // Raw shell requires the ceiling to *say* `shell = "full"`. Any narrower // shell posture — `none` or `read_only` — loses the raw command surface // outright. `from_runtime_role` may subsequently - // retain canonical `Bash` for a named scout/reviewer, whose concrete - // calls are bounded by the strict read-only classifier. + // retain canonical `Bash` for the Scout/Reviewer/Planner trio, whose concrete + // calls are bounded by the strict read-only classifier; a Verifier's probe + // `bash` is admitted by the registry lane instead, without touching this + // deny list. // // This is deliberately keyed on the shell field rather than only on // `write`, and that is the whole repair: the execution envelope reads @@ -644,9 +648,13 @@ impl ChildAuthority { FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner ) && authority.ceiling.shell != ShellCeiling::None { - // Runtime's Scout/Reviewer policy permits classifier-bounded Bash - // inspection. Keep the canonical entry while all other shell and - // execution aliases remain denied. + // Runtime's Scout/Reviewer/Planner policy permits classifier-bounded + // Bash inspection. Keep the canonical entry while all other shell and + // execution aliases remain denied. This literal is the one role set + // that intentionally differs from `role_requires_read_only_shell` + // (which adds Verifier): a Verifier's probe `bash` is admitted over + // the deny by the registry lane, not by this retain, so changing + // either side means changing the member fingerprint or dropping a lane. authority .disallowed_tools .retain(|name| !name.eq_ignore_ascii_case("Bash")); @@ -715,11 +723,20 @@ pub(crate) fn posture_role_for(ceiling: PermissionCeiling) -> &'static str { /// Whether a Fleet role is never allowed a mutating shell, whatever its /// requested runtime profile says. Spawn narrows the child to a read-only /// shell for these roles, and every receipt must report that same posture. +/// +/// The narrowing downgrades only the raw-command policy: canonical `bash` +/// becomes the classifier-bounded probe lane, while the bounded built-in +/// verification surface keeps keying on the role's shell *ceiling* and the +/// shell-authority sentinel — the `test` role keeps both, because running the +/// workspace's own gates is its entire purpose. "Keeps both" describes the +/// in-session registry path; the fleet executor envelope gives the Verifier +/// only the bounded verification surface (`shell = none`, no probe lane) by +/// design. #[must_use] pub(crate) fn role_requires_read_only_shell(role: &FleetRole) -> bool { matches!( role, - FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner + FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner | FleetRole::Verifier ) } diff --git a/crates/tui/src/fleet/worker_runtime.rs b/crates/tui/src/fleet/worker_runtime.rs index 6d86a1c8e9..e947b5cfea 100644 --- a/crates/tui/src/fleet/worker_runtime.rs +++ b/crates/tui/src/fleet/worker_runtime.rs @@ -1600,7 +1600,12 @@ mod tests { ..WorkerRuntimeProfile::default() }; - for role in [FleetRole::Scout, FleetRole::Reviewer, FleetRole::Planner] { + for role in [ + FleetRole::Scout, + FleetRole::Reviewer, + FleetRole::Planner, + FleetRole::Verifier, + ] { let effective = role::effective_runtime_profile_for_role(&role, &requested); assert_eq!(effective.shell, ShellPolicy::ReadOnly, "{role:?}"); assert_eq!( diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index df854b680c..17130b5066 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -263,7 +263,16 @@ impl ToolRegistry { .filter(|tool| tool.model_visible()) .filter(|tool| { read_only_authority.is_none_or(|authority| { - readonly_evidence_tool(tool.as_ref()) + // The classifier-bounded probe lane is an evidence tool, + // but a worker whose envelope grants no shell authority + // refuses every bash call at dispatch + // (`enforce_tool_authority` demands + // `ToolShellAuthority::ReadOnly`); listing it would + // advertise a surface the worker can never use. + let evidence = readonly_evidence_tool(tool.as_ref()) + && !(matches!(tool.name(), "bash" | "Bash") + && authority.shell != super::spec::ToolShellAuthority::ReadOnly); + evidence || (tool.name() == "Run" && authority.verification == super::spec::ToolVerificationAuthority::Bounded) diff --git a/crates/tui/src/tools/registry/tests.rs b/crates/tui/src/tools/registry/tests.rs index a56963ee52..abd677b868 100644 --- a/crates/tui/src/tools/registry/tests.rs +++ b/crates/tui/src/tools/registry/tests.rs @@ -711,6 +711,38 @@ fn readonly_verifier_context(workspace: &std::path::Path) -> ToolContext { .expect("bounded verifier authority") } +/// A worker whose envelope grants no shell authority refuses every bash call +/// at dispatch (`enforce_tool_authority` demands `ToolShellAuthority::ReadOnly` +/// for bash/Bash), so the evidence catalog must not advertise the lane it can +/// never use — while a worker whose envelope DOES grant read-only shell keeps +/// the classifier-bounded probe lane. +#[test] +fn machine_catalog_omits_bash_without_shell_authority() { + let tmp = tempdir().expect("tempdir"); + let shell_less = ToolRegistryBuilder::new() + .with_read_only_shell_tool() + .build(readonly_verifier_context(tmp.path())); + let shell_less_tools = shell_less.to_api_tools(); + let names: Vec<&str> = shell_less_tools + .iter() + .map(|tool| tool.name.as_str()) + .collect(); + assert!( + !names.contains(&"bash") && !names.contains(&"Bash"), + "a shell-less verifier catalog must not list the bash lane: {names:?}" + ); + // The twin: a read-only-shell envelope keeps the lane. + let scoped = ToolRegistryBuilder::new() + .with_read_only_shell_tool() + .build(readonly_scout_context(tmp.path(), true)); + let scoped_tools = scoped.to_api_tools(); + let names: Vec<&str> = scoped_tools.iter().map(|tool| tool.name.as_str()).collect(); + assert!( + names.contains(&"bash"), + "a read-only-shell scout catalog keeps the classified probe lane: {names:?}" + ); +} + #[test] fn machine_verifier_catalog_and_dispatch_add_only_bounded_run() { let tmp = tempdir().expect("tempdir"); @@ -917,6 +949,30 @@ fn fleet_authority_intersects_readonly_github_bash_with_network_ceiling() { .expect_err("network denial must win") .to_string(); assert!(error.contains("does not grant network access"), "{error}"); + // The envelope admits Bash through the parallel classifier, which refuses + // compounds and globs outright, so a gh read inside one fails closed + // before the per-stage network predicate is consulted — with or without + // a network grant. Compound probes are an in-session `agent` capability, + // not a fleet one. + for command in [ + "pwd; gh repo view", + "gh pr list --search '*'", + "gh repo view | head -n 3", + ] { + let compound = json!({"action": "run", "command": command}); + for context in [&offline, &networked] { + let error = enforce_tool_authority("Bash", &compound, &shell, context) + .expect_err("a compound gh read fails closed under the fleet envelope") + .to_string(); + // The wall that fires is the parallel classifier's (compounds + // are never parallel-admitted), not the network ceiling: name it + // so a refusal from any other wall cannot green this leg. + assert!( + error.contains("outside its machine-readable authority envelope"), + "{command}: {error}" + ); + } + } } #[tokio::test] diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 3abcf7e7db..222ca0af99 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -404,7 +404,7 @@ fn subagent_perf_enabled() -> bool { const VALID_SUBAGENT_TYPES: &str = "general, explore, planner, reviewer, implement, test, advisor, custom \ (legacy aliases remain accepted: worker, scout, builder, verifier, consultant, general-purpose, general_purpose, default, exploration/explorer, plan/planning/awaiter, review/code-review/code_review, implementer/implementation, verify/verification/validator/tester, oracle)"; -const SUBAGENT_TYPE_DESCRIPTION: &str = "Fleet role for this delegated worker. general: full tool access for multi-step tasks; write-capable, so it claims the parent workspace unless write_roots narrows it — parallel generals without disjoint write_roots contend, so prefer a read-only role for research fan-outs. explore: fast read-only exploration. planner: grounded strategy with read-only probes. reviewer: reads and grades code. implement: lands focused code changes; write-capable under the same workspace-claim rules as general, so parallel implements need disjoint write_roots or worktree=true. test: runs tests/validation gates and reports evidence. advisor: read-only high-reasoning counsel for judgement calls and design critique. custom: the tools listed in allowed_tools on the parent's posture; without an explicit write authority it starts read-only and never contends. Legacy aliases remain accepted at deserialization boundaries."; +const SUBAGENT_TYPE_DESCRIPTION: &str = "Fleet role for this delegated worker. general: full tool access for multi-step tasks; write-capable, so it claims the parent workspace unless write_roots narrows it — parallel generals without disjoint write_roots contend, so prefer a read-only role for research fan-outs. explore: fast read-only exploration. planner: grounded strategy with read-only probes. reviewer: reads and grades code. implement: lands focused code changes; write-capable under the same workspace-claim rules as general, so parallel implements need disjoint write_roots or worktree=true. test: runs the built-in verification gates and reports evidence, with a bounded read-only shell — for in-session delegations — for gathering it; custom commands are refused. advisor: read-only high-reasoning counsel for judgement calls and design critique. custom: the tools listed in allowed_tools on the parent's posture; without an explicit write authority it starts read-only and never contends. Legacy aliases remain accepted at deserialization boundaries."; // === Types === @@ -15636,6 +15636,14 @@ struct SubAgentToolRegistry { accept_edits: bool, agent_type: FleetRole, runtime_profile: WorkerRuntimeProfile, + /// Whether the child's requested shell was a full shell *after the parent + /// clamp but before the role's own narrowing* — i.e. the read-only shell + /// it now runs under came from its role's own narrowing, not from a + /// parent that itself held no shell authority. Keys the Verifier's + /// verification-surface carve-out in [`Self::holds_shell_authority`] so a + /// read-only role parent cannot obtain gate execution it never had by + /// delegating to `test`. + parent_shell_authority: bool, // Depth is the only special authority governing nested `agent` calls. can_spawn_child: bool, // Every mutation is attributed to the child and checked against its live @@ -15711,6 +15719,12 @@ impl SubAgentToolRegistry { // has a full shell. let parent_shell = ShellPolicy::from_legacy_allow_shell(runtime.allow_shell); let mut child_shell = runtime.worker_profile.shell.min_with(parent_shell); + // Capture the grant before the role narrowing below: a Full shell + // here means the parent itself could run processes, so the role's + // read-only downgrade is the child's own posture. A clamped + // (read-only-parent) child must not regain authority through a + // role carve-out it merely shares the name of. + let parent_shell_authority = matches!(child_shell, ShellPolicy::Full); if crate::fleet::role::role_requires_read_only_shell(&agent_type) && child_shell.allows_shell() { @@ -15756,6 +15770,7 @@ impl SubAgentToolRegistry { accept_edits: runtime.accept_edits, agent_type, runtime_profile: effective_profile, + parent_shell_authority, can_spawn_child, owner_agent_id, owner_agent_name, @@ -16373,8 +16388,18 @@ impl SubAgentToolRegistry { { return true; } - matches!(self.runtime_profile.shell, ShellPolicy::Full) - && role_posture_permits(&self.agent_type, ApprovalRequirement::Required) + // Canonical `bash` on a lane role is deliberately NOT + // covered by the authority check below: the classifier + // carve-out above is its only door, so a command the + // grammar refuses keeps the `[shell.readonly.command]` + // refusal instead of being waved through to the + // envelope. The check is case-aware because legacy + // `Bash` is the raw-shell alias — the carve-out is + // exact-name `bash` only, and the alias must not + // re-enter through this branch. + let bash_must_use_classifier_lane = name.eq_ignore_ascii_case("bash") + && self.allows_bounded_readonly_bash("bash"); + !bash_must_use_classifier_lane && self.holds_shell_authority() } }, None => true, @@ -16391,9 +16416,11 @@ impl SubAgentToolRegistry { /// Whether this child may surface and dispatch the canonical lowercase /// `bash` tool under the read-only inspection posture. /// - /// Scout, Reviewer, and Planner keep exactly one shell entry point — - /// canonical `bash` — whose concrete calls the strict read-only - /// classifier bounds. + /// Scout, Reviewer, Planner, and Verifier keep exactly one shell entry + /// point — canonical `bash` — whose concrete calls the strict read-only + /// classifier bounds. For the Verifier this is the evidence lane: running + /// the built-in gates is only half the role, and "pass/fail with + /// evidence" needs probes like `wc -c` the raw surface can never allow. /// Catalog visibility and dispatch authorization consult this same /// predicate, so a tool that appears on the wire can always be called and /// one that is denied never appears. @@ -16413,10 +16440,7 @@ impl SubAgentToolRegistry { /// first-turn catalog actually offers is bounded by the classifier. fn allows_bounded_readonly_bash(&self, name: &str) -> bool { name == "bash" - && matches!( - &self.agent_type, - FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner - ) + && crate::fleet::role::role_requires_read_only_shell(&self.agent_type) && self.runtime_profile.shell != crate::worker_profile::ShellPolicy::None } @@ -16474,8 +16498,10 @@ impl SubAgentToolRegistry { fn role_blocks_unhardened_process_tool(&self, name: &str) -> bool { // Catalog filtering is defense in depth. Scout/Reviewer see only the - // hardened evidence profile; Verifier may receive bounded Run but not - // any raw or session-oriented shell path. Dispatch repeats this check. + // hardened evidence profile; Verifier may receive bounded Run and the + // same classifier-bounded canonical `bash` lane as the inspection + // roles, but not any raw or session-oriented shell path. Dispatch + // repeats this check. let lower = name.to_ascii_lowercase(); let evidence_tool = crate::tools::registry::readonly_evidence_tool_name(name) || self @@ -16485,14 +16511,19 @@ impl SubAgentToolRegistry { let bounded_inspection = matches!(&self.agent_type, FleetRole::Scout | FleetRole::Reviewer) && lower != "agent" && !evidence_tool; - let raw_shell = lower == "bash" - || lower.starts_with("exec_shell") + let raw_shell = lower.starts_with("exec_shell") || matches!( lower.as_str(), "exec_wait" | "exec_interact" | "task_shell_start" | "task_shell_wait" ) || lower.starts_with("terminal/"); - bounded_inspection || matches!(&self.agent_type, FleetRole::Verifier) && raw_shell + // For a verifier, canonical `bash` is hardened exactly when the + // bounded read-only lane admits it (`allows_bounded_readonly_bash`); + // every other raw-shell spelling — and legacy `Bash`, which fails the + // lane's exact-name check — stays unhardened and blocked. + let verifier_shell = matches!(&self.agent_type, FleetRole::Verifier) + && (raw_shell || (lower == "bash" && !self.allows_bounded_readonly_bash(name))); + bounded_inspection || verifier_shell } fn network_is_denied(&self) -> bool { @@ -16505,12 +16536,33 @@ impl SubAgentToolRegistry { !self.runtime_profile.permissions.write } + /// Shell *authority* — the right to start the processes the built-in + /// verification surface runs. A full runtime shell holds it. The one + /// narrowed exception is the Verifier whose *parent granted a full + /// shell* (`parent_shell_authority`): `role_requires_read_only_shell` + /// downgrades the Verifier's raw-command policy to the classifier-bounded + /// probe lane, and running the workspace's own gates is the role's entire + /// purpose, so that role-own downgrade must not take the gates away + /// (`reject_unbounded_verification` and the envelope still bound every + /// concrete call). The grant cannot travel through delegation: a Verifier + /// child of a read-only role parent was clamped by that parent, holds no + /// authority of its own, and stays refused — delegation moves work, never + /// authority. No other role regains authority through a narrowed shell: a + /// Builder or Custom child of a read-only parent inherits + /// `ShellPolicy::ReadOnly` and must not start the test runner its parent + /// cannot — keying this on the role's *default* posture would hand + /// exactly that to every role whose default shell is full. + fn holds_shell_authority(&self) -> bool { + matches!(self.runtime_profile.shell, ShellPolicy::Full) + || (self.parent_shell_authority + && matches!(self.agent_type, FleetRole::Verifier) + && !matches!(self.runtime_profile.shell, ShellPolicy::None)) + } + fn shell_is_denied(&self) -> bool { - // Likewise, shell requires both a Full profile and no explicit shell - // sentinel. Read-only evidence commands are Auto-classified exceptions, - // not a Full-shell grant. - !matches!(self.runtime_profile.shell, ShellPolicy::Full) - || self.is_tool_denied(SHELL_AUTHORITY_SENTINEL) + // Read-only evidence commands are Auto-classified exceptions, not a + // shell-authority grant; the explicit shell sentinel always wins. + !self.holds_shell_authority() || self.is_tool_denied(SHELL_AUTHORITY_SENTINEL) } fn execution_envelope(&self) -> crate::tools::execution_envelope::ExecutionEnvelope { @@ -16609,10 +16661,9 @@ impl SubAgentToolRegistry { // Visibility and dispatch consult the same capability guard. These // representative calls let a read-only bash schema survive catalog // shaping without treating an empty input as arbitrary shell authority. - if !matches!( - &self.agent_type, - FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner - ) { + // The role set is the one `allows_bounded_readonly_bash` admits: the + // read-only-shell roles, including the verifier's evidence lane. + if !crate::fleet::role::role_requires_read_only_shell(&self.agent_type) { return None; } match name { @@ -17831,6 +17882,7 @@ const CONSULTANT_AGENT_INTRO: &str = concat!( const VERIFIER_AGENT_INTRO: &str = concat!( "You are a trusted Fleet test agent (role: `test`). Your job is to run the requested gates with your bounded validation tools — the allowed test/check selections — and report results. You never write: patching the workspace is denied. Unbounded shell forms are refused; use the verification surface.\n", + "When the session grants it — for in-session delegations, on envelopes that carry the probe lane — you also hold a bounded read-only shell, under the same classifier-gated grammar as the read-only roles, for gathering evidence with probes such as `wc`, `ls`, `cat`, and `grep` pipelines; envelopes without it omit the shell entirely and shell attempts refuse, and custom commands are refused.\n", "Report PASS/FAIL/FLAKY at the top of SUMMARY with exact command evidence.\n", "Capture failing assertion and file:line; put obvious fixes under RISKS.\n", "You may use more tool calls than quick exploration, but stop after decisive pass/fail evidence.\n", diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index e7f4ffd5fe..ad357ffd67 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -8,8 +8,10 @@ use std::collections::HashSet; use std::process::Command; use std::sync::atomic::{AtomicUsize, Ordering}; use tempfile::{Builder as TempDirBuilder, tempdir}; + mod launch_receipt; mod operation_identity; + fn built_in_whale_name_that_cannot_be_generated_for(agent_id: &str) -> &'static str { WHALE_NICKNAMES .iter() @@ -30,6 +32,7 @@ fn built_in_whale_name_that_cannot_be_generated_for(agent_id: &str) -> &'static .find(|name| generated_whale_name_base(agent_id, name).is_none()) .expect("the combined pools contain labels not generated for one id") } + #[test] fn generated_whale_names_follow_session_language_without_mixing() { let localized_pools: &[(&str, &[&str])] = &[ @@ -79,6 +82,7 @@ fn generated_whale_names_follow_session_language_without_mixing() { whale_name_for_id_in_locale("fallback", "en") ); } + #[test] fn locale_matched_whale_collision_suffix_stays_in_language() { let id = "agent_locale_collision"; @@ -90,6 +94,7 @@ fn locale_matched_whale_collision_suffix_stays_in_language() { assert!(unique.starts_with(&base)); assert!(!unique.is_ascii()); } + #[test] fn localized_whale_displays_rederive_legacy_names_from_neutral_ids() { let generated_a = whale_name_for_id_in_locale("agent_english_a", "zh-Hans"); @@ -132,6 +137,7 @@ fn localized_whale_displays_rederive_legacy_names_from_neutral_ids() { "a built-in whale word belonging to another id remains user-owned" ); } + #[test] fn exact_deterministic_whale_match_remains_generated_without_provenance() { let agent_id = "agent_ambiguous_whale"; @@ -148,9 +154,11 @@ fn exact_deterministic_whale_match_remains_generated_without_provenance() { "a collision suffix remains presentation-only" ); } + fn make_assignment() -> SubAgentAssignment { SubAgentAssignment::new("prompt".to_string(), Some("worker".to_string())) } + fn make_snapshot(status: SubAgentStatus) -> SubAgentResult { SubAgentResult { name: "agent_test".to_string(), @@ -178,6 +186,7 @@ fn make_snapshot(status: SubAgentStatus) -> SubAgentResult { from_prior_session: false, } } + #[test] fn forkguard_interrupted_snapshot_preserves_checkpoint_recovery() { let _spill = crate::tools::truncate::TEST_SPILLOVER_GUARD @@ -247,6 +256,7 @@ fn forkguard_interrupted_snapshot_preserves_checkpoint_recovery() { } }); } + fn make_worker_spec(worker_id: &str, workspace: PathBuf) -> AgentWorkerSpec { let tool_profile = AgentWorkerToolProfile::Explicit(vec!["read_file".to_string(), "grep_files".to_string()]); @@ -277,6 +287,7 @@ fn make_worker_spec(worker_id: &str, workspace: PathBuf) -> AgentWorkerSpec { launch_manifest: None, } } + fn make_write_worker_spec(worker_id: &str, workspace: PathBuf, root: &str) -> AgentWorkerSpec { let mut spec = make_worker_spec(worker_id, workspace.clone()); spec.agent_type = FleetRole::Builder; @@ -301,6 +312,7 @@ fn make_write_worker_spec(worker_id: &str, workspace: PathBuf, root: &str) -> Ag }); spec } + #[test] fn child_approval_ids_stay_unique_within_and_across_manager_boots() { // #5615: the per-manager sequence restarts at zero, so a resumed agent @@ -341,6 +353,7 @@ fn child_approval_ids_stay_unique_within_and_across_manager_boots() { ); } } + #[test] fn active_worker_records_are_never_pruned_by_history_retention() { let tmp = tempdir().expect("tempdir"); @@ -364,6 +377,7 @@ fn active_worker_records_are_never_pruned_by_history_retention() { .all(|record| !record.status.is_terminal()) ); } + #[test] fn headless_worker_record_tracks_lifecycle_without_tui_projection() { let tmp = tempdir().expect("tempdir"); @@ -468,6 +482,7 @@ fn headless_worker_record_tracks_lifecycle_without_tui_projection() { .any(|event| event.tool_name.as_deref() == Some("read_file")) ); } + #[test] fn worker_record_usage_accumulates_provider_tokens() { let tmp = tempdir().expect("tempdir"); @@ -530,6 +545,7 @@ fn worker_record_usage_accumulates_provider_tokens() { record.usage.note ); } + #[test] fn token_budget_scope_is_shared_across_nested_workers_and_blocks_when_spent() { let tmp = tempdir().expect("tempdir"); @@ -600,6 +616,7 @@ fn token_budget_scope_is_shared_across_nested_workers_and_blocks_when_spent() { assert_eq!(override_scope.limit, 20); assert_eq!(override_scope.spent, 0); } + #[test] fn agent_worker_profile_derives_from_parent_without_escalation() { let mut runtime = stub_runtime(); @@ -640,6 +657,7 @@ fn agent_worker_profile_derives_from_parent_without_escalation() { ToolScope::Explicit(vec!["read_file".to_string(), "write_file".to_string()]) ); } + #[test] fn declared_read_only_write_roles_derive_without_mutating_shell() { for input in [ @@ -667,6 +685,7 @@ fn declared_read_only_write_roles_derive_without_mutating_shell() { assert_eq!(profile.shell, ShellPolicy::None, "{request:?}"); } } + #[test] fn custom_runtime_inherits_the_parent_posture_and_explicit_authority_is_a_no_op_superset() { let runtime = stub_runtime().background_runtime(); @@ -714,6 +733,7 @@ fn custom_runtime_inherits_the_parent_posture_and_explicit_authority_is_a_no_op_ "scout parents carry the read-only inspection posture" ); } + #[test] fn subagent_progress_displays_legacy_shell_names_as_lowercase_bash() { assert_eq!(subagent_progress_tool_display_name("exec_shell"), "bash"); @@ -731,6 +751,7 @@ fn subagent_progress_displays_legacy_shell_names_as_lowercase_bash() { "read_file" ); } + #[test] fn agent_progress_preserves_event_channel_headroom_under_load() { let (tx, mut rx) = mpsc::channel(40); @@ -781,6 +802,7 @@ fn agent_progress_preserves_event_channel_headroom_under_load() { )); assert!(rx.try_recv().is_err()); } + #[test] fn agent_progress_uses_small_event_channels_without_headroom_reservation() { let (tx, mut rx) = mpsc::channel(8); @@ -805,6 +827,7 @@ fn agent_progress_uses_small_event_channels_without_headroom_reservation() { && activity.step == Some(1) )); } + #[test] fn headless_worker_records_persist_with_subagent_state() { let tmp = tempdir().expect("tempdir"); @@ -844,6 +867,7 @@ fn headless_worker_records_persist_with_subagent_state() { .any(|event| event.status == AgentWorkerStatus::Failed) ); } + #[test] fn persisted_subagent_state_has_bounded_serialized_size() { // #3885 / item 4: `subagents.v1.json` is transitively bounded but had no @@ -891,6 +915,7 @@ fn persisted_subagent_state_has_bounded_serialized_size() { "all registered workers should be persisted" ); } + #[test] fn coordination_ledger_persists_and_replays_bounded_contracts() { let tmp = tempdir().expect("tempdir"); @@ -942,6 +967,7 @@ fn coordination_ledger_persists_and_replays_bounded_contracts() { assert_eq!(inspect["decisions"][0]["decision_id"], "decision_storage"); assert_eq!(inspect["write_claims"][0]["claim"]["owner"], "agent_writer"); } + #[test] fn invalid_decision_and_reconciliation_inputs_cannot_poison_persisted_replay() { let tmp = tempdir().expect("tempdir"); @@ -1033,6 +1059,7 @@ fn invalid_decision_and_reconciliation_inputs_cannot_poison_persisted_replay() { assert_eq!(replayed.coordination.decisions.len(), 2); assert!(replayed.coordination.reconciliations.is_empty()); } + #[test] fn coordination_hot_paths_count_only_active_authoritative_owners() { let tmp = tempdir().expect("tempdir"); @@ -1071,6 +1098,7 @@ fn coordination_hot_paths_count_only_active_authoritative_owners() { }] ); } + #[test] fn headless_worker_registration_enforces_live_claims_and_projects_context() { let tmp = tempdir().expect("tempdir"); @@ -1151,6 +1179,7 @@ fn headless_worker_registration_enforces_live_claims_and_projects_context() { .expect("non-overlapping workers may proceed concurrently"); assert_eq!(manager.coordination.write_claims.len(), 2); } + #[test] fn session_close_finalizes_live_fleet_and_releases_write_claims() { let tmp = tempdir().expect("tempdir"); @@ -1224,6 +1253,7 @@ fn session_close_finalizes_live_fleet_and_releases_write_claims() { )) .expect("claim stays released after a second close pass"); } + #[test] fn terminal_result_releases_owner_from_write_claim_contention() { let tmp = tempdir().expect("tempdir"); @@ -1283,6 +1313,7 @@ fn terminal_result_releases_owner_from_write_claim_contention() { )) .expect("overlapping claim admitted after terminal result"); } + #[test] fn prior_session_owners_never_count_as_active_coordination_owners() { let tmp = tempdir().expect("tempdir"); @@ -1325,6 +1356,7 @@ fn prior_session_owners_never_count_as_active_coordination_owners() { )) .expect("prior-session owner must not block a new writer"); } + #[test] fn isolated_worktree_workers_skip_the_coordination_process_lock() { let tmp = tempdir().expect("tempdir"); @@ -1369,6 +1401,7 @@ fn isolated_worktree_workers_skip_the_coordination_process_lock() { .preflight_worker_coordination(&shared) .expect("shared-workspace writer proceeds once the lock is held"); } + #[test] fn coordination_detail_projection_reports_process_lock_ownership() { let tmp = tempdir().expect("tempdir"); @@ -1397,6 +1430,7 @@ fn coordination_detail_projection_reports_process_lock_ownership() { "shared projection carries no note" ); } + /// A second Codewhale session in the same workspace is ordinary usage. Losing /// the coordination flock means "I cannot append to the shared ledger" — it /// must never mean "my agents are dead". Bookkeeping failure and liveness are @@ -1451,6 +1485,7 @@ fn a_second_session_without_the_lock_keeps_its_running_agents_live() { snap.status ); } + /// A second session shares the workspace coordination read-lock and must load /// the existing ledger before it persists any of its own state. That preserves /// prior decisions instead of replacing them with an empty ledger. @@ -1520,6 +1555,7 @@ fn a_second_session_with_the_shared_lock_loads_the_workspace_ledger() { "the second session must not overwrite the workspace ledger with an empty one" ); } + /// The complement of the test above: an orphan still terminalizes, but only on /// heartbeat evidence. Lock ownership is not part of the decision either way. #[test] @@ -1588,6 +1624,7 @@ fn cleanup_terminalizes_orphans_on_heartbeat_evidence_not_on_lock_ownership() { "a heartbeat-stale orphan still becomes Interrupted" ); } + #[test] fn neutral_reconciliation_requires_the_nearest_common_planner() { let tmp = tempdir().expect("tempdir"); @@ -1672,6 +1709,7 @@ fn neutral_reconciliation_requires_the_nearest_common_planner() { assert_eq!(receipt.retry_limit, 3); assert_eq!(receipt.candidate_handles.len(), 2); } + #[test] fn coordination_acceptance_preserves_scopes_candidates_and_replay() { let repo = init_subagent_git_repo(); @@ -1966,6 +2004,7 @@ fn coordination_acceptance_preserves_scopes_candidates_and_replay() { "pub const B: u8 = 2;" ); } + fn init_subagent_git_repo() -> tempfile::TempDir { let dir = tempdir().expect("tempdir"); @@ -2015,6 +2054,7 @@ fn init_subagent_git_repo() -> tempfile::TempDir { dir } + fn git(repo: &Path, args: &[&str]) { let output = Command::new("git") .args(args) @@ -2027,6 +2067,7 @@ fn git(repo: &Path, args: &[&str]) { String::from_utf8_lossy(&output.stderr) ); } + fn git_stdout(repo: &Path, args: &[&str]) -> String { let output = Command::new("git") .args(args) @@ -2040,6 +2081,7 @@ fn git_stdout(repo: &Path, args: &[&str]) -> String { ); String::from_utf8_lossy(&output.stdout).trim().to_string() } + fn text_message(role: &str, text: &str) -> Message { Message { role: Role::from(role), @@ -2049,15 +2091,18 @@ fn text_message(role: &str, text: &str) -> Message { }], } } + fn make_checkpoint(agent_id: &str, steps_taken: u32, messages: Vec) -> SubAgentCheckpoint { build_subagent_checkpoint(agent_id, "test_checkpoint", &messages, steps_taken, true) } + fn message_text(message: &Message) -> &str { match message.content.first() { Some(ContentBlock::Text { text, .. }) => text.as_str(), other => panic!("expected text content block, got {other:?}"), } } + async fn delayed_chat_client( first_delay: Duration, response_text: &str, @@ -2125,6 +2170,7 @@ async fn delayed_chat_client( let client = DeepSeekClient::new(&config).expect("fake chat client"); (client, calls, bodies) } + #[tokio::test] async fn detached_interactive_usage_after_mailbox_seal_reaches_session_accounting() { let _cost_scope = crate::cost_status::test_scope(); @@ -2276,6 +2322,7 @@ async fn detached_interactive_usage_after_mailbox_seal_reaches_session_accountin ); } } + /// Like [`delayed_chat_client`] but delays *every* attempt, so the per-step /// API timeout fires on the first call and on every retry — the shape needed /// to drive the timeout-retry budget to exhaustion. @@ -2333,6 +2380,7 @@ async fn always_delayed_chat_client( let client = DeepSeekClient::new(&config).expect("fake always-slow chat client"); (client, calls) } + #[tokio::test] async fn tool_free_subagent_omits_chat_tools_and_tool_choice() { let tmp = tempdir().expect("tempdir"); @@ -2374,6 +2422,7 @@ async fn tool_free_subagent_omits_chat_tools_and_tool_choice() { "tool_choice must be omitted: {body}" ); } + async fn transient_header_timeout_then_success_chat_client( response_text: &str, ) -> (DeepSeekClient, Arc) { @@ -2438,6 +2487,7 @@ async fn transient_header_timeout_then_success_chat_client( let client = DeepSeekClient::new(&config).expect("fake transient chat client"); (client, calls) } + async fn always_rate_limited_chat_client() -> (DeepSeekClient, Arc) { let calls = Arc::new(AtomicUsize::new(0)); let app = Router::new().route( @@ -2486,6 +2536,7 @@ async fn always_rate_limited_chat_client() -> (DeepSeekClient, Arc) let client = DeepSeekClient::new(&config).expect("fake rate-limited chat client"); (client, calls) } + async fn always_invalid_request_chat_client() -> (DeepSeekClient, Arc) { let calls = Arc::new(AtomicUsize::new(0)); let app = Router::new().route( @@ -2535,9 +2586,11 @@ async fn always_invalid_request_chat_client() -> (DeepSeekClient, Arc usize { text.chars().count().div_ceil(3) } + #[test] fn test_agent_type_from_str() { assert_eq!(FleetRole::from_str("general"), Some(FleetRole::Worker)); @@ -2553,6 +2606,7 @@ fn test_agent_type_from_str() { assert_eq!(FleetRole::from_str("awaiter"), Some(FleetRole::Planner)); assert_eq!(FleetRole::from_str("invalid"), None); } + #[test] fn test_agent_type_implementer_aliases() { // #404 — Builder accepts the obvious legacy aliases the model is @@ -2567,6 +2621,7 @@ fn test_agent_type_implementer_aliases() { // Case-insensitive. assert_eq!(FleetRole::from_str("IMPLEMENTER"), Some(FleetRole::Builder)); } + #[test] fn test_agent_type_verifier_aliases() { // #404 — Verifier accepts test/validate aliases distinct from @@ -2580,6 +2635,7 @@ fn test_agent_type_verifier_aliases() { } assert_eq!(FleetRole::from_str("VERIFY"), Some(FleetRole::Verifier)); } + #[test] fn test_agent_type_round_trips_via_as_str() { // Every type should serialize to a string that round-trips back @@ -2601,6 +2657,7 @@ fn test_agent_type_round_trips_via_as_str() { assert_eq!(back, t, "round-trip failed for {t:?} via {label:?}"); } } + #[test] fn fleet_role_labels_are_canonical_while_legacy_snapshot_wire_stays_readable() { assert_eq!(FleetRole::Scout.as_str(), "explore"); @@ -2631,6 +2688,7 @@ fn fleet_role_labels_are_canonical_while_legacy_snapshot_wire_stays_readable() { "\"explore\"" ); } + #[test] fn fleet_role_deserialize_rejects_unknown_values_with_canonical_hint() { // Unknown role tokens fail closed at the serde boundary, and the error @@ -2666,6 +2724,7 @@ fn fleet_role_deserialize_rejects_unknown_values_with_canonical_hint() { "error must not advertise legacy aliases: {message}" ); } + #[test] fn write_capable_children_get_verify_contract_in_system_prompt() { let implementer = build_subagent_system_prompt( @@ -2691,6 +2750,7 @@ fn write_capable_children_get_verify_contract_in_system_prompt() { "read-only scout must not get write verify contract" ); } + #[test] fn test_implementer_and_verifier_have_distinct_prompts() { // The whole point of adding the types is that they carry distinct @@ -2726,6 +2786,7 @@ fn test_implementer_and_verifier_have_distinct_prompts() { "Verifier prompt should reference its role: {verifier}" ); } + #[test] fn test_agent_type_prompts_include_shared_output_contract_once() { for (agent_type, marker) in [ @@ -2767,6 +2828,7 @@ fn test_agent_type_prompts_include_shared_output_contract_once() { } } } + #[test] fn explore_prompt_orients_before_searching() { let prompt = FleetRole::Scout.system_prompt(); @@ -2775,6 +2837,7 @@ fn explore_prompt_orients_before_searching() { assert!(prompt.contains("workspace/project root")); assert!(prompt.contains("compressed evidence")); } + #[test] fn explore_prompt_is_quick_bounded_and_read_only() { let prompt = FleetRole::Scout.system_prompt(); @@ -2794,6 +2857,7 @@ fn explore_prompt_is_quick_bounded_and_read_only() { assert!(reviewer.contains("allowed read-only navigation/rg")); assert!(reviewer.contains("shell control actions are unavailable")); } + #[test] fn implementer_prompt_is_not_forced_into_explorer_cap() { let prompt = FleetRole::Builder.system_prompt(); @@ -2801,6 +2865,7 @@ fn implementer_prompt_is_not_forced_into_explorer_cap() { assert!(prompt.contains("Checkpoint before expanding scope")); assert!(!prompt.contains("Default to `EFFORT: quick`")); } + #[test] fn role_prompts_use_lowercase_primitive_contract() { let explore = FleetRole::Scout.system_prompt(); @@ -2821,6 +2886,7 @@ fn role_prompts_use_lowercase_primitive_contract() { } } } + #[test] fn review_and_verifier_prompts_stop_after_decisive_evidence() { let review = FleetRole::Reviewer.system_prompt(); @@ -2829,6 +2895,7 @@ fn review_and_verifier_prompts_stop_after_decisive_evidence() { assert!(review.contains("private `todo_write` list as editable working notes")); assert!(verifier.contains("stop after decisive pass/fail evidence")); } + #[test] fn child_artifact_copy_surfaces_working_notes_in_the_complete_transcript() { let artifacts = default_subagent_artifacts("agent_scout_notes"); @@ -2840,6 +2907,7 @@ fn child_artifact_copy_surfaces_working_notes_in_the_complete_transcript() { assert!(transcript.description.contains("todo_write working notes")); assert!(transcript.description.contains("transcript_handle")); } + #[test] fn agent_description_explains_background_child_and_transcript_handle() { let tmp = tempdir().expect("tempdir"); @@ -2909,6 +2977,7 @@ fn agent_description_explains_background_child_and_transcript_handle() { description.chars().count() ); } + #[test] fn stringy_deliberate_is_refused_not_dropped() { // A stringy "true" used to coerce to the default `false`, which skipped @@ -2925,6 +2994,7 @@ fn stringy_deliberate_is_refused_not_dropped() { "error must name expected and received types: {err}" ); } + #[test] fn bare_string_disallowed_tools_is_refused_not_dropped() { // A restriction the harness silently drops is worse than one never @@ -2952,6 +3022,7 @@ fn bare_string_disallowed_tools_is_refused_not_dropped() { "trimming and de-duplication survive the strict parse" ); } + #[test] fn spawn_parameters_refuse_type_mismatches_by_name() { // A representative sample across the parameter kinds the spawn parser @@ -2998,6 +3069,7 @@ fn spawn_parameters_refuse_type_mismatches_by_name() { })) .expect("explicit nulls read as absent"); } + #[test] fn deliberate_spawn_requires_delegation_fields() { let missing = parse_spawn_request(&json!({ @@ -3029,6 +3101,7 @@ fn deliberate_spawn_requires_delegation_fields() { "workspace_policy shared must not materialize a worktree" ); } + #[test] fn declared_workspace_policy_worktree_materializes_a_worktree_request() { // TUI-DOG-017: a declared policy must be enforced, not decorative. The @@ -3053,6 +3126,7 @@ fn declared_workspace_policy_worktree_materializes_a_worktree_request() { "shared policy plus explicit worktree must fail closed" ); } + #[test] fn builder_plus_read_only_authority_fails_closed() { // #5123: never launch a labeled builder that will only get read-only inspection tools. @@ -3071,6 +3145,7 @@ fn builder_plus_read_only_authority_fails_closed() { ); } } + #[test] fn read_only_worker_is_an_ordinary_general_child() { // Worker is the unnamed default (it renders as "general"); its capability @@ -3086,6 +3161,7 @@ fn read_only_worker_is_an_ordinary_general_child() { assert_eq!(request.agent_type, FleetRole::Worker); assert_eq!(request.write_authority, Some(SpawnWriteAuthority::ReadOnly)); } + #[test] fn roster_role_plus_read_only_authority_still_spawns() { // #5123 fail-closed must not swallow the read-only roster task. `role` is @@ -3118,6 +3194,7 @@ fn roster_role_plus_read_only_authority_still_spawns() { "a role alias resolves the type without claiming write capability" ); } + #[test] fn declared_write_authority_parses_and_worktree_write_requires_isolation() { let read_only = parse_spawn_request(&json!({ @@ -3182,6 +3259,7 @@ fn declared_write_authority_parses_and_worktree_write_requires_isolation() { .expect("explicit bounded custom write parses"); assert!(spawn_request_is_write_capable(&custom_writer)); } + #[test] fn prompt_only_general_children_default_read_only_instead_of_claiming_the_repo() { let request = parse_spawn_request(&json!({ @@ -3224,6 +3302,7 @@ fn prompt_only_general_children_default_read_only_instead_of_claiming_the_repo() .expect("resolved write-capable fleet role defaults write scope to parent workspace"); assert_eq!(fleet_role.write_roots, vec![".".to_string()]); } + #[test] fn read_only_roles_reject_write_authority_but_implementers_can_be_narrowed() { let reviewer = parse_spawn_request(&json!({ @@ -3252,6 +3331,7 @@ fn read_only_roles_reject_write_authority_but_implementers_can_be_narrowed() { Some(SpawnWriteAuthority::ReadOnly) ); } + /// #4752: Consultant must be a role, not a special-cased code path — so it has to /// travel the same spawn/schema machinery as every other role, and be refused /// write authority by the same guard that refuses reviewer. @@ -3274,6 +3354,7 @@ fn consultant_spawns_as_a_first_class_read_only_role() { .to_string(); assert!(escalation.contains("read-only role"), "{escalation}"); } + #[test] fn direct_consultant_aliases_apply_role_reasoning_default_after_inheritance() { for parent_effort in [None, Some("low")] { @@ -3359,6 +3440,7 @@ fn direct_consultant_aliases_apply_role_reasoning_default_after_inheritance() { "first-party DeepSeek keeps an explicit low child effort" ); } + /// The role name has to survive the wire, or receipts and resumed sessions /// silently reclassify a consultant as the default worker. #[test] @@ -3391,6 +3473,7 @@ fn consultant_round_trips_canonically_and_accepts_compatibility_aliases() { assert!(!FLEET_ROLE_SCHEMA_VALUES.contains(&"oracle")); assert!(FLEET_ROLE_SCHEMA_VALUES.contains(&"advisor")); } + #[test] fn declared_write_scope_is_normalized_and_rejects_traversal() { let request = parse_spawn_request(&json!({ @@ -3420,6 +3503,7 @@ fn declared_write_scope_is_normalized_and_rejects_traversal() { "{err}" ); } + #[test] fn shared_child_cwd_claims_use_one_root_namespace_for_collisions_and_mutations() { let repo = tempdir().expect("repo"); @@ -3502,6 +3586,7 @@ fn shared_child_cwd_claims_use_one_root_namespace_for_collisions_and_mutations() "{outside}" ); } + #[test] fn resident_file_context_is_workspace_relative_bounded_and_exclusive() { let tmp = tempdir().expect("tempdir"); @@ -3570,6 +3655,7 @@ fn resident_file_context_is_workspace_relative_bounded_and_exclusive() { rollback_pending_resident_lease(&resident.lease_key); rollback_pending_resident_lease(&other.lease_key); } + #[cfg(unix)] #[test] fn resident_file_context_rejects_symlink_aliases() { @@ -3583,11 +3669,13 @@ fn resident_file_context_rejects_symlink_aliases() { .to_string(); assert!(error.contains("must not traverse symlinks"), "{error}"); } + #[test] fn new_session_tools_use_single_agent_name() { let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 1))); assert_eq!(AgentTool::new(manager, stub_runtime()).name(), "agent"); } + #[test] fn test_parse_spawn_request_accepts_message_and_agent_type_aliases() { let input = json!({ @@ -3599,6 +3687,7 @@ fn test_parse_spawn_request_accepts_message_and_agent_type_aliases() { assert_eq!(parsed.agent_type, FleetRole::Scout); assert_eq!(parsed.assignment.role.as_deref(), Some("explore")); } + #[test] fn test_parse_spawn_request_accepts_objective_and_role_alias() { let input = json!({ @@ -3610,6 +3699,7 @@ fn test_parse_spawn_request_accepts_objective_and_role_alias() { assert_eq!(parsed.agent_type, FleetRole::Planner); assert_eq!(parsed.assignment.role.as_deref(), Some("planner")); } + #[test] fn test_parse_spawn_request_accepts_items_payload() { let input = json!({ @@ -3624,6 +3714,7 @@ fn test_parse_spawn_request_accepts_items_payload() { assert!(parsed.prompt.contains("[mention:$drive](app://drive)")); assert_eq!(parsed.agent_type, FleetRole::Scout); } + #[test] fn test_parse_spawn_request_accepts_fork_context() { let input = json!({ @@ -3645,6 +3736,7 @@ fn test_parse_spawn_request_accepts_fork_context() { let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert_eq!(parsed.fork_context, None); } + #[test] fn test_parse_spawn_request_accepts_model_strength() { let input = json!({ @@ -3663,6 +3755,7 @@ fn test_parse_spawn_request_accepts_model_strength() { let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert_eq!(parsed.model_strength, SubAgentModelStrength::Same); } + #[test] fn explore_subagent_inherits_active_model_by_default() { // Role names never silently change the model. A Fleet without custom @@ -3696,6 +3789,7 @@ fn explore_subagent_inherits_active_model_by_default() { assert_eq!(parsed.agent_type, FleetRole::Scout); assert_eq!(parsed.model_strength, SubAgentModelStrength::Same); } + #[test] fn non_explore_subagents_keep_default_same_model_strength() { // Non-explore roles keep the conservative Same default even with no model. @@ -3715,6 +3809,7 @@ fn non_explore_subagents_keep_default_same_model_strength() { ); } } + #[test] fn test_parse_spawn_request_accepts_child_thinking() { let input = json!({ @@ -3744,6 +3839,7 @@ fn test_parse_spawn_request_accepts_child_thinking() { let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert_eq!(parsed.thinking, SubAgentThinking::Auto); } + #[test] fn test_parse_spawn_request_rejects_invalid_model_strength() { let input = json!({ @@ -3756,6 +3852,7 @@ fn test_parse_spawn_request_rejects_invalid_model_strength() { .contains("model_strength must be one of: same, faster") ); } + #[test] fn test_parse_spawn_request_rejects_invalid_child_thinking() { let input = json!({ @@ -3768,6 +3865,7 @@ fn test_parse_spawn_request_rejects_invalid_child_thinking() { .contains("thinking must be one of: inherit, auto, off, low, medium, high, max") ); } + #[test] fn test_parse_spawn_request_accepts_session_name_for_agent() { let input = json!({ @@ -3781,6 +3879,7 @@ fn test_parse_spawn_request_accepts_session_name_for_agent() { assert_eq!(parsed.fork_context, Some(true)); assert_eq!(parsed.max_depth, Some(0)); } + #[test] fn test_parse_spawn_request_rejects_invalid_session_name() { let input = json!({ @@ -3790,6 +3889,7 @@ fn test_parse_spawn_request_rejects_invalid_session_name() { let err = parse_spawn_request(&input).expect_err("space in name should fail"); assert!(err.to_string().contains("name must not contain whitespace")); } + #[test] fn test_parse_spawn_request_rejects_out_of_range_max_depth() { let ceiling = codewhale_config::MAX_SPAWN_DEPTH_CEILING; @@ -3804,6 +3904,7 @@ fn test_parse_spawn_request_rejects_out_of_range_max_depth() { .contains(&format!("max_depth must be between 0 and {ceiling}")) ); } + #[test] fn test_parse_spawn_request_accepts_profile_and_preserves_safe_selector() { let input = json!({ @@ -3830,6 +3931,7 @@ fn test_parse_spawn_request_accepts_profile_and_preserves_safe_selector() { .expect("human model label should parse"); assert_eq!(parsed.profile.as_deref(), Some("DeepSeek V4 Flash")); } + #[test] fn test_parse_spawn_request_rejects_invalid_profile_token() { for bad in ["reviewer\nscout", "reviewer\tscout"] { @@ -3846,6 +3948,7 @@ fn test_parse_spawn_request_rejects_invalid_profile_token() { .expect_err("oversized selector should fail"); assert!(err.to_string().contains("at most 128"), "{err}"); } + #[tokio::test] async fn agent_roster_action_and_spawn_resolve_the_same_roles() { let tmp = tempdir().expect("tempdir"); @@ -3894,6 +3997,7 @@ async fn agent_roster_action_and_spawn_resolve_the_same_roles() { assert_eq!(request.agent_type, FleetRole::Scout); assert_eq!(request.profile.as_deref(), Some("explore")); } + #[tokio::test] async fn agent_roster_lists_host_presented_profiles_spawn_resolves() { let tmp = tempdir().expect("tempdir"); @@ -3965,6 +4069,7 @@ async fn agent_roster_lists_host_presented_profiles_spawn_resolves() { assert_eq!(request.profile.as_deref(), Some(profile_id)); } } + #[tokio::test] async fn agent_roster_truncates_host_profiles_at_the_listing_cap() { let tmp = tempdir().expect("tempdir"); @@ -4009,6 +4114,7 @@ async fn agent_roster_truncates_host_profiles_at_the_listing_cap() { // Descriptions fall back to the role name when the profile carries none. assert_eq!(host_profiles[0]["description"], json!("exp-host-000")); } + #[tokio::test] async fn agent_roster_profile_query_discovers_members_beyond_the_listing_cap() { // The T7 discovery-contract regression: a spawnable profile past @@ -4117,6 +4223,7 @@ async fn agent_roster_profile_query_discovers_members_beyond_the_listing_cap() { assert_eq!(resolved.id, "exp-host-049"); assert_eq!(request.profile.as_deref(), Some("exp-host-049")); } + #[tokio::test] async fn agent_roster_never_lists_ids_that_spawn_a_different_profile() { // `[fleet.profiles]` is a byte-order BTreeMap, so `Expert`, `expert`, and @@ -4197,6 +4304,7 @@ async fn agent_roster_never_lists_ids_that_spawn_a_different_profile() { .expect("host member is returned"); assert_eq!(resolved.id, "Expert"); } + #[tokio::test] async fn agent_roster_listing_and_spawn_agree_on_conflatable_config_ids() { // The wiring-level half of the collapse invariant. The `action=roster` @@ -4320,6 +4428,7 @@ async fn agent_roster_listing_and_spawn_agree_on_conflatable_config_ids() { ); } } + #[test] fn roster_host_profile_listing_cap_is_pinned_at_48() { // Copy-truth pin: the app-side roster contract teaches a 48-row @@ -4328,6 +4437,7 @@ fn roster_host_profile_listing_cap_is_pinned_at_48() { // this test, in the same style as `subagent_runtime_default_max_depth_is_three`. assert_eq!(ROSTER_HOST_PROFILE_LIMIT, 48); } + #[tokio::test] async fn forkguard_agent_roster_lists_only_spawnable_host_profiles() { let tmp = tempdir().expect("tempdir"); @@ -4443,6 +4553,7 @@ async fn forkguard_agent_roster_lists_only_spawnable_host_profiles() { "{error}" ); } + #[test] fn test_resolve_spawn_role_unknown_lists_roles() { let mut request = @@ -4466,6 +4577,7 @@ fn test_resolve_spawn_role_unknown_lists_roles() { assert!(message.contains(role), "missing {role}: {message}"); } } + #[test] fn forkguard_host_profile_overlay_is_config_only_and_prompt_only() { let profile_id = "exp-host-reviewer"; @@ -4531,6 +4643,7 @@ fn forkguard_host_profile_overlay_is_config_only_and_prompt_only() { .to_string(); assert!(error.contains("not prompt-only"), "{error}"); } + #[test] fn test_resolve_spawn_role_rejects_conflicting_explicit_type() { let mut request = parse_spawn_request(&json!({ @@ -4550,6 +4663,7 @@ fn test_resolve_spawn_role_rejects_conflicting_explicit_type() { "{message}" ); } + #[test] fn test_resolve_spawn_role_accepts_agreeing_explicit_type() { let mut request = parse_spawn_request(&json!({ @@ -4563,6 +4677,7 @@ fn test_resolve_spawn_role_accepts_agreeing_explicit_type() { assert_eq!(request.profile.as_deref(), Some("reviewer")); assert_eq!(request.assignment.role.as_deref(), Some("reviewer")); } + #[test] fn test_resolve_spawn_role_scout_yields_explore_type_and_inherits_route() { let mut request = parse_spawn_request(&json!({"prompt": "map the parser", "profile": "scout"})) @@ -4578,6 +4693,7 @@ fn test_resolve_spawn_role_scout_yields_explore_type_and_inherits_route() { ); assert_eq!(selected.source, SpawnRouteSource::RunModel); } + #[test] fn test_resolve_spawn_role_accepts_legacy_alias() { let mut request = parse_spawn_request(&json!({"prompt": "merge findings", "profile": "plan"})) @@ -4586,6 +4702,7 @@ fn test_resolve_spawn_role_accepts_legacy_alias() { assert_eq!(request.agent_type, FleetRole::Planner); assert_eq!(request.profile.as_deref(), Some("planner")); } + #[test] fn spawn_model_selection_has_stable_three_tier_precedence_and_source() { let mut runtime = stub_runtime(); @@ -4637,6 +4754,7 @@ fn spawn_model_selection_has_stable_three_tier_precedence_and_source() { assert_eq!(selected.model_route, ModelRoute::Inherit); assert_eq!(selected.source, SpawnRouteSource::RunModel); } + #[test] fn providerless_spawn_model_gate_rejects_known_foreign_route_before_spawn() { let runtime = stub_runtime_for_provider("moonshot"); @@ -4688,6 +4806,7 @@ fn providerless_spawn_model_gate_rejects_known_foreign_route_before_spawn() { "the child and receipt must use the provider's exact wire id" ); } + #[test] fn providerless_foreign_spawn_default_inherits_session_route() { // #5099 / checklist §2.2: a moonshot parent spawning a default child whose @@ -4740,6 +4859,7 @@ fn providerless_foreign_spawn_default_inherits_session_route() { owned.model_route ); } + #[test] fn spawn_route_sources_refresh_overlays_live_config_on_launch_time_defaults() { // #5099: role-model defaults are a launch-time snapshot, so a mid-session @@ -4782,6 +4902,7 @@ fn spawn_route_sources_refresh_overlays_live_config_on_launch_time_defaults() { "worker override covers the general alias too" ); } + #[test] fn test_child_max_spawn_depth_request_only_narrows() { // No request: inherit unchanged. @@ -4795,6 +4916,7 @@ fn test_child_max_spawn_depth_request_only_narrows() { // A request below the inherited budget is still honored (clamp, don't force). assert_eq!(child_max_spawn_depth_for_spawn(5, 0, Some(3)), 3); } + /// A descendant subagent must not widen the absolute recursion budget its root /// session selected by supplying an explicit `max_depth` on a nested spawn /// (#5253). The inherited budget is a hard cap even when the request (clamped @@ -4813,6 +4935,7 @@ fn test_child_max_spawn_depth_request_cannot_widen_inherited_budget() { // A request below the inherited budget is still honored (clamp, don't force). assert_eq!(child_max_spawn_depth_for_spawn(5, 0, Some(3)), 3); } + /// An explicit spawn-time thinking tier reaches the request unchanged: roles /// carry no reasoning tier of their own, so there is nothing to outrank or /// inherit — the caller's tier is the tier. @@ -4840,6 +4963,7 @@ fn test_explicit_spawn_thinking_reaches_the_request() { ); assert_eq!(route.reasoning_effort.as_deref(), Some("off")); } + #[test] fn test_unset_spawn_thinking_inherits_the_session_tier() { let mut request = parse_spawn_request(&json!({"prompt": "look around", "profile": "explore"})) @@ -4847,6 +4971,7 @@ fn test_unset_spawn_thinking_inherits_the_session_tier() { resolve_spawn_role(&mut request).expect("resolve"); assert_eq!(request.thinking, SubAgentThinking::Inherit); } + /// Roles bind no route (#5046 inverted): the dispatching model may always set /// `model` or `model_strength` — every role accepts model routing options. #[test] @@ -4897,6 +5022,7 @@ fn role_dispatch_allows_model_and_model_strength_options() { assert!(request.model_strength_explicit); assert_eq!(request.model_strength, SubAgentModelStrength::Faster); } + /// A custom (non-role) profile name fails closed: only the closed role set /// resolves — there are no saved members to promote. #[test] @@ -4915,6 +5041,7 @@ fn custom_profile_name_fails_closed() { "error must name the custom profile: {message}" ); } + /// A type alias dispatches as its role and keeps an explicit `model`: there /// is no saved member to promote to and no pinned model to collide with. #[test] @@ -4934,6 +5061,7 @@ fn type_alias_dispatch_keeps_explicit_model() { "explicit model is kept: roles pin no model" ); } + /// A Fleet worker subprocess launches as `--model --reasoning-effort /// auto`. That is a FIXED model with Auto reasoning: the raw `"auto"` sentinel /// must resolve, not travel to a provider that has no such tier. @@ -4964,6 +5092,7 @@ fn fixed_model_runtime_with_a_raw_auto_tier_resolves_instead_of_staying_raw() { assert_eq!(route.reasoning_effort.as_deref(), Some("max")); assert_eq!(route.tuning.reasoning_effort, Some(ReasoningEffort::Max)); } + #[test] fn a_concrete_runtime_tier_is_not_mistaken_for_auto() { let runtime = stub_runtime().with_reasoning_effort(Some("off".to_string()), false); @@ -4978,6 +5107,7 @@ fn a_concrete_runtime_tier_is_not_mistaken_for_auto() { assert_eq!(route.reasoning_effort.as_deref(), Some("off")); } + #[tokio::test] async fn session_projection_exposes_forked_prefix_cache_contract() { let mut snapshot = make_snapshot(SubAgentStatus::Running); @@ -5005,6 +5135,7 @@ async fn session_projection_exposes_forked_prefix_cache_contract() { assert_eq!(projection.transcript_handle.kind, "var_handle"); assert_eq!(projection.transcript_handle.name, "transcript"); } + #[tokio::test] async fn terminal_session_projection_prefers_full_transcript_handle() { let mut snapshot = make_snapshot(SubAgentStatus::Completed); @@ -5036,6 +5167,7 @@ async fn terminal_session_projection_prefers_full_transcript_handle() { assert_eq!(projection.transcript_handle, full_handle); assert_eq!(projection.transcript_handle.name, "full_transcript"); } + #[tokio::test] async fn interrupted_projection_exposes_checkpoint_metadata_and_messages() { let mut snapshot = make_snapshot(SubAgentStatus::Interrupted( @@ -5088,6 +5220,7 @@ async fn interrupted_projection_exposes_checkpoint_metadata_and_messages() { assert!(timed_out_projection.timed_out); assert!(timed_out_projection.timed_out_with_checkpoint); } + #[test] fn test_delegate_defaults_to_fork_context() { let input = with_default_fork_context(json!({ "prompt": "review current work" }), true); @@ -5101,6 +5234,7 @@ fn test_delegate_defaults_to_fork_context() { let parsed = parse_spawn_request(&input).expect("delegate override should parse"); assert_eq!(parsed.fork_context, Some(false)); } + #[test] fn spawn_request_parses_token_budget_override() { let parsed = parse_spawn_request(&json!({ @@ -5127,6 +5261,7 @@ fn spawn_request_parses_token_budget_override() { "clear token budget error: {err}" ); } + #[test] fn forked_subagent_messages_preserve_parent_prefix_then_append_task() { let parent_message = Message { @@ -5163,6 +5298,7 @@ fn forked_subagent_messages_preserve_parent_prefix_then_append_task() { assert_eq!(messages[3].role, "user"); assert!(message_text(&messages[3]).contains("inspect parser")); } + #[test] fn fresh_subagent_messages_keep_existing_single_turn_shape() { let assignment = SubAgentAssignment::new("list files".to_string(), None); @@ -5173,6 +5309,7 @@ fn fresh_subagent_messages_keep_existing_single_turn_shape() { assert_eq!(messages[0].role, "user"); assert!(message_text(&messages[0]).contains("list files")); } + #[test] fn test_parse_spawn_request_rejects_text_and_items_together() { let input = json!({ @@ -5182,6 +5319,7 @@ fn test_parse_spawn_request_rejects_text_and_items_together() { let err = parse_spawn_request(&input).expect_err("text+items should fail"); assert!(err.to_string().contains("either prompt text or items")); } + #[test] fn test_parse_spawn_request_rejects_human_model_name_as_role() { // A model name is not a role: without a roster of saved members there is @@ -5201,6 +5339,7 @@ fn test_parse_spawn_request_rejects_human_model_name_as_role() { "{err}" ); } + #[test] fn test_parse_spawn_request_accepts_fleet_role_token_for_runtime_resolution() { let input = json!({ @@ -5227,6 +5366,7 @@ fn test_parse_spawn_request_accepts_fleet_role_token_for_runtime_resolution() { resolve_spawn_role(&mut scout).expect("scout should resolve"); assert_eq!(scout.agent_type, FleetRole::Scout); } + #[test] fn test_parse_spawn_request_accepts_full_role_vocabulary() { // Regression for #2649: roles that `FleetRole::from_str` accepts must @@ -5298,6 +5438,7 @@ fn test_parse_spawn_request_accepts_full_role_vocabulary() { .unwrap_or_else(|e| panic!("role {role:?} should resolve without a profile: {e}")); } } + #[test] fn test_invalid_role_error_lists_real_aliases() { // Well-formed fleet role tokens parse and then fail clearly at role @@ -5325,6 +5466,7 @@ fn test_invalid_role_error_lists_real_aliases() { "hint should explain compatibility aliases: {err}" ); } + #[test] fn plugin_agent_profile_loads_but_is_not_a_spawn_role() { let _lock = crate::test_support::lock_test_env(); @@ -5367,17 +5509,20 @@ fn plugin_agent_profile_loads_but_is_not_a_spawn_role() { "reload removes the disabled plugin Agent" ); } + fn schema_property_description<'a>(schema: &'a Value, property: &str) -> &'a str { schema["properties"][property]["description"] .as_str() .unwrap_or_else(|| panic!("missing description for schema property {property:?}")) } + fn draft_2020_validator(schema: &Value) -> jsonschema::Validator { jsonschema::options() .with_draft(jsonschema::Draft::Draft202012) .build(schema) .expect("valid Draft 2020-12 tool schema") } + #[test] fn subagent_tool_schemas_advertise_real_type_and_role_vocabulary() { let tmp = tempdir().expect("tempdir"); @@ -5476,6 +5621,7 @@ fn subagent_tool_schemas_advertise_real_type_and_role_vocabulary() { "worktree description should teach isolated parallel edits: {worktree}" ); } + // The wait surfaces block the turn, so the model-facing text must disclose // the bounded block (default 30s, max 120s) and the timed_out receipt shape; // an undisclosed finite block reads as a hang when nothing settles. @@ -5537,6 +5683,7 @@ fn wait_schema_text_discloses_timeout_bound_and_timed_out_receipt() { the timed_out receipt:\n{wait_description}" ); } + // The two wait faces advertise from two independent constant sets, so each // face's own pin can stay green while the surfaces drift numerically apart; // this cross-assertion closes that gap. @@ -5561,6 +5708,7 @@ fn wait_bound_constants_agree_across_both_wait_faces() { same minimum timeout" ); } + #[test] fn wait_timeout_alias_stays_parse_accepted() { // docs/SUBAGENTS.md pins the `timeout` alias (same meaning as @@ -5574,6 +5722,7 @@ fn wait_timeout_alias_stays_parse_accepted() { assert_eq!(read(json!({"timeout": 7})), Some(7)); assert_eq!(read(json!({})), None); } + #[test] fn agent_tool_unadvertised_fields_remain_parse_accepted() { // #5324 compat: the fields removed from the advertised schema must stay @@ -5666,6 +5815,7 @@ fn agent_tool_unadvertised_fields_remain_parse_accepted() { AgentToolAction::Interrupt ); } + #[test] fn agent_tool_role_schema_is_a_closed_canonical_enum() { let tmp = tempdir().expect("tempdir"); @@ -5713,6 +5863,7 @@ fn agent_tool_role_schema_is_a_closed_canonical_enum() { ); } } + #[test] fn provider_schema_sanitizers_preserve_the_closed_fleet_role_enum() { use crate::tools::schema_sanitize; @@ -5789,6 +5940,7 @@ fn provider_schema_sanitizers_preserve_the_closed_fleet_role_enum() { "Kimi MFJS must receive a schema without dependentSchemas" ); } + #[test] fn agent_tool_prompt_schema_keeps_ordinary_starts_message_first() { let tmp = tempdir().expect("tempdir"); @@ -5815,6 +5967,7 @@ fn agent_tool_prompt_schema_keeps_ordinary_starts_message_first() { ); } } + #[test] fn agent_tool_schema_advertises_lifecycle_and_coordination_actions() { let tmp = tempdir().expect("tempdir"); @@ -5837,6 +5990,7 @@ fn agent_tool_schema_advertises_lifecycle_and_coordination_actions() { // absence; `timeout_secs` returned to the advertised surface in #119, // and the compat test below still covers both plus the `timeout` alias. } + #[test] fn agent_tool_schema_bounds_fields_by_explicit_action() { let tmp = tempdir().expect("tempdir"); @@ -5885,6 +6039,7 @@ fn agent_tool_schema_bounds_fields_by_explicit_action() { assert!(branch(action).get("anyOf").is_none()); } } + #[test] fn agent_tool_schema_rejects_empty_input_across_provider_forms() { use crate::tools::schema_sanitize; @@ -5953,6 +6108,7 @@ fn agent_tool_schema_rejects_empty_input_across_provider_forms() { "agent schema must reject empty input because runtime defaults it to start and then rejects the missing prompt; permissive forms: {permissive:?}" ); } + #[tokio::test] async fn agent_message_queues_and_followup_delivers_as_user_provenance() { let tmp = tempdir().expect("tempdir"); @@ -6052,6 +6208,7 @@ async fn agent_message_queues_and_followup_delivers_as_user_provenance() { .to_string(); assert!(absent.contains("not found"), "{absent}"); } + #[tokio::test] async fn agent_tool_status_returns_running_child_projection() { let tmp = tempdir().expect("tempdir"); @@ -6100,6 +6257,7 @@ async fn agent_tool_status_returns_running_child_projection() { assert!(result.content.contains("running")); assert!(result.content.contains("transcript_handle")); } + #[tokio::test] async fn agent_tool_status_reconciles_stale_single_agent_projection() { let tmp = tempdir().expect("tempdir"); @@ -6150,6 +6308,7 @@ async fn agent_tool_status_reconciles_stale_single_agent_projection() { assert!(result.content.contains("Auto-cancelled")); assert_eq!(manager.read().await.running_count(), 0); } + #[tokio::test] async fn agent_tool_cancel_stops_running_child() { let tmp = tempdir().expect("tempdir"); @@ -6218,6 +6377,7 @@ async fn agent_tool_cancel_stops_running_child() { "repeated stop must not append a second terminal outcome" ); } + #[tokio::test] async fn model_wait_cancel_fans_in_once_and_preserves_checkpoint() { use tokio_util::sync::CancellationToken; @@ -6316,6 +6476,7 @@ async fn model_wait_cancel_fans_in_once_and_preserves_checkpoint() { 1 ); } + #[tokio::test] async fn coordination_interrupt_fans_in_once_and_preserves_checkpoint() { use tokio_util::sync::CancellationToken; @@ -6436,6 +6597,7 @@ async fn coordination_interrupt_fans_in_once_and_preserves_checkpoint() { "repeated interrupt must not append a second terminal or parked outcome" ); } + #[tokio::test] async fn late_completion_does_not_overwrite_cancelled_outcome() { let tmp = tempdir().expect("tempdir"); @@ -6487,6 +6649,7 @@ async fn late_completion_does_not_overwrite_cancelled_outcome() { assert_eq!(terminal.len(), 1); assert_eq!(terminal[0].status, AgentWorkerStatus::Cancelled); } + #[tokio::test] async fn completion_claim_preserves_running_gate_and_excludes_late_cancel() { let tmp = tempdir().expect("tempdir"); @@ -6562,6 +6725,7 @@ async fn completion_claim_preserves_running_gate_and_excludes_late_cancel() { .count(); assert_eq!(terminal, 1, "exactly one terminal outcome is recorded"); } + #[test] fn test_parse_spawn_request_rejects_conflicting_type_and_role() { let input = json!({ @@ -6575,6 +6739,7 @@ fn test_parse_spawn_request_rejects_conflicting_type_and_role() { .contains("Fleet role conflicts with the explicit legacy agent type") ); } + #[test] fn test_build_allowed_tools_independent_of_allow_shell() { // v0.6.6: allow_shell no longer filters at the build_allowed_tools @@ -6586,6 +6751,7 @@ fn test_build_allowed_tools_independent_of_allow_shell() { assert!(with_shell.is_none()); assert!(without_shell.is_none()); } + #[test] fn test_allowed_tools_are_deduplicated() { let tools = build_allowed_tools( @@ -6604,11 +6770,13 @@ fn test_allowed_tools_are_deduplicated() { Some(vec!["read_file".to_string(), "grep_files".to_string()]) ); } + #[test] fn test_custom_agent_requires_allowed_tools() { let err = build_allowed_tools(&FleetRole::Custom, None, true).unwrap_err(); assert!(err.to_string().contains("requires")); } + #[test] fn role_posture_blocks_writes_and_shell_for_read_only_roles() { // #3217: read-only roles may never run write/edit/patch tools, regardless @@ -6670,6 +6838,7 @@ fn role_posture_blocks_writes_and_shell_for_read_only_roles() { ApprovalRequirement::Required )); } + #[test] fn test_build_assignment_prompt_includes_metadata() { let assignment = SubAgentAssignment::new( @@ -6681,6 +6850,7 @@ fn test_build_assignment_prompt_includes_metadata() { assert!(prompt.contains("resolved_type: explore")); assert!(prompt.contains("role: explore")); } + #[test] fn subagent_model_strength_defaults_to_parent_even_when_parent_auto_model() { let mut runtime = stub_runtime().with_auto_model(true); @@ -6698,6 +6868,7 @@ fn subagent_model_strength_defaults_to_parent_even_when_parent_auto_model() { assert_eq!(route.model, "deepseek-v4-pro", "prompt {prompt:?}"); } } + #[test] fn subagent_model_strength_faster_uses_known_family_sibling() { let mut runtime = stub_runtime().with_auto_model(true); @@ -6714,6 +6885,7 @@ fn subagent_model_strength_faster_uses_known_family_sibling() { assert_eq!(route.model, "deepseek-v4-flash"); assert_eq!(route.reasoning_effort.as_deref(), Some("off")); } + #[test] fn subagent_model_strength_explicit_model_wins_over_faster() { let runtime = stub_runtime().with_auto_model(true); @@ -6731,6 +6903,7 @@ fn subagent_model_strength_explicit_model_wins_over_faster() { ); assert_eq!(route.model, "deepseek-v4-pro"); } + #[test] fn explicit_child_thinking_overrides_faster_default_off() { let mut runtime = stub_runtime().with_reasoning_effort(Some("max".to_string()), false); @@ -6747,6 +6920,7 @@ fn explicit_child_thinking_overrides_faster_default_off() { assert_eq!(route.reasoning_effort.as_deref(), Some("high")); assert_eq!(route.tuning.reasoning_effort, Some(ReasoningEffort::High)); } + #[test] fn explicit_child_auto_thinking_resolves_from_child_prompt() { let runtime = stub_runtime().with_reasoning_effort(Some("off".to_string()), false); @@ -6760,6 +6934,7 @@ fn explicit_child_auto_thinking_resolves_from_child_prompt() { ); assert_eq!(route.reasoning_effort.as_deref(), Some("max")); } + #[tokio::test] async fn route_resolution_matrix_uses_explicit_model_strength_routes() { let mut runtime = stub_runtime() @@ -6855,6 +7030,7 @@ async fn route_resolution_matrix_uses_explicit_model_strength_routes() { ); } } + #[test] fn subagent_auto_reasoning_resolves_to_distinct_v4_tiers() { let runtime = stub_runtime().with_reasoning_effort(Some("high".to_string()), true); @@ -6882,6 +7058,7 @@ fn subagent_auto_reasoning_resolves_to_distinct_v4_tiers() { Some("max".to_string()) ); } + #[test] fn test_subagent_tool_registry_reports_unavailable_tools() { let tmp = tempdir().expect("tempdir"); @@ -6905,6 +7082,7 @@ fn test_subagent_tool_registry_reports_unavailable_tools() { vec!["update_goal".to_string(), "missing_tool".to_string()] ); } + #[test] fn test_subagent_tools_respect_nested_agent_depth_budget() { let tmp = tempdir().expect("tempdir"); @@ -6943,9 +7121,11 @@ fn test_subagent_tools_respect_nested_agent_depth_budget() { ); assert!(!capped.is_tool_allowed("agent")); } + fn tool_names(tools: Vec) -> HashSet { tools.into_iter().map(|tool| tool.name).collect() } + #[test] fn every_named_role_has_one_complete_capability_based_surface() { const INSPECTION: &[&str] = &[ @@ -7011,6 +7191,7 @@ fn every_named_role_has_one_complete_capability_based_surface() { "Web", "agent", "automation", + "bash", "diagnostics", "file_search", "finance", @@ -7157,6 +7338,7 @@ fn every_named_role_has_one_complete_capability_based_surface() { assert_eq!(names, expected, "{role:?} visible surface drifted"); } } + /// An explicit parent tool subset is a restriction, not a note. The registry /// consults `allowed_tools`, so a `ToolScope::Explicit` that never reaches it /// leaves the child holding the parent's whole surface while the profile claims @@ -7196,6 +7378,7 @@ fn an_explicit_parent_tool_scope_is_enforced_by_the_child_registry() { assert!(!names.contains("Bash"), "{names:?}"); assert!(!names.contains("Git"), "{names:?}"); } + /// A child may narrow inside its parent's explicit subset; it may not step /// outside it, however it asks. #[test] @@ -7238,6 +7421,7 @@ fn a_child_allowlist_cannot_widen_an_explicit_parent_tool_scope() { None ); } + fn enabled_agent_surface_options() -> AgentToolSurfaceOptions { let mut options = AgentToolSurfaceOptions::new(ShellPolicy::Full); options.apply_patch_enabled = true; @@ -7246,6 +7430,7 @@ fn enabled_agent_surface_options() -> AgentToolSurfaceOptions { options.goal_state = Some(crate::tools::goal::new_shared_goal_state()); options } + /// Return the exact model-visible catalog built for a default General child. /// /// Request-boundary tests use this fixture so Moonshot compatibility coverage @@ -7274,6 +7459,7 @@ pub(crate) fn kimi_general_child_request_tools_fixture() -> Vec { let mut surface = SubAgentToolSurface::new(catalog, &[]); model_request_tools(&mut surface) } + fn small_surface_registry(role: FleetRole) -> SubAgentToolRegistry { let mut runtime = stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options()); @@ -7286,9 +7472,11 @@ fn small_surface_registry(role: FleetRole) -> SubAgentToolRegistry { crate::tools::plan::new_shared_plan_state(), ) } + fn model_tool_names(tools: Vec) -> BTreeSet { tools.into_iter().map(|tool| tool.name).collect() } + #[test] fn small_surface_legacy_rules_cover_lowercase_primitives_with_deny_wins() { let file = vec!["File".to_string()]; @@ -7335,9 +7523,11 @@ fn small_surface_legacy_rules_cover_lowercase_primitives_with_deny_wins() { ); } } + fn model_request_tools(surface: &mut SubAgentToolSurface) -> Vec { surface.request_tools(surface.catalog.clone(), false) } + fn forked_child_request_fixture( registry: &SubAgentToolRegistry, role: &FleetRole, @@ -7353,6 +7543,7 @@ fn forked_child_request_fixture( let catalog = registry.deferred_catalog_for_model(role); (messages, SubAgentToolSurface::new(catalog, &[])) } + async fn execute_surface_tool( registry: &SubAgentToolRegistry, surface: &mut SubAgentToolSurface, @@ -7365,6 +7556,7 @@ async fn execute_surface_tool( .await .map(|result| result.result.content) } + #[test] fn small_surface_starts_with_only_pi_head_and_search() { let registry = small_surface_registry(FleetRole::Builder); @@ -7395,6 +7587,7 @@ fn small_surface_starts_with_only_pi_head_and_search() { Some(true) ); } + #[tokio::test] async fn small_surface_read_only_child_discovers_web_deferred() { let registry = small_surface_registry(FleetRole::Scout); @@ -7438,6 +7631,7 @@ async fn small_surface_read_only_child_discovers_web_deferred() { assert!(same_batch.result.content.contains("deferred")); assert!(model_tool_names(model_request_tools(&mut surface)).contains("Web")); } + #[tokio::test] async fn small_surface_fork_context_survives_fresh_child_discovery() { let registry = small_surface_registry(FleetRole::Builder); @@ -7511,6 +7705,7 @@ async fn small_surface_fork_context_survives_fresh_child_discovery() { assert!(names.contains("Web")); assert!(names.contains("apply_patch")); } + #[tokio::test] async fn small_surface_denied_warm_tool_is_not_resurrected() { let mut runtime = @@ -7538,6 +7733,7 @@ async fn small_surface_denied_warm_tool_is_not_resurrected() { assert!(!searched.contains("\"tool_name\":\"Web\"")); assert!(surface.hydrate("Web").is_err()); } + fn synthetic_deferred_tool(name: &str, description_bytes: usize) -> Tool { Tool { tool_type: Some("function".to_string()), @@ -7551,6 +7747,7 @@ fn synthetic_deferred_tool(name: &str, description_bytes: usize) -> Tool { cache_control: None, } } + // Pinvou fork: the toolbox size bounds are disabled (see the constant docs in // core/session.rs). Upstream tests that construct near-budget activations to // fire bounds eviction or oversize rejection are not carried here — those @@ -7603,6 +7800,7 @@ fn forkguard_small_surface_caches_are_independent_unbounded_and_revalidated() { assert!(byte_names.contains("bytes_0")); assert!(byte_names.contains("bytes_1") && byte_names.contains("bytes_2")); } + #[tokio::test] async fn small_surface_successful_cached_use_touches_lru() { let registry = small_surface_registry(FleetRole::Builder); @@ -7622,6 +7820,7 @@ async fn small_surface_successful_cached_use_touches_lru() { surface.hydrate(&ninth).expect("ninth activation"); assert!(model_tool_names(model_request_tools(&mut surface)).contains("get_goal")); } + /// The child's ride channel mirrors the parent's: parked notes ride success /// and error results alike (a note held back on `Err` would strand until a /// later `Ok` — or forever if the child ends first), and names the ride @@ -7690,6 +7889,7 @@ fn child_parked_note_rides_success_and_error_results_and_nets_re_admissions() { netted.result.content ); } + /// The park side is covered above; this pin is the consumption side — /// `execute_from_surface` must hand parked notes to the child's recorded /// result, the production edge that once discarded every report. Driven @@ -7722,6 +7922,7 @@ async fn parked_note_rides_through_the_execute_from_surface_glue() { surface.pending_eviction_notes ); } + #[test] fn small_surface_depth_cap_removes_only_agent() { let mut runtime = @@ -7747,11 +7948,13 @@ fn small_surface_depth_cap_removes_only_agent() { .collect() ); } + fn disabled_feature_agent_surface_options() -> AgentToolSurfaceOptions { let mut options = AgentToolSurfaceOptions::new(ShellPolicy::Full); options.goal_state = Some(crate::tools::goal::new_shared_goal_state()); options } + #[test] fn subagent_general_catalog_keeps_parent_surface_except_root_goal_mutators() { let tmp = tempdir().expect("tempdir"); @@ -7793,6 +7996,7 @@ fn subagent_general_catalog_keeps_parent_surface_except_root_goal_mutators() { "default General sub-agent catalog must match the parent Agent surface except for root-owned goal mutators" ); } + #[test] fn subagent_feature_gates_match_parent_agent_surface() { let tmp = tempdir().expect("tempdir"); @@ -7833,6 +8037,7 @@ fn subagent_feature_gates_match_parent_agent_surface() { assert!(!child_names.contains(name), "{name} should be child-gated"); } } + /// Model the clamp's deny list the way a real spawn threads it in /// (fleet/worker_runtime.rs): a `write: false` member loses the raw shell /// surface and the non-shell execution surface even when its posture holds @@ -7860,6 +8065,7 @@ fn seed_read_only_role_deny_list(runtime: &mut SubAgentRuntime) { } } } + /// #5426 acceptance point 1, gate-level: a live scout must be able to run /// the three canonical read-only inspection commands DIRECTLY through /// canonical `bash` — `git -C ... log`, `find ... | head`, `npm view` — with @@ -7940,6 +8146,7 @@ fn scout_posture_gate_admits_agent_readonly_bash_commands() { "legacy `Bash` is not the carve-out name; the envelope must keep refusing it" ); } + /// #5595: catalog admission, role posture, and the execution envelope are not /// enough. The concrete read-only executor must accept the canonical Git shape /// agents use when the repository is not their process cwd. This is the exact @@ -7992,6 +8199,7 @@ async fn read_only_inspection_roles_execute_pwd_and_absolute_git_log() { } } } + /// Read-only text filters may transform stdout, but they must not reach their /// file-output or helper-program forms through the same bounded bash carve-out. #[tokio::test] @@ -8071,6 +8279,344 @@ async fn read_only_inspection_roles_cannot_write_through_text_filters() { // bounded read-only probes the inspection roles use — so "with evidence" // includes evidence the child gathered itself (`wc`, `ls`, `cat`, `grep`) — // while custom commands stay refused and the workspace stays read-only. + +/// The roster description must promise exactly the surface the role has: +/// built-in gates plus a bounded read-only evidence shell, no custom commands. +#[test] +fn verifier_roster_description_states_the_bounded_contract() { + let description = FleetRole::Verifier.description(); + assert!( + description.contains("built-in verification gates"), + "roster description must name the built-in gates: {description}" + ); + assert!( + description.contains("bounded read-only shell"), + "roster description must promise the bounded evidence shell: {description}" + ); + assert!( + description.contains("cannot execute custom commands"), + "roster description must refuse custom commands: {description}" + ); + // The Agent-tool `type` description carries the same truth, briefly. + assert!( + SUBAGENT_TYPE_DESCRIPTION.contains("test: runs the built-in verification gates"), + "type description must match the roster: {SUBAGENT_TYPE_DESCRIPTION}" + ); +} + +/// Acceptance for the verifier lane, sibling of +/// `read_only_inspection_roles_execute_pwd_and_absolute_git_log`: a live +/// `test` child must execute a bounded read-only probe through canonical +/// `bash` — `wc -c` on a fixture — with the raw-shell deny list still +/// installed, reaching the lane *through* it via +/// `allows_bounded_readonly_bash`. +#[cfg_attr(not(unix), ignore = "wc and unix paths")] +#[tokio::test] +async fn verifier_child_executes_a_bounded_readonly_bash_probe_for_evidence() { + let (tmp, registry) = read_only_with_shell_registry(); + let fixture = tmp.path().join("evidence.txt"); + std::fs::write(&fixture, "countable\n").expect("fixture"); + + let command = format!("wc -c {}", fixture.display()); + assert!( + !registry.role_blocks_unhardened_process_tool("bash"), + "canonical bash must reach the bounded lane, not the unhardened wall" + ); + let output = registry + .execute( + "agent_verifier_evidence", + "bash", + json!({ "command": command }), + ) + .await + .unwrap_or_else(|error| panic!("verifier probe must run `{command}`: {error}")); + // The count is wc's FIRST whitespace-separated field (`10 /path`), not a + // substring: `contains("10")` would also match line noise like `210`. + let count = output.split_ascii_whitespace().next().unwrap_or_default(); + assert_eq!( + count, "10", + "verifier probe must report the fixture's byte count as wc's first field: {output}" + ); +} + +/// The walls around the lane stay up: the legacy raw `Bash` spelling, a +/// custom `Run` command, and a workspace write are all refused for a +/// verifier — at dispatch, and with the `reject_unbounded_verification` +/// contract text for the custom command. +#[tokio::test] +async fn verifier_child_still_refuses_raw_shell_custom_commands_and_writes() { + let (_tmp, registry) = read_only_with_shell_registry(); + + let raw = registry + .execute( + "agent_verifier_walls", + "Bash", + json!({"action": "run", "command": "rm -rf src"}), + ) + .await + .expect_err("legacy raw Bash must stay refused for a verifier"); + let raw = raw.to_string(); + assert!( + raw.contains("not available") || raw.contains("not allowed"), + "raw Bash refusal did not identify the wall: {raw}" + ); + // The second legacy-`Bash` wall is the posture's classifier-lane rule: a + // command the classifier cannot prove read-only must not re-enter + // through the alias, pinned here on this Verifier-labeled registry (the + // sibling pin covers the same wall through a Scout fixture). A + // proven-read-only shape still fails this wall — the posture rule + // refuses the legacy alias name itself (`bash_must_use_classifier_lane` + // is case-insensitive), so the alias never re-enters at any + // provability. + assert!( + !registry.posture_permits_tool("Bash", Some(&json!({"command": "rm -rf src"}))), + "the posture wall must refuse unprovable commands through the legacy `Bash` alias for a Verifier" + ); + + let custom_command = json!({ + "action": "verifiers", + "commands": [{"name": "x", "program": "bash", "args": ["-lc", "id"]}] + }); + // The contract text, at the guard that owns it: a custom command is the + // unbounded form, refused for a write-denied child with or without shell. + assert!( + super::reject_unbounded_verification("Run", &custom_command, true).is_err() + && super::reject_unbounded_verification("Run", &custom_command, false).is_err(), + "custom Run commands must be refused by the unbounded-verification guard" + ); + // …and the refusal reaches the real dispatch path too, from one of the + // two walls that own it — the approval gate (a Required call with no + // session decision) or the unbounded-verification guard — rather than + // failing for some unrelated reason. + let custom = registry + .execute("agent_verifier_walls", "Run", custom_command) + .await + .expect_err("custom Run commands are refused by design"); + let custom = custom.to_string(); + assert!( + custom.contains("requires approval") || custom.contains("operator-supplied commands"), + "custom command refusal must come from the approval or unbounded wall: {custom}" + ); + + let write = registry + .execute( + "agent_verifier_walls", + "File", + json!({"action": "write", "path": "a.rs", "content": "b"}), + ) + .await + .expect_err("a verifier must not mutate the workspace"); + assert!( + write.to_string().contains("not allowed") || write.to_string().contains("read-only"), + "workspace-write refusal did not identify the denial: {write}" + ); +} + +/// A registry for a write-capable role whose parent handed it a read-only +/// shell and no writes — what `derive_child` produces for a Builder, Worker, +/// or Custom child of a Scout or Planner on the plain `agent` nesting path, +/// where no Fleet deny list is installed. +fn read_only_shell_child_registry(role: FleetRole) -> (tempfile::TempDir, SubAgentToolRegistry) { + let tmp = tempdir().expect("tempdir"); + let mut runtime = + stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options()); + runtime.context = ToolContext::new(tmp.path().to_path_buf()); + runtime.allow_shell = true; + let mut profile = WorkerRuntimeProfile::for_role(role.clone()); + profile.permissions = crate::worker_profile::PermissionSet::read_only_with_network(); + profile.shell = crate::worker_profile::ShellPolicy::ReadOnly; + runtime.worker_profile = profile; + let registry = SubAgentToolRegistry::new( + runtime, + role, + None, + Arc::new(Mutex::new(TodoList::new())), + Arc::new(Mutex::new(PlanState::default())), + ); + (tmp, registry) +} + +/// Shell authority is the Verifier's carve-out alone. Every other role whose +/// runtime shell was narrowed to read-only — the Builder, Worker, or Custom +/// child of a read-only parent — holds no process authority: the built-in +/// test runner stays refused at posture and envelope and absent from the +/// catalog, exactly as before the verifier lane existed, so delegation +/// cannot widen a read-only parent into a child that runs the suite. +#[tokio::test] +async fn read_only_shell_children_of_other_roles_hold_no_shell_authority() { + let gate = json!({"action": "tests", "args": "--lib fleet::exact"}); + for role in [FleetRole::Builder, FleetRole::Worker, FleetRole::Custom] { + let (_tmp, registry) = read_only_shell_child_registry(role.clone()); + assert!( + !registry.posture_permits_tool("Run", Some(&gate)), + "{role:?} with a read-only shell must not hold the Required surface" + ); + assert!( + registry.envelope_refusal("Run", &gate).is_some(), + "{role:?} envelope must report no shell authority" + ); + let names: Vec = registry + .tools_for_model(&role) + .iter() + .map(|tool| tool.name.clone()) + .collect(); + assert!( + !names.iter().any(|name| name == "Run"), + "{role:?} catalog must not offer the verification gate: {names:?}" + ); + let refused = registry + .execute("agent_read_only_child", "Run", gate.clone()) + .await + .expect_err("the test runner must stay refused for a read-only-shell child"); + assert!( + refused.to_string().contains("not permitted") || refused.to_string().contains("shell"), + "{role:?}: {refused}" + ); + } + // The carve-out itself: the same narrowed shell on the Verifier keeps the + // bounded gates (pinned in detail by + // `verifier_child_keeps_the_default_and_selection_bounded_run_surface`). + let (_tmp, verifier) = read_only_with_shell_registry(); + assert!(verifier.posture_permits_tool("Run", Some(&gate))); + assert!(verifier.envelope_refusal("Run", &gate).is_none()); +} + +/// The purpose survives the narrowing: default gates and test-selection +/// arguments stay admitted for the verifier at posture and envelope, and the +/// catalog still offers `Run`. +#[test] +fn verifier_child_keeps_the_default_and_selection_bounded_run_surface() { + let (_tmp, registry) = read_only_with_shell_registry(); + + for input in [ + json!({}), + json!({"action": "tests"}), + json!({"action": "tests", "args": "-p codewhale-tui"}), + json!({"action": "tests", "args": "--lib fleet::exact"}), + ] { + assert!( + registry.posture_permits_tool("Run", Some(&input)), + "verifier posture must admit the bounded gate: {input}" + ); + assert!( + registry.envelope_refusal("Run", &input).is_none(), + "verifier envelope must admit the bounded gate: {input}" + ); + } + let tools = registry.tools_for_model(&FleetRole::Verifier); + let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_str()).collect(); + assert!( + names.contains(&"Run"), + "catalog must keep offering the verification gate: {names:?}" + ); +} + +/// A Verifier child of a read-only role parent (its shell was clamped by the +/// parent, not narrowed by the Verifier role) regains nothing through the +/// carve-out: the built-in gate runner stays refused at posture and envelope +/// and off the catalog, while the classifier-bounded bash evidence lane — +/// which the read-only parent holds too — keeps working. Delegation moves +/// work, never authority. +#[tokio::test] +async fn clamped_verifier_child_of_read_only_parent_holds_no_run_surface() { + let (_tmp, registry) = read_only_shell_child_registry(FleetRole::Verifier); + for input in [ + json!({}), + json!({"action": "tests"}), + json!({"action": "tests", "args": "-p codewhale-tui"}), + ] { + assert!( + !registry.posture_permits_tool("Run", Some(&input)), + "a clamped verifier must not run gates: {input}" + ); + assert!( + registry.envelope_refusal("Run", &input).is_some(), + "the envelope must refuse the gate for a clamped verifier: {input}" + ); + } + let tools = registry.tools_for_model(&FleetRole::Verifier); + let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_str()).collect(); + assert!( + !names.contains(&"Run"), + "the clamped verifier catalog must not offer the gate: {names:?}" + ); + assert!( + names.contains(&"bash"), + "the clamped verifier keeps the shared bash evidence lane: {names:?}" + ); + assert!( + registry.posture_permits_tool("bash", Some(&json!({"command": "pwd"}))), + "the shared evidence lane admits read-only probes" + ); + assert!( + !registry.posture_permits_tool("bash", Some(&json!({"command": "rm -rf src"}))), + "the evidence lane stays classifier-bounded" + ); +} + +/// The sub-subagent chain: a carve-out Verifier parent (its effective shell +/// is the role's read-only narrowing; the Run grant is a registry surface, +/// not shell authority) spawns a Builder or Worker child. The child +/// derivation re-clamps before it captures shell authority, so the chain +/// holds no extra authority: the spawn ceiling the carve-out parent +/// publishes stays read-only, and a child built under that ceiling keeps +/// the same no-Run, classifier-bounded shape this module pins elsewhere. +/// The round-3 re-key bug lived exactly in this ordering. +#[test] +fn sub_subagent_chain_of_a_carveout_verifier_holds_no_extra_authority() { + // A carve-out Verifier in a full session: the coarse session flag stays + // `allow_shell = true` while its effective runtime shell is the + // read-only narrowing. The ceiling its spawns publish must read the + // effective shell, not the flag. + let mut runtime = + stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options()); + runtime.allow_shell = true; + runtime.worker_profile.shell = crate::worker_profile::ShellPolicy::ReadOnly; + let ceiling = session_permission_ceiling(&runtime); + assert_eq!( + ceiling.shell, + codewhale_workflow::ShellCeiling::ReadOnly, + "the carve-out must not widen the shell ceiling a verifier publishes for its own spawns" + ); + // The chain child registry shape (what the derivation above produces for + // the next hop): no built-in gate surface, evidence lane classifier- + // bounded — for the Builder the chain would most plausibly reach for. + let (_tmp, registry) = read_only_shell_child_registry(FleetRole::Builder); + assert!( + !registry.posture_permits_tool("Run", Some(&json!({"action": "tests"}))), + "a chain Builder must not hold the verification gate" + ); + assert!( + registry + .envelope_refusal("Run", &json!({"action": "tests"})) + .is_some(), + "a chain Builder envelope must refuse the verification gate" + ); + assert!( + !registry.posture_permits_tool("bash", Some(&json!({"command": "rm -rf src"}))), + "a chain Builder keeps the evidence lane classifier-bounded" + ); +} + +/// The verifier intro tells the child about its bounded read-only shell and +/// the custom-command refusal, scoped to in-session delegations the way +/// `FleetRole`'s roster description scopes the role. +#[test] +fn verifier_intro_states_the_evidence_contract() { + assert!( + VERIFIER_AGENT_INTRO.contains("bounded read-only shell"), + "verifier intro must name the bounded read-only evidence shell: {VERIFIER_AGENT_INTRO}" + ); + assert!( + VERIFIER_AGENT_INTRO.contains("custom commands are refused"), + "verifier intro must repeat the custom-command refusal: {VERIFIER_AGENT_INTRO}" + ); + assert!( + VERIFIER_AGENT_INTRO.contains("in-session delegations"), + "the verifier's bounded read-only shell must be scoped to in-session delegations: {VERIFIER_AGENT_INTRO}" + ); +} + #[test] fn explore_catalog_inherits_web_but_hides_write_shell_and_fim_tools() { let tmp = tempdir().expect("tempdir"); @@ -8122,6 +8668,7 @@ fn explore_catalog_inherits_web_but_hides_write_shell_and_fim_tools() { file.input_schema ); } + /// Under the real spawn clamp — raw-shell deny list installed, `Bash` /// included — Scout and Reviewer reach exactly one shell entry point: /// canonical lowercase `bash`, bounded by the strict read-only classifier. @@ -8336,6 +8883,7 @@ async fn read_only_roles_expose_and_dispatch_lowercase_bash_only() { ); } } + #[tokio::test] async fn planner_exposes_and_dispatches_read_only_bash_probes() { let tmp = tempdir().expect("tempdir"); @@ -8389,6 +8937,7 @@ async fn planner_exposes_and_dispatches_read_only_bash_probes() { .expect("planner must dispatch a bounded read"); assert_eq!(output, sentinel); } + #[tokio::test] async fn scout_shell_respects_parent_shell_and_network_ceilings() { let tmp = tempdir().expect("tempdir"); @@ -8451,21 +9000,36 @@ async fn scout_shell_respects_parent_shell_and_network_ceilings() { crate::tools::todo::new_shared_todo_list(), crate::tools::plan::new_shared_plan_state(), ); - assert!( - !tool_names(registry.tools_for_model(&role)).contains("bash"), - "{role:?} must not inherit the read-only inspection bash catalog" - ); - if role == FleetRole::Verifier { + if role == FleetRole::Consultant { assert!( - registry - .execute("agent_verifier", "bash", json!({"command": "pwd"})) - .await - .is_err(), - "Verifier keeps bounded Run, never arbitrary Bash" + !tool_names(registry.tools_for_model(&role)).contains("bash"), + "{role:?} must not inherit the read-only inspection bash catalog" + ); + } else { + // The verifier shares the inspection lane: canonical lowercase + // `bash` is advertised and classifier-bounded — evidence probes + // run, arbitrary shell keeps the bounded-lane refusal. + assert!( + tool_names(registry.tools_for_model(&role)).contains("bash"), + "verifier must advertise the bounded read-only bash lane" + ); + let probe = registry + .execute("agent_verifier", "bash", json!({"command": "pwd"})) + .await + .expect("the verifier's bounded read-only probe runs"); + assert!(!probe.trim().is_empty(), "pwd must return evidence"); + let arbitrary = registry + .execute("agent_verifier", "bash", json!({"command": "rm -rf src"})) + .await + .expect_err("arbitrary shell stays refused behind the lane"); + assert!( + arbitrary.to_string().contains("[shell.readonly.command]"), + "unexpected refusal: {arbitrary}" ); } } } + /// An auto-approved Worker registry (shell-capable, everything else default) /// carrying the given exec-policy engine — the smallest fixture that reaches /// the delegated-call execpolicy gate past every posture gate. @@ -8475,6 +9039,7 @@ fn auto_approved_worker_registry_with_engine( ) -> SubAgentToolRegistry { worker_registry_with_engine_and_approval(tmp, exec_policy_engine, true) } + /// The same fixture with the parent session's approval posture under the /// caller's control: `false` models every prompting/fail-closed posture /// (Suggest/Auto/Never), where the main line would surface an approval @@ -8498,6 +9063,7 @@ fn worker_registry_with_engine_and_approval( crate::tools::plan::new_shared_plan_state(), ) } + /// Fork-delta behavior test: a command the parent session's execpolicy /// hard-denies must fail closed inside the child with the main line's deny /// wording — the auto-approve escape hatch this wiring closes — while a @@ -8689,6 +9255,7 @@ async fn subagent_execpolicy_empty_ruleset_keeps_auto_approve_behavior() { .to_string(); assert!(error.contains("denied prefix rule 'echo'"), "{error}"); } + /// A typed Deny rule on a file path blocks the child's File read of that path /// while a workspace-relative read outside the rule still lands. #[tokio::test] @@ -8725,6 +9292,7 @@ async fn subagent_execpolicy_blocks_denied_file_read_but_allows_workspace_relati .expect("a workspace-relative read outside the deny rule passes"); assert!(output.contains("plain notes"), "{output}"); } + /// Typed ask rules are prompt decisions on the main line. Under a /// parent-auto-approving session the main line would auto-run, so the child /// passes the call through and it really executes; the refusal face under a @@ -8750,6 +9318,7 @@ async fn subagent_execpolicy_prompt_decision_passes_under_parent_auto_approve() .expect("an ask-rule (prompt) decision must not block a child"); assert!(output.contains("askme"), "{output}"); } + /// The registry's engine handle is the parent's live engine, not a snapshot: /// a ruleset installed after the registry was built binds its delegated calls. #[tokio::test] @@ -8783,6 +9352,7 @@ async fn subagent_execpolicy_ruleset_update_binds_already_delegated_calls() { .to_string(); assert!(error.contains("denied prefix rule 'echo'"), "{error}"); } + #[test] fn implementer_catalog_inherits_patch_and_fim_when_enabled() { let tmp = tempdir().expect("tempdir"); @@ -8816,6 +9386,7 @@ fn implementer_catalog_inherits_patch_and_fim_when_enabled() { ); } } + #[test] fn every_fleet_role_catalog_advertises_one_executable_load_skill() { // load_skill contract (#4651): the parent Agent surface and every @@ -8901,12 +9472,13 @@ fn every_fleet_role_catalog_advertises_one_executable_load_skill() { "read-only role {role:?} keeps load_skill without gaining {denied}" ); } - // Scout/reviewer/planner expose only canonical lowercase bash, - // whose concrete calls are reclassified. Verifier keeps its - // bounded Run surface but not raw bash. + // Inspection roles and the verifier expose only canonical + // lowercase bash, whose concrete calls are reclassified by the + // read-only classifier (the verifier's evidence lane). Consultant + // is shell-less and gains no bash at all. if matches!( &role, - FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner + FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner | FleetRole::Verifier ) { assert!(names.contains("bash"), "{role:?} keeps read-only bash"); for denied in ["exec_shell", "task_shell_start"] { @@ -8930,6 +9502,7 @@ fn every_fleet_role_catalog_advertises_one_executable_load_skill() { } } } + #[test] fn custom_child_allowlist_omitting_load_skill_fails_closed() { // Custom children get exactly their explicit allow-list: load_skill is @@ -8971,6 +9544,7 @@ fn custom_child_allowlist_omitting_load_skill_fails_closed() { "explicitly listed load_skill is granted: {names:?}" ); } + #[tokio::test] async fn plan_parent_profile_narrows_even_implementer_child_to_read_only() { let tmp = tempdir().expect("tempdir"); @@ -9018,6 +9592,7 @@ async fn plan_parent_profile_narrows_even_implementer_child_to_read_only() { ); assert!(!workspace.join("plan-parent-write.txt").exists()); } + #[tokio::test] async fn api_timeout_preserves_checkpoint_and_returns_needs_input_without_parking() { let tmp = tempdir().expect("tempdir"); @@ -9192,6 +9767,7 @@ async fn api_timeout_preserves_checkpoint_and_returns_needs_input_without_parkin "projection inspection must not respawn the child implicitly" ); } + #[tokio::test] async fn subagent_retries_api_timeout_before_succeeding() { let tmp = tempdir().expect("tempdir"); @@ -9271,6 +9847,7 @@ async fn subagent_retries_api_timeout_before_succeeding() { assert_eq!(snapshot.status, SubAgentStatus::Completed); assert_eq!(snapshot.result.as_deref(), Some("recovered answer")); } + #[test] fn api_timeout_retry_backoff_doubles_and_caps() { let base = SUBAGENT_API_TIMEOUT_INITIAL_BACKOFF; @@ -9284,6 +9861,7 @@ fn api_timeout_retry_backoff_doubles_and_caps() { ); } } + #[test] fn api_timeout_retry_delay_stays_within_jitter_bounds() { let base = SUBAGENT_API_TIMEOUT_INITIAL_BACKOFF; @@ -9303,12 +9881,14 @@ fn api_timeout_retry_delay_stays_within_jitter_bounds() { } } } + #[test] fn transient_provider_classifier_matches_sse_header_timeout() { let err = anyhow::anyhow!("SSE stream request did not receive response headers after 45s"); assert!(is_transient_subagent_provider_error(&err)); } + #[test] fn transient_provider_classifier_matches_body_decode_failures() { // A provider that accepts the request and dies mid-response is a @@ -9329,6 +9909,7 @@ fn transient_provider_classifier_matches_body_decode_failures() { "auth failures stay fatal" ); } + #[test] fn transient_provider_classifier_matches_structured_rate_limit() { let err = anyhow::Error::new(crate::llm_client::LlmError::RateLimited { @@ -9339,6 +9920,7 @@ fn transient_provider_classifier_matches_structured_rate_limit() { assert!(is_transient_subagent_provider_error(&err)); } + #[tokio::test] async fn subagent_retries_transient_provider_header_timeout_before_succeeding() { let tmp = tempdir().expect("tempdir"); @@ -9413,6 +9995,7 @@ async fn subagent_retries_transient_provider_header_timeout_before_succeeding() assert_eq!(snapshot.status, SubAgentStatus::Completed); assert_eq!(snapshot.result.as_deref(), Some("recovered answer")); } + #[tokio::test] async fn subagent_rate_limit_exhaustion_interrupts_with_checkpoint() { let tmp = tempdir().expect("tempdir"); @@ -9498,6 +10081,7 @@ async fn subagent_rate_limit_exhaustion_interrupts_with_checkpoint() { assert!(checkpoint.continuable); assert!(snapshot.needs_input.is_some()); } + #[tokio::test] async fn spawn_duplicate_session_name_error_names_conflicting_agent() { // #2656: the duplicate-name error must identify the conflicting agent so a @@ -9558,6 +10142,7 @@ async fn spawn_duplicate_session_name_error_names_conflicting_agent() { "includes elapsed time since spawn: {msg}" ); } + #[tokio::test] async fn spawn_session_name_held_by_prior_session_agent_does_not_collide() { // A completed worker hydrated from an earlier session's ledger is not @@ -9617,6 +10202,7 @@ async fn spawn_session_name_held_by_prior_session_agent_does_not_collide() { assert_eq!(fresh.session_name, "researcher"); assert!(!guard.is_from_prior_session(fresh)); } + /// Seed a same-session child holding `name` in the given status; returns its /// agent id. Shared by the name-release tests below. async fn seed_named_same_session_agent( @@ -9651,6 +10237,7 @@ async fn seed_named_same_session_agent( assert!(!guard.is_from_prior_session(&guard.agents[&id])); id } + #[tokio::test] async fn spawn_session_name_released_once_same_session_holder_settles() { // 2026-10-23 research fan-out: the parent cancelled `macro-policy`, got @@ -9716,6 +10303,7 @@ async fn spawn_session_name_released_once_same_session_holder_settles() { ); } } + #[tokio::test] async fn spawn_session_name_still_held_by_interrupted_same_session_child() { // An interrupted child is parked, not finished: its name stays reserved @@ -9768,6 +10356,7 @@ async fn spawn_session_name_still_held_by_interrupted_same_session_child() { "cancel is a silent no-op on an interrupted child: {msg}" ); } + #[tokio::test] async fn spawn_session_name_running_holder_advises_wait_or_cancel() { // A running holder can be waited out or cancelled; the refusal must keep @@ -9810,6 +10399,7 @@ async fn spawn_session_name_running_holder_advises_wait_or_cancel() { "a running holder can be waited out or cancelled: {msg}" ); } + #[test] fn session_name_resolution_prefers_live_holder_then_newest_settled() { let tmp = tempdir().expect("tempdir"); @@ -9859,6 +10449,7 @@ fn session_name_resolution_prefers_live_holder_then_newest_settled() { older_id ); } + #[test] fn session_name_resolution_ignores_prior_boot_interrupted_ghost() { // Hydration rewrites a child that was Running at shutdown into a @@ -9912,6 +10503,7 @@ fn session_name_resolution_ignores_prior_boot_interrupted_ghost() { retry_id ); } + #[tokio::test] async fn id_shaped_session_name_requires_an_explicit_name_selector() { // Name reuse cannot reinterpret a durable receipt. Both live and settled @@ -9969,6 +10561,7 @@ async fn id_shaped_session_name_requires_an_explicit_name_selector() { ); } } + #[test] fn spawn_contention_error_names_the_read_only_path() { // The ledger text is written for writers. The refusal seen in the @@ -9994,6 +10587,7 @@ fn spawn_contention_error_names_the_read_only_path() { let other = "write-claim capacity is 64 active owners; complete, serialize, or isolate existing work before admitting another writer".to_string(); assert_eq!(spawn_claim_error_with_guidance(other.clone()), other); } + #[test] fn general_role_text_discloses_workspace_claim_and_read_only_alternative() { // The roster entry, the type enum text and the tool description are the @@ -10019,6 +10613,7 @@ fn general_role_text_discloses_workspace_claim_and_read_only_alternative() { "{description}" ); } + #[tokio::test] async fn shared_write_claim_is_registered_before_parallel_launch_and_manifested() { let tmp = tempdir().expect("tempdir"); @@ -10100,6 +10695,7 @@ async fn shared_write_claim_is_registered_before_parallel_launch_and_manifested( assert_eq!(manifest.coordination_contracts, vec!["public-api"]); assert_eq!(manifest.expected_artifact.as_deref(), Some("tested patch")); } + #[tokio::test] async fn write_capable_agent_does_not_launch_when_durable_registration_fails() { let tmp = tempdir().expect("tempdir"); @@ -10150,6 +10746,7 @@ async fn write_capable_agent_does_not_launch_when_durable_registration_fails() { ); assert!(blocked_state_path.is_dir()); } + #[tokio::test] async fn write_scope_contention_covers_regular_agent_and_active_fleet_writer() { let tmp = tempdir().expect("tempdir"); @@ -10223,6 +10820,7 @@ async fn write_scope_contention_covers_regular_agent_and_active_fleet_writer() { "{launch}" ); } + #[tokio::test] async fn test_running_count_counts_only_agents_with_live_task_handles() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); @@ -10255,6 +10853,7 @@ async fn test_running_count_counts_only_agents_with_live_task_handles() { .expect("live task handle") .abort(); } + #[test] fn test_running_count_ignores_running_status_without_task_handle() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); @@ -10276,6 +10875,7 @@ fn test_running_count_ignores_running_status_without_task_handle() { assert_eq!(manager.running_count(), 0); } + #[tokio::test] async fn test_running_count_counts_running_agents_until_status_reconciles() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); @@ -10302,6 +10902,7 @@ async fn test_running_count_counts_running_agents_until_status_reconciles() { assert_eq!(manager.running_count(), 1); } + #[tokio::test] async fn admission_limit_counts_queued_and_running_workers_separately() { let mut manager = SubAgentManager::new(PathBuf::from("."), 2).with_admission_limit(4); @@ -10371,6 +10972,7 @@ async fn admission_limit_counts_queued_and_running_workers_separately() { .abort(); } } + #[tokio::test] async fn cleanup_auto_cancels_stale_running_agent_and_releases_slot() { use tokio_util::sync::CancellationToken; @@ -10447,6 +11049,7 @@ async fn cleanup_auto_cancels_stale_running_agent_and_releases_slot() { AgentWorkerStatus::Cancelled ); } + #[tokio::test] async fn status_projection_reconciles_stale_running_agent() { let mut inner = SubAgentManager::new(PathBuf::from("."), 1) @@ -10497,6 +11100,7 @@ async fn status_projection_reconciles_stale_running_agent() { .contains("Auto-cancelled") ); } + #[tokio::test] async fn cleanup_keeps_recent_running_agent() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1) @@ -10534,6 +11138,7 @@ async fn cleanup_keeps_recent_running_agent() { .expect("live task handle") .abort(); } + #[tokio::test] async fn touch_refreshes_stale_running_agent_heartbeat() { // Use a heartbeat timeout that is comfortably larger than the synchronous @@ -10578,6 +11183,7 @@ async fn touch_refreshes_stale_running_agent_heartbeat() { .expect("live task handle") .abort(); } + #[test] fn test_persist_and_reload_marks_running_agent_as_interrupted() { let tmp = tempdir().expect("tempdir"); @@ -10618,6 +11224,7 @@ fn test_persist_and_reload_marks_running_agent_as_interrupted() { if message.contains(SUBAGENT_RESTART_REASON) )); } + #[test] fn generated_whale_name_is_not_persisted_or_replayed_on_load() { let tmp = tempdir().expect("tempdir"); @@ -10674,6 +11281,7 @@ fn generated_whale_name_is_not_persisted_or_replayed_on_load() { assert_eq!(snapshot.name, "docs-worker"); assert_eq!(snapshot.nickname, None); } + #[test] fn explicit_nonmatching_whale_word_is_persisted_and_loaded() { let tmp = tempdir().expect("tempdir"); @@ -10719,6 +11327,7 @@ fn explicit_nonmatching_whale_word_is_persisted_and_loaded() { let snapshot = reloaded.get_result(agent_id).expect("agent survives load"); assert_eq!(snapshot.nickname.as_deref(), Some(explicit_whale)); } + #[test] fn persist_and_reload_preserves_checkpoint_for_interrupted_running_agent() { let tmp = tempdir().expect("tempdir"); @@ -10769,6 +11378,7 @@ fn persist_and_reload_preserves_checkpoint_for_interrupted_running_agent() { assert_eq!(checkpoint.messages.len(), 2); assert_eq!(message_text(&checkpoint.messages[1]), "partial progress"); } + #[test] fn restart_reconciles_every_orphan_execution_status_once_and_preserves_receipts() { let tmp = tempdir().expect("tempdir"); @@ -10941,6 +11551,7 @@ fn restart_reconciles_every_orphan_execution_status_once_and_preserves_receipts( "a later restart does not append duplicate interrupted receipts" ); } + #[cfg(unix)] #[test] fn load_state_rejects_symlinked_state_file() { @@ -10965,6 +11576,7 @@ fn load_state_rejects_symlinked_state_file() { .expect_err("symlinked state should fail"); assert!(format!("{err:#}").contains("must not traverse symlinks")); } + #[test] fn persist_state_rejects_state_path_outside_state_root() { let tmp = tempdir().expect("tempdir"); @@ -10979,6 +11591,7 @@ fn persist_state_rejects_state_path_outside_state_root() { assert!(format!("{err:#}").contains("must stay within state root")); } + #[test] fn explicit_state_roots_isolate_managers_for_the_same_execution_workspace() { let tmp = tempdir().expect("tempdir"); @@ -11053,6 +11666,7 @@ fn explicit_state_roots_isolate_managers_for_the_same_execution_workspace() { "an explicit state root must keep control-plane files out of the execution workspace" ); } + #[cfg(unix)] #[test] fn persist_state_rejects_symlinked_state_directory() { @@ -11072,6 +11686,7 @@ fn persist_state_rejects_symlinked_state_directory() { || format!("{err:#}").contains("must not traverse symlinks") ); } + #[test] fn test_interrupted_status_name_and_summary() { let snapshot = make_snapshot(SubAgentStatus::Interrupted( @@ -11082,6 +11697,7 @@ fn test_interrupted_status_name_and_summary() { } // === v0.6.6 — sub-agent authority unification === + #[test] fn build_allowed_tools_general_returns_none_for_full_inheritance() { // Default behavior: General agent with no explicit list inherits the @@ -11092,6 +11708,7 @@ fn build_allowed_tools_general_returns_none_for_full_inheritance() { "General with no explicit_tools should default to full inheritance (None), got {result:?}" ); } + #[test] fn build_allowed_tools_explore_returns_none_for_full_inheritance() { // Per-type allowlists are now advisory — Explore also gets the full @@ -11102,6 +11719,7 @@ fn build_allowed_tools_explore_returns_none_for_full_inheritance() { "Explore with no explicit_tools should default to full inheritance" ); } + #[test] fn build_allowed_tools_custom_requires_explicit_list() { // Custom is the one type that REQUIRES explicit allowed_tools. @@ -11111,12 +11729,14 @@ fn build_allowed_tools_custom_requires_explicit_list() { "got: {err}" ); } + #[test] fn build_allowed_tools_explicit_list_returned_as_some() { let explicit = vec!["read_file".to_string(), "list_dir".to_string()]; let result = build_allowed_tools(&FleetRole::Custom, Some(explicit.clone()), true).unwrap(); assert_eq!(result, Some(explicit)); } + #[test] fn build_allowed_tools_explicit_list_dedupes_and_trims() { let explicit = vec![ @@ -11131,6 +11751,7 @@ fn build_allowed_tools_explicit_list_dedupes_and_trims() { Some(vec!["read_file".to_string(), "list_dir".to_string()]) ); } + #[test] fn parse_spawn_request_extracts_cwd_when_present() { let input = json!({ @@ -11143,6 +11764,7 @@ fn parse_spawn_request_extracts_cwd_when_present() { Some(".worktrees/feature-a".to_string()) ); } + #[test] fn parse_spawn_request_accepts_worktree_isolation() { let input = json!({ @@ -11164,6 +11786,7 @@ fn parse_spawn_request_accepts_worktree_isolation() { Some("feature-a".to_string()) ); } + #[test] fn parse_spawn_request_accepts_cwd_with_worktree_isolation() { let input = json!({ @@ -11175,6 +11798,7 @@ fn parse_spawn_request_accepts_cwd_with_worktree_isolation() { assert!(parsed.worktree.is_some()); assert!(parsed.cwd.is_some()); } + #[test] fn git_repo_root_finds_repo_from_direct_cwd() { let repo = init_subagent_git_repo(); @@ -11184,6 +11808,7 @@ fn git_repo_root_finds_repo_from_direct_cwd() { repo.path().canonicalize().expect("canonical repo") ); } + #[test] fn git_repo_root_discovers_one_level_nested_repo_from_harness() { let repo = init_subagent_git_repo(); @@ -11203,6 +11828,7 @@ fn git_repo_root_discovers_one_level_nested_repo_from_harness() { nested.canonicalize().expect("canonical nested") ); } + #[test] fn git_repo_root_reports_attempted_paths_when_no_repo_found() { // Use the system temp dir rather than the checkout's parent: a checkout @@ -11233,18 +11859,21 @@ fn git_repo_root_reports_attempted_paths_when_no_repo_found() { "expected friendly attempted-path error, got: {message}" ); } + #[test] fn parse_spawn_request_cwd_absent_yields_none() { let input = json!({ "prompt": "no cwd" }); let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert!(parsed.cwd.is_none()); } + #[test] fn parse_spawn_request_cwd_empty_string_yields_none() { let input = json!({ "prompt": "empty cwd", "cwd": " " }); let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert!(parsed.cwd.is_none(), "whitespace-only cwd should be None"); } + #[test] fn create_isolated_worktree_creates_branch_checkout_outside_parent_repo() { let repo = init_subagent_git_repo(); @@ -11273,6 +11902,7 @@ fn create_isolated_worktree_creates_branch_checkout_outside_parent_repo() { Some("codex/agent-isolated-test") ); } + #[test] fn create_isolated_worktree_rejects_invalid_branch_as_input() { let repo = init_subagent_git_repo(); @@ -11296,6 +11926,7 @@ fn create_isolated_worktree_rejects_invalid_branch_as_input() { "unexpected error: {err}" ); } + fn init_git_repo_at(path: &std::path::Path) { let init = Command::new("git") .arg("init") @@ -11319,6 +11950,7 @@ fn init_git_repo_at(path: &std::path::Path) { .expect("git commit should run"); assert!(commit.status.success(), "git commit failed"); } + #[test] fn create_isolated_worktree_discovers_nested_repo_from_harness_parent() { let harness = tempdir().expect("harness"); @@ -11346,6 +11978,7 @@ fn create_isolated_worktree_discovers_nested_repo_from_harness_parent() { Some("codex/agent-harness-nested") ); } + #[test] fn create_isolated_worktree_reports_friendly_error_when_no_repo_found() { let harness = tempdir().expect("harness"); @@ -11379,6 +12012,7 @@ fn create_isolated_worktree_reports_friendly_error_when_no_repo_found() { "expected actionable discovery error, got: {message}" ); } + #[test] fn create_isolated_worktree_rejects_ambiguous_nested_repos() { let harness = tempdir().expect("harness"); @@ -11403,6 +12037,7 @@ fn create_isolated_worktree_rejects_ambiguous_nested_repos() { "expected ambiguity diagnostic, got: {message}" ); } + #[test] fn build_subagent_system_prompt_appends_role_when_set() { let assignment = SubAgentAssignment::new("p".to_string(), Some("worker".to_string())); @@ -11415,18 +12050,21 @@ fn build_subagent_system_prompt_appends_role_when_set() { // The shared background-worker / caller framing follows the role line. assert!(prompt.contains("background sub-agent")); } + #[test] fn build_subagent_system_prompt_skips_role_when_none() { let assignment = SubAgentAssignment::new("p".to_string(), None); let prompt = build_subagent_system_prompt(&FleetRole::Worker, &assignment); assert!(!prompt.contains("You are operating in the role of")); } + #[test] fn build_subagent_system_prompt_skips_role_when_blank() { let assignment = SubAgentAssignment::new("p".to_string(), Some(" ".to_string())); let prompt = build_subagent_system_prompt(&FleetRole::Worker, &assignment); assert!(!prompt.contains("You are operating in the role of")); } + #[test] fn fresh_forked_and_nested_subagents_share_authority_bound_skill_catalogs() { let _env = crate::test_support::lock_test_env(); @@ -11540,6 +12178,7 @@ fn fresh_forked_and_nested_subagents_share_authority_bound_skill_catalogs() { "workspace plugin authority must not leak into another worktree: {isolated_system}" ); } + #[test] fn subagent_done_sentinel_format_is_well_formed() { let res = make_snapshot(SubAgentStatus::Completed); @@ -11566,6 +12205,7 @@ fn subagent_done_sentinel_format_is_well_formed() { assert!(parsed.get("duration_ms").is_none()); assert!(parsed.get("steps").is_none()); } + #[test] fn subagent_done_sentinel_keeps_large_result_out_of_metadata() { let mut res = make_snapshot(SubAgentStatus::Completed); @@ -11586,6 +12226,7 @@ fn subagent_done_sentinel_keeps_large_result_out_of_metadata() { "sentinel should not duplicate large result text" ); } + #[test] fn subagent_done_sentinel_marks_truncated_summaries() { // issue #2652: when the child summary was length-gated, the sentinel must @@ -11598,6 +12239,7 @@ fn subagent_done_sentinel_marks_truncated_summaries() { let parsed: serde_json::Value = serde_json::from_str(inner).expect("inner JSON parses"); assert_eq!(parsed["summary_kind"], "truncated"); } + #[test] fn failed_subagent_completion_is_high_priority_and_retrievable() { let mut res = make_snapshot(SubAgentStatus::Failed( @@ -11631,6 +12273,7 @@ fn failed_subagent_completion_is_high_priority_and_retrievable() { let completed = subagent_completion_from_result(&make_snapshot(SubAgentStatus::Completed)); assert!(!completed.is_high_priority_failure()); } + #[test] fn budget_exhaustion_is_a_high_priority_failure_event() { let completion = @@ -11648,6 +12291,7 @@ fn budget_exhaustion_is_a_high_priority_failure_event() { .contains(r#""failure_class":"token_budget""#) ); } + #[test] fn stamp_subagent_summary_appends_note_when_short() { // issue #2652: a short (complete) summary gets the soft self-report note @@ -11664,6 +12308,7 @@ fn stamp_subagent_summary_appends_note_when_short() { "short summary must not get the truncation footer" ); } + #[test] fn forkguard_subagent_report_artifact_keeps_exact_bytes() { let home = tempdir().expect("artifact home"); @@ -11705,6 +12350,7 @@ fn forkguard_subagent_report_artifact_keeps_exact_bytes() { assert!(spill_subagent_final_report(blocked_session, &result).is_none()); }); } + #[test] fn stamp_subagent_summary_truncates_when_over_budget() { // issue #2652: a summary exceeding the budget is head+tail truncated using @@ -11739,6 +12385,7 @@ fn stamp_subagent_summary_truncates_when_over_budget() { assert!(saved.contains("activate it via tool_search first")); assert!(saved.contains("if neither route is available")); } + #[test] fn subagent_failed_sentinel_format_is_well_formed() { let mut result = make_snapshot(SubAgentStatus::Failed("boom".to_string())); @@ -11764,6 +12411,7 @@ fn subagent_failed_sentinel_format_is_well_formed() { // Stays lean — the error text lives on the previous line, not the sentinel. assert!(parsed.get("error").is_none()); } + #[test] fn annotated_failure_message_composes_class_tag_and_model_hint() { // #3884: the failure recorder composes subagent_failure_message (adds the @@ -11806,6 +12454,7 @@ fn annotated_failure_message_composes_class_tag_and_model_hint() { assert!(annotated.contains("route:"), "{annotated}"); assert!(annotated.contains("explicit model id"), "{annotated}"); } + #[test] fn subagent_failure_message_preserves_error_chain() { // #3884: `to_string()` on an anyhow error prints only the outermost @@ -11846,6 +12495,7 @@ fn subagent_failure_message_preserves_error_chain() { let message = subagent_failure_message(&err); assert_eq!(message, "outer: boom"); } + #[test] fn forkguard_quota_exhausted_skips_rate_limit_governor_and_stays_fatal() { // PR#43 follow-up: only LlmError::RateLimited feeds the rate-limit @@ -11879,6 +12529,7 @@ fn forkguard_quota_exhausted_skips_rate_limit_governor_and_stays_fatal() { }); assert!(is_governor_reported_rate_limit(&rate_limited_err)); } + #[test] fn annotate_child_model_error_adds_actionable_hint() { // #2653: a bare provider 403 becomes actionable by naming the model and the @@ -11924,6 +12575,7 @@ fn annotate_child_model_error_adds_actionable_hint() { "OpenAI-style rejection gets the hint: {openai_style}" ); } + #[test] fn child_launch_error_names_provider_model_and_route_source() { // #4049: a model-not-found child launch failure must name the provider @@ -11967,11 +12619,13 @@ fn child_launch_error_names_provider_model_and_route_source() { "inherit route source: {inherited}" ); } + #[test] fn subagent_runtime_default_max_depth_is_three() { // Sanity-check the constant — bumping it without a test means stale docs. assert_eq!(DEFAULT_MAX_SPAWN_DEPTH, 3); } + #[test] fn would_exceed_depth_at_boundary() { // depth=2, max=3 → next spawn (depth 3) is allowed (allow-equal). @@ -11993,6 +12647,7 @@ fn would_exceed_depth_at_boundary() { "depth 2 + max 3 → next is 3, allowed" ); } + #[test] fn clamp_child_max_spawn_depth_enforces_absolute_ceiling() { let ceiling = codewhale_config::MAX_SPAWN_DEPTH_CEILING; @@ -12016,6 +12671,7 @@ fn clamp_child_max_spawn_depth_enforces_absolute_ceiling() { "at the ceiling, a further spawn must be blocked regardless of max_depth" ); } + #[tokio::test] #[allow(clippy::await_holding_lock)] async fn rate_limit_pause_blocks_subagent_spawn() { @@ -12050,6 +12706,7 @@ async fn rate_limit_pause_blocks_subagent_spawn() { "refused spawn must not register or launch a worker" ); } + #[test] fn child_runtime_increments_depth_and_preserves_auto_approve() { let mut parent = stub_runtime(); @@ -12080,6 +12737,7 @@ fn child_runtime_increments_depth_and_preserves_auto_approve() { // `WorkRuntime::matches_todos` keys on `Arc::ptr_eq`, drove the parent's work // graph). These tests pin the invariant: every spawned agent owns its list, // and no agent can reach a parent's or a sibling's. + /// Run `work_update` against `todos` with `runtime`'s own tool context, the /// way a live agent's registry would. async fn write_todos_as(runtime: &SubAgentRuntime, contents: &[&str]) { @@ -12092,6 +12750,7 @@ async fn write_todos_as(runtime: &SubAgentRuntime, contents: &[&str]) { .await .expect("work_update must succeed against the agent's own list"); } + async fn todo_contents(todos: &crate::tools::todo::SharedTodoList) -> Vec { todos .lock() @@ -12102,6 +12761,7 @@ async fn todo_contents(todos: &crate::tools::todo::SharedTodoList) -> Vec crate::todo_snapshot::TodoSource { crate::todo_snapshot::TodoSource::new( runtime.context.runtime.work.clone(), runtime.todos.clone(), ) } + /// A child sends its stored messages and nothing else. Its To-do state is /// already in those messages, as the result of its own `work_update` call — /// no step appends a synthetic copy of the list. @@ -12327,6 +12994,7 @@ async fn child_request_messages_are_exactly_its_stored_messages() { assert!(!text.contains("child step one"), "{text}"); } } + /// Sibling and parent isolation: each agent's snapshot states only its own /// list. This is what the fork handoff and the agent card both read. #[tokio::test] @@ -12374,6 +13042,7 @@ async fn child_todo_snapshot_never_leaks_across_siblings_or_to_the_parent() { } } } + #[test] fn child_and_background_runtimes_preserve_step_api_timeout() { let timeout = Duration::from_secs(7); @@ -12390,6 +13059,7 @@ fn child_and_background_runtimes_preserve_step_api_timeout() { assert_eq!(background.step_api_timeout, timeout); assert_eq!(background.api_timeout_retry_base_backoff, backoff); } + #[tokio::test] async fn subagent_registry_blocks_approval_tools_without_parent_auto_approve() { let mut runtime = stub_runtime(); @@ -12416,6 +13086,7 @@ async fn subagent_registry_blocks_approval_tools_without_parent_auto_approve() { "unexpected error: {err}" ); } + #[tokio::test] async fn prompt_only_general_cannot_mutate_under_parent_auto_approve() { let tmp = tempdir().expect("tempdir"); @@ -12461,7 +13132,9 @@ async fn prompt_only_general_cannot_mutate_under_parent_auto_approve() { assert!(!tmp.path().join("forbidden.txt").exists()); assert!(!tmp.path().join("shell.txt").exists()); } + const MCP_ACTION_TOOL: &str = "mcp_github_create_pull_request"; + fn subagent_registry_with_mcp_action(auto_approve: bool) -> SubAgentToolRegistry { let mut runtime = stub_runtime(); runtime.context.auto_approve = auto_approve; @@ -12479,6 +13152,7 @@ fn subagent_registry_with_mcp_action(auto_approve: bool) -> SubAgentToolRegistry )); registry } + #[tokio::test] async fn child_write_tool_fails_closed_outside_registered_scope() { let _env_lock = crate::test_support::lock_test_env(); @@ -12680,6 +13354,7 @@ async fn child_write_tool_fails_closed_outside_registered_scope() { ); assert!(!tmp.path().join("docs/run-escape.txt").exists()); } + /// A lone writer in the shared checkout keeps its shell. /// /// The gate above exists so concurrent children cannot overwrite each other. @@ -12744,6 +13419,7 @@ async fn lone_shared_writer_keeps_unbounded_shell() { ); } } + #[test] fn shared_claim_shell_gate_normalizes_only_the_run_action() { assert!(is_unbounded_shell_run( @@ -12762,6 +13438,7 @@ fn shared_claim_shell_gate_normalizes_only_the_run_action() { ); } } + /// Build one registry per contended child, both bound to the same shared /// manager and workspace — the two-builder shared-checkout topology. fn contended_builder_registry( @@ -12784,6 +13461,7 @@ fn contended_builder_registry( Arc::new(Mutex::new(PlanState::default())), ) } + /// Two live write-capable children with disjoint `exact_files` claims in one /// shared checkout: a bash call the read-only classifier proves mutation-free /// must still run on both. The contention gate used to re-test @@ -12830,6 +13508,7 @@ async fn contended_shared_writers_keep_proven_readonly_shell() { }); } } + /// The same contention still refuses a shell call the classifier cannot prove /// read-only — and the refusal names the blocking peer and its remediation so /// the child can recover instead of retrying blindly. @@ -12890,6 +13569,7 @@ async fn contended_shared_writer_refusal_names_blocking_peer_and_remediation() { "the refused command must not have run" ); } + /// A child whose task body panics must still reach a terminal state. /// `spawn_supervised` catches the panic to keep the parent alive; without the /// guard committing a crash result the child stayed `Running` and its write @@ -12953,6 +13633,7 @@ async fn panicked_child_task_terminalizes_and_stops_gating_peers() { "a panicked child must not keep gating peers as a live writer" ); } + /// A headless fleet worker (a worker record with no paired agent entry) that /// reaches `WaitingForUser` can never be answered — nothing will resume or /// finalize it — so it must not count as a live coordination owner, and the @@ -13886,6 +14567,7 @@ async fn claim_less_worktree_child_still_confers_isolation_through_its_manifest( "the manifest fallback still puts the claim on the isolated footing" ); } + #[tokio::test] async fn subagent_blocks_mcp_action_without_parent_auto_approve() { let registry = subagent_registry_with_mcp_action(false); @@ -13902,6 +14584,7 @@ async fn subagent_blocks_mcp_action_without_parent_auto_approve() { "unexpected MCP approval error: {err}" ); } + #[tokio::test] async fn auto_approved_subagent_passes_mcp_action_approval_gate() { let registry = subagent_registry_with_mcp_action(true); @@ -13920,6 +14603,7 @@ async fn auto_approved_subagent_passes_mcp_action_approval_gate() { "auto-approved MCP actions must not stop at the approval gate: {message}" ); } + #[tokio::test] async fn implementer_delegation_allows_suggest_write_without_parent_auto_approve() { // Issue #1828: implementer agents could not write files even when their @@ -13957,6 +14641,7 @@ async fn implementer_delegation_allows_suggest_write_without_parent_auto_approve "successful write should not look like an approval error: {result}" ); } + #[tokio::test] async fn workflow_accept_edits_allows_general_file_write_without_parent_auto_approve() { // Workflow-spawned children accept Suggest-level file edits for write-capable @@ -14005,6 +14690,7 @@ async fn workflow_accept_edits_allows_general_file_write_without_parent_auto_app "unexpected: {err}" ); } + #[tokio::test] async fn general_delegation_still_blocks_suggest_write_without_parent_auto_approve() { let tmp = tempdir().expect("tempdir"); @@ -14039,6 +14725,7 @@ async fn general_delegation_still_blocks_suggest_write_without_parent_auto_appro "general write must not land without parent auto-approve" ); } + #[tokio::test] async fn explore_role_still_blocks_suggest_writes_without_parent_auto_approve() { // Read-only stances (explore, plan, review, verifier) must not gain @@ -14078,6 +14765,7 @@ async fn explore_role_still_blocks_suggest_writes_without_parent_auto_approve() "file must not have been written" ); } + #[tokio::test] async fn explore_role_blocks_writes_even_under_parent_auto_approve() { // #3217: the authoritative per-role posture closes the auto-approve bypass — @@ -14123,6 +14811,7 @@ async fn explore_role_blocks_writes_even_under_parent_auto_approve() { "file must not have been written under auto-approve" ); } + #[tokio::test] async fn delegated_write_role_still_blocks_required_tools() { // Required-level tools such as Bash remain gated behind parent @@ -14154,6 +14843,7 @@ async fn delegated_write_role_still_blocks_required_tools() { "expected Required-level approval message, got: {err}" ); } + #[test] fn read_only_role_starts_do_not_require_approval() { // #5186: an explicit canonical read-only role spawns without a modal in @@ -14194,6 +14884,7 @@ fn read_only_role_starts_do_not_require_approval() { ApprovalRequirement::Auto ); } + #[test] fn write_capable_or_unproven_starts_keep_the_approval_gate() { // #5186: anything the parser cannot prove read-only keeps the modal. @@ -14233,6 +14924,7 @@ fn write_capable_or_unproven_starts_keep_the_approval_gate() { ApprovalRequirement::Auto ); } + #[tokio::test] async fn worker_child_inherits_workspace_write_carve_out_without_parent_auto_approve() { // #5186: a write-posture child that is NOT an explicit write-delegated @@ -14283,6 +14975,7 @@ async fn worker_child_inherits_workspace_write_carve_out_without_parent_auto_app ); } } + #[tokio::test] async fn worker_child_write_carve_out_requires_a_git_work_tree() { // #5186: without a `.git` marker the child keeps the old delegation @@ -14312,6 +15005,7 @@ async fn worker_child_write_carve_out_requires_a_git_work_tree() { "unexpected error: {err}" ); } + #[tokio::test] async fn builder_child_runs_bounded_verification_but_not_shell_without_parent_auto_approve() { // #5186: the bounded built-in verification surface is delegated to @@ -14370,6 +15064,7 @@ async fn builder_child_runs_bounded_verification_but_not_shell_without_parent_au "unexpected error: {argv_err}" ); } + #[tokio::test] async fn auto_approved_parent_runs_required_tools_in_subagent() { // Baseline: when the parent runtime IS auto-approved, every approval @@ -14398,6 +15093,7 @@ async fn auto_approved_parent_runs_required_tools_in_subagent() { .await .expect("auto-approved parent should allow writes"); } + #[test] fn subagent_request_budget_inherits_the_resolved_route_allowance() { assert_eq!( @@ -14406,6 +15102,7 @@ fn subagent_request_budget_inherits_the_resolved_route_allowance() { "sub-agents must not impose a smaller internal output ceiling" ); } + #[test] fn incomplete_response_failure_keeps_the_provider_stop_cause() { let response = MessageResponse { @@ -14425,6 +15122,7 @@ fn incomplete_response_failure_keeps_the_provider_stop_cause() { assert!(failure.contains("`content_filter`"), "{failure}"); assert!(!failure.contains("budget exhausted"), "{failure}"); } + #[test] fn child_cancellation_cascades_from_parent() { let parent = stub_runtime(); @@ -14436,6 +15134,7 @@ fn child_cancellation_cascades_from_parent() { "parent cancel() must propagate to child via child_token()" ); } + #[test] fn detached_background_children_survive_parent_cancellation() { let parent = stub_runtime(); @@ -14449,6 +15148,7 @@ fn detached_background_children_survive_parent_cancellation() { "parent stop must leave every detached child running until explicitly cancelled" ); } + #[test] fn agent_start_is_turn_owned_unless_detached_is_explicit() { let owned = parse_spawn_request(&json!({"prompt": "inspect the workspace"})) @@ -14468,6 +15168,7 @@ fn agent_start_is_turn_owned_unless_detached_is_explicit() { "only an explicit detached=true may outlive the turn" ); } + #[test] fn agent_start_marks_only_explicit_detachment_as_durable_work() { let runtime = stub_runtime(); @@ -14485,6 +15186,7 @@ fn agent_start_marks_only_explicit_detachment_as_durable_work() { "only detached=true may opt into durable background scheduling" ); } + #[tokio::test] async fn foreground_turn_cancellation_joins_direct_children_once_and_excludes_detached() { let registry = Arc::new(ForegroundChildRegistry::new()); @@ -14541,6 +15243,7 @@ async fn foreground_turn_cancellation_joins_direct_children_once_and_excludes_de assert!(late_token.is_cancelled()); assert_eq!(registry.active_count(), 0); } + #[tokio::test] async fn foreground_registration_releases_when_the_child_future_returns_or_unwinds() { let registry = Arc::new(ForegroundChildRegistry::new()); @@ -14571,6 +15274,7 @@ async fn foreground_registration_releases_when_the_child_future_returns_or_unwin .await .expect("return and panic-unwind both release foreground ownership"); } + #[tokio::test] async fn turn_owned_descendants_join_and_park_with_the_same_foreground_registry() { let registry = Arc::new(ForegroundChildRegistry::new()); @@ -14640,6 +15344,7 @@ async fn turn_owned_descendants_join_and_park_with_the_same_foreground_registry( ); assert!(!detached.cancel_token.is_cancelled()); } + #[test] fn derived_child_cancellation_reaches_tool_context_while_detached_stays_independent() { let root = stub_runtime(); @@ -14680,6 +15385,7 @@ fn derived_child_cancellation_reaches_tool_context_while_detached_stays_independ .is_some_and(CancellationToken::is_cancelled) ); } + #[tokio::test] async fn turn_end_parking_preserves_a_step_zero_resumable_checkpoint() { let tmp = tempdir().expect("tempdir"); @@ -14752,6 +15458,7 @@ async fn turn_end_parking_preserves_a_step_zero_resumable_checkpoint() { assert!(explicit_needs_input.is_none()); assert_eq!(explicit_worker_status, AgentWorkerStatus::Cancelled); } + #[test] fn turn_end_parking_wins_over_the_final_child_step_limit() { let agent_id = "agent_turn_end_final_step"; @@ -14778,6 +15485,7 @@ fn turn_end_parking_wins_over_the_final_child_step_limit() { assert!(needs_input.is_some()); assert_eq!(worker_status, AgentWorkerStatus::Interrupted); } + #[test] fn mailbox_propagates_through_child_runtime_chain() { use crate::tools::subagent::mailbox::Mailbox; @@ -14797,6 +15505,7 @@ fn mailbox_propagates_through_child_runtime_chain() { "grandchild inherits via the cloned Arc inside Mailbox" ); } + #[test] fn subagent_rejects_interactive_shell_terminal_takeover() { let err = reject_subagent_terminal_takeover( @@ -14839,6 +15548,7 @@ fn subagent_rejects_interactive_shell_terminal_takeover() { ) .expect("background shell remains allowed"); } + #[tokio::test] async fn mailbox_close_as_cancel_propagates_to_grandchild_runtime() { use crate::tools::subagent::mailbox::Mailbox; @@ -14863,6 +15573,7 @@ async fn mailbox_close_as_cancel_propagates_to_grandchild_runtime() { "close-as-cancel must propagate across max_spawn_depth=3" ); } + #[tokio::test] async fn mailbox_orders_messages_from_parent_and_child_runtimes() { use crate::tools::subagent::mailbox::{Mailbox, MailboxMessage}; @@ -14914,6 +15625,7 @@ async fn mailbox_orders_messages_from_parent_and_child_runtimes() { other => panic!("unexpected message order: {other:?}"), } } + #[test] fn persisted_empty_allowed_tools_loads_as_full_inheritance() { // Backward-compat: a v0.6.5 session that persisted with an empty Vec @@ -14946,6 +15658,7 @@ fn persisted_empty_allowed_tools_loads_as_full_inheritance() { "empty Vec on disk → None (full inheritance)" ); } + #[test] fn persisted_non_empty_allowed_tools_loads_as_narrow() { // Backward-compat the other way: a v0.6.5 session that persisted with @@ -14978,6 +15691,7 @@ fn persisted_non_empty_allowed_tools_loads_as_narrow() { "non-empty Vec → Some(list), narrow scope preserved" ); } + #[test] fn persisted_advisory_assignment_roles_replay_and_repersist_as_consultant() { for alias in ["oracle", "advisor"] { @@ -15025,6 +15739,7 @@ fn persisted_advisory_assignment_roles_replay_and_repersist_as_consultant() { assert_eq!(repersisted["agents"][0]["agent_type"], json!("advisor")); } } + /// Build a minimal `SubAgentRuntime` for tests that exercise pure runtime /// helpers (depth, cancellation, child_runtime). Doesn't construct a real /// HTTP client — calls that hit `runtime.client` would fail, but the @@ -15090,6 +15805,7 @@ pub(crate) fn stub_runtime() -> SubAgentRuntime { ask_on_guardrail: false, } } + #[test] fn root_operate_dispatch_delegates_file_edits_without_bypassing_required_tools() { let mut runtime = stub_runtime(); @@ -15107,6 +15823,7 @@ fn root_operate_dispatch_delegates_file_edits_without_bypassing_required_tools() "Operate dispatch must not silently grant Required tools such as shell" ); } + #[tokio::test] async fn root_operate_dispatch_delegates_builtin_verification_but_not_shell() { let tmp = tempdir().expect("tempdir"); @@ -15184,6 +15901,7 @@ async fn root_operate_dispatch_delegates_builtin_verification_but_not_shell() { "Operate verification delegation must not propagate past the direct worker" ); } + #[test] fn worker_lifecycle_records_direct_operate_approval_without_delegating_authority() { let todos = crate::tools::todo::new_shared_todo_list(); @@ -15282,6 +16000,7 @@ fn worker_lifecycle_records_direct_operate_approval_without_delegating_authority "nested workers must not inherit Operate approval authority" ); } + /// A minimal stub client. Test helpers below only ever check struct fields /// (depth, cancel_token, context); they don't call the network. We need a /// *some* `DeepSeekClient` because `SubAgentRuntime.client` isn't @@ -15292,6 +16011,7 @@ fn stub_runtime_for_provider(provider: &str) -> SubAgentRuntime { runtime.client = stub_client_for_provider(provider); runtime } + fn stub_client_for_provider(provider: &str) -> DeepSeekClient { let _ = rustls::crypto::ring::default_provider().install_default(); let mut providers = crate::config::ProvidersConfig::default(); @@ -15340,6 +16060,7 @@ fn stub_client_for_provider(provider: &str) -> DeepSeekClient { }; DeepSeekClient::new(&config).expect("stub client should construct") } + fn stub_client() -> DeepSeekClient { let _ = rustls::crypto::ring::default_provider().install_default(); let config = crate::config::Config { @@ -15354,6 +16075,7 @@ fn stub_client() -> DeepSeekClient { // There are no provider pins outside the durable Fleet runs: every child runs // on the parent's provider. Child and background runtimes clone the session // client and config unchanged — no cross-provider build, no misroute. + /// A session runtime on `deepseek` with the cross-provider `Config` threaded in, /// exactly as the engine wires it via `with_api_config`. fn cross_provider_runtime() -> SubAgentRuntime { @@ -15381,6 +16103,7 @@ fn cross_provider_runtime() -> SubAgentRuntime { runtime.client = client; runtime } + #[test] fn spawn_child_runtime_inherits_session_client_and_config() { let runtime = cross_provider_runtime(); @@ -15423,6 +16146,7 @@ fn spawn_child_runtime_inherits_session_client_and_config() { // carry the prior boot id and are classified as `from_prior_session`. // Listings default to current-session only; `include_archived=true` surfaces // the prior-session records with the flag set. + fn insert_prior_session_agent( manager: &mut SubAgentManager, id: &str, @@ -15468,6 +16192,7 @@ fn insert_prior_session_agent( } manager.agents.insert(id.to_string(), agent); } + fn assign_test_session_owner( manager: &mut SubAgentManager, agent_id: &str, @@ -15482,12 +16207,14 @@ fn assign_test_session_owner( record.owner_session_id = owner_session_id.to_string(); } } + #[test] fn session_boot_ids_are_unique_per_manager() { let a = SubAgentManager::new(PathBuf::from("."), 1); let b = SubAgentManager::new(PathBuf::from("."), 1); assert_ne!(a.session_boot_id(), b.session_boot_id()); } + #[test] fn terminal_synthesis_is_scoped_to_owner_and_keeps_delivery_history() { let mut manager = SubAgentManager::new(PathBuf::from("."), 5); @@ -15530,6 +16257,7 @@ fn terminal_synthesis_is_scoped_to_owner_and_keeps_delivery_history() { "returning A -> B -> A must not re-synthesize an already delivered terminal result" ); } + #[test] fn active_session_owns_every_roster_and_control_resolution() { let workspace = tempdir().expect("tempdir"); @@ -15645,6 +16373,7 @@ fn active_session_owns_every_roster_and_control_resolution() { "A -> B -> A restores A's archived control row" ); } + #[test] fn forkguard_cancel_all_running_is_session_scoped_and_idempotent() { let workspace = tempdir().expect("tempdir"); @@ -15702,6 +16431,7 @@ fn forkguard_cancel_all_running_is_session_scoped_and_idempotent() { "idempotent cleanup may not append another terminal event" ); } + #[test] fn ownerless_legacy_rows_fail_closed_and_scoped_close_preserves_foreign_workers() { let workspace = tempdir().expect("tempdir"); @@ -15753,6 +16483,7 @@ fn ownerless_legacy_rows_fail_closed_and_scoped_close_preserves_foreign_workers( .is_terminal() ); } + #[test] fn scoped_handle_eviction_drain_preserves_foreign_pending_ids() { let mut manager = SubAgentManager::new(PathBuf::from("."), 2); @@ -15770,6 +16501,7 @@ fn scoped_handle_eviction_drain_preserves_foreign_pending_ids() { vec!["agent-b".to_string()] ); } + #[test] fn coordination_records_are_visible_and_mutable_only_to_their_owner_session() { let mut manager = SubAgentManager::new(PathBuf::from("."), 2); @@ -15800,6 +16532,7 @@ fn coordination_records_are_visible_and_mutable_only_to_their_owner_session() { "B must not gain mutation authority over A's decision" ); } + #[test] fn list_filtered_drops_prior_session_terminals_by_default() { let mut manager = SubAgentManager::new(PathBuf::from("."), 5); @@ -15846,6 +16579,7 @@ fn list_filtered_drops_prior_session_terminals_by_default() { .unwrap(); assert!(!current.from_prior_session); } + #[test] fn list_snapshots_refresh_git_branch_from_agent_workspace() { let repo = init_subagent_git_repo(); @@ -15877,6 +16611,7 @@ fn list_snapshots_refresh_git_branch_from_agent_workspace() { .expect("current agent should still be listed"); assert_eq!(agent.git_branch.as_deref(), Some("feature/agent-new")); } + #[test] fn list_filtered_with_include_archived_returns_everything() { let mut manager = SubAgentManager::new(PathBuf::from("."), 5); @@ -15910,6 +16645,7 @@ fn list_filtered_with_include_archived_returns_everything() { .unwrap(); assert!(!current.from_prior_session); } + #[test] fn agents_with_empty_boot_id_classify_as_prior_session() { // Records persisted before #405 land with an empty `session_boot_id` @@ -15931,6 +16667,7 @@ fn agents_with_empty_boot_id_classify_as_prior_session() { .unwrap(); assert!(legacy.from_prior_session); } + #[test] fn persist_round_trip_preserves_session_and_boot_ownership() { let dir = tempdir().expect("tempdir"); @@ -16012,6 +16749,7 @@ fn persist_round_trip_preserves_session_and_boot_ownership() { // loop drains that channel; for nested agents the running parent sub-agent // owns a local receiver and injects the completion into its own transcript. // These tests cover the routing logic and no-channel safety. + fn runtime_with_depth( spawn_depth: u32, parent_completion_tx: Option>, @@ -16021,6 +16759,7 @@ fn runtime_with_depth( rt.parent_completion_tx = parent_completion_tx; rt } + #[test] fn emit_parent_completion_fires_for_direct_child() { let (tx, mut rx) = mpsc::unbounded_channel::(); @@ -16035,6 +16774,7 @@ fn emit_parent_completion_fires_for_direct_child() { assert_eq!(received.payload, "summary line\n"); assert!(rx.try_recv().is_err(), "should be exactly one message"); } + #[test] fn child_runtime_inherits_speech_output_dir() { let output_dir = PathBuf::from("configured-speech-output"); @@ -16048,6 +16788,7 @@ fn child_runtime_inherits_speech_output_dir() { Some(PathBuf::from("configured-speech-output")) ); } + #[test] fn emit_parent_completion_fires_for_nested_child() { let (tx, mut rx) = mpsc::unbounded_channel::(); @@ -16061,6 +16802,7 @@ fn emit_parent_completion_fires_for_nested_child() { assert_eq!(received.agent_id, "agent_grandchild"); assert_eq!(received.payload, "nested summary"); } + #[test] fn emit_parent_completion_skips_engine_self() { // depth 0 is the engine itself — the engine never spawns a task at @@ -16076,6 +16818,7 @@ fn emit_parent_completion_skips_engine_self() { ); assert!(rx.try_recv().is_err()); } + #[test] fn emit_parent_completion_no_channel_is_noop() { let runtime = runtime_with_depth(1, None); @@ -16087,6 +16830,7 @@ fn emit_parent_completion_no_channel_is_noop() { "missing channel should be a silent no-op, not a panic" ); } + #[test] fn emit_parent_completion_dropped_receiver_does_not_panic() { let (tx, rx) = mpsc::unbounded_channel::(); @@ -16103,6 +16847,7 @@ fn emit_parent_completion_dropped_receiver_does_not_panic() { "we still attempt the send; the engine being gone is not our problem" ); } + #[test] fn terminal_results_excluding_returns_only_current_root_undelivered_agents() { let tmp = tempdir().expect("tempdir"); @@ -16177,6 +16922,7 @@ fn terminal_results_excluding_returns_only_current_root_undelivered_agents() { let delivered = HashSet::from(["agent_root_done".to_string()]); assert!(manager.terminal_results_excluding(&delivered).is_empty()); } + #[tokio::test] async fn run_subagent_task_claims_before_delivery_and_then_finalizes() { let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 2))); @@ -16254,6 +17000,7 @@ async fn run_subagent_task_claims_before_delivery_and_then_finalizes() { snapshot.status ); } + #[tokio::test] async fn cancellation_wins_task_race_but_still_fans_in_exactly_once() { use tokio_util::sync::CancellationToken; @@ -16375,6 +17122,7 @@ async fn cancellation_wins_task_race_but_still_fans_in_exactly_once() { if id == &snapshot.agent_id && result.contains(r#""status":"cancelled""#) )); } + /// Call 1 answers with a tool call (so the child banks a real step); /// every later call fails with a non-retryable 400. async fn tool_call_then_invalid_request_chat_client() -> (DeepSeekClient, Arc) { @@ -16453,6 +17201,7 @@ async fn tool_call_then_invalid_request_chat_client() -> (DeepSeekClient, Arc(); @@ -16803,6 +17557,7 @@ fn nested_tool_runtime_routes_child_completions_to_local_inbox() { "root engine must not receive nested child completion directly" ); } + #[test] fn subagent_completion_from_result_surfaces_step_limit_not_silent_success() { let snap = make_snapshot(SubAgentStatus::Failed( @@ -16816,6 +17571,7 @@ fn subagent_completion_from_result_surfaces_step_limit_not_silent_success() { assert!(completion.payload.contains("max_steps"), "{completion:?}"); assert!(!completion.payload.contains("Completed (no output)")); } + #[test] fn subagent_completion_from_result_preserves_missing_final_summary_diagnostic() { let snap = make_snapshot(SubAgentStatus::Completed); @@ -16825,6 +17581,7 @@ fn subagent_completion_from_result_preserves_missing_final_summary_diagnostic() "{completion:?}" ); } + #[test] fn subagent_budget_exhaustion_completion_carries_budget_exhausted_sentinel() { let mut snap = make_snapshot(SubAgentStatus::BudgetExhausted); @@ -16847,6 +17604,7 @@ fn subagent_budget_exhaustion_completion_carries_budget_exhausted_sentinel() { assert_eq!(parsed["failure_class"], "token_budget"); assert_eq!(parsed["error_location"], "previous_line"); } + #[test] fn subagent_completion_inlines_evidence_before_sentinel() { let mut snap = make_snapshot(SubAgentStatus::Completed); @@ -16868,6 +17626,7 @@ fn subagent_completion_inlines_evidence_before_sentinel() { "summary before evidence" ); } + #[test] fn subagent_completion_skips_empty_evidence_on_failed_child() { let mut snap = make_snapshot(SubAgentStatus::Failed("boom".to_string())); @@ -16875,6 +17634,7 @@ fn subagent_completion_skips_empty_evidence_on_failed_child() { let completion = subagent_completion_from_result(&snap); assert!(!completion.payload.contains("### EVIDENCE")); } + #[test] fn child_completion_runtime_message_preserves_agent_and_provenance_guidance() { let message = child_completion_runtime_message(&[SubAgentCompletion { @@ -16892,6 +17652,7 @@ fn child_completion_runtime_message_preserves_agent_and_provenance_guidance() { assert!(text.contains("cite the child agent_id and the EVIDENCE lines")); assert!(text.contains("src/lib.rs:1-3")); } + #[test] fn subagent_runtime_default_step_api_timeout_matches_config_default() { // The runtime default is derived from the config default so call sites @@ -16908,11 +17669,13 @@ fn subagent_runtime_default_step_api_timeout_matches_config_default() { std::time::Duration::from_secs(600) ); } + #[test] fn with_step_api_timeout_overrides_runtime_field() { let runtime = stub_runtime().with_step_api_timeout(std::time::Duration::from_secs(900)); assert_eq!(runtime.step_api_timeout.as_secs(), 900); } + #[test] fn tool_timeout_defaults_to_generous_budget_and_survives_spawn() { // Track A raised the per-tool timeout from the old 30s (which killed long @@ -16927,6 +17690,7 @@ fn tool_timeout_defaults_to_generous_budget_and_survives_spawn() { assert_eq!(parent.child_runtime().tool_timeout, expected); assert_eq!(parent.background_runtime().tool_timeout, expected); } + #[test] fn child_runtime_preserves_step_api_timeout() { // Real sub-agents spawn through `child_runtime()` / `background_runtime()`; @@ -16947,6 +17711,7 @@ fn child_runtime_preserves_step_api_timeout() { "background_runtime (detached) must also preserve the parent's timeout" ); } + #[test] fn subagent_completion_payload_carries_existing_sentinel_format() { // The payload format is the same one already documented in @@ -16982,6 +17747,7 @@ fn subagent_completion_payload_carries_existing_sentinel_format() { "sentinel should not duplicate the human summary line" ); } + /// #2683 — Verify the model-facing tool catalog only advertises canonical /// subagent tools and never exposes legacy superseded names. #[test] @@ -17013,6 +17779,7 @@ fn model_catalog_only_advertises_canonical_subagent_tools() { } // ── #3018: provider-aware auto routing and model validation ───────────────── + #[tokio::test] async fn faster_route_on_provider_without_known_sibling_stays_on_parent_model() { // AC: Ollama must never build a request with a DeepSeek id; even when the @@ -17038,6 +17805,7 @@ async fn faster_route_on_provider_without_known_sibling_stays_on_parent_model() ); } } + #[test] fn faster_route_uses_known_deepseek_and_glm_family_siblings() { let mut deepseek = stub_runtime(); @@ -17077,6 +17845,7 @@ fn faster_route_uses_known_deepseek_and_glm_family_siblings() { assert_eq!(route.model, "z-ai/glm-5-turbo"); assert_ne!(route.model, "z-ai/glm-5.1"); } + #[test] fn inherit_route_remaps_stale_deepseek_model_for_sakana_provider() { let mut runtime = stub_runtime_for_provider("sakana"); @@ -17099,6 +17868,7 @@ fn inherit_route_remaps_stale_deepseek_model_for_sakana_provider() { "Sakana inherit must not keep DeepSeek ids: {validated}" ); } + #[test] fn faster_route_remaps_stale_deepseek_model_for_sakana_provider() { let mut runtime = stub_runtime_for_provider("sakana"); @@ -17115,6 +17885,7 @@ fn faster_route_remaps_stale_deepseek_model_for_sakana_provider() { .expect("faster should remap to operator route"); assert_eq!(validated, crate::config::DEFAULT_SAKANA_MODEL); } + #[test] fn fixed_route_rejects_deepseek_model_for_sakana_provider() { let runtime = stub_runtime_for_provider("sakana"); @@ -17129,6 +17900,7 @@ fn fixed_route_rejects_deepseek_model_for_sakana_provider() { "error should name the model: {err}" ); } + #[test] fn normalize_requested_subagent_model_rejects_cross_namespace_for_sakana() { let err = normalize_requested_subagent_model( @@ -17142,6 +17914,7 @@ fn normalize_requested_subagent_model_rejects_cross_namespace_for_sakana() { "error should name the model: {err}" ); } + #[test] fn gpt55_faster_route_stays_on_gpt55_with_low_reasoning() { // AC: a faster/explore child of a GPT-5.5 (OpenAI Codex) parent must stay @@ -17185,6 +17958,7 @@ fn gpt55_faster_route_stays_on_gpt55_with_low_reasoning() { assert_eq!(route.reasoning_effort.as_deref(), Some("low")); assert_ne!(route.reasoning_effort.as_deref(), Some("off")); } + #[test] fn role_model_validation_accepts_provider_native_ids() { // AC: [subagents] worker_model = "kimi-k2.5" on Moonshot must not fail @@ -17198,6 +17972,7 @@ fn role_model_validation_accepts_provider_native_ids() { .expect("provider-native id is accepted"); assert_eq!(model.as_deref(), Some("kimi-k2.5")); } + #[test] fn consultant_reads_released_advisory_role_model_override_keys() { for legacy_key in ["oracle", "consultant"] { @@ -17211,6 +17986,7 @@ fn consultant_reads_released_advisory_role_model_override_keys() { assert_eq!(model.as_deref(), Some("deepseek-v4-flash"), "{legacy_key}"); } } + #[test] fn canonical_consultant_model_override_precedes_compatibility_keys() { let mut runtime = stub_runtime(); @@ -17228,6 +18004,7 @@ fn canonical_consultant_model_override_precedes_compatibility_keys() { .expect("canonical consultant override should resolve"); assert_eq!(model.as_deref(), Some("deepseek-v4-pro")); } + #[test] fn raw_advisory_role_prefers_canonical_consultant_model_override() { for alias in ["oracle", "consultant"] { @@ -17245,6 +18022,7 @@ fn raw_advisory_role_prefers_canonical_consultant_model_override() { assert_eq!(model.as_deref(), Some("deepseek-v4-pro"), "alias={alias}"); } } + #[test] fn role_model_validation_stays_strict_on_official_deepseek() { let mut runtime = stub_runtime(); @@ -17261,6 +18039,7 @@ fn role_model_validation_stays_strict_on_official_deepseek() { "lists accepted ids from model_completion_names_for_provider: {msg}" ); } + #[test] fn operator_model_for_subagent_enumerates_from_catalog_facade() { // #4116: the operator-route fallback must source its model from the @@ -17305,6 +18084,7 @@ fn operator_model_for_subagent_enumerates_from_catalog_facade() { ); } } + #[test] fn normalize_requested_subagent_model_is_provider_aware() { assert_eq!( @@ -17337,15 +18117,18 @@ fn normalize_requested_subagent_model_is_provider_aware() { } // ── #3030: step-counter formatting ────────────────────────────────────────── + #[test] fn format_step_counter_hides_unbounded_sentinel() { assert_eq!(format_step_counter(16, 0), "step 16"); } + #[test] fn format_step_counter_keeps_concrete_budgets() { assert_eq!(format_step_counter(3, 25), "step 3/25"); assert_eq!(format_step_counter(0, 1), "step 0/1"); } + #[test] fn child_step_override_wins_and_clamps_to_hard_ceiling() { assert_eq!(resolve_max_steps(FleetRole::Scout, None, None), 0); @@ -17365,6 +18148,7 @@ fn child_step_override_wins_and_clamps_to_hard_ceiling() { MAX_SUBAGENT_STEPS ); } + #[test] fn child_wall_timeout_reason_is_typed_and_actionable() { let reason = child_wall_time_exhausted_reason(Duration::from_millis(1)); @@ -17376,6 +18160,7 @@ fn child_wall_timeout_reason_is_typed_and_actionable() { } // ── #3095: sub-agent launch gate ───────────────────────────────────────────── + #[test] fn launch_gate_defaults_to_launch_concurrency_capped_by_max_agents() { let tmp = tempdir().expect("tempdir"); @@ -17396,6 +18181,7 @@ fn launch_gate_defaults_to_launch_concurrency_capped_by_max_agents() { "clamps down to max_agents" ); } + #[tokio::test] async fn launch_gate_queues_extra_direct_children() { use tokio_util::sync::CancellationToken; @@ -17543,6 +18329,7 @@ async fn launch_gate_queues_extra_direct_children() { "queued child must start only after queuing, then complete: {messages:?}" ); } + #[tokio::test] async fn queued_turn_owned_child_parks_without_a_false_start_transition() { use tokio_util::sync::CancellationToken; @@ -17695,6 +18482,7 @@ async fn queued_turn_owned_child_parks_without_a_false_start_transition() { drop(manager); drop(held_launch_permit); } + #[tokio::test] async fn launch_gate_wait_counts_against_child_wall_timeout() { use tokio_util::sync::CancellationToken; @@ -17820,6 +18608,7 @@ async fn launch_gate_wait_counts_against_child_wall_timeout() { drop(manager); drop(held_launch_permit); } + /// Stub chat server that always replies with a final assistant text whose /// `usage` reports the given token counts. Returns the client plus a call /// counter so tests can assert how many model turns ran before a budget cap @@ -17880,6 +18669,7 @@ async fn token_heavy_chat_client( let client = DeepSeekClient::new(&config).expect("fake chat client"); (client, calls) } + /// First response carries partial text plus a tool call and the requested /// incomplete/output-limit reason. A second request, when the runtime is /// allowed to retry, completes normally. @@ -17955,6 +18745,7 @@ async fn incomplete_then_complete_chat_client( let client = DeepSeekClient::new(&config).expect("fake incomplete-response client"); (client, calls) } + async fn run_incomplete_response_worker( workspace: &Path, stop_reason: &str, @@ -18038,6 +18829,7 @@ async fn run_incomplete_response_worker( }; (result, calls, mailbox_messages, total_tokens) } + fn assert_partial_tool_was_not_executed(messages: &[MailboxMessage]) { assert!( !messages @@ -18046,6 +18838,7 @@ fn assert_partial_tool_was_not_executed(messages: &[MailboxMessage]) { "an incomplete response must never dispatch its partial tool call: {messages:?}" ); } + #[tokio::test] async fn output_limit_on_last_step_preserves_partial_text_and_exact_cause() { let tmp = tempdir().expect("tempdir"); @@ -18080,6 +18873,7 @@ async fn output_limit_on_last_step_preserves_partial_text_and_exact_cause() { "the partial assistant text must remain in the transcript checkpoint" ); } + #[tokio::test] async fn output_limit_cause_wins_over_generic_token_budget() { let tmp = tempdir().expect("tempdir"); @@ -18103,6 +18897,7 @@ async fn output_limit_cause_wins_over_generic_token_budget() { assert_eq!(total_tokens, Some(15), "usage must still be recorded"); assert_partial_tool_was_not_executed(&mailbox); } + #[tokio::test] async fn non_output_incomplete_cause_wins_over_generic_token_budget() { let tmp = tempdir().expect("tempdir"); @@ -18123,6 +18918,7 @@ async fn non_output_incomplete_cause_wins_over_generic_token_budget() { assert_eq!(total_tokens, Some(15), "usage must still be recorded"); assert_partial_tool_was_not_executed(&mailbox); } + #[tokio::test] async fn output_limit_never_retries_or_executes_partial_tools() { let tmp = tempdir().expect("tempdir"); @@ -18142,6 +18938,7 @@ async fn output_limit_never_retries_or_executes_partial_tools() { assert_eq!(total_tokens, Some(15)); assert_partial_tool_was_not_executed(&mailbox); } + /// Shared scaffolding for the per-worker token-budget runtime tests: spins up /// a general worker against `token_heavy_chat_client` with the given cap and /// returns the manager, agent id, call counter, and spawned task handle. @@ -18209,6 +19006,7 @@ async fn spawn_budget_capped_worker( let task_handle = tokio::spawn(run_subagent_task(task)); (manager, agent_id, calls, task_handle) } + #[tokio::test] async fn worker_stops_with_typed_wall_time_reason() { let tmp = tempdir().expect("tempdir"); @@ -18234,6 +19032,7 @@ async fn worker_stops_with_typed_wall_time_reason() { other => panic!("expected typed wall-time failure, got {other:?}"), } } + #[tokio::test] async fn worker_stops_when_per_worker_token_budget_exceeded() { let tmp = tempdir().expect("tempdir"); @@ -18264,6 +19063,7 @@ async fn worker_stops_when_per_worker_token_budget_exceeded() { result.status ); } + #[tokio::test] async fn worker_without_per_worker_token_budget_runs_to_completion() { let tmp = tempdir().expect("tempdir"); @@ -18289,6 +19089,7 @@ async fn worker_without_per_worker_token_budget_runs_to_completion() { result.status ); } + #[tokio::test] async fn per_worker_token_budget_does_not_double_count_scope_accounting() { let tmp = tempdir().expect("tempdir"); @@ -18326,6 +19127,7 @@ async fn per_worker_token_budget_does_not_double_count_scope_accounting() { worker_record.usage ); } + /// Variant of [`spawn_budget_capped_worker`] that attaches the worker to a /// shared workflow budget scope before its first model turn (no per-worker /// cap), returning the manager, agent id, call counter, and task handle. @@ -18391,6 +19193,7 @@ async fn spawn_scope_budgeted_worker( let task_handle = tokio::spawn(run_subagent_task(task)); (calls, task_handle) } + #[tokio::test] async fn shared_scope_budget_stops_admitted_children_mid_run() { // A workflow run's token_budget must be a collective ceiling for the @@ -18476,14 +19279,17 @@ async fn shared_scope_budget_stops_admitted_children_mid_run() { "collective spend is accounted once per child" ); } + /// Clears the process-wide rate-limit window on drop so a panicking test /// body cannot leak a live pause into concurrently running tests. struct ClearRateLimitOnDrop; + impl Drop for ClearRateLimitOnDrop { fn drop(&mut self) { crate::retry_status::clear_rate_limit(); } } + #[tokio::test] #[allow(clippy::await_holding_lock)] async fn worker_is_not_stranded_by_transient_global_rate_limit_window() { @@ -18524,6 +19330,7 @@ async fn worker_is_not_stranded_by_transient_global_rate_limit_window() { result.status ); } + /// #4217: terminal worker records must age out of the persisted ledger so /// long-lived sessions do not rewrite multi-MB `subagents.v1.json` forever. #[test] @@ -18605,6 +19412,7 @@ fn cleanup_evicts_stale_terminal_worker_records_and_keeps_live_ones() { assert!(reloaded.get_worker_record("agent_recent_done").is_some()); assert!(reloaded.get_worker_record("agent_still_running").is_some()); } + #[test] fn cleanup_removes_complete_transcript_after_worker_retention_expires() { let tmp = tempdir().expect("tempdir"); @@ -18633,6 +19441,7 @@ fn cleanup_removes_complete_transcript_after_worker_retention_expires() { "artifact must share the terminal worker retention lifecycle" ); } + #[test] fn cleanup_due_gates_write_locked_cleanup_to_a_bounded_cadence() { // #3803: a fresh manager is always due (never cleaned); right after a @@ -18659,6 +19468,7 @@ fn cleanup_due_gates_write_locked_cleanup_to_a_bounded_cadence() { } // ── #3882: bounded sub-agent output under Fleet fanout ───────────────────── + /// Serialize-and-restore guard for the shared spillover test root, mirroring /// the pattern in `tools::truncate::tests`. fn with_spillover_root(root: &std::path::Path, f: F) { @@ -18681,6 +19491,7 @@ fn with_spillover_root(root: &std::path::Path, f: F) { let _restore = Restore(prior, prior_artifacts); f(); } + #[test] fn bounded_tail_messages_keeps_recent_within_budget_and_counts_omitted() { let messages: Vec = (0..10) @@ -18704,6 +19515,7 @@ fn bounded_tail_messages_keeps_recent_within_budget_and_counts_omitted() { "kept tail exceeds budget by more than one message: {total}" ); } + #[test] fn bounded_tail_messages_always_keeps_the_final_message() { let messages = vec![ @@ -18721,6 +19533,7 @@ fn bounded_tail_messages_always_keeps_the_final_message() { assert_eq!(omitted, 1); assert!(message_text(&kept[0]).starts_with('b')); } + #[tokio::test] async fn complete_transcript_artifact_survives_resident_handle_compaction() { let tmp = tempdir().expect("tempdir"); @@ -18792,6 +19605,7 @@ async fn complete_transcript_artifact_survives_resident_handle_compaction() { ); } } + #[test] fn malformed_transcript_artifact_fails_closed_instead_of_showing_partial_chat() { let tmp = tempdir().expect("tempdir"); @@ -18813,6 +19627,7 @@ fn malformed_transcript_artifact_fails_closed_instead_of_showing_partial_chat() .expect_err("a malformed stream must not masquerade as a complete chat"); assert!(error.to_string().contains("line"), "{error:#}"); } + #[cfg(unix)] #[test] fn transcript_artifact_reader_rejects_symlink_replacement() { @@ -18835,6 +19650,7 @@ fn transcript_artifact_reader_rejects_symlink_replacement() { .expect_err("transcript reader must reject symlink replacement"); assert!(error.to_string().contains("must not traverse symlinks")); } + #[test] fn checkpoints_are_byte_bounded_under_fanout_scale_output() { // Simulates the #3882 report shape: a worker whose tool results are @@ -18865,6 +19681,7 @@ fn checkpoints_are_byte_bounded_under_fanout_scale_output() { serialized.len() ); } + #[test] fn checkpoint_without_omitted_field_still_deserializes() { // Records persisted before v0.8.67 carry no omitted_messages key. @@ -18882,6 +19699,7 @@ fn checkpoint_without_omitted_field_still_deserializes() { serde_json::from_str(legacy).expect("legacy checkpoint should load"); assert_eq!(checkpoint.omitted_messages, 0); } + #[test] fn forkguard_subagent_classic_hint_names_the_actual_storage_call() { use crate::tools::large_output_router::WorkshopConfig; @@ -19048,6 +19866,7 @@ fn subagent_tool_results_spill_to_disk_and_stay_bounded_inline() { ); }); } + #[test] fn fanout_of_workers_with_huge_outputs_keeps_resident_state_bounded() { // Acceptance shape for #3882: multiple workers, each emitting multi-MB @@ -19096,6 +19915,7 @@ fn fanout_of_workers_with_huge_outputs_keeps_resident_state_bounded() { ); }); } + #[test] fn write_json_atomic_survives_concurrent_writers() { use std::sync::Arc; @@ -19136,6 +19956,7 @@ fn write_json_atomic_survives_concurrent_writers() { .collect(); assert!(leftover.is_empty(), "temp files leaked: {leftover:?}"); } + #[test] fn coordination_process_lock_rejects_second_process() { const ROLE_ENV: &str = "CODEWHALE_TEST_COORDINATION_LOCK_ROLE"; @@ -19228,6 +20049,7 @@ fn coordination_process_lock_rejects_second_process() { } // === agent(action="wait") + peek throttling (#4097) === + fn insert_running_agent(inner: &mut SubAgentManager, name: &str) -> String { let current_boot = inner.session_boot_id().to_string(); let (input_tx, _input_rx) = mpsc::unbounded_channel(); @@ -19251,6 +20073,7 @@ fn insert_running_agent(inner: &mut SubAgentManager, name: &str) -> String { inner.agents.insert(agent_id.clone(), agent); agent_id } + #[tokio::test] async fn agent_wait_returns_immediately_with_no_children() { let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 1))); @@ -19268,6 +20091,7 @@ async fn agent_wait_returns_immediately_with_no_children() { .is_empty() ); } + #[tokio::test] async fn agent_wait_wakes_when_child_settles() { let mut inner = SubAgentManager::new(PathBuf::from("."), 1); @@ -19305,6 +20129,7 @@ async fn agent_wait_wakes_when_child_settles() { assert_eq!(settled[0]["status"], json!("completed")); assert_eq!(payload["timed_out"], json!(false)); } + #[tokio::test] async fn agent_wait_times_out_and_reports_running_child() { let mut inner = SubAgentManager::new(PathBuf::from("."), 1); @@ -19330,6 +20155,7 @@ async fn agent_wait_times_out_and_reports_running_child() { .is_empty() ); } + #[tokio::test] async fn agent_wait_rejects_unknown_agent_ref() { let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 1))); @@ -19343,6 +20169,7 @@ async fn agent_wait_rejects_unknown_agent_ref() { .expect_err("unknown agent ref must fail fast instead of blocking"); assert!(matches!(err, ToolError::InvalidInput { .. })); } + #[tokio::test] async fn agent_peek_unchanged_within_window_returns_compact_nudge() { let mut inner = SubAgentManager::new(PathBuf::from("."), 1); @@ -19377,6 +20204,7 @@ async fn agent_peek_unchanged_within_window_returns_compact_nudge() { "nudge should point at agent(action=wait)" ); } + #[test] fn agent_action_parses_wait_aliases() { for alias in ["wait", "join", "await", "block"] { @@ -19401,6 +20229,7 @@ fn agent_action_parses_wait_aliases() { // Deny always wins over allow. Wildcards (`prefix*`) and case-insensitive // matching mirror the session-side `command_denies_tool()`. // =========================================================================== + /// Build a stub runtime with the parent's `disallowed_tools` set on the /// `WorkerRuntimeProfile`. The registry reads deny lists from the profile at /// construction, and `child_runtime()` clones the profile so the list @@ -19410,6 +20239,7 @@ fn stub_runtime_with_disallowed(disallowed: Vec) -> SubAgentRuntime { rt.worker_profile.denied_tools = disallowed; rt } + /// Build a `SubAgentToolRegistry` wired with `disallowed_tools`. Passes the /// runtime through `SubAgentToolRegistry::new()` so the constructor picks up /// `worker_profile.denied_tools`. `allowed_tools` is forwarded directly. @@ -19425,6 +20255,7 @@ fn new_registry_with_disallowed( Arc::new(Mutex::new(PlanState::default())), ) } + #[test] fn test_disallowed_tools_inheritance_denies_tool() { let tmp = tempdir().expect("tempdir"); @@ -19464,6 +20295,7 @@ fn test_disallowed_tools_inheritance_denies_tool() { "the lowercase edit primitive survives: only write_file was denied" ); } + #[test] fn test_disallowed_tools_deny_wins_over_allow() { let tmp = tempdir().expect("tempdir"); @@ -19496,6 +20328,7 @@ fn test_disallowed_tools_deny_wins_over_allow() { ); assert!(!names.contains("bash"), "denied shell alias removes bash"); } + #[test] fn test_disallowed_tools_wildcard_matching() { let tmp = tempdir().expect("tempdir"); @@ -19516,6 +20349,7 @@ fn test_disallowed_tools_wildcard_matching() { "non-MCP tools are unaffected by mcp_* deny" ); } + #[test] fn test_disallowed_tools_case_insensitive_match() { let tmp = tempdir().expect("tempdir"); @@ -19536,6 +20370,7 @@ fn test_disallowed_tools_case_insensitive_match() { "unrelated tool unaffected" ); } + #[test] fn test_disallowed_tools_specific_server_wildcard() { let tmp = tempdir().expect("tempdir"); @@ -19552,6 +20387,7 @@ fn test_disallowed_tools_specific_server_wildcard() { "different server prefix is not denied" ); } + #[test] fn test_disallowed_tools_tools_for_model_excludes_denied() { let tmp = tempdir().expect("tempdir"); @@ -19582,6 +20418,7 @@ fn test_disallowed_tools_tools_for_model_excludes_denied() { "the lowercase edit primitive survives: only write_file was denied" ); } + #[tokio::test] async fn test_disallowed_tools_execute_rejects_denied_tool() { let tmp = tempdir().expect("tempdir"); @@ -19605,6 +20442,7 @@ async fn test_disallowed_tools_execute_rejects_denied_tool() { } // === deny-list propagation through runtime cloning === + #[test] fn test_disallowed_tools_propagates_through_child_runtime() { let runtime = stub_runtime_with_disallowed(vec!["exec_shell".to_string()]); @@ -19615,6 +20453,7 @@ fn test_disallowed_tools_propagates_through_child_runtime() { "child_runtime() must preserve parent's denied_tools" ); } + #[test] fn test_disallowed_tools_propagates_through_background_runtime() { let runtime = stub_runtime_with_disallowed(vec!["write_file".to_string()]); @@ -19625,6 +20464,7 @@ fn test_disallowed_tools_propagates_through_background_runtime() { "background_runtime() must preserve parent's denied_tools" ); } + #[test] fn test_disallowed_tools_across_two_generations() { let tmp = tempdir().expect("tempdir"); @@ -19652,6 +20492,7 @@ fn test_disallowed_tools_across_two_generations() { } // === spawn-path opt-out simulation === + #[test] fn test_disallowed_tools_opt_out_clears_inherited_denies() { // Simulate the spawn-path merge: parent runtime has denies, child sets @@ -19680,6 +20521,7 @@ fn test_disallowed_tools_opt_out_clears_inherited_denies() { ); assert!(registry.is_tool_allowed("read_file")); } + #[test] fn test_disallowed_tools_opt_out_keeps_explicit_caller_deny() { // Opt-out clears inherited denies, but explicit caller disallowed_tools @@ -19713,6 +20555,7 @@ fn test_disallowed_tools_opt_out_keeps_explicit_caller_deny() { } // === parse_spawn_request disallowed_tools === + #[test] fn test_parse_spawn_request_reads_disallowed_tools() { let input = json!({ @@ -19725,6 +20568,7 @@ fn test_parse_spawn_request_reads_disallowed_tools() { Some(vec!["exec_shell".to_string(), "write_file".to_string()]) ); } + #[test] fn test_parse_spawn_request_disallowed_tools_dedupes_and_trims() { let input = json!({ @@ -19738,6 +20582,7 @@ fn test_parse_spawn_request_disallowed_tools_dedupes_and_trims() { "blanks and duplicates are dropped" ); } + #[test] fn test_parse_spawn_request_disallowed_tools_defaults_to_none() { let input = json!({"prompt": "do something"}); @@ -19747,6 +20592,7 @@ fn test_parse_spawn_request_disallowed_tools_defaults_to_none() { "disallowed_tools should be None when not provided" ); } + #[test] fn test_parse_spawn_request_inherit_disallowed_tools_defaults_true() { let input = json!({"prompt": "do something"}); @@ -19756,6 +20602,7 @@ fn test_parse_spawn_request_inherit_disallowed_tools_defaults_true() { "inherit_disallowed_tools should default to true" ); } + #[test] fn test_parse_spawn_request_inherit_disallowed_tools_explicit_false() { let input = json!({ @@ -19768,6 +20615,7 @@ fn test_parse_spawn_request_inherit_disallowed_tools_explicit_false() { "inherit_disallowed_tools should parse an explicit false" ); } + /// #3874 acceptance: the no-progress heartbeat must not kill a worker whose /// only pending work is a tracked *running* background shell task. /// @@ -19886,6 +20734,7 @@ async fn tracked_running_background_shell_keeps_its_owner_off_the_heartbeat_reap .expect("live task handle") .abort(); } + /// #4810: a child publishes its *own* list, only when it actually changes. #[tokio::test] async fn child_work_state_publishes_only_real_changes_from_its_own_list() { @@ -19957,6 +20806,7 @@ async fn child_work_state_publishes_only_real_changes_from_its_own_list() { // These assert on the registry the child actually runs with, not on the // `ChildAuthority` value that produced it. A ceiling that is only a label on a // receipt is not a ceiling. + /// `tools = false` must leave the child with **zero** model tools. The empty /// allowlist is the mechanism; this is the proof it lands. #[test] @@ -19992,6 +20842,7 @@ fn an_exact_member_with_tools_false_gets_no_model_tools_at_all() { ); } } + /// `network_tool = false` removes every model-visible network, browser, and /// remote-MCP surface — even though `tools = true` — while keeping exactly the /// `Web` family's two read-only actions (`search`/`fetch`), and refusing a @@ -20123,6 +20974,7 @@ async fn an_exact_member_without_a_network_tool_really_loses_the_network_surface "the standalone browse tool stays denied by name" ); } + /// A Runtime scout under a network-denied parent gets exactly the bounded web /// surface — `Web` with /// `search`/`fetch` — while every reaching spelling (`web.run`, `fetch_url`, @@ -20237,6 +21089,7 @@ async fn a_read_only_inspection_member_gets_only_bounded_web_search() { "full member keeps the whole Web enum" ); } + /// The unified `rlm` tool routes to the same code as the legacy aliases through /// an `action` parameter. A deny list that stops at the alias names leaves /// `rlm{action:"open", url:...}` callable — the whole reach by another spelling. @@ -20293,6 +21146,7 @@ fn the_unified_rlm_action_cannot_bypass_a_denied_alias() { "the compatibility RLM family must stay hidden from new child turns" ); } + /// The generic layer: a network-denied child that reaches a remote address /// through *any* tool's URL-bearing field is refused at the execution seam, even /// when the tool itself is local and permitted. This is what catches the next @@ -20372,6 +21226,7 @@ fn a_network_denied_child_cannot_address_a_remote_location_through_any_tool() { ); } } + /// A read-only member keeps `Run.verifiers` so it can do its job, but the /// `commands` array spawns arbitrary programs — `bash -lc 'rm -rf src'` is the /// raw shell the deny list just removed, re-entered through the door left open @@ -20426,6 +21281,7 @@ fn a_read_only_member_cannot_smuggle_commands_through_the_verifier_surface() { ); } } + /// The shipped `verifier` role is `write = false, shell = "full"`, and running /// the suite is its documented job. A test *selection* must survive at the same /// guard that refuses a command line — otherwise the indirect-execution fix has @@ -20486,6 +21342,7 @@ fn a_shell_capable_read_only_member_keeps_test_selection_arguments() { ); } } + /// The parent posture wins. A saved `full` member inside a read-only, /// no-network session runs with the session's ceiling, in the real registry. #[tokio::test] @@ -20559,6 +21416,7 @@ async fn a_parent_read_only_session_narrows_a_full_exact_member_in_the_child_reg "the URL-input guard stays deny-closed inside a read-only session" ); } + /// The session ceiling is read off the live parent runtime, so a Fleet can /// never widen what the operator is currently allowed to do. #[test] @@ -20601,6 +21459,7 @@ fn the_session_ceiling_reflects_the_live_parent_posture() { // half-contract: visibility without dispatch means a model that guesses the // name still wins, and dispatch without visibility means the model is offered a // capability it will be refused for using. + /// The child registry a Runtime verifier under a full parent actually runs with. fn read_only_with_shell_registry() -> (tempfile::TempDir, SubAgentToolRegistry) { let tmp = tempdir().expect("tempdir"); @@ -20635,6 +21494,7 @@ fn read_only_with_shell_registry() -> (tempfile::TempDir, SubAgentToolRegistry) ); (tmp, registry) } + /// Adversarial payloads — raw mutation, deletion, network reach, and scheduled /// execution — refused at the real dispatch guard. #[test] @@ -20666,6 +21526,7 @@ fn indirect_execution_payloads_are_refused_at_dispatch() { ); } } + /// The verifier's job, in the registry it actually runs with: a bounded test /// selection passes the real dispatch guard, and everything that could name a /// program or touch the workspace does not. @@ -20718,10 +21579,13 @@ fn a_verifier_runs_bounded_test_selections_and_nothing_else_at_dispatch() { ); } } + /// Catalog and dispatch must agree: the verification surface the guard admits -/// is offered, and the raw shell it refuses is absent. +/// is offered, and the legacy raw-shell spellings it refuses are absent (the +/// verifier's shell surface is the bounded canonical `bash` tool pinned by +/// the read-only-shell tests, not the legacy `Bash`/`exec_shell` names). #[test] -fn the_verifier_catalog_offers_the_verification_surface_and_no_shell() { +fn the_verifier_catalog_offers_run_without_the_legacy_raw_shell_spellings() { let (_tmp, registry) = read_only_with_shell_registry(); let tools = registry.tools_for_model(&FleetRole::Verifier); let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_str()).collect(); @@ -20737,6 +21601,7 @@ fn the_verifier_catalog_offers_the_verification_surface_and_no_shell() { ); } } + /// The refusal reaches the *real* `execute` path, not only the classifier: the /// envelope check sits behind the allowlist, posture, and approval gates, and a /// guard that is never reached is not a guard. @@ -20761,6 +21626,7 @@ async fn a_verifier_is_refused_arbitrary_execution_at_the_real_execute_boundary( ); } } + /// The same payloads must never be *offered*. A model that can see a tool will /// try it, and a refusal is a worse experience than an absent capability. #[test] @@ -20814,6 +21680,7 @@ fn indirect_execution_actions_are_pruned_from_the_model_catalog() { ); } } + /// The bounded positives. This is the half that makes the contract honest: the /// point of `shell = "full"` on a read-only member is that it can still run the /// checks, and durable-task bookkeeping is exactly what such a member is for. @@ -20856,6 +21723,7 @@ fn bounded_read_only_and_verification_paths_survive_the_ceiling() { "durable-task bookkeeping must stay visible; got {actions:?}" ); } + /// A write-capable Runtime builder is unaffected. The guard narrows authority; /// it is not a new global restriction. #[test] @@ -20895,6 +21763,7 @@ fn a_write_capable_member_keeps_every_execution_gate() { ); } } + /// The unified-family bypass, for the durable-work families this time: a deny /// list naming `task_gate_run` has to reach `tasks{action:"gate_run"}`, or the /// canonical name is simply a second spelling that nothing checks. @@ -20932,6 +21801,7 @@ fn the_durable_work_families_resolve_their_actions_through_the_policy_seam() { } // ── A child may never widen its parent's envelope ─────────────────────────── + /// `inherit_disallowed_tools: false` is an escape hatch for *preference*, not /// for a ceiling. A Fleet member clamped to `network_tool = false` that spawns a /// grandchild asking for a clean surface must not hand it the network back. @@ -20982,6 +21852,7 @@ fn posture_denials_survive_a_child_that_declines_to_inherit() { } // ── The launch authority must reach the runtime, or the spawn fails ───────── + /// The fingerprint is the value the spawn boundary checks. It has to change /// whenever anything the child is constructed from changes, or checking it /// proves nothing. @@ -21019,6 +21890,7 @@ fn the_authority_fingerprint_distinguishes_every_envelope_it_names() { ); assert_ne!(narrow.fingerprint(), fingerprint("builder")); } + /// The spawn boundary itself. A missing, unparseable, or mismatched fingerprint /// must refuse the launch — a Runtime envelope that does not reach the child /// is not enforced authority. @@ -21080,6 +21952,7 @@ fn the_spawn_boundary_fails_closed_on_a_missing_or_mismatched_authority() { ); } } + /// End to end for the value that used to be computed and never read: the /// authority a launch resolves is the one whose fingerprint rides the receipt, /// and that fingerprint is what the spawn boundary accepts. @@ -21115,6 +21988,7 @@ fn the_launched_authority_is_the_one_the_spawn_boundary_accepts() { "one member's receipt must not authorize another member's child" ); } + /// R6 injection-size regressions (finish-operator 2026-08-02): build — never /// send — the real assembled payloads and pin them to measured-current +10%. /// Growth past the ceiling must be a deliberate, reviewed act, not drift. @@ -21128,6 +22002,7 @@ const READ_ONLY_CHILD_ENVELOPE_BYTE_CEILING: usize = 89_000; /// prompt-only start, review/tasks/verify/web/search/github/goal/terminal/ /// git/ocr/extract truthful-limit disclosures); +10%. const PARENT_SURFACE_BYTE_CEILING: usize = 90_000; + #[tokio::test] async fn read_only_child_envelope_stays_within_measured_ceiling() { let tmp = tempdir().expect("tempdir"); @@ -21161,6 +22036,7 @@ async fn read_only_child_envelope_stays_within_measured_ceiling() { "read-only child envelope grew past its reviewed ceiling: {envelope_bytes}B > {READ_ONLY_CHILD_ENVELOPE_BYTE_CEILING}B. If deliberate, re-measure and raise the ceiling in the same commit." ); } + #[tokio::test] async fn parent_agent_surface_stays_within_measured_ceiling() { let tmp = tempdir().expect("tempdir"); @@ -21191,6 +22067,7 @@ async fn parent_agent_surface_stays_within_measured_ceiling() { "parent prompt+catalog surface grew past its reviewed ceiling: {surface_bytes}B > {PARENT_SURFACE_BYTE_CEILING}B. If deliberate, re-measure and raise the ceiling in the same commit." ); } + fn init_claim_repo(root: &Path) { let run = |args: &[&str]| { let output = Command::new("git") @@ -21220,6 +22097,7 @@ fn init_claim_repo(root: &Path) { .expect("git available"); assert!(output.status.success(), "git commit: {output:?}"); } + #[test] fn completed_claim_of_untouched_file_taints_verification() { // R7 (finish-operator 2026-08-02): the morning report caught a child @@ -21250,6 +22128,7 @@ fn completed_claim_of_untouched_file_taints_verification() { } // ─── resume_from continuation-chain tests (#425) ───────────────────────────── + #[test] fn parse_spawn_request_accepts_resume_from() { let input = json!({ @@ -21259,6 +22138,7 @@ fn parse_spawn_request_accepts_resume_from() { let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert_eq!(parsed.resume_from.as_deref(), Some("agent_abc123")); } + #[test] fn parse_spawn_request_accepts_resume_from_camel_case() { let input = json!({ @@ -21268,12 +22148,14 @@ fn parse_spawn_request_accepts_resume_from_camel_case() { let parsed = parse_spawn_request(&input).expect("camelCase resumeFrom should parse"); assert_eq!(parsed.resume_from.as_deref(), Some("my-session")); } + #[test] fn parse_spawn_request_resume_from_absent_is_none() { let input = json!({ "prompt": "fresh start" }); let parsed = parse_spawn_request(&input).expect("prompt-only request should parse"); assert!(parsed.resume_from.is_none()); } + #[test] fn parse_spawn_request_resume_from_empty_string_is_none() { let input = json!({ @@ -21284,6 +22166,7 @@ fn parse_spawn_request_resume_from_empty_string_is_none() { .expect("whitespace-only resume_from should be treated as absent"); assert!(parsed.resume_from.is_none()); } + /// Spawning with resume_from + fork_context=false is contradictory and must /// be rejected at the spawn seam (not at parse time, since that is too early /// to know whether fork_context was explicit). @@ -21302,6 +22185,7 @@ fn parse_spawn_request_resume_from_with_fork_context_false_is_parseable() { assert_eq!(parsed.resume_from.as_deref(), Some("agent_abc123")); assert_eq!(parsed.fork_context, Some(false)); } + #[test] fn resume_from_rejects_running_source() { let tmp = tempdir().expect("tempdir"); @@ -21315,6 +22199,7 @@ fn resume_from_rejects_running_source() { "error should mention running status: {err}" ); } + #[test] fn completed_claim_matching_workspace_state_stays_untainted() { let tmp = tempdir().expect("tempdir"); @@ -21372,6 +22257,7 @@ fn completed_claim_matching_workspace_state_stays_untainted() { record.verification.summary ); } + #[tokio::test] async fn spawn_receipt_compacts_and_verbose_restores_the_archive() { // Morning-report issue #4 (W6 rest): every spawn returned ~12KB because @@ -21453,6 +22339,7 @@ async fn spawn_receipt_compacts_and_verbose_restores_the_archive() { assert!(verbose.get("compact").is_none()); assert_eq!(verbose, full, "verbose: true must restore the old shape"); } + fn spawn_route_metadata(provider: &str, model: &str, source: &str) -> WorkflowTaskSpawnMetadata { let child_route = ChildRouteReceipt { requested_type: "explore".to_string(), @@ -21486,9 +22373,11 @@ fn spawn_route_metadata(provider: &str, model: &str, source: &str) -> WorkflowTa resume_from_agent_id: None, } } + fn spawn_child_route_projection(metadata: &WorkflowTaskSpawnMetadata) -> ChildRouteReceipt { metadata.child_route.clone() } + #[test] fn spawn_receipt_route_names_the_provider_and_model_the_child_actually_got() { // #5305: a Fleet profile can route a child onto a provider the parent @@ -21522,6 +22411,7 @@ fn spawn_receipt_route_names_the_provider_and_model_the_child_actually_got() { assert_eq!(route["model_id"], json!("deepseek-v4-pro")); assert_eq!(route["route_source"], json!("task.model")); } + #[tokio::test] async fn spawn_receipt_route_survives_compaction_for_a_type_only_spawn() { // A type-only spawn resolves no roster member: no profile, no pins. The @@ -21575,6 +22465,7 @@ async fn spawn_receipt_route_survives_compaction_for_a_type_only_spawn() { "a type-only spawn resolves no profile: {receipt}" ); } + #[tokio::test] async fn unscoped_status_compacts_running_children_and_keeps_terminal_full() { // Morning-report issue #4: one unscoped status poll returned 203KB @@ -21646,6 +22537,7 @@ async fn unscoped_status_compacts_running_children_and_keeps_terminal_full() { assert!(verbose_row.get("snapshot").is_some(), "{verbose_row}"); assert!(verbose_row.get("compact").is_none(), "{verbose_row}"); } + #[test] fn resume_from_rejects_missing_source() { let tmp = tempdir().expect("tempdir"); @@ -21658,6 +22550,7 @@ fn resume_from_rejects_missing_source() { "error should mention not found: {err}" ); } + #[test] fn resume_from_accepts_completed_source() { let tmp = tempdir().expect("tempdir"); @@ -21671,6 +22564,7 @@ fn resume_from_accepts_completed_source() { validate_resume_from_source(&manager, &completed_id, tmp.path()) .expect("completed agent must be accepted as a resume source"); } + #[test] fn resume_from_accepts_interrupted_source() { let tmp = tempdir().expect("tempdir"); @@ -21687,6 +22581,7 @@ fn resume_from_accepts_interrupted_source() { validate_resume_from_source(&manager, &interrupted_id, tmp.path()) .expect("interrupted agent must be accepted as a resume source"); } + #[test] fn resume_from_accepts_failed_source() { let tmp = tempdir().expect("tempdir"); @@ -21699,6 +22594,7 @@ fn resume_from_accepts_failed_source() { validate_resume_from_source(&manager, &failed_id, tmp.path()) .expect("failed agent may be used as a resume source"); } + #[test] fn resume_from_accepts_cancelled_source() { let tmp = tempdir().expect("tempdir"); @@ -21711,6 +22607,7 @@ fn resume_from_accepts_cancelled_source() { validate_resume_from_source(&manager, &cancelled_id, tmp.path()) .expect("cancelled agent may be used as a resume source"); } + #[test] fn resume_from_rejects_cross_workspace_source() { let tmp_parent = tempdir().expect("parent tempdir"); @@ -21730,6 +22627,7 @@ fn resume_from_rejects_cross_workspace_source() { "error should mention workspace mismatch: {err}" ); } + #[test] fn resume_from_loads_transcript_artifact_when_available() { let tmp = tempdir().expect("tempdir"); @@ -21755,6 +22653,7 @@ fn resume_from_loads_transcript_artifact_when_available() { "resume transcript reads must not fall back to the execution workspace" ); } + #[test] fn resume_from_falls_back_to_checkpoint_when_artifact_missing() { let tmp = tempdir().expect("tempdir"); @@ -21784,6 +22683,7 @@ fn resume_from_falls_back_to_checkpoint_when_artifact_missing() { assert_eq!(fallback_messages.len(), 2); assert_eq!(message_text(&fallback_messages[0]), "checkpoint task"); } + #[test] fn resume_from_session_name_resolves_to_agent_id() { let tmp = tempdir().expect("tempdir"); @@ -21802,6 +22702,7 @@ fn resume_from_session_name_resolves_to_agent_id() { validate_resume_from_source(&manager, "named-source", tmp.path()) .expect("resolution by session name must work for resume_from"); } + #[test] fn child_launch_manifest_carries_resume_from_agent_id() { let tmp = tempdir().expect("tempdir"); @@ -21821,6 +22722,7 @@ fn child_launch_manifest_carries_resume_from_agent_id() { } // ── validation helper exposed for the tests above ──────────────────────────── + /// Extracted validation logic mirroring what `spawn_subagent_from_input` does. /// Returns `Ok(agent_id)` when the source is acceptable, `Err(message)` when /// it must be rejected. @@ -21858,6 +22760,7 @@ fn validate_resume_from_source( Ok(source_id) } + /// Owner report 2026-08-04: a model/provider switch spawns the new engine's /// manager while the old engine still holds the workspace coordination flock /// in this same process. That losing acquisition must classify itself as a @@ -21891,6 +22794,7 @@ fn coordination_lock_loss_to_own_process_reads_as_handover_and_self_heals() { } // -- resume-from-checkpoint tests (checkpoint-based continuation) -- + #[tokio::test] async fn resume_from_checkpoint_spawns_seeded_agent_with_checkpoint_context() { let tmp = tempdir().unwrap(); @@ -21951,6 +22855,7 @@ async fn resume_from_checkpoint_spawns_seeded_agent_with_checkpoint_context() { let prior = guard.agents.get(&agent_id).expect("prior record"); assert!(matches!(prior.status, SubAgentStatus::Interrupted(_))); } + #[tokio::test] async fn resume_from_checkpoint_is_idempotent_across_repeated_followups() { let tmp = tempdir().unwrap(); @@ -22000,6 +22905,7 @@ async fn resume_from_checkpoint_is_idempotent_across_repeated_followups() { "a repeated followup must reach the resumed target" ); } + #[tokio::test] async fn resume_from_checkpoint_rejects_non_interrupted_agents() { let tmp = tempdir().unwrap(); @@ -22021,6 +22927,7 @@ async fn resume_from_checkpoint_rejects_non_interrupted_agents() { "{err}" ); } + #[tokio::test] async fn resume_from_checkpoint_rejects_missing_continuable_checkpoint() { let tmp = tempdir().unwrap(); @@ -22053,6 +22960,7 @@ async fn resume_from_checkpoint_rejects_missing_continuable_checkpoint() { "{err}" ); } + /// The resume lane must honor the same isolation rule as a fresh worktree /// spawn: a worktree child's boundary is the worktree alone, so a multi-root /// parent's attached roots must not re-widen the resumed child. Both @@ -22159,6 +23067,7 @@ async fn resume_of_isolated_worktree_child_keeps_the_worktree_as_its_only_root() root set — the parent's attached root must not stay writable" ); } + /// Isolation must not key on the write claim's existence: a claim can be /// released or never created for a worktree child. The worker record's /// launch manifest durably pins the worktree spawn, so the claim-less resume @@ -22259,6 +23168,7 @@ async fn claim_less_resume_of_a_recorded_worktree_child_stays_isolated() { "a claim-less worktree child must not inherit the caller's writable roots" ); } + /// The rebuild idiom shared by both worktree clear sites: after the roots set /// is cleared, the WorkspaceWrite face of the cloned policy re-derives from /// the child's `(workspace, workspace_roots)` pair — the worktree alone — and @@ -22307,6 +23217,7 @@ fn rederive_sandbox_policy_roots_confines_the_exec_lane_to_the_cleared_set() { assert_eq!(context.elevated_sandbox_policy, Some(policy)); } } + #[test] fn user_follow_up_to_running_child_counts_queued_until_the_loop_takes_it() { let tmp = tempdir().expect("tempdir"); @@ -22353,6 +23264,7 @@ fn user_follow_up_to_running_child_counts_queued_until_the_loop_takes_it() { "nothing queued once the loop took both" ); } + #[test] fn user_follow_up_to_cancelled_child_fails_closed_with_the_reason() { let tmp = tempdir().expect("tempdir"); @@ -22369,6 +23281,7 @@ fn user_follow_up_to_cancelled_child_fails_closed_with_the_reason() { assert!(err.to_string().contains("cancelled"), "{err}"); assert!(manager.queued_follow_up_counts().is_empty()); } + #[test] fn user_follow_up_to_completed_child_requires_a_runtime_to_resume() { let tmp = tempdir().expect("tempdir"); @@ -22390,6 +23303,7 @@ fn user_follow_up_to_completed_child_requires_a_runtime_to_resume() { } // === child permission gate: the session posture applied to a worker's calls === + mod child_permission_gate { use super::*; use crate::approval_log::{ApprovalOutcome, ApprovalReceipt, ApprovalReceiptStore}; @@ -23121,6 +24035,7 @@ mod child_permission_gate { } // ── #5462: `agent` is the only model-facing sub-agent surface ──────────────── + /// The six narrow tools that used to sit beside `agent` in the model catalog. /// Kept as one list so a test cannot silently check five of them. const RETIRED_AGENTS_TOOLS: &[&str] = &[ @@ -23131,6 +24046,7 @@ const RETIRED_AGENTS_TOOLS: &[&str] = &[ "agents/coordinate", "agents/wait", ]; + fn subagent_registry_for_catalog(tmp: &std::path::Path) -> crate::tools::ToolRegistry { let runtime = stub_runtime(); let manager = runtime.manager.clone(); @@ -23138,6 +24054,7 @@ fn subagent_registry_for_catalog(tmp: &std::path::Path) -> crate::tools::ToolReg .with_subagent_tools(manager, runtime) .build(crate::tools::spec::ToolContext::new(tmp.to_path_buf())) } + /// The narrow tools must vanish from the advertised catalog while staying /// registered: a persisted transcript that replays `agents/followup` has to /// keep dispatching to the same implementation, exactly as `rlm` and @@ -23174,6 +24091,7 @@ fn retired_agents_tools_stay_registered_but_leave_the_model_catalog() { ); } } + /// Hiding a tool from the initial catalog is worthless if `tool_search` can /// hand it back. Both matching paths read the same catalog, so both are /// exercised — with queries chosen to hit the retired tools' own names and @@ -23226,6 +24144,7 @@ fn tool_search_cannot_return_a_retired_agents_tool() { } } } + /// A name the model can read is a name the model will try to call. The `agent` /// description and schema must not advertise a tool that is no longer in the /// catalog — the failure mode that motivated this change was the description @@ -23257,6 +24176,7 @@ fn the_agent_surface_never_names_a_retired_tool() { "the replacement action must be advertised" ); } + /// The claim case, end to end, through the `agent` surface. /// /// This is the test the audit demanded: the coordinate wire key is `roots` @@ -23358,6 +24278,7 @@ async fn agent_claim_expands_the_callers_write_scope() { .expect("the expanded scope admits the previously refused write"); assert!(tmp.path().join("docs/note.txt").exists()); } + /// `expand_write_claim` returns the unchanged claim with `Ok` when every list /// is empty, so an empty claim would read as "granted". Refuse it at the seam. #[test] @@ -23384,6 +24305,7 @@ fn agent_claim_refuses_a_scopeless_call() { "the coordinate wire key is `roots`: {translated}" ); } + /// Folding six tools into one multi-action tool must not hand a read-only role /// an authority its catalog previously withheld. `agents/coordinate` was kept /// off an inspection role's surface by the execution envelope; `claim` has to @@ -23461,6 +24383,7 @@ async fn agent_claim_is_withheld_from_a_role_with_no_write_authority() { .to_string(); assert!(refusal.contains("no write authority to widen"), "{refusal}"); } + // Regression (stopship scout repair): the workflow's read-only scout // activates `grep_files` with one `tool_search` call before searching. That // two-step path only works while the scout surface keeps a first-turn-active @@ -23513,6 +24436,7 @@ async fn forkguard_scout_surface_keeps_tool_search_grep_files_activation_path() "the hidden File alias must stay out of scout catalogs" ); } + // Regression (stopship scout repair, Pinvou #490 phantom-tool class): fleet // workflow briefs are model-facing text too. The scout brief must name tools // the child catalog can actually see (`tool_search`, `grep_files`) and never @@ -23554,6 +24478,7 @@ fn forkguard_workflow_briefs_name_catalog_visible_tools() { "workflow briefs must not command calls a catalog can never return: {cited:?}" ); } + // Behavioral counterpart to the two composition guards above: on the scout's // live surface, one `tool_search` call must make the deferred `grep_files` // dispatchable through the same gate the child step loop uses, while the @@ -23682,6 +24607,7 @@ fn forkguard_subagent_skill_catalog_uses_tool_search_discovery() { ); assert!(!catalog.contains("try `load_skill` directly anyway")); } + // Worker records, takeover targets, and transcript artifact descriptions all // point the parent at `handle_read`, which is deferred on stock hosts; every // prose site that names it must carry the `tool_search` activation hint so diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 506c0347d9..a7a9fc9dac 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -69,9 +69,9 @@ stewardship. | `general` | flexible; do whatever the parent says | yes | yes | yes | the default; multi-step tasks | | `explore` | read-only; map the relevant code fast | no | yes | read-only (net + bounded verify) | "find every call site of `Foo`; check the PR with gh" | | `planner` | analyse and produce a strategy | no | yes | read-only probes | "design the migration; don't execute" | -| `reviewer` | read-and-grade with severity scores | no | yes | read-only (net + bounded verify) | "audit this PR for bugs" | +| `reviewer` | read-and-grade with severity scores | no | yes | read-only (net) | "audit this PR for bugs" | | `implement` | land a specific change with min edit | yes | yes | yes | "rewrite `bar.rs::Foo::bar` to do X" | -| `test` | run tests / validation, report outcome | no | yes | bounded verification (no writes) | "verify the diff with the bounded test checks; report PASS/FAIL" | +| `test` | run the built-in verification gates and report outcome | no | yes | built-in gates + read-only probes (no custom commands) | "verify the diff with the built-in checks; report PASS/FAIL" | | `advisor` | short-lived, high-reasoning counsel | no | yes | none | "what are we missing in this design?" | | `custom` | explicit narrow tool allowlist | inherits | inherits | inherits | hand-picked tools on the parent's posture | @@ -90,11 +90,16 @@ builder) is a supported escape hatch for *work capacity* — the child brings its own model, route, and step budget — but the child's authority is clamped against the delegating parent's live posture, not the operator's: a scout's builder child lands read-only with raw shell and mutating tools denied, and -canonical `Bash` is denied to it too (only bounded-inspection roles keep the +canonical `Bash` is denied to it too (only the bounded-inspection roles and the verifier keep the classified read-only shell). Delegating to obtain shell is therefore mechanically useless — the scout's own bounded shell (`git -C … log`, `find … | head`, `npm view …`, classifier-gated) is the only shell path a -read-only parent has. Read-only is transitive through any delegation chain: +read-only parent has. The bounded grammar also admits compound probes of +those families — segments joined by `;`, `&&`, `||`, or `|`, where every +segment must individually be admitted (e.g. `wc -c f; wc -l f`, +`cd sub && wc -c notes.md`, `cat x | od -c`) — and a workspace-confined +`cd ` in any sequence position its connector always runs (joined by `;`, or first — a `cd` after `&&`/`||` may never execute, so it refuses). +Read-only is transitive through any delegation chain: the clamp (`ChildAuthority::clamp` in `fleet/exact.rs`) intersects every field with the narrower side, the deny-list union means a descendant can never drop an ancestor's restriction, and `inherit_disallowed_tools: false` @@ -314,9 +319,15 @@ OUTPUT: VERDICT, EVIDENCE, GAPS, NEXT. - **`test`** — when the parent needs an authoritative pass/fail on the test suite or other validation. Verifiers don't fix failures; they capture the failing assertion + stack and put fix - candidates under RISKS. The verifier posture never writes, and shell - is clamped to the bounded built-in verification surface: the write - ceiling is read-only and unbounded shell forms are refused (#5186). + candidates under RISKS. The verifier posture never writes; its shell + is the bounded built-in verification surface plus — on in-session + delegations whose envelope grants it — the same classifier-gated + read-only probe lane the inspection roles hold (`wc`, `ls`, `cat`, + `grep` pipelines) for gathering evidence; envelopes without the + lane omit it and shell attempts refuse. The + write ceiling is read-only, unbounded shell forms are refused + (#5186), and custom commands are refused — use a write-capable role + for those. - **`advisor`** — when the operator wants a high-leverage second opinion before cheaper execution continues. Consultants read enough to ground a recommendation, but cannot write or run shell commands. `oracle` and diff --git a/docs/zh_hans/FLEET.md b/docs/zh_hans/FLEET.md index df9652850c..0ae5afa5e6 100644 --- a/docs/zh_hans/FLEET.md +++ b/docs/zh_hans/FLEET.md @@ -121,7 +121,7 @@ call_reasrning = "lrw" 在运行时,它只能为已经冻结的 worker route 选择 reasoning 级别。它不能改变 worker、provider、model、role、tools 或 permissions。Router 调用本身被限制在 `off` 或 `low`;更贵的值会被拒绝。手动选择的 worker reasoning 级别不会产生 Router 调用。Route 与 reasoning 回执会指名 worker model,并在使用 Router 时给出 Router 的确切 provider/model,让操作者看到哪个 model 干了哪份活。如果相同的裸 Router 或 Fleet 名字在两个根里都存在,请用 `workspace/` 或 `codewhale_home/` 限定它,而不是依赖遮蔽(shadowing)。 -每个成员的 `permissions` 预设是一个**上限**,绝不是授权:它会与会话的实时姿态取交集,因此只读会话里的 `read_write` 成员以只读方式运行。这个交集成为子任务真实的工具包——`permissions = "none"` 让它一个工具都没有,而没有网络工具的成员会失去每一个可触达的网络表面——`web.run`、`fetch_url`、`web_search`、`github`、`mcp*` 系列以及可触达的 `rlm` 动作——而不是仅仅在调用时被拒绝。唯一刻意的例外是规范 `Web` 家族的 `search`/`fetch` 动作:它们是只读成员有权使用的只读 web 表面(与普通 scout 对等),因此网络被禁的成员恰好保留 `Web {search, fetch}`。它在事实上保持只读,而不仅仅是名义上:按 URL 寻址的 `fetch` 在派发时被拒绝,表面上没有任何其他东西被授予。不能写入的成员会失去可变的文件工具,并且——当它保留了 `shell = "full"` 以便运行检查时——也会失去原始 shell,只保留它为之而存在的有界验证工具(`run_tests`、`run_verifiers`):任意 shell 命令和 `write_file` 一样确定无疑地改变工作区,所以保留它会令 `write = false` 不成立。那个验证表面只在它的*默认*形式下有界,因此无界参数会随 shell 一起消失:被拒绝写入的成员可以运行内置 gate,但不能用显式 `commands` 数组运行 `run_verifiers`,也不能用原始 `args` 字符串运行 `run_tests`——这两者都会启动操作者提供的程序,是换了名字的原始 shell。 +每个成员的 `permissions` 预设是一个**上限**,绝不是授权:它会与会话的实时姿态取交集,因此只读会话里的 `read_write` 成员以只读方式运行。这个交集成为子任务真实的工具包——`permissions = "none"` 让它一个工具都没有,而没有网络工具的成员会失去每一个可触达的网络表面——`web.run`、`fetch_url`、`web_search`、`github`、`mcp*` 系列以及可触达的 `rlm` 动作——而不是仅仅在调用时被拒绝。唯一刻意的例外是规范 `Web` 家族的 `search`/`fetch` 动作:它们是只读成员有权使用的只读 web 表面(与普通 scout 对等),因此网络被禁的成员恰好保留 `Web {search, fetch}`。它在事实上保持只读,而不仅仅是名义上:按 URL 寻址的 `fetch` 在派发时被拒绝,表面上没有任何其他东西被授予。不能写入的成员会失去可变的文件工具,并且——当它保留了 `shell = "full"` 以便运行检查时——也会失去原始 shell,只保留它为之而存在的有界验证工具(`run_tests`、`run_verifiers`;经 `agent` 工具嵌套的 verifier 额外持有分类器门控的只读取证探针通道,但 Fleet 成员信封把有界验证与 Bash 权限定义为互斥,因此不带该探针通道):任意 shell 命令和 `write_file` 一样确定无疑地改变工作区,所以保留它会令 `write = false` 不成立。那个验证表面只在它的*默认*形式下有界,因此无界参数会随 shell 一起消失:被拒绝写入的成员可以运行内置 gate,但不能用显式 `commands` 数组运行 `run_verifiers`,也不能用原始 `args` 字符串运行 `run_tests`——这两者都会启动操作者提供的程序,是换了名字的原始 shell。 这些拒绝中有两项比工具名更窄,因为有两个工具触及了它们名字之外的地方。`rlm` 通过在进程*内部*、以它自己的名字调用 fetch 工具来加载 `url`,而 `rlm` 的 `eval` 动作针对活内核运行 Python——套接字和文件系统都触及。因此该家族**按动作**被拒绝,而不是整体拒绝: diff --git a/docs/zh_hans/SUBAGENTS.md b/docs/zh_hans/SUBAGENTS.md index 708cc14dda..a9dab671be 100644 --- a/docs/zh_hans/SUBAGENTS.md +++ b/docs/zh_hans/SUBAGENTS.md @@ -29,15 +29,15 @@ Fleet 角色是面向用户的委派工作词汇:父代理通过 `agent` 启 | `worker` | 灵活;父代理说什么就做什么 | 是 | 是 | 是 | 默认角色;多步任务 | | `scout` | 只读;快速摸清相关代码 | 否 | 是 | 只读(网络 + 有界验证) | "找到 `Foo` 的每个调用点;用 gh 检查这个 PR" | | `planner` | 分析并产出策略 | 否 | 是 | 只读探针 | "设计迁移方案;不要执行" | -| `reviewer` | 带严重度评分的阅读与评审 | 否 | 是 | 只读(网络 + 有界验证) | "审计这个 PR 的 bug" | +| `reviewer` | 带严重度评分的阅读与评审 | 否 | 是 | 只读(网络) | "审计这个 PR 的 bug" | | `builder` | 以最小改动落地某个具体变更 | 是 | 是 | 是 | "把 `bar.rs::Foo::bar` 重写为做 X" | -| `verifier` | 运行测试/验证并报告结果 | 否 | 是 | 测试导向 | "运行 cargo test --workspace,并报告" | +| `verifier` | 运行内置验证门禁并报告结果(只读探针取证) | 否 | 是 | 内置门禁 + 只读探针(无自定义命令) | "用内置检查验证这个 diff,并报告 PASS/FAIL" | | `consultant` | 短期、高推理密度的咨询 | 否 | 是 | 无 | "这个设计我们漏掉了什么?" | | `custom` | 显式的窄工具 allowlist | 继承 | 继承 | 继承 | 在父代理姿态上精选的工具 | 角色的默认值就是该角色*想要*的姿态,而父代理的有效姿态永远是天花板(子代理绝不会扩得比父代理更宽)。只读角色按意图扣留**工作区写入**;默认不拿走任何其他东西——每个角色都保留网络读取,`custom` 继承父代理的写入/网络/shell 姿态,并且只被它的显式工具列表或发起调用收窄。被聚焦的 worker 的头部会依据运行时自身的权限快照声明有效姿态(`scout · read-only · network · read-only shell`)。 -**委派移动的是工作,绝不是权限**(#5426 的遏制答案)。只读角色委派给可写角色(scout → builder)是*工作容量*受支持的逃生舱——子代理自带其模型、路由和步骤预算——但子代理的权限被钳制在委派父代理的实时姿态上,而不是操作者的姿态上:scout 的 builder 子代理以只读落地,raw shell 和可变工具被拒绝,规范的 `Bash` 对它也被拒绝(只有有界检查角色保留分类的只读 shell)。因此通过委派来获得 shell 在机制上是无用的——scout 自带的受限 shell(`git -C … log`、`find … | head`、`npm view …`,分类器门控)是只读父代理唯一的 shell 路径。只读通过任何委派链都是传递的:钳制(`fleet/exact.rs` 中的 `ChildAuthority::clamp`)把每个字段与更窄的一侧求交,拒绝列表的并集意味着后代永远无法去掉祖先的限制,而 `inherit_disallowed_tools: false` 无法去掉姿态拒绝(`is_posture_denial`)。这一点由 `crates/tui/src/fleet/exact.rs` 测试中的 `a_read_only_parents_delegation_never_widens_authority` 钉死。 +**委派移动的是工作,绝不是权限**(#5426 的遏制答案)。只读角色委派给可写角色(scout → builder)是*工作容量*受支持的逃生舱——子代理自带其模型、路由和步骤预算——但子代理的权限被钳制在委派父代理的实时姿态上,而不是操作者的姿态上:scout 的 builder 子代理以只读落地,raw shell 和可变工具被拒绝,规范的 `Bash` 对它也被拒绝(只有有界检查角色与 verifier 保留分类的只读 shell)。因此通过委派来获得 shell 在机制上是无用的——scout 自带的受限 shell(`git -C … log`、`find … | head`、`npm view …`,分类器门控)是只读父代理唯一的 shell 路径。该有界语法还允许把这些已准入的探针组合成复合序列——以 `;`、`&&`、`||` 或 `|` 连接的段,且每个段都必须单独准入(例如 `wc -c f; wc -l f`、`cd sub && wc -c notes.md`、`cat x | od -c`)——以及工作区受限的 `cd <单个相对路径>`(可单独使用,或出现在连接符必定执行它的序列位置——以 `;` 连接或位于序列首位;`&&`/`||` 之后的 `cd` 可能根本不会执行,因此会被拒绝)。只读通过任何委派链都是传递的:钳制(`fleet/exact.rs` 中的 `ChildAuthority::clamp`)把每个字段与更窄的一侧求交,拒绝列表的并集意味着后代永远无法去掉祖先的限制,而 `inherit_disallowed_tools: false` 无法去掉姿态拒绝(`is_posture_denial`)。这一点由 `crates/tui/src/fleet/exact.rs` 测试中的 `a_read_only_parents_delegation_never_widens_authority` 钉死。 会话的**权限姿态**在每个子代理内部的应用方式与父代理回合完全一致:在 Auto-Review 下,同一个确定性底线和一次性模型守护者决定 worker 的被扣留调用(绝不是提示词;守护者不可用时拒绝,fail closed);在 Ask 下,角色无法委派的被扣留调用会作为审批提示在父代理的 UI 中弹出,worker 可见地等待(`waiting for user`),或者在无法提示的主机上带着原因被拒绝;Full Access 仍然在不可绕过的安全底线上 fail closed。每一次没有人被提示的决策都是该 worker 转录中的一行备注(聚焦时可见)和一条审计日志记录。参见 `docs/MODES.md`。 @@ -122,7 +122,7 @@ OUTPUT: VERDICT、EVIDENCE、GAPS、NEXT。 - **`planner`** —— 当父代理有目标但没有可执行的分解。planner 写工件(`todo_write` 条目、响应体里的策略),但不执行它们。 - **`reviewer`** —— 当已经有一个变更,父代理想要它被评分。reviewer 不打补丁——他们在发现里描述修复方案,这样如果判定是"修它",父代理可以派一个 builder。 - **`builder`** —— 当变更已经被明确指定、只需要落地。builder 保持严格的范围:最小改动,不做顺手重构,交回前跑一次快速验证。 -- **`verifier`** —— 当父代理需要测试套件或其他验证上的权威通过/失败结论。verifier 不修失败;他们记录失败的断言 + 栈,把修复候选放在 RISKS 下。 +- **`verifier`** —— 当父代理需要测试套件或其他验证上的权威通过/失败结论。verifier 不修失败;他们记录失败的断言 + 栈,把修复候选放在 RISKS 下。verifier 姿态从不写入;其 shell 是有界内置验证面,加上——在授予该通道的会话内委派上——与检查角色相同的分类器门控只读探针通道(`wc`、`ls`、`cat`、`grep` 管道)用于取证;未授予该通道的 envelope 会省略它,shell 尝试一律拒绝。写入上限是只读,无界 shell 形式被拒绝(#5186),自定义命令也被拒绝——请改用具备写能力的角色。 - **`consultant`** —— 当操作者想在更便宜的执行继续之前得到一个高杠杆的第二意见。consultant 读足够的材料来支撑一条建议,但不能写,也不能运行 shell 命令。`oracle` 和 `advisor` 只在加载更老的请求或持久化记录时被接受;新的提示词、回执和 UI 使用 `consultant`。 - **`custom`** —— 只有当父代理需要显式约束工具集时。通过 legacy/internal 子代理记录上的 `allowed_tools` 字段传 allowlist;面向模型的 `agent` 工具刻意保持公共 schema 很小。 From d75b7ac83656b823cac666f38a16cf5c20ab8c74 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Wed, 4 Nov 2026 14:30:00 +0800 Subject: [PATCH 2/4] fix(fleet): round-1 review fixes - scope the gates contract and pin the chain honestly Review round 1 on this slice found three should-fix items and a set of consistency nits; this wave fixes everything actionable: - The gates claim is now conditional everywhere it appears (intro sentence 1, the roster description, the type description, the docs bullet and role tables in en+zh): a Verifier child of a read-only shell parent keeps the probe lane and loses the gates, which is the exact shape clamped_verifier_child_of_read_only_parent_holds_no_run_ surface pins. The unconditional phrasing was the PR's own overclaim. - sub_subagent_chain_of_a_carveout_verifier_holds_no_extra_authority now really chains: hop 2 is derived from the carve-out verifier's published narrowed profile through derive_child, and the Builder below it is asserted to hold no gate surface and no probe lane at posture, envelope, catalog, and dispatch. It guards the propagation side of the round-3 fix (a registry that leaked a pre-narrowing profile to its spawns would redden it), no longer claiming ordering coverage it did not have. - Read-only-shell children of other roles pin the probe lane's absence from their catalogs: a widened role set must not silently hand the classifier-bounded shell to write-capable children. - Docs consistency: the explore row (en) and scout row (zh) drop the stale 'bounded verify' claim the reviewer row fix left behind; the scout brief attributes the probe lane, not the verification surface; the fleet-envelope contract statement gains its English counterpart. - Copy honesty sweep: the fleet roster blurb and the FleetRole::Verifier rustdoc state the real contract; the network posture warning names the effective (narrowed) shell like the receipts do; three stale comments updated (role_posture_permits docstring, the narrowing comment, the delegation-refusal comment). - Test walls corrected: the legacy-Bash alias provability pin moved to the dispatch wall that owns it (posture's Auto classification admits provable shapes for any role), with the half-wrong comment fixed. Targeted tools::subagent:: + tools::registry:: + fleet:: + verifier:: suites 1028/0; cargo fmt and fork-CI-exact clippy clean. Signed-off-by: asto18089 --- crates/tui/src/fleet/role.rs | 12 ++- crates/tui/src/fleet/roster.rs | 2 +- crates/tui/src/fleet/worker_runtime.rs | 6 +- crates/tui/src/tools/subagent/mod.rs | 33 +++--- crates/tui/src/tools/subagent/tests.rs | 144 ++++++++++++++++++++----- docs/SUBAGENTS.md | 23 ++-- docs/zh_hans/SUBAGENTS.md | 8 +- 7 files changed, 167 insertions(+), 61 deletions(-) diff --git a/crates/tui/src/fleet/role.rs b/crates/tui/src/fleet/role.rs index 79e3bd47d4..7839988404 100644 --- a/crates/tui/src/fleet/role.rs +++ b/crates/tui/src/fleet/role.rs @@ -77,10 +77,12 @@ pub enum FleetRole { /// posture pushes hard on landing the change cleanly with the /// minimum surrounding edit (#404). Builder, - /// Verification — focused on running the test suite or other - /// validation gates and reporting pass/fail with evidence. - /// Distinct from `Reviewer` in that Reviewer reads code and grades it; - /// Verifier *runs* tests and reports the outcome (#404). + /// Verification — runs the built-in verification gates and reports + /// pass/fail with evidence; the gates are refused when a read-only-shell + /// parent clamps the child's shell authority. In-session delegations + /// also carry the bounded read-only probe lane. Distinct from `Reviewer` + /// in that Reviewer reads code and grades it; Verifier *runs* the gates + /// and reports the outcome (#404). Verifier, /// Advisory counsel — a strong-model second opinion the operator can ask /// for guidance, judgement calls, and design critique (#4752). @@ -193,7 +195,7 @@ impl FleetRole { "Lands focused code changes cleanly with the minimum surrounding edit." } Self::Verifier => { - "Runs the built-in verification gates with test-selection arguments, plus — for in-session delegations — a bounded read-only shell for evidence gathering; cannot execute custom commands — use a write-capable role for those." + "Runs the built-in verification gates with test-selection arguments unless a read-only-shell parent clamped its shell authority (the clamped child keeps the bounded read-only probes and loses the gates), plus — for in-session delegations — a bounded read-only shell for evidence gathering; cannot execute custom commands — use a write-capable role for those." } Self::Consultant => { "Read-only high-reasoning counsel for judgement calls and design critique." diff --git a/crates/tui/src/fleet/roster.rs b/crates/tui/src/fleet/roster.rs index c9ac4a2c7d..fef749f24b 100644 --- a/crates/tui/src/fleet/roster.rs +++ b/crates/tui/src/fleet/roster.rs @@ -481,7 +481,7 @@ impl FleetRoster { "verifier", FleetSlot::Verifier, FleetLoadout::Inherit, - "Runs builds and tests to verify claims; reports evidence, does not patch.", + "Runs the built-in verification gates to verify claims; reports evidence, does not patch, holds no shell.", None, ), ( diff --git a/crates/tui/src/fleet/worker_runtime.rs b/crates/tui/src/fleet/worker_runtime.rs index e947b5cfea..8d862dfb6b 100644 --- a/crates/tui/src/fleet/worker_runtime.rs +++ b/crates/tui/src/fleet/worker_runtime.rs @@ -1521,11 +1521,15 @@ pub(crate) fn network_posture_warning_for_task( return None; } + // Name the shell the role actually runs under (read-only roles run + // narrowed), not the requested default — the same truth the receipts + // report. + let effective = crate::fleet::role::effective_runtime_profile_for_role(&agent_type, &runtime); Some(format!( "Fleet task `{}` mentions network-backed verification, but role `{}` has network=off and shell={}. Dispatch a `worker` role with shell `read_only` for gh/curl evidence, or revise the brief.", task.id, role.as_deref().unwrap_or("worker"), - shell_policy_label(runtime.shell), + shell_policy_label(effective.shell), )) } diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 222ca0af99..20f899b09d 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -404,7 +404,7 @@ fn subagent_perf_enabled() -> bool { const VALID_SUBAGENT_TYPES: &str = "general, explore, planner, reviewer, implement, test, advisor, custom \ (legacy aliases remain accepted: worker, scout, builder, verifier, consultant, general-purpose, general_purpose, default, exploration/explorer, plan/planning/awaiter, review/code-review/code_review, implementer/implementation, verify/verification/validator/tester, oracle)"; -const SUBAGENT_TYPE_DESCRIPTION: &str = "Fleet role for this delegated worker. general: full tool access for multi-step tasks; write-capable, so it claims the parent workspace unless write_roots narrows it — parallel generals without disjoint write_roots contend, so prefer a read-only role for research fan-outs. explore: fast read-only exploration. planner: grounded strategy with read-only probes. reviewer: reads and grades code. implement: lands focused code changes; write-capable under the same workspace-claim rules as general, so parallel implements need disjoint write_roots or worktree=true. test: runs the built-in verification gates and reports evidence, with a bounded read-only shell — for in-session delegations — for gathering it; custom commands are refused. advisor: read-only high-reasoning counsel for judgement calls and design critique. custom: the tools listed in allowed_tools on the parent's posture; without an explicit write authority it starts read-only and never contends. Legacy aliases remain accepted at deserialization boundaries."; +const SUBAGENT_TYPE_DESCRIPTION: &str = "Fleet role for this delegated worker. general: full tool access for multi-step tasks; write-capable, so it claims the parent workspace unless write_roots narrows it — parallel generals without disjoint write_roots contend, so prefer a read-only role for research fan-outs. explore: fast read-only exploration. planner: grounded strategy with read-only probes. reviewer: reads and grades code. implement: lands focused code changes; write-capable under the same workspace-claim rules as general, so parallel implements need disjoint write_roots or worktree=true. test: runs the built-in verification gates and reports evidence, with a bounded read-only shell — for in-session delegations — for gathering it; a read-only-shell parent clamps the gates (probes remain), and custom commands are refused. advisor: read-only high-reasoning counsel for judgement calls and design critique. custom: the tools listed in allowed_tools on the parent's posture; without an explicit write authority it starts read-only and never contends. Legacy aliases remain accepted at deserialization boundaries."; // === Types === @@ -15560,12 +15560,14 @@ impl SubAgentToolSurface { /// Role-only approval posture; the registry allow/deny and runtime envelope /// remain the authoritative per-tool checks. /// -/// `Auto` is safe for every role. `Suggest` requires a write-capable role; -/// `Required` requires the full-shell posture. A Custom worker may describe -/// either posture, but its explicit allowlist and the runtime envelope still -/// narrow what it can execute. Read-only bash operations are classified Auto, -/// so Scouts and Reviewers do not gain general shell authority through this -/// branch. +/// Only the `Auto` and `Suggest` answers remain live: the posture gate's +/// `Required` branch keys on the held shell policy plus the verifier's +/// recorded parent-shell grant (`holds_shell_authority`), no longer on this +/// role-only table. `Suggest` still requires a write-capable role. A Custom +/// worker may describe either posture, but its explicit allowlist and the +/// runtime envelope still narrow what it can execute. Read-only bash +/// operations are classified Auto, so the read-only roles do not gain +/// general shell authority through this branch. fn role_posture_permits(agent_type: &FleetRole, approval: ApprovalRequirement) -> bool { if matches!(agent_type, FleetRole::Custom) { return true; @@ -15714,9 +15716,9 @@ impl SubAgentToolRegistry { let coordination_manager = Arc::clone(&runtime.manager); let mut surface_options = runtime.agent_tool_surface_options.clone(); // Shell authority is an intersection: a parent cannot delegate more - // than it owns, and read-only inspection roles (Scout, Reviewer) are - // narrowed to the hardened read-only classifier even when the parent - // has a full shell. + // than it owns, and read-only roles (Scout, Reviewer, Planner, + // Verifier) are narrowed to the hardened read-only classifier even + // when the parent has a full shell. let parent_shell = ShellPolicy::from_legacy_allow_shell(runtime.allow_shell); let mut child_shell = runtime.worker_profile.shell.min_with(parent_shell); // Capture the grant before the role narrowing below: a Full shell @@ -15789,10 +15791,11 @@ impl SubAgentToolRegistry { /// clears the role-based delegation rules on its own. fn delegation_refusal(&self, name: &str, input: &Value) -> Option { // #5595/#5438: session approval is transport, not a second shell - // classifier. Scout/Reviewer/Planner calls already proven by the exact - // predicate used at posture, envelope, and execute boundaries are - // delegated reads even though lowercase `bash` deliberately keeps the - // stricter parent-parallel classifier as its generic approval baseline. + // classifier. Scout/Reviewer/Planner/Verifier calls already proven by + // the exact predicate used at posture, envelope, and execute + // boundaries are delegated reads even though lowercase `bash` + // deliberately keeps the stricter parent-parallel classifier as its + // generic approval baseline. if self.bounded_readonly_bash_evidence(name, input) { return None; } @@ -17881,7 +17884,7 @@ const CONSULTANT_AGENT_INTRO: &str = concat!( ); const VERIFIER_AGENT_INTRO: &str = concat!( - "You are a trusted Fleet test agent (role: `test`). Your job is to run the requested gates with your bounded validation tools — the allowed test/check selections — and report results. You never write: patching the workspace is denied. Unbounded shell forms are refused; use the verification surface.\n", + "You are a trusted Fleet test agent (role: `test`). Your job is to run the requested gates with your bounded validation tools — the allowed test/check selections — and report results; the gates are yours on envelopes whose parent grants shell authority, while a child clamped by a read-only-shell parent keeps the read-only probes but has its gates refused. You never write: patching the workspace is denied. Unbounded shell forms are refused; use the verification surface.\n", "When the session grants it — for in-session delegations, on envelopes that carry the probe lane — you also hold a bounded read-only shell, under the same classifier-gated grammar as the read-only roles, for gathering evidence with probes such as `wc`, `ls`, `cat`, and `grep` pipelines; envelopes without it omit the shell entirely and shell attempts refuse, and custom commands are refused.\n", "Report PASS/FAIL/FLAKY at the top of SUMMARY with exact command evidence.\n", "Capture failing assertion and file:line; put obvious fixes under RISKS.\n", diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index ad357ffd67..1362726c67 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -8297,6 +8297,10 @@ fn verifier_roster_description_states_the_bounded_contract() { description.contains("cannot execute custom commands"), "roster description must refuse custom commands: {description}" ); + assert!( + description.contains("unless a read-only-shell parent clamped its shell authority"), + "roster description must condition the gates on shell authority — a clamped child keeps probes and loses gates: {description}" + ); // The Agent-tool `type` description carries the same truth, briefly. assert!( SUBAGENT_TYPE_DESCRIPTION.contains("test: runs the built-in verification gates"), @@ -8361,17 +8365,29 @@ async fn verifier_child_still_refuses_raw_shell_custom_commands_and_writes() { "raw Bash refusal did not identify the wall: {raw}" ); // The second legacy-`Bash` wall is the posture's classifier-lane rule: a - // command the classifier cannot prove read-only must not re-enter - // through the alias, pinned here on this Verifier-labeled registry (the - // sibling pin covers the same wall through a Scout fixture). A - // proven-read-only shape still fails this wall — the posture rule - // refuses the legacy alias name itself (`bash_must_use_classifier_lane` - // is case-insensitive), so the alias never re-enters at any - // provability. + // command the classifier cannot prove read-only stays `Required`, the + // case-insensitive lane-door check refuses the alias name itself, and + // the call is refused at posture — pinned here on this Verifier-labeled + // registry (the sibling pin covers the same wall through a Scout + // fixture). A *proven*-read-only shape clears posture's Auto + // classification for any role, so the alias-at-any-provability refusal + // is pinned below at the wall that actually owns it: the role clamp. assert!( !registry.posture_permits_tool("Bash", Some(&json!({"command": "rm -rf src"}))), "the posture wall must refuse unprovable commands through the legacy `Bash` alias for a Verifier" ); + // The exact-name asymmetry at the dispatch wall: only canonical + // lowercase `bash` reaches the lane, so the alias is refused even for a + // provably read-only shape — a case-relaxed lane check cannot re-admit + // it at any provability. + registry + .execute( + "agent_verifier_walls", + "Bash", + json!({"command": "pwd"}), + ) + .await + .expect_err("the legacy `Bash` alias must stay refused even for a provably read-only command — the lane is exact-name"); let custom_command = json!({ "action": "verifiers", @@ -8464,6 +8480,16 @@ async fn read_only_shell_children_of_other_roles_hold_no_shell_authority() { !names.iter().any(|name| name == "Run"), "{role:?} catalog must not offer the verification gate: {names:?}" ); + // The probe lane is read-only-roles-only: a widened role set must not + // silently hand the classifier-bounded shell to write-capable + // children. The catalog refusal keys on the same + // `allows_bounded_readonly_bash` predicate the production dispatch + // walls (the raw-shell deny rules' role-gated rescue) key on, so a + // widened role set flips both — and this catalog pin reddens first. + assert!( + !names.iter().any(|name| name == "bash"), + "{role:?} catalog must not offer the classifier probe lane: {names:?}" + ); let refused = registry .execute("agent_read_only_child", "Run", gate.clone()) .await @@ -8509,6 +8535,12 @@ fn verifier_child_keeps_the_default_and_selection_bounded_run_surface() { names.contains(&"Run"), "catalog must keep offering the verification gate: {names:?}" ); + // Catalog and dispatch must agree on the carve-out: the probe lane stays + // visible, not merely dispatchable. + assert!( + names.contains(&"bash"), + "catalog must keep offering the probe lane beside the gates: {names:?}" + ); } /// A Verifier child of a read-only role parent (its shell was clamped by the @@ -8556,14 +8588,16 @@ async fn clamped_verifier_child_of_read_only_parent_holds_no_run_surface() { /// The sub-subagent chain: a carve-out Verifier parent (its effective shell /// is the role's read-only narrowing; the Run grant is a registry surface, -/// not shell authority) spawns a Builder or Worker child. The child -/// derivation re-clamps before it captures shell authority, so the chain -/// holds no extra authority: the spawn ceiling the carve-out parent -/// publishes stays read-only, and a child built under that ceiling keeps -/// the same no-Run, classifier-bounded shape this module pins elsewhere. -/// The round-3 re-key bug lived exactly in this ordering. -#[test] -fn sub_subagent_chain_of_a_carveout_verifier_holds_no_extra_authority() { +/// not shell authority) spawning a Builder child. Hop 2 is built from hop +/// 1's actual published profile through the same intersection the +/// production spawn path runs, so the pin is a real two-hop chain: the +/// spawn ceiling the carve-out parent publishes stays read-only, and the +/// child derived under that ceiling keeps the no-Run, lane-less shape. It +/// guards the propagation side of the round-3 fix — if the registry ever +/// leaked a pre-narrowing profile to its spawns, the Builder below would +/// regain the gate surface, and this test would redden. +#[tokio::test] +async fn sub_subagent_chain_of_a_carveout_verifier_holds_no_extra_authority() { // A carve-out Verifier in a full session: the coarse session flag stays // `allow_shell = true` while its effective runtime shell is the // read-only narrowing. The ceiling its spawns publish must read the @@ -8578,29 +8612,82 @@ fn sub_subagent_chain_of_a_carveout_verifier_holds_no_extra_authority() { codewhale_workflow::ShellCeiling::ReadOnly, "the carve-out must not widen the shell ceiling a verifier publishes for its own spawns" ); - // The chain child registry shape (what the derivation above produces for - // the next hop): no built-in gate surface, evidence lane classifier- - // bounded — for the Builder the chain would most plausibly reach for. - let (_tmp, registry) = read_only_shell_child_registry(FleetRole::Builder); + // Hop 2 is derived from hop 1's actual output: the carve-out verifier's + // narrowed effective profile is what the registry publishes to its own + // spawns, so a Builder one hop below the verifier is built through the + // same derivation the production spawn path runs + // (`worker_profile_for_spawn` intersects the parent's effective profile + // with the child's role request). If the registry ever leaked a + // pre-narrowing profile to its spawns, the Builder below would regain + // the gate surface — this pin exists to redden exactly that regression. + let (_verifier_tmp, verifier) = read_only_with_shell_registry(); + assert_eq!( + verifier.runtime_profile.shell, + crate::worker_profile::ShellPolicy::ReadOnly, + "the carve-out fixture must publish the narrowed shell to its spawns" + ); + let derived = verifier + .runtime_profile + .derive_child(&WorkerRuntimeProfile::for_role(FleetRole::Builder)); + assert_eq!( + derived.shell, + crate::worker_profile::ShellPolicy::ReadOnly, + "the chain Builder must derive the read-only shell from the verifier's narrowed profile" + ); + let child_tmp = tempfile::tempdir().expect("tempdir"); + let mut child_runtime = + stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options()); + child_runtime.context = ToolContext::new(child_tmp.path().to_path_buf()); + child_runtime.allow_shell = true; + child_runtime.worker_profile = derived; + let builder = SubAgentToolRegistry::new( + child_runtime, + FleetRole::Builder, + None, + Arc::new(Mutex::new(TodoList::new())), + Arc::new(Mutex::new(PlanState::default())), + ); + // No gate surface at any wall, and no probe lane: the Builder two hops + // into a shell-granting chain holds exactly the read-only shell its + // ancestors owned. + let gate = json!({"action": "tests", "args": "--lib fleet::exact"}); assert!( - !registry.posture_permits_tool("Run", Some(&json!({"action": "tests"}))), + !builder.posture_permits_tool("Run", Some(&gate)), "a chain Builder must not hold the verification gate" ); assert!( - registry - .envelope_refusal("Run", &json!({"action": "tests"})) - .is_some(), + builder.envelope_refusal("Run", &gate).is_some(), "a chain Builder envelope must refuse the verification gate" ); + let names: Vec = builder + .tools_for_model(&FleetRole::Builder) + .iter() + .map(|tool| tool.name.clone()) + .collect(); assert!( - !registry.posture_permits_tool("bash", Some(&json!({"command": "rm -rf src"}))), - "a chain Builder keeps the evidence lane classifier-bounded" + !names.iter().any(|name| name == "Run"), + "a chain Builder catalog must not offer the verification gate: {names:?}" + ); + assert!( + !names.iter().any(|name| name == "bash"), + "a chain Builder catalog must not offer the classifier probe lane: {names:?}" + ); + let refused = builder + .execute("agent_chain_builder", "Run", gate.clone()) + .await + .expect_err("the test runner must stay refused one hop below the carve-out verifier"); + assert!( + refused.to_string().contains("not permitted") || refused.to_string().contains("shell"), + "chain Builder refusal did not identify the wall: {refused}" ); } /// The verifier intro tells the child about its bounded read-only shell and /// the custom-command refusal, scoped to in-session delegations the way -/// `FleetRole`'s roster description scopes the role. +/// `FleetRole`'s roster description scopes the role. The gates claim is +/// conditional too: a child clamped by a read-only-shell parent keeps the +/// probes and loses the gates — the intro must not promise the gates +/// unconditionally. #[test] fn verifier_intro_states_the_evidence_contract() { assert!( @@ -8615,6 +8702,11 @@ fn verifier_intro_states_the_evidence_contract() { VERIFIER_AGENT_INTRO.contains("in-session delegations"), "the verifier's bounded read-only shell must be scoped to in-session delegations: {VERIFIER_AGENT_INTRO}" ); + assert!( + VERIFIER_AGENT_INTRO + .contains("a child clamped by a read-only-shell parent keeps the read-only probes but has its gates refused"), + "the intro's gates claim must carry the read-only-parent conditional: {VERIFIER_AGENT_INTRO}" + ); } #[test] diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index a7a9fc9dac..49d0963e3d 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -67,11 +67,11 @@ stewardship. | Role | Stance | Writes? | Network? | Shell posture | Typical use | |---------------|----------------------------------------|---------|----------|---------------|----------------------------------------------| | `general` | flexible; do whatever the parent says | yes | yes | yes | the default; multi-step tasks | -| `explore` | read-only; map the relevant code fast | no | yes | read-only (net + bounded verify) | "find every call site of `Foo`; check the PR with gh" | +| `explore` | read-only; map the relevant code fast | no | yes | read-only (net) | "find every call site of `Foo`; check the PR with gh" | | `planner` | analyse and produce a strategy | no | yes | read-only probes | "design the migration; don't execute" | | `reviewer` | read-and-grade with severity scores | no | yes | read-only (net) | "audit this PR for bugs" | | `implement` | land a specific change with min edit | yes | yes | yes | "rewrite `bar.rs::Foo::bar` to do X" | -| `test` | run the built-in verification gates and report outcome | no | yes | built-in gates + read-only probes (no custom commands) | "verify the diff with the built-in checks; report PASS/FAIL" | +| `test` | run the built-in verification gates and report outcome | no | yes | built-in gates unless parent-clamped (then probes only) + read-only probes (no custom commands) | "verify the diff with the built-in checks; report PASS/FAIL" | | `advisor` | short-lived, high-reasoning counsel | no | yes | none | "what are we missing in this design?" | | `custom` | explicit narrow tool allowlist | inherits | inherits | inherits | hand-picked tools on the parent's posture | @@ -257,8 +257,8 @@ OUTPUT: VERDICT, EVIDENCE, GAPS, NEXT ``` `scout` briefs default to quick, read-only investigation (no writes, but -network reach and the bounded verification surface are available for real -scouting). About 3-5 tool calls +network reach and the classifier-gated read-only probe lane are available +for real scouting — no verification gates). About 3-5 tool calls is enough for quick exploration: orient, search, read the decisive lines, and return. Do not repeat `ALREADY_KNOWN` work unless evidence contradicts it. Review and verifier briefs can spend more calls, but should stop after decisive @@ -320,11 +320,16 @@ OUTPUT: VERDICT, EVIDENCE, GAPS, NEXT. on the test suite or other validation. Verifiers don't fix failures; they capture the failing assertion + stack and put fix candidates under RISKS. The verifier posture never writes; its shell - is the bounded built-in verification surface plus — on in-session - delegations whose envelope grants it — the same classifier-gated - read-only probe lane the inspection roles hold (`wc`, `ls`, `cat`, - `grep` pipelines) for gathering evidence; envelopes without the - lane omit it and shell attempts refuse. The + is the bounded built-in verification surface — refused for an + in-session child only when a read-only-shell parent clamps its + authority (the clamped child keeps the probe lane and loses the + gates) — plus, on in-session delegations whose envelope grants it, + the same classifier-gated read-only probe lane the inspection roles + hold (`wc`, `ls`, `cat`, `grep` pipelines) for gathering evidence; + envelopes without the lane omit it and shell attempts refuse. On + Fleet members the envelope is stricter still: bounded verification + and Bash are mutually exclusive there, so a fleet verifier holds the + gates without the probe lane. The write ceiling is read-only, unbounded shell forms are refused (#5186), and custom commands are refused — use a write-capable role for those. diff --git a/docs/zh_hans/SUBAGENTS.md b/docs/zh_hans/SUBAGENTS.md index a9dab671be..83b01da271 100644 --- a/docs/zh_hans/SUBAGENTS.md +++ b/docs/zh_hans/SUBAGENTS.md @@ -27,11 +27,11 @@ Fleet 角色是面向用户的委派工作词汇:父代理通过 `agent` 启 | 角色 | 姿态 | 可写? | 联网? | Shell 姿态 | 典型用途 | |---|---|---|---|---|---| | `worker` | 灵活;父代理说什么就做什么 | 是 | 是 | 是 | 默认角色;多步任务 | -| `scout` | 只读;快速摸清相关代码 | 否 | 是 | 只读(网络 + 有界验证) | "找到 `Foo` 的每个调用点;用 gh 检查这个 PR" | +| `scout` | 只读;快速摸清相关代码 | 否 | 是 | 只读(网络) | "找到 `Foo` 的每个调用点;用 gh 检查这个 PR" | | `planner` | 分析并产出策略 | 否 | 是 | 只读探针 | "设计迁移方案;不要执行" | | `reviewer` | 带严重度评分的阅读与评审 | 否 | 是 | 只读(网络) | "审计这个 PR 的 bug" | | `builder` | 以最小改动落地某个具体变更 | 是 | 是 | 是 | "把 `bar.rs::Foo::bar` 重写为做 X" | -| `verifier` | 运行内置验证门禁并报告结果(只读探针取证) | 否 | 是 | 内置门禁 + 只读探针(无自定义命令) | "用内置检查验证这个 diff,并报告 PASS/FAIL" | +| `verifier` | 运行内置验证门禁并报告结果(只读探针取证) | 否 | 是 | 内置门禁(被父代理钳制时仅剩探针)+ 只读探针(无自定义命令) | "用内置检查验证这个 diff,并报告 PASS/FAIL" | | `consultant` | 短期、高推理密度的咨询 | 否 | 是 | 无 | "这个设计我们漏掉了什么?" | | `custom` | 显式的窄工具 allowlist | 继承 | 继承 | 继承 | 在父代理姿态上精选的工具 | @@ -84,7 +84,7 @@ STOP_CONDITION: OUTPUT: VERDICT, EVIDENCE, GAPS, NEXT ``` -`scout` 简报默认为快速的只读调查(不写,但网络触达和有界验证面可用于真正的侦察)。约 3-5 次工具调用足以完成快速探索:定位、搜索、读取决定性代码行,然后返回。除非证据与之矛盾,否则不要重复 `ALREADY_KNOWN` 的工作。review 和 verifier 简报可以花更多调用,但应在拿到决定性证据后停止。builder 和修复型简报应在扩展示范围之前或反复失败之后设置检查点,而不是设置一个很小的调用上限。 +`scout` 简报默认为快速的只读调查(不写,但网络触达和分类器门控的只读探针通道可用于真正的侦察——不含验证门禁)。约 3-5 次工具调用足以完成快速探索:定位、搜索、读取决定性代码行,然后返回。除非证据与之矛盾,否则不要重复 `ALREADY_KNOWN` 的工作。review 和 verifier 简报可以花更多调用,但应在拿到决定性证据后停止。builder 和修复型简报应在扩展示范围之前或反复失败之后设置检查点,而不是设置一个很小的调用上限。 好的委派提示词示例: @@ -122,7 +122,7 @@ OUTPUT: VERDICT、EVIDENCE、GAPS、NEXT。 - **`planner`** —— 当父代理有目标但没有可执行的分解。planner 写工件(`todo_write` 条目、响应体里的策略),但不执行它们。 - **`reviewer`** —— 当已经有一个变更,父代理想要它被评分。reviewer 不打补丁——他们在发现里描述修复方案,这样如果判定是"修它",父代理可以派一个 builder。 - **`builder`** —— 当变更已经被明确指定、只需要落地。builder 保持严格的范围:最小改动,不做顺手重构,交回前跑一次快速验证。 -- **`verifier`** —— 当父代理需要测试套件或其他验证上的权威通过/失败结论。verifier 不修失败;他们记录失败的断言 + 栈,把修复候选放在 RISKS 下。verifier 姿态从不写入;其 shell 是有界内置验证面,加上——在授予该通道的会话内委派上——与检查角色相同的分类器门控只读探针通道(`wc`、`ls`、`cat`、`grep` 管道)用于取证;未授予该通道的 envelope 会省略它,shell 尝试一律拒绝。写入上限是只读,无界 shell 形式被拒绝(#5186),自定义命令也被拒绝——请改用具备写能力的角色。 +- **`verifier`** —— 当父代理需要测试套件或其他验证上的权威通过/失败结论。verifier 不修失败;他们记录失败的断言 + 栈,把修复候选放在 RISKS 下。verifier 姿态从不写入;其 shell 是有界内置验证面——对会话内子代理,只有当只读 shell 父代理钳制了其权限时才会被拒绝,被钳制的子代理保留探针通道、失去验证门禁——加上,在授予该通道的会话内委派上,与检查角色相同的分类器门控只读探针通道(`wc`、`ls`、`cat`、`grep` 管道)用于取证;未授予该通道的 envelope 会省略它,shell 尝试一律拒绝。Fleet 成员的 envelope 更严格:在那里有界验证与 Bash 权限互斥,因此 fleet verifier 只持有门禁、不带探针通道。写入上限是只读,无界 shell 形式被拒绝(#5186),自定义命令也被拒绝——请改用具备写能力的角色。 - **`consultant`** —— 当操作者想在更便宜的执行继续之前得到一个高杠杆的第二意见。consultant 读足够的材料来支撑一条建议,但不能写,也不能运行 shell 命令。`oracle` 和 `advisor` 只在加载更老的请求或持久化记录时被接受;新的提示词、回执和 UI 使用 `consultant`。 - **`custom`** —— 只有当父代理需要显式约束工具集时。通过 legacy/internal 子代理记录上的 `allowed_tools` 字段传 allowlist;面向模型的 `agent` 工具刻意保持公共 schema 很小。 From 253f5faaf50dab0d7ac65db6dd00b33d2c1d8a13 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 5 Nov 2026 02:41:17 +0800 Subject: [PATCH 3/4] fix(fleet): round-2 review fixes - publish the narrowed chain end-to-end and finish the copy sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the chain pin now drives hop 2 through the real spawn path: the carve-out verifier's agent tool starts a Builder and the recorded runtime_profile is the profile run_subagent derived from the runtime the registry published, so a publication-line regression (child_runtime.worker_profile reverted to the parent-runtime profile) reddens it; - the agent tool description's test blurb states the real contract (built-in verification gates, custom commands refused) instead of the unconditioned "runs tests" family, with the canonical-vocabulary pin extended; - the diff drops the file-wide blank-line churn in subagent/tests.rs (570 pure blank-line insertions that no formatter requires); - comment honesty: the fleet receipt test names the verifier's real bash-less fleet surface, the executor envelope comment scopes the probe lane to in-session delegations, and worker_profile's scout arm no longer claims the bounded verification surface; - docs: the zh verifier bullet and zh FLEET envelope paragraph use the established 信封 term and carry the envelope-grant qualifier, and the ChildAuthority::clamp path reference points at fleet/role.rs (en+zh). Round-2 fresh 6-lane review found no functional, security, or performance defect and no smuggled scope; these are its actionable findings. Signed-off-by: asto18089 --- crates/tui/src/fleet/executor.rs | 11 +- crates/tui/src/fleet/manager.rs | 15 +- crates/tui/src/tools/subagent/mod.rs | 2 +- crates/tui/src/tools/subagent/tests.rs | 668 +++---------------------- crates/tui/src/worker_profile.rs | 9 +- docs/SUBAGENTS.md | 2 +- docs/zh_hans/FLEET.md | 2 +- docs/zh_hans/SUBAGENTS.md | 4 +- 8 files changed, 100 insertions(+), 613 deletions(-) diff --git a/crates/tui/src/fleet/executor.rs b/crates/tui/src/fleet/executor.rs index a82ee4eacc..3871f1a970 100644 --- a/crates/tui/src/fleet/executor.rs +++ b/crates/tui/src/fleet/executor.rs @@ -244,10 +244,13 @@ pub(crate) fn authority_envelope_for_worker( { // Scout, Reviewer, and Planner get one classifier-bounded // foreground shell. On this executor path the Verifier keeps only - // the dedicated Run surface below (the in-process registry - // additionally admits it to the classifier-bounded `bash` evidence - // lane — see `SubAgentToolRegistry::allows_bounded_readonly_bash`), - // consultants remain shell-less, and writers retain the historical + // the dedicated Run surface below. (The classifier-bounded `bash` + // evidence lane named by + // `SubAgentToolRegistry::allows_bounded_readonly_bash` is an + // in-session delegation surface: a fleet member spawned here has no + // in-process subagent registry at all, so it never holds that lane — + // see the fleet mutual-exclusivity note in docs/SUBAGENTS.md.) + // Consultants remain shell-less, and writers retain the historical // subprocess contract. ToolShellAuthority::ReadOnly } else { diff --git a/crates/tui/src/fleet/manager.rs b/crates/tui/src/fleet/manager.rs index 35a3ae2f23..8a2df63982 100644 --- a/crates/tui/src/fleet/manager.rs +++ b/crates/tui/src/fleet/manager.rs @@ -4884,12 +4884,15 @@ esac !permissions.write, "verifier receipt {key} must stay read-only" ); - // The verifier joined the read-only-shell roles: its bash - // is the classifier-bounded probe lane, and the receipt - // records that effective posture (the old `full` label - // overclaimed a raw shell the clamp always denied). The - // built-in verification surface stays keyed on the shell - // ceiling, not on this label. + // The verifier joined the read-only-shell roles, so the + // receipt records the narrowed effective posture (the old + // `full` label overclaimed a raw shell the clamp always + // denied). On the fleet envelope the verifier holds the + // gates with no bash surface at all — the classifier + // probe lane is in-session only; in-session, its bash is + // the classifier-bounded lane. The built-in verification + // surface stays keyed on the shell ceiling, not on this + // label. assert_eq!(permissions.shell, "read_only"); } role => panic!("unexpected receipt role for {key}: {role:?}"), diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 20f899b09d..881bad346f 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -9513,7 +9513,7 @@ impl ToolSpec for AgentTool { concat!( "Start with action=start and prompt; returns a turn-owned agent_id immediately. A prompt-only start (no type/profile) launches read-only; pass a write-capable type (e.g. implement) or declare write_roots before write work. Read-only roles need no extra fields. Set detached=true only for work that must remain independently observable after the turn. ", "Use multiple starts for independent parallel tasks. ", - "type selects the Fleet role: general (full tool access), explore (fast read-only exploration), planner (grounded strategy, read-only probes), reviewer (reads and grades code), implement (lands focused code changes), test (runs tests and reports evidence), advisor (read-only design counsel), or custom (allowed_tools on the parent's posture); legacy aliases are still accepted. ", + "type selects the Fleet role: general (full tool access), explore (fast read-only exploration), planner (grounded strategy, read-only probes), reviewer (reads and grades code), implement (lands focused code changes), test (built-in verification gates; custom commands refused), advisor (read-only design counsel), or custom (allowed_tools on the parent's posture); legacy aliases are still accepted. ", "profile runs the child as a named Fleet role or an exact prompt-only profile explicitly presented by the embedding host — pass it only when the task needs that identity. The child follows the operator's per-role model configuration, else the parent's model; model or thinking overrides are not advertised (parse-accepted for compatibility). ", "Use action=roster to inspect Fleet roles and host-presented prompt-only profiles before choosing a type or profile. The host-profile listing is capped and unpaged — search a large pool with profile_query. ", "Child run budgets come from Fleet role defaults and operator [subagents] config; budget fields are not advertised (parse-accepted for compatibility). ", diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 1362726c67..38d7d5220c 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -11,7 +11,6 @@ use tempfile::{Builder as TempDirBuilder, tempdir}; mod launch_receipt; mod operation_identity; - fn built_in_whale_name_that_cannot_be_generated_for(agent_id: &str) -> &'static str { WHALE_NICKNAMES .iter() @@ -32,7 +31,6 @@ fn built_in_whale_name_that_cannot_be_generated_for(agent_id: &str) -> &'static .find(|name| generated_whale_name_base(agent_id, name).is_none()) .expect("the combined pools contain labels not generated for one id") } - #[test] fn generated_whale_names_follow_session_language_without_mixing() { let localized_pools: &[(&str, &[&str])] = &[ @@ -82,7 +80,6 @@ fn generated_whale_names_follow_session_language_without_mixing() { whale_name_for_id_in_locale("fallback", "en") ); } - #[test] fn locale_matched_whale_collision_suffix_stays_in_language() { let id = "agent_locale_collision"; @@ -94,7 +91,6 @@ fn locale_matched_whale_collision_suffix_stays_in_language() { assert!(unique.starts_with(&base)); assert!(!unique.is_ascii()); } - #[test] fn localized_whale_displays_rederive_legacy_names_from_neutral_ids() { let generated_a = whale_name_for_id_in_locale("agent_english_a", "zh-Hans"); @@ -137,7 +133,6 @@ fn localized_whale_displays_rederive_legacy_names_from_neutral_ids() { "a built-in whale word belonging to another id remains user-owned" ); } - #[test] fn exact_deterministic_whale_match_remains_generated_without_provenance() { let agent_id = "agent_ambiguous_whale"; @@ -154,11 +149,9 @@ fn exact_deterministic_whale_match_remains_generated_without_provenance() { "a collision suffix remains presentation-only" ); } - fn make_assignment() -> SubAgentAssignment { SubAgentAssignment::new("prompt".to_string(), Some("worker".to_string())) } - fn make_snapshot(status: SubAgentStatus) -> SubAgentResult { SubAgentResult { name: "agent_test".to_string(), @@ -186,7 +179,6 @@ fn make_snapshot(status: SubAgentStatus) -> SubAgentResult { from_prior_session: false, } } - #[test] fn forkguard_interrupted_snapshot_preserves_checkpoint_recovery() { let _spill = crate::tools::truncate::TEST_SPILLOVER_GUARD @@ -256,7 +248,6 @@ fn forkguard_interrupted_snapshot_preserves_checkpoint_recovery() { } }); } - fn make_worker_spec(worker_id: &str, workspace: PathBuf) -> AgentWorkerSpec { let tool_profile = AgentWorkerToolProfile::Explicit(vec!["read_file".to_string(), "grep_files".to_string()]); @@ -287,7 +278,6 @@ fn make_worker_spec(worker_id: &str, workspace: PathBuf) -> AgentWorkerSpec { launch_manifest: None, } } - fn make_write_worker_spec(worker_id: &str, workspace: PathBuf, root: &str) -> AgentWorkerSpec { let mut spec = make_worker_spec(worker_id, workspace.clone()); spec.agent_type = FleetRole::Builder; @@ -312,7 +302,6 @@ fn make_write_worker_spec(worker_id: &str, workspace: PathBuf, root: &str) -> Ag }); spec } - #[test] fn child_approval_ids_stay_unique_within_and_across_manager_boots() { // #5615: the per-manager sequence restarts at zero, so a resumed agent @@ -353,7 +342,6 @@ fn child_approval_ids_stay_unique_within_and_across_manager_boots() { ); } } - #[test] fn active_worker_records_are_never_pruned_by_history_retention() { let tmp = tempdir().expect("tempdir"); @@ -377,7 +365,6 @@ fn active_worker_records_are_never_pruned_by_history_retention() { .all(|record| !record.status.is_terminal()) ); } - #[test] fn headless_worker_record_tracks_lifecycle_without_tui_projection() { let tmp = tempdir().expect("tempdir"); @@ -482,7 +469,6 @@ fn headless_worker_record_tracks_lifecycle_without_tui_projection() { .any(|event| event.tool_name.as_deref() == Some("read_file")) ); } - #[test] fn worker_record_usage_accumulates_provider_tokens() { let tmp = tempdir().expect("tempdir"); @@ -545,7 +531,6 @@ fn worker_record_usage_accumulates_provider_tokens() { record.usage.note ); } - #[test] fn token_budget_scope_is_shared_across_nested_workers_and_blocks_when_spent() { let tmp = tempdir().expect("tempdir"); @@ -616,7 +601,6 @@ fn token_budget_scope_is_shared_across_nested_workers_and_blocks_when_spent() { assert_eq!(override_scope.limit, 20); assert_eq!(override_scope.spent, 0); } - #[test] fn agent_worker_profile_derives_from_parent_without_escalation() { let mut runtime = stub_runtime(); @@ -657,7 +641,6 @@ fn agent_worker_profile_derives_from_parent_without_escalation() { ToolScope::Explicit(vec!["read_file".to_string(), "write_file".to_string()]) ); } - #[test] fn declared_read_only_write_roles_derive_without_mutating_shell() { for input in [ @@ -685,7 +668,6 @@ fn declared_read_only_write_roles_derive_without_mutating_shell() { assert_eq!(profile.shell, ShellPolicy::None, "{request:?}"); } } - #[test] fn custom_runtime_inherits_the_parent_posture_and_explicit_authority_is_a_no_op_superset() { let runtime = stub_runtime().background_runtime(); @@ -733,7 +715,6 @@ fn custom_runtime_inherits_the_parent_posture_and_explicit_authority_is_a_no_op_ "scout parents carry the read-only inspection posture" ); } - #[test] fn subagent_progress_displays_legacy_shell_names_as_lowercase_bash() { assert_eq!(subagent_progress_tool_display_name("exec_shell"), "bash"); @@ -751,7 +732,6 @@ fn subagent_progress_displays_legacy_shell_names_as_lowercase_bash() { "read_file" ); } - #[test] fn agent_progress_preserves_event_channel_headroom_under_load() { let (tx, mut rx) = mpsc::channel(40); @@ -802,7 +782,6 @@ fn agent_progress_preserves_event_channel_headroom_under_load() { )); assert!(rx.try_recv().is_err()); } - #[test] fn agent_progress_uses_small_event_channels_without_headroom_reservation() { let (tx, mut rx) = mpsc::channel(8); @@ -827,7 +806,6 @@ fn agent_progress_uses_small_event_channels_without_headroom_reservation() { && activity.step == Some(1) )); } - #[test] fn headless_worker_records_persist_with_subagent_state() { let tmp = tempdir().expect("tempdir"); @@ -867,7 +845,6 @@ fn headless_worker_records_persist_with_subagent_state() { .any(|event| event.status == AgentWorkerStatus::Failed) ); } - #[test] fn persisted_subagent_state_has_bounded_serialized_size() { // #3885 / item 4: `subagents.v1.json` is transitively bounded but had no @@ -915,7 +892,6 @@ fn persisted_subagent_state_has_bounded_serialized_size() { "all registered workers should be persisted" ); } - #[test] fn coordination_ledger_persists_and_replays_bounded_contracts() { let tmp = tempdir().expect("tempdir"); @@ -967,7 +943,6 @@ fn coordination_ledger_persists_and_replays_bounded_contracts() { assert_eq!(inspect["decisions"][0]["decision_id"], "decision_storage"); assert_eq!(inspect["write_claims"][0]["claim"]["owner"], "agent_writer"); } - #[test] fn invalid_decision_and_reconciliation_inputs_cannot_poison_persisted_replay() { let tmp = tempdir().expect("tempdir"); @@ -1059,7 +1034,6 @@ fn invalid_decision_and_reconciliation_inputs_cannot_poison_persisted_replay() { assert_eq!(replayed.coordination.decisions.len(), 2); assert!(replayed.coordination.reconciliations.is_empty()); } - #[test] fn coordination_hot_paths_count_only_active_authoritative_owners() { let tmp = tempdir().expect("tempdir"); @@ -1098,7 +1072,6 @@ fn coordination_hot_paths_count_only_active_authoritative_owners() { }] ); } - #[test] fn headless_worker_registration_enforces_live_claims_and_projects_context() { let tmp = tempdir().expect("tempdir"); @@ -1179,7 +1152,6 @@ fn headless_worker_registration_enforces_live_claims_and_projects_context() { .expect("non-overlapping workers may proceed concurrently"); assert_eq!(manager.coordination.write_claims.len(), 2); } - #[test] fn session_close_finalizes_live_fleet_and_releases_write_claims() { let tmp = tempdir().expect("tempdir"); @@ -1253,7 +1225,6 @@ fn session_close_finalizes_live_fleet_and_releases_write_claims() { )) .expect("claim stays released after a second close pass"); } - #[test] fn terminal_result_releases_owner_from_write_claim_contention() { let tmp = tempdir().expect("tempdir"); @@ -1313,7 +1284,6 @@ fn terminal_result_releases_owner_from_write_claim_contention() { )) .expect("overlapping claim admitted after terminal result"); } - #[test] fn prior_session_owners_never_count_as_active_coordination_owners() { let tmp = tempdir().expect("tempdir"); @@ -1356,7 +1326,6 @@ fn prior_session_owners_never_count_as_active_coordination_owners() { )) .expect("prior-session owner must not block a new writer"); } - #[test] fn isolated_worktree_workers_skip_the_coordination_process_lock() { let tmp = tempdir().expect("tempdir"); @@ -1401,7 +1370,6 @@ fn isolated_worktree_workers_skip_the_coordination_process_lock() { .preflight_worker_coordination(&shared) .expect("shared-workspace writer proceeds once the lock is held"); } - #[test] fn coordination_detail_projection_reports_process_lock_ownership() { let tmp = tempdir().expect("tempdir"); @@ -1430,7 +1398,6 @@ fn coordination_detail_projection_reports_process_lock_ownership() { "shared projection carries no note" ); } - /// A second Codewhale session in the same workspace is ordinary usage. Losing /// the coordination flock means "I cannot append to the shared ledger" — it /// must never mean "my agents are dead". Bookkeeping failure and liveness are @@ -1485,7 +1452,6 @@ fn a_second_session_without_the_lock_keeps_its_running_agents_live() { snap.status ); } - /// A second session shares the workspace coordination read-lock and must load /// the existing ledger before it persists any of its own state. That preserves /// prior decisions instead of replacing them with an empty ledger. @@ -1555,7 +1521,6 @@ fn a_second_session_with_the_shared_lock_loads_the_workspace_ledger() { "the second session must not overwrite the workspace ledger with an empty one" ); } - /// The complement of the test above: an orphan still terminalizes, but only on /// heartbeat evidence. Lock ownership is not part of the decision either way. #[test] @@ -1624,7 +1589,6 @@ fn cleanup_terminalizes_orphans_on_heartbeat_evidence_not_on_lock_ownership() { "a heartbeat-stale orphan still becomes Interrupted" ); } - #[test] fn neutral_reconciliation_requires_the_nearest_common_planner() { let tmp = tempdir().expect("tempdir"); @@ -1709,7 +1673,6 @@ fn neutral_reconciliation_requires_the_nearest_common_planner() { assert_eq!(receipt.retry_limit, 3); assert_eq!(receipt.candidate_handles.len(), 2); } - #[test] fn coordination_acceptance_preserves_scopes_candidates_and_replay() { let repo = init_subagent_git_repo(); @@ -2004,7 +1967,6 @@ fn coordination_acceptance_preserves_scopes_candidates_and_replay() { "pub const B: u8 = 2;" ); } - fn init_subagent_git_repo() -> tempfile::TempDir { let dir = tempdir().expect("tempdir"); @@ -2054,7 +2016,6 @@ fn init_subagent_git_repo() -> tempfile::TempDir { dir } - fn git(repo: &Path, args: &[&str]) { let output = Command::new("git") .args(args) @@ -2067,7 +2028,6 @@ fn git(repo: &Path, args: &[&str]) { String::from_utf8_lossy(&output.stderr) ); } - fn git_stdout(repo: &Path, args: &[&str]) -> String { let output = Command::new("git") .args(args) @@ -2081,7 +2041,6 @@ fn git_stdout(repo: &Path, args: &[&str]) -> String { ); String::from_utf8_lossy(&output.stdout).trim().to_string() } - fn text_message(role: &str, text: &str) -> Message { Message { role: Role::from(role), @@ -2091,18 +2050,15 @@ fn text_message(role: &str, text: &str) -> Message { }], } } - fn make_checkpoint(agent_id: &str, steps_taken: u32, messages: Vec) -> SubAgentCheckpoint { build_subagent_checkpoint(agent_id, "test_checkpoint", &messages, steps_taken, true) } - fn message_text(message: &Message) -> &str { match message.content.first() { Some(ContentBlock::Text { text, .. }) => text.as_str(), other => panic!("expected text content block, got {other:?}"), } } - async fn delayed_chat_client( first_delay: Duration, response_text: &str, @@ -2170,7 +2126,6 @@ async fn delayed_chat_client( let client = DeepSeekClient::new(&config).expect("fake chat client"); (client, calls, bodies) } - #[tokio::test] async fn detached_interactive_usage_after_mailbox_seal_reaches_session_accounting() { let _cost_scope = crate::cost_status::test_scope(); @@ -2322,7 +2277,6 @@ async fn detached_interactive_usage_after_mailbox_seal_reaches_session_accountin ); } } - /// Like [`delayed_chat_client`] but delays *every* attempt, so the per-step /// API timeout fires on the first call and on every retry — the shape needed /// to drive the timeout-retry budget to exhaustion. @@ -2380,7 +2334,6 @@ async fn always_delayed_chat_client( let client = DeepSeekClient::new(&config).expect("fake always-slow chat client"); (client, calls) } - #[tokio::test] async fn tool_free_subagent_omits_chat_tools_and_tool_choice() { let tmp = tempdir().expect("tempdir"); @@ -2422,7 +2375,6 @@ async fn tool_free_subagent_omits_chat_tools_and_tool_choice() { "tool_choice must be omitted: {body}" ); } - async fn transient_header_timeout_then_success_chat_client( response_text: &str, ) -> (DeepSeekClient, Arc) { @@ -2487,7 +2439,6 @@ async fn transient_header_timeout_then_success_chat_client( let client = DeepSeekClient::new(&config).expect("fake transient chat client"); (client, calls) } - async fn always_rate_limited_chat_client() -> (DeepSeekClient, Arc) { let calls = Arc::new(AtomicUsize::new(0)); let app = Router::new().route( @@ -2536,7 +2487,6 @@ async fn always_rate_limited_chat_client() -> (DeepSeekClient, Arc) let client = DeepSeekClient::new(&config).expect("fake rate-limited chat client"); (client, calls) } - async fn always_invalid_request_chat_client() -> (DeepSeekClient, Arc) { let calls = Arc::new(AtomicUsize::new(0)); let app = Router::new().route( @@ -2586,11 +2536,9 @@ async fn always_invalid_request_chat_client() -> (DeepSeekClient, Arc usize { text.chars().count().div_ceil(3) } - #[test] fn test_agent_type_from_str() { assert_eq!(FleetRole::from_str("general"), Some(FleetRole::Worker)); @@ -2606,7 +2554,6 @@ fn test_agent_type_from_str() { assert_eq!(FleetRole::from_str("awaiter"), Some(FleetRole::Planner)); assert_eq!(FleetRole::from_str("invalid"), None); } - #[test] fn test_agent_type_implementer_aliases() { // #404 — Builder accepts the obvious legacy aliases the model is @@ -2621,7 +2568,6 @@ fn test_agent_type_implementer_aliases() { // Case-insensitive. assert_eq!(FleetRole::from_str("IMPLEMENTER"), Some(FleetRole::Builder)); } - #[test] fn test_agent_type_verifier_aliases() { // #404 — Verifier accepts test/validate aliases distinct from @@ -2635,7 +2581,6 @@ fn test_agent_type_verifier_aliases() { } assert_eq!(FleetRole::from_str("VERIFY"), Some(FleetRole::Verifier)); } - #[test] fn test_agent_type_round_trips_via_as_str() { // Every type should serialize to a string that round-trips back @@ -2657,7 +2602,6 @@ fn test_agent_type_round_trips_via_as_str() { assert_eq!(back, t, "round-trip failed for {t:?} via {label:?}"); } } - #[test] fn fleet_role_labels_are_canonical_while_legacy_snapshot_wire_stays_readable() { assert_eq!(FleetRole::Scout.as_str(), "explore"); @@ -2688,7 +2632,6 @@ fn fleet_role_labels_are_canonical_while_legacy_snapshot_wire_stays_readable() { "\"explore\"" ); } - #[test] fn fleet_role_deserialize_rejects_unknown_values_with_canonical_hint() { // Unknown role tokens fail closed at the serde boundary, and the error @@ -2724,7 +2667,6 @@ fn fleet_role_deserialize_rejects_unknown_values_with_canonical_hint() { "error must not advertise legacy aliases: {message}" ); } - #[test] fn write_capable_children_get_verify_contract_in_system_prompt() { let implementer = build_subagent_system_prompt( @@ -2750,7 +2692,6 @@ fn write_capable_children_get_verify_contract_in_system_prompt() { "read-only scout must not get write verify contract" ); } - #[test] fn test_implementer_and_verifier_have_distinct_prompts() { // The whole point of adding the types is that they carry distinct @@ -2786,7 +2727,6 @@ fn test_implementer_and_verifier_have_distinct_prompts() { "Verifier prompt should reference its role: {verifier}" ); } - #[test] fn test_agent_type_prompts_include_shared_output_contract_once() { for (agent_type, marker) in [ @@ -2828,7 +2768,6 @@ fn test_agent_type_prompts_include_shared_output_contract_once() { } } } - #[test] fn explore_prompt_orients_before_searching() { let prompt = FleetRole::Scout.system_prompt(); @@ -2837,7 +2776,6 @@ fn explore_prompt_orients_before_searching() { assert!(prompt.contains("workspace/project root")); assert!(prompt.contains("compressed evidence")); } - #[test] fn explore_prompt_is_quick_bounded_and_read_only() { let prompt = FleetRole::Scout.system_prompt(); @@ -2857,7 +2795,6 @@ fn explore_prompt_is_quick_bounded_and_read_only() { assert!(reviewer.contains("allowed read-only navigation/rg")); assert!(reviewer.contains("shell control actions are unavailable")); } - #[test] fn implementer_prompt_is_not_forced_into_explorer_cap() { let prompt = FleetRole::Builder.system_prompt(); @@ -2865,7 +2802,6 @@ fn implementer_prompt_is_not_forced_into_explorer_cap() { assert!(prompt.contains("Checkpoint before expanding scope")); assert!(!prompt.contains("Default to `EFFORT: quick`")); } - #[test] fn role_prompts_use_lowercase_primitive_contract() { let explore = FleetRole::Scout.system_prompt(); @@ -2886,7 +2822,6 @@ fn role_prompts_use_lowercase_primitive_contract() { } } } - #[test] fn review_and_verifier_prompts_stop_after_decisive_evidence() { let review = FleetRole::Reviewer.system_prompt(); @@ -2895,7 +2830,6 @@ fn review_and_verifier_prompts_stop_after_decisive_evidence() { assert!(review.contains("private `todo_write` list as editable working notes")); assert!(verifier.contains("stop after decisive pass/fail evidence")); } - #[test] fn child_artifact_copy_surfaces_working_notes_in_the_complete_transcript() { let artifacts = default_subagent_artifacts("agent_scout_notes"); @@ -2907,7 +2841,6 @@ fn child_artifact_copy_surfaces_working_notes_in_the_complete_transcript() { assert!(transcript.description.contains("todo_write working notes")); assert!(transcript.description.contains("transcript_handle")); } - #[test] fn agent_description_explains_background_child_and_transcript_handle() { let tmp = tempdir().expect("tempdir"); @@ -2930,7 +2863,7 @@ fn agent_description_explains_background_child_and_transcript_handle() { "planner (grounded strategy", "reviewer (reads and grades code)", "implement (lands focused code changes)", - "test (runs tests and reports evidence)", + "test (built-in verification gates; custom commands refused)", "advisor (read-only design counsel)", "custom (allowed_tools", "legacy aliases are still accepted", @@ -2977,7 +2910,6 @@ fn agent_description_explains_background_child_and_transcript_handle() { description.chars().count() ); } - #[test] fn stringy_deliberate_is_refused_not_dropped() { // A stringy "true" used to coerce to the default `false`, which skipped @@ -2994,7 +2926,6 @@ fn stringy_deliberate_is_refused_not_dropped() { "error must name expected and received types: {err}" ); } - #[test] fn bare_string_disallowed_tools_is_refused_not_dropped() { // A restriction the harness silently drops is worse than one never @@ -3022,7 +2953,6 @@ fn bare_string_disallowed_tools_is_refused_not_dropped() { "trimming and de-duplication survive the strict parse" ); } - #[test] fn spawn_parameters_refuse_type_mismatches_by_name() { // A representative sample across the parameter kinds the spawn parser @@ -3069,7 +2999,6 @@ fn spawn_parameters_refuse_type_mismatches_by_name() { })) .expect("explicit nulls read as absent"); } - #[test] fn deliberate_spawn_requires_delegation_fields() { let missing = parse_spawn_request(&json!({ @@ -3101,7 +3030,6 @@ fn deliberate_spawn_requires_delegation_fields() { "workspace_policy shared must not materialize a worktree" ); } - #[test] fn declared_workspace_policy_worktree_materializes_a_worktree_request() { // TUI-DOG-017: a declared policy must be enforced, not decorative. The @@ -3126,7 +3054,6 @@ fn declared_workspace_policy_worktree_materializes_a_worktree_request() { "shared policy plus explicit worktree must fail closed" ); } - #[test] fn builder_plus_read_only_authority_fails_closed() { // #5123: never launch a labeled builder that will only get read-only inspection tools. @@ -3145,7 +3072,6 @@ fn builder_plus_read_only_authority_fails_closed() { ); } } - #[test] fn read_only_worker_is_an_ordinary_general_child() { // Worker is the unnamed default (it renders as "general"); its capability @@ -3161,7 +3087,6 @@ fn read_only_worker_is_an_ordinary_general_child() { assert_eq!(request.agent_type, FleetRole::Worker); assert_eq!(request.write_authority, Some(SpawnWriteAuthority::ReadOnly)); } - #[test] fn roster_role_plus_read_only_authority_still_spawns() { // #5123 fail-closed must not swallow the read-only roster task. `role` is @@ -3194,7 +3119,6 @@ fn roster_role_plus_read_only_authority_still_spawns() { "a role alias resolves the type without claiming write capability" ); } - #[test] fn declared_write_authority_parses_and_worktree_write_requires_isolation() { let read_only = parse_spawn_request(&json!({ @@ -3259,7 +3183,6 @@ fn declared_write_authority_parses_and_worktree_write_requires_isolation() { .expect("explicit bounded custom write parses"); assert!(spawn_request_is_write_capable(&custom_writer)); } - #[test] fn prompt_only_general_children_default_read_only_instead_of_claiming_the_repo() { let request = parse_spawn_request(&json!({ @@ -3302,7 +3225,6 @@ fn prompt_only_general_children_default_read_only_instead_of_claiming_the_repo() .expect("resolved write-capable fleet role defaults write scope to parent workspace"); assert_eq!(fleet_role.write_roots, vec![".".to_string()]); } - #[test] fn read_only_roles_reject_write_authority_but_implementers_can_be_narrowed() { let reviewer = parse_spawn_request(&json!({ @@ -3331,7 +3253,6 @@ fn read_only_roles_reject_write_authority_but_implementers_can_be_narrowed() { Some(SpawnWriteAuthority::ReadOnly) ); } - /// #4752: Consultant must be a role, not a special-cased code path — so it has to /// travel the same spawn/schema machinery as every other role, and be refused /// write authority by the same guard that refuses reviewer. @@ -3354,7 +3275,6 @@ fn consultant_spawns_as_a_first_class_read_only_role() { .to_string(); assert!(escalation.contains("read-only role"), "{escalation}"); } - #[test] fn direct_consultant_aliases_apply_role_reasoning_default_after_inheritance() { for parent_effort in [None, Some("low")] { @@ -3440,7 +3360,6 @@ fn direct_consultant_aliases_apply_role_reasoning_default_after_inheritance() { "first-party DeepSeek keeps an explicit low child effort" ); } - /// The role name has to survive the wire, or receipts and resumed sessions /// silently reclassify a consultant as the default worker. #[test] @@ -3473,7 +3392,6 @@ fn consultant_round_trips_canonically_and_accepts_compatibility_aliases() { assert!(!FLEET_ROLE_SCHEMA_VALUES.contains(&"oracle")); assert!(FLEET_ROLE_SCHEMA_VALUES.contains(&"advisor")); } - #[test] fn declared_write_scope_is_normalized_and_rejects_traversal() { let request = parse_spawn_request(&json!({ @@ -3503,7 +3421,6 @@ fn declared_write_scope_is_normalized_and_rejects_traversal() { "{err}" ); } - #[test] fn shared_child_cwd_claims_use_one_root_namespace_for_collisions_and_mutations() { let repo = tempdir().expect("repo"); @@ -3586,7 +3503,6 @@ fn shared_child_cwd_claims_use_one_root_namespace_for_collisions_and_mutations() "{outside}" ); } - #[test] fn resident_file_context_is_workspace_relative_bounded_and_exclusive() { let tmp = tempdir().expect("tempdir"); @@ -3655,7 +3571,6 @@ fn resident_file_context_is_workspace_relative_bounded_and_exclusive() { rollback_pending_resident_lease(&resident.lease_key); rollback_pending_resident_lease(&other.lease_key); } - #[cfg(unix)] #[test] fn resident_file_context_rejects_symlink_aliases() { @@ -3669,13 +3584,11 @@ fn resident_file_context_rejects_symlink_aliases() { .to_string(); assert!(error.contains("must not traverse symlinks"), "{error}"); } - #[test] fn new_session_tools_use_single_agent_name() { let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 1))); assert_eq!(AgentTool::new(manager, stub_runtime()).name(), "agent"); } - #[test] fn test_parse_spawn_request_accepts_message_and_agent_type_aliases() { let input = json!({ @@ -3687,7 +3600,6 @@ fn test_parse_spawn_request_accepts_message_and_agent_type_aliases() { assert_eq!(parsed.agent_type, FleetRole::Scout); assert_eq!(parsed.assignment.role.as_deref(), Some("explore")); } - #[test] fn test_parse_spawn_request_accepts_objective_and_role_alias() { let input = json!({ @@ -3699,7 +3611,6 @@ fn test_parse_spawn_request_accepts_objective_and_role_alias() { assert_eq!(parsed.agent_type, FleetRole::Planner); assert_eq!(parsed.assignment.role.as_deref(), Some("planner")); } - #[test] fn test_parse_spawn_request_accepts_items_payload() { let input = json!({ @@ -3714,7 +3625,6 @@ fn test_parse_spawn_request_accepts_items_payload() { assert!(parsed.prompt.contains("[mention:$drive](app://drive)")); assert_eq!(parsed.agent_type, FleetRole::Scout); } - #[test] fn test_parse_spawn_request_accepts_fork_context() { let input = json!({ @@ -3736,7 +3646,6 @@ fn test_parse_spawn_request_accepts_fork_context() { let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert_eq!(parsed.fork_context, None); } - #[test] fn test_parse_spawn_request_accepts_model_strength() { let input = json!({ @@ -3755,7 +3664,6 @@ fn test_parse_spawn_request_accepts_model_strength() { let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert_eq!(parsed.model_strength, SubAgentModelStrength::Same); } - #[test] fn explore_subagent_inherits_active_model_by_default() { // Role names never silently change the model. A Fleet without custom @@ -3789,7 +3697,6 @@ fn explore_subagent_inherits_active_model_by_default() { assert_eq!(parsed.agent_type, FleetRole::Scout); assert_eq!(parsed.model_strength, SubAgentModelStrength::Same); } - #[test] fn non_explore_subagents_keep_default_same_model_strength() { // Non-explore roles keep the conservative Same default even with no model. @@ -3809,7 +3716,6 @@ fn non_explore_subagents_keep_default_same_model_strength() { ); } } - #[test] fn test_parse_spawn_request_accepts_child_thinking() { let input = json!({ @@ -3839,7 +3745,6 @@ fn test_parse_spawn_request_accepts_child_thinking() { let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert_eq!(parsed.thinking, SubAgentThinking::Auto); } - #[test] fn test_parse_spawn_request_rejects_invalid_model_strength() { let input = json!({ @@ -3852,7 +3757,6 @@ fn test_parse_spawn_request_rejects_invalid_model_strength() { .contains("model_strength must be one of: same, faster") ); } - #[test] fn test_parse_spawn_request_rejects_invalid_child_thinking() { let input = json!({ @@ -3865,7 +3769,6 @@ fn test_parse_spawn_request_rejects_invalid_child_thinking() { .contains("thinking must be one of: inherit, auto, off, low, medium, high, max") ); } - #[test] fn test_parse_spawn_request_accepts_session_name_for_agent() { let input = json!({ @@ -3879,7 +3782,6 @@ fn test_parse_spawn_request_accepts_session_name_for_agent() { assert_eq!(parsed.fork_context, Some(true)); assert_eq!(parsed.max_depth, Some(0)); } - #[test] fn test_parse_spawn_request_rejects_invalid_session_name() { let input = json!({ @@ -3889,7 +3791,6 @@ fn test_parse_spawn_request_rejects_invalid_session_name() { let err = parse_spawn_request(&input).expect_err("space in name should fail"); assert!(err.to_string().contains("name must not contain whitespace")); } - #[test] fn test_parse_spawn_request_rejects_out_of_range_max_depth() { let ceiling = codewhale_config::MAX_SPAWN_DEPTH_CEILING; @@ -3904,7 +3805,6 @@ fn test_parse_spawn_request_rejects_out_of_range_max_depth() { .contains(&format!("max_depth must be between 0 and {ceiling}")) ); } - #[test] fn test_parse_spawn_request_accepts_profile_and_preserves_safe_selector() { let input = json!({ @@ -3931,7 +3831,6 @@ fn test_parse_spawn_request_accepts_profile_and_preserves_safe_selector() { .expect("human model label should parse"); assert_eq!(parsed.profile.as_deref(), Some("DeepSeek V4 Flash")); } - #[test] fn test_parse_spawn_request_rejects_invalid_profile_token() { for bad in ["reviewer\nscout", "reviewer\tscout"] { @@ -3948,7 +3847,6 @@ fn test_parse_spawn_request_rejects_invalid_profile_token() { .expect_err("oversized selector should fail"); assert!(err.to_string().contains("at most 128"), "{err}"); } - #[tokio::test] async fn agent_roster_action_and_spawn_resolve_the_same_roles() { let tmp = tempdir().expect("tempdir"); @@ -3997,7 +3895,6 @@ async fn agent_roster_action_and_spawn_resolve_the_same_roles() { assert_eq!(request.agent_type, FleetRole::Scout); assert_eq!(request.profile.as_deref(), Some("explore")); } - #[tokio::test] async fn agent_roster_lists_host_presented_profiles_spawn_resolves() { let tmp = tempdir().expect("tempdir"); @@ -4069,7 +3966,6 @@ async fn agent_roster_lists_host_presented_profiles_spawn_resolves() { assert_eq!(request.profile.as_deref(), Some(profile_id)); } } - #[tokio::test] async fn agent_roster_truncates_host_profiles_at_the_listing_cap() { let tmp = tempdir().expect("tempdir"); @@ -4114,7 +4010,6 @@ async fn agent_roster_truncates_host_profiles_at_the_listing_cap() { // Descriptions fall back to the role name when the profile carries none. assert_eq!(host_profiles[0]["description"], json!("exp-host-000")); } - #[tokio::test] async fn agent_roster_profile_query_discovers_members_beyond_the_listing_cap() { // The T7 discovery-contract regression: a spawnable profile past @@ -4223,7 +4118,6 @@ async fn agent_roster_profile_query_discovers_members_beyond_the_listing_cap() { assert_eq!(resolved.id, "exp-host-049"); assert_eq!(request.profile.as_deref(), Some("exp-host-049")); } - #[tokio::test] async fn agent_roster_never_lists_ids_that_spawn_a_different_profile() { // `[fleet.profiles]` is a byte-order BTreeMap, so `Expert`, `expert`, and @@ -4304,7 +4198,6 @@ async fn agent_roster_never_lists_ids_that_spawn_a_different_profile() { .expect("host member is returned"); assert_eq!(resolved.id, "Expert"); } - #[tokio::test] async fn agent_roster_listing_and_spawn_agree_on_conflatable_config_ids() { // The wiring-level half of the collapse invariant. The `action=roster` @@ -4428,7 +4321,6 @@ async fn agent_roster_listing_and_spawn_agree_on_conflatable_config_ids() { ); } } - #[test] fn roster_host_profile_listing_cap_is_pinned_at_48() { // Copy-truth pin: the app-side roster contract teaches a 48-row @@ -4437,7 +4329,6 @@ fn roster_host_profile_listing_cap_is_pinned_at_48() { // this test, in the same style as `subagent_runtime_default_max_depth_is_three`. assert_eq!(ROSTER_HOST_PROFILE_LIMIT, 48); } - #[tokio::test] async fn forkguard_agent_roster_lists_only_spawnable_host_profiles() { let tmp = tempdir().expect("tempdir"); @@ -4553,7 +4444,6 @@ async fn forkguard_agent_roster_lists_only_spawnable_host_profiles() { "{error}" ); } - #[test] fn test_resolve_spawn_role_unknown_lists_roles() { let mut request = @@ -4577,7 +4467,6 @@ fn test_resolve_spawn_role_unknown_lists_roles() { assert!(message.contains(role), "missing {role}: {message}"); } } - #[test] fn forkguard_host_profile_overlay_is_config_only_and_prompt_only() { let profile_id = "exp-host-reviewer"; @@ -4643,7 +4532,6 @@ fn forkguard_host_profile_overlay_is_config_only_and_prompt_only() { .to_string(); assert!(error.contains("not prompt-only"), "{error}"); } - #[test] fn test_resolve_spawn_role_rejects_conflicting_explicit_type() { let mut request = parse_spawn_request(&json!({ @@ -4663,7 +4551,6 @@ fn test_resolve_spawn_role_rejects_conflicting_explicit_type() { "{message}" ); } - #[test] fn test_resolve_spawn_role_accepts_agreeing_explicit_type() { let mut request = parse_spawn_request(&json!({ @@ -4677,7 +4564,6 @@ fn test_resolve_spawn_role_accepts_agreeing_explicit_type() { assert_eq!(request.profile.as_deref(), Some("reviewer")); assert_eq!(request.assignment.role.as_deref(), Some("reviewer")); } - #[test] fn test_resolve_spawn_role_scout_yields_explore_type_and_inherits_route() { let mut request = parse_spawn_request(&json!({"prompt": "map the parser", "profile": "scout"})) @@ -4693,7 +4579,6 @@ fn test_resolve_spawn_role_scout_yields_explore_type_and_inherits_route() { ); assert_eq!(selected.source, SpawnRouteSource::RunModel); } - #[test] fn test_resolve_spawn_role_accepts_legacy_alias() { let mut request = parse_spawn_request(&json!({"prompt": "merge findings", "profile": "plan"})) @@ -4702,7 +4587,6 @@ fn test_resolve_spawn_role_accepts_legacy_alias() { assert_eq!(request.agent_type, FleetRole::Planner); assert_eq!(request.profile.as_deref(), Some("planner")); } - #[test] fn spawn_model_selection_has_stable_three_tier_precedence_and_source() { let mut runtime = stub_runtime(); @@ -4754,7 +4638,6 @@ fn spawn_model_selection_has_stable_three_tier_precedence_and_source() { assert_eq!(selected.model_route, ModelRoute::Inherit); assert_eq!(selected.source, SpawnRouteSource::RunModel); } - #[test] fn providerless_spawn_model_gate_rejects_known_foreign_route_before_spawn() { let runtime = stub_runtime_for_provider("moonshot"); @@ -4806,7 +4689,6 @@ fn providerless_spawn_model_gate_rejects_known_foreign_route_before_spawn() { "the child and receipt must use the provider's exact wire id" ); } - #[test] fn providerless_foreign_spawn_default_inherits_session_route() { // #5099 / checklist §2.2: a moonshot parent spawning a default child whose @@ -4859,7 +4741,6 @@ fn providerless_foreign_spawn_default_inherits_session_route() { owned.model_route ); } - #[test] fn spawn_route_sources_refresh_overlays_live_config_on_launch_time_defaults() { // #5099: role-model defaults are a launch-time snapshot, so a mid-session @@ -4902,7 +4783,6 @@ fn spawn_route_sources_refresh_overlays_live_config_on_launch_time_defaults() { "worker override covers the general alias too" ); } - #[test] fn test_child_max_spawn_depth_request_only_narrows() { // No request: inherit unchanged. @@ -4916,7 +4796,6 @@ fn test_child_max_spawn_depth_request_only_narrows() { // A request below the inherited budget is still honored (clamp, don't force). assert_eq!(child_max_spawn_depth_for_spawn(5, 0, Some(3)), 3); } - /// A descendant subagent must not widen the absolute recursion budget its root /// session selected by supplying an explicit `max_depth` on a nested spawn /// (#5253). The inherited budget is a hard cap even when the request (clamped @@ -4935,7 +4814,6 @@ fn test_child_max_spawn_depth_request_cannot_widen_inherited_budget() { // A request below the inherited budget is still honored (clamp, don't force). assert_eq!(child_max_spawn_depth_for_spawn(5, 0, Some(3)), 3); } - /// An explicit spawn-time thinking tier reaches the request unchanged: roles /// carry no reasoning tier of their own, so there is nothing to outrank or /// inherit — the caller's tier is the tier. @@ -4963,7 +4841,6 @@ fn test_explicit_spawn_thinking_reaches_the_request() { ); assert_eq!(route.reasoning_effort.as_deref(), Some("off")); } - #[test] fn test_unset_spawn_thinking_inherits_the_session_tier() { let mut request = parse_spawn_request(&json!({"prompt": "look around", "profile": "explore"})) @@ -4971,7 +4848,6 @@ fn test_unset_spawn_thinking_inherits_the_session_tier() { resolve_spawn_role(&mut request).expect("resolve"); assert_eq!(request.thinking, SubAgentThinking::Inherit); } - /// Roles bind no route (#5046 inverted): the dispatching model may always set /// `model` or `model_strength` — every role accepts model routing options. #[test] @@ -5022,7 +4898,6 @@ fn role_dispatch_allows_model_and_model_strength_options() { assert!(request.model_strength_explicit); assert_eq!(request.model_strength, SubAgentModelStrength::Faster); } - /// A custom (non-role) profile name fails closed: only the closed role set /// resolves — there are no saved members to promote. #[test] @@ -5041,7 +4916,6 @@ fn custom_profile_name_fails_closed() { "error must name the custom profile: {message}" ); } - /// A type alias dispatches as its role and keeps an explicit `model`: there /// is no saved member to promote to and no pinned model to collide with. #[test] @@ -5061,7 +4935,6 @@ fn type_alias_dispatch_keeps_explicit_model() { "explicit model is kept: roles pin no model" ); } - /// A Fleet worker subprocess launches as `--model --reasoning-effort /// auto`. That is a FIXED model with Auto reasoning: the raw `"auto"` sentinel /// must resolve, not travel to a provider that has no such tier. @@ -5092,7 +4965,6 @@ fn fixed_model_runtime_with_a_raw_auto_tier_resolves_instead_of_staying_raw() { assert_eq!(route.reasoning_effort.as_deref(), Some("max")); assert_eq!(route.tuning.reasoning_effort, Some(ReasoningEffort::Max)); } - #[test] fn a_concrete_runtime_tier_is_not_mistaken_for_auto() { let runtime = stub_runtime().with_reasoning_effort(Some("off".to_string()), false); @@ -5107,7 +4979,6 @@ fn a_concrete_runtime_tier_is_not_mistaken_for_auto() { assert_eq!(route.reasoning_effort.as_deref(), Some("off")); } - #[tokio::test] async fn session_projection_exposes_forked_prefix_cache_contract() { let mut snapshot = make_snapshot(SubAgentStatus::Running); @@ -5135,7 +5006,6 @@ async fn session_projection_exposes_forked_prefix_cache_contract() { assert_eq!(projection.transcript_handle.kind, "var_handle"); assert_eq!(projection.transcript_handle.name, "transcript"); } - #[tokio::test] async fn terminal_session_projection_prefers_full_transcript_handle() { let mut snapshot = make_snapshot(SubAgentStatus::Completed); @@ -5167,7 +5037,6 @@ async fn terminal_session_projection_prefers_full_transcript_handle() { assert_eq!(projection.transcript_handle, full_handle); assert_eq!(projection.transcript_handle.name, "full_transcript"); } - #[tokio::test] async fn interrupted_projection_exposes_checkpoint_metadata_and_messages() { let mut snapshot = make_snapshot(SubAgentStatus::Interrupted( @@ -5220,7 +5089,6 @@ async fn interrupted_projection_exposes_checkpoint_metadata_and_messages() { assert!(timed_out_projection.timed_out); assert!(timed_out_projection.timed_out_with_checkpoint); } - #[test] fn test_delegate_defaults_to_fork_context() { let input = with_default_fork_context(json!({ "prompt": "review current work" }), true); @@ -5234,7 +5102,6 @@ fn test_delegate_defaults_to_fork_context() { let parsed = parse_spawn_request(&input).expect("delegate override should parse"); assert_eq!(parsed.fork_context, Some(false)); } - #[test] fn spawn_request_parses_token_budget_override() { let parsed = parse_spawn_request(&json!({ @@ -5261,7 +5128,6 @@ fn spawn_request_parses_token_budget_override() { "clear token budget error: {err}" ); } - #[test] fn forked_subagent_messages_preserve_parent_prefix_then_append_task() { let parent_message = Message { @@ -5298,7 +5164,6 @@ fn forked_subagent_messages_preserve_parent_prefix_then_append_task() { assert_eq!(messages[3].role, "user"); assert!(message_text(&messages[3]).contains("inspect parser")); } - #[test] fn fresh_subagent_messages_keep_existing_single_turn_shape() { let assignment = SubAgentAssignment::new("list files".to_string(), None); @@ -5309,7 +5174,6 @@ fn fresh_subagent_messages_keep_existing_single_turn_shape() { assert_eq!(messages[0].role, "user"); assert!(message_text(&messages[0]).contains("list files")); } - #[test] fn test_parse_spawn_request_rejects_text_and_items_together() { let input = json!({ @@ -5319,7 +5183,6 @@ fn test_parse_spawn_request_rejects_text_and_items_together() { let err = parse_spawn_request(&input).expect_err("text+items should fail"); assert!(err.to_string().contains("either prompt text or items")); } - #[test] fn test_parse_spawn_request_rejects_human_model_name_as_role() { // A model name is not a role: without a roster of saved members there is @@ -5339,7 +5202,6 @@ fn test_parse_spawn_request_rejects_human_model_name_as_role() { "{err}" ); } - #[test] fn test_parse_spawn_request_accepts_fleet_role_token_for_runtime_resolution() { let input = json!({ @@ -5366,7 +5228,6 @@ fn test_parse_spawn_request_accepts_fleet_role_token_for_runtime_resolution() { resolve_spawn_role(&mut scout).expect("scout should resolve"); assert_eq!(scout.agent_type, FleetRole::Scout); } - #[test] fn test_parse_spawn_request_accepts_full_role_vocabulary() { // Regression for #2649: roles that `FleetRole::from_str` accepts must @@ -5438,7 +5299,6 @@ fn test_parse_spawn_request_accepts_full_role_vocabulary() { .unwrap_or_else(|e| panic!("role {role:?} should resolve without a profile: {e}")); } } - #[test] fn test_invalid_role_error_lists_real_aliases() { // Well-formed fleet role tokens parse and then fail clearly at role @@ -5466,7 +5326,6 @@ fn test_invalid_role_error_lists_real_aliases() { "hint should explain compatibility aliases: {err}" ); } - #[test] fn plugin_agent_profile_loads_but_is_not_a_spawn_role() { let _lock = crate::test_support::lock_test_env(); @@ -5509,20 +5368,17 @@ fn plugin_agent_profile_loads_but_is_not_a_spawn_role() { "reload removes the disabled plugin Agent" ); } - fn schema_property_description<'a>(schema: &'a Value, property: &str) -> &'a str { schema["properties"][property]["description"] .as_str() .unwrap_or_else(|| panic!("missing description for schema property {property:?}")) } - fn draft_2020_validator(schema: &Value) -> jsonschema::Validator { jsonschema::options() .with_draft(jsonschema::Draft::Draft202012) .build(schema) .expect("valid Draft 2020-12 tool schema") } - #[test] fn subagent_tool_schemas_advertise_real_type_and_role_vocabulary() { let tmp = tempdir().expect("tempdir"); @@ -5708,7 +5564,6 @@ fn wait_bound_constants_agree_across_both_wait_faces() { same minimum timeout" ); } - #[test] fn wait_timeout_alias_stays_parse_accepted() { // docs/SUBAGENTS.md pins the `timeout` alias (same meaning as @@ -5722,7 +5577,6 @@ fn wait_timeout_alias_stays_parse_accepted() { assert_eq!(read(json!({"timeout": 7})), Some(7)); assert_eq!(read(json!({})), None); } - #[test] fn agent_tool_unadvertised_fields_remain_parse_accepted() { // #5324 compat: the fields removed from the advertised schema must stay @@ -5815,7 +5669,6 @@ fn agent_tool_unadvertised_fields_remain_parse_accepted() { AgentToolAction::Interrupt ); } - #[test] fn agent_tool_role_schema_is_a_closed_canonical_enum() { let tmp = tempdir().expect("tempdir"); @@ -5863,7 +5716,6 @@ fn agent_tool_role_schema_is_a_closed_canonical_enum() { ); } } - #[test] fn provider_schema_sanitizers_preserve_the_closed_fleet_role_enum() { use crate::tools::schema_sanitize; @@ -5940,7 +5792,6 @@ fn provider_schema_sanitizers_preserve_the_closed_fleet_role_enum() { "Kimi MFJS must receive a schema without dependentSchemas" ); } - #[test] fn agent_tool_prompt_schema_keeps_ordinary_starts_message_first() { let tmp = tempdir().expect("tempdir"); @@ -5967,7 +5818,6 @@ fn agent_tool_prompt_schema_keeps_ordinary_starts_message_first() { ); } } - #[test] fn agent_tool_schema_advertises_lifecycle_and_coordination_actions() { let tmp = tempdir().expect("tempdir"); @@ -5990,7 +5840,6 @@ fn agent_tool_schema_advertises_lifecycle_and_coordination_actions() { // absence; `timeout_secs` returned to the advertised surface in #119, // and the compat test below still covers both plus the `timeout` alias. } - #[test] fn agent_tool_schema_bounds_fields_by_explicit_action() { let tmp = tempdir().expect("tempdir"); @@ -6039,7 +5888,6 @@ fn agent_tool_schema_bounds_fields_by_explicit_action() { assert!(branch(action).get("anyOf").is_none()); } } - #[test] fn agent_tool_schema_rejects_empty_input_across_provider_forms() { use crate::tools::schema_sanitize; @@ -6108,7 +5956,6 @@ fn agent_tool_schema_rejects_empty_input_across_provider_forms() { "agent schema must reject empty input because runtime defaults it to start and then rejects the missing prompt; permissive forms: {permissive:?}" ); } - #[tokio::test] async fn agent_message_queues_and_followup_delivers_as_user_provenance() { let tmp = tempdir().expect("tempdir"); @@ -6208,7 +6055,6 @@ async fn agent_message_queues_and_followup_delivers_as_user_provenance() { .to_string(); assert!(absent.contains("not found"), "{absent}"); } - #[tokio::test] async fn agent_tool_status_returns_running_child_projection() { let tmp = tempdir().expect("tempdir"); @@ -6257,7 +6103,6 @@ async fn agent_tool_status_returns_running_child_projection() { assert!(result.content.contains("running")); assert!(result.content.contains("transcript_handle")); } - #[tokio::test] async fn agent_tool_status_reconciles_stale_single_agent_projection() { let tmp = tempdir().expect("tempdir"); @@ -6308,7 +6153,6 @@ async fn agent_tool_status_reconciles_stale_single_agent_projection() { assert!(result.content.contains("Auto-cancelled")); assert_eq!(manager.read().await.running_count(), 0); } - #[tokio::test] async fn agent_tool_cancel_stops_running_child() { let tmp = tempdir().expect("tempdir"); @@ -6377,7 +6221,6 @@ async fn agent_tool_cancel_stops_running_child() { "repeated stop must not append a second terminal outcome" ); } - #[tokio::test] async fn model_wait_cancel_fans_in_once_and_preserves_checkpoint() { use tokio_util::sync::CancellationToken; @@ -6476,7 +6319,6 @@ async fn model_wait_cancel_fans_in_once_and_preserves_checkpoint() { 1 ); } - #[tokio::test] async fn coordination_interrupt_fans_in_once_and_preserves_checkpoint() { use tokio_util::sync::CancellationToken; @@ -6597,7 +6439,6 @@ async fn coordination_interrupt_fans_in_once_and_preserves_checkpoint() { "repeated interrupt must not append a second terminal or parked outcome" ); } - #[tokio::test] async fn late_completion_does_not_overwrite_cancelled_outcome() { let tmp = tempdir().expect("tempdir"); @@ -6649,7 +6490,6 @@ async fn late_completion_does_not_overwrite_cancelled_outcome() { assert_eq!(terminal.len(), 1); assert_eq!(terminal[0].status, AgentWorkerStatus::Cancelled); } - #[tokio::test] async fn completion_claim_preserves_running_gate_and_excludes_late_cancel() { let tmp = tempdir().expect("tempdir"); @@ -6725,7 +6565,6 @@ async fn completion_claim_preserves_running_gate_and_excludes_late_cancel() { .count(); assert_eq!(terminal, 1, "exactly one terminal outcome is recorded"); } - #[test] fn test_parse_spawn_request_rejects_conflicting_type_and_role() { let input = json!({ @@ -6739,7 +6578,6 @@ fn test_parse_spawn_request_rejects_conflicting_type_and_role() { .contains("Fleet role conflicts with the explicit legacy agent type") ); } - #[test] fn test_build_allowed_tools_independent_of_allow_shell() { // v0.6.6: allow_shell no longer filters at the build_allowed_tools @@ -6751,7 +6589,6 @@ fn test_build_allowed_tools_independent_of_allow_shell() { assert!(with_shell.is_none()); assert!(without_shell.is_none()); } - #[test] fn test_allowed_tools_are_deduplicated() { let tools = build_allowed_tools( @@ -6770,13 +6607,11 @@ fn test_allowed_tools_are_deduplicated() { Some(vec!["read_file".to_string(), "grep_files".to_string()]) ); } - #[test] fn test_custom_agent_requires_allowed_tools() { let err = build_allowed_tools(&FleetRole::Custom, None, true).unwrap_err(); assert!(err.to_string().contains("requires")); } - #[test] fn role_posture_blocks_writes_and_shell_for_read_only_roles() { // #3217: read-only roles may never run write/edit/patch tools, regardless @@ -6838,7 +6673,6 @@ fn role_posture_blocks_writes_and_shell_for_read_only_roles() { ApprovalRequirement::Required )); } - #[test] fn test_build_assignment_prompt_includes_metadata() { let assignment = SubAgentAssignment::new( @@ -6850,7 +6684,6 @@ fn test_build_assignment_prompt_includes_metadata() { assert!(prompt.contains("resolved_type: explore")); assert!(prompt.contains("role: explore")); } - #[test] fn subagent_model_strength_defaults_to_parent_even_when_parent_auto_model() { let mut runtime = stub_runtime().with_auto_model(true); @@ -6868,7 +6701,6 @@ fn subagent_model_strength_defaults_to_parent_even_when_parent_auto_model() { assert_eq!(route.model, "deepseek-v4-pro", "prompt {prompt:?}"); } } - #[test] fn subagent_model_strength_faster_uses_known_family_sibling() { let mut runtime = stub_runtime().with_auto_model(true); @@ -6885,7 +6717,6 @@ fn subagent_model_strength_faster_uses_known_family_sibling() { assert_eq!(route.model, "deepseek-v4-flash"); assert_eq!(route.reasoning_effort.as_deref(), Some("off")); } - #[test] fn subagent_model_strength_explicit_model_wins_over_faster() { let runtime = stub_runtime().with_auto_model(true); @@ -6903,7 +6734,6 @@ fn subagent_model_strength_explicit_model_wins_over_faster() { ); assert_eq!(route.model, "deepseek-v4-pro"); } - #[test] fn explicit_child_thinking_overrides_faster_default_off() { let mut runtime = stub_runtime().with_reasoning_effort(Some("max".to_string()), false); @@ -6920,7 +6750,6 @@ fn explicit_child_thinking_overrides_faster_default_off() { assert_eq!(route.reasoning_effort.as_deref(), Some("high")); assert_eq!(route.tuning.reasoning_effort, Some(ReasoningEffort::High)); } - #[test] fn explicit_child_auto_thinking_resolves_from_child_prompt() { let runtime = stub_runtime().with_reasoning_effort(Some("off".to_string()), false); @@ -6934,7 +6763,6 @@ fn explicit_child_auto_thinking_resolves_from_child_prompt() { ); assert_eq!(route.reasoning_effort.as_deref(), Some("max")); } - #[tokio::test] async fn route_resolution_matrix_uses_explicit_model_strength_routes() { let mut runtime = stub_runtime() @@ -7030,7 +6858,6 @@ async fn route_resolution_matrix_uses_explicit_model_strength_routes() { ); } } - #[test] fn subagent_auto_reasoning_resolves_to_distinct_v4_tiers() { let runtime = stub_runtime().with_reasoning_effort(Some("high".to_string()), true); @@ -7058,7 +6885,6 @@ fn subagent_auto_reasoning_resolves_to_distinct_v4_tiers() { Some("max".to_string()) ); } - #[test] fn test_subagent_tool_registry_reports_unavailable_tools() { let tmp = tempdir().expect("tempdir"); @@ -7082,7 +6908,6 @@ fn test_subagent_tool_registry_reports_unavailable_tools() { vec!["update_goal".to_string(), "missing_tool".to_string()] ); } - #[test] fn test_subagent_tools_respect_nested_agent_depth_budget() { let tmp = tempdir().expect("tempdir"); @@ -7121,11 +6946,9 @@ fn test_subagent_tools_respect_nested_agent_depth_budget() { ); assert!(!capped.is_tool_allowed("agent")); } - fn tool_names(tools: Vec) -> HashSet { tools.into_iter().map(|tool| tool.name).collect() } - #[test] fn every_named_role_has_one_complete_capability_based_surface() { const INSPECTION: &[&str] = &[ @@ -7338,7 +7161,6 @@ fn every_named_role_has_one_complete_capability_based_surface() { assert_eq!(names, expected, "{role:?} visible surface drifted"); } } - /// An explicit parent tool subset is a restriction, not a note. The registry /// consults `allowed_tools`, so a `ToolScope::Explicit` that never reaches it /// leaves the child holding the parent's whole surface while the profile claims @@ -7378,7 +7200,6 @@ fn an_explicit_parent_tool_scope_is_enforced_by_the_child_registry() { assert!(!names.contains("Bash"), "{names:?}"); assert!(!names.contains("Git"), "{names:?}"); } - /// A child may narrow inside its parent's explicit subset; it may not step /// outside it, however it asks. #[test] @@ -7421,7 +7242,6 @@ fn a_child_allowlist_cannot_widen_an_explicit_parent_tool_scope() { None ); } - fn enabled_agent_surface_options() -> AgentToolSurfaceOptions { let mut options = AgentToolSurfaceOptions::new(ShellPolicy::Full); options.apply_patch_enabled = true; @@ -7430,7 +7250,6 @@ fn enabled_agent_surface_options() -> AgentToolSurfaceOptions { options.goal_state = Some(crate::tools::goal::new_shared_goal_state()); options } - /// Return the exact model-visible catalog built for a default General child. /// /// Request-boundary tests use this fixture so Moonshot compatibility coverage @@ -7459,7 +7278,6 @@ pub(crate) fn kimi_general_child_request_tools_fixture() -> Vec { let mut surface = SubAgentToolSurface::new(catalog, &[]); model_request_tools(&mut surface) } - fn small_surface_registry(role: FleetRole) -> SubAgentToolRegistry { let mut runtime = stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options()); @@ -7472,11 +7290,9 @@ fn small_surface_registry(role: FleetRole) -> SubAgentToolRegistry { crate::tools::plan::new_shared_plan_state(), ) } - fn model_tool_names(tools: Vec) -> BTreeSet { tools.into_iter().map(|tool| tool.name).collect() } - #[test] fn small_surface_legacy_rules_cover_lowercase_primitives_with_deny_wins() { let file = vec!["File".to_string()]; @@ -7523,11 +7339,9 @@ fn small_surface_legacy_rules_cover_lowercase_primitives_with_deny_wins() { ); } } - fn model_request_tools(surface: &mut SubAgentToolSurface) -> Vec { surface.request_tools(surface.catalog.clone(), false) } - fn forked_child_request_fixture( registry: &SubAgentToolRegistry, role: &FleetRole, @@ -7543,7 +7357,6 @@ fn forked_child_request_fixture( let catalog = registry.deferred_catalog_for_model(role); (messages, SubAgentToolSurface::new(catalog, &[])) } - async fn execute_surface_tool( registry: &SubAgentToolRegistry, surface: &mut SubAgentToolSurface, @@ -7556,7 +7369,6 @@ async fn execute_surface_tool( .await .map(|result| result.result.content) } - #[test] fn small_surface_starts_with_only_pi_head_and_search() { let registry = small_surface_registry(FleetRole::Builder); @@ -7587,7 +7399,6 @@ fn small_surface_starts_with_only_pi_head_and_search() { Some(true) ); } - #[tokio::test] async fn small_surface_read_only_child_discovers_web_deferred() { let registry = small_surface_registry(FleetRole::Scout); @@ -7631,7 +7442,6 @@ async fn small_surface_read_only_child_discovers_web_deferred() { assert!(same_batch.result.content.contains("deferred")); assert!(model_tool_names(model_request_tools(&mut surface)).contains("Web")); } - #[tokio::test] async fn small_surface_fork_context_survives_fresh_child_discovery() { let registry = small_surface_registry(FleetRole::Builder); @@ -7705,7 +7515,6 @@ async fn small_surface_fork_context_survives_fresh_child_discovery() { assert!(names.contains("Web")); assert!(names.contains("apply_patch")); } - #[tokio::test] async fn small_surface_denied_warm_tool_is_not_resurrected() { let mut runtime = @@ -7733,7 +7542,6 @@ async fn small_surface_denied_warm_tool_is_not_resurrected() { assert!(!searched.contains("\"tool_name\":\"Web\"")); assert!(surface.hydrate("Web").is_err()); } - fn synthetic_deferred_tool(name: &str, description_bytes: usize) -> Tool { Tool { tool_type: Some("function".to_string()), @@ -7800,7 +7608,6 @@ fn forkguard_small_surface_caches_are_independent_unbounded_and_revalidated() { assert!(byte_names.contains("bytes_0")); assert!(byte_names.contains("bytes_1") && byte_names.contains("bytes_2")); } - #[tokio::test] async fn small_surface_successful_cached_use_touches_lru() { let registry = small_surface_registry(FleetRole::Builder); @@ -7820,7 +7627,6 @@ async fn small_surface_successful_cached_use_touches_lru() { surface.hydrate(&ninth).expect("ninth activation"); assert!(model_tool_names(model_request_tools(&mut surface)).contains("get_goal")); } - /// The child's ride channel mirrors the parent's: parked notes ride success /// and error results alike (a note held back on `Err` would strand until a /// later `Ok` — or forever if the child ends first), and names the ride @@ -7889,7 +7695,6 @@ fn child_parked_note_rides_success_and_error_results_and_nets_re_admissions() { netted.result.content ); } - /// The park side is covered above; this pin is the consumption side — /// `execute_from_surface` must hand parked notes to the child's recorded /// result, the production edge that once discarded every report. Driven @@ -7922,7 +7727,6 @@ async fn parked_note_rides_through_the_execute_from_surface_glue() { surface.pending_eviction_notes ); } - #[test] fn small_surface_depth_cap_removes_only_agent() { let mut runtime = @@ -7948,13 +7752,11 @@ fn small_surface_depth_cap_removes_only_agent() { .collect() ); } - fn disabled_feature_agent_surface_options() -> AgentToolSurfaceOptions { let mut options = AgentToolSurfaceOptions::new(ShellPolicy::Full); options.goal_state = Some(crate::tools::goal::new_shared_goal_state()); options } - #[test] fn subagent_general_catalog_keeps_parent_surface_except_root_goal_mutators() { let tmp = tempdir().expect("tempdir"); @@ -7996,7 +7798,6 @@ fn subagent_general_catalog_keeps_parent_surface_except_root_goal_mutators() { "default General sub-agent catalog must match the parent Agent surface except for root-owned goal mutators" ); } - #[test] fn subagent_feature_gates_match_parent_agent_surface() { let tmp = tempdir().expect("tempdir"); @@ -8037,7 +7838,6 @@ fn subagent_feature_gates_match_parent_agent_surface() { assert!(!child_names.contains(name), "{name} should be child-gated"); } } - /// Model the clamp's deny list the way a real spawn threads it in /// (fleet/worker_runtime.rs): a `write: false` member loses the raw shell /// surface and the non-shell execution surface even when its posture holds @@ -8065,7 +7865,6 @@ fn seed_read_only_role_deny_list(runtime: &mut SubAgentRuntime) { } } } - /// #5426 acceptance point 1, gate-level: a live scout must be able to run /// the three canonical read-only inspection commands DIRECTLY through /// canonical `bash` — `git -C ... log`, `find ... | head`, `npm view` — with @@ -8146,7 +7945,6 @@ fn scout_posture_gate_admits_agent_readonly_bash_commands() { "legacy `Bash` is not the carve-out name; the envelope must keep refusing it" ); } - /// #5595: catalog admission, role posture, and the execution envelope are not /// enough. The concrete read-only executor must accept the canonical Git shape /// agents use when the repository is not their process cwd. This is the exact @@ -8199,7 +7997,6 @@ async fn read_only_inspection_roles_execute_pwd_and_absolute_git_log() { } } } - /// Read-only text filters may transform stdout, but they must not reach their /// file-output or helper-program forms through the same bounded bash carve-out. #[tokio::test] @@ -8279,7 +8076,6 @@ async fn read_only_inspection_roles_cannot_write_through_text_filters() { // bounded read-only probes the inspection roles use — so "with evidence" // includes evidence the child gathered itself (`wc`, `ls`, `cat`, `grep`) — // while custom commands stay refused and the workspace stays read-only. - /// The roster description must promise exactly the surface the role has: /// built-in gates plus a bounded read-only evidence shell, no custom commands. #[test] @@ -8307,7 +8103,6 @@ fn verifier_roster_description_states_the_bounded_contract() { "type description must match the roster: {SUBAGENT_TYPE_DESCRIPTION}" ); } - /// Acceptance for the verifier lane, sibling of /// `read_only_inspection_roles_execute_pwd_and_absolute_git_log`: a live /// `test` child must execute a bounded read-only probe through canonical @@ -8342,7 +8137,6 @@ async fn verifier_child_executes_a_bounded_readonly_bash_probe_for_evidence() { "verifier probe must report the fixture's byte count as wc's first field: {output}" ); } - /// The walls around the lane stay up: the legacy raw `Bash` spelling, a /// custom `Run` command, and a workspace write are all refused for a /// verifier — at dispatch, and with the `reject_unbounded_verification` @@ -8427,7 +8221,6 @@ async fn verifier_child_still_refuses_raw_shell_custom_commands_and_writes() { "workspace-write refusal did not identify the denial: {write}" ); } - /// A registry for a write-capable role whose parent handed it a read-only /// shell and no writes — what `derive_child` produces for a Builder, Worker, /// or Custom child of a Scout or Planner on the plain `agent` nesting path, @@ -8451,7 +8244,6 @@ fn read_only_shell_child_registry(role: FleetRole) -> (tempfile::TempDir, SubAge ); (tmp, registry) } - /// Shell authority is the Verifier's carve-out alone. Every other role whose /// runtime shell was narrowed to read-only — the Builder, Worker, or Custom /// child of a read-only parent — holds no process authority: the built-in @@ -8506,7 +8298,6 @@ async fn read_only_shell_children_of_other_roles_hold_no_shell_authority() { assert!(verifier.posture_permits_tool("Run", Some(&gate))); assert!(verifier.envelope_refusal("Run", &gate).is_none()); } - /// The purpose survives the narrowing: default gates and test-selection /// arguments stay admitted for the verifier at posture and envelope, and the /// catalog still offers `Run`. @@ -8542,7 +8333,6 @@ fn verifier_child_keeps_the_default_and_selection_bounded_run_surface() { "catalog must keep offering the probe lane beside the gates: {names:?}" ); } - /// A Verifier child of a read-only role parent (its shell was clamped by the /// parent, not narrowed by the Verifier role) regains nothing through the /// carve-out: the built-in gate runner stays refused at posture and envelope @@ -8585,17 +8375,20 @@ async fn clamped_verifier_child_of_read_only_parent_holds_no_run_surface() { "the evidence lane stays classifier-bounded" ); } - /// The sub-subagent chain: a carve-out Verifier parent (its effective shell /// is the role's read-only narrowing; the Run grant is a registry surface, -/// not shell authority) spawning a Builder child. Hop 2 is built from hop -/// 1's actual published profile through the same intersection the -/// production spawn path runs, so the pin is a real two-hop chain: the -/// spawn ceiling the carve-out parent publishes stays read-only, and the -/// child derived under that ceiling keeps the no-Run, lane-less shape. It -/// guards the propagation side of the round-3 fix — if the registry ever -/// leaked a pre-narrowing profile to its spawns, the Builder below would -/// regain the gate surface, and this test would redden. +/// not shell authority) spawning a Builder child through the REAL spawn +/// path. Hop 2 is not hand-derived: the test starts the Builder through the +/// verifier's own `agent` tool and reads the child's recorded +/// `runtime_profile` — the profile `run_subagent` derived from the runtime +/// the registry published (`worker_profile_for_spawn` -> +/// `derive_child`). That recorded profile is the spawn ceiling the +/// carve-out parent actually published; a Builder built under it keeps the +/// no-Run, lane-less shape. This guards the propagation side of the +/// round-3 fix: if the registry ever leaked a pre-narrowing profile to its +/// spawns (`child_runtime.worker_profile` regressed to the parent-runtime +/// profile), the recorded shell below would come out `full` and this test +/// would redden. #[tokio::test] async fn sub_subagent_chain_of_a_carveout_verifier_holds_no_extra_authority() { // A carve-out Verifier in a full session: the coarse session flag stays @@ -8612,34 +8405,68 @@ async fn sub_subagent_chain_of_a_carveout_verifier_holds_no_extra_authority() { codewhale_workflow::ShellCeiling::ReadOnly, "the carve-out must not widen the shell ceiling a verifier publishes for its own spawns" ); - // Hop 2 is derived from hop 1's actual output: the carve-out verifier's - // narrowed effective profile is what the registry publishes to its own - // spawns, so a Builder one hop below the verifier is built through the - // same derivation the production spawn path runs - // (`worker_profile_for_spawn` intersects the parent's effective profile - // with the child's role request). If the registry ever leaked a - // pre-narrowing profile to its spawns, the Builder below would regain - // the gate surface — this pin exists to redden exactly that regression. - let (_verifier_tmp, verifier) = read_only_with_shell_registry(); + // Hop 2 through the real spawn path: the write-capable `agent` start + // needs an auto-approved surface, so the fixture runs in its + // auto-approving variant. Everything else about the carve-out shape is + // the fixture's own. + let (verifier_tmp, verifier) = read_only_with_shell_registry_auto_approving(true); assert_eq!( verifier.runtime_profile.shell, crate::worker_profile::ShellPolicy::ReadOnly, - "the carve-out fixture must publish the narrowed shell to its spawns" + "the carve-out fixture must narrow the verifier's own profile" + ); + let started = verifier + .execute( + "agent_chain_probe", + "agent", + json!({ + "action": "start", + "type": "builder", + "prompt": "noop chain probe", + }), + ) + .await + .expect("the carve-out verifier must be able to start a chain Builder"); + let started: Value = + serde_json::from_str(&started).expect("the agent start must answer with a JSON payload"); + let agent_id = started + .get("agent_id") + .and_then(Value::as_str) + .expect("the agent start must answer with an agent_id") + .to_string(); + // The record appears once the spawned task registers the child; the + // recorded profile is what production derived from the published + // runtime, not a field this test set. + let manager = verifier.gate_runtime.manager.clone(); + let record = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(record) = manager.read().await.get_worker_record(&agent_id) + && record.spec.runtime_profile != WorkerRuntimeProfile::default() + { + return record; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .expect("the chain Builder worker record must appear"); + assert_eq!( + record.spec.agent_type, + FleetRole::Builder, + "the spawned hop must be the requested Builder" ); - let derived = verifier - .runtime_profile - .derive_child(&WorkerRuntimeProfile::for_role(FleetRole::Builder)); assert_eq!( - derived.shell, + record.spec.runtime_profile.shell, crate::worker_profile::ShellPolicy::ReadOnly, - "the chain Builder must derive the read-only shell from the verifier's narrowed profile" + "the profile the registry published to its spawn must derive the read-only shell; \ + a pre-narrowing leak redds here" ); let child_tmp = tempfile::tempdir().expect("tempdir"); let mut child_runtime = stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options()); child_runtime.context = ToolContext::new(child_tmp.path().to_path_buf()); child_runtime.allow_shell = true; - child_runtime.worker_profile = derived; + child_runtime.worker_profile = record.spec.runtime_profile.clone(); let builder = SubAgentToolRegistry::new( child_runtime, FleetRole::Builder, @@ -8680,8 +8507,8 @@ async fn sub_subagent_chain_of_a_carveout_verifier_holds_no_extra_authority() { refused.to_string().contains("not permitted") || refused.to_string().contains("shell"), "chain Builder refusal did not identify the wall: {refused}" ); + drop(verifier_tmp); } - /// The verifier intro tells the child about its bounded read-only shell and /// the custom-command refusal, scoped to in-session delegations the way /// `FleetRole`'s roster description scopes the role. The gates claim is @@ -8708,7 +8535,6 @@ fn verifier_intro_states_the_evidence_contract() { "the intro's gates claim must carry the read-only-parent conditional: {VERIFIER_AGENT_INTRO}" ); } - #[test] fn explore_catalog_inherits_web_but_hides_write_shell_and_fim_tools() { let tmp = tempdir().expect("tempdir"); @@ -8760,7 +8586,6 @@ fn explore_catalog_inherits_web_but_hides_write_shell_and_fim_tools() { file.input_schema ); } - /// Under the real spawn clamp — raw-shell deny list installed, `Bash` /// included — Scout and Reviewer reach exactly one shell entry point: /// canonical lowercase `bash`, bounded by the strict read-only classifier. @@ -8975,7 +8800,6 @@ async fn read_only_roles_expose_and_dispatch_lowercase_bash_only() { ); } } - #[tokio::test] async fn planner_exposes_and_dispatches_read_only_bash_probes() { let tmp = tempdir().expect("tempdir"); @@ -9029,7 +8853,6 @@ async fn planner_exposes_and_dispatches_read_only_bash_probes() { .expect("planner must dispatch a bounded read"); assert_eq!(output, sentinel); } - #[tokio::test] async fn scout_shell_respects_parent_shell_and_network_ceilings() { let tmp = tempdir().expect("tempdir"); @@ -9121,7 +8944,6 @@ async fn scout_shell_respects_parent_shell_and_network_ceilings() { } } } - /// An auto-approved Worker registry (shell-capable, everything else default) /// carrying the given exec-policy engine — the smallest fixture that reaches /// the delegated-call execpolicy gate past every posture gate. @@ -9131,7 +8953,6 @@ fn auto_approved_worker_registry_with_engine( ) -> SubAgentToolRegistry { worker_registry_with_engine_and_approval(tmp, exec_policy_engine, true) } - /// The same fixture with the parent session's approval posture under the /// caller's control: `false` models every prompting/fail-closed posture /// (Suggest/Auto/Never), where the main line would surface an approval @@ -9155,7 +8976,6 @@ fn worker_registry_with_engine_and_approval( crate::tools::plan::new_shared_plan_state(), ) } - /// Fork-delta behavior test: a command the parent session's execpolicy /// hard-denies must fail closed inside the child with the main line's deny /// wording — the auto-approve escape hatch this wiring closes — while a @@ -9347,7 +9167,6 @@ async fn subagent_execpolicy_empty_ruleset_keeps_auto_approve_behavior() { .to_string(); assert!(error.contains("denied prefix rule 'echo'"), "{error}"); } - /// A typed Deny rule on a file path blocks the child's File read of that path /// while a workspace-relative read outside the rule still lands. #[tokio::test] @@ -9384,7 +9203,6 @@ async fn subagent_execpolicy_blocks_denied_file_read_but_allows_workspace_relati .expect("a workspace-relative read outside the deny rule passes"); assert!(output.contains("plain notes"), "{output}"); } - /// Typed ask rules are prompt decisions on the main line. Under a /// parent-auto-approving session the main line would auto-run, so the child /// passes the call through and it really executes; the refusal face under a @@ -9410,7 +9228,6 @@ async fn subagent_execpolicy_prompt_decision_passes_under_parent_auto_approve() .expect("an ask-rule (prompt) decision must not block a child"); assert!(output.contains("askme"), "{output}"); } - /// The registry's engine handle is the parent's live engine, not a snapshot: /// a ruleset installed after the registry was built binds its delegated calls. #[tokio::test] @@ -9444,7 +9261,6 @@ async fn subagent_execpolicy_ruleset_update_binds_already_delegated_calls() { .to_string(); assert!(error.contains("denied prefix rule 'echo'"), "{error}"); } - #[test] fn implementer_catalog_inherits_patch_and_fim_when_enabled() { let tmp = tempdir().expect("tempdir"); @@ -9478,7 +9294,6 @@ fn implementer_catalog_inherits_patch_and_fim_when_enabled() { ); } } - #[test] fn every_fleet_role_catalog_advertises_one_executable_load_skill() { // load_skill contract (#4651): the parent Agent surface and every @@ -9594,7 +9409,6 @@ fn every_fleet_role_catalog_advertises_one_executable_load_skill() { } } } - #[test] fn custom_child_allowlist_omitting_load_skill_fails_closed() { // Custom children get exactly their explicit allow-list: load_skill is @@ -9636,7 +9450,6 @@ fn custom_child_allowlist_omitting_load_skill_fails_closed() { "explicitly listed load_skill is granted: {names:?}" ); } - #[tokio::test] async fn plan_parent_profile_narrows_even_implementer_child_to_read_only() { let tmp = tempdir().expect("tempdir"); @@ -9684,7 +9497,6 @@ async fn plan_parent_profile_narrows_even_implementer_child_to_read_only() { ); assert!(!workspace.join("plan-parent-write.txt").exists()); } - #[tokio::test] async fn api_timeout_preserves_checkpoint_and_returns_needs_input_without_parking() { let tmp = tempdir().expect("tempdir"); @@ -9859,7 +9671,6 @@ async fn api_timeout_preserves_checkpoint_and_returns_needs_input_without_parkin "projection inspection must not respawn the child implicitly" ); } - #[tokio::test] async fn subagent_retries_api_timeout_before_succeeding() { let tmp = tempdir().expect("tempdir"); @@ -9939,7 +9750,6 @@ async fn subagent_retries_api_timeout_before_succeeding() { assert_eq!(snapshot.status, SubAgentStatus::Completed); assert_eq!(snapshot.result.as_deref(), Some("recovered answer")); } - #[test] fn api_timeout_retry_backoff_doubles_and_caps() { let base = SUBAGENT_API_TIMEOUT_INITIAL_BACKOFF; @@ -9953,7 +9763,6 @@ fn api_timeout_retry_backoff_doubles_and_caps() { ); } } - #[test] fn api_timeout_retry_delay_stays_within_jitter_bounds() { let base = SUBAGENT_API_TIMEOUT_INITIAL_BACKOFF; @@ -9973,14 +9782,12 @@ fn api_timeout_retry_delay_stays_within_jitter_bounds() { } } } - #[test] fn transient_provider_classifier_matches_sse_header_timeout() { let err = anyhow::anyhow!("SSE stream request did not receive response headers after 45s"); assert!(is_transient_subagent_provider_error(&err)); } - #[test] fn transient_provider_classifier_matches_body_decode_failures() { // A provider that accepts the request and dies mid-response is a @@ -10001,7 +9808,6 @@ fn transient_provider_classifier_matches_body_decode_failures() { "auth failures stay fatal" ); } - #[test] fn transient_provider_classifier_matches_structured_rate_limit() { let err = anyhow::Error::new(crate::llm_client::LlmError::RateLimited { @@ -10012,7 +9818,6 @@ fn transient_provider_classifier_matches_structured_rate_limit() { assert!(is_transient_subagent_provider_error(&err)); } - #[tokio::test] async fn subagent_retries_transient_provider_header_timeout_before_succeeding() { let tmp = tempdir().expect("tempdir"); @@ -10087,7 +9892,6 @@ async fn subagent_retries_transient_provider_header_timeout_before_succeeding() assert_eq!(snapshot.status, SubAgentStatus::Completed); assert_eq!(snapshot.result.as_deref(), Some("recovered answer")); } - #[tokio::test] async fn subagent_rate_limit_exhaustion_interrupts_with_checkpoint() { let tmp = tempdir().expect("tempdir"); @@ -10173,7 +9977,6 @@ async fn subagent_rate_limit_exhaustion_interrupts_with_checkpoint() { assert!(checkpoint.continuable); assert!(snapshot.needs_input.is_some()); } - #[tokio::test] async fn spawn_duplicate_session_name_error_names_conflicting_agent() { // #2656: the duplicate-name error must identify the conflicting agent so a @@ -10234,7 +10037,6 @@ async fn spawn_duplicate_session_name_error_names_conflicting_agent() { "includes elapsed time since spawn: {msg}" ); } - #[tokio::test] async fn spawn_session_name_held_by_prior_session_agent_does_not_collide() { // A completed worker hydrated from an earlier session's ledger is not @@ -10294,7 +10096,6 @@ async fn spawn_session_name_held_by_prior_session_agent_does_not_collide() { assert_eq!(fresh.session_name, "researcher"); assert!(!guard.is_from_prior_session(fresh)); } - /// Seed a same-session child holding `name` in the given status; returns its /// agent id. Shared by the name-release tests below. async fn seed_named_same_session_agent( @@ -10329,7 +10130,6 @@ async fn seed_named_same_session_agent( assert!(!guard.is_from_prior_session(&guard.agents[&id])); id } - #[tokio::test] async fn spawn_session_name_released_once_same_session_holder_settles() { // 2026-10-23 research fan-out: the parent cancelled `macro-policy`, got @@ -10395,7 +10195,6 @@ async fn spawn_session_name_released_once_same_session_holder_settles() { ); } } - #[tokio::test] async fn spawn_session_name_still_held_by_interrupted_same_session_child() { // An interrupted child is parked, not finished: its name stays reserved @@ -10448,7 +10247,6 @@ async fn spawn_session_name_still_held_by_interrupted_same_session_child() { "cancel is a silent no-op on an interrupted child: {msg}" ); } - #[tokio::test] async fn spawn_session_name_running_holder_advises_wait_or_cancel() { // A running holder can be waited out or cancelled; the refusal must keep @@ -10491,7 +10289,6 @@ async fn spawn_session_name_running_holder_advises_wait_or_cancel() { "a running holder can be waited out or cancelled: {msg}" ); } - #[test] fn session_name_resolution_prefers_live_holder_then_newest_settled() { let tmp = tempdir().expect("tempdir"); @@ -10541,7 +10338,6 @@ fn session_name_resolution_prefers_live_holder_then_newest_settled() { older_id ); } - #[test] fn session_name_resolution_ignores_prior_boot_interrupted_ghost() { // Hydration rewrites a child that was Running at shutdown into a @@ -10595,7 +10391,6 @@ fn session_name_resolution_ignores_prior_boot_interrupted_ghost() { retry_id ); } - #[tokio::test] async fn id_shaped_session_name_requires_an_explicit_name_selector() { // Name reuse cannot reinterpret a durable receipt. Both live and settled @@ -10653,7 +10448,6 @@ async fn id_shaped_session_name_requires_an_explicit_name_selector() { ); } } - #[test] fn spawn_contention_error_names_the_read_only_path() { // The ledger text is written for writers. The refusal seen in the @@ -10679,7 +10473,6 @@ fn spawn_contention_error_names_the_read_only_path() { let other = "write-claim capacity is 64 active owners; complete, serialize, or isolate existing work before admitting another writer".to_string(); assert_eq!(spawn_claim_error_with_guidance(other.clone()), other); } - #[test] fn general_role_text_discloses_workspace_claim_and_read_only_alternative() { // The roster entry, the type enum text and the tool description are the @@ -10705,7 +10498,6 @@ fn general_role_text_discloses_workspace_claim_and_read_only_alternative() { "{description}" ); } - #[tokio::test] async fn shared_write_claim_is_registered_before_parallel_launch_and_manifested() { let tmp = tempdir().expect("tempdir"); @@ -10787,7 +10579,6 @@ async fn shared_write_claim_is_registered_before_parallel_launch_and_manifested( assert_eq!(manifest.coordination_contracts, vec!["public-api"]); assert_eq!(manifest.expected_artifact.as_deref(), Some("tested patch")); } - #[tokio::test] async fn write_capable_agent_does_not_launch_when_durable_registration_fails() { let tmp = tempdir().expect("tempdir"); @@ -10838,7 +10629,6 @@ async fn write_capable_agent_does_not_launch_when_durable_registration_fails() { ); assert!(blocked_state_path.is_dir()); } - #[tokio::test] async fn write_scope_contention_covers_regular_agent_and_active_fleet_writer() { let tmp = tempdir().expect("tempdir"); @@ -10912,7 +10702,6 @@ async fn write_scope_contention_covers_regular_agent_and_active_fleet_writer() { "{launch}" ); } - #[tokio::test] async fn test_running_count_counts_only_agents_with_live_task_handles() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); @@ -10945,7 +10734,6 @@ async fn test_running_count_counts_only_agents_with_live_task_handles() { .expect("live task handle") .abort(); } - #[test] fn test_running_count_ignores_running_status_without_task_handle() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); @@ -10967,7 +10755,6 @@ fn test_running_count_ignores_running_status_without_task_handle() { assert_eq!(manager.running_count(), 0); } - #[tokio::test] async fn test_running_count_counts_running_agents_until_status_reconciles() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); @@ -10994,7 +10781,6 @@ async fn test_running_count_counts_running_agents_until_status_reconciles() { assert_eq!(manager.running_count(), 1); } - #[tokio::test] async fn admission_limit_counts_queued_and_running_workers_separately() { let mut manager = SubAgentManager::new(PathBuf::from("."), 2).with_admission_limit(4); @@ -11064,7 +10850,6 @@ async fn admission_limit_counts_queued_and_running_workers_separately() { .abort(); } } - #[tokio::test] async fn cleanup_auto_cancels_stale_running_agent_and_releases_slot() { use tokio_util::sync::CancellationToken; @@ -11141,7 +10926,6 @@ async fn cleanup_auto_cancels_stale_running_agent_and_releases_slot() { AgentWorkerStatus::Cancelled ); } - #[tokio::test] async fn status_projection_reconciles_stale_running_agent() { let mut inner = SubAgentManager::new(PathBuf::from("."), 1) @@ -11192,7 +10976,6 @@ async fn status_projection_reconciles_stale_running_agent() { .contains("Auto-cancelled") ); } - #[tokio::test] async fn cleanup_keeps_recent_running_agent() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1) @@ -11230,7 +11013,6 @@ async fn cleanup_keeps_recent_running_agent() { .expect("live task handle") .abort(); } - #[tokio::test] async fn touch_refreshes_stale_running_agent_heartbeat() { // Use a heartbeat timeout that is comfortably larger than the synchronous @@ -11275,7 +11057,6 @@ async fn touch_refreshes_stale_running_agent_heartbeat() { .expect("live task handle") .abort(); } - #[test] fn test_persist_and_reload_marks_running_agent_as_interrupted() { let tmp = tempdir().expect("tempdir"); @@ -11316,7 +11097,6 @@ fn test_persist_and_reload_marks_running_agent_as_interrupted() { if message.contains(SUBAGENT_RESTART_REASON) )); } - #[test] fn generated_whale_name_is_not_persisted_or_replayed_on_load() { let tmp = tempdir().expect("tempdir"); @@ -11373,7 +11153,6 @@ fn generated_whale_name_is_not_persisted_or_replayed_on_load() { assert_eq!(snapshot.name, "docs-worker"); assert_eq!(snapshot.nickname, None); } - #[test] fn explicit_nonmatching_whale_word_is_persisted_and_loaded() { let tmp = tempdir().expect("tempdir"); @@ -11419,7 +11198,6 @@ fn explicit_nonmatching_whale_word_is_persisted_and_loaded() { let snapshot = reloaded.get_result(agent_id).expect("agent survives load"); assert_eq!(snapshot.nickname.as_deref(), Some(explicit_whale)); } - #[test] fn persist_and_reload_preserves_checkpoint_for_interrupted_running_agent() { let tmp = tempdir().expect("tempdir"); @@ -11470,7 +11248,6 @@ fn persist_and_reload_preserves_checkpoint_for_interrupted_running_agent() { assert_eq!(checkpoint.messages.len(), 2); assert_eq!(message_text(&checkpoint.messages[1]), "partial progress"); } - #[test] fn restart_reconciles_every_orphan_execution_status_once_and_preserves_receipts() { let tmp = tempdir().expect("tempdir"); @@ -11643,7 +11420,6 @@ fn restart_reconciles_every_orphan_execution_status_once_and_preserves_receipts( "a later restart does not append duplicate interrupted receipts" ); } - #[cfg(unix)] #[test] fn load_state_rejects_symlinked_state_file() { @@ -11668,7 +11444,6 @@ fn load_state_rejects_symlinked_state_file() { .expect_err("symlinked state should fail"); assert!(format!("{err:#}").contains("must not traverse symlinks")); } - #[test] fn persist_state_rejects_state_path_outside_state_root() { let tmp = tempdir().expect("tempdir"); @@ -11683,7 +11458,6 @@ fn persist_state_rejects_state_path_outside_state_root() { assert!(format!("{err:#}").contains("must stay within state root")); } - #[test] fn explicit_state_roots_isolate_managers_for_the_same_execution_workspace() { let tmp = tempdir().expect("tempdir"); @@ -11758,7 +11532,6 @@ fn explicit_state_roots_isolate_managers_for_the_same_execution_workspace() { "an explicit state root must keep control-plane files out of the execution workspace" ); } - #[cfg(unix)] #[test] fn persist_state_rejects_symlinked_state_directory() { @@ -11778,7 +11551,6 @@ fn persist_state_rejects_symlinked_state_directory() { || format!("{err:#}").contains("must not traverse symlinks") ); } - #[test] fn test_interrupted_status_name_and_summary() { let snapshot = make_snapshot(SubAgentStatus::Interrupted( @@ -11789,7 +11561,6 @@ fn test_interrupted_status_name_and_summary() { } // === v0.6.6 — sub-agent authority unification === - #[test] fn build_allowed_tools_general_returns_none_for_full_inheritance() { // Default behavior: General agent with no explicit list inherits the @@ -11800,7 +11571,6 @@ fn build_allowed_tools_general_returns_none_for_full_inheritance() { "General with no explicit_tools should default to full inheritance (None), got {result:?}" ); } - #[test] fn build_allowed_tools_explore_returns_none_for_full_inheritance() { // Per-type allowlists are now advisory — Explore also gets the full @@ -11811,7 +11581,6 @@ fn build_allowed_tools_explore_returns_none_for_full_inheritance() { "Explore with no explicit_tools should default to full inheritance" ); } - #[test] fn build_allowed_tools_custom_requires_explicit_list() { // Custom is the one type that REQUIRES explicit allowed_tools. @@ -11821,14 +11590,12 @@ fn build_allowed_tools_custom_requires_explicit_list() { "got: {err}" ); } - #[test] fn build_allowed_tools_explicit_list_returned_as_some() { let explicit = vec!["read_file".to_string(), "list_dir".to_string()]; let result = build_allowed_tools(&FleetRole::Custom, Some(explicit.clone()), true).unwrap(); assert_eq!(result, Some(explicit)); } - #[test] fn build_allowed_tools_explicit_list_dedupes_and_trims() { let explicit = vec![ @@ -11843,7 +11610,6 @@ fn build_allowed_tools_explicit_list_dedupes_and_trims() { Some(vec!["read_file".to_string(), "list_dir".to_string()]) ); } - #[test] fn parse_spawn_request_extracts_cwd_when_present() { let input = json!({ @@ -11856,7 +11622,6 @@ fn parse_spawn_request_extracts_cwd_when_present() { Some(".worktrees/feature-a".to_string()) ); } - #[test] fn parse_spawn_request_accepts_worktree_isolation() { let input = json!({ @@ -11878,7 +11643,6 @@ fn parse_spawn_request_accepts_worktree_isolation() { Some("feature-a".to_string()) ); } - #[test] fn parse_spawn_request_accepts_cwd_with_worktree_isolation() { let input = json!({ @@ -11890,7 +11654,6 @@ fn parse_spawn_request_accepts_cwd_with_worktree_isolation() { assert!(parsed.worktree.is_some()); assert!(parsed.cwd.is_some()); } - #[test] fn git_repo_root_finds_repo_from_direct_cwd() { let repo = init_subagent_git_repo(); @@ -11900,7 +11663,6 @@ fn git_repo_root_finds_repo_from_direct_cwd() { repo.path().canonicalize().expect("canonical repo") ); } - #[test] fn git_repo_root_discovers_one_level_nested_repo_from_harness() { let repo = init_subagent_git_repo(); @@ -11920,7 +11682,6 @@ fn git_repo_root_discovers_one_level_nested_repo_from_harness() { nested.canonicalize().expect("canonical nested") ); } - #[test] fn git_repo_root_reports_attempted_paths_when_no_repo_found() { // Use the system temp dir rather than the checkout's parent: a checkout @@ -11951,21 +11712,18 @@ fn git_repo_root_reports_attempted_paths_when_no_repo_found() { "expected friendly attempted-path error, got: {message}" ); } - #[test] fn parse_spawn_request_cwd_absent_yields_none() { let input = json!({ "prompt": "no cwd" }); let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert!(parsed.cwd.is_none()); } - #[test] fn parse_spawn_request_cwd_empty_string_yields_none() { let input = json!({ "prompt": "empty cwd", "cwd": " " }); let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert!(parsed.cwd.is_none(), "whitespace-only cwd should be None"); } - #[test] fn create_isolated_worktree_creates_branch_checkout_outside_parent_repo() { let repo = init_subagent_git_repo(); @@ -11994,7 +11752,6 @@ fn create_isolated_worktree_creates_branch_checkout_outside_parent_repo() { Some("codex/agent-isolated-test") ); } - #[test] fn create_isolated_worktree_rejects_invalid_branch_as_input() { let repo = init_subagent_git_repo(); @@ -12018,7 +11775,6 @@ fn create_isolated_worktree_rejects_invalid_branch_as_input() { "unexpected error: {err}" ); } - fn init_git_repo_at(path: &std::path::Path) { let init = Command::new("git") .arg("init") @@ -12042,7 +11798,6 @@ fn init_git_repo_at(path: &std::path::Path) { .expect("git commit should run"); assert!(commit.status.success(), "git commit failed"); } - #[test] fn create_isolated_worktree_discovers_nested_repo_from_harness_parent() { let harness = tempdir().expect("harness"); @@ -12070,7 +11825,6 @@ fn create_isolated_worktree_discovers_nested_repo_from_harness_parent() { Some("codex/agent-harness-nested") ); } - #[test] fn create_isolated_worktree_reports_friendly_error_when_no_repo_found() { let harness = tempdir().expect("harness"); @@ -12104,7 +11858,6 @@ fn create_isolated_worktree_reports_friendly_error_when_no_repo_found() { "expected actionable discovery error, got: {message}" ); } - #[test] fn create_isolated_worktree_rejects_ambiguous_nested_repos() { let harness = tempdir().expect("harness"); @@ -12129,7 +11882,6 @@ fn create_isolated_worktree_rejects_ambiguous_nested_repos() { "expected ambiguity diagnostic, got: {message}" ); } - #[test] fn build_subagent_system_prompt_appends_role_when_set() { let assignment = SubAgentAssignment::new("p".to_string(), Some("worker".to_string())); @@ -12142,21 +11894,18 @@ fn build_subagent_system_prompt_appends_role_when_set() { // The shared background-worker / caller framing follows the role line. assert!(prompt.contains("background sub-agent")); } - #[test] fn build_subagent_system_prompt_skips_role_when_none() { let assignment = SubAgentAssignment::new("p".to_string(), None); let prompt = build_subagent_system_prompt(&FleetRole::Worker, &assignment); assert!(!prompt.contains("You are operating in the role of")); } - #[test] fn build_subagent_system_prompt_skips_role_when_blank() { let assignment = SubAgentAssignment::new("p".to_string(), Some(" ".to_string())); let prompt = build_subagent_system_prompt(&FleetRole::Worker, &assignment); assert!(!prompt.contains("You are operating in the role of")); } - #[test] fn fresh_forked_and_nested_subagents_share_authority_bound_skill_catalogs() { let _env = crate::test_support::lock_test_env(); @@ -12270,7 +12019,6 @@ fn fresh_forked_and_nested_subagents_share_authority_bound_skill_catalogs() { "workspace plugin authority must not leak into another worktree: {isolated_system}" ); } - #[test] fn subagent_done_sentinel_format_is_well_formed() { let res = make_snapshot(SubAgentStatus::Completed); @@ -12297,7 +12045,6 @@ fn subagent_done_sentinel_format_is_well_formed() { assert!(parsed.get("duration_ms").is_none()); assert!(parsed.get("steps").is_none()); } - #[test] fn subagent_done_sentinel_keeps_large_result_out_of_metadata() { let mut res = make_snapshot(SubAgentStatus::Completed); @@ -12318,7 +12065,6 @@ fn subagent_done_sentinel_keeps_large_result_out_of_metadata() { "sentinel should not duplicate large result text" ); } - #[test] fn subagent_done_sentinel_marks_truncated_summaries() { // issue #2652: when the child summary was length-gated, the sentinel must @@ -12331,7 +12077,6 @@ fn subagent_done_sentinel_marks_truncated_summaries() { let parsed: serde_json::Value = serde_json::from_str(inner).expect("inner JSON parses"); assert_eq!(parsed["summary_kind"], "truncated"); } - #[test] fn failed_subagent_completion_is_high_priority_and_retrievable() { let mut res = make_snapshot(SubAgentStatus::Failed( @@ -12365,7 +12110,6 @@ fn failed_subagent_completion_is_high_priority_and_retrievable() { let completed = subagent_completion_from_result(&make_snapshot(SubAgentStatus::Completed)); assert!(!completed.is_high_priority_failure()); } - #[test] fn budget_exhaustion_is_a_high_priority_failure_event() { let completion = @@ -12383,7 +12127,6 @@ fn budget_exhaustion_is_a_high_priority_failure_event() { .contains(r#""failure_class":"token_budget""#) ); } - #[test] fn stamp_subagent_summary_appends_note_when_short() { // issue #2652: a short (complete) summary gets the soft self-report note @@ -12400,7 +12143,6 @@ fn stamp_subagent_summary_appends_note_when_short() { "short summary must not get the truncation footer" ); } - #[test] fn forkguard_subagent_report_artifact_keeps_exact_bytes() { let home = tempdir().expect("artifact home"); @@ -12442,7 +12184,6 @@ fn forkguard_subagent_report_artifact_keeps_exact_bytes() { assert!(spill_subagent_final_report(blocked_session, &result).is_none()); }); } - #[test] fn stamp_subagent_summary_truncates_when_over_budget() { // issue #2652: a summary exceeding the budget is head+tail truncated using @@ -12477,7 +12218,6 @@ fn stamp_subagent_summary_truncates_when_over_budget() { assert!(saved.contains("activate it via tool_search first")); assert!(saved.contains("if neither route is available")); } - #[test] fn subagent_failed_sentinel_format_is_well_formed() { let mut result = make_snapshot(SubAgentStatus::Failed("boom".to_string())); @@ -12503,7 +12243,6 @@ fn subagent_failed_sentinel_format_is_well_formed() { // Stays lean — the error text lives on the previous line, not the sentinel. assert!(parsed.get("error").is_none()); } - #[test] fn annotated_failure_message_composes_class_tag_and_model_hint() { // #3884: the failure recorder composes subagent_failure_message (adds the @@ -12546,7 +12285,6 @@ fn annotated_failure_message_composes_class_tag_and_model_hint() { assert!(annotated.contains("route:"), "{annotated}"); assert!(annotated.contains("explicit model id"), "{annotated}"); } - #[test] fn subagent_failure_message_preserves_error_chain() { // #3884: `to_string()` on an anyhow error prints only the outermost @@ -12587,7 +12325,6 @@ fn subagent_failure_message_preserves_error_chain() { let message = subagent_failure_message(&err); assert_eq!(message, "outer: boom"); } - #[test] fn forkguard_quota_exhausted_skips_rate_limit_governor_and_stays_fatal() { // PR#43 follow-up: only LlmError::RateLimited feeds the rate-limit @@ -12621,7 +12358,6 @@ fn forkguard_quota_exhausted_skips_rate_limit_governor_and_stays_fatal() { }); assert!(is_governor_reported_rate_limit(&rate_limited_err)); } - #[test] fn annotate_child_model_error_adds_actionable_hint() { // #2653: a bare provider 403 becomes actionable by naming the model and the @@ -12667,7 +12403,6 @@ fn annotate_child_model_error_adds_actionable_hint() { "OpenAI-style rejection gets the hint: {openai_style}" ); } - #[test] fn child_launch_error_names_provider_model_and_route_source() { // #4049: a model-not-found child launch failure must name the provider @@ -12711,13 +12446,11 @@ fn child_launch_error_names_provider_model_and_route_source() { "inherit route source: {inherited}" ); } - #[test] fn subagent_runtime_default_max_depth_is_three() { // Sanity-check the constant — bumping it without a test means stale docs. assert_eq!(DEFAULT_MAX_SPAWN_DEPTH, 3); } - #[test] fn would_exceed_depth_at_boundary() { // depth=2, max=3 → next spawn (depth 3) is allowed (allow-equal). @@ -12739,7 +12472,6 @@ fn would_exceed_depth_at_boundary() { "depth 2 + max 3 → next is 3, allowed" ); } - #[test] fn clamp_child_max_spawn_depth_enforces_absolute_ceiling() { let ceiling = codewhale_config::MAX_SPAWN_DEPTH_CEILING; @@ -12763,7 +12495,6 @@ fn clamp_child_max_spawn_depth_enforces_absolute_ceiling() { "at the ceiling, a further spawn must be blocked regardless of max_depth" ); } - #[tokio::test] #[allow(clippy::await_holding_lock)] async fn rate_limit_pause_blocks_subagent_spawn() { @@ -12798,7 +12529,6 @@ async fn rate_limit_pause_blocks_subagent_spawn() { "refused spawn must not register or launch a worker" ); } - #[test] fn child_runtime_increments_depth_and_preserves_auto_approve() { let mut parent = stub_runtime(); @@ -12829,7 +12559,6 @@ fn child_runtime_increments_depth_and_preserves_auto_approve() { // `WorkRuntime::matches_todos` keys on `Arc::ptr_eq`, drove the parent's work // graph). These tests pin the invariant: every spawned agent owns its list, // and no agent can reach a parent's or a sibling's. - /// Run `work_update` against `todos` with `runtime`'s own tool context, the /// way a live agent's registry would. async fn write_todos_as(runtime: &SubAgentRuntime, contents: &[&str]) { @@ -12842,7 +12571,6 @@ async fn write_todos_as(runtime: &SubAgentRuntime, contents: &[&str]) { .await .expect("work_update must succeed against the agent's own list"); } - async fn todo_contents(todos: &crate::tools::todo::SharedTodoList) -> Vec { todos .lock() @@ -12853,7 +12581,6 @@ async fn todo_contents(todos: &crate::tools::todo::SharedTodoList) -> Vec crate::todo_snapshot::TodoSource { crate::todo_snapshot::TodoSource::new( runtime.context.runtime.work.clone(), runtime.todos.clone(), ) } - /// A child sends its stored messages and nothing else. Its To-do state is /// already in those messages, as the result of its own `work_update` call — /// no step appends a synthetic copy of the list. @@ -13086,7 +12806,6 @@ async fn child_request_messages_are_exactly_its_stored_messages() { assert!(!text.contains("child step one"), "{text}"); } } - /// Sibling and parent isolation: each agent's snapshot states only its own /// list. This is what the fork handoff and the agent card both read. #[tokio::test] @@ -13134,7 +12853,6 @@ async fn child_todo_snapshot_never_leaks_across_siblings_or_to_the_parent() { } } } - #[test] fn child_and_background_runtimes_preserve_step_api_timeout() { let timeout = Duration::from_secs(7); @@ -13151,7 +12869,6 @@ fn child_and_background_runtimes_preserve_step_api_timeout() { assert_eq!(background.step_api_timeout, timeout); assert_eq!(background.api_timeout_retry_base_backoff, backoff); } - #[tokio::test] async fn subagent_registry_blocks_approval_tools_without_parent_auto_approve() { let mut runtime = stub_runtime(); @@ -13178,7 +12895,6 @@ async fn subagent_registry_blocks_approval_tools_without_parent_auto_approve() { "unexpected error: {err}" ); } - #[tokio::test] async fn prompt_only_general_cannot_mutate_under_parent_auto_approve() { let tmp = tempdir().expect("tempdir"); @@ -13226,7 +12942,6 @@ async fn prompt_only_general_cannot_mutate_under_parent_auto_approve() { } const MCP_ACTION_TOOL: &str = "mcp_github_create_pull_request"; - fn subagent_registry_with_mcp_action(auto_approve: bool) -> SubAgentToolRegistry { let mut runtime = stub_runtime(); runtime.context.auto_approve = auto_approve; @@ -13244,7 +12959,6 @@ fn subagent_registry_with_mcp_action(auto_approve: bool) -> SubAgentToolRegistry )); registry } - #[tokio::test] async fn child_write_tool_fails_closed_outside_registered_scope() { let _env_lock = crate::test_support::lock_test_env(); @@ -13446,7 +13160,6 @@ async fn child_write_tool_fails_closed_outside_registered_scope() { ); assert!(!tmp.path().join("docs/run-escape.txt").exists()); } - /// A lone writer in the shared checkout keeps its shell. /// /// The gate above exists so concurrent children cannot overwrite each other. @@ -13511,7 +13224,6 @@ async fn lone_shared_writer_keeps_unbounded_shell() { ); } } - #[test] fn shared_claim_shell_gate_normalizes_only_the_run_action() { assert!(is_unbounded_shell_run( @@ -13530,7 +13242,6 @@ fn shared_claim_shell_gate_normalizes_only_the_run_action() { ); } } - /// Build one registry per contended child, both bound to the same shared /// manager and workspace — the two-builder shared-checkout topology. fn contended_builder_registry( @@ -13553,7 +13264,6 @@ fn contended_builder_registry( Arc::new(Mutex::new(PlanState::default())), ) } - /// Two live write-capable children with disjoint `exact_files` claims in one /// shared checkout: a bash call the read-only classifier proves mutation-free /// must still run on both. The contention gate used to re-test @@ -13600,7 +13310,6 @@ async fn contended_shared_writers_keep_proven_readonly_shell() { }); } } - /// The same contention still refuses a shell call the classifier cannot prove /// read-only — and the refusal names the blocking peer and its remediation so /// the child can recover instead of retrying blindly. @@ -13661,7 +13370,6 @@ async fn contended_shared_writer_refusal_names_blocking_peer_and_remediation() { "the refused command must not have run" ); } - /// A child whose task body panics must still reach a terminal state. /// `spawn_supervised` catches the panic to keep the parent alive; without the /// guard committing a crash result the child stayed `Running` and its write @@ -13725,7 +13433,6 @@ async fn panicked_child_task_terminalizes_and_stops_gating_peers() { "a panicked child must not keep gating peers as a live writer" ); } - /// A headless fleet worker (a worker record with no paired agent entry) that /// reaches `WaitingForUser` can never be answered — nothing will resume or /// finalize it — so it must not count as a live coordination owner, and the @@ -14659,7 +14366,6 @@ async fn claim_less_worktree_child_still_confers_isolation_through_its_manifest( "the manifest fallback still puts the claim on the isolated footing" ); } - #[tokio::test] async fn subagent_blocks_mcp_action_without_parent_auto_approve() { let registry = subagent_registry_with_mcp_action(false); @@ -14676,7 +14382,6 @@ async fn subagent_blocks_mcp_action_without_parent_auto_approve() { "unexpected MCP approval error: {err}" ); } - #[tokio::test] async fn auto_approved_subagent_passes_mcp_action_approval_gate() { let registry = subagent_registry_with_mcp_action(true); @@ -14695,7 +14400,6 @@ async fn auto_approved_subagent_passes_mcp_action_approval_gate() { "auto-approved MCP actions must not stop at the approval gate: {message}" ); } - #[tokio::test] async fn implementer_delegation_allows_suggest_write_without_parent_auto_approve() { // Issue #1828: implementer agents could not write files even when their @@ -14733,7 +14437,6 @@ async fn implementer_delegation_allows_suggest_write_without_parent_auto_approve "successful write should not look like an approval error: {result}" ); } - #[tokio::test] async fn workflow_accept_edits_allows_general_file_write_without_parent_auto_approve() { // Workflow-spawned children accept Suggest-level file edits for write-capable @@ -14782,7 +14485,6 @@ async fn workflow_accept_edits_allows_general_file_write_without_parent_auto_app "unexpected: {err}" ); } - #[tokio::test] async fn general_delegation_still_blocks_suggest_write_without_parent_auto_approve() { let tmp = tempdir().expect("tempdir"); @@ -14817,7 +14519,6 @@ async fn general_delegation_still_blocks_suggest_write_without_parent_auto_appro "general write must not land without parent auto-approve" ); } - #[tokio::test] async fn explore_role_still_blocks_suggest_writes_without_parent_auto_approve() { // Read-only stances (explore, plan, review, verifier) must not gain @@ -14857,7 +14558,6 @@ async fn explore_role_still_blocks_suggest_writes_without_parent_auto_approve() "file must not have been written" ); } - #[tokio::test] async fn explore_role_blocks_writes_even_under_parent_auto_approve() { // #3217: the authoritative per-role posture closes the auto-approve bypass — @@ -14903,7 +14603,6 @@ async fn explore_role_blocks_writes_even_under_parent_auto_approve() { "file must not have been written under auto-approve" ); } - #[tokio::test] async fn delegated_write_role_still_blocks_required_tools() { // Required-level tools such as Bash remain gated behind parent @@ -14935,7 +14634,6 @@ async fn delegated_write_role_still_blocks_required_tools() { "expected Required-level approval message, got: {err}" ); } - #[test] fn read_only_role_starts_do_not_require_approval() { // #5186: an explicit canonical read-only role spawns without a modal in @@ -14976,7 +14674,6 @@ fn read_only_role_starts_do_not_require_approval() { ApprovalRequirement::Auto ); } - #[test] fn write_capable_or_unproven_starts_keep_the_approval_gate() { // #5186: anything the parser cannot prove read-only keeps the modal. @@ -15016,7 +14713,6 @@ fn write_capable_or_unproven_starts_keep_the_approval_gate() { ApprovalRequirement::Auto ); } - #[tokio::test] async fn worker_child_inherits_workspace_write_carve_out_without_parent_auto_approve() { // #5186: a write-posture child that is NOT an explicit write-delegated @@ -15067,7 +14763,6 @@ async fn worker_child_inherits_workspace_write_carve_out_without_parent_auto_app ); } } - #[tokio::test] async fn worker_child_write_carve_out_requires_a_git_work_tree() { // #5186: without a `.git` marker the child keeps the old delegation @@ -15097,7 +14792,6 @@ async fn worker_child_write_carve_out_requires_a_git_work_tree() { "unexpected error: {err}" ); } - #[tokio::test] async fn builder_child_runs_bounded_verification_but_not_shell_without_parent_auto_approve() { // #5186: the bounded built-in verification surface is delegated to @@ -15156,7 +14850,6 @@ async fn builder_child_runs_bounded_verification_but_not_shell_without_parent_au "unexpected error: {argv_err}" ); } - #[tokio::test] async fn auto_approved_parent_runs_required_tools_in_subagent() { // Baseline: when the parent runtime IS auto-approved, every approval @@ -15185,7 +14878,6 @@ async fn auto_approved_parent_runs_required_tools_in_subagent() { .await .expect("auto-approved parent should allow writes"); } - #[test] fn subagent_request_budget_inherits_the_resolved_route_allowance() { assert_eq!( @@ -15194,7 +14886,6 @@ fn subagent_request_budget_inherits_the_resolved_route_allowance() { "sub-agents must not impose a smaller internal output ceiling" ); } - #[test] fn incomplete_response_failure_keeps_the_provider_stop_cause() { let response = MessageResponse { @@ -15214,7 +14905,6 @@ fn incomplete_response_failure_keeps_the_provider_stop_cause() { assert!(failure.contains("`content_filter`"), "{failure}"); assert!(!failure.contains("budget exhausted"), "{failure}"); } - #[test] fn child_cancellation_cascades_from_parent() { let parent = stub_runtime(); @@ -15226,7 +14916,6 @@ fn child_cancellation_cascades_from_parent() { "parent cancel() must propagate to child via child_token()" ); } - #[test] fn detached_background_children_survive_parent_cancellation() { let parent = stub_runtime(); @@ -15240,7 +14929,6 @@ fn detached_background_children_survive_parent_cancellation() { "parent stop must leave every detached child running until explicitly cancelled" ); } - #[test] fn agent_start_is_turn_owned_unless_detached_is_explicit() { let owned = parse_spawn_request(&json!({"prompt": "inspect the workspace"})) @@ -15260,7 +14948,6 @@ fn agent_start_is_turn_owned_unless_detached_is_explicit() { "only an explicit detached=true may outlive the turn" ); } - #[test] fn agent_start_marks_only_explicit_detachment_as_durable_work() { let runtime = stub_runtime(); @@ -15278,7 +14965,6 @@ fn agent_start_marks_only_explicit_detachment_as_durable_work() { "only detached=true may opt into durable background scheduling" ); } - #[tokio::test] async fn foreground_turn_cancellation_joins_direct_children_once_and_excludes_detached() { let registry = Arc::new(ForegroundChildRegistry::new()); @@ -15335,7 +15021,6 @@ async fn foreground_turn_cancellation_joins_direct_children_once_and_excludes_de assert!(late_token.is_cancelled()); assert_eq!(registry.active_count(), 0); } - #[tokio::test] async fn foreground_registration_releases_when_the_child_future_returns_or_unwinds() { let registry = Arc::new(ForegroundChildRegistry::new()); @@ -15366,7 +15051,6 @@ async fn foreground_registration_releases_when_the_child_future_returns_or_unwin .await .expect("return and panic-unwind both release foreground ownership"); } - #[tokio::test] async fn turn_owned_descendants_join_and_park_with_the_same_foreground_registry() { let registry = Arc::new(ForegroundChildRegistry::new()); @@ -15436,7 +15120,6 @@ async fn turn_owned_descendants_join_and_park_with_the_same_foreground_registry( ); assert!(!detached.cancel_token.is_cancelled()); } - #[test] fn derived_child_cancellation_reaches_tool_context_while_detached_stays_independent() { let root = stub_runtime(); @@ -15477,7 +15160,6 @@ fn derived_child_cancellation_reaches_tool_context_while_detached_stays_independ .is_some_and(CancellationToken::is_cancelled) ); } - #[tokio::test] async fn turn_end_parking_preserves_a_step_zero_resumable_checkpoint() { let tmp = tempdir().expect("tempdir"); @@ -15550,7 +15232,6 @@ async fn turn_end_parking_preserves_a_step_zero_resumable_checkpoint() { assert!(explicit_needs_input.is_none()); assert_eq!(explicit_worker_status, AgentWorkerStatus::Cancelled); } - #[test] fn turn_end_parking_wins_over_the_final_child_step_limit() { let agent_id = "agent_turn_end_final_step"; @@ -15577,7 +15258,6 @@ fn turn_end_parking_wins_over_the_final_child_step_limit() { assert!(needs_input.is_some()); assert_eq!(worker_status, AgentWorkerStatus::Interrupted); } - #[test] fn mailbox_propagates_through_child_runtime_chain() { use crate::tools::subagent::mailbox::Mailbox; @@ -15597,7 +15277,6 @@ fn mailbox_propagates_through_child_runtime_chain() { "grandchild inherits via the cloned Arc inside Mailbox" ); } - #[test] fn subagent_rejects_interactive_shell_terminal_takeover() { let err = reject_subagent_terminal_takeover( @@ -15640,7 +15319,6 @@ fn subagent_rejects_interactive_shell_terminal_takeover() { ) .expect("background shell remains allowed"); } - #[tokio::test] async fn mailbox_close_as_cancel_propagates_to_grandchild_runtime() { use crate::tools::subagent::mailbox::Mailbox; @@ -15665,7 +15343,6 @@ async fn mailbox_close_as_cancel_propagates_to_grandchild_runtime() { "close-as-cancel must propagate across max_spawn_depth=3" ); } - #[tokio::test] async fn mailbox_orders_messages_from_parent_and_child_runtimes() { use crate::tools::subagent::mailbox::{Mailbox, MailboxMessage}; @@ -15717,7 +15394,6 @@ async fn mailbox_orders_messages_from_parent_and_child_runtimes() { other => panic!("unexpected message order: {other:?}"), } } - #[test] fn persisted_empty_allowed_tools_loads_as_full_inheritance() { // Backward-compat: a v0.6.5 session that persisted with an empty Vec @@ -15750,7 +15426,6 @@ fn persisted_empty_allowed_tools_loads_as_full_inheritance() { "empty Vec on disk → None (full inheritance)" ); } - #[test] fn persisted_non_empty_allowed_tools_loads_as_narrow() { // Backward-compat the other way: a v0.6.5 session that persisted with @@ -15783,7 +15458,6 @@ fn persisted_non_empty_allowed_tools_loads_as_narrow() { "non-empty Vec → Some(list), narrow scope preserved" ); } - #[test] fn persisted_advisory_assignment_roles_replay_and_repersist_as_consultant() { for alias in ["oracle", "advisor"] { @@ -15831,7 +15505,6 @@ fn persisted_advisory_assignment_roles_replay_and_repersist_as_consultant() { assert_eq!(repersisted["agents"][0]["agent_type"], json!("advisor")); } } - /// Build a minimal `SubAgentRuntime` for tests that exercise pure runtime /// helpers (depth, cancellation, child_runtime). Doesn't construct a real /// HTTP client — calls that hit `runtime.client` would fail, but the @@ -15897,7 +15570,6 @@ pub(crate) fn stub_runtime() -> SubAgentRuntime { ask_on_guardrail: false, } } - #[test] fn root_operate_dispatch_delegates_file_edits_without_bypassing_required_tools() { let mut runtime = stub_runtime(); @@ -15915,7 +15587,6 @@ fn root_operate_dispatch_delegates_file_edits_without_bypassing_required_tools() "Operate dispatch must not silently grant Required tools such as shell" ); } - #[tokio::test] async fn root_operate_dispatch_delegates_builtin_verification_but_not_shell() { let tmp = tempdir().expect("tempdir"); @@ -15993,7 +15664,6 @@ async fn root_operate_dispatch_delegates_builtin_verification_but_not_shell() { "Operate verification delegation must not propagate past the direct worker" ); } - #[test] fn worker_lifecycle_records_direct_operate_approval_without_delegating_authority() { let todos = crate::tools::todo::new_shared_todo_list(); @@ -16092,7 +15762,6 @@ fn worker_lifecycle_records_direct_operate_approval_without_delegating_authority "nested workers must not inherit Operate approval authority" ); } - /// A minimal stub client. Test helpers below only ever check struct fields /// (depth, cancel_token, context); they don't call the network. We need a /// *some* `DeepSeekClient` because `SubAgentRuntime.client` isn't @@ -16103,7 +15772,6 @@ fn stub_runtime_for_provider(provider: &str) -> SubAgentRuntime { runtime.client = stub_client_for_provider(provider); runtime } - fn stub_client_for_provider(provider: &str) -> DeepSeekClient { let _ = rustls::crypto::ring::default_provider().install_default(); let mut providers = crate::config::ProvidersConfig::default(); @@ -16152,7 +15820,6 @@ fn stub_client_for_provider(provider: &str) -> DeepSeekClient { }; DeepSeekClient::new(&config).expect("stub client should construct") } - fn stub_client() -> DeepSeekClient { let _ = rustls::crypto::ring::default_provider().install_default(); let config = crate::config::Config { @@ -16167,7 +15834,6 @@ fn stub_client() -> DeepSeekClient { // There are no provider pins outside the durable Fleet runs: every child runs // on the parent's provider. Child and background runtimes clone the session // client and config unchanged — no cross-provider build, no misroute. - /// A session runtime on `deepseek` with the cross-provider `Config` threaded in, /// exactly as the engine wires it via `with_api_config`. fn cross_provider_runtime() -> SubAgentRuntime { @@ -16195,7 +15861,6 @@ fn cross_provider_runtime() -> SubAgentRuntime { runtime.client = client; runtime } - #[test] fn spawn_child_runtime_inherits_session_client_and_config() { let runtime = cross_provider_runtime(); @@ -16238,7 +15903,6 @@ fn spawn_child_runtime_inherits_session_client_and_config() { // carry the prior boot id and are classified as `from_prior_session`. // Listings default to current-session only; `include_archived=true` surfaces // the prior-session records with the flag set. - fn insert_prior_session_agent( manager: &mut SubAgentManager, id: &str, @@ -16284,7 +15948,6 @@ fn insert_prior_session_agent( } manager.agents.insert(id.to_string(), agent); } - fn assign_test_session_owner( manager: &mut SubAgentManager, agent_id: &str, @@ -16299,14 +15962,12 @@ fn assign_test_session_owner( record.owner_session_id = owner_session_id.to_string(); } } - #[test] fn session_boot_ids_are_unique_per_manager() { let a = SubAgentManager::new(PathBuf::from("."), 1); let b = SubAgentManager::new(PathBuf::from("."), 1); assert_ne!(a.session_boot_id(), b.session_boot_id()); } - #[test] fn terminal_synthesis_is_scoped_to_owner_and_keeps_delivery_history() { let mut manager = SubAgentManager::new(PathBuf::from("."), 5); @@ -16349,7 +16010,6 @@ fn terminal_synthesis_is_scoped_to_owner_and_keeps_delivery_history() { "returning A -> B -> A must not re-synthesize an already delivered terminal result" ); } - #[test] fn active_session_owns_every_roster_and_control_resolution() { let workspace = tempdir().expect("tempdir"); @@ -16465,7 +16125,6 @@ fn active_session_owns_every_roster_and_control_resolution() { "A -> B -> A restores A's archived control row" ); } - #[test] fn forkguard_cancel_all_running_is_session_scoped_and_idempotent() { let workspace = tempdir().expect("tempdir"); @@ -16523,7 +16182,6 @@ fn forkguard_cancel_all_running_is_session_scoped_and_idempotent() { "idempotent cleanup may not append another terminal event" ); } - #[test] fn ownerless_legacy_rows_fail_closed_and_scoped_close_preserves_foreign_workers() { let workspace = tempdir().expect("tempdir"); @@ -16575,7 +16233,6 @@ fn ownerless_legacy_rows_fail_closed_and_scoped_close_preserves_foreign_workers( .is_terminal() ); } - #[test] fn scoped_handle_eviction_drain_preserves_foreign_pending_ids() { let mut manager = SubAgentManager::new(PathBuf::from("."), 2); @@ -16593,7 +16250,6 @@ fn scoped_handle_eviction_drain_preserves_foreign_pending_ids() { vec!["agent-b".to_string()] ); } - #[test] fn coordination_records_are_visible_and_mutable_only_to_their_owner_session() { let mut manager = SubAgentManager::new(PathBuf::from("."), 2); @@ -16624,7 +16280,6 @@ fn coordination_records_are_visible_and_mutable_only_to_their_owner_session() { "B must not gain mutation authority over A's decision" ); } - #[test] fn list_filtered_drops_prior_session_terminals_by_default() { let mut manager = SubAgentManager::new(PathBuf::from("."), 5); @@ -16671,7 +16326,6 @@ fn list_filtered_drops_prior_session_terminals_by_default() { .unwrap(); assert!(!current.from_prior_session); } - #[test] fn list_snapshots_refresh_git_branch_from_agent_workspace() { let repo = init_subagent_git_repo(); @@ -16703,7 +16357,6 @@ fn list_snapshots_refresh_git_branch_from_agent_workspace() { .expect("current agent should still be listed"); assert_eq!(agent.git_branch.as_deref(), Some("feature/agent-new")); } - #[test] fn list_filtered_with_include_archived_returns_everything() { let mut manager = SubAgentManager::new(PathBuf::from("."), 5); @@ -16737,7 +16390,6 @@ fn list_filtered_with_include_archived_returns_everything() { .unwrap(); assert!(!current.from_prior_session); } - #[test] fn agents_with_empty_boot_id_classify_as_prior_session() { // Records persisted before #405 land with an empty `session_boot_id` @@ -16759,7 +16411,6 @@ fn agents_with_empty_boot_id_classify_as_prior_session() { .unwrap(); assert!(legacy.from_prior_session); } - #[test] fn persist_round_trip_preserves_session_and_boot_ownership() { let dir = tempdir().expect("tempdir"); @@ -16841,7 +16492,6 @@ fn persist_round_trip_preserves_session_and_boot_ownership() { // loop drains that channel; for nested agents the running parent sub-agent // owns a local receiver and injects the completion into its own transcript. // These tests cover the routing logic and no-channel safety. - fn runtime_with_depth( spawn_depth: u32, parent_completion_tx: Option>, @@ -16851,7 +16501,6 @@ fn runtime_with_depth( rt.parent_completion_tx = parent_completion_tx; rt } - #[test] fn emit_parent_completion_fires_for_direct_child() { let (tx, mut rx) = mpsc::unbounded_channel::(); @@ -16866,7 +16515,6 @@ fn emit_parent_completion_fires_for_direct_child() { assert_eq!(received.payload, "summary line\n"); assert!(rx.try_recv().is_err(), "should be exactly one message"); } - #[test] fn child_runtime_inherits_speech_output_dir() { let output_dir = PathBuf::from("configured-speech-output"); @@ -16880,7 +16528,6 @@ fn child_runtime_inherits_speech_output_dir() { Some(PathBuf::from("configured-speech-output")) ); } - #[test] fn emit_parent_completion_fires_for_nested_child() { let (tx, mut rx) = mpsc::unbounded_channel::(); @@ -16894,7 +16541,6 @@ fn emit_parent_completion_fires_for_nested_child() { assert_eq!(received.agent_id, "agent_grandchild"); assert_eq!(received.payload, "nested summary"); } - #[test] fn emit_parent_completion_skips_engine_self() { // depth 0 is the engine itself — the engine never spawns a task at @@ -16910,7 +16556,6 @@ fn emit_parent_completion_skips_engine_self() { ); assert!(rx.try_recv().is_err()); } - #[test] fn emit_parent_completion_no_channel_is_noop() { let runtime = runtime_with_depth(1, None); @@ -16922,7 +16567,6 @@ fn emit_parent_completion_no_channel_is_noop() { "missing channel should be a silent no-op, not a panic" ); } - #[test] fn emit_parent_completion_dropped_receiver_does_not_panic() { let (tx, rx) = mpsc::unbounded_channel::(); @@ -16939,7 +16583,6 @@ fn emit_parent_completion_dropped_receiver_does_not_panic() { "we still attempt the send; the engine being gone is not our problem" ); } - #[test] fn terminal_results_excluding_returns_only_current_root_undelivered_agents() { let tmp = tempdir().expect("tempdir"); @@ -17014,7 +16657,6 @@ fn terminal_results_excluding_returns_only_current_root_undelivered_agents() { let delivered = HashSet::from(["agent_root_done".to_string()]); assert!(manager.terminal_results_excluding(&delivered).is_empty()); } - #[tokio::test] async fn run_subagent_task_claims_before_delivery_and_then_finalizes() { let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 2))); @@ -17092,7 +16734,6 @@ async fn run_subagent_task_claims_before_delivery_and_then_finalizes() { snapshot.status ); } - #[tokio::test] async fn cancellation_wins_task_race_but_still_fans_in_exactly_once() { use tokio_util::sync::CancellationToken; @@ -17214,7 +16855,6 @@ async fn cancellation_wins_task_race_but_still_fans_in_exactly_once() { if id == &snapshot.agent_id && result.contains(r#""status":"cancelled""#) )); } - /// Call 1 answers with a tool call (so the child banks a real step); /// every later call fails with a non-retryable 400. async fn tool_call_then_invalid_request_chat_client() -> (DeepSeekClient, Arc) { @@ -17293,7 +16933,6 @@ async fn tool_call_then_invalid_request_chat_client() -> (DeepSeekClient, Arc(); @@ -17649,7 +17283,6 @@ fn nested_tool_runtime_routes_child_completions_to_local_inbox() { "root engine must not receive nested child completion directly" ); } - #[test] fn subagent_completion_from_result_surfaces_step_limit_not_silent_success() { let snap = make_snapshot(SubAgentStatus::Failed( @@ -17663,7 +17296,6 @@ fn subagent_completion_from_result_surfaces_step_limit_not_silent_success() { assert!(completion.payload.contains("max_steps"), "{completion:?}"); assert!(!completion.payload.contains("Completed (no output)")); } - #[test] fn subagent_completion_from_result_preserves_missing_final_summary_diagnostic() { let snap = make_snapshot(SubAgentStatus::Completed); @@ -17673,7 +17305,6 @@ fn subagent_completion_from_result_preserves_missing_final_summary_diagnostic() "{completion:?}" ); } - #[test] fn subagent_budget_exhaustion_completion_carries_budget_exhausted_sentinel() { let mut snap = make_snapshot(SubAgentStatus::BudgetExhausted); @@ -17696,7 +17327,6 @@ fn subagent_budget_exhaustion_completion_carries_budget_exhausted_sentinel() { assert_eq!(parsed["failure_class"], "token_budget"); assert_eq!(parsed["error_location"], "previous_line"); } - #[test] fn subagent_completion_inlines_evidence_before_sentinel() { let mut snap = make_snapshot(SubAgentStatus::Completed); @@ -17718,7 +17348,6 @@ fn subagent_completion_inlines_evidence_before_sentinel() { "summary before evidence" ); } - #[test] fn subagent_completion_skips_empty_evidence_on_failed_child() { let mut snap = make_snapshot(SubAgentStatus::Failed("boom".to_string())); @@ -17726,7 +17355,6 @@ fn subagent_completion_skips_empty_evidence_on_failed_child() { let completion = subagent_completion_from_result(&snap); assert!(!completion.payload.contains("### EVIDENCE")); } - #[test] fn child_completion_runtime_message_preserves_agent_and_provenance_guidance() { let message = child_completion_runtime_message(&[SubAgentCompletion { @@ -17744,7 +17372,6 @@ fn child_completion_runtime_message_preserves_agent_and_provenance_guidance() { assert!(text.contains("cite the child agent_id and the EVIDENCE lines")); assert!(text.contains("src/lib.rs:1-3")); } - #[test] fn subagent_runtime_default_step_api_timeout_matches_config_default() { // The runtime default is derived from the config default so call sites @@ -17761,13 +17388,11 @@ fn subagent_runtime_default_step_api_timeout_matches_config_default() { std::time::Duration::from_secs(600) ); } - #[test] fn with_step_api_timeout_overrides_runtime_field() { let runtime = stub_runtime().with_step_api_timeout(std::time::Duration::from_secs(900)); assert_eq!(runtime.step_api_timeout.as_secs(), 900); } - #[test] fn tool_timeout_defaults_to_generous_budget_and_survives_spawn() { // Track A raised the per-tool timeout from the old 30s (which killed long @@ -17782,7 +17407,6 @@ fn tool_timeout_defaults_to_generous_budget_and_survives_spawn() { assert_eq!(parent.child_runtime().tool_timeout, expected); assert_eq!(parent.background_runtime().tool_timeout, expected); } - #[test] fn child_runtime_preserves_step_api_timeout() { // Real sub-agents spawn through `child_runtime()` / `background_runtime()`; @@ -17803,7 +17427,6 @@ fn child_runtime_preserves_step_api_timeout() { "background_runtime (detached) must also preserve the parent's timeout" ); } - #[test] fn subagent_completion_payload_carries_existing_sentinel_format() { // The payload format is the same one already documented in @@ -17839,7 +17462,6 @@ fn subagent_completion_payload_carries_existing_sentinel_format() { "sentinel should not duplicate the human summary line" ); } - /// #2683 — Verify the model-facing tool catalog only advertises canonical /// subagent tools and never exposes legacy superseded names. #[test] @@ -17871,7 +17493,6 @@ fn model_catalog_only_advertises_canonical_subagent_tools() { } // ── #3018: provider-aware auto routing and model validation ───────────────── - #[tokio::test] async fn faster_route_on_provider_without_known_sibling_stays_on_parent_model() { // AC: Ollama must never build a request with a DeepSeek id; even when the @@ -17897,7 +17518,6 @@ async fn faster_route_on_provider_without_known_sibling_stays_on_parent_model() ); } } - #[test] fn faster_route_uses_known_deepseek_and_glm_family_siblings() { let mut deepseek = stub_runtime(); @@ -17937,7 +17557,6 @@ fn faster_route_uses_known_deepseek_and_glm_family_siblings() { assert_eq!(route.model, "z-ai/glm-5-turbo"); assert_ne!(route.model, "z-ai/glm-5.1"); } - #[test] fn inherit_route_remaps_stale_deepseek_model_for_sakana_provider() { let mut runtime = stub_runtime_for_provider("sakana"); @@ -17960,7 +17579,6 @@ fn inherit_route_remaps_stale_deepseek_model_for_sakana_provider() { "Sakana inherit must not keep DeepSeek ids: {validated}" ); } - #[test] fn faster_route_remaps_stale_deepseek_model_for_sakana_provider() { let mut runtime = stub_runtime_for_provider("sakana"); @@ -17977,7 +17595,6 @@ fn faster_route_remaps_stale_deepseek_model_for_sakana_provider() { .expect("faster should remap to operator route"); assert_eq!(validated, crate::config::DEFAULT_SAKANA_MODEL); } - #[test] fn fixed_route_rejects_deepseek_model_for_sakana_provider() { let runtime = stub_runtime_for_provider("sakana"); @@ -17992,7 +17609,6 @@ fn fixed_route_rejects_deepseek_model_for_sakana_provider() { "error should name the model: {err}" ); } - #[test] fn normalize_requested_subagent_model_rejects_cross_namespace_for_sakana() { let err = normalize_requested_subagent_model( @@ -18006,7 +17622,6 @@ fn normalize_requested_subagent_model_rejects_cross_namespace_for_sakana() { "error should name the model: {err}" ); } - #[test] fn gpt55_faster_route_stays_on_gpt55_with_low_reasoning() { // AC: a faster/explore child of a GPT-5.5 (OpenAI Codex) parent must stay @@ -18050,7 +17665,6 @@ fn gpt55_faster_route_stays_on_gpt55_with_low_reasoning() { assert_eq!(route.reasoning_effort.as_deref(), Some("low")); assert_ne!(route.reasoning_effort.as_deref(), Some("off")); } - #[test] fn role_model_validation_accepts_provider_native_ids() { // AC: [subagents] worker_model = "kimi-k2.5" on Moonshot must not fail @@ -18064,7 +17678,6 @@ fn role_model_validation_accepts_provider_native_ids() { .expect("provider-native id is accepted"); assert_eq!(model.as_deref(), Some("kimi-k2.5")); } - #[test] fn consultant_reads_released_advisory_role_model_override_keys() { for legacy_key in ["oracle", "consultant"] { @@ -18078,7 +17691,6 @@ fn consultant_reads_released_advisory_role_model_override_keys() { assert_eq!(model.as_deref(), Some("deepseek-v4-flash"), "{legacy_key}"); } } - #[test] fn canonical_consultant_model_override_precedes_compatibility_keys() { let mut runtime = stub_runtime(); @@ -18096,7 +17708,6 @@ fn canonical_consultant_model_override_precedes_compatibility_keys() { .expect("canonical consultant override should resolve"); assert_eq!(model.as_deref(), Some("deepseek-v4-pro")); } - #[test] fn raw_advisory_role_prefers_canonical_consultant_model_override() { for alias in ["oracle", "consultant"] { @@ -18114,7 +17725,6 @@ fn raw_advisory_role_prefers_canonical_consultant_model_override() { assert_eq!(model.as_deref(), Some("deepseek-v4-pro"), "alias={alias}"); } } - #[test] fn role_model_validation_stays_strict_on_official_deepseek() { let mut runtime = stub_runtime(); @@ -18131,7 +17741,6 @@ fn role_model_validation_stays_strict_on_official_deepseek() { "lists accepted ids from model_completion_names_for_provider: {msg}" ); } - #[test] fn operator_model_for_subagent_enumerates_from_catalog_facade() { // #4116: the operator-route fallback must source its model from the @@ -18176,7 +17785,6 @@ fn operator_model_for_subagent_enumerates_from_catalog_facade() { ); } } - #[test] fn normalize_requested_subagent_model_is_provider_aware() { assert_eq!( @@ -18209,18 +17817,15 @@ fn normalize_requested_subagent_model_is_provider_aware() { } // ── #3030: step-counter formatting ────────────────────────────────────────── - #[test] fn format_step_counter_hides_unbounded_sentinel() { assert_eq!(format_step_counter(16, 0), "step 16"); } - #[test] fn format_step_counter_keeps_concrete_budgets() { assert_eq!(format_step_counter(3, 25), "step 3/25"); assert_eq!(format_step_counter(0, 1), "step 0/1"); } - #[test] fn child_step_override_wins_and_clamps_to_hard_ceiling() { assert_eq!(resolve_max_steps(FleetRole::Scout, None, None), 0); @@ -18240,7 +17845,6 @@ fn child_step_override_wins_and_clamps_to_hard_ceiling() { MAX_SUBAGENT_STEPS ); } - #[test] fn child_wall_timeout_reason_is_typed_and_actionable() { let reason = child_wall_time_exhausted_reason(Duration::from_millis(1)); @@ -18252,7 +17856,6 @@ fn child_wall_timeout_reason_is_typed_and_actionable() { } // ── #3095: sub-agent launch gate ───────────────────────────────────────────── - #[test] fn launch_gate_defaults_to_launch_concurrency_capped_by_max_agents() { let tmp = tempdir().expect("tempdir"); @@ -18273,7 +17876,6 @@ fn launch_gate_defaults_to_launch_concurrency_capped_by_max_agents() { "clamps down to max_agents" ); } - #[tokio::test] async fn launch_gate_queues_extra_direct_children() { use tokio_util::sync::CancellationToken; @@ -18421,7 +18023,6 @@ async fn launch_gate_queues_extra_direct_children() { "queued child must start only after queuing, then complete: {messages:?}" ); } - #[tokio::test] async fn queued_turn_owned_child_parks_without_a_false_start_transition() { use tokio_util::sync::CancellationToken; @@ -18574,7 +18175,6 @@ async fn queued_turn_owned_child_parks_without_a_false_start_transition() { drop(manager); drop(held_launch_permit); } - #[tokio::test] async fn launch_gate_wait_counts_against_child_wall_timeout() { use tokio_util::sync::CancellationToken; @@ -18700,7 +18300,6 @@ async fn launch_gate_wait_counts_against_child_wall_timeout() { drop(manager); drop(held_launch_permit); } - /// Stub chat server that always replies with a final assistant text whose /// `usage` reports the given token counts. Returns the client plus a call /// counter so tests can assert how many model turns ran before a budget cap @@ -18761,7 +18360,6 @@ async fn token_heavy_chat_client( let client = DeepSeekClient::new(&config).expect("fake chat client"); (client, calls) } - /// First response carries partial text plus a tool call and the requested /// incomplete/output-limit reason. A second request, when the runtime is /// allowed to retry, completes normally. @@ -18837,7 +18435,6 @@ async fn incomplete_then_complete_chat_client( let client = DeepSeekClient::new(&config).expect("fake incomplete-response client"); (client, calls) } - async fn run_incomplete_response_worker( workspace: &Path, stop_reason: &str, @@ -18921,7 +18518,6 @@ async fn run_incomplete_response_worker( }; (result, calls, mailbox_messages, total_tokens) } - fn assert_partial_tool_was_not_executed(messages: &[MailboxMessage]) { assert!( !messages @@ -18930,7 +18526,6 @@ fn assert_partial_tool_was_not_executed(messages: &[MailboxMessage]) { "an incomplete response must never dispatch its partial tool call: {messages:?}" ); } - #[tokio::test] async fn output_limit_on_last_step_preserves_partial_text_and_exact_cause() { let tmp = tempdir().expect("tempdir"); @@ -18965,7 +18560,6 @@ async fn output_limit_on_last_step_preserves_partial_text_and_exact_cause() { "the partial assistant text must remain in the transcript checkpoint" ); } - #[tokio::test] async fn output_limit_cause_wins_over_generic_token_budget() { let tmp = tempdir().expect("tempdir"); @@ -18989,7 +18583,6 @@ async fn output_limit_cause_wins_over_generic_token_budget() { assert_eq!(total_tokens, Some(15), "usage must still be recorded"); assert_partial_tool_was_not_executed(&mailbox); } - #[tokio::test] async fn non_output_incomplete_cause_wins_over_generic_token_budget() { let tmp = tempdir().expect("tempdir"); @@ -19010,7 +18603,6 @@ async fn non_output_incomplete_cause_wins_over_generic_token_budget() { assert_eq!(total_tokens, Some(15), "usage must still be recorded"); assert_partial_tool_was_not_executed(&mailbox); } - #[tokio::test] async fn output_limit_never_retries_or_executes_partial_tools() { let tmp = tempdir().expect("tempdir"); @@ -19030,7 +18622,6 @@ async fn output_limit_never_retries_or_executes_partial_tools() { assert_eq!(total_tokens, Some(15)); assert_partial_tool_was_not_executed(&mailbox); } - /// Shared scaffolding for the per-worker token-budget runtime tests: spins up /// a general worker against `token_heavy_chat_client` with the given cap and /// returns the manager, agent id, call counter, and spawned task handle. @@ -19098,7 +18689,6 @@ async fn spawn_budget_capped_worker( let task_handle = tokio::spawn(run_subagent_task(task)); (manager, agent_id, calls, task_handle) } - #[tokio::test] async fn worker_stops_with_typed_wall_time_reason() { let tmp = tempdir().expect("tempdir"); @@ -19124,7 +18714,6 @@ async fn worker_stops_with_typed_wall_time_reason() { other => panic!("expected typed wall-time failure, got {other:?}"), } } - #[tokio::test] async fn worker_stops_when_per_worker_token_budget_exceeded() { let tmp = tempdir().expect("tempdir"); @@ -19155,7 +18744,6 @@ async fn worker_stops_when_per_worker_token_budget_exceeded() { result.status ); } - #[tokio::test] async fn worker_without_per_worker_token_budget_runs_to_completion() { let tmp = tempdir().expect("tempdir"); @@ -19181,7 +18769,6 @@ async fn worker_without_per_worker_token_budget_runs_to_completion() { result.status ); } - #[tokio::test] async fn per_worker_token_budget_does_not_double_count_scope_accounting() { let tmp = tempdir().expect("tempdir"); @@ -19219,7 +18806,6 @@ async fn per_worker_token_budget_does_not_double_count_scope_accounting() { worker_record.usage ); } - /// Variant of [`spawn_budget_capped_worker`] that attaches the worker to a /// shared workflow budget scope before its first model turn (no per-worker /// cap), returning the manager, agent id, call counter, and task handle. @@ -19285,7 +18871,6 @@ async fn spawn_scope_budgeted_worker( let task_handle = tokio::spawn(run_subagent_task(task)); (calls, task_handle) } - #[tokio::test] async fn shared_scope_budget_stops_admitted_children_mid_run() { // A workflow run's token_budget must be a collective ceiling for the @@ -19371,7 +18956,6 @@ async fn shared_scope_budget_stops_admitted_children_mid_run() { "collective spend is accounted once per child" ); } - /// Clears the process-wide rate-limit window on drop so a panicking test /// body cannot leak a live pause into concurrently running tests. struct ClearRateLimitOnDrop; @@ -19381,7 +18965,6 @@ impl Drop for ClearRateLimitOnDrop { crate::retry_status::clear_rate_limit(); } } - #[tokio::test] #[allow(clippy::await_holding_lock)] async fn worker_is_not_stranded_by_transient_global_rate_limit_window() { @@ -19422,7 +19005,6 @@ async fn worker_is_not_stranded_by_transient_global_rate_limit_window() { result.status ); } - /// #4217: terminal worker records must age out of the persisted ledger so /// long-lived sessions do not rewrite multi-MB `subagents.v1.json` forever. #[test] @@ -19504,7 +19086,6 @@ fn cleanup_evicts_stale_terminal_worker_records_and_keeps_live_ones() { assert!(reloaded.get_worker_record("agent_recent_done").is_some()); assert!(reloaded.get_worker_record("agent_still_running").is_some()); } - #[test] fn cleanup_removes_complete_transcript_after_worker_retention_expires() { let tmp = tempdir().expect("tempdir"); @@ -19533,7 +19114,6 @@ fn cleanup_removes_complete_transcript_after_worker_retention_expires() { "artifact must share the terminal worker retention lifecycle" ); } - #[test] fn cleanup_due_gates_write_locked_cleanup_to_a_bounded_cadence() { // #3803: a fresh manager is always due (never cleaned); right after a @@ -19560,7 +19140,6 @@ fn cleanup_due_gates_write_locked_cleanup_to_a_bounded_cadence() { } // ── #3882: bounded sub-agent output under Fleet fanout ───────────────────── - /// Serialize-and-restore guard for the shared spillover test root, mirroring /// the pattern in `tools::truncate::tests`. fn with_spillover_root(root: &std::path::Path, f: F) { @@ -19583,7 +19162,6 @@ fn with_spillover_root(root: &std::path::Path, f: F) { let _restore = Restore(prior, prior_artifacts); f(); } - #[test] fn bounded_tail_messages_keeps_recent_within_budget_and_counts_omitted() { let messages: Vec = (0..10) @@ -19607,7 +19185,6 @@ fn bounded_tail_messages_keeps_recent_within_budget_and_counts_omitted() { "kept tail exceeds budget by more than one message: {total}" ); } - #[test] fn bounded_tail_messages_always_keeps_the_final_message() { let messages = vec![ @@ -19625,7 +19202,6 @@ fn bounded_tail_messages_always_keeps_the_final_message() { assert_eq!(omitted, 1); assert!(message_text(&kept[0]).starts_with('b')); } - #[tokio::test] async fn complete_transcript_artifact_survives_resident_handle_compaction() { let tmp = tempdir().expect("tempdir"); @@ -19697,7 +19273,6 @@ async fn complete_transcript_artifact_survives_resident_handle_compaction() { ); } } - #[test] fn malformed_transcript_artifact_fails_closed_instead_of_showing_partial_chat() { let tmp = tempdir().expect("tempdir"); @@ -19719,7 +19294,6 @@ fn malformed_transcript_artifact_fails_closed_instead_of_showing_partial_chat() .expect_err("a malformed stream must not masquerade as a complete chat"); assert!(error.to_string().contains("line"), "{error:#}"); } - #[cfg(unix)] #[test] fn transcript_artifact_reader_rejects_symlink_replacement() { @@ -19742,7 +19316,6 @@ fn transcript_artifact_reader_rejects_symlink_replacement() { .expect_err("transcript reader must reject symlink replacement"); assert!(error.to_string().contains("must not traverse symlinks")); } - #[test] fn checkpoints_are_byte_bounded_under_fanout_scale_output() { // Simulates the #3882 report shape: a worker whose tool results are @@ -19773,7 +19346,6 @@ fn checkpoints_are_byte_bounded_under_fanout_scale_output() { serialized.len() ); } - #[test] fn checkpoint_without_omitted_field_still_deserializes() { // Records persisted before v0.8.67 carry no omitted_messages key. @@ -19791,7 +19363,6 @@ fn checkpoint_without_omitted_field_still_deserializes() { serde_json::from_str(legacy).expect("legacy checkpoint should load"); assert_eq!(checkpoint.omitted_messages, 0); } - #[test] fn forkguard_subagent_classic_hint_names_the_actual_storage_call() { use crate::tools::large_output_router::WorkshopConfig; @@ -19958,7 +19529,6 @@ fn subagent_tool_results_spill_to_disk_and_stay_bounded_inline() { ); }); } - #[test] fn fanout_of_workers_with_huge_outputs_keeps_resident_state_bounded() { // Acceptance shape for #3882: multiple workers, each emitting multi-MB @@ -20007,7 +19577,6 @@ fn fanout_of_workers_with_huge_outputs_keeps_resident_state_bounded() { ); }); } - #[test] fn write_json_atomic_survives_concurrent_writers() { use std::sync::Arc; @@ -20048,7 +19617,6 @@ fn write_json_atomic_survives_concurrent_writers() { .collect(); assert!(leftover.is_empty(), "temp files leaked: {leftover:?}"); } - #[test] fn coordination_process_lock_rejects_second_process() { const ROLE_ENV: &str = "CODEWHALE_TEST_COORDINATION_LOCK_ROLE"; @@ -20141,7 +19709,6 @@ fn coordination_process_lock_rejects_second_process() { } // === agent(action="wait") + peek throttling (#4097) === - fn insert_running_agent(inner: &mut SubAgentManager, name: &str) -> String { let current_boot = inner.session_boot_id().to_string(); let (input_tx, _input_rx) = mpsc::unbounded_channel(); @@ -20165,7 +19732,6 @@ fn insert_running_agent(inner: &mut SubAgentManager, name: &str) -> String { inner.agents.insert(agent_id.clone(), agent); agent_id } - #[tokio::test] async fn agent_wait_returns_immediately_with_no_children() { let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 1))); @@ -20183,7 +19749,6 @@ async fn agent_wait_returns_immediately_with_no_children() { .is_empty() ); } - #[tokio::test] async fn agent_wait_wakes_when_child_settles() { let mut inner = SubAgentManager::new(PathBuf::from("."), 1); @@ -20221,7 +19786,6 @@ async fn agent_wait_wakes_when_child_settles() { assert_eq!(settled[0]["status"], json!("completed")); assert_eq!(payload["timed_out"], json!(false)); } - #[tokio::test] async fn agent_wait_times_out_and_reports_running_child() { let mut inner = SubAgentManager::new(PathBuf::from("."), 1); @@ -20247,7 +19811,6 @@ async fn agent_wait_times_out_and_reports_running_child() { .is_empty() ); } - #[tokio::test] async fn agent_wait_rejects_unknown_agent_ref() { let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 1))); @@ -20261,7 +19824,6 @@ async fn agent_wait_rejects_unknown_agent_ref() { .expect_err("unknown agent ref must fail fast instead of blocking"); assert!(matches!(err, ToolError::InvalidInput { .. })); } - #[tokio::test] async fn agent_peek_unchanged_within_window_returns_compact_nudge() { let mut inner = SubAgentManager::new(PathBuf::from("."), 1); @@ -20296,7 +19858,6 @@ async fn agent_peek_unchanged_within_window_returns_compact_nudge() { "nudge should point at agent(action=wait)" ); } - #[test] fn agent_action_parses_wait_aliases() { for alias in ["wait", "join", "await", "block"] { @@ -20321,7 +19882,6 @@ fn agent_action_parses_wait_aliases() { // Deny always wins over allow. Wildcards (`prefix*`) and case-insensitive // matching mirror the session-side `command_denies_tool()`. // =========================================================================== - /// Build a stub runtime with the parent's `disallowed_tools` set on the /// `WorkerRuntimeProfile`. The registry reads deny lists from the profile at /// construction, and `child_runtime()` clones the profile so the list @@ -20331,7 +19891,6 @@ fn stub_runtime_with_disallowed(disallowed: Vec) -> SubAgentRuntime { rt.worker_profile.denied_tools = disallowed; rt } - /// Build a `SubAgentToolRegistry` wired with `disallowed_tools`. Passes the /// runtime through `SubAgentToolRegistry::new()` so the constructor picks up /// `worker_profile.denied_tools`. `allowed_tools` is forwarded directly. @@ -20347,7 +19906,6 @@ fn new_registry_with_disallowed( Arc::new(Mutex::new(PlanState::default())), ) } - #[test] fn test_disallowed_tools_inheritance_denies_tool() { let tmp = tempdir().expect("tempdir"); @@ -20387,7 +19945,6 @@ fn test_disallowed_tools_inheritance_denies_tool() { "the lowercase edit primitive survives: only write_file was denied" ); } - #[test] fn test_disallowed_tools_deny_wins_over_allow() { let tmp = tempdir().expect("tempdir"); @@ -20420,7 +19977,6 @@ fn test_disallowed_tools_deny_wins_over_allow() { ); assert!(!names.contains("bash"), "denied shell alias removes bash"); } - #[test] fn test_disallowed_tools_wildcard_matching() { let tmp = tempdir().expect("tempdir"); @@ -20441,7 +19997,6 @@ fn test_disallowed_tools_wildcard_matching() { "non-MCP tools are unaffected by mcp_* deny" ); } - #[test] fn test_disallowed_tools_case_insensitive_match() { let tmp = tempdir().expect("tempdir"); @@ -20462,7 +20017,6 @@ fn test_disallowed_tools_case_insensitive_match() { "unrelated tool unaffected" ); } - #[test] fn test_disallowed_tools_specific_server_wildcard() { let tmp = tempdir().expect("tempdir"); @@ -20479,7 +20033,6 @@ fn test_disallowed_tools_specific_server_wildcard() { "different server prefix is not denied" ); } - #[test] fn test_disallowed_tools_tools_for_model_excludes_denied() { let tmp = tempdir().expect("tempdir"); @@ -20510,7 +20063,6 @@ fn test_disallowed_tools_tools_for_model_excludes_denied() { "the lowercase edit primitive survives: only write_file was denied" ); } - #[tokio::test] async fn test_disallowed_tools_execute_rejects_denied_tool() { let tmp = tempdir().expect("tempdir"); @@ -20534,7 +20086,6 @@ async fn test_disallowed_tools_execute_rejects_denied_tool() { } // === deny-list propagation through runtime cloning === - #[test] fn test_disallowed_tools_propagates_through_child_runtime() { let runtime = stub_runtime_with_disallowed(vec!["exec_shell".to_string()]); @@ -20545,7 +20096,6 @@ fn test_disallowed_tools_propagates_through_child_runtime() { "child_runtime() must preserve parent's denied_tools" ); } - #[test] fn test_disallowed_tools_propagates_through_background_runtime() { let runtime = stub_runtime_with_disallowed(vec!["write_file".to_string()]); @@ -20556,7 +20106,6 @@ fn test_disallowed_tools_propagates_through_background_runtime() { "background_runtime() must preserve parent's denied_tools" ); } - #[test] fn test_disallowed_tools_across_two_generations() { let tmp = tempdir().expect("tempdir"); @@ -20584,7 +20133,6 @@ fn test_disallowed_tools_across_two_generations() { } // === spawn-path opt-out simulation === - #[test] fn test_disallowed_tools_opt_out_clears_inherited_denies() { // Simulate the spawn-path merge: parent runtime has denies, child sets @@ -20613,7 +20161,6 @@ fn test_disallowed_tools_opt_out_clears_inherited_denies() { ); assert!(registry.is_tool_allowed("read_file")); } - #[test] fn test_disallowed_tools_opt_out_keeps_explicit_caller_deny() { // Opt-out clears inherited denies, but explicit caller disallowed_tools @@ -20647,7 +20194,6 @@ fn test_disallowed_tools_opt_out_keeps_explicit_caller_deny() { } // === parse_spawn_request disallowed_tools === - #[test] fn test_parse_spawn_request_reads_disallowed_tools() { let input = json!({ @@ -20660,7 +20206,6 @@ fn test_parse_spawn_request_reads_disallowed_tools() { Some(vec!["exec_shell".to_string(), "write_file".to_string()]) ); } - #[test] fn test_parse_spawn_request_disallowed_tools_dedupes_and_trims() { let input = json!({ @@ -20674,7 +20219,6 @@ fn test_parse_spawn_request_disallowed_tools_dedupes_and_trims() { "blanks and duplicates are dropped" ); } - #[test] fn test_parse_spawn_request_disallowed_tools_defaults_to_none() { let input = json!({"prompt": "do something"}); @@ -20684,7 +20228,6 @@ fn test_parse_spawn_request_disallowed_tools_defaults_to_none() { "disallowed_tools should be None when not provided" ); } - #[test] fn test_parse_spawn_request_inherit_disallowed_tools_defaults_true() { let input = json!({"prompt": "do something"}); @@ -20694,7 +20237,6 @@ fn test_parse_spawn_request_inherit_disallowed_tools_defaults_true() { "inherit_disallowed_tools should default to true" ); } - #[test] fn test_parse_spawn_request_inherit_disallowed_tools_explicit_false() { let input = json!({ @@ -20707,7 +20249,6 @@ fn test_parse_spawn_request_inherit_disallowed_tools_explicit_false() { "inherit_disallowed_tools should parse an explicit false" ); } - /// #3874 acceptance: the no-progress heartbeat must not kill a worker whose /// only pending work is a tracked *running* background shell task. /// @@ -20826,7 +20367,6 @@ async fn tracked_running_background_shell_keeps_its_owner_off_the_heartbeat_reap .expect("live task handle") .abort(); } - /// #4810: a child publishes its *own* list, only when it actually changes. #[tokio::test] async fn child_work_state_publishes_only_real_changes_from_its_own_list() { @@ -20898,7 +20438,6 @@ async fn child_work_state_publishes_only_real_changes_from_its_own_list() { // These assert on the registry the child actually runs with, not on the // `ChildAuthority` value that produced it. A ceiling that is only a label on a // receipt is not a ceiling. - /// `tools = false` must leave the child with **zero** model tools. The empty /// allowlist is the mechanism; this is the proof it lands. #[test] @@ -20934,7 +20473,6 @@ fn an_exact_member_with_tools_false_gets_no_model_tools_at_all() { ); } } - /// `network_tool = false` removes every model-visible network, browser, and /// remote-MCP surface — even though `tools = true` — while keeping exactly the /// `Web` family's two read-only actions (`search`/`fetch`), and refusing a @@ -21066,7 +20604,6 @@ async fn an_exact_member_without_a_network_tool_really_loses_the_network_surface "the standalone browse tool stays denied by name" ); } - /// A Runtime scout under a network-denied parent gets exactly the bounded web /// surface — `Web` with /// `search`/`fetch` — while every reaching spelling (`web.run`, `fetch_url`, @@ -21181,7 +20718,6 @@ async fn a_read_only_inspection_member_gets_only_bounded_web_search() { "full member keeps the whole Web enum" ); } - /// The unified `rlm` tool routes to the same code as the legacy aliases through /// an `action` parameter. A deny list that stops at the alias names leaves /// `rlm{action:"open", url:...}` callable — the whole reach by another spelling. @@ -21238,7 +20774,6 @@ fn the_unified_rlm_action_cannot_bypass_a_denied_alias() { "the compatibility RLM family must stay hidden from new child turns" ); } - /// The generic layer: a network-denied child that reaches a remote address /// through *any* tool's URL-bearing field is refused at the execution seam, even /// when the tool itself is local and permitted. This is what catches the next @@ -21318,7 +20853,6 @@ fn a_network_denied_child_cannot_address_a_remote_location_through_any_tool() { ); } } - /// A read-only member keeps `Run.verifiers` so it can do its job, but the /// `commands` array spawns arbitrary programs — `bash -lc 'rm -rf src'` is the /// raw shell the deny list just removed, re-entered through the door left open @@ -21373,7 +20907,6 @@ fn a_read_only_member_cannot_smuggle_commands_through_the_verifier_surface() { ); } } - /// The shipped `verifier` role is `write = false, shell = "full"`, and running /// the suite is its documented job. A test *selection* must survive at the same /// guard that refuses a command line — otherwise the indirect-execution fix has @@ -21434,7 +20967,6 @@ fn a_shell_capable_read_only_member_keeps_test_selection_arguments() { ); } } - /// The parent posture wins. A saved `full` member inside a read-only, /// no-network session runs with the session's ceiling, in the real registry. #[tokio::test] @@ -21508,7 +21040,6 @@ async fn a_parent_read_only_session_narrows_a_full_exact_member_in_the_child_reg "the URL-input guard stays deny-closed inside a read-only session" ); } - /// The session ceiling is read off the live parent runtime, so a Fleet can /// never widen what the operator is currently allowed to do. #[test] @@ -21551,9 +21082,17 @@ fn the_session_ceiling_reflects_the_live_parent_posture() { // half-contract: visibility without dispatch means a model that guesses the // name still wins, and dispatch without visibility means the model is offered a // capability it will be refused for using. - /// The child registry a Runtime verifier under a full parent actually runs with. fn read_only_with_shell_registry() -> (tempfile::TempDir, SubAgentToolRegistry) { + read_only_with_shell_registry_auto_approving(false) +} + +/// The carve-out fixture with approvals auto-accepted, for tests that drive +/// the real spawn path (a write-capable `agent` start is otherwise held for +/// approval and never reaches `run_subagent`). +fn read_only_with_shell_registry_auto_approving( + auto_approve: bool, +) -> (tempfile::TempDir, SubAgentToolRegistry) { let tmp = tempdir().expect("tempdir"); let authority = crate::fleet::role::ChildAuthority::from_runtime_role( "verifier", @@ -21572,6 +21111,7 @@ fn read_only_with_shell_registry() -> (tempfile::TempDir, SubAgentToolRegistry) let mut runtime = stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options()); runtime.context = ToolContext::new(tmp.path().to_path_buf()); + runtime.context.auto_approve = auto_approve; runtime.allow_shell = true; // The posture reaches the child through the Runtime authority envelope. runtime.worker_profile = WorkerRuntimeProfile::for_role(FleetRole::Verifier); @@ -21586,7 +21126,6 @@ fn read_only_with_shell_registry() -> (tempfile::TempDir, SubAgentToolRegistry) ); (tmp, registry) } - /// Adversarial payloads — raw mutation, deletion, network reach, and scheduled /// execution — refused at the real dispatch guard. #[test] @@ -21618,7 +21157,6 @@ fn indirect_execution_payloads_are_refused_at_dispatch() { ); } } - /// The verifier's job, in the registry it actually runs with: a bounded test /// selection passes the real dispatch guard, and everything that could name a /// program or touch the workspace does not. @@ -21671,7 +21209,6 @@ fn a_verifier_runs_bounded_test_selections_and_nothing_else_at_dispatch() { ); } } - /// Catalog and dispatch must agree: the verification surface the guard admits /// is offered, and the legacy raw-shell spellings it refuses are absent (the /// verifier's shell surface is the bounded canonical `bash` tool pinned by @@ -21693,7 +21230,6 @@ fn the_verifier_catalog_offers_run_without_the_legacy_raw_shell_spellings() { ); } } - /// The refusal reaches the *real* `execute` path, not only the classifier: the /// envelope check sits behind the allowlist, posture, and approval gates, and a /// guard that is never reached is not a guard. @@ -21718,7 +21254,6 @@ async fn a_verifier_is_refused_arbitrary_execution_at_the_real_execute_boundary( ); } } - /// The same payloads must never be *offered*. A model that can see a tool will /// try it, and a refusal is a worse experience than an absent capability. #[test] @@ -21772,7 +21307,6 @@ fn indirect_execution_actions_are_pruned_from_the_model_catalog() { ); } } - /// The bounded positives. This is the half that makes the contract honest: the /// point of `shell = "full"` on a read-only member is that it can still run the /// checks, and durable-task bookkeeping is exactly what such a member is for. @@ -21815,7 +21349,6 @@ fn bounded_read_only_and_verification_paths_survive_the_ceiling() { "durable-task bookkeeping must stay visible; got {actions:?}" ); } - /// A write-capable Runtime builder is unaffected. The guard narrows authority; /// it is not a new global restriction. #[test] @@ -21855,7 +21388,6 @@ fn a_write_capable_member_keeps_every_execution_gate() { ); } } - /// The unified-family bypass, for the durable-work families this time: a deny /// list naming `task_gate_run` has to reach `tasks{action:"gate_run"}`, or the /// canonical name is simply a second spelling that nothing checks. @@ -21893,7 +21425,6 @@ fn the_durable_work_families_resolve_their_actions_through_the_policy_seam() { } // ── A child may never widen its parent's envelope ─────────────────────────── - /// `inherit_disallowed_tools: false` is an escape hatch for *preference*, not /// for a ceiling. A Fleet member clamped to `network_tool = false` that spawns a /// grandchild asking for a clean surface must not hand it the network back. @@ -21944,7 +21475,6 @@ fn posture_denials_survive_a_child_that_declines_to_inherit() { } // ── The launch authority must reach the runtime, or the spawn fails ───────── - /// The fingerprint is the value the spawn boundary checks. It has to change /// whenever anything the child is constructed from changes, or checking it /// proves nothing. @@ -21982,7 +21512,6 @@ fn the_authority_fingerprint_distinguishes_every_envelope_it_names() { ); assert_ne!(narrow.fingerprint(), fingerprint("builder")); } - /// The spawn boundary itself. A missing, unparseable, or mismatched fingerprint /// must refuse the launch — a Runtime envelope that does not reach the child /// is not enforced authority. @@ -22044,7 +21573,6 @@ fn the_spawn_boundary_fails_closed_on_a_missing_or_mismatched_authority() { ); } } - /// End to end for the value that used to be computed and never read: the /// authority a launch resolves is the one whose fingerprint rides the receipt, /// and that fingerprint is what the spawn boundary accepts. @@ -22080,7 +21608,6 @@ fn the_launched_authority_is_the_one_the_spawn_boundary_accepts() { "one member's receipt must not authorize another member's child" ); } - /// R6 injection-size regressions (finish-operator 2026-08-02): build — never /// send — the real assembled payloads and pin them to measured-current +10%. /// Growth past the ceiling must be a deliberate, reviewed act, not drift. @@ -22094,7 +21621,6 @@ const READ_ONLY_CHILD_ENVELOPE_BYTE_CEILING: usize = 89_000; /// prompt-only start, review/tasks/verify/web/search/github/goal/terminal/ /// git/ocr/extract truthful-limit disclosures); +10%. const PARENT_SURFACE_BYTE_CEILING: usize = 90_000; - #[tokio::test] async fn read_only_child_envelope_stays_within_measured_ceiling() { let tmp = tempdir().expect("tempdir"); @@ -22128,7 +21654,6 @@ async fn read_only_child_envelope_stays_within_measured_ceiling() { "read-only child envelope grew past its reviewed ceiling: {envelope_bytes}B > {READ_ONLY_CHILD_ENVELOPE_BYTE_CEILING}B. If deliberate, re-measure and raise the ceiling in the same commit." ); } - #[tokio::test] async fn parent_agent_surface_stays_within_measured_ceiling() { let tmp = tempdir().expect("tempdir"); @@ -22159,7 +21684,6 @@ async fn parent_agent_surface_stays_within_measured_ceiling() { "parent prompt+catalog surface grew past its reviewed ceiling: {surface_bytes}B > {PARENT_SURFACE_BYTE_CEILING}B. If deliberate, re-measure and raise the ceiling in the same commit." ); } - fn init_claim_repo(root: &Path) { let run = |args: &[&str]| { let output = Command::new("git") @@ -22189,7 +21713,6 @@ fn init_claim_repo(root: &Path) { .expect("git available"); assert!(output.status.success(), "git commit: {output:?}"); } - #[test] fn completed_claim_of_untouched_file_taints_verification() { // R7 (finish-operator 2026-08-02): the morning report caught a child @@ -22220,7 +21743,6 @@ fn completed_claim_of_untouched_file_taints_verification() { } // ─── resume_from continuation-chain tests (#425) ───────────────────────────── - #[test] fn parse_spawn_request_accepts_resume_from() { let input = json!({ @@ -22230,7 +21752,6 @@ fn parse_spawn_request_accepts_resume_from() { let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert_eq!(parsed.resume_from.as_deref(), Some("agent_abc123")); } - #[test] fn parse_spawn_request_accepts_resume_from_camel_case() { let input = json!({ @@ -22240,14 +21761,12 @@ fn parse_spawn_request_accepts_resume_from_camel_case() { let parsed = parse_spawn_request(&input).expect("camelCase resumeFrom should parse"); assert_eq!(parsed.resume_from.as_deref(), Some("my-session")); } - #[test] fn parse_spawn_request_resume_from_absent_is_none() { let input = json!({ "prompt": "fresh start" }); let parsed = parse_spawn_request(&input).expect("prompt-only request should parse"); assert!(parsed.resume_from.is_none()); } - #[test] fn parse_spawn_request_resume_from_empty_string_is_none() { let input = json!({ @@ -22258,7 +21777,6 @@ fn parse_spawn_request_resume_from_empty_string_is_none() { .expect("whitespace-only resume_from should be treated as absent"); assert!(parsed.resume_from.is_none()); } - /// Spawning with resume_from + fork_context=false is contradictory and must /// be rejected at the spawn seam (not at parse time, since that is too early /// to know whether fork_context was explicit). @@ -22277,7 +21795,6 @@ fn parse_spawn_request_resume_from_with_fork_context_false_is_parseable() { assert_eq!(parsed.resume_from.as_deref(), Some("agent_abc123")); assert_eq!(parsed.fork_context, Some(false)); } - #[test] fn resume_from_rejects_running_source() { let tmp = tempdir().expect("tempdir"); @@ -22291,7 +21808,6 @@ fn resume_from_rejects_running_source() { "error should mention running status: {err}" ); } - #[test] fn completed_claim_matching_workspace_state_stays_untainted() { let tmp = tempdir().expect("tempdir"); @@ -22349,7 +21865,6 @@ fn completed_claim_matching_workspace_state_stays_untainted() { record.verification.summary ); } - #[tokio::test] async fn spawn_receipt_compacts_and_verbose_restores_the_archive() { // Morning-report issue #4 (W6 rest): every spawn returned ~12KB because @@ -22431,7 +21946,6 @@ async fn spawn_receipt_compacts_and_verbose_restores_the_archive() { assert!(verbose.get("compact").is_none()); assert_eq!(verbose, full, "verbose: true must restore the old shape"); } - fn spawn_route_metadata(provider: &str, model: &str, source: &str) -> WorkflowTaskSpawnMetadata { let child_route = ChildRouteReceipt { requested_type: "explore".to_string(), @@ -22465,11 +21979,9 @@ fn spawn_route_metadata(provider: &str, model: &str, source: &str) -> WorkflowTa resume_from_agent_id: None, } } - fn spawn_child_route_projection(metadata: &WorkflowTaskSpawnMetadata) -> ChildRouteReceipt { metadata.child_route.clone() } - #[test] fn spawn_receipt_route_names_the_provider_and_model_the_child_actually_got() { // #5305: a Fleet profile can route a child onto a provider the parent @@ -22503,7 +22015,6 @@ fn spawn_receipt_route_names_the_provider_and_model_the_child_actually_got() { assert_eq!(route["model_id"], json!("deepseek-v4-pro")); assert_eq!(route["route_source"], json!("task.model")); } - #[tokio::test] async fn spawn_receipt_route_survives_compaction_for_a_type_only_spawn() { // A type-only spawn resolves no roster member: no profile, no pins. The @@ -22557,7 +22068,6 @@ async fn spawn_receipt_route_survives_compaction_for_a_type_only_spawn() { "a type-only spawn resolves no profile: {receipt}" ); } - #[tokio::test] async fn unscoped_status_compacts_running_children_and_keeps_terminal_full() { // Morning-report issue #4: one unscoped status poll returned 203KB @@ -22629,7 +22139,6 @@ async fn unscoped_status_compacts_running_children_and_keeps_terminal_full() { assert!(verbose_row.get("snapshot").is_some(), "{verbose_row}"); assert!(verbose_row.get("compact").is_none(), "{verbose_row}"); } - #[test] fn resume_from_rejects_missing_source() { let tmp = tempdir().expect("tempdir"); @@ -22642,7 +22151,6 @@ fn resume_from_rejects_missing_source() { "error should mention not found: {err}" ); } - #[test] fn resume_from_accepts_completed_source() { let tmp = tempdir().expect("tempdir"); @@ -22656,7 +22164,6 @@ fn resume_from_accepts_completed_source() { validate_resume_from_source(&manager, &completed_id, tmp.path()) .expect("completed agent must be accepted as a resume source"); } - #[test] fn resume_from_accepts_interrupted_source() { let tmp = tempdir().expect("tempdir"); @@ -22673,7 +22180,6 @@ fn resume_from_accepts_interrupted_source() { validate_resume_from_source(&manager, &interrupted_id, tmp.path()) .expect("interrupted agent must be accepted as a resume source"); } - #[test] fn resume_from_accepts_failed_source() { let tmp = tempdir().expect("tempdir"); @@ -22686,7 +22192,6 @@ fn resume_from_accepts_failed_source() { validate_resume_from_source(&manager, &failed_id, tmp.path()) .expect("failed agent may be used as a resume source"); } - #[test] fn resume_from_accepts_cancelled_source() { let tmp = tempdir().expect("tempdir"); @@ -22699,7 +22204,6 @@ fn resume_from_accepts_cancelled_source() { validate_resume_from_source(&manager, &cancelled_id, tmp.path()) .expect("cancelled agent may be used as a resume source"); } - #[test] fn resume_from_rejects_cross_workspace_source() { let tmp_parent = tempdir().expect("parent tempdir"); @@ -22719,7 +22223,6 @@ fn resume_from_rejects_cross_workspace_source() { "error should mention workspace mismatch: {err}" ); } - #[test] fn resume_from_loads_transcript_artifact_when_available() { let tmp = tempdir().expect("tempdir"); @@ -22745,7 +22248,6 @@ fn resume_from_loads_transcript_artifact_when_available() { "resume transcript reads must not fall back to the execution workspace" ); } - #[test] fn resume_from_falls_back_to_checkpoint_when_artifact_missing() { let tmp = tempdir().expect("tempdir"); @@ -22775,7 +22277,6 @@ fn resume_from_falls_back_to_checkpoint_when_artifact_missing() { assert_eq!(fallback_messages.len(), 2); assert_eq!(message_text(&fallback_messages[0]), "checkpoint task"); } - #[test] fn resume_from_session_name_resolves_to_agent_id() { let tmp = tempdir().expect("tempdir"); @@ -22794,7 +22295,6 @@ fn resume_from_session_name_resolves_to_agent_id() { validate_resume_from_source(&manager, "named-source", tmp.path()) .expect("resolution by session name must work for resume_from"); } - #[test] fn child_launch_manifest_carries_resume_from_agent_id() { let tmp = tempdir().expect("tempdir"); @@ -22814,7 +22314,6 @@ fn child_launch_manifest_carries_resume_from_agent_id() { } // ── validation helper exposed for the tests above ──────────────────────────── - /// Extracted validation logic mirroring what `spawn_subagent_from_input` does. /// Returns `Ok(agent_id)` when the source is acceptable, `Err(message)` when /// it must be rejected. @@ -22852,7 +22351,6 @@ fn validate_resume_from_source( Ok(source_id) } - /// Owner report 2026-08-04: a model/provider switch spawns the new engine's /// manager while the old engine still holds the workspace coordination flock /// in this same process. That losing acquisition must classify itself as a @@ -22886,7 +22384,6 @@ fn coordination_lock_loss_to_own_process_reads_as_handover_and_self_heals() { } // -- resume-from-checkpoint tests (checkpoint-based continuation) -- - #[tokio::test] async fn resume_from_checkpoint_spawns_seeded_agent_with_checkpoint_context() { let tmp = tempdir().unwrap(); @@ -22947,7 +22444,6 @@ async fn resume_from_checkpoint_spawns_seeded_agent_with_checkpoint_context() { let prior = guard.agents.get(&agent_id).expect("prior record"); assert!(matches!(prior.status, SubAgentStatus::Interrupted(_))); } - #[tokio::test] async fn resume_from_checkpoint_is_idempotent_across_repeated_followups() { let tmp = tempdir().unwrap(); @@ -22997,7 +22493,6 @@ async fn resume_from_checkpoint_is_idempotent_across_repeated_followups() { "a repeated followup must reach the resumed target" ); } - #[tokio::test] async fn resume_from_checkpoint_rejects_non_interrupted_agents() { let tmp = tempdir().unwrap(); @@ -23019,7 +22514,6 @@ async fn resume_from_checkpoint_rejects_non_interrupted_agents() { "{err}" ); } - #[tokio::test] async fn resume_from_checkpoint_rejects_missing_continuable_checkpoint() { let tmp = tempdir().unwrap(); @@ -23052,7 +22546,6 @@ async fn resume_from_checkpoint_rejects_missing_continuable_checkpoint() { "{err}" ); } - /// The resume lane must honor the same isolation rule as a fresh worktree /// spawn: a worktree child's boundary is the worktree alone, so a multi-root /// parent's attached roots must not re-widen the resumed child. Both @@ -23159,7 +22652,6 @@ async fn resume_of_isolated_worktree_child_keeps_the_worktree_as_its_only_root() root set — the parent's attached root must not stay writable" ); } - /// Isolation must not key on the write claim's existence: a claim can be /// released or never created for a worktree child. The worker record's /// launch manifest durably pins the worktree spawn, so the claim-less resume @@ -23260,7 +22752,6 @@ async fn claim_less_resume_of_a_recorded_worktree_child_stays_isolated() { "a claim-less worktree child must not inherit the caller's writable roots" ); } - /// The rebuild idiom shared by both worktree clear sites: after the roots set /// is cleared, the WorkspaceWrite face of the cloned policy re-derives from /// the child's `(workspace, workspace_roots)` pair — the worktree alone — and @@ -23309,7 +22800,6 @@ fn rederive_sandbox_policy_roots_confines_the_exec_lane_to_the_cleared_set() { assert_eq!(context.elevated_sandbox_policy, Some(policy)); } } - #[test] fn user_follow_up_to_running_child_counts_queued_until_the_loop_takes_it() { let tmp = tempdir().expect("tempdir"); @@ -23356,7 +22846,6 @@ fn user_follow_up_to_running_child_counts_queued_until_the_loop_takes_it() { "nothing queued once the loop took both" ); } - #[test] fn user_follow_up_to_cancelled_child_fails_closed_with_the_reason() { let tmp = tempdir().expect("tempdir"); @@ -23373,7 +22862,6 @@ fn user_follow_up_to_cancelled_child_fails_closed_with_the_reason() { assert!(err.to_string().contains("cancelled"), "{err}"); assert!(manager.queued_follow_up_counts().is_empty()); } - #[test] fn user_follow_up_to_completed_child_requires_a_runtime_to_resume() { let tmp = tempdir().expect("tempdir"); @@ -24127,7 +23615,6 @@ mod child_permission_gate { } // ── #5462: `agent` is the only model-facing sub-agent surface ──────────────── - /// The six narrow tools that used to sit beside `agent` in the model catalog. /// Kept as one list so a test cannot silently check five of them. const RETIRED_AGENTS_TOOLS: &[&str] = &[ @@ -24138,7 +23625,6 @@ const RETIRED_AGENTS_TOOLS: &[&str] = &[ "agents/coordinate", "agents/wait", ]; - fn subagent_registry_for_catalog(tmp: &std::path::Path) -> crate::tools::ToolRegistry { let runtime = stub_runtime(); let manager = runtime.manager.clone(); @@ -24146,7 +23632,6 @@ fn subagent_registry_for_catalog(tmp: &std::path::Path) -> crate::tools::ToolReg .with_subagent_tools(manager, runtime) .build(crate::tools::spec::ToolContext::new(tmp.to_path_buf())) } - /// The narrow tools must vanish from the advertised catalog while staying /// registered: a persisted transcript that replays `agents/followup` has to /// keep dispatching to the same implementation, exactly as `rlm` and @@ -24183,7 +23668,6 @@ fn retired_agents_tools_stay_registered_but_leave_the_model_catalog() { ); } } - /// Hiding a tool from the initial catalog is worthless if `tool_search` can /// hand it back. Both matching paths read the same catalog, so both are /// exercised — with queries chosen to hit the retired tools' own names and @@ -24236,7 +23720,6 @@ fn tool_search_cannot_return_a_retired_agents_tool() { } } } - /// A name the model can read is a name the model will try to call. The `agent` /// description and schema must not advertise a tool that is no longer in the /// catalog — the failure mode that motivated this change was the description @@ -24268,7 +23751,6 @@ fn the_agent_surface_never_names_a_retired_tool() { "the replacement action must be advertised" ); } - /// The claim case, end to end, through the `agent` surface. /// /// This is the test the audit demanded: the coordinate wire key is `roots` @@ -24370,7 +23852,6 @@ async fn agent_claim_expands_the_callers_write_scope() { .expect("the expanded scope admits the previously refused write"); assert!(tmp.path().join("docs/note.txt").exists()); } - /// `expand_write_claim` returns the unchanged claim with `Ok` when every list /// is empty, so an empty claim would read as "granted". Refuse it at the seam. #[test] @@ -24397,7 +23878,6 @@ fn agent_claim_refuses_a_scopeless_call() { "the coordinate wire key is `roots`: {translated}" ); } - /// Folding six tools into one multi-action tool must not hand a read-only role /// an authority its catalog previously withheld. `agents/coordinate` was kept /// off an inspection role's surface by the execution envelope; `claim` has to diff --git a/crates/tui/src/worker_profile.rs b/crates/tui/src/worker_profile.rs index 9a3b8ab203..0e322ef10e 100644 --- a/crates/tui/src/worker_profile.rs +++ b/crates/tui/src/worker_profile.rs @@ -205,10 +205,11 @@ impl WorkerRuntimeProfile { // the network or from shell for no role reason cannot do its job. let (permissions, shell) = match role { // Read-only investigators: no workspace writes, but network reach - // and the bounded verification surface so a scout/reviewer lane - // can run git/gh/web inspection. Raw shell stays denied by the - // registry clamp (read-only classifier), so this widens capability - // without widening mutation authority. + // so a scout/reviewer lane can run git/gh/web inspection. Raw + // shell stays denied by the registry clamp (read-only + // classifier), so this widens capability without widening + // mutation authority; the bounded verification surface is the + // Verifier's own arm below, not a scout/reviewer tool. FleetRole::Scout | FleetRole::Reviewer => { (PermissionSet::read_only_with_network(), ShellPolicy::Full) } diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 49d0963e3d..96b7ca006a 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -100,7 +100,7 @@ segment must individually be admitted (e.g. `wc -c f; wc -l f`, `cd sub && wc -c notes.md`, `cat x | od -c`) — and a workspace-confined `cd ` in any sequence position its connector always runs (joined by `;`, or first — a `cd` after `&&`/`||` may never execute, so it refuses). Read-only is transitive through any delegation chain: -the clamp (`ChildAuthority::clamp` in `fleet/exact.rs`) intersects every +the clamp (`ChildAuthority::clamp` in `fleet/role.rs`) intersects every field with the narrower side, the deny-list union means a descendant can never drop an ancestor's restriction, and `inherit_disallowed_tools: false` cannot drop a posture denial (`is_posture_denial`). This is pinned by diff --git a/docs/zh_hans/FLEET.md b/docs/zh_hans/FLEET.md index 0ae5afa5e6..32b10f3214 100644 --- a/docs/zh_hans/FLEET.md +++ b/docs/zh_hans/FLEET.md @@ -121,7 +121,7 @@ call_reasrning = "lrw" 在运行时,它只能为已经冻结的 worker route 选择 reasoning 级别。它不能改变 worker、provider、model、role、tools 或 permissions。Router 调用本身被限制在 `off` 或 `low`;更贵的值会被拒绝。手动选择的 worker reasoning 级别不会产生 Router 调用。Route 与 reasoning 回执会指名 worker model,并在使用 Router 时给出 Router 的确切 provider/model,让操作者看到哪个 model 干了哪份活。如果相同的裸 Router 或 Fleet 名字在两个根里都存在,请用 `workspace/` 或 `codewhale_home/` 限定它,而不是依赖遮蔽(shadowing)。 -每个成员的 `permissions` 预设是一个**上限**,绝不是授权:它会与会话的实时姿态取交集,因此只读会话里的 `read_write` 成员以只读方式运行。这个交集成为子任务真实的工具包——`permissions = "none"` 让它一个工具都没有,而没有网络工具的成员会失去每一个可触达的网络表面——`web.run`、`fetch_url`、`web_search`、`github`、`mcp*` 系列以及可触达的 `rlm` 动作——而不是仅仅在调用时被拒绝。唯一刻意的例外是规范 `Web` 家族的 `search`/`fetch` 动作:它们是只读成员有权使用的只读 web 表面(与普通 scout 对等),因此网络被禁的成员恰好保留 `Web {search, fetch}`。它在事实上保持只读,而不仅仅是名义上:按 URL 寻址的 `fetch` 在派发时被拒绝,表面上没有任何其他东西被授予。不能写入的成员会失去可变的文件工具,并且——当它保留了 `shell = "full"` 以便运行检查时——也会失去原始 shell,只保留它为之而存在的有界验证工具(`run_tests`、`run_verifiers`;经 `agent` 工具嵌套的 verifier 额外持有分类器门控的只读取证探针通道,但 Fleet 成员信封把有界验证与 Bash 权限定义为互斥,因此不带该探针通道):任意 shell 命令和 `write_file` 一样确定无疑地改变工作区,所以保留它会令 `write = false` 不成立。那个验证表面只在它的*默认*形式下有界,因此无界参数会随 shell 一起消失:被拒绝写入的成员可以运行内置 gate,但不能用显式 `commands` 数组运行 `run_verifiers`,也不能用原始 `args` 字符串运行 `run_tests`——这两者都会启动操作者提供的程序,是换了名字的原始 shell。 +每个成员的 `permissions` 预设是一个**上限**,绝不是授权:它会与会话的实时姿态取交集,因此只读会话里的 `read_write` 成员以只读方式运行。这个交集成为子任务真实的工具包——`permissions = "none"` 让它一个工具都没有,而没有网络工具的成员会失去每一个可触达的网络表面——`web.run`、`fetch_url`、`web_search`、`github`、`mcp*` 系列以及可触达的 `rlm` 动作——而不是仅仅在调用时被拒绝。唯一刻意的例外是规范 `Web` 家族的 `search`/`fetch` 动作:它们是只读成员有权使用的只读 web 表面(与普通 scout 对等),因此网络被禁的成员恰好保留 `Web {search, fetch}`。它在事实上保持只读,而不仅仅是名义上:按 URL 寻址的 `fetch` 在派发时被拒绝,表面上没有任何其他东西被授予。不能写入的成员会失去可变的文件工具,并且——当它保留了 `shell = "full"` 以便运行检查时——也会失去原始 shell,只保留它为之而存在的有界验证工具(`run_tests`、`run_verifiers`;经 `agent` 工具嵌套的 verifier 在授予该通道的会话内委派上额外持有分类器门控的只读取证探针通道,但 Fleet 成员信封把有界验证与 Bash 权限定义为互斥,因此不带该探针通道):任意 shell 命令和 `write_file` 一样确定无疑地改变工作区,所以保留它会令 `write = false` 不成立。那个验证表面只在它的*默认*形式下有界,因此无界参数会随 shell 一起消失:被拒绝写入的成员可以运行内置 gate,但不能用显式 `commands` 数组运行 `run_verifiers`,也不能用原始 `args` 字符串运行 `run_tests`——这两者都会启动操作者提供的程序,是换了名字的原始 shell。 这些拒绝中有两项比工具名更窄,因为有两个工具触及了它们名字之外的地方。`rlm` 通过在进程*内部*、以它自己的名字调用 fetch 工具来加载 `url`,而 `rlm` 的 `eval` 动作针对活内核运行 Python——套接字和文件系统都触及。因此该家族**按动作**被拒绝,而不是整体拒绝: diff --git a/docs/zh_hans/SUBAGENTS.md b/docs/zh_hans/SUBAGENTS.md index 83b01da271..f4b4584e51 100644 --- a/docs/zh_hans/SUBAGENTS.md +++ b/docs/zh_hans/SUBAGENTS.md @@ -37,7 +37,7 @@ Fleet 角色是面向用户的委派工作词汇:父代理通过 `agent` 启 角色的默认值就是该角色*想要*的姿态,而父代理的有效姿态永远是天花板(子代理绝不会扩得比父代理更宽)。只读角色按意图扣留**工作区写入**;默认不拿走任何其他东西——每个角色都保留网络读取,`custom` 继承父代理的写入/网络/shell 姿态,并且只被它的显式工具列表或发起调用收窄。被聚焦的 worker 的头部会依据运行时自身的权限快照声明有效姿态(`scout · read-only · network · read-only shell`)。 -**委派移动的是工作,绝不是权限**(#5426 的遏制答案)。只读角色委派给可写角色(scout → builder)是*工作容量*受支持的逃生舱——子代理自带其模型、路由和步骤预算——但子代理的权限被钳制在委派父代理的实时姿态上,而不是操作者的姿态上:scout 的 builder 子代理以只读落地,raw shell 和可变工具被拒绝,规范的 `Bash` 对它也被拒绝(只有有界检查角色与 verifier 保留分类的只读 shell)。因此通过委派来获得 shell 在机制上是无用的——scout 自带的受限 shell(`git -C … log`、`find … | head`、`npm view …`,分类器门控)是只读父代理唯一的 shell 路径。该有界语法还允许把这些已准入的探针组合成复合序列——以 `;`、`&&`、`||` 或 `|` 连接的段,且每个段都必须单独准入(例如 `wc -c f; wc -l f`、`cd sub && wc -c notes.md`、`cat x | od -c`)——以及工作区受限的 `cd <单个相对路径>`(可单独使用,或出现在连接符必定执行它的序列位置——以 `;` 连接或位于序列首位;`&&`/`||` 之后的 `cd` 可能根本不会执行,因此会被拒绝)。只读通过任何委派链都是传递的:钳制(`fleet/exact.rs` 中的 `ChildAuthority::clamp`)把每个字段与更窄的一侧求交,拒绝列表的并集意味着后代永远无法去掉祖先的限制,而 `inherit_disallowed_tools: false` 无法去掉姿态拒绝(`is_posture_denial`)。这一点由 `crates/tui/src/fleet/exact.rs` 测试中的 `a_read_only_parents_delegation_never_widens_authority` 钉死。 +**委派移动的是工作,绝不是权限**(#5426 的遏制答案)。只读角色委派给可写角色(scout → builder)是*工作容量*受支持的逃生舱——子代理自带其模型、路由和步骤预算——但子代理的权限被钳制在委派父代理的实时姿态上,而不是操作者的姿态上:scout 的 builder 子代理以只读落地,raw shell 和可变工具被拒绝,规范的 `Bash` 对它也被拒绝(只有有界检查角色与 verifier 保留分类的只读 shell)。因此通过委派来获得 shell 在机制上是无用的——scout 自带的受限 shell(`git -C … log`、`find … | head`、`npm view …`,分类器门控)是只读父代理唯一的 shell 路径。该有界语法还允许把这些已准入的探针组合成复合序列——以 `;`、`&&`、`||` 或 `|` 连接的段,且每个段都必须单独准入(例如 `wc -c f; wc -l f`、`cd sub && wc -c notes.md`、`cat x | od -c`)——以及工作区受限的 `cd <单个相对路径>`(可单独使用,或出现在连接符必定执行它的序列位置——以 `;` 连接或位于序列首位;`&&`/`||` 之后的 `cd` 可能根本不会执行,因此会被拒绝)。只读通过任何委派链都是传递的:钳制(`fleet/role.rs` 中的 `ChildAuthority::clamp`)把每个字段与更窄的一侧求交,拒绝列表的并集意味着后代永远无法去掉祖先的限制,而 `inherit_disallowed_tools: false` 无法去掉姿态拒绝(`is_posture_denial`)。这一点由 `crates/tui/src/fleet/exact.rs` 测试中的 `a_read_only_parents_delegation_never_widens_authority` 钉死。 会话的**权限姿态**在每个子代理内部的应用方式与父代理回合完全一致:在 Auto-Review 下,同一个确定性底线和一次性模型守护者决定 worker 的被扣留调用(绝不是提示词;守护者不可用时拒绝,fail closed);在 Ask 下,角色无法委派的被扣留调用会作为审批提示在父代理的 UI 中弹出,worker 可见地等待(`waiting for user`),或者在无法提示的主机上带着原因被拒绝;Full Access 仍然在不可绕过的安全底线上 fail closed。每一次没有人被提示的决策都是该 worker 转录中的一行备注(聚焦时可见)和一条审计日志记录。参见 `docs/MODES.md`。 @@ -122,7 +122,7 @@ OUTPUT: VERDICT、EVIDENCE、GAPS、NEXT。 - **`planner`** —— 当父代理有目标但没有可执行的分解。planner 写工件(`todo_write` 条目、响应体里的策略),但不执行它们。 - **`reviewer`** —— 当已经有一个变更,父代理想要它被评分。reviewer 不打补丁——他们在发现里描述修复方案,这样如果判定是"修它",父代理可以派一个 builder。 - **`builder`** —— 当变更已经被明确指定、只需要落地。builder 保持严格的范围:最小改动,不做顺手重构,交回前跑一次快速验证。 -- **`verifier`** —— 当父代理需要测试套件或其他验证上的权威通过/失败结论。verifier 不修失败;他们记录失败的断言 + 栈,把修复候选放在 RISKS 下。verifier 姿态从不写入;其 shell 是有界内置验证面——对会话内子代理,只有当只读 shell 父代理钳制了其权限时才会被拒绝,被钳制的子代理保留探针通道、失去验证门禁——加上,在授予该通道的会话内委派上,与检查角色相同的分类器门控只读探针通道(`wc`、`ls`、`cat`、`grep` 管道)用于取证;未授予该通道的 envelope 会省略它,shell 尝试一律拒绝。Fleet 成员的 envelope 更严格:在那里有界验证与 Bash 权限互斥,因此 fleet verifier 只持有门禁、不带探针通道。写入上限是只读,无界 shell 形式被拒绝(#5186),自定义命令也被拒绝——请改用具备写能力的角色。 +- **`verifier`** —— 当父代理需要测试套件或其他验证上的权威通过/失败结论。verifier 不修失败;他们记录失败的断言 + 栈,把修复候选放在 RISKS 下。verifier 姿态从不写入;其 shell 是有界内置验证面——对会话内子代理,只有当只读 shell 父代理钳制了其权限时才会被拒绝,被钳制的子代理保留探针通道、失去验证门禁——加上,在授予该通道的会话内委派上,与检查角色相同的分类器门控只读探针通道(`wc`、`ls`、`cat`、`grep` 管道)用于取证;未授予该通道的信封会省略它,shell 尝试一律拒绝。Fleet 成员的信封更严格:在那里有界验证与 Bash 权限互斥,因此 fleet verifier 只持有门禁、不带探针通道。写入上限是只读,无界 shell 形式被拒绝(#5186),自定义命令也被拒绝——请改用具备写能力的角色。 - **`consultant`** —— 当操作者想在更便宜的执行继续之前得到一个高杠杆的第二意见。consultant 读足够的材料来支撑一条建议,但不能写,也不能运行 shell 命令。`oracle` 和 `advisor` 只在加载更老的请求或持久化记录时被接受;新的提示词、回执和 UI 使用 `consultant`。 - **`custom`** —— 只有当父代理需要显式约束工具集时。通过 legacy/internal 子代理记录上的 `allowed_tools` 字段传 allowlist;面向模型的 `agent` 工具刻意保持公共 schema 很小。 From 6e2ed34fb843b91afe2494bedbfa0f4366b3a592 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 5 Nov 2026 12:29:57 +0800 Subject: [PATCH 4/4] fix(fleet): round-3 review fixes - pin the fleet roster blurb, complete the guide rows, finish the blank-line strip Signed-off-by: asto18089 --- crates/tui/src/tools/subagent/tests.rs | 45 +++++++++++++++++++------- docs/GUIDE.md | 2 +- docs/zh_hans/GUIDE.md | 2 +- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 38d7d5220c..eee3ec97c5 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -8,7 +8,6 @@ use std::collections::HashSet; use std::process::Command; use std::sync::atomic::{AtomicUsize, Ordering}; use tempfile::{Builder as TempDirBuilder, tempdir}; - mod launch_receipt; mod operation_identity; fn built_in_whale_name_that_cannot_be_generated_for(agent_id: &str) -> &'static str { @@ -5477,7 +5476,6 @@ fn subagent_tool_schemas_advertise_real_type_and_role_vocabulary() { "worktree description should teach isolated parallel edits: {worktree}" ); } - // The wait surfaces block the turn, so the model-facing text must disclose // the bounded block (default 30s, max 120s) and the timed_out receipt shape; // an undisclosed finite block reads as a hang when nothing settles. @@ -5539,7 +5537,6 @@ fn wait_schema_text_discloses_timeout_bound_and_timed_out_receipt() { the timed_out receipt:\n{wait_description}" ); } - // The two wait faces advertise from two independent constant sets, so each // face's own pin can stay green while the surfaces drift numerically apart; // this cross-assertion closes that gap. @@ -7555,7 +7552,6 @@ fn synthetic_deferred_tool(name: &str, description_bytes: usize) -> Tool { cache_control: None, } } - // Pinvou fork: the toolbox size bounds are disabled (see the constant docs in // core/session.rs). Upstream tests that construct near-budget activations to // fire bounds eviction or oversize rejection are not carried here — those @@ -8097,11 +8093,43 @@ fn verifier_roster_description_states_the_bounded_contract() { description.contains("unless a read-only-shell parent clamped its shell authority"), "roster description must condition the gates on shell authority — a clamped child keeps probes and loses gates: {description}" ); - // The Agent-tool `type` description carries the same truth, briefly. + // The Agent-tool `type` description carries the same truth, briefly — + // including the two clauses the model needs before picking: the gates + // clamp under a read-only-shell parent, and custom commands are refused. assert!( SUBAGENT_TYPE_DESCRIPTION.contains("test: runs the built-in verification gates"), "type description must match the roster: {SUBAGENT_TYPE_DESCRIPTION}" ); + assert!( + SUBAGENT_TYPE_DESCRIPTION.contains("custom commands are refused"), + "type description must refuse custom commands: {SUBAGENT_TYPE_DESCRIPTION}" + ); + assert!( + SUBAGENT_TYPE_DESCRIPTION + .contains("a read-only-shell parent clamps the gates (probes remain)"), + "type description must condition the gates on the parent shell: {SUBAGENT_TYPE_DESCRIPTION}" + ); + // The fleet roster slot blurb is the third roster surface a model reads + // (built-in members feed `AgentProfile.description`); it must not + // advertise builds or a shell the fleet envelope never grants. + let fleet_blurb = FleetRoster::built_in_members() + .into_iter() + .find(|member| member.id == "verifier") + .unwrap_or_else(|| panic!("built-in roster must carry the verifier slot")) + .description + .unwrap_or_else(|| panic!("built-in verifier slot must carry a description")); + assert!( + fleet_blurb.contains("built-in verification gates"), + "fleet roster blurb must name the built-in gates: {fleet_blurb}" + ); + assert!( + fleet_blurb.contains("holds no shell"), + "fleet roster blurb must disclaim the shell the fleet envelope refuses: {fleet_blurb}" + ); + assert!( + !fleet_blurb.contains("builds and tests"), + "fleet roster blurb must not re-overclaim builds: {fleet_blurb}" + ); } /// Acceptance for the verifier lane, sibling of /// `read_only_inspection_roles_execute_pwd_and_absolute_git_log`: a live @@ -12940,7 +12968,6 @@ async fn prompt_only_general_cannot_mutate_under_parent_auto_approve() { assert!(!tmp.path().join("forbidden.txt").exists()); assert!(!tmp.path().join("shell.txt").exists()); } - const MCP_ACTION_TOOL: &str = "mcp_github_create_pull_request"; fn subagent_registry_with_mcp_action(auto_approve: bool) -> SubAgentToolRegistry { let mut runtime = stub_runtime(); @@ -18959,7 +18986,6 @@ async fn shared_scope_budget_stops_admitted_children_mid_run() { /// Clears the process-wide rate-limit window on drop so a panicking test /// body cannot leak a live pause into concurrently running tests. struct ClearRateLimitOnDrop; - impl Drop for ClearRateLimitOnDrop { fn drop(&mut self) { crate::retry_status::clear_rate_limit(); @@ -22883,7 +22909,6 @@ fn user_follow_up_to_completed_child_requires_a_runtime_to_resume() { } // === child permission gate: the session posture applied to a worker's calls === - mod child_permission_gate { use super::*; use crate::approval_log::{ApprovalOutcome, ApprovalReceipt, ApprovalReceiptStore}; @@ -23955,7 +23980,6 @@ async fn agent_claim_is_withheld_from_a_role_with_no_write_authority() { .to_string(); assert!(refusal.contains("no write authority to widen"), "{refusal}"); } - // Regression (stopship scout repair): the workflow's read-only scout // activates `grep_files` with one `tool_search` call before searching. That // two-step path only works while the scout surface keeps a first-turn-active @@ -24008,7 +24032,6 @@ async fn forkguard_scout_surface_keeps_tool_search_grep_files_activation_path() "the hidden File alias must stay out of scout catalogs" ); } - // Regression (stopship scout repair, Pinvou #490 phantom-tool class): fleet // workflow briefs are model-facing text too. The scout brief must name tools // the child catalog can actually see (`tool_search`, `grep_files`) and never @@ -24050,7 +24073,6 @@ fn forkguard_workflow_briefs_name_catalog_visible_tools() { "workflow briefs must not command calls a catalog can never return: {cited:?}" ); } - // Behavioral counterpart to the two composition guards above: on the scout's // live surface, one `tool_search` call must make the deferred `grep_files` // dispatchable through the same gate the child step loop uses, while the @@ -24179,7 +24201,6 @@ fn forkguard_subagent_skill_catalog_uses_tool_search_discovery() { ); assert!(!catalog.contains("try `load_skill` directly anyway")); } - // Worker records, takeover targets, and transcript artifact descriptions all // point the parent at `handle_read`, which is deferred on stock hosts; every // prose site that names it must carry the `tool_search` activation hint so diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 977a403970..cf8b470e6f 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -471,7 +471,7 @@ Useful roles include: | `plan` | Design and migration planning | | `review` | Bug-focused review of an existing change | | `implementer` | A tightly specified edit | -| `verifier` | Running checks and reporting pass/fail evidence | +| `verifier` | Running the built-in verification gates and reporting pass/fail evidence | Sub-agents are most useful when work can be separated cleanly. Do not use them for tiny edits, and do not ask multiple agents to write the same files at the diff --git a/docs/zh_hans/GUIDE.md b/docs/zh_hans/GUIDE.md index f6ad23ae18..0d2d9634f2 100644 --- a/docs/zh_hans/GUIDE.md +++ b/docs/zh_hans/GUIDE.md @@ -325,7 +325,7 @@ Codewhale 的工具是结构化操作。模型不只是产出文字,还能调 | `plan` | 设计与迁移规划 | | `review` | 对已有改动的 bug 聚焦审查 | | `implementer` | 规格明确的编辑 | -| `verifier` | 运行检查并报告通过/失败证据 | +| `verifier` | 运行内置验证门禁并报告通过/失败证据 | 子智能体在可以干净切分工作的时候最有用。不要为微小编辑使用它们,也不要让多个智能体同时写入相同文件。