Compare commits

...

2 Commits

Author SHA1 Message Date
Sabhya Chhabria 77898e2630 fix(cursor): address review — guarantee a blank-line break + separate the final response
Two issues from the #254 review:

1. The separator was skipped whenever the pre-tool text ended in a single space
   or newline (or the post-tool text began with one), so it avoided hard
   concatenation but did not guarantee a paragraph break ("Checking. " + tool +
   "Done." stayed one paragraph; "Checking.\n" + ... was only a single newline).
   Now normalize: count the trailing/leading newlines the two blocks already
   carry and pad to a full blank line.

2. TurnComplete.response preferred the SDK's aggregate `result` (which has no
   separator) over the patched `response_text`, so direct consumers / the final
   response still saw run-on text — and the prior test missed it (result was "").
   Prefer `response_text` whenever any text streamed; fall back to `result` only
   for a tool-only turn.

Tests: blank-line guaranteed across a trailing space and a single newline; final
response uses the separated streamed text over a glued aggregate result.

Co-authored-by: Isaac
2026-06-16 05:10:18 +00:00
Sabhya Chhabria 079961f7f5 fix(cursor): separate post-tool narration from pre-tool text
The harness emitted one TextChunk per assistant text block with no boundary,
so when the model narrated, called a tool, then narrated again, the two blocks
rendered as a run-on string ("...returned by the tool.- Exit code: 2"). Track a
separator flag set on a tool call and insert a paragraph break before the next
assistant text block. Streamed deltas of a single response (no tool between)
still concatenate seamlessly — guarded by an endswith/startswith check so a
sentence is never split.

Found via the cursor SDK bug-bash (reproduced in every tool-using turn).

Co-authored-by: Isaac
2026-06-16 04:14:02 +00:00
2 changed files with 117 additions and 1 deletions
+25 -1
View File
@@ -540,14 +540,34 @@ class CursorExecutor(Executor):
state.has_sent_prompt = True
response_text = ""
tool_calls = 0
# A tool call between two assistant text blocks means they are distinct
# narration segments (pre- vs post-tool); insert a paragraph break so
# they don't render as one run-on string ("...by the tool.- Exit: 2").
# Streamed deltas of a single response (no tool between) still
# concatenate seamlessly, so this never splits one sentence.
separate_next_text = False
try:
run = await state.agent.send(prompt)
async for message in run.messages():
for event in _sdk_message_to_events(message):
if isinstance(event, TextChunk):
if separate_next_text and response_text and event.text:
# Guarantee a blank-line (paragraph) boundary between
# pre- and post-tool narration, regardless of any single
# trailing/leading newline the two blocks already carry
# (a lone space or "\n" must still become a blank line).
trailing = len(response_text) - len(response_text.rstrip("\n"))
leading = len(event.text) - len(event.text.lstrip("\n"))
if trailing + leading < 2:
pad = "\n" * (2 - trailing - leading)
event = TextChunk(text=pad + event.text)
separate_next_text = False
response_text += event.text
elif isinstance(event, ToolCallRequest):
tool_calls += 1
separate_next_text = True
elif isinstance(event, ToolCallComplete):
separate_next_text = True
yield event
result = await run.wait()
except asyncio.CancelledError:
@@ -564,7 +584,11 @@ class CursorExecutor(Executor):
yield ExecutorError(message=f"cursor-sdk run error: {detail}", retryable=True)
return
final = getattr(result, "result", "") or response_text or None
# Prefer the streamed text we accumulated (which carries the paragraph
# breaks inserted above) over the SDK's aggregate ``result`` (which does
# not) whenever any text was streamed; fall back to ``result`` only when
# nothing streamed (e.g. a tool-only turn).
final = response_text or getattr(result, "result", "") or None
# PHASE_LLM_RESPONSE policy (parity with the peer harnesses): evaluate the
# completed response before TurnComplete so a DENY blocks persistence.
if policy_eval is not None:
+92
View File
@@ -295,6 +295,98 @@ async def test_run_turn_streams_and_completes(monkeypatch: pytest.MonkeyPatch) -
assert completes[0].usage is None
async def test_run_turn_separates_text_across_a_tool_call(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Pre-tool and post-tool narration are distinct segments: a paragraph break
is inserted so they don't render as one run-on string. (Streamed deltas with
no tool between — see the test above — still concatenate seamlessly.)"""
script = {
"messages": [
_assistant("Let me check that."),
_tool("sys_x", "t1", "running", args={}),
_tool("sys_x", "t1", "completed", result="ok"),
_assistant("Done - exit 0."),
],
"result": "",
}
_install_fake_sdk(monkeypatch, [script])
executor = CursorExecutor(api_key="crsr_x")
try:
events = [e async for e in executor.run_turn([_user("hi")], [], "SYS")]
finally:
await executor.close()
texts = [e.text for e in events if isinstance(e, TextChunk)]
assert texts == ["Let me check that.", "\n\nDone - exit 0."] # post-tool text separated
completes = [e for e in events if isinstance(e, TurnComplete)]
assert completes[0].response == "Let me check that.\n\nDone - exit 0."
async def test_run_turn_separator_guarantees_blank_line_boundary(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The break must be a real blank line even when the pre-tool text already
ends in a single space or newline (which previously suppressed the separator,
leaving a run-on or a single-newline join)."""
scripts = [
{ # pre-tool text ends with a trailing space
"messages": [
_assistant("Checking. "),
_tool("x", "t1", "running", args={}),
_tool("x", "t1", "completed", result="ok"),
_assistant("Done."),
],
"result": "",
},
{ # pre-tool text ends with a single newline
"messages": [
_assistant("Checking.\n"),
_tool("x", "t2", "running", args={}),
_tool("x", "t2", "completed", result="ok"),
_assistant("Done."),
],
"result": "",
},
]
_install_fake_sdk(monkeypatch, scripts)
executor = CursorExecutor(api_key="crsr_x")
try:
ev_space = [e async for e in executor.run_turn([_user("a", "s1")], [], "SYS")]
ev_newline = [e async for e in executor.run_turn([_user("b", "s2")], [], "SYS")]
finally:
await executor.close()
resp_space = next(e.response for e in ev_space if isinstance(e, TurnComplete))
resp_newline = next(e.response for e in ev_newline if isinstance(e, TurnComplete))
assert resp_space == "Checking. \n\nDone." # trailing space -> still a blank line
assert resp_newline == "Checking.\n\nDone." # single \n upgraded to a blank line
async def test_run_turn_final_response_prefers_separated_streamed_text(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""TurnComplete.response must use the separator-corrected streamed text, not
the SDK's aggregate ``result`` (which lacks the paragraph break) — so direct
consumers of the final response see the same separation as the stream."""
script = {
"messages": [
_assistant("Pre."),
_tool("x", "t1", "running", args={}),
_tool("x", "t1", "completed", result="ok"),
_assistant("Post."),
],
"result": "Pre.Post.", # the SDK's glued aggregate, with no separator
}
_install_fake_sdk(monkeypatch, [script])
executor = CursorExecutor(api_key="crsr_x")
try:
events = [e async for e in executor.run_turn([_user("hi")], [], "SYS")]
finally:
await executor.close()
completes = [e for e in events if isinstance(e, TurnComplete)]
assert completes[0].response == "Pre.\n\nPost." # separated, not the glued "Pre.Post."
async def test_session_reused_across_turns(monkeypatch: pytest.MonkeyPatch) -> None:
scripts = [
{"messages": [_assistant("one")], "result": "one"},