|
3 | 3 |
|
4 | 4 | from cubepi.providers.base import ( |
5 | 5 | Model, |
| 6 | + StreamEvent, |
6 | 7 | StreamOptions, |
7 | 8 | TextContent, |
8 | 9 | ToolDefinition, |
@@ -709,6 +710,11 @@ def bad_factory(messages, model): |
709 | 710 | assert result.stop_reason == "error" |
710 | 711 | assert "base boom" in (result.error_message or "") |
711 | 712 |
|
| 713 | + # The re-raised BaseException surfaces on the producer task |
| 714 | + assert stream._producer_task is not None |
| 715 | + assert stream._producer_task.done() |
| 716 | + assert isinstance(stream._producer_task.exception(), CustomBaseException) |
| 717 | + |
712 | 718 |
|
713 | 719 | class TestFauxProviderAbortDuringBlocks: |
714 | 720 | """Tests for abort signal checks during block iteration and chunk streaming.""" |
@@ -749,112 +755,121 @@ def push_and_set_signal(event): |
749 | 755 | assert result.stop_reason == "aborted" |
750 | 756 |
|
751 | 757 | async def test_abort_during_thinking_chunks(self): |
752 | | - """Abort signal set while thinking deltas are being streamed |
753 | | - (lines 340-347).""" |
754 | | - # Use long thinking text to ensure multiple chunks |
| 758 | + """Abort signal set while thinking deltas are being streamed. |
| 759 | +
|
| 760 | + Uses push-patching to set the signal synchronously in the producer, |
| 761 | + avoiding consumer-side races. |
| 762 | + """ |
| 763 | + from cubepi.providers.base import MessageStream |
| 764 | + |
755 | 765 | long_thinking = "a" * 200 |
756 | 766 | provider = FauxProvider(token_size_min=1, token_size_max=1) |
757 | | - provider.set_responses( |
758 | | - [ |
759 | | - faux_assistant_message( |
760 | | - [faux_thinking(long_thinking), faux_text("answer")] |
761 | | - ) |
762 | | - ] |
763 | | - ) |
764 | | - model = self._make_model() |
765 | 767 | signal = asyncio.Event() |
766 | 768 |
|
767 | | - stream = await provider.stream(model, [], options=StreamOptions(signal=signal)) |
| 769 | + message = faux_assistant_message( |
| 770 | + [faux_thinking(long_thinking), faux_text("answer")] |
| 771 | + ) |
768 | 772 |
|
769 | | - events = [] |
| 773 | + ms = MessageStream() |
| 774 | + original_push = ms.push |
770 | 775 | thinking_delta_count = 0 |
771 | | - async for event in stream: |
772 | | - events.append(event) |
| 776 | + |
| 777 | + def push_and_abort(event): |
| 778 | + nonlocal thinking_delta_count |
| 779 | + original_push(event) |
773 | 780 | if event.type == "thinking_delta": |
774 | 781 | thinking_delta_count += 1 |
775 | | - # Abort after a few thinking deltas |
776 | 782 | if thinking_delta_count >= 3: |
777 | 783 | signal.set() |
778 | 784 |
|
779 | | - result = await stream.result() |
| 785 | + ms.push = push_and_abort # type: ignore[assignment] |
| 786 | + |
| 787 | + await provider._stream_with_deltas(ms, message, signal) |
| 788 | + |
| 789 | + result = await ms.result() |
780 | 790 | assert result.stop_reason == "aborted" |
781 | | - assert any(e.type == "error" for e in events) |
782 | | - # We should have some thinking deltas but not all of them |
783 | 791 | assert thinking_delta_count >= 3 |
784 | | - # The text block should NOT have started |
785 | | - event_types = [e.type for e in events] |
| 792 | + event_types = [e.type for e in ms._queue._queue if isinstance(e, StreamEvent)] |
786 | 793 | assert "text_start" not in event_types |
787 | 794 |
|
788 | 795 | async def test_abort_during_tool_call_chunks(self): |
789 | | - """Abort signal set while tool call deltas are being streamed |
790 | | - (lines 425-432).""" |
791 | | - # Use a large arguments dict to produce multiple chunks |
| 796 | + """Abort signal set while tool call deltas are being streamed. |
| 797 | +
|
| 798 | + Uses push-patching to set the signal synchronously in the producer, |
| 799 | + avoiding consumer-side races. |
| 800 | + """ |
| 801 | + from cubepi.providers.base import MessageStream |
| 802 | + |
792 | 803 | large_args = {f"key_{i}": f"value_{i}" for i in range(20)} |
793 | 804 | provider = FauxProvider(token_size_min=1, token_size_max=1) |
794 | | - provider.set_responses( |
795 | | - [ |
796 | | - faux_assistant_message( |
797 | | - [faux_tool_call("search", large_args, id="tc-1")], |
798 | | - stop_reason="tool_use", |
799 | | - ) |
800 | | - ] |
801 | | - ) |
802 | | - model = self._make_model() |
803 | 805 | signal = asyncio.Event() |
804 | 806 |
|
805 | | - stream = await provider.stream(model, [], options=StreamOptions(signal=signal)) |
| 807 | + message = faux_assistant_message( |
| 808 | + [faux_tool_call("search", large_args, id="tc-1")], |
| 809 | + stop_reason="tool_use", |
| 810 | + ) |
806 | 811 |
|
807 | | - events = [] |
| 812 | + ms = MessageStream() |
| 813 | + original_push = ms.push |
808 | 814 | toolcall_delta_count = 0 |
809 | | - async for event in stream: |
810 | | - events.append(event) |
| 815 | + |
| 816 | + def push_and_abort(event): |
| 817 | + nonlocal toolcall_delta_count |
| 818 | + original_push(event) |
811 | 819 | if event.type == "toolcall_delta": |
812 | 820 | toolcall_delta_count += 1 |
813 | | - # Abort after a few tool call deltas |
814 | 821 | if toolcall_delta_count >= 3: |
815 | 822 | signal.set() |
816 | 823 |
|
817 | | - result = await stream.result() |
| 824 | + ms.push = push_and_abort # type: ignore[assignment] |
| 825 | + |
| 826 | + await provider._stream_with_deltas(ms, message, signal) |
| 827 | + |
| 828 | + result = await ms.result() |
818 | 829 | assert result.stop_reason == "aborted" |
819 | | - assert any(e.type == "error" for e in events) |
820 | 830 | assert toolcall_delta_count >= 3 |
821 | | - # Tool call should NOT have ended normally |
822 | | - event_types = [e.type for e in events] |
| 831 | + event_types = [e.type for e in ms._queue._queue if isinstance(e, StreamEvent)] |
823 | 832 | assert "toolcall_end" not in event_types |
824 | 833 |
|
825 | 834 | async def test_abort_during_text_then_tool_blocks(self): |
826 | | - """Abort during text block prevents tool call block from starting.""" |
| 835 | + """Abort during text block prevents tool call block from starting. |
| 836 | +
|
| 837 | + Uses push-patching to set the signal synchronously in the producer, |
| 838 | + avoiding consumer-side races. |
| 839 | + """ |
| 840 | + from cubepi.providers.base import MessageStream |
| 841 | + |
827 | 842 | long_text = "word " * 100 |
828 | 843 | provider = FauxProvider(token_size_min=1, token_size_max=1) |
829 | | - provider.set_responses( |
830 | | - [ |
831 | | - faux_assistant_message( |
832 | | - [ |
833 | | - faux_text(long_text), |
834 | | - faux_tool_call("search", {"q": "test"}, id="tc-1"), |
835 | | - ], |
836 | | - stop_reason="tool_use", |
837 | | - ) |
838 | | - ] |
839 | | - ) |
840 | | - model = self._make_model() |
841 | 844 | signal = asyncio.Event() |
842 | 845 |
|
843 | | - stream = await provider.stream(model, [], options=StreamOptions(signal=signal)) |
| 846 | + message = faux_assistant_message( |
| 847 | + [ |
| 848 | + faux_text(long_text), |
| 849 | + faux_tool_call("search", {"q": "test"}, id="tc-1"), |
| 850 | + ], |
| 851 | + stop_reason="tool_use", |
| 852 | + ) |
844 | 853 |
|
845 | | - events = [] |
| 854 | + ms = MessageStream() |
| 855 | + original_push = ms.push |
846 | 856 | text_delta_count = 0 |
847 | | - async for event in stream: |
848 | | - events.append(event) |
| 857 | + |
| 858 | + def push_and_abort(event): |
| 859 | + nonlocal text_delta_count |
| 860 | + original_push(event) |
849 | 861 | if event.type == "text_delta": |
850 | 862 | text_delta_count += 1 |
851 | 863 | if text_delta_count >= 3: |
852 | 864 | signal.set() |
853 | 865 |
|
854 | | - result = await stream.result() |
| 866 | + ms.push = push_and_abort # type: ignore[assignment] |
| 867 | + |
| 868 | + await provider._stream_with_deltas(ms, message, signal) |
| 869 | + |
| 870 | + result = await ms.result() |
855 | 871 | assert result.stop_reason == "aborted" |
856 | | - # Tool call block should never start |
857 | | - event_types = [e.type for e in events] |
| 872 | + event_types = [e.type for e in ms._queue._queue if isinstance(e, StreamEvent)] |
858 | 873 | assert "toolcall_start" not in event_types |
859 | 874 |
|
860 | 875 |
|
|
0 commit comments