From 03a1f26b2879f678a48c036a9e6f4b9ace864c3b Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Mon, 13 Jul 2026 23:45:37 -0500 Subject: feat(ai-term): pick the agent runtime at session launch A fresh agent session now asks which runtime to run: claude, codex, or any local ollama model via codex --oss. RET keeps claude, so the common launch stays Enter-Enter. Reattaches and redisplays never ask. The picker fires only when neither a live agent buffer nor a surviving tmux session exists. I mirrored the runtime names and launch strings from the bin/ai launcher so the two stay one mental model, and the choice list itself comes from "ai --print-runtimes" when that launcher is installed, reusing its live ollama scan and dead-server timeout. A static claude-first list stands in without it. The explicit --local-provider=ollama flag is deliberate: configuring the provider through config.toml silently does nothing. The chosen command threads through show-or-create into the tmux launch line. On reattach, tmux new-session -A ignores it, so no reattach path changes behavior. cj/ai-term-agent-command keeps its meaning as the claude runtime's full line. Thirteen tests cover the runtime-to-command map, the print-runtimes parsing, the launcher fallback, and the launch-command override. --- tests/test-ai-term--runtime.el | 136 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/test-ai-term--runtime.el (limited to 'tests/test-ai-term--runtime.el') diff --git a/tests/test-ai-term--runtime.el b/tests/test-ai-term--runtime.el new file mode 100644 index 00000000..7644e468 --- /dev/null +++ b/tests/test-ai-term--runtime.el @@ -0,0 +1,136 @@ +;;; test-ai-term--runtime.el --- Tests for the ai-term runtime selection -*- lexical-binding: t; -*- + +;;; Commentary: +;; Multi-backend launch: a fresh agent session can run Claude, Codex, or a +;; local model through codex --oss (ollama). The runtime names and launch +;; strings mirror the rulesets bin/ai launcher so the two stay one mental +;; model: "claude", "codex", and "local:". +;; +;; Pure pieces tested here: +;; - `cj/--ai-term-runtime-command' maps a runtime name to the full shell +;; command (agent CLI + the shared opening prompt). +;; - `cj/--ai-term-parse-runtime-lines' parses `ai --print-runtimes' output +;; into (NAME . LABEL) choices. +;; - `cj/--ai-term-runtime-choices' shells out to `ai' at its boundary +;; (mocked here) and falls back to the static list when `ai' is absent. +;; The interactive picker is a thin completing-read wrapper and is not +;; tested (Interactive vs Internal split). + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-term) + +;;; ------------------------- runtime -> command ------------------------------ + +(ert-deftest test-ai-term-runtime-command-claude-is-agent-command () + "Normal: \"claude\" (and nil) return `cj/ai-term-agent-command' verbatim." + (let ((cj/ai-term-agent-command "claude \"do the thing\"")) + (should (equal (cj/--ai-term-runtime-command "claude") + "claude \"do the thing\"")) + (should (equal (cj/--ai-term-runtime-command nil) + "claude \"do the thing\"")))) + +(ert-deftest test-ai-term-runtime-command-codex-composes-prompt () + "Normal: \"codex\" is the codex CLI plus the shared opening prompt." + (let ((cj/ai-term-agent-prompt "read the protocols")) + (should (equal (cj/--ai-term-runtime-command "codex") + (concat "codex " (shell-quote-argument "read the protocols")))))) + +(ert-deftest test-ai-term-runtime-command-local-model () + "Normal: \"local:\" runs codex --oss against that ollama model. +The model is shell-quoted, and POSIX `shell-quote-argument' backslash-escapes +the colons, so the assertion matches the quoted form." + (let ((cj/ai-term-agent-prompt "read the protocols")) + (let ((cmd (cj/--ai-term-runtime-command "local:gpt-oss:120b"))) + (should (string-prefix-p "codex --oss --local-provider=ollama -m " cmd)) + (should (string-match-p + (regexp-quote (shell-quote-argument "gpt-oss:120b")) cmd)) + (should (string-suffix-p (shell-quote-argument "read the protocols") cmd))))) + +(ert-deftest test-ai-term-runtime-command-boundary-empty-model () + "Boundary: \"local:\" with no model is rejected, not launched half-formed." + (should-error (cj/--ai-term-runtime-command "local:") :type 'user-error)) + +(ert-deftest test-ai-term-runtime-command-error-unknown-runtime () + "Error: an unknown runtime name signals a `user-error' naming it." + (should-error (cj/--ai-term-runtime-command "gemini") :type 'user-error) + (condition-case err + (cj/--ai-term-runtime-command "gemini") + (user-error (should (string-match-p "gemini" (cadr err)))))) + +;;; --------------------------- choice-list parsing --------------------------- + +(ert-deftest test-ai-term-parse-runtime-lines-normal () + "Normal: `ai --print-runtimes' lines parse into (NAME . LABEL) pairs." + (should (equal (cj/--ai-term-parse-runtime-lines + "claude — Claude Code\ncodex — ChatGPT (Codex CLI)\nlocal:gpt-oss:120b — ollama\n") + '(("claude" . "Claude Code") + ("codex" . "ChatGPT (Codex CLI)") + ("local:gpt-oss:120b" . "ollama"))))) + +(ert-deftest test-ai-term-parse-runtime-lines-boundary-junk () + "Boundary: blank lines and lines without a separator are dropped." + (should (equal (cj/--ai-term-parse-runtime-lines + "\nclaude — Claude Code\nwarning: something\n\n") + '(("claude" . "Claude Code"))))) + +(ert-deftest test-ai-term-parse-runtime-lines-boundary-empty () + "Boundary: empty output parses to nil." + (should (null (cj/--ai-term-parse-runtime-lines "")))) + +;;; ------------------------------ choice list -------------------------------- + +(ert-deftest test-ai-term-runtime-choices-uses-ai-launcher () + "Normal: when the `ai' launcher exists, its runtime list is the choice list." + (cl-letf (((symbol-function 'executable-find) + (lambda (prog &rest _) (when (equal prog "ai") "/usr/bin/ai"))) + ((symbol-function 'process-file) + (lambda (_prog _infile buffer _display &rest _args) + (with-current-buffer (cond ((eq buffer t) (current-buffer)) + ((consp buffer) (car buffer)) + (t buffer)) + (insert "claude — Claude Code\nlocal:q — ollama\n")) + 0))) + (should (equal (cj/--ai-term-runtime-choices) + '(("claude" . "Claude Code") ("local:q" . "ollama")))))) + +(ert-deftest test-ai-term-runtime-choices-fallback-without-ai () + "Boundary: with no `ai' launcher, the static claude-first list stands." + (cl-letf (((symbol-function 'executable-find) (lambda (&rest _) nil))) + (let ((choices (cj/--ai-term-runtime-choices))) + (should (equal (caar choices) "claude")) + (should (assoc "codex" choices))))) + +(ert-deftest test-ai-term-runtime-choices-error-ai-fails () + "Error: a nonzero exit from `ai' falls back instead of erroring." + (cl-letf (((symbol-function 'executable-find) + (lambda (prog &rest _) (when (equal prog "ai") "/usr/bin/ai"))) + ((symbol-function 'process-file) (lambda (&rest _) 1))) + (should (equal (caar (cj/--ai-term-runtime-choices)) "claude")))) + +;;; --------------------- launch command takes an override -------------------- + +(ert-deftest test-ai-term-launch-command-runtime-override () + "Normal: an explicit agent command is embedded instead of the default. +The inner command is shell-quoted by the launch builder, so the assertion +matches the quoted form." + (let ((cj/ai-term-agent-command "claude default")) + (let ((cmd (cj/--ai-term-launch-command "/tmp/proj" "codex prompted"))) + (should (string-match-p + (regexp-quote (shell-quote-argument "codex prompted; exec bash")) + cmd)) + (should-not (string-match-p "claude" cmd))))) + +(ert-deftest test-ai-term-launch-command-no-override-falls-back () + "Boundary: without an override the configured agent command is used." + (let ((cj/ai-term-agent-command "claude default")) + (should (string-match-p + (regexp-quote (shell-quote-argument "claude default; exec bash")) + (cj/--ai-term-launch-command "/tmp/proj"))))) + +(provide 'test-ai-term--runtime) +;;; test-ai-term--runtime.el ends here -- cgit v1.2.3