Skip to content

Commit e98c346

Browse files
committed
fix(a2a-server): delimit SSE events with a blank line in /executeCommand
The streaming `/executeCommand` handler wrote each Server-Sent Event with a single trailing newline (`data: <json>\n`). The SSE specification requires events to be separated by a blank line (`\n\n`); without it a spec-compliant EventSource client coalesces every event into one record and never dispatches a well-formed event, so streaming command output is unusable for real clients. The existing streaming test did not catch this because it used the Mocha-style `done` callback, which Vitest 3 does not honour: the test body returned before its assertions ran, so the test always passed regardless of the payload. Convert that test to async/await so it actually verifies the emitted events. It fails before this fix (1 event parsed instead of 2) and passes after.
1 parent c82e2b5 commit e98c346

2 files changed

Lines changed: 22 additions & 36 deletions

File tree

packages/a2a-server/src/http/app.test.ts

Lines changed: 21 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,9 +1134,7 @@ describe('E2E Tests', () => {
11341134
});
11351135

11361136
describe('/executeCommand streaming', () => {
1137-
it('should execute a streaming command and stream back events', (done: (
1138-
err?: unknown,
1139-
) => void) => {
1137+
it('should execute a streaming command and stream back events', async () => {
11401138
const executeSpy = vi.fn(async (context: CommandContext) => {
11411139
context.eventBus?.publish({
11421140
kind: 'status-update',
@@ -1164,42 +1162,30 @@ describe('E2E Tests', () => {
11641162
vi.spyOn(commandRegistry, 'get').mockReturnValue(mockStreamCommand);
11651163

11661164
const agent = request.agent(app);
1167-
agent
1165+
const res = await agent
11681166
.post('/executeCommand')
11691167
.send({ command: 'stream-test', args: [] })
11701168
.set('Content-Type', 'application/json')
11711169
.set('Accept', 'text/event-stream')
1172-
.on('response', (res) => {
1173-
let data = '';
1174-
res.on('data', (chunk: Buffer) => {
1175-
data += chunk.toString();
1176-
});
1177-
res.on('end', () => {
1178-
try {
1179-
const events = streamToSSEEvents(data);
1180-
expect(events.length).toBe(2);
1181-
expect(events[0].result).toEqual({
1182-
kind: 'status-update',
1183-
status: { state: 'working' },
1184-
taskId: 'test-task',
1185-
contextId: 'test-context',
1186-
final: false,
1187-
});
1188-
expect(events[1].result).toEqual({
1189-
kind: 'status-update',
1190-
status: { state: 'completed' },
1191-
taskId: 'test-task',
1192-
contextId: 'test-context',
1193-
final: true,
1194-
});
1195-
expect(executeSpy).toHaveBeenCalled();
1196-
done();
1197-
} catch (e) {
1198-
done(e);
1199-
}
1200-
});
1201-
})
1202-
.end();
1170+
.expect(200);
1171+
1172+
const events = streamToSSEEvents(res.text);
1173+
expect(events.length).toBe(2);
1174+
expect(events[0].result).toEqual({
1175+
kind: 'status-update',
1176+
status: { state: 'working' },
1177+
taskId: 'test-task',
1178+
contextId: 'test-context',
1179+
final: false,
1180+
});
1181+
expect(events[1].result).toEqual({
1182+
kind: 'status-update',
1183+
status: { state: 'completed' },
1184+
taskId: 'test-task',
1185+
contextId: 'test-context',
1186+
final: true,
1187+
});
1188+
expect(executeSpy).toHaveBeenCalled();
12031189
});
12041190

12051191
it('should handle non-streaming commands gracefully', async () => {

packages/a2a-server/src/http/app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ async function handleExecuteCommand(
167167
id: 'taskId' in event ? event.taskId : (event as Message).messageId,
168168
result: event,
169169
};
170-
res.write(`data: ${JSON.stringify(jsonRpcResponse)}\n`);
170+
res.write(`data: ${JSON.stringify(jsonRpcResponse)}\n\n`);
171171
};
172172
eventBus.on('event', eventHandler);
173173

0 commit comments

Comments
 (0)