From c8484eca3acb1e83157044f2de94621c5759d9c1 Mon Sep 17 00:00:00 2001 From: Christopher Date: Sun, 12 Apr 2026 12:44:26 +0000 Subject: [PATCH] fix(copilot): consolidate chunk events in JSON log format PR #1047 only consolidated chunks in summary mode, leaving JSON mode writing one line per agent_message_chunk. Users with log_format: json still saw fragmented output. Apply the same buffering logic to both formats so JSON logs emit a single assistant_message entry per turn. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/evaluation/providers/copilot-utils.ts | 26 +++++++++++-------- .../providers/copilot-stream-logger.test.ts | 12 ++++++--- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/packages/core/src/evaluation/providers/copilot-utils.ts b/packages/core/src/evaluation/providers/copilot-utils.ts index 43bb8ae55..9cb781646 100644 --- a/packages/core/src/evaluation/providers/copilot-utils.ts +++ b/packages/core/src/evaluation/providers/copilot-utils.ts @@ -323,13 +323,7 @@ export class CopilotStreamLogger { } handleEvent(eventType: string, data: unknown): void { - if (this.format === 'json') { - const elapsed = formatElapsed(this.startedAt); - this.stream.write(`${JSON.stringify({ time: elapsed, event: eventType, data })}\n`); - return; - } - - // In summary mode, buffer chunk events and emit a single consolidated line. + // Buffer chunk events into a single consolidated entry (both formats). if (this.chunkExtractor) { const chunkText = this.chunkExtractor(eventType, data); if (chunkText === null) { @@ -348,6 +342,12 @@ export class CopilotStreamLogger { this.flushPendingText(); } + if (this.format === 'json') { + const elapsed = formatElapsed(this.startedAt); + this.stream.write(`${JSON.stringify({ time: elapsed, event: eventType, data })}\n`); + return; + } + const elapsed = formatElapsed(this.startedAt); const summary = this.summarize(eventType, data); if (summary) { @@ -358,14 +358,18 @@ export class CopilotStreamLogger { private flushPendingText(): void { if (!this.pendingText) return; const elapsed = formatElapsed(this.startedAt); - this.stream.write(`[+${elapsed}] [assistant_message] ${this.pendingText}\n`); + if (this.format === 'json') { + this.stream.write( + `${JSON.stringify({ time: elapsed, event: 'assistant_message', data: { content: this.pendingText } })}\n`, + ); + } else { + this.stream.write(`[+${elapsed}] [assistant_message] ${this.pendingText}\n`); + } this.pendingText = ''; } async close(): Promise { - if (this.format !== 'json') { - this.flushPendingText(); - } + this.flushPendingText(); await new Promise((resolve, reject) => { this.stream.once('error', reject); this.stream.end(() => resolve()); diff --git a/packages/core/test/evaluation/providers/copilot-stream-logger.test.ts b/packages/core/test/evaluation/providers/copilot-stream-logger.test.ts index 926e57d61..956f421c5 100644 --- a/packages/core/test/evaluation/providers/copilot-stream-logger.test.ts +++ b/packages/core/test/evaluation/providers/copilot-stream-logger.test.ts @@ -100,7 +100,7 @@ describe('CopilotStreamLogger', () => { expect(content).toMatch(/\[assistant_message\] Final answer/); }); - it('does not buffer in json format (keeps per-event for full fidelity)', async () => { + it('consolidates chunk events in json format as single assistant_message entry', async () => { const filePath = path.join(tempDir, 'test.log'); const chunkExtractor = (type: string, data: unknown): string | null | undefined => { if (type !== 'agent_message_chunk') return undefined; @@ -118,6 +118,7 @@ describe('CopilotStreamLogger', () => { logger.handleEvent('agent_message_chunk', { content: { type: 'text', text: 'chunk1' } }); logger.handleEvent('agent_message_chunk', { content: { type: 'text', text: 'chunk2' } }); + logger.handleEvent('tool_call', { title: 'read_file' }); await logger.close(); const content = await readFile(filePath, 'utf8'); @@ -125,8 +126,13 @@ describe('CopilotStreamLogger', () => { .split('\n') .filter((l) => l.trim().startsWith('{')) .map((l) => JSON.parse(l)); - // Both chunks emitted individually as JSON - expect(jsonLines.filter((e) => e.event === 'agent_message_chunk')).toHaveLength(2); + // No raw chunk events — consolidated into one assistant_message + expect(jsonLines.filter((e) => e.event === 'agent_message_chunk')).toHaveLength(0); + const msg = jsonLines.find((e) => e.event === 'assistant_message'); + expect(msg).toBeDefined(); + expect(msg.data.content).toBe('chunk1chunk2'); + // Non-chunk event still emitted + expect(jsonLines.find((e) => e.event === 'tool_call')).toBeDefined(); }); it('handles chunk events with no extractable text gracefully', async () => {