symposium 2.4.3 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,698 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import Agent from '../Agent.js';
5
+ import Symposium from '../Symposium.js';
6
+ import Model from '../Model.js';
7
+ import Message from '../Message.js';
8
+ import Thread from '../Thread.js';
9
+ import Toolkit from '../Toolkit.js';
10
+
11
+ import {drain} from './helpers/mockSdk.js';
12
+ import {createInputChannel} from '../InputChannel.js';
13
+
14
+ // A FakeModel whose generate() is supplied per-instance, so each test can script its own behavior.
15
+ class ScriptedModel extends Model {
16
+ constructor(label, script) {
17
+ super();
18
+ this.label = label;
19
+ this.script = script;
20
+ this.calls = 0;
21
+ }
22
+
23
+ async getModels() {
24
+ return new Map([
25
+ [this.label, {
26
+ name: this.label,
27
+ tokens: 1000,
28
+ tools: true,
29
+ structured_output: false,
30
+ }],
31
+ ]);
32
+ }
33
+
34
+ async *generate(_model, _thread, _functions, _options) {
35
+ const turn = this.script[this.calls++];
36
+ if (!turn)
37
+ throw new Error('No more scripted turns for model ' + this.label);
38
+ if (turn.throwBefore)
39
+ throw turn.throwBefore;
40
+ for (const delta of turn.deltas || []) {
41
+ yield delta;
42
+ if (delta._thenThrow)
43
+ throw delta._thenThrow;
44
+ }
45
+ return turn.messages;
46
+ }
47
+ }
48
+
49
+ async function makeThread(agent, label) {
50
+ const thread = new Thread('test-' + label, agent);
51
+ thread.state = {model: label};
52
+ return thread;
53
+ }
54
+
55
+ // ────────────────────────────────────────────────────────────────────────────────
56
+ // Preserved from Phase 1: generateCompletion still drains the model generator
57
+ // and returns Message[] as its async-generator return value.
58
+ // ────────────────────────────────────────────────────────────────────────────────
59
+ test('Agent.generateCompletion forwards text_delta as chunks and returns Message[]', async () => {
60
+ const label = 'fake-gen-completion';
61
+ await Symposium.loadModel(new ScriptedModel(label, [{
62
+ deltas: [
63
+ {type: 'text_delta', content: 'Hello'},
64
+ {type: 'text_delta', content: ' world'},
65
+ ],
66
+ messages: [new Message('assistant', [{type: 'text', content: 'Hello world'}])],
67
+ }]));
68
+
69
+ const agent = new Agent();
70
+ agent.default_model = label;
71
+ await agent.init();
72
+
73
+ const thread = await makeThread(agent, label);
74
+
75
+ const {deltas, value} = await drain(agent.generateCompletion(thread));
76
+
77
+ assert.deepEqual(deltas, [
78
+ {type: 'chunk', content: 'Hello'},
79
+ {type: 'chunk', content: ' world'},
80
+ ]);
81
+ assert.equal(value.length, 1);
82
+ assert.ok(value[0] instanceof Message);
83
+ assert.equal(value[0].role, 'assistant');
84
+ assert.deepEqual(value[0].content, [{type: 'text', content: 'Hello world'}]);
85
+ });
86
+
87
+ // ────────────────────────────────────────────────────────────────────────────────
88
+ // Chat happy path
89
+ // ────────────────────────────────────────────────────────────────────────────────
90
+ test('chat agent.message() yields start → chunk* → output → end', async () => {
91
+ const label = 'fake-chat-happy';
92
+ await Symposium.loadModel(new ScriptedModel(label, [{
93
+ deltas: [
94
+ {type: 'text_delta', content: 'Hi'},
95
+ {type: 'text_delta', content: ' there'},
96
+ ],
97
+ messages: [new Message('assistant', [{type: 'text', content: 'Hi there'}])],
98
+ }]));
99
+
100
+ const agent = new Agent();
101
+ agent.default_model = label;
102
+ await agent.init();
103
+
104
+ const thread = await makeThread(agent, label);
105
+
106
+ const events = [];
107
+ for await (const ev of agent.message('Hello', thread))
108
+ events.push(ev);
109
+
110
+ const types = events.map(e => e.type);
111
+ assert.deepEqual(types, ['start', 'chunk', 'chunk', 'output', 'end']);
112
+ assert.equal(events[1].content, 'Hi');
113
+ assert.equal(events[2].content, ' there');
114
+ assert.deepEqual(events[3].content, {type: 'text', content: 'Hi there'});
115
+ });
116
+
117
+ // ────────────────────────────────────────────────────────────────────────────────
118
+ // Tool loop: first turn calls a function; second turn answers in text.
119
+ // ────────────────────────────────────────────────────────────────────────────────
120
+ test('chat agent runs a tool then yields tool/tool_response/output', async () => {
121
+ const label = 'fake-chat-tool';
122
+ await Symposium.loadModel(new ScriptedModel(label, [
123
+ {
124
+ deltas: [],
125
+ messages: [new Message('assistant', [
126
+ {type: 'tool_call', content: [{id: 'call_1', name: 'echo', arguments: {msg: 'hi'}}]},
127
+ ])],
128
+ },
129
+ {
130
+ deltas: [{type: 'text_delta', content: 'Done'}],
131
+ messages: [new Message('assistant', [{type: 'text', content: 'Done'}])],
132
+ },
133
+ ]));
134
+
135
+ class EchoTool extends Toolkit {
136
+ name = 'echo';
137
+ async getTools() {
138
+ return [{name: 'echo', description: 'echoes', parameters: {type: 'object', properties: {msg: {type: 'string'}}}}];
139
+ }
140
+ async callTool(_thread, _name, payload) {
141
+ return {echoed: payload.msg};
142
+ }
143
+ }
144
+
145
+ const agent = new Agent();
146
+ agent.default_model = label;
147
+ await agent.addToolkit(new EchoTool());
148
+ await agent.init();
149
+
150
+ const thread = await makeThread(agent, label);
151
+
152
+ const events = [];
153
+ for await (const ev of agent.message('say hi', thread))
154
+ events.push(ev);
155
+
156
+ const types = events.map(e => e.type);
157
+ assert.deepEqual(types, ['start', 'tool', 'tool_response', 'chunk', 'output', 'end']);
158
+
159
+ const toolEv = events[1];
160
+ assert.equal(toolEv.name, 'echo');
161
+ assert.equal(toolEv.id, 'call_1');
162
+ assert.deepEqual(toolEv.arguments, {msg: 'hi'});
163
+
164
+ const respEv = events[2];
165
+ assert.equal(respEv.success, true);
166
+ assert.deepEqual(respEv.response, {echoed: 'hi'});
167
+ });
168
+
169
+ // ────────────────────────────────────────────────────────────────────────────────
170
+ // Utility text agent — direct await returns the raw text value (no generator)
171
+ // ────────────────────────────────────────────────────────────────────────────────
172
+ test('utility text agent: await message() returns the text directly', async () => {
173
+ const label = 'fake-utility-text';
174
+ await Symposium.loadModel(new ScriptedModel(label, [{
175
+ deltas: [],
176
+ messages: [new Message('assistant', [{type: 'text', content: 'The answer is 42'}])],
177
+ }]));
178
+
179
+ const agent = new Agent();
180
+ agent.default_model = label;
181
+ agent.type = 'utility';
182
+ await agent.init();
183
+
184
+ const thread = await makeThread(agent, label);
185
+ const value = await agent.message('what?', thread);
186
+ assert.equal(value, 'The answer is 42');
187
+ });
188
+
189
+ // ────────────────────────────────────────────────────────────────────────────────
190
+ // Utility agent with response_schema, structured-output path: text content IS JSON
191
+ // ────────────────────────────────────────────────────────────────────────────────
192
+ test('utility agent with response_schema (structured_output): await returns parsed object', async () => {
193
+ const label = 'fake-utility-json-structured';
194
+ class StructuredScriptedModel extends ScriptedModel {
195
+ async getModels() {
196
+ return new Map([[this.label, {name: this.label, tokens: 1000, tools: true, structured_output: true}]]);
197
+ }
198
+ }
199
+ await Symposium.loadModel(new StructuredScriptedModel(label, [{
200
+ deltas: [],
201
+ messages: [new Message('assistant', [{type: 'text', content: '{"name":"John","email":"john@example.com"}'}])],
202
+ }]));
203
+
204
+ const agent = new Agent();
205
+ agent.default_model = label;
206
+ agent.type = 'utility';
207
+ agent.response_schema = {
208
+ type: 'object',
209
+ properties: {
210
+ name: {type: 'string'},
211
+ email: {type: 'string'},
212
+ },
213
+ required: ['name', 'email'],
214
+ };
215
+ await agent.init();
216
+
217
+ const thread = await makeThread(agent, label);
218
+ const value = await agent.message('My name is John, email john@example.com', thread);
219
+ assert.deepEqual(value, {name: 'John', email: 'john@example.com'});
220
+ });
221
+
222
+ // ────────────────────────────────────────────────────────────────────────────────
223
+ // Utility agent with response_schema, function-call fallback (no structured_output)
224
+ // ────────────────────────────────────────────────────────────────────────────────
225
+ test('utility agent with response_schema (function-call fallback): await returns parsed args', async () => {
226
+ const label = 'fake-utility-json-funccall';
227
+ await Symposium.loadModel(new ScriptedModel(label, [{
228
+ deltas: [],
229
+ messages: [new Message('assistant', [
230
+ {type: 'tool_call', content: [{id: 'call_r', name: 'response', arguments: {name: 'Jane', email: 'jane@example.com'}}]},
231
+ ])],
232
+ }]));
233
+
234
+ const agent = new Agent();
235
+ agent.default_model = label;
236
+ agent.type = 'utility';
237
+ agent.response_schema = {
238
+ type: 'object',
239
+ properties: {
240
+ name: {type: 'string'},
241
+ email: {type: 'string'},
242
+ },
243
+ required: ['name', 'email'],
244
+ };
245
+ await agent.init();
246
+
247
+ const thread = await makeThread(agent, label);
248
+ const value = await agent.message('Extract Jane jane@example.com', thread);
249
+ assert.deepEqual(value, {name: 'Jane', email: 'jane@example.com'});
250
+ });
251
+
252
+ // ────────────────────────────────────────────────────────────────────────────────
253
+ // Chat agent with response_schema: events stream, final {type:'result', value}
254
+ // ────────────────────────────────────────────────────────────────────────────────
255
+ test('chat agent with response_schema yields normal events plus final result event', async () => {
256
+ const label = 'fake-chat-schema';
257
+ class StructuredScriptedModel extends ScriptedModel {
258
+ async getModels() {
259
+ return new Map([[this.label, {name: this.label, tokens: 1000, tools: true, structured_output: true}]]);
260
+ }
261
+ }
262
+ await Symposium.loadModel(new StructuredScriptedModel(label, [{
263
+ deltas: [{type: 'text_delta', content: '{"city":"Rome"}'}],
264
+ messages: [new Message('assistant', [{type: 'text', content: '{"city":"Rome"}'}])],
265
+ }]));
266
+
267
+ const agent = new Agent();
268
+ agent.default_model = label;
269
+ agent.response_schema = {
270
+ type: 'object',
271
+ properties: {city: {type: 'string'}},
272
+ required: ['city'],
273
+ };
274
+ await agent.init();
275
+
276
+ const thread = await makeThread(agent, label);
277
+
278
+ const events = [];
279
+ for await (const ev of agent.message('Where?', thread))
280
+ events.push(ev);
281
+
282
+ const types = events.map(e => e.type);
283
+ assert.deepEqual(types, ['start', 'chunk', 'result', 'end']);
284
+ assert.deepEqual(events[2].value, {city: 'Rome'});
285
+ });
286
+
287
+ // ────────────────────────────────────────────────────────────────────────────────
288
+ // Tool authorization: tool.authorize() returns false, generator suspends until an
289
+ // {type:'auth'} control message arrives on the input channel.
290
+ // ────────────────────────────────────────────────────────────────────────────────
291
+ test('tools_auth suspends until {type:"auth", decision:"approve"} resumes the run', async () => {
292
+ const label = 'fake-chat-auth';
293
+ await Symposium.loadModel(new ScriptedModel(label, [
294
+ {
295
+ deltas: [],
296
+ messages: [new Message('assistant', [
297
+ {type: 'tool_call', content: [{id: 'call_a', name: 'sensitive', arguments: {x: 1}}]},
298
+ ])],
299
+ },
300
+ {
301
+ deltas: [{type: 'text_delta', content: 'Approved'}],
302
+ messages: [new Message('assistant', [{type: 'text', content: 'Approved'}])],
303
+ },
304
+ ]));
305
+
306
+ class SensitiveTool extends Toolkit {
307
+ name = 'sensitive';
308
+ async getTools() {
309
+ return [{name: 'sensitive', description: 'guarded', parameters: {type: 'object', properties: {x: {type: 'number'}}}}];
310
+ }
311
+ async authorize() { return false; }
312
+ async callTool() { return {ok: true}; }
313
+ }
314
+
315
+ const agent = new Agent();
316
+ agent.default_model = label;
317
+ await agent.addToolkit(new SensitiveTool());
318
+ await agent.init();
319
+
320
+ const thread = await makeThread(agent, label);
321
+
322
+ const input = createInputChannel();
323
+ input.send('do the thing');
324
+
325
+ const events = [];
326
+ for await (const ev of agent.message(input, thread)) {
327
+ events.push(ev);
328
+ if (ev.type === 'tools_auth')
329
+ input.send({type: 'auth', id: ev.id, decision: 'approve'});
330
+ if (ev.type === 'output')
331
+ input.close();
332
+ }
333
+
334
+ const types = events.map(e => e.type);
335
+ assert.deepEqual(types, ['start', 'tools_auth', 'tool', 'tool_response', 'chunk', 'output', 'end']);
336
+ assert.ok(events[1].id);
337
+ assert.equal(events[1].tools[0].name, 'sensitive');
338
+ });
339
+
340
+ // ────────────────────────────────────────────────────────────────────────────────
341
+ // Tool authorization: reject ends the run without invoking the tool.
342
+ // ────────────────────────────────────────────────────────────────────────────────
343
+ test('{type:"auth", decision:"reject"} drops the tool call and ends the run', async () => {
344
+ const label = 'fake-chat-auth-reject';
345
+ await Symposium.loadModel(new ScriptedModel(label, [
346
+ {
347
+ deltas: [],
348
+ messages: [new Message('assistant', [
349
+ {type: 'tool_call', content: [{id: 'call_b', name: 'guarded', arguments: {}}]},
350
+ ])],
351
+ },
352
+ ]));
353
+
354
+ class GuardedTool extends Toolkit {
355
+ name = 'guarded';
356
+ called = 0;
357
+ async getTools() {
358
+ return [{name: 'guarded', description: 'guarded', parameters: {type: 'object', properties: {}}}];
359
+ }
360
+ async authorize() { return false; }
361
+ async callTool() { this.called++; return {nope: true}; }
362
+ }
363
+
364
+ const guarded = new GuardedTool();
365
+ const agent = new Agent();
366
+ agent.default_model = label;
367
+ await agent.addToolkit(guarded);
368
+ await agent.init();
369
+
370
+ const thread = await makeThread(agent, label);
371
+
372
+ const input = createInputChannel();
373
+ input.send('attempt');
374
+
375
+ const events = [];
376
+ for await (const ev of agent.message(input, thread)) {
377
+ events.push(ev);
378
+ if (ev.type === 'tools_auth') {
379
+ input.send({type: 'auth', id: ev.id, decision: 'reject'});
380
+ input.close();
381
+ }
382
+ }
383
+
384
+ const types = events.map(e => e.type);
385
+ assert.deepEqual(types, ['start', 'tools_auth', 'end']);
386
+ assert.equal(guarded.called, 0);
387
+ });
388
+
389
+ // ────────────────────────────────────────────────────────────────────────────────
390
+ // Tool authorization: closing the input channel without an auth response is
391
+ // treated as reject + cancel — the tool never runs and the loop ends.
392
+ // ────────────────────────────────────────────────────────────────────────────────
393
+ test('input channel closing without auth response rejects and cancels', async () => {
394
+ const label = 'fake-chat-auth-close';
395
+ await Symposium.loadModel(new ScriptedModel(label, [
396
+ {
397
+ deltas: [],
398
+ messages: [new Message('assistant', [
399
+ {type: 'tool_call', content: [{id: 'call_c', name: 'unguarded_close', arguments: {}}]},
400
+ ])],
401
+ },
402
+ ]));
403
+
404
+ class CloseTool extends Toolkit {
405
+ name = 'unguarded_close';
406
+ called = 0;
407
+ async getTools() {
408
+ return [{name: 'unguarded_close', description: 'guarded', parameters: {type: 'object', properties: {}}}];
409
+ }
410
+ async authorize() { return false; }
411
+ async callTool() { this.called++; return {nope: true}; }
412
+ }
413
+
414
+ const tool = new CloseTool();
415
+ const agent = new Agent();
416
+ agent.default_model = label;
417
+ await agent.addToolkit(tool);
418
+ await agent.init();
419
+
420
+ const thread = await makeThread(agent, label);
421
+
422
+ const input = createInputChannel();
423
+ input.send('attempt');
424
+
425
+ const events = [];
426
+ for await (const ev of agent.message(input, thread)) {
427
+ events.push(ev);
428
+ if (ev.type === 'tools_auth')
429
+ input.close();
430
+ }
431
+
432
+ const types = events.map(e => e.type);
433
+ assert.deepEqual(types, ['start', 'tools_auth', 'end']);
434
+ assert.equal(tool.called, 0);
435
+ });
436
+
437
+ // ────────────────────────────────────────────────────────────────────────────────
438
+ // Phase 3 — Streaming input
439
+ // ────────────────────────────────────────────────────────────────────────────────
440
+
441
+ test('streaming input — channel.send(string) + close() runs one turn like a plain string', async () => {
442
+ const label = 'fake-stream-basic';
443
+ await Symposium.loadModel(new ScriptedModel(label, [{
444
+ deltas: [{type: 'text_delta', content: 'Hi'}],
445
+ messages: [new Message('assistant', [{type: 'text', content: 'Hi'}])],
446
+ }]));
447
+
448
+ const agent = new Agent();
449
+ agent.default_model = label;
450
+ await agent.init();
451
+
452
+ const thread = await makeThread(agent, label);
453
+
454
+ const input = createInputChannel();
455
+ input.send('Hello');
456
+ input.close();
457
+
458
+ const events = [];
459
+ for await (const ev of agent.message(input, thread))
460
+ events.push(ev);
461
+
462
+ const types = events.map(e => e.type);
463
+ assert.deepEqual(types, ['start', 'chunk', 'output', 'end']);
464
+ assert.equal(events[1].content, 'Hi');
465
+ });
466
+
467
+ test('streaming input — second message after first turn triggers another turn', async () => {
468
+ const label = 'fake-stream-second-turn';
469
+ await Symposium.loadModel(new ScriptedModel(label, [
470
+ {
471
+ deltas: [{type: 'text_delta', content: 'First'}],
472
+ messages: [new Message('assistant', [{type: 'text', content: 'First'}])],
473
+ },
474
+ {
475
+ deltas: [{type: 'text_delta', content: 'Second'}],
476
+ messages: [new Message('assistant', [{type: 'text', content: 'Second'}])],
477
+ },
478
+ ]));
479
+
480
+ const agent = new Agent();
481
+ agent.default_model = label;
482
+ await agent.init();
483
+
484
+ const thread = await makeThread(agent, label);
485
+
486
+ const input = createInputChannel();
487
+ input.send('first');
488
+
489
+ const events = [];
490
+ const gen = agent.message(input, thread);
491
+
492
+ let firstOutputSeen = false;
493
+ let step = await gen.next();
494
+ while (!step.done) {
495
+ events.push(step.value);
496
+ if (step.value.type === 'output' && !firstOutputSeen) {
497
+ firstOutputSeen = true;
498
+ queueMicrotask(() => {
499
+ input.send('second');
500
+ queueMicrotask(() => input.close());
501
+ });
502
+ }
503
+ step = await gen.next();
504
+ }
505
+
506
+ const types = events.map(e => e.type);
507
+ assert.deepEqual(types, ['start', 'chunk', 'output', 'chunk', 'output', 'end']);
508
+ const outputs = events.filter(e => e.type === 'output').map(e => e.content.content);
509
+ assert.deepEqual(outputs, ['First', 'Second']);
510
+ });
511
+
512
+ test('streaming input — submit terminates initial buildup, concatenates pieces', async () => {
513
+ const label = 'fake-stream-submit';
514
+ let observedUserContent = null;
515
+
516
+ class CapturingModel extends ScriptedModel {
517
+ async *generate(_model, thread, _functions, _options) {
518
+ for (let m of thread.messages) {
519
+ if (m.role === 'user')
520
+ observedUserContent = m.content;
521
+ }
522
+ const turn = this.script[this.calls++];
523
+ for (const delta of turn.deltas || [])
524
+ yield delta;
525
+ return turn.messages;
526
+ }
527
+ }
528
+
529
+ await Symposium.loadModel(new CapturingModel(label, [{
530
+ deltas: [{type: 'text_delta', content: 'Ok'}],
531
+ messages: [new Message('assistant', [{type: 'text', content: 'Ok'}])],
532
+ }]));
533
+
534
+ const agent = new Agent();
535
+ agent.default_model = label;
536
+ await agent.init();
537
+
538
+ const thread = await makeThread(agent, label);
539
+
540
+ const input = createInputChannel();
541
+ input.send('part one');
542
+ input.send('part two');
543
+ input.send({type: 'submit'});
544
+ input.close();
545
+
546
+ const events = [];
547
+ for await (const ev of agent.message(input, thread))
548
+ events.push(ev);
549
+
550
+ assert.ok(observedUserContent, 'user message should reach the model');
551
+ assert.equal(observedUserContent.length, 2);
552
+ assert.equal(observedUserContent[0].content, 'part one');
553
+ assert.equal(observedUserContent[1].content, 'part two');
554
+ });
555
+
556
+ test('streaming input — cancel ends the loop gracefully without starting another turn', async () => {
557
+ const label = 'fake-stream-cancel';
558
+ const model = new ScriptedModel(label, [
559
+ {
560
+ deltas: [{type: 'text_delta', content: 'A'}],
561
+ messages: [new Message('assistant', [{type: 'text', content: 'A'}])],
562
+ },
563
+ ]);
564
+ await Symposium.loadModel(model);
565
+
566
+ const agent = new Agent();
567
+ agent.default_model = label;
568
+ await agent.init();
569
+
570
+ const thread = await makeThread(agent, label);
571
+
572
+ const input = createInputChannel();
573
+ input.send('go');
574
+
575
+ const events = [];
576
+ const gen = agent.message(input, thread);
577
+
578
+ let step = await gen.next();
579
+ while (!step.done) {
580
+ events.push(step.value);
581
+ if (step.value.type === 'output')
582
+ queueMicrotask(() => input.send({type: 'cancel'}));
583
+ step = await gen.next();
584
+ }
585
+
586
+ const types = events.map(e => e.type);
587
+ assert.deepEqual(types, ['start', 'chunk', 'output', 'end']);
588
+ assert.equal(model.calls, 1);
589
+ });
590
+
591
+ test('plain ContentBlock[] input continues to work', async () => {
592
+ const label = 'fake-array-input';
593
+ await Symposium.loadModel(new ScriptedModel(label, [{
594
+ deltas: [{type: 'text_delta', content: 'X'}],
595
+ messages: [new Message('assistant', [{type: 'text', content: 'X'}])],
596
+ }]));
597
+
598
+ const agent = new Agent();
599
+ agent.default_model = label;
600
+ await agent.init();
601
+
602
+ const thread = await makeThread(agent, label);
603
+
604
+ const events = [];
605
+ for await (const ev of agent.message([{type: 'text', content: 'hi'}], thread))
606
+ events.push(ev);
607
+
608
+ const types = events.map(e => e.type);
609
+ assert.deepEqual(types, ['start', 'chunk', 'output', 'end']);
610
+ });
611
+
612
+ // ────────────────────────────────────────────────────────────────────────────────
613
+ // Phase 5 — Hybrid retry: silent retry when no chunk has been yielded yet.
614
+ // ────────────────────────────────────────────────────────────────────────────────
615
+ test('retry is silent when error occurs before any chunk is yielded', async () => {
616
+ const label = 'fake-retry-silent';
617
+ const boom = Object.assign(new Error('boom'), {response: {status: 500, data: 'x'}});
618
+ await Symposium.loadModel(new ScriptedModel(label, [
619
+ {throwBefore: boom},
620
+ {
621
+ deltas: [{type: 'text_delta', content: 'ok'}],
622
+ messages: [new Message('assistant', [{type: 'text', content: 'ok'}])],
623
+ },
624
+ ]));
625
+
626
+ const agent = new Agent();
627
+ agent.default_model = label;
628
+ await agent.init();
629
+
630
+ const thread = await makeThread(agent, label);
631
+
632
+ const events = [];
633
+ for await (const ev of agent.message('hi', thread))
634
+ events.push(ev);
635
+
636
+ const types = events.map(e => e.type);
637
+ assert.deepEqual(types, ['start', 'chunk', 'output', 'end']);
638
+ assert.equal(events.find(e => e.type === 'retry'), undefined);
639
+ });
640
+
641
+ // ────────────────────────────────────────────────────────────────────────────────
642
+ // Phase 5 — Hybrid retry: visible retry when at least one chunk has been yielded.
643
+ // ────────────────────────────────────────────────────────────────────────────────
644
+ test('retry event is yielded when error occurs after a chunk', async () => {
645
+ const label = 'fake-retry-visible';
646
+ await Symposium.loadModel(new ScriptedModel(label, [
647
+ {
648
+ deltas: [{type: 'text_delta', content: 'partial', _thenThrow: new Error('mid-stream blew up')}],
649
+ messages: [],
650
+ },
651
+ {
652
+ deltas: [{type: 'text_delta', content: 'done'}],
653
+ messages: [new Message('assistant', [{type: 'text', content: 'done'}])],
654
+ },
655
+ ]));
656
+
657
+ const agent = new Agent();
658
+ agent.default_model = label;
659
+ await agent.init();
660
+
661
+ const thread = await makeThread(agent, label);
662
+
663
+ const events = [];
664
+ for await (const ev of agent.message('hi', thread))
665
+ events.push(ev);
666
+
667
+ const types = events.map(e => e.type);
668
+ assert.deepEqual(types, ['start', 'chunk', 'retry', 'chunk', 'output', 'end']);
669
+ const retryEv = events.find(e => e.type === 'retry');
670
+ assert.equal(retryEv.attempt, 1);
671
+ assert.equal(retryEv.reason, 'mid-stream blew up');
672
+ });
673
+
674
+ // ────────────────────────────────────────────────────────────────────────────────
675
+ // Phase 5 — Hybrid retry: exhausted retries surface the error out of the generator.
676
+ // ────────────────────────────────────────────────────────────────────────────────
677
+ test('exhausted retries throw out of the generator', async () => {
678
+ const label = 'fake-retry-exhausted';
679
+ await Symposium.loadModel(new ScriptedModel(label, [
680
+ {throwBefore: new Error('fail-1')},
681
+ {throwBefore: new Error('fail-2')},
682
+ {throwBefore: new Error('fail-3')},
683
+ ]));
684
+
685
+ const agent = new Agent();
686
+ agent.default_model = label;
687
+ agent.max_retries = 2;
688
+ await agent.init();
689
+
690
+ const thread = await makeThread(agent, label);
691
+
692
+ await assert.rejects(
693
+ (async () => {
694
+ for await (const _ev of agent.message('hi', thread)) { /* drain */ }
695
+ })(),
696
+ /fail-3/,
697
+ );
698
+ });