feat(chat): turn cancellation, thread.signal, typing lifecycle, agent-session events (#201) - #297
Conversation
…-session events (#201) Core half of vercel/chat 2ce2be00 (#862, chat@4.39.0): - TurnSignal (AbortSignal port) exposed as thread.signal; streams stop being consumed when the turn is aborted (_take_until_aborted keeps anext() in the consumer's task and closes the source). - Chat.abort_turn(thread_id): aborts local turns and, for adapters with supports_turn_cancellation, publishes active-turn:/abort-turn: markers and polls them so another process can abort the turn. - start_typing passes TypingOptions(initiator_user_id) to adapters whose signature accepts options= (chat_sdk._compat.accepts_kwarg probe); post/postable/fallback stream call the optional end_typing once. - StreamOptions.session_status/signal, StreamingPlanOptions.session_status. - on_agent_session_stopped / on_agent_session_title_changed handlers and process_* entry points; ChatInstance Protocol and mock chat instance. Ports chat.test.ts, thread.test.ts and agent-session.test.ts cases.
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 21 minutes. View limit detailsLimit details: You’ve used the included review currently available. Review configuration: ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (26)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…elled join, tighten options probe (#201) - from_full_stream closes its source when exited before exhaustion (upstream for-await calls return()), so an abort between chunks closes the caller's generator. - Turn cleanup clears markers in a nested finally so a cancellation during the monitor join still unregisters the turn and clears its markers. - accepts_kwarg ignores *args named like the keyword. - Document upstream-parity non-atomic marker clearing.
…e post (#201) aclose_quietly absorbs the BaseExceptionGroup([GeneratorExit]) a source holding a TaskGroup across yield raises when closed, while cancellation still propagates. Used by _take_until_aborted and from_full_stream.
…201) ThreadImpl.from_json(existing, chat=...) now resets the turn-local signal and typing flag with the other binding state. Document the upstream-parity behavior for an abort before the first chunk.
# Conflicts: # CHANGELOG.md # docs/UPSTREAM_SYNC.md # scripts/fidelity_target.json # src/chat_sdk/_compat.py # src/chat_sdk/thread.py
# Conflicts: # CHANGELOG.md # src/chat_sdk/adapters/teams/adapter.py
…s for poll stop, abort-marker cleanup, postable typing, post-abort chunk; bounded waits
|
Merge gate: CI green on 609001f (test 3.12, test 3.13, Lint & Type Check, CodeQL, Analyze python/actions); origin/main is an ancestor of HEAD, so no re-merge was needed and the code is unchanged since review. Local Codex review (gpt-6-astra, xhigh, --base origin/main) on 609001f: "No actionable regressions found against the specified merge base. Validation passed: 7,703 tests passed, 24 skipped, and Ruff and Pyrefly reported no errors." 7 astra rounds (6 during development plus 1 converge pass). Bots: CodeRabbit rate-limited and posted no inline/review comments; gemini has not commented. Merging with --admin (Protect Main requires a code-owner approval). |
Summary
Core half of upstream Agent Sessions (vercel/chat
2ce2be00, #862, chat@4.39.0): a per-turn cancellation signal (thread.signal),Chat.abort_turn(thread_id)(cross-process for adapters that opt in), typing lifecycle hooks (TypingOptions.initiator_user_id, optionalend_typing,session_status), and agent-session stop/title-change handlers. No default behavior change: polling and state writes happen only for adapters withsupports_turn_cancellation(none in-repo yet; the Slack emitter is #215).Upstream commits mapped
2ce2be00feat(slack): add Agent Sessions lifecycle and native stop (#862), core files only:chat.ts:ACTIVE_TURN_TTL_MS,ABORT_POLL_INTERVAL_MS,activeTurnControllers,abortTurn, thedispatchToHandlersturn wrapper,monitorTurnAbort,clearTurnMarkers,onAgentSession*/processAgentSession*,createThread(..., signal).thread.ts:signal,takeUntilAborted,startTyping(..., { initiatorUserId }),finishTypinginpost/ postable / fallback stream, the native-stream flag reset,sessionStatusmapping.types.ts:AgentSessionStatus,TypingOptions,AgentSession*Event+ handlers,StreamOptions.signal/sessionStatus,ChatInstance.abortTurn/processAgentSession*.streaming-plan.ts:sessionStatus.index.ts: exports.packages/testsfactories: mock chat instance members.Tests ported
chat.test.ts: "aborts an active thread signal from another Chat instance" (twoChats sharing oneMockStateAdapter).thread.test.ts: "passes the initiating user and clears processing after posting", plus thesessionStatus: "suspended"assertion added to the StreamingPlan options test.agent-session.test.ts(newtests/test_agent_session.py): "dispatches stop events to registered handlers", "dispatches title changes to registered handlers".tests/test_turn_cancellation.py,tests/test_agent_session.py): abort mid-stream closes the source and the fallback final edit lands; consumer cancellation still propagates; a source's ownTimeoutErroris not mistaken for an abort; no monitor task or registry entry left after dispatch; markers owned by a newer turn are not cleared; poll errors log and stop polling; a two-argument customstart_typingstill works; noend_typingafter a successful native stream, one after a failing one;session_statusreachesend_typingthrough the fallback; aMagicMockflag does not opt in; handler errors are logged and run under the event's conversation.Fidelity (
--report-targetagainst chat@4.41.1), as first committed on this branch:After merging main (now through #300,
f940f64), the regenerated report is 100 missing vs main's 104 (-4:agent-session.test.ts2 -> 0,chat.test.ts1 -> 0,thread.test.ts1 -> 0). On the merged branch--report-targetprintsDelta vs committed report (HEAD): missing 100 -> 100 (+0).Strict at the pin: 733/733.
Python adaptations (no divergence-table rows)
Recorded in
docs/UPSTREAM_SYNC.md("Turn cancellation, typing lifecycle, agent-session events"):AbortSignal→TurnSignal(aborted,async wait(),add_listener/remove_listener;_abort()internal).wait()uses a per-call future, so a signal never binds to a loop. Each thread without a turn gets its own never-aborted signal (upstream shares one)._take_until_abortedkeepsanext()in the consumer's task (a helper task per chunk would break sources that rely on their task acrossyield). The abort reschedules anasyncio.timeout(None)around the wait, so a real cancellation of the consumer still propagates viaasyncio.timeout's uncancel bookkeeping. The source is closed with an awaitedaclose()(upstream firesreturn()without awaiting). One observable difference: the abort interrupts the source's in-flightanext()(upstream leaves itsnext()running unobserved); whatever the source raises in response (ourTimeoutError, or its own error wrapping the cancellation) ends the stream cleanly, sopost()returns the text already streamed, as upstream.start_typingsignature probe:options=is passed (keyword) only whenchat_sdk._compat.accepts_kwarg(adapter.start_typing, "options")is true, so two-argument custom adapters keep working. All 10 in-repo adapters and the mock take*, options: TypingOptions | None = None. TheAdapterProtocol keeps the two-argument form. This reuses [4.41/C6] Thread.reply, Thread.mark_as_read, post_ephemeral options #200's helper (merged from main) and extends its "Adapter hook signature probe" non-parity row instead of adding a new one.from_full_streamnow closes its source when it exits before the source is exhausted. Upstream'sfor awaitdoes this automatically; Python'sasync fordoes not, so without the fix an abort between chunks would leave the caller's generator open.chat_sdk._compat.aclose_quietlydoes the best-effort close at both sites and also absorbs theBaseExceptionGroup([GeneratorExit])that aTaskGroup-backed source raises when closed.ThreadImpl.from_json(existing, chat=...)) resets the turn signal and typing flag along with the other binding state.supports_turn_cancellationis read withis True(aMagicMockadapter's truthy auto-attribute would otherwise turn on polling). It andend_typing(no-op default) live onBaseAdapteronly.trywhosefinallycancels and awaits the monitor and clears the markers, so a cancelled task still cleans up.Consumer impact
start_typingmay receiveoptions=(keyword); only adapters whose signature accepts it get it.thread.signalexists (never aborted outside a message handler);chat.abort_turn(thread_id)aborts the running turn.start_typingnothing callsend_typing, and in-repo adapters have none. Each dispatched stream now goes through_take_until_aborted(oneasyncio.timeout(None)per chunk).@runtime_checkableChatInstanceProtocol gainsabort_turn,process_agent_session_stopped,process_agent_session_title_changed; theThreadProtocol gainssignal.Review (gpt-6-astra)
Round 1 found 4 issues; 3 fixed (
from_full_streamsource closing, turn cleanup when cancelled during the monitor join,*optionsmisread by the probe). Rebutted: "make marker compare-and-delete atomic". Upstreamchat.tsclearTurnMarkers(chat@4.41.1, lines 3170-3187) also does a non-atomic get then delete, andStateAdapterhas no compare-and-delete, so the code now carries a parity comment instead of new machinery. Round 2 found 1 issue (TaskGroup close group), fixed. Round 3 found 2: the rebind reset was fixed. Rebutted: "close the original stream when the abort precedes the first pull". Upstream behaves the same way:iterator.return()on an unstartedfromFullStreamnever touches the source (thread.ts:120-155), so this gets a parity comment. Round 4: no actionable findings. Round 5 (after merging main): no actionable findings (PASS). Round 6 (after merging main through #208: #207 Slack native_streaming, #208 stream rotation, #226 Telegram post+edit, #217 Teams installation; theCHANGELOG.mdand Teams import-list conflicts were union merges): no actionable findings (PASS). Full validation on the merge: 7644 passed, 24 skipped; strict 733/733; pyrefly 0 errors.Deviations from the issue
_take_until_aborteddoes not race a helperanexttask againstsignal.wait()as the porting note suggests; it uses the same-taskasyncio.timeoutreschedule described above (no cross-task hazard, no per-chunk task).asyncio.gather(..., return_exceptions=True)), with no separate stopEvent.end_typing(like upstream's mock); tests opt in withadapter.end_typing = AsyncMock(). It recordsoptionsin a new_start_typing_optionslist, leaving the_start_typing_calls2-tuples unchanged.abort_turnis also added toChatInstance(upstream declares it there).Closes #201
Part of #184
Merge gate
Independent review findings (6), all fixed with tests that fail on the corresponding mutation:
_take_until_aborted: a source that turns the abort's cancellation into its own error (except CancelledError: raise SDKError from e) madepost()fail. The post-wait handler is nowexcept Exceptiongated onwait_scope.expired(), so the stream ends cleanly as upstream (thread.ts:120-153 never interrupts the source). Errors without an abort, including the source's ownTimeoutError, still propagate. New tests:test_abort_ends_a_source_that_wraps_the_interruption_in_its_own_errorandtest_source_error_without_an_abort_still_fails_the_post.docs/UPSTREAM_SYNC.mdno longer says "not observable"; it names the interrupted in-flightanext()as the one observable difference._clear_turn_markersabort-key deletion: newtest_an_aborted_turn_clears_its_abort_marker(abort recorded during the turn, gone afterwards)._handle_postable_objectfinally_finish_typing: newtest_posting_a_postable_object_ends_typing.expired() or abortedcheck: newtest_a_chunk_produced_after_the_abort_is_not_yielded(the source swallows the interruption and yields again; only pre-abort chunks are received).test_local_abort_without_opt_in_writes_no_statewrap their waits inasyncio.wait_for(..., 1), and the faithful test patchesABORT_POLL_INTERVAL_MSto 1. With the abort write removed, the test now fails in about 1 s instead of hanging.gpt-6-astra: the converge pass was 1 round on
609001f(the review fixes plus a merge of main through #300). Verdict: PASS ("No actionable regressions found... 7,703 tests passed, 24 skipped, and Ruff and Pyrefly reported no errors").Bots: CodeRabbit skipped the review while the PR was a draft and was rate-limited after it was marked ready. There are no inline or review comments, and gemini has not commented.
CI on
609001f: Tests 3.12 and 3.13, Lint & Type Check (re-triggered via ready_for_review), CodeQL, and Analyze are all green.Local full validation: ruff and format are clean, the audit has 0 hard failures, check-docs is OK, strict fidelity is 733/733, and pytest gives 7703 passed, 24 skipped. Pyrefly reports 0 errors.