Skip to content

Commit ccb5fde

Browse files
fix(streaming-plan): address PR #74 review feedback
- StreamingPlanOptions mapping: replace truthy checks with `is not None` so update_interval_ms=0 and end_with=[] propagate correctly (thread.py post dispatcher and _fallback_stream interval guard). Per CLAUDE.md Port Rule #1; diverges from upstream thread.ts, which has the same latent bug. - StreamingPlan: raise RuntimeError from is_supported() and get_fallback_text() so misroutes through post_postable_object / Channel.post fail loudly instead of posting "" or attempting a wrong-shape adapter.post_object("stream", ...). Also guard post_postable_object() with an early kind=="stream" check for a clearer error message. Diverges from upstream postable-object.ts, which silently posts the empty fallback string. - Fallback test: spy on _fallback_stream to capture the StreamOptions actually reaching the fallback path and assert task_display_mode, stop_blocks, and update_interval_ms all propagate -- previous test passed even when options were silently dropped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 452b0ac commit ccb5fde

3 files changed

Lines changed: 92 additions & 10 deletions

File tree

src/chat_sdk/plan.py

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,20 @@ async def post_postable_object(
168168
Optional logger for error reporting.
169169
"""
170170

171+
# StreamingPlan (kind == "stream") is a nominal PostableObject that only
172+
# Thread.post() knows how to consume (via its native-or-fallback streaming
173+
# path). Reject it here so callers get a clear error instead of blank
174+
# posts or a wrong-shape adapter.post_object("stream", ...) call. Diverges
175+
# from upstream postable-object.ts, which posts ``getFallbackText() == ""``
176+
# as an empty message.
177+
if getattr(obj, "kind", None) == "stream":
178+
raise RuntimeError(
179+
"StreamingPlan cannot be posted via post_postable_object / "
180+
"Channel.post -- its stream is consumed only by Thread.post(), "
181+
"which special-cases kind=='stream' for native or fallback "
182+
"streaming. Use thread.post(streaming_plan) instead."
183+
)
184+
171185
def _make_context(raw: Any) -> PostableObjectContext:
172186
return PostableObjectContext(
173187
adapter=adapter,
@@ -518,20 +532,52 @@ def options(self) -> StreamingPlanOptions:
518532
return self._options
519533

520534
# -- PostableObject protocol ------------------------------------------------
535+
#
536+
# StreamingPlan is a "nominal" PostableObject: it satisfies the duck-typing
537+
# protocol (so ``is_postable_object()`` detects it and ``Thread.post``'s
538+
# ``kind == "stream"`` branch fires), but it cannot actually round-trip
539+
# through the generic ``post_postable_object`` helper -- there is no static
540+
# fallback text to post and no meaningful ``adapter.post_object("stream",
541+
# ...)`` shape.
542+
#
543+
# Upstream TS has the same latent gap: ``ChannelImpl.post`` routes any
544+
# PostableObject through ``postPostableObject``, which would post an empty
545+
# string for StreamingPlan. We diverge by failing loudly rather than
546+
# silently posting blanks, per CLAUDE.md adversarial-review discipline.
521547

522548
def get_fallback_text(self) -> str:
523-
"""StreamingPlan has no static fallback text -- streaming is handled
524-
by Thread.post() directly via the native-or-fallback streaming path."""
525-
return ""
549+
"""StreamingPlan has no static fallback text.
550+
551+
Raises ``RuntimeError`` to fail loudly if a generic posting path
552+
(e.g. ``Channel.post`` or ``post_postable_object``) tries to
553+
consume a StreamingPlan as a normal PostableObject. StreamingPlan
554+
must be posted via :meth:`Thread.post`, which special-cases
555+
``kind == "stream"`` and consumes the wrapped async iterable.
556+
"""
557+
raise RuntimeError(
558+
"StreamingPlan cannot be posted via the generic PostableObject "
559+
"path (no static fallback text). Post it with Thread.post(), "
560+
"which routes kind=='stream' to native or fallback streaming."
561+
)
526562

527563
def get_post_data(self) -> _StreamingPlanData:
528564
"""Return the underlying stream + options for Thread.post to route."""
529565
return _StreamingPlanData(stream=self._stream, options=self._options)
530566

531567
def is_supported(self, _adapter: Adapter) -> bool:
532-
"""StreamingPlan is always supported -- fallback path handles adapters
533-
without native streaming."""
534-
return True
568+
"""StreamingPlan is not generically postable -- see
569+
:meth:`get_fallback_text`.
570+
571+
Raises ``RuntimeError`` so misroutes through
572+
``post_postable_object`` fail loudly rather than silently trying
573+
``adapter.post_object("stream", ...)`` on adapters that don't
574+
understand the shape.
575+
"""
576+
raise RuntimeError(
577+
"StreamingPlan cannot be posted via the generic PostableObject "
578+
"path. Post it with Thread.post(), which routes kind=='stream' "
579+
"to native or fallback streaming."
580+
)
535581

536582
def on_posted(self, _context: PostableObjectContext) -> None:
537583
"""Streams are one-shot, no lifecycle binding needed."""

src/chat_sdk/thread.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -472,11 +472,16 @@ async def post(
472472
group_tasks = getattr(plan_options, "group_tasks", None)
473473
end_with = getattr(plan_options, "end_with", None)
474474
update_interval_ms = getattr(plan_options, "update_interval_ms", None)
475-
if group_tasks:
475+
# Port Rule #1: use `is not None` so explicit falsy values
476+
# (``end_with=[]``, ``update_interval_ms=0``) still
477+
# propagate to the adapter/fallback instead of being
478+
# silently dropped by a truthiness check. Diverges from
479+
# upstream thread.ts, which has the same latent bug.
480+
if group_tasks is not None:
476481
extra.task_display_mode = group_tasks
477-
if end_with:
482+
if end_with is not None:
478483
extra.stop_blocks = end_with
479-
if update_interval_ms:
484+
if update_interval_ms is not None:
480485
extra.update_interval_ms = update_interval_ms
481486
await self._handle_stream(stream_iter, extra_options=extra)
482487
return message
@@ -650,8 +655,13 @@ async def _fallback_stream(
650655
Posts an initial placeholder, then edits the message at intervals as
651656
new text arrives from the stream.
652657
"""
658+
# ``is not None`` so explicit ``update_interval_ms=0`` (edit-on-every-
659+
# chunk) from ``StreamingPlan`` is honored rather than silently reset
660+
# to the thread default by a truthiness check.
653661
interval_ms = (
654-
options.update_interval_ms if options and options.update_interval_ms else self._streaming_update_interval_ms
662+
options.update_interval_ms
663+
if options is not None and options.update_interval_ms is not None
664+
else self._streaming_update_interval_ms
655665
)
656666
interval_s = interval_ms / 1000.0
657667
placeholder_text = self._fallback_streaming_placeholder_text

tests/test_thread_faithful.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -949,6 +949,20 @@ async def test_should_route_streamingplan_through_fallback_when_adapter_has_no_n
949949
assert not hasattr(adapter, "stream") or getattr(adapter, "stream", None) is None
950950

951951
thread = _make_thread(adapter, state)
952+
953+
# Capture StreamOptions at the fallback entry point so we can prove
954+
# that StreamingPlanOptions actually reach the fallback path -- this
955+
# guards issue #56 against silent truthiness drops that would leave
956+
# observable streaming behavior unchanged.
957+
captured_options: list[Any] = []
958+
original_fallback = thread._fallback_stream
959+
960+
async def _spy_fallback(text_stream: Any, options: Any = None) -> Any:
961+
captured_options.append(options)
962+
return await original_fallback(text_stream, options)
963+
964+
thread._fallback_stream = _spy_fallback # type: ignore[method-assign]
965+
952966
text_stream = _create_text_stream(["Hello", " ", "World"])
953967
await thread.post(
954968
StreamingPlan(
@@ -971,6 +985,18 @@ async def test_should_route_streamingplan_through_fallback_when_adapter_has_no_n
971985
assert isinstance(last_edit[2], PostableMarkdown)
972986
assert last_edit[2].markdown == "Hello World"
973987

988+
# All three StreamingPlanOptions fields must reach the fallback path.
989+
# Before the truthiness->`is not None` fix, end_with=[] and
990+
# update_interval_ms=0 would be dropped; asserting full propagation
991+
# here catches both the mapping bug and any future regression that
992+
# forgets to thread options through to _fallback_stream.
993+
assert len(captured_options) == 1
994+
options = captured_options[0]
995+
assert options is not None
996+
assert options.task_display_mode == "plan"
997+
assert options.stop_blocks == [{"type": "actions"}]
998+
assert options.update_interval_ms == 2000
999+
9741000
# it("should still work without options (backward compat)")
9751001
@pytest.mark.asyncio
9761002
async def test_should_still_work_without_options_backward_compat(self):

0 commit comments

Comments
 (0)