Skip to content

Commit c69992b

Browse files
committed
fix: harden FauxProvider tests against race conditions
Add task exception assertion to BaseException test and refactor three chunk-abort tests to use synchronous push-patching instead of consumer-side signal setting, eliminating timing races.
1 parent 20d4e87 commit c69992b

1 file changed

Lines changed: 77 additions & 62 deletions

File tree

tests/providers/test_faux.py

Lines changed: 77 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from cubepi.providers.base import (
55
Model,
6+
StreamEvent,
67
StreamOptions,
78
TextContent,
89
ToolDefinition,
@@ -709,6 +710,11 @@ def bad_factory(messages, model):
709710
assert result.stop_reason == "error"
710711
assert "base boom" in (result.error_message or "")
711712

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+
712718

713719
class TestFauxProviderAbortDuringBlocks:
714720
"""Tests for abort signal checks during block iteration and chunk streaming."""
@@ -749,112 +755,121 @@ def push_and_set_signal(event):
749755
assert result.stop_reason == "aborted"
750756

751757
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+
755765
long_thinking = "a" * 200
756766
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()
765767
signal = asyncio.Event()
766768

767-
stream = await provider.stream(model, [], options=StreamOptions(signal=signal))
769+
message = faux_assistant_message(
770+
[faux_thinking(long_thinking), faux_text("answer")]
771+
)
768772

769-
events = []
773+
ms = MessageStream()
774+
original_push = ms.push
770775
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)
773780
if event.type == "thinking_delta":
774781
thinking_delta_count += 1
775-
# Abort after a few thinking deltas
776782
if thinking_delta_count >= 3:
777783
signal.set()
778784

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()
780790
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
783791
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)]
786793
assert "text_start" not in event_types
787794

788795
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+
792803
large_args = {f"key_{i}": f"value_{i}" for i in range(20)}
793804
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()
803805
signal = asyncio.Event()
804806

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+
)
806811

807-
events = []
812+
ms = MessageStream()
813+
original_push = ms.push
808814
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)
811819
if event.type == "toolcall_delta":
812820
toolcall_delta_count += 1
813-
# Abort after a few tool call deltas
814821
if toolcall_delta_count >= 3:
815822
signal.set()
816823

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()
818829
assert result.stop_reason == "aborted"
819-
assert any(e.type == "error" for e in events)
820830
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)]
823832
assert "toolcall_end" not in event_types
824833

825834
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+
827842
long_text = "word " * 100
828843
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()
841844
signal = asyncio.Event()
842845

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+
)
844853

845-
events = []
854+
ms = MessageStream()
855+
original_push = ms.push
846856
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)
849861
if event.type == "text_delta":
850862
text_delta_count += 1
851863
if text_delta_count >= 3:
852864
signal.set()
853865

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()
855871
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)]
858873
assert "toolcall_start" not in event_types
859874

860875

0 commit comments

Comments
 (0)