use super::{
agent_types,
config::SubagentConfig,
fleet, manager,
types::{AgentStatus, AgentType, SpawnRequest},
util_notify, util_notify_spans,
};
use crate::{
core::extensions::{DockLine, DockSpan, RichSpan, ToolExecCtx, UiNotifyLevel},
extensions::{
command_arg,
util::{format_tokens, spinner_frame, truncate_chars},
},
modes::interactive::app::App,
utils::{
glyphs::{DEF_DONE, DEF_FAILED, DEF_QUEUED, DEF_STOPPED},
time::format_duration,
},
};
use std::{
sync::{
Arc, Mutex, MutexGuard, OnceLock,
atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
},
time::{Duration, Instant},
};
use strum_macros::{EnumString, IntoStaticStr};
use tokio::runtime::Handle;
pub(crate) type JobId = u64;
const MAX_ACTIVE: usize = 4;
const MAX_RETAINED: usize = 20;
pub(crate) const MAX_ITERATIONS: u32 = 100;
const SLOT_POLL: Duration = Duration::from_millis(100);
const MAX_DOCK_JOBS: usize = 4;
const JOB_AGENT_TYPE: &str = "general-purpose";
const COMPLETION_MARKER: &str = "[goal-complete]";
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
static ACTIVE: AtomicUsize = AtomicUsize::new(0);
#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, IntoStaticStr)]
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
pub(crate) enum JobStatus {
Queued,
Running,
Completed,
Failed,
Stopped,
}
impl JobStatus {
pub(crate) fn as_str(self) -> &'static str {
self.into()
}
pub(crate) fn is_finished(self) -> bool {
matches!(self, Self::Completed | Self::Failed | Self::Stopped)
}
fn icon(self) -> &'static str {
match self {
Self::Queued => DEF_QUEUED,
Self::Running => spinner_frame(),
Self::Completed => DEF_DONE,
Self::Failed => DEF_FAILED,
Self::Stopped => DEF_STOPPED,
}
}
fn color(self) -> (&'static str, &'static str) {
match self {
Self::Running => ("accent", "#8abeb7"),
Self::Completed => ("success", "#b5bd68"),
Self::Failed => ("error", "#cc6666"),
Self::Queued | Self::Stopped => ("muted", "#999999"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct JobSnapshot {
pub id: JobId,
pub goal: String,
pub status: JobStatus,
pub iteration: u32,
pub limit: Option<u32>,
pub tokens: u64,
pub elapsed: Duration,
pub agent_id: Option<String>,
pub latest_result: Option<String>,
pub error: Option<String>,
}
struct Job {
id: JobId,
goal: String,
limit: Option<u32>,
status: JobStatus,
iteration: u32,
tokens: u64,
started: Instant,
finished_at: Option<Instant>,
agent_id: Option<String>,
latest_result: Option<String>,
error: Option<String>,
cancel: Arc<AtomicBool>,
}
impl Job {
fn snapshot(&self) -> JobSnapshot {
JobSnapshot {
id: self.id,
goal: self.goal.clone(),
status: self.status,
iteration: self.iteration,
limit: self.limit,
tokens: self.tokens,
elapsed: self
.finished_at
.unwrap_or_else(Instant::now)
.duration_since(self.started),
agent_id: self.agent_id.clone(),
latest_result: self.latest_result.clone(),
error: self.error.clone(),
}
}
}
fn jobs() -> &'static Mutex<Vec<Job>> {
static S: OnceLock<Mutex<Vec<Job>>> = OnceLock::new();
S.get_or_init(|| Mutex::new(Vec::new()))
}
fn lock_jobs() -> MutexGuard<'static, Vec<Job>> {
jobs().lock().unwrap_or_else(|e| e.into_inner())
}
fn update(id: JobId, f: impl FnOnce(&mut Job)) {
let mut list = lock_jobs();
if let Some(job) = list.iter_mut().find(|j| j.id == id) {
f(job);
}
}
pub(crate) fn snapshots() -> Vec<JobSnapshot> {
lock_jobs().iter().map(Job::snapshot).collect()
}
pub(crate) fn has_live() -> bool {
lock_jobs().iter().any(|j| !j.status.is_finished())
}
pub(crate) fn start(goal: &str, limit: Option<u32>) -> Result<JobId, String> {
let goal = goal.trim();
if goal.is_empty() {
return Err("the job goal cannot be empty".to_string());
}
let Some((ctx, handle)) = fleet::cached_ctx() else {
return Err(
"cannot start an autoresearch job: this session has no execution context yet"
.to_string(),
);
};
Ok(start_with(ctx, handle, goal, limit))
}
pub(crate) fn start_with(
ctx: ToolExecCtx,
handle: Handle,
goal: &str,
limit: Option<u32>,
) -> JobId {
let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
let limit = limit.map(|limit| limit.clamp(1, MAX_ITERATIONS));
let cancel = Arc::new(AtomicBool::new(false));
{
let mut list = lock_jobs();
list.push(Job {
id,
goal: goal.to_string(),
limit,
status: JobStatus::Queued,
iteration: 0,
tokens: 0,
started: Instant::now(),
finished_at: None,
agent_id: None,
latest_result: None,
error: None,
cancel: cancel.clone(),
});
prune(&mut list);
}
handle.spawn(run(id, ctx, goal.to_string(), limit, cancel));
id
}
fn prune(list: &mut Vec<Job>) {
while list.len() > MAX_RETAINED {
let Some(position) = list.iter().position(|job| job.status.is_finished()) else {
break;
};
list.remove(position);
}
}
async fn run(
id: JobId,
ctx: ToolExecCtx,
goal: String,
limit: Option<u32>,
cancel: Arc<AtomicBool>,
) {
if !acquire_slot(&cancel).await {
settle(id, JobStatus::Stopped, None);
return;
}
let _slot = SlotGuard;
update(id, |job| job.status = JobStatus::Running);
let mut iteration = 1_u32;
let mut child: Option<String> = None;
loop {
if cancel.load(Ordering::SeqCst) {
settle(id, JobStatus::Stopped, None);
return;
}
update(id, |job| job.iteration = iteration);
let Some((ty, cfg)) = resolve_type(&ctx.cwd) else {
settle(
id,
JobStatus::Failed,
Some(format!("agent type {JOB_AGENT_TYPE} is not available")),
);
return;
};
let mut iter_ctx = ctx.clone();
iter_ctx.parent_abort = Arc::new(AtomicBool::new(false));
let mut req = SpawnRequest::from_type(
&ty,
&cfg,
iteration_prompt(&goal, iteration, limit),
format!("autoresearch: {}", truncate_chars(&goal, 40)),
);
req.run_in_background = false;
req.inherit_context = iteration == 1;
req.persist_session = ty.persist_session.unwrap_or(true);
req.resume = child.clone();
req.workflow_owned = true;
let on_id: Option<Arc<dyn Fn(String) + Send + Sync>> =
Some(Arc::new(move |agent_id: String| {
update(id, |job| job.agent_id = Some(agent_id));
}));
let cancel_wait = cancel.clone();
let mut dispatch = Box::pin(manager::dispatch_with_id(&iter_ctx, &ty, req, on_id));
let dispatched = tokio::select! {
result = &mut dispatch => match result {
Ok(dispatched) => dispatched,
Err(e) => {
settle(id, JobStatus::Failed, Some(e));
return;
}
},
() = wait_cancel(&cancel_wait) => {
if let Some(agent_id) = agent_of(id) {
_ = manager::stop(&agent_id);
}
settle(id, JobStatus::Stopped, None);
return;
}
};
let agent_id = dispatched.id;
let record = manager::record(&agent_id);
let status = record.as_ref().map_or(AgentStatus::Error, |r| r.status);
let result = record.as_ref().and_then(|r| r.result.clone());
let tokens = record.as_ref().map_or(0, |r| r.usage.display_total());
update(id, |job| {
job.agent_id = Some(agent_id.clone());
job.tokens = job.tokens.saturating_add(tokens);
job.latest_result = result.clone();
});
child = Some(agent_id);
if cancel.load(Ordering::SeqCst)
|| matches!(status, AgentStatus::Aborted | AgentStatus::Stopped)
{
settle(id, JobStatus::Stopped, None);
return;
}
if record.is_none() || status == AgentStatus::Error {
settle(
id,
JobStatus::Failed,
Some(result.unwrap_or_else(|| "the sub-agent ended with an error".to_string())),
);
return;
}
if result.as_deref().is_some_and(contains_completion_marker) {
settle(id, JobStatus::Completed, None);
return;
}
if limit == Some(iteration) {
settle(id, JobStatus::Completed, None);
return;
}
iteration = iteration.saturating_add(1);
}
}
fn settle(id: JobId, status: JobStatus, error: Option<String>) {
let snapshot = {
let mut list = lock_jobs();
let Some(job) = list.iter_mut().find(|job| job.id == id) else {
return;
};
job.status = status;
job.finished_at = Some(Instant::now());
if error.is_some() {
job.error = error;
}
job.snapshot()
};
notify_settled(&snapshot);
}
fn notify_settled(snapshot: &JobSnapshot) {
let goal = truncate_chars(&snapshot.goal, 40);
match snapshot.status {
JobStatus::Completed => util_notify(
&format!(
"autoresearch job #{} completed · {} · {} tok · {} · {goal} · /agents result {} for the answer",
snapshot.id,
progress(snapshot),
format_tokens(snapshot.tokens),
format_duration(snapshot.elapsed.as_millis() as u64),
snapshot.agent_id.as_deref().unwrap_or("(no record)"),
),
UiNotifyLevel::Success,
),
JobStatus::Failed => util_notify(
&format!(
"autoresearch job #{} failed · {} · {goal} · error: {}",
snapshot.id,
progress(snapshot),
truncate_chars(snapshot.error.as_deref().unwrap_or("unknown error"), 120),
),
UiNotifyLevel::Warning,
),
JobStatus::Queued | JobStatus::Running | JobStatus::Stopped => {}
}
}
async fn acquire_slot(cancel: &AtomicBool) -> bool {
loop {
if cancel.load(Ordering::SeqCst) {
return false;
}
let acquired = ACTIVE
.try_update(Ordering::SeqCst, Ordering::SeqCst, |n| {
(n < MAX_ACTIVE).then_some(n + 1)
})
.is_ok();
if acquired {
return true;
}
tokio::time::sleep(SLOT_POLL).await;
}
}
struct SlotGuard;
impl Drop for SlotGuard {
fn drop(&mut self) {
_ = ACTIVE.try_update(Ordering::SeqCst, Ordering::SeqCst, |n| {
Some(n.saturating_sub(1))
});
}
}
async fn wait_cancel(cancel: &AtomicBool) {
while !cancel.load(Ordering::SeqCst) {
tokio::time::sleep(SLOT_POLL).await;
}
}
fn agent_of(id: JobId) -> Option<String> {
lock_jobs()
.iter()
.find(|job| job.id == id)
.and_then(|job| job.agent_id.clone())
}
fn resolve_type(cwd: &str) -> Option<(AgentType, SubagentConfig)> {
let cfg = manager::config();
let roster = agent_types::discover(cwd);
agent_types::resolve(&roster.types, JOB_AGENT_TYPE)
.ok()
.cloned()
.map(|ty| (ty, cfg))
}
pub(crate) fn stop(id: JobId) -> Result<(), String> {
let agent_id = {
let mut list = lock_jobs();
let Some(job) = list.iter_mut().find(|j| j.id == id) else {
return Err(format!("unknown autoresearch job: {id}"));
};
if job.status.is_finished() {
return Err(format!(
"autoresearch job #{id} is already {}",
job.status.as_str()
));
}
job.cancel.store(true, Ordering::SeqCst);
job.agent_id.clone()
};
if let Some(agent_id) = agent_id {
_ = manager::stop(&agent_id);
}
Ok(())
}
pub(crate) fn cancel_all() {
let running: Vec<String> = {
let mut list = lock_jobs();
let mut running = Vec::new();
for job in list.iter_mut().filter(|j| !j.status.is_finished()) {
job.cancel.store(true, Ordering::SeqCst);
if let Some(agent_id) = job.agent_id.clone() {
running.push(agent_id);
}
}
running
};
for agent_id in running {
_ = manager::stop(&agent_id);
}
}
fn contains_completion_marker(result: &str) -> bool {
result.to_ascii_lowercase().contains(COMPLETION_MARKER)
}
fn iteration_prompt(goal: &str, iteration: u32, limit: Option<u32>) -> String {
let position = limit.map_or_else(
|| format!("iteration {iteration}"),
|limit| format!("iteration {iteration} of {limit}"),
);
let common = format!(
"You are in {position} for this goal:\n\n{goal}\n\nWork directly in the shared repository. Make one useful, verified unit of progress. Do not ask for permission. End the answer with exactly [goal-complete] if the goal is fully achieved and verified. Otherwise end with exactly [continue]."
);
format!(
"Autoresearch job. On the first iteration, establish a repeatable baseline and success metric. On each iteration, test one small idea, run the same verification, keep an improvement, and revert a regression. Protect existing behavior with focused tests. Record the measured result and the decision in the answer.\n\n{common}"
)
}
pub(crate) fn parse_request(arg: &str) -> Result<(String, Option<u32>), String> {
let arg = arg.trim();
let (goal, limit) = match arg.rsplit_once("--iterations") {
Some((goal, value)) => {
let value = value.trim();
if value.is_empty() || value.split_whitespace().count() != 1 {
return Err("use --iterations N at the end of the job goal".to_string());
}
let limit = value
.parse::<u32>()
.ok()
.filter(|limit| *limit > 0)
.ok_or_else(|| "iterations must be a positive number".to_string())?;
(goal.trim(), Some(limit))
}
None => (arg, None),
};
if goal.is_empty() {
return Err("the job goal cannot be empty".to_string());
}
Ok((goal.to_string(), limit))
}
fn parse_job_id(raw: &str) -> Option<JobId> {
let raw = raw.trim();
let raw = raw.strip_prefix('#').unwrap_or(raw);
let raw = raw.strip_prefix("autoresearch-").unwrap_or(raw);
raw.parse::<JobId>().ok().filter(|id| *id > 0)
}
fn progress(snapshot: &JobSnapshot) -> String {
match snapshot.limit {
Some(limit) => format!("{}/{}", snapshot.iteration, limit),
None => snapshot.iteration.to_string(),
}
}
pub(crate) fn dock_lines() -> Vec<DockLine> {
let (active, rows) = {
let list = lock_jobs();
let live: Vec<JobSnapshot> = list
.iter()
.filter(|job| !job.status.is_finished())
.map(Job::snapshot)
.collect();
let active = live.len();
(
active,
live.into_iter().take(MAX_DOCK_JOBS).collect::<Vec<_>>(),
)
};
if rows.is_empty() {
return Vec::new();
}
let mut lines: Vec<DockLine> = vec![vec![
DockSpan::new("accent", "#8abeb7", "Autoresearch"),
DockSpan::new(
"dim",
"#666666",
format!(" · {active} job{}", if active == 1 { "" } else { "s" }),
),
]];
for snapshot in rows {
lines.push(dock_line(&snapshot));
}
lines
}
fn dock_line(snapshot: &JobSnapshot) -> DockLine {
let (key, fallback) = snapshot.status.color();
vec![
DockSpan::new(key, fallback, format!("{} ", snapshot.status.icon())),
DockSpan::new("accent", "#8abeb7", format!("#{}", snapshot.id)),
DockSpan::new("dim", "#666666", format!(" {}", progress(snapshot))),
DockSpan::new(
"dim",
"#666666",
format!(
" · {} tok · {}",
format_tokens(snapshot.tokens),
format_duration(snapshot.elapsed.as_millis() as u64)
),
),
DockSpan::new(
"text",
"#d0d0d0",
format!(" · {}", truncate_chars(&snapshot.goal, 40)),
),
]
}
fn list_spans() -> Vec<RichSpan> {
let list = snapshots();
if list.is_empty() {
return vec![RichSpan::plain(
"No autoresearch jobs have run in this session.",
)];
}
let live = list
.iter()
.filter(|snapshot| !snapshot.status.is_finished())
.count();
let mut spans = vec![RichSpan::plain(format!(
"Autoresearch · {live} active · {} total\n",
list.len()
))];
for snapshot in &list {
let (key, _) = snapshot.status.color();
spans.push(RichSpan::fg(key, format!("{} ", snapshot.status.icon())));
spans.push(RichSpan::plain(format!(
"#{} {} · {} · {} tok · {} · {}\n",
snapshot.id,
snapshot.status.as_str(),
progress(snapshot),
crate::extensions::util::format_tokens(snapshot.tokens),
format_duration(snapshot.elapsed.as_millis() as u64),
truncate_chars(&snapshot.goal, 60),
)));
if let Some(error) = snapshot.error.as_deref() {
spans.push(RichSpan::plain(format!(
" error: {}\n",
truncate_chars(error, 120)
)));
} else if let Some(result) = snapshot.latest_result.as_deref() {
spans.push(RichSpan::plain(format!(
" last: {}\n",
truncate_chars(result, 120)
)));
}
}
spans.push(RichSpan::plain(
"Stop one with /autoresearch stop <id> (e.g. /autoresearch stop 3).",
));
spans
}
pub(crate) fn command(_app: &mut App, raw: &str) -> bool {
let arg = command_arg(raw).trim();
if arg.is_empty() {
util_notify_spans(list_spans(), UiNotifyLevel::Info);
return false;
}
let (head, rest) = match arg.split_once(char::is_whitespace) {
Some((head, rest)) => (head, rest.trim()),
None => (arg, ""),
};
if head == "stop" {
if rest.is_empty() {
util_notify("usage: /autoresearch stop <id>", UiNotifyLevel::Warning);
return false;
}
let Some(id) = parse_job_id(rest) else {
util_notify(
&format!("unknown autoresearch job: {rest}"),
UiNotifyLevel::Warning,
);
return false;
};
match stop(id) {
Ok(()) => util_notify(
&format!("stopping autoresearch job #{id}"),
UiNotifyLevel::Info,
),
Err(e) => util_notify(&e, UiNotifyLevel::Warning),
}
return false;
}
let (goal, limit) = match parse_request(arg) {
Ok(parsed) => parsed,
Err(e) => {
util_notify(&e, UiNotifyLevel::Warning);
return false;
}
};
match start(&goal, limit) {
Ok(id) => {
let schedule = match limit {
Some(limit) => format!("up to {limit} iterations"),
None => "no iteration limit".to_string(),
};
util_notify(
&format!(
"started autoresearch job #{id} · {schedule} · /autoresearch lists jobs, /agents shows its sub-agent"
),
UiNotifyLevel::Info,
);
}
Err(e) => util_notify(&e, UiNotifyLevel::Warning),
}
false
}
#[cfg(test)]
pub(crate) fn reset_for_test() {
lock_jobs().clear();
ACTIVE.store(0, Ordering::SeqCst);
}
#[cfg(test)]
pub(crate) fn snapshot(id: JobId) -> Option<JobSnapshot> {
lock_jobs().iter().find(|j| j.id == id).map(Job::snapshot)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::extensions::{
ExtensionUiRequest, SubAgentControls, SubAgentRunner, SubAgentSpec,
};
use futures_util::future::BoxFuture;
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Runtime::new().unwrap()
}
enum Reply {
Text(String),
Fail(String),
Hold,
}
struct ScriptRunner {
reply: Reply,
session: Option<String>,
abort: Arc<AtomicBool>,
prompts: Arc<Mutex<Vec<String>>>,
}
impl SubAgentRunner for ScriptRunner {
fn controls(&self) -> SubAgentControls {
SubAgentControls {
steer: Arc::new(|_message: String| {}),
abort: self.abort.clone(),
session_path: self.session.clone(),
}
}
fn run(&mut self, prompt: String) -> BoxFuture<'_, Result<String, String>> {
self.prompts.lock().unwrap().push(prompt);
let reply = match &self.reply {
Reply::Text(text) => Ok(text.clone()),
Reply::Fail(error) => Err(error.clone()),
Reply::Hold => Err("Operation aborted".to_string()),
};
let abort = self.abort.clone();
let hold = matches!(self.reply, Reply::Hold);
Box::pin(async move {
if hold {
while !abort.load(Ordering::SeqCst) {
tokio::time::sleep(Duration::from_millis(5)).await;
}
}
reply
})
}
}
#[derive(Default)]
struct Capture {
prompts: Arc<Mutex<Vec<String>>>,
resumes: Arc<Mutex<Vec<Option<String>>>>,
inherits: Arc<Mutex<Vec<bool>>>,
}
fn script_ctx(replies: Vec<Reply>) -> (ToolExecCtx, Capture) {
let capture = Capture::default();
let index = Arc::new(AtomicUsize::new(0));
let session = "/tmp/autoresearch-child.jsonl".to_string();
let (prompts, resumes, inherits) = (
capture.prompts.clone(),
capture.resumes.clone(),
capture.inherits.clone(),
);
let make: Arc<crate::core::extensions::MakeSubAgentFn> =
Arc::new(move |spec: SubAgentSpec| {
resumes
.lock()
.unwrap()
.push(spec.resume_session_path.clone());
inherits.lock().unwrap().push(spec.inherit_context);
let call = index.fetch_add(1, Ordering::SeqCst);
let reply = match replies.get(call) {
Some(Reply::Text(text)) => Reply::Text(text.clone()),
Some(Reply::Fail(error)) => Reply::Fail(error.clone()),
Some(Reply::Hold) | None => Reply::Hold,
};
Ok(Box::new(ScriptRunner {
reply,
session: Some(session.clone()),
abort: Arc::new(AtomicBool::new(false)),
prompts: prompts.clone(),
}) as Box<dyn SubAgentRunner>)
});
let ctx = ToolExecCtx {
execute_tool: crate::core::extensions::unavailable_tool_exec(),
parent_tool_call_id: None,
nested_calls: Default::default(),
session_branch_entries: Default::default(),
script_tools: Default::default(),
cwd: std::env::temp_dir().to_string_lossy().to_string(),
make_sub_agent: make,
parent_abort: Arc::new(AtomicBool::new(false)),
agent_id: None,
depth: 0,
parent_model: None,
script_call: false,
};
(ctx, capture)
}
fn run_job(replies: Vec<Reply>, goal: &str, limit: Option<u32>) -> (JobSnapshot, Capture) {
let (ctx, capture) = script_ctx(replies);
let runtime = rt();
let snapshot = runtime.block_on(async move {
let id = start_with(ctx, tokio::runtime::Handle::current(), goal, limit);
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if let Some(snapshot) = snapshot(id)
&& snapshot.status.is_finished()
{
return snapshot;
}
assert!(Instant::now() < deadline, "作业没有在预期时间内终结");
tokio::time::sleep(Duration::from_millis(5)).await;
}
});
(snapshot, capture)
}
struct Setup {
_dir: crate::test_support::AgentDirGuard,
_lock: super::manager::TestLock,
_auth: std::sync::MutexGuard<'static, ()>,
}
impl Drop for Setup {
fn drop(&mut self) {
while crate::core::extensions::take_pending_ui().is_some() {}
}
}
fn setup() -> Setup {
let auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let lock = super::manager::test_lock();
let dir = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
reset_for_test();
while crate::core::extensions::take_pending_ui().is_some() {}
Setup {
_dir: dir,
_lock: lock,
_auth: auth,
}
}
fn drain_ui() -> (Vec<(String, UiNotifyLevel)>, bool) {
let mut notes: Vec<(String, UiNotifyLevel)> = Vec::new();
let mut continuation = false;
while let Some(request) = crate::core::extensions::take_pending_ui() {
match request {
ExtensionUiRequest::Notify { text, level } => notes.push((text, level)),
ExtensionUiRequest::NotifyRich { spans, level } => notes.push((
spans.into_iter().map(|span| span.text).collect::<String>(),
level,
)),
ExtensionUiRequest::Continuation { .. } => continuation = true,
_ => {}
}
}
(notes, continuation)
}
fn drain_settled_ui() -> (Vec<(String, UiNotifyLevel)>, bool) {
rt().block_on(async {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let drained = drain_ui();
if !drained.0.is_empty() || Instant::now() >= deadline {
return drained;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
}
#[test]
fn prompts_define_progress_and_completion() {
let prompt = iteration_prompt("make it faster", 2, Some(10));
assert!(prompt.contains("iteration 2 of 10"), "{prompt}");
assert!(prompt.contains("[goal-complete]"), "{prompt}");
assert!(prompt.contains("[continue]"), "{prompt}");
}
#[test]
fn unlimited_prompts_do_not_claim_a_limit() {
let prompt = iteration_prompt("fix tests", 2, None);
assert!(prompt.contains("iteration 2 for this goal"), "{prompt}");
assert!(!prompt.contains("iteration 2 of"), "{prompt}");
}
#[test]
fn prompts_ask_for_a_baseline_and_a_regression_revert() {
let prompt = iteration_prompt("make it faster", 1, None);
assert!(prompt.contains("baseline"), "{prompt}");
assert!(prompt.contains("success metric"), "{prompt}");
assert!(prompt.contains("revert a regression"), "{prompt}");
assert!(prompt.contains("focused tests"), "{prompt}");
}
#[test]
fn completion_marker_is_case_insensitive() {
assert!(contains_completion_marker("done\n[GOAL-COMPLETE]"));
assert!(contains_completion_marker("[goal-complete]"));
assert!(!contains_completion_marker("keep going\n[continue]"));
}
#[test]
fn parse_request_reads_a_trailing_iterations_flag() {
let (goal, limit) = parse_request("reduce render time --iterations 20").unwrap();
assert_eq!(goal, "reduce render time");
assert_eq!(limit, Some(20));
let (goal, limit) = parse_request(" no limit here ").unwrap();
assert_eq!(goal, "no limit here");
assert_eq!(limit, None);
}
#[test]
fn parse_request_rejects_bad_limits_and_empty_goals() {
assert_eq!(
parse_request("--iterations 0").unwrap_err(),
"iterations must be a positive number"
);
assert_eq!(
parse_request("x --iterations 0").unwrap_err(),
"iterations must be a positive number"
);
assert_eq!(
parse_request("x --iterations nope").unwrap_err(),
"iterations must be a positive number"
);
assert_eq!(
parse_request("x --iterations 5 6").unwrap_err(),
"use --iterations N at the end of the job goal"
);
assert_eq!(
parse_request("x --iterations").unwrap_err(),
"use --iterations N at the end of the job goal"
);
assert_eq!(
parse_request(" ").unwrap_err(),
"the job goal cannot be empty"
);
}
#[test]
fn parse_job_id_accepts_the_documented_shapes() {
assert_eq!(parse_job_id("3"), Some(3));
assert_eq!(parse_job_id("#3"), Some(3));
assert_eq!(parse_job_id("autoresearch-3"), Some(3));
assert_eq!(parse_job_id("0"), None);
assert_eq!(parse_job_id("abc"), None);
}
#[test]
fn a_job_loops_until_the_completion_marker() {
let _setup = setup();
let (snapshot, capture) = run_job(
vec![
Reply::Text("first change\n[continue]".to_string()),
Reply::Text("verified\n[goal-complete]".to_string()),
],
"finish it",
None,
);
assert_eq!(snapshot.status, JobStatus::Completed, "{snapshot:?}");
assert_eq!(snapshot.iteration, 2, "{snapshot:?}");
assert_eq!(snapshot.limit, None);
assert!(
snapshot
.latest_result
.as_deref()
.is_some_and(contains_completion_marker),
"{snapshot:?}"
);
assert!(snapshot.agent_id.is_some(), "记录 id 必须留痕");
let resumes = capture.resumes.lock().unwrap().clone();
assert_eq!(resumes.len(), 2, "两轮派发");
assert_eq!(resumes[0], None);
assert_eq!(resumes[1].as_deref(), Some("/tmp/autoresearch-child.jsonl"));
let inherits = capture.inherits.lock().unwrap().clone();
assert_eq!(inherits, vec![true, false], "只有首轮 fork 父级上下文");
let prompts = capture.prompts.lock().unwrap().clone();
assert_eq!(prompts.len(), 2, "{prompts:?}");
assert!(prompts[0].contains("iteration 1 for this goal"));
assert!(prompts[1].contains("iteration 2 for this goal"));
}
#[test]
fn a_job_stops_at_the_iteration_limit() {
let _setup = setup();
let (snapshot, capture) = run_job(
vec![
Reply::Text("[continue]".to_string()),
Reply::Text("[continue]".to_string()),
],
"keep going",
Some(2),
);
assert_eq!(snapshot.status, JobStatus::Completed, "{snapshot:?}");
assert_eq!(snapshot.iteration, 2, "{snapshot:?}");
assert_eq!(snapshot.limit, Some(2));
assert_eq!(capture.prompts.lock().unwrap().len(), 2, "到上限就不再派发");
}
#[test]
fn iterations_are_clamped_to_the_maximum() {
let _setup = setup();
let (snapshot, _) = run_job(
vec![Reply::Text("[goal-complete]".to_string())],
"x",
Some(9_999),
);
assert_eq!(snapshot.limit, Some(MAX_ITERATIONS));
}
#[test]
fn a_job_fails_when_the_child_errors() {
let _setup = setup();
let (snapshot, capture) = run_job(
vec![Reply::Fail("provider exploded".to_string())],
"boom",
None,
);
assert_eq!(snapshot.status, JobStatus::Failed, "{snapshot:?}");
assert_eq!(snapshot.iteration, 1);
assert!(
snapshot
.error
.as_deref()
.is_some_and(|e| e.contains("exploded")),
"{snapshot:?}"
);
assert_eq!(capture.prompts.lock().unwrap().len(), 1, "失败即收工");
}
#[test]
fn start_without_an_execution_context_reports_an_error() {
let _lock = super::manager::test_lock();
fleet::forget_ctx_for_test();
let error = start("do something", None).unwrap_err();
assert!(error.contains("no execution context"), "{error}");
}
#[test]
fn stop_ends_a_running_job() {
let _setup = setup();
let (ctx, _capture) = script_ctx(vec![Reply::Hold]);
let runtime = rt();
let snapshot = runtime.block_on(async move {
let id = start_with(ctx, tokio::runtime::Handle::current(), "hang", None);
let deadline = Instant::now() + Duration::from_secs(5);
while snapshot(id).is_none_or(|s| s.iteration == 0) {
assert!(Instant::now() < deadline, "作业没有开始");
tokio::time::sleep(Duration::from_millis(5)).await;
}
stop(id).expect("停止运行中的作业");
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let current = snapshot(id).expect("作业还在表里");
if current.status.is_finished() {
return current;
}
assert!(Instant::now() < deadline, "停止没有生效");
tokio::time::sleep(Duration::from_millis(5)).await;
}
});
assert_eq!(snapshot.status, JobStatus::Stopped, "{snapshot:?}");
assert!(
stop(snapshot.id).is_err(),
"已终结的作业不能再停(应报错而不是重放)"
);
let (notes, _) = drain_ui();
assert!(notes.is_empty(), "用户主动停止不该再弹通知:{notes:?}");
}
#[test]
fn settled_jobs_notify_the_user_without_touching_the_conversation() {
let _setup = setup();
let (done, _) = run_job(
vec![Reply::Text("[goal-complete]".to_string())],
"ship it",
None,
);
let (notes, continuation) = drain_settled_ui();
assert!(!continuation, "完成通知不该往主会话注入消息");
assert_eq!(notes.len(), 1, "一次终结只弹一条:{notes:?}");
let (text, level) = ¬es[0];
assert_eq!(*level, UiNotifyLevel::Success, "{text}");
assert!(
text.contains(&format!("autoresearch job #{} completed · 1 ·", done.id)),
"{text}"
);
assert!(text.contains("ship it"), "{text}");
assert!(text.contains("/agents result"), "{text}");
let (failed, _) = run_job(
vec![Reply::Fail("provider exploded".to_string())],
"explode",
Some(3),
);
let (notes, continuation) = drain_settled_ui();
assert!(!continuation, "失败通知同样不该注入消息");
assert_eq!(notes.len(), 1, "{notes:?}");
let (text, level) = ¬es[0];
assert_eq!(*level, UiNotifyLevel::Warning, "{text}");
assert!(
text.contains(&format!("autoresearch job #{} failed · 1/3", failed.id)),
"{text}"
);
assert!(text.contains("provider exploded"), "{text}");
}
#[test]
fn cancel_all_leaves_finished_jobs_alone() {
let _setup = setup();
let (done, _) = run_job(
vec![Reply::Text("[goal-complete]".to_string())],
"done",
None,
);
assert_eq!(done.status, JobStatus::Completed);
cancel_all();
let after = snapshot(done.id).expect("已终结作业仍在表里");
assert_eq!(
after.status,
JobStatus::Completed,
"已终结的不该被改成 stopped"
);
assert!(!has_live(), "没有未终结作业");
}
#[test]
fn dock_lists_only_unfinished_jobs() {
let _setup = setup();
assert!(dock_lines().is_empty(), "没有作业时不占位");
let (ctx, _capture) = script_ctx(vec![Reply::Hold]);
let runtime = rt();
let id = runtime.block_on(async {
let id = start_with(ctx, tokio::runtime::Handle::current(), "watch me", None);
let deadline = Instant::now() + Duration::from_secs(5);
while !has_live() {
assert!(Instant::now() < deadline, "作业没有开始");
tokio::time::sleep(Duration::from_millis(5)).await;
}
id
});
let text: Vec<String> = dock_lines()
.iter()
.map(|line| line.iter().map(|span| span.text.as_str()).collect())
.collect();
assert!(
text.iter().any(|line| line.contains("Autoresearch")),
"{text:?}"
);
assert!(
text.iter().any(|line| line.contains(&format!("#{id}"))),
"{text:?}"
);
assert!(
text.iter().any(|line| line.contains("watch me")),
"{text:?}"
);
assert!(text.iter().any(|line| line.contains("1 job")), "{text:?}");
stop(id).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while has_live() {
assert!(Instant::now() < deadline, "停止没有生效");
std::thread::sleep(Duration::from_millis(5));
}
assert!(dock_lines().is_empty(), "终结后 dock 段消失");
}
#[test]
fn list_spans_report_jobs_and_failures() {
let _setup = setup();
let empty: String = list_spans().iter().map(|s| s.text.as_str()).collect();
assert!(empty.contains("No autoresearch jobs"), "{empty}");
let (snapshot, _) = run_job(vec![Reply::Fail("nope".to_string())], "explode", Some(3));
assert_eq!(snapshot.status, JobStatus::Failed);
let text: String = list_spans().iter().map(|s| s.text.as_str()).collect();
assert!(text.contains("Autoresearch · 0 active · 1 total"), "{text}");
assert!(
text.contains(&format!("#{} failed · 1/3", snapshot.id)),
"{text}"
);
assert!(text.contains("error: nope"), "{text}");
assert!(text.contains("/autoresearch stop <id>"), "{text}");
}
}