|
1 | 1 | import { describe, expect, it, vi } from 'vitest' |
2 | 2 | import { Agent } from '../../agent/agent.js' |
| 3 | +import { AgentPrinter } from '../../agent/printer.js' |
3 | 4 | import { MockMessageModel } from '../../__fixtures__/mock-message-model.js' |
4 | 5 | import { MockSnapshotStorage } from '../../__fixtures__/mock-storage-provider.js' |
5 | 6 | import { collectGenerator } from '../../__fixtures__/model-test-helpers.js' |
@@ -716,6 +717,106 @@ describe('Graph', () => { |
716 | 717 | ) |
717 | 718 | }) |
718 | 719 |
|
| 720 | + it('does not buffer agent printer output for sequential graphs', async () => { |
| 721 | + const a = makeAgent('a', 'a-reply') |
| 722 | + const b = makeAgent('b', 'b-reply') |
| 723 | + const printerA = new AgentPrinter(() => {}) |
| 724 | + const printerB = new AgentPrinter(() => {}) |
| 725 | + ;(a as unknown as { _printer: AgentPrinter })._printer = printerA |
| 726 | + ;(b as unknown as { _printer: AgentPrinter })._printer = printerB |
| 727 | + |
| 728 | + const graph = new Graph({ |
| 729 | + nodes: [a, b], |
| 730 | + edges: [['a', 'b']], |
| 731 | + }) |
| 732 | + |
| 733 | + const seenA: unknown[] = [] |
| 734 | + const seenB: unknown[] = [] |
| 735 | + const gen = graph.stream('go') |
| 736 | + for await (const event of gen) { |
| 737 | + if (event.type === 'nodeStreamUpdateEvent') { |
| 738 | + if (event.nodeId === 'a') seenA.push((a as unknown as { _printer: AgentPrinter })._printer) |
| 739 | + if (event.nodeId === 'b') seenB.push((b as unknown as { _printer: AgentPrinter })._printer) |
| 740 | + } |
| 741 | + } |
| 742 | + |
| 743 | + expect(seenA.length).toBeGreaterThan(0) |
| 744 | + expect(seenB.length).toBeGreaterThan(0) |
| 745 | + expect(seenA.every((p) => p === printerA)).toBe(true) |
| 746 | + expect(seenB.every((p) => p === printerB)).toBe(true) |
| 747 | + }) |
| 748 | + |
| 749 | + it('buffers agent printer output when topology allows parallel execution', async () => { |
| 750 | + const a = makeAgent('a', 'a-reply') |
| 751 | + const b = makeAgent('b', 'b-reply') |
| 752 | + const printerA = new AgentPrinter(() => {}) |
| 753 | + const printerB = new AgentPrinter(() => {}) |
| 754 | + ;(a as unknown as { _printer: AgentPrinter })._printer = printerA |
| 755 | + ;(b as unknown as { _printer: AgentPrinter })._printer = printerB |
| 756 | + |
| 757 | + // Two sources with no edges between them — both can run concurrently. |
| 758 | + const graph = new Graph({ |
| 759 | + nodes: [a, b], |
| 760 | + edges: [], |
| 761 | + }) |
| 762 | + |
| 763 | + const seenA: unknown[] = [] |
| 764 | + const seenB: unknown[] = [] |
| 765 | + const gen = graph.stream('go') |
| 766 | + for await (const event of gen) { |
| 767 | + if (event.type === 'nodeStreamUpdateEvent') { |
| 768 | + if (event.nodeId === 'a') seenA.push((a as unknown as { _printer: AgentPrinter })._printer) |
| 769 | + if (event.nodeId === 'b') seenB.push((b as unknown as { _printer: AgentPrinter })._printer) |
| 770 | + } |
| 771 | + } |
| 772 | + |
| 773 | + expect(seenA.some((p) => p !== printerA)).toBe(true) |
| 774 | + expect(seenB.some((p) => p !== printerB)).toBe(true) |
| 775 | + expect((a as unknown as { _printer: AgentPrinter })._printer).toBe(printerA) |
| 776 | + expect((b as unknown as { _printer: AgentPrinter })._printer).toBe(printerB) |
| 777 | + }) |
| 778 | + |
| 779 | + it('does not interleave output from parallel nodes on a shared writer', async () => { |
| 780 | + // Fan-out graph (A -> B, A -> C). B and C run in parallel after A completes. |
| 781 | + // Each emits several TextBlocks so their printer writes are naturally |
| 782 | + // interleavable; the buffering behavior guarantees each node's writes |
| 783 | + // land as a contiguous run on the shared writer. |
| 784 | + const a = makeAgent('a', 'a-reply') |
| 785 | + const b = new Agent({ |
| 786 | + model: new MockMessageModel().addTurn(['b-1 ', 'b-2 ', 'b-3'].map((text) => new TextBlock(text))), |
| 787 | + printer: false, |
| 788 | + id: 'b', |
| 789 | + }) |
| 790 | + const c = new Agent({ |
| 791 | + model: new MockMessageModel().addTurn(['c-1 ', 'c-2 ', 'c-3'].map((text) => new TextBlock(text))), |
| 792 | + printer: false, |
| 793 | + id: 'c', |
| 794 | + }) |
| 795 | + |
| 796 | + const writes: string[] = [] |
| 797 | + ;(b as unknown as { _printer: AgentPrinter })._printer = new AgentPrinter((text) => writes.push('b:' + text)) |
| 798 | + ;(c as unknown as { _printer: AgentPrinter })._printer = new AgentPrinter((text) => writes.push('c:' + text)) |
| 799 | + |
| 800 | + const graph = new Graph({ |
| 801 | + nodes: [a, b, c], |
| 802 | + edges: [ |
| 803 | + ['a', 'b'], |
| 804 | + ['a', 'c'], |
| 805 | + ], |
| 806 | + }) |
| 807 | + |
| 808 | + await graph.invoke('go') |
| 809 | + |
| 810 | + // Each node's writes form one contiguous slice — no other node's writes appear between them. |
| 811 | + const bIndices = writes.map((w, i) => (w.startsWith('b:') ? i : -1)).filter((i) => i !== -1) |
| 812 | + const cIndices = writes.map((w, i) => (w.startsWith('c:') ? i : -1)).filter((i) => i !== -1) |
| 813 | + expect(bIndices.length).toBeGreaterThan(0) |
| 814 | + expect(cIndices.length).toBeGreaterThan(0) |
| 815 | + const isContiguous = (indices: number[]): boolean => indices.every((v, i) => i === 0 || v === indices[i - 1]! + 1) |
| 816 | + expect(isContiguous(bIndices)).toBe(true) |
| 817 | + expect(isContiguous(cIndices)).toBe(true) |
| 818 | + }) |
| 819 | + |
719 | 820 | it('cleans up running nodes when consumer breaks mid-stream', async () => { |
720 | 821 | const graph = new Graph({ |
721 | 822 | nodes: [makeAgent('a', 'a-reply'), makeAgent('b', 'b-reply')], |
|
0 commit comments