Skip to content

[4.41/C5] Streaming & thread API: lightweight threads, placeholder sentinel, restore streaming settings, Plan.auto_complete_previous, AG-UI streams #199

Description

@patrick-chinchill

Summary

Port five core streaming/thread changes from 4.32–4.41.1: no fake current message on lightweight threads, a tri-state fallback_streaming_placeholder_text (unset / None / text), restored threads that keep their owning Chat's streaming settings, Plan.add_task without implicit auto-complete, and AG-UI streams in thread.post(). This is the core half of the Slack/Teams streaming fixes and must merge with, or just before, #207.

Upstream changes

  • 438f5513 fix: avoid dummy message context for lightweight threads (#633) — chat@4.32.0 — createThread takes Message | undefined, passed by thread(), openDM(), message-less reactions and actions without messageId.
  • 93a58af5 fix(teams): preserve native streaming with placeholders (#709) — chat@4.35.0 — default placeholder becomes undefined; StreamOptions.fallbackStreamingPlaceholderText is forwarded only when explicit; fallbackStream still defaults to "...".
  • 6f17495b fix(chat): preserve runtime ownership and streaming settings after deserialization (#967) — chat@4.41.1 — Chat.getStreamingOptions()/ownsAdapter() (identity); precedence caller > thread > owning Chat > 500/"..."; bot.reviver() binds Messages; a non-owning explicit Chat + explicit adapter throws.
  • 2e473511 fix: let Plan tasks run in parallel without implicit auto-completion (#632) — chat@4.32.0 — AddTaskOptions.autoCompletePrevious (default true).
  • dc2a7775 feat(chat): accept TanStack AI streams in thread.post() (#934) — chat@4.41.0 — TEXT_MESSAGE_CONTENT.delta → text, TEXT_MESSAGE_END → finish-step.

Current Python behavior

  • Author(user_id="") stubs: src/chat_sdk/chat.py:1475-1485 (actions, always), :1585-1597 (reactions), :1647-1660 (open_dm), :1803-1820 (thread()); _create_thread (:2394-2399) requires a Message. So thread.py:712-720 sends recipient_user_id="" and get_participants (thread.py:494-498) returns an empty-id author.
  • The placeholder defaults to "..." in types.py:1707 (ChatConfig) and thread.py:264, and is read directly at thread.py:809. StreamOptions (types.py:1056-1063) has no placeholder field.
  • reviver() (chat.py:927-947) skips set_message_adapter; modal restore (chat.py:1391/:1400) passes no chat; grep -rn 'owns_adapter\|get_streaming_options' src → nothing; _ChatSingleton (thread.py:66-76) has only get_adapter/get_state.
  • from_json (thread.py:1039-1163) applies an explicit adapter alongside a non-owning chat, and tests/test_serialization.py:754 pins this.
  • plan.py:70-74 AddTaskOptions has no option to skip, and plan.py:345-347 always completes in_progress tasks.
  • from_full_stream.py:116/:122 and the duplicate at thread.py:1348/:1363/:1392/:1403 match only text-delta/finish-step. grep -rn TEXT_MESSAGE src finds nothing.

Scope

  • chat.py: _create_thread(initial_message: Message | None); pass None from open_dm, thread() without current_message (keep the kwarg), message-less reactions, and actions with a falsy message_id (still build it when an id exists).
  • thread.py: every _current_message consumer handles None. No stub may appear in recent_messages.
  • Sentinel-typed fallback_streaming_placeholder_text on ChatConfig, _ThreadImplConfig and a new StreamOptions field. _handle_stream sets it only when explicit, and _fallback_stream maps unset to "...".
  • Chat.get_streaming_options() and Chat.owns_adapter(). owns_adapter compares with is over the registered values, since keys may differ from adapter.name. Add both to _ChatSingleton.
  • _ThreadImplConfig.streaming_update_interval_ms: int | None = None, resolved at stream time as caller > thread > owner > 500.
  • ThreadImpl/ChannelImpl.from_json: record the owning chat (explicit, or the singleton if it owns the adapter); raise when an explicit chat does not own an explicit adapter; rebind current_message.
  • Pass chat=self at chat.py:1391/:1400. reviver() calls set_message_adapter (recursing into reply_to after [4.41/C2a] Core mentions & message model: tri-state is_mention, mention regex, Author.email/is_system, Message.reply_to #192).
  • plan.py: auto_complete_previous: bool = True.
  • AG-UI support in both from_full_stream.py and thread.py::_from_full_stream (dict and attribute branches). Collapse the duplicate into one implementation if emit_thinking is preserved.
  • Export the sentinel so [4.41/T4] Teams outbound: reactions, targeted ephemeral messages, placeholder-aware native streaming #219 can compare with is.

Out of scope

Porting notes

  • Sentinel. None already means "disabled". Recommended default: class _Unset(enum.Enum): UNSET = "UNSET", typed Literal[_Unset.UNSET], compared with is (enum members survive deepcopy/pickle/dataclasses.replace; object() does not). to_json() never emits it; unset stays byte-identical to today.
  • Truthiness. "" is a valid explicit placeholder, and update_interval_ms=0 is valid. Use is not None/is _UNSET, never or. Ownership uses is, not ==.
  • Decision. Adopt the upstream raise for from_json(adapter=X, chat=C) when C does not own X ("does not belong to this Chat instance. Restore with bot.reviver()"). Rewrite the orthogonality test to use an owned adapter, and add a raising test.
  • AG-UI. Python producers (ag-ui-protocol, pydantic-ai) use a str-Enum type, so compare with == or normalize via getattr(t, "value", t). Match on the type so TOOL_CALL_ARGS (which has a delta) and STATE_DELTA (whose delta is a list) are skipped. Do not map THINKING_*, since that would be a new divergence.

Tests

All four files are fidelity-mapped in scripts/verify_test_fidelity.py MAPPING.

  • chat.test.ts → tests/test_chat_faithful.py: "should allow streaming from a reaction without message context"; "should allow streaming from an action without message context"; "should allow streaming to a DM thread"; "should allow streaming to a thread handle"
  • thread.test.ts → tests/test_thread_faithful.py [post with Plan]: "should not auto-complete in_progress tasks when autoCompletePrevious is false"; "should auto-complete all in_progress tasks when switching back to default addTask"; "should target the most recent in_progress task when updating without id"
  • serialization.test.ts → tests/test_serialization.py: "does not inherit another Chat configuration for directly constructed threads"; "can stream with an explicit restored adapter without a singleton"; "does not borrow an unrelated runtime for an explicit adapter"; "throws for an explicit Chat that does not own the explicit adapter"; "recognizes adapters registered under a key that differs from their name"; "honors a StreamingPlan updateIntervalMs of 0"; "binds messages revived by bot.reviver() to that bot's adapter"; "does not fall back to another Chat's adapter"; "retains ownership while streams from different bots interleave"; "rebinds serialized objects without retaining the previous runtime"
    • the 6f17495b it.each cases (e.g. "keeps restored modal %s context bound to its Chat")
  • from-full-stream.test.ts → tests/test_from_full_stream.py [AG-UI streams (TanStack AI chat())]: all 9, from "extracts TEXT_MESSAGE_CONTENT deltas" to "handles AI SDK and AG-UI events in the same stream". Run a subset through thread.post() as well, since two implementations exist.
  • Python-specific (AsyncMock for stream/post_message/edit_message): unset placeholder absent from StreamOptions and fallback posts exactly "..."; "" vs None distinct; sentinel survives deepcopy; get_participants() on chat.thread(id) is [].

Acceptance criteria

  • The full validation command from CLAUDE.md passes.
  • Non-strict fidelity against chat@4.41.1 no longer lists the tests above. Report the count in the PR.
  • docs/UPSTREAM_SYNC.md records the sentinel representation, eager vs lazy ownership, and the from_json raise.
  • CHANGELOG entry under "Unreleased (4.41 wave)".
  • Consumer-visible changes called out: handle/DM threads have current_message is None and no stub in recent_messages; from_json with a non-owning chat raises; an explicit placeholder is forwarded to adapter.stream.
  • Unset-placeholder streaming is byte-identical in the Slack and Teams mock tests.

Dependencies

Blocked by #192. Blocks #200, #201, #207, #219, #226, #203. Merge before, or together with, #207.

Verify first

  • No downstream code calls from_json(adapter=X, chat=C) with a non-owning C. Grep before adopting the raise.
  • The fidelity script counts "should not auto-complete in_progress tasks when autoCompletePrevious is false" as present only through a fuzzy match. Port it under its exact name.

Metadata

Part of #184.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions