fix(cli): flush buffered ACP assistant text after an idle gap (#1898)

* fix(cli): flush buffered ACP assistant text after an idle gap

ACP gives no "text segment finished" signal for a turn's last text segment and AcpMessageHandler only emits buffered text at explicit boundaries or through the prompt-lifecycle drains. When neither arrives - a text-only turn whose prompt() never returns - the answer is held until the next user prompt.

Emit buffered text once the stream has been silent for TEXT_IDLE_FLUSH_INTERVAL_MS (1000ms). The idle flush defers while a reasoning block is open (keeps Reasoning above the answer) and while the buffer may be an unfinished control envelope (keeps the internal-event filter complete). A later boundary emits only the remainder, so no character is emitted twice.

* fix(cli): stream idle ACP text as snapshots under one stream id

An id-less idle emission could render a reordered or duplicated answer when a dedupe agent rewrites its buffer with a prefix, and a pause inside an open Markdown fence split the rendered answer into separate blocks.

Stream idle emissions as full-text snapshots under a stable stream id and finalize with the same id at the boundary, mirroring flushReasoning; the partial-watermark computation is removed. The control-envelope guard no longer assumes type is the first JSON key.

* fix(cli): skip interim idle text snapshots in local launcher displays
This commit is contained in:
Maiz
2026-10-28 12:08:26 +08:00
committed by GitHub
parent 7a29b60353
commit b49c5d1dcb
9 changed files with 612 additions and 15 deletions
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MessageQueue2 } from '@/utils/MessageQueue2';
import { MessageBuffer } from '@/ui/ink/messageBuffer';
import type { OpencodeMode, PermissionMode } from './types';
const harness = vi.hoisted(() => ({
@@ -23,6 +24,9 @@ const harness = vi.hoisted(() => ({
stderrHandler: null as null | ((error: { type: string; message: string; raw: string }) => void),
hangPrompt: false,
resolvePrompt: null as null | (() => void),
// The message callback backend.prompt() receives, so tests can emit
// AgentMessages (e.g. live vs settled text) without a real backend.
promptOnMessage: null as null | ((message: unknown) => void),
cancelPrompt: vi.fn(async (_sessionId: string) => {}),
// Lets a test take full manual control of when a given prompt() call
// resolves, instead of the fixed-one-tick setImmediate delay below —
@@ -117,7 +121,8 @@ vi.mock('./utils/opencodeBackend', () => ({
harness.thoughtLevelOption = { ...harness.thoughtLevelOption, currentValue: value };
}
}),
prompt: vi.fn(async (_sessionId: string, content: unknown[]) => {
prompt: vi.fn(async (_sessionId: string, content: unknown[], onMessage?: (message: unknown) => void) => {
harness.promptOnMessage = onMessage ?? null;
harness.promptContents.push(content);
harness.events.push('prompt:start');
harness.promptCount++;
@@ -502,6 +507,7 @@ describe('opencodeRemoteLauncher inline model switch', () => {
compactHarness.triggerImpl = null;
compactHarness.resultImpl = null;
harness.promptImpl = null;
harness.promptOnMessage = null;
harness.sessionModelsMetadata = undefined;
harness.cancelPromptImpl = null;
harness.newSessionImpl = null;
@@ -2433,6 +2439,36 @@ describe('opencodeRemoteLauncher inline model switch', () => {
'prompt:end'
]);
});
it('skips interim idle text snapshots in the local log while still forwarding them to the hub', async () => {
const addMessage = vi.spyOn(MessageBuffer.prototype, 'addMessage');
const { session, agentMessages } = createSessionStub([
{ message: 'first', mode: createMode() }
]);
const launcherPromise = opencodeRemoteLauncher(session as never);
while (!harness.events.includes('prompt:start')) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
const onMessage = harness.promptOnMessage;
if (!onMessage) {
throw new Error('prompt() was not called with a message callback');
}
onMessage({ type: 'text', text: 'partial', id: 'stream-1', streamSnapshot: true, live: true });
onMessage({ type: 'text', text: 'partial settled', id: 'stream-1', streamSnapshot: true });
// The idle snapshot must reach the hub (web reducer keeps one block per
// stream id) but must not duplicate the answer in the local terminal log.
expect(agentMessages).toContainEqual({ type: 'message', message: 'partial', id: 'stream-1', streamSnapshot: true });
expect(agentMessages).toContainEqual({ type: 'message', message: 'partial settled', id: 'stream-1', streamSnapshot: true });
expect(addMessage).not.toHaveBeenCalledWith('partial', 'assistant');
expect(addMessage).toHaveBeenCalledWith('partial settled', 'assistant');
await launcherPromise;
addMessage.mockRestore();
});
});
describe('selectAbortStatusMessage', () => {
@@ -1295,6 +1295,11 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
switch (message.type) {
case 'text':
// Interim idle snapshots; the settled text arrives at the
// segment boundary, which is what belongs in the local log.
if (message.live) {
break;
}
this.messageBuffer.addMessage(message.text, 'assistant');
break;
case 'reasoning':