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
45 changes: 28 additions & 17 deletions pi-core.el
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,14 @@ Returns the process object."

;;;; State Management

(defvar-local pi--status 'idle
"Current status of the pi session (buffer-local in chat buffer).
One of: `idle', `sending', `streaming', `compacting'.
This is the single source of truth for session activity state.")

(defvar-local pi--state nil
"Current state of the pi session (buffer-local in chat buffer).
A plist with keys like :is-streaming, :model, :thinking-level, etc.")
A plist with keys like :model, :thinking-level, :messages, etc.")

(defvar-local pi--state-timestamp nil
"Time when state was last updated (buffer-local in chat buffer).")
Expand All @@ -194,11 +199,11 @@ A plist with keys like :is-streaming, :model, :thinking-level, etc.")
"Return t if state should be verified with get_state.
Verification is needed when:
- State and timestamp exist
- Not currently streaming
- Session is idle (not streaming, sending, or compacting)
- Timestamp is older than `pi--state-verify-interval' seconds."
(and pi--state
pi--state-timestamp
(not (plist-get pi--state :is-streaming))
(eq pi--status 'idle)
(> (- (float-time) pi--state-timestamp)
pi--state-verify-interval)))

Expand All @@ -212,17 +217,18 @@ JSON true (t) stays t, JSON false (:false) becomes nil."
(if (pi--json-false-p value) nil value))

(defun pi--update-state-from-event (event)
"Update `pi--state' based on EVENT.
Handles agent lifecycle, message events, and error/retry events."
"Update `pi--status' and `pi--state' based on EVENT.
Handles agent lifecycle, message events, and error/retry events.
Sets `pi--status' to `streaming' on agent_start, `idle' on agent_end."
(let ((type (plist-get event :type)))
(pcase type
("agent_start"
(plist-put pi--state :is-streaming t)
(setq pi--status 'streaming)
(plist-put pi--state :is-retrying nil)
(plist-put pi--state :last-error nil)
(setq pi--state-timestamp (float-time)))
("agent_end"
(plist-put pi--state :is-streaming nil)
(setq pi--status 'idle)
(plist-put pi--state :is-retrying nil)
(plist-put pi--state :messages (plist-get event :messages))
(setq pi--state-timestamp (float-time)))
Expand Down Expand Up @@ -313,21 +319,26 @@ Only processes successful responses for state-modifying commands."
(setq pi--state-timestamp (float-time)))
("get_state"
(let ((new-state (pi--extract-state-from-response response)))
(setq pi--state new-state
(setq pi--status (plist-get new-state :status)
pi--state new-state
pi--state-timestamp (float-time))))))))

(defun pi--extract-state-from-response (response)
"Extract state plist from a get_state RESPONSE.
Converts camelCase keys to kebab-case and normalizes booleans."
Converts camelCase keys to kebab-case and normalizes booleans.
Returns plist with :status key for setting `pi--status'."
(when-let ((data (plist-get response :data)))
(list :model (plist-get data :model)
:thinking-level (plist-get data :thinkingLevel)
:is-streaming (pi--normalize-boolean (plist-get data :isStreaming))
:is-compacting (pi--normalize-boolean (plist-get data :isCompacting))
:session-id (plist-get data :sessionId)
:session-file (plist-get data :sessionFile)
:message-count (plist-get data :messageCount)
:queued-message-count (plist-get data :queuedMessageCount))))
(let ((is-streaming (pi--normalize-boolean (plist-get data :isStreaming)))
(is-compacting (pi--normalize-boolean (plist-get data :isCompacting))))
(list :status (cond (is-streaming 'streaming)
(is-compacting 'compacting)
(t 'idle))
:model (plist-get data :model)
:thinking-level (plist-get data :thinkingLevel)
:session-id (plist-get data :sessionId)
:session-file (plist-get data :sessionFile)
:message-count (plist-get data :messageCount)
:queued-message-count (plist-get data :queuedMessageCount)))))

(provide 'pi-core)
;;; pi-core.el ends here
18 changes: 9 additions & 9 deletions pi.el
Original file line number Diff line number Diff line change
Expand Up @@ -487,9 +487,8 @@ TYPE is :chat or :input. Returns the buffer."
(defvar-local pi--streaming-marker nil
"Marker for current streaming insertion point.")

(defvar-local pi--status 'idle
"Current status of the pi session.
One of: idle, sending, streaming.")
;; pi--status is defined in pi-core.el as the single source of truth
;; for session activity state (idle, sending, streaming, compacting)

(defvar-local pi--cached-stats nil
"Cached session statistics for header-line display.
Expand Down Expand Up @@ -641,7 +640,8 @@ If TIMESTAMP (Emacs time value) is provided, display it in the header."

(defun pi--display-agent-start ()
"Display separator for new agent turn.
Only shows the Assistant header once per prompt, even during retries."
Only shows the Assistant header once per prompt, even during retries.
Note: `pi--status' is set to `streaming' by `pi--update-state-from-event'."
(setq pi--aborted nil) ; Reset abort flag for new turn
;; Only show header if not already shown for this prompt
(unless pi--assistant-header-shown
Expand All @@ -653,7 +653,6 @@ Only shows the Assistant header once per prompt, even during retries."
;; streaming-marker: where new deltas are inserted
(setq pi--message-start-marker (copy-marker (point-max) nil))
(setq pi--streaming-marker (copy-marker (point-max) t))
(setq pi--status 'streaming)
(pi--spinner-start)
(force-mode-line-update))

Expand Down Expand Up @@ -699,7 +698,8 @@ CONTENT is ignored - we use what was already streamed."
(set-marker pi--streaming-marker (point)))))))

(defun pi--display-agent-end ()
"Display end of agent turn."
"Display end of agent turn.
Note: `pi--status' is set to `idle' by `pi--update-state-from-event'."
(let ((inhibit-read-only t))
;; Show abort indicator if aborted
(when pi--aborted
Expand All @@ -716,7 +716,6 @@ CONTENT is ignored - we use what was already streamed."
(skip-chars-backward "\n")
(delete-region (point) (point-max))
(insert "\n\n")))
(setq pi--status 'idle)
(pi--spinner-stop)
(pi--refresh-header))

Expand Down Expand Up @@ -2317,8 +2316,9 @@ Returns the chat buffer."
(when (and (plist-get response :success)
(buffer-live-p buf))
(with-current-buffer buf
(setq pi--state
(pi--extract-state-from-response response))
(let ((new-state (pi--extract-state-from-response response)))
(setq pi--status (plist-get new-state :status)
pi--state new-state))
;; Check if no model available and warn user
(unless (plist-get pi--state :model)
(pi--display-no-model-warning))
Expand Down
82 changes: 55 additions & 27 deletions test/pi-core-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -255,20 +255,23 @@
;;;; State Event Handling Tests

(ert-deftest pi-test-event-agent-start-sets-streaming ()
"agent_start event sets is-streaming to t."
(let ((pi--state (list :is-streaming nil)))
"agent_start event sets pi--status to streaming."
(let ((pi--status 'idle)
(pi--state nil))
(pi--update-state-from-event '(:type "agent_start"))
(should (eq (plist-get pi--state :is-streaming) t))))
(should (eq pi--status 'streaming))))

(ert-deftest pi-test-event-agent-end-clears-streaming ()
"agent_end event sets is-streaming to nil."
(let ((pi--state (list :is-streaming t)))
"agent_end event sets pi--status to idle."
(let ((pi--status 'streaming)
(pi--state nil))
(pi--update-state-from-event '(:type "agent_end" :messages []))
(should (eq (plist-get pi--state :is-streaming) nil))))
(should (eq pi--status 'idle))))

(ert-deftest pi-test-event-agent-end-stores-messages ()
"agent_end event stores messages in state."
(let ((pi--state (list :is-streaming t :messages nil))
(let ((pi--status 'streaming)
(pi--state (list :messages nil))
(msgs [(:role "user" :content "hi") (:role "assistant" :content "hello")]))
(pi--update-state-from-event (list :type "agent_end" :messages msgs))
(should (plist-get pi--state :messages))))
Expand Down Expand Up @@ -370,31 +373,36 @@

(ert-deftest pi-test-state-needs-verify-when-stale ()
"State needs verification when timestamp is old."
(let ((pi--state (list :is-streaming nil))
(let ((pi--status 'idle)
(pi--state (list :model "test"))
(pi--state-timestamp (- (float-time) 60))) ;; 60 seconds ago
(should (pi--state-needs-verification-p))))

(ert-deftest pi-test-state-no-verify-when-fresh ()
"State does not need verification when recently updated."
(let ((pi--state (list :is-streaming nil))
(let ((pi--status 'idle)
(pi--state (list :model "test"))
(pi--state-timestamp (float-time))) ;; Now
(should (not (pi--state-needs-verification-p)))))

(ert-deftest pi-test-state-no-verify-during-streaming ()
"State does not need verification while streaming."
(let ((pi--state (list :is-streaming t))
(let ((pi--status 'streaming)
(pi--state (list :model "test"))
(pi--state-timestamp (- (float-time) 60))) ;; Old, but streaming
(should (not (pi--state-needs-verification-p)))))

(ert-deftest pi-test-state-no-verify-when-no-timestamp ()
"State does not need verification when not initialized."
(let ((pi--state nil)
(let ((pi--status 'idle)
(pi--state nil)
(pi--state-timestamp nil))
(should (not (pi--state-needs-verification-p)))))

(ert-deftest pi-test-event-dispatch-updates-state ()
"Events update buffer-local state via handler."
(let ((pi--state (list :is-streaming nil))
(let ((pi--status 'idle)
(pi--state nil)
(fake-proc (start-process "cat" nil "cat")))
(unwind-protect
(progn
Expand All @@ -403,7 +411,7 @@
(lambda (e)
(pi--update-state-from-event e)))
(pi--handle-event fake-proc '(:type "agent_start"))
(should (eq (plist-get pi--state :is-streaming) t)))
(should (eq pi--status 'streaming)))
(delete-process fake-proc))))

;;;; State Management Tests
Expand All @@ -422,23 +430,39 @@
(should state)
(should (equal (plist-get state :session-id) "test-123"))
(should (equal (plist-get state :thinking-level) "medium"))
(should (eq (plist-get state :is-streaming) nil))
(should (eq (plist-get state :status) 'idle))
(should (plist-get state :model)))))

(ert-deftest pi-test-state-converts-json-false-to-nil ()
"JSON :false is converted to nil for boolean fields."
(ert-deftest pi-test-state-extract-status-idle ()
"Extracted state has status idle when not streaming or compacting."
(let ((response '(:type "response"
:success t
:data (:isStreaming :false :isCompacting :false))))
(let ((state (pi--extract-state-from-response response)))
(should (eq (plist-get state :is-streaming) nil))
(should (eq (plist-get state :is-compacting) nil)))))
(should (eq (plist-get state :status) 'idle)))))

(ert-deftest pi-test-state-extract-status-streaming ()
"Extracted state has status streaming when isStreaming is true."
(let ((response '(:type "response"
:success t
:data (:isStreaming t :isCompacting :false))))
(let ((state (pi--extract-state-from-response response)))
(should (eq (plist-get state :status) 'streaming)))))

(ert-deftest pi-test-state-extract-status-compacting ()
"Extracted state has status compacting when isCompacting is true."
(let ((response '(:type "response"
:success t
:data (:isStreaming :false :isCompacting t))))
(let ((state (pi--extract-state-from-response response)))
(should (eq (plist-get state :status) 'compacting)))))

;;;; Auto-Retry Event State Tests

(ert-deftest pi-test-event-auto-retry-start-sets-retrying ()
"auto_retry_start event sets is-retrying to t."
(let ((pi--state (list :is-streaming t :is-retrying nil)))
(let ((pi--status 'streaming)
(pi--state (list :is-retrying nil)))
(pi--update-state-from-event
'(:type "auto_retry_start"
:attempt 1
Expand All @@ -451,7 +475,8 @@

(ert-deftest pi-test-event-auto-retry-end-success-clears-retrying ()
"auto_retry_end with success clears is-retrying."
(let ((pi--state (list :is-streaming t :is-retrying t :retry-attempt 2)))
(let ((pi--status 'streaming)
(pi--state (list :is-retrying t :retry-attempt 2)))
(pi--update-state-from-event
'(:type "auto_retry_end"
:success t
Expand All @@ -460,7 +485,8 @@

(ert-deftest pi-test-event-auto-retry-end-failure-stores-error ()
"auto_retry_end with failure stores final error."
(let ((pi--state (list :is-streaming t :is-retrying t)))
(let ((pi--status 'streaming)
(pi--state (list :is-retrying t)))
(pi--update-state-from-event
'(:type "auto_retry_end"
:success :false
Expand All @@ -471,7 +497,8 @@

(ert-deftest pi-test-event-hook-error-stores-error ()
"hook_error event stores error message in state."
(let ((pi--state (list :is-streaming t)))
(let ((pi--status 'streaming)
(pi--state (list :last-error nil)))
(pi--update-state-from-event
'(:type "hook_error"
:hookPath "/path/to/hook.ts"
Expand All @@ -482,19 +509,20 @@

(ert-deftest pi-test-event-agent-start-clears-error-state ()
"agent_start event clears error and retry state."
(let ((pi--state (list :is-streaming nil
:is-retrying t
(let ((pi--status 'idle)
(pi--state (list :is-retrying t
:last-error "Previous error")))
(pi--update-state-from-event '(:type "agent_start"))
(should (eq (plist-get pi--state :is-streaming) t))
(should (eq pi--status 'streaming))
(should (eq (plist-get pi--state :is-retrying) nil))
(should (eq (plist-get pi--state :last-error) nil))))

(ert-deftest pi-test-event-agent-end-clears-retry-state ()
"agent_end event clears retry state."
(let ((pi--state (list :is-streaming t :is-retrying t)))
(let ((pi--status 'streaming)
(pi--state (list :is-retrying t)))
(pi--update-state-from-event '(:type "agent_end" :messages []))
(should (eq (plist-get pi--state :is-streaming) nil))
(should (eq pi--status 'idle))
(should (eq (plist-get pi--state :is-retrying) nil))))

(provide 'pi-core-test)
Expand Down
12 changes: 8 additions & 4 deletions test/pi-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -891,7 +891,8 @@ then proper highlighting once block is closed."
"pi--handle-display-event handles auto_retry_start."
(with-temp-buffer
(pi-chat-mode)
(let ((pi--state (list :is-streaming t)))
(let ((pi--status 'streaming)
(pi--state nil))
(pi--handle-display-event '(:type "auto_retry_start"
:attempt 1
:maxAttempts 3
Expand All @@ -903,7 +904,8 @@ then proper highlighting once block is closed."
"pi--handle-display-event handles auto_retry_end."
(with-temp-buffer
(pi-chat-mode)
(let ((pi--state (list :is-streaming t)))
(let ((pi--status 'streaming)
(pi--state nil))
(pi--handle-display-event '(:type "auto_retry_end"
:success t
:attempt 2))
Expand All @@ -913,7 +915,8 @@ then proper highlighting once block is closed."
"pi--handle-display-event handles hook_error."
(with-temp-buffer
(pi-chat-mode)
(let ((pi--state (list :is-streaming t)))
(let ((pi--status 'streaming)
(pi--state (list :last-error nil)))
(pi--handle-display-event '(:type "hook_error"
:hookPath "/path/hook.ts"
:event "before_send"
Expand All @@ -926,7 +929,8 @@ then proper highlighting once block is closed."
(pi-chat-mode)
;; Need to set up markers first
(pi--display-agent-start)
(let ((pi--state (list :is-streaming t :current-message '(:role "assistant"))))
(let ((pi--status 'streaming)
(pi--state (list :current-message '(:role "assistant"))))
(pi--handle-display-event '(:type "message_update"
:message (:role "assistant")
:assistantMessageEvent (:type "error"
Expand Down
Loading