feat(modes): retire Operate as a mode

Work and Plan are the two modes. Tab cycles between them, `/mode`, the mode
picker, the config editor and the default Hotbar offer two choices (the freed
Hotbar slot becomes the reasoning cycle), and `Alt+A`/`Alt+P` still land on
Work and Plan.

`operate`, `operation`, `ops` and `3` still parse, as Work, so an old setting,
script or client never errors; `AppMode::is_retired_operate_alias` is the one
predicate callers ask when they want to say so. A saved
`default_mode = "operate"` is normalized to `agent` at the settings edge, and
the TUI explains the change once per install (a persisted
`operate_deprecation_shown` flag and a startup toast, mirroring the YOLO
migration) instead of silently dropping the mode someone chose. The permission
receipt no longer reads "applies in Work and Operate".

Operate-only behavior goes with it: the launch-concurrency floor of 4, the
top-level child auto-accept posture, the mode's contract message and its
"leaving Operate" replay rule, and the engine/settings/ACP wire spellings.
Fan-out is untouched — sub-agents and workflows keep their 64/128 ceilings, and
`/fleet` is unchanged.

Stale expectations updated in the same slice, including two that were already
red on main before this work: the config-view permission choice lists now
include Accept Edits (`7e6000aa43a` added the posture but left those two
assertions behind).

Verification: `cargo check --workspace --all-targets` clean; nextest
codewhale-config + codewhale-tui + codewhale-runtime → 7465 passed, 2 failed
(both the /context fixtures, re-frozen in the previous commit and re-run
green: 87/87); cargo fmt clean; sync-changelog --check clean; locale parity,
product vocabulary, web locales, dead-code budget, contributor credit and
runtime-contract budget all pass. The retired-mode UI is unverified on a real
Windows or macOS device; hosted CI on the next candidate is the proof.
This commit is contained in:
CodeWhale Bot committed 2026-11-08 05:23:47 -08:00
1 parent c0c7998188
commit c50612d6ab
53 files changed
+315 -809

No files matched your search

+1
View File
@@ -247,6 +247,7 @@ until the matching tag, packages, checksums, and release assets exist.
- **Sub-agents and goals by default.** The constitution now tells the agent to start independent parts of a request as parallel sub-agents in one message, keep working while their results arrive on their own, and end the turn when only waiting remains; to give each child a complete prompt and its own files; to inspect a write-capable child's evidence, review the diff and run the checks itself; to have an independent reviewer try to refute a non-trivial change before calling it done; and to start a goal for any request, in any language, whose verifiable end state needs more than one turn. Sub-agents, workflows and goals within the current request are in scope; spending real money (purchases, paid providers, billing) still needs the user's express authorization. Cost alone is not a reason to stop; only limits the user or configuration sets apply. **This changes the session-pinned prompt and tool descriptions, so each session re-pins its prompt cache once after upgrade.**
- **Operate's contract is retired.** The Engine no longer appends the Operate contract to a session; its review, cost and evidence rules now live in the constitution for every session. Saved sessions that contain it still replay.
- **The Operate mode is gone; Work and Plan are the two modes.** Tab cycles between them, `/mode` and the config editor offer only those two, and a saved `default_mode = "operate"` loads as Work with a one-time notice that `/fleet` runs parallel agents. `operate`, `operation`, `ops` and `3` still parse, as Work, so an old setting or script never errors. Fan-out is unchanged — 64 live sub-agents per message, workflows at a 64 default with a 128 ceiling — while the Operate-only launch-concurrency floor and its top-level child auto-accept posture are gone; a child's edits follow the permission posture like every other action. "Plan stays Read Only; this applies in Work" now says exactly that.
- **Clearer tool text.** The `agent` description leads with when to delegate, gives one trigger per role and an example child brief, and drops Operate and legacy wording; the start receipt says results arrive automatically. `create_goal` and `update_goal` say when to start a goal and what complete, blocked and yield mean (blocked means the same obstacle persists; difficulty is not blocked). `tool_search` lists the deferred tool groups. The eager tool schemas shrink by 681 bytes.
- **Workflow fan-out default 16 → 64.** `[workflow] max_concurrent` now defaults to 64 live agents per run, matching sub-agents; the per-run ceiling rises from 16 to 128. No spend caps were added.
- **Bundled skill `goals` is now `personal-goals`.** It tracks personal resolutions in `goals.md` and no longer collides with goal mode; `goals` still resolves to it. An untouched installed `goals` copy becomes `personal-goals` on upgrade; an edited one stays as `goals`.
+26 -55
View File
@@ -8,22 +8,17 @@
pub enum AppMode {
Agent,
Plan,
Operate,
}
impl AppMode {
/// Productive keyboard cycle: Plan -> Work -> Operate -> Plan.
///
/// Operate joins the visible cycle as the always-on fleet operation:
/// a lead plans slices, then workers execute against an optional burn rate.
pub const CYCLE: [Self; 3] = [Self::Plan, Self::Agent, Self::Operate];
pub const CYCLE: [Self; 2] = [Self::Plan, Self::Agent];
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"agent" | "act" | "work" | "auto" | "1" => Some(Self::Agent),
"plan" | "2" => Some(Self::Plan),
"operate" | "operation" | "ops" | "3" => Some(Self::Operate),
other if Self::is_retired_operate_alias(other) => Some(Self::Agent),
// Invisible one-way permission shorthand only — never a visible
// mode. These spellings resolve to Work; the bypass posture they
// imply is carried by the permission surface (settings load,
@@ -46,13 +41,20 @@ impl AppMode {
)
}
/// Operate was retired as a mode. Its spellings still parse, as Work, so a
/// saved setting or an older client never errors; callers that want to say
/// so once ask this predicate.
#[must_use]
pub fn is_retired_operate_alias(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"operate" | "operation" | "ops" | "3"
)
}
#[must_use]
pub fn from_setting(value: &str) -> Self {
// Unreleased Multitask never shipped; normalize leftover settings to Operate.
match value.trim().to_ascii_lowercase().as_str() {
"multitask" | "multi" | "5" => Self::Operate,
other => Self::parse(other).unwrap_or(Self::Agent),
}
Self::parse(value).unwrap_or(Self::Agent)
}
#[must_use]
@@ -60,7 +62,6 @@ impl AppMode {
match self {
Self::Agent => "agent",
Self::Plan => "plan",
Self::Operate => "operate",
}
}
@@ -69,7 +70,6 @@ impl AppMode {
match self {
AppMode::Agent => "WORK",
AppMode::Plan => "PLAN",
AppMode::Operate => "OPERATE",
}
}
@@ -78,7 +78,6 @@ impl AppMode {
match self {
AppMode::Agent => "ACT",
AppMode::Plan => "PLAN",
AppMode::Operate => "OPERATE",
}
}
@@ -87,7 +86,6 @@ impl AppMode {
match self {
AppMode::Agent => "Work",
AppMode::Plan => "Plan",
AppMode::Operate => "Operate",
}
}
@@ -96,23 +94,12 @@ impl AppMode {
match self {
AppMode::Agent => '1',
AppMode::Plan => '2',
AppMode::Operate => '3',
}
}
#[must_use]
pub fn uses_agent_baseline(self) -> bool {
matches!(self, Self::Agent | Self::Operate)
}
/// Operate gets a higher parallel launch floor so background fan-out is
/// not throttled to a single slot when config is low.
#[must_use]
pub fn mode_delegation_launch_floor(self) -> usize {
match self {
Self::Operate => 4,
_ => 1,
}
matches!(self, Self::Agent)
}
/// Description shown in help or onboarding text.
@@ -120,9 +107,6 @@ impl AppMode {
match self {
AppMode::Agent => "Work mode - direct work in the current session with tools",
AppMode::Plan => "Plan mode - research and design before implementing",
AppMode::Operate => {
"Operate mode - always-on fleet operation: lead plans, optional $/time burn rate, workers follow the plan"
}
}
}
@@ -154,8 +138,9 @@ mod tests {
assert_eq!(AppMode::parse("work"), Some(AppMode::Agent));
assert_eq!(AppMode::parse("2"), Some(AppMode::Plan));
assert_eq!(AppMode::parse("auto"), Some(AppMode::Agent));
assert_eq!(AppMode::parse("3"), Some(AppMode::Operate));
assert_eq!(AppMode::parse("operate"), Some(AppMode::Operate));
assert_eq!(AppMode::parse("3"), Some(AppMode::Agent));
assert_eq!(AppMode::parse("operate"), Some(AppMode::Agent));
assert!(AppMode::is_retired_operate_alias(" Operate "));
// Legacy YOLO spellings resolve to Work; the bypass posture they imply
// travels on the permission surface, not on a mode.
assert_eq!(AppMode::parse("YOLO"), Some(AppMode::Agent));
@@ -178,24 +163,18 @@ mod tests {
assert_eq!(AppMode::parse("multitask"), None);
assert_eq!(AppMode::parse("5"), None);
assert_eq!(AppMode::parse("fast"), None);
assert_eq!(AppMode::from_setting("multitask"), AppMode::Operate);
assert_eq!(AppMode::from_setting("5"), AppMode::Operate);
assert_eq!(AppMode::from_setting("multitask"), AppMode::Agent);
assert_eq!(AppMode::from_setting("5"), AppMode::Agent);
assert_eq!(AppMode::Agent.as_setting(), "agent");
assert_eq!(AppMode::Plan.display_name(), "Plan");
assert_eq!(AppMode::Agent.number(), '1');
assert_eq!(AppMode::Operate.number(), '3');
assert_eq!(
AppMode::CYCLE,
[AppMode::Plan, AppMode::Agent, AppMode::Operate]
);
assert_eq!(AppMode::CYCLE, [AppMode::Plan, AppMode::Agent]);
assert_eq!(AppMode::Plan.next(), AppMode::Agent);
assert_eq!(AppMode::Agent.next(), AppMode::Operate);
assert_eq!(AppMode::Operate.next(), AppMode::Plan);
assert_eq!(AppMode::Plan.previous(), AppMode::Operate);
assert_eq!(AppMode::Agent.next(), AppMode::Plan);
assert_eq!(AppMode::Plan.previous(), AppMode::Agent);
assert_eq!(AppMode::Agent.previous(), AppMode::Plan);
assert_eq!(AppMode::Operate.previous(), AppMode::Agent);
}
/// The durable form of a mode is the `as_setting()` string persisted into
@@ -215,28 +194,25 @@ mod tests {
assert_eq!(AppMode::Agent.as_setting(), "agent");
assert_eq!(AppMode::Plan.as_setting(), "plan");
assert_eq!(AppMode::Operate.as_setting(), "operate");
}
/// `from_setting` is the de-facto default: an absent, empty, or unreadable
/// stored value must land on Work rather than panicking or picking Operate.
/// stored value must land on Work rather than panicking.
#[test]
fn from_setting_falls_back_to_act_for_unknown_values() {
assert_eq!(AppMode::from_setting(""), AppMode::Agent);
assert_eq!(AppMode::from_setting(" "), AppMode::Agent);
assert_eq!(AppMode::from_setting("nonsense"), AppMode::Agent);
assert_eq!(AppMode::from_setting("OPERATE"), AppMode::Operate);
assert_eq!(AppMode::from_setting("OPERATE"), AppMode::Agent);
}
#[test]
fn labels_and_descriptions_cover_every_mode() {
assert_eq!(AppMode::Agent.label(), "WORK");
assert_eq!(AppMode::Plan.label(), "PLAN");
assert_eq!(AppMode::Operate.label(), "OPERATE");
assert_eq!(AppMode::Agent.display_name(), "Work");
assert_eq!(AppMode::Plan.display_name(), "Plan");
assert_eq!(AppMode::Operate.display_name(), "Operate");
for mode in AppMode::CYCLE {
assert!(!mode.description().is_empty());
@@ -244,13 +220,8 @@ mod tests {
}
#[test]
fn operate_shares_the_agent_baseline_and_raises_the_launch_floor() {
fn only_work_uses_the_agent_baseline() {
assert!(AppMode::Agent.uses_agent_baseline());
assert!(AppMode::Operate.uses_agent_baseline());
assert!(!AppMode::Plan.uses_agent_baseline());
assert_eq!(AppMode::Operate.mode_delegation_launch_floor(), 4);
assert_eq!(AppMode::Agent.mode_delegation_launch_floor(), 1);
assert_eq!(AppMode::Plan.mode_delegation_launch_floor(), 1);
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ pub const DEFAULT_HOTBAR_ACTIONS: [&str; HOTBAR_SLOT_COUNT as usize] = [
"slash.auto",
"mode.plan",
"mode.agent",
"mode.operate",
"reasoning.cycle",
"palette.open",
"sidebar.toggle",
];
+6 -5
View File
@@ -297,11 +297,6 @@ const DEFAULT_MODE: &[SettingOption] = &[
"ConfigChoiceDetailModeAgent",
),
SettingOption::new("plan", "ConfigChoiceModePlan", "ConfigChoiceDetailModePlan"),
SettingOption::new(
"operate",
"ConfigChoiceModeOperate",
"ConfigChoiceDetailModeOperate",
),
];
const FOCUS_TEXTURE: &[SettingOption] = &[
@@ -1544,6 +1539,12 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"false",
None,
),
def(
"operate_deprecation_shown",
SettingKind::Bool(ON_OFF),
"false",
None,
),
// Round 3 work-bar placement migration ran once (`top` → `bottom`).
def(
"work_surface_bottom_migrated",
+3 -11
View File
@@ -68,7 +68,6 @@ pub struct EffectiveModePolicy {
/// This is the single source of truth for the mode/permission table:
/// - `Plan` -> read-only: no shell, no trust, `Suggest` approvals.
/// - `Agent` -> the user's durable baseline (`prefs`).
/// - `Operate` -> Agent baseline plus orchestration capabilities in the runtime.
///
/// The legacy YOLO spelling resolves to Agent plus a `Bypass` approval
/// posture before it reaches this table; modes no longer carry permission.
@@ -81,7 +80,7 @@ pub fn base_policy_for_mode(mode: AppMode, prefs: &ModeSessionPrefs) -> Effectiv
trust_mode: false,
approval_mode: ApprovalMode::Suggest,
},
AppMode::Agent | AppMode::Operate => EffectiveModePolicy {
AppMode::Agent => EffectiveModePolicy {
mode,
allow_shell: prefs.agent_allow_shell,
trust_mode: prefs.agent_trust_mode,
@@ -424,7 +423,7 @@ pub fn shell_policy_for_mode(mode: AppMode, allow_shell: bool) -> ShellPolicy {
}
match mode {
AppMode::Plan => ShellPolicy::None,
AppMode::Agent | AppMode::Operate => ShellPolicy::Full,
AppMode::Agent => ShellPolicy::Full,
}
}
@@ -504,9 +503,7 @@ pub fn write_carve_out_posture(
approval_mode: ApprovalMode,
auto_approve: bool,
) -> bool {
!auto_approve
&& matches!(mode, AppMode::Agent | AppMode::Operate)
&& approval_mode == ApprovalMode::AcceptEdits
!auto_approve && matches!(mode, AppMode::Agent) && approval_mode == ApprovalMode::AcceptEdits
}
/// Whether every target path of a file-write call qualifies for the
@@ -873,11 +870,6 @@ mod tests {
ApprovalMode::AcceptEdits,
false
));
assert!(write_carve_out_posture(
AppMode::Operate,
ApprovalMode::AcceptEdits,
false
));
assert!(!write_carve_out_posture(
AppMode::Agent,
ApprovalMode::Suggest,
+5 -8
View File
@@ -3167,13 +3167,12 @@ impl Engine {
// head — so the user read "Runtime policy changed to" with the
// policy itself gone, which is the one word the notice exists
// to carry. Product words only (§19): Permissions, then
// Plan / Work / Operate — not "Policy" or the ACT tag.
// Plan / Work — not "Policy" or the ACT tag.
"Permissions: {} · {}",
effective_approval.permission_chip_label(),
match mode {
AppMode::Plan => "Plan",
AppMode::Agent => "Work",
AppMode::Operate => "Operate",
},
)))
.await;
@@ -9782,9 +9781,9 @@ impl Engine {
/// across modes).
///
/// Derived from the session log, like the workspace-trust note: a session
/// with no notice gets one in Plan or Operate, and once a notice exists a
/// with no notice gets one in Plan, and once a notice exists a
/// new one is appended whenever the mode differs from the latest. Restored
/// Operate contracts also require a current notice after leaving Operate.
/// Operate contracts also require a current notice.
/// Child and RLM hosts get none: their mode is fixed by their parent.
/// Runs at turn start and before every model request, so a mode switched
/// mid-turn reaches the next request. Known limitation: a response already
@@ -9807,10 +9806,8 @@ impl Engine {
});
let message = crate::runtime_handoff::mode_runtime_message(mode);
let record = match previous {
None => matches!(mode, AppMode::Plan | AppMode::Operate),
Some(previous) if crate::runtime_handoff::is_operate_contract_message(previous) => {
mode != AppMode::Operate
}
None => matches!(mode, AppMode::Plan),
Some(previous) if crate::runtime_handoff::is_operate_contract_message(previous) => true,
Some(previous) => previous != &message,
};
if record {
@@ -342,7 +342,7 @@ async fn operate_never_promotes_wording_to_a_goal() {
// The verb-list promotion is gone: an ordinary work prompt is an ordinary
// turn in every mode, and the model decides goals through `create_goal`
// (docs/design/TUI_DECONSTRUCTION.md, founder clarification 2026-09-09).
let (objective, active, contracts) = operate_goal_probe(AppMode::Operate, prompt).await;
let (objective, active, contracts) = operate_goal_probe(AppMode::Agent, prompt).await;
assert_eq!(
objective, None,
"the host must not infer a goal from wording"
@@ -359,7 +359,7 @@ async fn operate_never_promotes_wording_to_a_goal() {
// prose now — the host never parses it, and the model decides goals
// through `create_goal`.
let (objective, active, contracts) =
operate_goal_probe(AppMode::Operate, "Please set /goal to ship the release").await;
operate_goal_probe(AppMode::Agent, "Please set /goal to ship the release").await;
assert_eq!(
objective, None,
"prose asking for a goal must not create one host-side"
@@ -371,7 +371,7 @@ async fn operate_never_promotes_wording_to_a_goal() {
#[tokio::test]
async fn operate_leaves_followup_and_long_questions_as_ordinary_turns() {
let report = "what about like rust or docker builds or something";
let (objective, active, contracts) = operate_goal_probe(AppMode::Operate, report).await;
let (objective, active, contracts) = operate_goal_probe(AppMode::Agent, report).await;
assert_eq!(
objective, None,
"conversational followup must remain ordinary turn in Operate"
@@ -381,7 +381,7 @@ async fn operate_leaves_followup_and_long_questions_as_ordinary_turns() {
let long_q =
"why did the build fail on the last step when running under docker on macos with rust 1.80";
let (objective, active, contracts) = operate_goal_probe(AppMode::Operate, long_q).await;
let (objective, active, contracts) = operate_goal_probe(AppMode::Agent, long_q).await;
assert_eq!(
objective, None,
"long question without punctuation must remain ordinary turn"
@@ -390,7 +390,7 @@ async fn operate_leaves_followup_and_long_questions_as_ordinary_turns() {
assert_eq!(contracts, 0);
let zh_followup = "那 rust 或者 docker 构建呢";
let (objective, active, contracts) = operate_goal_probe(AppMode::Operate, zh_followup).await;
let (objective, active, contracts) = operate_goal_probe(AppMode::Agent, zh_followup).await;
assert_eq!(
objective, None,
"Chinese followup must remain ordinary turn in Operate"
@@ -402,7 +402,7 @@ async fn operate_leaves_followup_and_long_questions_as_ordinary_turns() {
#[tokio::test]
async fn operate_does_not_create_a_goal_when_the_work_request_declines_one() {
let prompt = "Run one bounded cancellation check. Do not edit files, inspect other files, create a goal, spawn agents, or start any other tool.";
let (objective, active, contracts) = operate_goal_probe(AppMode::Operate, prompt).await;
let (objective, active, contracts) = operate_goal_probe(AppMode::Agent, prompt).await;
assert_eq!(objective, None);
assert!(!active);
assert_eq!(contracts, 0, "Operate appends no contract");
@@ -445,7 +445,7 @@ async fn operate_turns_append_no_contract_and_never_replace_an_existing_goal() {
max_output_tokens: None,
content: content.to_string(),
images: Vec::new(),
mode: AppMode::Operate,
mode: AppMode::Agent,
route: resolved_route_for_test(&config, "local-model"),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
@@ -1270,7 +1270,7 @@ async fn operate_conversation_reaches_provider_when_workers_are_disabled() {
operate_handle
.send(external_user_message_op(
"what is a Rust worktree?",
AppMode::Operate,
AppMode::Agent,
&api_config,
))
.await
@@ -480,7 +480,7 @@ async fn measure_production_mode_tool_catalogs() -> serde_json::Value {
for (mode_name, mode) in [
("plan", AppMode::Plan),
("act", AppMode::Agent),
("operate", AppMode::Operate),
("operate", AppMode::Agent),
] {
let workspace = tmp.path().join(mode_name);
fs::create_dir_all(&workspace).expect("create isolated mode workspace");
@@ -704,7 +704,7 @@ fn print_mode_runtime_contract_metrics() {
for (mode_name, mode) in [
("plan", AppMode::Plan),
("act", AppMode::Agent),
("operate", AppMode::Operate),
("operate", AppMode::Agent),
] {
let prompt = system_prompt_for_mode_with_context_skills_and_session(
&workspace,
@@ -540,7 +540,7 @@ async fn operate_model_shell_uses_normal_approval_and_workspace_sandbox() {
max_output_tokens: None,
content: "Write the requested local fixture to the workspace".to_string(),
images: Vec::new(),
mode: AppMode::Operate,
mode: AppMode::Agent,
route: resolved_route_for_test(&api_config, codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
@@ -774,7 +774,6 @@ fn live_runtime_authority_narrows_only_when_a_grant_is_withdrawn() {
assert!(!at(AppMode::Agent, ApprovalMode::Bypass, None).narrows(&ask));
assert!(!ask.narrows(&at(AppMode::Plan, ApprovalMode::Suggest, None)));
assert!(at(AppMode::Plan, ApprovalMode::Suggest, None).narrows(&ask));
assert!(at(AppMode::Operate, ApprovalMode::Suggest, None).narrows(&ask));
assert!(ask.narrows(&at(AppMode::Agent, ApprovalMode::Bypass, None)));
assert!(at(AppMode::Agent, ApprovalMode::Never, None).narrows(&ask));
assert!(at(AppMode::Agent, ApprovalMode::Suggest, Some("read-only")).narrows(&ask));
@@ -1032,7 +1032,7 @@ fn plan_mode_toggle_preserves_catalog_byte_stability() {
fn parent_turn_registry_includes_goal_tools_for_all_modes() {
let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());
for mode in [AppMode::Plan, AppMode::Agent, AppMode::Operate] {
for mode in [AppMode::Plan, AppMode::Agent] {
let registry = engine
.build_turn_tool_registry_builder(
mode,
@@ -1149,7 +1149,7 @@ fn mode_invariant_matrix_covers_context_catalog_subagents_and_prompt_metadata()
},
ModeCase {
name: "operate",
mode: AppMode::Operate,
mode: AppMode::Agent,
shell_policy: ShellPolicy::Full,
sandbox: ExpectedSandbox::WorkspaceWrite,
trust_mode: false,
@@ -358,7 +358,7 @@ async fn live_runtime_authority_applies_latest_posture_and_sandbox_before_tools(
for (mode, posture, auto_approve, sandbox_mode, expected_sandbox) in [
(
AppMode::Operate,
AppMode::Agent,
ApprovalMode::Auto,
false,
Some("read-only".to_string()),
@@ -1282,8 +1282,8 @@ fn current_mode_field_assignment_takes_effect_synchronously() {
let (mut engine, _handle) = Engine::new(config, &Config::default());
assert_eq!(engine.current_mode, AppMode::Agent);
engine.current_mode = AppMode::Operate;
assert_eq!(engine.current_mode, AppMode::Operate);
engine.current_mode = AppMode::Plan;
assert_eq!(engine.current_mode, AppMode::Plan);
}
#[test]
@@ -1645,18 +1645,13 @@ fn mode_notice_is_recorded_on_entering_plan_or_operate_and_on_leaving_it() {
assert!(recorded[1].starts_with("Mode: Work."), "{recorded:?}");
engine.session.messages.clear();
engine.record_mode_notice(AppMode::Operate);
engine.record_mode_notice(AppMode::Operate);
assert_eq!(notices(&engine).len(), 1);
engine
.session
.add_message(crate::runtime_handoff::operate_contract_runtime_message());
engine.record_mode_notice(AppMode::Operate);
assert_eq!(notices(&engine).len(), 1);
engine.record_mode_notice(AppMode::Agent);
let recorded = notices(&engine);
assert_eq!(recorded.len(), 2);
assert!(recorded[1].starts_with("Mode: Work."));
assert_eq!(recorded.len(), 1);
assert!(recorded[0].starts_with("Mode: Work."));
engine.session.messages.clear();
engine
@@ -926,7 +926,7 @@ fn engine_handle_try_send_does_not_block_when_op_channel_is_full() {
// cannot fit. The already-queued operation will wake the engine, which
// applies this pending authority before handling it.
let result = handle.try_send(Op::ChangeMode {
mode: AppMode::Operate,
mode: AppMode::Agent,
allow_shell: true,
trust_mode: false,
auto_approve: false,
@@ -990,7 +990,7 @@ async fn full_mailbox_posture_update_supersedes_queued_change_mode() {
}
let result = handle.try_send(Op::ChangeMode {
mode: AppMode::Operate,
mode: AppMode::Agent,
allow_shell: true,
trust_mode: false,
auto_approve: false,
@@ -140,7 +140,7 @@ fn first_turn_surface_is_stable_across_plan_work_and_operate() {
.map(str::to_string)
.collect::<BTreeSet<_>>();
let mut expected_prefix = None;
for mode in [AppMode::Plan, AppMode::Agent, AppMode::Operate] {
for mode in [AppMode::Plan, AppMode::Agent] {
let mut catalog = [
"read",
"write",
@@ -181,7 +181,7 @@ fn first_turn_surface_is_stable_across_plan_work_and_operate() {
#[test]
fn eager_workflow_still_respects_command_allow_and_deny_gates() {
for mode in [AppMode::Plan, AppMode::Agent, AppMode::Operate] {
for mode in [AppMode::Plan, AppMode::Agent] {
for (allow, deny, expected) in [
(None, None, true),
(Some("read"), None, false),
+6 -6
View File
@@ -6086,8 +6086,8 @@ mod pre_tool_snapshot_gate_tests {
] {
assert!(mode_blocks_command_execution(AppMode::Plan, tool));
assert!(
!mode_blocks_command_execution(AppMode::Operate, tool),
"Operate must not add a mode-only command denial for {tool}"
!mode_blocks_command_execution(AppMode::Agent, tool),
"Work must not add a mode-only command denial for {tool}"
);
}
@@ -6099,8 +6099,8 @@ mod pre_tool_snapshot_gate_tests {
false
));
assert!(
!mode_blocks_write_capable_tool(AppMode::Operate, tool, &json!({}), false),
"Operate must not add a mode-only write denial for {tool}"
!mode_blocks_write_capable_tool(AppMode::Agent, tool, &json!({}), false),
"Work must not add a mode-only write denial for {tool}"
);
}
@@ -6113,7 +6113,7 @@ mod pre_tool_snapshot_gate_tests {
false
));
assert!(!mode_blocks_write_capable_tool(
AppMode::Operate,
AppMode::Agent,
"File",
&input,
false
@@ -6135,7 +6135,7 @@ mod pre_tool_snapshot_gate_tests {
false
));
assert!(!mode_blocks_write_capable_tool(
AppMode::Operate,
AppMode::Agent,
"mcp_filesystem_write",
&json!({}),
false
+1 -2
View File
@@ -69,7 +69,6 @@ pub fn app_mode_str(mode: AppMode) -> &'static str {
match mode {
AppMode::Agent => "agent",
AppMode::Plan => "plan",
AppMode::Operate => "operate",
}
}
@@ -1862,7 +1861,7 @@ mod tests {
#[test]
fn mode_labels_round_trip_through_app_mode_parse() {
for mode in [AppMode::Agent, AppMode::Plan, AppMode::Operate] {
for mode in [AppMode::Agent, AppMode::Plan] {
assert_eq!(AppMode::parse(app_mode_str(mode)), Some(mode), "{mode:?}");
}
for mode in [
+1 -2
View File
@@ -1920,7 +1920,7 @@ mod tests {
)
.expect("write project instruction");
for host in [PromptHost::Interactive, PromptHost::Headless] {
let prompts = [AppMode::Plan, AppMode::Agent, AppMode::Operate].map(|mode| {
let prompts = [AppMode::Plan, AppMode::Agent].map(|mode| {
system_prompt_flat_text(
&system_prompt_for_mode_with_context_skills_session_and_approval_for_host(
tmp.path(),
@@ -1936,7 +1936,6 @@ mod tests {
)
});
assert_eq!(prompts[0], prompts[1]);
assert_eq!(prompts[1], prompts[2]);
assert!(prompts[0].contains("Preserve the blue-ocean marker"));
assert!(!prompts[0].contains("##### Mode:"));
if host == PromptHost::Headless {
+1 -5
View File
@@ -215,10 +215,6 @@ fn mode_event_body(mode: codewhale_config::AppMode) -> &'static str {
AppMode::Agent => {
"Mode: Work. Purpose: do the user's request. The user changes modes with /mode."
}
AppMode::Operate => concat!(
"Mode: Operate. Purpose: carry the request through to verified completion. ",
"The user changes modes with /mode.",
),
}
}
@@ -226,7 +222,7 @@ fn mode_event_body(mode: codewhale_config::AppMode) -> &'static str {
///
/// KV-cache effect: append-only user history; the system prompt stays
/// byte-identical across modes. The engine records it when a session starts
/// in or enters Plan or Operate, and again whenever the mode differs from the last
/// in or enters Plan, and again whenever the mode differs from the last
/// recorded notice, so a notice in history is never stale.
pub fn mode_runtime_message(mode: codewhale_config::AppMode) -> Message {
runtime_handoff_message_with_meta(
+3 -3
View File
@@ -20103,8 +20103,8 @@ fn parse_mode_scenario() {
assert_eq!(parse_mode_opt("plan"), Some(AppMode::Plan));
assert_eq!(parse_mode_opt("2"), Some(AppMode::Plan));
assert_eq!(parse_mode_opt("auto"), Some(AppMode::Agent));
assert_eq!(parse_mode_opt("operate"), Some(AppMode::Operate));
assert_eq!(parse_mode_opt("3"), Some(AppMode::Operate));
assert_eq!(parse_mode_opt("operate"), Some(AppMode::Agent));
assert_eq!(parse_mode_opt("3"), Some(AppMode::Agent));
// Legacy YOLO spellings resolve to Act; the posture travels separately.
assert_eq!(parse_mode_opt("yolo"), Some(AppMode::Agent));
assert_eq!(parse_mode_opt("4"), Some(AppMode::Agent));
@@ -20128,7 +20128,7 @@ fn parse_mode_scenario() {
assert_eq!(parse_mode("auto"), AppMode::Agent);
assert_eq!(parse_mode("1"), AppMode::Agent);
assert_eq!(parse_mode("2"), AppMode::Plan);
assert_eq!(parse_mode("3"), AppMode::Operate);
assert_eq!(parse_mode("3"), AppMode::Agent);
assert_eq!(parse_mode("4"), AppMode::Agent);
}
}
+1 -1
View File
@@ -1601,7 +1601,7 @@ mod tests {
use codewhale_config::AppMode;
use std::collections::HashSet;
let empty = HashSet::new();
for mode in [AppMode::Agent, AppMode::Operate] {
for mode in [AppMode::Agent] {
let mut catalog = Vec::new();
crate::core::engine::tool_catalog::ensure_advanced_tooling(
&mut catalog,
+5 -19
View File
@@ -3089,11 +3089,10 @@ pub struct SubAgentRuntime {
pub allow_shell: bool,
/// When true, Suggest-level file writes auto-accept for write-capable roles
/// without full parent auto-approve. Shell/network/MCP still gated.
/// Set for Workflow-spawned children and parent-approved root Operate
/// workers.
/// Set for Workflow-spawned children.
pub accept_edits: bool,
/// Allow the built-in, non-custom verification tools after a root Operate
/// worker start has crossed the parent's approval boundary. This is not a
/// Allow the built-in, non-custom verification tools after a worker
/// start has crossed the parent's approval boundary. This is not a
/// general shell grant: arbitrary commands and custom verifier programs
/// remain blocked unless the parent session is auto-approved.
pub accept_verification: bool,
@@ -3598,7 +3597,7 @@ impl SubAgentRuntime {
context: child_context,
allow_shell: self.allow_shell,
accept_edits: self.accept_edits,
// A parent-approved Operate verification lease belongs to its
// A parent-approved verification lease belongs to its
// direct worker only; nested children must cross their own
// approval boundary instead of silently inheriting it.
accept_verification: self.accept_verification && self.spawn_depth == 0,
@@ -3697,7 +3696,7 @@ impl SubAgentWorkLifecycle {
return Ok(None);
}
// A root Operate verification lease is provenance, not delegated
// A root verification lease is provenance, not delegated
// authority. Nested workers cannot inherit this bit.
if runtime.accept_verification && runtime.spawn_depth == 1 {
let reference = format!("operate-verification:{agent_id}");
@@ -15885,7 +15884,6 @@ async fn spawn_subagent_from_input(
) -> Result<(SubAgentResult, WorkflowTaskSpawnMetadata), ToolError> {
let exact_fleet_binding =
workflow_identity.and_then(|identity| identity.exact_fleet_binding.as_ref());
apply_session_spawn_defaults(&mut runtime);
if start_requests_read_only_role(&input) {
runtime.accept_verification = false;
}
@@ -16500,18 +16498,6 @@ fn spawn_request_is_write_capable(request: &SpawnRequest) -> bool {
}
}
/// A root Operate dispatch has already crossed the approval boundary on the
/// `agent` call. Delegate Suggest-level file edits and the bounded built-in
/// verification surfaces so a normal message can produce verified work.
/// Arbitrary shell and custom verifier commands still follow the active
/// permission posture.
fn apply_session_spawn_defaults(runtime: &mut SubAgentRuntime) {
if runtime.spawn_depth == 0 && runtime.parent_mode == AppMode::Operate {
runtime.accept_edits = true;
runtime.accept_verification = true;
}
}
/// Spawn one Workflow `task(...)` through the same path as the public `agent`
/// tool. Keeping this adapter inside the sub-agent module prevents the
/// Workflow driver from copying Fleet roster/profile/depth/budget semantics.
-108
View File
@@ -16248,114 +16248,6 @@ pub fn stub_runtime() -> SubAgentRuntime {
}
}
#[test]
fn root_operate_dispatch_delegates_file_edits_without_bypassing_required_tools() {
let mut runtime = stub_runtime();
runtime.parent_mode = AppMode::Operate;
assert!(!runtime.accept_edits);
assert!(!runtime.accept_verification);
assert!(!runtime.context.auto_approve);
apply_session_spawn_defaults(&mut runtime);
assert!(runtime.accept_edits);
assert!(runtime.accept_verification);
assert!(
!runtime.context.auto_approve,
"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");
std::fs::create_dir_all(tmp.path().join("src")).expect("src dir");
std::fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"operate-verification-fixture\"\nversion = \"0.0.0\"\nedition = \"2024\"\n",
)
.expect("manifest");
std::fs::write(
tmp.path().join("src/lib.rs"),
"pub fn ready() -> bool { true }\n",
)
.expect("source");
let mut runtime = stub_runtime();
runtime.context = ToolContext::new(tmp.path().to_path_buf());
runtime.context.auto_approve = false;
runtime.parent_mode = AppMode::Operate;
apply_session_spawn_defaults(&mut runtime);
let registry = ChildCoreProbe::new(
runtime.clone(),
FleetRole::Worker,
None,
Arc::new(Mutex::new(TodoList::new())),
Arc::new(Mutex::new(PlanState::default())),
);
registry
.execute("agent_test", "Run", json!({"action": "tests"}))
.await
.expect("parent-approved Operate worker should run built-in tests");
let targeted_err = registry
.execute(
"agent_test",
"Run",
json!({
"action": "tests",
"args": "--manifest-path ../outside/Cargo.toml"
}),
)
.await
.expect_err("raw Cargo argv must stay approval-gated");
assert!(
matches!(targeted_err.downcast_ref::<ToolError>(), Some(ToolError::NotAvailable { message })
if message == "child caller has no host that can answer this approval"),
"unexpected approval refusal: {targeted_err}"
);
let shell_err = registry
.execute(
"agent_test",
"Bash",
json!({"action": "run", "command": "echo nope"}),
)
.await
.expect_err("Operate verification delegation must not grant raw shell");
assert!(
matches!(shell_err.downcast_ref::<ToolError>(), Some(ToolError::NotAvailable { message })
if message == "child caller has no host that can answer this approval"),
"unexpected approval refusal: {shell_err}"
);
let custom_err = registry
.execute(
"agent_test",
"Run",
json!({
"action": "verifiers",
"commands": [{"name": "custom", "program": "echo", "args": ["nope"]}]
}),
)
.await
.expect_err("Operate verification delegation must not grant custom commands");
assert!(
matches!(custom_err.downcast_ref::<ToolError>(), Some(ToolError::NotAvailable { message })
if message == "child caller has no host that can answer this approval"),
"unexpected approval refusal: {custom_err}"
);
let direct_child = runtime.child_runtime();
assert!(direct_child.accept_verification);
let grandchild = direct_child.child_runtime();
assert!(
!grandchild.accept_verification,
"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();
+1 -1
View File
@@ -141,7 +141,7 @@ mod tests {
fn named_posture_is_normalized_and_authoritative() {
let auto = RuntimePolicyProjection::from_request("operate", Some("auto-review"), None)
.expect("compat permission");
assert_eq!(auto.mode_setting(), "operate");
assert_eq!(auto.mode_setting(), "agent");
assert_eq!(auto.permission_wire(), "auto_review");
assert_eq!(auto.permission, ApprovalMode::Auto);
assert!(!auto.auto_approve());
+36 -16
View File
@@ -374,7 +374,7 @@ pub struct Settings {
pub composer_vim_mode: String,
/// Transcript spacing rhythm: compact, comfortable, spacious
pub transcript_spacing: String,
/// Default mode: "agent" (Work), "plan", or "operate". Legacy permission
/// Default mode: "agent" (Work) or "plan". Legacy permission
/// shorthands are accepted for migration but never advertised as modes.
pub default_mode: String,
/// Legacy sidebar width as percentage of terminal width. Load-only
@@ -489,6 +489,9 @@ pub struct Settings {
/// One-time YOLO deprecation toast has been shown. Suppresses the repeat
/// toast after the first sighting per install (persisted across sessions).
pub yolo_deprecation_shown: bool,
/// One-time Operate-retirement toast has been shown. Same suppression
/// contract as `yolo_deprecation_shown`.
pub operate_deprecation_shown: bool,
/// Round 3 (2026-09-01) moved the work bar under the composer. Every
/// settings.toml saved before that carries `work_surface_placement =
/// "top"` — the old default, persisted verbatim by ordinary saves, not a
@@ -517,6 +520,10 @@ pub struct Settings {
/// approval policy. It is never written back to disk.
#[serde(skip)]
pub legacy_yolo_default: bool,
/// True only for the current load when a retired `default_mode =
/// "operate"` spelling was read. Never written back to disk.
#[serde(skip)]
pub legacy_operate_default: bool,
/// Receipt for the one-time `tui.toml` fold performed by this load.
/// Never serialized: it describes what happened to a file, not a setting.
#[serde(skip)]
@@ -609,6 +616,8 @@ impl Default for Settings {
workspace_follow_symlinks: false,
feature_intro_shown: false,
yolo_deprecation_shown: false,
operate_deprecation_shown: false,
legacy_operate_default: false,
work_surface_bottom_migrated: false,
behavioral_tip_impressions: std::collections::BTreeMap::new(),
dismissed_plugin_suggestions: std::collections::BTreeSet::new(),
@@ -950,6 +959,10 @@ impl Settings {
// the app starts in a fictional mode.
let legacy_yolo_default = s.default_mode.trim().eq_ignore_ascii_case("yolo");
s.legacy_yolo_default = legacy_yolo_default;
s.legacy_operate_default = matches!(
s.default_mode.trim().to_ascii_lowercase().as_str(),
"operate" | "operation" | "ops"
);
s.default_mode = if legacy_yolo_default {
"agent".to_string()
} else {
@@ -1049,6 +1062,15 @@ impl Settings {
self.legacy_yolo_default
}
/// Whether this load read a retired `default_mode = "operate"` spelling.
///
/// Migration provenance only: the normalized value on disk is Work, and
/// startup uses this to explain the change once instead of silently
/// dropping the mode the user had saved.
pub fn legacy_operate_default_detected(&self) -> bool {
self.legacy_operate_default
}
/// Receipt for the one-time `tui.toml` fold, when this load performed one.
pub fn tui_prefs_migration(&self) -> Option<&TuiPrefsMigration> {
self.tui_prefs_migration.as_ref()
@@ -1703,14 +1725,14 @@ impl Settings {
self.workspace_follow_symlinks = parse_bool(value)?;
}
"default_mode" | "mode" => {
// Work (wire: agent), Plan, and Operate are valid startup modes.
// yolo remains a permission-migration alias, not a mode write.
// Work (wire: agent) and Plan are the startup modes. yolo and
// the retired operate spellings stay accepted aliases of Work.
self.default_mode = match value.trim().to_ascii_lowercase().as_str() {
"agent" | "normal" | "act" | "work" | "edit" => "agent".to_string(),
"plan" => "plan".to_string(),
"operate" | "operation" | "ops" => "operate".to_string(),
"operate" | "operation" | "ops" => "agent".to_string(),
_ => anyhow::bail!(
"Failed to update setting: invalid mode '{value}'. Expected: work (agent), plan, or operate."
"Failed to update setting: invalid mode '{value}'. Expected: work (agent) or plan."
),
};
}
@@ -2934,8 +2956,8 @@ fn normalize_mode(value: &str) -> &str {
"normal" => "agent",
"agent" | "act" | "work" => "agent",
"plan" => "plan",
// Operate is a first-class startup mode (Hunter 2026-07-24).
"operate" | "operation" | "ops" => "operate",
// Operate was retired as a mode; its spellings still parse, as Work.
"operate" | "operation" | "ops" => "agent",
// yolo was mode+permission; keep mode as Act and migrate posture on load.
"yolo" => "agent",
_ => value,
@@ -3223,7 +3245,7 @@ mod tests {
"slow-writer" => {
with_settings_transaction(|transaction| {
let mut settings = transaction.load()?;
settings.default_mode = "operate".to_string();
settings.default_mode = "plan".to_string();
// Announce *after* the read: from here on, any parent write
// that is not excluded by the lock will be lost by the save
// below.
@@ -3370,7 +3392,7 @@ mod tests {
let settled = Settings::load_persisted().expect("reload the shared settings");
assert_eq!(
settled.default_mode, "operate",
settled.default_mode, "plan",
"the child's field must survive the parent's whole-file save"
);
assert_eq!(
@@ -5333,8 +5355,8 @@ zai = ["GLM-5.2", "GLM-5.3"]
assert_eq!(settings.default_mode, "agent");
settings
.set("default_mode", "operate")
.expect("operate is a valid startup mode");
assert_eq!(settings.default_mode, "operate");
.expect("operate stays an accepted alias");
assert_eq!(settings.default_mode, "agent");
settings
.set("default_mode", "act")
.expect("act alias maps to agent wire value");
@@ -5343,10 +5365,7 @@ zai = ["GLM-5.2", "GLM-5.3"]
let err = settings
.set("default_mode", "yolo")
.expect_err("yolo remains a permission migration alias, not a mode write");
assert!(
err.to_string().contains("work (agent), plan, or operate"),
"{err}"
);
assert!(err.to_string().contains("work (agent) or plan"), "{err}");
}
#[test]
@@ -5375,7 +5394,8 @@ zai = ["GLM-5.2", "GLM-5.3"]
)
.expect("operate startup settings");
let loaded = Settings::load_persisted().expect("load operate settings");
assert_eq!(loaded.default_mode, "operate");
assert_eq!(loaded.default_mode, "agent");
assert!(loaded.legacy_operate_default_detected());
assert_eq!(loaded.permission_posture, None);
}
+7
View File
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Runtime API: find the agent CLIs already installed on this computer.**
`GET /v1/agents/external` reports Claude, Codex, Gemini CLI, OpenCode, Kimi
Code, Mistral Vibe, Qoder, CodeBuddy and Grok, with the version each one
reports and whether it answers Agent Client Protocol itself. The probe runs
`--version` and one `initialize` handshake, gets no Codewhale or provider
credentials, and stores nothing. Read-only: it does not start an agent yet.
- **Runtime API: check a provider key before storing it.**
`POST /v1/providers/{id}/key/check` asks the provider's own `/models`
endpoint once whether it accepts a key, and never stores it. A key the
@@ -241,6 +247,7 @@ until the matching tag, packages, checksums, and release assets exist.
- **Sub-agents and goals by default.** The constitution now tells the agent to start independent parts of a request as parallel sub-agents in one message, keep working while their results arrive on their own, and end the turn when only waiting remains; to give each child a complete prompt and its own files; to inspect a write-capable child's evidence, review the diff and run the checks itself; to have an independent reviewer try to refute a non-trivial change before calling it done; and to start a goal for any request, in any language, whose verifiable end state needs more than one turn. Sub-agents, workflows and goals within the current request are in scope; spending real money (purchases, paid providers, billing) still needs the user's express authorization. Cost alone is not a reason to stop; only limits the user or configuration sets apply. **This changes the session-pinned prompt and tool descriptions, so each session re-pins its prompt cache once after upgrade.**
- **Operate's contract is retired.** The Engine no longer appends the Operate contract to a session; its review, cost and evidence rules now live in the constitution for every session. Saved sessions that contain it still replay.
- **The Operate mode is gone; Work and Plan are the two modes.** Tab cycles between them, `/mode` and the config editor offer only those two, and a saved `default_mode = "operate"` loads as Work with a one-time notice that `/fleet` runs parallel agents. `operate`, `operation`, `ops` and `3` still parse, as Work, so an old setting or script never errors. Fan-out is unchanged — 64 live sub-agents per message, workflows at a 64 default with a 128 ceiling — while the Operate-only launch-concurrency floor and its top-level child auto-accept posture are gone; a child's edits follow the permission posture like every other action. "Plan stays Read Only; this applies in Work" now says exactly that.
- **Clearer tool text.** The `agent` description leads with when to delegate, gives one trigger per role and an example child brief, and drops Operate and legacy wording; the start receipt says results arrive automatically. `create_goal` and `update_goal` say when to start a goal and what complete, blocked and yield mean (blocked means the same obstacle persists; difficulty is not blocked). `tool_search` lists the deferred tool groups. The eager tool schemas shrink by 681 bytes.
- **Workflow fan-out default 16 → 64.** `[workflow] max_concurrent` now defaults to 64 live agents per run, matching sub-agents; the per-run ceiling rises from 16 to 128. No spend caps were added.
- **Bundled skill `goals` is now `personal-goals`.** It tracks personal resolutions in `goals.md` and no longer collides with goal mode; `goals` still resolves to it. An untouched installed `goals` copy becomes `personal-goals` on upgrade; an edited one stays as `goals`.
+1 -2
View File
@@ -169,7 +169,7 @@ fn to_app_mode(mode: CommandMode) -> AppMode {
match mode {
CommandMode::Agent => AppMode::Agent,
CommandMode::Plan => AppMode::Plan,
CommandMode::Operate => AppMode::Operate,
CommandMode::Operate => AppMode::Agent,
}
}
@@ -177,7 +177,6 @@ fn from_app_mode(mode: AppMode) -> CommandMode {
match mode {
AppMode::Agent => CommandMode::Agent,
AppMode::Plan => CommandMode::Plan,
AppMode::Operate => CommandMode::Operate,
}
}
+4 -7
View File
@@ -3690,17 +3690,14 @@ mod tests {
let _ = mode_command(&mut app, Some("plan"));
assert_eq!(app.mode, AppMode::Plan);
let result = mode_command(&mut app, Some("3"));
assert_eq!(
result.action,
Some(AppAction::ModeChanged(AppMode::Operate))
);
assert_eq!(app.mode, AppMode::Operate);
assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Agent)));
assert_eq!(app.mode, AppMode::Agent);
let result = mode_command(&mut app, Some("5"));
assert!(result.is_error);
assert_eq!(app.mode, AppMode::Operate);
assert_eq!(app.mode, AppMode::Agent);
let result = mode_command(&mut app, Some("9"));
assert!(result.is_error);
assert_eq!(app.mode, AppMode::Operate);
assert_eq!(app.mode, AppMode::Agent);
let result = mode_command(&mut app, Some("4"));
// "4" still routes to the deprecated YOLO alias, which lands in Agent
// mode with bypass approvals (M6 compat shim).
+16 -7
View File
@@ -123,7 +123,6 @@ pub(crate) fn to_command_mode(mode: AppMode) -> CommandMode {
match mode {
AppMode::Agent => CommandMode::Agent,
AppMode::Plan => CommandMode::Plan,
AppMode::Operate => CommandMode::Operate,
}
}
@@ -131,7 +130,7 @@ pub(crate) fn from_command_mode(mode: CommandMode) -> AppMode {
match mode {
CommandMode::Agent => AppMode::Agent,
CommandMode::Plan => AppMode::Plan,
CommandMode::Operate => AppMode::Operate,
CommandMode::Operate => AppMode::Agent,
}
}
@@ -5380,7 +5379,7 @@ mod tests {
#[test]
fn boundary_mappings_cover_every_variant() {
for mode in [AppMode::Agent, AppMode::Plan, AppMode::Operate] {
for mode in [AppMode::Agent, AppMode::Plan] {
let command = to_command_mode(mode);
assert_eq!(from_command_mode(command), mode);
}
@@ -5591,16 +5590,26 @@ mod tests {
let mut bundle = app.command_contexts();
let mut parts = bundle.parts();
let policy = parts.mode_policy.as_mut().expect("mode facet");
policy.set_mode(CommandMode::Operate);
policy.set_mode(CommandMode::Plan);
policy.set_shell_access(true);
assert!(policy.allow_shell());
assert_eq!(policy.mode(), CommandMode::Operate);
assert!(!policy.allow_shell(), "Plan stays read-only");
assert_eq!(policy.mode(), CommandMode::Plan);
}
assert_eq!(
app.mode,
AppMode::Operate,
AppMode::Plan,
"adapter delegates to App authority"
);
assert!(!app.allow_shell, "Plan ignores a shell grant");
{
let mut bundle = app.command_contexts();
let mut parts = bundle.parts();
let policy = parts.mode_policy.as_mut().expect("mode facet");
policy.set_mode(CommandMode::Agent);
policy.set_shell_access(true);
assert!(policy.allow_shell(), "Work uses the shell baseline");
}
assert_eq!(app.mode, AppMode::Agent);
assert!(app.allow_shell);
}
@@ -305,7 +305,7 @@ fn test_home_dashboard_shows_queued_when_present() {
#[test]
fn test_home_dashboard_mode_tips_for_each_mode() {
let modes = [AppMode::Agent, AppMode::Plan, AppMode::Operate];
let modes = [AppMode::Agent, AppMode::Plan];
for mode in modes {
let mut app = create_test_app();
app.mode = mode;
@@ -134,7 +134,7 @@ fn rename_persists_all_live_metadata_through_public_dispatch() {
harness
.app
.set_provider_identity(ProviderKind::Custom, "lm-studio");
harness.app.mode = AppMode::Operate;
harness.app.mode = AppMode::Plan;
harness.app.system_prompt = None;
harness.app.todos.try_lock().expect("todos lock").add(
"live rename state".to_string(),
@@ -160,7 +160,7 @@ fn rename_persists_all_live_metadata_through_public_dispatch() {
Some("lm-studio")
);
assert_eq!(reloaded.metadata.workspace, harness.app.workspace);
assert_eq!(reloaded.metadata.mode.as_deref(), Some("operate"));
assert_eq!(reloaded.metadata.mode.as_deref(), Some("plan"));
assert_eq!(
harness.app.session_title.as_deref(),
Some("Brand New Title")
+1 -1
View File
@@ -185,7 +185,7 @@ fn app_mode(case: &Value) -> AppMode {
match case["mode"].as_str().unwrap_or("agent") {
"agent" => AppMode::Agent,
"plan" => AppMode::Plan,
"operate" => AppMode::Operate,
"operate" => AppMode::Agent,
other => panic!("unknown mode `{other}`"),
}
}
+1 -1
View File
@@ -14866,7 +14866,7 @@ async fn build_direct_workflow_tool(
}
let yolo = config.yolo.unwrap_or(false);
let mode = AppMode::Operate;
let mode = AppMode::Agent;
let allow_shell = yolo || config.allow_shell();
let shell_policy = shell_policy_for_mode(mode, allow_shell);
let trusted = codewhale_runtime::workspace_trust::WorkspaceTrust::load_for(workspace);
+26 -1
View File
@@ -2213,6 +2213,9 @@ pub struct App {
pub yolo: bool,
/// One-shot YOLO→Act+Bypass migration notice for this session (#0.8.68 M6).
yolo_compat_notified: bool,
/// One-shot notice for a settings file that still names the retired
/// Operate mode, so the mode change is explained instead of silent.
operate_retired_notified: bool,
/// The single serialized owner of `settings.toml` startup-default writes
/// (mode, thinking, model). Keeping one owner per `App` is what stops two
/// rapid selections from interleaving their load/modify/save transactions
@@ -3766,6 +3769,28 @@ impl App {
);
}
fn notify_operate_retired_once(&mut self) {
if self.operate_retired_notified {
return;
}
self.operate_retired_notified = true;
if let Ok(settings) = codewhale_runtime::settings::Settings::load()
&& settings.operate_deprecation_shown
{
return;
}
let _ = codewhale_runtime::settings::Settings::transact(|settings| {
settings.operate_deprecation_shown = true;
Ok(())
});
self.push_status_toast(
"Operate mode was retired — this session starts in Work; /fleet runs parallel agents"
.to_string(),
StatusToastLevel::Info,
Some(10_000),
);
}
/// One-release migration notice for the Shift+Tab/Ctrl+T rebinding: users
/// pressing Shift+Tab expecting the old thinking cycle land here first.
fn notify_keybinding_migration_once(&mut self) {
@@ -4129,7 +4154,7 @@ impl App {
if self.mode == AppMode::Plan {
self.push_status_toast(
format!(
"Permissions set to {}. Plan stays Read Only; this applies in Work and Operate.",
"Permissions set to {}. Plan stays Read Only; this applies in Work.",
next.permission_chip_label()
),
StatusToastLevel::Info,
+4
View File
@@ -1035,6 +1035,7 @@ impl App {
lifecycle_outbox,
yolo: yolo_compat,
yolo_compat_notified: false,
operate_retired_notified: false,
startup_defaults: Default::default(),
keybinding_migration_notified: false,
mode_prefs,
@@ -1249,6 +1250,9 @@ impl App {
if yolo_compat {
app.notify_yolo_compat_once();
}
if settings.legacy_operate_default_detected() {
app.notify_operate_retired_once();
}
app
}
}
+43 -52
View File
@@ -3775,7 +3775,7 @@ fn entering_operate_preserves_user_rail_panel() {
let mut app = App::new(test_options(false), &Config::default());
app.work_surface.panel = crate::tui::work_surface::RailPanel::Agents;
assert!(app.set_mode(AppMode::Operate));
assert!(app.set_mode(AppMode::Plan));
assert_eq!(
app.work_surface.panel,
crate::tui::work_surface::RailPanel::Agents
@@ -3799,15 +3799,15 @@ fn test_cycle_scenario() {
app.mode = AppMode::Plan;
app.cycle_mode_reverse();
assert_eq!(app.mode, AppMode::Operate);
app.mode = AppMode::Operate;
app.cycle_mode_reverse();
assert_eq!(app.mode, AppMode::Agent);
app.mode = AppMode::Agent;
app.cycle_mode_reverse();
assert_eq!(app.mode, AppMode::Plan);
app.mode = AppMode::Agent;
app.cycle_mode_reverse();
assert_eq!(app.mode, AppMode::Plan);
}
}
@@ -4028,12 +4028,11 @@ fn base_policy_for_mode_projects_the_mode_permission_table() {
assert!(agent.trust_mode);
assert_eq!(agent.approval_mode, ApprovalMode::Never);
// Operate uses the Agent baseline.
let operate = base_policy_for_mode(AppMode::Operate, &prefs);
assert_eq!(operate.mode, AppMode::Operate);
assert_eq!(operate.allow_shell, agent.allow_shell);
assert_eq!(operate.trust_mode, agent.trust_mode);
assert_eq!(operate.approval_mode, ApprovalMode::Never);
// Work uses the Agent baseline.
let work = base_policy_for_mode(AppMode::Agent, &prefs);
assert_eq!(work.allow_shell, agent.allow_shell);
assert_eq!(work.trust_mode, agent.trust_mode);
assert_eq!(work.approval_mode, ApprovalMode::Never);
// Full Access is represented by the Bypass posture, not a mode row or a
// separate auto-approve field (#3736).
@@ -4048,10 +4047,6 @@ fn base_policy_for_mode_projects_the_mode_permission_table() {
assert!(!agent_min.allow_shell);
assert!(!agent_min.trust_mode);
assert_eq!(agent_min.approval_mode, ApprovalMode::Suggest);
let operate_min = base_policy_for_mode(AppMode::Operate, &minimal);
assert!(!operate_min.allow_shell);
assert!(!operate_min.trust_mode);
assert_eq!(operate_min.approval_mode, ApprovalMode::Suggest);
}
#[test]
@@ -4156,7 +4151,7 @@ fn plan_permission_cycle_moves_the_baseline_and_leaves_plan_read_only() {
assert!(
app.status_toasts
.iter()
.any(|toast| toast.text.contains("applies in Work and Operate")),
.any(|toast| toast.text.contains("applies in Work")),
"the receipt must say when the new posture starts applying"
);
@@ -4166,7 +4161,7 @@ fn plan_permission_cycle_moves_the_baseline_and_leaves_plan_read_only() {
"the posture is durable, not dropped because Plan was active: {persisted}"
);
app.set_mode(AppMode::Operate);
app.set_mode(AppMode::Agent);
assert_eq!(
app.approval_mode,
ApprovalMode::Bypass,
@@ -6872,19 +6867,15 @@ fn interactive_mode_cycle_persists_the_startup_default() {
app.mode = AppMode::Agent;
app.cycle_mode();
assert_eq!(
app.mode,
AppMode::Operate,
"Act -> Operate is the Tab cycle"
);
assert_eq!(app.mode, AppMode::Plan, "Work -> Plan is the Tab cycle");
let reloaded = Settings::load().expect("reload settings");
assert_eq!(
reloaded.default_mode, "operate",
reloaded.default_mode, "plan",
"the mode the user cycled into must be the startup default"
);
assert_eq!(
AppMode::from_setting(&reloaded.default_mode),
AppMode::Operate,
AppMode::Plan,
"a restart must restore the last user choice"
);
assert!(
@@ -6924,8 +6915,8 @@ fn session_restore_and_effective_turn_paths_do_not_rewrite_the_startup_default()
// `set_mode` is the session-only primitive used by session restore and
// preset application. It must move the live session without claiming the
// user picked a new startup default.
assert!(app.set_mode(AppMode::Operate));
assert_eq!(app.mode, AppMode::Operate);
assert!(app.set_mode(AppMode::Agent));
assert_eq!(app.mode, AppMode::Agent);
assert_eq!(
Settings::load().expect("reload").default_mode,
"plan",
@@ -6946,17 +6937,17 @@ fn reselecting_restored_live_mode_updates_the_startup_default() {
})
.expect("seed startup default");
let mut app = App::new(test_options(false), &Config::default());
assert!(app.set_mode(AppMode::Operate), "simulate session restore");
assert!(app.set_mode(AppMode::Plan), "simulate session restore");
assert_eq!(Settings::load().expect("reload").default_mode, "agent");
assert_eq!(
app.select_mode(AppMode::Operate),
app.select_mode(AppMode::Plan),
SettingSelection::PersistedSame,
"an accepted selection that did not move live mode is not a refusal"
);
assert_eq!(
Settings::load().expect("reload").default_mode,
"operate",
"plan",
"the explicit same-live selection must still become the startup default"
);
}
@@ -7074,10 +7065,10 @@ async fn rapid_mode_selections_persist_the_last_one_not_the_last_to_finish() {
// reordered transaction would land on a value that is also "plausible".
for mode in [
AppMode::Plan,
AppMode::Operate,
AppMode::Plan,
AppMode::Agent,
AppMode::Plan,
AppMode::Operate,
AppMode::Plan,
AppMode::Agent,
AppMode::Plan,
] {
@@ -7189,10 +7180,10 @@ async fn queued_mode_and_thinking_writes_finish_before_a_synchronous_selection()
let saved = Settings::load_persisted().expect("reload");
assert_eq!(saved.default_mode, "plan");
assert_eq!(saved.reasoning_effort.as_deref(), Some("high"));
assert_eq!(app.select_mode(AppMode::Operate), SettingSelection::Changed);
assert_eq!(app.select_mode(AppMode::Agent), SettingSelection::Changed);
app.startup_defaults.flush();
let saved = Settings::load_persisted().expect("reload");
assert_eq!(saved.default_mode, "operate");
assert_eq!(saved.default_mode, "agent");
assert_eq!(saved.reasoning_effort.as_deref(), Some("high"));
assert!(app.startup_defaults.drain_failures().is_empty());
}
@@ -7251,11 +7242,11 @@ async fn mode_and_permission_posture_writes_do_not_clobber_each_other() {
// skipped because it refuses permission changes by design (#3386), so every
// iteration below genuinely performs both writes.
for next_mode in [
AppMode::Operate,
AppMode::Plan,
AppMode::Agent,
AppMode::Operate,
AppMode::Plan,
AppMode::Agent,
AppMode::Operate,
AppMode::Plan,
] {
assert_eq!(
app.select_mode(next_mode),
@@ -7272,7 +7263,7 @@ async fn mode_and_permission_posture_writes_do_not_clobber_each_other() {
let expected_posture = App::approval_posture_setting(app.mode_prefs.agent_approval_mode);
let saved = Settings::load_persisted().expect("reload settings");
assert_eq!(
saved.default_mode, "operate",
saved.default_mode, "plan",
"the posture writer must not revert the mode the user cycled into"
);
assert_eq!(
@@ -7359,7 +7350,7 @@ async fn rapid_mixed_writes_settle_on_the_last_value_for_every_field() {
// Queued (background) writers.
assert_eq!(
app.select_mode(if index % 2 == 0 {
AppMode::Operate
AppMode::Plan
} else {
AppMode::Agent
}),
@@ -7405,11 +7396,11 @@ fn mode_cycling_in_an_unsealed_test_writes_nothing() {
let mut app = App::new(test_options(false), &Config::default());
app.mode = AppMode::Agent;
assert_eq!(
app.select_mode(AppMode::Operate),
app.select_mode(AppMode::Plan),
SettingSelection::Changed,
"the live session must still change"
);
assert_eq!(app.mode, AppMode::Operate);
assert_eq!(app.mode, AppMode::Plan);
assert_eq!(
app.startup_defaults.pending_len(),
0,
@@ -7552,7 +7543,7 @@ fn restart_only_default_mode_is_still_settable_while_a_turn_runs() {
);
assert_eq!(
Settings::load_persisted().expect("reload").default_mode,
"operate"
"agent"
);
assert_eq!(
app.mode,
@@ -7579,13 +7570,13 @@ async fn shutdown_flushes_the_last_selection_and_returns_late_failures() {
let mut app = App::new(test_options(false), &Config::default());
// Deliberately *not* flushed and never drained by an event-loop iteration:
// this is the "Tab, then immediately quit" shape.
assert_eq!(app.select_mode(AppMode::Operate), SettingSelection::Changed);
assert_eq!(app.select_mode(AppMode::Plan), SettingSelection::Changed);
let failures = app.startup_defaults.shutdown();
assert!(failures.is_empty(), "the write must land, not fail");
assert_eq!(
Settings::load_persisted().expect("reload").default_mode,
"operate",
"plan",
"the last immediate selection must be on disk after shutdown"
);
}
@@ -7610,7 +7601,7 @@ async fn a_late_startup_default_failure_is_returned_not_only_logged() {
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let mut app = App::new(test_options(false), &Config::default());
assert_eq!(app.select_mode(AppMode::Operate), SettingSelection::Changed);
assert_eq!(app.select_mode(AppMode::Plan), SettingSelection::Changed);
let failures = app.startup_defaults.shutdown();
let failure = failures
@@ -7653,12 +7644,12 @@ fn mode_selection_reports_refusal_change_and_persisted_same_distinctly() {
let mut app = App::new(test_options(false), &Config::default());
let _ = app.set_mode(AppMode::Agent);
assert_eq!(app.select_mode(AppMode::Operate), SettingSelection::Changed);
assert_eq!(app.select_mode(AppMode::Plan), SettingSelection::Changed);
assert!(SettingSelection::Changed.changed_live_state());
assert!(SettingSelection::Changed.accepted());
assert_eq!(
app.select_mode(AppMode::Operate),
app.select_mode(AppMode::Plan),
SettingSelection::PersistedSame
);
assert!(
@@ -7671,9 +7662,9 @@ fn mode_selection_reports_refusal_change_and_persisted_same_distinctly() {
);
app.is_loading = true;
assert_eq!(app.select_mode(AppMode::Plan), SettingSelection::Changed);
assert_eq!(app.select_mode(AppMode::Agent), SettingSelection::Changed);
assert!(!SettingSelection::Refused.accepted());
assert_eq!(app.mode, AppMode::Plan, "mode is not turn-locked");
assert_eq!(app.mode, AppMode::Agent, "mode is not turn-locked");
}
/// Every accepted same-live selection shows a saved receipt, and a mid-turn
@@ -7686,7 +7677,7 @@ fn slash_mode_distinguishes_a_saved_startup_default_from_a_switch() {
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let mut app = App::new(test_options(false), &Config::default());
let _ = app.set_mode(AppMode::Operate);
let _ = app.set_mode(AppMode::Plan);
Settings::transact(|settings| {
settings.default_mode = "agent".to_string();
Ok(())
@@ -7694,7 +7685,7 @@ fn slash_mode_distinguishes_a_saved_startup_default_from_a_switch() {
.expect("seed a startup default that disagrees with the live mode");
// Same live mode, different startup default: `/mode operate` is a real save.
let receipt = crate::commands::switch_mode(&mut app, AppMode::Operate);
let receipt = crate::commands::switch_mode(&mut app, AppMode::Plan);
assert!(
receipt.contains("saved as startup default"),
"the save must be reported, got {receipt:?}"
@@ -7702,11 +7693,11 @@ fn slash_mode_distinguishes_a_saved_startup_default_from_a_switch() {
app.startup_defaults.flush();
assert_eq!(
Settings::load_persisted().expect("reload").default_mode,
"operate"
"plan"
);
app.is_loading = true;
let switched = crate::commands::switch_mode(&mut app, AppMode::Plan);
let switched = crate::commands::switch_mode(&mut app, AppMode::Agent);
assert!(
switched.starts_with("Switched to"),
"mode is not turn-locked, got {switched:?}"
-2
View File
@@ -64,7 +64,6 @@ impl AppModeUi for AppMode {
match self {
AppMode::Agent => MessageId::AppModeAgent,
AppMode::Plan => MessageId::AppModePlan,
AppMode::Operate => MessageId::AppModeOperate,
},
)
}
@@ -76,7 +75,6 @@ impl AppModeUi for AppMode {
match self {
AppMode::Agent => MessageId::AppModeAgentHint,
AppMode::Plan => MessageId::AppModePlanHint,
AppMode::Operate => MessageId::AppModeOperateHint,
},
)
}
+3 -23
View File
@@ -593,13 +593,6 @@ impl HotbarActionSource for BuiltinHotbarActionSource {
"Do direct work in the current session.",
AppHotbarKind::Mode(AppMode::Agent),
));
registry.register(AppHotbarAction::new(
"mode.operate",
"operate",
"Operate mode",
"Send tasks while Fleet workers run in parallel.",
AppHotbarKind::Mode(AppMode::Operate),
));
registry.register(AppHotbarAction::new(
"reasoning.cycle",
"reason",
@@ -943,7 +936,6 @@ impl AppHotbarAction {
AppHotbarKind::SessionCompact => MessageId::HotbarActionSessionCompactName,
AppHotbarKind::Mode(AppMode::Plan) => MessageId::HotbarActionModePlanName,
AppHotbarKind::Mode(AppMode::Agent) => MessageId::HotbarActionModeAgentName,
AppHotbarKind::Mode(AppMode::Operate) => MessageId::HotbarActionModeOperateName,
AppHotbarKind::ReasoningCycle => MessageId::HotbarActionReasoningCycleName,
AppHotbarKind::SidebarToggle => MessageId::HotbarActionSidebarToggleName,
AppHotbarKind::FileTreeToggle => MessageId::HotbarActionFileTreeToggleName,
@@ -958,7 +950,6 @@ impl AppHotbarAction {
AppHotbarKind::SessionCompact => MessageId::HotbarActionSessionCompactDescription,
AppHotbarKind::Mode(AppMode::Plan) => MessageId::HotbarActionModePlanDescription,
AppHotbarKind::Mode(AppMode::Agent) => MessageId::HotbarActionModeAgentDescription,
AppHotbarKind::Mode(AppMode::Operate) => MessageId::HotbarActionModeOperateDescription,
AppHotbarKind::ReasoningCycle => MessageId::HotbarActionReasoningCycleDescription,
AppHotbarKind::SidebarToggle => MessageId::HotbarActionSidebarToggleDescription,
AppHotbarKind::FileTreeToggle => MessageId::HotbarActionFileTreeToggleDescription,
@@ -1944,7 +1935,7 @@ mod tests {
let metadata = reasoning.metadata(Locale::En);
assert_eq!(metadata.category, HotbarActionCategory::App);
assert_eq!(metadata.safety, HotbarSafetyClass::LocalState);
assert_eq!(metadata.recommendation, HotbarRecommendation::Eligible);
assert_eq!(metadata.recommendation, HotbarRecommendation::Default);
assert!(reasoning.disabled_reason(&app).is_none());
app.auto_model = true;
@@ -2060,7 +2051,7 @@ mod tests {
(3, Some("auto")),
(4, Some("plan")),
(5, Some("agent")),
(6, Some("operate")),
(6, Some("reason")),
(7, Some("palette")),
(8, Some("side")),
]
@@ -2087,7 +2078,6 @@ mod tests {
vec![
"filetree.toggle",
"mode.agent",
"mode.operate",
"mode.plan",
"palette.open",
"reasoning.cycle",
@@ -2512,7 +2502,6 @@ mod tests {
let registry = HotbarActionRegistry::with_builtins();
let plan = registry.get("mode.plan").expect("plan action");
let agent = registry.get("mode.agent").expect("agent action");
let operate = registry.get("mode.operate").expect("operate action");
let mut app = test_app();
assert!(agent.is_active(&app));
@@ -2527,16 +2516,7 @@ mod tests {
assert_eq!(app.mode, AppMode::Plan);
assert!(plan.is_active(&app));
assert!(!agent.is_active(&app));
assert_eq!(
operate
.dispatch(&mut app, &Config::default())
.expect("dispatch operate"),
HotbarDispatch::AppAction(AppAction::ModeChanged(AppMode::Operate))
);
assert_eq!(app.mode, AppMode::Operate);
assert!(operate.is_active(&app));
assert!(!agent.is_active(&app));
assert!(registry.get("mode.operate").is_none());
}
#[test]
+1 -1
View File
@@ -148,7 +148,7 @@ mod tests {
TurnOutcomeStatus::Completed,
prose
));
for mode in [AppMode::Agent, AppMode::Operate] {
for mode in [AppMode::Agent] {
assert!(!plan_ready(mode, TurnOutcomeStatus::Completed, prose));
}
for status in [TurnOutcomeStatus::Interrupted, TurnOutcomeStatus::Failed] {
+10 -10
View File
@@ -784,15 +784,15 @@ mod tests {
#[test]
fn mode_update_only_targets_default_mode() {
let update = StartupDefaults::mode(AppMode::Operate);
assert_eq!(update.mode, Some("operate"));
let update = StartupDefaults::mode(AppMode::Plan);
assert_eq!(update.mode, Some("plan"));
assert!(update.reasoning_effort.is_none());
assert_eq!(update.subjects(), vec![StartupDefaultSubject::Mode]);
}
#[test]
fn subjects_stay_typed_for_a_combined_mode_and_thinking_update() {
let update = StartupDefaults::mode(AppMode::Operate).with_reasoning_effort("high");
let update = StartupDefaults::mode(AppMode::Plan).with_reasoning_effort("high");
assert_eq!(
update.subjects(),
vec![StartupDefaultSubject::Mode, StartupDefaultSubject::Thinking,]
@@ -875,7 +875,7 @@ mod tests {
let worker = writer.clone();
let handle = std::thread::spawn(move || {
let _membership = crate::test_support::join_env_scope(ticket);
let result = worker.apply_blocking(StartupDefaults::mode(AppMode::Operate));
let result = worker.apply_blocking(StartupDefaults::mode(AppMode::Plan));
done_tx.send(result).ok();
});
@@ -894,7 +894,7 @@ mod tests {
Settings::load_persisted()
.expect("reload settings")
.default_mode,
"operate"
"plan"
);
assert!(tmp.path().join(".codewhale/settings.toml").exists());
}
@@ -968,13 +968,13 @@ mod tests {
// The sealed scope itself is unaffected: its own write still lands, and
// its guard has nothing foreign to wait for.
writer
.apply_blocking(StartupDefaults::mode(AppMode::Operate))
.apply_blocking(StartupDefaults::mode(AppMode::Plan))
.expect("the sealed scope's own write must land");
assert_eq!(
Settings::load_persisted()
.expect("reload settings")
.default_mode,
"operate"
"plan"
);
}
@@ -1047,7 +1047,7 @@ mod tests {
// Without its own opt-in, this scope's background path stays inert
// and writes nothing — not even into its own sealed HOME.
let writer = StartupDefaultsWriter::default();
writer.spawn(StartupDefaults::mode(AppMode::Operate));
writer.spawn(StartupDefaults::mode(AppMode::Plan));
assert_eq!(writer.pending_len(), 0);
assert!(!tmp.path().join(".codewhale/settings.toml").exists());
@@ -1055,11 +1055,11 @@ mod tests {
// generation.
let _writes = allow_writes_in_tests();
assert_eq!(authorized_test_write_generation(), Some(second_generation));
writer.spawn(StartupDefaults::mode(AppMode::Operate));
writer.spawn(StartupDefaults::mode(AppMode::Plan));
writer.flush();
assert_eq!(
Settings::load_persisted().expect("reload").default_mode,
"operate"
"plan"
);
assert!(tmp.path().join(".codewhale/settings.toml").exists());
}
+3 -125
View File
@@ -677,16 +677,9 @@ pub(crate) fn apply_goal_snapshot_to_app(app: &mut App, snapshot: &GoalSnapshot)
// to stop it. `/goal <objective>` sets the objective before this snapshot lands,
// so a user-declared goal does not repeat its own receipt.
if objective_changed && verdict == GoalStatus::Active {
// Operate set it from the prompt (or the model did while operating);
// the objective is the prompt the user just typed, so the receipt
// says what Operate will do with it instead of echoing it.
let content = if app.mode == AppMode::Operate {
app.tr(codewhale_localization::MessageId::GoalReceiptSetOperate)
.into_owned()
} else {
app.tr(codewhale_localization::MessageId::GoalReceiptSet)
.replace("{objective}", objective)
};
let content = app
.tr(codewhale_localization::MessageId::GoalReceiptSet)
.replace("{objective}", objective);
app.add_message(crate::tui::history::HistoryCell::System { content });
}
// A fresh reported-progress receipt reads like the model's own status
@@ -764,16 +757,10 @@ pub(crate) fn apply_goal_snapshot_to_app(app: &mut App, snapshot: &GoalSnapshot)
pub(crate) async fn apply_mode_update(
app: &mut App,
engine_handle: &EngineHandle,
config: &Config,
mode: AppMode,
) -> bool {
let outcome = app.select_mode(mode);
app.report_mode_selection(mode, outcome);
if mode == AppMode::Operate {
present_operate_board(app, config).await;
// First contact with the fleet, not first launch, owns its intro.
app.maybe_show_feature_intro();
}
if outcome.changed_live_state() {
sync_mode_update(app, engine_handle).await;
true
@@ -800,115 +787,6 @@ pub(crate) async fn apply_yolo_compat_update(
}
}
/// Entering Operate attaches to the recorded operation (a fresh one only
/// when none exists or the last was cancelled), shows the localized lead
/// plan, and keeps always-on mode durable by reinstalling the hourly lead
/// keepalive bound to this workspace. Burn rate is optional; default is
/// unbounded.
async fn present_operate_board(app: &mut App, config: &Config) {
let store = match codewhale_engine::operate::OperationStore::open(
codewhale_engine::operate::default_operate_dir(),
) {
Ok(store) => store,
Err(error) => {
app.add_message(crate::tui::history::HistoryCell::System {
content: format!("Operate store unavailable: {error}"),
});
return;
}
};
let Some(automations) = app
.runtime_services
.automations
.as_ref()
.map(std::sync::Arc::clone)
else {
app.add_message(crate::tui::history::HistoryCell::System {
content: "Operate keep-alive not installed: automation service unavailable".to_string(),
});
return;
};
let model = app.model_selection_for_persistence();
let identity = match config.resolve_persisted_provider_identity(
Some(app.api_provider.as_str()),
app.provider_id_for_persistence(),
) {
Ok(identity) => identity,
Err(error) => {
app.add_message(crate::tui::history::HistoryCell::System {
content: format!("Operate keep-alive not installed: {error}"),
});
return;
}
};
let (lead_model, credentials) = {
let manager = automations.lock().await;
match codewhale_engine::operate::keepalive_readiness(
&manager,
config,
Some((&identity, &model)),
) {
Ok(credentials) => credentials,
Err(error) => {
app.add_message(crate::tui::history::HistoryCell::System {
content: format!("Operate keep-alive not installed: {error}"),
});
return;
}
}
};
let operation = match codewhale_engine::operate::attach_or_start_operation(
&store,
&app.workspace,
None,
None,
credentials,
&lead_model,
) {
Ok(mut operation) => {
if credentials && !operation.direction.is_empty() && operation.lead_plan.is_none() {
operation.plan_from_direction();
if let Err(error) = store.save(&operation) {
app.add_message(crate::tui::history::HistoryCell::System {
content: format!("Operate plan not saved: {error}"),
});
}
}
operation
}
Err(error) => {
app.add_message(crate::tui::history::HistoryCell::System {
content: format!("Operate did not start: {error}"),
});
return;
}
};
// Always-on is durable only if the keepalive automation exists: entering
// Operate (re)installs it for this workspace, kicking an immediate
// lead-plan step when the attached operation still needs one.
let needs_lead_plan = !operation
.lead_plan
.as_ref()
.is_some_and(|plan| !plan.slices.is_empty());
{
let manager = automations.lock().await;
if let Err(error) = codewhale_engine::operate::upsert_keepalive(
&manager,
&app.workspace,
needs_lead_plan,
config,
Some((&identity, &model)),
) {
app.add_message(crate::tui::history::HistoryCell::System {
content: format!("Operate keep-alive not installed: {error}"),
});
}
}
app.add_message(crate::tui::history::HistoryCell::System {
content: codewhale_engine::operate::render_plan_board_locale(&operation, app.ui_locale),
});
}
pub(crate) async fn apply_model_and_compaction_update(
engine_handle: &EngineHandle,
compaction: codewhale_engine::compaction::CompactionConfig,
+4 -4
View File
@@ -7938,7 +7938,7 @@ pub(crate) async fn run_event_loop(
app.paste_from_clipboard();
}
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::ALT) => {
apply_mode_update(app, &engine_handle, config, AppMode::Agent).await;
apply_mode_update(app, &engine_handle, AppMode::Agent).await;
continue;
}
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::ALT) => {
@@ -7946,11 +7946,11 @@ pub(crate) async fn run_event_loop(
continue;
}
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::ALT) => {
apply_mode_update(app, &engine_handle, config, AppMode::Plan).await;
apply_mode_update(app, &engine_handle, AppMode::Plan).await;
continue;
}
KeyCode::Char('A') if key.modifiers.contains(KeyModifiers::ALT) => {
apply_mode_update(app, &engine_handle, config, AppMode::Agent).await;
apply_mode_update(app, &engine_handle, AppMode::Agent).await;
continue;
}
KeyCode::Char('Y') if key.modifiers.contains(KeyModifiers::ALT) => {
@@ -7958,7 +7958,7 @@ pub(crate) async fn run_event_loop(
continue;
}
KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::ALT) => {
apply_mode_update(app, &engine_handle, config, AppMode::Plan).await;
apply_mode_update(app, &engine_handle, AppMode::Plan).await;
continue;
}
// Vim composer: Normal-mode motion / operator keys.
+4 -6
View File
@@ -1080,12 +1080,10 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
|identity| config.max_admitted_subagents_for_provider(identity),
)
.max(max_subagents),
launch_concurrency: identity
.map_or_else(
|| config.launch_concurrency(),
|identity| config.launch_concurrency_for_provider(identity),
)
.max(app.mode.mode_delegation_launch_floor()),
launch_concurrency: identity.map_or_else(
|| config.launch_concurrency(),
|identity| config.launch_concurrency_for_provider(identity),
),
subagents_enabled: identity
.is_some_and(|identity| config.subagents_enabled_for_provider(identity)),
features: config.features(),
+3 -4
View File
@@ -1767,7 +1767,7 @@ pub(crate) async fn apply_plan_handoff_with_checkpoint(
}
PlanHandoffChoice::Revise(feedback) => QueuedMessage::new(feedback, None),
PlanHandoffChoice::Work(posture) => {
if !enter_work_for_plan(app, config, engine_handle, posture).await {
if !enter_work_for_plan(app, engine_handle, posture).await {
return Ok(());
}
let staged = seed_plan_handoff_todos(app, &mut plan).await;
@@ -1785,7 +1785,7 @@ pub(crate) async fn apply_plan_handoff_with_checkpoint(
Err(cleanup) => format!("{reason}; {cleanup}"),
};
app.pending_plan_handoff = Some(plan.clone());
apply_mode_update(app, engine_handle, config, AppMode::Plan).await;
apply_mode_update(app, engine_handle, AppMode::Plan).await;
reopen_plan_handoff(app, &plan, &reason);
return Ok(());
}
@@ -1813,7 +1813,6 @@ pub(crate) async fn apply_plan_handoff_with_checkpoint(
/// the session then stays where it was and nothing is sent.
pub(crate) async fn enter_work_for_plan(
app: &mut App,
config: &Config,
engine_handle: &EngineHandle,
posture: ApprovalMode,
) -> bool {
@@ -1826,7 +1825,7 @@ pub(crate) async fn enter_work_for_plan(
app.push_status_toast(reason, StatusToastLevel::Warning, Some(8_000));
return false;
}
apply_mode_update(app, engine_handle, config, AppMode::Agent).await;
apply_mode_update(app, engine_handle, AppMode::Agent).await;
if engine_handle.tx_op.is_closed() {
app.set_mode(AppMode::Plan);
return false;
+2 -206
View File
@@ -10962,15 +10962,7 @@ async fn mode_change_update_sends_restored_agent_policy() {
let _ = app.set_mode(AppMode::Plan);
let mut engine = codewhale_engine::core::engine::mock_engine_handle();
assert!(
apply_mode_update(
&mut app,
&engine.handle,
&codewhale_runtime::config::Config::default(),
AppMode::Agent
)
.await
);
assert!(apply_mode_update(&mut app, &engine.handle, AppMode::Agent).await);
match engine.rx_op.recv().await.expect("change mode op") {
codewhale_engine::core::ops::Op::ChangeMode {
@@ -10992,202 +10984,6 @@ async fn mode_change_update_sends_restored_agent_policy() {
}
}
fn operate_tui_route_config() -> Config {
let mut config: Config = toml::from_str(
r#"
provider = "startup-route"
[providers.startup-route]
kind = "openai-compatible"
base_url = "https://startup.example.test/v1"
model = "startup-model"
auth_mode = "none"
[providers.collision-route]
kind = "openai-compatible"
base_url = "https://custom-openai.example.test/v1"
model = "table-default"
auth_mode = "none"
"#,
)
.expect("operate TUI routes");
let custom = &mut config.providers.as_mut().expect("providers").custom;
let collision = custom.remove("collision-route").expect("custom fixture");
custom.insert("openai".to_string(), collision);
config
}
#[tokio::test]
async fn operate_entry_preserves_live_custom_identity_and_auto_over_startup_route() {
let _env = crate::test_support::lock_test_env();
for selected in ["session-model", "auto"] {
let root = TempDir::new().expect("root");
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path());
let _operate = crate::test_support::EnvVarGuard::set(
"CODEWHALE_OPERATE_DIR",
root.path().join("operate"),
);
let mut config = operate_tui_route_config();
config.fleet_operator_route_applied = true;
let manager = codewhale_engine::automation_manager::AutomationManager::open_for_test(
root.path().join("automations"),
)
.expect("manager");
let automations = Arc::new(tokio::sync::Mutex::new(manager));
let mut app = create_test_app();
app.workspace = root.path().to_path_buf();
app.runtime_services.automations = Some(automations.clone());
app.set_provider_identity_record(
config
.resolve_persisted_provider_identity(Some("custom"), Some("openai"))
.expect("exact custom"),
);
app.set_model_selection(selected.to_string());
app.last_effective_model = Some("previous-turn-model".to_string());
let engine = mock_engine_handle();
apply_mode_update(&mut app, &engine.handle, &config, AppMode::Operate).await;
let record = automations
.lock()
.await
.get_automation(codewhale_engine::operate::OPERATE_KEEPALIVE_ID)
.expect("keepalive installed");
assert_eq!(record.model.as_deref(), Some(selected));
assert_eq!(record.model_provider.as_deref(), Some("custom"));
assert_eq!(record.model_provider_id.as_deref(), Some("openai"));
assert_eq!(record.auto_approve, Some(false));
let operation =
codewhale_engine::operate::OperationStore::open(root.path().join("operate"))
.expect("store")
.load()
.expect("load")
.expect("operation");
assert_eq!(operation.lead_operator.model, selected);
assert!(
operation
.roster
.iter()
.filter(|member| member.id == "lead")
.all(|member| member.model == selected)
);
assert!(operation.credentials_present);
assert!(
automations
.lock()
.await
.list_runs(&record.id, None)
.expect("runs")
.is_empty(),
"entry alone is not task execution"
);
}
}
#[tokio::test]
async fn operate_rejected_attach_does_not_reactivate_saved_keepalive() {
let _env = crate::test_support::lock_test_env();
let root = TempDir::new().expect("root");
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path());
let operate_dir = root.path().join("operate");
let _operate = crate::test_support::EnvVarGuard::set("CODEWHALE_OPERATE_DIR", &operate_dir);
let config = operate_tui_route_config();
let manager = codewhale_engine::automation_manager::AutomationManager::open_for_test(
root.path().join("automations"),
)
.expect("manager");
codewhale_engine::operate::upsert_keepalive(&manager, root.path(), true, &config, None)
.expect("upsert");
codewhale_engine::operate::pause_keepalive(&manager).expect("pause");
let before = serde_json::to_value(
manager
.get_automation(codewhale_engine::operate::OPERATE_KEEPALIVE_ID)
.expect("record"),
)
.expect("json");
std::fs::create_dir_all(&operate_dir).expect("directory");
std::fs::write(operate_dir.join("current.json"), b"{broken")
.expect("corrupt operation fixture");
let automations = Arc::new(tokio::sync::Mutex::new(manager));
let mut app = create_test_app();
app.workspace = root.path().to_path_buf();
app.runtime_services.automations = Some(automations.clone());
app.set_provider_identity_record(config.active_provider_identity().expect("identity"));
app.set_model_selection("startup-model".into());
let engine = mock_engine_handle();
apply_mode_update(&mut app, &engine.handle, &config, AppMode::Operate).await;
let record = automations
.lock()
.await
.get_automation(codewhale_engine::operate::OPERATE_KEEPALIVE_ID)
.expect("saved keepalive");
assert_eq!(serde_json::to_value(&record).expect("json"), before);
assert_eq!(
record.status,
codewhale_engine::automation_manager::AutomationStatus::Paused
);
assert!(record.next_run_at.is_none());
assert_eq!(
std::fs::read(operate_dir.join("current.json")).expect("operation"),
b"{broken"
);
assert!(app.history.iter().any(|cell| matches!(cell, HistoryCell::System { content } if content.contains("Operate did not start"))));
}
#[tokio::test]
async fn operate_mode_entry_attaches_to_recorded_operation() {
let _lock = crate::test_support::lock_test_env();
let dir = tempfile::TempDir::new().expect("temp");
let _operate_dir = crate::test_support::EnvVarGuard::set("CODEWHALE_OPERATE_DIR", dir.path());
// Pre-record a planned operation: entering Operate must attach to it
// (same id) instead of minting a fresh record that resets spend and plan.
let store = codewhale_engine::operate::OperationStore::open(dir.path()).expect("store");
let recorded = codewhale_engine::operate::start_operation(
&store,
dir.path(),
Some("Steady ops".into()),
None,
true,
"selected-model",
)
.expect("start");
let mut planned = recorded.clone();
planned.plan_from_direction();
store.save(&planned).expect("save planned");
let mut app = create_test_app();
app.runtime_services.automations = Some(Arc::new(tokio::sync::Mutex::new(
codewhale_engine::automation_manager::AutomationManager::open_for_test(
dir.path().join("automations"),
)
.expect("automations"),
)));
let engine = codewhale_engine::core::engine::mock_engine_handle();
assert!(
apply_mode_update(
&mut app,
&engine.handle,
&Config::default(),
AppMode::Operate
)
.await
);
let board = app
.history
.iter()
.rev()
.find_map(|cell| match cell {
crate::tui::history::HistoryCell::System { content } => Some(content.clone()),
_ => None,
})
.expect("operate board message");
assert!(
board.contains(&planned.id),
"re-entry must show the recorded operation, saw: {board}"
);
assert!(
board.contains("slice-1"),
"board shows the kept plan: {board}"
);
}
#[test]
fn saved_default_provider_syncs_back_to_runtime_config() {
let _home = SettingsHomeGuard::new();
@@ -22040,7 +21836,7 @@ async fn empty_enter_promotes_the_oldest_queued_message() {
#[tokio::test]
async fn operate_streaming_enter_queues_another_parallel_task() {
let mut app = create_test_app();
app.mode = AppMode::Operate;
app.mode = AppMode::Plan;
app.is_loading = true;
app.streaming_message_index = Some(0);
let config = Config::default();
+2 -18
View File
@@ -23,15 +23,7 @@ async fn work_choice_leaves_plan_with_the_chosen_permission() {
let mut app = app_in_plan();
let mut engine = mock_engine_handle();
assert!(
enter_work_for_plan(
&mut app,
&Config::default(),
&engine.handle,
ApprovalMode::Suggest
)
.await
);
assert!(enter_work_for_plan(&mut app, &engine.handle, ApprovalMode::Suggest).await);
assert_eq!(app.mode, AppMode::Agent);
assert_eq!(app.approval_mode, ApprovalMode::Suggest);
@@ -56,15 +48,7 @@ async fn a_locked_permission_keeps_the_session_in_plan() {
// Ask is in force and locked; the person asked for Auto-Review. Starting
// the work under a permission they did not pick is the wrong outcome.
assert!(
!enter_work_for_plan(
&mut app,
&Config::default(),
&engine.handle,
ApprovalMode::Auto
)
.await
);
assert!(!enter_work_for_plan(&mut app, &engine.handle, ApprovalMode::Auto).await);
assert_eq!(app.mode, AppMode::Plan);
assert_eq!(app.agent_approval_baseline(), ApprovalMode::Suggest);
-2
View File
@@ -695,7 +695,6 @@ impl ShellPhase {
pub(crate) fn header_mode_ink(mode: AppMode) -> ChromeInk {
match mode {
AppMode::Plan => ChromeInk::PolicyPlan,
AppMode::Operate => ChromeInk::PolicyOperate,
AppMode::Agent => ChromeInk::PolicyAct,
}
}
@@ -913,7 +912,6 @@ pub(crate) fn mode_label(locale: Locale, mode: AppMode) -> Cow<'static, str> {
match mode {
AppMode::Agent => tr(locale, MessageId::ChipModeAct),
AppMode::Plan => tr(locale, MessageId::ChipModePlan),
AppMode::Operate => tr(locale, MessageId::ChipModeOperate),
}
}
+16 -25
View File
@@ -4114,7 +4114,6 @@ fn canonical_config_choice(key: &str, value: &str) -> String {
},
"default_mode" => match normalized.as_str() {
"plan" => "plan".to_string(),
"operate" | "operation" | "ops" => "operate".to_string(),
_ => "agent".to_string(),
},
"locale" => normalize_configured_locale(value)
@@ -8341,6 +8340,7 @@ consent_version = 1
&vec![
"use-tui-default".to_string(),
"ask".to_string(),
"accept-edits".to_string(),
"auto-review".to_string(),
"full-access".to_string(),
]
@@ -8350,7 +8350,7 @@ consent_version = 1
explicit.render(area, &mut buf);
let dump = buffer_text(&buf, area);
assert!(
dump.contains("4. Full Access"),
dump.contains("5. Full Access"),
"root permission chooser must expose the product posture:\n{dump}"
);
assert!(
@@ -10197,13 +10197,7 @@ context_window = 131072
let edit = view.editing.as_ref().expect("choice editor");
assert_eq!(
edit.choices.as_deref(),
Some(
&[
"agent".to_string(),
"plan".to_string(),
"operate".to_string(),
][..]
)
Some(&["agent".to_string(), "plan".to_string()][..])
);
assert!(
edit.choices
@@ -10213,7 +10207,7 @@ context_window = 131072
.all(|choice| choice != "yolo")
);
let _ = view.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE));
let _ = view.handle_key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
let apply = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match apply {
ViewAction::Emit(ViewEvent::ConfigUpdated {
@@ -10222,21 +10216,18 @@ context_window = 131072
persist,
}) => {
assert_eq!(key, "default_mode");
assert_eq!(value, "operate");
assert_eq!(value, "plan");
assert!(persist);
}
other => panic!("expected startup choice update, got {other:?}"),
}
assert_eq!(canonical_config_choice("default_mode", "Operate"), "agent");
assert_eq!(
canonical_config_choice("default_mode", "Operate"),
"operate"
config_choice_label(Locale::En, "default_mode", "plan"),
"Plan (read only)"
);
assert_eq!(
config_choice_label(Locale::En, "default_mode", "operate"),
"Operate"
);
assert!(!config_choice_detail(Locale::En, "default_mode", "operate").is_empty());
assert!(!config_choice_detail(Locale::En, "default_mode", "plan").is_empty());
}
#[test]
@@ -11753,18 +11744,18 @@ context_window = 131072
"{w}x{h}:\n{dump}"
);
let choices = view.last_choice_hitboxes.borrow().clone();
let (operate_rect, operate_idx) = choices
let (plan_rect, plan_idx) = choices
.iter()
.copied()
.find(|(_, idx)| *idx == 2)
.unwrap_or_else(|| panic!("{w}x{h} third choice painted:\n{dump}"));
.find(|(_, idx)| *idx == 1)
.unwrap_or_else(|| panic!("{w}x{h} second choice painted:\n{dump}"));
// Clicking a choice selects it; clicking beside the controls does
// nothing; clicking Apply emits exactly that choice.
assert!(matches!(
click(&mut view, operate_rect.x + 2, operate_rect.y),
click(&mut view, plan_rect.x + 2, plan_rect.y),
ViewAction::None
));
assert_eq!(view.editing.as_ref().unwrap().selected_choice, operate_idx);
assert_eq!(view.editing.as_ref().unwrap().selected_choice, plan_idx);
let beside = apply.right().saturating_add(1);
if beside < cancel.x {
assert!(matches!(
@@ -11780,7 +11771,7 @@ context_window = 131072
persist,
}) => {
assert_eq!(key, "default_mode");
assert_eq!(value, "operate");
assert_eq!(value, "plan");
assert!(persist);
}
other => panic!("{w}x{h} Apply click should emit: {other:?}"),
@@ -11947,7 +11938,6 @@ context_window = 131072
MessageId::ConfigEditChooseLabel,
MessageId::ConfigChoiceModeAct,
MessageId::ConfigChoiceModePlan,
MessageId::ConfigChoiceModeOperate,
MessageId::ConfigChoiceDetailModeAgent,
MessageId::ConfigEditorApply,
MessageId::ConfigEditorCancel,
@@ -12379,6 +12369,7 @@ context_window = 131072
.expect("session permission choices"),
&vec![
"ask".to_string(),
"accept-edits".to_string(),
"auto-review".to_string(),
"full-access".to_string(),
]
+11 -8
View File
@@ -22,9 +22,7 @@ use codewhale_localization::Locale;
use codewhale_palette as palette;
use codewhale_tui_kit::menu_style;
// Operate is visible because the engine now enforces a coordinator/worker
// boundary while allowing ordinary conversation and asynchronous dispatch.
const VISIBLE_MODES: [AppMode; 3] = [AppMode::Agent, AppMode::Plan, AppMode::Operate];
const VISIBLE_MODES: [AppMode; 2] = [AppMode::Agent, AppMode::Plan];
pub struct ModePickerView {
cursor: usize,
@@ -313,24 +311,29 @@ mod tests {
}
#[test]
fn operate_is_advertised_as_a_visible_mode() {
fn operate_is_not_advertised_as_a_visible_mode() {
let (buf, area) = render_at(80, 24);
let text = rows(&buf, area).join("\n");
assert!(text.contains("Operate"), "{text}");
assert!(!text.contains("Operate"), "{text}");
}
#[test]
fn number_keys_select_modes() {
// Visible roster: 1 Act, 2 Plan, 3 Operate.
// Visible roster: 1 Act, 2 Plan.
let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
let action = view.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE));
let action = view.handle_key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ModeSelected {
mode: AppMode::Operate
mode: AppMode::Plan
})
));
// Retired Operate number (3) selects nothing.
let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
let action = view.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE));
assert!(matches!(action, ViewAction::None));
// Legacy YOLO shorthand (4) is not offered by the picker.
let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
let action = view.handle_key(KeyEvent::new(KeyCode::Char('4'), KeyModifiers::NONE));
+6 -6
View File
@@ -204,7 +204,7 @@ Codewhale works best when you let investigation and implementation happen in
separate steps for unfamiliar code. For small, well-understood changes, a
single implementation request is fine.
Next: [MODES.md](MODES.md) explains when to use Plan, Work, and Operate.
Next: [MODES.md](MODES.md) explains when to use Plan and Work.
## 4. Understanding the Interface
@@ -234,7 +234,7 @@ screen while you type. Keys for the sheet are in [KEYBINDINGS.md](KEYBINDINGS.md
The bottom chrome is configurable. Run `/statusline` to choose what is
visible, or set `[tui].status_items` in `config.toml`. Each key owns exactly
one thing on screen: `mode` is the posture bar's plan/act/operate chip, and
one thing on screen: `mode` is the posture bar's plan/work chip, and
`model`, `context_percent`, `cost`, `balance` (prepaid providers only:
DeepSeek, DeepSeekCN, OpenRouter, SiliconFlow), `cache`, `tokens` and
`ttft`, `output_rate`, `workspace` and `git_branch` are segments of the metrics line below it. Omit
@@ -308,13 +308,12 @@ Next: [KEYBINDINGS.md](KEYBINDINGS.md) is the complete shortcut reference.
## 5. Modes
Codewhale has three visible TUI modes:
Codewhale has two visible TUI modes:
| Mode | Use it for | Default posture |
| --- | --- | --- |
| Plan | Exploration, design, and review before changes | Read-only investigation |
| Work | Normal multi-step coding work | Tool use with approval gates |
| Operate | Direct work plus parallel or background coordination | Tools follow the active posture; delegate when useful |
Switch modes from the TUI with the mode picker:
@@ -327,9 +326,10 @@ Or switch directly:
```text
/mode plan
/mode work
/mode operate
```
`operate` still parses, as Work, so an older setting or script keeps working.
Plan mode is the safest place to start in an unfamiliar repository. It is for
inspection and decision-making, not file edits.
When a Plan turn successfully finishes a nonempty response, Codewhale asks
@@ -345,7 +345,7 @@ the composer is empty and no other view is open; `/mode work` always works.
Work mode is the default for most contribution work. It lets Codewhale read,
run checks, and edit files while keeping risky actions behind approval gates.
Operate keeps that direct tool surface and its approval, sandbox, shell,
Work keeps that direct tool surface and its approval, sandbox, shell,
ask-rule, and repository protections. Small or tightly coupled work stays
direct. Multi-step delegation uses a compact Workflow plan with dependencies,
bounded scopes, and completion evidence passed between steps. Fleet configures
+24 -23
View File
@@ -4,7 +4,7 @@
Codewhale has three related concepts:
- **TUI mode**: what kind of visible interaction you're in (Plan/Work/Operate).
- **TUI mode**: what kind of visible interaction you're in (Plan/Work).
- **Permission posture**: how aggressively the UI asks before executing tools.
- **Workflow**: named steps that coordinate sub-agents, with dependencies and
results, available in any TUI mode.
@@ -25,42 +25,43 @@ the saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.
## TUI Modes
Press `Tab` to complete composer menus or cycle through the visible modes
when the composer is empty: **Plan → Work → Operate → Plan**. `Tab` never sends
when the composer is empty: **Plan → Work → Plan**. `Tab` never sends
or queues composer text; use `Enter` to send or queue it.
Press `Shift+Tab` to cycle permission posture (Ask → Accept Edits →
Auto-Review → Full Access).
Every change of effective permission writes a `Permissions changed to …` line
in the transcript.
Press `Ctrl+T` to cycle reasoning effort.
Run `/mode` to open the mode picker, or switch directly with `/mode work`,
`/mode plan`, or `/mode operate`.
Run `/mode` to open the mode picker, or switch directly with `/mode work` or
`/mode plan`. `operate` and its spellings still parse, as Work, so an older
setting or script never errors.
- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Accept Edits), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.
- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `bash`, `agent`, `workflow`, `todo_write`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.
- **Operate**: Work at full strength, the "ultra" mode. Same tools and authority as Work; the difference is how they are used. Each substantive request becomes a goal (`create_goal`), the plan stays visible (`todo_write`), multi-part work runs through `workflow` and parallel `agent` workers, non-trivial changes get an independent reviewer before they are called done, long commands keep running in the background, and recurring or watch-type work is offered as an `automation` (created after approval). It keeps going until the goal is verified, blocked on you, or paused.
- **Operate is retired.** It was Work with an orchestration contract, and that contract now lives in every session's constitution: make a goal of a durable request, keep the plan visible, fan independent work out to parallel sub-agents, and have an independent reviewer try to refute a non-trivial change. A saved `default_mode = "operate"` loads as Work and says so once. Multi-agent work stays on `agent`, `workflow` and `/fleet` in every mode.
`Act` and `/mode act` remain compatibility aliases for Work. Saved settings
still normalize to the internal value `agent`.
### Tool availability by mode
| Tool family | Plan | Work | Operate |
|:---|:---:|:---:|:---:|
| `read` and policy-allowed deferred research tools | yes | yes | yes |
| `write` and `edit` | visible names; execution denied | approval- and policy-gated | same as Work |
| `bash` | visible name; execution denied | approval- and policy-gated | same as Work; delegation is preferred when parallelism or isolation helps |
| `agent` | yes, subject to child-depth authority | yes, subject to child-depth authority | yes, subject to child-depth authority |
| Deferred native, MCP, and plugin tools | discoverable through `tool_search` when policy permits | same | same |
| Paid or external-service tools | follows permission posture | follows permission posture | follows permission posture |
| Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode | same trusted-path/trust policy as Work; fleet profiles never widen it |
| Tool family | Plan | Work |
|:---|:---:|:---:|
| `read` and policy-allowed deferred research tools | yes | yes |
| `write` and `edit` | visible names; execution denied | approval- and policy-gated |
| `bash` | visible name; execution denied | approval- and policy-gated; delegation is preferred when parallelism or isolation helps |
| `agent` | yes, subject to child-depth authority | yes, subject to child-depth authority |
| Deferred native, MCP, and plugin tools | discoverable through `tool_search` when policy permits | same |
| Paid or external-service tools | follows permission posture | follows permission posture |
| Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode; fleet profiles never widen it |
Operate changes scheduling emphasis, not authority. It neither adds a
mode-specific tool denial nor bypasses the active approval, sandbox, shell,
ask-rule, repository-law, or managed-policy boundary. Plan remains the
mode-specific execution boundary for shell and write-capable tools; that
authority difference does not require a different primitive vocabulary.
Mode changes scheduling emphasis, not authority. No mode adds a specific tool
denial or bypasses the active approval, sandbox, shell, ask-rule,
repository-law, or managed-policy boundary. Plan remains the mode-specific
execution boundary for shell and write-capable tools; that authority difference
does not require a different primitive vocabulary.
### Operate loop (one screen)
### Orchestration loop (one screen)
```text
User message
@@ -118,7 +119,7 @@ only controls model and thinking selection.
Workflow builds on the same separation: a goal can ask the agent to keep
working, while Workflow supplies the repeatable workflow/progress surface for
large fanout. In the UI, a Workflow run should be shown as an overlay on the
main screen, not as another mode beside Plan, Work, and Operate.
main screen, not as another mode beside Plan and Work.
App-server clients can persist a thread-scoped goal with `thread/goal/set`, read
it with `thread/goal/get`, and clear it with `thread/goal/clear`. That persisted
@@ -130,8 +131,8 @@ thread resume semantics.
Choosing a mode interactively also sets the mode a fresh session starts in.
Tab/Shift+Tab cycling, the `Alt+A` / `Alt+P` / `Alt+Y` shortcuts, the hotbar's
Plan/Work/Operate actions, and `/mode` all write `default_mode` to
`~/.codewhale/settings.toml`, so switching to Operate survives a restart. The
Plan/Work actions, and `/mode` all write `default_mode` to
`~/.codewhale/settings.toml`, so switching to Plan survives a restart. The
write happens off the event loop; if it fails, the TUI says so in a warning
toast rather than reverting silently on the next launch.
+1 -1
View File
@@ -319,7 +319,7 @@ owner = "crates/engine/src/tools/review_host.rs"
[[feature]]
id = "modes"
name = "Modes"
summary = "Plan, Work and Operate modes set what the agent may do in a session; a finished plan offers to start the work."
summary = "Plan and Work modes set what the agent may do in a session; a finished plan offers to start the work."
status = "stable"
surfaces = { tui = "stable", api = "stable" }
docs = "docs/MODES.md"