Skip to content

feat(chat): turn cancellation, thread.signal, typing lifecycle, agent-session events (#201) - #297

Merged
patrick-chinchill merged 8 commits into
mainfrom
sync/4.41-c7
Oct 1, 2026
Merged

patrick-chinchill merged 8 commits into
mainfrom
sync/4.41-c7

Conversation

@patrick-chinchill

@patrick-chinchill patrick-chinchill commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

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, optional end_typing, session_status), and agent-session stop/title-change handlers. No default behavior change: polling and state writes happen only for adapters with supports_turn_cancellation (none in-repo yet; the Slack emitter is #215).

Upstream commits mapped

  • 2ce2be00 feat(slack): add Agent Sessions lifecycle and native stop (#862), core files only:
    • chat.ts: ACTIVE_TURN_TTL_MS, ABORT_POLL_INTERVAL_MS, activeTurnControllers, abortTurn, the dispatchToHandlers turn wrapper, monitorTurnAbort, clearTurnMarkers, onAgentSession* / processAgentSession*, createThread(..., signal).
    • thread.ts: signal, takeUntilAborted, startTyping(..., { initiatorUserId }), finishTyping in post / postable / fallback stream, the native-stream flag reset, sessionStatus mapping.
    • types.ts: AgentSessionStatus, TypingOptions, AgentSession*Event + handlers, StreamOptions.signal / sessionStatus, ChatInstance.abortTurn / processAgentSession*. streaming-plan.ts: sessionStatus. index.ts: exports. packages/tests factories: mock chat instance members.

Tests ported

  • chat.test.ts: "aborts an active thread signal from another Chat instance" (two Chats sharing one MockStateAdapter).
  • thread.test.ts: "passes the initiating user and clears processing after posting", plus the sessionStatus: "suspended" assertion added to the StreamingPlan options test.
  • agent-session.test.ts (new tests/test_agent_session.py): "dispatches stop events to registered handlers", "dispatches title changes to registered handlers".
  • Python-specific (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 own TimeoutError is 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 custom start_typing still works; no end_typing after a successful native stream, one after a failing one; session_status reaches end_typing through the fallback; a MagicMock flag does not opt in; handler errors are logged and run under the event's conversation.

Fidelity (--report-target against chat@4.41.1), as first committed on this branch:

Delta vs committed report (HEAD): missing 118 -> 114 (-4)
  packages/chat/src/agent-session.test.ts: 2 -> 0 (-2)
  packages/chat/src/chat.test.ts: 1 -> 0 (-1)
  packages/chat/src/thread.test.ts: 15 -> 14 (-1)

After merging main (now through #300, f940f64), the regenerated report is 100 missing vs main's 104 (-4: agent-session.test.ts 2 -> 0, chat.test.ts 1 -> 0, thread.test.ts 1 -> 0). On the merged branch --report-target prints Delta 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_aborted keeps anext() in the consumer's task (a helper task per chunk would break sources that rely on their task across yield). The abort reschedules an asyncio.timeout(None) around the wait, so a real cancellation of the consumer still propagates via asyncio.timeout's uncancel bookkeeping. The source is closed with an awaited aclose() (upstream fires return() without awaiting). One observable difference: the abort interrupts the source's in-flight anext() (upstream leaves its next() running unobserved); whatever the source raises in response (our TimeoutError, or its own error wrapping the cancellation) ends the stream cleanly, so post() returns the text already streamed, as upstream.
  • start_typing signature probe: options= is passed (keyword) only when chat_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. The Adapter Protocol 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_stream now closes its source when it exits before the source is exhausted. Upstream's for await does this automatically; Python's async for does not, so without the fix an abort between chunks would leave the caller's generator open. chat_sdk._compat.aclose_quietly does the best-effort close at both sites and also absorbs the BaseExceptionGroup([GeneratorExit]) that a TaskGroup-backed source raises when closed.
  • The Python-only idempotent rebind (ThreadImpl.from_json(existing, chat=...)) resets the turn signal and typing flag along with the other binding state.
  • supports_turn_cancellation is read with is True (a MagicMock adapter's truthy auto-attribute would otherwise turn on polling). It and end_typing (no-op default) live on BaseAdapter only.
  • Turn registration, publish and monitor start sit inside the try whose finally cancels and awaits the monitor and clears the markers, so a cancelled task still cleans up.

Consumer impact

  • start_typing may receive options= (keyword); only adapters whose signature accepts it get it.
  • thread.signal exists (never aborted outside a message handler); chat.abort_turn(thread_id) aborts the running turn.
  • No default behavior change for Slack/Teams streaming: without start_typing nothing calls end_typing, and in-repo adapters have none. Each dispatched stream now goes through _take_until_aborted (one asyncio.timeout(None) per chunk).
  • Custom fakes: the @runtime_checkable ChatInstance Protocol gains abort_turn, process_agent_session_stopped, process_agent_session_title_changed; the Thread Protocol gains signal.

Review (gpt-6-astra)

Round 1 found 4 issues; 3 fixed (from_full_stream source closing, turn cleanup when cancelled during the monitor join, *options misread by the probe). Rebutted: "make marker compare-and-delete atomic". Upstream chat.ts clearTurnMarkers (chat@4.41.1, lines 3170-3187) also does a non-atomic get then delete, and StateAdapter has 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 unstarted fromFullStream never 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; the CHANGELOG.md and 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_aborted does not race a helper anext task against signal.wait() as the porting note suggests; it uses the same-task asyncio.timeout reschedule described above (no cross-task hazard, no per-chunk task).
  • The monitor stops by being cancelled and awaited (asyncio.gather(..., return_exceptions=True)), with no separate stop Event.
  • The mock adapter has no built-in end_typing (like upstream's mock); tests opt in with adapter.end_typing = AsyncMock(). It records options in a new _start_typing_options list, leaving the _start_typing_calls 2-tuples unchanged.
  • abort_turn is also added to ChatInstance (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:

  1. _take_until_aborted: a source that turns the abort's cancellation into its own error (except CancelledError: raise SDKError from e) made post() fail. The post-wait handler is now except Exception gated on wait_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 own TimeoutError, still propagate. New tests: test_abort_ends_a_source_that_wraps_the_interruption_in_its_own_error and test_source_error_without_an_abort_still_fails_the_post. docs/UPSTREAM_SYNC.md no longer says "not observable"; it names the interrupted in-flight anext() as the one observable difference.
  2. The poll-error test now checks that polling stops: the interval is patched to 0 and the test asserts exactly one abort-key read while the handler yields 10 times. It fails if polling continues.
  3. _clear_turn_markers abort-key deletion: new test_an_aborted_turn_clears_its_abort_marker (abort recorded during the turn, gone afterwards).
  4. _handle_postable_object finally _finish_typing: new test_posting_a_postable_object_ends_typing.
  5. Post-wait expired() or aborted check: new test_a_chunk_produced_after_the_abort_is_not_yielded (the source swallows the interruption and yields again; only pre-abort chunks are received).
  6. Unbounded waits: the faithful cross-Chat test and test_local_abort_without_opt_in_writes_no_state wrap their waits in asyncio.wait_for(..., 1), and the faithful test patches ABORT_POLL_INTERVAL_MS to 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.

…-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.
@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You'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.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 1e2d4ad7-1bfd-4c0a-9e00-9835ecabd7f4

📥 Commits

Reviewing files that changed from the base of the PR and between f940f64 and 609001f.

📒 Files selected for processing (26)
  • CHANGELOG.md
  • docs/UPSTREAM_SYNC.md
  • scripts/fidelity_target.json
  • src/chat_sdk/__init__.py
  • src/chat_sdk/_compat.py
  • src/chat_sdk/adapters/discord/adapter.py
  • src/chat_sdk/adapters/github/adapter.py
  • src/chat_sdk/adapters/google_chat/adapter.py
  • src/chat_sdk/adapters/linear/adapter.py
  • src/chat_sdk/adapters/messenger/adapter.py
  • src/chat_sdk/adapters/slack/adapter.py
  • src/chat_sdk/adapters/teams/adapter.py
  • src/chat_sdk/adapters/telegram/adapter.py
  • src/chat_sdk/adapters/twilio/adapter.py
  • src/chat_sdk/adapters/whatsapp/adapter.py
  • src/chat_sdk/chat.py
  • src/chat_sdk/from_full_stream.py
  • src/chat_sdk/plan.py
  • src/chat_sdk/shared/mock_adapter.py
  • src/chat_sdk/thread.py
  • src/chat_sdk/types.py
  • tests/test_agent_session.py
  • tests/test_chat_faithful.py
  • tests/test_installation_events.py
  • tests/test_thread_faithful.py
  • tests/test_turn_cancellation.py
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…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
@patrick-chinchill
patrick-chinchill marked this pull request as ready for review October 1, 2026 09:15
@patrick-chinchill
patrick-chinchill marked this pull request as draft October 1, 2026 09:20
@patrick-chinchill
patrick-chinchill marked this pull request as ready for review October 1, 2026 09:20
@patrick-chinchill

Copy link
Copy Markdown
Collaborator Author

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).

@patrick-chinchill
patrick-chinchill merged commit 5b6bc22 into main Oct 1, 2026
8 checks passed
@patrick-chinchill
patrick-chinchill deleted the sync/4.41-c7 branch October 1, 2026 09:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[4.41/C7] Turn cancellation: abort_turn, thread.signal, typing options, agent-session events

1 participant