From 1fef9aed248aaf7586950475aa22849e413f1c04 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sat, 16 May 2026 17:39:56 -0500 Subject: feat(ai-conversations): autosave on a periodic timer The existing autosave only fired after gptel-send returned, so a conversation paused mid-thought wasn't on disk if Emacs crashed. I added a buffer-local repeating timer that calls cj/gptel--save-buffer-to-file every cj/gptel-autosave-interval seconds (default 60) for as long as cj/gptel--autosave-active-p holds. Toggle-off and kill-buffer-hook cancel it cleanly. Tests cover start/stop idempotency, the active-p predicate, the kill-buffer cleanup hook, and the toggle integration. --- modules/ai-conversations.el | 60 +++++++++++++++++++++-- tests/test-ai-conversations.el | 105 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 155 insertions(+), 10 deletions(-) diff --git a/modules/ai-conversations.el b/modules/ai-conversations.el index 03ea6ec9..fc234a82 100644 --- a/modules/ai-conversations.el +++ b/modules/ai-conversations.el @@ -51,11 +51,53 @@ If displaying on the top or bottom, treat this value as a height fraction." (defvar-local cj/gptel-autosave-filepath nil "File path used for auto-saving the conversation buffer.") +(defvar-local cj/gptel-autosave--timer nil + "Repeating timer used to auto-save the current GPTel buffer.") + +(defcustom cj/gptel-autosave-interval 60 + "Seconds between periodic GPTel conversation autosaves." + :type 'number + :group 'cj/ai-conversations) + (defvar cj/gptel-autosave-mode-line-format '(:eval (when (bound-and-true-p cj/gptel-autosave-enabled) " [AS]")) "Mode-line construct that surfaces autosave state in GPTel buffers.") (put 'cj/gptel-autosave-mode-line-format 'risky-local-variable t) +(defun cj/gptel--autosave-active-p () + "Return non-nil when the current buffer has an autosave target." + (and (bound-and-true-p gptel-mode) + cj/gptel-autosave-enabled + (stringp cj/gptel-autosave-filepath) + (> (length cj/gptel-autosave-filepath) 0))) + +(defun cj/gptel--autosave-stop-timer () + "Cancel the current buffer's periodic autosave timer, if any." + (when cj/gptel-autosave--timer + (cancel-timer cj/gptel-autosave--timer) + (setq-local cj/gptel-autosave--timer nil))) + +(defun cj/gptel--autosave-timer-callback (buffer) + "Auto-save BUFFER from a periodic timer when autosave is still active." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (if (cj/gptel--autosave-active-p) + (condition-case err + (cj/gptel--save-buffer-to-file (current-buffer) cj/gptel-autosave-filepath) + (error (message "cj/gptel periodic autosave failed: %s" + (error-message-string err)))) + (cj/gptel--autosave-stop-timer))))) + +(defun cj/gptel--autosave-start-timer () + "Start the current buffer's periodic autosave timer when autosave is active." + (when (and (cj/gptel--autosave-active-p) + (not cj/gptel-autosave--timer)) + (setq-local cj/gptel-autosave--timer + (run-with-timer cj/gptel-autosave-interval + cj/gptel-autosave-interval + #'cj/gptel--autosave-timer-callback + (current-buffer))))) + (defun cj/gptel-autosave-toggle () "Toggle autosave on/off in the current GPTel buffer. Flips `cj/gptel-autosave-enabled' and forces a mode-line redisplay so @@ -68,11 +110,13 @@ so a path exists to autosave to." (if cj/gptel-autosave-enabled (progn (setq-local cj/gptel-autosave-enabled nil) + (cj/gptel--autosave-stop-timer) (message "Autosave disabled")) (cond ((and (stringp cj/gptel-autosave-filepath) (> (length cj/gptel-autosave-filepath) 0)) (setq-local cj/gptel-autosave-enabled t) + (cj/gptel--autosave-start-timer) (message "Autosave enabled (saving to %s)" (file-name-nondirectory cj/gptel-autosave-filepath))) ((y-or-n-p "No save target yet. Save conversation first? ") @@ -110,8 +154,13 @@ construct." (append mode-line-format (list 'cj/gptel-autosave-mode-line-format))))) +(defun cj/gptel--install-autosave-buffer-hooks () + "Install buffer-local cleanup hooks for GPTel autosave." + (add-hook 'kill-buffer-hook #'cj/gptel--autosave-stop-timer nil t)) + (with-eval-after-load 'gptel - (add-hook 'gptel-mode-hook #'cj/gptel--install-autosave-mode-line)) + (add-hook 'gptel-mode-hook #'cj/gptel--install-autosave-mode-line) + (add-hook 'gptel-mode-hook #'cj/gptel--install-autosave-buffer-hooks)) (defun cj/gptel--slugify-topic (s) "Return a filesystem-friendly slug for topic string S." @@ -226,7 +275,8 @@ Enable autosave for subsequent AI responses to the same file." (cj/gptel--save-buffer-to-file buf filepath) (with-current-buffer buf (setq-local cj/gptel-autosave-filepath filepath) - (setq-local cj/gptel-autosave-enabled t)) + (setq-local cj/gptel-autosave-enabled t) + (cj/gptel--autosave-start-timer)) (message "Conversation saved to: %s" filepath)))) (defun cj/gptel-delete-conversation () @@ -261,7 +311,8 @@ Enable autosave for subsequent AI responses to the same file." (defun cj/gptel-load-conversation () "Load a saved GPTel conversation into the AI-Assistant buffer. -Prompt to save the current conversation first when appropriate, then enable autosave." +Prompt to save the current conversation first when appropriate, then +enable autosave." (interactive) (let ((ai-buffer (get-buffer-create "*AI-Assistant*"))) (when (and (with-current-buffer ai-buffer (> (buffer-size) 0)) @@ -289,7 +340,8 @@ Prompt to save the current conversation first when appropriate, then enable auto (goto-char (point-max)) (set-buffer-modified-p t) (setq-local cj/gptel-autosave-filepath filepath) - (setq-local cj/gptel-autosave-enabled t)) + (setq-local cj/gptel-autosave-enabled t) + (cj/gptel--autosave-start-timer)) (let ((buf (get-buffer "*AI-Assistant*"))) (unless (get-buffer-window buf) (display-buffer-in-side-window diff --git a/tests/test-ai-conversations.el b/tests/test-ai-conversations.el index 26b2423b..2d5aefd1 100644 --- a/tests/test-ai-conversations.el +++ b/tests/test-ai-conversations.el @@ -328,21 +328,107 @@ ;; Must not signal even though the write will fail (cj/gptel--autosave-after-send))))) +;; ------------------------------------------------------ autosave timer + +(ert-deftest test-ai-conversations-autosave-start-timer-normal () + "Normal: starting autosave creates a repeating timer for the current buffer." + (with-temp-buffer + (setq-local gptel-mode t) + (setq-local cj/gptel-autosave-enabled t) + (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel") + (let ((calls nil)) + (cl-letf (((symbol-function 'run-with-timer) + (lambda (secs repeat function &rest args) + (push (list secs repeat function args) calls) + :fake-timer))) + (let ((cj/gptel-autosave-interval 17)) + (cj/gptel--autosave-start-timer))) + (should (eq cj/gptel-autosave--timer :fake-timer)) + (should (equal (caar calls) 17)) + (should (equal (cadar calls) 17)) + (should (eq (nth 2 (car calls)) #'cj/gptel--autosave-timer-callback)) + (should (eq (car (nth 3 (car calls))) (current-buffer)))))) + +(ert-deftest test-ai-conversations-autosave-start-timer-idempotent () + "Boundary: starting autosave twice does not create a second timer." + (with-temp-buffer + (setq-local gptel-mode t) + (setq-local cj/gptel-autosave-enabled t) + (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel") + (setq-local cj/gptel-autosave--timer :existing-timer) + (let ((created 0)) + (cl-letf (((symbol-function 'run-with-timer) + (lambda (&rest _) + (setq created (1+ created)) + :new-timer))) + (cj/gptel--autosave-start-timer)) + (should (= created 0)) + (should (eq cj/gptel-autosave--timer :existing-timer))))) + +(ert-deftest test-ai-conversations-autosave-stop-timer-cancels () + "Normal: stopping autosave cancels the current buffer's timer." + (with-temp-buffer + (setq-local cj/gptel-autosave--timer :fake-timer) + (let ((cancelled nil)) + (cl-letf (((symbol-function 'cancel-timer) + (lambda (timer) (setq cancelled timer)))) + (cj/gptel--autosave-stop-timer)) + (should (eq cancelled :fake-timer)) + (should-not cj/gptel-autosave--timer)))) + +(ert-deftest test-ai-conversations-autosave-timer-callback-saves-active-buffer () + "Normal: timer callback saves the live buffer when autosave is active." + (test-ai-conversations--with-temp-dir + (lambda (dir) + (let ((file (expand-file-name "timer.gptel" dir)) + (buf (generate-new-buffer " *gptel timer test*"))) + (unwind-protect + (with-current-buffer buf + (setq-local gptel-mode t) + (setq-local cj/gptel-autosave-enabled t) + (setq-local cj/gptel-autosave-filepath file) + (insert "timer body") + (cj/gptel--autosave-timer-callback buf) + (should (file-exists-p file))) + (when (buffer-live-p buf) + (kill-buffer buf))))))) + +(ert-deftest test-ai-conversations-autosave-timer-callback-stops-inactive-buffer () + "Boundary: timer callback cancels itself when autosave is no longer active." + (let ((buf (generate-new-buffer " *gptel timer inactive*"))) + (unwind-protect + (with-current-buffer buf + (setq-local gptel-mode t) + (setq-local cj/gptel-autosave-enabled nil) + (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel") + (setq-local cj/gptel-autosave--timer :fake-timer) + (let ((cancelled nil)) + (cl-letf (((symbol-function 'cancel-timer) + (lambda (timer) (setq cancelled timer)))) + (cj/gptel--autosave-timer-callback buf)) + (should (eq cancelled :fake-timer)) + (should-not cj/gptel-autosave--timer))) + (when (buffer-live-p buf) + (kill-buffer buf))))) + ;; ------------------------------------------------------ save-conversation (ert-deftest test-ai-conversations-save-conversation-interactive-new-topic () - "Save-conversation writes file under the topic-slugged name." + "Save-conversation writes file, enables autosave, and starts a timer." (test-ai-conversations--with-temp-dir (lambda (dir) (let ((ai-buffer (generate-new-buffer "*AI-Assistant*"))) (unwind-protect (progn (with-current-buffer ai-buffer + (setq-local gptel-mode t) (insert "session content")) (cl-letf (((symbol-function 'completing-read) (lambda (&rest _) "Test Topic")) ((symbol-function 'y-or-n-p) - (lambda (&rest _) nil))) + (lambda (&rest _) nil)) + ((symbol-function 'run-with-timer) + (lambda (&rest _) :save-timer))) (cj/gptel-save-conversation) (let ((files (directory-files dir nil "test-topic_.*\\.gptel$"))) (should files) @@ -350,7 +436,8 @@ ;; Autosave state is set in the AI buffer (with-current-buffer ai-buffer (should cj/gptel-autosave-enabled) - (should (stringp cj/gptel-autosave-filepath))))) + (should (stringp cj/gptel-autosave-filepath)) + (should (eq cj/gptel-autosave--timer :save-timer))))) (kill-buffer ai-buffer)))))) (ert-deftest test-ai-conversations-save-conversation-error-no-buffer () @@ -417,13 +504,19 @@ (should cj/gptel-autosave-enabled))) (ert-deftest test-ai-conversations-autosave-toggle-disables () - "Toggle turns autosave off when already on." + "Toggle turns autosave off and cancels the periodic timer when already on." (with-temp-buffer (setq-local gptel-mode t) (setq-local cj/gptel-autosave-enabled t) (setq-local cj/gptel-autosave-filepath "/tmp/foo.gptel") - (cj/gptel-autosave-toggle) - (should-not cj/gptel-autosave-enabled))) + (setq-local cj/gptel-autosave--timer :fake-timer) + (let ((cancelled nil)) + (cl-letf (((symbol-function 'cancel-timer) + (lambda (timer) (setq cancelled timer)))) + (cj/gptel-autosave-toggle)) + (should-not cj/gptel-autosave-enabled) + (should (eq cancelled :fake-timer)) + (should-not cj/gptel-autosave--timer)))) (ert-deftest test-ai-conversations-autosave-toggle-prompts-when-no-filepath () "Toggle prompts to save first when no filepath is configured." -- cgit v1.2.3