You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 "...".
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-74AddTaskOptions 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.
AG-UI support in bothfrom_full_stream.py and thread.py::_from_full_stream (dict and attribute branches). Collapse the duplicate into one implementation if emit_thinking is preserved.
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.
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
Effort: L. If it grows past about 1.5k LOC, split 6f17495b (ownership + serialization) into a follow-up.
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_taskwithout implicit auto-complete, and AG-UI streams inthread.post(). This is the core half of the Slack/Teams streaming fixes and must merge with, or just before, #207.Upstream changes
438f5513fix: avoid dummy message context for lightweight threads (#633) — chat@4.32.0 —createThreadtakesMessage | undefined, passed bythread(),openDM(), message-less reactions and actions withoutmessageId.93a58af5fix(teams): preserve native streaming with placeholders (#709) — chat@4.35.0 — default placeholder becomesundefined;StreamOptions.fallbackStreamingPlaceholderTextis forwarded only when explicit;fallbackStreamstill defaults to"...".6f17495bfix(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.2e473511fix: let Plan tasks run in parallel without implicit auto-completion (#632) — chat@4.32.0 —AddTaskOptions.autoCompletePrevious(defaulttrue).dc2a7775feat(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 aMessage. Sothread.py:712-720sendsrecipient_user_id=""andget_participants(thread.py:494-498) returns an empty-id author."..."intypes.py:1707(ChatConfig) andthread.py:264, and is read directly atthread.py:809.StreamOptions(types.py:1056-1063) has no placeholder field.reviver()(chat.py:927-947) skipsset_message_adapter; modal restore (chat.py:1391/:1400) passes nochat;grep -rn 'owns_adapter\|get_streaming_options' src→ nothing;_ChatSingleton(thread.py:66-76) has onlyget_adapter/get_state.from_json(thread.py:1039-1163) applies an explicitadapteralongside a non-owningchat, andtests/test_serialization.py:754pins this.plan.py:70-74AddTaskOptionshas no option to skip, andplan.py:345-347always completesin_progresstasks.from_full_stream.py:116/:122and the duplicate atthread.py:1348/:1363/:1392/:1403match onlytext-delta/finish-step.grep -rn TEXT_MESSAGE srcfinds nothing.Scope
chat.py:_create_thread(initial_message: Message | None); passNonefromopen_dm,thread()withoutcurrent_message(keep the kwarg), message-less reactions, and actions with a falsymessage_id(still build it when an id exists).thread.py: every_current_messageconsumer handlesNone. No stub may appear inrecent_messages.fallback_streaming_placeholder_textonChatConfig,_ThreadImplConfigand a newStreamOptionsfield._handle_streamsets it only when explicit, and_fallback_streammaps unset to"...".Chat.get_streaming_options()andChat.owns_adapter().owns_adaptercompares withisover the registered values, since keys may differ fromadapter.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; rebindcurrent_message.chat=selfatchat.py:1391/:1400.reviver()callsset_message_adapter(recursing intoreply_toafter [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.from_full_stream.pyandthread.py::_from_full_stream(dict and attribute branches). Collapse the duplicate into one implementation ifemit_thinkingis preserved.is.Out of scope
stream()returningNone, and the Slack DM streaming replies are dropped — stream() raises for empty thread_ts but _handle_stream never falls back #94 row: [4.41/SL2] Slack streaming: stream() returns None without context, native_streaming config with post+edit fallback #207.thread.signal, typing,session_status: [4.41/C7] Turn cancellation: abort_turn, thread.signal, typing options, agent-session events #201.Message.reply_to: [4.41/C2a] Core mentions & message model: tri-state is_mention, mention regex, Author.email/is_system, Message.reply_to #192.Porting notes
Nonealready means "disabled". Recommended default:class _Unset(enum.Enum): UNSET = "UNSET", typedLiteral[_Unset.UNSET], compared withis(enum members survivedeepcopy/pickle/dataclasses.replace;object()does not).to_json()never emits it; unset stays byte-identical to today.""is a valid explicit placeholder, andupdate_interval_ms=0is valid. Useis not None/is _UNSET, neveror. Ownership usesis, not==.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-protocol, pydantic-ai) use a str-Enumtype, so compare with==or normalize viagetattr(t, "value", t). Match on the type soTOOL_CALL_ARGS(which has adelta) andSTATE_DELTA(whosedeltais a list) are skipped. Do not mapTHINKING_*, since that would be a new divergence.Tests
All four files are fidelity-mapped in
scripts/verify_test_fidelity.pyMAPPING.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"it.each"passes only explicit placeholder config to adapter streaming" (counted once [4.41/P0] Fidelity tooling for the 4.41 wave: single pin constant, SHA pin, it.each expansion, map new core test files #185 expandsit.each)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"it.eachcases (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 throughthread.post()as well, since two implementations exist.AsyncMockforstream/post_message/edit_message): unset placeholder absent fromStreamOptionsand fallback posts exactly"...";""vsNonedistinct; sentinel survives deepcopy;get_participants()onchat.thread(id)is[].Acceptance criteria
docs/UPSTREAM_SYNC.mdrecords the sentinel representation, eager vs lazy ownership, and thefrom_jsonraise.current_message is Noneand no stub inrecent_messages;from_jsonwith a non-owning chat raises; an explicit placeholder is forwarded toadapter.stream.Dependencies
Blocked by #192. Blocks #200, #201, #207, #219, #226, #203. Merge before, or together with, #207.
Verify first
from_json(adapter=X, chat=C)with a non-owning C. Grep before adopting the raise.Metadata
chat.thread(id).post(stream)works only once [4.41/SL2] Slack streaming: stream() returns None without context, native_streaming config with post+edit fallback #207 lands (it still raises until then; no regression). Teams streaming is unchanged unless the placeholder is set explicitly. Code readingrecent_messages[0]on handle threads is affected.Part of #184.