fix(coder): strip dcp-message-id tags split across stream chunks

The dcp tag (<dcp-message-id>mNNNN</dcp-message-id>) is streamed token-by-token, so it arrives split across SSE deltas. The existing per-chunk stripDcpTags never sees a complete tag in any single fragment, so fragments pass through and the dispatcher reassembles the tag in textChunks (persisted + shown) — and the terminal message.part.updated path that would strip the full text is suppressed by the dedup gate. Add a stateful cross-chunk stripper (dcp-strip.ts: makeDcpStreamStripper) at the dispatcher's opencode frame boundary: it emits text that cannot be part of a forming tag, holds back only a trailing partial-tag prefix (without swallowing legitimate <…> content), and flushes at turn end. Fixes both live delta frames and persisted content. 11 unit tests incl. split-at-every-boundary and the documented per-chunk-fails case. opencode path only; ACP (goose/qwen/claude) untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 23:16:47 +00:00
parent c65daba5dd
commit f1a85627e4
3 changed files with 180 additions and 8 deletions

View File

@@ -4,6 +4,7 @@ import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames';
import type { Config } from '../config.js';
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
import { makeDcpStreamStripper } from './dcp-strip.js';
import { dispatchViaAcp } from './acp-dispatch.js';
import { getResolvedRegistry } from './provider-config-registry.js';
import { dispatchViaPty } from './pty-dispatch.js';
@@ -620,21 +621,30 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
const textChunks: string[] = [];
const reasoningChunks: string[] = [];
const toolSnaps = new Map<string, AcpToolSnapshot>();
// opencode's dcp plugin appends <dcp-message-id>…</dcp-message-id> to the
// text, streamed split across deltas — a per-chunk regex misses it (see
// dcp-strip.ts). Buffer text through a cross-chunk stripper so neither the
// live `delta` frames nor the persisted content ever carry the tag.
const dcp = makeDcpStreamStripper();
// Map transport-agnostic AgentEvents → the SAME WS frames the ACP path emits.
// This boundary is where message_id/chat_id get attached (the backend never
// owns them).
const onEvent = (e: AgentEvent): void => {
switch (e.type) {
case 'text':
textChunks.push(e.text);
broker.publishFrame(sessionId, {
type: 'delta',
message_id: assistantId,
chat_id: chatId,
content: e.text,
} as WsFrame);
case 'text': {
const safe = dcp.push(e.text);
if (safe) {
textChunks.push(safe);
broker.publishFrame(sessionId, {
type: 'delta',
message_id: assistantId,
chat_id: chatId,
content: safe,
} as WsFrame);
}
break;
}
case 'reasoning':
reasoningChunks.push(e.text);
broker.publishFrame(sessionId, {
@@ -680,6 +690,18 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
onEvent,
});
// Flush any text held back mid-tag at stream end (complete tags stripped).
const dcpTail = dcp.flush();
if (dcpTail) {
textChunks.push(dcpTail);
broker.publishFrame(sessionId, {
type: 'delta',
message_id: assistantId,
chat_id: chatId,
content: dcpTail,
} as WsFrame);
}
const assistantContent = textChunks.join('').slice(0, 50_000);
const reasoningText = reasoningChunks.join('').slice(0, 200_000);
const outputSummary = (result.ok ? textChunks.join('') : result.error ?? 'opencode turn failed').slice(0, 500);