diff --git a/integration-tests/sdk-typescript/message-event-pairing.test.ts b/integration-tests/sdk-typescript/message-event-pairing.test.ts index 32b81b21b..b439ec276 100644 --- a/integration-tests/sdk-typescript/message-event-pairing.test.ts +++ b/integration-tests/sdk-typescript/message-event-pairing.test.ts @@ -351,7 +351,7 @@ describe('Message Start/Stop Event Pairing (E2E)', () => { expect(event.type).toBe('message_stop'); }); - it('should have message_start and message_stop paired by message_id', async () => { + it('should have message_start and message_stop paired by count', async () => { const startEvents: SDKPartialAssistantMessage[] = []; const stopEvents: SDKPartialAssistantMessage[] = []; @@ -379,22 +379,19 @@ describe('Message Start/Stop Event Pairing (E2E)', () => { await q.close(); } - // Verify message_start and message_stop are paired (same count) + // Verify message_start and message_stop appear in pairs (same count) expect(startEvents.length).toBeGreaterThan(0); expect(stopEvents.length).toBe(startEvents.length); - // Verify each message_start has a corresponding message_stop with the same message_id - const startMessageIds = new Set( - startEvents.map((e) => (e.event as { message_id?: string }).message_id), - ); - const stopMessageIds = new Set( - stopEvents.map((e) => (e.event as { message_id?: string }).message_id), - ); - - // Each message_stop should have the same message_id as a message_start - startMessageIds.forEach((messageId) => { - expect(stopMessageIds.has(messageId)).toBe(true); - }); + // Verify message_start carries the message id via its nested message.id field + for (const e of startEvents) { + const event = e.event as { + type: 'message_start'; + message: { id: string }; + }; + expect(typeof event.message.id).toBe('string'); + expect(event.message.id.length).toBeGreaterThan(0); + } }); }); @@ -437,4 +434,437 @@ describe('Message Start/Stop Event Pairing (E2E)', () => { } }); }); + + describe('Content Block Event Pairing', () => { + it('should emit paired content_block_start and content_block_stop for each content block', async () => { + const contentBlockStartEvents: SDKPartialAssistantMessage[] = []; + const contentBlockStopEvents: SDKPartialAssistantMessage[] = []; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKPartialAssistantMessage(message)) { + if (message.event.type === 'content_block_start') { + contentBlockStartEvents.push(message); + } else if (message.event.type === 'content_block_stop') { + contentBlockStopEvents.push(message); + } + } + } + } finally { + await q.close(); + } + + // Verify content_block_start and content_block_stop are paired + expect(contentBlockStartEvents.length).toBeGreaterThan(0); + expect(contentBlockStopEvents.length).toBe( + contentBlockStartEvents.length, + ); + }); + + it('should emit content_block_start before content_block_stop', async () => { + const events: Array<{ type: string; index: number; timestamp: number }> = + []; + + const q = query({ + prompt: 'Say hello world', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKPartialAssistantMessage(message)) { + if ( + message.event.type === 'content_block_start' || + message.event.type === 'content_block_stop' + ) { + events.push({ + type: message.event.type, + index: message.event.index, + timestamp: Date.now(), + }); + } + } + } + } finally { + await q.close(); + } + + // Verify events exist + expect(events.length).toBeGreaterThanOrEqual(2); + + // Group events by index + const eventsByIndex = new Map(); + for (const event of events) { + if (!eventsByIndex.has(event.index)) { + eventsByIndex.set(event.index, []); + } + eventsByIndex.get(event.index)!.push(event); + } + + // For each index, verify content_block_start comes before content_block_stop + eventsByIndex.forEach((indexEvents) => { + const startIndex = indexEvents.findIndex( + (e) => e.type === 'content_block_start', + ); + const stopIndex = indexEvents.findIndex( + (e) => e.type === 'content_block_stop', + ); + expect(startIndex).toBeGreaterThanOrEqual(0); + expect(stopIndex).toBeGreaterThanOrEqual(0); + expect(startIndex).toBeLessThan(stopIndex); + }); + }); + + it('should have correct content_block_start event structure', async () => { + const contentBlockStartEvents: SDKPartialAssistantMessage[] = []; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if ( + isSDKPartialAssistantMessage(message) && + message.event.type === 'content_block_start' + ) { + contentBlockStartEvents.push(message); + } + } + } finally { + await q.close(); + } + + expect(contentBlockStartEvents.length).toBeGreaterThan(0); + + // Verify each content_block_start has correct structure + for (const message of contentBlockStartEvents) { + const event = message.event as { + type: 'content_block_start'; + index: number; + content_block: unknown; + }; + expect(event.type).toBe('content_block_start'); + expect(event).toHaveProperty('index'); + expect(typeof event.index).toBe('number'); + expect(event.index).toBeGreaterThanOrEqual(0); + expect(event).toHaveProperty('content_block'); + expect(event.content_block).toBeDefined(); + } + }); + + it('should have correct content_block_stop event structure', async () => { + const contentBlockStopEvents: SDKPartialAssistantMessage[] = []; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if ( + isSDKPartialAssistantMessage(message) && + message.event.type === 'content_block_stop' + ) { + contentBlockStopEvents.push(message); + } + } + } finally { + await q.close(); + } + + expect(contentBlockStopEvents.length).toBeGreaterThan(0); + + // Verify each content_block_stop has correct structure + for (const message of contentBlockStopEvents) { + const event = message.event as { + type: 'content_block_stop'; + index: number; + }; + expect(event.type).toBe('content_block_stop'); + expect(event).toHaveProperty('index'); + expect(typeof event.index).toBe('number'); + expect(event.index).toBeGreaterThanOrEqual(0); + } + }); + + it('should have matching index for paired content_block_start and content_block_stop', async () => { + const startEvents: SDKPartialAssistantMessage[] = []; + const stopEvents: SDKPartialAssistantMessage[] = []; + + const q = query({ + prompt: 'Say hello world', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKPartialAssistantMessage(message)) { + if (message.event.type === 'content_block_start') { + startEvents.push(message); + } else if (message.event.type === 'content_block_stop') { + stopEvents.push(message); + } + } + } + } finally { + await q.close(); + } + + // Verify events exist and are paired + expect(startEvents.length).toBeGreaterThan(0); + expect(stopEvents.length).toBe(startEvents.length); + + // Extract indices from start and stop events + const startIndices = startEvents.map( + (e) => (e.event as { index: number }).index, + ); + const stopIndices = stopEvents.map( + (e) => (e.event as { index: number }).index, + ); + + // Verify each start index has a matching stop index + expect(new Set(stopIndices)).toEqual(new Set(startIndices)); + + // Verify each index appears the same number of times in both start and stop events + const startIndexCounts = new Map(); + const stopIndexCounts = new Map(); + + for (const idx of startIndices) { + startIndexCounts.set(idx, (startIndexCounts.get(idx) || 0) + 1); + } + for (const idx of stopIndices) { + stopIndexCounts.set(idx, (stopIndexCounts.get(idx) || 0) + 1); + } + + startIndexCounts.forEach((count, idx) => { + expect(stopIndexCounts.get(idx)).toBe(count); + }); + }); + + it('should follow correct event flow: content_block_start -> content_block_delta -> content_block_stop', async () => { + const events: Array<{ + type: string; + index: number; + position: number; + }> = []; + + const q = query({ + prompt: 'Write a short story about a cat', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + let pos = 0; + try { + for await (const message of q) { + if (isSDKPartialAssistantMessage(message)) { + const eventType = message.event.type; + if ( + eventType === 'content_block_start' || + eventType === 'content_block_delta' || + eventType === 'content_block_stop' + ) { + events.push({ + type: eventType, + index: (message.event as { index: number }).index, + position: pos++, + }); + } + } + } + } finally { + await q.close(); + } + + expect(events.length).toBeGreaterThanOrEqual(2); + + // Pair content_block_start/stop sequentially (not by index, since + // block-type transitions reset the blocks array and reuse index 0). + // Each start is matched with the next stop that follows it. + const starts = events.filter((e) => e.type === 'content_block_start'); + const stops = events.filter((e) => e.type === 'content_block_stop'); + expect(starts.length).toBe(stops.length); + + for (let i = 0; i < starts.length; i++) { + const start = starts[i]; + const stop = stops[i]; + + // start must come before the paired stop + expect(start.position).toBeLessThan(stop.position); + + // All deltas between this pair must sit between start and stop + const deltas = events.filter( + (e) => + e.type === 'content_block_delta' && + e.position > start.position && + e.position < stop.position, + ); + for (const delta of deltas) { + expect(delta.position).toBeGreaterThan(start.position); + expect(delta.position).toBeLessThan(stop.position); + } + } + }); + + it('should have content_block_start after message_start and before message_stop', async () => { + const events: Array<{ + type: string; + timestamp: number; + }> = []; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKPartialAssistantMessage(message)) { + const eventType = message.event.type; + if ( + eventType === 'message_start' || + eventType === 'message_stop' || + eventType === 'content_block_start' + ) { + events.push({ + type: eventType, + timestamp: Date.now(), + }); + } + } + } + } finally { + await q.close(); + } + + // Verify message_start exists + const messageStartIndex = events.findIndex( + (e) => e.type === 'message_start', + ); + expect(messageStartIndex).toBeGreaterThanOrEqual(0); + + // Verify message_stop exists + const messageStopIndex = events.findIndex( + (e) => e.type === 'message_stop', + ); + expect(messageStopIndex).toBeGreaterThanOrEqual(0); + + // Verify content_block_start exists + const firstContentBlockStartIndex = events.findIndex( + (e) => e.type === 'content_block_start', + ); + expect(firstContentBlockStartIndex).toBeGreaterThanOrEqual(0); + + // content_block_start should be after message_start + expect(firstContentBlockStartIndex).toBeGreaterThan(messageStartIndex); + + // content_block_start should be before message_stop + expect(firstContentBlockStartIndex).toBeLessThan(messageStopIndex); + }); + + it('should have content_block_stop after message_start and before message_stop', async () => { + const events: Array<{ + type: string; + timestamp: number; + }> = []; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKPartialAssistantMessage(message)) { + const eventType = message.event.type; + if ( + eventType === 'message_start' || + eventType === 'message_stop' || + eventType === 'content_block_stop' + ) { + events.push({ + type: eventType, + timestamp: Date.now(), + }); + } + } + } + } finally { + await q.close(); + } + + // Verify message_start exists + const messageStartIndex = events.findIndex( + (e) => e.type === 'message_start', + ); + expect(messageStartIndex).toBeGreaterThanOrEqual(0); + + // Verify message_stop exists + const messageStopIndex = events.findIndex( + (e) => e.type === 'message_stop', + ); + expect(messageStopIndex).toBeGreaterThanOrEqual(0); + + // Verify content_block_stop exists (use reverse find for ES compatibility) + const lastContentBlockStopIndex = + events + .map((e, i) => ({ ...e, originalIndex: i })) + .reverse() + .find((e) => e.type === 'content_block_stop')?.originalIndex ?? -1; + expect(lastContentBlockStopIndex).toBeGreaterThanOrEqual(0); + + // content_block_stop should be after message_start + expect(lastContentBlockStopIndex).toBeGreaterThan(messageStartIndex); + + // content_block_stop should be before message_stop + expect(lastContentBlockStopIndex).toBeLessThan(messageStopIndex); + }); + }); }); diff --git a/package.json b/package.json index a49760350..c1dfa2448 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,8 @@ "test:integration:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests", "test:integration:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests", "test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests", - "test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests sdk-typescript", - "test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests sdk-typescript", + "test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript", + "test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript", "test:integration:cli:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'", "test:integration:cli:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'", "test:terminal-bench": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests", diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts index b0d6736a5..dc62f9ae2 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts @@ -282,12 +282,12 @@ export abstract class BaseJsonOutputAdapter { return; } - if (lastBlock.type === 'text') { - const index = state.blocks.length - 1; - this.onBlockClosed(state, index, actualParentToolUseId); - this.closeBlock(state, index); - } else if (lastBlock.type === 'thinking') { - const index = state.blocks.length - 1; + const index = state.blocks.length - 1; + if (!state.openBlocks.has(index)) { + return; + } + + if (lastBlock.type === 'text' || lastBlock.type === 'thinking') { this.onBlockClosed(state, index, actualParentToolUseId); this.closeBlock(state, index); } @@ -392,7 +392,9 @@ export abstract class BaseJsonOutputAdapter { } const message = this.buildMessage(parentToolUseId); - this.emitMessageImpl(message); + if (state.messageStarted) { + this.emitMessageImpl(message); + } return message; } @@ -656,12 +658,7 @@ export abstract class BaseJsonOutputAdapter { parentToolUseId: string, ): CLIAssistantMessage { const state = this.getMessageState(parentToolUseId); - const message = this.finalizeAssistantMessageInternal( - state, - parentToolUseId, - ); - this.updateLastAssistantMessage(message); - return message; + return this.finalizeAssistantMessageInternal(state, parentToolUseId); } /** diff --git a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts index a76de53a8..68633675b 100644 --- a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts @@ -52,12 +52,10 @@ export class JsonOutputAdapter } finalizeAssistantMessage(): CLIAssistantMessage { - const message = this.finalizeAssistantMessageInternal( + return this.finalizeAssistantMessageInternal( this.mainAgentMessageState, null, ); - this.updateLastAssistantMessage(message); - return message; } emitResult(options: ResultOptions): void { diff --git a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts index 96977d5b0..64448c8a6 100644 --- a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts +++ b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts @@ -654,6 +654,24 @@ describe('StreamJsonOutputAdapter', () => { 'Message not started', ); }); + + it('should not emit empty assistant message when started but no content processed', () => { + stdoutWriteSpy.mockClear(); + adapter.finalizeAssistantMessage(); + + const assistantCalls = stdoutWriteSpy.mock.calls.filter( + (call: unknown[]) => { + try { + const parsed = JSON.parse(call[0] as string); + return parsed.type === 'assistant'; + } catch { + return false; + } + }, + ); + + expect(assistantCalls).toHaveLength(0); + }); }); describe('emitResult', () => { @@ -1007,56 +1025,68 @@ describe('StreamJsonOutputAdapter', () => { }); }); - describe('message_id in stream events', () => { + describe('content_block event identification', () => { beforeEach(() => { adapter = new StreamJsonOutputAdapter(mockConfig, true); adapter.startAssistantMessage(); }); - it('should include message_id in stream events after message starts', () => { + it('should not include message_id in content_block events', () => { adapter.processEvent({ type: GeminiEventType.Content, value: 'Text', }); - // Process another event to ensure messageStarted is true adapter.processEvent({ type: GeminiEventType.Content, value: 'More', }); const calls = stdoutWriteSpy.mock.calls; - // Find all delta events - const deltaCalls = calls.filter((call: unknown[]) => { + const contentBlockCalls = calls.filter((call: unknown[]) => { try { const parsed = JSON.parse(call[0] as string); return ( parsed.type === 'stream_event' && - parsed.event.type === 'content_block_delta' + (parsed.event.type === 'content_block_start' || + parsed.event.type === 'content_block_delta' || + parsed.event.type === 'content_block_stop') ); } catch { return false; } }); - expect(deltaCalls.length).toBeGreaterThan(0); - // The second delta event should have message_id (after messageStarted becomes true) - // message_id is added to the event object, so check parsed.event.message_id - if (deltaCalls.length > 1) { - const secondDelta = JSON.parse( - (deltaCalls[1] as unknown[])[0] as string, - ); - // message_id is on the enriched event object - expect( - secondDelta.event.message_id || secondDelta.message_id, - ).toBeTruthy(); - } else { - // If only one delta, check if message_id exists - const delta = JSON.parse((deltaCalls[0] as unknown[])[0] as string); - // message_id is added when messageStarted is true - // First event may or may not have it, but subsequent ones should - expect(delta.event.message_id || delta.message_id).toBeTruthy(); + expect(contentBlockCalls.length).toBeGreaterThan(0); + for (const call of contentBlockCalls) { + const parsed = JSON.parse((call as unknown[])[0] as string); + expect(parsed.event.message_id).toBeUndefined(); } }); + + it('should identify content_block events by session_id and index', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Text', + }); + + const calls = stdoutWriteSpy.mock.calls; + const blockStartCall = calls.find((call: unknown[]) => { + try { + const parsed = JSON.parse(call[0] as string); + return ( + parsed.type === 'stream_event' && + parsed.event.type === 'content_block_start' + ); + } catch { + return false; + } + }); + + expect(blockStartCall).toBeDefined(); + const parsed = JSON.parse((blockStartCall as unknown[])[0] as string); + expect(parsed.session_id).toBe('test-session-id'); + expect(typeof parsed.event.index).toBe('number'); + }); }); describe('multiple text blocks', () => { diff --git a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts index 346c4b072..c67190e6a 100644 --- a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts @@ -36,6 +36,8 @@ export class StreamJsonOutputAdapter extends BaseJsonOutputAdapter implements JsonOutputAdapterInterface { + private mainTurnMessageStartEmitted = false; + constructor( config: Config, private readonly includePartialMessages: boolean, @@ -68,47 +70,27 @@ export class StreamJsonOutputAdapter return this.includePartialMessages; } + override startAssistantMessage(): void { + this.mainTurnMessageStartEmitted = false; + super.startAssistantMessage(); + } + finalizeAssistantMessage(): CLIAssistantMessage { - return this.finalizeAssistantMessageInternal( + const message = this.finalizeAssistantMessageInternal( this.mainAgentMessageState, null, ); - } - - /** - * Overrides base class to emit message_stop event when message is finalized. - * This ensures message_start and message_stop are always paired. - */ - protected override finalizeAssistantMessageInternal( - state: MessageState, - parentToolUseId: string | null, - ): CLIAssistantMessage { - if (state.finalized) { - return this.buildMessage(parentToolUseId); + if (this.mainTurnMessageStartEmitted && this.includePartialMessages) { + const partial: CLIPartialAssistantMessage = { + type: 'stream_event', + uuid: randomUUID(), + session_id: this.getSessionId(), + parent_tool_use_id: null, + event: { type: 'message_stop' }, + }; + this.emitMessageImpl(partial); } - state.finalized = true; - - this.finalizePendingBlocks(state, parentToolUseId); - const orderedOpenBlocks = Array.from(state.openBlocks).sort( - (a, b) => a - b, - ); - for (const index of orderedOpenBlocks) { - this.onBlockClosed(state, index, parentToolUseId); - this.closeBlock(state, index); - } - - // Emit message_stop for main agent when message was started and partial messages are enabled - if ( - state.messageStarted && - this.includePartialMessages && - parentToolUseId === null - ) { - this.emitStreamEventIfEnabled({ type: 'message_stop' }, null); - } - - const message = this.buildMessage(parentToolUseId); - this.updateLastAssistantMessage(message); - this.emitMessageImpl(message); + this.mainTurnMessageStartEmitted = false; return message; } @@ -267,14 +249,15 @@ export class StreamJsonOutputAdapter /** * Overrides base class hook to emit message_start event when message is started. - * Only emits for main agent, not for subagents. + * Only emits once per turn for the main agent (guarded by mainTurnMessageStartEmitted), + * so block-type transitions inside a single turn do not produce spurious message_start events. */ protected override onEnsureMessageStarted( state: MessageState, parentToolUseId: string | null, ): void { - // Only emit message_start for main agent, not for subagents - if (parentToolUseId === null) { + if (parentToolUseId === null && !this.mainTurnMessageStartEmitted) { + this.mainTurnMessageStartEmitted = true; this.emitStreamEventIfEnabled( { type: 'message_start', @@ -282,6 +265,7 @@ export class StreamJsonOutputAdapter id: state.messageId!, role: 'assistant', model: this.config.getModel(), + content: [], }, }, null, @@ -329,19 +313,12 @@ export class StreamJsonOutputAdapter return; } - const state = this.getMessageState(parentToolUseId); - const enrichedEvent = state.messageStarted - ? ({ ...event, message_id: state.messageId } as StreamEvent & { - message_id: string; - }) - : event; - const partial: CLIPartialAssistantMessage = { type: 'stream_event', uuid: randomUUID(), session_id: this.getSessionId(), parent_tool_use_id: parentToolUseId, - event: enrichedEvent, + event, }; this.emitMessageImpl(partial); } diff --git a/packages/cli/src/nonInteractive/types.ts b/packages/cli/src/nonInteractive/types.ts index 84c2d0ff7..69eaa1dcd 100644 --- a/packages/cli/src/nonInteractive/types.ts +++ b/packages/cli/src/nonInteractive/types.ts @@ -201,6 +201,7 @@ export interface MessageStartStreamEvent { id: string; role: 'assistant'; model: string; + content: []; }; } diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index e4c22cebb..bf29f8f0e 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -390,6 +390,16 @@ export async function runNonInteractive( } } } catch (error) { + // Ensure message_start / message_stop (and content_block events) are + // properly paired even when an error aborts the turn mid-stream. + // The call is safe when no message was started (throws → caught) or + // when already finalized (idempotent guard inside the adapter). + try { + adapter.finalizeAssistantMessage(); + } catch { + // Expected when no message was started or already finalized + } + // For JSON and STREAM_JSON modes, compute usage from metrics const message = error instanceof Error ? error.message : String(error); const metrics = uiTelemetryService.getMetrics();