Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
56 changes: 44 additions & 12 deletions ai-code-backends.el
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,34 @@ configuration paths, upgrade commands, and skill-install commands."
(const :tag "Not supported" nil))))
:group 'ai-code)

(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--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)
(read content))))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate history shape before returning it

If ai-code-backends-history.el contains valid Lisp that is not a proper list of backend symbols, such as a truncated atom or dotted list, this read returns it as-is instead of treating it as corrupted. The next C-c a s or backend switch then iterates/removes over that malformed value and can signal wrong-type-argument, leaving the selector broken until the user manually deletes the history file; the loader should reject and delete malformed-but-readable history too.

Useful? React with 👍 / 👎.

(error
(ignore-errors (delete-file ai-code-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))))

(defvar ai-code-selected-backend 'claude-code
"Currently selected backend key from `ai-code-backends'.")

Expand All @@ -432,7 +460,8 @@ configuration paths, upgrade commands, and skill-install commands."
(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'."
Expand Down Expand Up @@ -519,19 +548,22 @@ invoke `ai-code-cli-resume'; otherwise call `ai-code-cli-start'."
(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))
(history (ai-code--load-backends-history))
(history-choices (seq-filter #'identity
(mapcar (lambda (key)
(seq-find (lambda (c) (eq (cdr c) key)) choices))
history)))
(other-choices (seq-remove (lambda (c) (member c history-choices)) choices))
(sorted-choices (append history-choices other-choices))
(current-choice (seq-find (lambda (it) (eq (cdr it) effective-backend)) sorted-choices))
(ordered-choices (if current-choice
(cons current-choice
(seq-remove (lambda (it) (eq (cdr it) effective-backend))
sorted-choices))
sorted-choices))
(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
96 changes: 96 additions & 0 deletions test/test_ai-code-backends.el
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,102 @@
(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)
(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)
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")))

;; 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-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