Skip to content

Commit a3c3a01

Browse files
authored
fix: enable fine-grained tool streaming when include_partial_messages=True (#644)
## Problem When `include_partial_messages=True`, the SDK passes `--include-partial-messages` to the CLI, which tells the CLI to emit `stream_event` messages. However, tool input parameters are still **buffered by the API** and never streamed — `input_json_delta` events are withheld until the entire tool input is assembled. **Root cause:** CLI v2.1.40 (shipped Feb 10 2026) removed the always-on `fine-grained-tool-streaming-2025-05-14` beta header and replaced it with two opt-in paths: 1. A GrowthBook feature flag (`tengu_fgts`, default: `false`) 2. The `CLAUDE_CODE_ENABLE_FINE_GRAINED_TOOL_STREAMING` env var → sets `eager_input_streaming: true` per-tool in the API request The SDK was never updated to set the env var, so `include_partial_messages=True` silently stopped delivering tool input deltas for users without the GrowthBook flag. **Regression window:** **v0.1.36** (released Feb 13 2026, bundled CLI 2.1.42) through present. Last working version: **v0.1.35** (bundled CLI 2.1.39). Fixes #608. Same fix applied to the TypeScript SDK in parallel. ## Fix When `include_partial_messages=True`, inject `CLAUDE_CODE_ENABLE_FINE_GRAINED_TOOL_STREAMING=1` into the subprocess environment via `setdefault` (user-supplied values take precedence). ## Tests Three new unit tests in `test_transport.py`: - `include_partial_messages=True` → env var set to `"1"` - `include_partial_messages=False` → env var not set - User-supplied value in `options.env` is respected ``` $ python -m pytest tests/test_transport.py -v -k fgts tests/test_transport.py::TestSubprocessCLITransport::test_include_partial_messages_enables_fgts PASSED tests/test_transport.py::TestSubprocessCLITransport::test_include_partial_messages_false_does_not_set_fgts PASSED tests/test_transport.py::TestSubprocessCLITransport::test_user_can_override_fgts_env_var PASSED 3 passed in 0.35s ``` ## Live E2E verification Ran against the bundled CLI (v2.1.49, post-regression) with fix applied — confirmed `input_json_delta` events stream through the wire: ``` $ env -u CLAUDECODE PYTHONPATH=src python e2e_fgts.py Running E2E FGTS test... Prompt: "Use the Bash tool to run: echo 'Fine-grained tool streaming test'" input_json_delta [ 1]: '' input_json_delta [ 2]: '{"command":' input_json_delta [ 3]: ' "echo' input_json_delta [ 4]: ' \'Fine-gra' input_json_delta [ 5]: 'ined tool st' input_json_delta [ 6]: 'reamin' input_json_delta [ 7]: 'g tes' input_json_delta [ 8]: 't\'"}' Total stream_event messages: 23 input_json_delta events: 8 Assembled tool input JSON: '{"command": "echo \'Fine-grained tool streaming test\'"}' PASS: tool input deltas streamed correctly ``` **Note:** The "before" (broken) path cannot be demonstrated on this account because the `tengu_fgts` GrowthBook flag is currently on for authenticated users, which is the second condition in the CLI's FGTS check: ```typescript if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_fgts', false) || // on for us isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_FINE_GRAINED_TOOL_STREAMING)) { schema.eager_input_streaming = true } ``` The fix matters for users where the GrowthBook flag is off (the default is `false`).
1 parent f7e5955 commit a3c3a01

File tree

2 files changed

+141
-0
lines changed

2 files changed

+141
-0
lines changed

src/claude_agent_sdk/_internal/transport/subprocess_cli.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,15 @@ async def connect(self) -> None:
354354
if self._options.enable_file_checkpointing:
355355
process_env["CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING"] = "true"
356356

357+
# Enable fine-grained tool streaming when partial messages are requested.
358+
# --include-partial-messages emits stream_event messages, but tool input
359+
# parameters are still buffered by the API unless eager_input_streaming is
360+
# also enabled at the per-tool level via this env var.
361+
if self._options.include_partial_messages:
362+
process_env.setdefault(
363+
"CLAUDE_CODE_ENABLE_FINE_GRAINED_TOOL_STREAMING", "1"
364+
)
365+
357366
if self._cwd:
358367
process_env["PWD"] = self._cwd
359368

tests/test_transport.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -883,6 +883,138 @@ def test_build_command_always_uses_streaming(self):
883883
assert "stream-json" in cmd
884884
assert "--print" not in cmd
885885

886+
def test_include_partial_messages_enables_fgts(self):
887+
"""Test that include_partial_messages=True sets CLAUDE_CODE_ENABLE_FINE_GRAINED_TOOL_STREAMING.
888+
889+
--include-partial-messages tells the CLI to forward stream_event messages,
890+
but tool input parameters are still buffered by the API unless
891+
eager_input_streaming is enabled via this env var.
892+
"""
893+
894+
async def _test():
895+
options = make_options(include_partial_messages=True)
896+
897+
with patch(
898+
"anyio.open_process", new_callable=AsyncMock
899+
) as mock_open_process:
900+
mock_version_process = MagicMock()
901+
mock_version_process.stdout = MagicMock()
902+
mock_version_process.stdout.receive = AsyncMock(
903+
return_value=b"2.0.0 (Claude Code)"
904+
)
905+
mock_version_process.terminate = MagicMock()
906+
mock_version_process.wait = AsyncMock()
907+
908+
mock_process = MagicMock()
909+
mock_process.stdout = MagicMock()
910+
mock_stdin = MagicMock()
911+
mock_stdin.aclose = AsyncMock()
912+
mock_process.stdin = mock_stdin
913+
mock_process.returncode = None
914+
915+
mock_open_process.side_effect = [mock_version_process, mock_process]
916+
917+
transport = SubprocessCLITransport(
918+
prompt="test",
919+
options=options,
920+
)
921+
await transport.connect()
922+
923+
second_call_kwargs = mock_open_process.call_args_list[1].kwargs
924+
env_passed = second_call_kwargs["env"]
925+
assert (
926+
env_passed.get("CLAUDE_CODE_ENABLE_FINE_GRAINED_TOOL_STREAMING")
927+
== "1"
928+
)
929+
930+
anyio.run(_test)
931+
932+
def test_include_partial_messages_false_does_not_set_fgts(self):
933+
"""Test that include_partial_messages=False does not force-enable FGTS."""
934+
935+
async def _test():
936+
options = make_options(include_partial_messages=False)
937+
938+
with patch(
939+
"anyio.open_process", new_callable=AsyncMock
940+
) as mock_open_process:
941+
mock_version_process = MagicMock()
942+
mock_version_process.stdout = MagicMock()
943+
mock_version_process.stdout.receive = AsyncMock(
944+
return_value=b"2.0.0 (Claude Code)"
945+
)
946+
mock_version_process.terminate = MagicMock()
947+
mock_version_process.wait = AsyncMock()
948+
949+
mock_process = MagicMock()
950+
mock_process.stdout = MagicMock()
951+
mock_stdin = MagicMock()
952+
mock_stdin.aclose = AsyncMock()
953+
mock_process.stdin = mock_stdin
954+
mock_process.returncode = None
955+
956+
mock_open_process.side_effect = [mock_version_process, mock_process]
957+
958+
transport = SubprocessCLITransport(
959+
prompt="test",
960+
options=options,
961+
)
962+
await transport.connect()
963+
964+
second_call_kwargs = mock_open_process.call_args_list[1].kwargs
965+
env_passed = second_call_kwargs["env"]
966+
# Should not be set (unless the user already had it in their env)
967+
assert (
968+
"CLAUDE_CODE_ENABLE_FINE_GRAINED_TOOL_STREAMING" not in env_passed
969+
)
970+
971+
anyio.run(_test)
972+
973+
def test_user_can_override_fgts_env_var(self):
974+
"""Test that a user-supplied env var takes precedence over the SDK default."""
975+
976+
async def _test():
977+
options = make_options(
978+
include_partial_messages=True,
979+
env={"CLAUDE_CODE_ENABLE_FINE_GRAINED_TOOL_STREAMING": "0"},
980+
)
981+
982+
with patch(
983+
"anyio.open_process", new_callable=AsyncMock
984+
) as mock_open_process:
985+
mock_version_process = MagicMock()
986+
mock_version_process.stdout = MagicMock()
987+
mock_version_process.stdout.receive = AsyncMock(
988+
return_value=b"2.0.0 (Claude Code)"
989+
)
990+
mock_version_process.terminate = MagicMock()
991+
mock_version_process.wait = AsyncMock()
992+
993+
mock_process = MagicMock()
994+
mock_process.stdout = MagicMock()
995+
mock_stdin = MagicMock()
996+
mock_stdin.aclose = AsyncMock()
997+
mock_process.stdin = mock_stdin
998+
mock_process.returncode = None
999+
1000+
mock_open_process.side_effect = [mock_version_process, mock_process]
1001+
1002+
transport = SubprocessCLITransport(
1003+
prompt="test",
1004+
options=options,
1005+
)
1006+
await transport.connect()
1007+
1008+
second_call_kwargs = mock_open_process.call_args_list[1].kwargs
1009+
env_passed = second_call_kwargs["env"]
1010+
# User's explicit "0" should win over SDK default "1"
1011+
assert (
1012+
env_passed.get("CLAUDE_CODE_ENABLE_FINE_GRAINED_TOOL_STREAMING")
1013+
== "0"
1014+
)
1015+
1016+
anyio.run(_test)
1017+
8861018
def test_build_command_large_agents_work(self):
8871019
"""Test that large agent definitions work without size limits.
8881020

0 commit comments

Comments
 (0)