mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
feat(tui): change mode and permissions mid-turn; record steering in Unreleased (CW-7552)
Mode and permission posture can now change while a turn is running. Tab, Shift+Tab, Alt shortcuts, /mode, the hotbar, /auto and /config mode|approval* no longer hit the #2982 turn lock; model, thinking and provider stay locked because they change the route the in-flight request is on. Decision: a change applies at the engine's next step in the same turn, not at the next turn and not by restarting the streaming response. The engine already drains the live authority snapshot before each tool batch, at every approval gate and at the per-step preparation boundary; a call nobody approved, or one whose approval is narrowed, is returned to the model to retry. New: the preparation boundary now records the mode notice (and Operate contract) via Engine::record_mode_context, so the next model request is told the new mode and the notice is a session message (model-visible means logged). While a turn runs, the TUI shows "<Mode> · <permissions> — applies at the agent's next step in this turn"; the engine's existing "Permissions: … · <mode>" status confirms the apply. Reference comparison (CW/refs): Codex steers at the next safe point and applies /model and /permissions to the next turn; Grok Build queues by default, injects steers at tool/model safe points and allows Plan/permission toggles mid-turn with turn-start reminders. Neither preempts the in-flight request. Steering itself: the preemption source from4cf2d2cfffwas proven, not changed. Changelog entry added for it (it had none). Touched paths: CHANGELOG.md, crates/tui/CHANGELOG.md (sync-changelog.sh output) crates/tui/src/core/engine.rs (record_mode_context; turn start uses it) crates/tui/src/core/engine/turn_loop/preparation.rs crates/tui/src/tui/app.rs, crates/tui/src/tui/app/tests.rs crates/tui/src/commands/config_main_host.rs crates/tui/src/tui/ui/provider_routes.rs (sync_mode_update takes &mut App) docs/MODES.md Not included: uncommitted extension-host first-sync hunks in engine.rs, extension_host/mod.rs and plugins/install/dsh.rs belong to another writer. Evidence (candidate =3400ebb5c9+ this diff + then-dirty provider files, debug codewhale sha256 9f6d1934…052e): - Live TUI (private tmux, sandboxed, loopback provider holding a reasoning stream): Tab×2 mid-turn moved Work→Plan with the pending notice, no lock message; double-Enter steer reached the provider 143 ms later before the held response was released; next request carried the steer once and the Plan notice once; obsolete text never shown. PASS_SCOPED. Installed3400ebb5c9baseline: steer 130 ms OK, mode refused "Mode is locked while a turn is running". CW/artifacts/cw7552-steer-20261027/tui-runs/{baseline-3400-b,candidate-1} - Runtime steering-probe.py on candidate: headers, reasoning, text, tool_json all PASS_SCOPED (also PASS on installed3400ebb5c9, 28-33 ms). - Tests (hermetic home, crate dir): tui::app::tests 238 passed 0 failed; commands::config_main_host 109/0; runtime_handoff 22/0; core::queued_approval_tests 1/0; tui::ui::tests::plan_handoff 13/0; core::engine::tests 524 passed 9 failed: 8 MCP extension-host fixtures ("built-in extension host is down"), failing identically before the test edits and outside touched code, plus recursive_rlm_round_waits_on_the_ code_execution_gate, flaky under load (5/5 alone). - Updated obsolete lock tests in app/tests.rs and config_main_host.rs; removed approval_aliases_are_inert_while_a_turn_is_running. - rustfmt --check clean on touched files. No CI run (release step only). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9ff7099b90
commit
0cf390f01e
9 files changed
+115
-122
No files matched your search
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Change mode and permissions while the agent works:** Tab, Shift+Tab, `/mode`, the hotbar and `/config mode` no longer refuse while a turn is running. A response that is already streaming is not restarted; the change applies at the agent's next step in the same turn (before its next tool batch, at every approval gate, and before its next model request, which carries the new mode notice). The status line says so until it applies. A call still waiting for approval goes back to the model under the new posture, and a call you already approved runs only if the new posture is equal or broader. Model, thinking level and provider stay locked until the turn ends. (CW-7552)
|
||||
- **Steering interrupts thinking:** a steer (Ctrl+Enter, or Enter twice after queueing) now closes the in-flight model request at once, whether the model is waiting on headers, reasoning, writing text or emitting a tool call, and continues the same turn with your message. Text already shown is kept as interrupted output; unfinished reasoning and partial tool calls are discarded, never run. (CW-7552)
|
||||
- **Switching provider lands on its current model:** `/provider stepfun` now selects Step 5 Preview instead of the older Step 3.7 Flash; a model you saved for a provider is still kept. `/model` now lists the current provider's own models (bundled and live, newest first, up to 20) instead of only the selected one. Search also matches ids typed without separators (`step5`). While you search, Shift+P and Shift+F type their letters rather than pinning or adding to Fleet, so `Step-5-Preview` can be typed in full; Ctrl+P and Ctrl+F pin or add the highlighted row at any time.
|
||||
- **Remote control works again:** `codewhale rc` (`/rc`) can attach to the hosted control plane for web Chat again. The control plane had only accepted the retired 0.9.13 runtime identity, so every 0.10.x Engine was refused with `runner_runtime_identity_unavailable`; it now accepts the exact released runtime identity (commit, image and digest) and fails closed when they are not configured.
|
||||
- **Modern MCP client:** MCP servers now connect through the official MCP SDK (2.4) behind Codewhale's Rust broker by default (`mcp_backend = "host"`; `"rust"` stays available for one release). Servers that speak the 2026-07-28 revision are discovered with `server/discover`; other stdio and HTTP servers fall back to the legacy `initialize` handshake, and configured SSE servers keep theirs. Rust keeps process, network, credential, catalog, approval and cancellation authority. (CW-6713)
|
||||
|
||||
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Change mode and permissions while the agent works:** Tab, Shift+Tab, `/mode`, the hotbar and `/config mode` no longer refuse while a turn is running. A response that is already streaming is not restarted; the change applies at the agent's next step in the same turn (before its next tool batch, at every approval gate, and before its next model request, which carries the new mode notice). The status line says so until it applies. A call still waiting for approval goes back to the model under the new posture, and a call you already approved runs only if the new posture is equal or broader. Model, thinking level and provider stay locked until the turn ends. (CW-7552)
|
||||
- **Steering interrupts thinking:** a steer (Ctrl+Enter, or Enter twice after queueing) now closes the in-flight model request at once, whether the model is waiting on headers, reasoning, writing text or emitting a tool call, and continues the same turn with your message. Text already shown is kept as interrupted output; unfinished reasoning and partial tool calls are discarded, never run. (CW-7552)
|
||||
- **Switching provider lands on its current model:** `/provider stepfun` now selects Step 5 Preview instead of the older Step 3.7 Flash; a model you saved for a provider is still kept. `/model` now lists the current provider's own models (bundled and live, newest first, up to 20) instead of only the selected one. Search also matches ids typed without separators (`step5`). While you search, Shift+P and Shift+F type their letters rather than pinning or adding to Fleet, so `Step-5-Preview` can be typed in full; Ctrl+P and Ctrl+F pin or add the highlighted row at any time.
|
||||
- **Remote control works again:** `codewhale rc` (`/rc`) can attach to the hosted control plane for web Chat again. The control plane had only accepted the retired 0.9.13 runtime identity, so every 0.10.x Engine was refused with `runner_runtime_identity_unavailable`; it now accepts the exact released runtime identity (commit, image and digest) and fails closed when they are not configured.
|
||||
- **Modern MCP client:** MCP servers now connect through the official MCP SDK (2.4) behind Codewhale's Rust broker by default (`mcp_backend = "host"`; `"rust"` stays available for one release). Servers that speak the 2026-07-28 revision are discovered with `server/discover`; other stdio and HTTP servers fall back to the legacy `initialize` handshake, and configured SSE servers keep theirs. Rust keeps process, network, credential, catalog, approval and cancellation authority. (CW-6713)
|
||||
|
||||
@@ -1656,18 +1656,16 @@ fn subagents_runtime_action(
|
||||
///
|
||||
/// `default_mode` is deliberately absent: it is a restart default that
|
||||
/// `set_config_value` explicitly does *not* apply to the live session, so
|
||||
/// refusing it would lock a key that cannot affect the turn.
|
||||
/// refusing it would lock a key that cannot affect the turn. `mode` and the
|
||||
/// approval keys are absent too: the engine applies a posture change at its
|
||||
/// next step boundary in the running turn.
|
||||
fn live_route_setting_subject(key: &str) -> Option<MessageId> {
|
||||
match key {
|
||||
"mode" => Some(MessageId::SettingSubjectMode),
|
||||
// `default_model` is not merely a startup default: for the DeepSeek
|
||||
// routes `set_config_value` installs it as the live model.
|
||||
"model" | "default_model" => Some(MessageId::SettingSubjectModel),
|
||||
"reasoning_effort" | "effort" => Some(MessageId::SettingSubjectThinking),
|
||||
"provider" => Some(MessageId::SettingSubjectProvider),
|
||||
"approval_mode" | "approval_policy" | "approval" => {
|
||||
Some(MessageId::SettingSubjectPermissions)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -3433,27 +3431,32 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The refusal list is the contract for #2982 on the slash surfaces. Keep
|
||||
/// restart-only `default_mode` out of it: `set_config_value` deliberately
|
||||
/// does not apply that key to the live session.
|
||||
/// restart-only `default_mode` out of it, and mode and permissions, which
|
||||
/// the engine applies at its next step boundary mid-turn.
|
||||
#[test]
|
||||
fn live_route_key_list_covers_every_route_mutating_alias() {
|
||||
for key in [
|
||||
"mode",
|
||||
"model",
|
||||
"default_model",
|
||||
"reasoning_effort",
|
||||
"effort",
|
||||
"provider",
|
||||
"approval_mode",
|
||||
"approval_policy",
|
||||
"approval",
|
||||
] {
|
||||
assert!(
|
||||
live_route_setting_subject(key).is_some(),
|
||||
"{key} mutates the active route and must be locked mid-turn"
|
||||
);
|
||||
}
|
||||
for key in ["default_mode", "theme", "calm_mode", "rail_panel"] {
|
||||
for key in [
|
||||
"default_mode",
|
||||
"mode",
|
||||
"approval_mode",
|
||||
"approval_policy",
|
||||
"approval",
|
||||
"theme",
|
||||
"calm_mode",
|
||||
"rail_panel",
|
||||
] {
|
||||
assert!(
|
||||
live_route_setting_subject(key).is_none(),
|
||||
"{key} does not mutate the active route and must stay settable"
|
||||
@@ -3461,27 +3464,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_aliases_are_inert_while_a_turn_is_running() {
|
||||
let mut app = create_test_app();
|
||||
app.approval_mode = ApprovalMode::Suggest;
|
||||
app.is_loading = true;
|
||||
|
||||
for key in ["approval_mode", "approval_policy", "approval"] {
|
||||
let result = set_config_value(&mut app, key, "never", false);
|
||||
assert!(result.is_error, "{key} must be refused mid-turn");
|
||||
assert!(
|
||||
result
|
||||
.message
|
||||
.as_deref()
|
||||
.is_some_and(|message| message.contains("locked while a turn is running")),
|
||||
"unexpected refusal for {key}: {:?}",
|
||||
result.message
|
||||
);
|
||||
assert_eq!(app.approval_mode, ApprovalMode::Suggest);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_preset_calm_applies_bundle_to_session_and_keeps_evidence() {
|
||||
let mut app = create_test_app();
|
||||
|
||||
@@ -7101,23 +7101,7 @@ impl Engine {
|
||||
});
|
||||
}
|
||||
|
||||
self.record_mode_notice(mode);
|
||||
|
||||
// The Operate contract (docs/MODES.md) precedes the first Operate
|
||||
// prompt. KV-cache effect: append-only history, one user-role runtime
|
||||
// message; it is derived from the session log rather than a flag so a
|
||||
// cleared, restored, or compacted session gets it again exactly once
|
||||
// and every later Operate turn does not repeat it.
|
||||
if mode == AppMode::Operate
|
||||
&& !self
|
||||
.session
|
||||
.messages
|
||||
.iter()
|
||||
.any(crate::runtime_handoff::is_current_operate_contract_message)
|
||||
{
|
||||
self.session
|
||||
.add_message(crate::runtime_handoff::operate_contract_runtime_message());
|
||||
}
|
||||
self.record_mode_context(mode);
|
||||
|
||||
self.session
|
||||
.working_set
|
||||
@@ -9139,6 +9123,33 @@ impl Engine {
|
||||
/// new one is appended whenever the mode differs from the latest. Restored
|
||||
/// Operate contracts also require a current notice after leaving Operate.
|
||||
/// Child and RLM hosts get none: their mode is fixed by their parent.
|
||||
/// Record the mode notice and, on entering Operate, the Operate contract
|
||||
/// (docs/MODES.md), which precedes the first Operate prompt. KV-cache
|
||||
/// effect: append-only history, user-role runtime messages; derived from
|
||||
/// the session log rather than a flag so a cleared, restored, or compacted
|
||||
/// session gets the contract again exactly once and every later Operate
|
||||
/// step does not repeat it. Runs at turn start and before every model
|
||||
/// request, so a mode switched mid-turn reaches the next request. Known
|
||||
/// limitation: a response already streaming is not restarted for a mode
|
||||
/// change; it finishes under the notice it was sent with, and its tool
|
||||
/// calls are planned under the new posture. Returns whether anything was
|
||||
/// appended.
|
||||
fn record_mode_context(&mut self, mode: AppMode) -> bool {
|
||||
let before = self.session.messages.len();
|
||||
self.record_mode_notice(mode);
|
||||
if mode == AppMode::Operate
|
||||
&& !self
|
||||
.session
|
||||
.messages
|
||||
.iter()
|
||||
.any(crate::runtime_handoff::is_current_operate_contract_message)
|
||||
{
|
||||
self.session
|
||||
.add_message(crate::runtime_handoff::operate_contract_runtime_message());
|
||||
}
|
||||
self.session.messages.len() != before
|
||||
}
|
||||
|
||||
fn record_mode_notice(&mut self, mode: AppMode) {
|
||||
if self.child_host.is_some() || self.rlm_host.is_some() {
|
||||
return;
|
||||
|
||||
@@ -59,6 +59,9 @@ impl Engine {
|
||||
}
|
||||
progress.mode = self.current_mode;
|
||||
}
|
||||
if self.record_mode_context(self.current_mode) {
|
||||
self.emit_session_updated().await;
|
||||
}
|
||||
|
||||
let mut accepted_steer = false;
|
||||
while let Some(pending) = self.next_turn_steer() {
|
||||
|
||||
@@ -3485,9 +3485,6 @@ impl App {
|
||||
/// permission surface and refuses here; otherwise this behaves like
|
||||
/// [`Self::select_mode`] and persists the mode actually installed (Act).
|
||||
pub fn select_yolo_compat(&mut self) -> SettingSelection {
|
||||
if self.reject_setting_change_while_busy(MessageId::SettingSubjectMode) {
|
||||
return SettingSelection::Refused;
|
||||
}
|
||||
if self.approval_policy_locked() {
|
||||
self.push_status_toast(
|
||||
"Permissions are controlled by config or managed requirements".to_string(),
|
||||
@@ -3562,8 +3559,8 @@ impl App {
|
||||
/// What is persisted is `self.mode` — the mode `set_mode` actually
|
||||
/// installed — not the requested enum.
|
||||
///
|
||||
/// The outcome is typed, not a bool, because three things can happen and
|
||||
/// only one of them means "nothing was saved":
|
||||
/// The outcome is typed, not a bool, so callers can tell a live change
|
||||
/// from a selection that only saved the startup default:
|
||||
///
|
||||
/// - [`SettingSelection::Changed`] — live mode moved *and* the startup
|
||||
/// default was queued.
|
||||
@@ -3571,19 +3568,12 @@ impl App {
|
||||
/// requested one, but the startup default was still queued. This is a
|
||||
/// real, reportable action: after a session restore the live mode and the
|
||||
/// startup default routinely disagree.
|
||||
/// - [`SettingSelection::Refused`] — the #2982 turn lock rejected it and
|
||||
/// nothing was written anywhere.
|
||||
///
|
||||
/// A bool collapsed the last two, so every caller (slash `/mode`, the
|
||||
/// Alt+A/P/Y shortcuts, the hotbar mode rows) reported a refusal and a
|
||||
/// successful same-mode save identically — as "already in that mode", with
|
||||
/// no receipt for the write that did happen.
|
||||
/// Mode is not turn-locked: a change during a running turn is sent to the
|
||||
/// engine, which applies it at its next step boundary in that turn.
|
||||
///
|
||||
/// [`StartupDefaultsWriter`]: crate::tui::startup_defaults::StartupDefaultsWriter
|
||||
pub fn select_mode(&mut self, mode: AppMode) -> SettingSelection {
|
||||
if self.reject_setting_change_while_busy(MessageId::SettingSubjectMode) {
|
||||
return SettingSelection::Refused;
|
||||
}
|
||||
let changed = self.set_mode(mode);
|
||||
// Persist an explicit selection even when it matches the live mode.
|
||||
// A restored session can be Operate while the startup default remains
|
||||
@@ -3695,11 +3685,12 @@ impl App {
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether mode/thinking selection is locked because a turn is in flight.
|
||||
/// Whether a route setting is locked because a turn is in flight.
|
||||
///
|
||||
/// While `is_loading`, the model/permission surface the engine is acting on
|
||||
/// must not shift underneath it, so user-initiated mode and thinking changes
|
||||
/// are refused (#2982). Returns true (and posts a concise status message) if
|
||||
/// While `is_loading`, the model, provider and thinking the engine is
|
||||
/// acting on must not shift underneath it, so those changes are refused
|
||||
/// (#2982). Mode and permissions are not locked: the engine applies them
|
||||
/// at its next step boundary in the same turn. Returns true (and posts a concise status message) if
|
||||
/// the change should be rejected — the caller leaves the selection unchanged
|
||||
/// so the chip "twitches" back instead of moving.
|
||||
///
|
||||
@@ -3991,9 +3982,6 @@ impl App {
|
||||
}
|
||||
|
||||
fn next_approval_posture(&mut self, allow_root_policy: bool) -> Option<ApprovalMode> {
|
||||
if self.reject_setting_change_while_busy(MessageId::SettingSubjectPermissions) {
|
||||
return None;
|
||||
}
|
||||
// Plan used to refuse the change outright, which welded the two axes
|
||||
// together on the keyboard: Tab cycles the mode, Shift+Tab cycles the
|
||||
// posture, and in Plan the second key silently did nothing. They are
|
||||
@@ -4104,9 +4092,6 @@ impl App {
|
||||
/// landing on it; Plan stays read-only and only the Act baseline moves.
|
||||
/// Shared by `/auto` and the Plan hand-off.
|
||||
pub fn apply_agent_posture(&mut self, next: ApprovalMode) -> Result<(), String> {
|
||||
if self.reject_setting_change_while_busy(MessageId::SettingSubjectPermissions) {
|
||||
return Err(self.setting_locked_message(MessageId::SettingSubjectPermissions));
|
||||
}
|
||||
if self.approval_policy_locked() {
|
||||
return Err("Permissions are controlled by config or managed requirements".to_string());
|
||||
}
|
||||
|
||||
@@ -645,17 +645,18 @@ fn auto_reasoning_change_invalidates_the_previous_route_and_receipt() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_and_thinking_are_locked_while_a_turn_is_running() {
|
||||
// #2982: while a turn is in flight, user-initiated mode/thinking changes
|
||||
// are refused with a concise message instead of shifting the surface the
|
||||
// engine is acting on.
|
||||
fn thinking_is_locked_but_mode_moves_while_a_turn_is_running() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
app.mode = AppMode::Agent;
|
||||
app.reasoning_effort = ReasoningEffort::Max;
|
||||
app.is_loading = true;
|
||||
|
||||
app.cycle_mode();
|
||||
assert_eq!(app.mode, AppMode::Agent, "mode must not change while busy");
|
||||
let before_effort = app.reasoning_effort;
|
||||
app.cycle_effort();
|
||||
assert_eq!(
|
||||
app.reasoning_effort, before_effort,
|
||||
"thinking must not change while busy"
|
||||
);
|
||||
assert!(
|
||||
app.status_message
|
||||
.as_deref()
|
||||
@@ -665,17 +666,8 @@ fn mode_and_thinking_are_locked_while_a_turn_is_running() {
|
||||
app.status_message
|
||||
);
|
||||
|
||||
let before_effort = app.reasoning_effort;
|
||||
app.cycle_effort();
|
||||
assert_eq!(
|
||||
app.reasoning_effort, before_effort,
|
||||
"thinking must not change while busy"
|
||||
);
|
||||
|
||||
// Once the turn finishes, the same gesture works again.
|
||||
app.is_loading = false;
|
||||
app.cycle_mode();
|
||||
assert_ne!(app.mode, AppMode::Agent, "mode should change when idle");
|
||||
assert_ne!(app.mode, AppMode::Agent, "mode changes mid-turn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4157,7 +4149,7 @@ fn plan_permission_cycle_moves_the_baseline_and_leaves_plan_read_only() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn busy_permission_cycle_changes_neither_runtime_nor_persistence() {
|
||||
fn busy_permission_cycle_moves_the_live_posture() {
|
||||
let _env_lock = lock_test_env();
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
@@ -4168,12 +4160,10 @@ fn busy_permission_cycle_changes_neither_runtime_nor_persistence() {
|
||||
let before = app.approval_mode;
|
||||
app.is_loading = true;
|
||||
|
||||
assert!(!app.cycle_approval_posture());
|
||||
assert_eq!(app.approval_mode, before);
|
||||
assert_eq!(app.mode_prefs.agent_approval_mode, before);
|
||||
assert!(!tmp.path().join("settings.toml").exists());
|
||||
assert!(app.cycle_approval_posture());
|
||||
assert_ne!(app.mode_prefs.agent_approval_mode, before);
|
||||
assert!(
|
||||
app.status_message
|
||||
!app.status_message
|
||||
.as_deref()
|
||||
.is_some_and(|message| message.contains("locked"))
|
||||
);
|
||||
@@ -6928,7 +6918,7 @@ fn reselecting_restored_live_mode_updates_the_startup_default() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_change_refused_while_a_turn_runs_persists_nothing() {
|
||||
fn mode_change_while_a_turn_runs_is_live_and_persisted() {
|
||||
let _lock = lock_test_env();
|
||||
let tmp = tempfile::TempDir::new().expect("tempdir");
|
||||
let _env = sealed_settings_home(tmp.path());
|
||||
@@ -6938,12 +6928,13 @@ fn mode_change_refused_while_a_turn_runs_persists_nothing() {
|
||||
app.select_mode(AppMode::Plan);
|
||||
app.is_loading = true;
|
||||
app.cycle_mode();
|
||||
app.startup_defaults.flush();
|
||||
|
||||
assert_eq!(app.mode, AppMode::Plan, "#2982 lock still holds");
|
||||
assert_eq!(app.mode, AppMode::Plan.next());
|
||||
assert_eq!(
|
||||
Settings::load().expect("reload").default_mode,
|
||||
"plan",
|
||||
"a refused change must not be persisted"
|
||||
app.mode.as_setting(),
|
||||
"a mid-turn change is a real selection"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7394,7 +7385,7 @@ fn mode_cycling_in_an_unsealed_test_writes_nothing() {
|
||||
// hotbar. `/set` and `/config <key> <value>` reached the same live route through
|
||||
// a different door, and both are reachable mid-turn: the composer accepts
|
||||
// Shift+Enter and the slash menu while `is_loading`. So during a running turn a
|
||||
// slash command could swap the model, thinking level, mode, or provider out from
|
||||
// slash command could swap the model, thinking level, or provider out from
|
||||
// under the engine *and* persist it. The refusal now sits in one place, above
|
||||
// every disk write and every `App` mutation.
|
||||
|
||||
@@ -7438,7 +7429,6 @@ fn slash_config_and_set_refuse_every_live_route_key_while_a_turn_runs() {
|
||||
("default_model", "deepseek-v4-pro"),
|
||||
("reasoning_effort", "high"),
|
||||
("effort", "high"),
|
||||
("mode", "operate"),
|
||||
("provider", "openai"),
|
||||
] {
|
||||
let result = crate::commands::set_config_value(&mut app, key, value, persist);
|
||||
@@ -7637,15 +7627,15 @@ fn mode_selection_reports_refusal_change_and_persisted_same_distinctly() {
|
||||
);
|
||||
|
||||
app.is_loading = true;
|
||||
assert_eq!(app.select_mode(AppMode::Plan), SettingSelection::Refused);
|
||||
assert_eq!(app.select_mode(AppMode::Plan), SettingSelection::Changed);
|
||||
assert!(!SettingSelection::Refused.accepted());
|
||||
assert_eq!(app.mode, AppMode::Operate, "a refusal changes nothing");
|
||||
assert_eq!(app.mode, AppMode::Plan, "mode is not turn-locked");
|
||||
}
|
||||
|
||||
/// Every accepted same-live selection shows a saved receipt, and a refusal
|
||||
/// shows the lock message instead — the two must not read the same.
|
||||
/// Every accepted same-live selection shows a saved receipt, and a mid-turn
|
||||
/// switch reads as a switch — the two must not read the same.
|
||||
#[test]
|
||||
fn slash_mode_distinguishes_a_saved_startup_default_from_a_refusal() {
|
||||
fn slash_mode_distinguishes_a_saved_startup_default_from_a_switch() {
|
||||
let _lock = lock_test_env();
|
||||
let tmp = tempfile::TempDir::new().expect("tempdir");
|
||||
let _env = sealed_settings_home(tmp.path());
|
||||
@@ -7671,14 +7661,13 @@ fn slash_mode_distinguishes_a_saved_startup_default_from_a_refusal() {
|
||||
"operate"
|
||||
);
|
||||
|
||||
// Mid-turn the same command must be refused, and say so.
|
||||
app.is_loading = true;
|
||||
let refusal = crate::commands::switch_mode(&mut app, AppMode::Plan);
|
||||
let switched = crate::commands::switch_mode(&mut app, AppMode::Plan);
|
||||
assert!(
|
||||
refusal.contains("locked while a turn is running"),
|
||||
"a refusal must not read like a save, got {refusal:?}"
|
||||
switched.starts_with("Switched to"),
|
||||
"mode is not turn-locked, got {switched:?}"
|
||||
);
|
||||
assert_ne!(refusal, receipt);
|
||||
assert_ne!(switched, receipt);
|
||||
}
|
||||
|
||||
/// The hotbar mode rows share the receipt: dispatching a row for the live mode
|
||||
|
||||
@@ -627,7 +627,7 @@ pub(crate) fn reasoning_effort_receipt_for_route(
|
||||
.unwrap_or(EffectiveReasoningEffort::Tier(tier))
|
||||
}
|
||||
|
||||
pub(crate) async fn sync_mode_update(app: &App, engine_handle: &EngineHandle) {
|
||||
pub(crate) async fn sync_mode_update(app: &mut App, engine_handle: &EngineHandle) {
|
||||
// #6150: non-blocking send on the input path. ChangeMode is safe to drop
|
||||
// on a full channel — `try_send` still publishes the live authority
|
||||
// snapshot, which the drain applies before the next queued op.
|
||||
@@ -639,6 +639,17 @@ pub(crate) async fn sync_mode_update(app: &App, engine_handle: &EngineHandle) {
|
||||
approval_mode: app.approval_mode,
|
||||
configured_sandbox_mode: app.configured_sandbox_mode.clone(),
|
||||
});
|
||||
if app.is_loading {
|
||||
app.push_status_toast(
|
||||
format!(
|
||||
"{} · {} — applies at the agent's next step in this turn",
|
||||
crate::tui::app::AppModeUi::display_name_localized(app.mode, app.ui_locale),
|
||||
app.effective_permission_label(),
|
||||
),
|
||||
StatusToastLevel::Info,
|
||||
Some(6_000),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a `/provider` switch by resolving a complete route candidate before
|
||||
|
||||
+17
-9
@@ -139,9 +139,8 @@ selection you made last is the one on disk — a burst of Tab presses cannot end
|
||||
up persisting whichever write happened to finish last — and a mode write never
|
||||
rolls back an unrelated key such as `default_model`.
|
||||
|
||||
Two paths deliberately do **not** rewrite the startup default: restoring a saved
|
||||
session (which re-installs the mode that session was in) and a mode change
|
||||
refused because a turn is in flight. The legacy `yolo` entry point installs Work
|
||||
Restoring a saved session deliberately does **not** rewrite the startup
|
||||
default: it re-installs the mode that session was in. The legacy `yolo` entry point installs Work
|
||||
plus Full Access, and `agent` is what it persists — `yolo` is a permission
|
||||
alias, never a startup mode.
|
||||
|
||||
@@ -150,12 +149,21 @@ session the live mode and `default_mode` routinely disagree, so choosing the
|
||||
live mode again is how you make it durable; Codewhale confirms with a
|
||||
"saved as startup default" receipt rather than reporting "already in that mode".
|
||||
|
||||
While a turn is running, every change to the live route is refused — mode,
|
||||
model, thinking level, and provider — no matter which surface you use. That
|
||||
now includes the slash surfaces (`/mode`, `/model`, `/provider`,
|
||||
`/config <key> <value>`, `/config preset`), which are reachable mid-turn. Press
|
||||
Esc to interrupt first. The restart-only `default_mode` key is exempt, because
|
||||
it does not touch the running turn.
|
||||
Mode and permissions can change while a turn is running, from any surface
|
||||
(Tab, Shift+Tab, `/mode`, the hotbar, `/config mode …`). The change does not
|
||||
restart a response that is already streaming. The engine applies it at its next
|
||||
step in the same turn: before the next tool batch, at every approval gate, and
|
||||
before the next model request, which carries a new mode notice. A call that was
|
||||
still waiting for approval, or that no approval covers, goes back to the model
|
||||
to retry under the new posture; a call you already approved runs only if the
|
||||
new posture is equal or broader. The status line says the change is pending
|
||||
until that step, and the engine reports `Permissions: … · <mode>` once it
|
||||
applies.
|
||||
|
||||
Model, thinking level, and provider are still refused while a turn runs, on
|
||||
every surface (`/model`, `/provider`, `/config <key> <value>`,
|
||||
`/config preset`): they change the route the running request is on. Press Esc
|
||||
to interrupt first.
|
||||
|
||||
Codewhale writes `settings.toml` under a lock that spans processes, and replaces
|
||||
the file atomically, so a second Codewhale instance on the same home directory
|
||||
|
||||
Reference in new issue
Block a user