[^<]*<\/dcp-message-id>/g;
+const OPEN = '';
+const CLOSE = '';
+
+/** One-shot strip of COMPLETE tags. Safe for non-streaming / final content. */
+export function stripDcpTags(s: string): string {
+ return s.replace(DCP_TAG_RE, '');
+}
+
+/**
+ * Could `tail` (a substring starting at a `<`) still grow into a complete dcp
+ * tag on a future chunk? If so the caller must hold it back rather than emit it.
+ * Returns false for unrelated `<` content (``, ``, …) so those stream
+ * normally.
+ */
+function isPartialDcp(tail: string): boolean {
+ // A prefix of the opening marker: '<', '`).
+ for (let i = buf.indexOf('<'); i !== -1; i = buf.indexOf('<', i + 1)) {
+ if (isPartialDcp(buf.slice(i))) {
+ const emit = buf.slice(0, i);
+ buf = buf.slice(i);
+ return emit;
+ }
+ }
+ const emit = buf;
+ buf = '';
+ return emit;
+ },
+ flush(): string {
+ const out = stripDcpTags(buf);
+ buf = '';
+ return out;
+ },
+ };
+}
diff --git a/apps/coder/src/services/dispatcher.ts b/apps/coder/src/services/dispatcher.ts
index fe14c4d..fdbf716 100644
--- a/apps/coder/src/services/dispatcher.ts
+++ b/apps/coder/src/services/dispatcher.ts
@@ -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();
+ // opencode's dcp plugin appends … 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