mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
Fix SDK message event pairing and improve content block handling
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
eea92fc8db
commit
79083ffd50
8 changed files with 545 additions and 102 deletions
|
|
@ -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<number, typeof events>();
|
||||
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<number, number>();
|
||||
const stopIndexCounts = new Map<number, number>();
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ export interface MessageStartStreamEvent {
|
|||
id: string;
|
||||
role: 'assistant';
|
||||
model: string;
|
||||
content: [];
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue