Skip to content

Commit 5496a0d

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) <[email protected]>
1 parent 477d8b4 commit 5496a0d

File tree

3 files changed

+127
-21
lines changed

3 files changed

+127
-21
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 & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,7 @@ OUTGOING-REQUEST-DECORATOR (passed through to `acp-make-client')."
590590
(cons :modes nil)))
591591
(cons :last-entry-type nil)
592592
(cons :chunked-group-count 0)
593+
(cons :thought-accumulated nil)
593594
(cons :request-count 0)
594595
(cons :tool-calls nil)
595596
(cons :available-commands nil)
@@ -1192,27 +1193,34 @@ COMMAND, when present, may be a shell command string or an argv vector."
11921193
agent-shell-thought-process-icon
11931194
(propertize "Thought process" 'face font-lock-doc-markup-face)
11941195
(truncate-string-to-width (map-nested-elt acp-notification '(params update content text)) 100))
1195-
(unless (equal (map-elt state :last-entry-type)
1196-
"agent_thought_chunk")
1197-
(map-put! state :chunked-group-count (1+ (map-elt state :chunked-group-count)))
1198-
(agent-shell--append-transcript
1199-
:text (format "## Agent's Thoughts (%s)\n\n" (format-time-string "%F %T"))
1200-
:file-path agent-shell--transcript-file))
1201-
(agent-shell--append-transcript
1202-
:text (map-nested-elt acp-notification '(params update content text))
1203-
:file-path agent-shell--transcript-file)
1204-
(agent-shell--update-fragment
1205-
:state state
1206-
:block-id (format "%s-agent_thought_chunk"
1207-
(map-elt state :chunked-group-count))
1208-
:label-left (concat
1209-
agent-shell-thought-process-icon
1210-
" "
1211-
(propertize "Thought process" 'font-lock-face font-lock-doc-markup-face))
1212-
:body (map-nested-elt acp-notification '(params update content text))
1213-
:append (equal (map-elt state :last-entry-type)
1214-
"agent_thought_chunk")
1215-
:expanded agent-shell-thought-process-expand-by-default)
1196+
(let ((new-group (not (equal (map-elt state :last-entry-type)
1197+
"agent_thought_chunk"))))
1198+
(when new-group
1199+
(map-put! state :chunked-group-count (1+ (map-elt state :chunked-group-count)))
1200+
(map-put! state :thought-accumulated nil)
1201+
(agent-shell--append-transcript
1202+
:text (format "## Agent's Thoughts (%s)\n\n" (format-time-string "%F %T"))
1203+
:file-path agent-shell--transcript-file))
1204+
(let ((delta (agent-shell--thought-chunk-delta
1205+
(map-elt state :thought-accumulated)
1206+
(map-nested-elt acp-notification '(params update content text)))))
1207+
(map-put! state :thought-accumulated
1208+
(concat (or (map-elt state :thought-accumulated) "") delta))
1209+
(when (and delta (not (string-empty-p delta)))
1210+
(agent-shell--append-transcript
1211+
:text delta
1212+
:file-path agent-shell--transcript-file)
1213+
(agent-shell--update-fragment
1214+
:state state
1215+
:block-id (format "%s-agent_thought_chunk"
1216+
(map-elt state :chunked-group-count))
1217+
:label-left (concat
1218+
agent-shell-thought-process-icon
1219+
" "
1220+
(propertize "Thought process" 'font-lock-face font-lock-doc-markup-face))
1221+
:body delta
1222+
:append (not new-group)
1223+
:expanded agent-shell-thought-process-expand-by-default))))
12161224
(map-put! state :last-entry-type "agent_thought_chunk")))
12171225
((equal (map-nested-elt acp-notification '(params update sessionUpdate)) "agent_message_chunk")
12181226
;; Notification is out of context (session/prompt finished).

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)