mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
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:
1 parent
c0c7998188
commit
c50612d6ab
53 files changed
+315
-809
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}`"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user