Add arc llm chat subcommand for interactive multi-turn conversations

Adds a new `chat` subcommand under `arc llm` that reads user input in a
loop, maintains conversation history, streams LLM responses, and supports
`--model` and `--system` options. Includes an e2e test verifying multi-turn
context and system prompt behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-02 11:54:47 -05:00
parent 6dd422186e
commit 095dd5ff3c
3 changed files with 109 additions and 1 deletions

View file

@ -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 {

View file

@ -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]

View file

@ -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<String>,
/// System prompt
#[arg(short, long)]
pub system: Option<String>,
}
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<Message> = 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)?;