fix(acp): preserve streamed progress chunks (#78383)

Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com>
This commit is contained in:
Alex Knight
2026-05-06 20:00:34 +10:00
committed by GitHub
parent 7a73b37f87
commit fa445003b5
3 changed files with 117 additions and 3 deletions

View File

@@ -132,6 +132,7 @@ Docs: https://docs.openclaw.ai
- Discord/guilds: route plain text control commands such as `/steer` through the normal authorization and mention gate instead of silently dropping them before an agent session can see them. Fixes #78080. Thanks @ramitrkar-hash.
- Control UI/Sessions: make the compaction count a compact `N Checkpoint(s)` disclosure and show expanded session-level details with modern checkpoint history cards across responsive table layouts. Thanks @BunsDev.
- Control UI/performance: keep chat and channel tabs responsive while history payloads and channel probes are slow, label partial channel status, and record slow chat/config render timings in the event log. Thanks @BunsDev.
- ACP: preserve streamed chunk boundaries in background-task progress summaries so CJK text, paths, URLs, and identifiers are no longer split with synthetic spaces. Fixes #78312. Thanks @amknight.
- Control UI/sessions: fire the documented `/new` command and lifecycle hooks only for explicit Control UI session creation, restoring session-memory and custom hook capture without changing SDK parent-session creates. Fixes #76957. Thanks @BunsDev.
- Exec approvals: fall back to a guarded copy when Windows rejects rename-overwrite for `exec-approvals.json`, while preserving symlink, hard-link, and owner-only permission safeguards. Fixes #77785. (#77907) Thanks @Alex-Alaniz and @MilleniumGenAI.
- Slack: preserve Socket Mode SDK error context and structured Slack API fields in reconnect logs, so startup failures no longer collapse to a bare `unknown error`.

View File

@@ -98,11 +98,15 @@ function summarizeBackgroundTaskText(text: string): string {
}
function appendBackgroundTaskProgressSummary(current: string, chunk: string): string {
const normalizedChunk = normalizeText(chunk)?.replace(/\s+/g, " ");
const normalizedChunk = chunk.replace(/\s+/g, " ");
if (!normalizedChunk) {
return current;
}
const combined = current ? `${current} ${normalizedChunk}` : normalizedChunk;
const chunkToAppend = current ? normalizedChunk : normalizedChunk.trimStart();
if (!chunkToAppend) {
return current;
}
const combined = `${current}${chunkToAppend}`.replace(/\s+/g, " ");
if (combined.length <= ACP_BACKGROUND_TASK_PROGRESS_MAX_LENGTH) {
return combined;
}

View File

@@ -313,7 +313,32 @@ describe("AcpSessionManager", () => {
yield {
type: "text_delta" as const,
stream: "output" as const,
text: "Write failed: permission denied for /root/oc-acp-write-should-fail.txt.",
text: "Write failed: ",
};
yield {
type: "text_delta" as const,
stream: "output" as const,
text: "permission ",
};
yield {
type: "text_delta" as const,
stream: "output" as const,
text: "denied for ",
};
yield {
type: "text_delta" as const,
stream: "output" as const,
text: "/root/",
};
yield {
type: "text_delta" as const,
stream: "output" as const,
text: "oc-acp-write-",
};
yield {
type: "text_delta" as const,
stream: "output" as const,
text: "should-fail.txt.",
};
yield { type: "done" as const };
});
@@ -374,6 +399,90 @@ describe("AcpSessionManager", () => {
});
}, 300_000);
it("preserves token-streamed ACP progress boundaries in parented task summaries", async () => {
await withAcpManagerTaskStateDir(async () => {
const runtimeState = createRuntime();
const chunks = [
"현재 ",
"작업 ",
"디",
"렉토",
"리는 ",
"/home/",
"by",
"kim",
"0119/",
".open",
"claw/",
"workspace",
"\n\t",
"입니다",
];
runtimeState.runTurn.mockImplementation(async function* () {
for (const text of chunks) {
yield {
type: "text_delta" as const,
stream: "output" as const,
text,
};
}
yield { type: "done" as const };
});
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
id: "acpx",
runtime: runtimeState.runtime,
});
hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => {
const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey;
if (sessionKey === "agent:codex:acp:child-1") {
return {
sessionKey,
storeSessionKey: sessionKey,
entry: {
sessionId: "child-1",
updatedAt: Date.now(),
spawnedBy: "agent:quant:telegram:quant:direct:822430204",
label: "Korean path",
},
acp: readySessionMeta(),
};
}
if (sessionKey === "agent:quant:telegram:quant:direct:822430204") {
return {
sessionKey,
storeSessionKey: sessionKey,
entry: {
sessionId: "parent-1",
updatedAt: Date.now(),
},
};
}
return null;
});
const manager = new AcpSessionManager();
await manager.runTurn({
cfg: baseCfg,
sessionKey: "agent:codex:acp:child-1",
text: "Print the current directory in Korean",
mode: "prompt",
requestId: "direct-parented-korean-path-run",
});
await flushMicrotasks();
expect(findTaskByRunId("direct-parented-korean-path-run")).toMatchObject({
runtime: "acp",
ownerKey: "agent:quant:telegram:quant:direct:822430204",
scopeKind: "session",
childSessionKey: "agent:codex:acp:child-1",
label: "Korean path",
task: "Print the current directory in Korean",
status: "succeeded",
progressSummary: "현재 작업 디렉토리는 /home/bykim0119/.openclaw/workspace 입니다",
});
});
}, 300_000);
it("serializes concurrent turns for the same ACP session", async () => {
const runtimeState = createRuntime();
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({