StreamEmitter::test_channel() helper: returns a live mpsc receiver so
tests can assert which agent_message_chunk messages were emitted, unlike
noop() which drops the receiver.
Chunk-emission (2 tests): verify one chunk per text delta with correct
content, and that empty/absent text emits nothing.
Auto→Responses upgrade (2 tests): verify that Auto mode retries on
/responses when /chat/completions returns a 400 with an
is_responses_required body, and that the sticky auto_upgraded flag
causes subsequent calls to skip /chat/completions entirely. Both tests
assert which paths the fake server saw — the discriminating assertion
per Thufir's review.
Config validation (2 tests): stream_chunk_timeout=0 and
llm_body_chunk_timeout=0 both rejected with the documented error message.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Cover three high-priority gaps identified in the PR #944 test plan:
Gap 3 (dual-timeout switchover): Verify first_byte_timeout governs
pre-content and stream_chunk_timeout governs post-content. Three tests
exercise the transition using sub-second timeouts against a delayed-write
TCP server.
Gap 1 (split-boundary framing): Verify SseReader handles event boundaries
split across TCP chunks, data: prefix split mid-keyword, and trailing data
without a final \n\n boundary before EOF.
Gap 6 (send_stream_with_retry): Verify 5xx triggers retry and succeeds on
second attempt, all-503 exhausts retries with clear error, and 401 surfaces
LlmAuth immediately without retrying.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The buffered-body inter-chunk timeout was named llm_stream_chunk_timeout,
confusingly similar to stream_chunk_timeout (the SSE inter-chunk timeout).
Operators tuning "stream chunk timeout" would set the wrong env var.
Rename to llm_body_chunk_timeout / SPROUT_AGENT_LLM_BODY_CHUNK_TIMEOUT_SECS
to clearly distinguish the buffered path from the streaming path.
Extract the openai_to_sse_events test helper (duplicated in fake_llm.rs,
golden_transcripts.rs, and regressions.rs) into tests/common/mod.rs.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The SSE reader split events only on "\n\n", so providers or proxies
emitting CRLF or bare-CR line terminators broke event framing. It also
let SseReader.buf grow without bound when a stream never produced an
event boundary. Streaming requests had no retry on the initial send, and
the Responses-API tool-call accumulator used a HashMap, yielding
non-deterministic tool-call order.
- Normalize CR and CRLF to LF on chunk append (SSE spec equivalence)
- Cap SseReader.buf at MAX_LLM_RESPONSE_BYTES, erroring before any
unbounded growth
- Add send_stream_with_retry() applying the buffered path's transport/
5xx/429 backoff to the initial request only, stopping before any chunk
is emitted to avoid duplicate output
- Replace the HashMap tool-call accumulator with a first-seen-ordered Vec
to match the buffered parser's ordering
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
output_item.added may arrive after function_call_arguments.delta when
the Responses API reorders events. The delta handler creates the HashMap
entry with an empty name, and the previous or_insert_with no-oped on
existing entries. Use and_modify to backfill the name regardless of
insertion order.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Replace buffered LLM requests with Server-Sent Events streaming. Each
text delta emits an agent_message_chunk session update as it arrives,
providing a natural keepalive that resets the ACP idle clock without
relying solely on the 30s ticker.
Design decisions:
- Two reqwest::Client instances: `http` (with global timeout for
summarize) and `http_stream` (no global timeout, enforces first-byte
and inter-chunk timeouts via tokio::time::timeout)
- Two-phase timeout: llm_timeout (120s) until first content delta,
then stream_chunk_timeout (30s) between subsequent events
- Anthropic: index-keyed HashMap<usize, (String, String)> for parallel
tool-call accumulation via content_block index
- OpenAI Chat: Vec-indexed accumulation by tool_calls[].index
- Responses API: routes on JSON `type` field inside data payload, uses
response.output_text.delta and response.function_call_arguments.delta
- MAX_LLM_RESPONSE_BYTES caps accumulated semantic content (text + tool
args), not raw SSE wire bytes
- SSE parser handles : comments, multi-line data:, id:/retry: fields
- Keepalive ticker (G) preserved as fallback for reasoning models that
pause before producing content
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Death notices referenced the thread root with NIP-10 marker "reply"
instead of "root", causing incorrect threading in clients. Tag-parse
errors incorrectly mapped to RelayError::AuthFailed.
- Change e-tag marker from "reply" to "root" in build_death_notice
- Add RelayError::EventBuild variant for tag-parse failures
- Add publish_death_notice() method on HarnessRelay to consolidate
the build+publish+warn pattern used by both timeout and transport
error paths
- Replace inline match blocks in handle_prompt_result with method call
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Address review findings from Thufir on PR #935:
- Post death notice on transport-error respawns (Io, WriteTimeout,
Timeout, Protocol) — same user-facing silence as idle timeout
- Thread death notices into the original conversation via e-tag reply
so users know which task died in busy channels
- Add explicit "keepalive" match arm in handle_session_update for
documentation clarity
- Update idle-reset comment to clarify belt-and-suspenders intent
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The 20s margin between the 600s max shell timeout and the 620s idle
timeout caused spurious session kills during legitimate long-running
tool calls. This changeset addresses the problem from four angles:
- Raise DEFAULT_IDLE_TIMEOUT_SECS from 620 to 900 (300s margin)
- Reset idle clock explicitly on tool_call session updates with
observability logging
- Post a visible channel message (death notice) when a session ends
due to idle timeout or unexpected agent exit
- Emit a keepalive session update every 30s while waiting on the LLM
provider response, preventing idle timeout during slow completions
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>