diff --git a/benchmarks/eca-chat-tab-line-bench.el b/benchmarks/eca-chat-tab-line-bench.el new file mode 100644 index 0000000..6183681 --- /dev/null +++ b/benchmarks/eca-chat-tab-line-bench.el @@ -0,0 +1,309 @@ +;;; eca-chat-tab-line-bench.el --- Benchmark harness for ECA chat tabs -*- lexical-binding: t; -*- +;; Copyright (C) 2026 Eric Dallo +;; +;; SPDX-License-Identifier: Apache-2.0 +;; +;; This file is not part of GNU Emacs. +;; +;;; Commentary: +;; +;; Focused benchmark harness for ECA chat tab-line performance. +;; +;; The general chat benchmark covers render hot paths. This file +;; isolates tab-list construction, tab label construction, and tab +;; face selection across several chat counts. +;; +;; Batch usage with Eask: +;; eask emacs --batch -L . -l benchmarks/eca-chat-tab-line-bench.el \ +;; -f eca-chat-tab-line-bench-run +;; +;; Optional profiler correlation report with Eask: +;; eask emacs --batch -L . -l benchmarks/eca-chat-tab-line-bench.el \ +;; -f eca-chat-tab-line-bench-run-profile +;; +;;; Code: + +(require 'benchmark) +(require 'cl-lib) +(require 'subr-x) + +;; Prefer source files over stale byte-compiled files in local runs. +(setq load-prefer-newer t) + +(defvar eca-chat-tab-line-bench-repo-root nil + "Repository root that contains this benchmark file.") + +(let* ((this-file (or load-file-name buffer-file-name)) + (bench-dir (and this-file (file-name-directory this-file))) + (repo-root (and bench-dir + (file-name-directory + (directory-file-name bench-dir))))) + (setq eca-chat-tab-line-bench-repo-root repo-root) + (when repo-root + (add-to-list 'load-path repo-root)) + (when bench-dir + (add-to-list 'load-path bench-dir))) + +(require 'eca-chat-bench) +(require 'tab-line nil t) + +(declare-function profiler-start "profiler") +(declare-function profiler-stop "profiler") +(declare-function profiler-cpu-profile "profiler") +(declare-function profiler-write-profile "profiler") + +;;;; Configuration + +(defvar eca-chat-tab-line-bench-chat-counts '(1 10 50) + "Number of chat buffers to use for tab-line benchmarks.") + +(defvar eca-chat-tab-line-bench-turns-per-chat 20 + "Number of turns to put in each chat fixture.") + +(defvar eca-chat-tab-line-bench-iters 2000 + "Number of iterations to use for tab-line benchmarks.") + +(defvar eca-chat-tab-line-bench-profile-output-file nil + "File path for profiler output, or nil to print it.") + +(defvar eca-chat-tab-line-bench--results nil + "Accumulated benchmark result entries.") + +;;;; Fixture helpers + +(defun eca-chat-tab-line-bench--git-output (&rest args) + "Run git with ARGS and return trimmed output. +Return nil when git exits with a non-zero status." + (with-temp-buffer + (let ((status (apply #'process-file "git" nil t nil args))) + (when (eq status 0) + (string-trim (buffer-string)))))) + +(defun eca-chat-tab-line-bench--clear-cache (session) + "Clear cached tab-line descriptors for SESSION when available. +This keeps the benchmark compatible with revisions that do not use +that cache." + (when (boundp 'eca-chat--tab-line-cache-by-session) + (let ((cache (symbol-value 'eca-chat--tab-line-cache-by-session))) + (when (hash-table-p cache) + (remhash session cache))))) + +(defun eca-chat-tab-line-bench--configure-chat (session buffer id title pending) + "Register BUFFER as chat ID with TITLE in SESSION. +When PENDING is non-nil, mark the pending approval cache true." + (with-current-buffer buffer + (setq-local eca-chat--id id) + (setq-local eca-chat--title title) + (setq-local eca-chat--pending-approvals-cache (and pending t)) + (setq-local eca-chat--chat-loading nil) + (setq-local eca-chat--prompt-start-time + (time-subtract (current-time) (seconds-to-time 125))) + (eca-chat-bench--configure-context)) + (setf (eca--session-chats session) + (eca-assoc (eca--session-chats session) id buffer))) + +(defun eca-chat-tab-line-bench--make-buffer (session index turns pending) + "Return one benchmark chat buffer for SESSION. +INDEX is used for the chat id and title. TURNS controls fixture size. +PENDING marks the pending approval cache true when non-nil." + (let* ((eca-chat-bench--session session) + (buffer (eca-chat-bench--make-fixture turns)) + (id (format "tab-line-bench-%03d" index)) + (title (format "Tab-line bench chat %03d" index))) + (eca-chat-tab-line-bench--configure-chat session buffer id title pending) + buffer)) + +(defun eca-chat-tab-line-bench--call-with-fixtures + (chat-count turns pending-step fn) + "Create CHAT-COUNT chat fixtures and call FN. +Each chat has TURNS turns. PENDING-STEP marks every nth chat as +pending when it is non-nil. FN receives SESSION and BUFFERS." + (let* ((session (eca-create-session (list default-directory))) + (eca-chat-bench--session session) + (buffers nil)) + (unwind-protect + (progn + (dotimes (i chat-count) + (push (eca-chat-tab-line-bench--make-buffer + session i turns + (and pending-step (zerop (mod i pending-step)))) + buffers)) + (setq buffers (nreverse buffers)) + (setf (eca--session-last-chat-buffer session) (car buffers)) + (funcall fn session buffers)) + (dolist (buffer buffers) + (when (buffer-live-p buffer) + (kill-buffer buffer))) + (eca-delete-session session) + (setq eca-chat-bench--session nil)))) + +;;;; Timing and output + +(defun eca-chat-tab-line-bench--time (label size turns iters thunk) + "Run THUNK ITERS times and return a result plist. +LABEL, SIZE, and TURNS identify the workload." + (garbage-collect) + (let ((result (benchmark-call thunk iters))) + (list :label label + :size size + :turns turns + :iters iters + :elapsed (nth 0 result) + :gc-count (nth 1 result) + :gc-elapsed (nth 2 result)))) + +(defun eca-chat-tab-line-bench--add-result (result) + "Append RESULT to the benchmark result list." + (setq eca-chat-tab-line-bench--results + (append eca-chat-tab-line-bench--results (list result)))) + +(defun eca-chat-tab-line-bench--format-results () + "Return accumulated results as a markdown table." + (concat + "| op | chats | turns | iters | gc | gc-ms | wall-ms | per-call-us |\n" + "|----|------:|------:|------:|---:|------:|--------:|------------:|\n" + (mapconcat + (lambda (result) + (let* ((iters (plist-get result :iters)) + (elapsed (plist-get result :elapsed)) + (gc-elapsed (plist-get result :gc-elapsed)) + (per-us (if (> iters 0) + (/ (* elapsed 1000000.0) iters) + 0.0))) + (format "| %s | %d | %d | %d | %d | %.2f | %.2f | %.2f |" + (plist-get result :label) + (plist-get result :size) + (plist-get result :turns) + iters + (plist-get result :gc-count) + (* gc-elapsed 1000.0) + (* elapsed 1000.0) + per-us))) + eca-chat-tab-line-bench--results + "\n") + "\n")) + +(defun eca-chat-tab-line-bench--format-header () + "Return a markdown header for the benchmark run." + (let* ((repo-root (or eca-chat-tab-line-bench-repo-root + default-directory)) + (repo-label (abbreviate-file-name + (directory-file-name (expand-file-name repo-root)))) + (commit (or (eca-chat-tab-line-bench--git-output + "-C" repo-root "rev-parse" "--short" "HEAD") + "unknown")) + (branch (or (eca-chat-tab-line-bench--git-output + "-C" repo-root "branch" "--show-current") + "unknown"))) + (concat "# ECA chat tab-line benchmark\n\n" + (format "- Repo: `%s`\n" repo-label) + (format "- Branch: `%s`\n" branch) + (format "- Commit: `%s`\n" commit) + (format "- Emacs: `%s`\n" emacs-version) + "- Workload: synthetic ECA chat buffers.\n" + "- Purpose: tab-line performance.\n\n"))) + +;;;; Tab-line benchmarks + +(defun eca-chat-tab-line-bench--bench-tabs-warm (session buffers chat-count turns) + "Benchmark warm `eca-chat--tab-line-tabs' for SESSION and BUFFERS. +CHAT-COUNT and TURNS identify the fixture size." + (with-current-buffer (car buffers) + (setq-local eca--session-id-cache (eca--session-id session)) + (eca-chat--tab-line-tabs) + (eca-chat-tab-line-bench--time + 'tabs-warm chat-count turns eca-chat-tab-line-bench-iters + (lambda () + (eca-chat--tab-line-tabs))))) + +(defun eca-chat-tab-line-bench--bench-tabs-rebuild (session buffers chat-count turns) + "Benchmark invalidated `eca-chat--tab-line-tabs' for SESSION and BUFFERS. +CHAT-COUNT and TURNS identify the fixture size." + (with-current-buffer (car buffers) + (setq-local eca--session-id-cache (eca--session-id session)) + (eca-chat-tab-line-bench--time + 'tabs-rebuild chat-count turns eca-chat-tab-line-bench-iters + (lambda () + (eca-chat-tab-line-bench--clear-cache session) + (eca-chat--tab-line-tabs))))) + +(defun eca-chat-tab-line-bench--bench-tab-name-last + (_session buffers chat-count turns) + "Benchmark `eca-chat--tab-line-tab-name' for BUFFERS. +CHAT-COUNT and TURNS identify the fixture size." + (let ((target (car (last buffers)))) + (eca-chat-tab-line-bench--time + 'tab-name-last chat-count turns eca-chat-tab-line-bench-iters + (lambda () + (eca-chat--tab-line-tab-name target))))) + +(defun eca-chat-tab-line-bench--bench-tab-face-all + (session buffers chat-count turns) + "Benchmark `eca-chat--tab-line-face' for SESSION and BUFFERS. +CHAT-COUNT and TURNS identify the fixture size." + (with-current-buffer (car buffers) + (setq-local eca--session-id-cache (eca--session-id session)) + (let ((tabs (eca-chat--tab-line-tabs))) + (eca-chat-tab-line-bench--time + 'tab-face-all chat-count turns eca-chat-tab-line-bench-iters + (lambda () + (dolist (tab tabs) + (eca-chat--tab-line-face tab tabs 'tab-line-tab nil + (cdr (assq 'buffer tab))))))))) + +(defun eca-chat-tab-line-bench--run-tab-line () + "Run tab-line benchmarks for configured chat counts." + (dolist (chat-count eca-chat-tab-line-bench-chat-counts) + (message "[eca-chat-tab-line-bench] running chats=%d ..." chat-count) + (eca-chat-tab-line-bench--call-with-fixtures + chat-count eca-chat-tab-line-bench-turns-per-chat 5 + (lambda (session buffers) + (eca-chat-tab-line-bench--add-result + (eca-chat-tab-line-bench--bench-tabs-warm + session buffers chat-count eca-chat-tab-line-bench-turns-per-chat)) + (eca-chat-tab-line-bench--add-result + (eca-chat-tab-line-bench--bench-tabs-rebuild + session buffers chat-count eca-chat-tab-line-bench-turns-per-chat)) + (eca-chat-tab-line-bench--add-result + (eca-chat-tab-line-bench--bench-tab-name-last + session buffers chat-count eca-chat-tab-line-bench-turns-per-chat)) + (eca-chat-tab-line-bench--add-result + (eca-chat-tab-line-bench--bench-tab-face-all + session buffers chat-count eca-chat-tab-line-bench-turns-per-chat)))))) + +;;;; Driver + +;;;###autoload +(defun eca-chat-tab-line-bench-run () + "Run chat tab-line benchmarks and print markdown results." + (interactive) + (setq eca-chat-tab-line-bench--results nil) + (eca-chat-tab-line-bench--run-tab-line) + (let ((report (concat (eca-chat-tab-line-bench--format-header) + (eca-chat-tab-line-bench--format-results)))) + (if noninteractive + (princ report) + (with-current-buffer (get-buffer-create "*eca-chat-tab-line-bench*") + (let ((inhibit-read-only t)) + (erase-buffer) + (insert report)) + (display-buffer (current-buffer)))) + report)) + +;;;###autoload +(defun eca-chat-tab-line-bench-run-profile () + "Run the benchmark with Emacs CPU profiler enabled." + (interactive) + (require 'profiler) + (profiler-start 'cpu) + (unwind-protect + (eca-chat-tab-line-bench-run) + (profiler-stop)) + (let ((profile (profiler-cpu-profile))) + (if eca-chat-tab-line-bench-profile-output-file + (profiler-write-profile + profile eca-chat-tab-line-bench-profile-output-file nil) + (prin1 profile)))) + +(provide 'eca-chat-tab-line-bench) +;;; eca-chat-tab-line-bench.el ends here diff --git a/eca-chat-inline.el b/eca-chat-inline.el index 1557eab..212a543 100644 --- a/eca-chat-inline.el +++ b/eca-chat-inline.el @@ -704,6 +704,7 @@ one." (eq created-buffer (eca-chat--get-chat-buffer session chat-id))) (setf (eca--session-chats session) (eca-dissoc (eca--session-chats session) chat-id)) + (eca-chat--invalidate-tab-line-cache session) (with-current-buffer created-buffer (setq-local eca-chat--closed t)) (kill-buffer created-buffer) diff --git a/eca-chat.el b/eca-chat.el index 173677d..6725b8f 100644 --- a/eca-chat.el +++ b/eca-chat.el @@ -420,10 +420,23 @@ Set this to nil if typing in large ECA chat buffers is slow." Either a boolean or the symbol `dirty' when the buffer must be rescanned.") +(defvar eca-chat--tab-line-cache-by-session + (make-hash-table :test 'eq :weakness 'key) + "Stable chat tab-line descriptors keyed by session.") + (defun eca-chat--invalidate-pending-approvals-cache () "Mark the pending approvals cache of the current buffer stale." (setq-local eca-chat--pending-approvals-cache 'dirty)) +(defun eca-chat--invalidate-tab-line-cache (&optional session) + "Invalidate stable tab-line cache for SESSION. +When SESSION is nil, use the current buffer session if available." + (when-let* ((target (or session (ignore-errors (eca-session))))) + (remhash target eca-chat--tab-line-cache-by-session))) + +(add-hook 'eca-session-deleting-functions + #'eca-chat--invalidate-tab-line-cache) + (defcustom eca-chat-tool-call-approval-content-size 0.9 "The size of font of tool call approval." :type 'number @@ -1087,6 +1100,7 @@ explicitly with `eca-chat-delete' or the /delete-chat command." (eca-chat--switch-windows-to-sibling session buffer) (setf (eca--session-chats session) (eca-dissoc (eca--session-chats session) chat-id)) + (eca-chat--invalidate-tab-line-cache session) (eca-chat--force-tab-line-update) (eca-chat--notify-status-changed session))))) @@ -1729,6 +1743,7 @@ Recovery path for a corrupted prompt block (see #305)." (remove-overlays (point-min) (point-max))) (eca-chat--invalidate-overlay-caches) (eca-chat--invalidate-pending-approvals-cache) + (eca-chat--invalidate-tab-line-cache) (eca-chat-expandable--reset-id-table) (setq-local eca-chat--task-state nil) ;; Cancel loading-related timers and reset state @@ -1837,6 +1852,7 @@ current draft appended, after the server clears the chat." LOADING can be t (loading), \\='stopping (stop in progress), or nil (idle)." (unless (eq eca-chat--chat-loading loading) (setq-local eca-chat--chat-loading loading) + (eca-chat--invalidate-tab-line-cache session) (pcase loading ('t (setq-local eca-chat--prompt-start-time (current-time)) @@ -2943,6 +2959,29 @@ Shows 🚧 prefix for pending approvals." (pending (eca-chat--has-pending-approvals-p))) (concat " " (when pending "🚧 ") title " ")))) +(defun eca-chat--tab-line-active-p (buffer) + "Return non-nil if BUFFER needs active tab styling." + (and (buffer-live-p buffer) + (or (buffer-local-value 'eca-chat--chat-loading buffer) + (with-current-buffer buffer + (eca-chat--has-pending-approvals-p))))) + +(defun eca-chat--tab-line-tab-data (buffer) + "Return cached tab data for chat BUFFER." + (when (buffer-live-p buffer) + `(tab + (name . ,(eca-chat--tab-line-tab-name buffer)) + (buffer . ,buffer) + (active . ,(eca-chat--tab-line-active-p buffer))))) + +(defun eca-chat--tab-line-stable-tabs (session) + "Return stable tab descriptors for SESSION." + (or (gethash session eca-chat--tab-line-cache-by-session) + (puthash session + (-keep #'eca-chat--tab-line-tab-data + (eca-chat--session-chats-oldest-first session)) + eca-chat--tab-line-cache-by-session))) + (defun eca-chat--tab-line-face (tab _tabs face _selected-p _buffer) "Return FACE for TAB styled by selection and activity. Uses `eca-tab-inactive-face' for non-selected idle @@ -2951,11 +2990,11 @@ tabs, and `eca-chat-tab-inactive-active-face' for non-selected active (loading/approval) tabs." (let* ((buf (cdr (assq 'buffer tab))) (selectedp (cdr (assq 'selected tab))) - (activep (and buf (buffer-live-p buf) - (or (buffer-local-value - 'eca-chat--chat-loading buf) - (with-current-buffer buf - (eca-chat--has-pending-approvals-p)))))) + (cached-active (assq 'active tab)) + (activep (if cached-active + (cdr cached-active) + (and buf (buffer-live-p buf) + (eca-chat--tab-line-active-p buf))))) (cond ((and activep (not selectedp)) `(:inherit (eca-chat-tab-inactive-active-face ,face))) @@ -2967,19 +3006,16 @@ non-selected active (loading/approval) tabs." (defun eca-chat--tab-line-tabs () "Return tab descriptors for all chats in the current session. -Each tab is an alist with `name', `buffer', and `selected' entries. -Tabs are ordered oldest-first so new chats appear on the right." +Each tab is an alist with `name', `buffer', `active' and +`selected' entries. Tabs are ordered oldest-first so new chats +appear on the right." (when-let ((session (ignore-errors (eca-session)))) - (let* ((current-buf (current-buffer)) - (tabs (-keep - (lambda (buf) - (when (buffer-live-p buf) - `(tab - (name . ,(eca-chat--tab-line-tab-name buf)) - (buffer . ,buf) - (selected . ,(eq buf current-buf))))) - (eca-vals (eca--session-chats session))))) - (nreverse tabs)))) + (let ((current-buf (current-buffer))) + (-keep (lambda (tab) + (let ((buf (cdr (assq 'buffer tab)))) + (when (buffer-live-p buf) + (append tab `((selected . ,(eq buf current-buf))))))) + (eca-chat--tab-line-stable-tabs session))))) (defun eca-chat--tab-line-close-tab (&optional e) "Close the chat tab clicked on. @@ -4394,11 +4430,13 @@ Must be called with `eca-chat--with-current-buffer' or equivalent." ;; next status check rescans the buffer. (when (member content-type '("toolCallRun" "toolCallRunning" "toolCalled" "toolCallRejected")) - (eca-chat--invalidate-pending-approvals-cache)) + (eca-chat--invalidate-pending-approvals-cache) + (eca-chat--invalidate-tab-line-cache session)) (pcase content-type ("metadata" (unless parent-tool-call-id - (setq-local eca-chat--title (plist-get content :title)))) + (setq-local eca-chat--title (plist-get content :title)) + (eca-chat--invalidate-tab-line-cache session))) ("text" (when-let* ((text (plist-get content :text))) (pcase role @@ -5181,6 +5219,7 @@ own cleanup." (setq-local eca-chat--closed t))) (setf (eca--session-chats session) (eca-dissoc (eca--session-chats session) chat-id)) + (eca-chat--invalidate-tab-line-cache session) (when (buffer-live-p chat-buffer) (kill-buffer chat-buffer)) (eca-chat--notify-status-changed session)) @@ -5211,6 +5250,7 @@ resumed chat gets a fresh writable buffer." (when title (with-current-buffer existing (setq-local eca-chat--title title))) + (eca-chat--invalidate-tab-line-cache session) (eca-chat--force-tab-line-update) (eca-chat--notify-status-changed session)) (t @@ -5229,6 +5269,7 @@ resumed chat gets a fresh writable buffer." (eca-chat--initialize-selection-state session)) (setf (eca--session-chats session) (eca-assoc (eca--session-chats session) chat-id new-buffer)) + (eca-chat--invalidate-tab-line-cache session) (eca-chat--force-tab-line-update) (eca-chat--notify-status-changed session)))))) @@ -5502,6 +5543,7 @@ When ACTIVE is non-nil, show the question prefix; otherwise restore normal." (cl-assert eca-chat--id nil "eca-chat--id must be set before registering buffer") (setf (eca--session-chats session) (eca-assoc (eca--session-chats session) eca-chat--id (current-buffer))) + (eca-chat--invalidate-tab-line-cache session) (eca-chat--notify-status-changed session)) (if (window-live-p (get-buffer-window (buffer-name))) (eca-chat--select-window) @@ -5518,6 +5560,8 @@ When ACTIVE is non-nil, show the question prefix; otherwise restore normal." (setq eca-chat--cursor-context-timer nil)) ;; Remove the global window-size-change handler registered by eca-chat-mode. (remove-hook 'window-size-change-functions #'eca-chat--on-window-size-change) + ;; Closed chat buffers can keep SESSION reachable through buffer-local state. + (eca-chat--invalidate-tab-line-cache session) (mapcar (lambda (title+buffer) (let ((chat-buffer (cdr title+buffer))) (when (buffer-live-p chat-buffer) @@ -6114,7 +6158,8 @@ the empty buffer that was used to trigger the resume." (setq-local eca-chat--closed t) (when-let* ((cid eca-chat--id)) (setf (eca--session-chats session) - (eca-dissoc (eca--session-chats session) cid)))) + (eca-dissoc (eca--session-chats session) cid)) + (eca-chat--invalidate-tab-line-cache session))) (kill-buffer buffer) (eca-chat--force-tab-line-update) (eca-chat--notify-status-changed session))) @@ -6160,7 +6205,8 @@ FROM-BUFFER is the buffer where the resume command started." (setf (eca--session-last-chat-buffer session) chat-buffer) (eca-chat--with-current-buffer chat-buffer (when-let* ((title (plist-get response :title))) - (setq-local eca-chat--title title)) + (setq-local eca-chat--title title) + (eca-chat--invalidate-tab-line-cache session)) (eca-chat--apply-history-meta (plist-get response :meta)) (eca-chat--refresh-load-older-control) (eca-chat--protect-non-prompt)) @@ -6254,6 +6300,7 @@ FROM-BUFFER is the buffer where the resume command started." (setq-local eca-chat--title new-name) ;; Clear any custom title since we now have an official title (setq-local eca-chat--custom-title nil) + (eca-chat--invalidate-tab-line-cache (eca-session)) ;; Request server to persist and broadcast to other clients (eca-api-request-sync (eca-session) :method "chat/update" @@ -6306,6 +6353,7 @@ the deleted chat switches to another chat first." ;; dead buffer in the session registry. (setf (eca--session-chats session) (eca-dissoc (eca--session-chats session) chat-id)) + (eca-chat--invalidate-tab-line-cache session) (when (buffer-live-p buffer) ;; Keep the kill hook from prompting or sending a second delete. (with-current-buffer buffer diff --git a/eca-util.el b/eca-util.el index 5541c2b..5bec906 100644 --- a/eca-util.el +++ b/eca-util.el @@ -89,6 +89,10 @@ for client-generated `chatId' values sent to the eca server." (defvar eca--sessions '()) (defvar eca--session-ids 0) +(defvar eca-session-deleting-functions nil + "Functions called before an ECA session is deleted. +Each function receives the session being deleted.") + (defvar eca-sessions-updated-hook nil "Normal hook run after a ECA session is created or deleted.") @@ -333,6 +337,8 @@ workspace folder. Falls back to \"unknown\"." (defun eca-delete-session (session) "Delete SESSION from existing sessions." (when session + (with-demoted-errors "eca-session-deleting-functions: %S" + (run-hook-with-args 'eca-session-deleting-functions session)) (clrhash eca--git-common-dir-cache) (setq eca--sessions (eca-dissoc eca--sessions (eca--session-id session))) diff --git a/test/eca-chat-selection-test.el b/test/eca-chat-selection-test.el index e8c0362..e26cf7a 100644 --- a/test/eca-chat-selection-test.el +++ b/test/eca-chat-selection-test.el @@ -191,6 +191,32 @@ :to-be nil)) (eca-selection-test--kill-buffers a b source)))) + (it "refreshes a cached tab title after chat/open returns" + (let ((session (make-eca--session :opening-chat-id "A")) + a source) + (spy-on 'eca-session :and-return-value session) + (spy-on 'eca-chat-open) + (spy-on 'eca-chat--kill-empty-welcome-buffer) + (spy-on 'eca-chat--refresh-load-older-control) + (spy-on 'eca-chat--protect-non-prompt) + (unwind-protect + (progn + (setq a (eca-selection-test--make-chat session "A") + source (generate-new-buffer " *eca-selection-source*")) + (with-current-buffer a + (setq-local eca-chat--title "Old title") + (eca-chat--tab-line-tabs) + (eca-chat--handle-open-response + session source "A" + '(:found t :title "Restored chat")) + (expect (cdr (assq 'name (car (eca-chat--tab-line-tabs)))) + :to-equal + (concat " " + (propertize "Restored chat" + 'font-lock-face 'eca-chat-title-face) + " ")))) + (eca-selection-test--kill-buffers a source)))) + (it "applies nullable and partial atomic selection fields" (let ((session (make-eca--session)) chat) diff --git a/test/eca-chat-test.el b/test/eca-chat-test.el index fef22f2..3b1c161 100644 --- a/test/eca-chat-test.el +++ b/test/eca-chat-test.el @@ -114,10 +114,229 @@ When MANUAL is non-nil the tool call requires manual approval." :manualApproval manual :details (list :type "generic"))) +(defun eca-chat-test--make-tab-chat (session id &optional title) + "Create and register a chat buffer with ID for SESSION. +When TITLE is non-nil, use it as the chat title." + (let ((buffer (eca-chat-test--make-render-buffer))) + (with-current-buffer buffer + (setq-local eca-chat--id id) + (setq-local eca-chat--closed nil) + (setq-local eca-chat--title title)) + (setf (eca--session-chats session) + (eca-assoc (eca--session-chats session) id buffer)) + buffer)) + +(defun eca-chat-test--tab-for-buffer (tabs buffer) + "Return the tab descriptor in TABS for BUFFER." + (-first (lambda (tab) (eq (cdr (assq 'buffer tab)) buffer)) tabs)) + +(defun eca-chat-test--tab-selected-p (tabs buffer) + "Return non-nil when BUFFER's tab in TABS is selected." + (cdr (assq 'selected (eca-chat-test--tab-for-buffer tabs buffer)))) + +(defun eca-chat-test--tab-name (tabs buffer) + "Return BUFFER's tab name in TABS." + (cdr (assq 'name (eca-chat-test--tab-for-buffer tabs buffer)))) + +(defun eca-chat-test--tab-active-p (tabs buffer) + "Return BUFFER's tab active value in TABS." + (cdr (assq 'active (eca-chat-test--tab-for-buffer tabs buffer)))) + ;; --------------------------------------------------------------------------- ;; Tests ;; --------------------------------------------------------------------------- +(describe "eca-chat tab-line cache" + + (it "reuses stable tab labels between redisplay calls" + (let ((session (make-eca--session)) a b) + (spy-on 'eca-session :and-return-value session) + (unwind-protect + (progn + (setq a (eca-chat-test--make-tab-chat session "A" "Alpha") + b (eca-chat-test--make-tab-chat session "B" "Beta")) + (with-current-buffer a + (eca-chat--tab-line-tabs) + (spy-on 'eca-chat--tab-line-tab-name) + (eca-chat--tab-line-tabs) + (expect 'eca-chat--tab-line-tab-name + :not :to-have-been-called))) + (dolist (buffer (list a b)) + (when (buffer-live-p buffer) + (kill-buffer buffer)))))) + + (it "keeps selected state out of the stable cache" + (let ((session (make-eca--session)) a b) + (spy-on 'eca-session :and-return-value session) + (unwind-protect + (progn + (setq a (eca-chat-test--make-tab-chat session "A" "Alpha") + b (eca-chat-test--make-tab-chat session "B" "Beta")) + (let ((tabs-from-a (with-current-buffer a + (eca-chat--tab-line-tabs))) + (tabs-from-b (with-current-buffer b + (eca-chat--tab-line-tabs)))) + (expect (eca-chat-test--tab-selected-p tabs-from-a a) + :to-be-truthy) + (expect (eca-chat-test--tab-selected-p tabs-from-a b) + :to-be nil) + (expect (eca-chat-test--tab-selected-p tabs-from-b a) + :to-be nil) + (expect (eca-chat-test--tab-selected-p tabs-from-b b) + :to-be-truthy))) + (dolist (buffer (list a b)) + (when (buffer-live-p buffer) + (kill-buffer buffer)))))) + + (it "updates a cached tab label after metadata changes" + (let ((session (make-eca--session)) chat) + (spy-on 'eca-session :and-return-value session) + (unwind-protect + (progn + (setq chat (eca-chat-test--make-tab-chat session "A" "Old title")) + (with-current-buffer chat + (eca-chat--tab-line-tabs) + (eca-chat--render-content + session chat "assistant" + '(:type "metadata" :title "New title") + nil) + (expect (eca-chat-test--tab-name + (eca-chat--tab-line-tabs) chat) + :to-equal + (concat " " + (propertize "New title" + 'font-lock-face 'eca-chat-title-face) + " ")))) + (when (buffer-live-p chat) + (kill-buffer chat))))) + + (it "updates pending approval prefix and active state" + (let ((session (make-eca--session)) chat) + (spy-on 'eca-session :and-return-value session) + (unwind-protect + (progn + (setq chat (eca-chat-test--make-tab-chat session "A" "Needs approval")) + (with-current-buffer chat + (eca-chat--tab-line-tabs) + (eca-chat--render-content + session chat "assistant" + (eca-chat-test--tool-call-content "toolCallRun" "tool-1" t) + nil) + (let ((tabs (eca-chat--tab-line-tabs))) + (expect (eca-chat-test--tab-name tabs chat) + :to-equal + (concat " 🚧 " + (propertize "Needs approval" + 'font-lock-face 'eca-chat-title-face) + " ")) + (expect (eca-chat-test--tab-active-p tabs chat) + :to-be-truthy)) + (let ((inhibit-read-only t)) + (eca-chat--render-content + session chat "assistant" + (eca-chat-test--tool-call-content "toolCalled" "tool-1") + nil)) + (let ((tabs (eca-chat--tab-line-tabs))) + (expect (eca-chat-test--tab-name tabs chat) + :to-equal + (concat " " + (propertize "Needs approval" + 'font-lock-face 'eca-chat-title-face) + " ")) + (expect (eca-chat-test--tab-active-p tabs chat) + :to-be nil)))) + (when (buffer-live-p chat) + (kill-buffer chat))))) + + (it "updates active state after loading transitions" + (let ((session (make-eca--session)) chat) + (spy-on 'eca-session :and-return-value session) + (unwind-protect + (progn + (setq chat (eca-chat-test--make-tab-chat session "A" "Loading")) + (with-current-buffer chat + (eca-chat--tab-line-tabs) + (eca-chat--set-chat-loading session t) + (expect (eca-chat-test--tab-active-p + (eca-chat--tab-line-tabs) chat) + :to-be-truthy) + (eca-chat--set-chat-loading session nil) + (expect (eca-chat-test--tab-active-p + (eca-chat--tab-line-tabs) chat) + :to-be nil))) + (when (and (buffer-live-p chat) + (buffer-local-value 'eca-chat--modeline-timer chat)) + (cancel-timer (buffer-local-value 'eca-chat--modeline-timer chat))) + (when (buffer-live-p chat) + (kill-buffer chat))))) + + (it "updates the cached tab list after registration and removal" + (let ((session (make-eca--session)) a b) + (spy-on 'eca-session :and-return-value session) + (spy-on 'eca-chat--force-tab-line-update) + (unwind-protect + (progn + (setq a (eca-chat-test--make-tab-chat session "A" "Alpha")) + (with-current-buffer a + (expect (length (eca-chat--tab-line-tabs)) :to-equal 1) + (eca-chat-opened session '(:chatId "B" :title "Beta")) + (setq b (eca-get (eca--session-chats session) "B")) + (expect (length (eca-chat--tab-line-tabs)) :to-equal 2) + (eca-chat-deleted session '(:chatId "B")) + (expect (length (eca-chat--tab-line-tabs)) :to-equal 1))) + (dolist (buffer (list a b)) + (when (buffer-live-p buffer) + (kill-buffer buffer))) + ;; `eca-chat-opened' activates a real `eca-chat-mode' buffer, + ;; which installs global command advices. Remove them so later + ;; specs see raw editing commands. + (dolist (fn '(delete-char delete-backward-char + backward-delete-char + backward-delete-char-untabify + backward-kill-word)) + (advice-remove fn #'eca-chat--key-pressed-deletion)) + (dolist (fn eca-chat--kill-guarded-commands) + (advice-remove fn #'eca-chat--key-pressed-kill)) + (advice-remove 'yank #'eca-chat--yank-considering-image)))) + + (it "clears the cached tab list on chat exit" + (let ((session (make-eca--session)) chat) + (spy-on 'eca-session :and-return-value session) + (unwind-protect + (progn + (setq chat (eca-chat-test--make-tab-chat session "A" "Alpha")) + (with-current-buffer chat + (eca-chat--tab-line-tabs)) + (expect (gethash session eca-chat--tab-line-cache-by-session) + :to-be-truthy) + (eca-chat-exit session) + (expect (gethash session eca-chat--tab-line-cache-by-session) + :to-be nil)) + (when (buffer-live-p chat) + (kill-buffer chat))))) + + (it "clears the cached tab list when deleting a session" + (let ((eca--sessions '()) + (eca-chat--tab-line-cache-by-session + (make-hash-table :test 'eq :weakness 'key)) + (session (make-eca--session)) + chat) + (setf (eca--session-id session) 1) + (setq eca--sessions (eca-assoc eca--sessions 1 session)) + (spy-on 'eca-session :and-return-value session) + (unwind-protect + (progn + (setq chat (eca-chat-test--make-tab-chat session "A" "Alpha")) + (with-current-buffer chat + (eca-chat--tab-line-tabs)) + (expect (gethash session eca-chat--tab-line-cache-by-session) + :to-be-truthy) + (eca-delete-session session) + (expect (gethash session eca-chat--tab-line-cache-by-session) + :to-be nil)) + (when (buffer-live-p chat) + (kill-buffer chat)))))) + (describe "eca-chat--has-pending-approvals-p" (it "caches the buffer scan between calls"