-
-
Notifications
You must be signed in to change notification settings - Fork 118
Description
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_updatehandler inagent-shell--on-notificationprocesses each of the ~10,000 updates by extracting the fullcontenttext, passing it toagent-shell--update-fragment, which replaces the entire fragment body in the buffer viaagent-shell-ui-update-fragmentand then runsmarkdown-overlays-putover 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_updatenotification (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:
- Extend
acp.elto accept:terminal-capabilityand:meta-capabilitiesonacp-make-initialize-request. - Pass those capabilities from
agent-shell.elduring initialize. - Handle incremental
_meta.terminal_output.datachunks (codex-acp) and batch_meta.terminal_outputresults (claude-agent-acp) in a new streaming handler with deduplication. - 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-acptruncates large output to<persisted-output>codex-acpsends 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.elx.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"
donecodex-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.elchanges:(require 'agent-shell-streaming)- Replace inline
tool_call_updaterendering withagent-shell--handle-tool-call-update-streaming - Advertise
terminal_outputmeta capability in initialize request - Add
agent-shell--mark-tool-calls-cancelledinagent-shell-interrupt - Fix
shell-maker-define-major-modeto 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).