LazyBoy2/crates/lazyboy/src/main.rs

860 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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::<CancelledExit>().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<InputBroker> {
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<InputBroker>,
) -> Result<AgentVerdict> {
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::<Vec<_>>();
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 + HTTPS UI :8787)
lazyboy agents create <name> Create persistent agent in current workspace
lazyboy agents list List agent expertise
lazyboy agent --name <name> Chat with a persistent agent
lazyboy chat Interactive streaming chat (no tools)
lazyboy run \"<prompt>\" One-shot agent with tools
lazyboy run --session <id> \"...\" Continue a saved session
lazyboy agent Interactive multi-turn agent REPL (with tools)
lazyboy agent --session <id> Resume an agent session
lazyboy computer Start my computer (Docker box) and print the viewer URL
lazyboy web Start (or attach to) serve + HTTPS UI 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_TLS 0 = plain HTTP only (default HTTP+HTTPS on the same port)
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/<id>.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!(
"{}",
ready["host_viewer_url"]
.as_str()
.unwrap_or(&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<String> = None;
let mut prompt_parts: Vec<String> = 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 <id>] \"<prompt>\"\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 <id>] \"<prompt>\""));
}
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<String>, bool)> {
let mut session_id: Option<String> = 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 <id>]"
));
}
}
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 <id>]\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());
}
}