Skip to content

Commit 31c4d9c

Browse files
timvisher-ddclaude
andcommitted
Fix run-on paragraphs when model resumes after mid-turn tool call
Empty agent_message_chunk events mark content block boundaries in the ACP stream (e.g. the model described a code change, then reported background test results). These empty chunks were appended as-is, inserting nothing, so the two blocks merged: "pipeline.Full test suite". Convert empty mid-stream chunks to "\n\n" paragraph breaks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 04f615d commit 31c4d9c

2 files changed

Lines changed: 103 additions & 29 deletions

File tree

agent-shell.el

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1648,36 +1648,44 @@ COMMAND, when present, may be a shell command string or an argv vector."
16481648
:expanded agent-shell-thought-process-expand-by-default))))
16491649
(map-put! state :last-entry-type "agent_thought_chunk")))
16501650
((equal (map-nested-elt acp-notification '(params update sessionUpdate)) "agent_message_chunk")
1651-
;; Notification is out of context (session/prompt finished).
1652-
;; Cannot derive where to display, so show in minibuffer.
1653-
(if (not (agent-shell--active-requests-p state))
1654-
(when acp-logging-enabled
1655-
(message "Agent message (stale, consider reporting to ACP agent): %s"
1656-
(truncate-string-to-width (map-nested-elt acp-notification '(params update content text)) 100)))
1657-
(unless (equal (map-elt state :last-entry-type) "agent_message_chunk")
1658-
(map-put! state :chunked-group-count (1+ (map-elt state :chunked-group-count)))
1651+
(let ((chunk-text (map-nested-elt acp-notification '(params update content text))))
1652+
;; An empty chunk while already streaming message text
1653+
;; indicates a content block boundary (the model resumed
1654+
;; after a tool call within the same turn). Convert to a
1655+
;; paragraph break so the two blocks don't run together.
1656+
(when (and (equal (map-elt state :last-entry-type) "agent_message_chunk")
1657+
(stringp chunk-text)
1658+
(string-empty-p chunk-text))
1659+
(setq chunk-text "\n\n"))
1660+
;; Notification is out of context (session/prompt finished).
1661+
;; Cannot derive where to display, so show in minibuffer.
1662+
(if (not (agent-shell--active-requests-p state))
1663+
(when acp-logging-enabled
1664+
(message "Agent message (stale, consider reporting to ACP agent): %s"
1665+
(truncate-string-to-width chunk-text 100)))
1666+
(unless (equal (map-elt state :last-entry-type) "agent_message_chunk")
1667+
(map-put! state :chunked-group-count (1+ (map-elt state :chunked-group-count)))
1668+
(agent-shell--append-transcript
1669+
:text (format "\n## Agent (%s)\n\n" (format-time-string "%F %T"))
1670+
:file-path agent-shell--transcript-file))
1671+
;; Indent markdown headers in LLM output so they nest
1672+
;; below the transcript's ## section headers. Applied
1673+
;; per-chunk: if a header is split across chunks it may
1674+
;; not be indented (graceful degradation).
16591675
(agent-shell--append-transcript
1660-
:text (format "\n## Agent (%s)\n\n" (format-time-string "%F %T"))
1661-
:file-path agent-shell--transcript-file))
1662-
;; Indent markdown headers in LLM output so they nest
1663-
;; below the transcript's ## section headers. Applied
1664-
;; per-chunk: if a header is split across chunks it may
1665-
;; not be indented (graceful degradation).
1666-
(agent-shell--append-transcript
1667-
:text (agent-shell--indent-markdown-headers
1668-
(map-nested-elt acp-notification '(params update content text)))
1669-
:file-path agent-shell--transcript-file)
1670-
(agent-shell--update-fragment
1671-
:state state
1672-
:block-id (format "%s-agent_message_chunk"
1673-
(map-elt state :chunked-group-count))
1674-
:body (map-nested-elt acp-notification '(params update content text))
1675-
:create-new (not (equal (map-elt state :last-entry-type)
1676-
"agent_message_chunk"))
1677-
:append t
1678-
:navigation 'never
1679-
:render-body-images t)
1680-
(map-put! state :last-entry-type "agent_message_chunk")))
1676+
:text (agent-shell--indent-markdown-headers chunk-text)
1677+
:file-path agent-shell--transcript-file)
1678+
(agent-shell--update-fragment
1679+
:state state
1680+
:block-id (format "%s-agent_message_chunk"
1681+
(map-elt state :chunked-group-count))
1682+
:body chunk-text
1683+
:create-new (not (equal (map-elt state :last-entry-type)
1684+
"agent_message_chunk"))
1685+
:append t
1686+
:navigation 'never
1687+
:render-body-images t)
1688+
(map-put! state :last-entry-type "agent_message_chunk"))))
16811689
((equal (map-nested-elt acp-notification '(params update sessionUpdate)) "user_message_chunk")
16821690
;; Only handle user_message_chunks when there's an active session/load
16831691
;; or session/push to avoid inserting a redundant shell prompt

tests/agent-shell-streaming-tests.el

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,72 @@ with no prior meta chunks. The output must not be dropped."
702702
(when (buffer-live-p buffer)
703703
(kill-buffer buffer)))))
704704

705+
(ert-deftest agent-shell--empty-chunk-inserts-paragraph-break-test ()
706+
"An empty agent_message_chunk mid-stream inserts a paragraph break.
707+
Regression: when the model produces two content blocks in the same
708+
turn (e.g. a description followed by a background-task result),
709+
the ACP sends an empty chunk at the boundary. Without converting
710+
that to a paragraph break, the end of the first block and the
711+
start of the second get merged: \"pipeline.Full test suite passed\"."
712+
(let* ((buffer (get-buffer-create " *agent-shell-empty-chunk-para*"))
713+
(agent-shell--state (agent-shell--make-state :buffer buffer))
714+
(agent-shell--transcript-file nil)
715+
(tool-id "toolu_empty_chunk_test"))
716+
(map-put! agent-shell--state :client 'test-client)
717+
(map-put! agent-shell--state :request-count 1)
718+
(map-put! agent-shell--state :active-requests (list t))
719+
(with-current-buffer buffer
720+
(erase-buffer)
721+
(agent-shell-mode))
722+
(unwind-protect
723+
(cl-letf (((symbol-function 'agent-shell--make-diff-info)
724+
(cl-function (lambda (&key acp-tool-call) (ignore acp-tool-call)))))
725+
(with-current-buffer buffer
726+
;; Completed tool call (background task)
727+
(agent-shell--on-notification
728+
:state agent-shell--state
729+
:acp-notification `((method . "session/update")
730+
(params . ((update
731+
. ((toolCallId . ,tool-id)
732+
(sessionUpdate . "tool_call")
733+
(rawInput)
734+
(status . "pending")
735+
(title . "Bash")
736+
(kind . "execute")))))))
737+
(agent-shell--on-notification
738+
:state agent-shell--state
739+
:acp-notification `((method . "session/update")
740+
(params . ((update
741+
. ((toolCallId . ,tool-id)
742+
(sessionUpdate . "tool_call_update")
743+
(status . "completed")))))))
744+
;; First content block: empty start chunk + text
745+
(dolist (token (list "" "First paragraph" " ending."))
746+
(agent-shell--on-notification
747+
:state agent-shell--state
748+
:acp-notification `((method . "session/update")
749+
(params . ((update
750+
. ((sessionUpdate . "agent_message_chunk")
751+
(content (type . "text")
752+
(text . ,token)))))))))
753+
;; Second content block: empty boundary chunk + text
754+
(dolist (token (list "" "Second paragraph" " starting."))
755+
(agent-shell--on-notification
756+
:state agent-shell--state
757+
:acp-notification `((method . "session/update")
758+
(params . ((update
759+
. ((sessionUpdate . "agent_message_chunk")
760+
(content (type . "text")
761+
(text . ,token)))))))))
762+
;; The two paragraphs must NOT be merged.
763+
(let ((visible-text (agent-shell-test--visible-buffer-string)))
764+
(should (string-match-p "First paragraph ending\\." visible-text))
765+
(should (string-match-p "Second paragraph starting\\." visible-text))
766+
;; The boundary must include whitespace, not "ending.Second"
767+
(should-not (string-match-p "ending\\.Second" visible-text)))))
768+
(when (buffer-live-p buffer)
769+
(kill-buffer buffer)))))
770+
705771
(ert-deftest agent-shell--agent-message-chunks-fully-visible-test ()
706772
"All agent_message_chunk tokens must be visible in the buffer.
707773
Regression: label-less fragments defaulted to :collapsed t. The

0 commit comments

Comments
 (0)