ACP: simplify stream config to repeatSuppression

This commit is contained in:
Onur
2026-03-01 13:08:55 +01:00
committed by Onur Solmaz
parent 79fcc8404e
commit 4e2efaf659
9 changed files with 108 additions and 307 deletions

View File

@@ -64,7 +64,7 @@ describe("createAcpReplyProjector", () => {
expect(deliveries).toEqual([{ kind: "block", text: "What now?" }]);
});
it("suppresses usage_update by default and allows deduped usage when enabled", async () => {
it("suppresses usage_update by default and allows deduped usage when tag-visible", async () => {
const hidden: Array<{ kind: string; text?: string }> = [];
const hiddenProjector = createAcpReplyProjector({
cfg: createCfg(),
@@ -91,7 +91,6 @@ describe("createAcpReplyProjector", () => {
stream: {
coalesceIdleMs: 0,
maxChunkChars: 64,
showUsage: true,
tagVisibility: {
usage_update: true,
},
@@ -153,7 +152,7 @@ describe("createAcpReplyProjector", () => {
expect(deliveries).toEqual([]);
});
it("dedupes repeated tool lifecycle updates in minimal mode", async () => {
it("dedupes repeated tool lifecycle updates when repeatSuppression is enabled", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg(),
@@ -227,7 +226,7 @@ describe("createAcpReplyProjector", () => {
expect(deliveries[0]?.text).not.toContain("call_ABC123 (");
});
it("respects metaMode=off and still streams assistant text", async () => {
it("allows repeated status/tool summaries when repeatSuppression is disabled", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
@@ -236,7 +235,10 @@ describe("createAcpReplyProjector", () => {
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
metaMode: "off",
repeatSuppression: false,
tagVisibility: {
available_commands_update: true,
},
},
},
}),
@@ -247,6 +249,11 @@ describe("createAcpReplyProjector", () => {
},
});
await projector.onEvent({
type: "status",
text: "available commands updated",
tag: "available_commands_update",
});
await projector.onEvent({
type: "status",
text: "available commands updated",
@@ -259,6 +266,13 @@ describe("createAcpReplyProjector", () => {
toolCallId: "x",
status: "in_progress",
});
await projector.onEvent({
type: "tool_call",
text: "tool call",
tag: "tool_call_update",
toolCallId: "x",
status: "in_progress",
});
await projector.onEvent({
type: "text_delta",
text: "hello",
@@ -266,10 +280,21 @@ describe("createAcpReplyProjector", () => {
});
await projector.flush(true);
expect(deliveries).toEqual([{ kind: "block", text: "hello" }]);
expect(deliveries.filter((entry) => entry.kind === "tool").length).toBe(4);
expect(deliveries[0]).toEqual({
kind: "tool",
text: prefixSystemMessage("available commands updated"),
});
expect(deliveries[1]).toEqual({
kind: "tool",
text: prefixSystemMessage("available commands updated"),
});
expect(deliveries[2]?.text).toContain("Tool Call");
expect(deliveries[3]?.text).toContain("Tool Call");
expect(deliveries[4]).toEqual({ kind: "block", text: "hello" });
});
it("allows non-identical status updates in metaMode=verbose while suppressing exact duplicates", async () => {
it("suppresses exact duplicate status updates when repeatSuppression is enabled", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
@@ -278,7 +303,6 @@ describe("createAcpReplyProjector", () => {
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
metaMode: "verbose",
tagVisibility: {
available_commands_update: true,
},
@@ -324,7 +348,6 @@ describe("createAcpReplyProjector", () => {
coalesceIdleMs: 0,
maxChunkChars: 256,
maxTurnChars: 5,
metaMode: "minimal",
},
},
}),
@@ -363,7 +386,6 @@ describe("createAcpReplyProjector", () => {
coalesceIdleMs: 0,
maxChunkChars: 256,
maxMetaEventsPerTurn: 1,
showUsage: true,
tagVisibility: {
usage_update: true,
},

View File

@@ -157,16 +157,13 @@ export function createAcpReplyProjector(params: {
if (!params.shouldSendToolSummaries) {
return;
}
if (settings.metaMode === "off" && opts?.force !== true) {
return;
}
const bounded = truncateText(text.trim(), settings.maxStatusChars);
if (!bounded) {
return;
}
const formatted = prefixSystemMessage(bounded);
const hash = hashText(formatted);
const shouldDedupe = opts?.dedupe !== false;
const shouldDedupe = settings.repeatSuppression && opts?.dedupe !== false;
if (shouldDedupe && lastStatusHash === hash) {
return;
}
@@ -184,7 +181,7 @@ export function createAcpReplyProjector(params: {
event: Extract<AcpRuntimeEvent, { type: "tool_call" }>,
opts?: { force?: boolean },
) => {
if (!params.shouldSendToolSummaries || settings.metaMode === "off") {
if (!params.shouldSendToolSummaries) {
return;
}
if (!isAcpTagVisible(settings, event.tag)) {
@@ -198,11 +195,7 @@ export function createAcpReplyProjector(params: {
const isTerminal = status ? TERMINAL_TOOL_STATUSES.has(status) : false;
const isStart = status === "in_progress" || event.tag === "tool_call";
if (settings.metaMode === "verbose") {
if (lastToolHash === hash) {
return;
}
} else if (settings.metaMode === "minimal") {
if (settings.repeatSuppression) {
if (toolCallId) {
const state = toolLifecycleById.get(toolCallId) ?? {
started: false,
@@ -299,10 +292,7 @@ export function createAcpReplyProjector(params: {
if (!isAcpTagVisible(settings, event.tag)) {
return;
}
if (event.tag === "usage_update") {
if (!settings.showUsage) {
return;
}
if (event.tag === "usage_update" && settings.repeatSuppression) {
const usageTuple =
typeof event.used === "number" && typeof event.size === "number"
? `${event.used}/${event.size}`

View File

@@ -10,8 +10,7 @@ describe("acp stream settings", () => {
it("resolves stable defaults", () => {
const settings = resolveAcpProjectionSettings(createAcpTestConfig());
expect(settings.deliveryMode).toBe("live");
expect(settings.metaMode).toBe("minimal");
expect(settings.showUsage).toBe(false);
expect(settings.repeatSuppression).toBe(true);
expect(settings.maxTurnChars).toBe(24_000);
expect(settings.maxMetaEventsPerTurn).toBe(64);
});
@@ -23,8 +22,7 @@ describe("acp stream settings", () => {
enabled: true,
stream: {
deliveryMode: "final_only",
metaMode: "off",
showUsage: true,
repeatSuppression: false,
maxTurnChars: 500,
maxMetaEventsPerTurn: 7,
tagVisibility: {
@@ -35,8 +33,7 @@ describe("acp stream settings", () => {
}),
);
expect(settings.deliveryMode).toBe("final_only");
expect(settings.metaMode).toBe("off");
expect(settings.showUsage).toBe(true);
expect(settings.repeatSuppression).toBe(false);
expect(settings.maxTurnChars).toBe(500);
expect(settings.maxMetaEventsPerTurn).toBe(7);
expect(settings.tagVisibility.usage_update).toBe(true);

View File

@@ -4,8 +4,7 @@ import { resolveEffectiveBlockStreamingConfig } from "./block-streaming.js";
const DEFAULT_ACP_STREAM_COALESCE_IDLE_MS = 350;
const DEFAULT_ACP_STREAM_MAX_CHUNK_CHARS = 1800;
const DEFAULT_ACP_META_MODE = "minimal";
const DEFAULT_ACP_SHOW_USAGE = false;
const DEFAULT_ACP_REPEAT_SUPPRESSION = true;
const DEFAULT_ACP_DELIVERY_MODE = "live";
const DEFAULT_ACP_MAX_TURN_CHARS = 24_000;
const DEFAULT_ACP_MAX_TOOL_SUMMARY_CHARS = 320;
@@ -26,12 +25,10 @@ export const ACP_TAG_VISIBILITY_DEFAULTS: Record<AcpSessionUpdateTag, boolean> =
};
export type AcpDeliveryMode = "live" | "final_only";
export type AcpMetaMode = "off" | "minimal" | "verbose";
export type AcpProjectionSettings = {
deliveryMode: AcpDeliveryMode;
metaMode: AcpMetaMode;
showUsage: boolean;
repeatSuppression: boolean;
maxTurnChars: number;
maxToolSummaryChars: number;
maxStatusChars: number;
@@ -65,13 +62,6 @@ function resolveAcpDeliveryMode(value: unknown): AcpDeliveryMode {
return value === "final_only" ? "final_only" : DEFAULT_ACP_DELIVERY_MODE;
}
function resolveAcpMetaMode(value: unknown): AcpMetaMode {
if (value === "off" || value === "minimal" || value === "verbose") {
return value;
}
return DEFAULT_ACP_META_MODE;
}
function resolveAcpStreamCoalesceIdleMs(cfg: OpenClawConfig): number {
return clampPositiveInteger(
cfg.acp?.stream?.coalesceIdleMs,
@@ -94,8 +84,7 @@ export function resolveAcpProjectionSettings(cfg: OpenClawConfig): AcpProjection
const stream = cfg.acp?.stream;
return {
deliveryMode: resolveAcpDeliveryMode(stream?.deliveryMode),
metaMode: resolveAcpMetaMode(stream?.metaMode),
showUsage: clampBoolean(stream?.showUsage, DEFAULT_ACP_SHOW_USAGE),
repeatSuppression: clampBoolean(stream?.repeatSuppression, DEFAULT_ACP_REPEAT_SUPPRESSION),
maxTurnChars: clampPositiveInteger(stream?.maxTurnChars, DEFAULT_ACP_MAX_TURN_CHARS, {
min: 1,
max: 500_000,

View File

@@ -172,10 +172,8 @@ export const FIELD_HELP: Record<string, string> = {
"Coalescer idle flush window in milliseconds for ACP streamed text before block replies are emitted.",
"acp.stream.maxChunkChars":
"Maximum chunk size for ACP streamed block projection before splitting into multiple block replies.",
"acp.stream.metaMode":
"ACP metadata projection mode: off suppresses status/tool lines, minimal dedupes aggressively, verbose streams non-identical updates.",
"acp.stream.showUsage":
"When true, usage_update events are projected as system lines only when usage values change.",
"acp.stream.repeatSuppression":
"When true (default), suppress repeated ACP status/tool projection lines in a turn while keeping raw ACP events unchanged.",
"acp.stream.deliveryMode":
"ACP delivery style: live streams block chunks incrementally, final_only buffers text deltas until terminal turn events.",
"acp.stream.maxTurnChars":

View File

@@ -369,8 +369,7 @@ export const FIELD_LABELS: Record<string, string> = {
"acp.stream": "ACP Stream",
"acp.stream.coalesceIdleMs": "ACP Stream Coalesce Idle (ms)",
"acp.stream.maxChunkChars": "ACP Stream Max Chunk Chars",
"acp.stream.metaMode": "ACP Stream Meta Mode",
"acp.stream.showUsage": "ACP Stream Show Usage",
"acp.stream.repeatSuppression": "ACP Stream Repeat Suppression",
"acp.stream.deliveryMode": "ACP Stream Delivery Mode",
"acp.stream.maxTurnChars": "ACP Stream Max Turn Chars",
"acp.stream.maxToolSummaryChars": "ACP Stream Max Tool Summary Chars",

View File

@@ -10,10 +10,8 @@ export type AcpStreamConfig = {
coalesceIdleMs?: number;
/** Maximum text size per streamed chunk. */
maxChunkChars?: number;
/** Controls how ACP meta/system updates are projected to channels. */
metaMode?: "off" | "minimal" | "verbose";
/** Toggles usage_update projection in channel-facing output. */
showUsage?: boolean;
/** Suppresses repeated ACP status/tool projection lines within a turn. */
repeatSuppression?: boolean;
/** Live streams chunks or waits for terminal event before delivery. */
deliveryMode?: "live" | "final_only";
/** Maximum assistant text characters forwarded per turn. */

View File

@@ -339,10 +339,7 @@ export const OpenClawSchema = z
.object({
coalesceIdleMs: z.number().int().nonnegative().optional(),
maxChunkChars: z.number().int().positive().optional(),
metaMode: z
.union([z.literal("off"), z.literal("minimal"), z.literal("verbose")])
.optional(),
showUsage: z.boolean().optional(),
repeatSuppression: z.boolean().optional(),
deliveryMode: z.union([z.literal("live"), z.literal("final_only")]).optional(),
maxTurnChars: z.number().int().positive().optional(),
maxToolSummaryChars: z.number().int().positive().optional(),

View File

@@ -1,273 +1,84 @@
# ACP Meta Event Dedupe Implementation Plan (2026-03-01)
# ACP Meta Event Dedupe Plan (2026-03-01)
## Goal
Eliminate ACP thread spam from repeated meta/progress updates while preserving full raw ACP event logs for debugging and audits.
## Problem Summary
- Raw ACP streams can contain repeated updates with identical payloads (for example `usage_update` and repeated `tool_call_update` snapshots).
- OpenClaw currently projects many of these events into user-visible thread messages.
- Result: noisy thread output like repeated `⚙️ usage updated ...` and duplicated `🧰 call_...`.
## Design Decision
- Keep `~/.acpx/sessions/*.stream.ndjson` as raw, lossless source of truth.
- Apply dedupe/throttle/filter only in OpenClaw ACP projection before channel delivery.
- Do not mutate ACPX persistence format for this UX issue.
- Reuse canonical formatters:
- System/meta notices must use `prefixSystemMessage(...)` from `src/infra/system-message.ts`.
- Tool lifecycle/progress lines must use a shared tool-summary formatter path (no ACP-local emoji string assembly).
Keep ACP thread output readable by default while preserving full raw ACP logs.
## Scope
- In scope:
- ACP message projection/filtering in OpenClaw.
- Config controls for ACP meta visibility.
- Tests for dedupe behavior.
- ACP projection behavior in OpenClaw.
- ACP stream config keys for visibility + repeat suppression.
- ACP projector tests.
- Out of scope:
- Rewriting historical session/event files.
- Changing ACP wire protocol semantics.
- Removing raw event logging in ACPX.
- ACPX raw stream persistence format.
- ACP protocol changes.
## Implementation Plan
## Configuration Shape
### 1. Add ACP meta visibility config
Use only these ACP stream controls for this behavior:
- File: `src/config/types.acp.ts`
- File: `src/config/zod-schema.ts`
- File: `src/config/schema.help.ts`
- File: `src/config/schema.labels.ts`
- Add:
- `acp.stream.metaMode?: "off" | "minimal" | "verbose"`
- `acp.stream.showUsage?: boolean`
- `acp.stream.deliveryMode?: "live" | "final_only"`
- `acp.stream.maxTurnChars?: number`
- `acp.stream.maxToolSummaryChars?: number`
- `acp.stream.maxStatusChars?: number`
- `acp.stream.maxMetaEventsPerTurn?: number`
- `acp.stream.tagVisibility?: Partial<Record<AcpSessionUpdateTag, boolean>>`
- Defaults:
- `metaMode: "minimal"`
- `showUsage: false`
- `deliveryMode: "live"`
- `maxTurnChars: 24000` (guardrail for giant streamed output per turn)
- `maxToolSummaryChars: 320` (prevents giant tool summary lines)
- `maxStatusChars: 320` (prevents giant status lines)
- `maxMetaEventsPerTurn: 64` (prevents meta flood)
- `tagVisibility` defaults:
- `agent_message_chunk: true`
- `tool_call: true`
- `tool_call_update: true` (still deduped by lifecycle/content rules)
- `usage_update: false`
- `available_commands_update: false`
- `current_mode_update: false`
- `config_option_update: false`
- `session_info_update: false`
- `plan: false`
- `agent_thought_chunk: false`
- `acp.stream.repeatSuppression?: boolean`
- `acp.stream.tagVisibility?: Partial<Record<AcpSessionUpdateTag, boolean>>`
- Existing delivery/size guards stay unchanged:
- `deliveryMode`
- `maxTurnChars`
- `maxToolSummaryChars`
- `maxStatusChars`
- `maxMetaEventsPerTurn`
### 2. Introduce ACP projector dedupe state
Removed from plan/config:
- File: `src/auto-reply/reply/acp-projector.ts`
- Add per-turn dedupe memory:
- Last emitted status text hash.
- Last emitted usage tuple (`used`, `size`).
- Tool lifecycle state map keyed by `toolCallId`.
- Last emitted tool update content hash per `toolCallId`.
- Reset dedupe state on turn completion (`done`/`error`) and on new projector construction.
- `showUsage`
- `metaMode`
### 3. Define projection rules
## Default Behavior (Minimal)
- File: `src/auto-reply/reply/acp-projector.ts`
- Rules:
- Tag-gate first:
- classify incoming ACP updates by `sessionUpdate` tag in runtime parser.
- apply `tagVisibility` before rendering/projecting user-facing messages.
- keep raw ACP logs unchanged (filter only affects user-visible projection).
- `deliveryMode = live`:
- current behavior: stream block chunks as deltas arrive.
- `deliveryMode = final_only`:
- append all `text_delta` into the existing chunker, but do not drain on each delta.
- drain/flush once on terminal event (`done`/`error`) using the same `flush(true)` path.
- keep the same `BlockReplyPipeline` and coalescer path (no second sender implementation).
- preserve ordering guarantees (tool/status summaries flush through existing pipeline rules).
- `metaMode = off`: no `status` and no `tool_call` summaries (text stream only).
- `metaMode = minimal`:
- allow first `tool_call` start (`in_progress`) per `toolCallId`.
- allow terminal tool status once (`completed`/`failed`).
- suppress identical repeated status lines.
- suppress repeated `tool_call_update` snapshots with same rendered text.
- `metaMode = verbose`:
- allow all status/tool summaries except exact immediate duplicates.
- Text budget:
- track emitted visible text chars per turn.
- if `maxTurnChars` is reached, stop forwarding further `text_delta` for that turn.
- emit one bounded notice (`output truncated`) exactly once when truncation begins.
- Meta budgets:
- truncate status/tool summary text to `maxStatusChars` / `maxToolSummaryChars`.
- if `maxMetaEventsPerTurn` is reached, suppress additional meta events for the turn.
- preserve final assistant text delivery even when meta cap is reached.
- `showUsage = false`:
- suppress usage status entirely.
- `showUsage = true`:
- emit usage only when `(used,size)` changed from last emitted tuple.
Default should be minimal-noise out of the box:
### 4. Normalize ACP status classification
- `repeatSuppression: true`
- `tagVisibility` defaults:
- `agent_message_chunk: true`
- `tool_call: true`
- `tool_call_update: true`
- `usage_update: false`
- `available_commands_update: false`
- `current_mode_update: false`
- `config_option_update: false`
- `session_info_update: false`
- `plan: false`
- `agent_thought_chunk: false`
- File: `extensions/acpx/src/runtime-internals/events.ts`
- Keep raw parsing, but provide stable status text categories where possible:
- Distinguish `sessionUpdate` tags (including `usage_update`) so projector can gate by tag reliably.
- Improve tool-call fallback label rendering:
- avoid leaking raw `toolCallId` noise (`call_...`) as the primary user-visible label when title is missing.
- render stable human fallback (`tool call`) and keep raw ids only in debug logs/raw stream.
- Runtime event metadata contract (backward-compatible):
- ACP runtime events may include optional metadata fields used by projection/dedupe/edit:
- tool lifecycle: `toolCallId`, `status`, `title`, `tag`
- usage/status: `tag`, `used`, `size`
- These fields are optional. If absent, projection must fall back to current text-based behavior.
- Do not break existing ACPX output shape; only enrich when available.
- If needed, evolve runtime event shape with optional metadata fields while keeping existing consumers working.
## Projection Rules
### 5. Keep channel-agnostic behavior
1. Apply `tagVisibility` first.
2. For visible tags:
- `repeatSuppression=true`:
- suppress identical repeated status lines.
- suppress identical repeated usage tuples.
- suppress duplicate tool lifecycle snapshots for the same `toolCallId`.
- `repeatSuppression=false`:
- forward repeated status/tool updates as they arrive.
3. Keep existing text streaming path and existing guardrails (`maxTurnChars`, meta caps).
4. Keep canonical formatting:
- system lines via `prefixSystemMessage(...)`
- tool lines via shared tool formatter path.
- File: `src/auto-reply/reply/dispatch-acp.ts`
- Ensure dedupe/filter is done before any channel-specific delivery path.
- Do not add Discord-only conditions in ACP projection logic.
## Tests
### 5.1. Canonical message formatting (no ACP-local style drift)
Projector tests must cover:
- File: `src/auto-reply/reply/acp-projector.ts`
- File: `src/infra/system-message.ts`
- File: shared tool summary formatter (`src/agents/tool-display.ts`)
- Rules:
- ACP system/meta notices (`usage updated`, `available commands updated`, truncation notices, lifecycle notices) must be rendered with `prefixSystemMessage(...)`.
- ACP tool lifecycle/progress lines must use shared tool summary formatting from `src/agents/tool-display.ts` (`resolveToolDisplay` + `formatToolSummary`), not hardcoded ACP-local emoji prefixes.
- ACP emits normalized events + metadata and delegates final user-facing string shaping to shared formatters.
- Outcome:
- Consistent style across main/subagent/ACP.
- Lower drift risk when global system/tool styling changes.
### 6. Wire fast-abort triggers to ACP cancel
- File: `src/auto-reply/reply/abort.ts`
- File: `src/auto-reply/reply/dispatch-from-config.ts`
- Behavior:
- ACP path must use the same abort trigger detector/vocabulary as main path (no ACP-specific exceptions).
- When fast-abort resolves a concrete target session and that session is ACP-enabled,
call ACP manager cancel (`cancelSession`) with reason `fast-abort`.
- Keep existing queue/lane cleanup as fallback so abort remains robust even if ACP cancel fails.
- Preserve channel-agnostic handling (no Discord-specific conditionals).
- Confirmed policy:
- `wait` follows the same cancel behavior as main path (same as `stop` class in trigger handling semantics).
### 7. Edit-in-place tool lifecycle updates (when channel supports edit)
- File: `src/auto-reply/reply/acp-projector.ts`
- File: `src/auto-reply/reply/dispatch-acp.ts`
- File: `src/auto-reply/reply/reply-dispatcher.ts`
- File: outbound message-action path (`src/infra/outbound/message-action-runner.ts` usage)
- Behavior:
- On first `tool_call`, send one tool lifecycle message and store returned message handle keyed by `toolCallId`.
- On later `tool_call_update`, attempt in-place edit of the same message when channel action `edit` is supported.
- If edit is unsupported or fails, gracefully fall back to sending a new tool message.
- Keep this channel-agnostic: capability detection via channel action support, not Discord-specific checks.
- Required plumbing:
- Extend ACP tool-delivery path to keep outbound send receipts for tool messages using a stable handle shape:
- `{ channel, accountId, to, threadId, messageId }`
- Persist handle keyed by `sessionKey + toolCallId` for update/edit lookup.
- Allow dispatcher delivery callback to surface delivery metadata needed for follow-up edit actions.
- If `messageId` is unavailable, skip edit attempt and fall back to normal new-message send.
- Preserve existing ordering semantics with block/final messages.
### 8. Typing indicator parity for ACP-bound sessions
- File: `src/auto-reply/reply/dispatch-from-config.ts`
- File: `src/auto-reply/reply/dispatch-acp.ts`
- File: `src/auto-reply/reply/typing-mode.ts` (reuse existing signaler; no ACP-specific duplicate loop)
- Behavior:
- ACP turns should trigger the same typing lifecycle as non-ACP runs:
- start typing on first visible work (text delta or tool-start based on policy),
- keepalive/refresh while turn is active,
- stop typing when ACP turn reaches terminal state (`done`/`error`) and dispatch queue is idle.
- Respect existing typing mode/policy resolution from current channel/account/session settings.
- Keep this channel-agnostic and routed through existing typing callbacks.
- Design constraint:
- Do not add a second ACP-only typing controller. Reuse `createTypingSignaler` patterns and existing dispatcher idle hooks.
### 9. Test coverage
- File: `src/auto-reply/reply/acp-projector.test.ts`
- Add tests:
- Default tag visibility suppresses `usage_update` and keeps tool lifecycle summaries.
- Explicitly enabling `usage_update` allows deduped usage lines through.
- Disabling `tool_call` and/or `tool_call_update` suppresses those summaries without affecting text output.
- `available_commands_update` remains hidden by default.
- `deliveryMode=final_only` holds text deltas until terminal event.
- `deliveryMode=final_only` emits one final block payload using existing pipeline flush.
- `deliveryMode=live` preserves current incremental streaming behavior.
- Suppresses duplicate `usage updated` when values unchanged.
- Emits usage when values change and `showUsage=true`.
- Suppresses all usage when `showUsage=false`.
- Suppresses duplicate `tool_call`/`tool_call_update` snapshots for same call id.
- Emits start and terminal tool lifecycle exactly once in `minimal`.
- `metaMode=off` emits no tool/status summaries.
- `metaMode=verbose` allows non-identical progress lines.
- Truncates oversized status/tool summaries to configured limits.
- Stops forwarding text deltas after `maxTurnChars` and emits a single truncation notice.
- Enforces `maxMetaEventsPerTurn` without breaking final reply delivery.
- Tool fallback label does not default to long raw `call_...` ids in normal projection.
- System/meta lines are produced via `prefixSystemMessage(...)` (idempotent, no double prefix).
- Tool lifecycle lines use shared tool-summary formatting path (no ACP-local string rendering).
- File: `src/auto-reply/reply/abort.test.ts`
- Add tests:
- plain-language `stop` on ACP-bound session triggers ACP `cancelSession`.
- non-ACP sessions keep existing fast-abort behavior.
- ACP cancel failure does not skip queue/lane cleanup.
- File: ACP dispatch typing tests
- Add tests:
- ACP `text_delta` starts typing according to configured typing mode.
- ACP tool-start events refresh typing when policy allows.
- ACP terminal events stop typing/mark idle reliably.
- No typing regressions for non-ACP paths.
- File: ACP dispatch/integration tests
- Add tests:
- `tool_call_update` edits prior tool message when edit capability exists.
- fallback to new message send when edit capability is unavailable.
- fallback to new message send when edit call fails.
- tool lifecycle still dedupes correctly with edit mode enabled.
- Optional integration check:
- File: `src/auto-reply/reply/dispatch-from-config.test.ts`
- Simulate ACP noisy stream and assert bounded outbound message count.
### 10. Manual verification
- Start ACP thread-bound Codex session.
- Send prompt that triggers long-running tool updates.
- Confirm:
- No repeated identical `⚙️ usage updated ...` lines.
- No repeated identical `🧰 call_...` lines.
- Tool updates prefer editing the initial tool message instead of posting a new message each time (for channels with edit support).
- Channels without edit support still work via normal new-message fallback.
- Final assistant text still delivered correctly.
- Typing indicator appears during active ACP turn and clears after completion.
- During an active ACP turn, send plain-language `stop` in the thread and confirm
the in-flight ACP turn is cancelled (not only locally aborted).
- Check raw `.stream.ndjson` still contains full event stream.
- default usage hidden by `tagVisibility`.
- enabling `usage_update` via `tagVisibility` works.
- repeated usage/status/tool updates are suppressed when `repeatSuppression=true`.
- repeated usage/status/tool updates are allowed when `repeatSuppression=false`.
- `available_commands_update` hidden by default.
- text streaming and truncation behavior unchanged.
## Acceptance Criteria
- User-visible ACP thread output no longer shows repeated identical usage/tool meta spam.
- Raw ACPX event log remains unchanged and lossless.
- Dedupe logic is channel-agnostic and centralized in ACP projection.
- `final_only` delivery mode exists without introducing a second ACP sending path.
- Tool lifecycle updates use edit-in-place when supported, with safe fallback to new-message sends.
- ACP-bound sessions use the same typing lifecycle behavior as non-ACP runs.
- New config keys are documented and validated.
- Unit tests for projector dedupe pass.
- ACP no longer hardcodes system/tool message formatting independently from shared formatters.
## Rollout Notes
- Safe default should reduce noise immediately (`showUsage=false`, `metaMode=minimal`).
- Operators can switch to `metaMode=verbose` for debugging without code changes.
- No `showUsage` or `metaMode` in ACP stream config/types/schema/help/labels.
- `repeatSuppression` is the only repeat/dedupe toggle.
- `tagVisibility` defaults are minimal-noise.
- ACP projector behavior matches tests.
- Raw ACP logs remain unchanged/lossless.