Skip to content

feat(agent): add opt-in live event streaming to AgentBuilder#161

Merged
niclaslindstedt merged 1 commit intomainfrom
claude/fix-agent-output-streaming-qk4WR
Apr 18, 2026
Merged

feat(agent): add opt-in live event streaming to AgentBuilder#161
niclaslindstedt merged 1 commit intomainfrom
claude/fix-agent-output-streaming-qk4WR

Conversation

@niclaslindstedt
Copy link
Copy Markdown
Owner

Summary

A zag user reported that migrating from a shell-out-to-zag wrapper (which
tailed the CLI's stderr) to the in-process AgentBuilder::exec().await API
removed the live per-step agent output they used to see in their terminal.
exec captured everything into AgentOutput silently; output.result was
still there for saves/dependency injection, but there was no intermediate
stream. Their migration note said "A follow-up can restore live visibility
by tailing the zag session log in parallel"
— and for that plan to work the
library had to start writing a session log when exec runs. It didn't.

This PR makes live visibility a one-line opt-in on the builder and aligns
the library with CLAUDE.md's "libraries are the source of truth" rule.

Library-side usage

let output = AgentBuilder::new()
    .provider("claude")
    .stream_events_to_stderr(ListenFormat::Text)   // <-- the fix
    .exec(prompt).await?;

Or with a custom callback:

AgentBuilder::new()
    .on_log_event(|ev| my_tui.push(ev.clone()))
    .exec(prompt).await?;

Changes

  • New builder setters (zag-agent/src/builder.rs):
    • enable_session_log(bool) / session_log(SessionLogMode) — opt-in
    • on_log_event(fn) — generic per-event callback
    • stream_events_to_stderr(ListenFormat) — convenience tail-to-stderr
    • stream_show_thinking(bool) — include reasoning events
  • SessionLogModeDisabled (default) / Auto / External(coord).
    External lets advanced callers (zag-serve, review/plan handlers) hand
    in a pre-built coordinator so we don't double-start.
  • Event tap on SessionLogWriter::emit — single chokepoint, fires
    outside the writer mutex so subscribers can't deadlock or block peer
    emitters.
  • AgentOutput::log_path — new Option<String> populated when
    logging ran so callers can tail_session_log the file themselves. Marked
    #[serde(default, skip_serializing_if = "Option::is_none")] so all six
    bindings stay JSON-compatible.
  • Moved ListenFormat + format_event_* from zag-orch/src/listen.rs
    down into zag-agent/src/listen.rs. Dep direction is zag-orch → zag-agent, so the builder couldn't reach them before. zag-orch::listen
    re-exports the moved items for backward compatibility.
  • Moved live_adapter_for_provider from zag-cli into zag-agent
    (the per-provider adapters already lived there).

Backwards compatibility

  • Default SessionLogMode for AgentBuilder is Disabled, so existing
    library consumers see no side effects unless they opt in.
  • CLI path (AgentFactory + own coordinator) is untouched.
  • zag-serve path (AgentFactory direct) is untouched.
  • Bindings (TypeScript/Python/C#/Swift/Java/Kotlin) shell out to the zag
    CLI — unaffected. They'll silently ignore the new log_path JSON field.

Test plan

  • make build — workspace compiles
  • make clippy — zero warnings (cargo clippy --workspace --all-targets -- -D warnings)
  • make test — all 1039 tests pass
  • New tests in zag-agent/src/mock_integration_tests.rs:
    • test_exec_without_session_log_leaves_log_path_none
    • test_exec_with_enable_session_log_populates_log_path
    • test_on_log_event_receives_lifecycle_events
    • test_on_log_event_implicitly_enables_session_log
    • test_stream_events_to_stderr_implicitly_enables_session_log
  • Manual: run zag listen --latest in a second terminal while a
    builder-driven exec is running and confirm the log is identical before
    and after this PR.
  • Manual: write a small program that calls
    AgentBuilder::new().provider("claude").stream_events_to_stderr(ListenFormat::Text).exec(...)
    and confirm per-step events appear live on stderr.

https://claude.ai/code/session_019yzV2V34EeL9TJjtw5PaRL

Library callers of `AgentBuilder::exec()` previously lost the live
per-step stderr tail that a shell-out-to-`zag` wrapper produced —
`exec` captured everything into `AgentOutput` silently with no way
to observe the session while it ran.

- Adds `AgentBuilder::stream_events_to_stderr(ListenFormat)` for a
  drop-in replacement for the old live tail, and
  `AgentBuilder::on_log_event(fn)` for custom consumers.
- Lifts `SessionLogCoordinator` startup into the builder behind
  `SessionLogMode` (`Disabled` / `Auto` / `External`) so library
  users get the JSONL session log for free when they opt in.
- Populates `AgentOutput::log_path` so callers that prefer to tail
  the log themselves (via `zag_orch::listen::tail_session_log`) can
  find the file without guessing.
- Taps `SessionLogWriter::emit` with an optional per-event callback;
  fires outside the writer mutex so subscribers can't deadlock.
- Moves `ListenFormat` + `format_event_*` helpers from `zag-orch`
  down into `zag-agent` (dep direction is `zag-orch -> zag-agent`).
  `zag-orch::listen` re-exports them for backward compatibility.

Default for `AgentBuilder` is `SessionLogMode::Disabled`, so existing
Rust library consumers see no behaviour change. The CLI path
(`AgentFactory`-based) is untouched and continues to manage its own
coordinator.
@niclaslindstedt niclaslindstedt marked this pull request as ready for review April 18, 2026 18:02
@niclaslindstedt niclaslindstedt merged commit 28bd91e into main Apr 18, 2026
6 checks passed
@niclaslindstedt niclaslindstedt deleted the claude/fix-agent-output-streaming-qk4WR branch April 18, 2026 18:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants