-
-
Notifications
You must be signed in to change notification settings - Fork 85
Add session/resume support for resuming previous sessions #248
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -432,6 +432,8 @@ HEARTBEAT, and AUTHENTICATE-REQUEST-MAKER." | |
| (cons :available-commands nil) | ||
| (cons :available-modes nil) | ||
| (cons :prompt-capabilities nil) | ||
| (cons :session-capabilities nil) | ||
| (cons :resume-session-id nil) | ||
| (cons :pending-requests nil))) | ||
|
|
||
| (defvar-local agent-shell--state | ||
|
|
@@ -737,10 +739,18 @@ Flow: | |
| (map-put! (agent-shell--state) :authenticated t) | ||
| (agent-shell--handle :command command :shell shell)))) | ||
| ((not (map-nested-elt (agent-shell--state) '(:session :id))) | ||
| (agent-shell--initiate-session | ||
| :shell shell | ||
| :on-session-init (lambda () | ||
| (agent-shell--handle :command command :shell shell)))) | ||
| (if (map-elt (agent-shell--state) :resume-session-id) | ||
| ;; Resume existing session | ||
| (agent-shell--resume-session | ||
| :shell shell | ||
| :session-id (map-elt (agent-shell--state) :resume-session-id) | ||
| :on-session-resumed (lambda () | ||
| (agent-shell--handle :command command :shell shell))) | ||
| ;; Create new session | ||
| (agent-shell--initiate-session | ||
| :shell shell | ||
| :on-session-init (lambda () | ||
| (agent-shell--handle :command command :shell shell))))) | ||
| ((and (map-nested-elt (agent-shell--state) '(:agent-config :default-model-id)) | ||
| (funcall (map-nested-elt (agent-shell--state) | ||
| '(:agent-config :default-model-id))) | ||
|
|
@@ -1757,13 +1767,15 @@ FUNCTION should be a function accepting keyword arguments (&key ...)." | |
| (list (car pair) (cdr pair))) | ||
| alist))) | ||
|
|
||
| (cl-defun agent-shell--start (&key config no-focus new-session) | ||
| (cl-defun agent-shell--start (&key config no-focus new-session resume-session-id cwd) | ||
| "Programmatically start shell with CONFIG. | ||
|
|
||
| See `agent-shell-make-agent-config' for config format. | ||
|
|
||
| Set NO-FOCUS to start in background. | ||
| Set NEW-SESSION to start a separate new session." | ||
| Set NEW-SESSION to start a separate new session. | ||
| Set RESUME-SESSION-ID to resume an existing session by ID. | ||
| Set CWD to override the working directory." | ||
| (unless (version<= "0.84.9" shell-maker-version) | ||
| (error "Please update shell-maker to version 0.84.9 or newer")) | ||
| (unless (version<= "0.8.3" acp-package-version) | ||
|
|
@@ -1777,7 +1789,7 @@ variable (see makunbound)")) | |
| :prompt (map-elt config :shell-prompt) | ||
| :prompt-regexp (map-elt config :shell-prompt-regexp))) | ||
| (agent-shell--shell-maker-config shell-maker-config) | ||
| (default-directory (agent-shell-cwd)) | ||
| (default-directory (or cwd (agent-shell-cwd))) | ||
| (shell-buffer | ||
| (shell-maker-start agent-shell--shell-maker-config | ||
| t ; Always use no-focus, handle display below | ||
|
|
@@ -1824,6 +1836,9 @@ variable (see makunbound)")) | |
| :needs-authentication (map-elt config :needs-authentication) | ||
| :authenticate-request-maker (map-elt config :authenticate-request-maker) | ||
| :agent-config config)) | ||
| ;; Set resume-session-id if provided | ||
| (when resume-session-id | ||
| (map-put! agent-shell--state :resume-session-id resume-session-id)) | ||
| ;; Initialize buffer-local shell-maker-config | ||
| (setq-local agent-shell--shell-maker-config shell-maker-config) | ||
| (agent-shell--update-header-and-mode-line) | ||
|
|
@@ -2492,6 +2507,16 @@ Must provide ON-INITIATED (lambda ())." | |
| (map-put! agent-shell--state :prompt-capabilities | ||
| (list (cons :image (map-elt prompt-capabilities 'image)) | ||
| (cons :embedded-context (map-elt prompt-capabilities 'embeddedContext))))) | ||
| ;; Save session capabilities from agent (resume, fork, list) | ||
| ;; Note: In ACP, capabilities like resume/fork/list are indicated by | ||
| ;; presence of the key with an empty object {}, which json-read parses | ||
| ;; as nil. So we check key existence, not value truthiness. | ||
| (when-let ((session-capabilities | ||
| (map-nested-elt response '(agentCapabilities sessionCapabilities)))) | ||
| (map-put! agent-shell--state :session-capabilities | ||
| (list (cons :resume (map-contains-key session-capabilities 'resume)) | ||
| (cons :fork (map-contains-key session-capabilities 'fork)) | ||
| (cons :list (map-contains-key session-capabilities 'list))))) | ||
| ;; Save available modes from agent, converting to internal symbols | ||
| (when-let ((modes (map-elt response 'modes))) | ||
| (map-put! agent-shell--state :available-modes | ||
|
|
@@ -2650,10 +2675,135 @@ Must provide ON-SESSION-INIT (lambda ())." | |
| :body (agent-shell--format-available-modes | ||
| (agent-shell--get-available-modes agent-shell--state)))) | ||
| (agent-shell--update-header-and-mode-line) | ||
| ;; Auto-save session for potential resumption | ||
| (when-let ((session-id (map-nested-elt agent-shell--state '(:session :id))) | ||
| (agent-id (map-nested-elt agent-shell--state '(:agent-config :identifier)))) | ||
| (agent-shell--save-session | ||
| :session-id session-id | ||
| :agent-identifier agent-id | ||
| :cwd (agent-shell-cwd))) | ||
| (funcall on-session-init)) | ||
| :on-failure (agent-shell--make-error-handler | ||
| :state agent-shell--state :shell shell))) | ||
|
|
||
| (cl-defun agent-shell--resume-session (&key shell session-id on-session-resumed) | ||
| "Resume an existing ACP session with SHELL. | ||
|
|
||
| SESSION-ID is the ID of the session to resume. | ||
| Must provide ON-SESSION-RESUMED (lambda ()). | ||
|
|
||
| This function first initializes the agent to discover its capabilities, | ||
| then attempts session/resume if supported, falling back to session/start | ||
| if not supported or if resume fails." | ||
| (unless on-session-resumed | ||
| (error "Missing required argument: :on-session-resumed")) | ||
| (unless session-id | ||
| (error "Missing required argument: :session-id")) | ||
| ;; First initialize the agent to discover capabilities | ||
| (agent-shell--initiate-handshake | ||
| :shell shell | ||
| :on-initiated | ||
| (lambda () | ||
| ;; Now check if agent supports session resume | ||
| (if (map-nested-elt (agent-shell--state) '(:session-capabilities :resume)) | ||
| (agent-shell--do-session-resume | ||
| :shell shell | ||
| :session-id session-id | ||
| :on-session-resumed on-session-resumed) | ||
| ;; Agent doesn't support session resume - fall back to new session | ||
| (agent-shell--update-fragment | ||
| :state agent-shell--state | ||
| :block-id "starting" | ||
| :body "\n\nAgent does not support session resume. Creating new session..." | ||
| :append t) | ||
| ;; Delete the stale saved session | ||
| (agent-shell--delete-saved-session session-id) | ||
| ;; Clear resume-session-id and create new session | ||
| (map-put! agent-shell--state :resume-session-id nil) | ||
| (agent-shell--initiate-session | ||
| :shell shell | ||
| :on-session-init on-session-resumed))))) | ||
|
|
||
| (cl-defun agent-shell--do-session-resume (&key shell session-id on-session-resumed) | ||
| "Perform the actual session/resume request. | ||
|
|
||
| SHELL is the shell instance. | ||
| SESSION-ID is the ID of the session to resume. | ||
| ON-SESSION-RESUMED is called on success. | ||
|
|
||
| This is an internal function called by `agent-shell--resume-session' | ||
| after agent initialization confirms resume capability." | ||
| (with-current-buffer (map-elt (agent-shell--state) :buffer) | ||
| (agent-shell--update-fragment | ||
| :state (agent-shell--state) | ||
| :block-id "starting" | ||
| :body "\n\nResuming session..." | ||
| :append t)) | ||
| (acp-send-request | ||
| :client (map-elt (agent-shell--state) :client) | ||
| :request (acp-make-session-resume-request | ||
| :session-id session-id | ||
| :cwd (agent-shell--resolve-path (agent-shell-cwd)) | ||
| :mcp-servers (agent-shell--mcp-servers)) | ||
| :buffer (current-buffer) | ||
| :on-success (lambda (response) | ||
| (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)))))) | ||
| (agent-shell--update-fragment | ||
| :state agent-shell--state | ||
| :block-id "starting" | ||
| :label-left (format "%s %s" | ||
| (agent-shell--status-label "completed") | ||
| (propertize "Resuming session" 'font-lock-face 'font-lock-doc-markup-face)) | ||
| :body "\n\nResumed" | ||
| :append t) | ||
| (agent-shell--update-header-and-mode-line) | ||
| ;; Update session access time | ||
| (agent-shell--update-session-access-time session-id) | ||
| (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-resumed)) | ||
| :on-failure (lambda (error &optional _message) | ||
| ;; On failure, fall back to creating a new session | ||
| (agent-shell--update-fragment | ||
| :state agent-shell--state | ||
| :block-id "starting" | ||
| :body (format "\n\nFailed to resume session: %s\nCreating new session..." | ||
| (or (map-elt error 'message) "Unknown error")) | ||
| :append t) | ||
| ;; Delete the stale saved session | ||
| (agent-shell--delete-saved-session session-id) | ||
| ;; Clear resume-session-id and create new session | ||
| (map-put! agent-shell--state :resume-session-id nil) | ||
| (agent-shell--initiate-session | ||
| :shell shell | ||
| :on-session-init on-session-resumed)))) | ||
|
|
||
| (defun agent-shell--mcp-servers () | ||
| "Return normalized MCP servers configuration for JSON serialization. | ||
|
|
||
|
|
@@ -4372,6 +4522,160 @@ Includes STATUS, TITLE, KIND, DESCRIPTION, COMMAND, and OUTPUT." | |
| (error "Transcript file does not exist: %s" agent-shell--transcript-file)) | ||
| (find-file agent-shell--transcript-file)) | ||
|
|
||
| ;;; Session Persistence | ||
|
|
||
| (defcustom agent-shell-session-persistence-enabled t | ||
| "Whether to persist session IDs for resumption. | ||
| When non-nil, session IDs are saved to disk and can be resumed later | ||
| using `agent-shell-resume-session'." | ||
| :type 'boolean | ||
| :group 'agent-shell) | ||
|
|
||
| (defcustom agent-shell-sessions-directory-function | ||
| #'agent-shell--default-sessions-directory | ||
| "Function to determine the directory for storing session files. | ||
| Called with no arguments, should return a directory path string." | ||
| :type 'function | ||
| :group 'agent-shell) | ||
|
|
||
| (defun agent-shell--default-sessions-directory () | ||
| "Return the default directory for storing session files. | ||
| Uses `.agent-shell/sessions' in the project root." | ||
| (expand-file-name ".agent-shell/sessions" (agent-shell-cwd))) | ||
|
|
||
| (defun agent-shell--session-file-path (session-id) | ||
| "Return the file path for SESSION-ID." | ||
| (let ((dir (funcall agent-shell-sessions-directory-function))) | ||
| (expand-file-name (concat session-id ".json") dir))) | ||
|
|
||
| (cl-defun agent-shell--save-session (&key session-id agent-identifier cwd) | ||
| "Save session metadata to disk. | ||
| SESSION-ID is the session identifier from the agent. | ||
| AGENT-IDENTIFIER is the symbol identifying the agent type. | ||
| CWD is the working directory for the session." | ||
| (when (and agent-shell-session-persistence-enabled session-id) | ||
| (let* ((dir (funcall agent-shell-sessions-directory-function)) | ||
| (filepath (agent-shell--session-file-path session-id)) | ||
| (metadata `((sessionId . ,session-id) | ||
| (agentIdentifier . ,(symbol-name agent-identifier)) | ||
| (cwd . ,cwd) | ||
| (createdAt . ,(format-time-string "%FT%T%z")) | ||
| (lastAccessedAt . ,(format-time-string "%FT%T%z"))))) | ||
| (condition-case err | ||
| (progn | ||
| (make-directory dir t) | ||
| (with-temp-file filepath | ||
| (insert (json-serialize metadata))) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should consider saving as a printed elisp data structure: (with-temp-file filepath
(prin1 metadata (current-buffer))) |
||
| (message "Session saved: %s" session-id)) | ||
| (error | ||
| (message "Failed to save session: %S" err)))))) | ||
|
|
||
| (defun agent-shell--load-session-metadata (session-id) | ||
| "Load session metadata for SESSION-ID from disk. | ||
| Returns an alist with session metadata or nil if not found." | ||
| (let ((filepath (agent-shell--session-file-path session-id))) | ||
| (when (file-exists-p filepath) | ||
| (condition-case nil | ||
| (with-temp-buffer | ||
| (insert-file-contents filepath) | ||
| (json-parse-buffer :object-type 'alist)) | ||
| (error nil))))) | ||
|
|
||
| (defun agent-shell--update-session-access-time (session-id) | ||
| "Update the lastAccessedAt timestamp for SESSION-ID." | ||
| (when-let ((metadata (agent-shell--load-session-metadata session-id))) | ||
| (let ((filepath (agent-shell--session-file-path session-id))) | ||
| (setf (alist-get 'lastAccessedAt metadata) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we use map.el here? https://github.com/xenodium/agent-shell?tab=readme-ov-file#mapel |
||
| (format-time-string "%FT%T%z")) | ||
| (condition-case nil | ||
| (with-temp-file filepath | ||
| (insert (json-serialize metadata))) | ||
| (error nil))))) | ||
|
|
||
| (defun agent-shell--list-saved-sessions () | ||
| "Return a list of saved session metadata alists. | ||
| Sessions are sorted by lastAccessedAt, most recent first." | ||
| (let ((dir (funcall agent-shell-sessions-directory-function))) | ||
| (when (file-directory-p dir) | ||
| (let ((sessions nil)) | ||
| (dolist (file (directory-files dir t "\\.json$")) | ||
| (condition-case nil | ||
| (with-temp-buffer | ||
| (insert-file-contents file) | ||
| (push (json-parse-buffer :object-type 'alist) sessions)) | ||
| (error nil))) | ||
| (sort sessions | ||
| (lambda (a b) | ||
| (string> (or (alist-get 'lastAccessedAt a) "") | ||
| (or (alist-get 'lastAccessedAt b) "")))))))) | ||
|
|
||
| (defun agent-shell--delete-saved-session (session-id) | ||
| "Delete the saved session file for SESSION-ID." | ||
| (let ((filepath (agent-shell--session-file-path session-id))) | ||
| (when (file-exists-p filepath) | ||
| (delete-file filepath)))) | ||
|
|
||
| (defun agent-shell--format-session-for-display (session) | ||
| "Format SESSION metadata for display in completion." | ||
| (let* ((id (alist-get 'sessionId session)) | ||
| (agent (alist-get 'agentIdentifier session)) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please prefer map.el here and elsewhere (ie. alist-get instances) is possible https://github.com/xenodium/agent-shell?tab=readme-ov-file#mapel |
||
| (created (alist-get 'createdAt session)) | ||
| (accessed (alist-get 'lastAccessedAt session))) | ||
| (format "%s (%s) - %s" | ||
| (truncate-string-to-width id 20 nil nil "...") | ||
| agent | ||
| (or accessed created "unknown")))) | ||
|
|
||
| (defun agent-shell--supports-session-resume-p () | ||
| "Return non-nil if current agent supports session/resume." | ||
| (map-nested-elt agent-shell--state '(:session-capabilities :resume))) | ||
|
|
||
| ;;;###autoload | ||
| (defun agent-shell-resume-session () | ||
| "Resume a previously saved session. | ||
| Prompts for a session to resume from the list of saved sessions." | ||
| (interactive) | ||
| (let ((sessions (agent-shell--list-saved-sessions))) | ||
| (unless sessions | ||
| (user-error "No saved sessions found")) | ||
| (let* ((choices (mapcar (lambda (s) | ||
| (cons (agent-shell--format-session-for-display s) s)) | ||
| sessions)) | ||
| (selection (completing-read "Resume session: " choices nil t)) | ||
| (session (cdr (assoc selection choices)))) | ||
| (unless session | ||
| (user-error "No session selected")) | ||
| (let* ((session-id (alist-get 'sessionId session)) | ||
| (agent-id (intern (alist-get 'agentIdentifier session))) | ||
| (cwd (alist-get 'cwd session)) | ||
| (config (seq-find (lambda (c) | ||
| (eq (map-elt c :identifier) agent-id)) | ||
| agent-shell-agent-configs))) | ||
| (unless config | ||
| (user-error "Agent configuration not found for: %s" agent-id)) | ||
| (agent-shell--start :config config | ||
| :resume-session-id session-id | ||
| :cwd cwd))))) | ||
|
|
||
| ;;;###autoload | ||
| (defun agent-shell-delete-saved-session () | ||
| "Delete a saved session from disk." | ||
| (interactive) | ||
| (let ((sessions (agent-shell--list-saved-sessions))) | ||
| (unless sessions | ||
| (user-error "No saved sessions found")) | ||
| (let* ((choices (mapcar (lambda (s) | ||
| (cons (agent-shell--format-session-for-display s) s)) | ||
| sessions)) | ||
| (selection (completing-read "Delete session: " choices nil t)) | ||
| (session (cdr (assoc selection choices)))) | ||
| (unless session | ||
| (user-error "No session selected")) | ||
| (when (y-or-n-p (format "Delete session %s? " | ||
| (alist-get 'sessionId session))) | ||
| (agent-shell--delete-saved-session (alist-get 'sessionId session)) | ||
| (message "Session deleted"))))) | ||
|
|
||
| ;;; Queueing | ||
|
|
||
| (cl-defun agent-shell--process-pending-request () | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we use self-evaluating symbols? That is start with
:. Also-please, like we do for state. https://github.com/xenodium/agent-shell?tab=readme-ov-file#maps-use-alists