commit 1a10940bb412ceb68b371b20795b8b5d24f3cb81 from: mtmn date: Sun Sep 6 17:47:11 2026 UTC naming and compose redo commit - b81d25ac7fde5982105f7034cced093adead4b77 commit + 1a10940bb412ceb68b371b20795b8b5d24f3cb81 blob - 01e417b2c69dddd8b4df5205b2c4a317e01bd77b blob + fd5335427b07641e6eef1efb0061908522aa312c --- README.md +++ README.md @@ -49,20 +49,20 @@ $ make install ## Setup -`alpaca` reads an API key from `--apikey` or, more simply, the `API_KEY` +`alpaca` reads an API key from `--apikey` or, more simply, the `ALPACA_API_KEY` environment variable: ```sh # in shell configuration -export API_KEY=your-api-key +export ALPACA_API_KEY=your-api-key ``` `alpaca` sends requests to `https://api.openai.com/v1` by default. Set -`API_ENDPOINT` or pass `--base-url` to target another host, such as a local +`ALPACA_ENDPOINT` or pass `--base-url` to target another host, such as a local server: ```sh -export API_ENDPOINT=http://localhost:11434 +export ALPACA_ENDPOINT=http://localhost:11434 ``` The base URL sets the dialect of the endpoint. A base URL ending in a @@ -79,8 +79,8 @@ Shared settings, honoured by every subcommand: | variable | meaning | default | | --- | --- | --- | -| `API_KEY` | API key | none | -| `API_ENDPOINT` | base URL | `https://api.openai.com/v1` | +| `ALPACA_API_KEY` | API key | none | +| `ALPACA_ENDPOINT` | base URL | `https://api.openai.com/v1` | | `ALPACA_MODEL` | model | `gpt-4o-mini` | | `ALPACA_EFFORT` | reasoning effort | `none` | | `ALPACA_TIMEOUT` | request timeout, in seconds | `60` | @@ -126,7 +126,7 @@ alpaca -u "Hello" alpaca [OPTIONS] [FILE] # chat, the default command alpaca chat [OPTIONS] [FILE] # the same thing, named alpaca quota [OPTIONS] # provider quota usage -alpaca compose [OPTIONS] # edit a prompt in $EDITOR, then send it +alpaca compose [OPTIONS] # edit prompts in $EDITOR until an empty buffer is saved ``` `alpaca` treats a first argument matching a subcommand name as that @@ -178,13 +178,15 @@ $ echo "50 + 50" | alpaca --system "Solve the followin ## alpaca compose `alpaca compose` edits a prompt in `$VISUAL`, `$EDITOR`, or `vim`, sends the -saved text, then writes the reply to standard output. Piped input fills the -buffer first. The editor runs on the controlling terminal, so editor input and -output stay out of the pipeline. If the process has no controlling terminal, -the editor inherits `alpaca`'s streams. Saving an empty buffer sends nothing. +saved text, then opens the editor again with the reply in the buffer, ready +for the next prompt. It keeps the conversation going until you save an empty +buffer. Piped input fills the buffer first. The editor runs on the +controlling terminal, so editor input and output stay out of the pipeline. +If the process has no controlling terminal, the editor inherits `alpaca`'s +streams. Saving an empty buffer sends nothing and ends the session. ```sh -# Write a prompt in the editor, send it, see the reply +# Chat in the editor until you save an empty buffer $ alpaca compose # Pick a model and reasoning effort @@ -206,7 +208,6 @@ Options: - `-s, --system `: system prompt - `-t, --temperature `: sampling temperature - `-T, --timeout `: request timeout in seconds -- `-c, --continue`: continue the newest saved conversation - `--reasoning-effort `: reasoning effort - `--apikey `, `--base-url `: endpoint settings - `--json`, `--jsonp`: print the response as JSON instead of the reply text @@ -218,18 +219,17 @@ same status. `alpaca` saves each successful request as a JSON transcript in `$XDG_DATA_HOME/alpaca` (or `~/.local/share/alpaca`), mode 0600 in a mode -0700 directory. `alpaca compose -c` continues the newest transcript: its -final reply opens in the editor, and each follow-up sends the whole -conversation as context, reusing the saved model, effort and system prompt -unless you override them on the command line. Save an empty buffer to leave -the loop. +0700 directory. Each turn sends the whole conversation as context. -When the editor is `vim` or `neovim`, `-c` keeps one editor open for the -whole conversation. Writing the buffer sends the prompt, and the reply -replaces the buffer in place, so every turn happens in the same window. Quit -the editor to end the session; writing an empty buffer sends nothing. In this -mode the reply is written to standard output only when standard output is not -a terminal, since the editor owns the screen. +When the editor is `vim` or `neovim`, `alpaca` keeps one editor open for the +whole conversation instead of reopening it each turn. Writing the buffer +sends the prompt, and the reply replaces the buffer in place, so every turn +happens in the same window. Quit the editor to end the session. Writing an +empty buffer sends nothing. The editor reloads the buffer through `autoread` +and `checktime`, so a reply that arrives while you have unsaved changes +waits for your next write. In this mode, `alpaca` writes the reply to +standard output only when standard output is not a terminal, since the +editor owns the screen. See `man/alpaca-compose.1`. @@ -290,7 +290,7 @@ in. A table named after a provider is picked up automa its `apikey`. Its `base_url`, if set, is assumed to be for chat, not quota. To deliberately override the endpoint for a provider too, select a profile by name with `--profile` instead. `default_profile` does not apply here. -Command-line options and `API_ENDPOINT` still override any profile. +Command-line options and `ALPACA_ENDPOINT` still override any profile. Synthetic and DeepInfra also fall back to the top-level `apikey` when nothing more specific names one, since both double as chat endpoints. Anthropic and @@ -322,7 +322,7 @@ See `man/alpaca-quota.1`. Examples to get you started. Whatever you feed `alpaca` is sent to the endpoint you configure. Point -`API_ENDPOINT` at a local server if the data should not leave your machine. +`ALPACA_ENDPOINT` at a local server if the data should not leave your machine. ### In the shell blob - cbb8ab6430c5acf40d674d3e48cba8c348e2598e blob + 965af320ad6f48009192182e3a6702881ab6d19b --- man/alpaca-compose.1.scd +++ man/alpaca-compose.1.scd @@ -2,51 +2,48 @@ alpaca-compose(1) # NAME -alpaca compose - edit a prompt in the editor, then send it to the chat API +alpaca compose - edit prompts in the editor and chat until you save an empty buffer # SYNOPSIS *alpaca compose* [*-m* _model_] [*-s* _msg_] [*-t* _temp_] -\[*-T* _secs_] [*-c*] [*--reasoning-effort* _effort_] +\[*-T* _secs_] [*--reasoning-effort* _effort_] \[*--apikey* _key_] [*--base-url* _url_] [*--profile* _name_] \[*--json* | *--jsonp*] # DESCRIPTION *alpaca compose* opens a temporary file in the editor and sends the saved text -as a user prompt. It writes the reply to standard output. Piped standard input -fills the buffer first, so you can use *alpaca compose* in the middle of a -pipeline: pipe data in, edit it, pipe the reply onward. +as a user prompt. It writes the reply to standard output, then opens the +editor again with the reply in the buffer, ready for the next prompt. It +keeps the conversation going until you save an empty buffer. Piped standard +input fills the buffer first, so you can use *alpaca compose* in the middle +of a pipeline: pipe data in, edit it, pipe the reply onward. The editor runs on the controlling terminal. This keeps editor input and screen output out of the pipeline. If there is no controlling terminal, the editor -inherits this process's streams. Saving an empty buffer sends nothing and exits -0. +inherits this process's streams. Saving an empty buffer sends nothing and ends +the session with status 0. Each successful request is saved as a JSON transcript in _$XDG_DATA_HOME/alpaca_, or, when that variable is unset, _~/.local/share/alpaca_. Transcripts are written with mode 0600 in a directory -with mode 0700. +with mode 0700. Each turn sends the whole conversation as context. -With *-c*, *alpaca compose* opens the newest saved conversation's final reply -in the editor. It keeps prompting, sending the whole conversation as context on -each turn. The saved model, reasoning effort and system prompt are reused -unless given on the command line. Save an empty buffer to leave the loop. +When the editor is *vim* or *neovim*, *alpaca* keeps one editor open for the +whole conversation instead of reopening it each turn. Writing the buffer +sends the prompt, and the reply replaces the buffer in place, so every turn +happens in the same window. Quit the editor to end the session. Writing an +empty buffer sends nothing. The editor reloads the buffer through *autoread* +and *checktime*, so a reply that arrives while you have unsaved changes +waits for your next write. In this mode, *alpaca* writes the reply to +standard output only when standard output is not a terminal, since the +editor owns the screen. -When the editor is *vim* or *neovim*, *-c* keeps one editor open for the whole -conversation instead of reopening it each turn. Writing the buffer sends it, -and the reply replaces the buffer in place, so every turn happens in the same -window. Quit the editor to end the session; writing an empty buffer sends -nothing. The buffer is reloaded through *autoread* and *checktime*, so a reply -that arrives while unsaved changes are pending waits for the next write. In -this mode the reply is only written to standard output when standard output is -not a terminal, since the editor owns the screen. - # OPTIONS *-m*, *--model* _model_ - Model to use. When continuing, defaults to the saved model, else - *ALPACA_MODEL*, else *gpt-4o-mini*. + Model to use. Defaults to *ALPACA_MODEL*, else *gpt-4o-mini*. *-s*, *--system* _msg_ System prompt, sent before the conversation. @@ -57,17 +54,14 @@ not a terminal, since the editor owns the screen. *-T*, *--timeout* _secs_ Request timeout in seconds. Defaults to *ALPACA_TIMEOUT*, else 60. -*-c*, *--continue* - Continue the newest saved conversation. - *--reasoning-effort* _effort_ One of *low*, *medium*, *high* or *none*. *--apikey* _key_ - API key. Defaults to *API_KEY*. + API key. Defaults to *ALPACA_API_KEY*. *--base-url* _url_ - Base URL of the API endpoint. Defaults to *API_ENDPOINT*. + Base URL of the API endpoint. Defaults to *ALPACA_ENDPOINT*. *--json*, *--jsonp* Print the response as JSON instead of the reply text. @@ -87,9 +81,9 @@ not a terminal, since the editor owns the screen. # CONFIGURATION -This command uses its settings from the Alpaca configuration file. A setting -there overrides a value saved in a transcript. Command-line options and -environment variables take priority. See *alpaca-config*(5). +This command uses its settings from the Alpaca configuration file. +Command-line options and environment variables take priority. See +*alpaca-config*(5). # EXIT STATUS @@ -102,9 +96,9 @@ Edit piped input before sending it: $ git diff --staged | alpaca compose -s "Write a conventional commit message" -Continue the newest conversation: +Chat in one editor session: - $ alpaca compose -c + $ alpaca compose # SEE ALSO blob - 78dd47e12a23d882368b3908813e456e4f88ec21 blob + 390ef7866744125d06c610a26bc56c71a63f48aa --- man/alpaca.1.scd +++ man/alpaca.1.scd @@ -33,7 +33,8 @@ Three subcommands are available: Show how much provider quota you have used. See *alpaca-quota*(1). *compose* - Edit a prompt in the editor, then send it. See *alpaca-compose*(1). + Edit prompts in the editor and chat until you save an empty buffer. See + *alpaca-compose*(1). Alpaca treats a first argument that matches a subcommand name as that subcommand. To read messages from a file with such a name, separate it with @@ -65,11 +66,11 @@ subcommand. To read messages from a file with such a n else *none*, which omits the field from the request. *--apikey* _key_ - API key. Defaults to *API_KEY*. Authorization is only sent when a key is + API key. Defaults to *ALPACA_API_KEY*. Authorization is only sent when a key is known, so endpoints that need no authentication also work. *--base-url* _url_ - Base URL of the API endpoint. Defaults to *API_ENDPOINT*, else + Base URL of the API endpoint. Defaults to *ALPACA_ENDPOINT*, else *https://api.openai.com/v1*. A base URL ending in a version segment, such as _/v1_, addresses a chat completions endpoint. Any other base URL addresses _{base}/api/chat_. @@ -101,10 +102,10 @@ for the file format. # ENVIRONMENT -*API_KEY* +*ALPACA_API_KEY* Default API key. -*API_ENDPOINT* +*ALPACA_ENDPOINT* Default base URL. *ALPACA_MODEL* blob - 7977d774e03c2e6080595e4692d71ca5e55e5b5c blob + 6df41156085f43078e24f482ebd0c24afc591aae --- src/cli.rs +++ src/cli.rs @@ -24,7 +24,7 @@ pub const DEFAULT_TIMEOUT_SECS: &str = "60"; /// Default API endpoint /// /// Any host serving a compatible chat endpoint works, so this is only a -/// starting point: set `API_ENDPOINT` or `--base-url` to point elsewhere. +/// starting point: set `ALPACA_ENDPOINT` or `--base-url` to point elsewhere. pub const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; #[derive(Debug, Default, Deserialize, Clone)] #[serde(deny_unknown_fields)] @@ -300,9 +300,8 @@ pub struct QuotaArgs { /// Arguments for the compose subcommand /// -/// Model, effort and system prompt are optional here: when a saved -/// conversation is continued, values left unset are taken from the transcript -/// rather than from the defaults. +/// Model, effort and system prompt are optional here: values left unset are +/// taken from the configuration or the defaults. #[derive(Debug, Default, Builder)] pub struct ComposeArgs { #[builder(default)] @@ -319,9 +318,6 @@ pub struct ComposeArgs { pub(crate) output_format: OutputFormat, #[builder(default)] pub(crate) base_url: Option, - /// Continue the newest saved conversation - #[builder(default)] - pub(crate) continue_conversation: bool, } /// The format that invocation's results are in @@ -531,7 +527,6 @@ fn compose_command(config: &Config) -> Command { effort_arg(), config.reasoning_effort.map(effort_name), )) - .arg(arg!(continue_conversation: -c --"continue" "Continues the newest saved conversation")) } fn model_arg() -> Arg { @@ -573,7 +568,7 @@ fn profile_arg() -> Arg { fn api_key_arg(config: &Config) -> Arg { default_value_opt( arg!(api_key: --apikey "Sets the API key to use") - .env("API_KEY") + .env("ALPACA_API_KEY") .hide_env_values(true) .hide_default_value(true), config.apikey.clone(), @@ -583,7 +578,7 @@ fn api_key_arg(config: &Config) -> Arg { fn base_url_arg(config: &Config) -> Arg { default_value_opt( arg!(base_url: --"base-url" "Sets base URL of the API endpoint") - .env("API_ENDPOINT") + .env("ALPACA_ENDPOINT") .required(false), config.base_url.clone(), ) @@ -680,7 +675,6 @@ impl From<&ArgMatches> for ComposeArgs { reasoning_effort: matches.get_one::("reasoning_effort").copied(), output_format: output_format_of(matches), base_url: base_url_of(matches), - continue_conversation: matches.get_flag("continue_conversation"), } } } @@ -883,8 +877,8 @@ mod test { "ALPACA_MODEL", "ALPACA_EFFORT", "ALPACA_TIMEOUT", - "API_KEY", - "API_ENDPOINT", + "ALPACA_API_KEY", + "ALPACA_ENDPOINT", ] { // SAFETY: this runs exactly once and every parse in this module // waits on it, so no other thread reads the environment meanwhile. @@ -1165,7 +1159,6 @@ mod test { assert_eq!(args.timeout, Duration::from_secs(60)); assert_eq!(args.reasoning_effort, None); assert_eq!(args.system, None); - assert!(!args.continue_conversation); Ok(()) } @@ -1188,28 +1181,15 @@ mod test { } #[test] - fn compose_continue_and_system() -> Result<()> { + fn compose_takes_system_and_effort() -> Result<()> { let args = compose_args(&[ "alpaca", "compose", - "-c", "-s", "SYSTEM", "--reasoning-effort", "low", ])?; - assert!(args.continue_conversation, "-c is short for --continue"); - - let args = compose_args(&[ - "alpaca", - "compose", - "--continue", - "-s", - "SYSTEM", - "--reasoning-effort", - "low", - ])?; - assert!(args.continue_conversation); assert_eq!(args.system.as_deref(), Some("SYSTEM")); assert_eq!(args.reasoning_effort, Some(Effort::Low)); Ok(()) blob - b20af6da546f55a2bfdae7f9966b03b92be31691 blob + 8bad9cce6d6718338e677d56f8deb9ae25265f59 --- src/error.rs +++ src/error.rs @@ -38,9 +38,6 @@ pub enum Error { #[error("editor exited with status {0}")] EditorExit(i32), - #[error("no saved conversations")] - NoConversations, - #[error("invalid transcript {} - {message}", .path.display())] InvalidTranscript { path: PathBuf, message: String }, blob - 41d1f356ae7e5a9540c7bd8899a2a3fa0a6194a7 blob + 78864d62eae37e5591d4d6cfe75e6c384b7dbb6f --- src/exec/compose.rs +++ src/exec/compose.rs @@ -1,7 +1,7 @@ //! Implements compose subcommand //! -//! Edits a prompt in the user's editor, sends it, then saves the conversation -//! so it can be continued later. +//! Edits a prompt in the user's editor, sends it, then keeps the editor open +//! across turns until an empty buffer is saved. use std::io::{self, IsTerminal, Read}; use std::path::PathBuf; @@ -19,34 +19,22 @@ use crate::{Error, editor}; /// /// Returns an error if the editor fails, the request fails, or the /// conversation cannot be saved. -pub async fn exec(mut args: ComposeArgs) -> Result<()> { +pub async fn exec(args: ComposeArgs) -> Result<()> { let client = api::Client::new(args.api_key.clone(), super::base_url(args.base_url.clone())) .with_context(|| "failed to create http client")?; - let (mut path, mut transcript, mut prefill) = if args.continue_conversation { - let (path, transcript) = Transcript::newest()?; - let prefill = transcript.last_reply().unwrap_or_default().to_string(); - (Some(path), transcript, prefill) - } else { - ( - None, - Transcript::new(String::new(), None, None), - read_stdin()?, - ) - }; - - transcript.model = resolve( - args.model.take(), - &transcript.model, - DEFAULT_MODEL.to_string(), + let mut transcript = Transcript::new( + args.model + .clone() + .unwrap_or_else(|| DEFAULT_MODEL.to_string()), + args.reasoning_effort + .and_then(Option::::from), + args.system.clone(), ); - transcript.effort = args - .reasoning_effort - .map_or(transcript.effort, Option::::from); - transcript.system = args.system.take().or(transcript.system); + let mut prefill = read_stdin()?; - if args.continue_conversation && editor::is_live() { - return live(&client, &args, transcript, path, prefill).await; + if editor::is_live() { + return live(&client, &args, transcript, prefill).await; } loop { @@ -55,16 +43,11 @@ pub async fn exec(mut args: ComposeArgs) -> Result<()> return Ok(()); } - let reply = turn(&client, &args, &mut transcript, &prompt, &mut path, true).await?; - - if !args.continue_conversation { - return Ok(()); - } - prefill = reply; + prefill = turn(&client, &args, &mut transcript, &prompt, &mut None, true).await?; } } -/// Continue the conversation inside a live vim or neovim session +/// Run the whole conversation inside a live vim or neovim session /// /// The editor stays open for the whole conversation: writing the buffer sends /// it, and the reply replaces the buffer in place, so the next prompt is @@ -73,7 +56,6 @@ async fn live( client: &api::Client, args: &ComposeArgs, mut transcript: Transcript, - mut path: Option, prefill: String, ) -> Result<()> { // The editor owns the terminal, so replies are only written out when @@ -81,6 +63,7 @@ async fn live( // buffer either way. let show = !io::stdout().is_terminal(); let mut session = editor::Session::open(&prefill)?; + let mut path = None; while let Some(prompt) = session.next_prompt()? { let reply = turn(client, args, &mut transcript, &prompt, &mut path, show).await?; @@ -133,17 +116,6 @@ async fn turn( Ok(reply) } -/// Command line value, else the saved value, else the default -fn resolve(given: Option, saved: &str, default: String) -> String { - given.unwrap_or_else(|| { - if saved.is_empty() { - default - } else { - saved.to_string() - } - }) -} - /// Piped stdin prefills the editor buffer, so compose can sit in the middle of /// a pipeline fn read_stdin() -> Result { @@ -156,18 +128,3 @@ fn read_stdin() -> Result { stdin.lock().read_to_string(&mut buffer)?; Ok(buffer) } - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn command_line_value_wins_over_saved_and_default() { - assert_eq!( - resolve(Some("given".into()), "saved", "default".into()), - "given" - ); - assert_eq!(resolve(None, "saved", "default".into()), "saved"); - assert_eq!(resolve(None, "", "default".into()), "default"); - } -} blob - d42a73bd6246f28d199db6f8473e2feab8d66b7d blob + 0c5e72686f539aa894623fdeb4c1dfad729cabf5 --- src/transcript.rs +++ src/transcript.rs @@ -58,16 +58,6 @@ impl Transcript { system.chain(self.messages.iter().cloned()).collect() } - /// The most recent assistant reply, if there is one - #[must_use] - pub fn last_reply(&self) -> Option<&str> { - self.messages - .iter() - .rev() - .find(|m| m.role == Role::Assistant) - .map(|m| m.content.as_str()) - } - /// Read a transcript from `path` /// /// # Errors @@ -142,52 +132,6 @@ impl Transcript { Ok(serde_json::to_vec_pretty(self)?) } - /// Find the most recently modified usable transcript - /// - /// Unreadable files are skipped. Their error is only reported when no - /// transcript in the directory can be read. - /// - /// # Errors - /// - /// Returns [`Error::NoConversations`] when the directory holds no - /// transcripts, or the last parse error when none of them are usable. - pub fn newest() -> Result<(PathBuf, Self), Error> { - let dir = dir()?; - let entries = match fs::read_dir(&dir) { - Ok(entries) => entries, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - return Err(Error::NoConversations); - } - Err(err) => return Err(err.into()), - }; - - let mut candidates = vec![]; - for entry in entries { - let entry = entry?; - let path = entry.path(); - if path.extension().is_none_or(|ext| ext != "json") { - continue; - } - let metadata = entry.metadata()?; - if !metadata.is_file() { - continue; - } - candidates.push((metadata.modified()?, path)); - } - - candidates.sort_by(|(a, _), (b, _)| b.cmp(a)); - - let mut last_error = None; - for (_, path) in candidates { - match Self::load(&path) { - Ok(transcript) => return Ok((path, transcript)), - Err(err) => last_error = Some(err), - } - } - - Err(last_error.unwrap_or(Error::NoConversations)) - } - /// Check that messages alternate user, assistant, and end with a reply fn validate(&self, path: &Path) -> Result<(), Error> { let invalid = |message: &str| Error::InvalidTranscript { @@ -234,7 +178,6 @@ pub fn dir() -> Result { mod test { use super::*; use anyhow::Result; - use std::time::Duration; /// Point the transcript directory at a temporary tree for the duration of /// the guard. Tests that use it run serially, guarded by a mutex, because @@ -323,65 +266,6 @@ mod test { } #[test] - fn newest_picks_most_recent() -> Result<()> { - let _home = DataHome::new(); - let old = transcript("old").create()?; - let new = transcript("new").create()?; - - let past = SystemTime::now() - Duration::from_secs(60); - fs::File::open(&old)?.set_modified(past)?; - - let (path, found) = Transcript::newest()?; - - assert_eq!(path, new); - assert_eq!(found.model, "new"); - Ok(()) - } - - #[test] - fn newest_skips_unreadable_transcripts() -> Result<()> { - let _home = DataHome::new(); - let good = transcript("good").create()?; - fs::write(dir()?.join("corrupt.json"), b"not json")?; - - let past = SystemTime::now() - Duration::from_secs(60); - fs::File::open(&good)?.set_modified(past)?; - - let (path, found) = Transcript::newest()?; - - assert_eq!(path, good); - assert_eq!(found.model, "good"); - Ok(()) - } - - #[test] - fn newest_without_conversations_errors() { - let _home = DataHome::new(); - - let err = Transcript::newest().expect_err("should have no conversations"); - - assert!( - matches!(err, Error::NoConversations), - "unexpected error: {err:?}" - ); - } - - #[test] - fn newest_reports_parse_error_when_nothing_is_usable() -> Result<()> { - let _home = DataHome::new(); - fs::create_dir_all(dir()?)?; - fs::write(dir()?.join("corrupt.json"), b"not json")?; - - let err = Transcript::newest().expect_err("should fail"); - - assert!( - matches!(err, Error::InvalidTranscript { .. }), - "unexpected error: {err:?}" - ); - Ok(()) - } - - #[test] fn request_messages_puts_system_first() { let transcript = Transcript { version: VERSION, @@ -434,9 +318,4 @@ mod test { ); Ok(()) } - - #[test] - fn last_reply_returns_final_assistant_message() { - assert_eq!(transcript("m").last_reply(), Some("reply")); - } } blob - 71c9be65f7c2444532d0e3ae1519b8ad61615cf5 blob + 5027ebf20b59686a88c8ed594d9599655570a747 --- tests/chat.rs +++ tests/chat.rs @@ -72,8 +72,8 @@ fn chat_user_message_from_flag() { let cmd = alpaca() .args(["-u", "Hello"]) - .env("API_ENDPOINT", server.url()) - .env("API_KEY", "ABCDE") + .env("ALPACA_ENDPOINT", server.url()) + .env("ALPACA_API_KEY", "ABCDE") .assert(); mock.assert(); @@ -104,8 +104,8 @@ fn chat_user_message_from_stdin() { let cmd = alpaca() .args(["-"]) .write_stdin("Hello") - .env("API_ENDPOINT", server.url()) - .env("API_KEY", "ABCDE") + .env("ALPACA_ENDPOINT", server.url()) + .env("ALPACA_API_KEY", "ABCDE") .assert(); mock.assert(); @@ -135,8 +135,8 @@ fn chat_with_reasoning_effort() { let cmd = alpaca() .args(["-u", "Hello", "--reasoning-effort", "medium"]) - .env("API_ENDPOINT", server.url()) - .env("API_KEY", "ABCDE") + .env("ALPACA_ENDPOINT", server.url()) + .env("ALPACA_API_KEY", "ABCDE") .assert(); mock.assert(); @@ -188,8 +188,8 @@ fn chat_multiple_messages() { "-s", "SYSTEM", "-u", "USER_1", "-a", "ASSI_1", "-u", "USER_2", "-a", "ASSI_2", ]) .write_stdin("USER_STDIN") - .env("API_ENDPOINT", server.url()) - .env("API_KEY", "ABCDE") + .env("ALPACA_ENDPOINT", server.url()) + .env("ALPACA_API_KEY", "ABCDE") .assert(); mock.assert(); @@ -224,8 +224,8 @@ fn chat_api_error() { let cmd = alpaca() .args(["-u", "USER", "-t", "1000"]) .write_stdin("USER_STDIN") - .env("API_ENDPOINT", server.url()) - .env("API_KEY", "ABCDE") + .env("ALPACA_ENDPOINT", server.url()) + .env("ALPACA_API_KEY", "ABCDE") .assert(); mock.assert(); @@ -278,7 +278,7 @@ fn chat_against_a_completions_endpoint() { "--base-url", &format!("{}/v1", server.url()), ]) - .env("API_KEY", "ABCDE") + .env("ALPACA_API_KEY", "ABCDE") .assert(); mock.assert(); @@ -311,8 +311,8 @@ fn chat_user_message_from_file() { let cmd = alpaca() .args([infile.path().to_str().unwrap()]) - .env("API_ENDPOINT", server.url()) - .env("API_KEY", "ABCDE") + .env("ALPACA_ENDPOINT", server.url()) + .env("ALPACA_API_KEY", "ABCDE") .assert(); mock.assert(); blob - 129567edf4fd285888e49036219125e18279ef3b blob + 8b56561a25eb73ecdaa500abd27682f8726da4c2 --- tests/compose.rs +++ tests/compose.rs @@ -21,27 +21,9 @@ fn ok_body(content: &str) -> String { ) } -/// A saved conversation for `--continue` to pick up -fn saved_transcript(data_home: &TempDir) { - let dir = data_home.path().join("alpaca"); - fs::create_dir_all(&dir).unwrap(); - fs::write( - dir.join("1-0.json"), - r#"{ - "version": 1, - "model": "gpt-4o-mini", - "messages": [ - {"role": "user", "content": "earlier prompt"}, - {"role": "assistant", "content": "earlier reply"} - ] - }"#, - ) - .unwrap(); -} - -/// A stand-in for neovim that writes the buffer twice, then quits +/// A stand-in for neovim that submits two prompts, then quits /// -/// It checks the prefill and the reply that alpaca writes back, so a session +/// It checks the prefill and the replies that alpaca writes back, so a session /// that never reloads the buffer fails the test. fn fake_nvim(dir: &TempDir) -> std::path::PathBuf { let path = dir.path().join("nvim"); @@ -50,7 +32,7 @@ fn fake_nvim(dir: &TempDir) -> std::path::PathBuf { r#"#!/bin/sh set -e for buffer; do :; done -test "$(cat "$buffer")" = "earlier reply" +test "$(cat "$buffer")" = "hello" printf 'first prompt' > "$buffer" tries=0 while [ "$(cat "$buffer")" != "REPLY ONE" ]; do @@ -72,22 +54,46 @@ done path } +/// A stand-in for an editor that is reopened each turn +/// +/// The first invocation edits the prefill into a prompt; the second saves an +/// empty buffer, which is what ends the session. +fn fake_editor(dir: &TempDir) -> std::path::PathBuf { + let path = dir.path().join("editor"); + fs::write( + &path, + r#"#!/bin/sh +set -e +for buffer; do :; done +count=$(cat "$0.count" 2>/dev/null || echo 0) +count=$((count + 1)) +echo "$count" > "$0.count" +if [ "$count" -eq 1 ]; then + test "$(cat "$buffer")" = "hello" + printf 'first prompt' > "$buffer" +else + test "$(cat "$buffer")" = "REPLY ONE" + : > "$buffer" +fi +"#, + ) + .unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + path +} + #[test] -fn compose_continue_runs_turns_in_one_vim_session() { +fn compose_runs_turns_in_one_vim_session() { let mut server = mockito::Server::new(); let first = server .mock("POST", "/api/chat") - .match_body(mockito::Matcher::AllOf(vec![ - mockito::Matcher::Regex("earlier prompt".into()), - mockito::Matcher::Regex("first prompt".into()), - ])) + .match_body(mockito::Matcher::Regex("first prompt".into())) .with_header("content-type", "application/json") .with_body(ok_body("REPLY ONE")) .create(); let second = server .mock("POST", "/api/chat") .match_body(mockito::Matcher::AllOf(vec![ - mockito::Matcher::Regex("earlier prompt".into()), mockito::Matcher::Regex("first prompt".into()), mockito::Matcher::Regex("REPLY ONE".into()), mockito::Matcher::Regex("second prompt".into()), @@ -99,19 +105,19 @@ fn compose_continue_runs_turns_in_one_vim_session() { let config_home = TempDir::new().unwrap(); let data_home = TempDir::new().unwrap(); let editor_dir = TempDir::new().unwrap(); - saved_transcript(&data_home); let editor = fake_nvim(&editor_dir); // One editor invocation covers both turns: the fake editor exits only // after seeing each reply land in its buffer. Command::cargo_bin("alpaca") .unwrap() - .args(["compose", "--continue"]) + .args(["compose"]) .env("XDG_CONFIG_HOME", config_home.path()) .env("XDG_DATA_HOME", data_home.path()) .env("VISUAL", editor.to_str().unwrap()) - .env("API_ENDPOINT", server.url()) - .env("API_KEY", "ABCDE") + .env("ALPACA_ENDPOINT", server.url()) + .env("ALPACA_API_KEY", "ABCDE") + .write_stdin("hello") .assert() .success() .stdout(predicate::str::contains("REPLY ONE").and(predicate::str::contains("REPLY TWO"))); @@ -119,3 +125,36 @@ fn compose_continue_runs_turns_in_one_vim_session() { first.assert(); second.assert(); } + +#[test] +fn compose_reopens_other_editors_until_an_empty_buffer_is_saved() { + let mut server = mockito::Server::new(); + let only = server + .mock("POST", "/api/chat") + .match_body(mockito::Matcher::Regex("first prompt".into())) + .with_header("content-type", "application/json") + .with_body(ok_body("REPLY ONE")) + .create(); + + let config_home = TempDir::new().unwrap(); + let data_home = TempDir::new().unwrap(); + let editor_dir = TempDir::new().unwrap(); + let editor = fake_editor(&editor_dir); + + // The reply is piped back in as the next prefill; saving an empty buffer + // on the second invocation ends the session without another request. + Command::cargo_bin("alpaca") + .unwrap() + .args(["compose"]) + .env("XDG_CONFIG_HOME", config_home.path()) + .env("XDG_DATA_HOME", data_home.path()) + .env("VISUAL", editor.to_str().unwrap()) + .env("ALPACA_ENDPOINT", server.url()) + .env("ALPACA_API_KEY", "ABCDE") + .write_stdin("hello") + .assert() + .success() + .stdout(predicate::str::contains("REPLY ONE")); + + only.assert(); +} blob - 1c35062955e4bf8db101bab3e0c3fbaac60d6367 blob + fc06876bf3d09e978bd2a9da06b2c4e0f186b788 --- tests/quota.rs +++ tests/quota.rs @@ -30,8 +30,8 @@ fn alpaca() -> Command { fn offline(cmd: &mut Command) -> &mut Command { cmd.env_remove("SYNTHETIC_API_KEY") .env_remove("DEEPINFRA_API_KEY") - .env_remove("API_KEY") - .env_remove("API_ENDPOINT") + .env_remove("ALPACA_API_KEY") + .env_remove("ALPACA_ENDPOINT") } #[test] @@ -304,8 +304,8 @@ fn top_level_apikey_is_the_last_quota_fallback() { Command::cargo_bin("alpaca") .unwrap() .env("XDG_CONFIG_HOME", config_home.path()) - .env_remove("API_KEY") - .env_remove("API_ENDPOINT") + .env_remove("ALPACA_API_KEY") + .env_remove("ALPACA_ENDPOINT") .args(["quota", "--base-url", &server.url()]) .assert() .success()