Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 67 additions & 19 deletions pi-coding-agent-core.el
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
16 changes: 16 additions & 0 deletions pi-coding-agent-render.el
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
4 changes: 2 additions & 2 deletions pi-coding-agent-ui.el
Original file line number Diff line number Diff line change
Expand Up @@ -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))))
Expand All @@ -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)))
Expand Down
20 changes: 13 additions & 7 deletions pi-coding-agent.el
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
138 changes: 125 additions & 13 deletions test/pi-coding-agent-core-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -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 ()
Expand Down
12 changes: 12 additions & 0 deletions test/pi-coding-agent-render-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions test/pi-coding-agent-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -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/")
Expand Down
Loading