diff --git a/pi-coding-agent-core.el b/pi-coding-agent-core.el index 4253fb2..834c2d5 100644 --- a/pi-coding-agent-core.el +++ b/pi-coding-agent-core.el @@ -197,22 +197,59 @@ Calls only the handler registered for this specific process." (when-let* ((handler (process-get proc 'pi-coding-agent-display-handler))) (funcall handler event))) +(defconst pi-coding-agent--process-stderr-max-chars 4000 + "Maximum number of stderr characters to keep in process exit excerpts.") + +(defun pi-coding-agent--process-stderr-excerpt (proc) + "Return a bounded stderr excerpt for PROC, or nil when stderr is empty." + (when-let* ((stderr-buf (process-get proc 'pi-coding-agent-stderr-buf)) + ((buffer-live-p stderr-buf))) + (let ((text (string-trim-right + (with-current-buffer stderr-buf + (buffer-substring-no-properties (point-min) (point-max)))))) + (unless (string-empty-p text) + (if (<= (length text) pi-coding-agent--process-stderr-max-chars) + text + (let* ((head-chars (/ pi-coding-agent--process-stderr-max-chars 2)) + (tail-chars (- pi-coding-agent--process-stderr-max-chars + head-chars))) + (concat (substring text 0 head-chars) + "\n… [stderr truncated] …\n" + (substring text (- (length text) tail-chars))))))))) + +(defun pi-coding-agent--cleanup-process-stderr-buffer (proc) + "Kill PROC's stderr buffer, if any, and clear its process property." + (when-let* ((stderr-buf (process-get proc 'pi-coding-agent-stderr-buf))) + (process-put proc 'pi-coding-agent-stderr-buf nil) + (when (buffer-live-p stderr-buf) + (when-let* ((stderr-proc (get-buffer-process stderr-buf))) + (set-process-query-on-exit-flag stderr-proc nil) + (delete-process stderr-proc)) + (kill-buffer stderr-buf)))) + (defun pi-coding-agent--handle-process-exit (proc event) "Clean up when pi process PROC exits with EVENT. Calls pending request callbacks for this process with an error response containing EVENT, then clears this process's pending request tables." - (let ((pending (process-get proc 'pi-coding-agent-pending-requests)) - (pending-types (process-get proc 'pi-coding-agent-pending-command-types)) - (error-response (list :type "response" - :success :false - :error (format "Process exited: %s" (string-trim event))))) - (when pending - (maphash (lambda (_id callback) - (funcall callback error-response)) - pending) - (clrhash pending)) - (when pending-types - (clrhash pending-types)))) + (let* ((pending (process-get proc 'pi-coding-agent-pending-requests)) + (pending-types (process-get proc 'pi-coding-agent-pending-command-types)) + (stderr (pi-coding-agent--process-stderr-excerpt proc)) + (error-response + (append (list :type "response" + :success :false + :error (format "Process exited: %s" (string-trim event))) + (when stderr + (list :stderr stderr))))) + (unwind-protect + (progn + (when pending + (maphash (lambda (_id callback) + (funcall callback error-response)) + pending) + (clrhash pending)) + (when pending-types + (clrhash pending-types))) + (pi-coding-agent--cleanup-process-stderr-buffer proc)))) (defvar pi-coding-agent-executable) ; forward decl — core.el cannot require ui.el @@ -227,13 +264,24 @@ This is useful for testing extensions or passing additional flags.") (defun pi-coding-agent--start-process (directory) "Start pi RPC process in DIRECTORY. Returns the process object." - (let ((default-directory directory)) - (make-process - :name "pi" - :command `(,@pi-coding-agent-executable "--mode" "rpc" ,@pi-coding-agent-extra-args) - :connection-type 'pipe - :filter #'pi-coding-agent--process-filter - :sentinel #'pi-coding-agent--process-sentinel))) + (let ((default-directory directory) + (stderr-buf (generate-new-buffer " *pi-coding-agent-stderr*"))) + (condition-case err + (let ((proc (make-process + :name "pi" + :command `(,@pi-coding-agent-executable "--mode" "rpc" ,@pi-coding-agent-extra-args) + :connection-type 'pipe + :stderr stderr-buf + :filter #'pi-coding-agent--process-filter + :sentinel #'pi-coding-agent--process-sentinel))) + (process-put proc 'pi-coding-agent-stderr-buf stderr-buf) + (when-let* ((stderr-proc (get-buffer-process stderr-buf))) + (set-process-query-on-exit-flag stderr-proc nil)) + proc) + (error + (when (buffer-live-p stderr-buf) + (kill-buffer stderr-buf)) + (signal (car err) (cdr err)))))) ;;;; State Management diff --git a/pi-coding-agent-render.el b/pi-coding-agent-render.el index 20db002..24722ae 100644 --- a/pi-coding-agent-render.el +++ b/pi-coding-agent-render.el @@ -642,6 +642,22 @@ Shows success or final failure with raw error." 'face 'pi-coding-agent-error-notice) "\n"))) +(defun pi-coding-agent--display-startup-error (error-msg &optional stderr) + "Display a pi startup ERROR-MSG and optional STDERR." + (pi-coding-agent--append-to-chat + (concat "\n" + (propertize "āœ— pi failed to start" + 'face 'pi-coding-agent-error-notice) + "\n\n" + (or error-msg "unknown error") + (when stderr + (concat "\n\n" + (propertize "stderr:" 'face 'pi-coding-agent-retry-notice) + "\n```text\n" + stderr + (unless (string-suffix-p "\n" stderr) "\n") + "```\n"))))) + (defun pi-coding-agent--display-extension-error (event) "Display extension error from extension_error EVENT." (let* ((extension-path (plist-get event :extensionPath)) diff --git a/pi-coding-agent-ui.el b/pi-coding-agent-ui.el index 0e30827..76f90b0 100644 --- a/pi-coding-agent-ui.el +++ b/pi-coding-agent-ui.el @@ -1725,7 +1725,7 @@ Accesses state from the linked chat buffer." (let ((input-buf (buffer-local-value 'pi-coding-agent--input-buffer chat-buf))) (pi-coding-agent--rpc-async proc '(:type "get_session_stats") (lambda (response) - (when (plist-get response :success) + (when (eq (plist-get response :success) t) (when (buffer-live-p chat-buf) (with-current-buffer chat-buf (setq pi-coding-agent--cached-stats (plist-get response :data)))) @@ -1739,7 +1739,7 @@ Accesses state from the linked chat buffer." "Apply get_state RESPONSE to CHAT-BUF. Updates buffer-local state variables and refreshes mode-line. Safely handles dead buffers by checking liveness first." - (when (and (plist-get response :success) + (when (and (eq (plist-get response :success) t) (buffer-live-p chat-buf)) (with-current-buffer chat-buf (let ((new-state (pi-coding-agent--extract-state-from-response response))) diff --git a/pi-coding-agent.el b/pi-coding-agent.el index 6149050..5835ee3 100644 --- a/pi-coding-agent.el +++ b/pi-coding-agent.el @@ -110,13 +110,19 @@ Returns the chat buffer." (proc pi-coding-agent--process)) ; Capture for closures (pi-coding-agent--rpc-async proc '(:type "get_state") (lambda (response) - (pi-coding-agent--apply-state-response buf response) - ;; Check if no model available and warn user - (when (and (plist-get response :success) - (buffer-live-p buf)) - (with-current-buffer buf - (unless (plist-get pi-coding-agent--state :model) - (pi-coding-agent--display-no-model-warning)))))) + (if (eq (plist-get response :success) t) + (progn + (pi-coding-agent--apply-state-response buf response) + ;; Check if no model available and warn user + (when (buffer-live-p buf) + (with-current-buffer buf + (unless (plist-get pi-coding-agent--state :model) + (pi-coding-agent--display-no-model-warning))))) + (when (buffer-live-p buf) + (with-current-buffer buf + (pi-coding-agent--display-startup-error + (plist-get response :error) + (plist-get response :stderr))))))) ;; Fetch commands via RPC (independent of get_state) (pi-coding-agent--fetch-commands proc (lambda (commands) diff --git a/test/pi-coding-agent-core-test.el b/test/pi-coding-agent-core-test.el index 6b299fb..18bf800 100644 --- a/test/pi-coding-agent-core-test.el +++ b/test/pi-coding-agent-core-test.el @@ -603,32 +603,144 @@ Display is handled by the display handler, not by state updates." ;;;; Executable Customization Tests -(defun pi-coding-agent-test--capture-process-command (executable extra-args) - "Return the command list that `--start-process' would pass to make-process. -Mocks `make-process' to capture :command, binding +(defun pi-coding-agent-test--capture-process-launch (executable extra-args) + "Return the launch plist that `--start-process' passes to make-process. +Mocks `make-process' to capture :command and :stderr, binding `pi-coding-agent-executable' to EXECUTABLE and `pi-coding-agent-extra-args' to EXTRA-ARGS." (let ((pi-coding-agent-executable executable) (pi-coding-agent-extra-args extra-args) - (captured nil)) - (cl-letf (((symbol-function 'make-process) - (lambda (&rest args) - (setq captured (plist-get args :command)) - nil))) - (ignore-errors (pi-coding-agent--start-process "/tmp/"))) - captured)) + (captured nil) + (dummy-proc (start-process "pi-coding-agent-capture" nil "cat"))) + (unwind-protect + (progn + (cl-letf (((symbol-function 'make-process) + (lambda (&rest args) + (setq captured (list :command (plist-get args :command) + :stderr (plist-get args :stderr))) + dummy-proc))) + (ignore-errors (pi-coding-agent--start-process "/tmp/"))) + captured) + (when-let* ((stderr-buf (plist-get captured :stderr))) + (when (buffer-live-p stderr-buf) + (kill-buffer stderr-buf))) + (when (process-live-p dummy-proc) + (delete-process dummy-proc))))) (ert-deftest pi-coding-agent-test-start-process-uses-custom-executable () "start-process builds command from `pi-coding-agent-executable'." - (should (equal (pi-coding-agent-test--capture-process-command '("npx" "pi") nil) + (should (equal (plist-get (pi-coding-agent-test--capture-process-launch '("npx" "pi") nil) + :command) '("npx" "pi" "--mode" "rpc")))) (ert-deftest pi-coding-agent-test-start-process-custom-executable-with-extra-args () "start-process combines custom executable and extra-args." - (should (equal (pi-coding-agent-test--capture-process-command - '("npx" "pi") '("-e" "/path/to/ext.ts")) + (should (equal (plist-get (pi-coding-agent-test--capture-process-launch + '("npx" "pi") '("-e" "/path/to/ext.ts")) + :command) '("npx" "pi" "--mode" "rpc" "-e" "/path/to/ext.ts")))) +(ert-deftest pi-coding-agent-test-start-process-captures-stderr-separately () + "start-process routes stderr away from the JSON-RPC stdout pipe." + (let ((launch (pi-coding-agent-test--capture-process-launch '("pi") nil))) + (should (bufferp (plist-get launch :stderr))))) + +(ert-deftest pi-coding-agent-test-start-process-disables-stderr-query () + "start-process disables kill prompts for Emacs's stderr pipe process." + (let ((pi-coding-agent-executable '("sh" "-c" "sleep 5")) + (pi-coding-agent-extra-args nil) + (proc nil)) + (unwind-protect + (progn + (setq proc (pi-coding-agent--start-process "/tmp/")) + (let* ((stderr-buf (process-get proc 'pi-coding-agent-stderr-buf)) + (stderr-proc (and stderr-buf (get-buffer-process stderr-buf)))) + (should (process-live-p proc)) + (should (buffer-live-p stderr-buf)) + (should stderr-proc) + (should-not (process-query-on-exit-flag stderr-proc)))) + (when (processp proc) + (pi-coding-agent--cleanup-process-stderr-buffer proc) + (when (process-live-p proc) + (delete-process proc)))))) + +(ert-deftest pi-coding-agent-test-cleanup-stderr-buffer-kills-stderr-process () + "stderr cleanup kills Emacs's stderr pipe process before killing its buffer." + (let* ((stderr-buf (generate-new-buffer " *pi-coding-agent-test-stderr-cleanup*")) + (proc (make-process :name "pi-coding-agent-cleanup-stderr" + :command '("sh" "-c" "sleep 5") + :connection-type 'pipe + :stderr stderr-buf))) + (unwind-protect + (let ((stderr-proc (get-buffer-process stderr-buf)) + (asked nil)) + (should stderr-proc) + (should (process-query-on-exit-flag stderr-proc)) + (process-put proc 'pi-coding-agent-stderr-buf stderr-buf) + (cl-letf (((symbol-function 'yes-or-no-p) + (lambda (&rest _) + (setq asked t) + (error "stderr cleanup should not prompt")))) + (pi-coding-agent--cleanup-process-stderr-buffer proc)) + (should-not asked) + (should-not (buffer-live-p stderr-buf)) + (should-not (process-live-p stderr-proc))) + (when (buffer-live-p stderr-buf) + (kill-buffer stderr-buf)) + (when (process-live-p proc) + (delete-process proc))))) + +(ert-deftest pi-coding-agent-test-process-exit-includes-stderr-excerpt () + "Process exit errors include stderr when available." + (let ((fake-proc (start-process "pi-coding-agent-exit" nil "cat")) + (stderr-buf (generate-new-buffer " *pi-coding-agent-test-stderr*")) + (response nil)) + (unwind-protect + (progn + (with-current-buffer stderr-buf + (insert "/tmp/undici.js:245\n" + "InvalidArgumentError: Invalid URL protocol: the URL must start with `http:` or `https:`.\n" + " at parseURL (/tmp/undici.js:245:11)\n" + "Node.js v24.9.0\n")) + (process-put fake-proc 'pi-coding-agent-stderr-buf stderr-buf) + (puthash "req_1" (lambda (r) (setq response r)) + (pi-coding-agent--get-pending-requests fake-proc)) + (pi-coding-agent--handle-process-exit fake-proc "exited abnormally with code 1") + (should (equal (plist-get response :error) + "Process exited: exited abnormally with code 1")) + (should (string-match-p "Invalid URL protocol" + (plist-get response :stderr))) + (should-not (buffer-live-p stderr-buf))) + (when (buffer-live-p stderr-buf) + (kill-buffer stderr-buf)) + (when (process-live-p fake-proc) + (delete-process fake-proc))))) + +(ert-deftest pi-coding-agent-test-process-exit-truncates-long-stderr () + "Long stderr excerpts keep the beginning and end without growing unbounded." + (let ((fake-proc (start-process "pi-coding-agent-exit-long" nil "cat")) + (stderr-buf (generate-new-buffer " *pi-coding-agent-test-stderr-long*")) + (response nil)) + (unwind-protect + (progn + (with-current-buffer stderr-buf + (insert "START InvalidArgumentError: Invalid URL protocol\n") + (insert (make-string 5000 ?x)) + (insert "\nEND Node.js v24.9.0\n")) + (process-put fake-proc 'pi-coding-agent-stderr-buf stderr-buf) + (puthash "req_2" (lambda (r) (setq response r)) + (pi-coding-agent--get-pending-requests fake-proc)) + (pi-coding-agent--handle-process-exit fake-proc "exited abnormally with code 1") + (let ((stderr (plist-get response :stderr))) + (should (string-match-p "START InvalidArgumentError" stderr)) + (should (string-match-p "stderr truncated" stderr)) + (should (string-match-p "END Node.js" stderr)) + (should (< (length stderr) 4100)))) + (when (buffer-live-p stderr-buf) + (kill-buffer stderr-buf)) + (when (process-live-p fake-proc) + (delete-process fake-proc))))) + ;;;; Process Filter Tests (ert-deftest pi-coding-agent-test-process-filter-inhibits-redisplay () diff --git a/test/pi-coding-agent-render-test.el b/test/pi-coding-agent-render-test.el index 2054c97..b546e0b 100644 --- a/test/pi-coding-agent-render-test.el +++ b/test/pi-coding-agent-render-test.el @@ -1434,6 +1434,18 @@ since we don't display them locally. Let pi's message_start handle it." (should (string-match-p "Error:" (buffer-string))) (should (string-match-p "unknown" (buffer-string))))) +(ert-deftest pi-coding-agent-test-display-startup-error () + "Startup failures should show the error and stderr excerpt." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (pi-coding-agent--display-startup-error + "Process exited: exited abnormally with code 1" + "InvalidArgumentError: Invalid URL protocol") + (should (string-match-p "failed to start" (buffer-string))) + (should (string-match-p "exited abnormally" (buffer-string))) + (should (string-match-p "InvalidArgumentError" (buffer-string))) + (should (string-match-p "stderr" (buffer-string))))) + (ert-deftest pi-coding-agent-test-display-extension-error () "extension_error event shows extension name and error." (with-temp-buffer diff --git a/test/pi-coding-agent-test.el b/test/pi-coding-agent-test.el index 8c5a059..af1ccaf 100644 --- a/test/pi-coding-agent-test.el +++ b/test/pi-coding-agent-test.el @@ -115,6 +115,34 @@ (ignore-errors (delete-file file)) (ignore-errors (delete-directory root t))))) +(ert-deftest pi-coding-agent-test-setup-session-shows-startup-error-from-initial-state-request () + "Initial startup failure should be rendered into the chat buffer." + (let ((root (pi-coding-agent-test--make-temp-directory + "pi-coding-agent-test-startup-error-")) + (proc (start-process "pi-coding-agent-startup-error" nil "cat")) + (chat nil)) + (unwind-protect + (cl-letf (((symbol-function 'project-current) (lambda (&rest _) nil)) + ((symbol-function 'pi-coding-agent--start-process) (lambda (_) proc)) + ((symbol-function 'pi-coding-agent--fetch-commands) (lambda (&rest _) nil)) + ((symbol-function 'pi-coding-agent--rpc-async) + (lambda (_proc cmd callback) + (should (equal (plist-get cmd :type) "get_state")) + (funcall callback + '(:type "response" + :command "get_state" + :success :false + :error "Process exited: exited abnormally with code 1" + :stderr "InvalidArgumentError: Invalid URL protocol"))))) + (setq chat (pi-coding-agent--setup-session root nil)) + (should (buffer-live-p chat)) + (with-current-buffer chat + (should (string-match-p "failed to start" (buffer-string))) + (should (string-match-p "Invalid URL protocol" (buffer-string))))) + (when (process-live-p proc) + (delete-process proc)) + (pi-coding-agent-test--kill-session-buffers root)))) + (ert-deftest pi-coding-agent-test-from-chat-buffer-noop-when-both-visible () "From chat, `pi-coding-agent' avoids redisplay and focuses input." (let ((root "/tmp/pi-coding-agent-test-chat-visible/")