From 46d9555daf098b906f9b6b30112795035bb900fe Mon Sep 17 00:00:00 2001 From: Daniel Nouri Date: Thu, 12 Feb 2026 22:20:49 +0100 Subject: [PATCH 1/3] test: add thinking whitespace normalization coverage --- test/pi-coding-agent-render-test.el | 105 ++++++++++++++++++++++++---- 1 file changed, 93 insertions(+), 12 deletions(-) diff --git a/test/pi-coding-agent-render-test.el b/test/pi-coding-agent-render-test.el index 8be3227..322fb92 100644 --- a/test/pi-coding-agent-render-test.el +++ b/test/pi-coding-agent-render-test.el @@ -147,6 +147,70 @@ Models may send \\n\\n before thinking content too." ;; Thinking start should appear directly after header, no blank line (should (string-match-p "Assistant\n=+\n>" (buffer-string))))) +(ert-deftest pi-coding-agent-test-thinking-empty-lifecycle-no-visible-blockquote () + "Empty thinking start/end should not leave a visible blank blockquote." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (pi-coding-agent--display-agent-start) + (pi-coding-agent--display-thinking-start) + (pi-coding-agent--display-thinking-end "") + (goto-char (point-min)) + (should-not (re-search-forward "^>\\s-*$" nil t)))) + +(ert-deftest pi-coding-agent-test-thinking-leading-trailing-newlines-normalized () + "Thinking boundaries should not render extra empty blockquote lines." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (pi-coding-agent--display-agent-start) + (pi-coding-agent--display-thinking-start) + (pi-coding-agent--display-thinking-delta "\n\nSingle thought.\n\n") + (pi-coding-agent--display-thinking-end "") + (goto-char (point-min)) + (should (re-search-forward "^> Single thought\\.$" nil t)) + (goto-char (point-min)) + (should-not (re-search-forward "^>\\s-*$" nil t)))) + +(ert-deftest pi-coding-agent-test-thinking-paragraph-spacing-no-runaway-blank-lines () + "Thinking paragraphs keep a single readable separator, not multiple blanks." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (pi-coding-agent--display-agent-start) + (pi-coding-agent--display-thinking-start) + (pi-coding-agent--display-thinking-delta + "First paragraph.\n\n\n\nSecond paragraph.") + (pi-coding-agent--display-thinking-end "") + (goto-char (point-min)) + (should-not (re-search-forward "^>\\s-*$\n>\\s-*$" nil t)) + (should (string-match-p "> First paragraph\\.\n>\\s-*\n> Second paragraph\\." + (buffer-string))))) + +(ert-deftest pi-coding-agent-test-thinking-interleaved-with-tool-has-stable-spacing () + "Interleaving thinking and tool events keeps one blank line separation." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (pi-coding-agent--handle-display-event '(:type "agent_start")) + (pi-coding-agent--handle-display-event + '(:type "message_start" :message (:role "assistant"))) + (pi-coding-agent--handle-display-event + '(:type "message_update" + :assistantMessageEvent (:type "thinking_start"))) + (pi-coding-agent--handle-display-event + '(:type "message_update" + :assistantMessageEvent (:type "toolcall_start" :contentIndex 0) + :message (:role "assistant" + :content [(:type "toolCall" :id "call_1" :name "read" + :arguments (:path "/tmp/AGENTS.md"))]))) + (pi-coding-agent--handle-display-event + '(:type "message_update" + :assistantMessageEvent (:type "thinking_delta" + :delta "Reviewing docs"))) + (pi-coding-agent--handle-display-event + '(:type "message_update" + :assistantMessageEvent (:type "thinking_end" :content ""))) + (let ((text (buffer-string))) + (should (string-match-p "Reviewing docs\n\nread /tmp/AGENTS\\.md" text)) + (should-not (string-match-p "Reviewing docs\n\n\n" text))))) + (ert-deftest pi-coding-agent-test-spacing-blank-line-before-tool () "Tool block is preceded by blank line when after text." (with-temp-buffer @@ -2848,53 +2912,70 @@ Commands with embedded newlines should not have any lines deleted." (should (search-forward "> Second line." nil t)))) (ert-deftest pi-coding-agent-test-agent-end-clears-thinking-marker-buffer () - "agent_end should detach thinking marker from the chat buffer." + "agent_end should detach thinking markers and clear thinking stream state." (with-temp-buffer (pi-coding-agent-chat-mode) (pi-coding-agent--display-agent-start) (pi-coding-agent--display-thinking-start) - (let ((marker pi-coding-agent--thinking-marker)) + (let ((marker pi-coding-agent--thinking-marker) + (start-marker pi-coding-agent--thinking-start-marker)) + (should (stringp pi-coding-agent--thinking-raw)) (pi-coding-agent--display-agent-end) (should-not pi-coding-agent--thinking-marker) - (should-not (marker-buffer marker))))) + (should-not pi-coding-agent--thinking-start-marker) + (should-not pi-coding-agent--thinking-raw) + (should-not (marker-buffer marker)) + (should-not (marker-buffer start-marker))))) (ert-deftest pi-coding-agent-test-message-start-clears-previous-thinking-marker () - "message_start should detach leftover thinking marker from prior message." + "message_start should clear stale thinking markers and stream state." (with-temp-buffer (pi-coding-agent-chat-mode) (pi-coding-agent--display-agent-start) (pi-coding-agent--display-thinking-start) - (let ((marker pi-coding-agent--thinking-marker)) + (let ((marker pi-coding-agent--thinking-marker) + (start-marker pi-coding-agent--thinking-start-marker)) (pi-coding-agent--handle-display-event '(:type "message_start" :message (:role "assistant"))) (should-not pi-coding-agent--thinking-marker) - (should-not (marker-buffer marker))))) + (should-not pi-coding-agent--thinking-start-marker) + (should-not pi-coding-agent--thinking-raw) + (should-not (marker-buffer marker)) + (should-not (marker-buffer start-marker))))) (ert-deftest pi-coding-agent-test-message-start-user-clears-previous-thinking-marker () - "message_start for user should also detach leftover thinking marker." + "message_start for user should also clear stale thinking state." (with-temp-buffer (pi-coding-agent-chat-mode) (pi-coding-agent--display-agent-start) (pi-coding-agent--display-thinking-start) - (let ((marker pi-coding-agent--thinking-marker)) + (let ((marker pi-coding-agent--thinking-marker) + (start-marker pi-coding-agent--thinking-start-marker)) (pi-coding-agent--handle-display-event '(:type "message_start" :message (:role "user" :content [(:type "text" :text "hi")])) ) (should-not pi-coding-agent--thinking-marker) - (should-not (marker-buffer marker))))) + (should-not pi-coding-agent--thinking-start-marker) + (should-not pi-coding-agent--thinking-raw) + (should-not (marker-buffer marker)) + (should-not (marker-buffer start-marker))))) (ert-deftest pi-coding-agent-test-message-start-custom-clears-previous-thinking-marker () - "message_start for custom messages should clear stale thinking marker." + "message_start for custom messages should clear stale thinking state." (with-temp-buffer (pi-coding-agent-chat-mode) (pi-coding-agent--display-agent-start) (pi-coding-agent--display-thinking-start) - (let ((marker pi-coding-agent--thinking-marker)) + (let ((marker pi-coding-agent--thinking-marker) + (start-marker pi-coding-agent--thinking-start-marker)) (pi-coding-agent--handle-display-event '(:type "message_start" :message (:role "custom" :display t :content "done"))) (should-not pi-coding-agent--thinking-marker) - (should-not (marker-buffer marker))))) + (should-not pi-coding-agent--thinking-start-marker) + (should-not pi-coding-agent--thinking-raw) + (should-not (marker-buffer marker)) + (should-not (marker-buffer start-marker))))) (ert-deftest pi-coding-agent-test-blockquote-has-wrap-prefix () "Blockquotes have wrap-prefix for continuation lines after font-lock." From 83f07e8d18444e4ae29d1f8c7533b490e99dd3ef Mon Sep 17 00:00:00 2001 From: Daniel Nouri Date: Thu, 12 Feb 2026 22:21:02 +0100 Subject: [PATCH 2/3] fix: normalize thinking block whitespace while streaming --- pi-coding-agent-render.el | 106 ++++++++++++++++++++++++++++++++------ pi-coding-agent-ui.el | 8 +++ 2 files changed, 97 insertions(+), 17 deletions(-) diff --git a/pi-coding-agent-render.el b/pi-coding-agent-render.el index 1f72423..9f51e3d 100644 --- a/pi-coding-agent-render.el +++ b/pi-coding-agent-render.el @@ -193,11 +193,65 @@ tool headers do not move the thinking insertion point." (marker-position pi-coding-agent--thinking-marker) (marker-position pi-coding-agent--streaming-marker))) +(defun pi-coding-agent--thinking-normalize-text (text) + "Normalize streaming thinking TEXT for stable markdown rendering. +Removes boundary whitespace and collapses internal blank-line runs to +at most one empty paragraph separator." + (let ((trimmed (string-trim (or text "")))) + (if (string-empty-p trimmed) + "" + (replace-regexp-in-string + "\n\\(?:[ \t]*\n\\)\\{2,\\}" "\n\n" trimmed)))) + +(defun pi-coding-agent--thinking-blockquote-text (text) + "Convert normalized thinking TEXT to markdown blockquote lines." + (if (string-empty-p text) + "" + (concat "> " (replace-regexp-in-string "\n" "\n> " text)))) + +(defun pi-coding-agent--render-thinking-content () + "Render normalized accumulated thinking content in place. +Returns non-nil when meaningful content remains after normalization." + (when (and (markerp pi-coding-agent--thinking-start-marker) + (markerp pi-coding-agent--thinking-marker) + (marker-position pi-coding-agent--thinking-start-marker) + (marker-position pi-coding-agent--thinking-marker)) + (let* ((start (marker-position pi-coding-agent--thinking-start-marker)) + (end (marker-position pi-coding-agent--thinking-marker)) + (normalized (pi-coding-agent--thinking-normalize-text + pi-coding-agent--thinking-raw)) + (rendered (pi-coding-agent--thinking-blockquote-text normalized))) + (when (<= start end) + (goto-char start) + (delete-region start end) + (insert rendered) + (set-marker pi-coding-agent--thinking-marker (point))) + (not (string-empty-p normalized))))) + +(defun pi-coding-agent--ensure-thinking-separator () + "Ensure exactly one blank line separator at point. +Normalizes any existing newline run to two newlines." + (let ((start (point)) + (scan (point)) + (newline-count 0)) + (while (eq (char-after scan) ?\n) + (setq newline-count (1+ newline-count)) + (setq scan (1+ scan))) + (cond + ((< newline-count 2) + (insert (make-string (- 2 newline-count) ?\n))) + ((> newline-count 2) + (delete-region (+ start 2) (+ start newline-count)))))) + (defun pi-coding-agent--clear-thinking-marker () - "Detach and clear `pi-coding-agent--thinking-marker'." + "Detach and clear thinking markers and raw streaming state." (when (markerp pi-coding-agent--thinking-marker) (set-marker pi-coding-agent--thinking-marker nil)) - (setq pi-coding-agent--thinking-marker nil)) + (when (markerp pi-coding-agent--thinking-start-marker) + (set-marker pi-coding-agent--thinking-start-marker nil)) + (setq pi-coding-agent--thinking-marker nil + pi-coding-agent--thinking-start-marker nil + pi-coding-agent--thinking-raw nil)) (defun pi-coding-agent--display-thinking-start () "Insert opening marker for thinking block (blockquote)." @@ -207,30 +261,43 @@ tool headers do not move the thinking insertion point." (pi-coding-agent--with-scroll-preservation (save-excursion (goto-char (marker-position pi-coding-agent--streaming-marker)) - (insert "> ") ;; Track thinking insertion separately so it stays anchored even if ;; other block types (tool headers) interleave in the same message. ;; Keep insertion-type nil so inserts at this exact point happen ;; after the marker (we then advance it explicitly per delta). (pi-coding-agent--clear-thinking-marker) - (setq pi-coding-agent--thinking-marker (copy-marker (point) nil))))))) + (setq pi-coding-agent--thinking-raw "") + (let ((start (point))) + (insert "> ") + (setq pi-coding-agent--thinking-start-marker + (copy-marker start nil)) + (setq pi-coding-agent--thinking-marker + (copy-marker (point) nil)))))))) (defun pi-coding-agent--display-thinking-delta (delta) "Display streaming thinking DELTA in the current thinking block. -Transforms newlines to include blockquote prefix. +Normalizes boundary and paragraph whitespace while streaming. Inhibits modification hooks to prevent expensive jit-lock fontification on each delta - fontification happens at message end instead." (when (and delta pi-coding-agent--streaming-marker) (let ((inhibit-read-only t) - (inhibit-modification-hooks t) - ;; Transform newlines to include blockquote prefix on next line - (transformed (replace-regexp-in-string "\n" "\n> " delta))) - (pi-coding-agent--with-scroll-preservation - (save-excursion - (goto-char (pi-coding-agent--thinking-insert-position)) - (insert transformed) - (when pi-coding-agent--thinking-marker - (set-marker pi-coding-agent--thinking-marker (point)))))))) + (inhibit-modification-hooks t)) + (if (and pi-coding-agent--thinking-start-marker + pi-coding-agent--thinking-marker) + (progn + (setq pi-coding-agent--thinking-raw + (concat (or pi-coding-agent--thinking-raw "") delta)) + (pi-coding-agent--with-scroll-preservation + (save-excursion + (pi-coding-agent--render-thinking-content)))) + ;; Fallback for malformed event streams that skip thinking_start. + (let ((transformed (replace-regexp-in-string "\n" "\n> " delta))) + (pi-coding-agent--with-scroll-preservation + (save-excursion + (goto-char (pi-coding-agent--thinking-insert-position)) + (insert transformed) + (when pi-coding-agent--thinking-marker + (set-marker pi-coding-agent--thinking-marker (point)))))))))) (defun pi-coding-agent--display-thinking-end (_content) "End thinking block (blockquote). @@ -240,9 +307,14 @@ CONTENT is ignored - we use what was already streamed." (let ((inhibit-read-only t)) (pi-coding-agent--with-scroll-preservation (save-excursion - (goto-char (pi-coding-agent--thinking-insert-position)) - ;; End blockquote with blank line - (insert "\n\n") + (if (and pi-coding-agent--thinking-start-marker + pi-coding-agent--thinking-marker) + (when (pi-coding-agent--render-thinking-content) + (goto-char (pi-coding-agent--thinking-insert-position)) + (pi-coding-agent--ensure-thinking-separator)) + ;; Fallback for malformed event streams that skip thinking_start. + (goto-char (pi-coding-agent--thinking-insert-position)) + (pi-coding-agent--ensure-thinking-separator)) (pi-coding-agent--clear-thinking-marker)))))) (defun pi-coding-agent--display-agent-end () diff --git a/pi-coding-agent-ui.el b/pi-coding-agent-ui.el index 30099d6..bb72393 100644 --- a/pi-coding-agent-ui.el +++ b/pi-coding-agent-ui.el @@ -559,6 +559,14 @@ Unlike `pi-coding-agent--streaming-marker', this marker stays anchored in thinking text when other content blocks (for example, tool headers) interleave during streaming.") +(defvar-local pi-coding-agent--thinking-start-marker nil + "Marker for the start of the current thinking block. +Used to rewrite thinking content in place after whitespace normalization.") + +(defvar-local pi-coding-agent--thinking-raw nil + "Accumulated raw thinking deltas for the current thinking block. +Normalized and re-rendered incrementally to avoid excess whitespace.") + (defvar-local pi-coding-agent--line-parse-state 'line-start "Parsing state for current line during streaming. Values: From 483f81d2e698458ded7395968aad1acc96edc209 Mon Sep 17 00:00:00 2001 From: Daniel Nouri Date: Thu, 12 Feb 2026 22:37:20 +0100 Subject: [PATCH 3/3] fix: harden thinking streaming normalization and state reset --- pi-coding-agent-menu.el | 9 ++++ pi-coding-agent-render.el | 41 ++++++++++------- pi-coding-agent-ui.el | 4 +- test/pi-coding-agent-menu-test.el | 6 +++ test/pi-coding-agent-render-test.el | 69 ++++++++++++++++------------- 5 files changed, 79 insertions(+), 50 deletions(-) diff --git a/pi-coding-agent-menu.el b/pi-coding-agent-menu.el index 81e457d..ee91994 100644 --- a/pi-coding-agent-menu.el +++ b/pi-coding-agent-menu.el @@ -207,6 +207,12 @@ Prefers session name over first message when available." (defun pi-coding-agent--reset-session-state () "Reset all session-specific state for a new session. Call this when starting a new session to ensure no stale state persists." + (dolist (marker (list pi-coding-agent--message-start-marker + pi-coding-agent--streaming-marker + pi-coding-agent--thinking-marker + pi-coding-agent--thinking-start-marker)) + (when (markerp marker) + (set-marker marker nil))) (setq pi-coding-agent--session-name nil pi-coding-agent--cached-stats nil pi-coding-agent--assistant-header-shown nil @@ -214,6 +220,9 @@ Call this when starting a new session to ensure no stale state persists." pi-coding-agent--extension-status nil pi-coding-agent--in-code-block nil pi-coding-agent--in-thinking-block nil + pi-coding-agent--thinking-marker nil + pi-coding-agent--thinking-start-marker nil + pi-coding-agent--thinking-raw nil pi-coding-agent--line-parse-state 'line-start pi-coding-agent--pending-tool-overlay nil) ;; Use accessors for cross-module state diff --git a/pi-coding-agent-render.el b/pi-coding-agent-render.el index 9f51e3d..d222c71 100644 --- a/pi-coding-agent-render.el +++ b/pi-coding-agent-render.el @@ -195,13 +195,19 @@ tool headers do not move the thinking insertion point." (defun pi-coding-agent--thinking-normalize-text (text) "Normalize streaming thinking TEXT for stable markdown rendering. -Removes boundary whitespace and collapses internal blank-line runs to -at most one empty paragraph separator." - (let ((trimmed (string-trim (or text "")))) - (if (string-empty-p trimmed) +Removes boundary blank lines and collapses internal blank-line runs to +at most one empty paragraph separator while preserving indentation." + (let* ((source (or text "")) + (without-leading-blank-lines + (replace-regexp-in-string "\\`\\(?:[ \t]*\n\\)+" "" source)) + (without-boundary-blank-lines + (replace-regexp-in-string "\\(?:\n[ \t]*\\)+\\'" "" + without-leading-blank-lines))) + (if (string-empty-p without-boundary-blank-lines) "" (replace-regexp-in-string - "\n\\(?:[ \t]*\n\\)\\{2,\\}" "\n\n" trimmed)))) + "\n\\(?:[ \t]*\n\\)\\{2,\\}" "\n\n" + without-boundary-blank-lines)))) (defun pi-coding-agent--thinking-blockquote-text (text) "Convert normalized thinking TEXT to markdown blockquote lines." @@ -222,11 +228,14 @@ Returns non-nil when meaningful content remains after normalization." pi-coding-agent--thinking-raw)) (rendered (pi-coding-agent--thinking-blockquote-text normalized))) (when (<= start end) - (goto-char start) - (delete-region start end) - (insert rendered) - (set-marker pi-coding-agent--thinking-marker (point))) - (not (string-empty-p normalized))))) + (let ((existing (buffer-substring-no-properties start end))) + (unless (equal existing rendered) + (goto-char start) + (delete-region start end) + (insert rendered) + (set-marker pi-coding-agent--thinking-marker (point))))) + (and (<= start end) + (not (string-empty-p normalized)))))) (defun pi-coding-agent--ensure-thinking-separator () "Ensure exactly one blank line separator at point. @@ -243,8 +252,8 @@ Normalizes any existing newline run to two newlines." ((> newline-count 2) (delete-region (+ start 2) (+ start newline-count)))))) -(defun pi-coding-agent--clear-thinking-marker () - "Detach and clear thinking markers and raw streaming state." +(defun pi-coding-agent--reset-thinking-state () + "Detach and clear all thinking-stream state for the current turn." (when (markerp pi-coding-agent--thinking-marker) (set-marker pi-coding-agent--thinking-marker nil)) (when (markerp pi-coding-agent--thinking-start-marker) @@ -265,7 +274,7 @@ Normalizes any existing newline run to two newlines." ;; other block types (tool headers) interleave in the same message. ;; Keep insertion-type nil so inserts at this exact point happen ;; after the marker (we then advance it explicitly per delta). - (pi-coding-agent--clear-thinking-marker) + (pi-coding-agent--reset-thinking-state) (setq pi-coding-agent--thinking-raw "") (let ((start (point))) (insert "> ") @@ -315,7 +324,7 @@ CONTENT is ignored - we use what was already streamed." ;; Fallback for malformed event streams that skip thinking_start. (goto-char (pi-coding-agent--thinking-insert-position)) (pi-coding-agent--ensure-thinking-separator)) - (pi-coding-agent--clear-thinking-marker)))))) + (pi-coding-agent--reset-thinking-state)))))) (defun pi-coding-agent--display-agent-end () "Finalize agent turn: normalize whitespace, handle abort, process queue. @@ -324,7 +333,7 @@ Note: status is set to `idle' by the event handler." (setq pi-coding-agent--local-user-message nil) (setq pi-coding-agent--streaming-tool-id nil) (setq pi-coding-agent--in-thinking-block nil) - (pi-coding-agent--clear-thinking-marker) + (pi-coding-agent--reset-thinking-state) (let ((was-aborted pi-coding-agent--aborted)) (let ((inhibit-read-only t)) (pi-coding-agent--tool-overlay-finalize 'pi-coding-agent-tool-block-error) @@ -622,7 +631,7 @@ Updates buffer-local state and renders display updates." (role (plist-get message :role))) ;; A new message starts a fresh rendering context. (setq pi-coding-agent--in-thinking-block nil) - (pi-coding-agent--clear-thinking-marker) + (pi-coding-agent--reset-thinking-state) (pcase role ("user" ;; User message from pi - check if we displayed it locally diff --git a/pi-coding-agent-ui.el b/pi-coding-agent-ui.el index bb72393..ddc19de 100644 --- a/pi-coding-agent-ui.el +++ b/pi-coding-agent-ui.el @@ -550,8 +550,8 @@ TYPE is :chat or :input. Returns the buffer." Used to suppress ATX heading transforms inside code.") (defvar-local pi-coding-agent--in-thinking-block nil - "Non-nil when streaming inside a thinking block. -Used to add blockquote prefix to each line.") + "Non-nil while processing a thinking block for the current message. +Used for lifecycle resets when new messages or turns begin.") (defvar-local pi-coding-agent--thinking-marker nil "Marker for insertion point inside the current thinking block. diff --git a/test/pi-coding-agent-menu-test.el b/test/pi-coding-agent-menu-test.el index a992f51..9169f7c 100644 --- a/test/pi-coding-agent-menu-test.el +++ b/test/pi-coding-agent-menu-test.el @@ -71,6 +71,9 @@ pi-coding-agent--extension-status '(("ext1" . "status")) pi-coding-agent--message-start-marker (point-marker) pi-coding-agent--streaming-marker (point-marker) + pi-coding-agent--thinking-marker (point-marker) + pi-coding-agent--thinking-start-marker (point-marker) + pi-coding-agent--thinking-raw "pending" pi-coding-agent--in-code-block t pi-coding-agent--in-thinking-block t pi-coding-agent--line-parse-state 'code-fence @@ -90,6 +93,9 @@ (should (null pi-coding-agent--extension-status)) (should (null pi-coding-agent--message-start-marker)) (should (null pi-coding-agent--streaming-marker)) + (should (null pi-coding-agent--thinking-marker)) + (should (null pi-coding-agent--thinking-start-marker)) + (should (null pi-coding-agent--thinking-raw)) (should (null pi-coding-agent--in-code-block)) (should (null pi-coding-agent--in-thinking-block)) (should (eq pi-coding-agent--line-parse-state 'line-start)) diff --git a/test/pi-coding-agent-render-test.el b/test/pi-coding-agent-render-test.el index 322fb92..eb525d1 100644 --- a/test/pi-coding-agent-render-test.el +++ b/test/pi-coding-agent-render-test.el @@ -170,6 +170,29 @@ Models may send \\n\\n before thinking content too." (goto-char (point-min)) (should-not (re-search-forward "^>\\s-*$" nil t)))) +(ert-deftest pi-coding-agent-test-thinking-normalization-preserves-first-line-indentation () + "Normalization should trim blank boundaries without stripping indentation." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (pi-coding-agent--display-agent-start) + (pi-coding-agent--display-thinking-start) + (pi-coding-agent--display-thinking-delta "\n\n indented thought") + (pi-coding-agent--display-thinking-end "") + (should (string-match-p "^> indented thought" (buffer-string))))) + +(ert-deftest pi-coding-agent-test-thinking-whitespace-only-delta-does-not-rewrite-buffer () + "Adding ignorable trailing whitespace should not rewrite rendered thinking." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (pi-coding-agent--display-agent-start) + (pi-coding-agent--display-thinking-start) + (pi-coding-agent--display-thinking-delta "Stable") + (let ((before (buffer-string)) + (before-tick (buffer-chars-modified-tick))) + (pi-coding-agent--display-thinking-delta "\n") + (should (equal before (buffer-string))) + (should (= before-tick (buffer-chars-modified-tick)))))) + (ert-deftest pi-coding-agent-test-thinking-paragraph-spacing-no-runaway-blank-lines () "Thinking paragraphs keep a single readable separator, not multiple blanks." (with-temp-buffer @@ -2927,55 +2950,37 @@ Commands with embedded newlines should not have any lines deleted." (should-not (marker-buffer marker)) (should-not (marker-buffer start-marker))))) -(ert-deftest pi-coding-agent-test-message-start-clears-previous-thinking-marker () - "message_start should clear stale thinking markers and stream state." +(defun pi-coding-agent-test--assert-message-start-clears-thinking-state (event) + "Assert that message_start EVENT clears all thinking-stream state." (with-temp-buffer (pi-coding-agent-chat-mode) (pi-coding-agent--display-agent-start) (pi-coding-agent--display-thinking-start) (let ((marker pi-coding-agent--thinking-marker) (start-marker pi-coding-agent--thinking-start-marker)) - (pi-coding-agent--handle-display-event - '(:type "message_start" :message (:role "assistant"))) + (pi-coding-agent--handle-display-event event) (should-not pi-coding-agent--thinking-marker) (should-not pi-coding-agent--thinking-start-marker) (should-not pi-coding-agent--thinking-raw) (should-not (marker-buffer marker)) (should-not (marker-buffer start-marker))))) +(ert-deftest pi-coding-agent-test-message-start-clears-previous-thinking-marker () + "message_start should clear stale thinking markers and stream state." + (pi-coding-agent-test--assert-message-start-clears-thinking-state + '(:type "message_start" :message (:role "assistant")))) + (ert-deftest pi-coding-agent-test-message-start-user-clears-previous-thinking-marker () "message_start for user should also clear stale thinking state." - (with-temp-buffer - (pi-coding-agent-chat-mode) - (pi-coding-agent--display-agent-start) - (pi-coding-agent--display-thinking-start) - (let ((marker pi-coding-agent--thinking-marker) - (start-marker pi-coding-agent--thinking-start-marker)) - (pi-coding-agent--handle-display-event - '(:type "message_start" - :message (:role "user" :content [(:type "text" :text "hi")])) ) - (should-not pi-coding-agent--thinking-marker) - (should-not pi-coding-agent--thinking-start-marker) - (should-not pi-coding-agent--thinking-raw) - (should-not (marker-buffer marker)) - (should-not (marker-buffer start-marker))))) + (pi-coding-agent-test--assert-message-start-clears-thinking-state + '(:type "message_start" + :message (:role "user" :content [(:type "text" :text "hi")])))) (ert-deftest pi-coding-agent-test-message-start-custom-clears-previous-thinking-marker () "message_start for custom messages should clear stale thinking state." - (with-temp-buffer - (pi-coding-agent-chat-mode) - (pi-coding-agent--display-agent-start) - (pi-coding-agent--display-thinking-start) - (let ((marker pi-coding-agent--thinking-marker) - (start-marker pi-coding-agent--thinking-start-marker)) - (pi-coding-agent--handle-display-event - '(:type "message_start" - :message (:role "custom" :display t :content "done"))) - (should-not pi-coding-agent--thinking-marker) - (should-not pi-coding-agent--thinking-start-marker) - (should-not pi-coding-agent--thinking-raw) - (should-not (marker-buffer marker)) - (should-not (marker-buffer start-marker))))) + (pi-coding-agent-test--assert-message-start-clears-thinking-state + '(:type "message_start" + :message (:role "custom" :display t :content "done")))) (ert-deftest pi-coding-agent-test-blockquote-has-wrap-prefix () "Blockquotes have wrap-prefix for continuation lines after font-lock."