Background
We've now shipped the same fix to the same bug pattern twice:
In both cases the symptom in the Agents window chat was the same: consecutive assistant text items fused into one run-on paragraph (e.g. "...wiring:Now add..." instead of two paragraphs).
The Claude Code SDK path (extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts) likely has the same latent multiple text content blocks in one SDKAssistantMessage (or consecutive messages in one turn) are forwarded via stream.markdown(item.text) with no but no user-visible report has come in yet.separator bug
Why this keeps happening
- Impedance mismatch. Every model SDK models a turn as discrete items (
content_blocks, output_index-tagged events, messageId-tagged messages). vscode.ChatResponseStream.markdown(...) is a purely concatenative sink. The natural way to write the handler (for (item) stream.markdown(item.text)) silently fuses items.
- Hand-rolled each time. No shared helper exists, so each new integration reinvents the same ~5-line state or forgets to.machine
- Hides in dev testing. Only surfaces when the model emits multiple text items in one turn; short answers don't trigger it. There is no invariant test that would catch the omission at PR review or CI time.
Proposal
Extract a tiny, sink-agnostic segment-boundary helper, then refactor both existing call sites onto it. Sketch:
// e.g. extensions/copilot/src/util/common/markdownSegmentSeparator.ts
export class MarkdownSegmentSeparator {
private lastSegmentKey: string | number | undefined;
constructor(private readonly emitSeparator: () => void) { }
/** Call before emitting text from `segmentKey`. Emits `\n\n` if the
* segment has changed and this is not the first emission. Defined-on-both
* keys are compared; otherwise no separator is emitted (legacy fallback). */
onSegment(segmentKey: string | number | undefined): void {
if (
segmentKey !== undefined &&
this.lastSegmentKey !== undefined &&
segmentKey !== this.lastSegmentKey
) {
this.emitSeparator();
}
if (segmentKey !== undefined) {
this.lastSegmentKey = segmentKey;
}
}
/** Call at request/turn boundaries to avoid spurious separators on the next turn. */
reset(): void { this.lastSegmentKey = undefined; }
}
Call sites become:
// Copilot CLI (assistant.message_delta / assistant.message)
const sep = new MarkdownSegmentSeparator(() => requestStream?.markdown('\n\n'));
// ... in handler:
sep.onSegment(event.data.messageId);
requestStream?.markdown(event.data.deltaContent);
// OpenAI Responses API (response.output_text.delta)
const sep = new MarkdownSegmentSeparator(() => onProgress({ text: '\n\n' }));
// ... in handler:
sep.onSegment(capiChunk.output_index);
return onProgress({ text: capiChunk.delta, ... });
The value isn't saving 5 lines per it's:site
- Naming the operation so the intent (
onSegment(...)) is visible at every call site, making a missing call easier to spot during review.
- One place for the invariant test that locks in the behavior so the pattern can't regress silently.
- Lower friction for the next SDK integration to do the right thing by default.
Scope of work
- Add
MarkdownSegmentSeparator (location probably extensions/copilot/src/util/common/) with focused unit tests covering: first emission (no separator), same key repeated (no separator), key change (separator), undefined-key legacy fallback (no separator), and reset().TBD
- Refactor
responsesApi.ts OpenAIResponsesProcessor to use the helper (replace lastTextDeltaOutputIndex).
- Refactor
copilotcliSession.ts to use the helper (replace lastEmittedAssistantMessageId + maybeEmitMessageSeparator).
- Audit and fix the Claude Code SDK path (
claudeMessageDispatch.ts handleAssistantMessage) using the same helper, keyed on <message.uuid>:<contentIndex>. This is the third site likely affected; fixing it preemptively is cheap once the helper exists.
- Migrate the existing tests in
responsesApi.spec.ts and copilotcliSession.spec.ts to remain passing; add a Claude Code SDK test.
Non-goals
- Changing the separator string (
\n\n is correct and markdown-safe between blocks).
- Touching the
chunkMessageIds/assistantMessageChunks dedup logic in copilotcliSession. that's pre-existing and out of scope.ts
- Auto-detecting fusion at the chat-rendering layer (heuristic would break legitimate intra-word streaming like
"writ" + "ing").
Acceptance
- All three integrations use
MarkdownSegmentSeparator.
- Existing tests still pass; new tests assert no regression on each site.
- A future new SDK integration that forgets to call
onSegment(...) is caught either by review (intent is now explicit) or by a written test pattern this issue establishes.
References
Background
We've now shipped the same fix to the same bug pattern twice:
lastTextDeltaOutputIndexinextensions/copilot/src/platform/endpoint/node/responsesApi.ts312173))lastEmittedAssistantMessageIdinextensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts319727))In both cases the symptom in the Agents window chat was the same: consecutive assistant text items fused into one run-on paragraph (e.g.
"...wiring:Now add..."instead of two paragraphs).The Claude Code SDK path (
extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts) likely has the same latent multipletextcontent blocks in oneSDKAssistantMessage(or consecutive messages in one turn) are forwarded viastream.markdown(item.text)with no but no user-visible report has come in yet.separator bugWhy this keeps happening
content_blocks,output_index-tagged events,messageId-tagged messages).vscode.ChatResponseStream.markdown(...)is a purely concatenative sink. The natural way to write the handler (for (item) stream.markdown(item.text)) silently fuses items.Proposal
Extract a tiny, sink-agnostic segment-boundary helper, then refactor both existing call sites onto it. Sketch:
Call sites become:
The value isn't saving 5 lines per it's:site
onSegment(...)) is visible at every call site, making a missing call easier to spot during review.Scope of work
MarkdownSegmentSeparator(location probablyextensions/copilot/src/util/common/) with focused unit tests covering: first emission (no separator), same key repeated (no separator), key change (separator),undefined-key legacy fallback (no separator), andreset().TBDresponsesApi.tsOpenAIResponsesProcessorto use the helper (replacelastTextDeltaOutputIndex).copilotcliSession.tsto use the helper (replacelastEmittedAssistantMessageId+maybeEmitMessageSeparator).claudeMessageDispatch.tshandleAssistantMessage) using the same helper, keyed on<message.uuid>:<contentIndex>. This is the third site likely affected; fixing it preemptively is cheap once the helper exists.responsesApi.spec.tsandcopilotcliSession.spec.tsto remain passing; add a Claude Code SDK test.Non-goals
\n\nis correct and markdown-safe between blocks).chunkMessageIds/assistantMessageChunksdedup logic incopilotcliSession. that's pre-existing and out of scope.ts"writ"+"ing").Acceptance
MarkdownSegmentSeparator.onSegment(...)is caught either by review (intent is now explicit) or by a written test pattern this issue establishes.References