diff --git a/pi-coding-agent-menu.el b/pi-coding-agent-menu.el index 0ac9237..e9165e6 100644 --- a/pi-coding-agent-menu.el +++ b/pi-coding-agent-menu.el @@ -204,25 +204,79 @@ from either chat or input buffer." "Format OBJ's current thinking-display value for the transient menu." (propertize (symbol-name (oref obj value)) 'face 'transient-value)) +(defun pi-coding-agent--new-session-ready-p (chat-buf) + "Return non-nil when CHAT-BUF can safely start a fresh session. +Server-owned streaming may be reset deliberately; unresolved local ownership +and another transition may not be discarded." + (with-current-buffer chat-buf + (cond + ((pi-coding-agent--session-transition-active-p) + (message "Pi: Cannot start a new session while session is switching") + nil) + ((pi-coding-agent--prompt-start-wait-active-p) + (message "Pi: Cannot start a new session while prompt acceptance is pending") + nil) + ((or pi-coding-agent--followup-queue + (pi-coding-agent--followup-drain-pending-p)) + (message "Pi: Cannot start a new session with queued follow-ups") + nil) + (pi-coding-agent--local-user-message + (message "Pi: Wait for pi to echo your prompt before starting a new session") + nil) + (t t)))) + ;;;###autoload (defun pi-coding-agent-new-session () "Start a new pi session (reset)." (interactive) (when-let* ((proc (pi-coding-agent--get-process)) - (chat-buf (pi-coding-agent--get-chat-buffer))) - (pi-coding-agent--rpc-async proc '(:type "new_session") - (lambda (response) - (let* ((data (plist-get response :data)) - (cancelled (plist-get data :cancelled))) - (if (and (eq (plist-get response :success) t) - (pi-coding-agent--json-false-p cancelled)) - (when (buffer-live-p chat-buf) - (with-current-buffer chat-buf - (pi-coding-agent--clear-chat-buffer) - (pi-coding-agent--refresh-header)) - (pi-coding-agent--refresh-session-state proc chat-buf) - (message "Pi: New session started")) - (message "Pi: New session cancelled"))))))) + (chat-buf (pi-coding-agent--get-chat-buffer)) + ((pi-coding-agent--new-session-ready-p chat-buf))) + (let ((generation + (with-current-buffer chat-buf + (pi-coding-agent--begin-session-transition proc)))) + (condition-case err + (pi-coding-agent--rpc-async + proc '(:type "new_session") + (lambda (response) + (when (pi-coding-agent--session-transition-current-p + chat-buf proc generation) + (condition-case callback-error + (let* ((success (eq (plist-get response :success) t)) + (data (plist-get response :data)) + (cancelled (plist-get data :cancelled))) + (cond + ((and success + (pi-coding-agent--json-false-p cancelled)) + (unwind-protect + (with-current-buffer chat-buf + (pi-coding-agent--clear-chat-buffer) + (pi-coding-agent--refresh-header)) + (pi-coding-agent--refresh-session-state proc chat-buf)) + (message "Pi: New session started")) + (t + (with-current-buffer chat-buf + (pi-coding-agent--finish-session-transition generation)) + (if (and success cancelled + (not (pi-coding-agent--json-false-p cancelled))) + (message "Pi: New session cancelled") + (message "Pi: Failed to start new session: %s" + (or (plist-get response :error) + "unknown error")))))) + ((error quit) + (when (pi-coding-agent--session-transition-current-p + chat-buf proc generation) + (with-current-buffer chat-buf + (pi-coding-agent--finish-session-transition generation))) + (if (eq (car callback-error) 'quit) + (signal (car callback-error) (cdr callback-error)) + (message "Pi: Failed to start new session: %s" + (error-message-string callback-error)))))))) + ((error quit) + (when (buffer-live-p chat-buf) + (with-current-buffer chat-buf + (pi-coding-agent--finish-session-transition generation))) + (signal (car err) (cdr err))))))) (defun pi-coding-agent--session-list-directory (&optional chat-buf) "Return the directory containing CHAT-BUF's current JSONL session file. @@ -310,6 +364,7 @@ Call this when starting a new session to ensure no stale state persists." pi-coding-agent--tool-block-order-counter 0 pi-coding-agent--thinking-block-order-counter 0) (pi-coding-agent--set-activity-phase "idle" 'reset t) + (pi-coding-agent--clear-local-user-message-region) (pi-coding-agent--clear-unsupported-extension-ui-warnings) (pi-coding-agent--invalidate-history-loads) (pi-coding-agent--finish-session-transition @@ -447,28 +502,34 @@ handled, even when the response failed." (let* ((own-generation (null generation)) (generation (or generation (pi-coding-agent--begin-session-transition)))) - (pi-coding-agent--rpc-async proc '(:type "get_state") - (lambda (response) - (when (pi-coding-agent--session-transition-current-p - chat-buf proc generation) - (unwind-protect - (when (eq (plist-get response :success) t) - (pi-coding-agent--apply-state-response chat-buf response) - (when (buffer-live-p chat-buf) + (condition-case err + (pi-coding-agent--rpc-async proc '(:type "get_state") + (lambda (response) + (when (pi-coding-agent--session-transition-current-p + chat-buf proc generation) + (unwind-protect + (when (eq (plist-get response :success) t) + (pi-coding-agent--apply-state-response chat-buf response) + (when (buffer-live-p chat-buf) + (with-current-buffer chat-buf + (unless session-file + (when-let* ((current-session-file + (plist-get pi-coding-agent--state + :session-file))) + (pi-coding-agent--update-session-name-from-file + current-session-file))) + (force-mode-line-update t)))) + (when completion-callback + (funcall completion-callback response)) + (when (and own-generation (buffer-live-p chat-buf)) (with-current-buffer chat-buf - (unless session-file - (when-let* ((current-session-file - (plist-get pi-coding-agent--state - :session-file))) - (pi-coding-agent--update-session-name-from-file - current-session-file))) - (force-mode-line-update t)))) - (when completion-callback - (funcall completion-callback response)) - (when (and own-generation (buffer-live-p chat-buf)) - (with-current-buffer chat-buf - (pi-coding-agent--finish-session-transition - generation))))))))))) + (pi-coding-agent--finish-session-transition + generation))))))) + ((error quit) + (when (and own-generation (buffer-live-p chat-buf)) + (with-current-buffer chat-buf + (pi-coding-agent--finish-session-transition generation))) + (signal (car err) (cdr err)))))))) ;;;###autoload (defun pi-coding-agent-reload () diff --git a/pi-coding-agent-render.el b/pi-coding-agent-render.el index 27530ac..17075c6 100644 --- a/pi-coding-agent-render.el +++ b/pi-coding-agent-render.el @@ -103,15 +103,39 @@ call ID in `pi-coding-agent--live-tool-blocks'.") (unless (pi-coding-agent--history-postprocessing-deferred-p) (pi-coding-agent--decorate-tables-in-region start end))) -(defun pi-coding-agent--display-user-message (text &optional timestamp) +(defun pi-coding-agent--display-user-message (text &optional timestamp track-region) "Display user message TEXT in the chat buffer. -If TIMESTAMP (Emacs time value) is provided, display it in the header." - (let ((start (with-current-buffer (pi-coding-agent--get-chat-buffer) (point-max)))) +If TIMESTAMP (Emacs time value) is provided, display it in the header. When +TRACK-REGION is non-nil, return a marker pair bounding the inserted turn." + (let* ((chat-buffer (pi-coding-agent--get-chat-buffer)) + (start (with-current-buffer chat-buffer (point-max)))) (pi-coding-agent--append-to-chat (concat "\n" (pi-coding-agent--make-separator "You" timestamp) "\n" text "\n")) - (with-current-buffer (pi-coding-agent--get-chat-buffer) - (pi-coding-agent--decorate-tables-unless-deferred start (point-max))))) + (with-current-buffer chat-buffer + (pi-coding-agent--decorate-tables-unless-deferred start (point-max)) + (when track-region + (cons (copy-marker start nil) (copy-marker (point-max) nil)))))) + +(defun pi-coding-agent--discard-local-user-message () + "Retract the speculative local user turn when pi handled it invisibly." + (unwind-protect + (when-let* ((region pi-coding-agent--local-user-message-region) + (start (marker-position (car region))) + (end (marker-position (cdr region))) + ((<= start end))) + (let ((inhibit-read-only t)) + (save-restriction + (widen) + (delete-region start end)))) + (setq pi-coding-agent--local-user-message nil) + (pi-coding-agent--clear-local-user-message-region))) + +(defun pi-coding-agent--handle-no-turn-local-prompt () + "Release speculative local echo state, then process queued follow-ups." + (unwind-protect + (pi-coding-agent--discard-local-user-message) + (pi-coding-agent--schedule-followup-queue-processing))) (defun pi-coding-agent--display-agent-start () "Display separator for new agent turn. @@ -655,6 +679,7 @@ follow-up as a fresh prompt.") "Finalize agent turn: normalize whitespace, handle abort, schedule queue." ;; Reset per-turn state for clean next turn. (setq pi-coding-agent--local-user-message nil) + (pi-coding-agent--clear-local-user-message-region) (setq pi-coding-agent--in-thinking-block nil) (pi-coding-agent--reset-thinking-state) (let ((was-aborted pi-coding-agent--aborted)) @@ -749,20 +774,22 @@ transitions; prompt submission marks the local pre-event window as busy." text (lambda () (when (pi-coding-agent--drop-followup text) - (pi-coding-agent--display-user-message text (current-time)) + (setq pi-coding-agent--local-user-message-region + (pi-coding-agent--display-user-message text (current-time) t)) (setq pi-coding-agent--local-user-message text) (setq pi-coding-agent--assistant-header-shown nil))) #'pi-coding-agent--restore-followup-queue-to-input - #'pi-coding-agent--schedule-followup-queue-processing)) + #'pi-coding-agent--handle-no-turn-local-prompt)) (t (pi-coding-agent--send-prompt text (lambda () - (pi-coding-agent--display-user-message text (current-time)) + (setq pi-coding-agent--local-user-message-region + (pi-coding-agent--display-user-message text (current-time) t)) (setq pi-coding-agent--local-user-message text) (setq pi-coding-agent--assistant-header-shown nil)) (lambda () (pi-coding-agent--restore-input-text text)) - #'pi-coding-agent--schedule-followup-queue-processing)))) + #'pi-coding-agent--handle-no-turn-local-prompt)))) (defun pi-coding-agent--process-followup-queue () "Send the oldest follow-up only when it is safe to become the next prompt. @@ -1183,6 +1210,7 @@ which asks upfront before any buffers are touched." (pi-coding-agent--set-process nil) (pi-coding-agent--set-activity-phase "idle") (setq pi-coding-agent--local-user-message nil) + (pi-coding-agent--clear-local-user-message-region) (setq pi-coding-agent--pre-compaction-status nil) (pi-coding-agent--cancel-followup-drain-timer) (pi-coding-agent--invalidate-prompt-start-wait) @@ -1228,6 +1256,7 @@ Updates buffer-local state and renders display updates." (local-msg pi-coding-agent--local-user-message)) ;; Clear local tracking (setq pi-coding-agent--local-user-message nil) + (pi-coding-agent--clear-local-user-message-region) ;; Display if: no local message, OR pi's message differs (expanded template) (when (and text (or (null local-msg) diff --git a/pi-coding-agent-ui.el b/pi-coding-agent-ui.el index 97eba1f..ccba54d 100644 --- a/pi-coding-agent-ui.el +++ b/pi-coding-agent-ui.el @@ -879,6 +879,7 @@ This is a read-only buffer showing the conversation history." (setq-local pi-coding-agent--history-load-generation 0) (setq-local pi-coding-agent--session-transition-generation 0) (setq-local pi-coding-agent--session-transition-active nil) + (setq-local pi-coding-agent--local-user-message-region nil) ;; Disable hl-line-mode: its post-command-hook overlay update causes ;; scroll oscillation in buffers with invisible text + variable heights. (setq-local global-hl-line-mode nil) @@ -1417,6 +1418,16 @@ When nil and we receive message_start role=user, we display it. When set but different from pi's message, we display pi's version \(e.g., expanded template).") +(defvar-local pi-coding-agent--local-user-message-region nil + "Marker pair bounding the locally displayed user turn awaiting pi's echo.") + +(defun pi-coding-agent--clear-local-user-message-region () + "Detach and clear markers for the locally displayed pending user turn." + (when (consp pi-coding-agent--local-user-message-region) + (set-marker (car pi-coding-agent--local-user-message-region) nil) + (set-marker (cdr pi-coding-agent--local-user-message-region) nil)) + (setq pi-coding-agent--local-user-message-region nil)) + (defvar-local pi-coding-agent--prompt-start-wait-active nil "Non-nil while a prompt is waiting for response, agent_start, or fallback.") @@ -2445,22 +2456,68 @@ returns the frontend to idle for that no-turn success path.") (pi-coding-agent--prompt-start-wait-active-p) (= generation pi-coding-agent--prompt-start-generation))) +(defun pi-coding-agent--finish-prompt-without-agent-start + (chat-buf generation on-no-agent-start) + "Finish CHAT-BUF prompt GENERATION after Pi confirms no agent turn. +Call ON-NO-AGENT-START after releasing local ownership." + (when (and (buffer-live-p chat-buf) + (with-current-buffer chat-buf + (pi-coding-agent--prompt-start-current-p generation))) + (with-current-buffer chat-buf + (setq pi-coding-agent--prompt-start-wait-active nil) + (setq pi-coding-agent--prompt-start-generation + (1+ pi-coding-agent--prompt-start-generation)) + (when (eq pi-coding-agent--status 'sending) + (setq pi-coding-agent--status 'idle) + (pi-coding-agent--set-activity-phase "idle")) + (when on-no-agent-start + (funcall on-no-agent-start))))) + +(defun pi-coding-agent--probe-prompt-start-state + (chat-buf generation on-no-agent-start) + "Ask Pi whether CHAT-BUF prompt GENERATION started an agent turn. +Call ON-NO-AGENT-START only after Pi authoritatively reports idle." + (let ((proc (and (buffer-live-p chat-buf) + (with-current-buffer chat-buf + pi-coding-agent--process)))) + (when (and proc (process-live-p proc)) + (condition-case nil + (pi-coding-agent--rpc-async + proc '(:type "get_state") + (lambda (response) + (when (and (buffer-live-p chat-buf) + (with-current-buffer chat-buf + (pi-coding-agent--prompt-start-current-p generation))) + (let* ((data (plist-get response :data)) + (active + (and (eq (plist-get response :success) t) + (or (pi-coding-agent--normalize-boolean + (plist-get data :isStreaming)) + (pi-coding-agent--normalize-boolean + (plist-get data :isCompacting)))))) + (if (or active (not (eq (plist-get response :success) t))) + (pi-coding-agent--schedule-prompt-start-fallback + chat-buf generation on-no-agent-start) + (pi-coding-agent--finish-prompt-without-agent-start + chat-buf generation on-no-agent-start)))))) + (error + (pi-coding-agent--schedule-prompt-start-fallback + chat-buf generation on-no-agent-start)))))) + (defun pi-coding-agent--clear-sending-if-no-agent-start (chat-buf generation &optional on-no-agent-start) - "Return CHAT-BUF to idle if GENERATION produced no agent_start. -When ON-NO-AGENT-START is non-nil, call it after the session returns to idle." + "Check whether CHAT-BUF prompt GENERATION produced no agent_start. +Elapsed time alone is not authoritative: query Pi before releasing local prompt +ownership or invoking ON-NO-AGENT-START." (when (buffer-live-p chat-buf) (with-current-buffer chat-buf (when (pi-coding-agent--prompt-start-current-p generation) (setq pi-coding-agent--prompt-start-timer nil) - (setq pi-coding-agent--prompt-start-wait-active nil) - (setq pi-coding-agent--prompt-start-generation - (1+ pi-coding-agent--prompt-start-generation)) - (when (eq pi-coding-agent--status 'sending) - (setq pi-coding-agent--status 'idle) - (pi-coding-agent--set-activity-phase "idle") - (when on-no-agent-start - (funcall on-no-agent-start))))))) + (if (memq pi-coding-agent--status '(streaming compacting)) + (pi-coding-agent--schedule-prompt-start-fallback + chat-buf generation on-no-agent-start) + (pi-coding-agent--probe-prompt-start-state + chat-buf generation on-no-agent-start)))))) (defun pi-coding-agent--schedule-prompt-start-fallback (chat-buf generation &optional on-no-agent-start) @@ -2540,6 +2597,7 @@ Resets activity phase and status to idle." (with-current-buffer chat-buf (pi-coding-agent--invalidate-prompt-start-wait) (setq pi-coding-agent--local-user-message nil) + (pi-coding-agent--clear-local-user-message-region) (setq pi-coding-agent--pre-compaction-status nil) (setq pi-coding-agent--status 'idle) (pi-coding-agent--set-activity-phase "idle")))) diff --git a/test/pi-coding-agent-input-test.el b/test/pi-coding-agent-input-test.el index e06efcd..36bc7bf 100644 --- a/test/pi-coding-agent-input-test.el +++ b/test/pi-coding-agent-input-test.el @@ -2717,6 +2717,97 @@ Pi handles command expansion on the server side." (should-not (plist-member rpc-message :images))) (delete-process fake-proc)))) +(ert-deftest pi-coding-agent-test-no-turn-fallback-keeps-server-active-prompt () + "A delayed agent_start must not be mistaken for an extension-handled prompt." + (let ((fake-proc (start-process "test-active-prompt" nil "cat"))) + (unwind-protect + (with-temp-buffer + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--process fake-proc + pi-coding-agent--status 'sending + pi-coding-agent--local-user-message "slow prompt" + pi-coding-agent--followup-queue '("wait behind it")) + (setq pi-coding-agent--local-user-message-region + (pi-coding-agent--display-user-message + "slow prompt" (current-time) t)) + (let ((generation (pi-coding-agent--begin-prompt-start-wait))) + (cl-letf (((symbol-function 'pi-coding-agent--rpc-async) + (lambda (_process command callback) + (should (equal (plist-get command :type) "get_state")) + (funcall callback + '(:success t + :data (:isStreaming t + :isCompacting :false))))) + ((symbol-function 'run-at-time) + (lambda (&rest _) 'fake-prompt-start-timer))) + (pi-coding-agent--clear-sending-if-no-agent-start + (current-buffer) generation + #'pi-coding-agent--handle-no-turn-local-prompt)) + (should (pi-coding-agent--prompt-start-current-p generation)) + (should (equal pi-coding-agent--local-user-message "slow prompt")) + (should pi-coding-agent--local-user-message-region) + (should (equal pi-coding-agent--followup-queue '("wait behind it"))) + (should (string-match-p "slow prompt" (buffer-string))))) + (when (process-live-p fake-proc) + (delete-process fake-proc))))) + +(ert-deftest pi-coding-agent-test-no-turn-prompt-retracts-local-echo () + "An extension-handled prompt leaves no speculative user turn behind." + (let ((chat-buf (generate-new-buffer "*pi-no-turn-retract-chat*")) + (input-buf (generate-new-buffer "*pi-no-turn-retract-input*")) + (fake-proc (start-process "test-no-turn-retract" nil "cat")) + prompt-callback state-callback fallback-callback fallback-args) + (unwind-protect + (progn + (with-current-buffer chat-buf + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--process fake-proc + pi-coding-agent--input-buffer input-buf)) + (with-current-buffer input-buf + (pi-coding-agent-input-mode) + (setq pi-coding-agent--chat-buffer chat-buf) + (insert "Handle this without a turn")) + (cl-letf (((symbol-function 'pi-coding-agent--get-process) + (lambda () fake-proc)) + ((symbol-function 'pi-coding-agent--get-chat-buffer) + (lambda () chat-buf)) + ((symbol-function 'pi-coding-agent--rpc-async) + (lambda (_process command callback) + (pcase (plist-get command :type) + ("prompt" (setq prompt-callback callback)) + ("get_state" (setq state-callback callback))))) + ((symbol-function 'run-at-time) + (lambda (_seconds _repeat function &rest args) + (if (eq function + 'pi-coding-agent--clear-sending-if-no-agent-start) + (setq fallback-callback function + fallback-args args) + 'fake-drain-timer) + 'fake-prompt-start-timer)) + ((symbol-function 'message) #'ignore)) + (with-current-buffer input-buf + (pi-coding-agent-send)) + (funcall prompt-callback '(:success t)) + (with-current-buffer chat-buf + (should (equal pi-coding-agent--local-user-message + "Handle this without a turn")) + (narrow-to-region (1+ (point-min)) (point-max))) + (apply fallback-callback fallback-args) + (funcall state-callback + '(:success t + :data (:isStreaming :false :isCompacting :false)))) + (with-current-buffer chat-buf + (widen) + (should (eq pi-coding-agent--status 'idle)) + (should-not pi-coding-agent--local-user-message) + (should-not pi-coding-agent--local-user-message-region) + (should-not (string-match-p "Handle this without a turn" + (buffer-string))))) + (when (process-live-p fake-proc) + (delete-process fake-proc)) + (kill-buffer chat-buf) + (kill-buffer input-buf)))) + (ert-deftest pi-coding-agent-test-send-prompt-marks-sending-until-preflight-fails () "pi-coding-agent--send-prompt closes the local pre-agent_start idle gap." (let* ((rpc-callback nil) @@ -2914,13 +3005,21 @@ Pi handles command expansion on the server side." (with-temp-buffer (pi-coding-agent-chat-mode) (setq pi-coding-agent--status 'idle - pi-coding-agent--activity-phase "idle") + pi-coding-agent--activity-phase "idle" + pi-coding-agent--process fake-proc) (cl-letf (((symbol-function 'pi-coding-agent--get-process) (lambda () fake-proc)) ((symbol-function 'pi-coding-agent--get-chat-buffer) (lambda () (current-buffer))) ((symbol-function 'pi-coding-agent--rpc-async) - (lambda (_proc _msg cb) (setq rpc-callback cb))) + (lambda (_proc command cb) + (pcase (plist-get command :type) + ("prompt" (setq rpc-callback cb)) + ("get_state" + (funcall cb + '(:success t + :data (:isStreaming :false + :isCompacting :false))))))) ((symbol-function 'run-at-time) (lambda (_secs _repeat fn &rest args) (setq fallback-callback fn @@ -2952,6 +3051,7 @@ Pi handles command expansion on the server side." (pi-coding-agent-chat-mode) (setq pi-coding-agent--status 'idle pi-coding-agent--activity-phase "idle" + pi-coding-agent--process fake-proc pi-coding-agent--input-buffer input-buf)) (with-current-buffer input-buf (pi-coding-agent-input-mode) @@ -2962,8 +3062,15 @@ Pi handles command expansion on the server side." (lambda () chat-buf)) ((symbol-function 'pi-coding-agent--rpc-async) (lambda (_proc cmd cb) - (push (plist-get cmd :message) sent-messages) - (push cb rpc-callbacks))) + (pcase (plist-get cmd :type) + ("prompt" + (push (plist-get cmd :message) sent-messages) + (push cb rpc-callbacks)) + ("get_state" + (funcall cb + '(:success t + :data (:isStreaming :false + :isCompacting :false))))))) ((symbol-function 'run-at-time) (lambda (_secs _repeat fn &rest args) (cond diff --git a/test/pi-coding-agent-menu-test.el b/test/pi-coding-agent-menu-test.el index ac94ec0..523c698 100644 --- a/test/pi-coding-agent-menu-test.el +++ b/test/pi-coding-agent-menu-test.el @@ -196,6 +196,90 @@ Also verifies that the new session-file is stored in state for reload to work." (when (buffer-live-p chat-buf) (kill-buffer chat-buf))))) +(ert-deftest pi-coding-agent-test-new-session-refuses-prompt-preflight () + "New session cannot discard a prompt whose acceptance is unresolved." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--process 'mock-proc + pi-coding-agent--status 'sending + pi-coding-agent--prompt-start-wait-active t) + (let (rpc-called feedback) + (cl-letf (((symbol-function 'pi-coding-agent--get-process) + (lambda () 'mock-proc)) + ((symbol-function 'pi-coding-agent--get-chat-buffer) + (lambda () (current-buffer))) + ((symbol-function 'pi-coding-agent--rpc-async) + (lambda (&rest _) + (setq rpc-called t))) + ((symbol-function 'message) + (lambda (format-string &rest args) + (when format-string + (setq feedback (apply #'format format-string args)))))) + (pi-coding-agent-new-session)) + (should-not rpc-called) + (should (string-match-p "Cannot start a new session" + (or feedback "")))))) + +(ert-deftest pi-coding-agent-test-new-session-preserves-queued-followups () + "Reset refuses rather than silently discarding accepted local follow-ups." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--process 'mock-proc + pi-coding-agent--status 'streaming + pi-coding-agent--followup-queue '("keep me")) + (let (rpc-called feedback) + (cl-letf (((symbol-function 'pi-coding-agent--get-process) + (lambda () 'mock-proc)) + ((symbol-function 'pi-coding-agent--get-chat-buffer) + (lambda () (current-buffer))) + ((symbol-function 'pi-coding-agent--rpc-async) + (lambda (&rest _) + (setq rpc-called t))) + ((symbol-function 'message) + (lambda (format-string &rest args) + (when format-string + (setq feedback (apply #'format format-string args)))))) + (pi-coding-agent-new-session)) + (should-not rpc-called) + (should (equal pi-coding-agent--followup-queue '("keep me"))) + (should (string-match-p "queued follow-ups" (or feedback "")))))) + +(ert-deftest pi-coding-agent-test-new-session-can-reset-server-streaming () + "A deliberate reset still reaches Pi while its agent is streaming." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--process 'mock-proc + pi-coding-agent--status 'streaming) + (let (callback) + (cl-letf (((symbol-function 'pi-coding-agent--get-process) + (lambda () 'mock-proc)) + ((symbol-function 'pi-coding-agent--get-chat-buffer) + (lambda () (current-buffer))) + ((symbol-function 'pi-coding-agent--rpc-async) + (lambda (_process _command cb) + (setq callback cb)))) + (pi-coding-agent-new-session) + (should (functionp callback)) + (should (pi-coding-agent--session-transition-active-p)))))) + +(ert-deftest pi-coding-agent-test-new-session-blocks-work-until-response () + "A scheduled reset owns the session transition until its response." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--process 'mock-proc + pi-coding-agent--status 'idle) + (let (callback) + (cl-letf (((symbol-function 'pi-coding-agent--get-process) + (lambda () 'mock-proc)) + ((symbol-function 'pi-coding-agent--get-chat-buffer) + (lambda () (current-buffer))) + ((symbol-function 'pi-coding-agent--rpc-async) + (lambda (_process _command cb) + (setq callback cb)))) + (pi-coding-agent-new-session) + (should (functionp callback)) + (should (pi-coding-agent--session-transition-active-p)))))) + (ert-deftest pi-coding-agent-test-find-session-returns-existing () "pi-coding-agent--find-session returns an existing chat buffer." (let* ((root (pi-coding-agent-test--make-temp-directory