From 656676ccff60f949be2cd436e9a82932763686bd Mon Sep 17 00:00:00 2001 From: tninja Date: Wed, 24 Jun 2026 19:43:54 -0700 Subject: [PATCH 1/3] Persist and Use MRU Backend History --- ai-code-backends.el | 54 +++++++++++++++++++++++++++-------- test/test_ai-code-backends.el | 43 ++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 12 deletions(-) diff --git a/ai-code-backends.el b/ai-code-backends.el index b90485d1..5be30591 100644 --- a/ai-code-backends.el +++ b/ai-code-backends.el @@ -423,6 +423,32 @@ 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)))) + (error 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'.") @@ -432,7 +458,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'." @@ -519,19 +546,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) diff --git a/test/test_ai-code-backends.el b/test/test_ai-code-backends.el index eb774119..beb73562 100644 --- a/test/test_ai-code-backends.el +++ b/test/test_ai-code-backends.el @@ -405,6 +405,49 @@ (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))))) + (provide 'test_ai-code-backends) ;;; test_ai-code-backends.el ends here From 5e15926f87a6eff27638f320203121b19d4fd850 Mon Sep 17 00:00:00 2001 From: tninja Date: Wed, 24 Jun 2026 19:47:19 -0700 Subject: [PATCH 2/3] Delete corrupted backends history and add tests --- ai-code-backends.el | 4 ++- test/test_ai-code-backends.el | 53 +++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/ai-code-backends.el b/ai-code-backends.el index 5be30591..fdaced28 100644 --- a/ai-code-backends.el +++ b/ai-code-backends.el @@ -436,7 +436,9 @@ configuration paths, upgrade commands, and skill-install commands." (let ((content (buffer-string))) (unless (string-empty-p content) (read content)))) - (error nil)) + (error + (ignore-errors (delete-file ai-code-backends-history-file)) + nil)) nil)) (defun ai-code--save-backend-history (backend) diff --git a/test/test_ai-code-backends.el b/test/test_ai-code-backends.el index beb73562..aff1a029 100644 --- a/test/test_ai-code-backends.el +++ b/test/test_ai-code-backends.el @@ -448,6 +448,59 @@ (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 From 8271122ffcdbdbe3808cb223a14879986896c3d4 Mon Sep 17 00:00:00 2001 From: tninja Date: Wed, 24 Jun 2026 20:04:29 -0700 Subject: [PATCH 3/3] Validate backend history and order choices --- ai-code-backends.el | 78 ++++++++++++++++++++++++----------- test/test_ai-code-backends.el | 19 +++++++++ 2 files changed, 73 insertions(+), 24 deletions(-) diff --git a/ai-code-backends.el b/ai-code-backends.el index fdaced28..d220d9cf 100644 --- a/ai-code-backends.el +++ b/ai-code-backends.el @@ -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.") @@ -427,6 +429,18 @@ configuration paths, upgrade commands, and skill-install commands." (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) @@ -435,9 +449,13 @@ configuration paths, upgrade commands, and skill-install commands." (insert-file-contents ai-code-backends-history-file) (let ((content (buffer-string))) (unless (string-empty-p content) - (read content)))) + (let ((history (read content))) + (if (ai-code--valid-backends-history-p history) + history + (ai-code--delete-backends-history-file) + nil))))) (error - (ignore-errors (delete-file ai-code-backends-history-file)) + (ai-code--delete-backends-history-file) nil)) nil)) @@ -451,9 +469,6 @@ configuration paths, upgrade commands, and skill-install commands." (prin1-to-string updated-history)))) (error nil)))) -(defvar ai-code-selected-backend 'claude-code - "Currently selected backend key from `ai-code-backends'.") - (defun ai-code-set-backend (new-backend) "Set the AI backend to NEW-BACKEND." (unless (ai-code--backend-spec new-backend) @@ -467,6 +482,35 @@ configuration paths, upgrade commands, and skill-install commands." "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." @@ -542,25 +586,11 @@ 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)) - (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)) + (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 (car current-choice))) diff --git a/test/test_ai-code-backends.el b/test/test_ai-code-backends.el index aff1a029..76612e31 100644 --- a/test/test_ai-code-backends.el +++ b/test/test_ai-code-backends.el @@ -414,6 +414,7 @@ (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 @@ -425,6 +426,7 @@ (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)) @@ -438,6 +440,10 @@ ;; - 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 @@ -466,6 +472,19 @@ (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-"))