From 2e71594ebad7d8636b39ca65a260307733ac2def Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 16 Jun 2026 00:14:17 -0500 Subject: fix(ai-config): intern model in gptel-switch-backend (modeline hang) cj/gptel-switch-backend set gptel-model to the raw completing-read string. gptel's modeline code calls symbolp on gptel-model and signals wrong-type-argument on a string, which surfaces as a redisplay hang (reachable from C-; a B). The sibling command cj/gptel-change-model already interns. This one didn't. I added a pure cj/gptel--model-to-symbol helper (mirroring cj/gptel--model-to-string) and route the model through it before the setq. The existing switch-backend test asserted the buggy string value. It now asserts a symbol plus an explicit symbolp guard. --- modules/ai-config.el | 12 ++++++- tests/test-ai-config-gptel-commands.el | 5 ++- tests/test-ai-config-model-to-symbol.el | 61 +++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 tests/test-ai-config-model-to-symbol.el diff --git a/modules/ai-config.el b/modules/ai-config.el index e439ab5c9..20bf6ec88 100644 --- a/modules/ai-config.el +++ b/modules/ai-config.el @@ -192,6 +192,16 @@ Ensures gptel and backends are initialized." ((symbolp m) (symbol-name m)) (t (format "%s" m)))) +(defun cj/gptel--model-to-symbol (m) + "Return model M as a symbol regardless of its type. +`gptel-model' must be a symbol: gptel's modeline code calls `symbolp' +on it and signals `wrong-type-argument' on a string, which surfaces as a +redisplay hang. Coerce any model value through this before assigning it." + (cond + ((symbolp m) m) + ((stringp m) (intern m)) + (t (intern (format "%s" m))))) + ;; Backend/model switching helpers (pure logic, extracted for testability) (defun cj/gptel--build-model-list (backends model-fn) @@ -270,7 +280,7 @@ necessary. Prompt for whether to apply the selection globally or buffer-locally. (mapcar #'cj/gptel--model-to-string models) nil t nil nil (cj/gptel--model-to-string (bound-and-true-p gptel-model))))) (setq gptel-backend backend - gptel-model model) + gptel-model (cj/gptel--model-to-symbol model)) (message "Switched to %s with model: %s" choice model)))) ;; Clear assistant buffer (moved out so it's always available) diff --git a/tests/test-ai-config-gptel-commands.el b/tests/test-ai-config-gptel-commands.el index b87c4975e..371a75cc8 100644 --- a/tests/test-ai-config-gptel-commands.el +++ b/tests/test-ai-config-gptel-commands.el @@ -77,7 +77,10 @@ (lambda (fmt &rest args) (setq msg (apply #'format fmt args))))) (cj/gptel-switch-backend)) (should (eq gptel-backend 'anthropic-backend)) - (should (equal gptel-model "claude-opus")) + ;; gptel-model must be a symbol, not the raw completing-read string: + ;; gptel's modeline calls `symbolp' on it and hangs redisplay otherwise. + (should (symbolp gptel-model)) + (should (eq gptel-model 'claude-opus)) (should (string-match-p "Anthropic - Claude" msg)))) (ert-deftest test-ai-config-switch-backend-error-invalid-choice () diff --git a/tests/test-ai-config-model-to-symbol.el b/tests/test-ai-config-model-to-symbol.el new file mode 100644 index 000000000..de6f18ff8 --- /dev/null +++ b/tests/test-ai-config-model-to-symbol.el @@ -0,0 +1,61 @@ +;;; test-ai-config-model-to-symbol.el --- Tests for cj/gptel--model-to-symbol -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for cj/gptel--model-to-symbol from ai-config.el. +;; +;; Pure function that coerces a model identifier (string, symbol, or other +;; type) to a symbol. `gptel-model' MUST be a symbol -- gptel's modeline +;; code calls `symbolp' on it and signals wrong-type-argument on a string, +;; which manifests as a redisplay hang. The function's invariant is that +;; the result is always a symbol, so a value coerced through it is safe to +;; assign to `gptel-model'. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'testutil-ai-config) +(require 'ai-config) + +;;; Normal Cases + +(ert-deftest test-ai-config-model-to-symbol-normal-string-interns () + "Normal: a string model name is interned to the matching symbol." + (should (eq (cj/gptel--model-to-symbol "claude-opus-4-8") 'claude-opus-4-8))) + +(ert-deftest test-ai-config-model-to-symbol-normal-symbol-returns-symbol () + "Normal: a symbol model name is returned unchanged." + (should (eq (cj/gptel--model-to-symbol 'gpt-4o) 'gpt-4o))) + +(ert-deftest test-ai-config-model-to-symbol-normal-result-always-symbol () + "Normal: the invariant -- the result is always a symbol (the crash guard)." + (should (symbolp (cj/gptel--model-to-symbol "gpt-5.5"))) + (should (symbolp (cj/gptel--model-to-symbol 'gpt-5.5)))) + +;;; Boundary Cases + +(ert-deftest test-ai-config-model-to-symbol-boundary-empty-string-is-symbol () + "Boundary: empty string interns to a symbol (still satisfies the invariant)." + (should (symbolp (cj/gptel--model-to-symbol "")))) + +(ert-deftest test-ai-config-model-to-symbol-boundary-nil-returns-nil () + "Boundary: nil is already a symbol, returned unchanged." + (should (eq (cj/gptel--model-to-symbol nil) nil)) + (should (symbolp (cj/gptel--model-to-symbol nil)))) + +(ert-deftest test-ai-config-model-to-symbol-boundary-string-with-spaces-interns () + "Boundary: a string with spaces interns to a single symbol with that name." + (should (eq (cj/gptel--model-to-symbol "model with spaces") + (intern "model with spaces")))) + +;;; Error/Odd Cases + +(ert-deftest test-ai-config-model-to-symbol-number-formats-then-interns () + "Error: a non-string, non-symbol value is formatted then interned to a symbol." + (should (eq (cj/gptel--model-to-symbol 42) (intern "42"))) + (should (symbolp (cj/gptel--model-to-symbol 42)))) + +(provide 'test-ai-config-model-to-symbol) +;;; test-ai-config-model-to-symbol.el ends here -- cgit v1.2.3