diff --git a/README.md b/README.md index fbc6170..dadffb3 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ Chat - `eca-chat-prompt-prefix`: Prompt prefix string shown before user input. - `eca-chat-prompt-prefix-loading`: Prompt prefix string while a request is in progress. - `eca-chat-read-only-history`: Whether the chat history/output, the `---` separator and the task area are read-only so only the progress, `@`-context and prompt input lines stay editable (default `t`). Set to `nil` to keep the whole buffer writable. -- `eca-chat-stream-flush-interval`: Seconds to buffer top-level assistant stream chunks before rendering (default `0.05`). Set to `nil` for immediate rendering. +- `eca-chat-stream-flush-interval`: Seconds to buffer chat stream UI before rendering (default `0.05`). This delays top-level assistant text, subagent assistant text, and `toolCallPrepare` UI updates. Set to `nil` for immediate rendering. - `eca-chat-hide-markdown-markup`: Whether to hide markdown markup in chat buffers (default `t`). Set to `nil` to keep fences/backticks visible, which may avoid fenced code blocks jumping while typing or streaming. - `eca-chat-fontify-prompt`: Whether to apply Markdown fontification to prompt text (default `t`). Set to `nil` to skip prompt-area Markdown block scans in large chat buffers. - `eca-chat-history-page-size`: Number of newest messages to load when opening a persisted chat (default `50`). When non-nil, `eca-chat-resume` opens chats with a bounded window and shows a "Load older messages" control to page through earlier history on demand; set to `nil` to replay the entire history on open. diff --git a/benchmarks/eca-chat-render-coalescing-bench.el b/benchmarks/eca-chat-render-coalescing-bench.el new file mode 100644 index 0000000..c3f77c1 --- /dev/null +++ b/benchmarks/eca-chat-render-coalescing-bench.el @@ -0,0 +1,556 @@ +;;; eca-chat-render-coalescing-bench.el --- Render coalescing bench -*- lexical-binding: t; -*- +;; Copyright (C) 2026 Eric Dallo +;; +;; SPDX-License-Identifier: Apache-2.0 +;; +;; This file is not part of GNU Emacs. +;; +;;; Commentary: +;; +;; Focused benchmark harness for chat render coalescing work. +;; +;; This benchmark targets high-frequency render paths that can benefit +;; from batching or coalescing: +;; +;; - top-level assistant text +;; - subagent assistant text +;; - toolCallPrepare argument updates +;; - mixed text, prepare, and lifecycle events +;; - history replay +;; +;; Batch usage with Eask: +;; eask emacs --batch -L . \ +;; -l benchmarks/eca-chat-render-coalescing-bench.el \ +;; -f eca-chat-render-coalescing-bench-run +;; +;;; Code: + +(require 'benchmark) +(require 'cl-lib) +(require 'subr-x) + +;; Prefer source files over stale byte-compiled files in local runs. +(setq load-prefer-newer t) + +(defvar eca-chat-render-coalescing-bench-repo-root nil + "Repository root that contains this benchmark file.") + +(let* ((this-file (or load-file-name buffer-file-name)) + (bench-dir (and this-file (file-name-directory this-file))) + (repo-root (and bench-dir + (file-name-directory + (directory-file-name bench-dir))))) + (setq eca-chat-render-coalescing-bench-repo-root repo-root) + (when repo-root + (add-to-list 'load-path repo-root)) + (when bench-dir + (add-to-list 'load-path bench-dir))) + +(require 'eca-chat-bench) + +;;;; Configuration + +(defvar eca-chat-render-coalescing-bench-base-turns 25 + "Number of seed turns in the synthetic chat buffer.") + +(defvar eca-chat-render-coalescing-bench-text-chunks 1500 + "Number of text chunks in stream benchmark cases.") + +(defvar eca-chat-render-coalescing-bench-prepare-chunks 600 + "Number of argument chunks in toolCallPrepare benchmark cases.") + +(defvar eca-chat-render-coalescing-bench-history-groups 80 + "Number of generated groups in the history replay benchmark.") + +(defvar eca-chat-render-coalescing-bench-parent-count 4 + "Number of parent subagent tool calls for split-parent benchmarks.") + +(defvar eca-chat-render-coalescing-bench--results nil + "Accumulated benchmark result plists.") + +;;;; Metadata + +(defun eca-chat-render-coalescing-bench--git-output (&rest args) + "Run git with ARGS and return trimmed output. +Return nil when git exits with a non-zero status." + (when eca-chat-render-coalescing-bench-repo-root + (with-temp-buffer + (let* ((default-directory eca-chat-render-coalescing-bench-repo-root) + (status (apply #'process-file "git" nil t nil args))) + (when (eq status 0) + (string-trim (buffer-string))))))) + +(defun eca-chat-render-coalescing-bench--metadata () + "Return benchmark metadata as markdown." + (let ((branch (or (eca-chat-render-coalescing-bench--git-output + "branch" "--show-current") + "unknown")) + (commit (or (eca-chat-render-coalescing-bench--git-output + "rev-parse" "--short" "HEAD") + "unknown")) + (dirty (eca-chat-render-coalescing-bench--git-output + "status" "--short")) + (command (mapconcat #'shell-quote-argument command-line-args " "))) + (concat + "# ECA chat render coalescing benchmark\n\n" + (format "- Emacs: `%s`\n" emacs-version) + (format "- Branch: `%s`\n" branch) + (format "- Commit: `%s`\n" commit) + (format "- Command: `%s`\n" command) + (format "- Worktree dirty: `%s`\n\n" + (if (and dirty (not (string-empty-p dirty))) "yes" "no"))))) + +;;;; Fixture and event helpers + +(defun eca-chat-render-coalescing-bench--chunk (i) + "Return one representative text chunk for index I." + (format "chunk-%04d with `code`, **bold**, _emphasis_, and prose.\n" i)) + +(defun eca-chat-render-coalescing-bench--prepare-chunk (i) + "Return one representative tool argument chunk for index I." + (format "\"field-%04d\": \"value with `code` and **markdown**\",\n" i)) + +(defun eca-chat-render-coalescing-bench--tool-prepare-content (id i) + "Return a toolCallPrepare content plist for ID and chunk index I." + (list :type "toolCallPrepare" + :id id + :name "benchTool" + :server "benchServer" + :argumentsText (eca-chat-render-coalescing-bench--prepare-chunk i) + :details (list :type "generic"))) + +(defun eca-chat-render-coalescing-bench--tool-running-content (id) + "Return a toolCallRunning content plist for ID." + (list :type "toolCallRunning" + :id id + :name "benchTool" + :server "benchServer" + :arguments "{}" + :details (list :type "generic"))) + +(defun eca-chat-render-coalescing-bench--tool-called-content (id) + "Return a toolCalled content plist for ID." + (list :type "toolCalled" + :id id + :name "benchTool" + :server "benchServer" + :arguments "{}" + :outputs (vector (list :text "done")) + :details (list :type "generic") + :totalTimeMs 42)) + +(defun eca-chat-render-coalescing-bench--subagent-run-content + (id subagent-chat-id) + "Return a subagent toolCallRun content plist. +ID is the parent tool-call ID. +SUBAGENT-CHAT-ID identifies the child chat." + (list :type "toolCallRun" + :id id + :name "subagent" + :server "eca" + :arguments (list :agent "coder" :task "benchmark subagent stream") + :details (list :type "subagent" + :model "bench-model" + :subagentChatId subagent-chat-id + :step 1 + :maxSteps 10))) + +(defun eca-chat-render-coalescing-bench--subagent-called-content + (id subagent-chat-id) + "Return a subagent toolCalled content plist for ID. +SUBAGENT-CHAT-ID identifies the child chat." + (list :type "toolCalled" + :id id + :name "subagent" + :server "eca" + :arguments (list :agent "coder" :task "benchmark subagent stream") + :outputs (vector (list :text "subagent complete")) + :details (list :type "subagent" + :model "bench-model" + :subagentChatId subagent-chat-id + :step 10 + :maxSteps 10) + :totalTimeMs 1234)) + +(defun eca-chat-render-coalescing-bench--assistant-content (text) + "Return assistant text content with TEXT." + (list :type "text" :text text)) + +(defun eca-chat-render-coalescing-bench--render (session buffer role content + &optional parent-id chat-id) + "Render CONTENT in BUFFER for SESSION. +ROLE, PARENT-ID, and CHAT-ID match `eca-chat--render-content'." + (eca-chat--render-content + session buffer role content + (eca--session-workspace-folders session) + parent-id chat-id)) + +(defun eca-chat-render-coalescing-bench--make-buffer () + "Return (BUFFER . SESSION) for a benchmark chat buffer." + (let ((eca-chat-bench--session nil)) + (let ((buffer (eca-chat-bench--make-fixture + eca-chat-render-coalescing-bench-base-turns))) + (cons buffer eca-chat-bench--session)))) + +(defun eca-chat-render-coalescing-bench--setup-subagent-parent + (session buffer parent-id chat-id) + "Create and open a subagent parent block. +SESSION and BUFFER identify the current benchmark chat. +PARENT-ID is the parent tool-call ID. +CHAT-ID is the subagent child chat ID." + (eca-chat-render-coalescing-bench--render + session buffer "assistant" + (eca-chat-render-coalescing-bench--subagent-run-content parent-id chat-id)) + (eca-chat--expandable-content-toggle parent-id t nil)) + +(defun eca-chat-render-coalescing-bench--setup-prepare-block + (session buffer tool-id) + "Create and open a toolCallPrepare block for TOOL-ID." + (let ((eca-chat-stream-flush-interval nil) + (eca-chat-tool-call-prepare-throttle 'all)) + (eca-chat-render-coalescing-bench--render + session buffer "assistant" + (eca-chat-render-coalescing-bench--tool-prepare-content tool-id 0)) + (eca-chat--expandable-content-toggle tool-id t nil))) + +(defun eca-chat-render-coalescing-bench--history-item-count () + "Return the generated history item count." + (* eca-chat-render-coalescing-bench-history-groups 5)) + +(defun eca-chat-render-coalescing-bench--history-contents () + "Return generated history contents for replay benchmarking." + (let (items) + (dotimes (i eca-chat-render-coalescing-bench-history-groups) + (let ((parent-id (format "hist-subagent-parent-%03d" i)) + (child-chat-id (format "hist-subagent-child-%03d" i)) + (root-chat-id (format "hist-root-%03d" i))) + (push (list :chatId root-chat-id + :role "user" + :content (list :type "text" + :contentId (format "hist-user-%03d" i) + :text (format "history user %03d" i))) + items) + (push (list :chatId root-chat-id + :role "assistant" + :content (eca-chat-render-coalescing-bench--assistant-content + (eca-chat-render-coalescing-bench--chunk i))) + items) + (push (list :chatId root-chat-id + :role "assistant" + :content (eca-chat-render-coalescing-bench--tool-called-content + (format "hist-tool-%03d" i))) + items) + (push (list :chatId root-chat-id + :role "assistant" + :content (eca-chat-render-coalescing-bench--subagent-run-content + parent-id child-chat-id)) + items) + (push (list :chatId child-chat-id + :parentChatId root-chat-id + :role "assistant" + :content (eca-chat-render-coalescing-bench--assistant-content + (concat "nested " + (eca-chat-render-coalescing-bench--chunk i)))) + items))) + (nreverse items))) + +;;;; Counting and timing + +(defvar eca-chat-render-coalescing-bench--counts nil + "Current benchmark call counters.") + +(defun eca-chat-render-coalescing-bench--inc (key) + "Increment count KEY in the active benchmark counter table." + (when (hash-table-p eca-chat-render-coalescing-bench--counts) + (puthash key + (1+ (gethash key eca-chat-render-coalescing-bench--counts 0)) + eca-chat-render-coalescing-bench--counts))) + +(defun eca-chat-render-coalescing-bench--call-with-counts (fn) + "Call FN while counting important render helper calls." + (let ((render-content-fn (symbol-function 'eca-chat--render-content)) + (update-expandable-fn + (symbol-function 'eca-chat--update-expandable-content)) + (add-text-fn (symbol-function 'eca-chat--add-text-content))) + (cl-letf (((symbol-function 'eca-chat--render-content) + (lambda (&rest args) + (eca-chat-render-coalescing-bench--inc 'render-content) + (apply render-content-fn args))) + ((symbol-function 'eca-chat--update-expandable-content) + (lambda (&rest args) + (eca-chat-render-coalescing-bench--inc 'update-expandable) + (apply update-expandable-fn args))) + ((symbol-function 'eca-chat--add-text-content) + (lambda (&rest args) + (eca-chat-render-coalescing-bench--inc 'add-text) + (apply add-text-fn args)))) + (funcall fn)))) + +(defun eca-chat-render-coalescing-bench--count (counts key) + "Return COUNTS value for KEY, or zero." + (gethash key counts 0)) + +(defun eca-chat-render-coalescing-bench--time-buffer + (label events setup-fn run-fn) + "Run one benchmark row. +LABEL names the row. +EVENTS is the number of target events in RUN-FN. +SETUP-FN prepares the fixture outside timing. +RUN-FN executes the measured workload." + (let* ((fixture (eca-chat-render-coalescing-bench--make-buffer)) + (buffer (car fixture)) + (session (cdr fixture)) + (counts (make-hash-table :test 'eq)) + result buffer-size) + (advice-add 'face-background + :around #'eca-chat-bench--safe-face-background) + (unwind-protect + (with-current-buffer buffer + (let ((inhibit-message t) + (inhibit-read-only t)) + (funcall setup-fn session buffer) + (garbage-collect) + (let ((eca-chat-render-coalescing-bench--counts counts)) + (setq result + (benchmark-call + (lambda () + (let ((inhibit-read-only t)) + (eca-chat-render-coalescing-bench--call-with-counts + (lambda () + (funcall run-fn session buffer))))) + 1))) + (setq buffer-size (buffer-size)))) + (advice-remove 'face-background #'eca-chat-bench--safe-face-background) + (when (buffer-live-p buffer) + (kill-buffer buffer))) + (list :label label + :events events + :iters 1 + :elapsed (nth 0 result) + :gc-count (nth 1 result) + :gc-elapsed (nth 2 result) + :render-calls + (eca-chat-render-coalescing-bench--count counts 'render-content) + :update-calls + (eca-chat-render-coalescing-bench--count counts 'update-expandable) + :add-text-calls + (eca-chat-render-coalescing-bench--count counts 'add-text) + :buffer-size buffer-size))) + +;;;; Benchmark cases + +(defun eca-chat-render-coalescing-bench--bench-top-level () + "Benchmark top-level assistant stream rendering." + (let ((chunks eca-chat-render-coalescing-bench-text-chunks)) + (eca-chat-render-coalescing-bench--time-buffer + 'top-level-assistant chunks + (lambda (_session _buffer)) + (lambda (session buffer) + (let ((eca-chat-stream-flush-interval 60) + (eca-chat-fontify-debounce-interval nil)) + (dotimes (i chunks) + (eca-chat-render-coalescing-bench--render + session buffer "assistant" + (eca-chat-render-coalescing-bench--assistant-content + (eca-chat-render-coalescing-bench--chunk i)))) + (eca-chat--stream-flush)))))) + +(defun eca-chat-render-coalescing-bench--bench-subagent-one-parent () + "Benchmark subagent assistant stream under one parent tool call." + (let ((chunks eca-chat-render-coalescing-bench-text-chunks) + (parent-id "subagent-parent-1") + (child-chat-id "subagent-child-1")) + (eca-chat-render-coalescing-bench--time-buffer + 'subagent-one-parent chunks + (lambda (session buffer) + (eca-chat-render-coalescing-bench--setup-subagent-parent + session buffer parent-id child-chat-id)) + (lambda (session buffer) + (let ((eca-chat-stream-flush-interval 60)) + (dotimes (i chunks) + (eca-chat-render-coalescing-bench--render + session buffer "assistant" + (eca-chat-render-coalescing-bench--assistant-content + (eca-chat-render-coalescing-bench--chunk i)) + parent-id child-chat-id)) + (eca-chat--stream-flush)))))) + +(defun eca-chat-render-coalescing-bench--bench-subagent-split-parents () + "Benchmark subagent assistant streams split across parent tool calls." + (let* ((chunks eca-chat-render-coalescing-bench-text-chunks) + (parent-count eca-chat-render-coalescing-bench-parent-count) + (parents (cl-loop for i below parent-count + collect (format "subagent-parent-%d" i))) + (children (cl-loop for i below parent-count + collect (format "subagent-child-%d" i)))) + (eca-chat-render-coalescing-bench--time-buffer + 'subagent-split-parents chunks + (lambda (session buffer) + (cl-loop for parent in parents + for child in children + do (eca-chat-render-coalescing-bench--setup-subagent-parent + session buffer parent child))) + (lambda (session buffer) + (let ((eca-chat-stream-flush-interval 60)) + (dotimes (i chunks) + (let ((index (mod i parent-count))) + (eca-chat-render-coalescing-bench--render + session buffer "assistant" + (eca-chat-render-coalescing-bench--assistant-content + (eca-chat-render-coalescing-bench--chunk i)) + (nth index parents) + (nth index children)))) + (eca-chat--stream-flush)))))) + +(defun eca-chat-render-coalescing-bench--bench-tool-prepare (throttle label) + "Benchmark toolCallPrepare rendering with THROTTLE. +LABEL names the result row." + (let ((chunks eca-chat-render-coalescing-bench-prepare-chunks) + (tool-id (format "prepare-%s" label))) + (eca-chat-render-coalescing-bench--time-buffer + label chunks + (lambda (session buffer) + (eca-chat-render-coalescing-bench--setup-prepare-block + session buffer tool-id)) + (lambda (session buffer) + (let ((eca-chat-stream-flush-interval 60) + (eca-chat-tool-call-prepare-throttle throttle) + (eca-chat-tool-call-prepare-update-interval 5)) + (dotimes (i chunks) + (eca-chat-render-coalescing-bench--render + session buffer "assistant" + (eca-chat-render-coalescing-bench--tool-prepare-content + tool-id (1+ i)))) + (eca-chat--stream-flush)))))) + +(defun eca-chat-render-coalescing-bench--bench-mixed-stream () + "Benchmark mixed assistant, prepare, subagent, and lifecycle events." + (let* ((chunks 500) + (parent-id "mixed-subagent-parent") + (child-chat-id "mixed-subagent-child") + (tool-id "mixed-prepare")) + (eca-chat-render-coalescing-bench--time-buffer + 'mixed-stream (+ (* chunks 3) 2) + (lambda (session buffer) + (eca-chat-render-coalescing-bench--setup-subagent-parent + session buffer parent-id child-chat-id) + (eca-chat-render-coalescing-bench--setup-prepare-block + session buffer tool-id)) + (lambda (session buffer) + (let ((eca-chat-stream-flush-interval 60) + (eca-chat-tool-call-prepare-throttle 'smart) + (eca-chat-tool-call-prepare-update-interval 5)) + (dotimes (i chunks) + (eca-chat-render-coalescing-bench--render + session buffer "assistant" + (eca-chat-render-coalescing-bench--assistant-content + (eca-chat-render-coalescing-bench--chunk i))) + (eca-chat-render-coalescing-bench--render + session buffer "assistant" + (eca-chat-render-coalescing-bench--tool-prepare-content + tool-id i)) + (eca-chat-render-coalescing-bench--render + session buffer "assistant" + (eca-chat-render-coalescing-bench--assistant-content + (concat "subagent " + (eca-chat-render-coalescing-bench--chunk i))) + parent-id child-chat-id)) + (eca-chat-render-coalescing-bench--render + session buffer "assistant" + (eca-chat-render-coalescing-bench--tool-running-content tool-id)) + (eca-chat-render-coalescing-bench--render + session buffer "assistant" + (eca-chat-render-coalescing-bench--subagent-called-content + parent-id child-chat-id)) + (eca-chat--stream-flush)))))) + +(defun eca-chat-render-coalescing-bench--bench-history-replay () + "Benchmark batch history replay." + (let ((contents (eca-chat-render-coalescing-bench--history-contents))) + (eca-chat-render-coalescing-bench--time-buffer + 'history-replay (length contents) + (lambda (_session _buffer)) + (lambda (session buffer) + (eca-chat--render-history-contents session buffer contents))))) + +;;;; Output + +(defun eca-chat-render-coalescing-bench--reset () + "Reset benchmark result state." + (setq eca-chat-render-coalescing-bench--results nil)) + +(defun eca-chat-render-coalescing-bench--add-result (result) + "Append RESULT to the benchmark results." + (setq eca-chat-render-coalescing-bench--results + (append eca-chat-render-coalescing-bench--results (list result)))) + +(defun eca-chat-render-coalescing-bench--format-results () + "Return benchmark results as a markdown table." + (concat + "| op | events | renders | updates | add-text | chars | gc | gc-ms | wall-ms | per-event-us |\n" + "|----|-------:|--------:|--------:|---------:|------:|---:|------:|--------:|-------------:|\n" + (mapconcat + (lambda (result) + (let* ((events (plist-get result :events)) + (elapsed (plist-get result :elapsed)) + (gc-elapsed (plist-get result :gc-elapsed)) + (per-event-us (if (> events 0) + (/ (* elapsed 1000000.0) events) + 0.0))) + (format "| %s | %d | %d | %d | %d | %d | %d | %.2f | %.2f | %.2f |" + (plist-get result :label) + events + (plist-get result :render-calls) + (plist-get result :update-calls) + (plist-get result :add-text-calls) + (plist-get result :buffer-size) + (plist-get result :gc-count) + (* gc-elapsed 1000.0) + (* elapsed 1000.0) + per-event-us))) + eca-chat-render-coalescing-bench--results + "\n") + "\n")) + +;;;###autoload +(defun eca-chat-render-coalescing-bench-run () + "Run render coalescing benchmarks and print markdown results." + (interactive) + (eca-chat-render-coalescing-bench--reset) + (message "[render-coalescing-bench] top-level assistant ...") + (eca-chat-render-coalescing-bench--add-result + (eca-chat-render-coalescing-bench--bench-top-level)) + (message "[render-coalescing-bench] subagent one parent ...") + (eca-chat-render-coalescing-bench--add-result + (eca-chat-render-coalescing-bench--bench-subagent-one-parent)) + (message "[render-coalescing-bench] subagent split parents ...") + (eca-chat-render-coalescing-bench--add-result + (eca-chat-render-coalescing-bench--bench-subagent-split-parents)) + (message "[render-coalescing-bench] tool prepare all ...") + (eca-chat-render-coalescing-bench--add-result + (eca-chat-render-coalescing-bench--bench-tool-prepare + 'all 'tool-prepare-all)) + (message "[render-coalescing-bench] tool prepare smart ...") + (eca-chat-render-coalescing-bench--add-result + (eca-chat-render-coalescing-bench--bench-tool-prepare + 'smart 'tool-prepare-smart)) + (message "[render-coalescing-bench] mixed stream ...") + (eca-chat-render-coalescing-bench--add-result + (eca-chat-render-coalescing-bench--bench-mixed-stream)) + (message "[render-coalescing-bench] history replay ...") + (eca-chat-render-coalescing-bench--add-result + (eca-chat-render-coalescing-bench--bench-history-replay)) + (let ((output (concat (eca-chat-render-coalescing-bench--metadata) + (eca-chat-render-coalescing-bench--format-results)))) + (if noninteractive + (princ output) + (with-current-buffer + (get-buffer-create "*eca-chat-render-coalescing-bench*") + (let ((inhibit-read-only t)) + (erase-buffer) + (insert output)) + (display-buffer (current-buffer)))) + output)) + +(provide 'eca-chat-render-coalescing-bench) +;;; eca-chat-render-coalescing-bench.el ends here diff --git a/eca-chat-expandable.el b/eca-chat-expandable.el index 9d26dec..102507d 100644 --- a/eca-chat-expandable.el +++ b/eca-chat-expandable.el @@ -369,16 +369,20 @@ the default content insertion point." (eca-chat--update-expandable-content id label content nil parent-id) (let* ((icon-face (get-text-property 0 'font-lock-face label)) (child-spec (list :type 'child :id id :label label :content content :icon-face icon-face)) - ;; Capture any unsegmented text that was appended to content - ;; since the last segment, and add it as a text segment first + ;; Capture text that arrived before this child block so + ;; collapsed parents reopen in receive order. (ov-content (overlay-get parent-ov 'eca-chat--expandable-content-ov-content)) (full-content (overlay-get ov-content 'eca-chat--expandable-content-content)) (segmented-text (eca-chat--segments-total-text segments)) - (unsegmented-text (when (> (length full-content) (length segmented-text)) - (substring full-content (length segmented-text)))) + (unsegmented-text (if segments + (when (> (length full-content) (length segmented-text)) + (substring full-content (length segmented-text))) + full-content)) + (text-segment (when (and unsegmented-text + (not (string-empty-p unsegmented-text))) + (list :type 'text :content unsegmented-text))) (new-segments (append segments - (when unsegmented-text - (list (list :type 'text :content unsegmented-text))) + (when text-segment (list text-segment)) (list child-spec)))) (overlay-put parent-ov 'eca-chat--expandable-content-segments new-segments) (when (overlay-get parent-ov 'eca-chat--expandable-content-toggle) @@ -527,10 +531,24 @@ in parent." (plist-put existing-spec :label label) (plist-put existing-spec :icon-face (get-text-property 0 'font-lock-face label))) (plist-put existing-spec :content new-content)) - (let ((child-spec (list :type 'child :id id :label label :content content - :icon-face (get-text-property 0 'font-lock-face label)))) + (let* ((child-spec (list :type 'child :id id :label label :content content + :icon-face (get-text-property 0 'font-lock-face label))) + ;; Capture text that arrived before this child block so + ;; collapsed parents reopen in receive order. + (ov-content (overlay-get parent-ov 'eca-chat--expandable-content-ov-content)) + (full-content (overlay-get ov-content 'eca-chat--expandable-content-content)) + (segmented-text (eca-chat--segments-total-text segments)) + (unsegmented-text (if segments + (when (> (length full-content) (length segmented-text)) + (substring full-content (length segmented-text))) + full-content)) + (text-segment (when (and unsegmented-text + (not (string-empty-p unsegmented-text))) + (list :type 'text :content unsegmented-text)))) (overlay-put parent-ov 'eca-chat--expandable-content-segments - (append segments (list child-spec))) + (append segments + (when text-segment (list text-segment)) + (list child-spec))) (when (overlay-get parent-ov 'eca-chat--expandable-content-toggle) (eca-chat--render-nested-block parent-ov child-spec)))))) ;; Top-level: create the block so toolCallRun et al. don't diff --git a/eca-chat.el b/eca-chat.el index 5263d23..5c681cc 100644 --- a/eca-chat.el +++ b/eca-chat.el @@ -328,10 +328,11 @@ Must be a positive integer." :group 'eca) (defcustom eca-chat-stream-flush-interval 0.05 - "Seconds to buffer assistant text chunks before rendering. -Default is 0.05. When nil, assistant text renders immediately. -When set to a non-negative number, top-level assistant text chunks -are combined and rendered on a timer." + "Seconds to buffer chat stream UI before rendering. +The default is 0.05. When nil, assistant text and prepare UI +render immediately. When set to a non-negative number, top-level +assistant text, subagent text, and `toolCallPrepare' UI updates +render on a timer." :type '(choice (const :tag "Immediate rendering" nil) (number :tag "Seconds")) :group 'eca) @@ -434,6 +435,15 @@ Set this to nil if typing in large ECA chat buffers is slow." (defvar-local eca-chat--tool-call-prepare-content-cache (make-hash-table :test 'equal) "Hash table mapping toolCall ID to accumulated argument text.") +(defvar-local eca-chat--tool-call-prepare-display-cache nil + "Hash table mapping toolCall ID to pending prepare render metadata.") + +(defvar-local eca-chat--tool-call-prepare-pending-order nil + "Tool-call IDs with pending prepare renders, in first-seen order.") + +(defvar-local eca-chat--tool-call-prepare-finalized-ids nil + "Hash table of tool-call IDs past the prepare phase.") + (defvar-local eca-chat--pending-approvals-cache 'dirty "Cached result of `eca-chat--has-pending-approvals-p'. Either a boolean or the symbol `dirty' when the buffer must be @@ -878,6 +888,14 @@ once by `eca-chat-cleared'.") (defvar-local eca-chat--table-resize-timer nil) (defvar-local eca-chat--stream-pending-chunks nil "Pending top-level assistant text chunks for buffered stream rendering.") +(defvar-local eca-chat--stream-pending-parent-chunks nil + "Hash table from parent tool-call IDs to pending assistant text chunks.") +(defvar-local eca-chat--stream-pending-parent-order nil + "Parent tool-call IDs with pending stream chunks, in first-seen order.") +(defvar-local eca-chat--stream-pending-render-order nil + "Pending stream render entries, in first-seen order.") +(defvar-local eca-chat--stream-pending-copy-start nil + "Non-nil when buffered top-level text must set copy start.") (defvar-local eca-chat--stream-flush-timer nil "Timer that flushes pending assistant stream text.") (defvar-local eca-chat--fontify-timer nil @@ -885,7 +903,7 @@ once by `eca-chat-cleared'.") (defvar-local eca-chat--progress-text "") (defvar-local eca-chat--last-user-message-pos nil) (defvar-local eca-chat--last-response-copy-start nil - "Buffer position where the latest assistant response starts.") + "Buffer position or marker where the latest assistant response starts.") (defvar-local eca-chat--last-response-copy-kind nil "Kind of latest top-level assistant content for copy scoping.") (defvar-local eca-chat--chat-loading nil) @@ -1792,6 +1810,13 @@ Recovery path for a corrupted prompt block (see #305)." (setq-local eca-chat--history-compaction-cursor nil) (setq-local eca-chat--history-total nil) (setq-local eca-chat--history-loading nil) + (clrhash eca-chat--tool-call-prepare-counters) + (clrhash eca-chat--tool-call-prepare-content-cache) + (clrhash (eca-chat--tool-call-prepare-display-table)) + (setq-local eca-chat--tool-call-prepare-pending-order nil) + (setq-local eca-chat--stream-pending-render-order nil) + (setq-local eca-chat--stream-pending-copy-start nil) + (clrhash (eca-chat--tool-call-prepare-finalized-table)) (clrhash eca-chat--subagent-chat-id->tool-call-id) (clrhash eca-chat--subagent-usage) (eca-chat--insert "\n") @@ -3740,23 +3765,262 @@ Add a overlay before with OVERLAY-KEY = OVERLAY-VALUE if passed." #'eca-chat--stream-flush-callback buffer)))) -(defun eca-chat--stream-buffered-text (text) - "Buffer top-level assistant stream TEXT in the current chat." - (push text eca-chat--stream-pending-chunks) +(defun eca-chat--stream-parent-chunks-table () + "Return the buffer-local parent stream chunk table." + (unless (hash-table-p eca-chat--stream-pending-parent-chunks) + (setq-local eca-chat--stream-pending-parent-chunks + (make-hash-table :test 'equal))) + eca-chat--stream-pending-parent-chunks) + +(defun eca-chat--tool-call-prepare-display-table () + "Return the buffer-local pending prepare render table." + (unless (hash-table-p eca-chat--tool-call-prepare-display-cache) + (setq-local eca-chat--tool-call-prepare-display-cache + (make-hash-table :test 'equal))) + eca-chat--tool-call-prepare-display-cache) + +(defun eca-chat--tool-call-prepare-finalized-table () + "Return the buffer-local finalized prepare ID table." + (unless (hash-table-p eca-chat--tool-call-prepare-finalized-ids) + (setq-local eca-chat--tool-call-prepare-finalized-ids + (make-hash-table :test 'equal))) + eca-chat--tool-call-prepare-finalized-ids) + +(defun eca-chat--pending-chunks-push (text chunks) + "Return CHUNKS with TEXT queued for first-in flushing." + (cons text chunks)) + +(defun eca-chat--pending-chunks-text (chunks) + "Return queued CHUNKS concatenated in first-in order." + (mapconcat #'identity (nreverse chunks) "")) + +(defun eca-chat--ordered-queue-put (id value table order) + "Store VALUE for ID and return ORDER with ID appended once." + (prog1 (if (gethash id table) + order + (append order (list id))) + (puthash id value table))) + +(defun eca-chat--ordered-queue-remove (id table order) + "Remove ID from TABLE and return ORDER without ID." + (remhash id table) + (delete id order)) + +(defun eca-chat--stream-render-entry (kind &optional id) + "Return render-order entry for KIND and optional ID." + (cons kind id)) + +(defun eca-chat--stream-render-order-add (kind &optional id) + "Append render entry KIND and ID to pending order once." + (let ((entry (eca-chat--stream-render-entry kind id))) + (unless (member entry eca-chat--stream-pending-render-order) + (setq-local eca-chat--stream-pending-render-order + (append eca-chat--stream-pending-render-order + (list entry)))))) + +(defun eca-chat--stream-render-order-remove (kind &optional id) + "Remove render entry KIND and ID from pending order." + (setq-local eca-chat--stream-pending-render-order + (delq nil + (mapcar (lambda (entry) + (unless (and (eq (car entry) kind) + (equal (cdr entry) id)) + entry)) + eca-chat--stream-pending-render-order)))) + +(defun eca-chat--tool-call-prepare-pending-p () + "Return non-nil when prepare renders are pending." + (> (hash-table-count (eca-chat--tool-call-prepare-display-table)) 0)) + +(defun eca-chat--tool-call-prepare-cancel (id) + "Cancel the pending prepare render for ID." + (when id + (setq-local eca-chat--tool-call-prepare-pending-order + (eca-chat--ordered-queue-remove + id + (eca-chat--tool-call-prepare-display-table) + eca-chat--tool-call-prepare-pending-order)) + (eca-chat--stream-render-order-remove 'prepare id))) + +(defun eca-chat--tool-call-prepare-clear (id) + "Clear pending and cached prepare state for ID." + (when id + (eca-chat--tool-call-prepare-cancel id) + (remhash id eca-chat--tool-call-prepare-counters) + (remhash id eca-chat--tool-call-prepare-content-cache))) + +(defun eca-chat--tool-call-prepare-finalize (id) + "Mark ID as past the prepare phase and clear prepare state." + (when id + (puthash id t (eca-chat--tool-call-prepare-finalized-table)) + (eca-chat--tool-call-prepare-clear id))) + +(defun eca-chat--tool-call-prepare-render (id metadata) + "Render the pending prepare UI for ID from METADATA." + (let* ((name (plist-get metadata :name)) + (server (plist-get metadata :server)) + (details (plist-get metadata :details)) + (summary (plist-get metadata :summary)) + (parent-tool-call-id (plist-get metadata :parent-tool-call-id)) + (subagent? (string= "subagent" (plist-get details :type))) + (label-text (or summary + (format "Preparing tool: %s__%s" server name))) + (label-face (if subagent? + 'eca-chat-subagent-tool-call-label-face + 'eca-chat-mcp-tool-call-label-face)) + (label (concat (propertize label-text 'font-lock-face label-face) + " " eca-chat-mcp-tool-call-loading-symbol)) + (args (gethash id eca-chat--tool-call-prepare-content-cache "")) + (body (if subagent? + (eca-chat--content-table `()) + (propertize + (eca-chat--content-table + `(("Tool" . ,name) + ("Server" . ,server) + ("Arguments" . ,args))) + 'eca-no-fontify t))) + (update-content (if subagent? + body + (propertize args 'eca-no-fontify t)))) + (if (eca-chat--get-expandable-content id) + (eca-chat--update-expandable-content + id label update-content nil parent-tool-call-id) + (eca-chat--add-expandable-content + id label body parent-tool-call-id)))) + +(defun eca-chat--tool-call-prepare-queue (id metadata) + "Queue the latest prepare METADATA for ID." + (let* ((table (eca-chat--tool-call-prepare-display-table)) + (pending (gethash id table))) + (setq-local eca-chat--tool-call-prepare-pending-order + (eca-chat--ordered-queue-put + id metadata table eca-chat--tool-call-prepare-pending-order)) + (unless pending + (eca-chat--stream-render-order-add 'prepare id)) + (eca-chat--stream-schedule-flush))) + +(defun eca-chat--tool-call-prepare-flush (id) + "Render the pending prepare UI for ID." + (when-let* ((metadata (gethash id + (eca-chat--tool-call-prepare-display-table)))) + (eca-chat--tool-call-prepare-cancel id) + (eca-chat--tool-call-prepare-render id metadata))) + +(defun eca-chat--tool-call-prepare-flush-all () + "Render all pending prepare UIs in first-seen order." + (let ((ids (copy-sequence eca-chat--tool-call-prepare-pending-order))) + (dolist (id ids) + (eca-chat--tool-call-prepare-flush id)))) + +(defun eca-chat--stream-pending-p () + "Return non-nil when the current chat has buffered stream text." + (or eca-chat--stream-pending-chunks + (> (hash-table-count (eca-chat--stream-parent-chunks-table)) 0) + (eca-chat--tool-call-prepare-pending-p))) + +(defun eca-chat--stream-buffered-text (text &optional parent-tool-call-id) + "Buffer assistant stream TEXT in the current chat. +PARENT-TOOL-CALL-ID stores TEXT under a subagent parent block." + (if parent-tool-call-id + (let* ((table (eca-chat--stream-parent-chunks-table)) + (chunks (gethash parent-tool-call-id table))) + (unless chunks + (eca-chat--stream-render-order-add 'parent parent-tool-call-id)) + (setq-local eca-chat--stream-pending-parent-order + (eca-chat--ordered-queue-put + parent-tool-call-id + (eca-chat--pending-chunks-push text chunks) + table + eca-chat--stream-pending-parent-order))) + (unless eca-chat--stream-pending-chunks + (eca-chat--stream-render-order-add 'top-level)) + (setq-local eca-chat--stream-pending-chunks + (eca-chat--pending-chunks-push + text eca-chat--stream-pending-chunks))) (eca-chat--stream-schedule-flush)) -(defun eca-chat--stream-flush () - "Render pending assistant stream text in the current chat." - (let ((chunks eca-chat--stream-pending-chunks)) - (eca-chat--stream-cancel) - (setq-local eca-chat--stream-pending-chunks nil) +(defun eca-chat--stream-flush-parent (parent-tool-call-id) + "Render pending assistant stream text for PARENT-TOOL-CALL-ID." + (eca-chat--stream-render-order-remove 'parent parent-tool-call-id) + (let* ((table (eca-chat--stream-parent-chunks-table)) + (chunks (gethash parent-tool-call-id table))) (when chunks - (let ((text (mapconcat #'identity (nreverse chunks) ""))) + (setq-local eca-chat--stream-pending-parent-order + (eca-chat--ordered-queue-remove + parent-tool-call-id table eca-chat--stream-pending-parent-order)) + (let ((text (eca-chat--pending-chunks-text chunks))) (unless (string-empty-p text) (save-excursion - (eca-chat--add-text-content text) + (eca-chat--update-expandable-content + parent-tool-call-id nil text t) (eca-chat--schedule-fontify) - (eca-chat--protect-non-prompt eca-chat--last-user-message-pos))))))) + (eca-chat--protect-non-prompt + eca-chat--last-user-message-pos))))))) + +(defun eca-chat--stream-flush-top-level () + "Render pending top-level assistant stream text in the current chat." + (eca-chat--stream-render-order-remove 'top-level) + (let ((chunks eca-chat--stream-pending-chunks)) + (setq-local eca-chat--stream-pending-chunks nil) + (when chunks + (let ((text (eca-chat--pending-chunks-text chunks))) + (unwind-protect + (unless (string-empty-p text) + (save-excursion + (when eca-chat--stream-pending-copy-start + (setq-local eca-chat--last-response-copy-start + (copy-marker + (eca-chat--content-insertion-point) + nil))) + (eca-chat--add-text-content text) + (eca-chat--schedule-fontify) + (eca-chat--protect-non-prompt + eca-chat--last-user-message-pos))) + (setq-local eca-chat--stream-pending-copy-start nil)))))) + +(defun eca-chat--stream-flush-top-level-when-next () + "Render pending top-level text only when it is next in order." + (when (equal (car eca-chat--stream-pending-render-order) + (eca-chat--stream-render-entry 'top-level)) + (eca-chat--stream-flush-top-level))) + +(defun eca-chat--stream-flush-scoped (scope-id) + "Render pending parent and prepare entries related to SCOPE-ID." + (let ((order (copy-sequence eca-chat--stream-pending-render-order)) + (prepare-table (eca-chat--tool-call-prepare-display-table))) + (dolist (entry order) + (pcase (car entry) + ('parent + (when (equal (cdr entry) scope-id) + (eca-chat--stream-flush-parent scope-id))) + ('prepare + (let* ((prepare-id (cdr entry)) + (metadata (gethash prepare-id prepare-table)) + (parent-id (plist-get metadata :parent-tool-call-id))) + (when (or (equal prepare-id scope-id) + (equal parent-id scope-id)) + (eca-chat--tool-call-prepare-flush prepare-id)))))))) + +(defun eca-chat--stream-flush (&optional parent-tool-call-id) + "Render pending assistant stream text in the current chat. +PARENT-TOOL-CALL-ID flushes matching parent and prepare queues." + (if parent-tool-call-id + (progn + (eca-chat--stream-flush-scoped parent-tool-call-id) + (unless (eca-chat--stream-pending-p) + (eca-chat--stream-cancel))) + (let ((order (copy-sequence eca-chat--stream-pending-render-order))) + (eca-chat--stream-cancel) + (dolist (entry order) + (pcase (car entry) + ('top-level (eca-chat--stream-flush-top-level)) + ('parent (eca-chat--stream-flush-parent (cdr entry))) + ('prepare (eca-chat--tool-call-prepare-flush (cdr entry))))) + ;; Clean up any state queued before the render-order entry existed. + (eca-chat--stream-flush-top-level) + (dolist (parent-id (copy-sequence eca-chat--stream-pending-parent-order)) + (eca-chat--stream-flush-parent parent-id)) + (eca-chat--tool-call-prepare-flush-all)))) (defun eca-chat--relativize-filename-for-workspace-root (filename roots &optional hide-filename?) "Relativize the FILENAME if a workspace root is found for ROOTS. @@ -3930,8 +4194,18 @@ CHILD, NAME, DOCSTRING and BODY are passed down." (make-hash-table :test 'equal)) (setq-local eca-chat--tool-call-prepare-content-cache (make-hash-table :test 'equal)) + (setq-local eca-chat--tool-call-prepare-display-cache + (make-hash-table :test 'equal)) + (setq-local eca-chat--tool-call-prepare-pending-order nil) + (setq-local eca-chat--tool-call-prepare-finalized-ids + (make-hash-table :test 'equal)) (setq-local eca-chat--tool-call-elapsed-times (make-hash-table :test 'equal)) + (setq-local eca-chat--stream-pending-parent-chunks + (make-hash-table :test 'equal)) + (setq-local eca-chat--stream-pending-parent-order nil) + (setq-local eca-chat--stream-pending-render-order nil) + (setq-local eca-chat--stream-pending-copy-start nil) (setq-local eca-chat--subagent-chat-id->tool-call-id (make-hash-table :test 'equal)) (setq-local eca-chat--subagent-usage @@ -4317,13 +4591,52 @@ Append STATUS symbol. Optional PARENT-ID for nested rendering." (new-label (concat (propertize label 'font-lock-face 'eca-chat-subagent-tool-call-label-face) steps-info " " status time (when approval-text (concat "\n" approval-text)))) + (body (eca-chat--content-table + `(("Agent" . ,agent-name) + ("Model" . ,model) + ,@(when variant `(("Variant" . ,variant))) + ,@(when task `(("Task" . ,(concat task "\n\n")))) + ,@(when output-text `(("Output" . ,(concat "\n" output-text))))))) + (body-without-output (eca-chat--content-table + `(("Agent" . ,agent-name) + ("Model" . ,model) + ,@(when variant `(("Variant" . ,variant))) + ,@(when task `(("Task" . ,(concat task "\n\n"))))))) + (existing-content (when-let* ((ov existing-ov) + (ov-content (overlay-get + ov 'eca-chat--expandable-content-ov-content))) + (overlay-get ov-content 'eca-chat--expandable-content-content))) + (segments (and existing-ov + (overlay-get existing-ov 'eca-chat--expandable-content-segments))) + (has-extra-content? (and existing-content + (not (string-empty-p existing-content)) + (not (string= (substring-no-properties existing-content) + (substring-no-properties body-without-output))))) (has-children? (and existing-ov - (eca-chat--segments-children - (overlay-get existing-ov 'eca-chat--expandable-content-segments))))) - (if has-children? - ;; Block already has nested child content (subagent tool calls, reasoning, - ;; approval prompts, etc). Only update the label line to reflect the new - ;; step count / status, preserving all rendered children. + (eca-chat--segments-children segments))) + (output-fragment (when (and output-text + (not (string-empty-p output-text))) + (eca-chat--content-table + `(("Output" . ,(concat "\n" output-text)))))) + (output-present? (and output-fragment + (let ((needle (regexp-quote output-text))) + (or (and existing-content + (string-match-p + needle + (substring-no-properties existing-content))) + (-some (lambda (segment) + (when-let* ((content (plist-get segment :content))) + (string-match-p + needle + (substring-no-properties content)))) + segments))))) + (output-to-append (and output-fragment + (not output-present?) + output-fragment)) + (preserve-content? (or has-children? has-extra-content?))) + (if preserve-content? + ;; Block already has nested or streamed child content. Only update the + ;; label line, preserving child output that arrived before this event. (let* ((ov-content (overlay-get existing-ov 'eca-chat--expandable-content-ov-content)) (open? (overlay-get existing-ov 'eca-chat--expandable-content-toggle)) (content (overlay-get ov-content 'eca-chat--expandable-content-content)) @@ -4346,17 +4659,15 @@ Append STATUS symbol. Optional PARENT-ID for nested rendering." (cdr new-icons) (car new-icons)))) 'help-echo "mouse-1 / RET / tab: expand/collapse")) - (eca-chat--paint-nested-label existing-ov))) - ;; No children yet — safe to replace the full content body + (eca-chat--paint-nested-label existing-ov)) + (when output-to-append + (eca-chat--update-expandable-content + id nil output-to-append t parent-id))) + ;; No child output yet, so the details table can be replaced safely. (eca-chat--update-expandable-content id new-label - (eca-chat--content-table - `(("Agent" . ,agent-name) - ("Model" . ,model) - ,@(when variant `(("Variant" . ,variant))) - ,@(when task `(("Task" . ,(concat task "\n\n")))) - ,@(when output-text `(("Output" . ,(concat "\n" output-text)))))) + body nil parent-id)) ;; Store status and label on the overlay so we can update them later @@ -4491,6 +4802,22 @@ Only updates the label line, preserving all nested child content." "toolCallPrepare" "toolCallRun" "toolCallRunning" "toolCalled" "toolCallRejected"))) +(defun eca-chat--running-progress-content-p (content) + "Return non-nil if CONTENT is running progress." + (and (equal (plist-get content :type) "progress") + (equal (plist-get content :state) "running"))) + +(defun eca-chat--immediate-history-content-p (role type parent-tool-call-id) + "Return non-nil if content TYPE renders without stream buffering." + (and (not parent-tool-call-id) + (or (and (equal type "text") + (member role '("user" "system"))) + (member type '("url" "image" "flag" + "reasonStarted" "reasonText" "reasonFinished" + "hookActionStarted" "hookActionFinished" + "toolCallRun" "toolCallRunning" + "toolCalled" "toolCallRejected"))))) + (defun eca-chat--mark-response-copy-break (type parent-tool-call-id) "Mark TYPE as a top-level break for response copy scope. PARENT-TOOL-CALL-ID means content belongs to a tool block." @@ -4621,6 +4948,7 @@ approval requests. Falls back to the buffer-local `eca-chat--id'. Must be called with `eca-chat--with-current-buffer' or equivalent." (let* ((content-id (plist-get content :contentId)) (content-type (plist-get content :type)) + (tool-call-id (plist-get content :id)) (tool-call-next-line-spacing (make-string (1+ (length eca-chat-expandable-block-open-symbol)) ?\s)) ;; Whether the user acted on this approval from within its ;; block; checked now, before rendering drops the buttons. @@ -4637,7 +4965,31 @@ Must be called with `eca-chat--with-current-buffer' or equivalent." (when (and (not parent-tool-call-id) (or (not (equal content-type "text")) (member role '("user" "system")))) - (eca-chat--stream-flush)) + (cond + ((and (equal content-type "progress") + (equal (plist-get content :state) "finished")) + (eca-chat--stream-flush)) + ((eca-chat--running-progress-content-p content) + nil) + ((eca-chat--immediate-history-content-p role content-type parent-tool-call-id) + (eca-chat--stream-flush)) + (t + (eca-chat--stream-flush-top-level-when-next) + (when (and tool-call-id + (member content-type '("toolCallRun" "toolCallRunning" + "toolCalled" "toolCallRejected"))) + (eca-chat--stream-flush tool-call-id)) + (unless (eca-chat--stream-pending-p) + (eca-chat--stream-cancel))))) + (when (and parent-tool-call-id + (not (eca-chat--running-progress-content-p content)) + (or (not (equal content-type "text")) + (member role '("user" "system")))) + (eca-chat--stream-flush parent-tool-call-id) + (when (and tool-call-id + (member content-type '("toolCallRun" "toolCallRunning" + "toolCalled" "toolCallRejected"))) + (eca-chat--stream-flush tool-call-id))) (pcase content-type ("metadata" (unless parent-tool-call-id @@ -4685,20 +5037,26 @@ Must be called with `eca-chat--with-current-buffer' or equivalent." 'line-height 20))) (_ (if parent-tool-call-id - ;; Subagent: append assistant text to the parent tool call content - (eca-chat--update-expandable-content - parent-tool-call-id nil text t) - (unless (eq eca-chat--last-response-copy-kind 'text) - (setq-local eca-chat--last-response-copy-start - (eca-chat--content-insertion-point))) - (setq-local eca-chat--last-response-copy-kind 'text) - (if (eca-chat--stream-buffering-enabled-p) - (eca-chat--stream-buffered-text text) - (eca-chat--add-text-content text) - ;; Defer fontification: let jit-lock handle visible-area - ;; updates and run a single final ensure in the - ;; "finished" progress arm below. - (eca-chat--schedule-fontify))))))) + ;; Subagent: keep child text ordered under the parent block. + (if (eca-chat--stream-buffering-enabled-p) + (eca-chat--stream-buffered-text text parent-tool-call-id) + (eca-chat--update-expandable-content + parent-tool-call-id nil text t)) + (let ((buffered (eca-chat--stream-buffering-enabled-p))) + (unless (eq eca-chat--last-response-copy-kind 'text) + (if buffered + (setq-local eca-chat--stream-pending-copy-start t) + (setq-local eca-chat--last-response-copy-start + (copy-marker + (eca-chat--content-insertion-point) nil)))) + (setq-local eca-chat--last-response-copy-kind 'text) + (if buffered + (eca-chat--stream-buffered-text text) + (eca-chat--add-text-content text) + ;; Defer fontification for live text. History replay + ;; does one explicit batch finalization after replay. + (unless eca-chat--insertion-point-override + (eca-chat--schedule-fontify))))))))) ("url" (unless parent-tool-call-id (eca-chat--add-header @@ -4769,55 +5127,33 @@ Must be called with `eca-chat--with-current-buffer' or equivalent." (eca-chat--render-task-widget t)) (when-let* ((id (plist-get content :id)) (name (plist-get content :name)) - (server (plist-get content :server))) - (let* ((argsText (plist-get content :argumentsText)) + (server (plist-get content :server)) + ((not (gethash + id + (eca-chat--tool-call-prepare-finalized-table))))) + (let* ((argsText (or (plist-get content :argumentsText) "")) (details (plist-get content :details)) - (subagent? (string= "subagent" (plist-get details :type))) - (label (or (plist-get content :summary) - (format "Preparing tool: %s__%s" server name))) (current-count (gethash id eca-chat--tool-call-prepare-counters 0)) (cached-content (gethash id eca-chat--tool-call-prepare-content-cache "")) (new-content (concat cached-content argsText)) + (metadata (list :name name + :server server + :details details + :summary (plist-get content :summary) + :parent-tool-call-id parent-tool-call-id)) (should-update-ui-p (pcase eca-chat-tool-call-prepare-throttle ('all t) ('smart (or (= current-count 0) (= (mod current-count eca-chat-tool-call-prepare-update-interval) 0)))))) - ;; Always cache the metadata and content + ;; Always cache the metadata and content. (puthash id (1+ current-count) eca-chat--tool-call-prepare-counters) (puthash id new-content eca-chat--tool-call-prepare-content-cache) - ;; Only update UI when throttling permits + ;; Only update UI when throttling permits. (when should-update-ui-p - (let* ((label-face (if subagent? - 'eca-chat-subagent-tool-call-label-face - 'eca-chat-mcp-tool-call-label-face)) - (label (concat (propertize label 'font-lock-face label-face) - " " eca-chat-mcp-tool-call-loading-symbol)) - ;; Tag body and accumulated content with `eca-no-fontify' so - ;; the custom `font-lock-fontify-region-function' skips them - ;; while the tool args are still streaming (#234). The - ;; property travels through `eca-chat--insert' and - ;; `eca-chat--update-expandable-content' to every code - ;; path that lands the body in the buffer; the `toolCalled' - ;; arm later reinserts un-tagged final content, so jit-lock - ;; refontifies normally on the next redisplay. - (body (if subagent? - (eca-chat--content-table `()) - (propertize - (eca-chat--content-table - `(("Tool" . ,name) - ("Server" . ,server) - ("Arguments" . ,new-content))) - 'eca-no-fontify t))) - (update-content (if subagent? - body - (propertize new-content 'eca-no-fontify t)))) - (if (eca-chat--get-expandable-content id) - ;; Update with accumulated content, not just this chunk - (eca-chat--update-expandable-content - id label update-content nil parent-tool-call-id) - (eca-chat--add-expandable-content - id label body parent-tool-call-id)))))))) + (if (eca-chat--stream-buffering-enabled-p) + (eca-chat--tool-call-prepare-queue id metadata) + (eca-chat--tool-call-prepare-render id metadata))))))) ("toolCallRun" (when (and (eca-chat--task-tool-call-p content) (plist-get content :manualApproval)) @@ -4868,11 +5204,9 @@ Must be called with `eca-chat--with-current-buffer' or equivalent." ("Arguments" . ,args))) nil parent-tool-call-id))) - ;; Mark this ID as having received toolCallRun so that any late-arriving - ;; toolCallPrepare events (still in-flight for long files) don't overwrite - ;; the approval prompt we just rendered. Set this AFTER the pcase dispatch - ;; so that if rendering errors, the flag doesn't poison subsequent prepare - ;; events (which would prevent the block from ever being created). + ;; Mark this ID after rendering so a rendering error does not poison + ;; later prepare events for a block that was never created. + (eca-chat--tool-call-prepare-finalize id) (when (and eca-chat-expand-pending-approval-tools manual?) (when parent-tool-call-id (eca-chat--expandable-content-toggle parent-tool-call-id t nil)) @@ -4922,6 +5256,7 @@ Must be called with `eca-chat--with-current-buffer' or equivalent." ("Arguments" . ,args))) nil parent-tool-call-id))) + (eca-chat--tool-call-prepare-finalize id) (eca-chat--mark-tool-call-approval-resolved id) ;; Keep parent pending while sibling approvals remain pending (eca-chat--restore-parent-subagent-status parent-tool-call-id)))) @@ -4947,9 +5282,8 @@ Must be called with `eca-chat--with-current-buffer' or equivalent." eca-chat-mcp-tool-call-error-symbol) (bg? "🟡") (t eca-chat-mcp-tool-call-success-symbol)))) - ;; Cleanup counters for this tool-call id to avoid unbounded growth - (remhash id eca-chat--tool-call-prepare-counters) - (remhash id eca-chat--tool-call-prepare-content-cache) + ;; Cleanup prepare state for this tool-call id. + (eca-chat--tool-call-prepare-finalize id) ;; Stop elapsed-time tracking for this tool call (eca-chat--tool-call-elapsed-stop id) ;; Another client may have answered this ask_user question first, @@ -4993,9 +5327,8 @@ Must be called with `eca-chat--with-current-buffer' or equivalent." (details (plist-get content :details)) (status eca-chat-mcp-tool-call-error-symbol) (id (plist-get content :id))) - ;; Cleanup counters for this tool-call id - (remhash id eca-chat--tool-call-prepare-counters) - (remhash id eca-chat--tool-call-prepare-content-cache) + ;; Cleanup prepare state for this tool-call id. + (eca-chat--tool-call-prepare-finalize id) ;; Another client may have cancelled this ask_user question first, ;; rejecting the tool call here; drop our now-stale prompt state. (eca-chat--dismiss-pending-question-for-tool-call id) diff --git a/test/eca-chat-render-coalescing-bench-test.el b/test/eca-chat-render-coalescing-bench-test.el new file mode 100644 index 0000000..71cb95d --- /dev/null +++ b/test/eca-chat-render-coalescing-bench-test.el @@ -0,0 +1,93 @@ +;;; eca-chat-render-coalescing-bench-test.el --- Tests for render benchmark -*- lexical-binding: t; -*- +;;; Commentary: +;; Tests for the chat render coalescing benchmark harness. +;;; Code: +(require 'buttercup) +(require 'cl-lib) +(require 'subr-x) + +(let* ((test-file (or load-file-name buffer-file-name)) + (test-dir (and test-file (file-name-directory test-file))) + (repo-root (and test-dir + (file-name-directory + (directory-file-name test-dir)))) + (bench-dir (and repo-root + (expand-file-name "benchmarks" repo-root)))) + (when bench-dir + (add-to-list 'load-path bench-dir))) + +(require 'eca-chat-render-coalescing-bench) + +(describe "eca-chat-render-coalescing-bench" + (it "runs git metadata commands in the benchmark repo root" + (let* ((repo-root eca-chat-render-coalescing-bench-repo-root) + (expected (let ((default-directory repo-root)) + (with-temp-buffer + (expect (process-file "git" nil t nil + "rev-parse" "--show-toplevel") + :to-equal 0) + (string-trim (buffer-string))))) + (default-directory temporary-file-directory)) + (expect (eca-chat-render-coalescing-bench--git-output + "rev-parse" "--show-toplevel") + :to-equal expected))) + + (it "passes the fixture session to setup and workload functions" + (let* ((fixture-session + (make-eca--session :id "fixture-session" + :workspace-folders '("/fixture"))) + (workload-session + (make-eca--session :id "workload-session" + :workspace-folders '("/workload"))) + (calls 0) + seen-setup + seen-workload) + (cl-letf (((symbol-function 'eca-chat-bench--ensure-session) + (lambda () + (setq calls (1+ calls)) + (setq eca-chat-bench--session + (if (= calls 1) + fixture-session + workload-session))))) + (eca-chat-render-coalescing-bench--time-buffer + 'session-check 0 + (lambda (session _buffer) + (setq seen-setup + (list :passed-session (eca--session-id session) + :buffer-session eca--session-id-cache))) + (lambda (session _buffer) + (setq seen-workload + (list :passed-session (eca--session-id session) + :buffer-session eca--session-id-cache))))) + (expect seen-setup + :to-equal + '(:passed-session "fixture-session" + :buffer-session "fixture-session")) + (expect seen-workload + :to-equal + '(:passed-session "fixture-session" + :buffer-session "fixture-session")))) + + (it "creates prepare fixture blocks under stream buffering" + (let* ((fixture (eca-chat-render-coalescing-bench--make-buffer)) + (buffer (car fixture)) + (session (cdr fixture))) + (unwind-protect + (with-current-buffer buffer + (let ((eca-chat-stream-flush-interval 0.05)) + (eca-chat-render-coalescing-bench--setup-prepare-block + session buffer "prepare-default")) + (expect (eca-chat--get-expandable-content "prepare-default") + :not :to-be nil) + (expect (overlay-get + (eca-chat--get-expandable-content "prepare-default") + 'eca-chat--expandable-content-toggle) + :to-be-truthy)) + (when (buffer-live-p buffer) + (with-current-buffer buffer + (when (fboundp 'eca-chat--stream-cancel) + (eca-chat--stream-cancel))) + (kill-buffer buffer)))))) + +(provide 'eca-chat-render-coalescing-bench-test) +;;; eca-chat-render-coalescing-bench-test.el ends here diff --git a/test/eca-chat-test.el b/test/eca-chat-test.el index 0d35191..2f3ff8f 100644 --- a/test/eca-chat-test.el +++ b/test/eca-chat-test.el @@ -107,8 +107,18 @@ does not treat the first line as metadata. Returns FN's value." (make-hash-table :test 'equal)) (setq-local eca-chat--tool-call-prepare-content-cache (make-hash-table :test 'equal)) + (setq-local eca-chat--tool-call-prepare-display-cache + (make-hash-table :test 'equal)) + (setq-local eca-chat--tool-call-prepare-pending-order nil) + (setq-local eca-chat--tool-call-prepare-finalized-ids + (make-hash-table :test 'equal)) (setq-local eca-chat--tool-call-elapsed-times (make-hash-table :test 'equal)) + (setq-local eca-chat--stream-pending-parent-chunks + (make-hash-table :test 'equal)) + (setq-local eca-chat--stream-pending-parent-order nil) + (setq-local eca-chat--stream-pending-render-order nil) + (setq-local eca-chat--stream-pending-copy-start nil) (setq-local eca-chat--subagent-chat-id->tool-call-id (make-hash-table :test 'equal)) (setq-local eca-chat--subagent-usage @@ -175,6 +185,95 @@ When TITLE is non-nil, use it as the chat title." (with-current-buffer buf (eca-chat--stream-flush))) +(defun eca-chat-test--tool-call-prepare-content + (id arguments-text &optional summary) + "Build a generic toolCallPrepare content plist for ID." + (append (list :type "toolCallPrepare" + :id id + :name "testTool" + :server "testServer" + :argumentsText arguments-text + :details (list :type "generic")) + (when summary (list :summary summary)))) + +(defun eca-chat-test--render-tool-call-prepare + (session buf id arguments-text &optional summary) + "Render toolCallPrepare ARGUMENTS-TEXT into BUF." + (eca-chat--render-content + session buf "assistant" + (eca-chat-test--tool-call-prepare-content id arguments-text summary) + nil)) + +(defun eca-chat-test--tool-call-content-with-arguments + (type id arguments &optional manual) + "Build a generic tool-call TYPE for ID with ARGUMENTS." + (plist-put (eca-chat-test--tool-call-content type id manual) + :arguments arguments)) + +(defun eca-chat-test--expanded-tool-history-text (buf id) + "Open tool block ID in BUF and return the history text." + (with-current-buffer buf + (when (eca-chat--get-expandable-content id) + (eca-chat--expandable-content-toggle id t nil)) + (eca-chat-test--history-text buf))) + +(defun eca-chat-test--subagent-tool-call-content + (id &optional chat-id agent task) + "Build a subagent tool-call run content plist for ID." + (list :type "toolCallRun" + :id id + :name "subagentTool" + :server "testServer" + :arguments (list :agent (or agent (concat id "-agent")) + :task (or task (concat id " task"))) + :details (list :type "subagent" + :subagentChatId (or chat-id (concat id "-chat")) + :model "test-model" + :step 1 + :maxSteps 1))) + +(defun eca-chat-test--render-subagent-parent + (session buf id &optional chat-id agent task) + "Render a subagent parent block with ID into BUF." + (let ((resolved-chat-id (or chat-id (concat id "-chat")))) + (eca-chat--render-content + session buf "assistant" + (eca-chat-test--subagent-tool-call-content + id resolved-chat-id agent task) + nil) + resolved-chat-id)) + +(defun eca-chat-test--render-subagent-text + (session buf parent-id text &optional chat-id) + "Render child assistant TEXT for PARENT-ID." + (eca-chat--render-content + session buf "assistant" (list :type "text" :text text) + nil parent-id (or chat-id (concat parent-id "-chat")))) + +(defun eca-chat-test--expanded-parent-history-text (buf parent-id) + "Open PARENT-ID and return its history block text from BUF." + (with-current-buffer buf + (eca-chat--expandable-content-toggle parent-id t nil) + (let* ((start (overlay-start (eca-chat--get-expandable-content parent-id))) + (end (eca-chat--prompt-area-start-point))) + (maphash (lambda (id ov) + (let ((pos (overlay-start ov))) + (when (and (not (equal id parent-id)) + (> pos start) + (< pos end)) + (setq end pos)))) + eca-chat-expandable--id->ov) + (buffer-substring-no-properties start end)))) + +(defun eca-chat-test--string-count (text needle) + "Return the number of NEEDLE occurrences in TEXT." + (let ((start 0) + (count 0)) + (while (string-match (regexp-quote needle) text start) + (setq count (1+ count) + start (match-end 0))) + count)) + ;; --------------------------------------------------------------------------- ;; Tests ;; --------------------------------------------------------------------------- @@ -1646,6 +1745,28 @@ around rendering applies, as when the chat window is selected." (eca-chat-copy-at-point) (expect (current-kill 0 t) :to-equal "Final answer"))))) + (it "copies buffered text without earlier subagent text" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60) + kill-ring + kill-ring-yank-pointer) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-subagent-parent + session buf "copy-parent" "copy-child") + (eca-chat--expandable-content-toggle "copy-parent" t nil) + (eca-chat-test--render-assistant-text + session buf "final answer") + (eca-chat-test--render-subagent-text + session buf "copy-parent" "subagent details" "copy-child") + (eca-chat-test--stream-flush buf) + (eca-chat--refresh-copy-scopes) + (eca-chat-copy-at-point t) + (expect (current-kill 0 t) :to-equal "final answer")) + (when (buffer-live-p buf) + (kill-buffer buf))))) + (it "copies response text including fenced code" (let (kill-ring kill-ring-yank-pointer) @@ -2340,6 +2461,929 @@ detected even on filesystems with a coarse modtime resolution." (when (buffer-live-p buf) (kill-buffer buf))))) + (it "flushes same-scope prepare before later assistant text" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60)) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-tool-call-prepare + session buf "prep-before-text" "prepare before text") + (eca-chat-test--render-assistant-text + session buf "assistant after prepare") + (eca-chat-test--stream-flush buf) + (eca-chat--expandable-content-toggle "prep-before-text" t nil) + (let ((prepare-index (eca-chat-test--history-index + buf "prepare before text")) + (text-index (eca-chat-test--history-index + buf "assistant after prepare"))) + (expect prepare-index :not :to-be nil) + (expect text-index :not :to-be nil) + (expect prepare-index :to-be-less-than text-index))) + (when (buffer-live-p buf) + (ignore-errors (eca-chat-test--stream-flush buf)) + (kill-buffer buf))))) + + (it "keeps prepare hidden before later assistant text flush" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60)) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-tool-call-prepare + session buf "prep-buffered-before-text" + "prepare buffered before text") + (eca-chat-test--render-assistant-text + session buf "assistant after buffered prepare") + (expect (eca-chat--get-expandable-content + "prep-buffered-before-text") + :to-be nil) + (expect (eca-chat-test--history-text buf) + :not :to-match + (regexp-quote "assistant after buffered prepare")) + (eca-chat-test--stream-flush buf) + (eca-chat--expandable-content-toggle + "prep-buffered-before-text" t nil) + (let ((prepare-index (eca-chat-test--history-index + buf "prepare buffered before text")) + (text-index (eca-chat-test--history-index + buf "assistant after buffered prepare"))) + (expect prepare-index :not :to-be nil) + (expect text-index :not :to-be nil) + (expect prepare-index :to-be-less-than text-index))) + (when (buffer-live-p buf) + (ignore-errors (eca-chat-test--stream-flush buf)) + (kill-buffer buf))))) + + (it "keeps prepare hidden across running progress" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60)) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-tool-call-prepare + session buf "prep-running-progress" "prepare-before-progress") + (expect (eca-chat-test--expanded-tool-history-text + buf "prep-running-progress") + :not :to-match + (regexp-quote "prepare-before-progress")) + (eca-chat--render-content + session buf "system" + (list :type "progress" :state "running" :text "Running...") + nil) + (expect (eca-chat-test--expanded-tool-history-text + buf "prep-running-progress") + :not :to-match + (regexp-quote "prepare-before-progress")) + (eca-chat-test--stream-flush buf) + (expect (eca-chat-test--string-count + (eca-chat-test--expanded-tool-history-text + buf "prep-running-progress") + "prepare-before-progress") + :to-equal 1)) + (when (buffer-live-p buf) + (ignore-errors (eca-chat-test--stream-flush buf)) + (kill-buffer buf))))) + + (it "keeps smart prepare throttle under stream buffering" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60) + (eca-chat-tool-call-prepare-throttle 'smart) + (eca-chat-tool-call-prepare-update-interval 5)) + (unwind-protect + (eca-chat--with-current-buffer buf + (dotimes (i 5) + (eca-chat-test--render-tool-call-prepare + session buf "prep-smart-buffered" (format "chunk-%d " i)) + (eca-chat-test--stream-flush buf)) + (let ((history (eca-chat-test--expanded-tool-history-text + buf "prep-smart-buffered"))) + (expect history :to-match (regexp-quote "chunk-0")) + (expect history :not :to-match (regexp-quote "chunk-4"))) + (eca-chat-test--render-tool-call-prepare + session buf "prep-smart-buffered" "chunk-5 ") + (eca-chat-test--stream-flush buf) + (let ((history (eca-chat-test--expanded-tool-history-text + buf "prep-smart-buffered"))) + (expect history :to-match (regexp-quote "chunk-4")) + (expect history :to-match (regexp-quote "chunk-5")))) + (when (buffer-live-p buf) + (ignore-errors (eca-chat-test--stream-flush buf)) + (kill-buffer buf))))) + + (it "keeps top-level text hidden across running progress" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60) + captured-callback) + (unwind-protect + (eca-chat--with-current-buffer buf + (cl-letf (((symbol-function 'run-with-timer) + (lambda (_secs _repeat function &rest _args) + (setq captured-callback function) + (let ((timer (timer-create))) + (timer-set-function timer #'ignore) + timer)))) + (eca-chat-test--render-assistant-text + session buf "assistant before running progress") + (expect captured-callback :not :to-be nil) + (expect (timerp eca-chat--stream-flush-timer) + :to-be-truthy) + (expect (eca-chat-test--history-text buf) + :not :to-match + (regexp-quote "assistant before running progress")) + (eca-chat--render-content + session buf "system" + (list :type "progress" :state "running" :text "Running...") + nil) + (expect (eca-chat-test--history-text buf) + :not :to-match + (regexp-quote "assistant before running progress")) + (expect (eca-chat--stream-pending-p) :to-be-truthy) + (expect (timerp eca-chat--stream-flush-timer) + :to-be-truthy) + (eca-chat-test--stream-flush buf) + (expect (eca-chat-test--string-count + (eca-chat-test--history-text buf) + "assistant before running progress") + :to-equal 1))) + (when (buffer-live-p buf) + (ignore-errors (eca-chat-test--stream-flush buf)) + (kill-buffer buf))))) + + (it "keeps later top-level text hidden across metadata" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60)) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-tool-call-prepare + session buf "prep-before-metadata" + "metadata prepare arguments" "Metadata prepare label") + (eca-chat-test--render-assistant-text + session buf "assistant after metadata prepare") + (eca-chat--render-content + session buf "assistant" + (list :type "metadata" :title "metadata boundary") + nil) + (expect (eca-chat--get-expandable-content + "prep-before-metadata") + :to-be nil) + (expect (eca-chat-test--history-text buf) + :not :to-match + (regexp-quote "assistant after metadata prepare")) + (eca-chat-test--stream-flush buf) + (eca-chat--expandable-content-toggle + "prep-before-metadata" t nil) + (let ((prepare-index (eca-chat-test--history-index + buf "Metadata prepare label")) + (text-index (eca-chat-test--history-index + buf "assistant after metadata prepare"))) + (expect prepare-index :not :to-be nil) + (expect text-index :not :to-be nil) + (expect prepare-index :to-be-less-than text-index) + (expect (eca-chat-test--string-count + (eca-chat-test--history-text buf) + "Metadata prepare label") + :to-equal 1) + (expect (eca-chat-test--string-count + (eca-chat-test--history-text buf) + "assistant after metadata prepare") + :to-equal 1))) + (when (buffer-live-p buf) + (ignore-errors (eca-chat-test--stream-flush buf)) + (kill-buffer buf))))) + + (it "keeps later top-level text hidden across running progress" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60)) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-tool-call-prepare + session buf "prep-before-running-progress" + "running prepare arguments" "Running prepare label") + (eca-chat-test--render-assistant-text + session buf "assistant after running prepare") + (eca-chat--render-content + session buf "system" + (list :type "progress" :state "running" :text "Running...") + nil) + (expect (eca-chat--get-expandable-content + "prep-before-running-progress") + :to-be nil) + (expect (eca-chat-test--history-text buf) + :not :to-match + (regexp-quote "assistant after running prepare")) + (eca-chat-test--stream-flush buf) + (eca-chat--expandable-content-toggle + "prep-before-running-progress" t nil) + (let ((prepare-index (eca-chat-test--history-index + buf "Running prepare label")) + (text-index (eca-chat-test--history-index + buf "assistant after running prepare"))) + (expect prepare-index :not :to-be nil) + (expect text-index :not :to-be nil) + (expect prepare-index :to-be-less-than text-index))) + (when (buffer-live-p buf) + (ignore-errors (eca-chat-test--stream-flush buf)) + (kill-buffer buf))))) + + (it "keeps later top-level text hidden behind parent text" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60)) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-subagent-parent + session buf "parent-before-metadata" "child-before-metadata") + (eca-chat-test--render-subagent-text + session buf "parent-before-metadata" + "parent text before metadata" "child-before-metadata") + (eca-chat-test--render-assistant-text + session buf "top-level after parent metadata") + (eca-chat--render-content + session buf "assistant" + (list :type "metadata" :title "parent metadata boundary") + nil) + (expect (eca-chat-test--expanded-parent-history-text + buf "parent-before-metadata") + :not :to-match + (regexp-quote "parent text before metadata")) + (expect (eca-chat-test--history-text buf) + :not :to-match + (regexp-quote "top-level after parent metadata")) + (eca-chat-test--stream-flush buf) + (eca-chat--expandable-content-toggle + "parent-before-metadata" t nil) + (let ((parent-index (eca-chat-test--history-index + buf "parent text before metadata")) + (text-index (eca-chat-test--history-index + buf "top-level after parent metadata"))) + (expect parent-index :not :to-be nil) + (expect text-index :not :to-be nil) + (expect parent-index :to-be-less-than text-index) + (expect (eca-chat-test--string-count + (eca-chat-test--history-text buf) + "parent text before metadata") + :to-equal 1) + (expect (eca-chat-test--string-count + (eca-chat-test--history-text buf) + "top-level after parent metadata") + :to-equal 1))) + (when (buffer-live-p buf) + (ignore-errors (eca-chat-test--stream-flush buf)) + (kill-buffer buf))))) + + (it "flushes prepare before system text boundary" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60)) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-tool-call-prepare + session buf "prep-system-text" "prepare-before-system-text") + (eca-chat--render-content + session buf "system" + (list :type "text" :text "system-text-boundary") + nil) + (eca-chat--expandable-content-toggle "prep-system-text" t nil) + (let ((prepare-index (eca-chat-test--history-index + buf "prepare-before-system-text")) + (text-index (eca-chat-test--history-index + buf "system-text-boundary"))) + (expect prepare-index :not :to-be nil) + (expect text-index :not :to-be nil) + (expect prepare-index :to-be-less-than text-index))) + (when (buffer-live-p buf) + (ignore-errors (eca-chat-test--stream-flush buf)) + (kill-buffer buf))))) + + (it "toolCallRun flushes same-ID prepare before lifecycle state" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60) + (eca-chat-expand-pending-approval-tools t) + approval-history) + (unwind-protect + (save-window-excursion + (set-window-buffer (selected-window) buf) + (eca-chat--with-current-buffer buf + (cl-letf (((symbol-function + 'eca-chat--ensure-tool-call-approval-visible) + (lambda (&rest _) + (setq approval-history + (eca-chat-test--expanded-tool-history-text + buf "prep-run-1"))))) + (eca-chat-test--render-tool-call-prepare + session buf "prep-run-1" "run-order-arguments") + (expect (eca-chat-test--expanded-tool-history-text + buf "prep-run-1") + :not :to-match (regexp-quote "run-order-arguments")) + (eca-chat--render-content + session buf "assistant" + (eca-chat-test--tool-call-content-with-arguments + "toolCallRun" "prep-run-1" "run-order-arguments" t) + nil)) + (expect approval-history :not :to-be nil) + (expect approval-history + :to-match (regexp-quote "run-order-arguments")) + (let ((after (eca-chat-test--expanded-tool-history-text + buf "prep-run-1"))) + (expect after :to-match (regexp-quote "run-order-arguments")) + (expect after :to-match (regexp-quote "Accept"))) + (eca-chat-test--stream-flush buf) + (expect (eca-chat-test--string-count + (eca-chat-test--expanded-tool-history-text + buf "prep-run-1") + "run-order-arguments") + :to-equal 1))) + (when (buffer-live-p buf) + (ignore-errors (eca-chat-test--stream-flush buf)) + (kill-buffer buf))))) + + (it "flushes earlier prepare before different-ID lifecycle state" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60) + (eca-chat-expand-pending-approval-tools t)) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-tool-call-prepare + session buf "prep-cross-a" "cross-a-pending-arguments") + (expect (eca-chat-test--expanded-tool-history-text + buf "prep-cross-a") + :not :to-match + (regexp-quote "cross-a-pending-arguments")) + (eca-chat--render-content + session buf "assistant" + (eca-chat-test--tool-call-content-with-arguments + "toolCallRun" "prep-cross-b" "cross-b-lifecycle-arguments" t) + nil) + (let ((a-text (eca-chat-test--expanded-tool-history-text + buf "prep-cross-a")) + (b-text (eca-chat-test--expanded-tool-history-text + buf "prep-cross-b"))) + (expect a-text + :to-match + (regexp-quote "cross-a-pending-arguments")) + (expect b-text + :to-match + (regexp-quote "cross-b-lifecycle-arguments")) + (expect b-text :to-match (regexp-quote "Accept"))) + (let ((a-index (eca-chat-test--history-index + buf "cross-a-pending-arguments")) + (b-index (eca-chat-test--history-index + buf "cross-b-lifecycle-arguments"))) + (expect a-index :not :to-be nil) + (expect b-index :not :to-be nil) + (expect a-index :to-be-less-than b-index)) + (eca-chat-test--stream-flush buf) + (expect (eca-chat-test--string-count + (eca-chat-test--expanded-tool-history-text + buf "prep-cross-a") + "cross-a-pending-arguments") + :to-equal 1)) + (when (buffer-live-p buf) + (ignore-errors (eca-chat-test--stream-flush buf)) + (kill-buffer buf))))) + + (it "flushes parent-scoped prepare before sibling lifecycle state" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60)) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-subagent-parent + session buf "parent-prepare-order" "child-prepare-order") + (eca-chat--render-content + session buf "assistant" + (eca-chat-test--tool-call-prepare-content + "parent-prepare-a" "parent prepare A arguments" + "Parent prepare A") + nil "parent-prepare-order" "child-prepare-order") + (expect (eca-chat--get-expandable-content "parent-prepare-a") + :to-be nil) + (eca-chat--render-content + session buf "assistant" + (append (eca-chat-test--tool-call-content-with-arguments + "toolCallRun" "parent-run-b" "parent run B arguments") + (list :summary "Parent run B")) + nil "parent-prepare-order" "child-prepare-order") + (eca-chat--expandable-content-toggle + "parent-prepare-order" t nil) + (let ((prepare-index (eca-chat-test--history-index + buf "Parent prepare A")) + (run-index (eca-chat-test--history-index + buf "Parent run B"))) + (expect prepare-index :not :to-be nil) + (expect run-index :not :to-be nil) + (expect prepare-index :to-be-less-than run-index)) + (eca-chat-test--stream-flush buf) + (expect (eca-chat-test--string-count + (eca-chat-test--history-text buf) + "Parent prepare A") + :to-equal 1)) + (when (buffer-live-p buf) + (ignore-errors (eca-chat-test--stream-flush buf)) + (kill-buffer buf))))) + + (it "toolCalled cancels stale same-ID prepare after lifecycle render" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60)) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-tool-call-prepare + session buf "prep-called-1" "called-stale-prepare") + (expect (eca-chat-test--expanded-tool-history-text + buf "prep-called-1") + :not :to-match (regexp-quote "called-stale-prepare")) + (eca-chat--render-content + session buf "assistant" + (eca-chat-test--tool-call-content-with-arguments + "toolCalled" "prep-called-1" "called-final-arguments") + nil) + (let ((after (eca-chat-test--expanded-tool-history-text + buf "prep-called-1"))) + (expect after + :to-match (regexp-quote "called-final-arguments")) + (expect after + :not :to-match (regexp-quote "called-stale-prepare"))) + (eca-chat-test--stream-flush buf) + (let ((after-flush (eca-chat-test--expanded-tool-history-text + buf "prep-called-1"))) + (expect after-flush + :to-match (regexp-quote "called-final-arguments")) + (expect after-flush + :not :to-match (regexp-quote "called-stale-prepare")))) + (when (buffer-live-p buf) + (ignore-errors (eca-chat-test--stream-flush buf)) + (kill-buffer buf))))) + + (it "toolCallRejected cancels stale same-ID prepare after lifecycle render" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60)) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-tool-call-prepare + session buf "prep-rejected-1" "rejected-stale-prepare") + (expect (eca-chat-test--expanded-tool-history-text + buf "prep-rejected-1") + :not :to-match (regexp-quote "rejected-stale-prepare")) + (eca-chat--render-content + session buf "assistant" + (eca-chat-test--tool-call-content-with-arguments + "toolCallRejected" "prep-rejected-1" "rejected-final-arguments") + nil) + (let ((after (eca-chat-test--expanded-tool-history-text + buf "prep-rejected-1"))) + (expect after + :to-match (regexp-quote "rejected-final-arguments")) + (expect after + :not :to-match (regexp-quote "rejected-stale-prepare"))) + (eca-chat-test--stream-flush buf) + (let ((after-flush (eca-chat-test--expanded-tool-history-text + buf "prep-rejected-1"))) + (expect after-flush + :to-match (regexp-quote "rejected-final-arguments")) + (expect after-flush + :not :to-match (regexp-quote "rejected-stale-prepare")))) + (when (buffer-live-p buf) + (ignore-errors (eca-chat-test--stream-flush buf)) + (kill-buffer buf))))) + + (it "hides subagent assistant text before explicit flush" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60)) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-subagent-parent + session buf "parent-tool" "child-chat") + (eca-chat-test--render-subagent-text + session buf "parent-tool" "subagent-hidden-token" "child-chat") + (expect (eca-chat-test--history-text buf) + :not :to-match (regexp-quote "subagent-hidden-token")) + (eca-chat-test--stream-flush buf) + (expect (eca-chat-test--expanded-parent-history-text + buf "parent-tool") + :to-match (regexp-quote "subagent-hidden-token"))) + (when (buffer-live-p buf) + (kill-buffer buf))))) + + (it "keeps subagent text hidden across running progress" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60)) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-subagent-parent + session buf "parent-running-progress" "child-running-progress") + (eca-chat-test--render-subagent-text + session buf "parent-running-progress" + "subagent-before-progress" "child-running-progress") + (expect (eca-chat-test--expanded-parent-history-text + buf "parent-running-progress") + :not :to-match + (regexp-quote "subagent-before-progress")) + (eca-chat--render-content + session buf "system" + (list :type "progress" :state "running" :text "Running...") + nil) + (expect (eca-chat-test--expanded-parent-history-text + buf "parent-running-progress") + :not :to-match + (regexp-quote "subagent-before-progress")) + (eca-chat-test--stream-flush buf) + (expect (eca-chat-test--string-count + (eca-chat-test--expanded-parent-history-text + buf "parent-running-progress") + "subagent-before-progress") + :to-equal 1)) + (when (buffer-live-p buf) + (kill-buffer buf))))) + + (it "keeps timer after top-level partial flush with parent text pending" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60) + captured-callback + captured-args) + (unwind-protect + (eca-chat--with-current-buffer buf + (cl-letf (((symbol-function 'run-with-timer) + (lambda (_secs _repeat function &rest args) + (setq captured-callback function + captured-args args) + (let ((timer (timer-create))) + (timer-set-function timer #'ignore) + timer)))) + (eca-chat-test--render-subagent-parent + session buf "timer-parent" "timer-child") + (eca-chat-test--render-subagent-text + session buf "timer-parent" "timer parent text" "timer-child") + (expect captured-callback :not :to-be nil) + (expect (timerp eca-chat--stream-flush-timer) + :to-be-truthy) + (eca-chat--render-content + session buf "assistant" + (list :type "metadata" :title "timer metadata") nil) + (expect (eca-chat--stream-pending-p) :to-be-truthy) + (expect (timerp eca-chat--stream-flush-timer) + :to-be-truthy) + (expect (eca-chat-test--expanded-parent-history-text + buf "timer-parent") + :not :to-match (regexp-quote "timer parent text")) + (apply captured-callback captured-args) + (expect (eca-chat-test--expanded-parent-history-text + buf "timer-parent") + :to-match (regexp-quote "timer parent text")))) + (when (buffer-live-p buf) + (ignore-errors (eca-chat-test--stream-flush buf)) + (kill-buffer buf))))) + + (it "keeps subagent text hidden across parent-scoped running progress" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60)) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-subagent-parent + session buf "parent-scoped-running-progress" + "child-scoped-running-progress") + (eca-chat-test--render-subagent-text + session buf "parent-scoped-running-progress" + "subagent-before-parent-progress" + "child-scoped-running-progress") + (expect (eca-chat-test--expanded-parent-history-text + buf "parent-scoped-running-progress") + :not :to-match + (regexp-quote "subagent-before-parent-progress")) + (eca-chat--render-content + session buf "system" + (list :type "progress" :state "running" :text "Running...") + nil "parent-scoped-running-progress" + "child-scoped-running-progress") + (expect (eca-chat-test--expanded-parent-history-text + buf "parent-scoped-running-progress") + :not :to-match + (regexp-quote "subagent-before-parent-progress")) + (eca-chat-test--stream-flush buf) + (expect (eca-chat-test--string-count + (eca-chat-test--expanded-parent-history-text + buf "parent-scoped-running-progress") + "subagent-before-parent-progress") + :to-equal 1)) + (when (buffer-live-p buf) + (kill-buffer buf))))) + + (it "flushes subagent text before nested child content" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60)) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-subagent-parent + session buf "parent-tool" "child-chat") + (eca-chat-test--render-subagent-text + session buf "parent-tool" "text-before-nested" "child-chat") + (expect (eca-chat-test--history-text buf) + :not :to-match (regexp-quote "text-before-nested")) + (eca-chat--render-content + session buf "assistant" + (list :type "toolCallRun" + :id "nested-tool" + :name "nestedTool" + :server "nestedServer" + :arguments "{}" + :details (list :type "generic")) + nil "parent-tool" "child-chat") + (eca-chat--expandable-content-toggle "parent-tool" t nil) + (let ((text-index (eca-chat-test--history-index + buf "text-before-nested")) + (nested-index (eca-chat-test--history-index + buf "nestedServer__nestedTool"))) + (expect text-index :not :to-be nil) + (expect nested-index :not :to-be nil) + (expect text-index :to-be-less-than nested-index))) + (when (buffer-live-p buf) + (kill-buffer buf))))) + + (it "keeps sibling subagent text hidden across another parent boundary" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60)) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-subagent-parent + session buf "parent-a" "child-a" "agent-a" "task-a") + (eca-chat-test--render-subagent-parent + session buf "parent-b" "child-b" "agent-b" "task-b") + (eca-chat-test--render-subagent-text + session buf "parent-a" "a-before-boundary" "child-a") + (eca-chat-test--render-subagent-text + session buf "parent-b" "b-still-buffered" "child-b") + (let ((before-boundary (eca-chat-test--history-text buf))) + (expect before-boundary + :not :to-match (regexp-quote "a-before-boundary")) + (expect before-boundary + :not :to-match (regexp-quote "b-still-buffered"))) + (eca-chat--render-content + session buf "assistant" + (list :type "toolCallRun" + :id "nested-a" + :name "nestedToolA" + :server "nestedServer" + :arguments "{}" + :details (list :type "generic")) + nil "parent-a" "child-a") + (expect (eca-chat-test--expanded-parent-history-text buf "parent-a") + :to-match (regexp-quote "a-before-boundary")) + (expect (eca-chat-test--history-text buf) + :not :to-match (regexp-quote "b-still-buffered")) + (eca-chat-test--stream-flush buf) + (expect (eca-chat-test--expanded-parent-history-text buf "parent-b") + :to-match (regexp-quote "b-still-buffered"))) + (when (buffer-live-p buf) + (kill-buffer buf))))) + + (it "flushes subagent text before top-level lifecycle boundary" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60)) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-subagent-parent + session buf "parent-tool" "child-chat") + (eca-chat-test--render-subagent-text + session buf "parent-tool" "text-before-top-level" + "child-chat") + (expect (eca-chat-test--expanded-parent-history-text + buf "parent-tool") + :not :to-match (regexp-quote "text-before-top-level")) + (eca-chat--render-content + session buf "assistant" + (list :type "toolCallRun" + :id "top-level-tool" + :name "topLevelTool" + :server "topServer" + :arguments "{}" + :details (list :type "generic")) + nil) + (eca-chat--expandable-content-toggle "parent-tool" t nil) + (eca-chat--expandable-content-toggle "top-level-tool" t nil) + (let ((text-index (eca-chat-test--history-index + buf "text-before-top-level")) + (tool-index (eca-chat-test--history-index + buf "topServer__topLevelTool"))) + (expect text-index :not :to-be nil) + (expect tool-index :not :to-be nil) + (expect text-index :to-be-less-than tool-index)) + (eca-chat-test--stream-flush buf) + (expect (eca-chat-test--string-count + (eca-chat-test--expanded-parent-history-text + buf "parent-tool") + "text-before-top-level") + :to-equal 1)) + (when (buffer-live-p buf) + (kill-buffer buf))))) + + (it "flushes subagent text before parent completion" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60) + (summary "unique-subagent-complete-marker")) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-subagent-parent + session buf "parent-tool" "child-chat") + (eca-chat-test--render-subagent-text + session buf "parent-tool" "text-before-completion" "child-chat") + (expect (eca-chat-test--history-text buf) + :not :to-match (regexp-quote "text-before-completion")) + (eca-chat--render-content + session buf "assistant" + (list :type "toolCalled" + :id "parent-tool" + :name "subagentTool" + :server "testServer" + :arguments (list :agent "parent-tool-agent" + :task "parent-tool task") + :summary summary + :details (list :type "subagent" + :subagentChatId "child-chat" + :model "test-model" + :step 1 + :maxSteps 1)) + nil) + (eca-chat--expandable-content-toggle "parent-tool" t nil) + (let ((parent-text (eca-chat-test--expanded-parent-history-text + buf "parent-tool"))) + (expect parent-text + :to-match (regexp-quote "text-before-completion")) + (expect (eca-chat-test--history-index buf summary) + :not :to-be nil))) + (when (buffer-live-p buf) + (kill-buffer buf))))) + + (it "keeps final subagent output with streamed parent content" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60) + (streamed-text "streamed child output marker") + (final-output "final subagent output marker")) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-subagent-parent + session buf "parent-tool" "child-chat") + (eca-chat-test--render-subagent-text + session buf "parent-tool" streamed-text "child-chat") + (eca-chat-test--stream-flush buf) + (eca-chat--render-content + session buf "assistant" + (list :type "toolCalled" + :id "parent-tool" + :name "subagentTool" + :server "testServer" + :arguments (list :agent "parent-tool-agent" + :task "parent-tool task") + :details (list :type "subagent" + :subagentChatId "child-chat" + :model "test-model" + :step 1 + :maxSteps 1) + :outputs (list (list :text final-output))) + nil) + (let ((parent-text (eca-chat-test--expanded-parent-history-text + buf "parent-tool"))) + (expect (eca-chat-test--string-count parent-text streamed-text) + :to-equal 1) + (expect (eca-chat-test--string-count parent-text final-output) + :to-equal 1)) + (eca-chat--expandable-content-toggle "parent-tool" t t) + (eca-chat--expandable-content-toggle "parent-tool" t nil) + (let ((parent-text (eca-chat-test--expanded-parent-history-text + buf "parent-tool"))) + (expect (eca-chat-test--string-count parent-text streamed-text) + :to-equal 1) + (expect (eca-chat-test--string-count parent-text final-output) + :to-equal 1))) + (when (buffer-live-p buf) + (kill-buffer buf))))) + + (it "flushes subagent chunks in order for one parent" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60)) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-subagent-parent + session buf "parent-tool" "child-chat") + (eca-chat-test--render-subagent-text + session buf "parent-tool" "sub-one " "child-chat") + (eca-chat-test--render-subagent-text + session buf "parent-tool" "sub-two " "child-chat") + (eca-chat-test--render-subagent-text + session buf "parent-tool" "sub-three" "child-chat") + (eca-chat-test--stream-flush buf) + (let ((parent-text (eca-chat-test--expanded-parent-history-text + buf "parent-tool"))) + (expect parent-text + :to-match (regexp-quote "sub-one sub-two sub-three")) + (expect (string-match-p (regexp-quote "sub-one ") parent-text) + :to-be-less-than + (string-match-p (regexp-quote "sub-two ") parent-text)) + (expect (string-match-p (regexp-quote "sub-two ") parent-text) + :to-be-less-than + (string-match-p (regexp-quote "sub-three") parent-text)) + (eca-chat-test--stream-flush buf) + (setq parent-text (eca-chat-test--expanded-parent-history-text + buf "parent-tool")) + (expect (eca-chat-test--string-count parent-text "sub-one ") + :to-equal 1) + (expect (eca-chat-test--string-count parent-text "sub-two ") + :to-equal 1) + (expect (eca-chat-test--string-count parent-text "sub-three") + :to-equal 1))) + (when (buffer-live-p buf) + (kill-buffer buf))))) + + (it "immediate mode renders subagent text immediately" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval nil)) + (unwind-protect + (eca-chat--with-current-buffer buf + (eca-chat-test--render-subagent-parent + session buf "parent-tool" "child-chat") + (eca-chat-test--render-subagent-text + session buf "parent-tool" "immediate-subagent-token" "child-chat") + (let ((parent-text (eca-chat-test--expanded-parent-history-text + buf "parent-tool"))) + (expect parent-text + :to-match (regexp-quote "immediate-subagent-token")) + (eca-chat-test--stream-flush buf) + (expect (eca-chat-test--string-count + (eca-chat-test--expanded-parent-history-text + buf "parent-tool") + "immediate-subagent-token") + :to-equal 1))) + (when (buffer-live-p buf) + (kill-buffer buf))))) + + (it "flushes all queues before progress finished finalization" + (let ((buf (eca-chat-test--make-render-buffer)) + (session (make-eca--session)) + (eca-chat-stream-flush-interval 60) + history-at-finalize + pending-at-finalize) + (unwind-protect + (eca-chat--with-current-buffer buf + (setq-local eca-chat--progress-text "thinking...") + (setq-local eca-chat--chat-loading t) + (setq-local eca-chat--last-user-message-pos (point-min)) + (spy-on 'eca-chat--align-tables) + (spy-on 'eca-chat--beautify-tables) + (spy-on 'eca-chat--refresh-progress) + (spy-on 'eca-chat--set-chat-loading) + (spy-on 'eca-chat--send-steered-prompt) + (spy-on 'eca-chat--send-queued-prompt) + (cl-letf (((symbol-function 'eca-chat--font-lock-ensure) + (lambda (&rest _) + (setq pending-at-finalize + (eca-chat--stream-pending-p)) + (when (eca-chat--get-expandable-content + "finish-parent") + (eca-chat--expandable-content-toggle + "finish-parent" t nil)) + (when (eca-chat--get-expandable-content + "finish-prepare") + (eca-chat--expandable-content-toggle + "finish-prepare" t nil)) + (setq history-at-finalize + (eca-chat-test--history-text buf))))) + (eca-chat-test--render-subagent-parent + session buf "finish-parent" "finish-child") + (eca-chat-test--render-subagent-text + session buf "finish-parent" "finish child text" + "finish-child") + (eca-chat-test--render-tool-call-prepare + session buf "finish-prepare" "finish prepare text") + (eca-chat--render-content + session buf "system" + (list :type "progress" :state "finished") nil)) + (expect pending-at-finalize :to-be nil) + (expect history-at-finalize + :to-match (regexp-quote "finish child text")) + (expect history-at-finalize + :to-match (regexp-quote "finish prepare text"))) + (when (buffer-live-p buf) + (ignore-errors (eca-chat-test--stream-flush buf)) + (kill-buffer buf))))) + (it "clear cancels pending stream state before a later callback" (let ((buf (eca-chat-test--make-render-buffer)) (session (make-eca--session)) diff --git a/test/eca-workspaces-test.el b/test/eca-workspaces-test.el index 821a184..493c709 100644 --- a/test/eca-workspaces-test.el +++ b/test/eca-workspaces-test.el @@ -150,6 +150,7 @@ CHATS is a list of chat buffers ordered oldest-first." (describe "eca-workspaces rendering" + (before-each (eca-workspaces-test--cleanup)) (after-each (eca-workspaces-test--cleanup)) (it "shows an empty state when no session is running"