ACP: fix projector dedupe regressions

This commit is contained in:
Onur
2026-03-01 17:16:28 +01:00
committed by Onur Solmaz
parent be73eb28b3
commit f4538b22f7
2 changed files with 144 additions and 10 deletions

View File

@@ -28,6 +28,37 @@ describe("createAcpReplyProjector", () => {
]);
});
it("does not suppress identical short text across terminal turn boundaries", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
deliveryMode: "live",
coalesceIdleMs: 0,
maxChunkChars: 64,
},
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
await projector.onEvent({ type: "text_delta", text: "A", tag: "agent_message_chunk" });
await projector.onEvent({ type: "done", stopReason: "end_turn" });
await projector.onEvent({ type: "text_delta", text: "A", tag: "agent_message_chunk" });
await projector.onEvent({ type: "done", stopReason: "end_turn" });
expect(deliveries.filter((entry) => entry.kind === "block")).toEqual([
{ kind: "block", text: "A" },
{ kind: "block", text: "A" },
]);
});
it("flushes staggered live text deltas after idle gaps", async () => {
vi.useFakeTimers();
try {
@@ -411,6 +442,53 @@ describe("createAcpReplyProjector", () => {
expect(deliveries[1]?.text).toContain("Tool Call");
});
it("keeps terminal tool updates even when rendered summaries are truncated", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
deliveryMode: "live",
maxToolSummaryChars: 48,
tagVisibility: {
tool_call: true,
tool_call_update: true,
},
},
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
const longTitle =
"Run an intentionally long command title that truncates before lifecycle status is visible";
await projector.onEvent({
type: "tool_call",
tag: "tool_call",
toolCallId: "call_truncated_status",
status: "in_progress",
title: longTitle,
text: `${longTitle} (in_progress)`,
});
await projector.onEvent({
type: "tool_call",
tag: "tool_call_update",
toolCallId: "call_truncated_status",
status: "completed",
title: longTitle,
text: `${longTitle} (completed)`,
});
expect(deliveries.length).toBe(2);
expect(deliveries[0]?.kind).toBe("tool");
expect(deliveries[1]?.kind).toBe("tool");
});
it("renders fallback tool labels without leaking call ids as primary label", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
@@ -737,6 +815,57 @@ describe("createAcpReplyProjector", () => {
expect(combinedText).toBe("fallback. I don't");
});
it("preserves hidden boundary across nonterminal hidden tool updates", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
deliveryMode: "live",
tagVisibility: {
tool_call: false,
tool_call_update: false,
},
},
},
}),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
await projector.onEvent({ type: "text_delta", text: "fallback.", tag: "agent_message_chunk" });
await projector.onEvent({
type: "tool_call",
tag: "tool_call",
toolCallId: "hidden_boundary_1",
status: "in_progress",
title: "Run test",
text: "Run test (in_progress)",
});
await projector.onEvent({
type: "tool_call",
tag: "tool_call_update",
toolCallId: "hidden_boundary_1",
status: "in_progress",
title: "Run test",
text: "Run test (in_progress)",
});
await projector.onEvent({ type: "text_delta", text: "I don't", tag: "agent_message_chunk" });
await projector.flush(true);
const combinedText = deliveries
.filter((entry) => entry.kind === "block")
.map((entry) => entry.text ?? "")
.join("");
expect(combinedText).toBe("fallback. I don't");
});
it("supports hiddenBoundarySeparator=space", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({

View File

@@ -182,13 +182,15 @@ export function createAcpReplyProjector(params: {
accountId: params.accountId,
deliveryMode: settings.deliveryMode,
});
const blockReplyPipeline = createBlockReplyPipeline({
onBlockReply: async (payload) => {
await params.deliver("block", payload);
},
timeoutMs: ACP_BLOCK_REPLY_TIMEOUT_MS,
coalescing: settings.deliveryMode === "live" ? undefined : streaming.coalescing,
});
const createTurnBlockReplyPipeline = () =>
createBlockReplyPipeline({
onBlockReply: async (payload) => {
await params.deliver("block", payload);
},
timeoutMs: ACP_BLOCK_REPLY_TIMEOUT_MS,
coalescing: settings.deliveryMode === "live" ? undefined : streaming.coalescing,
});
let blockReplyPipeline = createTurnBlockReplyPipeline();
const chunker = new EmbeddedBlockChunker(streaming.chunking);
const liveIdleFlushMs = Math.max(streaming.coalescing.idleMs, ACP_LIVE_IDLE_FLUSH_FLOOR_MS);
@@ -259,6 +261,8 @@ export function createAcpReplyProjector(params: {
const resetTurnState = () => {
clearLiveIdleTimer();
blockReplyPipeline.stop();
blockReplyPipeline = createTurnBlockReplyPipeline();
emittedTurnChars = 0;
emittedMetaEvents = 0;
truncationNoticeEmitted = false;
@@ -346,8 +350,9 @@ export function createAcpReplyProjector(params: {
return;
}
const toolSummary = truncateText(renderToolSummaryText(event), settings.maxToolSummaryChars);
const hash = hashText(toolSummary);
const renderedToolSummary = renderToolSummaryText(event);
const toolSummary = truncateText(renderedToolSummary, settings.maxToolSummaryChars);
const hash = hashText(renderedToolSummary);
const toolCallId = event.toolCallId?.trim() || undefined;
const status = normalizeToolStatus(event.status);
const isTerminal = status ? TERMINAL_TOOL_STATUSES.has(status) : false;
@@ -495,7 +500,7 @@ export function createAcpReplyProjector(params: {
if (event.tag && HIDDEN_BOUNDARY_TAGS.has(event.tag)) {
const status = normalizeToolStatus(event.status);
const isTerminal = status ? TERMINAL_TOOL_STATUSES.has(status) : false;
pendingHiddenBoundary = event.tag === "tool_call" || isTerminal;
pendingHiddenBoundary = pendingHiddenBoundary || event.tag === "tool_call" || isTerminal;
}
return;
}