Skip to content

Commit bcbe303

Browse files
fix: remove RESCHEDULING from stream stop-set + add regression test
- session_events.py: 4 stop-set tuples referenced deleted SessionStatus.RESCHEDULING, causing AttributeError on every stream's normal end (session_status: idle) - Extracted _TERMINAL_STATUSES tuple (DRY: sync+async text_stream/text_deltas) - Added from_raw_events classmethod for testing without private member access - Added test_text_stream_stops_on_session_status_idle regression test Co-Authored-By: Claude Code <noreply@anthropic.com>
1 parent fb81ca6 commit bcbe303

2 files changed

Lines changed: 69 additions & 94 deletions

File tree

‎dashscope/agentstudio/resources/session_events.py‎

Lines changed: 37 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@
3737
SSEEventType,
3838
)
3939

40+
_TERMINAL_STATUSES = (
41+
SessionStatus.IDLE,
42+
SessionStatus.TERMINATED,
43+
SessionStatus.RESCHEDULED,
44+
SessionStatus.DELETED,
45+
)
46+
4047

4148
class SessionEvents:
4249
"""Session event send / list / stream."""
@@ -49,14 +56,7 @@ def send(
4956
session_id: str,
5057
events: Sequence[Mapping[str, Any]],
5158
) -> Dict[str, Any]:
52-
"""Send events to a session.
53-
54-
Returns the server response dict::
55-
56-
result = client.sessions.events.send(
57-
session.id, [user_message("hello")],
58-
)
59-
"""
59+
"""Send events to a session."""
6060
if not events:
6161
raise ValueError("events must contain at least 1 entry")
6262
body = SessionEventSendParams(input=events).to_dict()
@@ -127,10 +127,7 @@ def stream(
127127
128128
``event_deltas`` opts into incremental text streaming for the given
129129
event types (``"message"`` and/or ``"reasoning"``; aliases
130-
``"agent.message"`` / ``"agent.thinking"``). When set, the stream
131-
additionally emits ``event_start`` / ``event_delta`` frames carrying
132-
partial text — consume them via :attr:`_TypedEventStream.text_deltas`.
133-
Other values are rejected by the server (HTTP 400).
130+
``"agent.message"`` / ``"agent.thinking"``).
134131
"""
135132
params: Optional[Dict[str, Any]] = None
136133
if event_deltas:
@@ -154,6 +151,27 @@ class _TypedEventStream:
154151
def __init__(self, stream: EventStream) -> None:
155152
self._stream = stream
156153

154+
@classmethod
155+
def from_raw_events(
156+
cls,
157+
raw_events: Sequence[Mapping[str, Any]],
158+
) -> "_TypedEventStream":
159+
"""Create from a list of raw event dicts (for testing).
160+
161+
Events flow through the normal :func:`_coerce_event` pipeline.
162+
"""
163+
obj = object.__new__(cls)
164+
165+
class _RawStream:
166+
def __iter__(self):
167+
return iter(raw_events)
168+
169+
def close(self):
170+
pass
171+
172+
obj._stream = _RawStream()
173+
return obj
174+
157175
def __enter__(self) -> "_TypedEventStream":
158176
return self
159177

@@ -169,8 +187,7 @@ def text_stream(self):
169187
"""Iterate over text chunks from agent messages.
170188
171189
Automatically stops when the session reaches ``idle`` or
172-
``terminated`` status, so callers don't need to handle
173-
``session_status`` events manually.
190+
``terminated`` status.
174191
"""
175192
for event in self:
176193
if getattr(event, "type", None) == SSEEventType.MESSAGE:
@@ -182,23 +199,15 @@ def text_stream(self):
182199
elif getattr(event, "type", None) == SSEEventType.SESSION_STATUS:
183200
block = event.content[0] if event.content else None
184201
d = getattr(block, "data", None) or {}
185-
if d.get("session_status") in (
186-
SessionStatus.IDLE,
187-
SessionStatus.TERMINATED,
188-
SessionStatus.RESCHEDULED,
189-
SessionStatus.DELETED,
190-
):
202+
if d.get("session_status") in _TERMINAL_STATUSES:
191203
return
192204

193205
@property
194206
def text_deltas(self):
195207
"""Iterate over incremental text chunks from ``event_delta`` frames.
196208
197-
Requires the stream to be opened with ``event_deltas`` (e.g.
198-
``event_deltas=["message"]``); otherwise the server emits only
199-
terminal messages and this yields nothing (use
200-
:attr:`text_stream` for terminal full text). Stops on terminal
201-
``session_status`` like :attr:`text_stream`.
209+
Requires the stream to be opened with ``event_deltas``; otherwise
210+
yields nothing (use :attr:`text_stream` for terminal full text).
202211
"""
203212
for event in self:
204213
etype = getattr(event, "type", None)
@@ -209,12 +218,7 @@ def text_deltas(self):
209218
elif etype == SSEEventType.SESSION_STATUS:
210219
block = event.content[0] if event.content else None
211220
d = getattr(block, "data", None) or {}
212-
if d.get("session_status") in (
213-
SessionStatus.IDLE,
214-
SessionStatus.TERMINATED,
215-
SessionStatus.RESCHEDULED,
216-
SessionStatus.DELETED,
217-
):
221+
if d.get("session_status") in _TERMINAL_STATUSES:
218222
return
219223

220224
def close(self) -> None:
@@ -232,14 +236,6 @@ async def send(
232236
session_id: str,
233237
events: Sequence[Mapping[str, Any]],
234238
) -> Dict[str, Any]:
235-
"""Send events to a session.
236-
237-
Returns the server response dict::
238-
239-
result = await client.sessions.events.send(
240-
session.id, [user_message("hello")],
241-
)
242-
"""
243239
if not events:
244240
raise ValueError("events must contain at least 1 entry")
245241
body = SessionEventSendParams(input=events).to_dict()
@@ -309,11 +305,6 @@ async def stream(
309305
event_deltas: Optional[Sequence[str]] = None,
310306
timeout: Optional[float] = None,
311307
) -> "_AioTypedEventStream":
312-
"""Open the SSE stream and return an async iterator of typed events.
313-
314-
See :meth:`SessionEvents.stream` for the ``event_deltas`` opt-in
315-
for incremental text streaming.
316-
"""
317308
params: Optional[Dict[str, Any]] = None
318309
if event_deltas:
319310
params = {"event_deltas[]": list(event_deltas)}
@@ -349,16 +340,9 @@ async def _aiter(self) -> AsyncIterator[ServerEvent]:
349340

350341
@property
351342
def text_stream(self):
352-
"""Async iterator over text chunks from agent messages."""
353343
return self._text_stream()
354344

355345
async def _text_stream(self):
356-
"""Async iterator over text chunks from agent messages.
357-
358-
Automatically stops when the session reaches ``idle`` or
359-
``terminated`` status, so callers don't need to handle
360-
``session_status`` events manually.
361-
"""
362346
async for event in self:
363347
if getattr(event, "type", None) == SSEEventType.MESSAGE:
364348
for block in event.content or []:
@@ -369,21 +353,11 @@ async def _text_stream(self):
369353
elif getattr(event, "type", None) == SSEEventType.SESSION_STATUS:
370354
block = event.content[0] if event.content else None
371355
d = getattr(block, "data", None) or {}
372-
if d.get("session_status") in (
373-
SessionStatus.IDLE,
374-
SessionStatus.TERMINATED,
375-
SessionStatus.RESCHEDULED,
376-
SessionStatus.DELETED,
377-
):
356+
if d.get("session_status") in _TERMINAL_STATUSES:
378357
return
379358

380359
@property
381360
def text_deltas(self):
382-
"""Async iterator over incremental text from ``event_delta`` frames.
383-
384-
Requires the stream to be opened with ``event_deltas``; otherwise
385-
yields nothing (use :attr:`text_stream` for terminal full text).
386-
"""
387361
return self._text_deltas()
388362

389363
async def _text_deltas(self):
@@ -396,12 +370,7 @@ async def _text_deltas(self):
396370
elif etype == SSEEventType.SESSION_STATUS:
397371
block = event.content[0] if event.content else None
398372
d = getattr(block, "data", None) or {}
399-
if d.get("session_status") in (
400-
SessionStatus.IDLE,
401-
SessionStatus.TERMINATED,
402-
SessionStatus.RESCHEDULED,
403-
SessionStatus.DELETED,
404-
):
373+
if d.get("session_status") in _TERMINAL_STATUSES:
405374
return
406375

407376
async def aclose(self) -> None:

‎tests/unit/test_agentstudio_protocol.py‎

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -928,32 +928,38 @@ def test_text_stream_stops_on_session_status_idle():
928928
"""Regression: text_stream/text_deltas must not crash on session_status events.
929929
RESCHEDULING was deleted from constants but stop-set tuples still referenced it,
930930
causing AttributeError on every stream's normal end path."""
931-
from dashscope.agentstudio.resources.session_events import _TypedEventStream
932-
933-
payloads = [
934-
{"object": "message", "status": "completed", "id": "m1", "type": "message",
935-
"role": "assistant", "content": [{"type": "text", "text": "hello"}]},
936-
{"object": "message", "status": "completed", "id": "m2", "type": "session_status",
937-
"content": [{"type": "data", "data": {"session_status": "idle",
938-
"stop_reason": {"type": "end_turn"}}}]},
931+
from dashscope.agentstudio.resources.session_events import (
932+
_TypedEventStream,
933+
)
934+
935+
raw_events = [
936+
{
937+
"object": "message",
938+
"status": "completed",
939+
"id": "m1",
940+
"type": "message",
941+
"role": "assistant",
942+
"content": [{"type": "text", "text": "hello"}],
943+
},
944+
{
945+
"object": "message",
946+
"status": "completed",
947+
"id": "m2",
948+
"type": "session_status",
949+
"content": [
950+
{
951+
"type": "data",
952+
"data": {
953+
"session_status": "idle",
954+
"stop_reason": {"type": "end_turn"},
955+
},
956+
},
957+
],
958+
},
939959
]
940960

941-
class _Fake:
942-
def __init__(self, items):
943-
self._items = items
944-
def __iter__(self):
945-
return iter(self._items)
946-
def close(self):
947-
pass
961+
ts = _TypedEventStream.from_raw_events(raw_events)
962+
assert list(ts.text_stream) == ["hello"]
948963

949-
# text_stream: must stop on idle without AttributeError
950-
ts = _TypedEventStream.__new__(_TypedEventStream)
951-
ts._stream = _Fake(payloads)
952-
chunks = list(ts.text_stream)
953-
assert chunks == ["hello"], chunks
954-
955-
# text_deltas: also must stop on idle without AttributeError
956-
ts2 = _TypedEventStream.__new__(_TypedEventStream)
957-
ts2._stream = _Fake(payloads)
958-
chunks2 = list(ts2.text_deltas)
959-
assert chunks2 == [], "no event_delta frames, should yield nothing and stop on idle"
964+
ts2 = _TypedEventStream.from_raw_events(raw_events)
965+
assert not list(ts2.text_deltas)

0 commit comments

Comments
 (0)