Skip to content

fix: type the agent SSE payload — render event content, end the stream on a terminal event - #121

Open
ambiorix2099 wants to merge 3 commits into
mainfrom
fix/agent-stream-typed-events
Open

fix: type the agent SSE payload — render event content, end the stream on a terminal event#121
ambiorix2099 wants to merge 3 commits into
mainfrom
fix/agent-stream-typed-events

Conversation

@ambiorix2099

@ambiorix2099 ambiorix2099 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

The SSE layer read payload keys the server doesn't send, and waited for a connection close the server doesn't perform. This replaces the untyped map with a typed payload mirroring AgentSSEEvent, so a renamed field breaks the build instead of printing blank.

Rendering (#116). thinking and error read message; the server sends content — so every thinking line was blank and a failed run printed [error] with no reason. Also args not input, and target not agentName. The issue's table was wrong on one row: guardrail-fail detail does exist, in content.

Termination (#102) — partial. The stream now ends on a terminal event rather than waiting for a close that never comes. Also closes a leak where the producer goroutine could be stranded on a full channel.

This does not close #102. The server buffers events for 5 minutes; past that it replays nothing and holds the connection open, so no terminal event ever arrives and the client still hangs. Verified: an execution that ended 41s ago now exits in ~1s, while two from ~18 minutes earlier still hang. The remaining fix is server-side: AgentService.openStream already looks up the execution status for an existence check and discards the result. Completing the emitter when that status is terminal would close this at any age, without depending on the buffer.

Also fixed: run_bounded in the e2e helpers could never return 124 under bats' errexit, so the regression guard would have passed or failed for the wrong reason.

Tests: go test ./... and go test ./internal/agent -race pass. The #102 guard is unskipped — it fails before this change and passes after, against a real server.

Closes #116
Part of #102

ambiorix2099 and others added 3 commits August 13, 2026 12:41
Streaming an already-terminal execution hung forever: the parse loop
returned only on body EOF, and the server holds that connection open
emitting nothing but heartbeat comments, which the parser discards.

StreamExecution now stops once a done or error event has reached the
sink, by cancelling the stream context — the client's documented
shutdown path, which the service already treats as a clean stop, rather
than a second shutdown mechanism. The CLI no longer depends on the
server closing the connection.

Cancellation is also what releases the producer: its send onto the event
channel now selects on the context, so a consumer that stops reading
while the buffer is full can no longer strand that goroutine.

Introduces the typed EventPayload mirroring the server's AgentSSEEvent.
It is what makes the terminal types checkable here, and what the renderer
reads in place of untyped map lookups in the following commit.

Refs #102, #116

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sink looked payload keys up by string literal, and several of those
keys are not in what the server sends. thinking and error both read
"message" where the server sends "content", so a failed run printed
"[error]" with no reason at all. Tool-call arguments arrive as "args",
not "input", and a handoff names its destination "target", not
"agentName" — both confirmed against the server's AgentSSEEvent.

guardrail_fail has no "reason" field either; the server puts the failure
detail in "content", so that is what renders, and a payload without one
leaves the guardrail name standing rather than trailing an empty
separator.

Reading the typed payload means the next server-side rename is a compile
error instead of a blank line. terminalSink writes to an io.Writer so
each event type's rendering is covered by a test.

Refs #116

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guard for #102 now passes: streaming a just-completed execution
returns as soon as the done event arrives.

run_bounded needed fixing to make the assertion meaningful. Under bats'
errexit, an unguarded `wait` on the child it had just killed aborted the
test with the signal status before the helper could return 124, and the
`rc=$?` at the call site was equally unreachable — so the timeout the
test checks for could never be observed.

Refs #102

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@propp-orkes propp-orkes left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review:
This PR fixes a regression where the agent stream would hang indefinitely if the server kept the connection open after a terminal event (issue #102). It implements several key improvements:

  1. Terminal Event Detection: The service layer now detects EventDone and EventError and terminates the stream, preventing hangs on servers that keep connections open for heartbeats.
  2. Robust Parsing: Replaced generic map[string]any parsing in the CLI with a strongly-typed EventPayload struct. This prevents silent failures if the server's JSON schema changes slightly and provides better type safety.
  3. Goroutine Leak Prevention: Updated the SSE parser to use context.Context in its select statements. This ensures that if a consumer stops reading (e.g., due to an error or user interruption), the producer goroutine is not stranded on a full channel.
  4. Improved UX: The CLI now handles guardrail failures more gracefully, avoiding trailing separators if no failure reason is provided.
  5. Comprehensive Testing: Added extensive unit and integration tests to verify the fix and prevent future regressions in stream handling and payload parsing.

Comment thread cmd/agent_stream.go
w io.Writer
}

func newTerminalSink() terminalSink {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit] Could we have a more clear name for this? I feel like new is a touch ambiguous and using a verb like createTerminalSink() might be better.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants