mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 00:01:36 +00:00
ACP: make live mode flush incremental chunks
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { prefixSystemMessage } from "../../infra/system-message.js";
|
import { prefixSystemMessage } from "../../infra/system-message.js";
|
||||||
import { createAcpReplyProjector } from "./acp-projector.js";
|
import { createAcpReplyProjector } from "./acp-projector.js";
|
||||||
import { createAcpTestConfig as createCfg } from "./test-fixtures/acp-runtime.js";
|
import { createAcpTestConfig as createCfg } from "./test-fixtures/acp-runtime.js";
|
||||||
@@ -28,6 +28,81 @@ describe("createAcpReplyProjector", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("flushes staggered live text deltas after idle gaps", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||||
|
const projector = createAcpReplyProjector({
|
||||||
|
cfg: createCfg({
|
||||||
|
acp: {
|
||||||
|
enabled: true,
|
||||||
|
stream: {
|
||||||
|
deliveryMode: "live",
|
||||||
|
coalesceIdleMs: 50,
|
||||||
|
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 vi.advanceTimersByTimeAsync(60);
|
||||||
|
await projector.flush(false);
|
||||||
|
|
||||||
|
await projector.onEvent({ type: "text_delta", text: "B", tag: "agent_message_chunk" });
|
||||||
|
await vi.advanceTimersByTimeAsync(60);
|
||||||
|
await projector.flush(false);
|
||||||
|
|
||||||
|
await projector.onEvent({ type: "text_delta", text: "C", tag: "agent_message_chunk" });
|
||||||
|
await vi.advanceTimersByTimeAsync(60);
|
||||||
|
await projector.flush(false);
|
||||||
|
|
||||||
|
expect(deliveries.filter((entry) => entry.kind === "block")).toEqual([
|
||||||
|
{ kind: "block", text: "A" },
|
||||||
|
{ kind: "block", text: "B" },
|
||||||
|
{ kind: "block", text: "C" },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits oversized live text by maxChunkChars", async () => {
|
||||||
|
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||||
|
const projector = createAcpReplyProjector({
|
||||||
|
cfg: createCfg({
|
||||||
|
acp: {
|
||||||
|
enabled: true,
|
||||||
|
stream: {
|
||||||
|
deliveryMode: "live",
|
||||||
|
coalesceIdleMs: 0,
|
||||||
|
maxChunkChars: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
shouldSendToolSummaries: true,
|
||||||
|
deliver: async (kind, payload) => {
|
||||||
|
deliveries.push({ kind, text: payload.text });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = `${"a".repeat(50)}${"b".repeat(50)}${"c".repeat(20)}`;
|
||||||
|
await projector.onEvent({ type: "text_delta", text, tag: "agent_message_chunk" });
|
||||||
|
await projector.flush(true);
|
||||||
|
|
||||||
|
expect(deliveries.filter((entry) => entry.kind === "block")).toEqual([
|
||||||
|
{ kind: "block", text: "a".repeat(50) },
|
||||||
|
{ kind: "block", text: "b".repeat(50) },
|
||||||
|
{ kind: "block", text: "c".repeat(20) },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("supports deliveryMode=final_only by buffering all projected output until done", async () => {
|
it("supports deliveryMode=final_only by buffering all projected output until done", async () => {
|
||||||
const deliveries: Array<{ kind: string; text?: string }> = [];
|
const deliveries: Array<{ kind: string; text?: string }> = [];
|
||||||
const projector = createAcpReplyProjector({
|
const projector = createAcpReplyProjector({
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ export function createAcpReplyProjector(params: {
|
|||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
provider: params.provider,
|
provider: params.provider,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
|
deliveryMode: settings.deliveryMode,
|
||||||
});
|
});
|
||||||
const blockReplyPipeline = createBlockReplyPipeline({
|
const blockReplyPipeline = createBlockReplyPipeline({
|
||||||
onBlockReply: async (payload) => {
|
onBlockReply: async (payload) => {
|
||||||
@@ -179,8 +180,9 @@ export function createAcpReplyProjector(params: {
|
|||||||
if (settings.deliveryMode === "final_only" && !force) {
|
if (settings.deliveryMode === "final_only" && !force) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const effectiveForce = settings.deliveryMode === "live" ? true : force;
|
||||||
chunker.drain({
|
chunker.drain({
|
||||||
force,
|
force: effectiveForce,
|
||||||
emit: (chunk) => {
|
emit: (chunk) => {
|
||||||
blockReplyPipeline.enqueue({ text: chunk });
|
blockReplyPipeline.enqueue({ text: chunk });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -89,4 +89,27 @@ describe("acp stream settings", () => {
|
|||||||
expect(streaming.chunking.maxChars).toBe(64);
|
expect(streaming.chunking.maxChars).toBe(64);
|
||||||
expect(streaming.coalescing.idleMs).toBe(0);
|
expect(streaming.coalescing.idleMs).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies live-mode streaming overrides for incremental delivery", () => {
|
||||||
|
const streaming = resolveAcpStreamingConfig({
|
||||||
|
cfg: createAcpTestConfig({
|
||||||
|
acp: {
|
||||||
|
enabled: true,
|
||||||
|
stream: {
|
||||||
|
deliveryMode: "live",
|
||||||
|
coalesceIdleMs: 350,
|
||||||
|
maxChunkChars: 256,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
provider: "discord",
|
||||||
|
deliveryMode: "live",
|
||||||
|
});
|
||||||
|
expect(streaming.chunking.minChars).toBe(1);
|
||||||
|
expect(streaming.chunking.maxChars).toBe(256);
|
||||||
|
expect(streaming.coalescing.minChars).toBe(1);
|
||||||
|
expect(streaming.coalescing.maxChars).toBe(256);
|
||||||
|
expect(streaming.coalescing.joiner).toBe("");
|
||||||
|
expect(streaming.coalescing.idleMs).toBe(350);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -131,14 +131,34 @@ export function resolveAcpStreamingConfig(params: {
|
|||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
deliveryMode?: AcpDeliveryMode;
|
||||||
}) {
|
}) {
|
||||||
return resolveEffectiveBlockStreamingConfig({
|
const resolved = resolveEffectiveBlockStreamingConfig({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
provider: params.provider,
|
provider: params.provider,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
maxChunkChars: resolveAcpStreamMaxChunkChars(params.cfg),
|
maxChunkChars: resolveAcpStreamMaxChunkChars(params.cfg),
|
||||||
coalesceIdleMs: resolveAcpStreamCoalesceIdleMs(params.cfg),
|
coalesceIdleMs: resolveAcpStreamCoalesceIdleMs(params.cfg),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// In live mode, ACP text deltas should flush promptly and never be held
|
||||||
|
// behind large generic min-char thresholds.
|
||||||
|
if (params.deliveryMode === "live") {
|
||||||
|
return {
|
||||||
|
chunking: {
|
||||||
|
...resolved.chunking,
|
||||||
|
minChars: 1,
|
||||||
|
},
|
||||||
|
coalescing: {
|
||||||
|
...resolved.coalescing,
|
||||||
|
minChars: 1,
|
||||||
|
// ACP delta streams already carry spacing/newlines; preserve exact text.
|
||||||
|
joiner: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAcpTagVisible(
|
export function isAcpTagVisible(
|
||||||
|
|||||||
Reference in New Issue
Block a user