@@ -37,6 +37,85 @@ where `shell-maker-define-major-mode' evals keymap structs as code."
3737 (use-local-map agent-shell-mode-map)))))
3838 buffer))
3939
40+ (defun agent-shell-table-test--fire-debounce ()
41+ " Fire pending markdown overlay debounce timer if present."
42+ (when (and (boundp 'agent-shell--markdown-overlay-timer )
43+ (timerp agent-shell--markdown-overlay-timer))
44+ (timer-event-handler agent-shell--markdown-overlay-timer)))
45+
46+ (defun agent-shell-table-test--table-overlays ()
47+ " Return table overlays in the current buffer, sorted by position.
48+ Each element is an alist with :start, :end, and :before-string."
49+ (let ((result nil ))
50+ (dolist (ov (overlays-in (point-min ) (point-max )))
51+ (when (eq (overlay-get ov 'invisible ) 'markdown-overlays-tables )
52+ (push (list (cons :start (overlay-start ov))
53+ (cons :end (overlay-end ov))
54+ (cons :before-string
55+ (when-let ((bs (overlay-get ov 'before-string )))
56+ (substring-no-properties bs))))
57+ result)))
58+ (sort result (lambda (a b ) (< (map-elt a :start ) (map-elt b :start ))))))
59+
60+ (defun agent-shell-table-test--send-tool-call (state tool-id )
61+ " Send a complete tool_call lifecycle (pending → meta → completed).
62+ STATE is agent-shell--state, TOOL-ID is the tool call identifier."
63+ (agent-shell--on-notification
64+ :state state
65+ :acp-notification `((method . " session/update" )
66+ (params . ((update
67+ . ((toolCallId . ,tool-id )
68+ (sessionUpdate . " tool_call" )
69+ (rawInput)
70+ (status . " pending" )
71+ (title . " Bash" )
72+ (kind . " execute" )))))))
73+ (agent-shell--on-notification
74+ :state state
75+ :acp-notification `((method . " session/update" )
76+ (params . ((update
77+ . ((_meta (claudeCode (toolResponse (stdout . " tool output" )
78+ (stderr . " " )
79+ (interrupted)
80+ (isImage)
81+ (noOutputExpected))
82+ (toolName . " Bash" )))
83+ (toolCallId . ,tool-id )
84+ (sessionUpdate . " tool_call_update" )))))))
85+ (agent-shell--on-notification
86+ :state state
87+ :acp-notification `((method . " session/update" )
88+ (params . ((update
89+ . ((toolCallId . ,tool-id )
90+ (sessionUpdate . " tool_call_update" )
91+ (status . " completed" ))))))))
92+
93+ (defun agent-shell-table-test--send-message-chunks (state tokens )
94+ " Send agent_message_chunk notifications for each token in TOKENS.
95+ STATE is agent-shell--state."
96+ (dolist (token tokens)
97+ (agent-shell--on-notification
98+ :state state
99+ :acp-notification `((method . " session/update" )
100+ (params . ((update
101+ . ((sessionUpdate . " agent_message_chunk" )
102+ (content (type . " text" )
103+ (text . ,token ))))))))))
104+
105+ ; ;; The real-world chunks from the debug session, split exactly as ACP
106+ ; ;; delivered them. The table has 4 columns and ~10 data rows.
107+ (defconst agent-shell-table-test--chunks
108+ (list
109+ " Here's the comparison:\n\n | Policy"
110+ " | lab-green-003 | prod-green-003 | lab-v6.4-003 |\n |---|---|---|---|\n | dd_pastebin | `removed"
111+ " ` | `removed` | `removed` |\n | onepassword_scim | `removed` | `removed` | `removed` |\n | us1_prod_dog_incidents_app | `removed` | `removed` | `removed` |"
112+ " \n | us1_prod_dog_pagerbeauty | `removed` | `removed` | `removed` |\n | us1_prod_dog_support_eng_access | `removed` | `removed` | `removed` |\n | us1-"
113+ " staging-fed-ssh | `removed` | `removed` | `removed` |\n | us1-staging-fed-dns | `removed` | `removed` | `removed` |\n | pci | `removed` | `removed` | `removed` |\n |"
114+ " production_ga | `removed` | `removed` | `removed` |\n | production_common_services | `removed` | `removed` | `removed` |"
115+ " \n\n All 18 policies are aligned." )
116+ " Chunk sequence from a real debug session containing a markdown table." )
117+
118+
40119(ert-deftest agent-shell--table-rows-not-split-across-lines-test ()
41120 " Markdown table rows must render with pipe-delimited cells on single lines.
42121Regression test: table rows with backtick-wrapped content like `removed`
@@ -57,69 +136,20 @@ was streamed across multiple chunks with cell boundaries split mid-chunk."
57136 (cl-letf (((symbol-function 'agent-shell--make-diff-info )
58137 (cl-function (lambda (&key acp-tool-call ) (ignore acp-tool-call)))))
59138 (with-current-buffer buffer
60- ; ; Precede with a completed tool call (as in the real session).
61- (agent-shell--on-notification
62- :state agent-shell--state
63- :acp-notification `((method . " session/update" )
64- (params . ((update
65- . ((toolCallId . ,tool-id )
66- (sessionUpdate . " tool_call" )
67- (rawInput)
68- (status . " pending" )
69- (title . " Bash" )
70- (kind . " execute" )))))))
71- (agent-shell--on-notification
72- :state agent-shell--state
73- :acp-notification `((method . " session/update" )
74- (params . ((update
75- . ((_meta (claudeCode (toolResponse (stdout . " tool output" )
76- (stderr . " " )
77- (interrupted)
78- (isImage)
79- (noOutputExpected))
80- (toolName . " Bash" )))
81- (toolCallId . ,tool-id )
82- (sessionUpdate . " tool_call_update" )))))))
83- (agent-shell--on-notification
84- :state agent-shell--state
85- :acp-notification `((method . " session/update" )
86- (params . ((update
87- . ((toolCallId . ,tool-id )
88- (sessionUpdate . " tool_call_update" )
89- (status . " completed" )))))))
90- ; ; Stream agent_message_chunk tokens containing a markdown
91- ; ; table. These are the actual chunks from a real debug
92- ; ; session, split exactly as the ACP delivered them.
93- (dolist (token (list
94- " Here's the comparison:\n\n | Policy"
95- " | lab-green-003 | prod-green-003 | lab-v6.4-003 |\n |---|---|---|---|\n | dd_pastebin | `removed"
96- " ` | `removed` | `removed` |\n | onepassword_scim | `removed` | `removed` | `removed` |\n | us1_prod_dog_incidents_app | `removed` | `removed` | `removed` |"
97- " \n | us1_prod_dog_pagerbeauty | `removed` | `removed` | `removed` |\n | us1_prod_dog_support_eng_access | `removed` | `removed` | `removed` |\n | us1-"
98- " staging-fed-ssh | `removed` | `removed` | `removed` |\n | us1-staging-fed-dns | `removed` | `removed` | `removed` |\n | pci | `removed` | `removed` | `removed` |\n |"
99- " production_ga | `removed` | `removed` | `removed` |\n | production_common_services | `removed` | `removed` | `removed` |"
100- " \n\n All 18 policies are aligned." ))
101- (agent-shell--on-notification
102- :state agent-shell--state
103- :acp-notification `((method . " session/update" )
104- (params . ((update
105- . ((sessionUpdate . " agent_message_chunk" )
106- (content (type . " text" )
107- (text . ,token )))))))))
108- ; ; Fire any pending debounce timers (streaming-dedup branch).
109- (when (and (boundp 'agent-shell--markdown-overlay-timer )
110- (timerp agent-shell--markdown-overlay-timer))
111- (timer-event-handler agent-shell--markdown-overlay-timer))
112- ; ; Verify: the table content is visible.
139+ (agent-shell-table-test--send-tool-call agent-shell--state tool-id)
140+ (agent-shell-table-test--send-message-chunks
141+ agent-shell--state agent-shell-table-test--chunks)
142+ (agent-shell-table-test--fire-debounce)
143+ ; ; Verify: the table content is visible in the raw text.
113144 (let ((visible-text (agent-shell-test--visible-buffer-string)))
114145 ; ; Header row must be intact on one line.
115146 (should (string-match-p
116147 " | Policy.*| lab-green-003.*| prod-green-003.*| lab-v6.4-003 |"
117148 visible-text))
118149 ; ; Separator row.
119150 (should (string-match-p " |---|---|---|---|" visible-text))
120- ; ; A data row: policy name and all three `removed` cells
121- ; ; must appear on the same logical line (no stray newlines
122- ; ; splitting cell content from its row).
151+ ; ; Data rows: policy name and all three `removed` cells
152+ ; ; must appear on the same logical line.
123153 (should (string-match-p
124154 " | dd_pastebin.*|.*removed.*|.*removed.*|.*removed.*|"
125155 visible-text))
@@ -128,10 +158,8 @@ was streamed across multiple chunks with cell boundaries split mid-chunk."
128158 visible-text))
129159 ; ; Post-table text must be visible.
130160 (should (string-match-p " All 18 policies are aligned" visible-text)))
131- ; ; Stronger check: no line in the visible text should consist
132- ; ; of just "removed" (with optional backticks/whitespace).
133- ; ; This is the specific regression symptom: cell content
134- ; ; appearing on standalone lines outside the table structure.
161+ ; ; No line should consist of just "removed" — the regression
162+ ; ; symptom of cell content breaking out of the table.
135163 (let ((visible-text (agent-shell-test--visible-buffer-string)))
136164 (dolist (line (split-string visible-text " \n " ))
137165 (should-not (string-match-p
@@ -140,5 +168,92 @@ was streamed across multiple chunks with cell boundaries split mid-chunk."
140168 (when (buffer-live-p buffer)
141169 (kill-buffer buffer)))))
142170
171+ (ert-deftest agent-shell--table-overlay-structure-test ()
172+ " Each table row must have exactly one overlay with correct before-string.
173+ After all chunks arrive and markdown overlays are applied, the overlay
174+ structure should show:
175+ - 1 header row overlay containing all column names
176+ - 1 separator overlay
177+ - N data row overlays, each containing the policy name and all cells"
178+ (let* ((buffer (agent-shell-table-test--setup-buffer))
179+ (agent-shell--state (agent-shell--make-state :buffer buffer))
180+ (agent-shell--transcript-file nil )
181+ (tool-id " toolu_overlay_test" ))
182+ (map-put! agent-shell--state :client 'test-client )
183+ (map-put! agent-shell--state :request-count 1 )
184+ (map-put! agent-shell--state :active-requests (list t ))
185+ (unwind-protect
186+ (cl-letf (((symbol-function 'agent-shell--make-diff-info )
187+ (cl-function (lambda (&key acp-tool-call ) (ignore acp-tool-call)))))
188+ (with-current-buffer buffer
189+ (agent-shell-table-test--send-tool-call agent-shell--state tool-id)
190+ (agent-shell-table-test--send-message-chunks
191+ agent-shell--state agent-shell-table-test--chunks)
192+ (agent-shell-table-test--fire-debounce)
193+ (let ((table-ovs (agent-shell-table-test--table-overlays)))
194+ ; ; 1 header + 1 separator + 10 data rows = 12 overlays
195+ (should (= 12 (length table-ovs)))
196+ ; ; First overlay is the header row.
197+ (let ((header-bs (map-elt (car table-ovs) :before-string )))
198+ (should (string-match-p " Policy" header-bs))
199+ (should (string-match-p " lab-green-003" header-bs))
200+ (should (string-match-p " prod-green-003" header-bs))
201+ (should (string-match-p " lab-v6.4-003" header-bs)))
202+ ; ; Each data row overlay (index 2+) must contain "removed"
203+ ; ; and the cell content must be on a single line.
204+ (dolist (ov (nthcdr 2 table-ovs))
205+ (let ((bs (map-elt ov :before-string )))
206+ (should (string-match-p " removed" bs))
207+ ; ; The before-string for a single-line row should
208+ ; ; NOT contain newlines (multi-line wrapping aside).
209+ ; ; If it does, cells are being split.
210+ (should-not (string-match-p " \n " bs)))))))
211+ (when (buffer-live-p buffer)
212+ (kill-buffer buffer)))))
213+
214+ (ert-deftest agent-shell--table-mid-stream-overlay-cleanup-test ()
215+ " Overlays from partial table rendering must be cleaned up after full table arrives.
216+ Simulates the debounce timer firing mid-stream (when only part of the
217+ table has been received), then checks that the final overlay state is
218+ correct after all chunks arrive."
219+ (let* ((buffer (agent-shell-table-test--setup-buffer))
220+ (agent-shell--state (agent-shell--make-state :buffer buffer))
221+ (agent-shell--transcript-file nil )
222+ (tool-id " toolu_midstream_test" )
223+ (all-chunks agent-shell-table-test--chunks)
224+ ; ; Split: first 3 chunks = partial table, rest = completion.
225+ (early-chunks (seq-take all-chunks 3 ))
226+ (late-chunks (seq-drop all-chunks 3 )))
227+ (map-put! agent-shell--state :client 'test-client )
228+ (map-put! agent-shell--state :request-count 1 )
229+ (map-put! agent-shell--state :active-requests (list t ))
230+ (unwind-protect
231+ (cl-letf (((symbol-function 'agent-shell--make-diff-info )
232+ (cl-function (lambda (&key acp-tool-call ) (ignore acp-tool-call)))))
233+ (with-current-buffer buffer
234+ (agent-shell-table-test--send-tool-call agent-shell--state tool-id)
235+ ; ; Stream partial table
236+ (agent-shell-table-test--send-message-chunks
237+ agent-shell--state early-chunks)
238+ ; ; Fire debounce mid-stream (partial table gets overlaid)
239+ (agent-shell-table-test--fire-debounce)
240+ (let ((partial-ovs (agent-shell-table-test--table-overlays)))
241+ ; ; Partial table should have SOME overlays (header + sep + rows so far).
242+ (should (< 0 (length partial-ovs))))
243+ ; ; Stream remaining chunks
244+ (agent-shell-table-test--send-message-chunks
245+ agent-shell--state late-chunks)
246+ ; ; Fire debounce again (full table)
247+ (agent-shell-table-test--fire-debounce)
248+ (let ((final-ovs (agent-shell-table-test--table-overlays)))
249+ ; ; Full table: 1 header + 1 separator + 10 data rows = 12
250+ (should (= 12 (length final-ovs)))
251+ ; ; Every data row overlay should contain "removed".
252+ (dolist (ov (nthcdr 2 final-ovs))
253+ (should (string-match-p " removed"
254+ (map-elt ov :before-string )))))))
255+ (when (buffer-live-p buffer)
256+ (kill-buffer buffer)))))
257+
143258(provide 'agent-shell-table-tests )
144259; ;; agent-shell-table-tests.el ends here
0 commit comments