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(),