From 9496218c129ace4c9abcce5389827ca4eca02d64 Mon Sep 17 00:00:00 2001 From: John Buckley Date: Mon, 18 May 2026 21:09:14 +0100 Subject: [PATCH 1/2] Add `agent-shell-restore-context` defcustom --- README.org | 2 +- agent-shell.el | 242 +++++++++++++++++++++++++++++-------- tests/agent-shell-tests.el | 126 +++++++++++++++++++ 3 files changed, 321 insertions(+), 49 deletions(-) diff --git a/README.org b/README.org index 8ffe6f0e..42caf8fd 100644 --- a/README.org +++ b/README.org @@ -797,12 +797,12 @@ always go to Evil modes if you need to with ~C-z~). | agent-shell-permission-icon | Icon displayed when shell commands require permission to execute. | | agent-shell-pi-acp-command | Command and parameters for the Pi ACP client. | | agent-shell-pi-environment | Environment variables for the Pi client. | -| agent-shell-prefer-session-resume | Prefer ACP session resume over session load when both are available. | | agent-shell-prefer-viewport-interaction | Non-nil makes ‘agent-shell’ prefer viewport interaction over shell interaction. | | agent-shell-preferred-agent-config | Default agent to use for all new shells. | | agent-shell-qwen-acp-command | Command and parameters for the Qwen Code client. | | agent-shell-qwen-authentication | Configuration for Qwen Code authentication. | | agent-shell-qwen-environment | Environment variables for the Qwen Code client. | +| agent-shell-restore-context | How much prior context to show when restoring a session. | | agent-shell-screenshot-command | The program to use for capturing screenshots. | | agent-shell-section-functions | Abnormal hook run after overlays are applied (experimental). | | agent-shell-session-strategy | How to handle sessions when starting a new shell. | diff --git a/agent-shell.el b/agent-shell.el index 074eaa6a..67c90f14 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -573,6 +573,30 @@ When non-nil (and supported by agent), prefer ACP session resumes over loading." :type 'boolean :group 'agent-shell) +(make-obsolete-variable 'agent-shell-prefer-session-resume + 'agent-shell-restore-context + "agent-shell 0.52") + +(defcustom agent-shell-restore-context 'minimal + "How much prior context to show when restoring a session. + + `minimal': Show only the session title (default). Uses + `session/resume' when supported (no message replay), + so restore is fast and quiet. + `summary': Use `session/load' and, when the replay completes, + render the initial user prompt and the last agent + text response. Other notifications (tool calls, + thoughts) are suppressed during restore. + `full': Use `session/load' and replay the entire conversation. + +`summary' and `full' both require the agent to advertise +`session/load' support. When unavailable, restore falls back +to `minimal' behavior." + :type '(choice (const :tag "Title only (minimal)" minimal) + (const :tag "First prompt + last response (summary)" summary) + (const :tag "Full replay" full)) + :group 'agent-shell) + (defcustom agent-shell-session-strategy 'prompt "How to handle sessions when starting a new shell. @@ -759,6 +783,7 @@ OUTGOING-REQUEST-DECORATOR (passed through to `acp-make-client')." (cons :supports-session-fork nil) (cons :resume-session-id nil) (cons :fork-session-id nil) + (cons :restore-summary nil) (cons :prompt-capabilities nil) (cons :event-subscriptions nil) (cons :idle-timer nil) @@ -1538,6 +1563,11 @@ COMMAND, when present, may be a shell command string or an argv vector." (map-put! state :last-activity-time (current-time)) (cond ((equal (map-elt acp-notification 'method) "session/update") (cond + ;; Restore-summary mode: buffer chunks during session/load + ;; and suppress normal rendering. The summary fragments are + ;; emitted once the load completes. + ((map-elt state :restore-summary) + (agent-shell--restore-summary-handle-notification state acp-notification)) ((equal (map-nested-elt acp-notification '(params update sessionUpdate)) "tool_call") ;; Notification is out of context (session/prompt finished). ;; Cannot derive where to display, so show in minibuffer. @@ -4661,6 +4691,118 @@ Falls back to latest session in batch mode (e.g. tests)." :on-failure (agent-shell--make-error-handler :state agent-shell--state :shell-buffer shell-buffer))) +(defun agent-shell--use-session-load-p (state) + "Return non-nil when STATE should restore via `session/load'. + +`agent-shell-restore-context' decides the protocol: + + `summary' and `full' force `session/load' when the agent + advertises it (so a replay is available to read from); they + fall back to `session/resume' otherwise. + + `minimal' uses `session/resume' when available, falling back + to `session/load' only if the agent doesn't support resume." + (cond + ((and (memq agent-shell-restore-context '(summary full)) + (map-elt state :supports-session-load)) + t) + ((map-elt state :supports-session-resume) + nil) + (t + (map-elt state :supports-session-load)))) + +(defun agent-shell--restore-summary-mode-p (state) + "Return non-nil when STATE should accumulate a restore summary. + +Only true when `agent-shell-restore-context' is `summary' and the +agent supports `session/load' (so a replay is available to read +from)." + (and (eq agent-shell-restore-context 'summary) + (map-elt state :supports-session-load))) + +(defun agent-shell--restore-summary-init (state) + "Initialize the restore-summary accumulator on STATE." + (map-put! state :restore-summary + (list (cons :current-kind nil) + (cons :current-text nil) + (cons :first-user nil) + (cons :last-agent nil)))) + +(defun agent-shell--restore-summary-commit-in-flight (summary) + "Commit the in-flight chunk of SUMMARY to first-user or last-agent. + +The first user message is preserved across commits. The agent +message is overwritten on each commit so the latest reply wins." + (let ((kind (map-elt summary :current-kind)) + (text (map-elt summary :current-text))) + (when (and kind text (not (string-empty-p text))) + (pcase kind + ('user + (unless (map-elt summary :first-user) + (map-put! summary :first-user text))) + ('agent + (map-put! summary :last-agent text)))) + (map-put! summary :current-kind nil) + (map-put! summary :current-text nil))) + +(defun agent-shell--restore-summary-append (summary kind text) + "Append TEXT to SUMMARY's in-flight chunk, switching to KIND if needed. + +KIND is `user' or `agent'. When KIND differs from the current +in-flight kind, the previous chunk is committed first." + (unless (eq (map-elt summary :current-kind) kind) + (agent-shell--restore-summary-commit-in-flight summary) + (map-put! summary :current-kind kind) + (map-put! summary :current-text "")) + (map-put! summary :current-text + (concat (map-elt summary :current-text) text))) + +(defun agent-shell--restore-summary-handle-notification (state acp-notification) + "Route ACP-NOTIFICATION into STATE's restore-summary accumulator. + +`user_message_chunk' and `agent_message_chunk' contribute text; +any other `session/update' commits the in-flight chunk so the +boundary between consecutive same-kind chunks is preserved." + (let* ((summary (map-elt state :restore-summary)) + (update-type (map-nested-elt acp-notification '(params update sessionUpdate))) + (text (or (map-nested-elt acp-notification '(params update content text)) + (format "[%s]" (or (map-nested-elt acp-notification '(params update content type)) + "unknown"))))) + (pcase update-type + ("user_message_chunk" + (agent-shell--restore-summary-append summary 'user text)) + ("agent_message_chunk" + (agent-shell--restore-summary-append summary 'agent text)) + (_ + (agent-shell--restore-summary-commit-in-flight summary))))) + +(defun agent-shell--render-restore-summary (state) + "Render the accumulated restore-summary fragments from STATE. + +Adds an `Initial prompt' fragment for the first user message and +a `Last response' fragment for the latest agent text reply, then +clears the summary state. Does nothing if neither was captured." + (when-let ((summary (map-elt state :restore-summary))) + (agent-shell--restore-summary-commit-in-flight summary) + (when-let ((text (map-elt summary :first-user))) + (agent-shell--update-fragment + :state state + :namespace-id "bootstrapping" + :block-id "restore_summary_first_user" + :label-left (propertize "Initial prompt" 'font-lock-face 'font-lock-doc-markup-face) + :body text + :expanded t)) + (when-let ((text (map-elt summary :last-agent))) + (agent-shell--update-fragment + :state state + :namespace-id "bootstrapping" + :block-id "restore_summary_last_agent" + :label-left (propertize "Last response" 'font-lock-face 'font-lock-doc-markup-face) + :body text + :expanded t + :render-body-images t)) + (map-put! state :restore-summary nil))) + (cl-defun agent-shell--initiate-session-resume-by-id (&key session-id session-title shell-buffer on-session-init) "Resume or load session SESSION-ID with SHELL-BUFFER and ON-SESSION-INIT. @@ -4671,49 +4813,51 @@ SESSION-TITLE is an optional display title for the resumed session." :block-id "starting" :body (format "\n\nLoading session %s..." session-id) :append t) - (agent-shell--send-request - :state (agent-shell--state) - :client (map-elt (agent-shell--state) :client) - :request (let ((cwd (agent-shell--resolve-path (agent-shell-cwd))) - (mcp-servers (agent-shell--mcp-servers))) - (let ((use-resume (if agent-shell-prefer-session-resume - (map-elt (agent-shell--state) :supports-session-resume) - (not (map-elt (agent-shell--state) :supports-session-load))))) - (if use-resume - (acp-make-session-resume-request + (let ((use-load (agent-shell--use-session-load-p (agent-shell--state)))) + (when (and use-load (agent-shell--restore-summary-mode-p (agent-shell--state))) + (agent-shell--restore-summary-init (agent-shell--state))) + (agent-shell--send-request + :state (agent-shell--state) + :client (map-elt (agent-shell--state) :client) + :request (let ((cwd (agent-shell--resolve-path (agent-shell-cwd))) + (mcp-servers (agent-shell--mcp-servers))) + (if use-load + (acp-make-session-load-request :session-id session-id :cwd cwd :mcp-servers mcp-servers) - (acp-make-session-load-request + (acp-make-session-resume-request :session-id session-id :cwd cwd - :mcp-servers mcp-servers)))) - :buffer (current-buffer) - :on-success (lambda (acp-load-response) - (agent-shell--set-session-from-response - :acp-response acp-load-response - :acp-session-id session-id) - (agent-shell--update-fragment - :state (agent-shell--state) - :namespace-id "bootstrapping" - :block-id "resumed_session" - :label-left (format "%s %s" - (agent-shell--make-status-kind-label :status "completed") - (propertize "Resuming session" 'font-lock-face 'font-lock-doc-markup-face)) - :expanded t - :body (or session-title session-id "")) - (agent-shell--finalize-session-init :on-session-init on-session-init)) - :on-failure (lambda (_acp-error _raw-message) - (message "Couldn't resume session. Starting a new one.") - (agent-shell--update-fragment - :state (agent-shell--state) - :namespace-id "bootstrapping" - :block-id "starting" - :body "\n\nCouldn't resume session." - :append t) - (agent-shell--initiate-session-list-and-load - :shell-buffer shell-buffer - :on-session-init on-session-init)))) + :mcp-servers mcp-servers))) + :buffer (current-buffer) + :on-success (lambda (acp-load-response) + (agent-shell--set-session-from-response + :acp-response acp-load-response + :acp-session-id session-id) + (agent-shell--update-fragment + :state (agent-shell--state) + :namespace-id "bootstrapping" + :block-id "resumed_session" + :label-left (format "%s %s" + (agent-shell--make-status-kind-label :status "completed") + (propertize "Resuming session" 'font-lock-face 'font-lock-doc-markup-face)) + :expanded t + :body (or session-title session-id "")) + (agent-shell--render-restore-summary (agent-shell--state)) + (agent-shell--finalize-session-init :on-session-init on-session-init)) + :on-failure (lambda (_acp-error _raw-message) + (map-put! (agent-shell--state) :restore-summary nil) + (message "Couldn't resume session. Starting a new one.") + (agent-shell--update-fragment + :state (agent-shell--state) + :namespace-id "bootstrapping" + :block-id "starting" + :body "\n\nCouldn't resume session." + :append t) + (agent-shell--initiate-session-list-and-load + :shell-buffer shell-buffer + :on-session-init on-session-init))))) (cl-defun agent-shell--initiate-session-fork-by-id (&key session-id shell-buffer on-session-init) "Fork session SESSION-ID with SHELL-BUFFER and ON-SESSION-INIT." @@ -4790,30 +4934,30 @@ SESSION-TITLE is an optional display title for the resumed session." :event 'session-selected :data (list (cons :session-id acp-session-id))) (if acp-session-id - (progn + (let ((use-load (agent-shell--use-session-load-p (agent-shell--state)))) (agent-shell--update-fragment :state (agent-shell--state) :namespace-id "bootstrapping" :block-id "starting" :body (format "\n\nLoading session %s..." acp-session-id) :append t) + (when (and use-load + (agent-shell--restore-summary-mode-p (agent-shell--state))) + (agent-shell--restore-summary-init (agent-shell--state))) (agent-shell--send-request :state (agent-shell--state) :client (map-elt (agent-shell--state) :client) :request (let ((cwd (agent-shell--resolve-path (agent-shell-cwd))) (mcp-servers (agent-shell--mcp-servers))) - (let ((use-resume (if agent-shell-prefer-session-resume - (map-elt (agent-shell--state) :supports-session-resume) - (not (map-elt (agent-shell--state) :supports-session-load))))) - (if use-resume - (acp-make-session-resume-request - :session-id acp-session-id - :cwd cwd - :mcp-servers mcp-servers) + (if use-load (acp-make-session-load-request :session-id acp-session-id :cwd cwd - :mcp-servers mcp-servers)))) + :mcp-servers mcp-servers) + (acp-make-session-resume-request + :session-id acp-session-id + :cwd cwd + :mcp-servers mcp-servers))) :buffer (current-buffer) :on-success (lambda (acp-load-response) (agent-shell--set-session-from-response @@ -4828,8 +4972,10 @@ SESSION-TITLE is an optional display title for the resumed session." (propertize "Resuming session" 'font-lock-face 'font-lock-doc-markup-face)) :expanded t :body (or (map-elt acp-session 'title) "")) + (agent-shell--render-restore-summary (agent-shell--state)) (agent-shell--finalize-session-init :on-session-init on-session-init)) :on-failure (lambda (_acp-error _raw-message) + (map-put! (agent-shell--state) :restore-summary nil) (agent-shell--update-fragment :state (agent-shell--state) :namespace-id "bootstrapping" diff --git a/tests/agent-shell-tests.el b/tests/agent-shell-tests.el index ab9c106b..11b1367e 100644 --- a/tests/agent-shell-tests.el +++ b/tests/agent-shell-tests.el @@ -2675,5 +2675,131 @@ and it must handle that cleanly." (let ((result (agent-shell--filter-buffer-substring (point-min) (point-max)))) (should (equal result "Use foo-bar for that."))))) +(defun agent-shell-tests--make-session-update (kind text) + "Build a fake `session/update' notification of KIND with TEXT. +KIND is a sessionUpdate string such as \"user_message_chunk\"." + `((method . "session/update") + (params . ((update . ((sessionUpdate . ,kind) + (content . ((type . "text") + (text . ,text)))))))) +) + +(ert-deftest agent-shell--restore-summary-picks-first-user-and-last-agent () + "Test summary accumulation keeps first user prompt and last agent reply." + (let ((state (list (cons :restore-summary nil)))) + (agent-shell--restore-summary-init state) + (dolist (notif (list + (agent-shell-tests--make-session-update "user_message_chunk" "Hello ") + (agent-shell-tests--make-session-update "user_message_chunk" "world") + (agent-shell-tests--make-session-update "agent_message_chunk" "Hi ") + (agent-shell-tests--make-session-update "agent_message_chunk" "there") + (agent-shell-tests--make-session-update "user_message_chunk" "second prompt") + (agent-shell-tests--make-session-update "agent_message_chunk" "intermediate") + (agent-shell-tests--make-session-update "tool_call" "ignored") + (agent-shell-tests--make-session-update "agent_message_chunk" "final answer"))) + (agent-shell--restore-summary-handle-notification state notif)) + (agent-shell--restore-summary-commit-in-flight + (map-elt state :restore-summary)) + (should (equal (map-elt (map-elt state :restore-summary) :first-user) + "Hello world")) + (should (equal (map-elt (map-elt state :restore-summary) :last-agent) + "final answer")))) + +(ert-deftest agent-shell--restore-summary-handles-non-text-content () + "Test summary accumulator falls back to a placeholder for non-text content." + (let ((state (list (cons :restore-summary nil)))) + (agent-shell--restore-summary-init state) + (agent-shell--restore-summary-handle-notification + state + '((method . "session/update") + (params . ((update . ((sessionUpdate . "user_message_chunk") + (content . ((type . "image"))))))))) + (agent-shell--restore-summary-commit-in-flight + (map-elt state :restore-summary)) + (should (equal (map-elt (map-elt state :restore-summary) :first-user) + "[image]")))) + +(ert-deftest agent-shell--use-session-load-p-modes () + "Test `agent-shell--use-session-load-p' across context/protocol combinations." + ;; summary mode forces session/load when supported + (let ((agent-shell-restore-context 'summary)) + (should (agent-shell--use-session-load-p + '((:supports-session-load . t) + (:supports-session-resume . t)))) + ;; summary falls back to resume when load unsupported + (should-not (agent-shell--use-session-load-p + '((:supports-session-load . nil) + (:supports-session-resume . t))))) + ;; full mode forces session/load when supported + (let ((agent-shell-restore-context 'full)) + (should (agent-shell--use-session-load-p + '((:supports-session-load . t) + (:supports-session-resume . t))))) + ;; minimal mode prefers resume when available + (let ((agent-shell-restore-context 'minimal)) + (should-not (agent-shell--use-session-load-p + '((:supports-session-load . t) + (:supports-session-resume . t)))) + ;; minimal falls back to load when resume unavailable + (should (agent-shell--use-session-load-p + '((:supports-session-load . t) + (:supports-session-resume . nil)))))) + +(ert-deftest agent-shell--initiate-session-summary-mode-uses-session-load () + "Test that `summary' mode bypasses `session/resume' in favor of `session/load'." + (with-temp-buffer + (let* ((agent-shell-session-strategy 'latest) + (agent-shell-restore-context 'summary) + (requests '()) + (session-init-called nil) + (state (list (cons :buffer (current-buffer)) + (cons :client 'test-client) + (cons :session (list (cons :id nil) + (cons :mode-id nil) + (cons :modes nil))) + (cons :supports-session-list t) + (cons :supports-session-load t) + (cons :supports-session-resume t) + (cons :restore-summary nil) + (cons :active-requests nil) + (cons :event-subscriptions nil)))) + (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-abc") + (cwd . "/tmp") + (title . "Some session"))])))) + ("session/load" + (funcall (plist-get args :on-success) '())) + (_ (error "Unexpected method: %s" method))))))) + (agent-shell--initiate-session + :shell-buffer (current-buffer) + :on-session-init (lambda () + (setq session-init-called t))) + (should (equal (mapcar (lambda (req) + (map-elt (plist-get req :request) :method)) + (nreverse requests)) + '("session/list" "session/load"))) + (should session-init-called) + (should-not (map-elt agent-shell--state :restore-summary)))))) + (provide 'agent-shell-tests) ;;; agent-shell-tests.el ends here From b5870f031340f22937a7fb365f801db785eefb44 Mon Sep 17 00:00:00 2001 From: John Buckley Date: Thu, 28 May 2026 21:41:02 +0100 Subject: [PATCH 2/2] Rename `agent-shell-restore-context` -> `agent-shell-session-restore-strategy` --- README.org | 2 +- agent-shell.el | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.org b/README.org index 42caf8fd..cff1117b 100644 --- a/README.org +++ b/README.org @@ -802,7 +802,7 @@ always go to Evil modes if you need to with ~C-z~). | agent-shell-qwen-acp-command | Command and parameters for the Qwen Code client. | | agent-shell-qwen-authentication | Configuration for Qwen Code authentication. | | agent-shell-qwen-environment | Environment variables for the Qwen Code client. | -| agent-shell-restore-context | How much prior context to show when restoring a session. | +| agent-shell-session-restore-strategy | How much prior context to show when restoring a session. | | agent-shell-screenshot-command | The program to use for capturing screenshots. | | agent-shell-section-functions | Abnormal hook run after overlays are applied (experimental). | | agent-shell-session-strategy | How to handle sessions when starting a new shell. | diff --git a/agent-shell.el b/agent-shell.el index 67c90f14..17d3b2af 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -574,26 +574,26 @@ When non-nil (and supported by agent), prefer ACP session resumes over loading." :group 'agent-shell) (make-obsolete-variable 'agent-shell-prefer-session-resume - 'agent-shell-restore-context + 'agent-shell-session-restore-strategy "agent-shell 0.52") -(defcustom agent-shell-restore-context 'minimal +(defcustom agent-shell-session-restore-strategy 'minimal "How much prior context to show when restoring a session. `minimal': Show only the session title (default). Uses `session/resume' when supported (no message replay), so restore is fast and quiet. - `summary': Use `session/load' and, when the replay completes, - render the initial user prompt and the last agent + `first-last': Use `session/load', when the replay completes, + render the first user prompt and the last agent text response. Other notifications (tool calls, thoughts) are suppressed during restore. `full': Use `session/load' and replay the entire conversation. -`summary' and `full' both require the agent to advertise +`first-last' and `full' both require the agent to advertise `session/load' support. When unavailable, restore falls back to `minimal' behavior." :type '(choice (const :tag "Title only (minimal)" minimal) - (const :tag "First prompt + last response (summary)" summary) + (const :tag "First prompt + last response (first-last)" first-last) (const :tag "Full replay" full)) :group 'agent-shell) @@ -4694,16 +4694,16 @@ Falls back to latest session in batch mode (e.g. tests)." (defun agent-shell--use-session-load-p (state) "Return non-nil when STATE should restore via `session/load'. -`agent-shell-restore-context' decides the protocol: +`agent-shell-session-restore-strategy' decides the protocol: - `summary' and `full' force `session/load' when the agent + `first-last' and `full' force `session/load' when the agent advertises it (so a replay is available to read from); they fall back to `session/resume' otherwise. `minimal' uses `session/resume' when available, falling back to `session/load' only if the agent doesn't support resume." (cond - ((and (memq agent-shell-restore-context '(summary full)) + ((and (memq agent-shell-session-restore-strategy '(first-last full)) (map-elt state :supports-session-load)) t) ((map-elt state :supports-session-resume) @@ -4714,10 +4714,10 @@ Falls back to latest session in batch mode (e.g. tests)." (defun agent-shell--restore-summary-mode-p (state) "Return non-nil when STATE should accumulate a restore summary. -Only true when `agent-shell-restore-context' is `summary' and the +Only true when `agent-shell-session-restore-strategy' is `first-last' and the agent supports `session/load' (so a replay is available to read from)." - (and (eq agent-shell-restore-context 'summary) + (and (eq agent-shell-session-restore-strategy 'first-last) (map-elt state :supports-session-load))) (defun agent-shell--restore-summary-init (state) @@ -4779,7 +4779,7 @@ boundary between consecutive same-kind chunks is preserved." (defun agent-shell--render-restore-summary (state) "Render the accumulated restore-summary fragments from STATE. -Adds an `Initial prompt' fragment for the first user message and +Adds an `First prompt' fragment for the first user message and a `Last response' fragment for the latest agent text reply, then clears the summary state. Does nothing if neither was captured." (when-let ((summary (map-elt state :restore-summary))) @@ -4789,7 +4789,7 @@ clears the summary state. Does nothing if neither was captured." :state state :namespace-id "bootstrapping" :block-id "restore_summary_first_user" - :label-left (propertize "Initial prompt" 'font-lock-face 'font-lock-doc-markup-face) + :label-left (propertize "First prompt" 'font-lock-face 'font-lock-doc-markup-face) :body text :expanded t)) (when-let ((text (map-elt summary :last-agent)))