Skip to content

Commit 152b98d

Browse files
authored
fix(examples): reject incomplete multi-agent streams (#2666)
## Summary Require a coordinator `response.completed` event before the multi-agent SSE example treats stream termination as success. The production change is six added lines in the handwritten example. Child-agent completion/failure remains nonterminal for the coordinator. The example still consumes the stream after coordinator completion, preserving explicit root failures, named SSE errors, and transport errors rather than hiding them with an early break. ## Reproduction On current main, the actual executable example exits successfully when the server sends `[DONE]` or closes the SSE body before the coordinator completes—even with only partial coordinator text or a completed/failed child agent. Ten new executable regressions fail before the source change, while fourteen controls pass. ## Validation - Final example suite: 25/25 tests pass on exact Node 22.0.0, Node 24.19.0, and Node 26.7.0. - Covers empty/partial/child-only EOF and `[DONE]`, omitted/null/explicit root ownership, child-status continuation, and a named SSE error received after coordinator completion. - Independent public built CommonJS/ESM checks pass on Node 22/24/26, including later root failures, malformed SSE, transport-error identity, output preservation, and existing sentinel behavior. - Full canonical handwritten suite: 7,817 tests pass across 205 files. - Canonical format, lint, TypeScript checking, build, and `git diff --check` pass. The entire built SDK is byte-for-byte identical to the base build. - Adversarial review completed against the final source and test hashes with no remaining findings. Only the example and its existing regression-test file change. No SDK runtime/parser changes, generated files, dependencies, new payload limits, or WebSocket changes. All inputs and responses are synthetic; no live API calls. This complements the existing explicit-terminal-error handling and the separate WebSocket early-close fix.
1 parent a11bb1b commit 152b98d

2 files changed

Lines changed: 66 additions & 16 deletions

File tree

examples/responses/multi-agent-streaming.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ async function main() {
1919

2020
const agents = new Map<string, string>();
2121
let currentItemID: string | undefined;
22+
let coordinatorCompleted = false;
2223
for await (const event of stream) {
2324
if (event.type === 'response.output_item.added' && event.item.type === 'message') {
2425
agents.set(event.item.id, event.item.agent?.agent_name ?? '/root');
@@ -31,13 +32,18 @@ async function main() {
3132
process.stdout.write(`${separator}━━━ ${role}: ${name} ━━━\n\n`);
3233
}
3334
process.stdout.write(event.delta);
35+
} else if (event.type === 'response.completed' && (!event.agent || event.agent.agent_name === '/root')) {
36+
coordinatorCompleted = true;
3437
} else if (
3538
(event.type === 'response.failed' || event.type === 'response.incomplete') &&
3639
(!event.agent || event.agent.agent_name === '/root')
3740
) {
3841
throw new Error(`Response ended with ${event.type}.`);
3942
}
4043
}
44+
if (!coordinatorCompleted) {
45+
throw new Error('Stream ended before the coordinator response completed.');
46+
}
4147
process.stdout.write('\n');
4248
}
4349

tests/lib/multi-agent-streaming-example.test.ts

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const textEvent: BetaResponseStreamEvent = {
4040
sequence_number: 0,
4141
};
4242

43-
async function runExample(events: BetaResponseStreamEvent[]) {
43+
async function runExample(events: readonly BetaResponseStreamEvent[], ending: 'done' | 'eof' = 'done') {
4444
const requests: { url: string | undefined; body: unknown }[] = [];
4545
const server = createServer((request, response) => {
4646
let body = '';
@@ -53,7 +53,7 @@ async function runExample(events: BetaResponseStreamEvent[]) {
5353
for (const [sequence_number, event] of events.entries()) {
5454
response.write(`event: ${event.type}\ndata: ${JSON.stringify({ ...event, sequence_number })}\n\n`);
5555
}
56-
response.end('data: [DONE]\n\n');
56+
response.end(ending === 'done' ? 'data: [DONE]\n\n' : '');
5757
});
5858
});
5959
server.listen(0, '127.0.0.1');
@@ -131,6 +131,46 @@ test.each([
131131
expect(result.stdout).toBe(partialOutput ? '━━━ Coordinator: /root ━━━\n\nSynthetic answer' : '');
132132
});
133133

134+
test.each(
135+
(['done', 'eof'] as const).flatMap((ending) => [
136+
{ name: 'no events', events: [], ending },
137+
{ name: 'partial coordinator text', events: [textEvent], ending },
138+
...(['completed', 'failed', 'incomplete'] as const).map((status) => ({
139+
name: `only a child with ${status} status`,
140+
events: [{ ...terminalEvent(status), agent: { agent_name: '/root/alpha' } }],
141+
ending,
142+
})),
143+
]),
144+
)('rejects $ending after $name without coordinator completion', async ({ name, events, ending }) => {
145+
const result = await runExample(events, ending);
146+
147+
expect(result.exitCode).toBe(1);
148+
expect(result.stderr).toContain('Stream ended before the coordinator response completed.');
149+
expect(result.stderr).not.toContain('Synthetic private detail');
150+
expect(result.stdout).toBe(
151+
name === 'partial coordinator text' ? '━━━ Coordinator: /root ━━━\n\nSynthetic answer' : '',
152+
);
153+
});
154+
155+
test.each(
156+
(['done', 'eof'] as const).flatMap((ending) =>
157+
[
158+
{ ownership: 'omitted', agent: undefined },
159+
{ ownership: 'null', agent: null },
160+
{ ownership: 'explicit root', agent: { agent_name: '/root' } },
161+
].map((owner) => ({ ...owner, ending })),
162+
),
163+
)('accepts $ending after coordinator completion with $ownership ownership', async ({ agent, ending }) => {
164+
const result = await runExample(
165+
[textEvent, { ...terminalEvent('completed'), ...(agent === undefined ? {} : { agent }) }],
166+
ending,
167+
);
168+
169+
expect(result.exitCode).toBe(0);
170+
expect(result.stderr).toBe('');
171+
expect(result.stdout).toBe('━━━ Coordinator: /root ━━━\n\nSynthetic answer\n');
172+
});
173+
134174
test.each(['completed', 'failed', 'incomplete'] as const)(
135175
'continues to a successful root response after a child response is %s',
136176
async (status) => {
@@ -162,18 +202,22 @@ test.each(['completed', 'failed', 'incomplete'] as const)(
162202
},
163203
);
164204

165-
test('preserves SDK error handling for a named SSE error frame', async () => {
166-
const result = await runExample([
167-
{
168-
type: 'error',
169-
code: 'server_error',
170-
message: 'Synthetic SSE error',
171-
param: null,
172-
sequence_number: 0,
173-
},
174-
]);
205+
test.each([false, true])(
206+
'preserves a named SSE error after coordinator completion: %s',
207+
async (completed) => {
208+
const result = await runExample([
209+
...(completed ? [terminalEvent('completed')] : []),
210+
{
211+
type: 'error',
212+
code: 'server_error',
213+
message: 'Synthetic SSE error',
214+
param: null,
215+
sequence_number: 0,
216+
},
217+
]);
175218

176-
expect(result.exitCode).toBe(1);
177-
expect(result.stderr).toContain('APIError: Synthetic SSE error');
178-
expect(result.stdout).toBe('');
179-
});
219+
expect(result.exitCode).toBe(1);
220+
expect(result.stderr).toContain('APIError: Synthetic SSE error');
221+
expect(result.stdout).toBe('');
222+
},
223+
);

0 commit comments

Comments
 (0)