Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 82 additions & 20 deletions ai-code-backends.el
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
(defvar ai-code--cli-resume-fn #'ai-code--unsupported-resume)
(defvar ai-code--cli-switch-fn #'ai-code--unsupported-switch-to-buffer)
(defvar ai-code--cli-send-fn #'ai-code--unsupported-send-command)
(defvar ai-code-selected-backend 'claude-code
"Currently selected backend key from `ai-code-backends'.")

(defvar ai-code--repo-backend-alist nil
"Alist of (GIT-ROOT . BACKEND) to keep backend affinity per repository.")
Expand Down Expand Up @@ -423,21 +425,92 @@ configuration paths, upgrade commands, and skill-install commands."
(const :tag "Not supported" nil))))
:group 'ai-code)

(defvar ai-code-selected-backend 'claude-code
"Currently selected backend key from `ai-code-backends'.")
(defvar ai-code-backends-history-file
(expand-file-name "ai-code-backends-history.el" user-emacs-directory)
"File path to store the MRU history of selected backends.")

(defun ai-code--delete-backends-history-file ()
"Delete `ai-code-backends-history-file' when it exists."
(when (file-exists-p ai-code-backends-history-file)
(ignore-errors (delete-file ai-code-backends-history-file))))

(defun ai-code--valid-backends-history-p (history)
"Return non-nil when HISTORY is a list of backend symbols."
(and (listp history)
(seq-every-p (lambda (item)
(and item (symbolp item)))
history)))

(defun ai-code--load-backends-history ()
"Load the MRU backend history from `ai-code-backends-history-file'."
(if (file-exists-p ai-code-backends-history-file)
(condition-case nil
(with-temp-buffer
(insert-file-contents ai-code-backends-history-file)
(let ((content (buffer-string)))
(unless (string-empty-p content)
(let ((history (read content)))
(if (ai-code--valid-backends-history-p history)
history
(ai-code--delete-backends-history-file)
nil)))))
(error
(ai-code--delete-backends-history-file)
nil))
nil))

(defun ai-code--save-backend-history (backend)
"Save BACKEND to the MRU history list file."
(let* ((history (ai-code--load-backends-history))
(updated-history (cons backend (seq-remove (lambda (x) (eq x backend)) history))))
(condition-case nil
(with-temp-file ai-code-backends-history-file
(insert (let ((print-circle nil))
(prin1-to-string updated-history))))
(error nil))))

(defun ai-code-set-backend (new-backend)
"Set the AI backend to NEW-BACKEND."
(unless (ai-code--backend-spec new-backend)
(user-error "Unknown backend: %s" new-backend))
(setq ai-code-selected-backend new-backend)
(ai-code--apply-backend new-backend)
(ai-code--remember-current-backend-for-repo))
(ai-code--remember-current-backend-for-repo)
(ai-code--save-backend-history new-backend))

(defun ai-code--backend-spec (key)
"Return backend plist for KEY from `ai-code-backends'."
(seq-find (lambda (it) (eq (car it) key)) ai-code-backends))

(defun ai-code--ordered-backend-choices ()
"Return backend choices with the effective backend first, then MRU entries."
(let* ((choices (mapcar (lambda (it)
(let* ((key (car it))
(label (plist-get (cdr it) :label)))
(cons (format "%s" label) key)))
ai-code-backends))
(effective-backend (ai-code--effective-backend))
(history (ai-code--load-backends-history))
(history-choices (seq-filter #'identity
(mapcar (lambda (key)
(seq-find (lambda (candidate)
(eq (cdr candidate) key))
choices))
history)))
(other-choices (seq-remove (lambda (candidate)
(member candidate history-choices))
choices))
(sorted-choices (append history-choices other-choices))
(current-choice (seq-find (lambda (candidate)
(eq (cdr candidate) effective-backend))
sorted-choices)))
(if current-choice
(cons current-choice
(seq-remove (lambda (candidate)
(eq (cdr candidate) effective-backend))
sorted-choices))
sorted-choices)))

(defun ai-code-current-backend-label ()
"Return label string of the currently selected backend.
Falls back to symbol name when label is unavailable."
Expand Down Expand Up @@ -513,25 +586,14 @@ invoke `ai-code-cli-resume'; otherwise call `ai-code-cli-start'."
(defun ai-code-select-backend ()
"Interactively select and apply an AI backend from `ai-code-backends'."
(interactive)
(let* ((choices (mapcar (lambda (it)
(let* ((key (car it))
(label (plist-get (cdr it) :label)))
(cons (format "%s" label) key)))
ai-code-backends))
(effective-backend (ai-code--effective-backend))
(current-label (car (seq-find (lambda (it)
(eq (cdr it) effective-backend))
choices)))
(ordered-choices (if current-label
(let ((current (assoc current-label choices)))
(cons current
(seq-remove (lambda (it)
(equal (car it) current-label))
choices)))
choices))
(let* ((ordered-choices (ai-code--ordered-backend-choices))
(current-choice (car ordered-choices))
(completion-extra-properties
'(:display-sort-function identity
:cycle-sort-function identity))
(choice (completing-read "Select backend: "
(mapcar #'car ordered-choices)
nil t nil nil current-label))
nil t nil nil (car current-choice)))
(key (cdr (assoc choice ordered-choices))))
(ai-code-set-backend key)
(when (fboundp 'ai-code-onboarding-show-backend-switch-hint)
Expand Down
115 changes: 115 additions & 0 deletions test/test_ai-code-backends.el
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,121 @@
(should spec)
(should-not (plist-get (cdr spec) :install-skills))))

(ert-deftest ai-code-test-select-backend-mru-ordering ()
"Test that backend selection ordering follows the MRU list loaded from the history file, and updates it upon selection."
(let* ((temp-file (make-temp-file "ai-code-backends-history-"))
(ai-code-backends-history-file temp-file)
(ai-code-backends '((backend-a :label "Backend A" :start ignore :switch ignore :send ignore)
(backend-b :label "Backend B" :start ignore :switch ignore :send ignore)
(backend-c :label "Backend C" :start ignore :switch ignore :send ignore)))
(ai-code-selected-backend 'backend-a)
(captured-candidates nil)
(captured-extra-properties nil)
(selected-choice "Backend B"))
(unwind-protect
(progn
;; Write initial history: backend-c was used recently, backend-b before that
(with-temp-file temp-file
(insert (prin1-to-string '(backend-c backend-b))))

;; Spy completing-read to capture the candidates offered to the user
(cl-letf (((symbol-function 'completing-read)
(lambda (_prompt candidates &rest _args)
(setq captured-candidates candidates)
(setq captured-extra-properties completion-extra-properties)
selected-choice))
((symbol-function 'ai-code-onboarding-show-backend-switch-hint) #'ignore)
((symbol-function 'message) #'ignore))
(ai-code-select-backend)

;; 1. The selected backend should be switched to backend-b
(should (eq ai-code-selected-backend 'backend-b))

;; 2. The candidates list passed to completing-read should have:
;; - First: Current effective backend (backend-a)
;; - Then: history items in order (backend-c, backend-b) excluding current
;; So candidates should be '("Backend A" "Backend C" "Backend B")
(should (equal captured-candidates '("Backend A" "Backend C" "Backend B")))
(should (eq (plist-get captured-extra-properties :display-sort-function)
#'identity))
(should (eq (plist-get captured-extra-properties :cycle-sort-function)
#'identity))

;; 3. The history file should have been updated after selection:
;; New selection B is now at the top, C is next
(let ((new-history (with-temp-buffer
(insert-file-contents temp-file)
(read (buffer-string)))))
(should (equal new-history '(backend-b backend-c))))))
(when (file-exists-p temp-file)
(delete-file temp-file)))))

(ert-deftest ai-code-test-backends-corrupted-history-file-deleted ()
"Test that if the history file contains invalid Lisp, load-backends-history deletes it and returns nil."
(let* ((temp-file (make-temp-file "ai-code-backends-history-"))
(ai-code-backends-history-file temp-file))
(unwind-protect
(progn
;; Write corrupted content
(write-region "(invalid-lisp" nil temp-file nil 'silent)
(should (file-exists-p temp-file))

;; Load should return nil
(should-not (ai-code--load-backends-history))

;; File should have been deleted
(should-not (file-exists-p temp-file)))
(when (file-exists-p temp-file)
(delete-file temp-file)))))

(ert-deftest ai-code-test-backends-readable-malformed-history-file-deleted ()
"Test that readable but malformed history content is discarded."
(let* ((temp-file (make-temp-file "ai-code-backends-history-"))
(ai-code-backends-history-file temp-file))
(unwind-protect
(progn
(write-region "backend-b" nil temp-file nil 'silent)
(should (file-exists-p temp-file))
(should-not (ai-code--load-backends-history))
(should-not (file-exists-p temp-file)))
(when (file-exists-p temp-file)
(delete-file temp-file)))))

(ert-deftest ai-code-test-select-backend-partial-history-keeps-all-backends ()
"Test that even if the history file only covers some backends or contains invalid keys, all backends are still available as choices."
(let* ((temp-file (make-temp-file "ai-code-backends-history-"))
(ai-code-backends-history-file temp-file)
(ai-code-backends '((backend-a :label "Backend A" :start ignore :switch ignore :send ignore)
(backend-b :label "Backend B" :start ignore :switch ignore :send ignore)
(backend-c :label "Backend C" :start ignore :switch ignore :send ignore)))
(ai-code-selected-backend 'backend-a)
(captured-candidates nil)
(selected-choice "Backend C"))
(unwind-protect
(progn
;; Write partial/invalid history: only backend-b and some invalid key
(with-temp-file temp-file
(insert (prin1-to-string '(backend-b invalid-backend-key))))

(cl-letf (((symbol-function 'completing-read)
(lambda (_prompt candidates &rest _args)
(setq captured-candidates candidates)
selected-choice))
((symbol-function 'ai-code-onboarding-show-backend-switch-hint) #'ignore)
((symbol-function 'message) #'ignore))
(ai-code-select-backend)

;; 1. The selection should complete successfully
(should (eq ai-code-selected-backend 'backend-c))
;; 2. Candidates list must still include all three backends:
;; - First: Current effective backend (backend-a)
;; - Then: history items that are valid (backend-b)
;; - Last: other remaining backends (backend-c)
;; So candidates should be '("Backend A" "Backend B" "Backend C")
(should (equal captured-candidates '("Backend A" "Backend B" "Backend C")))))
(when (file-exists-p temp-file)
(delete-file temp-file)))))

(provide 'test_ai-code-backends)

;;; test_ai-code-backends.el ends here
Loading