From 9bfa30ca5c3a150937f5baaaba80a0d72a1a01bd Mon Sep 17 00:00:00 2001 From: Daniel Nouri Date: Sun, 10 May 2026 22:26:51 +0200 Subject: [PATCH] Make session resume use pi's session file Pi may store sessions outside ~/.pi/agent/sessions, for example when the user sets PI_CODING_AGENT_DIR or resumes from a custom path. Use the sessionFile reported by get_state and offer valid sibling session files from that directory instead of reconstructing pi's storage path in Emacs. If cached state has no session file yet, refresh get_state before listing sessions rather than making the user retry. Filter out JSONL files that are not pi sessions so arbitrary sibling files are not offered in the resume picker. Fixes #205. --- pi-coding-agent-menu.el | 190 ++++++++++------- test/pi-coding-agent-input-test.el | 42 ---- test/pi-coding-agent-menu-test.el | 332 ++++++++++++++++++++++------- 3 files changed, 367 insertions(+), 197 deletions(-) diff --git a/pi-coding-agent-menu.el b/pi-coding-agent-menu.el index c705b08..aab40e7 100644 --- a/pi-coding-agent-menu.el +++ b/pi-coding-agent-menu.el @@ -189,14 +189,36 @@ from either chat or input buffer." (message "Pi: New session started")) (message "Pi: New session cancelled"))))))) -(defun pi-coding-agent--session-dir-name (dir) - "Convert DIR to session directory name. -Matches pi's encoding: --path-with-dashes--. -Note: Handles both Unix and Windows path separators." - (let* ((clean-dir (directory-file-name dir)) ; Remove trailing slash - (safe-path (replace-regexp-in-string "[/\\\\:]" "-" - (replace-regexp-in-string "^[/\\\\]" "" clean-dir)))) - (concat "--" safe-path "--"))) +(defun pi-coding-agent--session-list-directory (&optional chat-buf) + "Return the directory containing CHAT-BUF's current JSONL session file. +Return nil when the current state has no usable session file. Relative +session file names are resolved from the chat buffer's stable session +directory." + (let ((chat-buf (or chat-buf (pi-coding-agent--get-chat-buffer)))) + (when (and chat-buf (buffer-live-p chat-buf)) + (with-current-buffer chat-buf + (when-let* ((session-file (plist-get pi-coding-agent--state + :session-file)) + ((stringp session-file)) + ((not (string-empty-p session-file)))) + (file-name-directory + (expand-file-name session-file + (pi-coding-agent--chat-session-directory + chat-buf)))))))) + +(defun pi-coding-agent--with-session-list-directory (proc chat-buf callback) + "Call CALLBACK with the session list directory for CHAT-BUF. +When cached state has no session file, fetch fresh state from PROC first." + (if-let* ((session-dir (pi-coding-agent--session-list-directory chat-buf))) + (funcall callback session-dir) + (pi-coding-agent--rpc-async proc '(:type "get_state") + (lambda (response) + (when (and (plist-get response :success) + (buffer-live-p chat-buf)) + (pi-coding-agent--apply-state-response chat-buf response)) + (when (buffer-live-p chat-buf) + (funcall callback + (pi-coding-agent--session-list-directory chat-buf))))))) (defun pi-coding-agent--session-metadata (path) "Extract metadata from session file PATH. @@ -251,43 +273,43 @@ Call this from the chat buffer after switching or loading a session." (let ((metadata (pi-coding-agent--session-metadata session-file))) (setq pi-coding-agent--session-name (plist-get metadata :session-name))))) -(defun pi-coding-agent--list-sessions (dir) - "List available session files for project DIR. -Returns list of absolute paths to .jsonl files, sorted by modification -time with most recently used first." - (let* ((sessions-base (expand-file-name "~/.pi/agent/sessions/")) - (session-dir (expand-file-name (pi-coding-agent--session-dir-name dir) sessions-base))) - (when (file-directory-p session-dir) - ;; Sort by modification time descending (most recently used first) - (sort (directory-files session-dir t "\\.jsonl$") +(defun pi-coding-agent--list-sessions (session-dir) + "List valid session files in SESSION-DIR. +Returns absolute paths to JSONL pi sessions, sorted by modification time with +most recently used first." + (when (and session-dir (file-directory-p session-dir)) + (let ((sessions (delq nil + (mapcar (lambda (path) + (and (pi-coding-agent--session-metadata path) + path)) + (directory-files session-dir t + "\\.jsonl\\'"))))) + (sort sessions (lambda (a b) - (time-less-p (file-attribute-modification-time (file-attributes b)) - (file-attribute-modification-time (file-attributes a)))))))) + (time-less-p (file-attribute-modification-time + (file-attributes b)) + (file-attribute-modification-time + (file-attributes a)))))))) (defun pi-coding-agent--format-session-choice (path) "Format session PATH for display in selector. -Returns (display-string . path) for `completing-read'. -Prefers session name over first message when available." - (let ((metadata (pi-coding-agent--session-metadata path))) - (if metadata - (let* ((modified-time (plist-get metadata :modified-time)) - (session-name (plist-get metadata :session-name)) - (first-msg (plist-get metadata :first-message)) - (msg-count (plist-get metadata :message-count)) - (relative-time (pi-coding-agent--format-relative-time modified-time)) - ;; Prefer session name, fall back to first message preview - (label (cond - (session-name (pi-coding-agent--truncate-string session-name 50)) - (first-msg (pi-coding-agent--truncate-string first-msg 50)) - (t nil))) - (display (if label - (format "%s · %s (%d msgs)" - label relative-time msg-count) - (format "[empty session] · %s" relative-time)))) - (cons display path)) - ;; Fallback to filename if metadata extraction fails - (let ((filename (file-name-nondirectory path))) - (cons filename path))))) +Returns (display-string . path) for `completing-read', or nil when PATH is not +a valid pi session. Prefers session name over first message when available." + (when-let* ((metadata (pi-coding-agent--session-metadata path))) + (let* ((modified-time (plist-get metadata :modified-time)) + (session-name (plist-get metadata :session-name)) + (first-msg (plist-get metadata :first-message)) + (msg-count (plist-get metadata :message-count)) + (relative-time (pi-coding-agent--format-relative-time modified-time)) + (label (cond + (session-name (pi-coding-agent--truncate-string session-name 50)) + (first-msg (pi-coding-agent--truncate-string first-msg 50)) + (t nil))) + (display (if label + (format "%s · %s (%d msgs)" + label relative-time msg-count) + (format "[empty session] · %s" relative-time)))) + (cons display path)))) (defun pi-coding-agent--reset-session-state () "Reset all session-specific state for a new session. @@ -465,43 +487,65 @@ chat buffer from session history." (message "Pi: Failed to reload - %s" (or (plist-get response :error) "unknown error")))))))))))) +(defun pi-coding-agent--resume-selected-session (proc chat-buf selected-path) + "Resume SELECTED-PATH using PROC and rebuild CHAT-BUF from its history." + (pi-coding-agent--rpc-async + proc + (list :type "switch_session" :sessionPath selected-path) + (lambda (response) + (let* ((data (plist-get response :data)) + (cancelled (plist-get data :cancelled))) + (if (and (plist-get response :success) + (pi-coding-agent--json-false-p cancelled)) + (progn + (pi-coding-agent--refresh-session-state proc chat-buf selected-path) + (pi-coding-agent--load-session-history + proc + (lambda (count) + (message "Pi: Resumed session (%d messages)" count)) + chat-buf)) + (message "Pi: Failed to resume session")))))) + +(defun pi-coding-agent--resume-session-from-directory (proc chat-buf session-dir) + "Prompt for a session from SESSION-DIR, then resume it using PROC. +CHAT-BUF is rebuilt from the selected session history." + (let ((sessions (pi-coding-agent--list-sessions session-dir))) + (if (null sessions) + (message "Pi: No previous sessions found") + (let* ((choices (delq nil + (mapcar #'pi-coding-agent--format-session-choice + sessions))) + (choice-strings (mapcar #'car choices))) + (if (null choices) + (message "Pi: No previous sessions found") + (let* ((choice (completing-read + "Resume session: " + (lambda (string pred action) + (if (eq action 'metadata) + '(metadata (display-sort-function . identity)) + (complete-with-action action choice-strings + string pred))) + nil t)) + (selected-path (cdr (assoc choice choices)))) + (when selected-path + (pi-coding-agent--resume-selected-session + proc chat-buf selected-path)))))))) + (defun pi-coding-agent-resume-session () - "Resume a previous pi session from the current project." + "Resume a previous pi session stored beside the current session file." (interactive) (when-let* ((proc (pi-coding-agent--get-process)) - (dir (pi-coding-agent--session-directory)) (chat-buf (pi-coding-agent--get-chat-buffer))) (when (pi-coding-agent--session-transition-ready-p chat-buf "resume") - (let ((sessions (pi-coding-agent--list-sessions dir))) - (if (null sessions) - (message "Pi: No previous sessions found") - (let* ((choices (mapcar #'pi-coding-agent--format-session-choice sessions)) - (choice-strings (mapcar #'car choices)) - (choice (completing-read "Resume session: " - (lambda (string pred action) - (if (eq action 'metadata) - '(metadata (display-sort-function . identity)) - (complete-with-action action choice-strings string pred))) - nil t)) - (selected-path (cdr (assoc choice choices)))) - (when selected-path - (pi-coding-agent--rpc-async - proc - (list :type "switch_session" :sessionPath selected-path) - (lambda (response) - (let* ((data (plist-get response :data)) - (cancelled (plist-get data :cancelled))) - (if (and (plist-get response :success) - (pi-coding-agent--json-false-p cancelled)) - (progn - (pi-coding-agent--refresh-session-state - proc chat-buf selected-path) - (pi-coding-agent--load-session-history - proc - (lambda (count) - (message "Pi: Resumed session (%d messages)" count)) - chat-buf)) - (message "Pi: Failed to resume session")))))))))))) + (pi-coding-agent--with-session-list-directory + proc chat-buf + (lambda (session-dir) + (cond + ((not session-dir) + (message "Pi: Session file not available")) + ((pi-coding-agent--session-transition-ready-p chat-buf "resume") + (pi-coding-agent--resume-session-from-directory + proc chat-buf session-dir)))))))) ;;;; Model and Thinking diff --git a/test/pi-coding-agent-input-test.el b/test/pi-coding-agent-input-test.el index 74ca247..1a1da64 100644 --- a/test/pi-coding-agent-input-test.el +++ b/test/pi-coding-agent-input-test.el @@ -1367,48 +1367,6 @@ This ensures history loads correctly when callback runs in arbitrary context." (when (buffer-live-p chat-buf) (kill-buffer chat-buf))))) -(ert-deftest pi-coding-agent-test-session-dir-name () - "Session directory name derived from project path." - (should (equal (pi-coding-agent--session-dir-name "/home/daniel/co/pi-coding-agent") - "--home-daniel-co-pi-coding-agent--")) - (should (equal (pi-coding-agent--session-dir-name "/tmp/test") - "--tmp-test--"))) - -(ert-deftest pi-coding-agent-test-list-sessions-sorted-by-mtime () - "Sessions are sorted by modification time, most recent first. -Regression test for #25: sessions were sorted by filename (creation time) -and then re-sorted alphabetically by completing-read." - (let* ((temp-base (make-temp-file "pi-coding-agent-sessions-" t)) - (session-dir (expand-file-name "--test-project--" temp-base)) - ;; Create files with names that would sort differently alphabetically - (old-file (expand-file-name "2024-01-01_10-00-00.jsonl" session-dir)) - (new-file (expand-file-name "2024-01-01_09-00-00.jsonl" session-dir))) - (unwind-protect - (progn - (make-directory session-dir t) - (let* ((now (current-time)) - (old-time (time-subtract now (seconds-to-time 10))) - (new-time (time-subtract now (seconds-to-time 5)))) - ;; Create "old" file first - (with-temp-file old-file (insert "{}")) - (set-file-times old-file old-time) - ;; Create "new" file second (more recent mtime despite earlier filename) - (with-temp-file new-file (insert "{}")) - (set-file-times new-file new-time)) - ;; Directly call directory-files and sort logic to test sorting - (let* ((files (directory-files session-dir t "\\.jsonl$")) - (sorted (sort files - (lambda (a b) - (time-less-p - (file-attribute-modification-time (file-attributes b)) - (file-attribute-modification-time (file-attributes a))))))) - ;; new-file should be first (most recent mtime) - ;; even though "09-00-00" < "10-00-00" alphabetically - (should (equal (length sorted) 2)) - (should (string-suffix-p "09-00-00.jsonl" (car sorted))))) - ;; Cleanup - (delete-directory temp-base t)))) - (ert-deftest pi-coding-agent-test-session-metadata-extracts-first-message () "pi-coding-agent--session-metadata extracts first user message text." (let ((temp-file (make-temp-file "pi-coding-agent-test-session" nil ".jsonl"))) diff --git a/test/pi-coding-agent-menu-test.el b/test/pi-coding-agent-menu-test.el index d14cbf0..ace3938 100644 --- a/test/pi-coding-agent-menu-test.el +++ b/test/pi-coding-agent-menu-test.el @@ -1049,71 +1049,151 @@ replaced by the resumed or forked history." (overlay-get ov 'pi-coding-agent-tool-block)) (overlays-in (point-min) (point-max)))))) +(defun pi-coding-agent-test--write-session-file (path &optional text) + "Write a minimal pi session file to PATH with optional first message TEXT." + (with-temp-file path + (insert (json-encode '(:type "session" :id "test")) "\n") + (when text + (insert (json-encode `(:type "message" + :message (:role "user" + :content [(:type "text" :text ,text)]))) + "\n")))) + +(ert-deftest pi-coding-agent-test-session-list-directory-uses-session-file-parent () + "Session listing uses the current JSONL session file parent directory." + (let* ((project-dir (pi-coding-agent-test--make-temp-directory + "pi-coding-agent-project-")) + (expected-dir (file-name-as-directory + (expand-file-name "sessions" project-dir)))) + (unwind-protect + (with-temp-buffer + (pi-coding-agent-chat-mode) + (pi-coding-agent--set-chat-session-identity project-dir) + (setq pi-coding-agent--state '(:session-file "sessions/current.jsonl")) + (should (equal (pi-coding-agent--session-list-directory (current-buffer)) + expected-dir)) + (setq pi-coding-agent--state '(:session-file "")) + (should-not (pi-coding-agent--session-list-directory (current-buffer))) + (setq pi-coding-agent--state '(:session-file :json-false)) + (should-not (pi-coding-agent--session-list-directory (current-buffer)))) + (delete-directory project-dir t)))) + +(ert-deftest pi-coding-agent-test-list-sessions-sorted-by-mtime () + "Session files are sorted by modification time, most recent first." + (let* ((session-dir (pi-coding-agent-test--make-temp-directory + "pi-coding-agent-sessions-")) + (old-file (expand-file-name "2024-01-01_10-00-00.jsonl" session-dir)) + (new-file (expand-file-name "2024-01-01_09-00-00.jsonl" session-dir))) + (unwind-protect + (progn + (let* ((now (current-time)) + (old-time (time-subtract now (seconds-to-time 10))) + (new-time (time-subtract now (seconds-to-time 5)))) + (pi-coding-agent-test--write-session-file old-file "old") + (set-file-times old-file old-time) + (pi-coding-agent-test--write-session-file new-file "new") + (set-file-times new-file new-time)) + (let ((sessions (pi-coding-agent--list-sessions session-dir))) + (should (equal (length sessions) 2)) + (should (string-suffix-p "09-00-00.jsonl" (car sessions))))) + (delete-directory session-dir t)))) + +(ert-deftest pi-coding-agent-test-list-sessions-filters-invalid-jsonl-files () + "Session listing ignores JSONL files that are not pi sessions." + (let* ((session-dir (pi-coding-agent-test--make-temp-directory + "pi-coding-agent-sessions-")) + (session-file (expand-file-name "session.jsonl" session-dir)) + (other-file (expand-file-name "other.jsonl" session-dir))) + (unwind-protect + (progn + (pi-coding-agent-test--write-session-file session-file "hello") + (with-temp-file other-file (insert "{}\n")) + (should (equal (pi-coding-agent--list-sessions session-dir) + (list session-file)))) + (delete-directory session-dir t)))) + (ert-deftest pi-coding-agent-test-resume-session-from-input-switches-session-and-rebuilds-history () "Resuming from the input buffer refreshes the linked chat and session state." - (let ((dir "/tmp/pi-coding-agent-test-resume-happy/") - (shown-message nil) - (rpc-calls nil)) - (pi-coding-agent-test-with-mock-session dir - (let* ((chat-buf (get-buffer (pi-coding-agent-test--chat-buffer-name dir))) - (input-buf (get-buffer (pi-coding-agent-test--input-buffer-name dir))) - (target-session "/tmp/resume-target.jsonl") - (messages [(:role "assistant" - :content [(:type "text" :text "Resumed history")] - :timestamp 1704067200000)])) - (pi-coding-agent-test--seed-stale-session-rebuild-state - chat-buf "STALE RESUME CONTENT") - (cl-letf (((symbol-function 'pi-coding-agent--session-directory) - (lambda () dir)) - ((symbol-function 'pi-coding-agent--list-sessions) - (lambda (_dir) (list target-session))) - ((symbol-function 'pi-coding-agent--format-session-choice) - (lambda (_path) - (cons "Resume target" target-session))) - ((symbol-function 'completing-read) - (lambda (&rest _) "Resume target")) - ((symbol-function 'pi-coding-agent--rpc-async) - (lambda (_proc cmd cb) - (push (plist-get cmd :type) rpc-calls) - (pcase (plist-get cmd :type) - ("switch_session" - (should (equal (plist-get cmd :sessionPath) - target-session)) - (funcall cb '(:success t :data (:cancelled :false)))) - ("get_state" - (funcall cb '(:success t - :data (:model (:name "resumed-model") - :thinkingLevel "medium" - :isStreaming :json-false - :isCompacting :json-false - :sessionId "resumed-session-id" - :sessionFile "/tmp/resumed.jsonl" - :messageCount 1 - :pendingMessageCount 0)))) - ("get_messages" - (funcall cb (list :success t :data (list :messages messages)))) - (_ - (ert-fail (format "Unexpected RPC during resume test: %S" - cmd)))))) - ((symbol-function 'pi-coding-agent--update-session-name-from-file) - #'ignore) - ((symbol-function 'pi-coding-agent--refresh-header) #'ignore) - ((symbol-function 'message) - (lambda (fmt &rest args) - (setq shown-message (apply #'format fmt args))))) - (with-current-buffer input-buf - (pi-coding-agent-resume-session))) - (with-current-buffer chat-buf - (should (equal (plist-get pi-coding-agent--state :session-id) - "resumed-session-id")) - (should (equal (plist-get pi-coding-agent--state :session-file) - "/tmp/resumed.jsonl")) - (should (string-match-p "Resumed history" (buffer-string)))) - (pi-coding-agent-test--assert-clean-session-rebuild - chat-buf messages "STALE RESUME CONTENT") - (should (equal (nreverse rpc-calls) - '("switch_session" "get_state" "get_messages"))) - (should (equal shown-message "Pi: Resumed session (1 messages)")))))) + (let* ((dir (pi-coding-agent-test--make-temp-directory + "pi-coding-agent-test-resume-happy-")) + (session-dir (pi-coding-agent-test--make-temp-directory + "pi-coding-agent-test-current-sessions-")) + (current-session (expand-file-name "current.jsonl" session-dir)) + (target-session (expand-file-name "target.jsonl" session-dir)) + (resumed-session (expand-file-name "resumed.jsonl" session-dir)) + (shown-message nil) + (listed-dir nil) + (rpc-calls nil)) + (unwind-protect + (pi-coding-agent-test-with-mock-session dir + (let* ((chat-buf (get-buffer (pi-coding-agent-test--chat-buffer-name dir))) + (input-buf (get-buffer + (pi-coding-agent-test--input-buffer-name dir))) + (messages [(:role "assistant" + :content [(:type "text" :text "Resumed history")] + :timestamp 1704067200000)])) + (pi-coding-agent-test--seed-stale-session-rebuild-state + chat-buf "STALE RESUME CONTENT") + (with-current-buffer chat-buf + (setq pi-coding-agent--state + (plist-put pi-coding-agent--state :session-file + current-session))) + (cl-letf (((symbol-function 'pi-coding-agent--list-sessions) + (lambda (session-dir) + (setq listed-dir session-dir) + (list target-session))) + ((symbol-function 'pi-coding-agent--format-session-choice) + (lambda (_path) + (cons "Resume target" target-session))) + ((symbol-function 'completing-read) + (lambda (&rest _) "Resume target")) + ((symbol-function 'pi-coding-agent--rpc-async) + (lambda (_proc cmd cb) + (push (plist-get cmd :type) rpc-calls) + (pcase (plist-get cmd :type) + ("switch_session" + (should (equal (plist-get cmd :sessionPath) + target-session)) + (funcall cb '(:success t :data (:cancelled :false)))) + ("get_state" + (funcall cb `(:success t + :data (:model (:name "resumed-model") + :thinkingLevel "medium" + :isStreaming :json-false + :isCompacting :json-false + :sessionId "resumed-session-id" + :sessionFile ,resumed-session + :messageCount 1 + :pendingMessageCount 0)))) + ("get_messages" + (funcall cb (list :success t + :data (list :messages messages)))) + (_ + (ert-fail + (format "Unexpected RPC during resume test: %S" + cmd)))))) + ((symbol-function 'pi-coding-agent--update-session-name-from-file) + #'ignore) + ((symbol-function 'pi-coding-agent--refresh-header) #'ignore) + ((symbol-function 'message) + (lambda (fmt &rest args) + (setq shown-message (apply #'format fmt args))))) + (with-current-buffer input-buf + (pi-coding-agent-resume-session))) + (with-current-buffer chat-buf + (should (equal (plist-get pi-coding-agent--state :session-id) + "resumed-session-id")) + (should (equal (plist-get pi-coding-agent--state :session-file) + resumed-session)) + (should (string-match-p "Resumed history" (buffer-string)))) + (pi-coding-agent-test--assert-clean-session-rebuild + chat-buf messages "STALE RESUME CONTENT") + (should (equal listed-dir session-dir)) + (should (equal (nreverse rpc-calls) + '("switch_session" "get_state" "get_messages"))) + (should (equal shown-message "Pi: Resumed session (1 messages)")))) + (delete-directory dir t) + (delete-directory session-dir t)))) (ert-deftest pi-coding-agent-test-fork-from-input-switches-session-rebuilds-history-and-prefills-input () "Forking from the input buffer rebuilds chat history and prefills input." @@ -1191,29 +1271,117 @@ replaced by the resumed or forked history." (ert-deftest pi-coding-agent-test-resume-session-skips-while-streaming () "Resume refuses to switch sessions while the current chat is busy." - (let ((shown-message nil) - (listed-sessions nil)) - (pi-coding-agent-test-with-mock-session "/tmp/pi-coding-agent-test-resume-streaming/" - (let ((chat-buf (get-buffer (pi-coding-agent-test--chat-buffer-name - "/tmp/pi-coding-agent-test-resume-streaming/")))) - (with-current-buffer chat-buf - (setq pi-coding-agent--status 'streaming)) - (cl-letf (((symbol-function 'pi-coding-agent--get-process) (lambda () 'mock-proc)) - ((symbol-function 'pi-coding-agent--session-directory) - (lambda () "/tmp/pi-coding-agent-test-resume-streaming/")) - ((symbol-function 'pi-coding-agent--list-sessions) - (lambda (_dir) - (setq listed-sessions t) - '("/tmp/pi-coding-agent-test-resume-streaming/session.jsonl"))) - ((symbol-function 'message) - (lambda (fmt &rest args) - (setq shown-message (apply #'format fmt args))))) - (with-current-buffer chat-buf - (pi-coding-agent-resume-session))))) + (let* ((dir (pi-coding-agent-test--make-temp-directory + "pi-coding-agent-test-resume-streaming-")) + (shown-message nil) + (listed-sessions nil)) + (unwind-protect + (pi-coding-agent-test-with-mock-session dir + (let ((chat-buf (get-buffer + (pi-coding-agent-test--chat-buffer-name dir)))) + (with-current-buffer chat-buf + (setq pi-coding-agent--status 'streaming)) + (cl-letf (((symbol-function 'pi-coding-agent--get-process) + (lambda () 'mock-proc)) + ((symbol-function 'pi-coding-agent--list-sessions) + (lambda (_dir) + (setq listed-sessions t) + (list "session.jsonl"))) + ((symbol-function 'message) + (lambda (fmt &rest args) + (setq shown-message (apply #'format fmt args))))) + (with-current-buffer chat-buf + (pi-coding-agent-resume-session))))) + (delete-directory dir t)) (should-not listed-sessions) (should (equal shown-message "Pi: Cannot resume while streaming")))) +(ert-deftest pi-coding-agent-test-resume-session-fetches-missing-session-file () + "Resume asks pi for state before listing sessions when the cache is empty." + (let* ((dir (pi-coding-agent-test--make-temp-directory + "pi-coding-agent-test-resume-no-state-")) + (session-dir (pi-coding-agent-test--make-temp-directory + "pi-coding-agent-test-session-files-")) + (session-file (expand-file-name "current.jsonl" session-dir)) + (shown-message nil) + (listed-dir nil) + (rpc-calls nil)) + (unwind-protect + (pi-coding-agent-test-with-mock-session dir + (let ((chat-buf (get-buffer + (pi-coding-agent-test--chat-buffer-name dir)))) + (with-current-buffer chat-buf + (setq pi-coding-agent--status 'idle + pi-coding-agent--state nil)) + (cl-letf (((symbol-function 'pi-coding-agent--get-process) + (lambda () 'mock-proc)) + ((symbol-function 'pi-coding-agent--rpc-async) + (lambda (_proc cmd cb) + (push (plist-get cmd :type) rpc-calls) + (should (equal (plist-get cmd :type) "get_state")) + (funcall cb `(:success t + :data (:model (:name "model") + :thinkingLevel "medium" + :isStreaming :json-false + :isCompacting :json-false + :sessionId "session-id" + :sessionFile ,session-file + :messageCount 0 + :pendingMessageCount 0))))) + ((symbol-function 'pi-coding-agent--list-sessions) + (lambda (session-dir) + (setq listed-dir session-dir) + nil)) + ((symbol-function 'message) + (lambda (fmt &rest args) + (setq shown-message (apply #'format fmt args))))) + (with-current-buffer chat-buf + (pi-coding-agent-resume-session))))) + (delete-directory dir t) + (delete-directory session-dir t)) + (should (equal listed-dir session-dir)) + (should (equal (nreverse rpc-calls) '("get_state"))) + (should (equal shown-message "Pi: No previous sessions found")))) + +(ert-deftest pi-coding-agent-test-resume-session-reports-missing-session-file () + "Resume stops clearly when pi state has no session file." + (let* ((dir (pi-coding-agent-test--make-temp-directory + "pi-coding-agent-test-resume-no-file-")) + (shown-message nil) + (listed-sessions nil)) + (unwind-protect + (pi-coding-agent-test-with-mock-session dir + (let ((chat-buf (get-buffer + (pi-coding-agent-test--chat-buffer-name dir)))) + (with-current-buffer chat-buf + (setq pi-coding-agent--status 'idle + pi-coding-agent--state nil)) + (cl-letf (((symbol-function 'pi-coding-agent--get-process) + (lambda () 'mock-proc)) + ((symbol-function 'pi-coding-agent--rpc-async) + (lambda (_proc _cmd cb) + (funcall cb '(:success t + :data (:model (:name "model") + :thinkingLevel "medium" + :isStreaming :json-false + :isCompacting :json-false + :sessionId "session-id" + :messageCount 0 + :pendingMessageCount 0))))) + ((symbol-function 'pi-coding-agent--list-sessions) + (lambda (_session-dir) + (setq listed-sessions t) + nil)) + ((symbol-function 'message) + (lambda (fmt &rest args) + (setq shown-message (apply #'format fmt args))))) + (with-current-buffer chat-buf + (pi-coding-agent-resume-session))))) + (delete-directory dir t)) + (should-not listed-sessions) + (should (equal shown-message "Pi: Session file not available")))) + (ert-deftest pi-coding-agent-test-fork-waits-for-local-user-echo () "Fork refuses to switch sessions while a local prompt is awaiting echo." (let ((shown-message nil)