Skip to content

Commit 260064e

Browse files
declan-scaleclaude
andcommitted
refactor(harness): exception-safe full-message post + drop dead state + cover error/finally paths in auto_send
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3cc0326 commit 260064e

2 files changed

Lines changed: 46 additions & 14 deletions

File tree

src/agentex/lib/core/harness/auto_send.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,12 @@ async def auto_send(
4848
deriver = SpanDeriver() if tracer is not None else None
4949
final_text_parts: list[str] = []
5050
current_ctx: Any = None
51-
current_kind: str | None = None # "text" | "reasoning"
5251

5352
async def _close_current() -> None:
54-
nonlocal current_ctx, current_kind
53+
nonlocal current_ctx
5554
if current_ctx is not None:
5655
await current_ctx.close()
5756
current_ctx = None
58-
current_kind = None
5957

6058
try:
6159
async for event in events:
@@ -72,7 +70,6 @@ async def _close_current() -> None:
7270
initial_content=event.content,
7371
)
7472
current_ctx = await ctx.__aenter__()
75-
current_kind = ctype
7673

7774
elif isinstance(event, StreamTaskMessageDelta):
7875
if current_ctx is not None and event.delta is not None:
@@ -100,14 +97,15 @@ async def _close_current() -> None:
10097
# streaming context first, then post the full message by opening
10198
# a context with the content and closing it immediately
10299
# (no deltas; StreamingTaskMessageContext.close() persists
103-
# initial_content when the accumulator is empty).
100+
# initial_content when the accumulator is empty). Use async with
101+
# so the context is closed even if close() raises (__aexit__
102+
# delegates to close()).
104103
await _close_current()
105-
ctx = streaming.streaming_task_message_context(
104+
async with streaming.streaming_task_message_context(
106105
task_id=task_id,
107106
initial_content=event.content,
108-
)
109-
full_ctx = await ctx.__aenter__()
110-
await full_ctx.close()
107+
):
108+
pass
111109

112110
finally:
113111
await _close_current()

tests/lib/core/harness/test_auto_send.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,15 +126,24 @@ async def test_auto_send_streams_text_and_returns_final_text():
126126
async def test_auto_send_posts_full_tool_messages():
127127
streaming = _FakeStreaming()
128128
events = [
129+
# A bare tool_request Start (no Done/Full) must NOT open a streaming
130+
# context on its own — only Full events post messages.
131+
StreamTaskMessageStart(
132+
type="start", index=0,
133+
content=ToolRequestContent(
134+
type="tool_request", author="agent",
135+
tool_call_id="c0", name="Bash", arguments={},
136+
),
137+
),
129138
StreamTaskMessageFull(
130-
type="full", index=0,
139+
type="full", index=1,
131140
content=ToolRequestContent(
132141
type="tool_request", author="agent",
133142
tool_call_id="c1", name="Bash", arguments={"cmd": "ls"},
134143
),
135144
),
136145
StreamTaskMessageFull(
137-
type="full", index=1,
146+
type="full", index=2,
138147
content=ToolResponseContent(
139148
type="tool_response", author="agent",
140149
tool_call_id="c1", name="Bash", content="file.py",
@@ -145,12 +154,12 @@ async def test_auto_send_posts_full_tool_messages():
145154

146155
assert result.final_text == ""
147156

148-
# One context per Full event
157+
# The opened contexts correspond ONLY to the two Full events — the
158+
# tool_request Start did not open a context.
149159
ctx_events = [s for s in streaming.sink if s[0] == "ctx"]
150160
assert len(ctx_events) == 2
151161
content_types = [s[1] for s in ctx_events]
152-
assert "tool_request" in content_types
153-
assert "tool_response" in content_types
162+
assert content_types == ["tool_request", "tool_response"]
154163

155164
# Each context is opened and closed
156165
opens = [s for s in streaming.sink if s[0] == "open"]
@@ -246,3 +255,28 @@ async def test_auto_send_closes_text_context_before_full_message():
246255
text_close_idx = next(i for i, s in enumerate(event_sequence) if s == ("close", "text"))
247256
tool_open_idx = next(i for i, s in enumerate(event_sequence) if s == ("open", "tool_request"))
248257
assert text_open_idx < text_close_idx < tool_open_idx
258+
259+
260+
# ---------------------------------------------------------------------------
261+
# Test 5: midstream error — propagates AND the open context is closed (finally)
262+
# ---------------------------------------------------------------------------
263+
264+
@pytest.mark.asyncio
265+
async def test_open_context_closed_on_midstream_error():
266+
streaming = _FakeStreaming()
267+
268+
async def _exploding_gen():
269+
yield StreamTaskMessageStart(
270+
type="start", index=0,
271+
content=TextContent(type="text", author="agent", content=""),
272+
)
273+
raise RuntimeError("boom")
274+
275+
with pytest.raises(RuntimeError, match="boom"):
276+
await auto_send(
277+
_exploding_gen(), task_id="task1", tracer=None, streaming=streaming
278+
)
279+
280+
# The text context that was opened mid-stream was closed by the finally block.
281+
assert ("open", "text") in [(s[0], s[1]) for s in streaming.sink]
282+
assert ("close", "text") in [(s[0], s[1]) for s in streaming.sink]

0 commit comments

Comments
 (0)