diff --git a/README.org b/README.org index 267c3f7..e260694 100644 --- a/README.org +++ b/README.org @@ -600,6 +600,7 @@ always go to Evil modes if you need to with ~C-z~). | agent-shell-qwen-command | Command and parameters for the Qwen Code client. | | agent-shell-qwen-environment | Environment variables for the Qwen Code client. | | agent-shell-screenshot-command | The program to use for capturing screenshots. | +| agent-shell-session-load-strategy | How to choose existing sessions when session/list and session/load are available. | | agent-shell-section-functions | Abnormal hook run after overlays are applied (experimental). | | agent-shell-show-busy-indicator | Non-nil to show the busy indicator animation in the header and mode line. | | agent-shell-show-config-icons | Whether to show icons in agent config selection. | diff --git a/agent-shell-viewport.el b/agent-shell-viewport.el index 45a36ad..01da00f 100644 --- a/agent-shell-viewport.el +++ b/agent-shell-viewport.el @@ -744,6 +744,7 @@ For example, offer to kill associated shell session." (define-key map (kbd "C-c C-p") #'agent-shell-viewport-compose-peek-last) (define-key map (kbd "C-c C-k") #'agent-shell-viewport-compose-cancel) (define-key map (kbd "C-") #'agent-shell-viewport-cycle-session-mode) + (define-key map (kbd "C-c C-d") #'agent-shell-delete-session) (define-key map (kbd "C-c C-m") #'agent-shell-viewport-set-session-mode) (define-key map (kbd "C-c C-v") #'agent-shell-viewport-set-session-model) (define-key map (kbd "C-c C-o") #'agent-shell-other-buffer) @@ -775,6 +776,7 @@ For example, offer to kill associated shell session." (define-key map (kbd "r") #'agent-shell-viewport-reply) (define-key map (kbd "q") #'bury-buffer) (define-key map (kbd "C-") #'agent-shell-viewport-cycle-session-mode) + (define-key map (kbd "C-c C-d") #'agent-shell-delete-session) (define-key map (kbd "v") #'agent-shell-viewport-set-session-model) (define-key map (kbd "m") #'agent-shell-viewport-set-session-mode) (define-key map (kbd "o") #'agent-shell-other-buffer) diff --git a/agent-shell.el b/agent-shell.el index 3ea90ae..99a8d74 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -394,6 +394,19 @@ configuration alist for backwards compatibility." :key-type symbol :value-type sexp)) :group 'agent-shell) +(defcustom agent-shell-session-load-strategy 'latest + "How to choose an existing session when `session/list' and `session/load' are available. + +Available values: + + `latest': Load the latest session returned by `session/list'. + `prompt': Prompt to choose which session to load (or start a new one). + `new': Always start a new session and skip `session/list' and `session/load'." + :type '(choice (const :tag "Load latest session" latest) + (const :tag "Prompt for session" prompt) + (const :tag "Always start new session" new)) + :group 'agent-shell) + (defun agent-shell--resolve-preferred-config () "Resolve `agent-shell-preferred-agent-config' to a full configuration. @@ -454,7 +467,7 @@ Example configuration with multiple servers: Lambdas can be used anywhere in the configuration hierarchy for dynamic evaluation at session startup time. This is useful for values that depend on runtime context like the current working directory -(`agent-shell-cwd'). Note: only lambdas are evaluated, not named +\(`agent-shell-cwd'). Note: only lambdas are evaluated, not named functions, to avoid accidentally calling external symbols. For example, using the `claude-code-ide' package (see its documentation @@ -505,6 +518,9 @@ HEARTBEAT, and AUTHENTICATE-REQUEST-MAKER." (cons :tool-calls nil) (cons :available-commands nil) (cons :available-modes nil) + (cons :supports-session-list nil) + (cons :supports-session-load nil) + (cons :supports-session-delete nil) (cons :prompt-capabilities nil) (cons :pending-requests nil))) @@ -743,6 +759,7 @@ When FORCE is non-nil, skip confirmation prompt." "p" #'agent-shell-previous-item "C-" #'agent-shell-cycle-session-mode "C-c C-c" #'agent-shell-interrupt + "C-c C-d" #'agent-shell-delete-session "C-c C-m" #'agent-shell-set-session-mode "C-c C-v" #'agent-shell-set-session-model "C-c C-o" #'agent-shell-other-buffer) @@ -1766,7 +1783,7 @@ Returns propertized labels in :status and :title propertized." (agent-shell--status-label (map-elt entry 'status))) (lambda (entry) (map-elt entry 'content))) - :separator " " + :separator " " :joiner "\n")) (cl-defun agent-shell--make-button (&key text help kind action keymap) @@ -2604,13 +2621,23 @@ Must provide ON-INITIATED (lambda ())." (version . ,agent-shell--version)) :read-text-file-capability agent-shell-text-file-capabilities :write-text-file-capability agent-shell-text-file-capabilities) - :on-success (lambda (response) - (with-current-buffer (map-elt shell :buffer) - ;; Save prompt capabilities from agent, converting to internal symbols - (when-let ((prompt-capabilities - (map-nested-elt response '(agentCapabilities promptCapabilities)))) - (map-put! agent-shell--state :prompt-capabilities - (list (cons :image (map-elt prompt-capabilities 'image)) + :on-success (lambda (response) + (with-current-buffer (map-elt shell :buffer) + (let ((session-capabilities (or (map-elt response 'sessionCapabilities) + (map-nested-elt response '(agentCapabilities sessionCapabilities))))) + (map-put! agent-shell--state :supports-session-list + (and (listp session-capabilities) + (assq 'list session-capabilities) + t)) + (map-put! agent-shell--state :supports-session-delete + (and (listp session-capabilities) + (assq 'delete session-capabilities) + t))) + ;; Save prompt capabilities from agent, converting to internal symbols + (when-let ((prompt-capabilities + (map-nested-elt response '(agentCapabilities promptCapabilities)))) + (map-put! agent-shell--state :prompt-capabilities + (list (cons :image (map-elt prompt-capabilities 'image)) (cons :embedded-context (map-elt prompt-capabilities 'embeddedContext))))) ;; Save available modes from agent, converting to internal symbols (when-let ((modes (map-elt response 'modes))) @@ -2622,6 +2649,8 @@ Must provide ON-INITIATED (lambda ())." (:description . ,(map-elt mode 'description)))) (map-elt modes 'availableModes)))))) (when-let ((agent-capabilities (map-elt response 'agentCapabilities))) + (map-put! agent-shell--state :supports-session-load + (eq (map-elt agent-capabilities 'loadSession) t)) (agent-shell--update-fragment :state agent-shell--state :block-id "agent_capabilities" @@ -2725,6 +2754,270 @@ Must provide ON-SESSION-INIT (lambda ())." :block-id "starting" :body "\n\nCreating session..." :append t)) + (if (and (map-elt (agent-shell--state) :supports-session-list) + (map-elt (agent-shell--state) :supports-session-load) + (not (eq agent-shell-session-load-strategy 'new))) + (agent-shell--initiate-session-list-and-load + :shell shell + :on-session-init on-session-init) + (agent-shell--initiate-new-session + :shell shell + :on-session-init on-session-init))) + +(defun agent-shell--session-choice-label (session) + "Return completion label for SESSION." + (let* ((session-id (or (map-elt session 'sessionId) + "unknown-session")) + (title (or (map-elt session 'title) + "Untitled")) + (updated-at (or (map-elt session 'updatedAt) + (map-elt session 'createdAt) + "unknown-time"))) + (format "%s | %s | %s" title updated-at session-id))) + +(defconst agent-shell--start-new-session-choice "Start a new session" + "Label for creating a new session from the session picker.") + +(defun agent-shell--session-picker-sort (candidates) + "Return CANDIDATES with `agent-shell--start-new-session-choice' first." + (if (member agent-shell--start-new-session-choice candidates) + (cons agent-shell--start-new-session-choice + (delete agent-shell--start-new-session-choice + (copy-sequence candidates))) + candidates)) + +(defun agent-shell--prompt-select-session-to-load (sessions) + "Prompt to choose one from SESSIONS. + +Return selected session alist, or nil to start a new session." + (when sessions + (let* ((session-choices (mapcar (lambda (session) + (cons (agent-shell--session-choice-label session) + session)) + sessions)) + (choices (cons (cons agent-shell--start-new-session-choice nil) + session-choices)) + (completion-extra-properties + '(:display-sort-function agent-shell--session-picker-sort + :cycle-sort-function agent-shell--session-picker-sort)) + (selection (completing-read "Load session: " + (mapcar #'car choices) + nil t nil nil + agent-shell--start-new-session-choice))) + (cdr (assoc selection choices))))) + +(defun agent-shell--select-session-to-load (sessions) + "Select a session from SESSIONS based on `agent-shell-session-load-strategy'." + (pcase agent-shell-session-load-strategy + ('new nil) + ('latest (car sessions)) + ('prompt (if noninteractive + (car sessions) + (agent-shell--prompt-select-session-to-load sessions))) + (_ (car sessions)))) + +(defun agent-shell--prompt-select-session-to-delete (sessions) + "Prompt to choose one from SESSIONS for deletion. + +Return selected session alist, or nil if user quit." + (when sessions + (let* ((choices (mapcar (lambda (session) + (cons (agent-shell--session-choice-label session) + session)) + sessions)) + (selection (completing-read "Delete session: " + (mapcar #'car choices) + nil t))) + (cdr (assoc selection choices))))) + +(defun agent-shell--select-session-to-delete (sessions) + "Select a session from SESSIONS for deletion." + (if noninteractive + (car sessions) + (agent-shell--prompt-select-session-to-delete sessions))) + +(defun agent-shell--clear-session-state () + "Reset current session-scoped state for the active shell." + (let* ((state (agent-shell--state)) + (session (or (map-elt state :session) + (list (cons :id nil) + (cons :mode-id nil) + (cons :modes nil))))) + (map-put! session :id nil) + (map-put! session :mode-id nil) + (map-put! session :modes nil) + ;; Clear optional fields if they were previously populated. + (map-put! session :model-id nil) + (map-put! session :models nil) + (map-put! state :session session) + (map-put! state :set-session-mode nil) + (map-put! state :set-model nil) + (map-put! state :tool-calls nil) + (map-put! state :available-commands nil) + (agent-shell--update-header-and-mode-line))) + +(cl-defun agent-shell--delete-session-by-id (&key shell session-id on-success) + "Delete SESSION-ID via ACP using SHELL. + +ON-SUCCESS is called with no args after successful delete." + (unless session-id + (error "Missing required argument: :session-id")) + (with-current-buffer (map-elt (agent-shell--state) :buffer) + (agent-shell--update-fragment + :state (agent-shell--state) + :block-id "session_delete" + :label-left (propertize "Deleting session" 'font-lock-face 'font-lock-doc-markup-face) + :body (format "Requesting deletion for %s..." (substring-no-properties session-id)) + :append t)) + (acp-send-request + :client (map-elt (agent-shell--state) :client) + :request `((:method . "session/delete") + (:params . ((sessionId . ,session-id)))) + :buffer (current-buffer) + :on-success (lambda (_response) + (with-current-buffer (map-elt (agent-shell--state) :buffer) + (agent-shell--update-fragment + :state (agent-shell--state) + :block-id "session_delete" + :body "\n\nDone" + :append t)) + (when on-success + (funcall on-success))) + :on-failure (agent-shell--make-error-handler + :state (agent-shell--state) :shell shell))) + +(defun agent-shell-delete-session (&optional force-current) + "Delete an existing agent session from the agent's session history. + +This requires the agent to support the experimental ACP method +\"session/delete\". + +With prefix argument FORCE-CURRENT, delete the current session without +prompting for a session to pick (still asks for confirmation)." + (interactive "P") + (unless (or (derived-mode-p 'agent-shell-mode) + (derived-mode-p 'agent-shell-viewport-view-mode) + (derived-mode-p 'agent-shell-viewport-edit-mode)) + (user-error "Not in an agent-shell buffer")) + (let* ((shell-buffer (if (derived-mode-p 'agent-shell-mode) + (current-buffer) + (or (agent-shell-viewport--shell-buffer) + (user-error "No shell buffer available"))))) + (with-current-buffer shell-buffer + (unless (map-elt (agent-shell--state) :client) + (user-error "Agent not initialized")) + (unless (map-elt (agent-shell--state) :supports-session-delete) + (user-error "Agent does not support session/delete")) + (let* ((shell `((:buffer . ,(current-buffer)))) + (current-session-id (map-nested-elt (agent-shell--state) '(:session :id)))) + (cond + ((and force-current current-session-id) + (when (y-or-n-p (format "Delete current session %s? " + (substring-no-properties current-session-id))) + (agent-shell--delete-session-by-id + :shell shell + :session-id current-session-id + :on-success (lambda () + (agent-shell--clear-session-state) + (message "Deleted session %s" + (substring-no-properties current-session-id)))))) + ((map-elt (agent-shell--state) :supports-session-list) + (with-current-buffer (map-elt (agent-shell--state) :buffer) + (agent-shell--update-fragment + :state (agent-shell--state) + :block-id "session_delete" + :label-left (propertize "Deleting session" 'font-lock-face 'font-lock-doc-markup-face) + :body "\n\nLooking for existing sessions..." + :append t)) + (acp-send-request + :client (map-elt (agent-shell--state) :client) + :request `((:method . "session/list") + (:params . ((cwd . ,(agent-shell--resolve-path (agent-shell-cwd))))))) + :buffer (current-buffer) + :on-success (lambda (response) + (let* ((sessions (append (or (map-elt response 'sessions) '()) nil)) + (selected-session (agent-shell--select-session-to-delete sessions)) + (session-id (and selected-session + (map-elt selected-session 'sessionId)))) + (cond + ((not session-id) + (message "No session selected")) + ((not (y-or-n-p (format "Delete session %s? " + (substring-no-properties session-id)))) + (message "Cancelled")) + (t + (agent-shell--delete-session-by-id + :shell shell + :session-id session-id + :on-success (lambda () + (when (and current-session-id + (equal (substring-no-properties session-id) + (substring-no-properties current-session-id))) + (agent-shell--clear-session-state)) + (message "Deleted session %s" + (substring-no-properties session-id)))))))) + :on-failure (agent-shell--make-error-handler + :state (agent-shell--state) :shell shell))) + (current-session-id + (when (y-or-n-p (format "Delete current session %s? " + (substring-no-properties current-session-id))) + (agent-shell--delete-session-by-id + :shell shell + :session-id current-session-id + :on-success (lambda () + (agent-shell--clear-session-state) + (message "Deleted session %s" + (substring-no-properties current-session-id)))))) + (t + (user-error "No session to delete")))))) + +(cl-defun agent-shell--set-session-from-response (&key response session-id) + "Set active session state from RESPONSE and SESSION-ID." + (map-put! agent-shell--state + :session (list (cons :id session-id) + (cons :mode-id (map-nested-elt response '(modes currentModeId))) + (cons :modes (mapcar (lambda (mode) + `((:id . ,(map-elt mode 'id)) + (:name . ,(map-elt mode 'name)) + (:description . ,(map-elt mode 'description)))) + (map-nested-elt response '(modes availableModes)))) + (cons :model-id (map-nested-elt response '(models currentModelId))) + (cons :models (mapcar (lambda (model) + `((:model-id . ,(map-elt model 'modelId)) + (:name . ,(map-elt model 'name)) + (:description . ,(map-elt model 'description)))) + (map-nested-elt response '(models availableModels))))))) + +(cl-defun agent-shell--finalize-session-init (&key on-session-init) + "Finalize session initialization and invoke ON-SESSION-INIT." + (agent-shell--update-fragment + :state agent-shell--state + :block-id "starting" + :label-left (format "%s %s" + (agent-shell--status-label "completed") + (propertize "Starting agent" 'font-lock-face 'font-lock-doc-markup-face)) + :body "\n\nReady" + :append t) + (agent-shell--update-header-and-mode-line) + (when (map-nested-elt agent-shell--state '(:session :models)) + (agent-shell--update-fragment + :state agent-shell--state + :block-id "available_models" + :label-left (propertize "Available models" 'font-lock-face 'font-lock-doc-markup-face) + :body (agent-shell--format-available-models + (map-nested-elt agent-shell--state '(:session :models))))) + (when (agent-shell--get-available-modes agent-shell--state) + (agent-shell--update-fragment + :state agent-shell--state + :block-id "available_modes" + :label-left (propertize "Available modes" 'font-lock-face 'font-lock-doc-markup-face) + :body (agent-shell--format-available-modes + (agent-shell--get-available-modes agent-shell--state)))) + (agent-shell--update-header-and-mode-line) + (funcall on-session-init)) + +(cl-defun agent-shell--initiate-new-session (&key shell on-session-init) + "Initiate ACP session/new with SHELL and ON-SESSION-INIT." (acp-send-request :client (map-elt (agent-shell--state) :client) :request (acp-make-session-new-request @@ -2732,48 +3025,71 @@ Must provide ON-SESSION-INIT (lambda ())." :mcp-servers (agent-shell--mcp-servers)) :buffer (current-buffer) :on-success (lambda (response) - (map-put! agent-shell--state - :session (list (cons :id (map-elt response 'sessionId)) - (cons :mode-id (map-nested-elt response '(modes currentModeId))) - (cons :modes (mapcar (lambda (mode) - `((:id . ,(map-elt mode 'id)) - (:name . ,(map-elt mode 'name)) - (:description . ,(map-elt mode 'description)))) - (map-nested-elt response '(modes availableModes)))) - (cons :model-id (map-nested-elt response '(models currentModelId))) - (cons :models (mapcar (lambda (model) - `((:model-id . ,(map-elt model 'modelId)) - (:name . ,(map-elt model 'name)) - (:description . ,(map-elt model 'description)))) - (map-nested-elt response '(models availableModels)))))) - (agent-shell--update-fragment - :state agent-shell--state - :block-id "starting" - :label-left (format "%s %s" - (agent-shell--status-label "completed") - (propertize "Starting agent" 'font-lock-face 'font-lock-doc-markup-face)) - :body "\n\nReady" - :append t) - (agent-shell--update-header-and-mode-line) - (when (map-nested-elt agent-shell--state '(:session :models)) - (agent-shell--update-fragment - :state agent-shell--state - :block-id "available_models" - :label-left (propertize "Available models" 'font-lock-face 'font-lock-doc-markup-face) - :body (agent-shell--format-available-models - (map-nested-elt agent-shell--state '(:session :models))))) - (when (agent-shell--get-available-modes agent-shell--state) - (agent-shell--update-fragment - :state agent-shell--state - :block-id "available_modes" - :label-left (propertize "Available modes" 'font-lock-face 'font-lock-doc-markup-face) - :body (agent-shell--format-available-modes - (agent-shell--get-available-modes agent-shell--state)))) - (agent-shell--update-header-and-mode-line) - (funcall on-session-init)) + (agent-shell--set-session-from-response + :response response + :session-id (map-elt response 'sessionId)) + (agent-shell--finalize-session-init :on-session-init on-session-init)) :on-failure (agent-shell--make-error-handler :state agent-shell--state :shell shell))) +(cl-defun agent-shell--initiate-session-list-and-load (&key shell on-session-init) + "Try loading latest existing session with SHELL and ON-SESSION-INIT." + (with-current-buffer (map-elt (agent-shell--state) :buffer) + (agent-shell--update-fragment + :state (agent-shell--state) + :block-id "starting" + :body "\n\nLooking for existing sessions..." + :append t)) + (acp-send-request + :client (map-elt (agent-shell--state) :client) + :request `((:method . "session/list") + (:params . ((cwd . ,(agent-shell--resolve-path (agent-shell-cwd)))))) + :buffer (current-buffer) + :on-success (lambda (response) + (let* ((sessions (append (or (map-elt response 'sessions) '()) nil)) + (selected-session + (condition-case nil + (agent-shell--select-session-to-load sessions) + (quit nil))) + (session-id (and selected-session + (map-elt selected-session 'sessionId)))) + (if session-id + (progn + (agent-shell--update-fragment + :state (agent-shell--state) + :block-id "starting" + :body (format "\n\nLoading session %s..." + (substring-no-properties session-id)) + :append t) + (acp-send-request + :client (map-elt (agent-shell--state) :client) + :request `((:method . "session/load") + (:params . ((sessionId . ,session-id) + (cwd . ,(agent-shell--resolve-path (agent-shell-cwd))) + (mcpServers . ,(or (agent-shell--mcp-servers) []))))) + :buffer (current-buffer) + :on-success (lambda (load-response) + (agent-shell--set-session-from-response + :response load-response + :session-id session-id) + (agent-shell--finalize-session-init :on-session-init on-session-init)) + :on-failure (lambda (_error _raw-message) + (agent-shell--update-fragment + :state (agent-shell--state) + :block-id "starting" + :body "\n\nCould not load existing session. Creating a new one..." + :append t) + (agent-shell--initiate-new-session + :shell shell + :on-session-init on-session-init)))) + (agent-shell--initiate-new-session + :shell shell + :on-session-init on-session-init)))) + :on-failure (lambda (_error _raw-message) + (agent-shell--initiate-new-session + :shell shell + :on-session-init on-session-init)))) + (defun agent-shell--eval-dynamic-values (obj) "Recursively evaluate any lambda values in OBJ. Named functions (symbols) are not evaluated to avoid accidentally @@ -3815,7 +4131,9 @@ Returns an alist with insertion details or nil otherwise: ((:buffer . BUFFER) (:start . START) - (:end . END))" + (:end . END)) + +Uses optional SHELL-BUFFER to make paths relative to shell project." (if agent-shell-prefer-viewport-interaction (agent-shell-viewport--show-buffer :text text :submit submit :no-focus no-focus :shell-buffer shell-buffer) diff --git a/tests/agent-shell-tests.el b/tests/agent-shell-tests.el index 5da0064..91a111c 100644 --- a/tests/agent-shell-tests.el +++ b/tests/agent-shell-tests.el @@ -849,5 +849,261 @@ code block content with spaces (should (equal (buffer-string) "test ")) (should (equal (point) 6)))) +(ert-deftest agent-shell--initiate-session-prefers-list-and-load-when-supported () + "Test `agent-shell--initiate-session' prefers session/list + session/load." + (with-temp-buffer + (let* ((agent-shell-session-load-strategy 'latest) + (requests '()) + (session-init-called nil) + (state `((:buffer . ,(current-buffer)) + (:client . test-client) + (:session . ((:id . nil) + (:mode-id . nil) + (:modes . nil))) + (:supports-session-list . t) + (:supports-session-load . t)))) + (setq-local agent-shell--state state) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state)) + ((symbol-function 'agent-shell--update-fragment) + (lambda (&rest _args) nil)) + ((symbol-function 'agent-shell--update-header-and-mode-line) + (lambda () nil)) + ((symbol-function 'agent-shell-cwd) + (lambda () "/tmp")) + ((symbol-function 'agent-shell--resolve-path) + (lambda (path) path)) + ((symbol-function 'agent-shell--mcp-servers) + (lambda () [])) + ((symbol-function 'acp-send-request) + (lambda (&rest args) + (push args requests) + (let* ((request (plist-get args :request)) + (method (map-elt request :method))) + (pcase method + ("session/list" + (funcall (plist-get args :on-success) + '((sessions . [((sessionId . "session-123") + (cwd . "/tmp") + (title . "Recent session"))])))) + ("session/load" + (funcall (plist-get args :on-success) + '((modes (currentModeId . "default") + (availableModes . [((id . "default") + (name . "Default") + (description . "Default mode"))])) + (models (currentModelId . "gpt-5") + (availableModels . [((modelId . "gpt-5") + (name . "GPT-5") + (description . "Test model"))]))))) + (_ (error "Unexpected method: %s" method))))))) + (agent-shell--initiate-session + :shell `((:buffer . ,(current-buffer))) + :on-session-init (lambda () + (setq session-init-called t))) + (let ((ordered-requests (nreverse requests))) + (should (equal (mapcar (lambda (req) + (map-elt (plist-get req :request) :method)) + ordered-requests) + '("session/list" "session/load"))) + (let* ((load-request (plist-get (nth 1 ordered-requests) :request)) + (load-params (map-elt load-request :params))) + (should (equal (map-elt load-params 'sessionId) "session-123")) + (should (equal (map-elt load-params 'cwd) "/tmp")))) + (should session-init-called) + (should (equal (map-nested-elt agent-shell--state '(:session :id)) "session-123")))))) + +(ert-deftest agent-shell--initiate-session-falls-back-to-new-on-list-failure () + "Test `agent-shell--initiate-session' falls back to session/new on list failure." + (with-temp-buffer + (let* ((agent-shell-session-load-strategy 'latest) + (requests '()) + (session-init-called nil) + (state `((:buffer . ,(current-buffer)) + (:client . test-client) + (:session . ((:id . nil) + (:mode-id . nil) + (:modes . nil))) + (:supports-session-list . t) + (:supports-session-load . t)))) + (setq-local agent-shell--state state) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state)) + ((symbol-function 'agent-shell--update-fragment) + (lambda (&rest _args) nil)) + ((symbol-function 'agent-shell--update-header-and-mode-line) + (lambda () nil)) + ((symbol-function 'agent-shell-cwd) + (lambda () "/tmp")) + ((symbol-function 'agent-shell--resolve-path) + (lambda (path) path)) + ((symbol-function 'agent-shell--mcp-servers) + (lambda () [])) + ((symbol-function 'acp-send-request) + (lambda (&rest args) + (push args requests) + (let* ((request (plist-get args :request)) + (method (map-elt request :method))) + (pcase method + ("session/list" + (funcall (plist-get args :on-failure) + '((code . -32601) + (message . "Method not found")) + nil)) + ("session/new" + (funcall (plist-get args :on-success) + '((sessionId . "new-session-456")))) + (_ (error "Unexpected method: %s" method))))))) + (agent-shell--initiate-session + :shell `((:buffer . ,(current-buffer))) + :on-session-init (lambda () + (setq session-init-called t))) + (let ((ordered-requests (nreverse requests))) + (should (equal (mapcar (lambda (req) + (map-elt (plist-get req :request) :method)) + ordered-requests) + '("session/list" "session/new")))) + (should session-init-called) + (should (equal (map-nested-elt agent-shell--state '(:session :id)) "new-session-456")))))) + +(ert-deftest agent-shell--prompt-select-session-to-load-test () + "Test `agent-shell--prompt-select-session-to-load' choices." + (let* ((session-a '((sessionId . "session-1") + (title . "First") + (updatedAt . "2026-01-19T14:00:00Z"))) + (session-b '((sessionId . "session-2") + (title . "Second") + (updatedAt . "2026-01-20T16:00:00Z"))) + (sessions (list session-a session-b))) + ;; Select existing session + (cl-letf (((symbol-function 'completing-read) + (lambda (&rest _args) + (agent-shell--session-choice-label session-b)))) + (should (equal (agent-shell--prompt-select-session-to-load sessions) + session-b))) + ;; Select "new session" option + (cl-letf (((symbol-function 'completing-read) + (lambda (&rest _args) + agent-shell--start-new-session-choice))) + (should-not (agent-shell--prompt-select-session-to-load sessions))))) + +(ert-deftest agent-shell--session-picker-sort-test () + "Test `agent-shell--session-picker-sort' keeps the new-session option first." + (let* ((session-a-label "First | 2026-01-19T14:00:00Z | session-1") + (session-b-label "Second | 2026-01-20T16:00:00Z | session-2") + (candidates (list session-a-label + agent-shell--start-new-session-choice + session-b-label))) + (should (equal (agent-shell--session-picker-sort candidates) + (list agent-shell--start-new-session-choice + session-a-label + session-b-label))))) + +(ert-deftest agent-shell--prompt-select-session-to-load-defaults-to-new-session-test () + "Test prompt defaults to `agent-shell--start-new-session-choice'." + (let* ((session-a '((sessionId . "session-1") + (title . "First") + (updatedAt . "2026-01-19T14:00:00Z"))) + (session-b '((sessionId . "session-2") + (title . "Second") + (updatedAt . "2026-01-20T16:00:00Z"))) + (sessions (list session-a session-b)) + (captured-default nil)) + (cl-letf (((symbol-function 'completing-read) + (lambda (&rest args) + (setq captured-default (nth 6 args)) + agent-shell--start-new-session-choice))) + (agent-shell--prompt-select-session-to-load sessions) + (should (equal captured-default agent-shell--start-new-session-choice))))) + +(ert-deftest agent-shell--initiate-session-strategy-new-skips-list-load () + "Test `agent-shell--initiate-session' skips list/load when strategy is `new'." + (with-temp-buffer + (let* ((agent-shell-session-load-strategy 'new) + (requests '()) + (session-init-called nil) + (state `((:buffer . ,(current-buffer)) + (:client . test-client) + (:session . ((:id . nil) + (:mode-id . nil) + (:modes . nil))) + (:supports-session-list . t) + (:supports-session-load . t)))) + (setq-local agent-shell--state state) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state)) + ((symbol-function 'agent-shell--update-fragment) + (lambda (&rest _args) nil)) + ((symbol-function 'agent-shell--update-header-and-mode-line) + (lambda () nil)) + ((symbol-function 'agent-shell-cwd) + (lambda () "/tmp")) + ((symbol-function 'agent-shell--resolve-path) + (lambda (path) path)) + ((symbol-function 'agent-shell--mcp-servers) + (lambda () [])) + ((symbol-function 'acp-send-request) + (lambda (&rest args) + (push args requests) + (let* ((request (plist-get args :request)) + (method (map-elt request :method))) + (pcase method + ("session/new" + (funcall (plist-get args :on-success) + '((sessionId . "new-session-789")))) + (_ (error "Unexpected method: %s" method))))))) + (agent-shell--initiate-session + :shell `((:buffer . ,(current-buffer))) + :on-session-init (lambda () + (setq session-init-called t))) + (let ((ordered-requests (nreverse requests))) + (should (equal (mapcar (lambda (req) + (map-elt (plist-get req :request) :method)) + ordered-requests) + '("session/new")))) + (should session-init-called) + (should (equal (map-nested-elt agent-shell--state '(:session :id)) "new-session-789")))))) + +(ert-deftest agent-shell--delete-session-by-id-sends-session-delete () + "Test `agent-shell--delete-session-by-id' sends session/delete request." + (with-temp-buffer + (let* ((requests '()) + (success-called nil) + (state `((:buffer . ,(current-buffer)) + (:client . test-client) + (:session . ((:id . "session-2") + (:mode-id . nil) + (:modes . nil))) + (:supports-session-list . t) + (:supports-session-delete . t)))) + (setq-local agent-shell--state state) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state)) + ((symbol-function 'agent-shell--update-fragment) + (lambda (&rest _args) nil)) + ((symbol-function 'agent-shell--update-header-and-mode-line) + (lambda () nil)) + ((symbol-function 'acp-send-request) + (lambda (&rest args) + (push args requests) + (let* ((request (plist-get args :request)) + (method (map-elt request :method))) + (pcase method + ("session/delete" + (let ((params (map-elt request :params))) + (should (equal (map-elt params 'sessionId) "session-2"))) + (funcall (plist-get args :on-success) '((ok . t)))) + (_ (error "Unexpected method: %s" method))))))) + (agent-shell--delete-session-by-id + :shell `((:buffer . ,(current-buffer))) + :session-id "session-2" + :on-success (lambda () (setq success-called t))) + (should success-called) + (let ((ordered-requests (nreverse requests))) + (should (equal (mapcar (lambda (req) + (map-elt (plist-get req :request) :method)) + ordered-requests) + '("session/delete")))))))) + (provide 'agent-shell-tests) ;;; agent-shell-tests.el ends here