Synced from monorepo
Synced from monorepo Changes: - Workspace task snapshots only list incomplete backgrounded tasks - Quiet auth, LSP, and config warnings in the shell - Fix observability attributes for warm store errors, restore setup, remote tools, and preview denials - Fail closed when soak metrics are missing - Run plan-mode exit last in mixed tool batches - Allow /loop to store prompts that can terminate the loop - Make subagent maximum nesting depth configurable - Security: apply sandbox profile to the leader process that executes tools Source-Revision: 1adcd1f477870e4a97bacbd6be78c8a3bfbac46d
This commit is contained in:
parent
b41c75a578
commit
02d9359435
96 changed files with 2346 additions and 351 deletions
|
|
@ -304,6 +304,13 @@ pub(super) fn handle_settings_update(notif: &acp::ExtNotification, app: &mut App
|
|||
}
|
||||
}
|
||||
|
||||
// `scheduler_background_loops` is deliberately absent from this handler,
|
||||
// unlike the flags above. A live session's scheduled fires keep the mode
|
||||
// the shell pinned when the session's actor spawned, so applying a pushed
|
||||
// flip here would make `/loop` promise a runtime those fires never get.
|
||||
// The per-session value arrives on the `session/new` / `session/load`
|
||||
// response instead (`AgentView::scheduler_background_loops`).
|
||||
|
||||
// Re-resolve tips from config layers + the updated remote tips.
|
||||
if let Some(remote_tips) = update.tips {
|
||||
use xai_grok_shell::util::config::resolve_tips;
|
||||
|
|
|
|||
|
|
@ -1817,6 +1817,7 @@ pub(super) fn task_completed_notif(
|
|||
explicitly_killed: false,
|
||||
owner_session_id: None,
|
||||
description: None,
|
||||
is_backgrounded: false,
|
||||
},
|
||||
will_wake,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1042,6 +1042,7 @@
|
|||
restore_summary: None,
|
||||
restore_degree: None,
|
||||
running_prompt_id: Some("p-run".to_string()),
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -1167,6 +1168,7 @@
|
|||
restore_summary: None,
|
||||
restore_degree: None,
|
||||
running_prompt_id: Some("task-completed-abc-123".to_string()),
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -1207,6 +1209,7 @@
|
|||
restore_summary: None,
|
||||
restore_degree: None,
|
||||
running_prompt_id: Some(pid.to_string()),
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -1244,6 +1247,7 @@
|
|||
restore_summary: None,
|
||||
restore_degree: None,
|
||||
running_prompt_id: Some("p-run".to_string()),
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -1277,6 +1281,7 @@
|
|||
restore_summary: None,
|
||||
restore_degree: None,
|
||||
running_prompt_id: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -2526,6 +2531,7 @@
|
|||
restore_summary: None,
|
||||
restore_degree: None,
|
||||
running_prompt_id: Some("p-run".to_string()),
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -134,6 +134,134 @@
|
|||
assert!(!app.voice_mode_enabled);
|
||||
}
|
||||
|
||||
/// Build an `x.ai/settings/update` carrying only the scheduler flag.
|
||||
fn scheduler_background_loops_update(value: bool) -> acp::ExtNotification {
|
||||
acp::ExtNotification::new(
|
||||
"x.ai/settings/update",
|
||||
std::sync::Arc::from(
|
||||
serde_json::value::to_raw_value(&serde_json::json!({
|
||||
"scheduler_background_loops": value
|
||||
}))
|
||||
.unwrap(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Drain the `/loop` instruction the pager actually stored for a session.
|
||||
fn loop_instruction(app: &mut AppView, args: &str) -> String {
|
||||
use crate::app::actions::Action;
|
||||
|
||||
// `/loop` is `required_tools()`-gated and the registry fails closed
|
||||
// until the toolset is advertised, so a bare test agent never reaches
|
||||
// the command.
|
||||
if let Some(agent) = app.agents.get_mut(&AgentId(0)) {
|
||||
agent
|
||||
.prompt
|
||||
.slash_controller
|
||||
.registry_mut()
|
||||
.set_available_tools(
|
||||
[xai_grok_tools::implementations::grok_build::SCHEDULER_CREATE_TOOL_NAME]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
let effects =
|
||||
crate::app::dispatch::dispatch(Action::SendPrompt(format!("/loop {args}")), app);
|
||||
let blocks = effects
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
Effect::SendPromptBlocks { blocks, .. } => Some(blocks),
|
||||
_ => None,
|
||||
})
|
||||
.expect("/loop must enqueue an instruction and drain it");
|
||||
match &blocks[0] {
|
||||
acp::ContentBlock::Text(text) => text.text.clone(),
|
||||
other => panic!("expected a text prompt block, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `/loop`'s wording must describe THIS session's fires. The shell pins the
|
||||
/// fire mode when a session's actor spawns, so a mid-session settings push
|
||||
/// carrying the opposite value must not change the instruction: describing
|
||||
/// detached fires as in-session drops the self-contained state those fires
|
||||
/// need.
|
||||
#[test]
|
||||
fn loop_fire_mode_follows_session_not_later_settings_push() {
|
||||
use crate::app::actions::{Action, TaskResult};
|
||||
use xai_grok_tools::implementations::grok_build::{
|
||||
LoopFireMode, loop_schedule_instruction,
|
||||
};
|
||||
|
||||
let mut app = make_app_with_agent("sess-loop");
|
||||
// Seed says detached; only the session's own answer can produce the
|
||||
// in-session wording asserted below.
|
||||
app.scheduler_background_loops_seed = true;
|
||||
crate::app::dispatch::dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionCreated {
|
||||
agent_id: AgentId(0),
|
||||
session_id: acp::SessionId::new("sess-loop"),
|
||||
models: None,
|
||||
scheduler_background_loops: Some(false),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(handle_ext_notification(
|
||||
&scheduler_background_loops_update(true),
|
||||
&mut app
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
loop_instruction(&mut app, "5m check ci"),
|
||||
loop_schedule_instruction("5m check ci", LoopFireMode::InSession),
|
||||
"a pushed flip must not re-describe fires this session already pinned"
|
||||
);
|
||||
}
|
||||
|
||||
/// The value is session-scoped, not frozen for the process: resuming a
|
||||
/// session adopts the mode that resume's spawn pinned.
|
||||
#[test]
|
||||
fn loop_fire_mode_adopts_the_loaded_session_value() {
|
||||
use crate::app::actions::{Action, TaskResult};
|
||||
use xai_grok_tools::implementations::grok_build::{
|
||||
LoopFireMode, loop_schedule_instruction,
|
||||
};
|
||||
|
||||
let mut app = make_app_with_agent("sess-loop-load");
|
||||
// Opposite of both the seed and the pre-resume value, so only the load
|
||||
// response can produce the detached wording asserted below.
|
||||
app.scheduler_background_loops_seed = false;
|
||||
crate::app::dispatch::dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionCreated {
|
||||
agent_id: AgentId(0),
|
||||
session_id: acp::SessionId::new("sess-loop-load"),
|
||||
models: None,
|
||||
scheduler_background_loops: Some(false),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
crate::app::dispatch::dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionLoaded {
|
||||
agent_id: AgentId(0),
|
||||
session_id: acp::SessionId::new("sess-loop-load"),
|
||||
models: None,
|
||||
code_restored: false,
|
||||
restore_summary: None,
|
||||
restore_degree: None,
|
||||
running_prompt_id: None,
|
||||
scheduler_background_loops: Some(true),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
loop_instruction(&mut app, "5m check ci"),
|
||||
loop_schedule_instruction("5m check ci", LoopFireMode::Detached),
|
||||
"resume must adopt the value its own spawn pinned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_update_clearing_group_tool_verbs_reverts_to_default() {
|
||||
// Expected values come from the same chain the handler resolves, so the
|
||||
|
|
|
|||
|
|
@ -1277,6 +1277,7 @@
|
|||
restore_summary: None,
|
||||
restore_degree: None,
|
||||
running_prompt_id: Some("p-run".to_string()),
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2163,6 +2163,12 @@ pub enum TaskResult {
|
|||
agent_id: AgentId,
|
||||
session_id: acp::SessionId,
|
||||
models: Option<acp::SessionModelState>,
|
||||
/// Whether this session's scheduled fires run detached, as the shell
|
||||
/// resolved it at spawn (response
|
||||
/// `_meta["x.ai/schedulerBackgroundLoops"]`). `None` from a shell that
|
||||
/// predates the key. See
|
||||
/// [`crate::app::effects::parse_session_scheduler_background_loops`].
|
||||
scheduler_background_loops: Option<bool>,
|
||||
},
|
||||
/// Session creation failed.
|
||||
SessionFailed {
|
||||
|
|
@ -2178,6 +2184,8 @@ pub enum TaskResult {
|
|||
/// Effective cwd inside the worktree (preserves subdirectory offset).
|
||||
session_cwd: std::path::PathBuf,
|
||||
models: Option<acp::SessionModelState>,
|
||||
/// See [`TaskResult::SessionCreated::scheduler_background_loops`].
|
||||
scheduler_background_loops: Option<bool>,
|
||||
},
|
||||
/// Worktree created and session forked, but not yet loaded.
|
||||
/// The dispatch handler sets session_id eagerly, then emits LoadSession.
|
||||
|
|
@ -2209,6 +2217,10 @@ pub enum TaskResult {
|
|||
/// pass the live `session/update` gate without re-rendering the user
|
||||
/// block (replay already rendered it).
|
||||
running_prompt_id: Option<String>,
|
||||
/// See [`TaskResult::SessionCreated::scheduler_background_loops`]. A
|
||||
/// resumed session re-spawns its actor, so the load response carries
|
||||
/// the value that spawn just pinned.
|
||||
scheduler_background_loops: Option<bool>,
|
||||
},
|
||||
/// Session load (resume) failed.
|
||||
SessionLoadFailed {
|
||||
|
|
|
|||
|
|
@ -1385,6 +1385,15 @@ pub struct AgentView {
|
|||
/// Whether the `/share` slash command is available (mirrors
|
||||
/// `AppView::sharing_enabled`). Used to gate palette entries.
|
||||
pub sharing_enabled: bool,
|
||||
/// Whether THIS session's scheduled fires run as detached background
|
||||
/// subagents, as resolved by the shell when the session's actor spawned and
|
||||
/// delivered on the `session/new` / `session/load` response. `/loop` reads
|
||||
/// it to describe the runtime a fire will get. `None` until that response
|
||||
/// lands (or against a shell that predates the key), where readers fall
|
||||
/// back to `AppView::scheduler_background_loops_seed`. Deliberately NOT
|
||||
/// refreshed by `x.ai/settings/update`: the fire side is pinned for the
|
||||
/// session's lifetime, so a live mirror would drift out of agreement.
|
||||
pub scheduler_background_loops: Option<bool>,
|
||||
/// Mirrors `AppView::usage_visible` (credit warning + `/usage manage`).
|
||||
pub billing_surface_visible: bool,
|
||||
/// Input flight recorder — rolling buffer of recent key events.
|
||||
|
|
|
|||
|
|
@ -297,6 +297,7 @@ impl AgentView {
|
|||
is_subagent_view: false,
|
||||
hit_subagent_frame_close: Default::default(),
|
||||
sharing_enabled: false,
|
||||
scheduler_background_loops: None,
|
||||
billing_surface_visible: false,
|
||||
input_log: crate::input_log::InputRingBuffer::new(),
|
||||
esc_pressed_at: None,
|
||||
|
|
|
|||
|
|
@ -755,6 +755,13 @@ pub struct AppView {
|
|||
/// non-selectable headers. Gated by `GROK_SESSION_PICKER_GROUPED` env var
|
||||
/// or remote settings `session_picker_grouped`; defaults to `false`.
|
||||
pub session_picker_grouped: bool,
|
||||
/// Startup-only seed for `AgentView::scheduler_background_loops`, resolved
|
||||
/// once from the config layers plus the remote tier known at connect.
|
||||
/// Read only until a session's own value arrives on its `session/new` /
|
||||
/// `session/load` response, and by the session-less dashboard. Never
|
||||
/// refreshed afterwards — the authoritative value is per session, pinned by
|
||||
/// the shell when that session's actor spawned.
|
||||
pub scheduler_background_loops_seed: bool,
|
||||
/// Whether Ctrl+C before first server activity rewinds the prompt
|
||||
/// back into the input box. Gated by `GROK_CANCEL_REWIND` env /
|
||||
/// `[features] cancel_rewind` config / remote settings flag.
|
||||
|
|
@ -1555,6 +1562,7 @@ impl AppView {
|
|||
optimistic_prompt_echoes: std::collections::HashMap::new(),
|
||||
pending_running_adoptions: std::collections::HashMap::new(),
|
||||
session_picker_grouped: false,
|
||||
scheduler_background_loops_seed: true,
|
||||
cancel_rewind_enabled: true,
|
||||
session_recap_available: false,
|
||||
tutorial: None,
|
||||
|
|
@ -5827,6 +5835,7 @@ pub(crate) mod tests {
|
|||
optimistic_prompt_echoes: std::collections::HashMap::new(),
|
||||
pending_running_adoptions: std::collections::HashMap::new(),
|
||||
session_picker_grouped: false,
|
||||
scheduler_background_loops_seed: true,
|
||||
cancel_rewind_enabled: true,
|
||||
session_recap_available: false,
|
||||
tutorial: None,
|
||||
|
|
|
|||
|
|
@ -1293,6 +1293,9 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String)
|
|||
let auto_mode_gate_from_app = app.auto_mode_gate;
|
||||
let ask_user_question_timeout_enabled_from_app = app.ask_user_question_timeout_enabled;
|
||||
let voice_stt_language_from_app = app.voice_config.language.clone();
|
||||
// Dashboard commands run before any session exists, so the startup seed is
|
||||
// the only answer available here.
|
||||
let scheduler_background_loops_seed = app.scheduler_background_loops_seed;
|
||||
|
||||
// Build the execution context from app-wide state. The dashboard
|
||||
// is session-less, so `session_id` is `None`. Offered session-less
|
||||
|
|
@ -1401,6 +1404,7 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String)
|
|||
auto_mode_gate: auto_mode_gate_from_app,
|
||||
ask_user_question_timeout_enabled: ask_user_question_timeout_enabled_from_app,
|
||||
voice_stt_language: voice_stt_language_from_app,
|
||||
scheduler_background_loops: scheduler_background_loops_seed,
|
||||
},
|
||||
};
|
||||
command.run(&mut ctx, invocation.args)
|
||||
|
|
|
|||
|
|
@ -477,6 +477,7 @@ pub(super) fn dispatch_send_prompt_inner(
|
|||
// shown after the agent borrow ends so we can re-enter via the tip helper.
|
||||
let mut tip_send_now_after_queue = false;
|
||||
let voice_stt_language_from_app = app.voice_config.language.clone();
|
||||
let scheduler_background_loops_seed = app.scheduler_background_loops_seed;
|
||||
let login_method_id_from_app = app.login_method_id.as_ref().map(|id| id.0.to_string());
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
|
|
@ -575,6 +576,11 @@ pub(super) fn dispatch_send_prompt_inner(
|
|||
auto_mode_gate: auto_mode_gate_from_app,
|
||||
ask_user_question_timeout_enabled: ask_user_question_timeout_enabled_from_app,
|
||||
voice_stt_language: voice_stt_language_from_app,
|
||||
// This session's own value (what its fires will actually
|
||||
// do), seed only until the session response lands.
|
||||
scheduler_background_loops: agent
|
||||
.scheduler_background_loops
|
||||
.unwrap_or(scheduler_background_loops_seed),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -801,6 +801,7 @@ pub(in crate::app::dispatch) fn handle_session_created(
|
|||
agent_id: AgentId,
|
||||
session_id: acp::SessionId,
|
||||
new_models: Option<acp::SessionModelState>,
|
||||
scheduler_background_loops: Option<bool>,
|
||||
) -> Vec<Effect> {
|
||||
let agent_count = app.agents.len();
|
||||
let switch_hint =
|
||||
|
|
@ -822,6 +823,7 @@ pub(in crate::app::dispatch) fn handle_session_created(
|
|||
)));
|
||||
}
|
||||
agent.bind_session_id(session_id);
|
||||
agent.scheduler_background_loops = scheduler_background_loops;
|
||||
if let Some(m) = new_models {
|
||||
app.models = Some(m).into();
|
||||
agent.session.models = app.models.clone();
|
||||
|
|
@ -910,12 +912,14 @@ pub(in crate::app::dispatch) fn handle_worktree_session_created(
|
|||
worktree_path: std::path::PathBuf,
|
||||
session_cwd: std::path::PathBuf,
|
||||
new_models: Option<acp::SessionModelState>,
|
||||
scheduler_background_loops: Option<bool>,
|
||||
) -> Vec<Effect> {
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
agent.session.finish_command();
|
||||
agent.mark_turn_finished();
|
||||
let session_id_clone = session_id.clone();
|
||||
agent.bind_session_id(session_id);
|
||||
agent.scheduler_background_loops = scheduler_background_loops;
|
||||
agent.session.cwd = session_cwd.clone();
|
||||
agent.session.is_worktree = true;
|
||||
if let Some(m) = new_models {
|
||||
|
|
|
|||
|
|
@ -879,6 +879,7 @@ pub(in crate::app::dispatch) fn handle_session_loaded(
|
|||
restore_summary: Option<String>,
|
||||
restore_degree: Option<xai_grok_workspace::session::git::RestoreDegree>,
|
||||
running_prompt_id: Option<String>,
|
||||
scheduler_background_loops: Option<bool>,
|
||||
) -> Vec<Effect> {
|
||||
tracing::info!(
|
||||
"Session loaded for agent {:?} session {:?}",
|
||||
|
|
@ -891,6 +892,7 @@ pub(in crate::app::dispatch) fn handle_session_loaded(
|
|||
}
|
||||
let hydrate_sid = session_id.clone();
|
||||
agent.bind_session_id(session_id);
|
||||
agent.scheduler_background_loops = scheduler_background_loops;
|
||||
agent.scrollback.end_batch();
|
||||
agent.session.loading_replay = false;
|
||||
agent.session.restore_degree = restore_degree;
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ pub(crate) fn refresh_open_settings_modals(app: &mut AppView) {
|
|||
let auto_mode_gate_from_app = app.auto_mode_gate;
|
||||
let ask_user_question_timeout_enabled_from_app = app.ask_user_question_timeout_enabled;
|
||||
let voice_stt_language_from_app = app.voice_config.language.clone();
|
||||
let scheduler_background_loops_seed = app.scheduler_background_loops_seed;
|
||||
for agent in app.agents.values_mut() {
|
||||
// Walk both `Settings` and `ResetSettingsConfirm` — the
|
||||
// confirm dialog embeds settings state that must stay fresh
|
||||
|
|
@ -92,6 +93,9 @@ pub(crate) fn refresh_open_settings_modals(app: &mut AppView) {
|
|||
auto_mode_gate: auto_mode_gate_from_app,
|
||||
ask_user_question_timeout_enabled: ask_user_question_timeout_enabled_from_app,
|
||||
voice_stt_language: voice_stt_language_from_app.clone(),
|
||||
scheduler_background_loops: agent
|
||||
.scheduler_background_loops
|
||||
.unwrap_or(scheduler_background_loops_seed),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -191,6 +195,7 @@ pub(in crate::app::dispatch) fn dispatch_open_settings(
|
|||
let auto_mode_gate_from_app = app.auto_mode_gate;
|
||||
let ask_user_question_timeout_enabled_from_app = app.ask_user_question_timeout_enabled;
|
||||
let voice_stt_language_from_app = app.voice_config.language.clone();
|
||||
let scheduler_background_loops_seed = app.scheduler_background_loops_seed;
|
||||
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return effects;
|
||||
|
|
@ -238,6 +243,9 @@ pub(in crate::app::dispatch) fn dispatch_open_settings(
|
|||
auto_mode_gate: auto_mode_gate_from_app,
|
||||
ask_user_question_timeout_enabled: ask_user_question_timeout_enabled_from_app,
|
||||
voice_stt_language: voice_stt_language_from_app,
|
||||
scheduler_background_loops: agent
|
||||
.scheduler_background_loops
|
||||
.unwrap_or(scheduler_background_loops_seed),
|
||||
};
|
||||
let mut state = Box::new(SettingsModalState::new(
|
||||
registry,
|
||||
|
|
@ -650,6 +658,20 @@ fn agent_auto_mode(app: &AppView) -> bool {
|
|||
false
|
||||
}
|
||||
|
||||
/// Effective `scheduler_background_loops` for the active agent: the value the
|
||||
/// shell pinned for that session, falling back to the startup seed while the
|
||||
/// session response is still in flight (or with no agent at all). See
|
||||
/// [`agent_multiline_mode`] for the no-agent fallback rationale.
|
||||
fn agent_scheduler_background_loops(app: &AppView) -> bool {
|
||||
if let ActiveView::Agent(id) = app.active_view
|
||||
&& let Some(agent) = app.agents.get(&id)
|
||||
&& let Some(value) = agent.scheduler_background_loops
|
||||
{
|
||||
return value;
|
||||
}
|
||||
app.scheduler_background_loops_seed
|
||||
}
|
||||
|
||||
/// Effective `plan_mode` for the active agent
|
||||
/// (`pending.unwrap_or(active)`).
|
||||
fn agent_plan_mode(app: &AppView) -> bool {
|
||||
|
|
@ -716,6 +738,7 @@ pub(crate) fn build_pager_snapshot(app: &AppView) -> crate::settings::PagerLocal
|
|||
auto_mode_gate: app.auto_mode_gate,
|
||||
ask_user_question_timeout_enabled: app.ask_user_question_timeout_enabled,
|
||||
voice_stt_language: app.voice_config.language.clone(),
|
||||
scheduler_background_loops: agent_scheduler_background_loops(app),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -244,7 +244,14 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
|||
agent_id,
|
||||
session_id,
|
||||
models: new_models,
|
||||
} => handle_session_created(app, agent_id, session_id, new_models),
|
||||
scheduler_background_loops,
|
||||
} => handle_session_created(
|
||||
app,
|
||||
agent_id,
|
||||
session_id,
|
||||
new_models,
|
||||
scheduler_background_loops,
|
||||
),
|
||||
TaskResult::SessionFailed { agent_id, error } => {
|
||||
handle_session_failed(app, agent_id, error)
|
||||
}
|
||||
|
|
@ -254,6 +261,7 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
|||
worktree_path,
|
||||
session_cwd,
|
||||
models: new_models,
|
||||
scheduler_background_loops,
|
||||
} => handle_worktree_session_created(
|
||||
app,
|
||||
agent_id,
|
||||
|
|
@ -261,6 +269,7 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
|||
worktree_path,
|
||||
session_cwd,
|
||||
new_models,
|
||||
scheduler_background_loops,
|
||||
),
|
||||
TaskResult::WorktreeForked {
|
||||
agent_id,
|
||||
|
|
@ -326,6 +335,7 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
|||
restore_summary,
|
||||
restore_degree,
|
||||
running_prompt_id,
|
||||
scheduler_background_loops,
|
||||
} => handle_session_loaded(
|
||||
app,
|
||||
agent_id,
|
||||
|
|
@ -335,6 +345,7 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
|||
restore_summary,
|
||||
restore_degree,
|
||||
running_prompt_id,
|
||||
scheduler_background_loops,
|
||||
),
|
||||
TaskResult::SessionTitleFromDisk { agent_id, title } => {
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id)
|
||||
|
|
|
|||
|
|
@ -2071,6 +2071,7 @@ fn dashboard_deferred_plan_mode_applied_on_session_created() {
|
|||
agent_id: id,
|
||||
session_id: session_id.clone(),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -5234,6 +5235,7 @@ fn dashboard_attach_roster_focuses_existing_local_agent() {
|
|||
agent_id: id,
|
||||
session_id: "local-owned".into(),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ fn test_app() -> AppView {
|
|||
optimistic_prompt_echoes: std::collections::HashMap::new(),
|
||||
pending_running_adoptions: std::collections::HashMap::new(),
|
||||
session_picker_grouped: false,
|
||||
scheduler_background_loops_seed: true,
|
||||
cancel_rewind_enabled: true,
|
||||
session_recap_available: false,
|
||||
tutorial: None,
|
||||
|
|
|
|||
|
|
@ -229,6 +229,7 @@ fn fork_initiation_supersedes_open_reload_window() {
|
|||
restore_summary: None,
|
||||
restore_degree: None,
|
||||
running_prompt_id: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ fn session_created_sets_session_id() {
|
|||
agent_id: id,
|
||||
session_id: "new-session-123".into(),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -175,6 +176,7 @@ fn session_created_omits_cta_catalog_when_disabled() {
|
|||
agent_id: id,
|
||||
session_id: "new-session-123".into(),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -219,6 +221,7 @@ fn session_created_banner_advertises_resume_in_minimal_mode() {
|
|||
agent_id: id,
|
||||
session_id: "new-session-123".into(),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -300,6 +303,7 @@ fn worktree_session_created_sets_session_and_cwd() {
|
|||
worktree_path: worktree_path.clone(),
|
||||
session_cwd: session_cwd.clone(),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -358,6 +362,7 @@ fn worktree_session_preserves_subdirectory_offset() {
|
|||
worktree_path: worktree_root.clone(),
|
||||
session_cwd: session_cwd.clone(),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -486,6 +491,7 @@ fn worktree_session_created_drains_queued_prompts() {
|
|||
worktree_path,
|
||||
session_cwd: PathBuf::from("/tmp/grok-worktrees/pager-abc"),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -519,6 +525,7 @@ fn session_created_drains_queued_prompts() {
|
|||
agent_id: id,
|
||||
session_id: acp::SessionId::new("sess-drain-1"),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -555,6 +562,7 @@ fn session_created_with_flag_emits_five_fetches_and_clears_flag() {
|
|||
agent_id: id,
|
||||
session_id: acp::SessionId::new("s"),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -577,6 +585,7 @@ fn session_created_without_flag_emits_no_extension_fetches() {
|
|||
agent_id: id,
|
||||
session_id: acp::SessionId::new("s"),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -940,6 +949,7 @@ fn deferred_model_switch_applied_on_session_created() {
|
|||
agent_id: id,
|
||||
session_id: session_id.clone(),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -980,6 +990,7 @@ fn deferred_model_switch_applied_on_worktree_session_created() {
|
|||
worktree_path: PathBuf::from("/tmp/worktree"),
|
||||
session_cwd: PathBuf::from("/tmp/worktree"),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ fn session_loaded_with_restore_shows_summary_in_scrollback() {
|
|||
),
|
||||
restore_degree: Some(xai_grok_workspace::session::git::RestoreDegree::Full),
|
||||
running_prompt_id: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -206,6 +207,7 @@ fn session_loaded_without_adoption_finishes_replayed_running_entries() {
|
|||
restore_summary: None,
|
||||
restore_degree: None,
|
||||
running_prompt_id: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -273,6 +275,7 @@ fn session_loaded_purges_replay_transient() {
|
|||
restore_summary: None,
|
||||
restore_degree: None,
|
||||
running_prompt_id: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -297,6 +300,7 @@ fn session_loaded_during_open_reload_window_defers_to_window() {
|
|||
restore_summary: None,
|
||||
restore_degree: None,
|
||||
running_prompt_id: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -406,6 +410,7 @@ fn session_loaded_with_restore_failure_shows_warning_banner() {
|
|||
),
|
||||
restore_degree: None,
|
||||
running_prompt_id: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -447,6 +452,7 @@ fn session_loaded_without_restore_no_summary() {
|
|||
restore_summary: None,
|
||||
restore_degree: None,
|
||||
running_prompt_id: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -489,6 +495,7 @@ fn session_loaded_without_restore_resets_restore_degree() {
|
|||
restore_summary: Some("checked out abc".into()),
|
||||
restore_degree: Some(xai_grok_workspace::session::git::RestoreDegree::Full),
|
||||
running_prompt_id: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -505,6 +512,7 @@ fn session_loaded_without_restore_resets_restore_degree() {
|
|||
restore_summary: None,
|
||||
restore_degree: None,
|
||||
running_prompt_id: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -533,6 +541,7 @@ fn session_loaded_with_flag_emits_five_fetches_and_clears_flag() {
|
|||
restore_summary: None,
|
||||
restore_degree: None,
|
||||
running_prompt_id: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -725,6 +734,7 @@ fn session_loaded_drains_pending_first_prompt_to_front() {
|
|||
restore_summary: None,
|
||||
restore_degree: None,
|
||||
running_prompt_id: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -754,6 +764,7 @@ fn session_loaded_with_no_pending_first_prompt_does_not_enqueue() {
|
|||
restore_summary: None,
|
||||
restore_degree: None,
|
||||
running_prompt_id: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -859,6 +870,7 @@ fn session_loaded_clears_stale_running_entries() {
|
|||
restore_summary: None,
|
||||
restore_degree: None,
|
||||
running_prompt_id: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -898,6 +910,7 @@ fn resume_focuses_existing_agent_for_open_session() {
|
|||
agent_id: agent_0,
|
||||
session_id: "wt-sess-1".into(),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -915,6 +928,7 @@ fn resume_focuses_existing_agent_for_open_session() {
|
|||
agent_id: agent_1,
|
||||
session_id: "new-sess-2".into(),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -945,6 +959,7 @@ fn resume_unknown_session_still_creates_new_agent() {
|
|||
agent_id: AgentId(0),
|
||||
session_id: "sess-aaa".into(),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -975,6 +990,7 @@ fn resume_open_session_does_not_rearm_stale_overlay() {
|
|||
agent_id: agent_0,
|
||||
session_id: "sess-a".into(),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -985,6 +1001,7 @@ fn resume_open_session_does_not_rearm_stale_overlay() {
|
|||
agent_id: agent_1,
|
||||
session_id: "sess-b".into(),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -1010,6 +1027,7 @@ fn resume_conversation_does_not_focus_build_id_collision() {
|
|||
agent_id: agent_0,
|
||||
session_id: "shared-id".into(),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -1041,6 +1059,7 @@ fn duplicate_load_unbind_invalidates_old_minimal_btw_response() {
|
|||
agent_id: old_owner,
|
||||
session_id: "shared-id".into(),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -1084,6 +1103,7 @@ fn resume_under_chat_mode_focuses_despite_entry_false() {
|
|||
agent_id: agent_0,
|
||||
session_id: "chat-mode-sess".into(),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -1095,6 +1115,7 @@ fn resume_under_chat_mode_focuses_despite_entry_false() {
|
|||
agent_id: agent_1,
|
||||
session_id: "other".into(),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -1120,6 +1141,7 @@ fn resume_stale_attached_target_focuses_dashboard_row() {
|
|||
agent_id: agent_0,
|
||||
session_id: "sess-a".into(),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -1130,6 +1152,7 @@ fn resume_stale_attached_target_focuses_dashboard_row() {
|
|||
agent_id: agent_1,
|
||||
session_id: "sess-b".into(),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
@ -1203,6 +1226,7 @@ fn session_restored_clears_stale_session_id() {
|
|||
agent_id: AgentId(0),
|
||||
session_id: "remote-sess".into(),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ fn session_created_with_flag_but_modal_closed_clears_flag_no_fetches() {
|
|||
agent_id: id,
|
||||
session_id: acp::SessionId::new("s"),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1489,6 +1489,7 @@ fn no_deferred_switch_means_no_extra_effect() {
|
|||
agent_id: id,
|
||||
session_id: "new-session".into(),
|
||||
models: None,
|
||||
scheduler_background_loops: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -168,6 +168,25 @@ pub(crate) fn parse_session_load_running_prompt_id(
|
|||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
}
|
||||
/// CANONICAL wire parser for the `session/new` / `session/load` response
|
||||
/// `_meta[SCHEDULER_BACKGROUND_LOOPS_META_KEY]`.
|
||||
///
|
||||
/// Carries whether THIS session's scheduled fires run as detached background
|
||||
/// subagents, as the shell resolved it when the session's actor spawned. The
|
||||
/// pager stores it per session and must not re-resolve the setting: a
|
||||
/// mid-session flip would then make `/loop`'s wording describe a runtime the
|
||||
/// already-spawned session will never use. `None` when the shell predates the
|
||||
/// key (or for gateway chat sessions, which have no local fires), leaving the
|
||||
/// reader on the startup seed.
|
||||
pub(crate) fn parse_session_scheduler_background_loops(
|
||||
resp_meta: Option<&acp::Meta>,
|
||||
) -> Option<bool> {
|
||||
resp_meta
|
||||
.and_then(|m| {
|
||||
m.get(xai_grok_shell::session::SCHEDULER_BACKGROUND_LOOPS_META_KEY)
|
||||
})
|
||||
.and_then(|v| v.as_bool())
|
||||
}
|
||||
/// Whether `raw` is (or wraps) a disk-full / ENOSPC failure.
|
||||
fn is_disk_full_error(raw: &str) -> bool {
|
||||
raw.contains(xai_fast_worktree::OUT_OF_DISK_CONTEXT)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ use super::session_title_resolve::worktree_resume_failure_message;
|
|||
#[allow(unused_imports)]
|
||||
use super::{agent, dispatch};
|
||||
pub use helpers::ConversationsPartial;
|
||||
pub(super) use helpers::parse_session_load_running_prompt_id;
|
||||
pub(super) use helpers::{
|
||||
parse_session_load_running_prompt_id, parse_session_scheduler_background_loops,
|
||||
};
|
||||
pub(crate) use helpers::{
|
||||
EffectMeta, RestoreProgressMsg, SessionFlags, persist_permission_mode_and_notify,
|
||||
persist_setting, sanitize_user_error,
|
||||
|
|
@ -199,6 +201,9 @@ pub(crate) fn execute(
|
|||
agent_id,
|
||||
session_id: resp.session_id,
|
||||
models: resp.models,
|
||||
scheduler_background_loops: parse_session_scheduler_background_loops(
|
||||
resp.meta.as_ref(),
|
||||
),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -493,6 +498,9 @@ pub(crate) fn execute(
|
|||
worktree_path: worktree_root,
|
||||
session_cwd,
|
||||
models: resp.models,
|
||||
scheduler_background_loops: parse_session_scheduler_background_loops(
|
||||
resp.meta.as_ref(),
|
||||
),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -574,6 +582,9 @@ pub(crate) fn execute(
|
|||
restore_summary,
|
||||
restore_degree,
|
||||
running_prompt_id,
|
||||
scheduler_background_loops: parse_session_scheduler_background_loops(
|
||||
resp.meta.as_ref(),
|
||||
),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
|
|||
|
|
@ -94,6 +94,11 @@ struct AgentLoadOutcome {
|
|||
/// client is driving mid-reconnect, adopted at finalize (mirrors the
|
||||
/// `SessionLoaded` adoption in `dispatch.rs`).
|
||||
running_prompt_id: Option<String>,
|
||||
/// `x.ai/schedulerBackgroundLoops` from the reload response. A reconnect
|
||||
/// re-spawns the session actor, which re-pins the fire mode, so the
|
||||
/// pre-reconnect value can be stale — adopt the reloaded one or `/loop`
|
||||
/// describes a runtime the new actor will not use.
|
||||
scheduler_background_loops: Option<bool>,
|
||||
}
|
||||
|
||||
/// Fields of the reconnect `session/load`, derived from the agent being
|
||||
|
|
@ -165,10 +170,11 @@ fn plan_reconnect_load(
|
|||
fn reconnect_restore_outcome(
|
||||
init_ok: bool,
|
||||
pending_agent_ids: &[super::agent::AgentId],
|
||||
loads: &std::collections::HashMap<super::agent::AgentId, (bool, Option<String>)>,
|
||||
loads: &std::collections::HashMap<super::agent::AgentId, (bool, Option<String>, Option<bool>)>,
|
||||
active_agent_id: Option<super::agent::AgentId>,
|
||||
) -> (bool, bool) {
|
||||
let load_ok = |id: &super::agent::AgentId| -> bool { loads.get(id).is_some_and(|(ok, _)| *ok) };
|
||||
let load_ok =
|
||||
|id: &super::agent::AgentId| -> bool { loads.get(id).is_some_and(|(ok, ..)| *ok) };
|
||||
let all_restored = init_ok && pending_agent_ids.iter().all(load_ok);
|
||||
let active_restored = init_ok
|
||||
&& active_agent_id.is_some_and(|aid| pending_agent_ids.contains(&aid) && load_ok(&aid));
|
||||
|
|
@ -1123,6 +1129,18 @@ pub(crate) async fn run(
|
|||
.value,
|
||||
);
|
||||
|
||||
// Pre-arrival seed only. The authoritative per-session value rides the
|
||||
// `session/new` / `session/load` response, but `/loop` can be reached from
|
||||
// the session-less dashboard and from a session whose response has not
|
||||
// landed yet; both need an answer now, and this is the same resolver the
|
||||
// shell runs at spawn, so the seed agrees with the flag as it stands today.
|
||||
app.scheduler_background_loops_seed =
|
||||
xai_grok_shell::util::config::resolve_scheduler_background_loops(
|
||||
remote_settings
|
||||
.as_ref()
|
||||
.and_then(|s| s.scheduler_background_loops),
|
||||
);
|
||||
|
||||
app.usage_billing_redirect_url = remote_settings
|
||||
.as_ref()
|
||||
.and_then(|s| s.usage_billing_redirect_url.clone());
|
||||
|
|
@ -2571,6 +2589,10 @@ pub(crate) async fn run(
|
|||
effects::parse_session_load_running_prompt_id(
|
||||
resp.meta.as_ref(),
|
||||
),
|
||||
scheduler_background_loops:
|
||||
effects::parse_session_scheduler_background_loops(
|
||||
resp.meta.as_ref(),
|
||||
),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -2581,6 +2603,7 @@ pub(crate) async fn run(
|
|||
agent_id,
|
||||
success: false,
|
||||
running_prompt_id: None,
|
||||
scheduler_background_loops: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -2656,7 +2679,12 @@ pub(crate) async fn run(
|
|||
let mut loads: std::collections::HashMap<_, _> = outcome
|
||||
.loads
|
||||
.into_iter()
|
||||
.map(|l| (l.agent_id, (l.success, l.running_prompt_id)))
|
||||
.map(|l| {
|
||||
(
|
||||
l.agent_id,
|
||||
(l.success, l.running_prompt_id, l.scheduler_background_loops),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// Resolved BEFORE the finalize loop drains `loads` via `remove`
|
||||
// (see `reconnect_restore_outcome`).
|
||||
|
|
@ -2672,8 +2700,14 @@ pub(crate) async fn run(
|
|||
);
|
||||
restore_dashboard_peek_before_reload(&mut app.dashboard, &mut app.agents);
|
||||
for id in &pending.agent_ids {
|
||||
let (ok, running_prompt_id) = loads.remove(id).unwrap_or((false, None));
|
||||
let (ok, running_prompt_id, scheduler_background_loops) =
|
||||
loads.remove(id).unwrap_or((false, None, None));
|
||||
if let Some(agent) = app.agents.get_mut(id) {
|
||||
// The reloaded actor re-pinned the fire mode; a failed
|
||||
// load leaves the previous value rather than guessing.
|
||||
if let Some(mode) = scheduler_background_loops {
|
||||
agent.scheduler_background_loops = Some(mode);
|
||||
}
|
||||
agent.finalize_reload_and_maybe_adopt(
|
||||
pending.generation,
|
||||
ok,
|
||||
|
|
@ -3908,8 +3942,8 @@ mod tests {
|
|||
let active = AgentId(0);
|
||||
let background = AgentId(1);
|
||||
let mut loads = std::collections::HashMap::new();
|
||||
loads.insert(active, (true, None));
|
||||
loads.insert(background, (false, None));
|
||||
loads.insert(active, (true, None, None));
|
||||
loads.insert(background, (false, None, None));
|
||||
let pending = vec![active, background];
|
||||
|
||||
let (all_restored, active_restored) =
|
||||
|
|
@ -3932,8 +3966,8 @@ mod tests {
|
|||
let active = AgentId(0);
|
||||
let background = AgentId(1);
|
||||
let mut loads = std::collections::HashMap::new();
|
||||
loads.insert(active, (false, None));
|
||||
loads.insert(background, (true, None));
|
||||
loads.insert(active, (false, None, None));
|
||||
loads.insert(background, (true, None, None));
|
||||
let pending = vec![active, background];
|
||||
|
||||
let (all_restored, active_restored) =
|
||||
|
|
@ -3952,7 +3986,7 @@ mod tests {
|
|||
use super::super::agent::AgentId;
|
||||
let active = AgentId(0);
|
||||
let mut loads = std::collections::HashMap::new();
|
||||
loads.insert(active, (true, None));
|
||||
loads.insert(active, (true, None, None));
|
||||
let pending = vec![active];
|
||||
|
||||
let (all_restored, active_restored) =
|
||||
|
|
@ -3982,7 +4016,7 @@ mod tests {
|
|||
use super::super::agent::AgentId;
|
||||
let background = AgentId(1);
|
||||
let mut loads = std::collections::HashMap::new();
|
||||
loads.insert(background, (true, None));
|
||||
loads.insert(background, (true, None, None));
|
||||
let pending = vec![background];
|
||||
|
||||
let (all_restored, active_restored) =
|
||||
|
|
|
|||
|
|
@ -389,37 +389,117 @@ pub(crate) struct ExitSummary {
|
|||
/// `None` when the newest prompt is still unanswered.
|
||||
pub last_response: Option<String>,
|
||||
}
|
||||
/// Resolve leader mode → `(use_leader, policy_disable_reason)`.
|
||||
/// Resolve leader mode, reporting both why it is off and what turned it off.
|
||||
///
|
||||
/// Precedence (highest first): `--no-leader` → `--leader` → eligibility → local
|
||||
/// config `use_leader` → remote `leader_mode` (release-dist) → default off.
|
||||
/// `requested_confinement` then vetoes leader use when `Some` (in-process tools
|
||||
/// stay under the OS sandbox) without reclaiming a shared leader on its own.
|
||||
///
|
||||
/// `policy_disable_reason` is `Some("config"|"remote")` only when leader mode is
|
||||
/// *definitively* off by policy (local `use_leader = false`, or remote
|
||||
/// `leader_mode` fetched as `false`). Unknown remote state (`None` / prefetch
|
||||
/// timeout), the default, `--no-leader`, and ineligibility are `None` — never
|
||||
/// reclaim a leader on an unknown signal.
|
||||
pub fn resolve_use_leader(
|
||||
pub fn resolve_leader_mode<'p>(
|
||||
leader_flag: bool,
|
||||
no_leader_flag: bool,
|
||||
raw_config: &toml::Value,
|
||||
_remote_settings: Option<&xai_grok_shell::util::config::RemoteSettings>,
|
||||
eligible: bool,
|
||||
requested_confinement: Option<&'p str>,
|
||||
) -> LeaderMode<'p> {
|
||||
let (use_leader, policy_disable_reason) = 'policy: {
|
||||
if no_leader_flag {
|
||||
break 'policy (false, None);
|
||||
}
|
||||
if leader_flag {
|
||||
break 'policy (true, None);
|
||||
}
|
||||
if !eligible {
|
||||
break 'policy (false, None);
|
||||
}
|
||||
if let Some(v) = config::use_leader_from_toml_opt(raw_config) {
|
||||
break 'policy (v, (!v).then_some("config"));
|
||||
}
|
||||
#[cfg(feature = "release-dist")]
|
||||
if let Some(remote_val) = _remote_settings.and_then(|s| s.leader_mode) {
|
||||
break 'policy (remote_val, (!remote_val).then_some("remote"));
|
||||
}
|
||||
(false, None)
|
||||
};
|
||||
if let Some(profile) = requested_confinement {
|
||||
return LeaderMode {
|
||||
use_leader: false,
|
||||
policy_disable_reason,
|
||||
disabled_by_confinement: use_leader.then_some(profile),
|
||||
};
|
||||
}
|
||||
LeaderMode {
|
||||
use_leader,
|
||||
policy_disable_reason,
|
||||
disabled_by_confinement: None,
|
||||
}
|
||||
}
|
||||
/// Leader mode as resolved, plus the sandbox profile that overrode it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LeaderMode<'p> {
|
||||
pub use_leader: bool,
|
||||
/// `Some` only when leader mode is *definitively* off by policy, which is
|
||||
/// what licenses reclaiming a leftover leader.
|
||||
pub policy_disable_reason: Option<&'static str>,
|
||||
/// The profile that turned leader mode off, set only when leader mode was
|
||||
/// otherwise on — the case worth telling the user about.
|
||||
pub disabled_by_confinement: Option<&'p str>,
|
||||
}
|
||||
/// The leader-mode decision alone, for callers with nothing to report.
|
||||
///
|
||||
/// See [`resolve_leader_mode`] for the precedence chain and the
|
||||
/// `policy_disable_reason` contract.
|
||||
pub fn resolve_use_leader(
|
||||
leader_flag: bool,
|
||||
no_leader_flag: bool,
|
||||
raw_config: &toml::Value,
|
||||
remote_settings: Option<&xai_grok_shell::util::config::RemoteSettings>,
|
||||
eligible: bool,
|
||||
requested_confinement: Option<&str>,
|
||||
) -> (bool, Option<&'static str>) {
|
||||
if no_leader_flag {
|
||||
return (false, None);
|
||||
}
|
||||
if leader_flag {
|
||||
return (true, None);
|
||||
}
|
||||
if !eligible {
|
||||
return (false, None);
|
||||
}
|
||||
if let Some(v) = config::use_leader_from_toml_opt(raw_config) {
|
||||
return (v, (!v).then_some("config"));
|
||||
}
|
||||
#[cfg(feature = "release-dist")]
|
||||
if let Some(remote_val) = _remote_settings.and_then(|s| s.leader_mode) {
|
||||
return (remote_val, (!remote_val).then_some("remote"));
|
||||
}
|
||||
(false, None)
|
||||
let resolved = resolve_leader_mode(
|
||||
leader_flag,
|
||||
no_leader_flag,
|
||||
raw_config,
|
||||
remote_settings,
|
||||
eligible,
|
||||
requested_confinement,
|
||||
);
|
||||
(resolved.use_leader, resolved.policy_disable_reason)
|
||||
}
|
||||
/// How long the sandbox note stays uncovered before a fullscreen TUI opens over
|
||||
/// it. Paid only when the note was printed and the screen is about to hide it.
|
||||
const SANDBOX_NOTICE_LINGER: std::time::Duration = std::time::Duration::from_millis(1_200);
|
||||
/// Tell the user at startup that the sandbox turned leader mode off.
|
||||
///
|
||||
/// Writes to the dup'd terminal stderr, which survives the TUI's fd-2 redirect
|
||||
/// (`redirect_native_stderr`). A fullscreen TUI still paints over it, leaving
|
||||
/// the line to be read on exit; `leader_disabled_by_sandbox` on the
|
||||
/// leader-mode decision log is the durable record.
|
||||
pub fn warn_leader_disabled_by_sandbox(profile: &str) {
|
||||
xai_grok_shell::util::with_locked_stderr(|stderr| {
|
||||
print_leader_disabled_by_sandbox(profile, stderr)
|
||||
});
|
||||
}
|
||||
/// Says only that the profile was *requested*: enforcement can still fail
|
||||
/// (`apply_sandbox` warns and continues) while the leader is refused either way.
|
||||
///
|
||||
/// Write errors are dropped — `eprintln!` would panic on a closed stderr.
|
||||
fn print_leader_disabled_by_sandbox(profile: &str, w: &mut impl Write) {
|
||||
let _ = writeln!(
|
||||
w,
|
||||
"note: sandbox profile '{profile}' was requested, so leader mode is off for this \
|
||||
session and tool calls stay in this process instead of the shared leader. \
|
||||
Disable the profile at the source that selected it (CLI, env, config, or a \
|
||||
managed requirement) to use the leader."
|
||||
);
|
||||
}
|
||||
/// Join early prefetch to get remote settings (with timeout).
|
||||
///
|
||||
|
|
@ -527,19 +607,32 @@ pub async fn run(
|
|||
let raw_config = xai_grok_shell::config::load_effective_config()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
|
||||
let prefetch_elapsed = startup_start.elapsed();
|
||||
let (use_leader, policy_disable_reason) = resolve_use_leader(
|
||||
let requested_confinement = xai_grok_sandbox::requested_confinement_profile();
|
||||
let LeaderMode {
|
||||
use_leader,
|
||||
policy_disable_reason,
|
||||
disabled_by_confinement,
|
||||
} = resolve_leader_mode(
|
||||
args.leader,
|
||||
args.no_leader,
|
||||
&raw_config,
|
||||
remote_settings.as_ref(),
|
||||
true,
|
||||
requested_confinement,
|
||||
);
|
||||
tracing::info!(
|
||||
use_leader,
|
||||
?policy_disable_reason,
|
||||
sandbox_profile = ?requested_confinement,
|
||||
// The other fields cannot distinguish this from leader mode being off
|
||||
// already while a sandbox is on.
|
||||
leader_disabled_by_sandbox = disabled_by_confinement.is_some(),
|
||||
prefetch_ms = prefetch_elapsed.as_millis() as u64,
|
||||
"pager TUI leader mode resolved"
|
||||
);
|
||||
if let Some(profile) = disabled_by_confinement {
|
||||
warn_leader_disabled_by_sandbox(profile);
|
||||
}
|
||||
if session_startup::chat_mode_conflicts_with_leader(args.chat(), use_leader) {
|
||||
anyhow::bail!("{}", session_startup::CHAT_MODE_LEADER_CONFLICT);
|
||||
}
|
||||
|
|
@ -713,6 +806,9 @@ pub async fn run(
|
|||
multiplexer = ?term_ctx.multiplexer,
|
||||
"resolved fullscreen policy"
|
||||
);
|
||||
if disabled_by_confinement.is_some() && screen_mode.is_fullscreen() {
|
||||
tokio::time::sleep(SANDBOX_NOTICE_LINGER).await;
|
||||
}
|
||||
engage_startup_theme(screen_mode);
|
||||
let minimal_live_rows = config_watcher.current().minimal_live_rows;
|
||||
let (frame_tx, writer_sync, writer_event_rx, writer_thread) =
|
||||
|
|
@ -1614,50 +1710,128 @@ mod tests {
|
|||
#[test]
|
||||
fn no_leader_flag_wins_over_leader_flag_and_config() {
|
||||
let cfg = config_with_leader(true);
|
||||
let (use_leader, reason) = resolve_use_leader(true, true, &cfg, None, true);
|
||||
let (use_leader, reason) = resolve_use_leader(true, true, &cfg, None, true, None);
|
||||
assert!(!use_leader);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
#[test]
|
||||
fn leader_flag_enables() {
|
||||
let (use_leader, reason) = resolve_use_leader(true, false, &empty_config(), None, true);
|
||||
let (use_leader, reason) =
|
||||
resolve_use_leader(true, false, &empty_config(), None, true, None);
|
||||
assert!(use_leader);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
#[test]
|
||||
fn not_eligible_returns_false() {
|
||||
let cfg = config_with_leader(true);
|
||||
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, None, false);
|
||||
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, None, false, None);
|
||||
assert!(!use_leader);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
#[test]
|
||||
fn config_toml_enables() {
|
||||
let cfg = config_with_leader(true);
|
||||
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, None, true);
|
||||
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, None, true, None);
|
||||
assert!(use_leader);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
#[test]
|
||||
fn config_toml_disables() {
|
||||
let cfg = config_with_leader(false);
|
||||
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, None, true);
|
||||
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, None, true, None);
|
||||
assert!(!use_leader);
|
||||
assert_eq!(reason, Some("config"));
|
||||
}
|
||||
#[test]
|
||||
fn default_is_false() {
|
||||
let (use_leader, reason) = resolve_use_leader(false, false, &empty_config(), None, true);
|
||||
let (use_leader, reason) =
|
||||
resolve_use_leader(false, false, &empty_config(), None, true, None);
|
||||
assert!(!use_leader);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
#[test]
|
||||
fn cli_flag_overrides_config() {
|
||||
let cfg = config_with_leader(false);
|
||||
let (use_leader, reason) = resolve_use_leader(true, false, &cfg, None, true);
|
||||
let (use_leader, reason) = resolve_use_leader(true, false, &cfg, None, true, None);
|
||||
assert!(use_leader);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
#[test]
|
||||
fn sandbox_confinement_refuses_leader_even_with_leader_flag_and_config_on() {
|
||||
let cfg = config_with_leader(true);
|
||||
let (use_leader, reason) =
|
||||
resolve_use_leader(true, false, &cfg, None, true, Some("strict"));
|
||||
assert!(!use_leader);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
/// `disabled_by_confinement` for the four leader × sandbox cells, driven by
|
||||
/// every input that can decide leader mode — not just `[cli] use_leader`.
|
||||
#[test]
|
||||
fn matrix_reports_the_profile_only_when_the_sandbox_takes_leader_mode_away() {
|
||||
let on = config_with_leader(true);
|
||||
let off = config_with_leader(false);
|
||||
let sandbox = Some("strict");
|
||||
for (label, leader_flag, cfg) in [
|
||||
("config on", false, &on),
|
||||
("--leader", true, &empty_config()),
|
||||
("--leader over config off", true, &off),
|
||||
] {
|
||||
let resolved = resolve_leader_mode(leader_flag, false, cfg, None, true, sandbox);
|
||||
assert!(!resolved.use_leader, "{label}: leader must be vetoed");
|
||||
assert_eq!(
|
||||
resolved.disabled_by_confinement,
|
||||
Some("strict"),
|
||||
"{label}: the profile that took leader mode away must be named"
|
||||
);
|
||||
}
|
||||
for (label, cfg, expect_leader) in [("leader on", &on, true), ("leader off", &off, false)] {
|
||||
let resolved = resolve_leader_mode(false, false, cfg, None, true, None);
|
||||
assert_eq!(resolved.use_leader, expect_leader, "{label}");
|
||||
assert_eq!(resolved.disabled_by_confinement, None, "{label}");
|
||||
}
|
||||
for (label, leader_flag, no_leader_flag, cfg, eligible) in [
|
||||
("config off", false, false, &off, true),
|
||||
("--no-leader over config on", false, true, &on, true),
|
||||
("default", false, false, &empty_config(), true),
|
||||
("ineligible mode with config on", false, false, &on, false),
|
||||
] {
|
||||
let resolved =
|
||||
resolve_leader_mode(leader_flag, no_leader_flag, cfg, None, eligible, sandbox);
|
||||
assert!(!resolved.use_leader, "{label}");
|
||||
assert_eq!(
|
||||
resolved.disabled_by_confinement, None,
|
||||
"{label}: the sandbox took nothing away, so it must stay silent"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn sandbox_notice_names_the_profile_without_promising_enforcement() {
|
||||
let mut out = Vec::new();
|
||||
print_leader_disabled_by_sandbox("strict", &mut out);
|
||||
let msg = String::from_utf8(out).expect("utf-8");
|
||||
assert!(msg.contains("'strict'"), "must name the profile: {msg}");
|
||||
assert!(
|
||||
msg.contains("was requested"),
|
||||
"must describe the request, not enforcement: {msg}"
|
||||
);
|
||||
assert!(
|
||||
!msg.contains("is active"),
|
||||
"must not claim the profile is enforced: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("Disable the profile at the source"),
|
||||
"must say how to get leader mode back: {msg}"
|
||||
);
|
||||
assert_eq!(msg.lines().count(), 1, "single line: {msg}");
|
||||
}
|
||||
#[test]
|
||||
fn sandbox_confinement_preserves_config_off_reclaim_reason() {
|
||||
let cfg = config_with_leader(false);
|
||||
let (use_leader, reason) =
|
||||
resolve_use_leader(false, false, &cfg, None, true, Some("strict"));
|
||||
assert!(!use_leader);
|
||||
assert_eq!(reason, Some("config"));
|
||||
}
|
||||
fn try_parse_pager(args: &[&str]) -> Result<PagerArgs, clap::Error> {
|
||||
use clap::Parser;
|
||||
PagerArgs::try_parse_from(args)
|
||||
|
|
@ -1688,7 +1862,7 @@ mod tests {
|
|||
#[test]
|
||||
fn no_leader_flag_overrides_config_for_tui_fallback() {
|
||||
let cfg = config_with_leader(true);
|
||||
let (use_leader, reason) = resolve_use_leader(false, true, &cfg, None, true);
|
||||
let (use_leader, reason) = resolve_use_leader(false, true, &cfg, None, true, None);
|
||||
assert!(!use_leader);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
|
|
@ -1708,7 +1882,8 @@ mod tests {
|
|||
}
|
||||
#[test]
|
||||
fn remote_settings_none_falls_through_to_default() {
|
||||
let (use_leader, reason) = resolve_use_leader(false, false, &empty_config(), None, true);
|
||||
let (use_leader, reason) =
|
||||
resolve_use_leader(false, false, &empty_config(), None, true, None);
|
||||
assert!(!use_leader);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
|
|
@ -1720,7 +1895,7 @@ mod tests {
|
|||
..Default::default()
|
||||
};
|
||||
let (use_leader, reason) =
|
||||
resolve_use_leader(false, false, &empty_config(), Some(&rs), true);
|
||||
resolve_use_leader(false, false, &empty_config(), Some(&rs), true, None);
|
||||
assert!(use_leader);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
|
|
@ -1732,7 +1907,7 @@ mod tests {
|
|||
..Default::default()
|
||||
};
|
||||
let (use_leader, reason) =
|
||||
resolve_use_leader(false, false, &empty_config(), Some(&rs), true);
|
||||
resolve_use_leader(false, false, &empty_config(), Some(&rs), true, None);
|
||||
assert!(!use_leader);
|
||||
assert_eq!(reason, Some("remote"));
|
||||
}
|
||||
|
|
@ -1744,7 +1919,7 @@ mod tests {
|
|||
..Default::default()
|
||||
};
|
||||
let (use_leader, reason) =
|
||||
resolve_use_leader(false, false, &empty_config(), Some(&rs), true);
|
||||
resolve_use_leader(false, false, &empty_config(), Some(&rs), true, None);
|
||||
assert!(!use_leader);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
|
|
@ -1756,7 +1931,7 @@ mod tests {
|
|||
..Default::default()
|
||||
};
|
||||
let cfg = config_with_leader(false);
|
||||
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, Some(&rs), true);
|
||||
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, Some(&rs), true, None);
|
||||
assert!(!use_leader);
|
||||
assert_eq!(reason, Some("config"));
|
||||
}
|
||||
|
|
@ -2089,6 +2264,7 @@ mod tests {
|
|||
print_exit_resume_hint(&bare_exit_info("sess-abc", true), 80, &mut w);
|
||||
print_exit_resume_hint(&full_exit_info("sess-abc"), 80, &mut w);
|
||||
print_relaunch_failure_hint(&"exec failed", "sess-xyz", true, &mut w);
|
||||
print_leader_disabled_by_sandbox("strict", &mut w);
|
||||
}
|
||||
/// Close the *read* end so writes on the write end get EPIPE
|
||||
/// (SIGPIPE is SIG_IGN → BrokenPipe, not process death).
|
||||
|
|
@ -2107,5 +2283,6 @@ mod tests {
|
|||
print_exit_resume_hint(&bare_exit_info("pipe-sid", true), 80, &mut writer);
|
||||
print_exit_resume_hint(&full_exit_info("pipe-sid"), 80, &mut writer);
|
||||
print_relaunch_failure_hint(&"exec failed", "pipe-sid", false, &mut writer);
|
||||
print_leader_disabled_by_sandbox("strict", &mut writer);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -299,6 +299,11 @@ pub struct PagerLocalSnapshot {
|
|||
/// language actually in effect when `[ui].voice_stt_language` is unset but
|
||||
/// an explicit `[voice].language` applies.
|
||||
pub voice_stt_language: String,
|
||||
/// Mirrors `AgentView::scheduler_background_loops` — the value the shell
|
||||
/// pinned for THIS session — falling back to
|
||||
/// `AppView::scheduler_background_loops_seed` before the session response
|
||||
/// lands. `/loop` reads it to describe where a scheduled fire runs.
|
||||
pub scheduler_background_loops: bool,
|
||||
}
|
||||
|
||||
impl Default for PagerLocalSnapshot {
|
||||
|
|
@ -323,6 +328,8 @@ impl Default for PagerLocalSnapshot {
|
|||
auto_mode_gate: false,
|
||||
ask_user_question_timeout_enabled: None,
|
||||
voice_stt_language: xai_grok_voice::STT_LANGUAGE_DEFAULT.to_string(),
|
||||
// Matches `resolve_scheduler_background_loops`'s default.
|
||||
scheduler_background_loops: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use agent_client_protocol as acp;
|
||||
use xai_grok_tools::implementations::grok_build::{
|
||||
SCHEDULER_CREATE_TOOL_NAME, loop_schedule_instruction, loop_usage_message,
|
||||
LoopFireMode, SCHEDULER_CREATE_TOOL_NAME, loop_schedule_instruction, loop_usage_message,
|
||||
};
|
||||
|
||||
use crate::slash::command::{CommandExecCtx, CommandResult, ScheduledTaskPreview, SlashCommand};
|
||||
|
|
@ -108,12 +108,17 @@ impl SlashCommand for LoopCommand {
|
|||
LOOP_REQUIRED_TOOLS
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
|
||||
fn run(&self, ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
|
||||
if args.trim().is_empty() {
|
||||
return CommandResult::Message(loop_usage_message().to_string());
|
||||
}
|
||||
|
||||
let (interval_token, prompt) = parse_loop_args(args);
|
||||
let fire_mode = if ctx.pager_state.scheduler_background_loops {
|
||||
LoopFireMode::Detached
|
||||
} else {
|
||||
LoopFireMode::InSession
|
||||
};
|
||||
|
||||
// Show a concrete cadence only for an unambiguous leading token;
|
||||
// otherwise a neutral placeholder, since the authoritative schedule
|
||||
|
|
@ -127,7 +132,7 @@ impl SlashCommand for LoopCommand {
|
|||
CommandResult::InjectSkill {
|
||||
display_text: format!("/loop {args}"),
|
||||
prompt_blocks: vec![acp::ContentBlock::Text(acp::TextContent::new(
|
||||
loop_schedule_instruction(args),
|
||||
loop_schedule_instruction(args, fire_mode),
|
||||
))],
|
||||
display_as_skill: false,
|
||||
scheduled_task_preview: Some(ScheduledTaskPreview {
|
||||
|
|
@ -254,6 +259,10 @@ mod tests {
|
|||
}
|
||||
|
||||
fn run_loop(args: &str) -> CommandResult {
|
||||
run_loop_with_background_loops(args, true)
|
||||
}
|
||||
|
||||
fn run_loop_with_background_loops(args: &str, background_loops: bool) -> CommandResult {
|
||||
let models = ModelState::default();
|
||||
let bundle = BundleState::default();
|
||||
let mut ctx = CommandExecCtx {
|
||||
|
|
@ -262,7 +271,10 @@ mod tests {
|
|||
bundle_state: &bundle,
|
||||
screen_mode: crate::app::ScreenMode::Inline,
|
||||
billing_surface_visible: true,
|
||||
pager_state: crate::settings::PagerLocalSnapshot::default(),
|
||||
pager_state: crate::settings::PagerLocalSnapshot {
|
||||
scheduler_background_loops: background_loops,
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
LoopCommand.run(&mut ctx, args)
|
||||
}
|
||||
|
|
@ -353,14 +365,19 @@ mod tests {
|
|||
#[test]
|
||||
fn run_instruction_matches_shared_helper() {
|
||||
let args = "2h run tests";
|
||||
match run_loop(args) {
|
||||
CommandResult::InjectSkill { prompt_blocks, .. } => {
|
||||
let acp::ContentBlock::Text(text) = &prompt_blocks[0] else {
|
||||
panic!("expected a text prompt block");
|
||||
};
|
||||
assert_eq!(text.text, loop_schedule_instruction(args));
|
||||
for (background_loops, mode) in [
|
||||
(true, LoopFireMode::Detached),
|
||||
(false, LoopFireMode::InSession),
|
||||
] {
|
||||
match run_loop_with_background_loops(args, background_loops) {
|
||||
CommandResult::InjectSkill { prompt_blocks, .. } => {
|
||||
let acp::ContentBlock::Text(text) = &prompt_blocks[0] else {
|
||||
panic!("expected a text prompt block");
|
||||
};
|
||||
assert_eq!(text.text, loop_schedule_instruction(args, mode));
|
||||
}
|
||||
other => panic!("expected InjectSkill, got {other:?}"),
|
||||
}
|
||||
other => panic!("expected InjectSkill, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue