diff --git a/README.org b/README.org index 5ebf67db..b004979a 100644 --- a/README.org +++ b/README.org @@ -27,6 +27,10 @@ A soft fork of [[https://github.com/xenodium/agent-shell][agent-shell]] with ext - Live-validate workflow doc (=.agents/commands/live-validate.md=) describing the batch-mode rendering verification used for rendering-pipeline changes ([[https://github.com/timvisher-dd/agent-shell-plus/pull/7][#7]]) - =gfm-mode= compose buffer for the interactive =agent-shell-queue-request=, replacing the read-string minibuffer prompt (non-interactive callers still pass =PROMPT= directly) ([[https://github.com/timvisher-dd/agent-shell-plus/pull/9][#9]]) - =agent-shell-resume-session= repurposed from an ID-prompt command into a session-picker entry point: opens the same fuzzy-completion picker that =agent-shell-session-strategy= ='prompt= uses, and force-shows session IDs in the picker (via a new =:show-session-id= keyword on =agent-shell--start=) so users with an ID in hand can find it via their completion framework ([[https://github.com/timvisher-dd/agent-shell-plus/pull/11][#11]]) +- New =agent-shell-quote-reply= command (bound to =q= in the =agent-shell-help-menu= "Insert" group) pops the queue-compose buffer prefilled with the agent's last prose reply wrapped as a GFM block quote. Filters out tool-call drawers and thought blocks by walking back to the most recent =agent_message_chunk= fragment instead of using =shell-maker--command-and-response-at-point=, which would otherwise include rendered drawer text in the quoted output. =agent-shell-queue-request= now also gathers any DWIM context available in the source buffer (active region, error at point, dired marked files) and prefills the compose buffer with it; regions from buffers with no file association are wrapped as GFM block quotes while file-visiting and dired context keep their existing purpose-built decoration +- Fix queued-request submit stranding point: when a queued request submits (auto-resume on turn completion, or interactive =C-c C-c=), point no longer lands at the end of the last agent message above the new prompt. =agent-shell--insert-to-shell-buffer= parked point at the start of the inserted text (the DWIM "land on your context" behavior), but for the =:submit t= path that text becomes transcript above the new prompt — and shell-maker only auto-scrolls streamed output while point is at =point-max=, so the cursor stayed stranded there for the whole turn. Submit now leaves point at =point-max= so it tracks the prompt; the DWIM start-of-context placement is kept only for non-submit insertions +- Fix interactive =RET= submit stranding point above the streamed reply: when you submit a prompt by typing at the shell prompt and pressing =RET=, =shell-maker-submit= leaves point at the response boundary just below the echoed input rather than at =point-max= (because =shell-maker-point-at-last-prompt-p= is true, it skips its own =goto-char=), so the streamed reply and new prompt land below point and the cursor is stranded above them for the whole turn. =agent-shell--handle= (the =:execute-command= callback) now snaps point to =point-max= whenever a command is submitted so the cursor follows streaming output down to the new prompt. This is the interactive sibling of the queued-request submit fix above; the programmatic =:submit t= path was already covered +- Fix queue-compose =C-c C-c= leaving the shell cursor at its stale pre-queue position: the compose-buffer submit path is =:no-focus t=, so the shell window is never selected and the earlier submit fixes — which moved only buffer point via =goto-char= — left the shell window's =window-point= where the user had parked it before invoking =agent-shell-queue-request=. =agent-shell--handle= now also pushes =window-point= to =point-max= for every window showing the shell, so the visible cursor tracks the prompt even when the shell window is unselected (mirrors =agent-shell-jump-to-latest-permission-button-row=) ----- diff --git a/agent-shell.el b/agent-shell.el index 3e6721ba..d2f7a7f3 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -1461,6 +1461,27 @@ Flow: (shell-maker--current-request-id)) (map-put! (agent-shell--state) :last-activity-time (current-time)) (agent-shell--reset-insert-cursor) + ;; A freshly submitted prompt should track the streaming response down + ;; to the new prompt. Interactive `shell-maker-submit' (RET) leaves + ;; point at the response boundary just below the echoed input — not at + ;; `point-max' — because `shell-maker-point-at-last-prompt-p' is true, + ;; so it skips its own `goto-char'. Streaming then inserts the reply + ;; below point and the cursor is stranded above it for the whole turn + ;; (shell-maker only auto-scrolls while point is at end-of-buffer). + ;; Snap to `point-max' on submit so the cursor follows output. + ;; + ;; `goto-char' only moves BUFFER point. On the `:no-focus t' + ;; queue-compose submit path (C-c C-c in the compose buffer) the + ;; shell window is never selected, so its `window-point' keeps the + ;; pre-queue position the user had parked point at and the cursor + ;; visibly snaps back there. Push `window-point' to `point-max' for + ;; every window showing the shell so the visible cursor tracks the + ;; prompt too — mirrors + ;; `agent-shell-jump-to-latest-permission-button-row'. + (when command + (goto-char (point-max)) + (dolist (window (get-buffer-window-list nil nil t)) + (set-window-point window (point-max)))) (cond ((not (map-elt (agent-shell--state) :client)) ;; Needs a client (agent-shell--emit-event :event 'init-started) @@ -6224,13 +6245,20 @@ Returns an alist with insertion details or nil otherwise: (let ((markdown-overlays-highlight-blocks agent-shell-highlight-blocks) (markdown-overlays-render-images nil)) (markdown-overlays-put)))) - ;; Leave point at the start of the inserted region so the - ;; user lands on their context, not after it — DWIM users - ;; expect to keep typing where the prompt is, not below - ;; the freshly-inserted text. - (goto-char insert-start) - (when submit - (shell-maker-submit))) + ;; For a plain insertion, leave point at the start of the + ;; inserted region so the user lands on their context, not + ;; after it — DWIM users expect to keep typing where the + ;; prompt is, not below the freshly-inserted text. When + ;; submitting, that text becomes transcript above the new + ;; prompt; leave point at `point-max' so it tracks the + ;; prompt. shell-maker only auto-scrolls streamed output + ;; while point is at end-of-buffer, so parking it above the + ;; prompt would strand the cursor there for the whole turn. + (if submit + (progn + (goto-char (point-max)) + (shell-maker-submit)) + (goto-char insert-start))) `((:buffer . ,shell-buffer) (:start . ,insert-start) (:end . ,insert-end))) @@ -7068,6 +7096,7 @@ Optionally, get notified of completion with ON-SUCCESS function." ("!" "Shell command" agent-shell-insert-shell-command-output :transient t) ("@" "File" agent-shell-insert-file :transient t) ("d" "Dwim" agent-shell-send-dwim :transient t) + ("q" "Quote last reply" agent-shell-quote-reply :transient t) ]] [["Session" ("m" "Cycle modes" agent-shell-cycle-session-mode :transient t) @@ -7335,6 +7364,76 @@ Remove: M-x agent-shell-remove-pending-request (append pending (list prompt))) (message "Request queued (%d pending)" (length (map-elt agent-shell--state :pending-requests))))) +(defun agent-shell--gfm-blockquote (text) + "Return TEXT wrapped as a GFM block quote. + +Each line is prefixed with `> ' via `markdown-blockquote-region', +which also normalizes surrounding blank lines." + (with-temp-buffer + (insert text) + (markdown-blockquote-region (point-min) (point-max)) + (string-trim (buffer-string)))) + +(defun agent-shell--last-agent-message-text () + "Return the most recent agent prose reply as raw text, or nil. + +Walks the current buffer backward from `point-max' for the latest +fragment whose `agent-shell-ui-state' qualified-id ends in +`-agent_message_chunk', then pulls the raw body from +`agent-shell-ui--content-store'. Tool-call drawers and thought +blocks are skipped. + +`text-property-search-backward' returns nil when its predicate +rejects the nearest match instead of continuing the walk, so loop +manually — visiting each propertied region and checking the +qualified-id ourselves." + (when-let ((content-store agent-shell-ui--content-store)) + (save-mark-and-excursion + (goto-char (point-max)) + (let (qualified-id) + (while (and (not qualified-id) + (text-property-search-backward + 'agent-shell-ui-state nil nil t)) + (when-let* ((state (get-text-property (point) 'agent-shell-ui-state)) + (qid (map-elt state :qualified-id)) + ((string-suffix-p "-agent_message_chunk" qid))) + (setq qualified-id qid))) + (when qualified-id + (gethash (concat qualified-id "-body") content-store)))))) + +(defun agent-shell--compose-initial-content (shell-buffer) + "Return initial content for a compose buffer popped from `current-buffer'. + +For an active region in a buffer with no file association, block-quote +the raw region content. Otherwise delegate to `agent-shell--context', +which already produces purpose-built decoration for file-visiting +buffers, dired marked files, errors, etc. SHELL-BUFFER scopes +relative paths to the target shell's project." + (cond + ((and (region-active-p) (not (buffer-file-name))) + (when-let* ((region (agent-shell--get-region :deactivate t)) + (content (map-elt region :content))) + (agent-shell--gfm-blockquote content))) + (t + (agent-shell--context :shell-buffer shell-buffer)))) + +(defun agent-shell-quote-reply () + "Pop the compose buffer prefilled with the agent's last reply, quoted. + +Locates the most recent agent_message_chunk fragment in the current +shell buffer, wraps its body as a GFM block quote, and pops the +queue-compose buffer with that as initial content." + (declare (modes agent-shell-mode)) + (interactive) + (unless (derived-mode-p 'agent-shell-mode) + (user-error "Not in a shell")) + (let ((text (agent-shell--last-agent-message-text))) + (unless text + (user-error "No agent reply found in this shell")) + (agent-shell-queue-compose-pop + (current-buffer) + :initial-content (agent-shell--gfm-blockquote text)))) + (defun agent-shell-queue-request (&optional prompt) "Queue or immediately send a request depending on shell busy state. @@ -7344,6 +7443,13 @@ If the shell is busy when submitted, add to the pending requests queue; otherwise submit immediately. Queued requests will be automatically sent when the current request completes. +When called interactively, any DWIM context available in the current +buffer (active region, error at point, dired marked files, etc.) is +gathered via `agent-shell--context' and prefilled into the compose +buffer. An active region in a buffer with no file association is +wrapped as a GFM block quote; other context sources keep their +purpose-built formatting. + When called non-interactively with PROMPT, submit or queue PROMPT directly, bypassing the compose buffer." (declare (modes agent-shell-mode)) @@ -7352,7 +7458,10 @@ PROMPT directly, bypassing the compose buffer." (user-error "Not in a shell")) (cond ((not prompt) - (agent-shell-queue-compose-pop (current-buffer))) + (let ((shell-buffer (current-buffer))) + (agent-shell-queue-compose-pop + shell-buffer + :initial-content (agent-shell--compose-initial-content shell-buffer)))) ((string-empty-p (string-trim prompt)) (user-error "PROMPT is empty")) (t @@ -7447,11 +7556,15 @@ idle." (agent-shell--insert-to-shell-buffer :shell-buffer shell-buffer :text prompt :submit t :no-focus t)))) -(defun agent-shell-queue-compose-pop (shell-buffer) +(cl-defun agent-shell-queue-compose-pop (shell-buffer &key initial-content) "Pop a `gfm-mode' compose buffer bound to SHELL-BUFFER. Reuses the shell's existing compose buffer when alive so an -in-progress draft survives a re-invocation." +in-progress draft survives a re-invocation. + +INITIAL-CONTENT, when supplied, is inserted at `point-max'. If the +buffer already has draft content, INITIAL-CONTENT is appended after +a blank-line separator." (unless (buffer-live-p shell-buffer) (user-error "Shell buffer is not live")) (let* ((existing (buffer-local-value 'agent-shell--queue-compose-buffer @@ -7482,6 +7595,17 @@ in-progress draft survives a re-invocation." (with-current-buffer shell-buffer (setq agent-shell--queue-compose-buffer new)) new)))) + (when initial-content + (with-current-buffer buffer + (goto-char (point-max)) + ;; Ensure a blank-line separator before appending to an + ;; existing draft so the new content doesn't run into it. + (unless (zerop (buffer-size)) + (unless (bolp) (insert "\n")) + (unless (save-excursion (forward-line -1) (looking-at-p "^$")) + (insert "\n"))) + (insert initial-content) + (set-buffer-modified-p nil))) (pop-to-buffer buffer) buffer)) diff --git a/tests/agent-shell-tests.el b/tests/agent-shell-tests.el index a0548a37..b4472752 100644 --- a/tests/agent-shell-tests.el +++ b/tests/agent-shell-tests.el @@ -2926,6 +2926,147 @@ Asserts: (message \"world\")) ```")) +(ert-deftest agent-shell--insert-to-shell-buffer-submit-leaves-point-at-prompt-test () + "A submitted (e.g. queued) request must leave point at the prompt. + +Regression: `agent-shell--insert-to-shell-buffer' parked point at the +START of the inserted text (the DWIM \"land on your context\" +behavior). For the :submit t path used by +`agent-shell--process-pending-request' (and interactive C-c C-c on a +queued request), that text becomes transcript ABOVE the new prompt, so +point was left at the end of the last agent message. shell-maker only +auto-scrolls streamed output while point is at `point-max', so the +cursor stayed stranded above the prompt for the whole turn. Submit +must leave point at end-of-buffer so it tracks the prompt." + (require 'agent-shell-fakes) + (let* ((agent-shell-session-strategy 'new) + (shell-buffer (agent-shell-fakes-start-agent + agent-shell-tests--bootstrap-messages)) + submit-at-eob) + (unwind-protect + (with-current-buffer shell-buffer + (cl-letf (((symbol-function 'shell-maker-submit) + (lambda (&rest _) (setq submit-at-eob (eobp))))) + (agent-shell--insert-to-shell-buffer :text "queued request" + :submit t + :no-focus t + :shell-buffer shell-buffer)) + ;; At the moment of submission point must be at end-of-buffer + ;; so shell-maker's streaming auto-scroll keeps the cursor at + ;; the prompt instead of stranding it above the inserted text. + (should submit-at-eob) + (should (= (point) (point-max)))) + (when (buffer-live-p shell-buffer) + (kill-buffer shell-buffer))))) + +(ert-deftest agent-shell--handle-command-moves-point-to-prompt-test () + "Submitting a command must move point to `point-max' so it tracks output. + +Regression: interactive `shell-maker-submit' (RET) leaves point at the +response boundary just below the echoed input — not at `point-max' — +because `shell-maker-point-at-last-prompt-p' is true, so it skips its +own `goto-char'. The streamed reply then lands below point and the +cursor is stranded above it for the whole turn. `agent-shell--handle' +(the `:execute-command' callback) must snap point to `point-max' when a +command is submitted. Verified live on `dev': without the fix point +sat several lines above `point-max' after a real turn." + (require 'agent-shell-fakes) + (let* ((agent-shell-session-strategy 'new) + (shell-buffer (agent-shell-fakes-start-agent + agent-shell-tests--bootstrap-messages))) + (unwind-protect + (with-current-buffer shell-buffer + ;; Stub every dispatch the cond can reach so no real ACP traffic + ;; runs; the point move happens before the cond regardless. + (cl-letf (((symbol-function 'agent-shell--send-command) #'ignore) + ((symbol-function 'agent-shell--initialize-client) #'ignore) + ((symbol-function 'agent-shell--initialize-subscriptions) #'ignore) + ((symbol-function 'agent-shell--initiate-handshake) #'ignore) + ((symbol-function 'agent-shell--authenticate) #'ignore) + ((symbol-function 'agent-shell--initiate-session) #'ignore) + ((symbol-function 'agent-shell--set-default-model) #'ignore) + ((symbol-function 'agent-shell--set-default-session-mode) #'ignore)) + ;; Park point above end-of-buffer, as RET does after the echoed + ;; input when the new prompt sits below. + (goto-char (point-min)) + (should (not (eobp))) + (agent-shell--handle :command "hello" :shell-buffer shell-buffer) + (should (= (point) (point-max))))) + (when (buffer-live-p shell-buffer) + (kill-buffer shell-buffer))))) + +(ert-deftest agent-shell--handle-nil-command-leaves-point-test () + "Initialization (nil command) must not move point. + +Only a real submission should snap point to `point-max'; bootstrap +re-entries of `agent-shell--handle' with no command must leave the +user's point alone." + (require 'agent-shell-fakes) + (let* ((agent-shell-session-strategy 'new) + (shell-buffer (agent-shell-fakes-start-agent + agent-shell-tests--bootstrap-messages))) + (unwind-protect + (with-current-buffer shell-buffer + (cl-letf (((symbol-function 'agent-shell--send-command) #'ignore) + ((symbol-function 'agent-shell--initialize-client) #'ignore) + ((symbol-function 'agent-shell--initialize-subscriptions) #'ignore) + ((symbol-function 'agent-shell--initiate-handshake) #'ignore) + ((symbol-function 'agent-shell--authenticate) #'ignore) + ((symbol-function 'agent-shell--initiate-session) #'ignore) + ((symbol-function 'agent-shell--set-default-model) #'ignore) + ((symbol-function 'agent-shell--set-default-session-mode) #'ignore)) + (goto-char (point-min)) + (agent-shell--handle :command nil :shell-buffer shell-buffer) + (should (= (point) (point-min))))) + (when (buffer-live-p shell-buffer) + (kill-buffer shell-buffer))))) + +(ert-deftest agent-shell--handle-command-moves-window-point-when-unselected-test () + "Submitting must move `window-point' even when the shell window is not selected. + +Regression (agent-shell-rnpd): the `:no-focus t' queue-compose submit +path (C-c C-c in the compose buffer) submits while a different window is +selected. `agent-shell--handle' moved only BUFFER point with +`goto-char', so the shell window kept the `window-point' the user had +parked above the prompt and the cursor visibly snapped back there. +`agent-shell--handle' must push `window-point' to `point-max' for every +window showing the shell." + (require 'agent-shell-fakes) + (let* ((agent-shell-session-strategy 'new) + (shell-buffer (agent-shell-fakes-start-agent + agent-shell-tests--bootstrap-messages)) + (other-buffer (get-buffer-create "*agent-shell-rnpd-other*")) + shell-window) + (unwind-protect + (progn + ;; Show the shell in one window and select a DIFFERENT window, so + ;; the shell window's `window-point' is decoupled from buffer point + ;; — exactly the no-focus compose-submit situation. + (delete-other-windows) + (switch-to-buffer shell-buffer) + (setq shell-window (selected-window)) + (let ((other-window (split-window))) + (set-window-buffer other-window other-buffer) + ;; Park the shell window above the prompt, then move focus away. + (set-window-point shell-window (point-min)) + (select-window other-window)) + (with-current-buffer shell-buffer + (cl-letf (((symbol-function 'agent-shell--send-command) #'ignore) + ((symbol-function 'agent-shell--initialize-client) #'ignore) + ((symbol-function 'agent-shell--initialize-subscriptions) #'ignore) + ((symbol-function 'agent-shell--initiate-handshake) #'ignore) + ((symbol-function 'agent-shell--authenticate) #'ignore) + ((symbol-function 'agent-shell--initiate-session) #'ignore) + ((symbol-function 'agent-shell--set-default-model) #'ignore) + ((symbol-function 'agent-shell--set-default-session-mode) #'ignore)) + (should (/= (window-point shell-window) (point-max))) + (agent-shell--handle :command "hello" :shell-buffer shell-buffer) + (should (= (window-point shell-window) (point-max)))))) + (when (buffer-live-p shell-buffer) + (kill-buffer shell-buffer)) + (when (buffer-live-p other-buffer) + (kill-buffer other-buffer))))) + (ert-deftest agent-shell-filter-buffer-substring-strips-hidden-markup () "Copying text should exclude markdown syntax hidden by overlays." (with-temp-buffer @@ -3124,7 +3265,7 @@ Stubs `pop-to-buffer' to avoid display side-effects in batch mode." (should (null popped)))) (ert-deftest agent-shell-queue-request-without-prompt-pops-compose () - "Calling without PROMPT pops the compose buffer." + "Calling without PROMPT pops the compose buffer with gathered context." (let ((shell (generate-new-buffer "*test-shell*")) submitted popped) @@ -3132,12 +3273,14 @@ Stubs `pop-to-buffer' to avoid display side-effects in batch mode." (cl-letf (((symbol-function 'agent-shell--queue-or-submit) (lambda (p sb) (setq submitted (list p sb)))) ((symbol-function 'agent-shell-queue-compose-pop) - (lambda (sb) (setq popped sb)))) + (lambda (sb &rest args) (setq popped (cons sb args)))) + ((symbol-function 'agent-shell--compose-initial-content) + (lambda (_sb) "context text"))) (with-current-buffer shell (setq major-mode 'agent-shell-mode) (agent-shell-queue-request))) (kill-buffer shell)) - (should (eq popped shell)) + (should (equal popped (list shell :initial-content "context text"))) (should (null submitted)))) (ert-deftest agent-shell-queue-compose-pop-after-submit-creates-fresh-buffer () @@ -3217,5 +3360,169 @@ Stubs `pop-to-buffer' to avoid display side-effects in batch mode." (should (eq killed-buffer (current-buffer))))) (should (null quit-called)))) +;;; gfm-blockquote / quote-reply tests + +(ert-deftest agent-shell--gfm-blockquote-single-line () + "A single-line input becomes a single quoted line." + (should (string= "> hello" + (agent-shell--gfm-blockquote "hello")))) + +(ert-deftest agent-shell--gfm-blockquote-multiline () + "Each line gets `> ' and blank lines become `>'." + (should (string= "> first\n>\n> third" + (agent-shell--gfm-blockquote "first\n\nthird")))) + +(ert-deftest agent-shell--gfm-blockquote-preserves-code-fence () + "GFM block quotes can wrap nested fenced code blocks." + (should (string= "> ```\n> code\n> ```" + (agent-shell--gfm-blockquote "```\ncode\n```")))) + +(ert-deftest agent-shell--gfm-blockquote-quotes-existing-quote () + "Nested quotes get a leading `> ' added to the existing `> '." + (should (string= "> > already quoted" + (agent-shell--gfm-blockquote "> already quoted")))) + +(ert-deftest agent-shell--last-agent-message-text-returns-body () + "Returns the raw body from the most recent agent_message_chunk fragment." + (with-temp-buffer + (setq agent-shell-ui--content-store (make-hash-table :test 'equal)) + (let ((qid "1-0-agent_message_chunk")) + (insert (propertize "Agent prose" + 'agent-shell-ui-state (list (cons :qualified-id qid)))) + (puthash (concat qid "-body") "Agent prose" agent-shell-ui--content-store)) + (should (string= "Agent prose" + (agent-shell--last-agent-message-text))))) + +(ert-deftest agent-shell--last-agent-message-text-skips-tool-calls () + "Tool-call fragments after the last agent message are skipped." + (with-temp-buffer + (setq agent-shell-ui--content-store (make-hash-table :test 'equal)) + (let ((agent-qid "1-0-agent_message_chunk") + (tool-qid "1-toolCallId-grep")) + (insert (propertize "Agent reply" + 'agent-shell-ui-state (list (cons :qualified-id agent-qid)))) + (insert "\n") + (insert (propertize "Tool drawer" + 'agent-shell-ui-state (list (cons :qualified-id tool-qid)))) + (puthash (concat agent-qid "-body") "Agent reply" agent-shell-ui--content-store) + (puthash (concat tool-qid "-body") "Tool drawer" agent-shell-ui--content-store)) + (should (string= "Agent reply" + (agent-shell--last-agent-message-text))))) + +(ert-deftest agent-shell--last-agent-message-text-returns-last-of-many () + "Returns the LATEST agent_message_chunk when several are present." + (with-temp-buffer + (setq agent-shell-ui--content-store (make-hash-table :test 'equal)) + (let ((first-qid "1-0-agent_message_chunk") + (second-qid "1-1-agent_message_chunk")) + (insert (propertize "First reply" + 'agent-shell-ui-state (list (cons :qualified-id first-qid)))) + (insert "\n") + (insert (propertize "Second reply" + 'agent-shell-ui-state (list (cons :qualified-id second-qid)))) + (puthash (concat first-qid "-body") "First reply" agent-shell-ui--content-store) + (puthash (concat second-qid "-body") "Second reply" agent-shell-ui--content-store)) + (should (string= "Second reply" + (agent-shell--last-agent-message-text))))) + +(ert-deftest agent-shell--last-agent-message-text-nil-when-empty () + "Returns nil when no agent_message_chunk fragments exist." + (with-temp-buffer + (setq agent-shell-ui--content-store (make-hash-table :test 'equal)) + (should-not (agent-shell--last-agent-message-text)))) + +(ert-deftest agent-shell-quote-reply-errors-outside-shell () + "`agent-shell-quote-reply' refuses to run outside `agent-shell-mode'." + (with-temp-buffer + (should-error (agent-shell-quote-reply) :type 'user-error))) + +(ert-deftest agent-shell-quote-reply-errors-when-no-reply () + "`agent-shell-quote-reply' surfaces a user-error when the shell has no reply." + (let ((shell (generate-new-buffer "*test-shell*"))) + (unwind-protect + (with-current-buffer shell + (setq major-mode 'agent-shell-mode) + (should-error (agent-shell-quote-reply) :type 'user-error)) + (kill-buffer shell)))) + +(ert-deftest agent-shell-quote-reply-pops-compose-with-quoted-reply () + "Quote-reply pops the compose buffer with the agent's last reply quoted." + (let ((shell (generate-new-buffer "*test-shell*")) + popped) + (unwind-protect + (cl-letf (((symbol-function 'agent-shell--last-agent-message-text) + (lambda () "Hello there")) + ((symbol-function 'agent-shell-queue-compose-pop) + (lambda (sb &rest args) (setq popped (cons sb args))))) + (with-current-buffer shell + (setq major-mode 'agent-shell-mode) + (agent-shell-quote-reply))) + (kill-buffer shell)) + (should (equal popped (list shell :initial-content "> Hello there"))))) + +;;; compose-pop initial-content tests + +(ert-deftest agent-shell-queue-compose-pop-inserts-initial-content-into-fresh-buffer () + "Fresh compose buffer is prefilled with INITIAL-CONTENT when supplied." + (let ((shell (generate-new-buffer "*test-shell*")) + compose) + (unwind-protect + (cl-letf (((symbol-function 'pop-to-buffer) (lambda (b &rest _) b))) + (with-current-buffer shell + (setq major-mode 'agent-shell-mode)) + (setq compose (agent-shell-queue-compose-pop + shell :initial-content "seeded text")) + (with-current-buffer compose + (should (string= "seeded text" (buffer-string))) + ;; Buffer-modified-p stays nil so the modeline doesn't show + ;; `**' for content the user didn't type. + (should-not (buffer-modified-p)))) + (when (buffer-live-p compose) (kill-buffer compose)) + (when (buffer-live-p shell) (kill-buffer shell))))) + +(ert-deftest agent-shell-queue-compose-pop-appends-initial-content-to-existing-draft () + "When draft already has content, INITIAL-CONTENT is appended after a blank line." + (let ((shell (generate-new-buffer "*test-shell*")) + compose) + (unwind-protect + (cl-letf (((symbol-function 'pop-to-buffer) (lambda (b &rest _) b))) + (with-current-buffer shell + (setq major-mode 'agent-shell-mode)) + (setq compose (agent-shell-queue-compose-pop shell)) + (with-current-buffer compose + (insert "draft line")) + (agent-shell-queue-compose-pop shell :initial-content "> quoted") + (with-current-buffer compose + (should (string= "draft line\n\n> quoted" (buffer-string))))) + (when (buffer-live-p compose) (kill-buffer compose)) + (when (buffer-live-p shell) (kill-buffer shell))))) + +(ert-deftest agent-shell-queue-compose-pop-without-initial-content-leaves-buffer-empty () + "Omitting INITIAL-CONTENT preserves existing behavior (fresh empty buffer)." + (agent-shell-tests--with-compose shell compose + (with-current-buffer compose + (should (string= "" (buffer-string)))))) + +;;; compose-initial-content tests + +(ert-deftest agent-shell--compose-initial-content-quotes-region-from-non-file-buffer () + "Active region in a buffer with no file association is block-quoted." + (with-temp-buffer + (insert "shell output line 1\nshell output line 2") + (goto-char (point-min)) + (set-mark (point)) + (goto-char (point-max)) + (activate-mark) + (should (string= "> shell output line 1\n> shell output line 2" + (agent-shell--compose-initial-content nil))))) + +(ert-deftest agent-shell--compose-initial-content-delegates-to-context-otherwise () + "With no region (or in a file-visiting buffer) defer to `agent-shell--context'." + (cl-letf (((symbol-function 'agent-shell--context) + (lambda (&rest _) "decorated context"))) + (with-temp-buffer + (should (string= "decorated context" + (agent-shell--compose-initial-content nil)))))) + (provide 'agent-shell-tests) ;;; agent-shell-tests.el ends here