Skip to content

Support streaming tool output and deduplication #342

@timvisher-dd

Description

@timvisher-dd

Fix drafts in #343 and xenodium/acp.el#15.

Problem

The two most popular agent ACPs, codex-acp and claude-agent-acp, perform very poorly in agent-shell when tool executions emit a lot of text. codex-acp is more broken but claude-agent-acp still has issues:

  • codex-acp: O(n^2) rendering and massive data transfer.

    A 35k-line bash command takes 53 seconds and transfers ~890 MB of JSON — a 3,000x amplification of the ~280 KB actual output. agent-shell's tool_call_update handler in agent-shell--on-notification processes each of the ~10,000 updates by extracting the full content text, passing it to agent-shell--update-fragment, which replaces the entire fragment body in the buffer via agent-shell-ui-update-fragment and then runs markdown-overlays-put over it. Every update triggers a full buffer replacement and full markdown fontification of progressively larger text — O(n^2) rendering on top of the O(n^2) data transfer.

  • claude-agent-acp: output is silently lost.

    The same command truncates to 241 of 35,001 lines. The user sees <persisted-output> Output too large (365.1KB) with raw XML tags rendered verbatim in the shell buffer.

Cause

agent-shell does not advertise _meta.terminal_output in the clientCapabilities sent during the ACP initialize handshake. Without this capability:

  • codex-acp falls back to sending the full accumulated output in every tool_call_update notification (O(n^2) content growth).

  • claude-agent-acp sends a single truncated result at completion instead of streaming the full output via _meta.terminal_output.

Fix

Advertise _meta.terminal_output during initialize and handle the resulting streaming behavior:

  1. Extend acp.el to accept :terminal-capability and :meta-capabilities on acp-make-initialize-request.
  2. Pass those capabilities from agent-shell.el during initialize.
  3. Handle incremental _meta.terminal_output.data chunks (codex-acp) and batch _meta.terminal_output results (claude-agent-acp) in a new streaming handler with deduplication.
  4. Strip <persisted-output> tags and render previews cleanly.

Root cause: O(n^2) content in codex-acp without terminal caps

The core issue lives in codex-acp's exec_command_output_delta function (src/thread.rs:1228-1274). On every output chunk:

// thread.rs:1242-1273 (codex-acp v0.9.4)
let update = if client.supports_terminal_output(active_command) {
    // GOOD PATH: send only the delta chunk via _meta
    ToolCallUpdate::new(...)
        .meta(Meta::from_iter([("terminal_output", json!({
            "terminal_id": call_id,
            "data": data_str       // <-- just the new chunk
        }))]))
} else {
    // BAD PATH: accumulate and re-send everything
    active_command.output.push_str(&data_str);
    let content = format!("```sh\n{}\n```\n",
        active_command.output.trim_end_matches('\n')  // <-- FULL output
    );
    ToolCallUpdate::new(...)
        .content(vec![content.into()])
};

The "bad path" fires when supports_terminal_output returns false, which happens when the client does not advertise _meta.terminal_output during initialize. Every chunk appends to active_command.output and then sends the entire accumulated string. For 35k lines this produces ~10k updates with the last one carrying ~188KB, totaling ~890MB of JSON content across all updates.

The gate (thread.rs:1535-1547):

fn supports_terminal_output(&self, active_command: &ActiveCommand) -> bool {
    active_command.terminal_output
        && self.client_capabilities.lock().unwrap()
            .meta.as_ref()
            .is_some_and(|v| v.get("terminal_output")
                .is_some_and(|v| v.as_bool().unwrap_or_default()))
}

acp.el does not even accept :terminal-capability or :meta-capabilities keyword arguments on acp-make-initialize-request, so there is no way to opt into the efficient path today.

How agents send incremental output

ACP's _meta field (protocol schema) is a reserved extension namespace that agents use to attach streaming output to tool_call_update notifications. Two patterns exist in practice:

codex-acp (v0.9.4): O(n^2) content vs incremental chunks

Without terminal caps (current behavior):

tool_call_update  content = "```sh\nline 0\n```"
tool_call_update  content = "```sh\nline 0\nline 1\n```"
tool_call_update  content = "```sh\nline 0\nline 1\nline 2\n```"
...  (10,000+ updates, each carrying ALL previous output)
tool_call_update  status: "completed"

With terminal caps:

tool_call_update  _meta.terminal_output.data = "line 0\n"
tool_call_update  _meta.terminal_output.data = "line 1\n"
...  (10,000+ chunks, each carrying only the NEW delta)
tool_call_update  status: "completed"

claude-agent-acp (v0.18.0): truncated content, no streaming

Claude Code's ACP bridge uses the claude-agent-sdk's built-in tools, which execute commands internally and emit a single tool result at completion. It does not stream tool_call_update notifications during execution.

Without _meta.terminal_output capability, the final result is sent as a content block. For large output, the SDK truncates it:

tool_call_update  content = "```console\n<persisted-output>\nOutput too large (365.1KB)..."

With terminal caps, it sends the full output via _meta:

tool_call_update
  _meta.terminal_info.terminal_id = <id>
  _meta.terminal_output = { terminal_id: <id>, data: <full output> }
  _meta.terminal_exit = { terminal_id: <id>, exit_code: 0 }

Enabling these paths: terminal_output capability

Both agents gate their streaming behavior on the client advertising terminal_output support in the _meta field of clientCapabilities during the ACP initialize request. Without it:

  • claude-agent-acp truncates large output to <persisted-output>
  • codex-acp sends O(n^2) accumulated content in every update

The fix requires changes in acp.el (accept the keyword args) and agent-shell.el (pass them during initialize).

Perf measurements (2026-02-26)

Test: for x in {0..35000}; do printf 'line %d\n' "$x"; done (35,001 lines of output)

Stack: xenodium acp.el 49de56f, agent-shell f6023c6, shell-maker a7ff78f. ACP agents at trunk: codex-acp c0b82cc (v0.9.4), claude-agent-acp b57a429+ (v0.18.0).

Timing varies with backend server responsiveness. Three sequential runs of each configuration (12 runs total, no parallel contention).

The "with terminal caps" runs use the acp.el branch that adds :terminal-capability and :meta-capabilities to acp-make-initialize-request. The test harness is a pure ACP client (no agent-shell in the loop) that counts notifications and measures wall-clock time.

Repro scripts and usage

Single-run examples:

# codex-acp without terminal caps (reproduces O(n^2))
ACP_BIN=codex-acp ACP_AUTH_METHOD=none \
  op run --no-masking -- emacs -Q --batch \
    -L path/to/acp.el \
    -l x.perf-baseline.el

# codex-acp with terminal caps (needs acp.el branch)
TERMINAL_CAPS=1 ACP_BIN=codex-acp ACP_AUTH_METHOD=none \
  op run --no-masking -- emacs -Q --batch \
    -L path/to/acp.el-with-caps \
    -l x.perf-baseline.el

# claude-agent-acp without terminal caps
CLAUDECODE= ACP_BIN=claude-agent-acp ACP_AUTH_METHOD=none \
  op run --no-masking -- emacs -Q --batch \
    -L path/to/acp.el \
    -l x.perf-baseline.el

# claude-agent-acp with terminal caps
CLAUDECODE= TERMINAL_CAPS=1 ACP_BIN=claude-agent-acp ACP_AUTH_METHOD=none \
  op run --no-masking -- emacs -Q --batch \
    -L path/to/acp.el-with-caps \
    -l x.perf-baseline.el
x.perf-baseline.el (ACP perf test harness)
;;; x.perf-baseline.el --- ACP perf test -*- lexical-binding: t; -*-
;;
;; Env vars:
;;   ACP_BIN          — ACP binary (default: codex-acp)
;;   ACP_AUTH_METHOD  — "none" to skip authenticate call
;;   TERMINAL_CAPS    — when set, advertise _meta.terminal_output

(require 'acp)
(require 'cl-lib)

(defvar x-bl--update-count 0)
(defvar x-bl--tool-update-count 0)
(defvar x-bl--content-bytes 0)
(defvar x-bl--content-newlines 0)
(defvar x-bl--terminal-chunks 0)
(defvar x-bl--terminal-bytes 0)
(defvar x-bl--terminal-newlines 0)

(defun x-bl--on-notification (notification)
  "Track metrics from NOTIFICATION."
  (when (equal (map-elt notification 'method) "session/update")
    (setq x-bl--update-count (1+ x-bl--update-count))
    (let ((update (map-elt (map-elt notification 'params) 'update)))
      (when (equal (map-elt update 'sessionUpdate) "tool_call_update")
        (setq x-bl--tool-update-count (1+ x-bl--tool-update-count))
        (when-let ((meta (or (map-elt update '_meta) (map-elt update 'meta))))
          (when-let ((terminal (map-elt meta 'terminal_output)))
            (when-let ((data (map-elt terminal 'data)))
              (setq x-bl--terminal-chunks (1+ x-bl--terminal-chunks))
              (setq x-bl--terminal-bytes (+ x-bl--terminal-bytes (length data)))
              (setq x-bl--terminal-newlines
                    (+ x-bl--terminal-newlines (cl-count ?\n data))))))
        (dolist (item (append (map-elt update 'content) nil))
          (when-let ((text (cond
                            ((and (listp item) (equal (map-elt item 'type) "text"))
                             (map-elt item 'text))
                            ((and (listp item) (map-elt item 'content))
                             (map-elt (map-elt item 'content) 'text)))))
            (setq x-bl--content-bytes (+ x-bl--content-bytes (length text)))
            (setq x-bl--content-newlines
                  (+ x-bl--content-newlines (cl-count ?\n text)))))))))

(defun x-bl--on-request (client request)
  "Auto-approve permissions and serve file reads."
  (let-alist request
    (cond
     ((equal .method "session/request_permission")
      (acp-send-response
       :client client
       :response (acp-make-session-request-permission-response
                  :request-id .id
                  :option-id (map-elt (car (map-elt .params 'options)) 'optionId))))
     ((equal .method "fs/read_text_file")
      (acp-send-response
       :client client
       :response (acp-make-fs-read-text-file-response
                  :request-id .id
                  :content (with-temp-buffer
                             (insert-file-contents (map-elt .params 'path))
                             (buffer-string)))))
     (t
      (acp-send-response
       :client client
       :response `((:request-id . ,.id)
                   (:error . ((code . -32601)
                              (message . ,(format "Unhandled: %s" .method))))))))))

(defun x-bl--main ()
  (let* ((bin (or (getenv "ACP_BIN") "codex-acp"))
         (client (acp-make-client :command bin))
         (prompt "Run this exact bash command: \
`for x in {0..35000}; do printf 'line %d\\n' \"$x\"; done`"))
    (acp-subscribe-to-notifications
     :client client :on-notification #'x-bl--on-notification)
    (acp-subscribe-to-requests
     :client client
     :on-request (lambda (req) (x-bl--on-request client req)))
    (let ((init-request
           (if (getenv "TERMINAL_CAPS")
               (acp-make-initialize-request
                :protocol-version 1
                :client-info '((name . "x-perf-baseline") (version . "0.1"))
                :read-text-file-capability t :write-text-file-capability t
                :terminal-capability t
                :meta-capabilities '((terminal_output . t)))
             (acp-make-initialize-request
              :protocol-version 1
              :client-info '((name . "x-perf-baseline") (version . "0.1"))
              :read-text-file-capability t :write-text-file-capability t))))
      (acp-send-request :client client :request init-request :sync t))
    (let ((auth-method (or (getenv "ACP_AUTH_METHOD") "anthropic-api-key")))
      (unless (string= auth-method "none")
        (acp-send-request :client client
         :request (acp-make-authenticate-request :method-id auth-method)
         :sync t)))
    (let* ((new-resp (acp-send-request :client client
                      :request (acp-make-session-new-request
                                :cwd default-directory :mcp-servers [])
                      :sync t))
           (session-id (map-elt new-resp 'sessionId)))
      (unless session-id (kill-emacs 1))
      (let ((start (float-time)))
        (acp-send-request :client client
         :request (acp-make-session-prompt-request
                   :session-id session-id
                   :prompt '[((type . "text") (text . "say READY"))])
         :sync t)
        (princ (format "warmup_ms=%.0f\n" (* 1000.0 (- (float-time) start)))))
      (let ((start (float-time)))
        (acp-send-request :client client
         :request (acp-make-session-prompt-request
                   :session-id session-id
                   :prompt `[((type . "text") (text . ,prompt))])
         :sync t)
        (princ (format "measure_ms=%.0f\n" (* 1000.0 (- (float-time) start))))))
    (princ (format "updates=%d tool_updates=%d\n"
                   x-bl--update-count x-bl--tool-update-count))
    (princ (format "content_bytes=%d content_newlines=%d\n"
                   x-bl--content-bytes x-bl--content-newlines))
    (princ (format "terminal_chunks=%d terminal_bytes=%d terminal_newlines=%d\n"
                   x-bl--terminal-chunks x-bl--terminal-bytes
                   x-bl--terminal-newlines)))
  (kill-emacs 0))

(when noninteractive (x-bl--main))
x.perf-batch.sh (multi-sample runner)
#!/usr/bin/env bash
set -euo pipefail

# Run N samples of 4 configurations.
# Within each sample, all 4 configs run in parallel.
# Samples run sequentially to avoid overwhelming the backends.

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SAMPLES="${PERF_SAMPLES:-3}"
OUT_DIR="${SCRIPT_DIR}/x.perf-results"
mkdir -p "$OUT_DIR"

ACP_UPSTREAM=path/to/acp.el          # without terminal caps
ACP_BRANCH=path/to/acp.el-with-caps  # with terminal caps

run_one() {
  local label="$1" sample="$2" acp_bin="$3" acp_lib="$4"
  local auth_method="$5" terminal_caps="$6" unset_claudecode="$7"
  local outfile="${OUT_DIR}/${label}.sample${sample}.log"
  local -a env_args=(ACP_BIN="$acp_bin" ACP_AUTH_METHOD="$auth_method")
  [[ "$terminal_caps" == "1" ]] && env_args+=(TERMINAL_CAPS=1)
  [[ "$unset_claudecode" == "1" ]] && env_args+=(CLAUDECODE=)
  if env "${env_args[@]}" op run --no-masking -- \
    emacs -Q --batch -L "$acp_lib" \
      -l "$SCRIPT_DIR/x.perf-baseline.el" 2>&1 \
  | grep -E '^(warmup_ms|measure_ms|updates=|content_|terminal_|STREAMING|PASS|RESULT)' \
  > "$outfile"; then
    echo "status=ok" >> "$outfile"
  else
    echo "status=error" >> "$outfile"
  fi
}

for i in $(seq 1 "$SAMPLES"); do
  echo "--- sample $i/$SAMPLES ---"
  run_one codex-baseline  "$i" codex-acp       "$ACP_UPSTREAM" none 0 0 &
  run_one codex-caps      "$i" codex-acp       "$ACP_BRANCH"   none 1 0 &
  run_one claude-baseline "$i" claude-agent-acp "$ACP_UPSTREAM" none 0 1 &
  run_one claude-caps     "$i" claude-agent-acp "$ACP_BRANCH"   none 1 1 &
  wait
done

for label in codex-baseline codex-caps claude-baseline claude-caps; do
  outfile="${OUT_DIR}/${label}.log"; : > "$outfile"
  for i in $(seq 1 "$SAMPLES"); do
    echo "sample=$i" >> "$outfile"
    cat "${OUT_DIR}/${label}.sample${i}.log" >> "$outfile"
  done
  echo "[$label] $SAMPLES samples -> $outfile"
done

codex-acp (v0.9.4)

Without terminal caps (current behavior):

Run measure_ms content_bytes content_newlines
1 58,934 859,366,639 85,676,703
2 67,455 1,001,821,479 98,978,257
3 55,116 838,508,066 83,638,468

Each of the ~10k tool_call_update notifications carries the full accumulated output in markdown fences. Total content transferred is ~900 MB for ~280 KB of actual output. agent-shell replaces the fragment body and runs markdown-overlays-put on each update.

With terminal caps:

Run measure_ms content_bytes terminal_bytes
1 8,476 1,217 254,071
2 6,996 4,987 228,302
3 7,280 3,429 241,597

~7-8x faster. Content bytes drop from ~900 MB to ~3 KB. Terminal output arrives as incremental _meta.terminal_output.data chunks (~240 KB total, close to the actual output size).

claude-agent-acp (v0.18.0)

Without terminal caps (current behavior):

Run measure_ms content_bytes content_newlines
1 24,380 2,321 241
2 20,977 2,321 241
3 21,900 2,321 241

Output truncated to <persisted-output> Output too large (365.1KB). User sees 241 lines of 35,001. The raw <persisted-output> XML tags render verbatim in the shell buffer. The streaming handler strips the tags and renders the preview content with font-lock-comment-face (see agent-shell--tool-call-normalize-output in agent-shell-streaming.el).

With terminal caps:

Run measure_ms content_bytes terminal_bytes
1 20,993 0 2,270
2 22,547 0 2,270
3 24,970 0 2,270

No timing improvement (execution is server-side), but content_bytes drops to 0 and terminal output arrives via _meta.terminal_output (2,270 bytes / 239 lines). The output is still truncated by claude-agent-sdk before reaching ACP, but the <persisted-output> tags no longer appear in content — they're handled cleanly by the streaming handler.

Historical comparison (Feb 11, 2026)

The original investigation on the perf-scratch worktree at codex-acp ~v0.9.1 showed: 10,002 tool_call_updates, body_max=188,693, measure_ms=10,670. The pattern has been present since at least v0.9.1 and is unchanged in v0.9.4.

Solution

Three new files, one modified:

  • agent-shell-meta.el -- Meta-response extractors: meta-lookup, meta-find-tool-response, tool-call-meta-response-text (stdout/content/vector shapes), tool-call-terminal-output-data.

  • agent-shell-streaming.el -- Streaming tool call handler: output normalization, chunk accumulation, the three-branch streaming handler (terminal-data live stream, meta-response accumulate, final render), and cancellation support.

  • agent-shell.el changes:

    • (require 'agent-shell-streaming)
    • Replace inline tool_call_update rendering with agent-shell--handle-tool-call-update-streaming
    • Advertise terminal_output meta capability in initialize request
    • Add agent-shell--mark-tool-calls-cancelled in agent-shell-interrupt
    • Fix shell-maker-define-major-mode to pass quoted keymap symbol
  • tests/agent-shell-streaming-tests.el -- 7 tests covering meta extraction, output normalization, claude dedup, codex streaming dedup, and capability advertisement.

Prerequisite: acp.el changes

acp.el needs to accept :terminal-capability and :meta-capabilities keyword arguments on acp-make-initialize-request so that agent-shell.el can advertise the capability. This is a small change (see timvisher-dd/acp.el commit 2b8029d).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions