From 6bd1161bbd3f9eff7c28e7b42531498233b7f344 Mon Sep 17 00:00:00 2001 From: Madan Kumar Date: Sun, 19 Jul 2026 01:19:38 +0530 Subject: [PATCH] Don't mutate caller message dicts in ClaudeSDKClient.query() The AsyncIterable prompt path injected session_id straight into each message dict yielded by the caller's iterable. Beyond the unexpected side effect on caller-owned data, it broke reuse: passing the same messages to query() again with a different session_id left the original id in place (the "session_id" key was already present), so the second call silently sent the stale id and ignored its session_id argument. Build a shallow copy before injecting session_id instead of mutating the caller's dict. --- src/claude_agent_sdk/client.py | 8 +++++-- tests/test_streaming_client.py | 40 ++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/claude_agent_sdk/client.py b/src/claude_agent_sdk/client.py index 03705f085..19f0d42f4 100644 --- a/src/claude_agent_sdk/client.py +++ b/src/claude_agent_sdk/client.py @@ -309,9 +309,13 @@ async def query( else: # Handle AsyncIterable prompts - stream them async for msg in prompt: - # Ensure session_id is set on each message + # Ensure session_id is set on each message without mutating the + # caller's dict. Mutating it would make a reused message + # iterable ignore a changed session_id on a later query() call + # (the "session_id" key would already be present from the first + # call), silently sending the stale id. if "session_id" not in msg: - msg["session_id"] = session_id + msg = {**msg, "session_id": session_id} await self._transport.write(json.dumps(msg) + "\n") async def interrupt(self) -> None: diff --git a/tests/test_streaming_client.py b/tests/test_streaming_client.py index 00fe785cb..bea3295bb 100644 --- a/tests/test_streaming_client.py +++ b/tests/test_streaming_client.py @@ -1097,6 +1097,46 @@ def mock_build_command(self): # Clean up Path(test_script).unlink() + @pytest.mark.anyio + async def test_query_does_not_mutate_caller_messages(self): + """query() must not inject session_id into the caller's message dicts. + + Mutating them would make a reused message iterable send a stale + session_id on the next query() call, silently ignoring the new one. + """ + + with patch( + "claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = create_mock_transport() + mock_transport_class.return_value = mock_transport + + # Caller-owned, reusable message templates (no session_id set). + messages = [{"type": "user", "message": {"role": "user", "content": "hi"}}] + + async def stream(): + for msg in messages: + yield msg + + async with ClaudeSDKClient() as client: + await client.query(stream(), session_id="session-A") + # The caller's dict must be untouched. + assert "session_id" not in messages[0] + + await client.query(stream(), session_id="session-B") + + sent_session_ids = [] + for call in mock_transport.write.call_args_list: + try: + sent = json.loads(call[0][0].strip()) + except (json.JSONDecodeError, AttributeError): + continue + if sent.get("type") == "user": + sent_session_ids.append(sent.get("session_id")) + + # Each call honors its own session_id argument on reuse. + assert sent_session_ids == ["session-A", "session-B"] + class TestClaudeSDKClientEdgeCases: """Test edge cases and error scenarios."""