mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
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:
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user