Skip to content

Commit 36ba031

Browse files
timvisher-ddclaude
andcommitted
Add thought chunk dedup to eliminate codex doubling
Codex-acp sends cumulative re-deliveries of thought text after incremental tokens, causing doubled output in the buffer. agent-shell--thought-chunk-delta detects prefix/suffix overlap and emits only the genuinely new tail. Accumulator state resets at each new thought group. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0ee3405 commit 36ba031

File tree

3 files changed

+127
-24
lines changed

3 files changed

+127
-24
lines changed

agent-shell-streaming.el

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,35 @@ back to content-text extraction."
352352
:expanded agent-shell-tool-use-expand-by-default))
353353
(agent-shell--tool-call-clear-output state .toolCallId))))
354354

355+
;;; Thought chunk dedup
356+
357+
(defun agent-shell--thought-chunk-delta (accumulated chunk)
358+
"Return the portion of CHUNK not already present in ACCUMULATED.
359+
When an agent re-delivers the full accumulated thought text (e.g.
360+
codex-acp sending a cumulative summary after incremental tokens),
361+
only the genuinely new tail is returned.
362+
363+
Three cases are handled:
364+
;; Cumulative from start (prefix match)
365+
(agent-shell--thought-chunk-delta \"AB\" \"ABCD\") => \"CD\"
366+
367+
;; Already present (suffix match, e.g. leading whitespace trimmed)
368+
(agent-shell--thought-chunk-delta \"\\n\\nABCD\" \"ABCD\") => \"\"
369+
370+
;; Incremental token (no overlap)
371+
(agent-shell--thought-chunk-delta \"AB\" \"CD\") => \"CD\""
372+
(cond
373+
((or (null accumulated) (string-empty-p accumulated))
374+
chunk)
375+
;; Chunk starts with all accumulated text (cumulative from start).
376+
((string-prefix-p accumulated chunk)
377+
(substring chunk (length accumulated)))
378+
;; Chunk is already fully contained as a suffix of accumulated
379+
;; (e.g. re-delivery omits leading whitespace tokens).
380+
((string-suffix-p chunk accumulated)
381+
"")
382+
(t chunk)))
383+
355384
;;; Cancellation
356385

357386
(defun agent-shell--mark-tool-calls-cancelled (state)

agent-shell.el

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,7 @@ OUTGOING-REQUEST-DECORATOR (passed through to `acp-make-client')."
588588
(cons :modes nil)))
589589
(cons :last-entry-type nil)
590590
(cons :chunked-group-count 0)
591+
(cons :thought-accumulated nil)
591592
(cons :request-count 0)
592593
(cons :tool-calls nil)
593594
(cons :available-commands nil)
@@ -1181,30 +1182,34 @@ COMMAND, when present, may be a shell command string or an argv vector."
11811182
(map-put! state :last-entry-type "tool_call"))
11821183
((equal (map-elt update 'sessionUpdate) "agent_thought_chunk")
11831184
(let-alist update
1184-
;; (message "agent_thought_chunk: last-type=%s, will-append=%s"
1185-
;; (map-elt state :last-entry-type)
1186-
;; (equal (map-elt state :last-entry-type) "agent_thought_chunk"))
1187-
(unless (equal (map-elt state :last-entry-type)
1188-
"agent_thought_chunk")
1189-
(map-put! state :chunked-group-count (1+ (map-elt state :chunked-group-count)))
1190-
(agent-shell--append-transcript
1191-
:text (format "## Agent's Thoughts (%s)\n\n" (format-time-string "%F %T"))
1192-
:file-path agent-shell--transcript-file))
1193-
(agent-shell--append-transcript
1194-
:text .content.text
1195-
:file-path agent-shell--transcript-file)
1196-
(agent-shell--update-fragment
1197-
:state state
1198-
:block-id (format "%s-agent_thought_chunk"
1199-
(map-elt state :chunked-group-count))
1200-
:label-left (concat
1201-
agent-shell-thought-process-icon
1202-
" "
1203-
(propertize "Thought process" 'font-lock-face font-lock-doc-markup-face))
1204-
:body .content.text
1205-
:append (equal (map-elt state :last-entry-type)
1206-
"agent_thought_chunk")
1207-
:expanded agent-shell-thought-process-expand-by-default))
1185+
(let ((new-group (not (equal (map-elt state :last-entry-type)
1186+
"agent_thought_chunk"))))
1187+
(when new-group
1188+
(map-put! state :chunked-group-count (1+ (map-elt state :chunked-group-count)))
1189+
(map-put! state :thought-accumulated nil)
1190+
(agent-shell--append-transcript
1191+
:text (format "## Agent's Thoughts (%s)\n\n" (format-time-string "%F %T"))
1192+
:file-path agent-shell--transcript-file))
1193+
(let ((delta (agent-shell--thought-chunk-delta
1194+
(map-elt state :thought-accumulated)
1195+
.content.text)))
1196+
(map-put! state :thought-accumulated
1197+
(concat (or (map-elt state :thought-accumulated) "") delta))
1198+
(when (and delta (not (string-empty-p delta)))
1199+
(agent-shell--append-transcript
1200+
:text delta
1201+
:file-path agent-shell--transcript-file)
1202+
(agent-shell--update-fragment
1203+
:state state
1204+
:block-id (format "%s-agent_thought_chunk"
1205+
(map-elt state :chunked-group-count))
1206+
:label-left (concat
1207+
agent-shell-thought-process-icon
1208+
" "
1209+
(propertize "Thought process" 'font-lock-face font-lock-doc-markup-face))
1210+
:body delta
1211+
:append (not new-group)
1212+
:expanded agent-shell-thought-process-expand-by-default)))))
12081213
(map-put! state :last-entry-type "agent_thought_chunk"))
12091214
((equal (map-elt update 'sessionUpdate) "agent_message_chunk")
12101215
(unless (equal (map-elt state :last-entry-type) "agent_message_chunk")

tests/agent-shell-streaming-tests.el

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,5 +223,74 @@ c)))
223223
(kill-buffer buffer)))))
224224

225225

226+
;;; Thought chunk dedup tests
227+
228+
(ert-deftest agent-shell--thought-chunk-delta-incremental-test ()
229+
"Incremental tokens with no prefix overlap pass through unchanged."
230+
(should (equal (agent-shell--thought-chunk-delta "AB" "CD") "CD"))
231+
(should (equal (agent-shell--thought-chunk-delta nil "hello") "hello"))
232+
(should (equal (agent-shell--thought-chunk-delta "" "hello") "hello")))
233+
234+
(ert-deftest agent-shell--thought-chunk-delta-cumulative-test ()
235+
"Cumulative re-delivery returns only the new tail."
236+
(should (equal (agent-shell--thought-chunk-delta "AB" "ABCD") "CD"))
237+
(should (equal (agent-shell--thought-chunk-delta "hello " "hello world") "world")))
238+
239+
(ert-deftest agent-shell--thought-chunk-delta-exact-duplicate-test ()
240+
"Exact duplicate returns empty string."
241+
(should (equal (agent-shell--thought-chunk-delta "ABCD" "ABCD") "")))
242+
243+
(ert-deftest agent-shell--thought-chunk-delta-suffix-test ()
244+
"Chunk already present as suffix of accumulated returns empty string.
245+
This handles the case where leading whitespace tokens were streamed
246+
incrementally but the re-delivery omits them."
247+
(should (equal (agent-shell--thought-chunk-delta "\n\nABCD" "ABCD") ""))
248+
(should (equal (agent-shell--thought-chunk-delta "\n\n**bold**" "**bold**") "")))
249+
250+
(ert-deftest agent-shell--thought-chunk-no-duplication-test ()
251+
"Thought chunks must not produce duplicate output in the buffer.
252+
Replays the codex doubling pattern: incremental tokens followed by
253+
a cumulative re-delivery of the complete thought text."
254+
(let* ((buffer (get-buffer-create " *agent-shell-thought-dedup*"))
255+
(agent-shell--state (agent-shell--make-state :buffer buffer))
256+
(agent-shell--transcript-file nil)
257+
(thought-text "**Checking beads**\n\nLooking for .beads directory."))
258+
(map-put! agent-shell--state :client 'test-client)
259+
(map-put! agent-shell--state :request-count 1)
260+
(with-current-buffer buffer
261+
(erase-buffer)
262+
(agent-shell-mode))
263+
(unwind-protect
264+
(with-current-buffer buffer
265+
;; Send incremental tokens
266+
(dolist (token (list "\n\n" "**Checking" " beads**" "\n\n"
267+
"Looking" " for" " .beads" " directory."))
268+
(agent-shell--on-notification
269+
:state agent-shell--state
270+
:notification `((method . "session/update")
271+
(params . ((update
272+
. ((sessionUpdate . "agent_thought_chunk")
273+
(content (type . "text")
274+
(text . ,token)))))))))
275+
;; Cumulative re-delivery of the complete text
276+
(agent-shell--on-notification
277+
:state agent-shell--state
278+
:notification `((method . "session/update")
279+
(params . ((update
280+
. ((sessionUpdate . "agent_thought_chunk")
281+
(content (type . "text")
282+
(text . ,thought-text))))))))
283+
(let* ((buf-text (buffer-substring-no-properties (point-min) (point-max)))
284+
(count (let ((c 0) (s 0))
285+
(while (string-match "Checking beads" buf-text s)
286+
(setq c (1+ c) s (match-end 0)))
287+
c)))
288+
;; Content must be present
289+
(should (string-match-p "Checking beads" buf-text))
290+
;; Must appear exactly once (no duplication)
291+
(should (= count 1))))
292+
(when (buffer-live-p buffer)
293+
(kill-buffer buffer)))))
294+
226295
(provide 'agent-shell-streaming-tests)
227296
;;; agent-shell-streaming-tests.el ends here

0 commit comments

Comments
 (0)