diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..5751e3b7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main] + +jobs: + readme-updated: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check README.org updated when code changes + run: | + base="${{ github.event.pull_request.base.sha }}" + head="${{ github.event.pull_request.head.sha }}" + changed_files=$(git diff --name-only "$base" "$head") + + has_code_changes=false + for f in $changed_files; do + case "$f" in + *.el|tests/*) has_code_changes=true; break ;; + esac + done + + if "$has_code_changes"; then + if ! echo "$changed_files" | grep -q '^README\.org$'; then + echo "::error::Code or test files changed but README.org was not updated." + echo "Please update the soft-fork features list in README.org." + exit 1 + fi + fi + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/checkout@v4 + with: + repository: timvisher-dd/acp.el-plus + path: deps/acp.el + + - uses: actions/checkout@v4 + with: + repository: xenodium/shell-maker + path: deps/shell-maker + + - uses: purcell/setup-emacs@master + with: + version: 29.4 + + - name: Remove stale .elc files + run: find . deps -follow -name '*.elc' -print0 | xargs -0 rm -f + + - name: Byte-compile + run: | + compile_files=() + for f in *.el; do + case "$f" in x.*|y.*|z.*) ;; *) compile_files+=("$f") ;; esac + done + emacs -Q --batch \ + -L . -L deps/acp.el -L deps/shell-maker \ + -f batch-byte-compile \ + "${compile_files[@]}" + + - name: Run ERT tests + run: | + test_args=() + for f in tests/*-tests.el; do + test_args+=(-l "$f") + done + emacs -Q --batch \ + -L . -L deps/acp.el -L deps/shell-maker -L tests \ + "${test_args[@]}" \ + -f ert-run-tests-batch-and-exit diff --git a/.gitignore b/.gitignore index 0dfe168e..d1b1e191 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /.agent-shell/ +/deps/ *.elc diff --git a/README.org b/README.org index c2b74793..5892ca14 100644 --- a/README.org +++ b/README.org @@ -1,5 +1,18 @@ #+TITLE: Emacs Agent Shell -#+AUTHOR: Álvaro Ramírez +#+AUTHOR: Tim Visher + +A soft fork of [[https://github.com/xenodium/agent-shell][agent-shell]] with extra features on top. + +* Features on top of agent-shell + +- CI workflow and local test runner ([[https://github.com/timvisher-dd/agent-shell-plus/pull/1][#1]]) +- Desktop notifications when the prompt is idle and waiting for input ([[https://github.com/timvisher-dd/agent-shell-plus/pull/2][#2]]) +- Per-shell debug logging infrastructure ([[https://github.com/timvisher-dd/agent-shell-plus/pull/2][#2]]) +- Regression tests for shell buffer selection ordering ([[https://github.com/timvisher-dd/agent-shell-plus/pull/3][#3]]) +- CI check that README.org is updated when code changes ([[https://github.com/timvisher-dd/agent-shell-plus/pull/4][#4]]) +- Usage tests and defense against ACP =used > size= bug ([[https://github.com/timvisher-dd/agent-shell-plus/pull/5][#5]]) + +----- [[https://melpa.org/#/agent-shell][file:https://melpa.org/packages/agent-shell-badge.svg]] diff --git a/agent-shell-alert.el b/agent-shell-alert.el new file mode 100644 index 00000000..efe06571 --- /dev/null +++ b/agent-shell-alert.el @@ -0,0 +1,236 @@ +;;; agent-shell-alert.el --- Desktop notifications via OSC and macOS native -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Alvaro Ramirez + +;; Author: Alvaro Ramirez https://xenodium.com +;; URL: https://github.com/xenodium/agent-shell + +;; This package is free software; you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation; either version 3, or (at your option) +;; any later version. + +;; This package is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with GNU Emacs. If not, see . + +;;; Commentary: +;; +;; Send desktop notifications from Emacs. +;; +;; GUI Emacs on macOS: +;; +;; Uses `ns-do-applescript' to run AppleScript's `display +;; notification' from within the Emacs process. Because the +;; notification originates from Emacs itself, macOS attributes it to +;; Emacs: the Emacs icon appears and clicking the notification +;; activates Emacs. No compilation, no dynamic module, no external +;; dependencies. +;; +;; We originally built a JIT-compiled Objective-C dynamic module +;; (inspired by vterm's approach to vterm-module.so) that used +;; UNUserNotificationCenter — Apple's modern notification API. It +;; worked perfectly on an adhoc-signed Emacs built from source, but +;; fails with UNErrorDomain error 1 (UNErrorCodeNotificationsNotAllowed) +;; on the Homebrew emacs-app cask build from emacsformacosx.com. +;; Apple's documentation says no entitlement is needed for local +;; notifications and the hardened runtime has no notification-related +;; restrictions, so the root cause is unclear. The investigation is +;; tracked in x.notification-center-spiking.md and in beads issue +;; agent-shell-4217. +;; +;; `ns-do-applescript' turns out to give you essentially native +;; notifications for free: Emacs-branded, no compilation step, works +;; on every macOS Emacs build. It uses the deprecated AppleScript +;; notification bridge rather than UNUserNotificationCenter, but it +;; works on current macOS versions and is the pragmatic choice until +;; the UNUserNotificationCenter issue is resolved. +;; +;; Terminal Emacs: +;; +;; Auto-detects the host terminal emulator and sends the appropriate +;; OSC escape sequence: OSC 9 (iTerm2, Ghostty, WezTerm, foot, +;; mintty, ConEmu), OSC 99 (kitty), or OSC 777 (urxvt, VTE-based +;; terminals), with DCS passthrough for tmux (when +;; allow-passthrough is enabled). +;; +;; Fallback: +;; +;; Falls back to osascript on macOS when the terminal is unknown or +;; tmux passthrough is not available. On non-macOS platforms where +;; the terminal is unrecognized, no OS-level notification is sent. +;; +;; Terminal detection and DCS wrapping are inspired by clipetty's +;; approach. + +;;; Code: + +(defvar agent-shell-alert--osascript-warned nil + "Non-nil after the osascript fallback warning has been shown.") + +(defun agent-shell-alert--detect-terminal () + "Detect the host terminal emulator. + +Inside tmux, TERM_PROGRAM is \"tmux\", so we query tmux's global +environment for the outer terminal. Falls back to terminal-specific +environment variables that survive tmux session inheritance. + + ;; In iTerm2: + (agent-shell-alert--detect-terminal) + ;; => \"iTerm.app\" + + ;; In kitty inside tmux: + (agent-shell-alert--detect-terminal) + ;; => \"kitty\"" + (let ((tp (getenv "TERM_PROGRAM" (selected-frame)))) + (cond + ((and tp (not (string= tp "tmux"))) + tp) + ((string= tp "tmux") + (when-let ((raw (ignore-errors + (string-trim + (shell-command-to-string + "tmux show-environment -g TERM_PROGRAM 2>/dev/null"))))) + (when (string-match "^TERM_PROGRAM=\\(.+\\)" raw) + (let ((val (match-string 1 raw))) + (unless (string= val "tmux") + val))))) + ((getenv "GHOSTTY_RESOURCES_DIR" (selected-frame)) + "ghostty") + ((getenv "ITERM_SESSION_ID" (selected-frame)) + "iTerm.app") + ((getenv "WEZTERM_EXECUTABLE" (selected-frame)) + "WezTerm") + ((getenv "KITTY_PID" (selected-frame)) + "kitty") + ((getenv "ConEmuPID" (selected-frame)) + "ConEmu") + ((getenv "VTE_VERSION" (selected-frame)) + "vte") + ((when-let ((term (getenv "TERM" (selected-frame)))) + (string-match-p "^rxvt" term)) + "urxvt") + ((when-let ((term (getenv "TERM" (selected-frame)))) + (string-match-p "^foot" term)) + "foot") + ((when-let ((term (getenv "TERM" (selected-frame)))) + (string-match-p "^mintty" term)) + "mintty")))) + +(defun agent-shell-alert--osc-payload (title body) + "Build the raw OSC notification payload for TITLE and BODY. + +Selects the OSC protocol based on the detected terminal: +OSC 9 for iTerm2, Ghostty, WezTerm, foot, mintty, ConEmu; +OSC 99 for kitty; OSC 777 for urxvt and VTE-based terminals. +Returns nil if the terminal does not support OSC notifications. + + (agent-shell-alert--osc-payload \"Done\" \"Task finished\") + ;; => \"\\e]9;Task finished\\e\\\\\" (in iTerm2) + + (agent-shell-alert--osc-payload \"Done\" \"Task finished\") + ;; => nil (in Apple Terminal)" + (let ((terminal (agent-shell-alert--detect-terminal))) + (pcase terminal + ("kitty" + (format "\e]99;i=1:d=0;%s\e\\\e]99;i=1:p=body;%s\e\\" title body)) + ;; Extend these lists as users report supported terminals. + ((or "urxvt" "vte") + (format "\e]777;notify;%s;%s\e\\" title body)) + ((or "iTerm.app" "ghostty" "WezTerm" "foot" "mintty" "ConEmu") + (format "\e]9;%s\e\\" body))))) + +(defun agent-shell-alert--tmux-allow-passthrough-p () + "Return non-nil if tmux has allow-passthrough enabled. + + ;; With `set -g allow-passthrough on': + (agent-shell-alert--tmux-allow-passthrough-p) + ;; => t" + (when-let ((out (ignore-errors + (string-trim + (shell-command-to-string + "tmux show-option -gv allow-passthrough 2>/dev/null"))))) + (string= out "on"))) + +(defun agent-shell-alert--tmux-passthrough (seq) + "Wrap SEQ in tmux DCS passthrough if inside tmux. + +Returns SEQ unchanged outside tmux. Returns nil if inside tmux +but allow-passthrough is not enabled, signaling the caller to +fall back to osascript. + + ;; Inside tmux with passthrough enabled: + (agent-shell-alert--tmux-passthrough \"\\e]9;hi\\e\\\\\") + ;; => \"\\ePtmux;\\e\\e]9;hi\\e\\\\\\e\\\\\" + + ;; Outside tmux: + (agent-shell-alert--tmux-passthrough \"\\e]9;hi\\e\\\\\") + ;; => \"\\e]9;hi\\e\\\\\"" + (if (not (getenv "TMUX" (selected-frame))) + seq + (when (agent-shell-alert--tmux-allow-passthrough-p) + (let ((escaped (replace-regexp-in-string "\e" "\e\e" seq t t))) + (concat "\ePtmux;" escaped "\e\\"))))) + +(defun agent-shell-alert--osascript-notify (title body) + "Send a macOS notification via osascript as a fallback. + +TITLE and BODY are the notification title and message. + + (agent-shell-alert--osascript-notify \"agent-shell\" \"Done\")" + (unless agent-shell-alert--osascript-warned + (setq agent-shell-alert--osascript-warned t) + (message "agent-shell-alert: using osascript for notifications.\ + For native terminal notifications:") + (message " - Use a terminal that supports OSC 9 \ +(iTerm2, Ghostty, WezTerm) or OSC 99 (Kitty)") + (when (getenv "TMUX" (selected-frame)) + (message " - Enable tmux passthrough: \ +set -g allow-passthrough on"))) + (call-process "osascript" nil 0 nil + "-e" + (format "display notification %S with title %S" + body title))) + +(defun agent-shell-alert-notify (title body) + "Send a desktop notification with TITLE and BODY. + +In GUI Emacs on macOS, uses `ns-do-applescript' to run `display +notification' from within the Emacs process so the notification +is attributed to Emacs (Emacs icon, click activates Emacs). In +terminal Emacs, auto-detects the terminal emulator and sends the +appropriate OSC escape sequence, with tmux DCS passthrough when +available. Falls back to osascript on macOS when the terminal is +unknown or tmux passthrough is not enabled. + + (agent-shell-alert-notify \"agent-shell\" \"Turn complete\")" + (cond + ;; GUI Emacs on macOS: use ns-do-applescript for Emacs-branded + ;; notifications (Emacs icon, click activates Emacs). + ((and (eq system-type 'darwin) + (display-graphic-p) + (fboundp 'ns-do-applescript)) + (condition-case nil + (ns-do-applescript + (format "display notification %S with title %S" body title)) + (error + (agent-shell-alert--osascript-notify title body)))) + ;; Terminal: try OSC escape sequences for terminal notifications. + ((not (display-graphic-p)) + (if-let ((payload (agent-shell-alert--osc-payload title body)) + (wrapped (agent-shell-alert--tmux-passthrough payload))) + (send-string-to-terminal wrapped) + (when (eq system-type 'darwin) + (agent-shell-alert--osascript-notify title body)))) + ;; GUI on macOS without ns-do-applescript (shouldn't happen), or + ;; non-macOS GUI: fall back to osascript or just message. + ((eq system-type 'darwin) + (agent-shell-alert--osascript-notify title body)))) + +(provide 'agent-shell-alert) + +;;; agent-shell-alert.el ends here diff --git a/agent-shell-usage.el b/agent-shell-usage.el index a1b8ba9e..58c00a0c 100644 --- a/agent-shell-usage.el +++ b/agent-shell-usage.el @@ -145,11 +145,12 @@ When MULTILINE is non-nil, format as right-aligned labeled rows." (if (> (or (map-elt usage :context-size) 0) 0) (agent-shell--format-number-compact (or (map-elt usage :context-size) 0)) "?") - (if (and (map-elt usage :context-size) - (> (map-elt usage :context-size) 0)) - (format " (%.1f%%)" (* 100.0 (/ (float (or (map-elt usage :context-used) 0)) - (map-elt usage :context-size)))) - ""))) + (let ((used (or (map-elt usage :context-used) 0)) + (size (or (map-elt usage :context-size) 0))) + (cond + ((< size used) " (?)") + ((< 0 size) (format " (%.1f%%)" (* 100.0 (/ (float used) size)))) + (t ""))))) (total (let ((n (or (map-elt usage :total-tokens) 0))) (if (> n 0) @@ -201,26 +202,30 @@ Only returns an indicator if enabled and usage data is available." (context-used (map-elt usage :context-used)) (context-size (map-elt usage :context-size)) ((> context-size 0))) - (let* ((percentage (/ (* 100.0 context-used) context-size)) - ;; Unicode vertical block characters from empty to full - (indicator (cond - ((>= percentage 100) "█") ; Full - ((>= percentage 87.5) "▇") - ((>= percentage 75) "▆") - ((>= percentage 62.5) "▅") - ((>= percentage 50) "▄") - ((>= percentage 37.5) "▃") - ((>= percentage 25) "▂") - ((> percentage 0) "▁") - (t nil))) ; Return nil for no usage - (face (cond - ((>= percentage 85) 'error) ; Red for critical - ((>= percentage 60) 'warning) ; Yellow/orange for warning - (t 'success)))) ; Green for normal - (when indicator - (propertize indicator - 'face face - 'help-echo (agent-shell--format-usage usage)))))) + (if (< context-size context-used) + (propertize "?" + 'face 'warning + 'help-echo (agent-shell--format-usage usage)) + (let* ((percentage (/ (* 100.0 context-used) context-size)) + ;; Unicode vertical block characters from empty to full + (indicator (cond + ((>= percentage 100) "█") ; Full + ((>= percentage 87.5) "▇") + ((>= percentage 75) "▆") + ((>= percentage 62.5) "▅") + ((>= percentage 50) "▄") + ((>= percentage 37.5) "▃") + ((>= percentage 25) "▂") + ((> percentage 0) "▁") + (t nil))) ; Return nil for no usage + (face (cond + ((>= percentage 85) 'error) ; Red for critical + ((>= percentage 60) 'warning) ; Yellow/orange for warning + (t 'success)))) ; Green for normal + (when indicator + (propertize indicator + 'face face + 'help-echo (agent-shell--format-usage usage))))))) (provide 'agent-shell-usage) ;;; agent-shell-usage.el ends here diff --git a/agent-shell.el b/agent-shell.el index e60ea7aa..15a9cf8a 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -61,6 +61,7 @@ (require 'agent-shell-goose) (require 'agent-shell-heartbeat) (require 'agent-shell-active-message) +(require 'agent-shell-alert) (require 'agent-shell-kiro) (require 'agent-shell-mistral) (require 'agent-shell-openai) @@ -643,6 +644,111 @@ the session and returns the appropriate endpoint: :type '(repeat (choice (alist :key-type symbol :value-type sexp) function)) :group 'agent-shell) +;;; Debug logging + +(defvar agent-shell-logging-enabled nil + "When non-nil, write debug messages to the log buffer.") + +(defvar agent-shell--log-buffer-max-bytes (* 100 1000 1000) + "Maximum size of the log buffer in bytes.") + +(defun agent-shell--make-log-buffer (shell-buffer) + "Create a log buffer for SHELL-BUFFER. +The name is derived from SHELL-BUFFER's name at creation time." + (let ((name (format "%s log*" (string-remove-suffix + "*" (buffer-name shell-buffer))))) + (with-current-buffer (get-buffer-create name) + (buffer-disable-undo) + (current-buffer)))) + +(defun agent-shell--log (label format-string &rest args) + "Log message with LABEL using FORMAT-STRING and ARGS. +Does nothing unless `agent-shell-logging-enabled' is non-nil. +Must be called from an agent-shell-mode buffer." + (when agent-shell-logging-enabled + (when-let ((log-buffer (map-elt (agent-shell--state) :log-buffer))) + (when (buffer-live-p log-buffer) + (let ((body (apply #'format format-string args))) + (with-current-buffer log-buffer + (goto-char (point-max)) + (let ((entry-start (point))) + (insert (if label + (format "%s >\n\n%s\n\n" label body) + (format "%s\n\n" body))) + (when (< entry-start (point)) + (add-text-properties entry-start (1+ entry-start) + '(agent-shell-log-boundary t))))) + (agent-shell--trim-log-buffer log-buffer)))))) + +(defun agent-shell--trim-log-buffer (buffer) + "Trim BUFFER to `agent-shell--log-buffer-max-bytes' at message boundaries." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (save-excursion + (let ((total-bytes (1- (position-bytes (point-max))))) + (when (< agent-shell--log-buffer-max-bytes total-bytes) + (goto-char (byte-to-position (- total-bytes agent-shell--log-buffer-max-bytes))) + (when (get-text-property (point) 'agent-shell-log-boundary) + (forward-char 1)) + (delete-region (point-min) + (next-single-property-change + (point) 'agent-shell-log-boundary nil (point-max))))))))) + +(defun agent-shell--save-buffer-to-file (buffer file) + "Write contents of BUFFER to FILE if BUFFER is live and non-empty." + (when (and (buffer-live-p buffer) + (< 0 (buffer-size buffer))) + (with-current-buffer buffer + (save-restriction + (widen) + (write-region (point-min) (point-max) file))) + t)) + +(defun agent-shell-debug-save-to (directory) + "Save debug buffers for the current shell to DIRECTORY. +When called interactively, prompts for a directory. + +Writes the following files: + log.txt - agent-shell log buffer contents + shell.txt - shell buffer contents + messages.txt - *Messages* buffer contents" + (interactive + (list (read-directory-name "Save debug logs to: " + (expand-file-name + (format "agent-shell-debug-%s/" + (format-time-string "%Y%m%d-%H%M%S")) + temporary-file-directory)))) + (unless directory + (error "directory is required")) + (let ((directory (file-name-as-directory (expand-file-name directory))) + (saved-files nil)) + (make-directory directory t) + (when (agent-shell--save-buffer-to-file + (map-elt (agent-shell--state) :log-buffer) + (expand-file-name "log.txt" directory)) + (push "log.txt" saved-files)) + (when (agent-shell--save-buffer-to-file + (map-elt (agent-shell--state) :buffer) + (expand-file-name "shell.txt" directory)) + (push "shell.txt" saved-files)) + (when (agent-shell--save-buffer-to-file + (get-buffer "*Messages*") + (expand-file-name "messages.txt" directory)) + (push "messages.txt" saved-files)) + (when-let ((client (map-elt (agent-shell--state) :client))) + (when (agent-shell--save-buffer-to-file + (acp-traffic-buffer :client client) + (expand-file-name "traffic.txt" directory)) + (push "traffic.txt" saved-files)) + (when (agent-shell--save-buffer-to-file + (acp-logs-buffer :client client) + (expand-file-name "acp-log.txt" directory)) + (push "acp-log.txt" saved-files))) + (if saved-files + (message "Saved %s to %s" (string-join (nreverse saved-files) ", ") directory) + (message "No debug data to save")) + directory)) + (cl-defun agent-shell--make-state (&key agent-config buffer client-maker needs-authentication authenticate-request-maker heartbeat outgoing-request-decorator) "Construct shell agent state with AGENT-CONFIG and BUFFER. @@ -651,6 +757,7 @@ HEARTBEAT, AUTHENTICATE-REQUEST-MAKER, and optionally OUTGOING-REQUEST-DECORATOR (passed through to `acp-make-client')." (list (cons :agent-config agent-config) (cons :buffer buffer) + (cons :log-buffer (when buffer (agent-shell--make-log-buffer buffer))) (cons :client nil) (cons :client-maker client-maker) (cons :outgoing-request-decorator outgoing-request-decorator) @@ -687,11 +794,20 @@ OUTGOING-REQUEST-DECORATOR (passed through to `acp-make-client')." (cons :context-used 0) (cons :context-size 0) (cons :cost-amount 0.0) - (cons :cost-currency nil))))) + (cons :cost-currency nil))) + (cons :idle-notification-timer nil))) (defvar-local agent-shell--state (agent-shell--make-state)) +(defvar agent-shell-idle-notification-delay 30 + "Seconds of idle time before sending a terminal notification. +Defaults to 30. When non-nil, a timer starts each time an agent +turn completes. If the user does not interact with the buffer +within this many seconds, a desktop notification is sent via OSC +escape sequences. Any user input in the buffer cancels the +pending notification. Set to nil to disable idle notifications.") + (defvar-local agent-shell--transcript-file nil "Path to the shell's transcript file.") @@ -2094,6 +2210,7 @@ DIFF should be in the form returned by `agent-shell--make-diff-info': For example, shut down ACP client." (unless (derived-mode-p 'agent-shell-mode) (error "Not in a shell")) + (agent-shell--idle-notification-cancel) (agent-shell--shutdown) (when-let (((map-elt (agent-shell--state) :buffer)) (viewport-buffer (agent-shell-viewport--buffer @@ -3404,6 +3521,44 @@ DATA is an optional alist of event-specific data." (with-current-buffer (map-elt (agent-shell--state) :buffer) (funcall (map-elt sub :on-event) event-alist)))))) +;;; Idle notification + +(defun agent-shell--idle-notification-cancel () + "Cancel pending idle notification timer and remove the hook." + (when-let ((timer (map-elt (agent-shell--state) :idle-notification-timer))) + (when (timerp timer) + (cancel-timer timer)) + (map-put! (agent-shell--state) :idle-notification-timer nil)) + (remove-hook 'post-command-hook #'agent-shell--idle-notification-cancel t)) + +(defun agent-shell--idle-notification-fire () + "Send idle notification and clean up the hook. +Does nothing if the shell is busy — notifications should only fire +when the prompt is idle and waiting for input." + (remove-hook 'post-command-hook #'agent-shell--idle-notification-cancel t) + (map-put! (agent-shell--state) :idle-notification-timer nil) + (if (shell-maker-busy) + (agent-shell--log "IDLE NOTIFICATION" "suppressed (shell busy)") + (agent-shell--log "IDLE NOTIFICATION" "fire") + (unless (eq (map-elt (agent-shell--state) :buffer) + (window-buffer (selected-window))) + (message "agent-shell: Prompt is waiting for input")) + (agent-shell-alert-notify "agent-shell" "Prompt is waiting for input"))) + +(defun agent-shell--idle-notification-start () + "Start idle notification timer if `agent-shell-idle-notification-delay' is set." + (when agent-shell-idle-notification-delay + (agent-shell--idle-notification-cancel) + (let ((shell-buffer (map-elt (agent-shell--state) :buffer))) + (map-put! (agent-shell--state) + :idle-notification-timer + (run-at-time agent-shell-idle-notification-delay nil + (lambda () + (when (buffer-live-p shell-buffer) + (with-current-buffer shell-buffer + (agent-shell--idle-notification-fire)))))) + (add-hook 'post-command-hook #'agent-shell--idle-notification-cancel nil t)))) + ;;; Initialization (cl-defun agent-shell--initialize-client () @@ -4402,6 +4557,7 @@ If FILE-PATH is not an image, returns nil." :event 'turn-complete :data (list (cons :stop-reason (map-elt acp-response 'stopReason)) (cons :usage (map-elt (agent-shell--state) :usage)))) + (agent-shell--idle-notification-start) ;; Update viewport header (longer busy) (when-let ((viewport-buffer (agent-shell-viewport--buffer :shell-buffer shell-buffer @@ -5238,6 +5394,9 @@ Returns an alist with insertion details or nil otherwise: (user-error "No text provided to insert")) (let* ((shell-buffer (or shell-buffer (agent-shell--shell-buffer :no-create t)))) + (when (buffer-live-p shell-buffer) + (with-current-buffer shell-buffer + (agent-shell--idle-notification-cancel))) (if (with-current-buffer shell-buffer (or (map-nested-elt agent-shell--state '(:session :id)) (eq agent-shell-session-strategy 'new-deferred))) @@ -6259,6 +6418,7 @@ automatically sent when the current request completes." (error "Not in a shell")) (list (read-string (or (map-nested-elt (agent-shell--state) '(:agent-config :shell-prompt)) "Enqueue request: "))))) + (agent-shell--idle-notification-cancel) (if (shell-maker-busy) (agent-shell--enqueue-request :prompt prompt) (agent-shell--insert-to-shell-buffer :text prompt :submit t :no-focus t))) diff --git a/bin/test b/bin/test new file mode 100755 index 00000000..93e9b9c9 --- /dev/null +++ b/bin/test @@ -0,0 +1,79 @@ +#!/usr/bin/env bash -O globstar -O extglob + +# Assume that acp.el and shell-maker are checked out in sibling trunk +# worktrees and allow their location to be overridden: +# …/agent-shell/main/bin/test +# …/acp.el/main +# …/shell-maker/main +root=$(dirname "$0")/.. +tests_dir=${root}/tests +acp_root=${acp_root:-${root}/../../acp.el/main} +shell_maker_root=${shell_maker_root:-${root}/../../shell-maker/main} + +if ! [[ -r ${acp_root}/acp.el ]] +then + echo "Set shell_maker_root to your shell-maker checkout (e.g. ~/git/xenodium/shell-maker/main)" >&2 + die=1 +fi + +if ! [[ -r ${shell_maker_root}/shell-maker.el ]] +then + echo "Set shell_maker_root to your shell-maker checkout (e.g. ~/git/xenodium/shell-maker/main)" >&2 + die=1 +fi + +if [[ -n $die ]] +then + echo "Fix the ↑ problems" >&2 + exit 1 +fi + +shopt -s nullglob +all_elc_files=({"${root}","${acp_root}","${shell_maker_root}"}/**/*.elc) +all_el_files=("${root}"/*.el) +test_files=("${tests_dir}"/*-tests.el) +shopt -u nullglob + +if (( 0 < ${#all_elc_files[@]} )) +then + rm -v "${all_elc_files[@]}" +fi + +# Filter out x./y./z. prefixed scratch files from compilation +compile_files=() +for f in "${all_el_files[@]}"; do + case "$(basename "$f")" in + x.*|y.*|z.*) ;; + *) compile_files+=("$f") ;; + esac +done + +if (( ${#compile_files[@]} < 1 )); then + echo "No compile targets found in ${root}" >&2 + exit 1 +fi + +if (( ${#test_files[@]} < 1 )); then + echo "No test files found in ${tests_dir}" >&2 + exit 1 +fi + +test_args=() +for file in "${test_files[@]}"; do + test_args+=(-l "$file") +done + +emacs -Q --batch \ + -L "${root}" \ + -L "${acp_root}" \ + -L "${shell_maker_root}" \ + -f batch-byte-compile \ + "${compile_files[@]}" + +emacs -Q --batch \ + -L "${root}" \ + -L "${acp_root}" \ + -L "${shell_maker_root}" \ + -L "${tests_dir}" \ + "${test_args[@]}" \ + -f ert-run-tests-batch-and-exit diff --git a/tests/agent-shell-buffer-ordering-tests.el b/tests/agent-shell-buffer-ordering-tests.el new file mode 100644 index 00000000..52cbaea2 --- /dev/null +++ b/tests/agent-shell-buffer-ordering-tests.el @@ -0,0 +1,158 @@ +;;; agent-shell-buffer-ordering-tests.el --- Tests for shell buffer ordering -*- lexical-binding: t; -*- + +(require 'ert) +(require 'agent-shell) + +;;; Code: + +(defmacro agent-shell-buffer-ordering-tests--with-fake-shells (bindings &rest body) + "Create temporary buffers in `agent-shell-mode', bind them, and run BODY. + +BINDINGS is a list of (VAR PROJECT-DIR) pairs. Each VAR is bound to a +buffer whose `major-mode' is `agent-shell-mode' and whose +`default-directory' is PROJECT-DIR. + +All buffers are killed after BODY completes. Viewport lookup is +stubbed out so only shell-mode buffers are considered." + (declare (indent 1) (debug ((&rest (symbolp sexp)) body))) + (let ((buffer-syms (mapcar #'car bindings))) + `(let ,(mapcar (lambda (b) (list (car b) nil)) bindings) + (unwind-protect + (progn + ,@(mapcar + (lambda (b) + `(setq ,(car b) + (generate-new-buffer + ,(format " *test-%s*" (car b))))) + bindings) + ,@(mapcar + (lambda (b) + `(with-current-buffer ,(car b) + (setq major-mode 'agent-shell-mode) + (setq default-directory ,(cadr b)))) + bindings) + (cl-letf (((symbol-function 'agent-shell-viewport--shell-buffer) + (lambda (_buf) nil)) + ((symbol-function 'agent-shell-cwd) + (lambda () + (expand-file-name default-directory)))) + ,@body)) + ,@(mapcar (lambda (sym) `(when (buffer-live-p ,sym) + (kill-buffer ,sym))) + buffer-syms))))) + +;; --------------------------------------------------------------------------- +;; Tests for (buffer-list) based ordering +;; --------------------------------------------------------------------------- + +(ert-deftest agent-shell-buffers-reflects-buffer-list-order () + "Shells are returned in `(buffer-list)' order. + +`agent-shell-buffers' iterates `(buffer-list)' and collects +`agent-shell-mode' buffers in the order it encounters them, so +the result should mirror `(buffer-list)' ordering." + (agent-shell-buffer-ordering-tests--with-fake-shells + ((shell-a "/tmp/project/") + (shell-b "/tmp/project/")) + ;; Newly generated buffers go to the END of (buffer-list), so + ;; iterating (buffer-list) encounters shell-a before shell-b. + (should (equal (agent-shell-buffers) + (list shell-a shell-b))))) + +(ert-deftest agent-shell-buffers-switch-to-buffer-promotes () + "`switch-to-buffer' promotes a shell to the front of `(buffer-list)'. + +After `switch-to-buffer' to shell-b followed by switching away, +shell-b should appear before shell-a in `agent-shell-buffers'." + (agent-shell-buffer-ordering-tests--with-fake-shells + ((shell-a "/tmp/project/") + (shell-b "/tmp/project/")) + (switch-to-buffer shell-b) + (switch-to-buffer "*scratch*") + (should (equal (agent-shell-buffers) + (list shell-b shell-a))))) + +(ert-deftest agent-shell-buffers-select-window-promotes () + "`select-window' + `display-buffer' promotes a shell. + +This is the code path used by `agent-shell--display-buffer'." + (agent-shell-buffer-ordering-tests--with-fake-shells + ((shell-a "/tmp/project/") + (shell-b "/tmp/project/")) + (select-window (display-buffer shell-b)) + (switch-to-buffer "*scratch*") + (should (equal (agent-shell-buffers) + (list shell-b shell-a))))) + +(ert-deftest agent-shell-buffers-with-current-buffer-does-not-promote () + "`with-current-buffer' does NOT change `(buffer-list)' order. + +`agent-shell--handle' dispatches commands via `with-current-buffer', +so sending commands to a shell does not promote it." + (agent-shell-buffer-ordering-tests--with-fake-shells + ((shell-a "/tmp/project/") + (shell-b "/tmp/project/")) + (with-current-buffer shell-b + (insert "simulated command")) + (should (equal (agent-shell-buffers) + (list shell-a shell-b))))) + +(ert-deftest agent-shell-buffers-bury-buffer-demotes () + "`bury-buffer' sends a shell to the end of `(buffer-list)'. + +If a user leaves a shell via `quit-window' (which buries), the +shell drops to the back even if it was most recently used." + (agent-shell-buffer-ordering-tests--with-fake-shells + ((shell-a "/tmp/project/") + (shell-b "/tmp/project/")) + ;; Promote shell-b to front + (switch-to-buffer shell-b) + (switch-to-buffer "*scratch*") + ;; Verify shell-b is first + (should (eq (seq-first (agent-shell-buffers)) shell-b)) + ;; Bury it + (bury-buffer shell-b) + ;; Now shell-a is first again + (should (eq (seq-first (agent-shell-buffers)) shell-a)))) + +(ert-deftest agent-shell-buffers-no-display-buffer-stays-at-end () + "`generate-new-buffer' without display leaves shell at end. + +Shells created via no-focus paths are never selected in a window, +so they stay at the end of `(buffer-list)' behind older shells." + (agent-shell-buffer-ordering-tests--with-fake-shells + ((shell-a "/tmp/project/") + (shell-b "/tmp/project/")) + ;; Promote shell-a (simulates it being displayed at some point) + (switch-to-buffer shell-a) + (switch-to-buffer "*scratch*") + ;; shell-b was never displayed, so shell-a stays ahead + (should (eq (seq-first (agent-shell-buffers)) shell-a)))) + +(ert-deftest agent-shell-project-buffers-filters-by-project () + "`agent-shell-project-buffers' only returns shells matching the CWD." + (agent-shell-buffer-ordering-tests--with-fake-shells + ((shell-a "/tmp/project-a/") + (shell-b "/tmp/project-b/") + (shell-c "/tmp/project-a/")) + (with-current-buffer shell-a + (let ((project-buffers (agent-shell-project-buffers))) + (should (= (length project-buffers) 2)) + (should (memq shell-a project-buffers)) + (should (memq shell-c project-buffers)) + (should-not (memq shell-b project-buffers)))))) + +(ert-deftest agent-shell-project-buffers-preserves-buffer-list-order () + "`agent-shell-project-buffers' preserves `(buffer-list)' order within a project." + (agent-shell-buffer-ordering-tests--with-fake-shells + ((shell-a "/tmp/project/") + (shell-b "/tmp/project/")) + ;; Promote shell-b + (switch-to-buffer shell-b) + (switch-to-buffer "*scratch*") + (with-current-buffer shell-a + (should (equal (agent-shell-project-buffers) + (list shell-b shell-a)))))) + +(provide 'agent-shell-buffer-ordering-tests) +;;; agent-shell-buffer-ordering-tests.el ends here diff --git a/tests/agent-shell-tests.el b/tests/agent-shell-tests.el index ec82ca50..d0e0073e 100644 --- a/tests/agent-shell-tests.el +++ b/tests/agent-shell-tests.el @@ -525,6 +525,7 @@ (cons :session (list (cons :id "test-session"))) (cons :last-entry-type nil) (cons :tool-calls nil) + (cons :idle-notification-timer nil) (cons :usage (list (cons :total-tokens 0))))) (agent-shell-show-busy-indicator nil) (agent-shell-show-usage-at-turn-end nil)) @@ -1924,5 +1925,332 @@ code block content (should-not responded) (should (equal (map-elt state :last-entry-type) "session/request_permission")))))) +;;; Idle notification tests + +(ert-deftest agent-shell--idle-notification-start-sets-timer-and-hook-test () + "Test that `agent-shell--idle-notification-start' sets up timer and hook." + (with-temp-buffer + (let ((agent-shell-idle-notification-delay 30) + (agent-shell--state (list (cons :buffer (current-buffer)) + (cons :idle-notification-timer nil)))) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state))) + (agent-shell--idle-notification-start) + (should (timerp (map-elt agent-shell--state :idle-notification-timer))) + (should (memq #'agent-shell--idle-notification-cancel + (buffer-local-value 'post-command-hook (current-buffer)))) + (agent-shell--idle-notification-cancel))))) + +(ert-deftest agent-shell--idle-notification-cancel-cleans-up-test () + "Test that user input cancels the idle notification timer and hook." + (with-temp-buffer + (let ((agent-shell-idle-notification-delay 30) + (agent-shell--state (list (cons :buffer (current-buffer)) + (cons :idle-notification-timer nil)))) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state))) + (agent-shell--idle-notification-start) + (let ((timer (map-elt agent-shell--state :idle-notification-timer))) + (should (timerp timer)) + (agent-shell--idle-notification-cancel) + (should-not (map-elt agent-shell--state :idle-notification-timer)) + (should-not (memq #'agent-shell--idle-notification-cancel + (buffer-local-value 'post-command-hook (current-buffer))))))))) + +(ert-deftest agent-shell--idle-notification-fire-sends-and-cleans-up-test () + "Test that timer firing sends notification and removes hook." + (with-temp-buffer + (let ((agent-shell-idle-notification-delay 30) + (agent-shell--state (list (cons :buffer (current-buffer)) + (cons :idle-notification-timer nil))) + (notified nil) + (other-buf (generate-new-buffer " *other*"))) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state)) + ((symbol-function 'agent-shell-alert-notify) + (lambda (title body) + (setq notified (list title body)))) + ((symbol-function 'shell-maker-busy) + (lambda () nil)) + ((symbol-function 'window-buffer) + (lambda (&optional _window) other-buf))) + (agent-shell--idle-notification-start) + (should (timerp (map-elt agent-shell--state :idle-notification-timer))) + (agent-shell--idle-notification-fire) + (should (equal notified '("agent-shell" "Prompt is waiting for input"))) + (should-not (map-elt agent-shell--state :idle-notification-timer)) + (should-not (memq #'agent-shell--idle-notification-cancel + (buffer-local-value 'post-command-hook (current-buffer))))) + (kill-buffer other-buf)))) + +(ert-deftest agent-shell--idle-notification-fire-skips-message-when-buffer-visible-test () + "Test that message is skipped but OS notification still fires when active." + (with-temp-buffer + (let ((agent-shell-idle-notification-delay 30) + (shell-buf (current-buffer)) + (agent-shell--state (list (cons :buffer (current-buffer)) + (cons :idle-notification-timer nil))) + (notified nil) + (messages nil)) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state)) + ((symbol-function 'agent-shell-alert-notify) + (lambda (title body) + (setq notified (list title body)))) + ((symbol-function 'shell-maker-busy) + (lambda () nil)) + ((symbol-function 'window-buffer) + (lambda (&optional _window) shell-buf)) + ((symbol-function 'message) + (lambda (fmt &rest args) + (push (apply #'format fmt args) messages)))) + (agent-shell--idle-notification-start) + (agent-shell--idle-notification-fire) + (should (equal notified '("agent-shell" "Prompt is waiting for input"))) + (should-not messages) + (should-not (map-elt agent-shell--state :idle-notification-timer)))))) + +(ert-deftest agent-shell--idle-notification-nil-delay-does-nothing-test () + "Test that nil delay means no timer is started." + (with-temp-buffer + (let ((agent-shell-idle-notification-delay nil) + (agent-shell--state (list (cons :buffer (current-buffer)) + (cons :idle-notification-timer nil)))) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state))) + (agent-shell--idle-notification-start) + (should-not (map-elt agent-shell--state :idle-notification-timer)) + (should-not (memq #'agent-shell--idle-notification-cancel + (buffer-local-value 'post-command-hook (current-buffer)))))))) + +(ert-deftest agent-shell-alert--detect-terminal-term-program-test () + "Test terminal detection via TERM_PROGRAM." + (cl-letf (((symbol-function 'getenv) + (lambda (var &optional _frame) + (pcase var + ("TERM_PROGRAM" "iTerm.app") + (_ nil))))) + (should (equal (agent-shell-alert--detect-terminal) "iTerm.app")))) + +(ert-deftest agent-shell-alert--detect-terminal-ghostty-env-test () + "Test terminal detection via GHOSTTY_RESOURCES_DIR fallback." + (cl-letf (((symbol-function 'getenv) + (lambda (var &optional _frame) + (pcase var + ("GHOSTTY_RESOURCES_DIR" "/usr/share/ghostty") + (_ nil))))) + (should (equal (agent-shell-alert--detect-terminal) "ghostty")))) + +(ert-deftest agent-shell-alert--detect-terminal-kitty-env-test () + "Test terminal detection via KITTY_PID fallback." + (cl-letf (((symbol-function 'getenv) + (lambda (var &optional _frame) + (pcase var + ("KITTY_PID" "12345") + (_ nil))))) + (should (equal (agent-shell-alert--detect-terminal) "kitty")))) + +(ert-deftest agent-shell-alert--detect-terminal-conemu-env-test () + "Test terminal detection via ConEmuPID fallback." + (cl-letf (((symbol-function 'getenv) + (lambda (var &optional _frame) + (pcase var + ("ConEmuPID" "9876") + (_ nil))))) + (should (equal (agent-shell-alert--detect-terminal) "ConEmu")))) + +(ert-deftest agent-shell-alert--detect-terminal-vte-env-test () + "Test terminal detection via VTE_VERSION fallback." + (cl-letf (((symbol-function 'getenv) + (lambda (var &optional _frame) + (pcase var + ("VTE_VERSION" "7200") + (_ nil))))) + (should (equal (agent-shell-alert--detect-terminal) "vte")))) + +(ert-deftest agent-shell-alert--detect-terminal-urxvt-term-test () + "Test terminal detection via TERM=rxvt fallback." + (cl-letf (((symbol-function 'getenv) + (lambda (var &optional _frame) + (pcase var + ("TERM" "rxvt-unicode-256color") + (_ nil))))) + (should (equal (agent-shell-alert--detect-terminal) "urxvt")))) + +(ert-deftest agent-shell-alert--detect-terminal-foot-term-test () + "Test terminal detection via TERM=foot fallback." + (cl-letf (((symbol-function 'getenv) + (lambda (var &optional _frame) + (pcase var + ("TERM" "foot") + (_ nil))))) + (should (equal (agent-shell-alert--detect-terminal) "foot")))) + +(ert-deftest agent-shell-alert--detect-terminal-mintty-term-test () + "Test terminal detection via TERM=mintty fallback." + (cl-letf (((symbol-function 'getenv) + (lambda (var &optional _frame) + (pcase var + ("TERM" "mintty") + (_ nil))))) + (should (equal (agent-shell-alert--detect-terminal) "mintty")))) + +(ert-deftest agent-shell-alert--detect-terminal-unknown-test () + "Test terminal detection returns nil for unknown terminals." + (cl-letf (((symbol-function 'getenv) + (lambda (_var &optional _frame) nil))) + (should-not (agent-shell-alert--detect-terminal)))) + +(ert-deftest agent-shell-alert--osc-payload-osc9-test () + "Test OSC 9 payload generation for iTerm2/Ghostty/WezTerm/foot/mintty/ConEmu." + (cl-letf (((symbol-function 'agent-shell-alert--detect-terminal) + (lambda () "iTerm.app"))) + (should (equal (agent-shell-alert--osc-payload "Title" "Body") + "\e]9;Body\e\\")))) + +(ert-deftest agent-shell-alert--osc-payload-kitty-test () + "Test OSC 99 payload generation for kitty." + (cl-letf (((symbol-function 'agent-shell-alert--detect-terminal) + (lambda () "kitty"))) + (should (equal (agent-shell-alert--osc-payload "Title" "Body") + "\e]99;i=1:d=0;Title\e\\\e]99;i=1:p=body;Body\e\\")))) + +(ert-deftest agent-shell-alert--osc-payload-osc777-test () + "Test OSC 777 payload generation for urxvt and VTE terminals." + (cl-letf (((symbol-function 'agent-shell-alert--detect-terminal) + (lambda () "urxvt"))) + (should (equal (agent-shell-alert--osc-payload "Title" "Body") + "\e]777;notify;Title;Body\e\\")))) + +(ert-deftest agent-shell-alert--osc-payload-unsupported-terminal-test () + "Test that unsupported terminals return nil." + (cl-letf (((symbol-function 'agent-shell-alert--detect-terminal) + (lambda () "Apple_Terminal"))) + (should-not (agent-shell-alert--osc-payload "Title" "Body")))) + +(ert-deftest agent-shell-alert--tmux-passthrough-bare-terminal-test () + "Test no wrapping outside tmux." + (cl-letf (((symbol-function 'getenv) + (lambda (_var &optional _frame) nil))) + (should (equal (agent-shell-alert--tmux-passthrough "payload") + "payload")))) + +(ert-deftest agent-shell-alert--tmux-passthrough-enabled-test () + "Test DCS wrapping when tmux passthrough is enabled." + (cl-letf (((symbol-function 'getenv) + (lambda (var &optional _frame) + (pcase var + ("TMUX" "/tmp/tmux-501/default,12345,0") + (_ nil)))) + ((symbol-function 'agent-shell-alert--tmux-allow-passthrough-p) + (lambda () t))) + (should (equal (agent-shell-alert--tmux-passthrough "\e]9;hi\e\\") + "\ePtmux;\e\e]9;hi\e\e\\\e\\")))) + +(ert-deftest agent-shell-alert--tmux-passthrough-disabled-test () + "Test nil return when tmux passthrough is disabled." + (cl-letf (((symbol-function 'getenv) + (lambda (var &optional _frame) + (pcase var + ("TMUX" "/tmp/tmux-501/default,12345,0") + (_ nil)))) + ((symbol-function 'agent-shell-alert--tmux-allow-passthrough-p) + (lambda () nil))) + (should-not (agent-shell-alert--tmux-passthrough "\e]9;hi\e\\")))) + +(ert-deftest agent-shell-alert-notify-dispatches-to-mac-when-available-test () + "Test that notify dispatches to ns-do-applescript on GUI macOS." + (let ((notified nil) + (system-type 'darwin)) + (cl-letf (((symbol-function 'display-graphic-p) + (lambda (&rest _) t)) + ((symbol-function 'ns-do-applescript) + (lambda (script) + (setq notified script)))) + (agent-shell-alert-notify "Test" "Hello") + (should (stringp notified)) + (should (string-match-p "display notification" notified))))) + +(ert-deftest agent-shell-alert-notify-sends-osc-in-known-terminal-test () + "Test that notify sends OSC in a known terminal." + (let ((sent nil)) + (cl-letf (((symbol-function 'agent-shell-alert--mac-available-p) + (lambda () nil)) + ((symbol-function 'display-graphic-p) + (lambda (&rest _) nil)) + ((symbol-function 'agent-shell-alert--detect-terminal) + (lambda () "iTerm.app")) + ((symbol-function 'agent-shell-alert--tmux-passthrough) + (lambda (seq) seq)) + ((symbol-function 'send-string-to-terminal) + (lambda (str) (setq sent str)))) + (agent-shell-alert-notify "T" "B") + (should (equal sent "\e]9;B\e\\"))))) + +(ert-deftest agent-shell-alert-notify-falls-back-to-osascript-no-terminal-test () + "Test osascript fallback when no terminal is detected on macOS." + (let ((osascript-called nil)) + (cl-letf (((symbol-function 'agent-shell-alert--mac-available-p) + (lambda () nil)) + ((symbol-function 'display-graphic-p) + (lambda (&rest _) nil)) + ((symbol-function 'agent-shell-alert--detect-terminal) + (lambda () nil)) + ((symbol-function 'agent-shell-alert--osascript-notify) + (lambda (title body) + (setq osascript-called (list title body))))) + (let ((system-type 'darwin)) + (agent-shell-alert-notify "T" "B") + (should (equal osascript-called '("T" "B"))))))) + +(ert-deftest agent-shell-alert-notify-falls-back-to-osascript-unsupported-test () + "Test osascript fallback when terminal is detected but not OSC-capable." + (let ((osascript-called nil)) + (cl-letf (((symbol-function 'agent-shell-alert--mac-available-p) + (lambda () nil)) + ((symbol-function 'display-graphic-p) + (lambda (&rest _) nil)) + ((symbol-function 'agent-shell-alert--detect-terminal) + (lambda () "Apple_Terminal")) + ((symbol-function 'agent-shell-alert--osascript-notify) + (lambda (title body) + (setq osascript-called (list title body))))) + (let ((system-type 'darwin)) + (agent-shell-alert-notify "T" "B") + (should (equal osascript-called '("T" "B"))))))) + +;;; Debug logging tests + +(ert-deftest agent-shell--log-writes-to-buffer-when-enabled-test () + "Test that `agent-shell--log' writes to the per-shell log buffer when enabled." + (with-temp-buffer + (rename-buffer "*agent-shell test*" t) + (let* ((log-buf (agent-shell--make-log-buffer (current-buffer))) + (agent-shell-logging-enabled t) + (agent-shell--state (list (cons :buffer (current-buffer)) + (cons :log-buffer log-buf)))) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state))) + (agent-shell--log "TEST" "hello %s" "world") + (with-current-buffer log-buf + (should (string-match-p "TEST >" (buffer-string))) + (should (string-match-p "hello world" (buffer-string)))) + (kill-buffer log-buf))))) + +(ert-deftest agent-shell--log-does-nothing-when-disabled-test () + "Test that `agent-shell--log' is silent when logging is disabled." + (with-temp-buffer + (rename-buffer "*agent-shell test*" t) + (let* ((log-buf (agent-shell--make-log-buffer (current-buffer))) + (agent-shell-logging-enabled nil) + (agent-shell--state (list (cons :buffer (current-buffer)) + (cons :log-buffer log-buf)))) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state))) + (agent-shell--log "TEST" "should not appear") + (with-current-buffer log-buf + (should (equal (buffer-string) ""))) + (kill-buffer log-buf))))) + (provide 'agent-shell-tests) ;;; agent-shell-tests.el ends here diff --git a/tests/agent-shell-usage-tests.el b/tests/agent-shell-usage-tests.el new file mode 100644 index 00000000..46d753aa --- /dev/null +++ b/tests/agent-shell-usage-tests.el @@ -0,0 +1,332 @@ +;;; agent-shell-usage-tests.el --- Tests for usage tracking -*- lexical-binding: t; -*- + +(require 'ert) +(require 'cl-lib) +(require 'map) + +;; Load agent-shell-usage without pulling in the full agent-shell dependency tree. +;; Provide the declarations it needs. +(defvar agent-shell--state nil) +(defvar agent-shell-mode nil) +(require 'agent-shell-usage) + +;;; Code: + +(defun agent-shell-usage-tests--make-state (context-used context-size) + "Create minimal usage state with CONTEXT-USED and CONTEXT-SIZE." + (list (cons :usage + (list (cons :total-tokens 0) + (cons :input-tokens 0) + (cons :output-tokens 0) + (cons :thought-tokens 0) + (cons :cached-read-tokens 0) + (cons :cached-write-tokens 0) + (cons :context-used context-used) + (cons :context-size context-size) + (cons :cost-amount 0.0) + (cons :cost-currency nil))))) + +(defmacro agent-shell-usage-tests--with-stub (&rest body) + "Evaluate BODY with `agent-shell--state' stubbed to return the variable." + (declare (indent 0) (debug body)) + `(cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state))) + ,@body)) + +;; ============================================================ +;; agent-shell--update-usage-from-notification +;; ============================================================ + +(ert-deftest agent-shell-usage--update-sets-used-and-size () + "Notification with used/size updates state." + (let ((state (agent-shell-usage-tests--make-state 0 0))) + (agent-shell--update-usage-from-notification + :state state + :acp-update '((used . 50000) (size . 200000))) + (should (equal 50000 (map-elt (map-elt state :usage) :context-used))) + (should (equal 200000 (map-elt (map-elt state :usage) :context-size))))) + +(ert-deftest agent-shell-usage--compaction-resets-used () + "After compaction, a lower used value replaces the prior peak." + (let ((state (agent-shell-usage-tests--make-state 0 0))) + (agent-shell--update-usage-from-notification + :state state + :acp-update '((used . 965200) (size . 1000000))) + (should (equal 965200 (map-elt (map-elt state :usage) :context-used))) + ;; Compaction + (agent-shell--update-usage-from-notification + :state state + :acp-update '((used . 24095) (size . 1000000))) + (should (equal 24095 (map-elt (map-elt state :usage) :context-used))) + (should (equal 1000000 (map-elt (map-elt state :usage) :context-size))))) + +(ert-deftest agent-shell-usage--update-cost-fields () + "Cost amount and currency are extracted from the notification." + (let ((state (agent-shell-usage-tests--make-state 0 0))) + (agent-shell--update-usage-from-notification + :state state + :acp-update '((used . 10000) + (size . 200000) + (cost . ((amount . 0.42) (currency . "USD"))))) + (should (equal 0.42 (map-elt (map-elt state :usage) :cost-amount))) + (should (equal "USD" (map-elt (map-elt state :usage) :cost-currency))))) + +(ert-deftest agent-shell-usage--update-partial-fields () + "Notification with only used (no size) preserves previously-stored size." + (let ((state (agent-shell-usage-tests--make-state 0 0))) + (agent-shell--update-usage-from-notification + :state state + :acp-update '((used . 50000) (size . 200000))) + (agent-shell--update-usage-from-notification + :state state + :acp-update '((used . 60000))) + (should (equal 60000 (map-elt (map-elt state :usage) :context-used))) + (should (equal 200000 (map-elt (map-elt state :usage) :context-size))))) + +;; ============================================================ +;; agent-shell--context-usage-indicator +;; ============================================================ + +(ert-deftest agent-shell-usage--indicator-low-usage-green () + "Low usage (< 60%) shows green." + (let ((agent-shell-show-context-usage-indicator t) + (agent-shell--state (agent-shell-usage-tests--make-state 50000 200000))) + (agent-shell-usage-tests--with-stub + (let ((indicator (agent-shell--context-usage-indicator))) + (should indicator) + (should (equal 'success (get-text-property 0 'face indicator))))))) + +(ert-deftest agent-shell-usage--indicator-medium-usage-warning () + "Medium usage (60-84%) shows warning." + (let ((agent-shell-show-context-usage-indicator t) + (agent-shell--state (agent-shell-usage-tests--make-state 140000 200000))) + (agent-shell-usage-tests--with-stub + (let ((indicator (agent-shell--context-usage-indicator))) + (should indicator) + (should (equal 'warning (get-text-property 0 'face indicator))))))) + +(ert-deftest agent-shell-usage--indicator-high-usage-error () + "High usage (>= 85%) shows error/red." + (let ((agent-shell-show-context-usage-indicator t) + (agent-shell--state (agent-shell-usage-tests--make-state 180000 200000))) + (agent-shell-usage-tests--with-stub + (let ((indicator (agent-shell--context-usage-indicator))) + (should indicator) + (should (equal 'error (get-text-property 0 'face indicator))))))) + +(ert-deftest agent-shell-usage--indicator-full-usage () + "used == size shows full block with error face." + (let ((agent-shell-show-context-usage-indicator t) + (agent-shell--state (agent-shell-usage-tests--make-state 200000 200000))) + (agent-shell-usage-tests--with-stub + (let ((indicator (agent-shell--context-usage-indicator))) + (should (equal "█" (substring-no-properties indicator))) + (should (equal 'error (get-text-property 0 'face indicator))))))) + +(ert-deftest agent-shell-usage--indicator-overflow-shows-question-mark () + "used > size shows ? with warning face, not a block character." + (let ((agent-shell-show-context-usage-indicator t) + (agent-shell--state (agent-shell-usage-tests--make-state 419574 200000))) + (agent-shell-usage-tests--with-stub + (let ((indicator (agent-shell--context-usage-indicator))) + (should (equal "?" (substring-no-properties indicator))) + (should (equal 'warning (get-text-property 0 'face indicator))))))) + +(ert-deftest agent-shell-usage--indicator-resets-after-compaction () + "Indicator reflects the lower usage after compaction." + (let ((agent-shell-show-context-usage-indicator t) + (agent-shell--state (agent-shell-usage-tests--make-state 965200 1000000))) + (agent-shell-usage-tests--with-stub + ;; Pre-compaction: red + (should (equal 'error + (get-text-property 0 'face (agent-shell--context-usage-indicator)))) + ;; Compaction + (agent-shell--update-usage-from-notification + :state agent-shell--state + :acp-update '((used . 24095) (size . 1000000))) + ;; Post-compaction: green, smallest block + (let ((indicator (agent-shell--context-usage-indicator))) + (should (equal 'success (get-text-property 0 'face indicator))) + (should (equal "▁" (substring-no-properties indicator))))))) + +(ert-deftest agent-shell-usage--indicator-block-characters-scale () + "Block characters scale with usage percentage." + (let ((agent-shell-show-context-usage-indicator t)) + (agent-shell-usage-tests--with-stub + (let ((agent-shell--state (agent-shell-usage-tests--make-state 100000 1000000))) + (should (equal "▁" (substring-no-properties (agent-shell--context-usage-indicator))))) + (let ((agent-shell--state (agent-shell-usage-tests--make-state 300000 1000000))) + (should (equal "▂" (substring-no-properties (agent-shell--context-usage-indicator))))) + (let ((agent-shell--state (agent-shell-usage-tests--make-state 400000 1000000))) + (should (equal "▃" (substring-no-properties (agent-shell--context-usage-indicator))))) + (let ((agent-shell--state (agent-shell-usage-tests--make-state 550000 1000000))) + (should (equal "▄" (substring-no-properties (agent-shell--context-usage-indicator))))) + (let ((agent-shell--state (agent-shell-usage-tests--make-state 650000 1000000))) + (should (equal "▅" (substring-no-properties (agent-shell--context-usage-indicator))))) + (let ((agent-shell--state (agent-shell-usage-tests--make-state 800000 1000000))) + (should (equal "▆" (substring-no-properties (agent-shell--context-usage-indicator))))) + (let ((agent-shell--state (agent-shell-usage-tests--make-state 900000 1000000))) + (should (equal "▇" (substring-no-properties (agent-shell--context-usage-indicator))))) + (let ((agent-shell--state (agent-shell-usage-tests--make-state 1000000 1000000))) + (should (equal "█" (substring-no-properties (agent-shell--context-usage-indicator)))))))) + +(ert-deftest agent-shell-usage--indicator-nil-when-disabled () + "Return nil when the indicator is disabled." + (let ((agent-shell-show-context-usage-indicator nil) + (agent-shell--state (agent-shell-usage-tests--make-state 500000 1000000))) + (agent-shell-usage-tests--with-stub + (should-not (agent-shell--context-usage-indicator))))) + +(ert-deftest agent-shell-usage--indicator-nil-when-no-data () + "Return nil when context-size is 0." + (let ((agent-shell-show-context-usage-indicator t) + (agent-shell--state (agent-shell-usage-tests--make-state 0 0))) + (agent-shell-usage-tests--with-stub + (should-not (agent-shell--context-usage-indicator))))) + +(ert-deftest agent-shell-usage--indicator-nil-when-zero-usage () + "Return nil when context-used is 0." + (let ((agent-shell-show-context-usage-indicator t) + (agent-shell--state (agent-shell-usage-tests--make-state 0 1000000))) + (agent-shell-usage-tests--with-stub + (should-not (agent-shell--context-usage-indicator))))) + +;; ============================================================ +;; agent-shell--format-usage: overflow handling +;; ============================================================ + +(ert-deftest agent-shell-usage--format-usage-normal-percentage () + "Format shows percentage when used <= size." + (let ((usage (map-elt (agent-shell-usage-tests--make-state 50000 200000) :usage))) + (let ((formatted (agent-shell--format-usage usage))) + (should (string-match-p "(25.0%)" formatted)) + (should-not (string-match-p "(\\?)" formatted))))) + +(ert-deftest agent-shell-usage--format-usage-overflow-shows-unreliable () + "Format shows (?) instead of percentage when used > size." + (let ((usage (map-elt (agent-shell-usage-tests--make-state 419574 200000) :usage))) + (let ((formatted (agent-shell--format-usage usage))) + (should (string-match-p "420k/200k" formatted)) + (should (string-match-p "(\\?)" formatted)) + (should-not (string-match-p "209" formatted))))) + +(ert-deftest agent-shell-usage--format-usage-exact-full () + "Format shows 100.0% when used == size." + (let ((usage (map-elt (agent-shell-usage-tests--make-state 200000 200000) :usage))) + (let ((formatted (agent-shell--format-usage usage))) + (should (string-match-p "(100.0%)" formatted)) + (should-not (string-match-p "(\\?)" formatted))))) + +;; ============================================================ +;; Regression: model-switch ACP traffic replay +;; ============================================================ + +;; This test replays real observed ACP traffic where a model switch from +;; Opus 1M to Sonnet 200k caused the server to report used > size. +;; The server takes Math.min across all models for `size`, so after the +;; switch size dropped from 1000000 to 200000 while used kept growing. +;; This is the regression test that would have caught this bug originally. +(ert-deftest agent-shell-usage--model-switch-overflow-replay () + "Replay model-switch traffic: size drops, used exceeds it, indicator shows ?." + (let ((agent-shell-show-context-usage-indicator t) + (agent-shell--state (agent-shell-usage-tests--make-state 0 0)) + ;; Real observed ACP traffic from Opus 1M -> Sonnet 200k switch + (traffic '(;; On Opus 1M — normal + (32449 . 1000000) + ;; Switched to Sonnet — size drops to 200k + (60978 . 200000) + (122601 . 200000) + (209712 . 200000) + ;; used now exceeds size — server bug + (419574 . 200000)))) + (agent-shell-usage-tests--with-stub + ;; First update: normal, on Opus 1M + (agent-shell--update-usage-from-notification + :state agent-shell--state + :acp-update (list (cons 'used (caar traffic)) + (cons 'size (cdar traffic)))) + (let ((indicator (agent-shell--context-usage-indicator))) + (should (equal "▁" (substring-no-properties indicator))) + (should (equal 'success (get-text-property 0 'face indicator)))) + ;; Replay remaining updates + (dolist (pair (cdr traffic)) + (agent-shell--update-usage-from-notification + :state agent-shell--state + :acp-update (list (cons 'used (car pair)) + (cons 'size (cdr pair))))) + ;; Final state: used=419574 > size=200000 + (should (equal 419574 (map-elt (map-elt agent-shell--state :usage) :context-used))) + (should (equal 200000 (map-elt (map-elt agent-shell--state :usage) :context-size))) + ;; Indicator: ? with warning face (not a block character) + (let ((indicator (agent-shell--context-usage-indicator))) + (should (equal "?" (substring-no-properties indicator))) + (should (equal 'warning (get-text-property 0 'face indicator))))))) + +;; ============================================================ +;; Full compaction replay from observed ACP traffic +;; ============================================================ + +(ert-deftest agent-shell-usage--compaction-replay () + "Replay observed traffic: linear fill -> compaction -> refill." + (let ((agent-shell-show-context-usage-indicator t) + (agent-shell--state (agent-shell-usage-tests--make-state 0 0)) + (traffic '((48724 . 1000000) + (259218 . 1000000) + (494277 . 1000000) + (729572 . 1000000) + (870846 . 1000000) + (965200 . 1000000) ; pre-compaction peak + (24095 . 1000000) ; post-compaction drop + (74111 . 1000000) ; refilling + (262548 . 1000000)))) + (dolist (pair traffic) + (agent-shell--update-usage-from-notification + :state agent-shell--state + :acp-update (list (cons 'used (car pair)) + (cons 'size (cdr pair))))) + ;; Final state reflects last update + (should (equal 262548 (map-elt (map-elt agent-shell--state :usage) :context-used))) + (should (equal 1000000 (map-elt (map-elt agent-shell--state :usage) :context-size))) + ;; Indicator: green, ▂ for 26.3% + (agent-shell-usage-tests--with-stub + (let ((indicator (agent-shell--context-usage-indicator))) + (should (equal 'success (get-text-property 0 'face indicator))) + (should (equal "▂" (substring-no-properties indicator))))))) + +;; ============================================================ +;; agent-shell--save-usage (PromptResponse tokens) +;; ============================================================ + +(ert-deftest agent-shell-usage--save-usage-token-counts () + "PromptResponse usage updates token counts." + (let ((state (agent-shell-usage-tests--make-state 0 0))) + (agent-shell--save-usage + :state state + :acp-usage '((totalTokens . 5000) + (inputTokens . 3000) + (outputTokens . 2000) + (thoughtTokens . 500) + (cachedReadTokens . 1000) + (cachedWriteTokens . 200))) + (should (equal 5000 (map-elt (map-elt state :usage) :total-tokens))) + (should (equal 3000 (map-elt (map-elt state :usage) :input-tokens))) + (should (equal 2000 (map-elt (map-elt state :usage) :output-tokens))) + (should (equal 500 (map-elt (map-elt state :usage) :thought-tokens))) + (should (equal 1000 (map-elt (map-elt state :usage) :cached-read-tokens))) + (should (equal 200 (map-elt (map-elt state :usage) :cached-write-tokens))))) + +;; ============================================================ +;; agent-shell--format-number-compact +;; ============================================================ + +(ert-deftest agent-shell-usage--format-number-compact () + "Number formatting uses k/m/b suffixes." + (should (equal "42" (agent-shell--format-number-compact 42))) + (should (equal "1k" (agent-shell--format-number-compact 1000))) + (should (equal "24k" (agent-shell--format-number-compact 24095))) + (should (equal "965k" (agent-shell--format-number-compact 965200))) + (should (equal "1m" (agent-shell--format-number-compact 1000000))) + (should (equal "2b" (agent-shell--format-number-compact 2000000000)))) + +(provide 'agent-shell-usage-tests) +;;; agent-shell-usage-tests.el ends here