fix(opencode): preserve ACP text deltas (#1023)

This commit is contained in:
Hao
2026-07-12 18:40:18 +08:00
committed by GitHub
parent d9ea9db507
commit d0c8391261
4 changed files with 45 additions and 5 deletions
@@ -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));
@@ -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;
}
+8 -3
View File
@@ -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<string, string> }) {}
constructor(private readonly options: {
command: string;
args?: string[];
env?: Record<string, string>;
textChunkMode?: AcpTextChunkMode;
}) {}
async initialize(): Promise<void> {
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;
+2 -1
View File
@@ -21,6 +21,7 @@ export function createOpencodeBackend(opts: {
return new AcpSdkBackend({
command: 'opencode',
args,
env: filterEnv(env)
env: filterEnv(env),
textChunkMode: 'delta'
});
}