From 4e2efaf659c4c81e658f5a180a4a2ee7fc6315e3 Mon Sep 17 00:00:00 2001 From: Onur <2453968+osolmaz@users.noreply.github.com> Date: Sun, 1 Mar 2026 13:08:55 +0100 Subject: [PATCH] ACP: simplify stream config to repeatSuppression --- src/auto-reply/reply/acp-projector.test.ts | 42 ++- src/auto-reply/reply/acp-projector.ts | 18 +- .../reply/acp-stream-settings.test.ts | 9 +- src/auto-reply/reply/acp-stream-settings.ts | 17 +- src/config/schema.help.ts | 6 +- src/config/schema.labels.ts | 3 +- src/config/types.acp.ts | 6 +- src/config/zod-schema.ts | 5 +- ...nt-dedup-implementation-plan-2026-03-01.md | 309 ++++-------------- 9 files changed, 108 insertions(+), 307 deletions(-) diff --git a/src/auto-reply/reply/acp-projector.test.ts b/src/auto-reply/reply/acp-projector.test.ts index b97408c91..f1dba3950 100644 --- a/src/auto-reply/reply/acp-projector.test.ts +++ b/src/auto-reply/reply/acp-projector.test.ts @@ -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, }, diff --git a/src/auto-reply/reply/acp-projector.ts b/src/auto-reply/reply/acp-projector.ts index 8d0083cef..14ed1571e 100644 --- a/src/auto-reply/reply/acp-projector.ts +++ b/src/auto-reply/reply/acp-projector.ts @@ -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, 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}` diff --git a/src/auto-reply/reply/acp-stream-settings.test.ts b/src/auto-reply/reply/acp-stream-settings.test.ts index 950b07b27..fb1e5ee87 100644 --- a/src/auto-reply/reply/acp-stream-settings.test.ts +++ b/src/auto-reply/reply/acp-stream-settings.test.ts @@ -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); diff --git a/src/auto-reply/reply/acp-stream-settings.ts b/src/auto-reply/reply/acp-stream-settings.ts index 6655ba9f3..5662bd5d8 100644 --- a/src/auto-reply/reply/acp-stream-settings.ts +++ b/src/auto-reply/reply/acp-stream-settings.ts @@ -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 = }; 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, diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index eb6b61161..02f993ae2 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -172,10 +172,8 @@ export const FIELD_HELP: Record = { "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": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index bb29fe76a..f13359d1e 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -369,8 +369,7 @@ export const FIELD_LABELS: Record = { "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", diff --git a/src/config/types.acp.ts b/src/config/types.acp.ts index f50cfaf71..fd4efa74c 100644 --- a/src/config/types.acp.ts +++ b/src/config/types.acp.ts @@ -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. */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 9681b3df2..181bcc2ba 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -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(), diff --git a/temp/acp-meta-event-dedup-implementation-plan-2026-03-01.md b/temp/acp-meta-event-dedup-implementation-plan-2026-03-01.md index d1f3b18f8..e249a92cf 100644 --- a/temp/acp-meta-event-dedup-implementation-plan-2026-03-01.md +++ b/temp/acp-meta-event-dedup-implementation-plan-2026-03-01.md @@ -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>` -- 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>` +- 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.