Skip to content

Commit 43bb3c1

Browse files
test(agentex): cover SSE fallback termination and reclaimed-key edge
Add two integration regression tests that had no coverage: - test_stream_ends_on_late_connect_to_terminal_task: connecting after a task is already terminal never delivers a terminal event, so the keepalive-cadence fallback must end the stream. - test_running_task_stream_survives_reclaimed_key: a still-RUNNING task whose idle stream key is reclaimed by the sliding TTL must NOT be closed (guards the review fix in the prior commit). Both shrink SSE_KEEPALIVE_PING_INTERVAL so the fallback runs in ~1s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a8d4b7e commit 43bb3c1

1 file changed

Lines changed: 107 additions & 0 deletions

File tree

agentex/tests/integration/test_task_stream.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,3 +744,110 @@ async def brief_reader():
744744
)
745745

746746
print("✅ Shared stream survives a single subscriber disconnecting")
747+
748+
async def test_stream_ends_on_late_connect_to_terminal_task(
749+
self, test_agent_and_task, tasks_use_case, streams_use_case
750+
):
751+
"""
752+
Fallback termination path: a viewer that connects *after* the task has
753+
already reached a terminal state never receives a terminal task_updated
754+
event (it is behind the snapshot cursor), so the event-driven exit can't
755+
fire. The keepalive-cadence fallback must still end the stream instead of
756+
blocking on the topic forever.
757+
"""
758+
# Shrink the keepalive interval so the fallback check runs within ~1s
759+
# instead of the production 15s.
760+
streams_use_case.environment_variables.SSE_KEEPALIVE_PING_INTERVAL = 1
761+
762+
_agent, task = test_agent_and_task
763+
764+
# Finish the task BEFORE anyone subscribes. The terminal task_updated is
765+
# XADDed to the stream now, so a later subscriber snapshots past it.
766+
completed = await tasks_use_case.complete_task(id=task.id)
767+
assert completed.status == TaskStatus.COMPLETED
768+
769+
async def drain_until_end():
770+
# Returns normally only if the generator terminates by itself.
771+
async for _event_data in streams_use_case.stream_task_events(
772+
task_id=task.id
773+
):
774+
pass
775+
776+
reader_task = asyncio.create_task(drain_until_end())
777+
778+
# With no terminal event to react to, only the fallback can end it.
779+
try:
780+
await asyncio.wait_for(reader_task, timeout=10)
781+
except TimeoutError as err:
782+
reader_task.cancel()
783+
try:
784+
await reader_task
785+
except asyncio.CancelledError:
786+
pass
787+
raise AssertionError(
788+
"SSE stream to an already-terminal task did not self-close — the "
789+
"fallback termination check is not firing, so late-connect "
790+
"subscriptions leak as zombies."
791+
) from err
792+
793+
print("✅ Stream self-closes when connecting to an already-terminal task")
794+
795+
async def test_running_task_stream_survives_reclaimed_key(
796+
self, test_agent_and_task, streams_use_case
797+
):
798+
"""
799+
Regression for the review finding "expired key ends live stream": a
800+
still-RUNNING task whose idle stream key is reclaimed by the sliding TTL
801+
must NOT be closed. A later XADD recreates the key, so the fallback must
802+
only treat a vanished topic as terminal when the task status is unknown,
803+
never when the task is confirmed non-terminal.
804+
"""
805+
from src.utils.stream_topics import get_task_event_stream_topic
806+
807+
# Fast fallback cadence so the vanished-key check gets a chance to (wrongly)
808+
# fire within the test window if the regression is present.
809+
streams_use_case.environment_variables.SSE_KEEPALIVE_PING_INTERVAL = 1
810+
811+
_agent, task = test_agent_and_task # created in RUNNING state
812+
stream_topic = get_task_event_stream_topic(task_id=task.id)
813+
repo = streams_use_case.stream_repository
814+
815+
# Seed the topic so it has existed (arms the vanished-topic fallback), then
816+
# subscribe.
817+
await repo.send_data(stream_topic, {"type": "error", "message": "seed"})
818+
assert await repo.stream_exists(stream_topic), "precondition: topic exists"
819+
820+
async def reader():
821+
try:
822+
async for _event_data in streams_use_case.stream_task_events(
823+
task_id=task.id
824+
):
825+
pass
826+
except asyncio.CancelledError:
827+
pass
828+
829+
reader_task = asyncio.create_task(reader())
830+
831+
# Let the reader connect and go idle, then simulate the sliding TTL
832+
# reclaiming the key while the task is still RUNNING.
833+
await asyncio.sleep(0.5)
834+
await repo.cleanup_stream(stream_topic)
835+
assert not await repo.stream_exists(stream_topic), "precondition: key reclaimed"
836+
837+
# Give the fallback several cycles to (wrongly) close the stream.
838+
await asyncio.sleep(4)
839+
840+
try:
841+
assert not reader_task.done(), (
842+
"SSE stream for a still-RUNNING task was closed after its idle key "
843+
"was reclaimed — the fallback must not treat a reclaimed key as "
844+
"terminal when the task is confirmed non-terminal."
845+
)
846+
finally:
847+
reader_task.cancel()
848+
try:
849+
await reader_task
850+
except asyncio.CancelledError:
851+
pass
852+
853+
print("✅ Running task's stream stays open when its idle key is reclaimed")

0 commit comments

Comments
 (0)