mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
ACP: fix projector dedupe regressions
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user