Start Python SDK turn subscriptions at their attachment point (#44400)

## What changed

Make turn event delivery depend on when each consumer attaches:

- Handles returned by `thread.turn(...)` receive events from when the request is sent, including events received before the response arrives.
- Other handles and explicit low-level registrations receive only events from when they join. Remove replay of completed items, token usage, and terminal events; use `thread.read(include_turns=True)` for earlier history.
- Retain unread events for active subscribers and pending starts, release unused turn state, and close the returned subscription when an async start is cancelled.

Preserve the existing low-level subscription when repeated `turn_start(...)` calls return the same turn.

## Testing

Update coverage for late joins, request-time buffering, completion or transport failure before a start returns, async cancellation cleanup, and repeated low-level starts. Adjust external-message integration tests to allow handles to collect different items while receiving the same final response.

GitOrigin-RevId: 3b7f1268df1193dee88385006ae9510101086c24
This commit is contained in:
Ahmed Ibrahim
2026-09-10 03:40:55 +00:00
committed by copyberry
parent ea53c8d4f7
commit ddea03ad04
10 changed files with 195 additions and 153 deletions
+14 -3
View File
@@ -654,6 +654,15 @@ class CodexClient:
params: V2TurnStartParams | JsonObject | None = None,
) -> TurnStartResponse:
"""Start a turn and register its notification queue as early as possible."""
return self._start_turn(thread_id, input_items, params, for_handle=False)[0]
def _start_turn(
self,
thread_id: str,
input_items: list[JsonObject] | JsonObject | str,
params: V2TurnStartParams | JsonObject | None,
for_handle: bool,
) -> tuple[TurnStartResponse, _TurnSubscription | None]:
with self._thread_start_lock(thread_id):
if self._router.has_goal(thread_id):
raise InvalidRequestError(
@@ -665,10 +674,12 @@ class CodexClient:
"threadId": thread_id,
"input": self._normalize_input_items(input_items),
}
with self._router.pending_turn(thread_id):
with self._router.pending_turn(thread_id) as cursors:
started = self.request("turn/start", payload, response_model=TurnStartResponse)
self._router.prepare_turn(started.turn.id, thread_id)
return started
subscription = self._router.prepare_turn(
started.turn.id, thread_id, cursors, for_handle=for_handle
)
return started, subscription
@contextmanager
def _thread_start_lock(self, thread_id: str) -> Iterator[None]: