diff --git a/crates/arc-cli/src/main.rs b/crates/arc-cli/src/main.rs index 2575a36e0..4b59bc845 100644 --- a/crates/arc-cli/src/main.rs +++ b/crates/arc-cli/src/main.rs @@ -54,6 +54,8 @@ enum RunCommand { enum LlmCommand { /// Execute a prompt Prompt(arc_llm::cli::PromptArgs), + /// Interactive multi-turn chat + Chat(arc_llm::cli::ChatArgs), } #[tokio::main] @@ -80,6 +82,7 @@ async fn main() -> Result<()> { match cli.command { Command::Llm { command } => match command { LlmCommand::Prompt(args) => arc_llm::cli::run_prompt(args).await?, + LlmCommand::Chat(args) => arc_llm::cli::run_chat(args).await?, }, Command::Agent(args) => arc_agent::cli::run_with_args(args).await?, Command::Run { command } => match command { diff --git a/crates/arc-cli/tests/cli.rs b/crates/arc-cli/tests/cli.rs index e65bdcb2f..2ed055546 100644 --- a/crates/arc-cli/tests/cli.rs +++ b/crates/arc-cli/tests/cli.rs @@ -248,6 +248,48 @@ fn prompt_schema_stream_generates_json() { ); } +// == LLM: chat ================================================================ + +#[test] +#[ignore = "requires API key"] +fn chat_multi_turn_with_system_prompt() { + let assert = arc() + .args([ + "llm", + "chat", + "-m", + "claude-haiku-4-5", + "-s", + "You are a pilot. End every response with 'Roger that.'", + ]) + .write_stdin("What is your profession?\nWhat did I just ask you?\n") + .assert() + .success(); + + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); + + // Verify model info printed to stderr + assert!( + stderr.contains("Using model:"), + "stderr should show model info" + ); + + // Verify the system prompt influenced the output + assert!( + stdout.to_lowercase().contains("roger that"), + "response should follow pilot system prompt, got: {stdout}" + ); + + // Verify multi-turn: the second response should reference the first question + assert!( + stdout.to_lowercase().contains("profession") + || stdout.to_lowercase().contains("asked") + || stdout.to_lowercase().contains("pilot"), + "second response should show multi-turn context, got: {stdout}" + ); +} + // == Agent ==================================================================== #[test] diff --git a/crates/arc-llm/src/cli.rs b/crates/arc-llm/src/cli.rs index 291cd920c..e8dfb77f7 100644 --- a/crates/arc-llm/src/cli.rs +++ b/crates/arc-llm/src/cli.rs @@ -1,4 +1,4 @@ -use std::io::{self, IsTerminal, Read}; +use std::io::{self, BufRead, IsTerminal, Read, Write}; use std::time::Duration; use anyhow::{bail, Context, Result}; @@ -7,6 +7,7 @@ use futures::StreamExt; use crate::catalog; use crate::generate::{self, GenerateParams}; +use crate::types::Message; #[derive(Args)] pub struct PromptArgs { @@ -169,6 +170,68 @@ fn print_usage(usage: &crate::types::Usage) { ); } +#[derive(Args)] +pub struct ChatArgs { + /// Model to use + #[arg(short, long)] + pub model: Option, + + /// System prompt + #[arg(short, long)] + pub system: Option, +} + +pub async fn run_chat(args: ChatArgs) -> Result<()> { + let (model_id, provider) = resolve_model(args.model); + eprintln!("Using model: {model_id}"); + + let mut messages: Vec = Vec::new(); + let stdin = io::stdin(); + let mut lines = stdin.lock().lines(); + + loop { + eprint!("> "); + io::stderr().flush()?; + + let line = match lines.next() { + Some(Ok(line)) => line, + Some(Err(e)) => return Err(e.into()), + None => break, // EOF + }; + + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + messages.push(Message::user(trimmed)); + + let mut params = GenerateParams::new(&model_id) + .messages(messages.clone()) + .max_tokens(4096); + if let Some(ref p) = provider { + params = params.provider(p); + } + if let Some(ref sys) = args.system { + params = params.system(sys); + } + + let mut stream_result = generate::stream(params).await?; + let mut full_text = String::new(); + while let Some(event) = stream_result.next().await { + if let crate::types::StreamEvent::TextDelta { delta, .. } = event? { + print!("{delta}"); + full_text.push_str(&delta); + } + } + println!(); + + messages.push(Message::assistant(full_text)); + } + + Ok(()) +} + pub async fn run_prompt(args: PromptArgs) -> Result<()> { let stdin_prompt = read_stdin_prompt(); let prompt_text = resolve_prompt(args.prompt, stdin_prompt)?;