Skip to content

Commit 23401ee

Browse files
author
Mateusz
committed
fix(acp): preserve reasoning_content in non-stream responses and add newline separators to progress lines
Accumulate reasoning_content alongside content in _chat_completions_non_streaming so non-stream clients receive thinking/progress data. Append newline separators to _acp_progress_reasoning_line outputs so concatenated reasoning chunks render on separate lines. Add regression tests for both behaviors.
1 parent 35b191b commit 23401ee

3 files changed

Lines changed: 91 additions & 15 deletions

File tree

src/connectors/acp_core/base_connector.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -579,8 +579,8 @@ def _acp_progress_reasoning_line(
579579
tc = update.get("toolCall")
580580
if isinstance(tc, dict):
581581
name = tc.get("name") or tc.get("toolName") or tc.get("title") or "tool"
582-
return f"[tool] {name}"
583-
return "[tool]"
582+
return f"[tool] {name}\n"
583+
return "[tool]\n"
584584
if session_update_kind == "tool_call_update":
585585
tcu = update.get("toolCallUpdate")
586586
if not isinstance(tcu, dict):
@@ -589,19 +589,19 @@ def _acp_progress_reasoning_line(
589589
name = tcu.get("name") or tcu.get("toolName") or "tool"
590590
status = tcu.get("status") or tcu.get("state")
591591
if isinstance(status, str) and status:
592-
return f"[tool] {name}: {status}"
593-
return f"[tool] {name} …"
594-
return "[tool] …"
592+
return f"[tool] {name}: {status}\n"
593+
return f"[tool] {name}\n"
594+
return "[tool] …\n"
595595
if session_update_kind == "plan":
596596
title = update.get("title")
597597
if isinstance(title, str) and title.strip():
598-
return f"[plan] {title.strip()}"
599-
return "[plan]"
598+
return f"[plan] {title.strip()}\n"
599+
return "[plan]\n"
600600
if session_update_kind == "current_mode_update":
601601
mode = update.get("modeId") or update.get("mode")
602602
if isinstance(mode, str) and mode:
603-
return f"[mode] {mode}"
604-
return "[mode]"
603+
return f"[mode] {mode}\n"
604+
return "[mode]\n"
605605
return None
606606

607607
def _session_update_to_stream_piece(
@@ -1009,12 +1009,18 @@ async def _stream_with_keepalive() -> AsyncIterator[ProcessedResponse]:
10091009
cancellable_registered = True
10101010
try:
10111011
fragments: list[str] = []
1012+
reasoning_fragments: list[str] = []
10121013
async for piece in self._iter_acp_stream_pieces(
10131014
runtime, prompt_request_id, requested_model
10141015
):
10151016
if piece.content:
10161017
fragments.append(piece.content)
1018+
if piece.reasoning_content:
1019+
reasoning_fragments.append(piece.reasoning_content)
10171020
full_response = "".join(fragments)
1021+
full_reasoning = (
1022+
"".join(reasoning_fragments) if reasoning_fragments else None
1023+
)
10181024

10191025
response = CanonicalChatResponse(
10201026
id=str(uuid.uuid4()),
@@ -1027,6 +1033,7 @@ async def _stream_with_keepalive() -> AsyncIterator[ProcessedResponse]:
10271033
message=ChatCompletionChoiceMessage(
10281034
role="assistant",
10291035
content=full_response,
1036+
reasoning_content=full_reasoning,
10301037
),
10311038
finish_reason="stop",
10321039
)

tests/unit/connectors/acp_core/test_base_connector.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
from __future__ import annotations
22

3+
from collections.abc import AsyncGenerator
34
from pathlib import Path
45
from typing import Any
56
from unittest.mock import AsyncMock, MagicMock, patch
67

78
import pytest
89
from src.connectors.acp_core.base_connector import BaseAcpConnector
9-
from src.connectors.acp_core.types import (
10-
ACPNotification,
11-
ACPProcessRuntime,
12-
AcpStreamPiece,
13-
)
10+
from src.connectors.acp_core.types import ACPNotification, ACPProcessRuntime
11+
from src.connectors.acp_core.types import AcpStreamPiece as AcpStreamPiece
1412
from src.connectors.contracts import ConnectorChatCompletionsRequest
1513
from src.core.domain.chat import CanonicalChatRequest, ChatMessage
14+
from src.core.domain.responses import ResponseEnvelope
1615

1716

1817
class DummyAcpConnector(BaseAcpConnector):
@@ -112,7 +111,7 @@ def test_session_update_tool_call_maps_to_progress_piece(
112111
},
113112
)
114113
piece = connector._session_update_to_stream_piece(msg)
115-
assert piece == AcpStreamPiece(reasoning_content="[tool] read_file")
114+
assert piece == AcpStreamPiece(reasoning_content="[tool] read_file\n")
116115

117116

118117
def test_resolve_stream_keepalive_interval_from_config(
@@ -162,3 +161,36 @@ async def test_base_acp_connector_history_injected_logic(
162161
prompt_text_2 = sent_params_2["prompt"][0]["text"]
163162
assert "System Note:" not in prompt_text_2
164163
assert prompt_text_2 == "hello"
164+
165+
166+
@pytest.mark.asyncio
167+
async def test_non_streaming_chat_completions_preserve_reasoning_content(
168+
connector: DummyAcpConnector,
169+
) -> None:
170+
connector.is_functional = True
171+
connector._default_project_dir = Path("/tmp/dummy")
172+
runtime = connector._create_runtime(Path("/tmp/dummy"), "dummy/model")
173+
174+
async def _mock_iter(
175+
_: ACPProcessRuntime, __: int, ___: str
176+
) -> AsyncGenerator[AcpStreamPiece, None]:
177+
yield AcpStreamPiece(reasoning_content="plan step\n")
178+
yield AcpStreamPiece(content="Answer")
179+
yield AcpStreamPiece(reasoning_content="tool finished\n")
180+
181+
with (
182+
patch.object(connector, "_acquire_runtime", AsyncMock(return_value=runtime)),
183+
patch.object(
184+
connector,
185+
"_prepare_prompt_request_locked",
186+
AsyncMock(return_value=(5, "dummy/model")),
187+
),
188+
patch.object(connector, "_iter_acp_stream_pieces", side_effect=_mock_iter),
189+
):
190+
response = await connector.chat_completions(_make_request())
191+
192+
assert isinstance(response, ResponseEnvelope)
193+
assert isinstance(response.content, dict)
194+
message = response.content["choices"][0]["message"]
195+
assert message["content"] == "Answer"
196+
assert message["reasoning_content"] == "plan step\ntool finished\n"

tests/unit/connectors/test_gemini_cli_acp.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,43 @@ async def _read(_: ACPProcessRuntime) -> ACPNotification:
254254

255255

256256
class TestGeminiCliAcpChatCompletions:
257+
async def test_non_streaming_chat_completions_preserve_reasoning_content(
258+
self, connector: GeminiCliAcpConnector, temp_workspace: Path
259+
) -> None:
260+
connector.is_functional = True
261+
connector._default_project_dir = temp_workspace
262+
runtime = connector._create_runtime(temp_workspace, "gemini-2.5-flash")
263+
264+
async def _mock_iter(
265+
_: ACPProcessRuntime, __: int, ___: str
266+
) -> AsyncGenerator[AcpStreamPiece, None]:
267+
yield AcpStreamPiece(reasoning_content="[tool] read_file\n")
268+
yield AcpStreamPiece(content="Hello")
269+
yield AcpStreamPiece(reasoning_content="[tool] read_file: complete\n")
270+
yield AcpStreamPiece(content=" world")
271+
272+
with (
273+
patch.object(
274+
connector, "_acquire_runtime", AsyncMock(return_value=runtime)
275+
),
276+
patch.object(
277+
connector,
278+
"_prepare_prompt_request_locked",
279+
AsyncMock(return_value=(5, "google/gemini-2.5-flash")),
280+
),
281+
patch.object(connector, "_iter_acp_stream_pieces", side_effect=_mock_iter),
282+
):
283+
response = await connector.chat_completions(_make_request())
284+
285+
assert isinstance(response, ResponseEnvelope)
286+
assert isinstance(response.content, dict)
287+
message = response.content["choices"][0]["message"]
288+
assert message["content"] == "Hello world"
289+
assert (
290+
message["reasoning_content"]
291+
== "[tool] read_file\n[tool] read_file: complete\n"
292+
)
293+
257294
async def test_non_streaming_chat_completions(
258295
self, connector: GeminiCliAcpConnector, temp_workspace: Path
259296
) -> None:

0 commit comments

Comments
 (0)