Skip to content

Agent Event Stream Rendering

Date: 2026-07-06 Issue: #3170 Status: Draft

Problem

When an agent runs via fullsend run, the agent's reasoning and tool usage are invisible until the run completes. The existing progressParser emits only tool name + safe context (e.g. Read: path/to/file) via printer.Heartbeat(). It ignores thinking text, assistant text, token usage, errors, retries, and stream-level deltas. In GHA workflow logs the agent step appears as a long-running black box with no incremental output.

Decision

Implement full structured rendering of the agent event stream during execution, behind the Runtime interface so it works for any supported runtime. Claude Code exposes --output-format stream-json; opencode exposes --format json with ndjson events. A normalized event type set bridges runtime-specific parsing and runtime-agnostic rendering.

Architecture

Three layers:

  1. Normalized event types (internal/runtime/event.go) -- a Go interface AgentEvent with concrete structs for each event kind.
  2. Runtime-specific parsers -- each runtime reads its native ndjson format and emits AgentEvent values via a callback.
  3. EventRenderer (internal/runtime/renderer.go) -- consumes AgentEvent values and renders structured, colored terminal output via *ui.Printer.
sandbox stdout (ndjson)
    |
    v
parseClaudeStream()  -- or parseOpenCodeStream() for opencode
    |
    | calls onEvent(AgentEvent) synchronously per event
    v
EventRenderer.Handle()
    |
    v
ui.Printer  -->  stderr (terminal / GHA log)

The callback is synchronous: the parser blocks on the handler, which matches the existing pattern where progressParser blocks on printer.Heartbeat. No channels or extra goroutines.

Event Types

File: internal/runtime/event.go

go
// AgentEvent is the normalized event interface.
type AgentEvent interface{ agentEvent() }

type InitEvent struct {
    Model   string
    Version string // runtime version, optional
}

type ThinkingEvent struct {
    Text string // incremental thinking delta
}

type TextEvent struct {
    Text string // incremental assistant text delta
}

type ToolUseEvent struct {
    Name    string // "Bash", "Read", "Write", etc. ("tool" for unknown)
    Summary string // compact one-line summary
}

type TokensEvent struct {
    InputTokens  int
    OutputTokens int
    CacheRead    int
    CacheWrite   int
}

type ResultEvent struct {
    NumTurns                 int
    TotalCostUSD             float64
    IsError                  bool
    ErrorMessage             string
    Subtype                  string // "success", "error_max_turns", etc.
    InputTokens              int
    OutputTokens             int
    CacheCreationInputTokens int
    CacheReadInputTokens     int
}

type ErrorEvent struct {
    ErrorType string
    Message   string
}

type RetryEvent struct {
    Attempt    int
    MaxRetries int
    DelayMs    int
    Error      string
}

ToolUseEvent carries a pre-computed Summary rather than raw input params. The runtime-specific parser builds the summary using the existing extractSafeContext logic (expanded to match agentic-ci's _format_tool coverage). This keeps the renderer simple and avoids leaking raw tool arguments across the abstraction boundary.

ThinkingEvent and TextEvent carry incremental deltas so the renderer prints text as it arrives, matching agentic-ci's streaming behavior.

Each struct embeds a no-op agentEvent() marker method to satisfy the interface.

Callback Integration

A new field on RunParams:

go
type RunParams struct {
    // ... existing fields unchanged ...
    OnEvent func(AgentEvent) // if nil, events are silently discarded
}

The runtime's Run method calls params.OnEvent(evt) as it parses each line.

Claude Stream Parser Refactor

File: internal/runtime/claude_progress.go

The existing progressParser is replaced by:

go
func parseClaudeStream(r io.Reader, onEvent func(AgentEvent)) error

It processes all event types from the stream-json format:

stream-json eventEmitted AgentEvent
system / initInitEvent{Model, Version}
system / api_retryRetryEvent{Attempt, MaxRetries, DelayMs, Error}
stream_event / content_block_start (text)sets internal state
stream_event / content_block_start (thinking)sets internal state
stream_event / content_block_start (tool_use)sets internal state, records tool name
stream_event / content_block_delta (text_delta)TextEvent{Text}
stream_event / content_block_delta (thinking_delta)ThinkingEvent{Text}
stream_event / content_block_delta (input_json_delta)accumulates tool input JSON
stream_event / content_block_stop (tool block)ToolUseEvent{Name, Summary}
stream_event / message_deltaTokensEvent (throttled to every 5k tokens)
stream_event / errorErrorEvent{ErrorType, Message}
resultResultEvent with all metric fields
assistantif no stream_event has been seen yet, parse tool_use blocks and emit ToolUseEvent (supports older Claude Code versions that emit complete messages without streaming deltas)

The existing extractSafeContext, extractBinaryName, and allowedTools functions are reused to build ToolUseEvent.Summary. Unknown tools get name "tool" and empty summary.

The old progressParser signature is preserved as a thin wrapper:

go
func progressParser(r io.Reader, printer *ui.Printer, start time.Time, metrics *RunMetrics) error {
    renderer := NewEventRenderer(printer, start, metrics)
    return parseClaudeStream(r, renderer.Handle)
}

This maintains backward compatibility for any internal callers.

EventRenderer

File: internal/runtime/renderer.go

go
type EventRenderer struct {
    printer    *ui.Printer
    start      time.Time
    metrics    *RunMetrics
    isCI       bool
    inText     bool
    inThinking bool
    lastTokenTotal int
}

func NewEventRenderer(printer *ui.Printer, start time.Time, metrics *RunMetrics) *EventRenderer
func (r *EventRenderer) Handle(evt AgentEvent)

Handle dispatches on event type:

EventRendering
InitEventprinter.Header with model/version, printer.KeyValue for details
ThinkingEventdim/italic prefix "Thinking" then incremental text via printer.Raw
TextEventprefix "Claude" then incremental text via printer.Raw
ToolUseEventwrench icon + name + summary via printer.Heartbeat, increments metrics.ToolCalls
TokensEventtoken stats via printer.StepInfo, only when total crosses 5k boundary
ResultEventpopulates metrics.* fields, renders summary via printer.Header + printer.KeyValue
ErrorEventprinter.StepFail with error type and message
RetryEventprinter.StepWarn with attempt count and error

Block state tracking: the renderer tracks inText / inThinking. When a new block starts (ThinkingEvent after text, ToolUseEvent after thinking, etc.), the renderer closes the previous block by emitting a newline and resetting style. This is the same state machine as agentic-ci's _end_block().

For CI (GITHUB_ACTIONS=true), tool use events also emit ::notice:: annotations to stderr, same as today.

No new Printer methods are needed. Raw() handles incremental text output. The renderer uses lipgloss directly for italic/dim styling on thinking text, writing through printer.Raw().

ClaudeRuntime.Run Wiring

Minimal change to claude.go:

go
func (ClaudeRuntime) Run(ctx context.Context, params RunParams, printer *ui.Printer,
    start time.Time, metrics *RunMetrics) (int, error) {
    // ... existing sandbox exec + TeeReader setup unchanged ...

    handler := params.OnEvent
    if handler == nil {
        renderer := NewEventRenderer(printer, start, metrics)
        handler = renderer.Handle
    }

    if parseErr := parseClaudeStream(r, handler); parseErr != nil {
        // ... existing error handling unchanged ...
    }
    // ... existing wait logic unchanged ...
}

When fullsend run in run.go sets up the call, it passes OnEvent: nil to get the default rendering. Custom handlers can be provided for testing or alternative output modes.

Information Disclosure

The existing allowedTools safeguard carries over unchanged -- unknown tools get name "tool" and empty summary.

Thinking and text events contain the model's own output inside the sandbox. This is the same content that ends up in the transcript JSONL artifact. Anyone who can see the GHA logs can already see the transcript artifact. If redaction is needed later, the EventRenderer is the natural place to add a filter.

Future: opencode Integration

When opencode lands, it adds:

go
func parseOpenCodeStream(r io.Reader, onEvent func(AgentEvent)) error

This maps opencode's ndjson events (tool_use, text, thinking, step_start, step_finish, error) to the same AgentEvent types. The EventRenderer works unchanged. The event types become the format-neutral bridge between runtime-specific parsing and runtime-agnostic rendering, which is the contract issue #1935 is asking for.

Testing Strategy

  • Event types: Unit tests that parse known Claude Code stream-json fixtures and assert the sequence of AgentEvent values emitted. Fixtures derived from real agent runs (sanitized).
  • Renderer: Unit tests that feed AgentEvent sequences to EventRenderer and assert the output written to a bytes.Buffer via the Printer.
  • Integration: The existing claude_progress_test.go tests are updated to verify the new parser produces equivalent output to the old one for the same input fixtures.
  • Block state machine: Tests for edge cases -- thinking followed by text, tool_use inside thinking, multiple consecutive tools, empty deltas.
  • Sanitization: Tests that allowedTools filtering and sanitizeOutput are applied correctly to all rendered output.

Files Changed

FileChange
internal/runtime/event.goNew: normalized event type definitions
internal/runtime/renderer.goNew: EventRenderer struct and Handle method
internal/runtime/claude_progress.goRefactor: replace progressParser with parseClaudeStream, keep progressParser as wrapper
internal/runtime/runtime.goAdd OnEvent field to RunParams
internal/runtime/claude.goWire OnEvent / default EventRenderer in Run
internal/runtime/renderer_test.goNew: renderer unit tests
internal/runtime/claude_progress_test.goUpdate: verify new parser against existing fixtures
  • #663 -- Evaluate showboat for code agent work visibility (different approach)
  • #1591 -- StepDebug and --verbose flag (complementary: debug internals vs agent progress)
  • #1935 -- TranscriptHandler format-neutral contract (event types serve as that contract for live streaming)
  • #872 -- Surface failure reasons when logs inaccessible (partially addressed by live progress)