mod team_cli; mod ui; use anyhow::{anyhow, Context, Result}; use lazyboy_core::{ execute_tool, is_completion_tool, load_or_create, max_rounds_budget, max_rounds_total_budget, messages_char_len, run_agent, save_session, stream_chat, tool_definitions, truncate_messages, AgentEvent, AgentVerdict, BoxHub, ChatMessage, Config, InputBroker, Runtime, Session, ToolContext, AGENT_SYSTEM, }; use serde_json::json; use std::io::{self, Write}; use std::path::PathBuf; use std::process::ExitCode; const CHAT_SYSTEM: &str = "You are LazyBoy, a concise local coding assistant. Prefer clear, short answers. Traditional Chinese is welcome when the user writes in Chinese."; #[tokio::main] async fn main() -> ExitCode { if let Err(err) = run().await { eprintln!("error: {err:#}"); return if err.downcast_ref::().is_some() { ExitCode::from(130) } else { ExitCode::FAILURE }; } ExitCode::SUCCESS } #[derive(Debug)] struct CancelledExit; impl std::fmt::Display for CancelledExit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("agent cancelled") } } impl std::error::Error for CancelledExit {} fn render_agent_event(event: &AgentEvent) { ui::render_event(event); } fn start_input() -> std::sync::Arc { let input = InputBroker::stdin(); let signals = input.clone(); tokio::spawn(async move { while tokio::signal::ctrl_c().await.is_ok() { signals.interrupt(); } }); input } async fn execute_turn( config: &Config, session: &mut Session, ctx: &ToolContext, input: &std::sync::Arc, ) -> Result { input.begin(); let runtime = Runtime::for_session(session, input.clone()); runtime.set_event_handler(render_agent_event); let turn_ctx = ctx.clone().with_runtime(runtime.clone()); let result = run_agent( config, &mut session.messages, &turn_ctx, max_rounds_budget(), ) .await; input.end(); runtime.sync_session(session); session.last_browser_url = turn_ctx .last_browser_url_value() .or(session.last_browser_url.clone()); let verdict = result.unwrap_or_else(|error| AgentVerdict::Failed(format!("runtime failed: {error:#}"))); session.last_verdict = Some(verdict.kind().into()); session.last_message = Some(verdict.message().into()); session.touch(); save_session(session)?; Ok(verdict) } async fn run() -> Result<()> { lazyboy_core::bootstrap_env(); let mut args = std::env::args().skip(1).collect::>(); if args.is_empty() { print_help(); return Ok(()); } let cmd = args.remove(0); match cmd.as_str() { "serve" => lazyboy_core::team::serve().await, "agents" => team_cli::agents(&args).await, "agent" if args.first().map(String::as_str) == Some("--name") => { team_cli::chat(args.get(1).ok_or_else(|| anyhow!("missing agent name"))?).await } "chat" => cmd_chat().await, "run" => cmd_run(&args).await, "agent" => cmd_agent(&args).await, "smoke" => cmd_smoke().await, "computer" => cmd_computer().await, "web" => lazyboy_core::serve_web(lazyboy_core::WebListen::default()).await, "version" | "-V" | "--version" => { println!("lazyboy {}", env!("CARGO_PKG_VERSION")); Ok(()) } "help" | "-h" | "--help" => { print_help(); Ok(()) } other => { eprintln!("unknown command: {other}\n"); print_help(); Err(anyhow!("unknown command")) } } } fn print_help() { println!( "\ LazyBoy — minimal local CLI agent USAGE: lazyboy serve Start named-agent daemon (Unix socket + HTTP API :8787) lazyboy agents create Create persistent agent in current workspace lazyboy agents list List agent expertise lazyboy agent --name Chat with a persistent agent lazyboy chat Interactive streaming chat (no tools) lazyboy run \"\" One-shot agent with tools lazyboy run --session \"...\" Continue a saved session lazyboy agent Interactive multi-turn agent REPL (with tools) lazyboy agent --session Resume an agent session lazyboy computer Start my computer (Docker box) and print the viewer URL lazyboy web Start (or attach to) serve + HTTP API on :8787 Frontend is independent: cd web && npm run dev lazyboy smoke Offline checks (no API key required) lazyboy version lazyboy help ENV: LAZYBOY_API_KEY API key (or XAI_API_KEY / OPENAI_API_KEY) LAZYBOY_BASE_URL default https://api.x.ai/v1 LAZYBOY_MODEL default grok-4.6 LAZYBOY_CONTEXT_CHARS approximate context byte budget (default 100000) LAZYBOY_SESSIONS_DIR optional session storage directory LAZYBOY_MAX_ROUNDS Progress interval in model requests (default 12) LAZYBOY_MAX_ROUNDS_TOTAL Model request ceiling per user turn (default 5000) LAZYBOY_PROGRESS 0 = silence live progress on stderr (思考/工具/續跑/結束) LAZYBOY_BROWSER_SURFACE Docker by default; local selects the legacy local browser LAZYBOY_BROWSER_HEADED 1 = visible Chromium in legacy local mode LAZYBOY_WEB_BACKEND_URL Optional AiService-compatible web gateway LAZYBOY_WEB_PROVIDER xai = enable native search on an xAI-compatible proxy LAZYBOY_WEB_HOST web UI bind (default 0.0.0.0) LAZYBOY_WEB_PORT web UI port (default 8787) LAZYBOY_WEB_TOKEN optional bearer token for the web UI LAZYBOY_HANDOFF_AUTO 1 = auto-resume handoff (tests); abort = auto-abort LAZYBOY_CONFIRM_AUTO 1 = auto-approve confirm (tests); abort = auto-deny (falls back to HANDOFF_AUTO if unset) Tools: send_message, report_progress, update_plan, request_user_input, report_done, report_blocked, grep, glob, web_fetch, await_command, exec_command, write_stdin, shell, search_files, list_dir, read_file, edit_file, write_file, request_user_confirm, browser_navigate, browser_snapshot, browser_read_page, browser_click, browser_type, browser_press, browser_select, browser_scroll, browser_wait, browser_tabs, browser_upload, browser_download, browser_eval, browser_handoff Sessions: ~/.lazyboy/sessions/.json Browser defaults to Docker. Legacy local setup: cd tools/playwright && npm i && npx playwright install chromium Handoff: agent pauses on login/OTP/captcha → you fix in headed Chromium → Enter REPL: /exit /quit leave; /session show id; /plan show plan; /stop or Ctrl-C stops a turn During a task: enter additional instructions to steer the next step. " ); } async fn cmd_computer() -> Result<()> { let ready = BoxHub::new().ensure_ready().await?; println!("我的電腦已就緒"); println!("{}", BoxHub::viewer_url()); if let Some(msg) = ready["instruction"].as_str() { eprintln!("{msg}"); } Ok(()) } async fn cmd_chat() -> Result<()> { let config = Config::from_env().map_err(anyhow::Error::msg)?; println!( "LazyBoy chat model={} base={}", config.model, config.base_url ); println!("Type a message. Empty line or /exit to quit.\n"); let mut history = vec![ChatMessage::system(CHAT_SYSTEM)]; let stdin = io::stdin(); let mut stdout = io::stdout(); loop { ui::print_you_prompt(); let mut line = String::new(); if stdin.read_line(&mut line).context("stdin")? == 0 { println!(); break; } let input = line.trim(); if input.is_empty() || input == "/exit" || input == "/quit" { break; } history.push(ChatMessage::user(input)); println!(); print!("Grok "); stdout.flush().ok(); let reply = stream_chat(&config, &history, |delta| { print!("{delta}"); let _ = io::stdout().flush(); }) .await?; println!("\n"); history.push(ChatMessage::assistant(reply)); } Ok(()) } async fn cmd_run(args: &[String]) -> Result<()> { let mut session_id: Option = None; let mut prompt_parts: Vec = Vec::new(); let mut i = 0; while i < args.len() { match args[i].as_str() { "--session" | "-s" => { i += 1; let id = args .get(i) .ok_or_else(|| anyhow!("--session requires an id"))?; session_id = Some(id.clone()); } "--help" | "-h" => { println!( "Usage: lazyboy run [--session ] \"\"\n\ Runs a one-shot tool-using agent. Creates a session under ~/.lazyboy/sessions/." ); return Ok(()); } other => prompt_parts.push(other.to_string()), } i += 1; } let prompt = prompt_parts.join(" "); if prompt.trim().is_empty() { return Err(anyhow!("usage: lazyboy run [--session ] \"\"")); } let config = Config::from_env().map_err(anyhow::Error::msg)?; let cwd = std::env::current_dir().context("cwd")?; let mut session = load_or_create(session_id.as_deref(), &cwd)?; // Ensure system prompt is present once at the start. if session .messages .first() .is_some_and(|m| m.role == lazyboy_core::Role::System) { session.messages[0] = ChatMessage::system(AGENT_SYSTEM); } else { session .messages .insert(0, ChatMessage::system(AGENT_SYSTEM)); } session.cwd = cwd.clone(); if matches!(session.last_verdict.as_deref(), Some("done" | "answer")) { session.plan.clear(); } session.push(ChatMessage::user(&prompt)); let tool_ctx = ToolContext::new(session.cwd.clone()); if let Some(url) = &session.last_browser_url { *tool_ctx.last_browser_url.lock().unwrap() = Some(url.clone()); } let input = start_input(); let verdict = execute_turn(&config, &mut session, &tool_ctx, &input).await?; tool_ctx.shutdown().await; session.last_verdict = Some(verdict.kind().into()); session.last_message = Some(verdict.message().into()); if let Some(url) = tool_ctx.last_browser_url_value() { session.last_browser_url = Some(url); } session.touch(); let path = save_session(&session)?; print_verdict(&verdict); ui::print_run_footer(verdict.kind(), &session.id, &path); if matches!(verdict, AgentVerdict::Cancelled(_)) { return Err(CancelledExit.into()); } // Non-zero exit on blocked so scripts can detect fail-closed. if matches!(verdict, AgentVerdict::Waiting(_)) { eprintln!( "提示:已暫停等你。下一句話就是回覆: lazyboy run --session {} \"你的回覆\"", session.id ); return Ok(()); } if matches!( verdict, AgentVerdict::Blocked(_) | AgentVerdict::BudgetExhausted(_) | AgentVerdict::Failed(_) ) { eprintln_blocked_recovery_hint_run(&session.id); return Err(anyhow!("agent {}: {}", verdict.kind(), verdict.message())); } Ok(()) } /// Parse `agent` CLI flags. Returns (session_id, show_help). fn parse_agent_args(args: &[String]) -> Result<(Option, bool)> { let mut session_id: Option = None; let mut i = 0; while i < args.len() { match args[i].as_str() { "--session" | "-s" => { i += 1; let id = args .get(i) .ok_or_else(|| anyhow!("--session requires an id"))?; session_id = Some(id.clone()); } "--help" | "-h" => return Ok((session_id, true)), other => { return Err(anyhow!( "unexpected argument: {other}\nusage: lazyboy agent [--session ]" )); } } i += 1; } Ok((session_id, false)) } async fn cmd_agent(args: &[String]) -> Result<()> { let (session_id, show_help) = parse_agent_args(args)?; if show_help { println!( "Usage: lazyboy agent [--session ]\n\ Interactive multi-turn ReAct agent with tools.\n\ Creates/resumes a session under ~/.lazyboy/sessions/.\n\ Commands: /exit /quit leave; /session show id; empty line ignored.\n\ Tip: LAZYBOY_BROWSER_HEADED=1 for visible Chromium (handoff)." ); return Ok(()); } let config = Config::from_env().map_err(anyhow::Error::msg)?; let cwd = std::env::current_dir().context("cwd")?; let created_new = session_id.is_none(); let mut session = load_or_create(session_id.as_deref(), &cwd)?; if session .messages .first() .is_some_and(|m| m.role == lazyboy_core::Role::System) { session.messages[0] = ChatMessage::system(AGENT_SYSTEM); } else { session .messages .insert(0, ChatMessage::system(AGENT_SYSTEM)); } session.cwd = cwd.clone(); // One ToolContext for the whole REPL so browser URL/state carries across turns. let mut tool_ctx = ToolContext::new(session.cwd.clone()); tool_ctx.hold_background = false; if let Some(url) = &session.last_browser_url { *tool_ctx.last_browser_url.lock().unwrap() = Some(url.clone()); } // Persist early so the printed session id is on disk. let path = save_session(&session)?; ui::print_session_header( &config.model, &session.id, if created_new { "新 session" } else { "續跑" }, ); eprintln!("{}", path.display()); eprintln!("問你時停住,下一句就是答案。背景工作進行中也可先打字。/exit 離開。"); let input_broker = start_input(); loop { let live = tool_ctx.has_live_background().await || tool_ctx.has_parked_background(); if live { eprintln!("背景工作進行中 · 完成後會自動繼續 · 也可以先打字"); } ui::print_you_prompt(); let incoming = if live { tokio::select! { line = input_broker.next() => line, _ = tool_ctx.wait_background_progress() => { println!(); eprintln!("背景完成,繼續"); None } } } else { input_broker.next().await }; let Some(line) = incoming else { if live { let verdict = execute_turn(&config, &mut session, &tool_ctx, &input_broker).await?; session.last_verdict = Some(verdict.kind().into()); session.last_message = Some(verdict.message().into()); if let Some(url) = tool_ctx.last_browser_url_value() { session.last_browser_url = Some(url); } session.touch(); let _path = save_session(&session)?; print_verdict(&verdict); continue; } println!(); break; }; let input = line.trim(); if input.is_empty() { continue; } if input == "/exit" || input == "/quit" { break; } if input == "/session" { println!("session {}", session.id); continue; } if input == "/plan" { println!("{}", serde_json::to_string_pretty(&session.plan)?); continue; } if input == "/stop" { continue; } if matches!(session.last_verdict.as_deref(), Some("done" | "answer")) { session.plan.clear(); } session.push(ChatMessage::user(input)); let verdict = execute_turn(&config, &mut session, &tool_ctx, &input_broker).await?; session.last_verdict = Some(verdict.kind().into()); session.last_message = Some(verdict.message().into()); if let Some(url) = tool_ctx.last_browser_url_value() { session.last_browser_url = Some(url); } session.touch(); let _path = save_session(&session)?; print_verdict(&verdict); if matches!( verdict, AgentVerdict::Blocked(_) | AgentVerdict::BudgetExhausted(_) | AgentVerdict::Failed(_) ) { eprintln_blocked_recovery_hint_agent(); } } tool_ctx.shutdown().await; session.touch(); let _ = save_session(&session)?; Ok(()) } async fn cmd_smoke() -> Result<()> { println!("LazyBoy smoke (offline P8 auto-continue)…"); let stamp = uuid_like(); let dir = std::env::temp_dir().join(format!("lazyboy-smoke-{stamp}")); std::fs::create_dir_all(&dir).context("temp dir")?; let ctx = ToolContext::new(dir.clone()); // write_file let w = execute_tool( &ctx, "external_write_file", &json!({"path": "note.txt", "content": "smoke ok\n第二行"}).to_string(), ) .await; let w: serde_json::Value = serde_json::from_str(&w)?; assert_ok(&w, "external_write_file")?; println!(" write_file ok"); // read_file let r = execute_tool(&ctx, "external_read_file", &json!({"path": "note.txt"}).to_string()).await; let r: serde_json::Value = serde_json::from_str(&r)?; assert_ok(&r, "external_read_file")?; if r["content"].as_str() != Some("smoke ok\n第二行") { return Err(anyhow!("read_file content mismatch: {r}")); } println!(" read_file ok"); // list_dir let l = execute_tool(&ctx, "external_list_dir", &json!({"path": "."}).to_string()).await; let l: serde_json::Value = serde_json::from_str(&l)?; assert_ok(&l, "external_list_dir")?; let names: Vec<&str> = l["entries"] .as_array() .context("entries")? .iter() .filter_map(|e| e["name"].as_str()) .collect(); if !names.contains(&"note.txt") { return Err(anyhow!("list_dir missing note.txt: {l}")); } println!(" list_dir ok"); // shell let s = execute_tool( &ctx, "external_shell", &json!({"command": "printf 'hi'; wc -c < note.txt"}).to_string(), ) .await; let s: serde_json::Value = serde_json::from_str(&s)?; assert_ok(&s, "external_shell")?; if s["exit_code"] != 0 { return Err(anyhow!("shell exit_code != 0: {s}")); } println!(" shell ok"); // path traversal should fail let bad = execute_tool( &ctx, "external_read_file", &json!({"path": "../outside.txt"}).to_string(), ) .await; let bad: serde_json::Value = serde_json::from_str(&bad)?; if bad.get("error").is_none() { return Err(anyhow!("expected path traversal error, got {bad}")); } println!(" sandbox ok"); // completion tools let done = execute_tool( &ctx, "report_done", &json!({"message": "smoke done"}).to_string(), ) .await; let done: serde_json::Value = serde_json::from_str(&done)?; if done["status"] != "done" || done["message"] != "smoke done" { return Err(anyhow!("report_done mismatch: {done}")); } let blocked = execute_tool( &ctx, "report_blocked", &json!({"reason": "smoke blocked"}).to_string(), ) .await; let blocked: serde_json::Value = serde_json::from_str(&blocked)?; if blocked["status"] != "blocked" || blocked["reason"] != "smoke blocked" { return Err(anyhow!("report_blocked mismatch: {blocked}")); } if !is_completion_tool("report_done") || !is_completion_tool("report_blocked") { return Err(anyhow!("is_completion_tool failed")); } println!(" completion ok"); // tool definitions present (6 core + confirm + 6 browser incl. handoff) let defs = tool_definitions(); let n_tools = defs.as_array().map(|a| a.len()).unwrap_or(0); if n_tools < 46 { return Err(anyhow!("expected at least 46 tool defs, got {n_tools}")); } let tool_names: Vec<&str> = defs .as_array() .unwrap() .iter() .filter_map(|t| t["function"]["name"].as_str()) .collect(); if !tool_names.contains(&"browser_handoff") { return Err(anyhow!("browser_handoff missing from tool defs")); } if !tool_names.contains(&"request_user_confirm") { return Err(anyhow!("request_user_confirm missing from tool defs")); } if !tool_names.contains(&"send_message") { return Err(anyhow!("send_message missing from tool defs")); } if !tool_names.contains(&"web_fetch") { return Err(anyhow!("web_fetch missing from tool defs")); } if !tool_names.contains(&"spawn_subagent") { return Err(anyhow!("spawn_subagent missing from tool defs")); } println!(" tool defs ok"); // browser helper protocol (no Chromium required) let repo = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); match lazyboy_core::browser_self_test(&repo) { Ok(_) => println!(" browser proto ok"), Err(e) => { // Soft: smoke still passes if helper missing in weird cwd; warn. println!(" browser proto skip ({e:#})"); } } // Fail-closed install hint when navigating without ensuring chromium — oneshot missing url already covered in helper self-test. let nav = execute_tool( &ToolContext::new(repo.clone()), "browser_navigate", &json!({"url": ""}).to_string(), ) .await; let nav_v: serde_json::Value = serde_json::from_str(&nav)?; // empty url should error from helper; if helper missing, also error with install hint if nav_v.get("error").is_none() && nav_v.get("blocked").is_none() { // empty string might still "succeed" oddly — accept either error or blocked println!(" browser fail note: {nav_v}"); } else { println!(" browser fail ok"); } // handoff protocol (auto-resume; no interactive stdin) // SAFETY: smoke is single-threaded for this env toggle. let prev_auto = std::env::var("LAZYBOY_HANDOFF_AUTO").ok(); unsafe { std::env::set_var("LAZYBOY_HANDOFF_AUTO", "1") }; let handoff = execute_tool( &ToolContext::new(repo.clone()), "browser_handoff", &json!({"reason": "smoke handoff check", "timeout_secs": 3}).to_string(), ) .await; let handoff_v: serde_json::Value = serde_json::from_str(&handoff)?; // Fail-closed without Chromium, or resumed with snapshot if browsers installed. if handoff_v.get("error").is_none() && handoff_v.get("blocked").is_none() && handoff_v.get("handoff").and_then(|h| h.as_str()) != Some("resumed") { return Err(anyhow!("unexpected handoff smoke result: {handoff_v}")); } println!(" handoff auto ok"); // abort path unsafe { std::env::set_var("LAZYBOY_HANDOFF_AUTO", "abort") }; let abort_wait = lazyboy_core::wait_for_handoff_resume(1); if !matches!(abort_wait, lazyboy_core::HandoffWait::Aborted(_)) { return Err(anyhow!("expected HandoffWait::Aborted, got {abort_wait:?}")); } println!(" handoff abort ok"); match prev_auto { Some(v) => unsafe { std::env::set_var("LAZYBOY_HANDOFF_AUTO", v) }, None => unsafe { std::env::remove_var("LAZYBOY_HANDOFF_AUTO") }, } // request_user_confirm (auto approve/deny; no interactive stdin) let prev_confirm = std::env::var("LAZYBOY_CONFIRM_AUTO").ok(); unsafe { std::env::set_var("LAZYBOY_CONFIRM_AUTO", "1") }; let conf = execute_tool( &ctx, "request_user_confirm", &json!({ "reason": "smoke confirm check", "prompt": "範例草稿", "timeout_secs": 2 }) .to_string(), ) .await; let conf_v: serde_json::Value = serde_json::from_str(&conf)?; if conf_v["status"] != "approved" || conf_v["approved"] != true { return Err(anyhow!("request_user_confirm approve mismatch: {conf_v}")); } println!(" confirm auto ok"); unsafe { std::env::set_var("LAZYBOY_CONFIRM_AUTO", "abort") }; let deny = execute_tool( &ctx, "request_user_confirm", &json!({"reason": "smoke deny check", "timeout_secs": 2}).to_string(), ) .await; let deny_v: serde_json::Value = serde_json::from_str(&deny)?; if deny_v["status"] != "denied" || deny_v["approved"] != false { return Err(anyhow!("request_user_confirm deny mismatch: {deny_v}")); } let deny_wait = lazyboy_core::wait_for_user_confirm(1); if !matches!(deny_wait, lazyboy_core::ConfirmWait::Denied(_)) { return Err(anyhow!("expected ConfirmWait::Denied, got {deny_wait:?}")); } println!(" confirm deny ok"); match prev_confirm { Some(v) => unsafe { std::env::set_var("LAZYBOY_CONFIRM_AUTO", v) }, None => unsafe { std::env::remove_var("LAZYBOY_CONFIRM_AUTO") }, } // context truncation let mut msgs = vec![ ChatMessage::system(AGENT_SYSTEM), ChatMessage::user("goal"), ChatMessage::tool("t1", "X".repeat(50_000)), ChatMessage::tool("t2", "Y".repeat(50_000)), ChatMessage::tool("t3", "Z".repeat(50_000)), ChatMessage::tool("t4", "W".repeat(50_000)), ChatMessage::tool("t5", "V".repeat(50_000)), ChatMessage::user("goal again"), ]; let before = messages_char_len(&msgs); truncate_messages(&mut msgs, 10_000); let after = messages_char_len(&msgs); if after >= before { return Err(anyhow!("truncate did not shrink ({before} -> {after})")); } if msgs[0].text() != AGENT_SYSTEM { return Err(anyhow!("truncate dropped system prompt")); } println!(" truncation ok"); // session roundtrip let mut sess = Session::new(dir.clone()); sess.push(ChatMessage::system(AGENT_SYSTEM)); sess.push(ChatMessage::user("smoke")); let sess_path = dir.join("session.json"); let data = serde_json::to_vec_pretty(&sess)?; std::fs::write(&sess_path, data)?; let loaded: Session = serde_json::from_slice(&std::fs::read(&sess_path)?)?; if loaded.messages.len() != 2 { return Err(anyhow!("session roundtrip failed")); } println!(" session ok"); // agent CLI parse (offline) let (sid, help) = parse_agent_args(&[]).expect("empty agent args"); if sid.is_some() || help { return Err(anyhow!("parse_agent_args([]) unexpected")); } let sess_args = vec!["--session".to_string(), "abc-123".to_string()]; let (sid, help) = parse_agent_args(&sess_args)?; if sid.as_deref() != Some("abc-123") || help { return Err(anyhow!("parse_agent_args --session failed")); } let help_args = vec!["--help".to_string()]; let (_, help) = parse_agent_args(&help_args)?; if !help { return Err(anyhow!("parse_agent_args --help failed")); } // one-turn session plumbing without API: system + user + fake assistant, save/load let mut agent_sess = Session::new(dir.clone()); agent_sess.push(ChatMessage::system(AGENT_SYSTEM)); agent_sess.push(ChatMessage::user("第一輪")); agent_sess.push(ChatMessage::assistant("回覆一")); agent_sess.push(ChatMessage::user("第二輪")); let agent_path = dir.join("agent-turn.json"); let data = serde_json::to_vec_pretty(&agent_sess)?; std::fs::write(&agent_path, data)?; let loaded_agent: Session = serde_json::from_slice(&std::fs::read(&agent_path)?)?; if loaded_agent.messages.len() != 4 { return Err(anyhow!("agent multi-turn session plumbing failed")); } if loaded_agent.messages[0].text() != AGENT_SYSTEM { return Err(anyhow!("agent session missing system prompt")); } println!(" agent parse ok"); println!(" agent session ok"); // max rounds env resolver (per chunk default 12; total default 48) let rounds = max_rounds_budget(); if rounds == 0 { return Err(anyhow!("max_rounds_budget returned 0")); } let total = max_rounds_total_budget(); if total == 0 { return Err(anyhow!("max_rounds_total_budget returned 0")); } println!(" max rounds ok (chunk={rounds}, total={total})"); let _ = std::fs::remove_dir_all(&dir); if Config::from_env().is_ok() { println!(" (API key present — skipping live call in smoke; use `run` to exercise)"); } else { println!(" (no API key — live agent not checked; offline smoke passed)"); } println!("smoke passed"); Ok(()) } fn print_verdict(verdict: &AgentVerdict) { ui::print_verdict(verdict); } fn eprintln_blocked_recovery_hint_agent() { eprintln!( "提示:session 已保存。額度用完可輸入「繼續」;卡住請補充缺少的資訊;請求失敗可修正設定後重試。" ); } fn eprintln_blocked_recovery_hint_run(session_id: &str) { eprintln!( "提示:session 已保存。可用 lazyboy run --session {session_id} \"繼續\" 接續;若卡住或失敗,請先處理上面的原因。" ); } fn assert_ok(v: &serde_json::Value, label: &str) -> Result<()> { if let Some(err) = v.get("error") { return Err(anyhow!("{label} failed: {err}")); } Ok(()) } fn uuid_like() -> String { use std::time::{SystemTime, UNIX_EPOCH}; let n = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); format!("{n}") } #[cfg(test)] mod tests { use super::parse_agent_args; #[test] fn agent_args_empty() { let (sid, help) = parse_agent_args(&[]).unwrap(); assert!(sid.is_none()); assert!(!help); } #[test] fn agent_args_session() { let args = vec!["--session".into(), "sess-1".into()]; let (sid, help) = parse_agent_args(&args).unwrap(); assert_eq!(sid.as_deref(), Some("sess-1")); assert!(!help); } #[test] fn agent_args_help() { let args = vec!["-h".into()]; let (_, help) = parse_agent_args(&args).unwrap(); assert!(help); } #[test] fn agent_args_rejects_extra() { let args = vec!["nope".into()]; assert!(parse_agent_args(&args).is_err()); } }