Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,49 @@
* by the Apache License, Version 2.0
*/

import type { TaskStatusUpdateEvent } from '@a2a-js/sdk';
import type { TaskArtifactUpdateEvent, TaskStatusUpdateEvent } from '@a2a-js/sdk';
import { describe, expect, vi } from 'vitest';

import { handleStatusUpdateEvent } from './event-handlers';
import { handleArtifactUpdateEvent, handleStatusUpdateEvent } from './event-handlers';
import type { StreamingState } from './streaming-types';
import type { ChatMessage } from '../types';

describe('artifact duplication bug', () => {
const createMockState = (): StreamingState => ({
contentBlocks: [],
activeTextBlock: null,
lastEventTimestamp: new Date(),
capturedTaskId: undefined,
capturedTaskState: undefined,
previousTaskState: undefined,
taskIdCapturedAtBlockIndex: undefined,
latestUsage: undefined,
});
const createMockState = (overrides?: Partial<StreamingState>): StreamingState => ({
contentBlocks: [],
activeTextBlock: null,
lastEventTimestamp: new Date(),
capturedTaskId: undefined,
capturedTaskState: undefined,
previousTaskState: undefined,
taskIdCapturedAtBlockIndex: undefined,
latestUsage: undefined,
...overrides,
});

const createMockMessage = (): ChatMessage => ({
id: 'test-msg-1',
role: 'assistant',
contextId: 'test-context',
timestamp: new Date(),
contentBlocks: [],
});
const createMockMessage = (): ChatMessage => ({
id: 'test-msg-1',
role: 'assistant',
contextId: 'test-context',
timestamp: new Date(),
contentBlocks: [],
});

const makeArtifactEvent = (
text: string,
opts: { append?: boolean; lastChunk?: boolean } = {},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Biome] reported by reviewdog 🐶

Suggested change
opts: { append?: boolean; lastChunk?: boolean } = {},
opts: { append?: boolean; lastChunk?: boolean } = {}

): TaskArtifactUpdateEvent => ({
kind: 'artifact-update',
contextId: 'test-context',
taskId: 'task-123',
append: opts.append,
lastChunk: opts.lastChunk,
artifact: {
artifactId: 'artifact-1',
parts: text ? [{ kind: 'text', text }] : [],
},
});

describe('handleStatusUpdateEvent', () => {
test('should allow normal agent messages through', () => {
const state = createMockState();
const assistantMessage = createMockMessage();
Expand All @@ -56,7 +72,7 @@ describe('artifact duplication bug', () => {
parts: [
{
kind: 'text',
text: 'Artifact created successfully.\n\n- Name: Test Markdown Artifact\n- Description: A concise markdown sample\n- Artifact ID: artifact-d3trg0tuui6c73cprmj0',
text: 'Artifact created successfully.\n\n- Name: Test Markdown Artifact',
},
],
},
Expand All @@ -65,9 +81,83 @@ describe('artifact duplication bug', () => {

handleStatusUpdateEvent(event, state, assistantMessage, onMessageUpdate);

// SHOULD create task-status-update block (normal message, not tool-related)
const statusBlocks = state.contentBlocks.filter((b) => b.type === 'task-status-update');
expect(statusBlocks).toHaveLength(1);
expect(statusBlocks[0].text).toContain('Artifact created successfully');
expect(statusBlocks[0].type === 'task-status-update' && statusBlocks[0].text).toContain(
'Artifact created successfully',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Biome] reported by reviewdog 🐶

Suggested change
'Artifact created successfully',
'Artifact created successfully'

);
});
});

describe('handleArtifactUpdateEvent', () => {
/**
* Regression test for React Compiler memoization bug.
*
* The handler mutated activeTextBlock in place and passed the same reference
* to onMessageUpdate. React Compiler's auto-memoization detected the unchanged
* reference and skipped re-renders, so only the first chunk was displayed.
*
* The fix clones the artifact block for each UI update so React sees a new
* object on every call.
*/
test('each streaming chunk must produce a new artifact block reference', () => {
const state = createMockState({ capturedTaskId: 'task-123' });
const assistantMessage = createMockMessage();
const onMessageUpdate = vi.fn();

// Simulate 3 streaming chunks
handleArtifactUpdateEvent(makeArtifactEvent('Hello'), state, assistantMessage, onMessageUpdate);
handleArtifactUpdateEvent(makeArtifactEvent(' world', { append: true }), state, assistantMessage, onMessageUpdate);
handleArtifactUpdateEvent(makeArtifactEvent('!', { append: true }), state, assistantMessage, onMessageUpdate);

expect(onMessageUpdate).toHaveBeenCalledTimes(3);

const getArtifactBlock = (callIndex: number) => {
const msg = onMessageUpdate.mock.calls[callIndex][0] as ChatMessage;
return msg.contentBlocks.find((b) => b.type === 'artifact');
};

const block1 = getArtifactBlock(0);
const block2 = getArtifactBlock(1);
const block3 = getArtifactBlock(2);

// References must differ so React detects the change
expect(block1).not.toBe(block2);
expect(block2).not.toBe(block3);

// Text must accumulate
expect(block1?.type === 'artifact' && block1.parts[0]?.kind === 'text' && block1.parts[0].text).toBe('Hello');
expect(block2?.type === 'artifact' && block2.parts[0]?.kind === 'text' && block2.parts[0].text).toBe(
'Hello world',
);
Comment on lines +130 to +132
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Biome] reported by reviewdog 🐶

Suggested change
expect(block2?.type === 'artifact' && block2.parts[0]?.kind === 'text' && block2.parts[0].text).toBe(
'Hello world',
);
expect(block2?.type === 'artifact' && block2.parts[0]?.kind === 'text' && block2.parts[0].text).toBe('Hello world');

expect(block3?.type === 'artifact' && block3.parts[0]?.kind === 'text' && block3.parts[0].text).toBe(
'Hello world!',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Biome] reported by reviewdog 🐶

Suggested change
'Hello world!',
'Hello world!'

);
});

test('lastChunk finalizes the artifact into contentBlocks', () => {
const state = createMockState({ capturedTaskId: 'task-123' });
const assistantMessage = createMockMessage();
const onMessageUpdate = vi.fn();

handleArtifactUpdateEvent(makeArtifactEvent('Hello'), state, assistantMessage, onMessageUpdate);
handleArtifactUpdateEvent(makeArtifactEvent(' world', { append: true }), state, assistantMessage, onMessageUpdate);
// lastChunk with empty parts (matches real backend behavior)
handleArtifactUpdateEvent(
makeArtifactEvent('', { append: true, lastChunk: true }),
state,
assistantMessage,
onMessageUpdate,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Biome] reported by reviewdog 🐶

Suggested change
onMessageUpdate,
onMessageUpdate

);

// activeTextBlock should be cleared
expect(state.activeTextBlock).toBeNull();

// Artifact should be persisted in contentBlocks with accumulated text
const artifacts = state.contentBlocks.filter((b) => b.type === 'artifact');
expect(artifacts).toHaveLength(1);
expect(artifacts[0].type === 'artifact' && artifacts[0].parts[0]?.kind === 'text' && artifacts[0].parts[0].text).toBe(
'Hello world',
);
Comment on lines +159 to +161
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Biome] reported by reviewdog 🐶

Suggested change
expect(artifacts[0].type === 'artifact' && artifacts[0].parts[0]?.kind === 'text' && artifacts[0].parts[0].text).toBe(
'Hello world',
);
expect(
artifacts[0].type === 'artifact' && artifacts[0].parts[0]?.kind === 'text' && artifacts[0].parts[0].text
).toBe('Hello world');

});
});
Original file line number Diff line number Diff line change
Expand Up @@ -400,9 +400,15 @@ export const handleArtifactUpdateEvent = (
}

// Build message with current blocks + active artifact block (if streaming)
// IMPORTANT: Clone the active artifact block so React detects the change.
// The activeTextBlock is mutated in place during streaming, so pushing the
// same object reference causes React to skip re-renders.
const currentBlocks = [...state.contentBlocks];
if (state.activeTextBlock && state.activeTextBlock.type === 'artifact') {
currentBlocks.push(state.activeTextBlock);
currentBlocks.push({
...state.activeTextBlock,
parts: state.activeTextBlock.parts.map((p) => ({ ...p })),
});
}

const updatedMessage = buildMessageWithContentBlocks({
Expand Down
Loading