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)