From d0c83912614c046f5e837cefdac717de902e3cf1 Mon Sep 17 00:00:00 2001 From: Hao Date: Sun, 12 Jul 2026 18:40:18 +0800 Subject: [PATCH] fix(opencode): preserve ACP text deltas (#1023) --- .../backends/acp/AcpMessageHandler.test.ts | 19 +++++++++++++++++++ .../agent/backends/acp/AcpMessageHandler.ts | 17 ++++++++++++++++- cli/src/agent/backends/acp/AcpSdkBackend.ts | 11 ++++++++--- cli/src/opencode/utils/opencodeBackend.ts | 3 ++- 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/cli/src/agent/backends/acp/AcpMessageHandler.test.ts b/cli/src/agent/backends/acp/AcpMessageHandler.test.ts index ed699f59..ec882ce7 100644 --- a/cli/src/agent/backends/acp/AcpMessageHandler.test.ts +++ b/cli/src/agent/backends/acp/AcpMessageHandler.test.ts @@ -377,6 +377,25 @@ describe('AcpMessageHandler', () => { expect(messages).toEqual([{ type: 'text', text: 'hello world' }]); }); + it('preserves overlapping text chunks in delta mode', () => { + const messages: AgentMessage[] = []; + const handler = new AcpMessageHandler( + (message) => messages.push(message), + { textChunkMode: 'delta' } + ); + + for (const text of ['|-----|', '-----|', '-----|\n']) { + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk, + content: { type: 'text', text } + }); + } + + handler.flushText(); + + expect(messages).toEqual([{ type: 'text', text: '|-----|-----|-----|\n' }]); + }); + it('keeps existing tool name when update only has kind fallback', () => { const messages: AgentMessage[] = []; const handler = new AcpMessageHandler((message) => messages.push(message)); diff --git a/cli/src/agent/backends/acp/AcpMessageHandler.ts b/cli/src/agent/backends/acp/AcpMessageHandler.ts index 68a9ac00..f994f229 100644 --- a/cli/src/agent/backends/acp/AcpMessageHandler.ts +++ b/cli/src/agent/backends/acp/AcpMessageHandler.ts @@ -358,6 +358,8 @@ function normalizePlanEntries(entries: unknown): PlanItem[] { return items; } +export type AcpTextChunkMode = 'dedupe' | 'delta'; + function getSuffixPrefixOverlap(base: string, next: string): number { const maxOverlap = Math.min(base.length, next.length); for (let length = maxOverlap; length > 0; length -= 1) { @@ -380,8 +382,14 @@ export class AcpMessageHandler { private lastReasoningSnapshotAt: number | null = null; private lastReasoningSnapshotText = ''; private reasoningSnapshotEmitted = false; + private readonly textChunkMode: AcpTextChunkMode; - constructor(private readonly onMessage: (message: AgentMessage) => void) {} + constructor( + private readonly onMessage: (message: AgentMessage) => void, + options: { textChunkMode?: AcpTextChunkMode } = {} + ) { + this.textChunkMode = options.textChunkMode ?? 'dedupe'; + } /** * Emits any buffered assistant text as a single message and clears the @@ -444,6 +452,13 @@ export class AcpMessageHandler { } private appendTextChunk(text: string): void { + if (this.textChunkMode === 'delta') { + if (text) { + this.bufferedText += text; + } + return; + } + if (!text) { return; } diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index b82421a9..ddbc474d 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -2,7 +2,7 @@ import type { AgentFlavor } from '@hapi/protocol'; import type { AgentBackend, AgentMessage, AgentSessionConfig, PermissionRequest, PermissionResponse, PromptContent } from '@/agent/types'; import { asString, isObject } from '@hapi/protocol'; import { AcpStdioTransport, type AcpStderrError } from './AcpStdioTransport'; -import { AcpMessageHandler } from './AcpMessageHandler'; +import { AcpMessageHandler, type AcpTextChunkMode } from './AcpMessageHandler'; import { ACP_SESSION_UPDATE_TYPES } from './constants'; import { logger } from '@/ui/logger'; import { withRetry } from '@/utils/time'; @@ -101,7 +101,12 @@ export class AcpSdkBackend implements AgentBackend { private static readonly LATE_FLUSH_QUIET_PERIOD_MS = 250; private static readonly LATE_FLUSH_WINDOW_MS = 6000; - constructor(private readonly options: { command: string; args?: string[]; env?: Record }) {} + constructor(private readonly options: { + command: string; + args?: string[]; + env?: Record; + textChunkMode?: AcpTextChunkMode; + }) {} async initialize(): Promise { if (this.transport) return; @@ -401,7 +406,7 @@ export class AcpSdkBackend implements AgentBackend { AcpSdkBackend.PRE_PROMPT_UPDATE_DRAIN_TIMEOUT_MS ); this.messageHandler?.drainBuffers(); - this.messageHandler = new AcpMessageHandler(onUpdate); + this.messageHandler = new AcpMessageHandler(onUpdate, { textChunkMode: this.options.textChunkMode }); this.isProcessingMessage = true; this.lastSessionUpdateAt = Date.now(); this.latestUsageUpdate = null; diff --git a/cli/src/opencode/utils/opencodeBackend.ts b/cli/src/opencode/utils/opencodeBackend.ts index b49a3455..7776cd6c 100644 --- a/cli/src/opencode/utils/opencodeBackend.ts +++ b/cli/src/opencode/utils/opencodeBackend.ts @@ -21,6 +21,7 @@ export function createOpencodeBackend(opts: { return new AcpSdkBackend({ command: 'opencode', args, - env: filterEnv(env) + env: filterEnv(env), + textChunkMode: 'delta' }); }