Summary
RawClaudeAgent keeps a single mutable ClaudeSDKClient on the agent instance and reuses it across distinct AgentSession objects when both sessions have not yet been bound to a provider conversation. As a result, two independent fresh sessions run against the same shared agent instance end up sharing one provider conversation, so the second session continues the first session's conversation instead of starting its own.
This is a session-continuity/isolation bug in the client lifecycle logic. It shows up whenever one long-lived ClaudeAgent instance is shared across multiple logical sessions (for example, a single hosted agent serving multiple sessions).
Affected component
- Package:
agent-framework-claude
- File:
python/packages/claude/agent_framework_claude/_agent.py
- Method:
RawClaudeAgent._ensure_session()
Root cause
_get_stream() resolves the provider continuation id from the session and passes it to _ensure_session():
# python/packages/claude/agent_framework_claude/_agent.py
session = session or self.create_session()
await self._ensure_session(self._get_chat_conversation_id(session))
For a fresh session, service_session_id is None, so _get_chat_conversation_id() returns None.
The reuse decision in _ensure_session() is:
needs_new_client = (
not self._started or self._client is None or (session_id and session_id != self._current_session_id)
)
Walking two independent fresh sessions against the same agent instance:
- Session A (
service_session_id=None) → not self._started is True → a new client is created, _current_session_id is set to None. The resulting provider session id is written back onto session A only.
- Session B (
service_session_id=None) → _started is now True, _client is not None, and (None and ...) is falsey → needs_new_client is False → the existing client (still attached to session A's provider conversation) is reused. Session B therefore continues session A's conversation.
The _current_session_id field also only ever tracks the requested id (None for a fresh session), never the provider session id that the client actually became bound to, which is what allows the falsey comparison to collapse two distinct sessions onto one client.
Expected behavior
Two distinct fresh AgentSession objects run against the same agent instance must each get their own provider conversation. A session should only continue an existing provider conversation when it explicitly carries the matching continuation id (service_session_id).
Actual behavior
The second fresh session reuses the first session's still-active client and continues the first session's conversation, so conversation state leaks between independent sessions.
Proposed fix
Treat a fresh/unbound (None) continuation id as always requiring a new client, since a not-yet-bound session must never inherit an existing provider conversation:
needs_new_client = (
not self._started
or self._client is None
or session_id is None # fresh/unbound session -> never reuse
or session_id != self._current_session_id # different explicit continuation id
)
This preserves legitimate continuity: once a session runs, its service_session_id is written back, so subsequent runs pass a real (truthy) id and correctly resume the same conversation via resume=. Only genuinely fresh sessions get an isolated new client.
As defense in depth, guard the client selection/creation in _ensure_session() with an asyncio.Lock so concurrent runs cannot race between the check and the client assignment.
Tests
- Assert that two distinct fresh
AgentSession objects on one cached agent produce two ClaudeSDKClient instantiations / distinct provider conversations.
- Keep coverage for explicit continuation-id resume and for sequential and concurrent runs.
- Update the existing
test_ensure_session_reuses_for_same_session expectation, which currently encodes reuse for the None case.
Summary
RawClaudeAgentkeeps a single mutableClaudeSDKClienton the agent instance and reuses it across distinctAgentSessionobjects when both sessions have not yet been bound to a provider conversation. As a result, two independent fresh sessions run against the same shared agent instance end up sharing one provider conversation, so the second session continues the first session's conversation instead of starting its own.This is a session-continuity/isolation bug in the client lifecycle logic. It shows up whenever one long-lived
ClaudeAgentinstance is shared across multiple logical sessions (for example, a single hosted agent serving multiple sessions).Affected component
agent-framework-claudepython/packages/claude/agent_framework_claude/_agent.pyRawClaudeAgent._ensure_session()Root cause
_get_stream()resolves the provider continuation id from the session and passes it to_ensure_session():For a fresh session,
service_session_idisNone, so_get_chat_conversation_id()returnsNone.The reuse decision in
_ensure_session()is:Walking two independent fresh sessions against the same agent instance:
service_session_id=None) →not self._startedisTrue→ a new client is created,_current_session_idis set toNone. The resulting provider session id is written back onto session A only.service_session_id=None) →_startedis nowTrue,_clientis notNone, and(None and ...)is falsey →needs_new_clientisFalse→ the existing client (still attached to session A's provider conversation) is reused. Session B therefore continues session A's conversation.The
_current_session_idfield also only ever tracks the requested id (Nonefor a fresh session), never the provider session id that the client actually became bound to, which is what allows the falsey comparison to collapse two distinct sessions onto one client.Expected behavior
Two distinct fresh
AgentSessionobjects run against the same agent instance must each get their own provider conversation. A session should only continue an existing provider conversation when it explicitly carries the matching continuation id (service_session_id).Actual behavior
The second fresh session reuses the first session's still-active client and continues the first session's conversation, so conversation state leaks between independent sessions.
Proposed fix
Treat a fresh/unbound (
None) continuation id as always requiring a new client, since a not-yet-bound session must never inherit an existing provider conversation:This preserves legitimate continuity: once a session runs, its
service_session_idis written back, so subsequent runs pass a real (truthy) id and correctly resume the same conversation viaresume=. Only genuinely fresh sessions get an isolated new client.As defense in depth, guard the client selection/creation in
_ensure_session()with anasyncio.Lockso concurrent runs cannot race between the check and the client assignment.Tests
AgentSessionobjects on one cached agent produce twoClaudeSDKClientinstantiations / distinct provider conversations.test_ensure_session_reuses_for_same_sessionexpectation, which currently encodes reuse for theNonecase.