ACP: simplify stream config to repeatSuppression
This commit is contained in:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user