From fa445003b5eb908908feff6fa3ba0a8208740927 Mon Sep 17 00:00:00 2001 From: Alex Knight Date: Wed, 6 May 2026 20:00:34 +1000 Subject: [PATCH] fix(acp): preserve streamed progress chunks (#78383) Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com> --- CHANGELOG.md | 1 + src/acp/control-plane/manager.core.ts | 8 +- src/acp/control-plane/manager.test.ts | 111 +++++++++++++++++++++++++- 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42c3d056110..6854a58d976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/src/acp/control-plane/manager.core.ts b/src/acp/control-plane/manager.core.ts index 993f5a4d9b8..3ef47f06b34 100644 --- a/src/acp/control-plane/manager.core.ts +++ b/src/acp/control-plane/manager.core.ts @@ -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; } diff --git a/src/acp/control-plane/manager.test.ts b/src/acp/control-plane/manager.test.ts index 05491d6a27e..ab8c14ecca0 100644 --- a/src/acp/control-plane/manager.test.ts +++ b/src/acp/control-plane/manager.test.ts @@ -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({