Thinking
Let's start by exploring the repository structure to understand what we're dealing with.
Tool
🔧 list_files
{"path":".","recursive":false}
Arguments
{"path":".","recursive":false}
Result
drwxr-xr-x 6 user user 4096 Aug 26 11:11 .
drwxr-xr-x 1 root root 4096 Aug 26 11:11 ..
drwxr-xr-x 7 user user 4096 Aug 26 11:11 .git
-rw-r--r-- 1 user user 22 Aug 26 11:11 .gitignore
-rw-r--r-- 1 user user 6387 Aug 26 11:11 README.md
drwxr-xr-x 3 user user 4096 Aug 26 11:11 cmd
drwxr-xr-x 2 user user 4096 Aug 26 11:11 docker
-rw-r--r-- 1 user user 2628 Aug 26 11:11 go.mod
-rw-r--r-- 1 user user 16578 Aug 26 11:11 go.sum
drwxr-xr-x 11 user user 4096 Aug 26 11:11 internal
-rw-r--r-- 1 user user 3092 Aug 26 11:11 zoo.hcl.example
Tool
🔧 bash
{"command":"cd /project && git log --oneline -5 && git status"}
Arguments
{"command":"cd /project && git log --oneline -5 && git status"}
Result
7423d86 Try to re-roll broken tool calls.
f58963a Merge pull request 'Jobs page: follow the live log tail, with a jump-to-bottom button' (#58) from issue-57-greg into main
0b5138d Merge pull request 'tools: drop the PTY from docker exec, keep stdout/stderr separate' (#54) from issue-52-greg into main
c91b25a More correct tool schema.
f4da8cd Jobs page: follow the live log tail, with a jump-to-bottom button
On branch issue-62-greg
nothing to commit, working tree clean
Thinking
This is a Go project. Let's explore the structure to understand the codebase, especially around streaming and job logs.
Tool
🔧 bash
{"command":"cd /project && find . -type f -name \"*.go\" | head -50 && echo \"---\" && cat README.md | head -60"}
Arguments
{"command":"cd /project && find . -type f -name \"*.go\" | head -50 && echo \"---\" && cat README.md | head -60"}
Result
./cmd/zoo/main.go
./internal/config/config.go
./internal/config/config_test.go
./internal/web/templates.go
./internal/web/web_test.go
./internal/web/web.go
./internal/livelog/hub.go
./internal/livelog/hub_test.go
./internal/tools/forgejo.go
./internal/tools/grep_search.go
./internal/tools/context.go
./internal/tools/remove_file.go
./internal/tools/registry.go
./internal/tools/write_file.go
./internal/tools/move_file.go
./internal/tools/read_file.go
./internal/tools/edit-file.go
./internal/tools/list_files.go
./internal/tools/bash.go
./internal/tools/shell.go
./internal/tools/tools_test.go
./internal/llm/arguments_test.go
./internal/llm/streamer_test.go
./internal/llm/client.go
./internal/llm/streamer.go
./internal/llm/arguments.go
./internal/llm/types.go
./internal/store/store.go
./internal/store/store_test.go
./internal/forgejo/watcher_test.go
./internal/forgejo/poll.go
./internal/forgejo/watcher.go
./internal/forgejo/repo_filter.go
./internal/forgejo/webhook_test.go
./internal/forgejo/webhook.go
./internal/forgejo/repo_filter_test.go
./internal/forgejo/client.go
./internal/forgejo/poll_test.go
./internal/forgejo/client_test.go
./internal/forgejo/event.go
./internal/agentrun/forgejo.go
./internal/agentrun/roster_test.go
./internal/agentrun/context.go
./internal/agentrun/loop.go
./internal/agentrun/sandboxgit.go
./internal/agentrun/loop_test.go
./internal/agentrun/docker.go
./internal/agentrun/seed_test.go
./internal/agentrun/docker_integration_test.go
./internal/agentrun/run.go
---
# 🐾 Zoo
**Autonomous AI agents that work your Forgejo issues & pull requests.**
Zoo watches a [Forgejo](https://codeberg.org/forgejo/forgejo) instance for issue and pull request events, then dispatches them to configured AI agents running in isolated Docker containers. Each agent has its own role, LLM model, and personality — like a menagerie working together to keep your projects moving.
---
## ✨ Features
- **Multi-agent orchestration** — Assign different tasks to specialized agents (reviewers, managers, developers).
- **Event-driven routing** — Configure which agent handles `issue:new`, `pr:new`, `issue:comment`, `issue:assigned`, and more.
- **LLM flexibility** — Plug in any OpenAI-compatible API; each agent gets its own model choice.
- **Isolated execution** — Agents run in Docker containers with full filesystem access but no persistence between runs.
- **Live dashboard** — Real-time web UI showing active agents, logs, and job history.
- **Webhook & polling support** — React to events instantly via webhooks, or fall back to polling.
---
## 🚀 Quick Start
### Prerequisites
| Requirement | Version |
|-------------|---------|
| Go | 1.26+ |
| Docker | Latest |
| Forgejo | Any (self-hosted or codeberg.dk) |
| LLM endpoint | OpenAI-compatible API |
### Configuration
Copy the example config and customize it:
```bash
cp zoo.hcl.example zoo.hcl
```
Edit `zoo.hcl` with your Forgejo credentials, LLM tokens, and agent definitions. See the [configuration reference](#-configuration-reference) below.
### Running
```bash
go build -o zoo ./cmd/zoo
./zoo
```
The daemon starts on port `:8080` by default. Open your browser to see the dashboard.
---
## 👥 Meet the Agents
The example configuration includes four agents, each with a distinct role:
| Agent | Role | Suggested LLM | Handles |
|----------|-----------------------|---------------------|----------------------------------|
| **leon** | Engineering Manager | Qwen 3.8 | New issues, comments |
| **greg** | Senior Developer | Qwen 3.8 | Pull request reviews |
| **anna** | UI/UX Designer | Qwen 3.6 | Design-related issues & PRs |
Thinking
This is a Go project called "zoo" — an autonomous AI agent system. The issue is about streaming creating empty blocks in the job log. Let's take a look at the relevant files:
1. `internal/livelog/hub.go` - Live log hub
2. `internal/llm/streamer.go` - LLM streamer
3. `internal/web/web.go` - Web UI
4. `internal/web/templates.go` - Templates
The problem: When streaming a job, empty blocks appear after the thinking block. After refreshing (re-fetching the entire log), the empty blocks disappear. In other words, the streaming path is creating blocks that don't exist in the persisted log.
Let's take a look at the livelog hub and the streamer.
Tool
🔧 read_file
{"end_line":1,"path":"internal/livelog/hub.go","start_line":1}
Arguments
{"end_line":1,"path":"internal/livelog/hub.go","start_line":1}
Result
{"path":"internal/livelog/hub.go","content":"// Package livelog is an in-memory pub/sub hub that lets agentrun publish","start_line":1,"end_line":1,"total_lines":165,"truncated":true}
Tool
🔧 read_file
{"end_line":1,"path":"internal/llm/streamer.go","start_line":1}
Arguments
{"end_line":1,"path":"internal/llm/streamer.go","start_line":1}
Result
{"path":"internal/llm/streamer.go","content":"package llm","start_line":1,"end_line":1,"total_lines":79,"truncated":true}
Thinking
The read_file tool requires start_line and end_line. Let's read the entire file.
Tool
🔧 read_file
{"end_line":165,"path":"internal/livelog/hub.go","start_line":1}
Arguments
{"end_line":165,"path":"internal/livelog/hub.go","start_line":1}
Result
{"path":"internal/livelog/hub.go","content":"// Package livelog is an in-memory pub/sub hub that lets agentrun publish\n// fine-grained, not-yet-persisted events (reasoning/content deltas, tool\n// calls) for a running job, and lets the web dashboard subscribe to them\n// live over SSE. It carries only the tail of a run that hasn't made it\n// into SQLite yet — see Hub.Checkpoint.\npackage livelog\n\nimport \"sync\"\n\ntype Type string\n\nconst (\n\tReasoningStart Type = \"reasoning_start\"\n\tReasoningDelta Type = \"reasoning_delta\"\n\tReasoningEnd Type = \"reasoning_end\"\n\tContentStart Type = \"content_start\"\n\tContentDelta Type = \"content_delta\"\n\tContentEnd Type = \"content_end\"\n\tTool Type = \"tool\"\n\tSystem Type = \"system\"\n\tStatus Type = \"status\"\n)\n\ntype Event struct {\n\tType Type `json:\"type\"`\n\tText string `json:\"text,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\tArguments string `json:\"arguments,omitempty\"`\n\tResult string `json:\"result,omitempty\"`\n\tError bool `json:\"error,omitempty\"`\n\tStatus string `json:\"status,omitempty\"`\n}\n\n// subChanBuffer bounds how many events a slow subscriber can lag behind\n// by before new events are dropped for it. Generous for a single-user\n// local dashboard; a dropped event just means a subscriber's browser\n// tab misses a chunk and catches up on the next one, never a hang.\nconst subChanBuffer = 256\n\n// maxBufferedEvents caps the per-job replay buffer as a safety net\n// against unbounded growth if a caller forgets to Checkpoint. Ordinary\n// operation clears the buffer far below this on every persisted block.\nconst maxBufferedEvents = 10000\n\ntype stream struct {\n\tmu sync.Mutex\n\tbuf []Event\n\tsubs map[chan Event]struct{}\n}\n\n// Hub fans out per-job Events to any number of live subscribers.\ntype Hub struct {\n\tmu sync.Mutex\n\tjobs map[string]*stream\n}\n\nfunc NewHub() *Hub {\n\treturn \u0026Hub{jobs: make(map[string]*stream)}\n}\n\nfunc (h *Hub) get(jobID string) *stream {\n\th.mu.Lock()\n\tdefer h.mu.Unlock()\n\n\ts, ok := h.jobs[jobID]\n\tif !ok {\n\t\ts = \u0026stream{subs: make(map[chan Event]struct{})}\n\t\th.jobs[jobID] = s\n\t}\n\n\treturn s\n}\n\n// Publish appends ev to jobID's replay buffer and fans it out to every\n// current subscriber. Never blocks: a subscriber whose channel is full\n// simply misses this event.\nfunc (h *Hub) Publish(jobID string, ev Event) {\n\ts := h.get(jobID)\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.buf = append(s.buf, ev)\n\tif len(s.buf) \u003e maxBufferedEvents {\n\t\ts.buf = s.buf[len(s.buf)-maxBufferedEvents:]\n\t}\n\n\tfor ch := range s.subs {\n\t\tselect {\n\t\tcase ch \u003c- ev:\n\t\tdefault:\n\t\t}\n\t}\n}\n\n// Checkpoint clears jobID's replay buffer. Call this right after a\n// block derived from buffered events has been durably written to\n// storage, so a subscriber that connects afterward — and therefore\n// already sees that block via the persisted history — is never replayed\n// it a second time over the live channel.\nfunc (h *Hub) Checkpoint(jobID string) {\n\ts := h.get(jobID)\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.buf = nil\n}\n\n// Subscribe returns the events currently buffered for jobID (i.e. not\n// yet checkpointed) plus a channel of everything published from this\n// point on. The returned cancel func must be called when the caller is\n// done reading, to unregister and close the channel.\nfunc (h *Hub) Subscribe(jobID string) (buffered []Event, ch \u003c-chan Event, cancel func()) {\n\ts := h.get(jobID)\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tbuffered = append([]Event(nil), s.buf...)\n\n\tc := make(chan Event, subChanBuffer)\n\ts.subs[c] = struct{}{}\n\n\tvar once sync.Once\n\n\tcancelFn := func() {\n\t\tonce.Do(func() {\n\t\t\ts.mu.Lock()\n\t\t\tdefer s.mu.Unlock()\n\n\t\t\tif _, ok := s.subs[c]; ok {\n\t\t\t\tdelete(s.subs, c)\n\t\t\t\tclose(c)\n\t\t\t}\n\t\t})\n\t}\n\n\treturn buffered, c, cancelFn\n}\n\n// Close publishes a terminal Status event, disconnects every current\n// subscriber, and drops jobID's state. Call this exactly once, when the\n// job's run has finished.\nfunc (h *Hub) Close(jobID string) {\n\th.Publish(jobID, Event{Type: Status, Status: \"done\"})\n\n\th.mu.Lock()\n\ts, ok := h.jobs[jobID]\n\tdelete(h.jobs, jobID)\n\th.mu.Unlock()\n\n\tif !ok {\n\t\treturn\n\t}\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tfor ch := range s.subs {\n\t\tclose(ch)\n\t}\n\n\ts.subs = nil\n}","start_line":1,"end_line":165,"total_lines":165,"truncated":false}
Tool
🔧 read_file
{"end_line":79,"path":"internal/llm/streamer.go","start_line":1}
Arguments
{"end_line":79,"path":"internal/llm/streamer.go","start_line":1}
Result
{"path":"internal/llm/streamer.go","content":"package llm\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n)\n\ntype Streamer struct {\n\tcompletion ChatCompletion\n\tresp *http.Response\n\tscanner *bufio.Scanner\n\teof bool\n}\n\nfunc NewStreamer(resp *http.Response) (*Streamer, error) {\n\tif resp.StatusCode != http.StatusOK {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\tresp.Body.Close()\n\t\treturn nil, fmt.Errorf(\"status: %d: %s\", resp.StatusCode, body)\n\t}\n\n\treturn \u0026Streamer{\n\t\tresp: resp,\n\t\tscanner: bufio.NewScanner(resp.Body),\n\t}, nil\n}\n\n// Get returns the accumulated completion after merging in the next SSE\n// chunk. Callers should keep calling Get until it returns io.EOF.\nfunc (s *Streamer) Get() (*ChatCompletion, error) {\n\tif s.eof {\n\t\treturn nil, io.EOF\n\t}\n\n\tprefix := []byte(\"data: \")\n\n\tvar buf []byte\n\n\tfor s.scanner.Scan() {\n\t\tif err := s.scanner.Err(); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tbuf = bytes.TrimSpace(s.scanner.Bytes())\n\n\t\tif len(buf) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tif bytes.HasPrefix(buf, prefix) {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tbuf = bytes.TrimPrefix(buf, prefix)\n\n\tif bytes.Equal(buf, []byte(\"[DONE]\")) {\n\t\ts.resp.Body.Close()\n\t\ts.eof = true\n\t\treturn nil, io.EOF\n\t}\n\n\tvar completion ChatCompletion\n\n\t// Unknown fields are tolerated on purpose: servers (e.g. newer\n\t// llama.cpp builds) may add fields we don't know about yet, and\n\t// failing on them would break compatibility.\n\tif err := json.Unmarshal(buf, \u0026completion); err != nil {\n\t\treturn nil, fmt.Errorf(\"decode completion chunk: %w: %s\", err, buf)\n\t}\n\n\ts.completion.Merge(\u0026completion)\n\n\treturn \u0026s.completion, nil\n}","start_line":1,"end_line":79,"total_lines":79,"truncated":false}
Thinking
Next, let's take a look at how the web dashboard renders blocks, and how agentrun publishes events and persists blocks. I'll check `internal/web/web.go` and `internal/agentrun/loop.go`.
Tool
🔧 read_file
{"end_line":1,"path":"internal/web/web.go","start_line":1}
Arguments
{"end_line":1,"path":"internal/web/web.go","start_line":1}
Result
{"path":"internal/web/web.go","content":"// Package web is zoo's minimal server-rendered dashboard: config","start_line":1,"end_line":1,"total_lines":374,"truncated":true}
Thinking
The read_file tool seems to be ignoring my start_line/end_line parameters and returning only the first line. Weird. Let me try reading the whole file with bash's cat instead.
Tool
🔧 bash
{"command":"cd /project && wc -l internal/web/web.go internal/web/templates.go internal/agentrun/loop.go internal/agentrun/run.go internal/llm/types.go internal/store/store.go"}
Arguments
{"command":"cd /project && wc -l internal/web/web.go internal/web/templates.go internal/agentrun/loop.go internal/agentrun/run.go internal/llm/types.go internal/store/store.go"}
Result
374 internal/web/web.go
1076 internal/web/templates.go
192 internal/agentrun/loop.go
447 internal/agentrun/run.go
276 internal/llm/types.go
360 internal/store/store.go
2725 total
Thinking
The read_file tool seems to have a bug where it only returns the first line. Let me try using bash's cat to read the file instead.
Tool
🔧 bash
{"command":"cd /project && cat internal/web/web.go"}
Arguments
{"command":"cd /project && cat internal/web/web.go"}
Result
// Package web is zoo's minimal server-rendered dashboard: config
// overview, live queue/history, and a per-job log tail.
package web
import (
"encoding/json"
"fmt"
"html/template"
"net/http"
"strings"
"sync"
"time"
"github.com/abrander/zoo/internal/config"
"github.com/abrander/zoo/internal/forgejo"
"github.com/abrander/zoo/internal/livelog"
"github.com/abrander/zoo/internal/store"
)
type Server struct {
cfg *config.Config
store *store.Store
hub *livelog.Hub
fg *forgejo.Client
tmpl *template.Template
avatarMu sync.Mutex
avatarCache map[string]avatarCacheEntry
}
// avatarCacheTTL bounds how long a resolved avatar URL is trusted before
// it's re-fetched from Forgejo. Avatars rarely change, but a user can
// re-upload one (which changes its URL), so the cache expires instead of
// living for the process lifetime.
const avatarCacheTTL = time.Hour
type avatarCacheEntry struct {
url string
fetchedAt time.Time
}
func New(cfg *config.Config, st *store.Store, hub *livelog.Hub, fg *forgejo.Client) *Server {
return &Server{
cfg: cfg,
store: st,
hub: hub,
fg: fg,
tmpl: template.Must(template.New("").Parse(templates)),
avatarCache: map[string]avatarCacheEntry{},
}
}
// Handler returns the dashboard's http.Handler, gated by config.Web's
// bearer token if one is set.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", s.handleIndex)
mux.HandleFunc("GET /jobs", s.handleJobs)
mux.HandleFunc("GET /jobs/{id}", s.handleJobDetail)
mux.HandleFunc("GET /jobs/{id}/events", s.handleJobEvents)
return s.authMiddleware(mux)
}
func (s *Server) authMiddleware(next http.Handler) http.Handler {
if s.cfg.Web == nil || s.cfg.Web.Token == "" {
return next
}
token := s.cfg.Web.Token
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if auth != "Bearer "+token {
w.Header().Set("WWW-Authenticate", `Bearer realm="zoo"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
// Fetch active (pending or running) jobs for the dashboard overview.
// We fetch more than we display so we can filter to just active ones.
allJobs, err := s.store.ListJobs(r.Context(), 200)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Collect unique agent names from active jobs.
var agentNames []string
seenAgents := make(map[string]bool)
var activeJobs []activeJobRow
for _, j := range allJobs {
if j.Status != store.JobPending && j.Status != store.JobRunning {
continue
}
if !seenAgents[j.Agent] {
seenAgents[j.Agent] = true
agentNames = append(agentNames, j.Agent)
}
activeJobs = append(activeJobs, activeJobRow{
Job: j,
AvatarURL: s.avatarFor(j.Agent),
})
}
type indexData struct {
*config.Config
ActiveJobs []activeJobRow
}
s.render(w, "index", indexData{
Config: s.cfg,
ActiveJobs: activeJobs,
})
}
// activeJobRow is a store.Job enriched with the agent's avatar URL.
type activeJobRow struct {
store.Job
AvatarURL string
}
// jobRow is a store.Job plus the agent's Forgejo avatar, resolved for the
// jobs table so it's immediately clear who is running each job.
type jobRow struct {
store.Job
AvatarURL string
}
func (s *Server) handleJobs(w http.ResponseWriter, r *http.Request) {
jobs, err := s.store.ListJobs(r.Context(), 200)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
rows := make([]jobRow, 0, len(jobs))
for _, j := range jobs {
rows = append(rows, jobRow{Job: j, AvatarURL: s.avatarFor(j.Agent)})
}
s.render(w, "jobs", rows)
}
// avatarFor returns the Forgejo avatar URL of the agent named username,
// or "" if it can't be resolved (no Forgejo client configured, unknown
// user, API error). The dashboard must never fail to render because of a
// missing avatar, so every failure mode degrades to no image. Results are
// cached per username for avatarCacheTTL so a page refresh doesn't turn
// into one GetUserInfo call per unique agent.
func (s *Server) avatarFor(username string) string {
if s.fg == nil || username == "" {
return ""
}
s.avatarMu.Lock()
if e, ok := s.avatarCache[username]; ok && time.Since(e.fetchedAt) < avatarCacheTTL {
s.avatarMu.Unlock()
return e.url
}
s.avatarMu.Unlock()
profile, err := s.fg.AgentProfile(username)
if err != nil {
return ""
}
s.avatarMu.Lock()
s.avatarCache[username] = avatarCacheEntry{url: profile.AvatarURL, fetchedAt: time.Now()}
s.avatarMu.Unlock()
return profile.AvatarURL
}
// toolBlock is the parsed form of a stream="tool" store.LogLine, for the
// template to render as a single collapsed detail.
type toolBlock struct {
Name string
Arguments string
Result string
Error bool
}
// block is one self-contained, already-complete unit of job output: a
// finished reasoning or assistant-message block, a finished tool call,
// or a misc system note. Unlike the old flat log view, one store.LogLine
// maps to exactly one block — grouping/streaming happens upstream, when
// agentrun persists the row.
type block struct {
Kind string // "reasoning" | "content" | "tool" | "system"
Text string
Tool *toolBlock
}
func buildBlocks(logs []store.LogLine) []block {
blocks := make([]block, 0, len(logs))
for _, l := range logs {
switch l.Stream {
case "reasoning", "content":
// Model output routinely starts/ends with newlines. The block
// body renders with white-space: pre-wrap, so those would show
// up as visible blank lines inflating the block's height. Trim
// them for display (internal newlines are kept) and drop
// blocks that are nothing but whitespace.
text := strings.TrimSpace(l.Line)
if text == "" {
continue
}
blocks = append(blocks, block{Kind: l.Stream, Text: text})
case "tool":
var entry store.ToolLogEntry
if err := json.Unmarshal([]byte(l.Line), &entry); err != nil {
blocks = append(blocks, block{Kind: "system", Text: l.Line})
continue
}
blocks = append(blocks, block{Kind: "tool", Tool: &toolBlock{
Name: entry.Name,
Arguments: entry.Arguments,
Result: entry.Result,
Error: entry.Error,
}})
default:
blocks = append(blocks, block{Kind: "system", Text: l.Line})
}
}
return blocks
}
func (s *Server) handleJobDetail(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
job, err := s.store.GetJob(r.Context(), id)
if err != nil {
http.Error(w, "job not found", http.StatusNotFound)
return
}
logs, err := s.store.TailLogs(r.Context(), id, -1)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.render(w, "job_detail", struct {
Job store.Job
Blocks []block
Live bool
AvatarURL string
}{job, buildBlocks(logs), job.Status == store.JobPending || job.Status == store.JobRunning, s.avatarFor(job.Agent)})
}
// handleJobEvents streams job jobID's live output as Server-Sent
// Events. It only ever carries events for the current, not-yet-persisted
// block (see livelog.Hub.Checkpoint) — everything already written to
// the store is rendered once, statically, by handleJobDetail. If the
// job isn't live, the response just ends immediately and the client
// falls back to the static page.
func (s *Server) handleJobEvents(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
job, err := s.store.GetJob(r.Context(), id)
if err != nil {
http.Error(w, "job not found", http.StatusNotFound)
return
}
if job.Status != store.JobPending && job.Status != store.JobRunning {
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
buffered, ch, cancel := s.hub.Subscribe(id)
defer cancel()
write := func(ev livelog.Event) bool {
data, err := json.Marshal(ev)
if err != nil {
return true
}
if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
return false
}
flusher.Flush()
return true
}
for _, ev := range buffered {
if !write(ev) {
return
}
}
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
for {
select {
case ev, ok := <-ch:
if !ok {
return
}
if !write(ev) {
return
}
if ev.Type == livelog.Status {
return
}
case <-heartbeat.C:
if _, err := fmt.Fprint(w, ": heartbeat\n\n"); err != nil {
return
}
flusher.Flush()
case <-r.Context().Done():
return
}
}
}
func (s *Server) render(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Every page here reflects live, fast-changing state (job status, log
// blocks). Without this, browsers are free to serve a stale cached
// copy of the job detail page on a same-URL navigation — including
// the auto location.reload() a job's SSE stream triggers when it
// finishes — which looks exactly like blocks that were visible while
// streaming vanishing once the page reloads.
w.Header().Set("Cache-Control", "no-store")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
Thinking
Alright, let's look at the templates and check how the blocks are rendered, particularly the "block-content" class mentioned in the issue.
Tool
🔧 bash
{"command":"cd /project && cat internal/web/templates.go"}
Arguments
{"command":"cd /project && cat internal/web/templates.go"}
Result
package web
const templates = `
{{define "layout_head"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>zoo</title>
<style>
:root {
--bg: #0f0f13;
--bg-surface: #1a1a24;
--bg-surface-hover: #22222e;
--bg-code: #12121a;
--border: #2a2a3a;
--text: #e4e4e8;
--text-muted: #8888a0;
--accent: #7c6aef;
--accent-glow: rgba(124, 106, 239, 0.15);
--radius: 12px;
--radius-sm: 8px;
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
}
/* ── Navigation ─────────────────────────────── */
nav {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2rem;
height: 60px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(12px);
}
nav .brand {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--text);
text-decoration: none;
letter-spacing: -0.02em;
}
nav .brand .logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
background: linear-gradient(135deg, var(--accent), #a78bfa);
color: #fff;
font-size: 1rem;
font-weight: 800;
}
nav .links {
display: flex;
gap: 0.25rem;
}
nav .links a {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
border-radius: var(--radius-sm);
color: var(--text-muted);
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.15s ease;
}
nav .links a:hover {
color: var(--text);
background: var(--bg-surface-hover);
}
nav .links a.active {
color: var(--accent);
background: var(--accent-glow);
}
/* ── Main container ─────────────────────────── */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
/* ── Page header ────────────────────────────── */
.page-header {
margin-bottom: 2rem;
}
h1 {
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.03em;
margin-bottom: 0.25rem;
background: linear-gradient(135deg, var(--text), var(--text-muted));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.page-header p {
color: var(--text-muted);
font-size: 0.95rem;
}
h2 {
font-size: 1.15rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 2rem 0 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
/* ── Cards ──────────────────────────────────── */
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
transition: border-color 0.2s ease;
}
.card:hover {
border-color: #3a3a50;
}
/* ── Job Cards ──────────────────────────────── */
.job-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.job-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.25rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.job-card:hover {
border-color: #3a3a50;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
}
.job-card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.job-card-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
color: var(--text-muted);
text-decoration: none;
font-size: 1.1rem;
font-weight: 600;
transition: all 0.15s ease;
}
.job-card-link:hover {
color: var(--accent);
background: var(--accent-glow);
}
.job-card-body {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.job-card-title {
font-size: 1rem;
font-weight: 600;
color: var(--text);
line-height: 1.4;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.job-card-meta {
font-size: 0.82rem;
color: var(--text-muted);
margin: 0;
}
.job-card-meta code {
background: var(--bg-code);
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-size: 0.8rem;
}
.job-card-agent {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
.job-card-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--bg-code);
flex-shrink: 0;
}
.job-card-agent-name {
font-size: 0.9rem;
font-weight: 500;
color: var(--text);
}
/* ── Tables ─────────────────────────────────── */
.table-wrap {
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--border);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
thead {
background: var(--bg-surface-hover);
}
th {
text-align: left;
padding: 0.75rem 1rem;
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
}
td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
tbody tr:last-child td {
border-bottom: none;
}
tbody tr {
transition: background 0.15s ease;
}
tbody tr:hover {
background: var(--bg-surface-hover);
}
td a {
color: var(--accent);
text-decoration: none;
font-weight: 500;
}
td a:hover {
text-decoration: underline;
}
/* ── Badges ─────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.65rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.badge .dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.badge-pending {
background: rgba(234, 170, 2, 0.12);
color: #eab308;
}
.badge-pending .dot { background: #eab308; }
.badge-running {
background: rgba(124, 106, 239, 0.15);
color: var(--accent);
}
.badge-running .dot {
background: var(--accent);
animation: pulse 1.5s ease-in-out infinite;
}
.badge-succeeded {
background: rgba(34, 197, 94, 0.12);
color: #22c55e;
}
.badge-succeeded .dot { background: #22c55e; }
.badge-failed, .badge-timed_out {
background: rgba(239, 68, 68, 0.12);
color: #ef4444;
}
.badge-failed .dot, .badge-timed_out .dot { background: #ef4444; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* ── Agent avatars ──────────────────────────── */
.agent {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.agent-avatar {
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--bg-code);
flex-shrink: 0;
}
/* ── Info grid ──────────────────────────────── */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.info-item {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.info-item .label {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
font-weight: 600;
}
.info-item .value {
font-size: 0.95rem;
color: var(--text);
word-break: break-all;
}
/* ── Code / Log ─────────────────────────────── */
.log-container {
background: var(--bg-code);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow-y: auto;
max-height: 70vh;
padding: 1rem;
}
/* The log box (not the window) is the scrollable element, so the
jump-to-bottom button floats over it via a positioned wrapper. */
.log-wrap {
position: relative;
}
.log-jump {
position: absolute;
right: 1.5rem;
bottom: 1.5rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.45rem 0.9rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg-surface);
color: var(--text);
font-family: var(--font);
font-size: 0.8rem;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45);
transition: background 0.15s ease, border-color 0.15s ease;
}
.log-jump:hover {
background: var(--bg-surface-hover);
border-color: var(--accent);
}
/* The display rule above would otherwise outrank the UA stylesheet's
[hidden] { display: none }. */
.log-jump[hidden] {
display: none;
}
/* Plain block flow, not flex: a flex column with overflow:hidden
children (.block-tool) gives those children an automatic min-height
of 0 instead of their content height, so once total content
exceeded max-height, flexbox was free to squash them down. */
.log-container .block + .block {
margin-top: 0.6rem;
}
pre {
margin: 0;
padding: 1.25rem;
font-family: var(--mono);
font-size: 0.82rem;
line-height: 1.7;
color: #c4c4d0;
white-space: pre-wrap;
word-break: break-all;
}
/* ── Log blocks ─────────────────────────────── */
.block-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
font-weight: 600;
margin-bottom: 0.35rem;
}
.block-body {
font-family: var(--font);
font-size: 0.9rem;
line-height: 1.6;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
}
.block-reasoning,
.block-content {
padding: 0.75rem 1rem;
border-radius: var(--radius-sm);
}
.block-reasoning {
background: rgba(124, 106, 239, 0.06);
border-left: 3px solid var(--accent);
}
.block-reasoning .block-body {
color: var(--text-muted);
font-style: italic;
}
.block-content {
background: var(--bg-surface);
border: 1px solid var(--border);
}
.block-system {
padding: 0.35rem 0.75rem;
color: var(--text-muted);
font-family: var(--mono);
font-size: 0.8rem;
}
.block-tool {
background: rgba(34, 211, 238, 0.06);
border: 1px solid var(--border);
border-left: 4px solid #22d3ee;
border-radius: var(--radius-sm);
overflow: hidden;
}
.block-tool summary {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
padding: 0.9rem 1.1rem;
min-height: 2.75rem;
color: var(--text);
list-style: none;
}
.block-tool summary::-webkit-details-marker { display: none; }
.block-tool summary::before {
content: "▸";
display: inline-block;
font-size: 1.1rem;
color: var(--text-muted);
transition: transform 0.15s ease;
flex-shrink: 0;
}
.block-tool[open] summary::before { transform: rotate(90deg); }
.tool-badge {
flex-shrink: 0;
padding: 0.25rem 0.6rem;
border-radius: 999px;
background: rgba(34, 211, 238, 0.15);
color: #22d3ee;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.tool-summary-text {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.block-tool .tool-name {
font-size: 1rem;
font-weight: 700;
color: var(--text);
}
.block-tool .tool-args-preview {
color: var(--text-muted);
font-family: var(--mono);
font-size: 0.78rem;
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.block-tool-error { border-left-color: #ef4444; }
.block-tool-error .tool-badge { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
.block-tool .block-body {
padding: 0 1.1rem 1rem;
border-top: 1px solid var(--border);
/* Unlike a reasoning/content block, this wraps element children
(labels + <pre>s), not raw text, so it must not inherit the base
.block-body's white-space: pre-wrap — that would render the
template source's own whitespace between those child tags as
visible blank lines. */
white-space: normal;
}
.block-tool .tool-section-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
font-weight: 600;
margin: 0.6rem 0 0.25rem;
}
.block-tool pre {
margin: 0;
padding: 0;
background: transparent;
font-size: 0.8rem;
color: #c4c4d0;
}
code {
font-family: var(--mono);
background: var(--bg-code);
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-size: 0.85em;
color: #c4b5fd;
}
/* ── Job detail meta ────────────────────────── */
.job-meta {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.job-meta-item {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.job-meta-item .label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
font-weight: 600;
}
.job-meta-item .value {
font-size: 0.95rem;
}
.error-text {
color: #ef4444;
}
/* ── Responsive ─────────────────────────────── */
@media (max-width: 768px) {
nav { padding: 0 1rem; }
.container { padding: 1rem; }
h1 { font-size: 1.5rem; }
th, td { padding: 0.5rem 0.65rem; font-size: 0.82rem; }
.info-grid { grid-template-columns: 1fr; }
.job-meta { gap: 1rem; }
}
/* ── Scrollbar ──────────────────────────────── */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover { background: #3a3a50; }
</style>
</head>
<body>
<nav>
<a href="/" class="brand">
<span class="logo">Z</span>
zoo
</a>
<div class="links">
<a href="/">Dashboard</a>
<a href="/jobs">Jobs</a>
</div>
</nav>
{{end}}
{{define "index"}}
{{template "layout_head" .}}
<div class="container">
<div class="page-header">
<h1>Dashboard</h1>
<p>Overview of your zoo configuration and running agents.</p>
</div>
{{if .ActiveJobs}}
<h2>Running Jobs</h2>
<div class="job-cards">
{{range .ActiveJobs}}
<div class="job-card">
<div class="job-card-header">
<span class="badge badge-{{.Status}}">
<span class="dot"></span>
{{.Status}}
</span>
<a href="/jobs/{{.ID}}" class="job-card-link" title="View job details">→</a>
</div>
<div class="job-card-body">
<h3 class="job-card-title">
{{if .Title}}{{.Title}}{{else}}Issue #{{.IssueIndex}}{{end}}
</h3>
<p class="job-card-meta">
<code>{{.Owner}}/{{.Repo}}#{{.IssueIndex}}</code>
</p>
<div class="job-card-agent">
{{if .AvatarURL}}<img class="job-card-avatar" src="{{.AvatarURL}}" alt="{{.Agent}}" title="{{.Agent}}" loading="lazy">{{end}}
<span class="job-card-agent-name">{{.Agent}}</span>
</div>
</div>
</div>
{{end}}
</div>
{{end}}
<h2>LLMs</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Endpoint</th><th>Model</th></tr></thead>
<tbody>
{{range .LLMs}}
<tr>
<td><strong>{{.Name}}</strong></td>
<td><code>{{.OpenAI}}</code></td>
<td>{{.Model}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<h2>Agents</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>LLM</th></tr></thead>
<tbody>
{{range .Agents}}
<tr>
<td><strong>{{.Name}}</strong></td>
<td>{{.LLM}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<h2>Event Mappings</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Event</th><th>Agent</th><th>Instructions</th></tr></thead>
<tbody>
{{range .Events}}
<tr>
<td><code>{{.Kind}}</code></td>
<td>{{if .Agent}}<strong>{{.Agent}}</strong>{{else}}<em style="color:var(--text-muted)">dynamic (assignee username)</em>{{end}}</td>
<td>{{.Instructions}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<h2>Environment</h2>
<div class="info-grid">
<div class="info-item">
<span class="label">Docker Image</span>
<span class="value"><code>{{.Environment.DockerImage}}</code></span>
</div>
<div class="info-item">
<span class="label">Max Live Agents</span>
<span class="value">{{.Environment.MaxLive}}</span>
</div>
</div>
</div>
</body>
</html>
{{end}}
{{define "jobs"}}
{{template "layout_head" .}}
<div class="container">
<div class="page-header">
<h1>Jobs</h1>
<p>All agent runs and their current status.</p>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Event</th>
<th>Agent</th>
<th>Repository</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{{range .}}
<tr>
<td><a href="/jobs/{{.ID}}">{{.ID}}</a></td>
<td>
<span class="badge badge-{{.Status}}">
<span class="dot"></span>
{{.Status}}
</span>
</td>
<td>{{.EventKind}}</td>
<td>
<span class="agent">
{{if .AvatarURL}}<img class="agent-avatar" src="{{.AvatarURL}}" alt="{{.Agent}}" title="{{.Agent}}" loading="lazy">{{end}}
<strong>{{.Agent}}</strong>
</span>
</td>
<td><code>{{.Owner}}/{{.Repo}}#{{.IssueIndex}}</code></td>
<td>{{.CreatedAt.Format "2006-01-02 15:04:05"}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</body>
</html>
{{end}}
{{define "job_detail"}}
{{template "layout_head" .}}
<div class="container">
<div class="page-header">
<h1>Job {{.Job.ID}}</h1>
<p>Details and log output for this agent run.</p>
</div>
<div class="job-meta">
<div class="job-meta-item">
<span class="label">Status</span>
<span class="value">
<span class="badge badge-{{.Job.Status}}">
<span class="dot"></span>
{{.Job.Status}}
</span>
</span>
</div>
<div class="job-meta-item">
<span class="label">Event</span>
<span class="value">{{.Job.EventKind}} on <code>{{.Job.Owner}}/{{.Job.Repo}}#{{.Job.IssueIndex}}</code></span>
</div>
<div class="job-meta-item">
<span class="label">Agent</span>
<span class="value">
<span class="agent">
{{if .AvatarURL}}<img class="agent-avatar" src="{{.AvatarURL}}" alt="{{.Job.Agent}}" title="{{.Job.Agent}}">{{end}}
<strong>{{.Job.Agent}}</strong>
</span>
</span>
</div>
{{if .Job.Error}}
<div class="job-meta-item">
<span class="label">Error</span>
<span class="value error-text">{{.Job.Error}}</span>
</div>
{{end}}
</div>
<h2>Log</h2>
<div class="log-wrap">
<div class="log-container" id="log">
{{range .Blocks}}
{{if eq .Kind "reasoning"}}
<div class="block block-reasoning">
<div class="block-label">Thinking</div>
<div class="block-body">{{.Text}}</div>
</div>
{{else if eq .Kind "content"}}
<div class="block block-content">
<div class="block-body">{{.Text}}</div>
</div>
{{else if eq .Kind "tool"}}
<details class="block block-tool{{if .Tool.Error}} block-tool-error{{end}}">
<summary>
<span class="tool-badge">Tool</span>
<span class="tool-summary-text">
<span class="tool-name">🔧 {{.Tool.Name}}</span>
<span class="tool-args-preview">{{.Tool.Arguments}}</span>
</span>
</summary>
<div class="block-body">
<div class="tool-section-label">Arguments</div>
<pre>{{.Tool.Arguments}}</pre>
<div class="tool-section-label">Result</div>
<pre>{{.Tool.Result}}</pre>
</div>
</details>
{{else}}
<div class="block block-system">{{.Text}}</div>
{{end}}
{{end}}
</div>
{{if .Live}}
<button type="button" class="log-jump" id="log-jump" hidden>↓ Latest</button>
{{end}}
</div>
{{if .Live}}
<script>
(function() {
var jobID = {{.Job.ID}};
var log = document.getElementById("log");
var jumpBtn = document.getElementById("log-jump");
var reasoningBody = null;
var contentBody = null;
// The log box (not the window) is what scrolls, so all scroll
// math is done against it. While the view is pinned to the
// newest output we keep it there as the stream grows; the moment
// the user scrolls up to read earlier output we stop, and the
// jump button reappears so they can get back to the live tail.
var stick = true;
function atBottom() {
return log.scrollHeight - log.scrollTop - log.clientHeight <= 80;
}
function follow() {
if (stick) log.scrollTop = log.scrollHeight;
}
log.addEventListener("scroll", function() {
stick = atBottom();
jumpBtn.hidden = !stick;
});
jumpBtn.addEventListener("click", function() {
stick = true;
log.scrollTop = log.scrollHeight;
jumpBtn.hidden = true;
});
// Opening a live job means spying on its tail: start at the
// newest output.
follow();
function newBlock(kind, label) {
var div = document.createElement("div");
div.className = "block block-" + kind;
if (label) {
var l = document.createElement("div");
l.className = "block-label";
l.textContent = label;
div.appendChild(l);
}
var body = document.createElement("div");
body.className = "block-body";
div.appendChild(body);
log.appendChild(div);
return body;
}
function newToolBlock(ev) {
var details = document.createElement("details");
details.className = "block block-tool" + (ev.error ? " block-tool-error" : "");
var summary = document.createElement("summary");
var badge = document.createElement("span");
badge.className = "tool-badge";
badge.textContent = "Tool";
var text = document.createElement("span");
text.className = "tool-summary-text";
var name = document.createElement("span");
name.className = "tool-name";
name.textContent = "🔧 " + ev.name;
var preview = document.createElement("span");
preview.className = "tool-args-preview";
preview.textContent = ev.arguments;
text.appendChild(name);
text.appendChild(preview);
summary.appendChild(badge);
summary.appendChild(text);
details.appendChild(summary);
var body = document.createElement("div");
body.className = "block-body";
var argsLabel = document.createElement("div");
argsLabel.className = "tool-section-label";
argsLabel.textContent = "Arguments";
var argsPre = document.createElement("pre");
argsPre.textContent = ev.arguments;
var resultLabel = document.createElement("div");
resultLabel.className = "tool-section-label";
resultLabel.textContent = "Result";
var resultPre = document.createElement("pre");
resultPre.textContent = ev.result;
body.appendChild(argsLabel);
body.appendChild(argsPre);
body.appendChild(resultLabel);
body.appendChild(resultPre);
details.appendChild(body);
log.appendChild(details);
}
var es = new EventSource("/jobs/" + jobID + "/events");
es.onmessage = function(e) {
var ev = JSON.parse(e.data);
switch (ev.type) {
case "reasoning_start":
reasoningBody = newBlock("reasoning", "Thinking");
break;
case "reasoning_delta":
if (reasoningBody) reasoningBody.textContent += ev.text;
break;
case "reasoning_end":
// Model output routinely starts/ends with newlines; drop them
// so the finished block matches the static (trimmed) view.
if (reasoningBody) reasoningBody.textContent = reasoningBody.textContent.trim();
reasoningBody = null;
break;
case "content_start":
contentBody = newBlock("content", null);
break;
case "content_delta":
if (contentBody) contentBody.textContent += ev.text;
break;
case "content_end":
if (contentBody) contentBody.textContent = contentBody.textContent.trim();
contentBody = null;
break;
case "tool":
newToolBlock(ev);
break;
case "system":
newBlock("system", null).textContent = ev.text;
break;
case "status":
es.close();
location.reload();
return;
}
follow();
};
})();
</script>
{{end}}
</div>
</body>
</html>
{{end}}
`
Thinking
Now I understand the structure. Let's look at how agentrun emits events and persists blocks. The key issue is as follows:
- During streaming: `thinking, empty, toolcall, thinking, empty, toolcall, thinking, response`
- After refresh: `thinking, toolcall, thinking, toolcall, thinking, response`
So, during streaming, an empty `content` block appears after the thinking block. The empty block is `<div class="block block-content"><div class="block-body">\n\n</div></div>` — a content block with empty body.
Looking at the JS:
- `content_start` → `contentBody = newBlock("content", null)` — creates a block immediately.
- `content_delta` → appends text.
- `content_end` → trims text, sets contentBody = null.
So, if a `content_start` event is emitted but no deltas follow (or the content is only whitespace), the block is created and remains empty. After refresh, `buildBlocks` skips whitespace-only content blocks, so the empty block disappears.
Therefore, the fix is: in the JS, don't create the block until the first non-empty delta arrives (lazy creation), or at `content_end`, if the trimmed text is empty, remove the block from the DOM.
But wait — where does the `content_start` event come from? Let's look at agentrun's loop to see when ContentStart is emitted. The LLM might emit a `content_start` for an empty content block (e.g., when the model outputs only reasoning + tool calls, the stream might still contain a content chunk with empty content). Let's check.
Now let me look at the agentrun loop to see how events are published and blocks persisted:
Tool
🔧 bash
{"command":"cd /project && cat internal/agentrun/loop.go"}
Arguments
{"command":"cd /project && cat internal/agentrun/loop.go"}
Result
package agentrun
import (
"context"
"fmt"
"io"
"strings"
"github.com/abrander/zoo/internal/llm"
"github.com/abrander/zoo/internal/tools"
)
// Hooks lets a caller observe a runLoop invocation live, as the model
// streams and tool calls execute, in addition to the final []llm.Message
// it returns. Any of these may be nil.
type Hooks struct {
// OnReasoningDelta and OnContentDelta fire with just the newly
// streamed text for the current turn, not the accumulated total.
OnReasoningDelta func(delta string)
OnContentDelta func(delta string)
// OnTurnEnd fires once per completed streamer round-trip, after the
// model's message for that turn is fully received and before any of
// its tool calls run.
OnTurnEnd func()
// OnTool fires once per tool call, after it has run.
OnTool func(name, arguments, result string, toolErr bool)
// OnReroll fires when a turn is thrown away because a tool call's
// arguments couldn't be parsed, just before the model is asked
// again. attempt counts from 1 and resets on every usable turn.
OnReroll func(name, arguments string, attempt int)
}
// maxRerolls bounds how many times in a row a turn may be discarded for
// unparsable tool-call arguments before the run gives up. A model that
// keeps mangling the same call would otherwise resample until the run
// deadline, spending the whole timeout on nothing.
const maxRerolls = 3
// runLoop is a headless port of ../a's App.generate(): send messages +
// tool defs, get a completion, run any tool_calls and append their
// results, repeat until a plain finish or ctx is done.
func runLoop(ctx context.Context, client *llm.Client, toolsCtx tools.Context, messages []llm.Message, hooks Hooks) ([]llm.Message, error) {
rerolls := 0
for {
if err := ctx.Err(); err != nil {
return messages, err
}
streamer, err := client.StreamChatCompletion(ctx, &llm.ChatCompletionRequest{
Messages: messages,
Stream: true,
Tools: tools.All(),
})
if err != nil {
return messages, fmt.Errorf("chat completion: %w", err)
}
var completion *llm.ChatCompletion
var prevContent, prevReasoning string
for {
c, err := streamer.Get()
if err == io.EOF {
break
}
if err != nil {
return messages, fmt.Errorf("stream completion: %w", err)
}
completion = c
if len(c.Choices) > 0 {
msg := c.Choices[0].Message
if hooks.OnReasoningDelta != nil && len(msg.ReasoningContent) > len(prevReasoning) {
hooks.OnReasoningDelta(msg.ReasoningContent[len(prevReasoning):])
}
prevReasoning = msg.ReasoningContent
if hooks.OnContentDelta != nil && len(msg.Content) > len(prevContent) {
hooks.OnContentDelta(msg.Content[len(prevContent):])
}
prevContent = msg.Content
}
}
if hooks.OnTurnEnd != nil {
hooks.OnTurnEnd()
}
if completion == nil || len(completion.Choices) == 0 {
return messages, fmt.Errorf("model returned an empty completion")
}
choice := completion.Choices[0]
// Tool-call arguments have to be valid JSON before the turn can
// enter the history: servers validate every assistant tool call we
// send back, and reject the whole conversation with a 400 once one
// of them doesn't parse, so a single bad call would fail every
// following request and kill the run. Trailing junk after the
// arguments object is dropped losslessly; anything we can't make
// sense of makes the whole turn unusable.
broken := brokenToolCall(choice.Message.ToolCalls)
if broken != nil {
// Throw the turn away wholesale, before running any of its
// tool calls — a discarded turn must leave no side effects
// behind, since no trace of it remains in the history. The
// next request is then identical to this one, and (with no
// seed pinned) samples afresh.
rerolls++
if rerolls > maxRerolls {
return messages, fmt.Errorf("model called %s with arguments that are not valid JSON %d times in a row: %s",
broken.Name, rerolls, truncate(broken.Arguments, 200))
}
if hooks.OnReroll != nil {
hooks.OnReroll(broken.Name, truncate(broken.Arguments, 200), rerolls)
}
continue
}
rerolls = 0
messages = append(messages, choice.Message)
if choice.FinishReason != "tool_calls" || len(choice.Message.ToolCalls) == 0 {
return messages, nil
}
for _, tc := range choice.Message.ToolCalls {
if err := ctx.Err(); err != nil {
return messages, err
}
result, err := tools.Call(toolsCtx, tc.FunctionCall)
toolErr := err != nil
if toolErr {
result = fmt.Sprintf("ERROR CALLING TOOL: %s", err.Error())
}
if hooks.OnTool != nil {
hooks.OnTool(tc.FunctionCall.Name, tc.FunctionCall.Arguments, result, toolErr)
}
messages = append(messages, llm.Message{
Role: "tool",
Content: result,
ToolCallID: tc.Id,
Name: tc.FunctionCall.Name,
})
}
}
}
// brokenToolCall repairs the arguments of every call in place and
// returns the first one that couldn't be repaired, or nil if the whole
// turn is usable. The returned call carries the model's original,
// unrepaired arguments.
func brokenToolCall(calls []llm.ToolCall) *llm.FunctionCall {
for i := range calls {
fcall := &calls[i].FunctionCall
clean, ok := llm.SanitizeArguments(fcall.Arguments)
if !ok {
return &llm.FunctionCall{Name: fcall.Name, Arguments: fcall.Arguments}
}
fcall.Arguments = clean
}
return nil
}
// truncate shortens s to at most limit bytes, so a mangled tool call
// echoed back to the model can't flood the context.
func truncate(s string, limit int) string {
if len(s) <= limit {
return s
}
return strings.ToValidUTF8(s[:limit], "") + "…"
}
Thinking
Next, let's look at run.go and check how the hooks are connected to livelog's hub and the store.
Tool
🔧 bash
{"command":"cd /project && cat internal/agentrun/run.go"}
Arguments
{"command":"cd /project && cat internal/agentrun/run.go"}
Result
// Package agentrun creates the throwaway per-job environment an agent
// runs in: a Docker container with the agent's git working tree cloned
// inside it (authenticated with the agent's own Forgejo token), the
// triggering event written to /event, and the tool-calling loop
// (internal/llm + internal/tools) driven against it.
package agentrun
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/abrander/zoo/internal/config"
"github.com/abrander/zoo/internal/forgejo"
"github.com/abrander/zoo/internal/livelog"
"github.com/abrander/zoo/internal/llm"
"github.com/abrander/zoo/internal/store"
)
//go:embed system.md
var defaultSystemPrompt string
// DefaultTimeout bounds a single agent run's wall-clock time if the
// caller doesn't override it.
const DefaultTimeout = 120 * time.Minute
type Runner struct {
docker *dockerRuntime
forgejo *forgejo.Client
store *store.Store
hub *livelog.Hub
cfg *config.Config
logger *slog.Logger
timeout time.Duration
keepOnFailure bool
agentClientsMu sync.Mutex
agentClients map[string]*forgejo.Client
}
func NewRunner(cfg *config.Config, fg *forgejo.Client, st *store.Store, hub *livelog.Hub, logger *slog.Logger, timeout time.Duration, keepOnFailure bool) (*Runner, error) {
docker, err := newDockerRuntime()
if err != nil {
return nil, err
}
if timeout <= 0 {
timeout = DefaultTimeout
}
return &Runner{
docker: docker,
forgejo: fg,
store: st,
hub: hub,
cfg: cfg,
logger: logger,
timeout: timeout,
keepOnFailure: keepOnFailure,
agentClients: make(map[string]*forgejo.Client),
}, nil
}
// forgejoAs returns a Forgejo client that authenticates as the given
// agent (using the agent's own token from config). This lets each agent
// act as themselves on Forgejo without needing a global token with sudo
// privileges. Clients are built once per agent and cached, since
// constructing one costs an extra API round trip.
//
// If the agent has no token configured, falls back to the shared zoo
// identity so existing deployments without per-agent tokens still work.
func (r *Runner) forgejoAs(agentName, token string) *forgejo.Client {
r.agentClientsMu.Lock()
defer r.agentClientsMu.Unlock()
if c, ok := r.agentClients[agentName]; ok {
return c
}
var c *forgejo.Client
if token != "" {
c = r.forgejo.As(token)
} else {
// Fallback: use shared identity. Optionally log a warning
// if we ever want to enforce per-agent tokens.
c = r.forgejo
}
r.agentClients[agentName] = c
return c
}
// Run implements scheduler.Runner.
func (r *Runner) Run(ctx context.Context, jobID string, agent config.AgentConfig, llmCfg config.LLM, dockerImage string, ev forgejo.Event) error {
ctx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
logger := r.logger.With("job", jobID, "agent", agent.Name)
repoInfo, err := r.forgejo.RepositoryInfo(ev.Owner, ev.Repo)
if err != nil {
return fmt.Errorf("look up repository: %w", err)
}
workDir, err := os.MkdirTemp("", "zoo-run-*")
if err != nil {
return fmt.Errorf("create work dir: %w", err)
}
succeeded := false
defer func() {
if succeeded || !r.keepOnFailure {
os.RemoveAll(workDir)
} else {
logger.Warn("keeping work dir after failure", "dir", workDir)
}
}()
// The container bind-mounts projectDir as /project and does the
// initial clone into it, so the (empty) directory must exist on the
// host before the container is created — otherwise Docker would
// create it itself, root-owned.
projectDir := filepath.Join(workDir, "project")
if err := os.MkdirAll(projectDir, 0o755); err != nil {
return fmt.Errorf("create project dir: %w", err)
}
// A pr:review run works on the PR's own head branch, so the agent's
// commits push straight to the PR. Every other event kind branches
// off the default branch as usual.
var review *forgejo.ReviewDetail
branch := fmt.Sprintf("issue-%d-%s", ev.Index, agent.Name)
if ev.Kind == forgejo.EventPRReview {
// Always fetch the current head ref, not just when the event
// lacks one (the polling path doesn't carry it): the webhook's
// copy could be stale if the PR's head branch was renamed since
// the review, and the push target depends on it.
headRef := ev.HeadRef
if prInfo, err := r.forgejo.PullRequestInfo(ev.Owner, ev.Repo, ev.Index); err != nil {
logger.Warn("fetch pull request head failed; falling back to the event's head ref", "error", err)
} else if prInfo.HeadRef != "" {
headRef = prInfo.HeadRef
}
if headRef == "" {
return fmt.Errorf("pr:review event has no pull request head branch to check out")
}
branch = headRef
// Fetch the full review (verdict, body, inline comments) so the
// agent sees all the feedback, not just the triggering event. A
// failure degrades to no review detail rather than failing the
// run: the agent can still do its job, just without the inline
// comments.
review, err = r.forgejo.ReviewDetail(ev.Owner, ev.Repo, ev.Index, ev.ReviewID)
if err != nil {
logger.Warn("fetch review detail failed; agent will not see inline review comments", "error", err)
review = nil
}
}
roster := buildRoster(r.forgejo, r.cfg.Agents, logger)
gitName, gitEmail := gitIdentity(agent.Name, roster)
// The credential the sandbox's git uses for remote operations: the
// agent's own Forgejo token when configured, so its git activity is
// attributed to its own account, falling back to the shared zoo
// identity for deployments without per-agent tokens (mirroring
// forgejoAs).
gitUser, gitToken := "zoo", r.forgejo.Token()
if agent.Token != "" {
gitUser, gitToken = agent.Name, agent.Token
}
eventPath := filepath.Join(workDir, "event.json")
if err := os.WriteFile(eventPath, ev.Raw, 0o644); err != nil {
return fmt.Errorf("write event file: %w", err)
}
containerID, err := r.docker.createContainer(ctx, dockerImage, []string{
projectDir + ":/project",
eventPath + ":/event:ro",
}, fmt.Sprintf("zoo-issue-%d-%s", ev.Index, agent.Name))
if err != nil {
return fmt.Errorf("start container: %w", err)
}
defer func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cleanupCancel()
if err := r.docker.remove(cleanupCtx, containerID); err != nil {
logger.Warn("failed to remove container", "container", containerID, "error", err)
}
}()
// Git must simply work inside the sandbox: safe.directory, commit
// identity, and the remote credential all go into the container's
// system gitconfig (see configureSandboxGit).
if err := configureSandboxGit(ctx, r.docker, containerID, repoInfo.CloneURL, gitUser, gitToken, gitName, gitEmail); err != nil {
return fmt.Errorf("configure git in container: %w", err)
}
// The initial clone happens inside the sandbox, so the working tree
// is owned by the container's user and git never runs on the host.
if ev.Kind == forgejo.EventPRReview {
if err := clonePRHead(ctx, r.docker, containerID, repoInfo.CloneURL, repoInfo.DefaultBranch, branch, ev.Index); err != nil {
return fmt.Errorf("prepare git working tree: %w", err)
}
} else {
if err := cloneAndBranch(ctx, r.docker, containerID, repoInfo.CloneURL, repoInfo.DefaultBranch, branch); err != nil {
return fmt.Errorf("prepare git working tree: %w", err)
}
}
logAppend := func(stream, line string) {
if err := r.store.AppendLog(context.Background(), jobID, stream, line); err != nil {
logger.Warn("failed to append log", "error", err)
}
}
runCtx := &runContext{
docker: r.docker,
containerID: containerID,
forgejo: &runForgejoActions{
client: r.forgejoAs(agent.Name, agent.Token),
owner: ev.Owner,
repo: ev.Repo,
index: ev.Index,
logger: logger,
},
}
llmClient := llm.NewClient(llmCfg)
systemPrompt := defaultSystemPrompt + identitySection(agent.Name, roster)
instructions := r.cfg.EventInstructions(ev.Kind)
// Fetch the full comment thread so the agent sees everything that's
// been said on the issue/PR, not just the triggering event (which
// only carries the latest comment, if any). A failure degrades to
// no comments rather than failing the run: the agent can still do
// its job, just without prior context.
comments, err := r.forgejo.ListIssueComments(ev.Owner, ev.Repo, ev.Index)
if err != nil {
logger.Warn("fetch issue comments failed; agent will not see prior comments", "error", err)
comments = nil
}
messages := []llm.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions, comments, review)},
}
hooks := r.streamHooks(jobID, logAppend)
_, err = runLoop(ctx, llmClient, runCtx, messages, hooks)
if err != nil {
return fmt.Errorf("agent loop: %w", err)
}
succeeded = true
return nil
}
// streamHooks builds the Hooks a single Run passes to runLoop: every
// delta is published live to the hub for connected dashboard viewers,
// and once a reasoning/content block or tool call is complete, it's
// persisted to the store as one row and the hub's replay buffer for
// jobID is checkpointed — so a viewer connecting from this point on
// sees it via the persisted history instead of a live replay, and is
// never shown it twice.
func (r *Runner) streamHooks(jobID string, logAppend func(stream, line string)) Hooks {
var reasoningBuf, contentBuf strings.Builder
reasoningOpen, contentOpen := false, false
return Hooks{
OnReasoningDelta: func(delta string) {
if !reasoningOpen {
reasoningOpen = true
reasoningBuf.Reset()
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})
}
reasoningBuf.WriteString(delta)
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})
},
OnContentDelta: func(delta string) {
if !contentOpen {
contentOpen = true
contentBuf.Reset()
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})
}
contentBuf.WriteString(delta)
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})
},
OnTurnEnd: func() {
if reasoningOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})
logAppend("reasoning", reasoningBuf.String())
r.hub.Checkpoint(jobID)
reasoningOpen = false
}
if contentOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})
logAppend("content", contentBuf.String())
r.hub.Checkpoint(jobID)
contentOpen = false
}
},
OnReroll: func(name, arguments string, attempt int) {
// A discarded turn leaves its reasoning/content already in
// the log, so say why the next one repeats it.
note := fmt.Sprintf("discarded turn %d/%d: %s was called with arguments that are not valid JSON, asking again: %s",
attempt, maxRerolls, name, arguments)
r.hub.Publish(jobID, livelog.Event{Type: livelog.System, Text: note})
logAppend("system", note)
r.hub.Checkpoint(jobID)
},
OnTool: func(name, arguments, result string, toolErr bool) {
r.hub.Publish(jobID, livelog.Event{
Type: livelog.Tool,
Name: name,
Arguments: arguments,
Result: result,
Error: toolErr,
})
line, err := json.Marshal(store.ToolLogEntry{Name: name, Arguments: arguments, Result: result, Error: toolErr})
if err != nil {
r.logger.Warn("failed to marshal tool log entry", "job", jobID, "error", err)
} else {
logAppend("tool", string(line))
}
r.hub.Checkpoint(jobID)
},
}
}
func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string, comments []forgejo.IssueComment, review *forgejo.ReviewDetail) string {
raw, _ := json.MarshalIndent(json.RawMessage(ev.Raw), "", " ")
var instructionsSection string
if instructions != "" {
instructionsSection = fmt.Sprintf("Instructions for this event, from zoo.hcl:\n%s\n\n", instructions)
}
// A pr:review run works on the PR's own head branch, not a fresh
// branch off the default branch.
branchLine := fmt.Sprintf("Your working branch is %q, checked out from the default branch %q.\n\n", branch, defaultBranch)
if ev.Kind == forgejo.EventPRReview {
branchLine = fmt.Sprintf("Your working branch is %q, the pull request's head branch — commits you push here update the pull request directly.\n\n", branch)
}
var reviewSection string
if review != nil {
reviewSection = renderReviewSection(review)
}
var commentsSection string
if len(comments) > 0 {
var b strings.Builder
fmt.Fprintf(&b, "Comments (%d):\n\n", len(comments))
for i, c := range comments {
fmt.Fprintf(&b, "%d. %s (%s):\n%s\n\n", i+1, c.Author, c.Created.Format(time.RFC3339), c.Body)
}
commentsSection = b.String()
}
return fmt.Sprintf(
"You were triggered by a %q event on %s/%s.\n\n"+
"%s%s"+
"%sTitle: %s\n\nBody:\n%s\n\n%sFull event payload:\n```json\n%s\n```",
ev.Kind, ev.Owner, ev.Repo, instructionsSection, branchLine, reviewSection, ev.Title, ev.Body, commentsSection, raw)
}
// renderReviewSection renders the submitted review as a briefing
// section: the verdict, the review body, and each inline comment with
// its location and id (the id lets the agent refer to a specific
// comment in its reply).
func renderReviewSection(review *forgejo.ReviewDetail) string {
var b strings.Builder
fmt.Fprintf(&b, "Review submitted by %s:\n", review.Reviewer)
fmt.Fprintf(&b, " Verdict: %s\n", humanReviewState(review.State))
if review.Body != "" {
b.WriteString(" Review body:\n")
for _, line := range strings.Split(review.Body, "\n") {
fmt.Fprintf(&b, " %s\n", line)
}
}
if len(review.Comments) > 0 {
fmt.Fprintf(&b, " Inline comments (%d):\n", len(review.Comments))
for i, c := range review.Comments {
where := c.Path
if c.Line > 0 {
where = fmt.Sprintf("%s:%d", c.Path, c.Line)
}
fmt.Fprintf(&b, " %d. [%s] %s (comment id %d):\n", i+1, where, c.Author, c.ID)
for _, line := range strings.Split(c.Body, "\n") {
fmt.Fprintf(&b, " %s\n", line)
}
}
}
return b.String() + "\n"
}
// humanReviewState maps a Forgejo review state to a readable verdict.
func humanReviewState(state string) string {
switch state {
case "APPROVED":
return "approved"
case "REQUEST_CHANGES":
return "changes requested"
case "COMMENT":
return "comment"
default:
return state
}
}
Thinking
Now I understand the whole picture. Let me trace the bug:
1. `OnReasoningDelta` fires → publishes `ReasoningStart` + `ReasoningDelta`.
2. `OnContentDelta` fires → publishes `ContentStart` + `ContentDelta`.
3. `OnTurnEnd` fires → publishes `ReasoningEnd`, persists the reasoning line, checkpoints. Then if `contentOpen`, publishes `ContentEnd`, persists the content line, and checkpoints.
Now, the problem: when does `OnContentDelta` fire with content that's empty or whitespace-only?
Looking at `runLoop`:
```go
if hooks.OnContentDelta != nil && len(msg.Content) > len(prevContent) {
hooks.OnContentDelta(msg.Content[len(prevContent):])
}
prevContent = msg.Content
```
So `OnContentDelta` fires every time `msg.Content` grows. If the model streams reasoning + tool calls, some servers (llama.cpp) may emit a `content` field that's an empty string `""` in the first chunk — wait, `len("") > len("")` is false, so no delta. But some servers might emit content like `" "` (a space) or `"\n"` as a placeholder for content chunks. For example, llama.cpp with `stream_reasoning` might emit `content: ""` in some chunks and `content: " "` in others.
Actually, let me think about what happens with llama.cpp: when the model produces reasoning + tool calls, the content field in the stream is usually empty. But some servers emit a single space or newline as content. In that case:
- `OnContentDelta(" ")` → `ContentStart` published, `contentBuf` = " ".
- `OnTurnEnd` → `ContentEnd` published, `logAppend("content", " ")` — persists a whitespace-only content line, checkpoints.
Then in the live view, the JS creates a content block on `content_start`, appends " " on `content_delta`, and trims to "" on `content_end`. The block remains in the DOM with an empty body — that's the empty block!
In the static view (after refresh), `buildBlocks` skips whitespace-only content lines:
```go
text := strings.TrimSpace(l.Line)
if text == "" {
continue
}
```
So the empty block disappears after refresh. That matches the issue exactly: "empty blocks often appear after thinking blocks" — because the model's turn is reasoning → (empty content) → tool call.
Wait, but there's a subtlety: the empty content block appears after the thinking block and before the tool call. In the live view:
- thinking block (created on reasoning_start, filled with deltas, trimmed on reasoning_end)
- empty content block (created on content_start, filled with " " delta, trimmed to "" on content_end)
- tool block (created on tool event)
Yes! That matches: thinking, empty, toolcall.
So the root cause is: the model (via the OpenAI-compatible server) emits a whitespace-only content chunk on turns that are reasoning + tool calls. The live view renders that as a visible empty block, whereas the static view filters it out.
There are two aspects to the fix:
1. **Server-side (root cause)**: don't persist whitespace-only content (and reasoning) lines in the first place. That way the empty block won't exist in the DB at all. But note: the static view already filters those out. Persisting whitespace-only lines is harmless but wasteful, and it's the source of the mismatch.
2. **Client-side (visible bug)**: the JS should not render empty blocks. Options:
a. Lazily create the block: on `content_start`, don't create the DOM node yet. Create it on the first `content_delta` (and only if the delta has non-whitespace content… hmm, but the delta might be " " followed by actual text later).
b. On `content_end`, if the trimmed text is empty, remove the block from the DOM.
Option (b) is simple and robust: on `content_end` (and `reasoning_end`), if the trimmed text is empty, remove the block element from the DOM. This exactly matches the static view's behavior (which skips whitespace-only blocks).
But wait — there's a subtle point with lazy creation (option a): if the first delta is " " (whitespace) and subsequent deltas have actual text, we'd still want to create the block. If we only create on the first delta with non-whitespace content, we'd drop the leading whitespace, but that's fine since we trim anyway. But if the content is only whitespace, the block is never created. That's also fine.
Actually, the cleanest approach that mirrors the server's static rendering: on end, if trimmed text is empty, remove the block. Let me think about which is more elegant.
Option (b) — remove on end if empty:
- `content_start`: create block (as now).
- `content_delta`: append.
- `content_end`: trim; if empty, `contentBody.parentElement.remove()` (remove the whole block div); otherwise set the trimmed text.
This is minimal and exactly matches the static behavior. The empty block is briefly visible during streaming (from start to end) — but that's inherent to streaming: we don't know until the end that the block will be empty. Actually, in the case of a whitespace-only content chunk, the block is created on `content_start` and removed on `content_end`. The turn end comes right after the stream finishes, so the empty block is visible only for a moment (between the last delta and the end event). Hmm, but the issue says "empty blocks often appear after thinking blocks" — in the streaming view, the block stays empty and visible. When does the user see it? The empty block persists in the DOM after `content_end` (currently the JS just trims and sets textContent = "", leaving an empty div). So the empty block remains visible throughout the rest of the run — that's the bug.
With option (b), the empty block is removed at `content_end`, so it's only visible for a moment (between the last delta and the end event, which are published back-to-back from the same goroutine).
Option (a) — lazy creation:
- `content_start`: set `contentBody = null` (or a "pending" state), don't create the DOM node.
- `content_delta`: if `contentBody` is null, create the block (only if the delta contains non-whitespace? Or always?). If we always create on the first delta, a whitespace-only content still creates an empty block that stays. So we'd need to create only on the first non-whitespace delta. But then leading whitespace before the first non-whitespace delta is dropped — which is fine since we trim at the end anyway. But internal whitespace is preserved.
Hmm, but there's a subtlety: what if the content is " hello" (leading spaces)? First delta " " → skip creation. Second delta "hello" → create block with text "hello". Trimmed result is "hello". Static view: TrimSpace(" hello") = "hello". Match.
What if the content is "hello " (trailing spaces)? First delta "hello" → create with "hello". Second delta " " → append. End: trim → "hello". Match.
What if the content is " " (whitespace only)? No block created. Static view: skipped. Match.
Both options work. Option (b) is simpler and has less state to manage. But option (a) avoids the flicker of an empty block appearing.
Actually, let me reconsider. The most elegant fix addresses both:
1. **Server-side**: in `streamHooks`, don't persist whitespace-only reasoning/content blocks (and don't publish start/end events either?). Hmm, but if we don't publish `ContentStart`/`ContentEnd` for whitespace-only content, the client won't create the block in the first place. That's the cleanest: the server knows the full block text at `OnTurnEnd`, so it can decide whether the block is worth persisting/streaming.
But wait — there's a problem: `ContentStart` is published at the first delta, before we know the final content. We can't retroactively un-publish `ContentStart` to live subscribers. So if the server suppresses the end event, the client's `contentBody` stays non-null… actually, if we suppress both start and end, the client never sees a content block. But we can't suppress start at the time it's published, because we don't know yet whether the content will be whitespace-only.
Hmm, so the options are:
**Option A: Client-side removal on end.**
- JS: on `reasoning_end`/`content_end`, if the trimmed text is empty, remove the block from the DOM.
- This is a one-line-ish fix in the template JS. It exactly matches the static view.
- The empty block flickers briefly during the stream (from start to end) but is removed at the end.
**Option B: Server-side suppression of whitespace-only blocks.**
- In `OnTurnEnd`, if the buffer is whitespace-only, don't persist. But the start event has already been published. So the client would have created the block. We'd need to publish an end event anyway (to close the block on the client), and the client would still render it empty. So Option B alone doesn't fix the visible bug; it only prevents the whitespace line from being persisted (which is already filtered in the static view).
**Option C: Client-side lazy creation.**
- JS: don't create the block on `content_start`; create it on the first delta with non-whitespace content. On `content_end`, if no block was created, do nothing. If a block was created, trim.
- This avoids the flicker entirely and matches the static view.
I think the most elegant and robust fix is a combination:
- **Client-side (Option C)**: lazy block creation, so whitespace-only blocks never appear in the DOM. This directly fixes the visible bug.
- **Server-side (Option B)**: don't persist whitespace-only content/reasoning lines, so the DB doesn't accumulate junk lines (and the static view's filter becomes a belt-and-suspenders safety net).
Wait, but let me reconsider whether we should suppress server-side. The static view already filters whitespace-only lines. So persisting them is harmless for the static view. But it's cleaner not to persist junk. However, changing server-side persistence behavior is a bigger change. Let me focus on the minimal fix that addresses the reported bug.
Actually, let me reconsider the root cause more carefully. Is the content really whitespace-only? Let me re-read the issue:
"empty blocks often appear after thinking blocks"
Example:
```html
<div class="block block-content"><div class="block-body">
</div></div>
```
So the block-body contains a newline (or is empty). This is a content block (block-content class). So yes, it's a whitespace-only content block.
Now, why does the model emit whitespace-only content? Let me think about llama.cpp. When the model produces reasoning + tool calls, the server may still emit a `content` field. Actually, in many OpenAI-compatible servers, when there's no content, `content` is `""` (empty string), not whitespace. So `len("") > len("")` is false, and no delta fires. So where does the whitespace come from?
Hmm, let me reconsider. Maybe the server emits `content: " "` (a single space) as a placeholder. Or maybe the reasoning and content are interleaved in a way that produces a whitespace delta.
Actually, wait. Let me reconsider. Let me look at how `Merge` works and how `msg.Content` accumulates. Let me look at types.go.
Actually, the key insight: the bug is real and the fix is clear regardless of the exact whitespace source. The live view renders whitespace-only blocks as visible empty blocks, whereas the static view filters them out. The fix is to make the live view match the static view: don't render whitespace-only blocks.
Let me look at types.go to understand Merge, and also check the tests to understand the expected behavior.
Let me also reconsider: is it possible that the empty block is a reasoning block rather than a content block? The example HTML shows `block-content`, so it's a content block. And the issue says "empty blocks often appear after thinking blocks" — thinking = reasoning. So the pattern is reasoning → empty content → tool call. This strongly suggests that on reasoning+toolcall turns, the server emits a whitespace-only content chunk.
Let me verify by looking at the LLM types and how content is handled.
Let me read types.go.
Let me look at the LLM types and the streamer tests to understand how content/reasoning accumulate:
Tool
🔧 bash
{"command":"cd /project && cat internal/llm/types.go"}
Arguments
{"command":"cd /project && cat internal/llm/types.go"}
Result
// Package llm is an OpenAI-compatible chat completions client with
// streaming and tool-call support, ported from ../a's llamacpp package.
package llm
import "fmt"
type Message struct {
Content string `json:"content"`
Role string `json:"role"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Name string `json:"name,omitempty"`
}
func (m *Message) String() string {
str := fmt.Sprintf("Role: %s, Content: %s ReasoningContent: %s", m.Role, m.Content, m.ReasoningContent)
for _, call := range m.ToolCalls {
str += fmt.Sprintf("\n ToolCall: %s", call.String())
}
return str
}
// Merge folds a streamed delta into the accumulated message.
func (m *Message) Merge(next *Message) {
m.Content += next.Content
if next.Role != "" {
m.Role = next.Role
}
m.ReasoningContent += next.ReasoningContent
minLength := 0
for _, choice := range next.ToolCalls {
if choice.Index+1 > minLength {
minLength = choice.Index + 1
}
}
if len(m.ToolCalls) < minLength {
newToolCalls := make([]ToolCall, minLength)
copy(newToolCalls, m.ToolCalls)
m.ToolCalls = newToolCalls
}
for _, nextChoice := range next.ToolCalls {
m.ToolCalls[nextChoice.Index].Merge(&nextChoice)
}
}
type FunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type ToolCall struct {
Type string `json:"type"`
Id string `json:"id"`
Index int `json:"index"`
Name string `json:"name"`
FunctionCall FunctionCall `json:"function"`
}
func (t *ToolCall) String() string {
return fmt.Sprintf("Type: %s, Id: %s, Index: %d, Name: %s, FunctionCall: {Name: %s, Arguments: %s}",
t.Type, t.Id, t.Index, t.Name, t.FunctionCall.Name, t.FunctionCall.Arguments)
}
func (t *ToolCall) Merge(next *ToolCall) {
if next.Type != "" {
t.Type = next.Type
}
if next.Id != "" {
t.Id = next.Id
}
if next.Name != "" {
t.Name = next.Name
}
t.Index = next.Index
if next.FunctionCall.Name != "" {
t.FunctionCall.Name = next.FunctionCall.Name
}
t.FunctionCall.Arguments += next.FunctionCall.Arguments
}
type Property struct {
Type string `json:"type"`
Description string `json:"description,omitempty"`
Enum []string `json:"enum,omitempty"`
Items *Property `json:"items,omitempty"`
}
type Parameters struct {
Type string `json:"type"` // always "object"
Properties map[string]Property `json:"properties"`
Required []string `json:"required,omitempty"`
}
type Function struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters Parameters `json:"parameters,omitempty"`
Strict bool `json:"strict"`
}
type Tool struct {
Type string `json:"type"`
Function Function `json:"function"`
}
func NewTool(name, description string) Tool {
return Tool{
Type: "function",
Function: Function{
Name: name,
Description: description,
Strict: true,
// Servers validate parameters against the JSON Schema
// metaschema, which rejects a missing or empty type,
// and an object schema without properties.
Parameters: Parameters{
Type: "object",
Properties: map[string]Property{},
},
},
}
}
func (t *Tool) AddProperty(name string, property Property, required bool) *Tool {
if t.Function.Parameters.Properties == nil {
t.Function.Parameters.Properties = make(map[string]Property)
}
if _, exists := t.Function.Parameters.Properties[name]; exists {
panic("property already exists: " + name)
}
t.Function.Parameters.Properties[name] = property
if required {
t.Function.Parameters.Required = append(t.Function.Parameters.Required, name)
}
return t
}
func (t *Tool) AddStringProperty(name, description string, required bool) *Tool {
return t.AddProperty(name, Property{Type: "string", Description: description}, required)
}
func (t *Tool) AddBooleanProperty(name, description string, required bool) *Tool {
return t.AddProperty(name, Property{Type: "boolean", Description: description}, required)
}
func (t *Tool) AddIntegerProperty(name, description string, required bool) *Tool {
return t.AddProperty(name, Property{Type: "integer", Description: description}, required)
}
func (t *Tool) AddEnumProperty(name, description string, values []string, required bool) *Tool {
return t.AddProperty(name, Property{Type: "string", Description: description, Enum: values}, required)
}
func (t *Tool) AddArrayProperty(name, itemType, description string, required bool) *Tool {
return t.AddProperty(name, Property{
Type: "array",
Description: description,
Items: &Property{Type: itemType},
}, required)
}
func (t *Tool) AddStringArrayProperty(name, description string, required bool) *Tool {
return t.AddArrayProperty(name, "string", description, required)
}
type ChatCompletionRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages,omitempty"`
Stream bool `json:"stream"`
Tools []Tool `json:"tools,omitempty"`
Seed int `json:"seed,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
TopP float64 `json:"top_p,omitempty"`
}
type Choice struct {
Delta Message `json:"delta"`
FinishReason string `json:"finish_reason,omitempty"`
Index int `json:"index"`
Message Message `json:"message"`
Role string `json:"role"`
}
func (c *Choice) String() string {
return fmt.Sprintf("Index: %d, Role: %s, FinishReason: %v, Delta: %s, Message: %s",
c.Index, c.Role, c.FinishReason, c.Delta.String(), c.Message.String())
}
func (c *Choice) Merge(next *Choice) {
c.Message.Merge(&next.Delta)
if next.FinishReason != "" {
c.FinishReason = next.FinishReason
}
if next.Role != "" {
c.Role = next.Role
}
}
// Timings mirrors llama.cpp's /chat/completions timing block. The
// streamer tolerates unknown fields, so extra fields from the server
// are simply ignored.
type Timings struct {
CacheN int `json:"cache_n"`
DraftN int `json:"draft_n"`
DraftNAccepted int `json:"draft_n_accepted"`
PromptN int `json:"prompt_n"`
PromptMs float64 `json:"prompt_ms"`
PromptPerTokenMs float64 `json:"prompt_per_token_ms"`
PromptPerSecond float64 `json:"prompt_per_second"`
PredictedN int `json:"predicted_n"`
PredictedMs float64 `json:"predicted_ms"`
PredictedPerTokenMs float64 `json:"predicted_per_token_ms"`
PredictedPerSecond float64 `json:"predicted_per_second"`
}
type ChatCompletion struct {
Choices []Choice `json:"choices"`
Created int64 `json:"created"`
Id string `json:"id"`
Model string `json:"model"`
SystemFingerprint string `json:"system_fingerprint"`
Object string `json:"object"`
Timings Timings `json:"timings"`
}
func (c *ChatCompletion) String() string {
s := fmt.Sprintf("Id: %s, Model: %s, Created: %d, SystemFingerprint: %s, Object: %s\n",
c.Id, c.Model, c.Created, c.SystemFingerprint, c.Object)
for _, choice := range c.Choices {
s += fmt.Sprintf(" Choice: %s\n", choice.String())
}
return s
}
// Merge folds a streamed chunk into the accumulated completion.
func (c *ChatCompletion) Merge(next *ChatCompletion) {
minLength := 0
for _, choice := range next.Choices {
if choice.Index+1 > minLength {
minLength = choice.Index + 1
}
}
if len(c.Choices) < minLength {
newChoices := make([]Choice, minLength)
copy(newChoices, c.Choices)
c.Choices = newChoices
}
for _, nextChoice := range next.Choices {
c.Choices[nextChoice.Index].Merge(&nextChoice)
}
c.Timings = next.Timings
}
Thinking
Okay, now I understand the data flow. Let me check the existing tests for web and livelog to understand the test patterns, and then I'll design the fix.
Let me check `web_test.go` and `hub_test.go`:
Tool
🔧 bash
{"command":"cd /project && cat internal/web/web_test.go"}
Arguments
{"command":"cd /project && cat internal/web/web_test.go"}
Result
package web
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/abrander/zoo/internal/config"
"github.com/abrander/zoo/internal/forgejo"
"github.com/abrander/zoo/internal/livelog"
"github.com/abrander/zoo/internal/store"
)
func testServer(t *testing.T) (*Server, *store.Store) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "zoo.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
cfg := &config.Config{
LLMs: []config.LLM{{Name: "qwen", OpenAI: "https://example.com", Model: "qwen3"}},
Agents: []config.Agent{{Name: "leon", LLM: "qwen"}},
Events: []config.Event{{Kind: config.EventIssueNew, Agent: "leon"}},
Environment: config.Environment{DockerImage: "debian:unstable", MaxLive: 2},
}
// No Forgejo client: pages render without avatars.
return New(cfg, st, livelog.NewHub(), nil), st
}
func TestIndexRenders(t *testing.T) {
s, _ := testServer(t)
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "leon") {
t.Fatalf("expected agent name in output, got %s", rr.Body.String())
}
}
func TestJobsAndDetail(t *testing.T) {
s, st := testServer(t)
ctx := context.Background()
if err := st.CreateJob(ctx, store.Job{ID: "job-1", EventKind: "issue:new", Agent: "leon", Owner: "acme", Repo: "widgets", IssueIndex: 1}); err != nil {
t.Fatal(err)
}
if err := st.AppendLog(ctx, "job-1", "stdout", "hello"); err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/jobs", nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "job-1") {
t.Fatalf("unexpected jobs page: %d %s", rr.Code, rr.Body.String())
}
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/jobs/job-1", nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "hello") {
t.Fatalf("unexpected job detail page: %d %s", rr.Code, rr.Body.String())
}
// job-1 was never marked started/finished, so it's still "live":
// hitting /events should stream whatever's currently buffered in the
// hub (not yet checkpointed/persisted) and then wait for more, until
// the request context is done.
s.hub.Publish("job-1", livelog.Event{Type: livelog.ContentDelta, Text: "streaming"})
evCtx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/jobs/job-1/events", nil).WithContext(evCtx))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "streaming") {
t.Fatalf("unexpected events response: %d %s", rr.Code, rr.Body.String())
}
// A finished job isn't live: /events should return immediately with
// nothing to stream, so the client falls back to the static page.
if err := st.CreateJob(ctx, store.Job{ID: "job-2", EventKind: "issue:new", Agent: "leon", Owner: "acme", Repo: "widgets", IssueIndex: 2}); err != nil {
t.Fatal(err)
}
if err := st.MarkJobFinished(ctx, "job-2", store.JobSucceeded, ""); err != nil {
t.Fatal(err)
}
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/jobs/job-2/events", nil))
if rr.Code != http.StatusOK || rr.Body.Len() != 0 {
t.Fatalf("expected empty response for a non-live job, got %d %q", rr.Code, rr.Body.String())
}
}
// TestJobDetailTrimsBlockWhitespace verifies that reasoning/content
// blocks are rendered without the leading/trailing newlines the model
// routinely emits: the block body uses white-space: pre-wrap, so those
// would show up as visible blank lines inflating the block's height
// (issue #53). Internal newlines must be preserved.
func TestJobDetailTrimsBlockWhitespace(t *testing.T) {
s, st := testServer(t)
ctx := context.Background()
if err := st.CreateJob(ctx, store.Job{ID: "job-1", EventKind: "issue:new", Agent: "leon", Owner: "acme", Repo: "widgets", IssueIndex: 1}); err != nil {
t.Fatal(err)
}
if err := st.MarkJobFinished(ctx, "job-1", store.JobSucceeded, ""); err != nil {
t.Fatal(err)
}
if err := st.AppendLog(ctx, "job-1", "content", "\nNow the remaining tool callers:\n\n\n"); err != nil {
t.Fatal(err)
}
// A block that is nothing but whitespace should not render at all.
if err := st.AppendLog(ctx, "job-1", "content", " \n\t "); err != nil {
t.Fatal(err)
}
if err := st.AppendLog(ctx, "job-1", "reasoning", " \ninner\nlines\nkept\n "); err != nil {
t.Fatal(err)
}
r := httptest.NewRecorder()
s.Handler().ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/jobs/job-1", nil))
if r.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", r.Code, r.Body.String())
}
body := r.Body.String()
if !strings.Contains(body, `<div class="block-body">Now the remaining tool callers:</div>`) {
t.Fatalf("expected trimmed content block, got: %s", body)
}
if strings.Contains(body, "<div class=\"block-body\">\n") {
t.Fatalf("block body still starts with a newline: %s", body)
}
if got := strings.Count(body, `class="block block-content"`); got != 1 {
t.Fatalf("expected exactly one content block (whitespace-only one dropped), got %d: %s", got, body)
}
if !strings.Contains(body, "inner\nlines\nkept") {
t.Fatalf("expected internal newlines to be preserved, got: %s", body)
}
}
// TestJobDetailAutoScroll verifies the live job detail page ships the
// tail-following controls (issue #57): the log box is wrapped so the
// jump-to-bottom button can float over it, and the streaming script does
// its scroll math against the log container (the actual scrollable
// element) instead of the window. A finished job is static and must not
// carry either the button or the streaming script.
func TestJobDetailAutoScroll(t *testing.T) {
s, st := testServer(t)
ctx := context.Background()
if err := st.CreateJob(ctx, store.Job{ID: "job-1", EventKind: "issue:new", Agent: "leon", Owner: "acme", Repo: "widgets", IssueIndex: 1}); err != nil {
t.Fatal(err)
}
r := httptest.NewRecorder()
s.Handler().ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/jobs/job-1", nil))
if r.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", r.Code, r.Body.String())
}
body := r.Body.String()
for _, want := range []string{
`class="log-wrap"`,
`id="log-jump"`,
`log.scrollHeight - log.scrollTop - log.clientHeight`,
`log.scrollTop = log.scrollHeight`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q in live job detail page, got:\n%s", want, body)
}
}
// The old window-based scroll math must be gone: it never worked,
// because the log box (not the window) is what scrolls.
for _, gone := range []string{
"window.scrollY",
"window.scrollTo",
} {
if strings.Contains(body, gone) {
t.Fatalf("live job detail page still uses window scroll math %q:\n%s", gone, body)
}
}
// A finished job is static: no jump button, no streaming script.
if err := st.MarkJobFinished(ctx, "job-1", store.JobSucceeded, ""); err != nil {
t.Fatal(err)
}
r = httptest.NewRecorder()
s.Handler().ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/jobs/job-1", nil))
if r.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", r.Code, r.Body.String())
}
if b := r.Body.String(); strings.Contains(b, `id="log-jump"`) || strings.Contains(b, "EventSource") {
t.Fatalf("finished job detail page should not carry live streaming controls:\n%s", b)
}
}
func TestAuthGate(t *testing.T) {
st, err := store.Open(filepath.Join(t.TempDir(), "zoo.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
cfg := &config.Config{Web: &config.Web{Token: "secret"}}
s := New(cfg, st, livelog.NewHub(), nil)
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/jobs", nil))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 without token, got %d", rr.Code)
}
req := httptest.NewRequest(http.MethodGet, "/jobs", nil)
req.Header.Set("Authorization", "Bearer secret")
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 with correct token, got %d", rr.Code)
}
}
// TestJobsShowAgentAvatars verifies the jobs pages render each agent's
// Forgejo avatar, and that an unresolvable avatar (unknown user) degrades
// to no image instead of breaking the page.
func TestJobsShowAgentAvatars(t *testing.T) {
// Minimal in-test Forgejo API: the version probe the SDK makes when
// the client is constructed, plus per-user profile lookups.
avatars := map[string]string{
"leon": "https://forgejo.example/avatars/leon",
}
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/version" {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"version":"1.23.0"}`)
return
}
if user, ok := strings.CutPrefix(r.URL.Path, "/api/v1/users/"); ok {
if url, ok := avatars[user]; ok {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"login_name":%q,"avatar_url":%q}`, user, url)
return
}
}
http.NotFound(w, r)
}))
t.Cleanup(api.Close)
fg, err := forgejo.NewClient(config.Forgejo{URL: api.URL, Token: "test"})
if err != nil {
t.Fatal(err)
}
st, err := store.Open(filepath.Join(t.TempDir(), "zoo.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
s := New(&config.Config{}, st, livelog.NewHub(), fg)
ctx := context.Background()
if err := st.CreateJob(ctx, store.Job{ID: "job-1", EventKind: "issue:new", Agent: "leon", Owner: "acme", Repo: "widgets", IssueIndex: 1}); err != nil {
t.Fatal(err)
}
// "ghost" doesn't exist on the mock Forgejo: the page must still
// render, just without an avatar for that job.
if err := st.CreateJob(ctx, store.Job{ID: "job-2", EventKind: "issue:new", Agent: "ghost", Owner: "acme", Repo: "widgets", IssueIndex: 2}); err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/jobs", nil))
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, `src="https://forgejo.example/avatars/leon"`) {
t.Fatalf("expected leon's avatar on the jobs page, got: %s", body)
}
if !strings.Contains(body, "job-2") {
t.Fatalf("expected the unknown-agent job to still render, got: %s", body)
}
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/jobs/job-1", nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `src="https://forgejo.example/avatars/leon"`) {
t.Fatalf("expected leon's avatar on the job detail page, got %d: %s", rr.Code, rr.Body.String())
}
}
Thinking
Now I have a good understanding of the codebase and the test patterns. Let's analyze the bug:
**Root cause:**
When the model produces a turn consisting of reasoning + tool calls (no actual content), the OpenAI-compatible server (e.g., llama.cpp) may still emit a whitespace-only `content` field (e.g., `" "` or `"\n"`). This causes:
1. `runLoop` fires `OnContentDelta(" ")` (because `len(" ") > len("")`).
2. `streamHooks.OnContentDelta` publishes `ContentStart` + `ContentDelta(" ")` to the hub.
3. The dashboard's JS creates a `block-content` div on `content_start` and appends `" "` on `content_delta`.
4. On `OnTurnEnd`, `ContentEnd` is published, and the content line `" "` is persisted to the store.
5. The JS trims the text on `content_end` → the block remains in the DOM with an empty body → **an empty block is displayed**.
6. On refresh (static view), `buildBlocks` skips whitespace-only content lines → the empty block disappears.
This matches the issue exactly: `thinking, empty, toolcall, thinking, empty, toolcall, thinking, response`.
**Fix:**
The elegant fix is to make the live view behave the same as the static view: don't render whitespace-only blocks.
There are two places to fix:
1. **Client-side (the visible bug):** The JS in the template should not leave empty blocks in the DOM. The cleanest approach: lazily create the block — don't create a DOM node on `content_start`/`reasoning_start`; create it on the first delta containing non-whitespace content. On `content_end`/`reasoning_end`, if no block was created, do nothing; otherwise trim.
Actually, wait. Let me think a bit more carefully about lazy creation. The problem with creating only on the first *non-whitespace* delta: if the content is `" hello "`, the first delta might be `" "` (whitespace) → no block created. The second delta `"hello"` → block created with `"hello"`. The third delta `" "` → appended → `"hello "`. On end: trim → `"hello"`. Static view: `TrimSpace(" hello ")` = `"hello"`. Match.
But what if the deltas arrive as `" h"`, `"ello "`? First delta `" h"` contains non-whitespace → create block with `" h"`. Second delta → `" hello "`. On end: trim → `"hello"`. Static: `"hello"`. Match.
So lazy creation on the first delta containing non-whitespace works, and matches the static view exactly (since the static view trims the whole thing and only renders if the trimmed result is non-empty).
Hmm, wait, actually there's a subtle difference. The static view trims the entire accumulated text. Lazy creation drops leading whitespace before the first non-whitespace character. But since we trim on end anyway, the leading whitespace gets dropped either way. So the result is identical.
Actually, let me reconsider. Is it simpler and safer to just remove the block on end if it's empty? Let me compare:
**Approach A (remove on end if empty):**
```js
case "content_end":
if (contentBody) {
contentBody.textContent = contentBody.textContent.trim();
if (!contentBody.textContent) contentBody.parentElement.remove();
contentBody = null;
}
break;
```
This is minimal. The empty block flickers briefly (from start to end) but is removed on end. Since start and end are emitted back-to-back from the same goroutine (OnTurnEnd emits end right after the stream completes), the flicker is minimal. But actually, the content block is created on the first delta, which could be much earlier than the end. So if the content is whitespace-only, the block is created on the first whitespace delta and removed on end. If the whitespace delta arrives early in the turn and the turn is long (lots of reasoning), the empty block could be visible for a while. Hmm.
Wait, no. Let me re-trace. On a reasoning+toolcall turn:
- Reasoning deltas stream in (reasoning block is created and filled).
- At some point, a whitespace-only content delta arrives → content block is created (empty).
- The turn ends → content end → block removed (Approach A) or block stays empty (current bug).
So with Approach A, the empty content block is visible from when the whitespace delta arrives until the turn ends. If the whitespace content delta arrives at the start of the turn (before reasoning), the empty block is visible for the entire duration of the reasoning stream. That's not good — it's exactly the "empty block after thinking block" the user is seeing, just transient.
Actually, wait. The order is: the issue says "empty blocks often appear after thinking blocks". So the order is thinking → empty → toolcall. So the whitespace content delta arrives after the reasoning (thinking) is done, right before the toolcall. So the empty block is created after thinking and removed on turn end (right before the toolcall is emitted). So the flicker is short. But it's still visible.
**Approach B (lazy creation):**
The block is never created for whitespace-only content. No flicker at all. This is cleaner and matches the static view exactly.
Let's go with Approach B (lazy creation) as the primary fix. It's the most elegant: whitespace-only blocks simply never appear in the DOM, exactly as they don't appear in the static view.
But there's a subtlety with lazy creation and the "start" event. Currently:
```js
case "content_start":
contentBody = newBlock("content", null);
break;
case "content_delta":
if (contentBody) contentBody.textContent += ev.text;
break;
case "content_end":
if (contentBody) contentBody.textContent = contentBody.textContent.trim();
contentBody = null;
break;
```
With lazy creation:
```js
case "content_start":
contentBody = null; // don't create yet
break;
case "content_delta":
if (!contentBody && ev.text.trim()) contentBody = newBlock("content", null);
if (contentBody) contentBody.textContent += ev.text;
break;
case "content_end":
if (contentBody) {
contentBody.textContent = contentBody.textContent.trim();
contentBody = null;
}
break;
```
Wait, but there's a problem: if the first delta is whitespace (no block created), and the second delta is also whitespace, and then a third delta has text, we create the block on the third delta. But we've lost the leading whitespace. That's fine (we trim anyway). But what if the content is only whitespace? No block is ever created.
But there's another subtle issue: what if `ev.text.trim()` is non-empty but the block should start from an earlier position? No, since we trim on end, it's fine to drop leading whitespace.
Hmm, but actually, there's an even simpler and more robust approach that handles all cases and exactly matches the static view: **buffer the deltas on the client side and only create the block on end if the trimmed buffer is non-empty**. But that defeats the purpose of streaming (live text display). We want to show the text as it streams in.
So lazy creation on the first non-whitespace delta is the right approach. Let me refine:
Actually, I realize there's a subtle issue with checking `ev.text.trim()` per delta. Consider content = `"a"`. Deltas: `"a"`. First delta `"a"`, trim → `"a"` non-empty → create block, append `"a"`. On end: trim → `"a"`. Good.
Consider content = `" a"`. Deltas could be `" "`, `"a"` or `" a"`.
- If deltas are `" "`, `"a"`: first delta `" "` trim → `""` → no block. Second delta `"a"` trim → `"a"` → create block, append `"a"`. On end: trim → `"a"`. Static: `TrimSpace(" a")` = `"a"`. Match.
- If delta is `" a"`: trim → `"a"` → create block, append `" a"`. On end: trim → `"a"`. Match.
Good, both work.
Now, should we apply the same lazy creation to reasoning blocks? The issue specifically mentions empty blocks after thinking blocks, and the example is a content block. But reasoning blocks could theoretically also be whitespace-only. To be safe and consistent, let's apply the same lazy creation to reasoning blocks too. This makes the live view fully match the static view for both block types.
Actually, let me reconsider. Is it possible for a reasoning block to be whitespace-only? The model emits reasoning content. If the reasoning is only whitespace, the static view drops it. For consistency, the live view should also drop it. So yes, let's apply lazy creation to reasoning too.
2. **Server-side (root cause / data hygiene):** Should we also avoid persisting whitespace-only content/reasoning lines? The static view already filters them out, so it's not strictly necessary for the fix. But it's cleaner not to persist junk. However, changing server-side persistence is a bigger change and might have side effects. Let me think about whether it's worth it.
Actually, there's a subtle argument for fixing it server-side too: the persisted log is the source of truth. If we persist whitespace-only lines, they accumulate in the DB. The static view filters them, so it's harmless for display. But it's cleaner not to persist them.
However, the minimal, surgical fix for the reported bug (empty blocks in the live view) is the client-side lazy creation. Let me focus on that as the primary fix.
But wait — I want to reconsider whether the fix should be server-side instead. Let me think about what's "elegant" here.
The real question: where should the decision "this block is empty, don't render it" live?
- The static view already makes this decision in `buildBlocks` (server-side, Go).
- The live view makes rendering decisions in the client JS.
For the two views to be consistent, the decision should be the same. The cleanest architecture: the decision "is this block worth rendering" should be made in one place.
Option 1: Make the decision server-side at persistence time (in `streamHooks.OnTurnEnd`). Don't persist whitespace-only blocks, and don't publish start/end events for them. But we can't retroactively un-publish the start event that was already sent. So this doesn't fully work for the live view.
Option 2: Make the decision client-side (lazy creation). The live view never renders whitespace-only blocks. The static view already filters them. Both views are consistent. This is the fix.
So Option 2 (client-side lazy creation) is the right fix. It's minimal, surgical, and makes the live view match the static view.
Now, should we also avoid persisting whitespace-only lines server-side? I think that's a nice-to-have for data hygiene, but it's not required for the fix, and it's a separate concern. Let me consider whether to include it.
Actually, let me reconsider. If we don't persist whitespace-only lines, the DB is cleaner. But the static view already handles them. So it's not necessary. To keep the change surgical, I'll focus on the client-side fix. But let me reconsider — is there any scenario where persisting whitespace-only lines causes a problem beyond display? No. So I'll leave server-side persistence as-is to keep the change minimal.
Hmm, but actually, let me reconsider once more. The issue is specifically about the live/streaming view showing empty blocks. The static view is fine. So the fix is purely in the live view (client JS). Let me go with that.
Wait, but I want to double-check: is the empty block really caused by whitespace-only content, or could it be caused by something else? Let me re-examine.
The example HTML:
```html
<div class="block block-content"><div class="block-body">
</div></div>
```
The block-body contains a newline. In the JS, `contentBody.textContent = contentBody.textContent.trim()` on `content_end`. If the content was `" "` (space) or `"\n"` (newline), after trim it becomes `""`. So the block-body would be empty (or contain the trimmed empty string). But the example shows a newline inside block-body.
Hmm, wait. Let me re-read. The example shows:
```html
<div class="block-body">
</div>
```
There's a newline between `>` and `</div>`. That's a bit odd. If textContent was set to `""` (empty after trim), there should be no newline. Unless... the trim didn't happen, or the content was something that doesn't get fully trimmed.
Wait, actually, let me reconsider. Maybe the issue is that the block is created on `content_start`, and then `content_end` never fires? No, `content_end` always fires on `OnTurnEnd` if `contentOpen`.
Actually, wait. Let me reconsider the whitespace. `strings.TrimSpace` in Go and `.trim()` in JS both remove leading and trailing whitespace (including newlines, spaces, tabs). So if the content is `"\n"`, after trim it's `""`. The block-body would be empty.
But the example shows a newline. Let me think... maybe the HTML in the issue is just illustrative and the newline is an artifact of how they copy-pasted the HTML. The key point is that the block-body is effectively empty (whitespace-only). So my analysis holds.
Actually, let me reconsider. Maybe the content isn't whitespace-only but is actually empty (`""`), and the block is created on `content_start` without any deltas. How could that happen?
In `streamHooks.OnContentDelta`, `ContentStart` is published only when a content delta arrives. So `ContentStart` is only published if there's at least one content delta. So the block is only created if there's at least one delta. If the delta is `""` (empty string), `len("") > len("")` is false, so no delta fires. So `ContentStart` is not published. So the block is not created.
Therefore, for the block to be created, there must be at least one non-empty delta. And for the block to be empty after trim, that delta must be whitespace-only. So the content is whitespace-only. My analysis holds.
So the fix is: lazy creation of the block on the first non-whitespace delta.
Let me also double-check: could the empty block be a reasoning block instead? The example shows `block-content`, so it's a content block. But for robustness, let's apply the same fix to reasoning blocks too.
Now let me also reconsider: are there other sources of empty blocks? Let me think about the `system` block. `newBlock("system", null).textContent = ev.text`. If `ev.text` is empty, an empty system block is created. But system events are only published with non-empty text (reroll notes). So that's fine.
What about tool blocks? They always have a name. So that's fine.
So the fix is for reasoning and content blocks: lazy creation.
Let me now write the fix.
Current JS:
```js
var reasoningBody = null;
var contentBody = null;
...
es.onmessage = function(e) {
var ev = JSON.parse(e.data);
switch (ev.type) {
case "reasoning_start":
reasoningBody = newBlock("reasoning", "Thinking");
break;
case "reasoning_delta":
if (reasoningBody) reasoningBody.textContent += ev.text;
break;
case "reasoning_end":
if (reasoningBody) reasoningBody.textContent = reasoningBody.textContent.trim();
reasoningBody = null;
break;
case "content_start":
contentBody = newBlock("content", null);
break;
case "content_delta":
if (contentBody) contentBody.textContent += ev.text;
break;
case "content_end":
if (contentBody) contentBody.textContent = contentBody.textContent.trim();
contentBody = null;
break;
...
}
follow();
};
```
New JS (lazy creation):
```js
var reasoningBody = null;
var contentBody = null;
// A reasoning/content block is only worth rendering if it ends up
// with non-whitespace text (the model routinely emits whitespace-only
// content on reasoning+tool-call turns). The static view drops such
// blocks, so the live view must too: create the block lazily, on the
// first delta that carries real text, and drop it if the whole block
// turns out to be whitespace.
function ensureBlock(kind, label, body, text) {
if (body) return body;
if (!text.trim()) return null;
return newBlock(kind, label);
}
...
case "reasoning_start":
reasoningBody = null; // created lazily on first real delta
break;
case "reasoning_delta":
if (!reasoningBody) reasoningBody = ensureBlock("reasoning", "Thinking", null, ev.text);
if (reasoningBody) reasoningBody.textContent += ev.text;
break;
case "reasoning_end":
if (reasoningBody) {
reasoningBody.textContent = reasoningBody.textContent.trim();
if (!reasoningBody.textContent) reasoningBody.parentElement.remove();
reasoningBody = null;
}
break;
...
```
Wait, with lazy creation, if the block is only created on the first non-whitespace delta, then on end the trimmed text is guaranteed to be non-empty (because we only created the block if there was non-whitespace text, and we append all subsequent deltas). Actually, no — we only create the block if the first delta has non-whitespace. But subsequent deltas could be whitespace. So the block's text is (first non-whitespace delta) + (subsequent deltas). The first delta has non-whitespace, so the total text has non-whitespace, so the trimmed text is non-empty. So the `if (!reasoningBody.textContent) remove()` on end is actually unnecessary. But it's a safety net. Let me keep it for robustness, or remove it for simplicity.
Actually, let me reconsider. With lazy creation:
- The block is created only if some delta has non-whitespace text.
- Once created, we append all subsequent deltas (including whitespace).
- On end, we trim. Since at least one delta had non-whitespace, the trimmed text is non-empty.
So the block will never be empty on end. So the `remove()` on end is unnecessary. But it's a harmless safety net. To keep the code clean, I'll omit it, since lazy creation guarantees non-emptiness.
Wait, but there's a subtle issue. What if the first delta has non-whitespace, we create the block, and then... no, the text is only ever appended, never removed. So once non-whitespace is in there, it stays. So the trimmed text is always non-empty. Good.
So the fix is:
```js
case "reasoning_start":
reasoningBody = null;
break;
case "reasoning_delta":
if (!reasoningBody && ev.text.trim()) reasoningBody = newBlock("reasoning", "Thinking");
if (reasoningBody) reasoningBody.textContent += ev.text;
break;
case "reasoning_end":
if (reasoningBody) reasoningBody.textContent = reasoningBody.textContent.trim();
reasoningBody = null;
break;
```
And similarly for content.
Hmm wait, but there's a subtle bug here. If the first delta is whitespace (no block created), and the second delta is whitespace (no block), and the third delta is `"hello"`, we create the block on the third delta and append `"hello"`. But we've lost the leading whitespace. That's fine (we trim anyway).
But what if the deltas are `"he"`, `"llo"`? First delta `"he"` trim → `"he"` → create block, append `"he"`. Second delta `"llo"` → append → `"hello"`. On end: trim → `"hello"`. Good.
What if the first delta is `" "` (whitespace) and the second is `"hello"`? First: no block. Second: create block, append `"hello"`. On end: `"hello"`. Static: `TrimSpace(" hello")` = `"hello"`. Match.
Great, the fix works.
Now, let me also reconsider: do we even need the `content_start`/`reasoning_start` events anymore? With lazy creation, the start event just resets the body to null. Actually, the start event is still useful for resetting state (in case a previous block wasn't properly closed). Let me keep the start event and just set the body to null.
Actually, wait. There's a subtle issue with resetting on start. Consider: a content block starts, some deltas, then end (body set to null). Then a new content block starts. The start event sets contentBody = null (already null). Fine.
But what if a content block starts and no deltas arrive (whitespace-only, but... no, if no deltas arrive, the block is never created). Actually, if content is whitespace-only, deltas do arrive (whitespace deltas), but the block is never created. Then end arrives, body is null, nothing happens. Good.
OK here's my concern: with lazy creation, the `content_start` event no longer creates a block. So if there's a bug where `content_start` is emitted but no deltas follow, the block is never created. That's the desired behavior (no empty block). Good.
Let me now also think about whether we should fix this server-side to avoid publishing `ContentStart`/`ContentEnd` for whitespace-only content. The problem: we can't know at the time of the first delta whether the content will be whitespace-only. So we have to publish `ContentStart` (or not). If we don't publish `ContentStart` at all and rely on the client to create the block on the first non-whitespace delta, then the client doesn't need the start event. But the client also uses the start event to reset state.
Actually, the cleanest server-side approach: buffer the content, and only publish `ContentStart` on the first non-whitespace delta. But that requires the server to track whether it has published the start. Let me think...
Actually, this is getting complicated. The client-side lazy creation is simpler and sufficient. Let me go with that.
But wait, I want to reconsider the architecture. The issue is that the live view and static view disagree. The most elegant fix would be to make them agree by construction.
The static view's rule: a reasoning/content block is rendered iff `TrimSpace(text) != ""`.
The live view should implement the same rule. With lazy creation, the live view renders a block iff at least one delta has non-whitespace text, which is equivalent to `TrimSpace(total) != ""`. So the two views agree.
So the client-side lazy creation is the right fix. Let me implement it.
Now, let me also consider: should I add a test? The existing tests test the static view (`TestJobDetailTrimsBlockWhitespace`). I should add a test for the live view's behavior. But the live view is client-side JS, which is hard to test in Go. The existing test `TestJobDetailAutoScroll` checks that the JS is present in the page. I could add a test that checks the JS contains the lazy-creation logic. But that's fragile.
Alternatively, I could test the server-side event sequence. But the fix is client-side, so there's no server-side change to test.
Hmm. Let me reconsider. Maybe I should also make a server-side change to make the fix more robust and testable.
Actually, let me step back and think about the cleanest overall solution.
**The real root cause:** The model emits whitespace-only content on reasoning+toolcall turns. The live view renders this as an empty block. The static view filters it out.
**The cleanest fix:** Make the live view filter it out too, exactly like the static view.
**Where to implement:**
- Option A: Client-side JS (lazy block creation).
- Option B: Server-side (don't publish start/end for whitespace-only blocks). But we can't know at start time.
Option A is the way to go.
But actually, wait. Let me reconsider Option B more carefully. What if the server buffers the entire block and only publishes it as a single event at the end? No, that defeats the purpose of streaming (we want to see the text as it streams in).
What if the server publishes deltas as it does now, but the client does lazy creation? That's Option A. Yes.
OK let me also reconsider: is there a way to make the server not emit whitespace-only content in the first place? The whitespace comes from the model/server, not from our code. We can't control that. So we have to handle it in our rendering.
Alright, let me implement Option A (client-side lazy creation) and add a test.
For the test, since the fix is in the JS embedded in the template, I'll add a test that verifies the JS implements lazy creation. But that's fragile. Alternatively, I could refactor the block-building logic into a shared function that both the static view and the live view use. But the live view is client-side JS, so it can't share Go code.
Hmm. Let me think about whether there's a way to make this testable and clean.
Actually, I think the best approach is:
1. Fix the client-side JS (lazy creation).
2. Add a Go test that verifies the rendered page's JS contains the lazy-creation logic (to prevent regression). This is a bit fragile but follows the pattern of `TestJobDetailAutoScroll`.
Actually, let me reconsider. Maybe I'm overcomplicating this. Let me re-read the issue and think about the minimal fix.
The issue: empty blocks in the live view. The fix: don't render empty blocks in the live view.
The minimal fix is the client-side JS change. Let me implement it and add a reasonable test.
Actually, let me reconsider whether there's a server-side component I'm missing. Let me re-examine `streamHooks.OnTurnEnd`:
```go
OnTurnEnd: func() {
if reasoningOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})
logAppend("reasoning", reasoningBuf.String())
r.hub.Checkpoint(jobID)
reasoningOpen = false
}
if contentOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})
logAppend("content", contentBuf.String())
r.hub.Checkpoint(jobID)
contentOpen = false
}
},
```
So on turn end, if content was open, it publishes `ContentEnd` and persists the content. If the content is whitespace-only, it persists a whitespace-only line. The static view filters it out. So the static view is fine.
Now, should we also avoid persisting whitespace-only lines? For data hygiene, yes. But it's not required for the fix. Let me consider adding it as a secondary improvement.
Actually, I think the cleanest and most complete fix is:
1. **Client-side:** Lazy block creation (the primary fix for the visible bug).
2. **Server-side:** Don't persist whitespace-only reasoning/content lines (data hygiene, and makes the persisted log cleaner). This also means the static view's filter becomes a belt-and-suspenders safety net.
But wait, if I do the server-side change (don't persist whitespace-only lines), I need to be careful: the `logAppend` is also used for the checkpoint logic. Let me re-examine.
Actually, the checkpoint is about the hub's replay buffer, not about persistence. The checkpoint clears the hub's buffer after a block is persisted. If I skip persisting a whitespace-only block, I should still checkpoint (to clear the hub's buffer for that block's events). Otherwise, the hub's buffer would retain the whitespace block's events, and a new subscriber would replay them.
Hmm, this is getting complicated. Let me keep the server-side persistence as-is (persist whitespace-only lines) and only fix the client-side rendering. The static view already filters them out, so persisting them is harmless. This keeps the change surgical and focused on the reported bug.
Wait, but actually, let me reconsider. If I persist whitespace-only lines, and the static view filters them out, then the static view is fine. The live view is fixed by lazy creation. So both views are consistent.
But there's a subtle issue: the hub's replay buffer. When a new subscriber connects, they get the buffered (not-yet-checkpointed) events. If a whitespace-only content block's events are in the buffer (start + deltas + end), the new subscriber would replay them. With lazy creation, the client would process them: start (body=null), deltas (whitespace, no block created), end (body=null, nothing). So no empty block. Good. So even with replay, the lazy creation handles it correctly.
So the client-side lazy creation is robust to replay.
Alright, let me finalize the plan:
1. Fix the client-side JS in `templates.go`: lazy creation of reasoning and content blocks.
2. Add a test to verify the fix.
For the test, let me think about what's testable. The fix is in the JS. I could:
- Add a test that checks the rendered page's JS contains the lazy-creation logic.
- Or, better, extract the "should this block be rendered" logic into a shared, testable place.
Actually, I realize there might be a cleaner architectural fix. Let me reconsider.
What if the server, instead of publishing `ContentStart` immediately on the first delta, buffers the content and only publishes `ContentStart` when it sees the first non-whitespace delta? Then the client would only create the block when it receives `ContentStart`, which only happens for non-whitespace content. This would make the server the single source of truth for "is this block worth rendering".
Let me think about this:
```go
OnContentDelta: func(delta string) {
contentBuf.WriteString(delta)
// Only open the block (publish ContentStart) once we've seen real
// text, so whitespace-only content never produces an empty block.
if !contentOpen && strings.TrimSpace(contentBuf.String()) != "" {
contentOpen = true
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})
}
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})
},
```
Wait, but there's a problem: if the first delta is whitespace, we don't publish `ContentStart`, but we do publish `ContentDelta`. Then the client receives a `ContentDelta` without a `ContentStart`. The client's `contentBody` is null, so `if (contentBody) contentBody.textContent += ev.text` does nothing. Then when a non-whitespace delta arrives, we publish `ContentStart` and `ContentDelta`. The client creates the block and appends. But the client has already "missed" the earlier whitespace deltas (which is fine, since they're whitespace).
But wait, there's a subtle issue: the client appends `ev.text` on each `ContentDelta`. If we publish `ContentStart` late (on the first non-whitespace delta), the client creates the block on `ContentStart` and then appends the current delta. But the earlier whitespace deltas were published as `ContentDelta` without a block existing, so they were dropped. That's fine (they're whitespace).
But actually, this approach has a problem: the client's `ContentDelta` handler appends `ev.text` to the block. If the block is created on `ContentStart` (which is published together with the first non-whitespace `ContentDelta`), the order of events matters. Let me trace:
- Delta 1: `" "` (whitespace). Server: contentBuf = `" "`, TrimSpace = `""`, so no `ContentStart`. Publish `ContentDelta(" ")`. Client: contentBody is null, so nothing.
- Delta 2: `"hello"`. Server: contentBuf = `" hello"`, TrimSpace = `"hello"`, so publish `ContentStart`. Then publish `ContentDelta("hello")`. Client: `ContentStart` → create block (empty). `ContentDelta("hello")` → append → `"hello"`.
- End: Server publishes `ContentEnd`, persists `" hello"`. Client: `ContentEnd` → trim → `"hello"`.
Static view: `TrimSpace(" hello")` = `"hello"`. Match!
So this server-side approach works too. And it makes the server the single source of truth. But it changes the event semantics: `ContentDelta` can now arrive before `ContentStart`. That's a bit unusual and might confuse other consumers.
Hmm, which approach is better?
**Client-side lazy creation:**
- Pros: Minimal change, client handles it, no change to event semantics.
- Cons: The logic is in JS (harder to test), the decision is duplicated between client JS and server Go (`buildBlocks`).
**Server-side lazy start:**
- Pros: Server is the single source of truth, testable in Go, consistent with `buildBlocks`.
- Cons: `ContentDelta` can arrive before `ContentStart` (unusual), slightly more complex server logic.
Actually, I think the server-side approach is cleaner architecturally, because it makes the server responsible for deciding "is this block worth rendering", which is the same decision `buildBlocks` makes. And it's testable in Go.
But the concern about `ContentDelta` before `ContentStart` is real. Let me think about how to handle that cleanly.
Actually, the client already handles `ContentDelta` gracefully when `contentBody` is null (it just skips). So `ContentDelta` before `ContentStart` is already handled. So the server-side approach is safe.
But wait, there's another subtlety. What if the content is whitespace-only? Then:
- All deltas are whitespace. Server never publishes `ContentStart` (since TrimSpace is always `""`). Server publishes `ContentDelta` for each whitespace delta.
- On end: Server publishes `ContentEnd`, persists whitespace.
- Client: receives `ContentDelta`s (contentBody is null, skip), receives `ContentEnd` (contentBody is null, nothing). No block created.
So no empty block.
But wait, the client's `ContentEnd` handler: `if (contentBody) contentBody.textContent = contentBody.textContent.trim(); contentBody = null;`. If contentBody is null, it just sets contentBody = null (no-op). Good.
So the server-side approach works and is clean.
But now I have two options. Let me decide.
I think the **server-side approach** is more elegant and testable. It makes the server the single source of truth for "is this block worth rendering", consistent with `buildBlocks`. And it's testable in Go.
But actually, wait. Let me reconsider. The server-side approach changes the event stream semantics (ContentDelta before ContentStart). Is that a problem for the hub's replay? No, the replay just replays events in order. The client handles them gracefully.
Hmm, but actually, I realize there's an even cleaner approach that combines the best of both:
**Hybrid:** The server does lazy start (only publishes `ContentStart` on the first non-whitespace delta), AND the client does lazy creation as a safety net. But that's redundant.
Let me just pick one. I'll go with the **server-side lazy start** approach, because:
1. It makes the server the single source of truth (consistent with `buildBlocks`).
2. It's testable in Go.
3. It's a cleaner architectural fix.
Wait, but actually, let me reconsider once more. The server-side approach requires the server to buffer the content and check `TrimSpace` on every delta. That's a bit of overhead, but negligible.
Actually, let me reconsider the client-side approach once more. The advantage of the client-side approach is that it's a smaller, more surgical change. The server-side approach changes the event stream, which is a bigger change.
Hmm, let me think about what a senior developer would do. The issue is a rendering bug in the live view. The cleanest fix is to make the live view match the static view. The static view's logic is in `buildBlocks` (Go). The live view's logic is in the client JS.
I think the most elegant fix is to make the decision in one place. Since the live view is inherently client-side (it's rendering streamed events), the decision has to be made client-side. But we can make the server help by not emitting start events for whitespace-only blocks.
Actually, you know what, let me just go with the client-side lazy creation. It's the most surgical fix, it directly addresses the bug, and it's robust to replay. I'll add a test that verifies the JS is present and correct.
Wait, but I'm worried about testability. Let me think about how to test the client-side JS fix.
Option 1: Test that the rendered page's JS contains the lazy-creation logic. This is fragile but follows the existing pattern (`TestJobDetailAutoScroll` checks for specific JS strings).
Option 2: Extract the block-building logic into a shared function. But the live view is client-side, so it can't share Go code.
I'll go with Option 1: add a test that checks the JS contains the lazy-creation logic.
Actually, let me reconsider. Maybe I should do both the server-side and client-side fixes for robustness. But that's overkill.
Let me make a decision: I'll implement the **server-side lazy start** approach. Here's my reasoning:
1. It's the root-cause fix: the server decides not to open a block for whitespace-only content.
2. It's testable in Go (I can test `streamHooks` directly).
3. It's consistent with `buildBlocks` (the static view's logic).
4. The client already handles `ContentDelta` before `ContentStart` gracefully.
Wait, but actually, I realize the server-side approach has a subtle issue with the hub's replay buffer and checkpoint. Let me re-examine.
In `streamHooks`, the `contentOpen` flag tracks whether a content block is open. With lazy start, `contentOpen` is set to true only when we publish `ContentStart` (on the first non-whitespace delta). But we still need to track the content buffer even before the block is "open", so that we can persist it on turn end.
Let me re-examine the current code:
```go
OnContentDelta: func(delta string) {
if !contentOpen {
contentOpen = true
contentBuf.Reset()
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})
}
contentBuf.WriteString(delta)
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})
},
```
So `contentOpen` is set on the first delta, and `contentBuf` is reset. With lazy start, I want to:
- Always append to `contentBuf` (so we can persist on turn end).
- Only set `contentOpen` and publish `ContentStart` when we see the first non-whitespace content.
But wait, `contentBuf.Reset()` is called when `contentOpen` transitions from false to true. If I delay that transition, I need to make sure `contentBuf` is reset at the right time.
Let me restructure:
```go
OnContentDelta: func(delta string) {
contentBuf.WriteString(delta)
// Only open the block once we've seen real text, so whitespace-only
// content (common on reasoning+tool-call turns) never produces an
// empty block in the live view — matching the static view, which
// drops whitespace-only blocks.
if !contentOpen && strings.TrimSpace(contentBuf.String()) != "" {
contentOpen = true
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})
}
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})
},
```
But wait, where is `contentBuf` reset? Currently it's reset when `contentOpen` transitions from false to true. But with lazy start, the transition is delayed. So I need to reset `contentBuf` at the start of each turn, not when the block opens.
Hmm, actually, `contentBuf` should be reset at the start of each turn (before any deltas). Let me look at where that happens.
Currently, `contentBuf.Reset()` is called in `OnContentDelta` when `contentOpen` transitions from false to true. And `contentOpen` is set to false in `OnTurnEnd`. So the buffer is reset at the start of the next content block.
But with lazy start, if the content is whitespace-only, `contentOpen` never becomes true, so `contentBuf.Reset()` is never called. So the buffer would accumulate across turns! That's a bug.
Wait, let me re-examine. In the current code:
- Turn 1: content delta arrives → `contentOpen` false→true, `contentBuf.Reset()`, publish start. More deltas appended. Turn end → `contentOpen` = false.
- Turn 2: content delta arrives → `contentOpen` false→true, `contentBuf.Reset()`, publish start. Etc.
So the buffer is reset at the start of each content block. If a turn has no content, the buffer is not reset, but it's also not used (since `contentOpen` is false, `OnTurnEnd` doesn't persist it).
With lazy start:
- Turn 1: whitespace-only content. Deltas arrive, `contentBuf` accumulates whitespace, but `contentOpen` stays false (since TrimSpace is `""`). Turn end → `contentOpen` is already false, so `OnTurnEnd` doesn't persist. But `contentBuf` still has the whitespace from turn 1!
- Turn 2: real content. First delta: `contentBuf.WriteString(delta)` appends to the existing whitespace from turn 1! So `contentBuf` = turn 1's whitespace + turn 2's content. Then `TrimSpace` is non-empty, so `contentOpen` = true, publish start. But the buffer has stale whitespace from turn 1!
So I need to reset `contentBuf` at the start of each turn, not when the block opens. Let me restructure to reset the buffer in `OnTurnEnd` (or at the start of each turn).
Actually, the cleanest approach: reset both buffers in `OnTurnEnd` (after persisting). That way, each turn starts with a fresh buffer.
Let me restructure:
```go
OnContentDelta: func(delta string) {
contentBuf.WriteString(delta)
if !contentOpen && strings.TrimSpace(contentBuf.String()) != "" {
contentOpen = true
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})
}
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})
},
OnReasoningDelta: func(delta string) {
reasoningBuf.WriteString(delta)
if !reasoningOpen && strings.TrimSpace(reasoningBuf.String()) != "" {
reasoningOpen = true
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})
}
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})
},
OnTurnEnd: func() {
if reasoningOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})
logAppend("reasoning", reasoningBuf.String())
r.hub.Checkpoint(jobID)
}
if contentOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})
logAppend("content", contentBuf.String())
r.hub.Checkpoint(jobID)
}
reasoningOpen = false
contentOpen = false
reasoningBuf.Reset()
contentBuf.Reset()
},
```
Wait, but there's a problem: if the reasoning is whitespace-only, `reasoningOpen` stays false, and we don't persist it. But we also don't checkpoint. Is that a problem? The checkpoint clears the hub's replay buffer. If we don't checkpoint, the hub's buffer retains the reasoning deltas (whitespace). But since `reasoningOpen` is false, no `ReasoningStart`/`ReasoningEnd` were published, only `ReasoningDelta`s. A new subscriber would replay those `ReasoningDelta`s. The client would receive `ReasoningDelta`s without a `ReasoningStart`. The client's `reasoningBody` is null, so it skips them. So no empty block. Good.
But the hub's buffer would accumulate these orphan `ReasoningDelta`s until the next checkpoint. That's a minor memory concern, but the buffer is capped at `maxBufferedEvents` (10000). So it's bounded. And the next real block would checkpoint and clear them. So it's fine.
Actually, wait. Let me reconsider. If the reasoning is whitespace-only, we publish `ReasoningDelta`s (whitespace) but no `ReasoningStart`/`ReasoningEnd`. These deltas sit in the hub's buffer. On the next turn, when a real reasoning block starts, we publish `ReasoningStart`, and on turn end, we checkpoint. The checkpoint clears the buffer, including the orphan deltas from the previous turn. So they don't accumulate indefinitely. Good.
But there's a subtle issue: the orphan `ReasoningDelta`s from the previous turn would be replayed to a new subscriber before the `ReasoningStart` of the next turn. The client would skip them (reasoningBody is null). So no visual artifact. Good.
OK so the server-side lazy start approach works, but it requires restructuring the buffer reset logic. Let me make sure I get it right.
Actually, I realize the current code has a subtle behavior: `contentBuf.Reset()` is called when `contentOpen` transitions from false to true. This means the buffer is reset at the start of each content block. But if a turn has no content, the buffer is not reset. However, since the buffer is only read when `contentOpen` is true (in `OnTurnEnd`), and `contentOpen` is only true if there was content, the stale buffer content is never read. So it's fine in the current code.
But with lazy start, I want to reset the buffer at the start of each turn to avoid accumulating stale content. Let me reset the buffers in `OnTurnEnd`.
Hmm, but actually, there's a cleaner way. Let me reset the buffers when a new turn starts. But there's no explicit "turn start" hook. The first delta of a turn is the signal. So I could reset the buffer on the first delta of a turn. But how do I know it's the first delta of a turn? I could use the `contentOpen`/`reasoningOpen` flags, but they're per-block, not per-turn.
Actually, the simplest and most correct approach: reset the buffers in `OnTurnEnd`. That way, each turn starts with a fresh buffer. Let me do that.
Wait, but there's a subtlety: `OnTurnEnd` is called after the stream completes and before tool calls run. So resetting the buffers in `OnTurnEnd` is the right place (after we've persisted the current turn's blocks).
Let me restructure the code:
```go
func (r *Runner) streamHooks(jobID string, logAppend func(stream, line string)) Hooks {
var reasoningBuf, contentBuf strings.Builder
reasoningOpen, contentOpen := false, false
return Hooks{
OnReasoningDelta: func(delta string) {
reasoningBuf.WriteString(delta)
if !reasoningOpen && strings.TrimSpace(reasoningBuf.String()) != "" {
reasoningOpen = true
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})
}
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})
},
OnContentDelta: func(delta string) {
contentBuf.WriteString(delta)
if !contentOpen && strings.TrimSpace(contentBuf.String()) != "" {
contentOpen = true
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})
}
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})
},
OnTurnEnd: func() {
if reasoningOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})
logAppend("reasoning", reasoningBuf.String())
r.hub.Checkpoint(jobID)
}
if contentOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})
logAppend("content", contentBuf.String())
r.hub.Checkpoint(jobID)
}
reasoningOpen, contentOpen = false, false
reasoningBuf.Reset()
contentBuf.Reset()
},
...
}
}
```
Wait, but there's a problem with this. If the reasoning is whitespace-only, we don't publish `ReasoningStart`/`ReasoningEnd`, and we don't persist it. But we do publish `ReasoningDelta`s. And we reset the buffer in `OnTurnEnd`. So the next turn starts fresh. Good.
But wait, there's another subtlety. What if the reasoning is non-whitespace, but the content is whitespace-only? Then:
- Reasoning: `reasoningOpen` becomes true on the first non-whitespace delta, publish start. On turn end, publish end, persist, checkpoint.
- Content: `contentOpen` stays false (whitespace-only). On turn end, don't persist. Reset buffer.
So the content's whitespace deltas were published but no start/end. The client skips them. Good.
Now, what about the checkpoint? We checkpoint after persisting the reasoning. The checkpoint clears the hub's buffer, including the content's whitespace deltas. So they're cleared. Good.
Wait, but the order matters. In `OnTurnEnd`, we first handle reasoning (publish end, persist, checkpoint), then content (publish end, persist, checkpoint). If the reasoning is non-whitespace and the content is whitespace-only:
- Reasoning: publish end, persist, checkpoint (clears buffer, including content's whitespace deltas that were published before this).
Wait, no. The content's whitespace deltas were published during the stream (before `OnTurnEnd`). So they're in the hub's buffer. When we checkpoint after the reasoning, we clear the buffer, including the content's whitespace deltas. But then we handle the content: `contentOpen` is false, so we don't publish end or persist. So the content's whitespace deltas were cleared by the reasoning's checkpoint. Good.
But what if the reasoning is whitespace-only and the content is non-whitespace?
- Reasoning: `reasoningOpen` stays false. On turn end, don't publish end or persist.
- Content: `contentOpen` is true. Publish end, persist, checkpoint (clears buffer, including reasoning's whitespace deltas).
Good.
What if both are whitespace-only?
- Neither is persisted. No checkpoint. The buffer retains both sets of whitespace deltas. On the next turn, the next real block's checkpoint would clear them. So they don't accumulate indefinitely. Good.
OK so the server-side lazy start approach works. But it's a bit complex. Let me reconsider whether the client-side approach is simpler.
Actually, I think I've been overthinking this. Let me step back and consider the simplest, most surgical fix.
The bug: empty blocks in the live view. The static view is fine.
The simplest fix: make the live view not render empty blocks. This is a client-side JS change.
The client-side change:
```js
case "reasoning_start":
reasoningBody = null; // lazy: created on first real delta
break;
case "reasoning_delta":
if (!reasoningBody && ev.text.trim()) reasoningBody = newBlock("reasoning", "Thinking");
if (reasoningBody) reasoningBody.textContent += ev.text;
break;
case "reasoning_end":
if (reasoningBody) reasoningBody.textContent = reasoningBody.textContent.trim();
reasoningBody = null;
break;
// Similarly for content.
```
This is a small, surgical change. It directly fixes the bug. It's robust to replay. It doesn't change the event stream semantics.
The downside: the logic is in JS (harder to test), and the decision is duplicated between client JS and server Go.
But you know what, I think the client-side fix is the right call for a surgical bug fix. The server-side fix is a bigger architectural change that I'd want to do as a separate, more thoughtful change.
Actually, wait. Let me reconsider. The issue says "implement an elegant solution". A senior developer would want an elegant solution, not just a quick patch. So maybe the server-side fix is more "elegant".
Hmm. Let me think about what's truly elegant.
The root cause is that the model emits whitespace-only content, and the live view renders it as an empty block. The elegant solution is to make the live view and static view consistent by having a single source of truth for "is this block worth rendering".
The static view's source of truth is `buildBlocks` (Go). The live view's source of truth is the client JS. To have a single source of truth, we'd need to either:
1. Make the server decide (server-side lazy start), so the client just renders what the server sends.
2. Make the client decide (client-side lazy creation), and accept that the decision is duplicated.
Option 1 is more elegant (single source of truth in the server). Option 2 is simpler (smaller change).
I'll go with **Option 1 (server-side lazy start)** because it's the more elegant, architecturally sound fix. And I'll make sure to handle the buffer reset correctly.
Wait, but actually, I realize there's a problem with Option 1 that I need to address: the client still needs to handle the case where a block is created but ends up empty (defensive). But with server-side lazy start, the server never sends `ContentStart` for whitespace-only content, so the client never creates an empty block. So the client doesn't need the defensive check. But it's still good to have it for robustness.
Hmm, but if I do the server-side fix, do I still need the client-side fix? No, the server-side fix is sufficient. But adding the client-side fix as a safety net is good defensive programming.
Let me decide: I'll do the **server-side lazy start** as the primary fix, and I'll also add the **client-side lazy creation** as a safety net. This gives us defense in depth.
Wait, but that's two changes. Let me reconsider whether that's overkill.
Actually, I think doing both is the right call for an "elegant" solution:
1. Server-side: don't open a block for whitespace-only content (root cause fix, single source of truth).
2. Client-side: lazily create the block on the first non-whitespace delta (defensive, in case the server sends a start for an empty block).
But actually, if the server never sends a start for whitespace-only content, the client-side lazy creation is redundant. So maybe I should just do the server-side fix.
Hmm, let me reconsider. The client-side lazy creation is actually a good defensive measure, and it's a small change. But it's redundant if the server-side fix is in place.
Let me just do the server-side fix. It's the root cause fix, it's testable, and it's elegant. I'll add a test for it.
Wait, but I need to make sure the server-side fix is correct. Let me re-examine the buffer reset issue.
Actually, I realize there's a cleaner way to handle the buffer reset. Instead of resetting in `OnTurnEnd`, I can reset the buffer when a new turn's first delta arrives. But how do I detect the first delta of a turn?
Actually, the current code resets the buffer when `contentOpen` transitions from false to true. With lazy start, that transition is delayed. So I need a different signal for "new turn".
The cleanest signal: `OnTurnEnd` marks the end of a turn. So I reset the buffers in `OnTurnEnd`. That way, each turn starts with a fresh buffer.
But wait, there's a subtlety: `OnTurnEnd` is called once per turn, after the stream completes. So resetting the buffers there is correct.
Let me also reconsider: is there any case where `OnTurnEnd` is not called? Looking at `runLoop`, `OnTurnEnd` is called after the stream completes (after the `for` loop that reads the streamer). It's called before checking for tool calls. So it's always called for each turn (unless the stream errors, in which case we return early). So resetting in `OnTurnEnd` is safe.
But wait, if the stream errors, we return early without calling `OnTurnEnd`. So the buffers are not reset. But since we're returning (the run is over), it doesn't matter. Good.
OK let me also reconsider: what if a turn has reasoning but no content, and the next turn has content? The buffers are reset in `OnTurnEnd` after each turn, so each turn starts fresh. Good.
Alright, let me finalize the server-side fix:
```go
func (r *Runner) streamHooks(jobID string, logAppend func(stream, line string)) Hooks {
var reasoningBuf, contentBuf strings.Builder
reasoningOpen, contentOpen := false, false
return Hooks{
OnReasoningDelta: func(delta string) {
reasoningBuf.WriteString(delta)
// Only open the block once it carries real text: the model
// routinely emits whitespace-only content/reasoning on
// reasoning+tool-call turns, and a whitespace-only block would
// render as an empty block in the live view (the static view
// already drops such blocks in buildBlocks).
if !reasoningOpen && strings.TrimSpace(reasoningBuf.String()) != "" {
reasoningOpen = true
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})
}
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})
},
OnContentDelta: func(delta string) {
contentBuf.WriteString(delta)
if !contentOpen && strings.TrimSpace(contentBuf.String()) != "" {
contentOpen = true
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})
}
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})
},
OnTurnEnd: func() {
if reasoningOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})
logAppend("reasoning", reasoningBuf.String())
r.hub.Checkpoint(jobID)
}
if contentOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})
logAppend("content", contentBuf.String())
r.hub.Checkpoint(jobID)
}
reasoningOpen, contentOpen = false, false
reasoningBuf.Reset()
contentBuf.Reset()
},
...
}
}
```
Wait, but there's a problem. The current code resets `contentBuf` when `contentOpen` transitions from false to true (in `OnContentDelta`). With my change, I'm resetting it in `OnTurnEnd`. But what if there are multiple content blocks in a single turn? No, there's only one content block per turn (the assistant's message has one content field). So resetting in `OnTurnEnd` is fine.
But wait, what about the reasoning? Same thing, one reasoning block per turn. So resetting in `OnTurnEnd` is fine.
But actually, there's a subtle issue: the current code resets the buffer when the block opens, which means if a block is opened and closed within a turn, the buffer is reset for the next block. But since there's only one block per turn, this doesn't matter.
Hmm, wait, actually, I realize the current code's `contentBuf.Reset()` in `OnContentDelta` is a bit odd. It resets the buffer when the block opens. But the block opens on the first delta of the turn. So the buffer is reset at the start of the turn's content. That's equivalent to resetting in `OnTurnEnd` (for the previous turn). So my change is equivalent.
Actually, no. Let me re-examine. In the current code:
- Turn 1: content delta 1 → `contentOpen` false→true, `contentBuf.Reset()`, publish start. Deltas 2..n appended. Turn end → persist, `contentOpen` = false.
- Turn 2: content delta 1 → `contentOpen` false→true, `contentBuf.Reset()`, publish start. Etc.
So the buffer is reset at the start of each turn's content (when the block opens). In my change:
- Turn 1: content deltas appended (buffer not reset yet). Turn end → persist, `contentBuf.Reset()`.
- Turn 2: content deltas appended (buffer was reset at end of turn 1). Turn end → persist, reset.
So the buffer is reset at the end of each turn. Equivalent. Good.
But wait, there's a difference: in the current code, the buffer is reset when the block opens (on the first delta). In my change, the buffer is reset at the end of the turn. So if a turn has no content, the current code doesn't reset the buffer (since the block never opens), but my change does reset it (in `OnTurnEnd`). This is actually better, because it avoids accumulating stale content.
But wait, in the current code, if a turn has no content, the buffer retains the previous turn's content. But since `contentOpen` is false, the buffer is never read. So it's fine. In my change, the buffer is reset at the end of each turn, so it's always fresh. Better.
OK I'm confident the server-side fix is correct. Let me also add the client-side lazy creation as a safety net. Actually, let me reconsider whether to add the client-side fix.
If the server-side fix is in place, the server never sends `ContentStart` for whitespace-only content. So the client never creates an empty block. So the client-side fix is redundant.
But there's a scenario where the client-side fix would help: if a future server change or a different code path sends a `ContentStart` for an empty block. But that's speculative.
I'll add the client-side lazy creation as a small defensive measure. It's a 4-line change and makes the client robust. Actually, let me reconsider — if I add both, the code is more complex. Let me just do the server-side fix and keep the client as-is.
Wait, but actually, I realize the client-side fix is important for one reason: the hub's replay buffer. When a new subscriber connects, they get the buffered events. If the buffer contains a `ContentStart` for a block that turns out to be empty (e.g., due to a race or a bug), the client would create an empty block. The client-side lazy creation would prevent that.
But with the server-side fix, the server never sends a `ContentStart` for an empty block. So the buffer never contains such a start. So the client-side fix is redundant.
OK let me just do the server-side fix. It's the root cause fix, it's testable, and it's elegant.
Hmm, but wait. Let me reconsider the client-side approach once more, because I'm now second-guessing the server-side approach.
The server-side approach changes the event stream: `ContentDelta` can now arrive before `ContentStart`. Is that a problem? Let me think about the client's handling:
```js
case "content_delta":
if (contentBody) contentBody.textContent += ev.text;
break;
```
If `contentBody` is null (no start yet), the delta is skipped. So `ContentDelta` before `ContentStart` is handled gracefully. Good.
But is it semantically correct? The event stream would look like:
```
ContentDelta(" ")
ContentDelta("hello")
ContentStart
ContentDelta("hello") // wait, no
```
Wait, let me re-trace. In my server-side fix:
- Delta 1: `" "`. `contentBuf` = `" "`, TrimSpace = `""`, so no start. Publish `ContentDelta(" ")`.
- Delta 2: `"hello"`. `contentBuf` = `" hello"`, TrimSpace = `"hello"`, so publish `ContentStart`. Then publish `ContentDelta("hello")`.
So the event stream is:
```
ContentDelta(" ")
ContentStart
ContentDelta("hello")
ContentEnd
```
The client:
- `ContentDelta(" ")`: contentBody is null, skip.
- `ContentStart`: create block (empty).
- `ContentDelta("hello")`: append → `"hello"`.
- `ContentEnd`: trim → `"hello"`.
So the block shows `"hello"`. Correct!
But wait, the client missed the `" "` delta (which is fine, it's whitespace). And the block is created on `ContentStart`, which is after the first delta. So the block appears a bit late (on the second delta, not the first). That's a minor UX issue, but acceptable.
Actually, this is a concern: with the server-side fix, the block appears on the first non-whitespace delta, not the first delta. So if the content starts with whitespace, the block appears a bit late. But since we trim the leading whitespace anyway, the block's content is the same. So it's fine.
OK I'm confident. Let me go with the server-side fix.
Actually, wait. Let me reconsider one more time. I want to make sure I'm not missing a simpler explanation for the bug.
Let me re-read the issue: "When streaming a job, empty blocks like these will often follow a thinking block."
So the empty block follows a thinking (reasoning) block. The pattern is: thinking → empty → toolcall. This strongly suggests that on reasoning+toolcall turns, there's a whitespace-only content block.
But wait, why would there be a content block at all on a reasoning+toolcall turn? The model is producing reasoning and tool calls, not content. So the content should be empty.
The answer: the OpenAI-compatible server (llama.cpp) may emit a `content` field with a whitespace value (e.g., `" "` or `"\n"`) even when there's no actual content. This is a quirk of the server. So the content is whitespace-only, and our code treats it as a content block.
So the root cause is: the server emits whitespace-only content, and our live view renders it as an empty block.
The fix: don't render whitespace-only blocks in the live view (and optionally, don't persist them).
OK I'm confident in my analysis. Let me implement the server-side fix.
Actually, let me reconsider whether to also fix the client side. I think the cleanest solution is:
1. Server-side: lazy start (don't open a block for whitespace-only content).
2. Client-side: lazy creation (defensive, in case a start is sent for an empty block).
But since the server-side fix prevents the server from sending a start for an empty block, the client-side fix is redundant. So I'll just do the server-side fix.
Wait, but I realize there's a problem with only doing the server-side fix. What about existing jobs that are already running (with the old server code)? No, that's not a concern, since the fix is deployed atomically.
What about the hub's replay buffer for a job that was started before the fix? No, the fix is in the server code that publishes events. Once deployed, all new events follow the new logic. So it's fine.
OK let me implement the server-side fix.
Actually, hold on. Let me reconsider the client-side approach once more, because I think it might actually be the better fix. Here's why:
The server-side fix changes the event stream semantics (ContentDelta before ContentStart). This is a bit unusual and could confuse future developers. The client-side fix keeps the event stream semantics clean (ContentStart always before ContentDelta) and just makes the client not render empty blocks.
Also, the client-side fix is simpler and more surgical.
But the server-side fix is more "elegant" in the sense that it makes the server the single source of truth.
Hmm. I'm torn. Let me make a decision and stick with it.
I'll go with the **client-side lazy creation** as the primary fix, because:
1. It's the most surgical change (only touches the template JS).
2. It keeps the event stream semantics clean.
3. It directly addresses the bug (the live view rendering empty blocks).
4. It's robust to replay.
And I'll add a test that verifies the JS implements lazy creation.
Wait, but the testability concern. Let me think about how to test the client-side JS fix in Go.
The JS is embedded in the template. I can test that the rendered page contains the lazy-creation logic. But that's fragile (it checks for specific strings).
Alternatively, I could extract the "should this block be rendered" logic into a Go function and test that. But the live view is client-side, so it can't use a Go function.
Hmm. Let me reconsider.
Actually, you know what, I think the best approach is to do the **server-side fix** because it's testable in Go, and it's the root cause fix. The event stream semantics concern (ContentDelta before ContentStart) is minor and handled gracefully by the client.
Let me go with the server-side fix. Final decision.
Wait, I keep going back and forth. Let me just commit to the server-side fix and implement it. If I find issues, I'll adjust.
Actually, let me reconsider from a different angle. What would the "elegant solution" be that leon (the EM) had in mind? "implement an elegant solution for handling empty blocks".
An elegant solution would:
1. Fix the root cause.
2. Be consistent (live and static views agree).
3. Be testable.
4. Be minimal.
The server-side lazy start satisfies all of these:
1. Root cause: the server decides not to open a block for whitespace-only content.
2. Consistent: the server's decision matches `buildBlocks` (the static view's logic).
3. Testable: I can test `streamHooks` in Go.
4. Minimal: it's a focused change to `streamHooks`.
So the server-side fix is the elegant solution. Let me implement it.
Now, let me also consider: should I also avoid persisting whitespace-only lines? With the server-side lazy start, if the content is whitespace-only, `contentOpen` stays false, and we don't persist it. So we automatically avoid persisting whitespace-only lines.
Wait, let me re-examine. In my server-side fix:
```go
OnTurnEnd: func() {
if reasoningOpen {
...persist reasoning...
}
if contentOpen {
...persist content...
}
...reset...
},
```
If the content is whitespace-only, `contentOpen` is false, so we don't persist it. So we automatically avoid persisting whitespace-only lines.
So the server-side fix also fixes the data hygiene issue (no whitespace-only lines persisted).
So the server-side fix is a complete fix: it fixes the live view (no empty blocks) and the data hygiene (no whitespace-only lines persisted). And it's testable.
Alright, let me implement the server-side fix.
Let me now write the code.
First, let me re-read the current `streamHooks` to make sure I understand it fully.
```go
func (r *Runner) streamHooks(jobID string, logAppend func(stream, line string)) Hooks {
var reasoningBuf, contentBuf strings.Builder
reasoningOpen, contentOpen := false, false
return Hooks{
OnReasoningDelta: func(delta string) {
if !reasoningOpen {
reasoningOpen = true
reasoningBuf.Reset()
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})
}
reasoningBuf.WriteString(delta)
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})
},
OnContentDelta: func(delta string) {
if !contentOpen {
contentOpen = true
contentBuf.Reset()
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})
}
contentBuf.WriteString(delta)
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})
},
OnTurnEnd: func() {
if reasoningOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})
logAppend("reasoning", reasoningBuf.String())
r.hub.Checkpoint(jobID)
reasoningOpen = false
}
if contentOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})
logAppend("content", contentBuf.String())
r.hub.Checkpoint(jobID)
contentOpen = false
}
},
OnReroll: func(name, arguments string, attempt int) {
note := fmt.Sprintf("discarded turn %d/%d: %s was called with arguments that are not valid JSON, asking again: %s",
attempt, maxRerolls, name, arguments)
r.hub.Publish(jobID, livelog.Event{Type: livelog.System, Text: note})
logAppend("system", note)
r.hub.Checkpoint(jobID)
},
OnTool: func(name, arguments, result string, toolErr bool) {
r.hub.Publish(jobID, livelog.Event{
Type: livelog.Tool,
Name: name,
Arguments: arguments,
Result: result,
Error: toolErr,
})
line, err := json.Marshal(store.ToolLogEntry{Name: name, Arguments: arguments, Result: result, Error: toolErr})
if err != nil {
r.logger.Warn("failed to marshal tool log entry", "job", jobID, "error", err)
} else {
logAppend("tool", string(line))
}
r.hub.Checkpoint(jobID)
},
}
}
```
Now, my fix:
```go
func (r *Runner) streamHooks(jobID string, logAppend func(stream, line string)) Hooks {
var reasoningBuf, contentBuf strings.Builder
reasoningOpen, contentOpen := false, false
return Hooks{
OnReasoningDelta: func(delta string) {
reasoningBuf.WriteString(delta)
// A block is only worth opening (and persisting) if it carries
// real text. The model routinely emits whitespace-only
// reasoning/content on reasoning+tool-call turns, and a
// whitespace-only block would render as an empty block in the
// live view, while the static view (buildBlocks) drops it — so
// the two views would disagree. Open the block lazily, on the
// first non-whitespace delta, so the live view matches the
// static view.
if !reasoningOpen && strings.TrimSpace(reasoningBuf.String()) != "" {
reasoningOpen = true
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})
}
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})
},
OnContentDelta: func(delta string) {
contentBuf.WriteString(delta)
if !contentOpen && strings.TrimSpace(contentBuf.String()) != "" {
contentOpen = true
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})
}
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})
},
OnTurnEnd: func() {
if reasoningOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})
logAppend("reasoning", reasoningBuf.String())
r.hub.Checkpoint(jobID)
}
if contentOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})
logAppend("content", contentBuf.String())
r.hub.Checkpoint(jobID)
}
reasoningOpen, contentOpen = false, false
reasoningBuf.Reset()
contentBuf.Reset()
},
OnReroll: func(name, arguments string, attempt int) {
note := fmt.Sprintf("discarded turn %d/%d: %s was called with arguments that are not valid JSON, asking again: %s",
attempt, maxRerolls, name, arguments)
r.hub.Publish(jobID, livelog.Event{Type: livelog.System, Text: note})
logAppend("system", note)
r.hub.Checkpoint(jobID)
},
OnTool: func(name, arguments, result string, toolErr bool) {
r.hub.Publish(jobID, livelog.Event{
Type: livelog.Tool,
Name: name,
Arguments: arguments,
Result: result,
Error: toolErr,
})
line, err := json.Marshal(store.ToolLogEntry{Name: name, Arguments: arguments, Result: result, Error: toolErr})
if err != nil {
r.logger.Warn("failed to marshal tool log entry", "job", jobID, "error", err)
} else {
logAppend("tool", string(line))
}
r.hub.Checkpoint(jobID)
},
}
}
```
Wait, I moved the buffer reset from `OnContentDelta`/`OnReasoningDelta` (where it was on block open) to `OnTurnEnd`. Let me make sure this is correct.
In the original code, the buffer is reset when the block opens (on the first delta). In my code, the buffer is reset at the end of the turn. Let me verify this is equivalent.
Original:
- Turn 1: delta 1 → open block, reset buffer, append delta 1. Deltas 2..n appended. Turn end → persist, close block.
- Turn 2: delta 1 → open block, reset buffer, append delta 1. Etc.
My code:
- Turn 1: delta 1 → append to buffer (not reset yet). If non-whitespace, open block. Deltas 2..n appended. Turn end → persist, close block, reset buffer.
- Turn 2: delta 1 → append to buffer (was reset at end of turn 1). Etc.
So the buffer is reset at the end of each turn in my code, versus at the start of each block in the original. Since there's one block per turn, these are equivalent. Good.
But wait, there's a subtle difference: in the original code, if a turn has no content (no content deltas), the buffer is not reset (since the block never opens). In my code, the buffer is reset at the end of the turn regardless. So my code is cleaner (always fresh buffer).
But actually, in the original code, if a turn has no content, the buffer retains the previous turn's content. But since `contentOpen` is false, the buffer is never read. So it's fine. In my code, the buffer is reset, so it's fresh. Better.
OK the fix looks correct. Let me also consider: is there any issue with the `reasoningOpen`/`contentOpen` flags not being reset in the original code's `OnTurnEnd`? No, they are reset (set to false) in `OnTurnEnd`. In my code, I also reset them. Good.
Now, let me also reconsider the `OnReroll` hook. It publishes a system event and checkpoints. It doesn't touch the reasoning/content buffers. So it's fine.
But wait, there's a subtle issue with `OnReroll`. When a turn is rerolled (discarded), the reasoning/content for that turn are already in the log (persisted in `OnTurnEnd`). Wait, no. Let me re-examine.
In `runLoop`, the flow is:
1. Stream the completion (deltas fire `OnReasoningDelta`/`OnContentDelta`).
2. `OnTurnEnd` fires (persists reasoning/content).
3. Check for broken tool calls. If broken, `OnReroll` fires, and we `continue` (retry).
So `OnTurnEnd` fires before `OnReroll`. So the reasoning/content for the discarded turn are persisted in `OnTurnEnd`, and then `OnReroll` publishes a system note. So the log has: reasoning, content, system note (reroll), and then the next turn's reasoning, content, etc.
Wait, but the comment in `OnReroll` says "A discarded turn leaves its reasoning/content already in the log, so say why the next one repeats it." So yes, the discarded turn's reasoning/content are in the log.
So with my fix, if the discarded turn's content is whitespace-only, it's not persisted (since `contentOpen` is false). So the log has: reasoning (if non-whitespace), system note (reroll), and then the next turn. That's fine.
OK the fix is correct. Let me implement it.
Now, let me also add a test. I'll add a test for `streamHooks` that verifies:
1. A whitespace-only content block does not publish `ContentStart`/`ContentEnd` and is not persisted.
2. A non-whitespace content block publishes `ContentStart`/`ContentEnd` and is persisted.
3. A mixed (leading whitespace) content block publishes `ContentStart` on the first non-whitespace delta and persists the full content.
Let me look at how `streamHooks` is tested (if at all) and how to construct a `Runner` for testing.
Let me check the existing tests in agentrun.
Let me look at `loop_test.go` and `seed_test.go` to understand the test patterns.
Actually, `streamHooks` is a method on `*Runner`. To test it, I need a `Runner` instance. Let me see how to construct one. `NewRunner` requires a docker runtime, which is hard to set up in a unit test. Let me see if there's a way to construct a `Runner` without docker.
Actually, `streamHooks` only uses `r.hub` and `r.logger`. So I could construct a `Runner` with just those fields set. Let me see if I can do that.
```go
r := &Runner{
hub: livelog.NewHub(),
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
hooks := r.streamHooks("job-1", logAppend)
```
Yes, I can construct a `Runner` with just the fields I need. Let me do that.
Let me now write the test.
Actually, let me first check if there's an existing test file for run.go or streamHooks.
Let me look at the agentrun test files.
Let me check what test files exist in agentrun.
Let me look at the existing agentrun tests and the store to understand test patterns and how logs are persisted:
Tool
🔧 bash
{"command":"cd /project && ls -la internal/agentrun/ && echo \"=== loop_test.go (head) ===\" && head -60 internal/agentrun/loop_test.go"}
Arguments
{"command":"cd /project && ls -la internal/agentrun/ && echo \"=== loop_test.go (head) ===\" && head -60 internal/agentrun/loop_test.go"}
Result
total 100
drwxr-xr-x 2 user user 4096 Aug 26 11:11 .
drwxr-xr-x 11 user user 4096 Aug 26 11:11 ..
-rw-r--r-- 1 user user 1026 Aug 26 11:11 context.go
-rw-r--r-- 1 user user 5163 Aug 26 11:11 docker.go
-rw-r--r-- 1 user user 9325 Aug 26 11:11 docker_integration_test.go
-rw-r--r-- 1 user user 2752 Aug 26 11:11 forgejo.go
-rw-r--r-- 1 user user 5691 Aug 26 11:11 loop.go
-rw-r--r-- 1 user user 6922 Aug 26 11:11 loop_test.go
-rw-r--r-- 1 user user 2999 Aug 26 11:11 roster.go
-rw-r--r-- 1 user user 2203 Aug 26 11:11 roster_test.go
-rw-r--r-- 1 user user 14505 Aug 26 11:11 run.go
-rw-r--r-- 1 user user 6600 Aug 26 11:11 sandboxgit.go
-rw-r--r-- 1 user user 2232 Aug 26 11:11 sandboxgit_test.go
-rw-r--r-- 1 user user 4333 Aug 26 11:11 seed_test.go
-rw-r--r-- 1 user user 2284 Aug 26 11:11 system.md
=== loop_test.go (head) ===
package agentrun
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/abrander/zoo/internal/config"
"github.com/abrander/zoo/internal/llm"
"github.com/abrander/zoo/internal/tools"
)
// fakeToolsContext is enough for tools that only shell out.
type fakeToolsContext struct{}
func (fakeToolsContext) Exec(context.Context, string) (string, string, error) {
return "hi\n", "", nil
}
func (fakeToolsContext) Forgejo() tools.ForgejoActions { return nil }
func sseChunk(t *testing.T, choice llm.Choice) string {
t.Helper()
body, err := json.Marshal(llm.ChatCompletion{Choices: []llm.Choice{choice}})
if err != nil {
t.Fatal(err)
}
return "data: " + string(body) + "\n\n"
}
// sseServer serves one canned SSE response per request, and rejects any
// history containing a tool call whose arguments aren't valid JSON —
// the server-side validation that produced the 400 in production.
func sseServer(t *testing.T, turns []string, requests *[]llm.ChatCompletionRequest) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req llm.ChatCompletionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode request: %v", err)
}
for _, msg := range req.Messages {
for _, tc := range msg.ToolCalls {
var obj map[string]any
if err := json.Unmarshal([]byte(tc.FunctionCall.Arguments), &obj); err != nil {
http.Error(w, fmt.Sprintf("tool call function.arguments must be valid JSON: %q", tc.FunctionCall.Arguments), http.StatusBadRequest)
return
}
}
}
Thinking
Let me check the rest of loop_test.go and see how the hooks are being tested there.
Tool
🔧 bash
{"command":"cd /project && sed -n 60,220p internal/agentrun/loop_test.go"}
Arguments
{"command":"cd /project && sed -n 60,220p internal/agentrun/loop_test.go"}
Result
turn := len(*requests)
*requests = append(*requests, req)
if turn >= len(turns) {
t.Errorf("unexpected request %d", turn)
http.Error(w, "too many requests", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, turns[turn])
}))
}
func toolCallTurn(t *testing.T, calls ...llm.ToolCall) string {
t.Helper()
return sseChunk(t, llm.Choice{
Delta: llm.Message{Role: "assistant", ToolCalls: calls},
FinishReason: "tool_calls",
}) + "data: [DONE]\n\n"
}
func doneTurn(t *testing.T) string {
t.Helper()
return sseChunk(t, llm.Choice{
Delta: llm.Message{Role: "assistant", Content: "done"},
FinishReason: "stop",
}) + "data: [DONE]\n\n"
}
// brokenCall is the shape that killed job 6a36c082: the model leaked
// chat-template markup where the arguments object should have been.
func brokenCall() llm.ToolCall {
return llm.ToolCall{
Index: 1, Id: "call-2", Type: "function",
FunctionCall: llm.FunctionCall{Name: "grep_search", Arguments: "</parameter>\nmax_results>30"},
}
}
func bashCall() llm.ToolCall {
// A stray trailing brace: repairable without guessing.
return llm.ToolCall{
Index: 0, Id: "call-1", Type: "function",
FunctionCall: llm.FunctionCall{Name: "bash", Arguments: `{"command": "echo hi"}}`},
}
}
// A repairable turn runs as normal, and the arguments that reach both
// the tool and the history are valid JSON.
func TestRunLoopRepairsTrailingJunkInToolArguments(t *testing.T) {
var requests []llm.ChatCompletionRequest
srv := sseServer(t, []string{toolCallTurn(t, bashCall()), doneTurn(t)}, &requests)
defer srv.Close()
var called []string
messages, err := runLoop(context.Background(), llm.NewClient(config.LLM{OpenAI: srv.URL, Model: "test"}),
fakeToolsContext{}, []llm.Message{{Role: "user", Content: "go"}}, Hooks{
OnTool: func(name, arguments, result string, _ bool) {
called = append(called, name+" "+arguments+" -> "+result)
},
OnReroll: func(string, string, int) {
t.Error("a repairable turn must not be discarded")
},
})
if err != nil {
t.Fatalf("runLoop: %v", err)
}
if len(called) != 1 || called[0] != `bash {"command":"echo hi"} -> hi`+"\n" {
t.Fatalf("tool calls = %q", called)
}
if got := messages[len(messages)-1].Content; got != "done" {
t.Errorf("final message = %q, want %q", got, "done")
}
}
// An unparsable tool call takes its whole turn with it: nothing runs,
// nothing is recorded, and the retry request is identical to the first.
func TestRunLoopRerollsTurnWithBrokenToolCall(t *testing.T) {
var requests []llm.ChatCompletionRequest
// The broken call shares a turn with a perfectly good one, which
// must not run: the turn it belongs to never reaches the history.
srv := sseServer(t, []string{
toolCallTurn(t, bashCall(), brokenCall()),
doneTurn(t),
}, &requests)
defer srv.Close()
var called []string
var rerolls []string
messages, err := runLoop(context.Background(), llm.NewClient(config.LLM{OpenAI: srv.URL, Model: "test"}),
fakeToolsContext{}, []llm.Message{{Role: "user", Content: "go"}}, Hooks{
OnTool: func(name, _, _ string, _ bool) {
called = append(called, name)
},
OnReroll: func(name, arguments string, attempt int) {
rerolls = append(rerolls, fmt.Sprintf("%d %s %s", attempt, name, arguments))
},
})
if err != nil {
t.Fatalf("runLoop: %v", err)
}
if len(called) != 0 {
t.Errorf("discarded turn ran tools: %q", called)
}
want := []string{"1 grep_search </parameter>\nmax_results>30"}
if !reflect.DeepEqual(rerolls, want) {
t.Errorf("rerolls = %q, want %q", rerolls, want)
}
if len(requests) != 2 {
t.Fatalf("expected 2 requests, got %d", len(requests))
}
// The discarded turn left no trace, so the retry asks exactly the
// same question — which is only useful because no seed is pinned.
if !reflect.DeepEqual(requests[0].Messages, requests[1].Messages) {
t.Errorf("retry history differs:\n%+v\n%+v", requests[0].Messages, requests[1].Messages)
}
if requests[1].Seed != 0 {
t.Errorf("request pins seed %d; rerolling can't resample", requests[1].Seed)
}
if got := messages[len(messages)-1].Content; got != "done" {
t.Errorf("final message = %q, want %q", got, "done")
}
}
// A model that keeps mangling the same call must not burn the whole run
// deadline on it.
func TestRunLoopGivesUpAfterMaxRerolls(t *testing.T) {
turns := make([]string, maxRerolls+1)
for i := range turns {
turns[i] = toolCallTurn(t, brokenCall())
}
var requests []llm.ChatCompletionRequest
srv := sseServer(t, turns, &requests)
defer srv.Close()
_, err := runLoop(context.Background(), llm.NewClient(config.LLM{OpenAI: srv.URL, Model: "test"}),
fakeToolsContext{}, []llm.Message{{Role: "user", Content: "go"}}, Hooks{})
if err == nil {
t.Fatal("expected an error once the rerolls are exhausted")
}
if !strings.Contains(err.Error(), "grep_search") || !strings.Contains(err.Error(), "not valid JSON") {
Thinking
Now I have a good understanding of the test patterns. Let me also check the store to understand how logs are persisted and how `TailLogs` works.
Let me look at the store's `AppendLog` and `TailLogs`.
Now let me look at the store to understand log persistence:
Tool
🔧 bash
{"command":"cd /project && grep -n \"func (s \\*Store)\" internal/store/store.go && echo \"=== AppendLog/TailLogs ===\" && sed -n '/func (s \\*Store) AppendLog/,/^}/p' internal/store/store.go && sed -n '/func (s \\*Store) TailLogs/,/^}/p' internal/store/store.go"}
Arguments
{"command":"cd /project && grep -n \"func (s \\*Store)\" internal/store/store.go && echo \"=== AppendLog/TailLogs ===\" && sed -n '/func (s \\*Store) AppendLog/,/^}/p' internal/store/store.go && sed -n '/func (s \\*Store) TailLogs/,/^}/p' internal/store/store.go"}
Result
43:func (s *Store) Close() error {
50:func (s *Store) MarkSeen(ctx context.Context, id string) (isNew bool, err error) {
75:func (s *Store) SyncAssignees(ctx context.Context, issueID int64, assignees []string) (added []string, err error) {
147:func (s *Store) GetWatermark(ctx context.Context, key string) (string, error) {
162:func (s *Store) SetWatermark(ctx context.Context, key, value string) error {
200:func (s *Store) CreateJob(ctx context.Context, j Job) error {
226:func (s *Store) ReapOrphanedJobs(ctx context.Context) (int64, error) {
243:func (s *Store) MarkJobStarted(ctx context.Context, id string) error {
256:func (s *Store) MarkJobFinished(ctx context.Context, id string, status JobStatus, errMsg string) error {
268:func (s *Store) GetJob(ctx context.Context, id string) (Job, error) {
284:func (s *Store) ListJobs(ctx context.Context, limit int) ([]Job, error) {
327:func (s *Store) AppendLog(ctx context.Context, jobID, stream, line string) error {
340:func (s *Store) TailLogs(ctx context.Context, jobID string, afterSeq int64) ([]LogLine, error) {
=== AppendLog/TailLogs ===
func (s *Store) AppendLog(ctx context.Context, jobID, stream, line string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO job_logs (job_id, seq, stream, line, ts)
VALUES (?, COALESCE((SELECT MAX(seq) + 1 FROM job_logs WHERE job_id = ?), 0), ?, ?, ?)`,
jobID, jobID, stream, line, time.Now().UTC())
if err != nil {
return fmt.Errorf("append log: %w", err)
}
return nil
}
func (s *Store) TailLogs(ctx context.Context, jobID string, afterSeq int64) ([]LogLine, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT seq, stream, line, ts FROM job_logs WHERE job_id = ? AND seq > ? ORDER BY seq ASC`,
jobID, afterSeq)
if err != nil {
return nil, fmt.Errorf("tail logs: %w", err)
}
defer rows.Close()
var lines []LogLine
for rows.Next() {
var l LogLine
if err := rows.Scan(&l.Seq, &l.Stream, &l.Line, &l.TS); err != nil {
return nil, fmt.Errorf("tail logs: %w", err)
}
lines = append(lines, l)
}
return lines, rows.Err()
}
Thinking
Now I have a complete understanding. Let me confirm my analysis of the bug and the fix.
**Root cause:**
When the model produces a reasoning + tool-call turn, the OpenAI-compatible server may emit a whitespace-only `content` field (e.g., `" "` or `"\n"`). This causes:
1. `runLoop` fires `OnContentDelta(" ")` (because `len(" ") > len("")`).
2. `streamHooks.OnContentDelta` publishes `ContentStart` + `ContentDelta(" ")`.
3. The dashboard's JS creates a `block-content` div on `content_start` and appends `" "`.
4. On `OnTurnEnd`, `ContentEnd` is published and the whitespace content line is persisted.
5. The JS trims on `content_end` → the block remains in the DOM with an empty body → **empty block visible**.
6. On refresh (static view), `buildBlocks` skips whitespace-only content lines → the empty block disappears.
**Fix (server-side lazy block open):**
In `streamHooks`, only open a reasoning/content block (publish `*Start`) once the accumulated buffer carries real (non-whitespace) text. This way:
- Whitespace-only blocks never produce a `*Start` event → the client never creates an empty block.
- Whitespace-only blocks are never persisted → the DB stays clean.
- The live view matches the static view (`buildBlocks` drops whitespace-only blocks).
I also need to move the buffer reset to `OnTurnEnd` so each turn starts with a fresh buffer (since the reset was previously tied to block-open, which now happens lazily).
Let me now also reconsider: should I also add the client-side defensive fix? I decided the server-side fix is sufficient and elegant. But let me reconsider whether there's a scenario where the server-side fix is insufficient.
Actually, wait. Let me reconsider the client-side handling of `*Delta` before `*Start`. With the server-side fix, `ContentDelta` can arrive before `ContentStart`. The client handles this gracefully (skips deltas when body is null). So it's fine.
But there's a subtle issue: the client's `content_start` handler creates the block. With the server-side fix, `content_start` arrives on the first non-whitespace delta. So the block is created then. Good.
But what about the `follow()` call? After each event, `follow()` is called to scroll to the bottom. That's fine.
OK let me also reconsider: is there a risk that the server-side fix changes the behavior for legitimate content that starts with whitespace? Let me trace:
- Content = `" hello"` (leading spaces).
- Delta 1: `" "`. Buffer = `" "`, TrimSpace = `""`, no start. Publish `ContentDelta(" ")`.
- Delta 2: `"hello"`. Buffer = `" hello"`, TrimSpace = `"hello"`, publish `ContentStart`. Publish `ContentDelta("hello")`.
- Turn end: publish `ContentEnd`, persist `" hello"`.
Client:
- `ContentDelta(" ")`: body null, skip.
- `ContentStart`: create block.
- `ContentDelta("hello")`: append → `"hello"`.
- `ContentEnd`: trim → `"hello"`.
Static view: `TrimSpace(" hello")` = `"hello"`. Match!
So the block shows `"hello"` in both views. The leading spaces are dropped (trimmed). Correct.
Now, what if the content is `"hello "` (trailing spaces)?
- Delta 1: `"hello"`. Buffer = `"hello"`, TrimSpace = `"hello"`, publish start. Publish delta.
- Delta 2: `" "`. Buffer = `"hello "`. Publish delta.
- Turn end: publish end, persist `"hello "`.
Client:
- `ContentStart`: create block.
- `ContentDelta("hello")`: append → `"hello"`.
- `ContentDelta(" ")`: append → `"hello "`.
- `ContentEnd`: trim → `"hello"`.
Static: `TrimSpace("hello ")` = `"hello"`. Match!
Great, the fix is correct.
Now, let me also think about whether the server-side fix could cause a problem with the `OnReroll` flow. When a turn is rerolled, `OnTurnEnd` fires (persisting the reasoning/content), then `OnReroll` fires. With my fix, if the content is whitespace-only, it's not persisted. That's fine.
But wait, there's a subtle issue. In the reroll case, the discarded turn's reasoning/content are "already in the log" (per the comment). With my fix, if they're whitespace-only, they're not in the log. That's fine — the reroll note still explains why the next turn repeats.
OK I'm confident. Let me implement the server-side fix.
Actually, wait. Let me reconsider one more time whether I should also do the client-side fix. The reason: the issue is specifically about the live view showing empty blocks. The server-side fix prevents the server from sending start events for empty blocks. But what if there's a race condition or a different code path that sends a start event for an empty block? The client-side fix would be a safety net.
But I think the server-side fix is sufficient and clean. Adding the client-side fix would be redundant. Let me keep it minimal and just do the server-side fix.
Hmm, but actually, let me reconsider. The client-side fix is a small, defensive change that makes the client robust. And it directly addresses the "rendering" aspect of the bug. A senior developer might argue that the client should be defensive and not render empty blocks regardless of what the server sends.
But I think the cleanest solution is the server-side fix, because:
1. It's the root cause fix.
2. It's testable.
3. It makes the server the single source of truth.
4. It also fixes the data hygiene issue.
Let me go with the server-side fix. If the reviewers want the client-side fix too, they can ask.
Actually, you know what, let me reconsider the whole thing one more time. I want to make sure I'm implementing the most elegant and correct solution.
The core issue: live view shows empty blocks, static view doesn't. The fix should make them consistent.
The static view's rule (in `buildBlocks`): render a reasoning/content block iff `TrimSpace(text) != ""`.
For the live view to match, it should render a block iff the block's text (when complete) has `TrimSpace(text) != ""`.
The server-side lazy start implements this: the server only opens a block if it will have non-whitespace text. So the live view only renders blocks that the static view would render.
So the server-side fix is the correct, elegant solution.
Let me implement it now.
Let me write the new `streamHooks` function.
Actually, I realize I should double-check: is `strings` already imported in run.go? Let me check the imports.
From the run.go I read earlier:
```go
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
...
)
```
Yes, `strings` is imported. Good.
Now let me implement the fix.
Let me write the edit. I'll replace the `OnReasoningDelta`, `OnContentDelta`, and `OnTurnEnd` hooks.
Current:
```go
return Hooks{
OnReasoningDelta: func(delta string) {
if !reasoningOpen {
reasoningOpen = true
reasoningBuf.Reset()
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})
}
reasoningBuf.WriteString(delta)
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})
},
OnContentDelta: func(delta string) {
if !contentOpen {
contentOpen = true
contentBuf.Reset()
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})
}
contentBuf.WriteString(delta)
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})
},
OnTurnEnd: func() {
if reasoningOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})
logAppend("reasoning", reasoningBuf.String())
r.hub.Checkpoint(jobID)
reasoningOpen = false
}
if contentOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})
logAppend("content", contentBuf.String())
r.hub.Checkpoint(jobID)
contentOpen = false
}
},
```
New:
```go
return Hooks{
OnReasoningDelta: func(delta string) {
reasoningBuf.WriteString(delta)
// Open the block lazily, on the first delta that carries
// real text. The model routinely emits whitespace-only
// reasoning/content on reasoning+tool-call turns, and a
// whitespace-only block would render as an empty block in
// the live view while the static view (buildBlocks) drops
// it — so the two views would disagree. Gating the start on
// non-whitespace text keeps them in lockstep (and keeps the
// whitespace-only turn out of the persisted log too).
if !reasoningOpen && strings.TrimSpace(reasoningBuf.String()) != "" {
reasoningOpen = true
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})
}
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})
},
OnContentDelta: func(delta string) {
contentBuf.WriteString(delta)
if !contentOpen && strings.TrimSpace(contentBuf.String()) != "" {
contentOpen = true
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})
}
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})
},
OnTurnEnd: func() {
if reasoningOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})
logAppend("reasoning", reasoningBuf.String())
r.hub.Checkpoint(jobID)
}
if contentOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})
logAppend("content", contentBuf.String())
r.hub.Checkpoint(jobID)
}
// Each turn starts from a clean slate: the buffers were
// previously reset when a block opened, but with lazy open a
// whitespace-only turn never opens a block, so reset here
// instead to avoid leaking one turn's text into the next.
reasoningOpen, contentOpen = false, false
reasoningBuf.Reset()
contentBuf.Reset()
},
```
Wait, I need to double-check the buffer reset logic. In the original code, the buffer is reset when the block opens. In my code, the buffer is reset at the end of the turn. Let me make sure there's no issue.
Consider a turn with real content:
- Delta 1: `"hello"`. Buffer = `"hello"`, non-whitespace, open block, publish start. Publish delta.
- Delta 2: `" world"`. Buffer = `"hello world"`. Publish delta.
- Turn end: publish end, persist `"hello world"`, reset buffer.
Good.
Consider a turn with whitespace-only content:
- Delta 1: `" "`. Buffer = `" "`, whitespace, no open. Publish delta.
- Turn end: contentOpen is false, so no persist. Reset buffer.
Good, the buffer is reset, so the next turn starts fresh.
Consider two turns, first whitespace-only, second real:
- Turn 1: Delta 1: `" "`. Buffer = `" "`, no open. Turn end: no persist, reset buffer (buffer now empty).
- Turn 2: Delta 1: `"hello"`. Buffer = `"hello"` (was reset), non-whitespace, open block, publish start. Publish delta. Turn end: persist `"hello"`, reset.
Good, no leakage.
Now, what about the case where the buffer is NOT reset between turns in the original code? In the original code, if turn 1 has real content and turn 2 has no content, the buffer retains turn 1's content. But since contentOpen is false after turn 1's OnTurnEnd, the buffer is not read. In my code, the buffer is reset at the end of turn 1, so it's fresh. Better.
OK the fix is correct. Let me implement it.
Now, let me also think about whether I need to update the client-side JS. With the server-side fix, the server never sends a start event for whitespace-only blocks. So the client never creates an empty block. So the client-side JS doesn't need to change.
But wait, there's a subtle issue. The client's `content_start` handler creates the block. With the server-side fix, `content_start` arrives on the first non-whitespace delta. So the block is created then. But the client's `content_delta` handler appends `ev.text`. The first delta that triggers the start is also published as a delta. So the client creates the block on start, then appends the delta. Let me verify the order.
In my server code:
```go
OnContentDelta: func(delta string) {
contentBuf.WriteString(delta)
if !contentOpen && strings.TrimSpace(contentBuf.String()) != "" {
contentOpen = true
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})
}
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})
},
```
So for the first non-whitespace delta, the server publishes `ContentStart` first, then `ContentDelta`. So the client receives:
1. `ContentStart` → create block (empty).
2. `ContentDelta(delta)` → append delta.
So the block shows the delta. Correct.
But what about the earlier whitespace deltas? They were published as `ContentDelta` without a start. The client skips them (body is null). So they're not shown. That's fine (they're whitespace).
So the block shows only the non-whitespace content (plus any trailing whitespace that's trimmed). Correct.
Great, the fix is correct and the client doesn't need to change.
Now, let me also reconsider: is there any issue with the `follow()` scroll behavior? No, `follow()` is called after each event, regardless of whether a block was created. So it's fine.
Alright, let me implement the server-side fix and add a test.
Let me write the edit to run.go.
Actually, let me reconsider whether I should also add a client-side defensive fix. I've been going back and forth, but let me make a final decision.
I'll do the server-side fix as the primary fix. I'll also add a small client-side defensive fix (remove the block on end if it's empty) as a safety net. This gives defense in depth and ensures the live view never shows empty blocks, even if the server sends a start for an empty block (e.g., due to a future regression).
Wait, but if I add the client-side fix, I need to be careful not to break the existing behavior. Let me think.
The client-side fix: on `content_end`/`reasoning_end`, if the trimmed text is empty, remove the block from the DOM.
```js
case "content_end":
if (contentBody) {
contentBody.textContent = contentBody.textContent.trim();
if (!contentBody.textContent) contentBody.parentElement.remove();
contentBody = null;
}
break;
```
This is a safe, defensive change. It only removes the block if it's empty after trimming. With the server-side fix, this never triggers (since the server doesn't send a start for empty blocks). But it's a safety net.
Hmm, but actually, if the server-side fix is in place, the client-side fix is redundant. And adding redundant code is not "elegant". Let me just do the server-side fix.
Final decision: server-side fix only. It's the root cause fix, it's testable, it's elegant, and it makes the live and static views consistent.
Let me implement it now.
Actually, wait. I want to reconsider whether the server-side fix is truly sufficient, or if there's a scenario where the client still shows an empty block.
Scenario: The server sends `ContentStart` (because the content is non-whitespace), then the content deltas are all... no, if the content is non-whitespace, the block has real text. So no empty block.
Scenario: The server sends `ContentStart` but no deltas follow. Can that happen? In my server code, `ContentStart` is only published when a delta arrives (in `OnContentDelta`). And the same delta is published as `ContentDelta` right after. So `ContentStart` is always followed by at least one `ContentDelta`. So the block always has at least one delta. And since the start was triggered by a non-whitespace buffer, the delta is non-whitespace (or the buffer was already non-whitespace from a previous delta). So the block has real text. No empty block.
Wait, let me reconsider. The start is triggered when `TrimSpace(contentBuf.String()) != ""`. The buffer includes the current delta and all previous deltas. So if the buffer is non-whitespace, at least one delta (current or previous) is non-whitespace. If it's a previous delta, then the start should have been triggered earlier (on that previous delta). So the start is triggered on the first delta that makes the buffer non-whitespace. That delta itself is non-whitespace (or the buffer was already non-whitespace, which means a previous delta was non-whitespace and should have triggered the start). So the start is always triggered on a non-whitespace delta. And that delta is published as a `ContentDelta`. So the block has at least one non-whitespace delta. No empty block.
Great, the server-side fix is sufficient.
Let me implement it now.
Let me make the edit to run.go.
I now have a complete understanding of the bug. Let me trace through the root cause:
**Root cause:** When the model produces a reasoning + tool-call turn, the OpenAI-compatible server often emits a whitespace-only `content` field (e.g. `" "` or `"\n"`). This triggers `OnContentDelta`, which immediately publishes `ContentStart` and opens a block in the live view. When the turn ends, the whitespace content is persisted. The live view renders it as an **empty block** (the JS trims it to `""` but leaves the `<div>` in the DOM), while the static view (`buildBlocks`) **drops** whitespace-only blocks — so the two views disagree.
The elegant fix is to make the server the single source of truth: **open a reasoning/content block lazily, only once it carries real (non-whitespace) text**. This matches `buildBlocks` exactly, keeps whitespace-only turns out of the persisted log, and is testable in Go.
Let me verify the current imports and then make the change:
Tool
🔧 bash
{"command":"cd /project && sed -n '/^import (/,/^)/p' internal/agentrun/run.go"}
Arguments
{"command":"cd /project && sed -n '/^import (/,/^)/p' internal/agentrun/run.go"}
Result
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/abrander/zoo/internal/config"
"github.com/abrander/zoo/internal/forgejo"
"github.com/abrander/zoo/internal/livelog"
"github.com/abrander/zoo/internal/llm"
"github.com/abrander/zoo/internal/store"
)
Thinking
`strings` is already imported. Alright, let's make edits to `streamHooks` in run.go.
`strings` is already imported. Now let me make the fix to `streamHooks`:
Tool
🔧 edit_file
{"find":"\treturn Hooks{\n\t\tOnReasoningDelta: func(delta string) {\n\t\t\tif !reasoningOpen {\n\t\t\t\treasoningOpen = true\n\t\t\t\treasoningBuf.Reset()\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})\n\t\t\t}\n\n\t\t\treasoningBuf.WriteString(delta)\n\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})\n\t\t},\n\t\tOnContentDelta: func(delta string) {\n\t\t\tif !contentOpen {\n\t\t\t\tcontentOpen = true\n\t\t\t\tcontentBuf.Reset()\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})\n\t\t\t}\n\n\t\t\tcontentBuf.WriteString(delta)\n\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})\n\t\t},\n\t\tOnTurnEnd: func() {\n\t\t\tif reasoningOpen {\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})\n\t\t\t\tlogAppend(\"reasoning\", reasoningBuf.String())\n\t\t\t\tr.hub.Checkpoint(jobID)\n\t\t\t\treasoningOpen = false\n\t\t\t}\n\n\t\t\tif contentOpen {\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})\n\t\t\t\tlogAppend(\"content\", contentBuf.String())\n\t\t\t\tr.hub.Checkpoint(jobID)\n\t\t\t\tcontentOpen = false\n\t\t\t}\n\t\t},","path":"internal/agentrun/run.go","replace":"\treturn Hooks{\n\t\tOnReasoningDelta: func(delta string) {\n\t\t\treasoningBuf.WriteString(delta)\n\n\t\t\t// Open the block lazily, on the first delta that gives it\n\t\t\t// real text. The model routinely emits whitespace-only\n\t\t\t// reasoning/content on reasoning+tool-call turns, and a\n\t\t\t// whitespace-only block renders as an empty block in the\n\t\t\t// live view while the static view (buildBlocks) drops it —\n\t\t\t// so the two views would disagree. Gating the start on\n\t\t\t// non-whitespace text keeps them in lockstep, and keeps the\n\t\t\t// whitespace-only turn out of the persisted log as well.\n\t\t\tif !reasoningOpen && strings.TrimSpace(reasoningBuf.String()) != \"\" {\n\t\t\t\treasoningOpen = true\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})\n\t\t\t}\n\n\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})\n\t\t},\n\t\tOnContentDelta: func(delta string) {\n\t\t\tcontentBuf.WriteString(delta)\n\n\t\t\tif !contentOpen && strings.TrimSpace(contentBuf.String()) != \"\" {\n\t\t\t\tcontentOpen = true\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})\n\t\t\t}\n\n\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})\n\t\t},\n\t\tOnTurnEnd: func() {\n\t\t\tif reasoningOpen {\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})\n\t\t\t\tlogAppend(\"reasoning\", reasoningBuf.String())\n\t\t\t\tr.hub.Checkpoint(jobID)\n\t\t\t}\n\n\t\t\tif contentOpen {\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})\n\t\t\t\tlogAppend(\"content\", contentBuf.String())\n\t\t\t\tr.hub.Checkpoint(jobID)\n\t\t\t}\n\n\t\t\t// Each turn starts from a clean slate. The buffers were\n\t\t\t// previously reset when a block opened, but with lazy open a\n\t\t\t// whitespace-only turn never opens one, so reset here instead\n\t\t\t// to keep one turn's text from leaking into the next.\n\t\t\treasoningOpen, contentOpen = false, false\n\t\t\treasoningBuf.Reset()\n\t\t\tcontentBuf.Reset()\n\t\t},"}
Arguments
{"find":"\treturn Hooks{\n\t\tOnReasoningDelta: func(delta string) {\n\t\t\tif !reasoningOpen {\n\t\t\t\treasoningOpen = true\n\t\t\t\treasoningBuf.Reset()\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})\n\t\t\t}\n\n\t\t\treasoningBuf.WriteString(delta)\n\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})\n\t\t},\n\t\tOnContentDelta: func(delta string) {\n\t\t\tif !contentOpen {\n\t\t\t\tcontentOpen = true\n\t\t\t\tcontentBuf.Reset()\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})\n\t\t\t}\n\n\t\t\tcontentBuf.WriteString(delta)\n\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})\n\t\t},\n\t\tOnTurnEnd: func() {\n\t\t\tif reasoningOpen {\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})\n\t\t\t\tlogAppend(\"reasoning\", reasoningBuf.String())\n\t\t\t\tr.hub.Checkpoint(jobID)\n\t\t\t\treasoningOpen = false\n\t\t\t}\n\n\t\t\tif contentOpen {\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})\n\t\t\t\tlogAppend(\"content\", contentBuf.String())\n\t\t\t\tr.hub.Checkpoint(jobID)\n\t\t\t\tcontentOpen = false\n\t\t\t}\n\t\t},","path":"internal/agentrun/run.go","replace":"\treturn Hooks{\n\t\tOnReasoningDelta: func(delta string) {\n\t\t\treasoningBuf.WriteString(delta)\n\n\t\t\t// Open the block lazily, on the first delta that gives it\n\t\t\t// real text. The model routinely emits whitespace-only\n\t\t\t// reasoning/content on reasoning+tool-call turns, and a\n\t\t\t// whitespace-only block renders as an empty block in the\n\t\t\t// live view while the static view (buildBlocks) drops it —\n\t\t\t// so the two views would disagree. Gating the start on\n\t\t\t// non-whitespace text keeps them in lockstep, and keeps the\n\t\t\t// whitespace-only turn out of the persisted log as well.\n\t\t\tif !reasoningOpen && strings.TrimSpace(reasoningBuf.String()) != \"\" {\n\t\t\t\treasoningOpen = true\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})\n\t\t\t}\n\n\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})\n\t\t},\n\t\tOnContentDelta: func(delta string) {\n\t\t\tcontentBuf.WriteString(delta)\n\n\t\t\tif !contentOpen && strings.TrimSpace(contentBuf.String()) != \"\" {\n\t\t\t\tcontentOpen = true\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})\n\t\t\t}\n\n\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})\n\t\t},\n\t\tOnTurnEnd: func() {\n\t\t\tif reasoningOpen {\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})\n\t\t\t\tlogAppend(\"reasoning\", reasoningBuf.String())\n\t\t\t\tr.hub.Checkpoint(jobID)\n\t\t\t}\n\n\t\t\tif contentOpen {\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})\n\t\t\t\tlogAppend(\"content\", contentBuf.String())\n\t\t\t\tr.hub.Checkpoint(jobID)\n\t\t\t}\n\n\t\t\t// Each turn starts from a clean slate. The buffers were\n\t\t\t// previously reset when a block opened, but with lazy open a\n\t\t\t// whitespace-only turn never opens one, so reset here instead\n\t\t\t// to keep one turn's text from leaking into the next.\n\t\t\treasoningOpen, contentOpen = false, false\n\t\t\treasoningBuf.Reset()\n\t\t\tcontentBuf.Reset()\n\t\t},"}
Result
1 occurrences replaced
Thinking
Next, let's also consider the client side. With the server-side fix, the server will no longer send `*Start` events for whitespace-only blocks. But there's one edge case to consider: a subscriber that connects mid-stream (via replay) might receive `*Delta` events before the `*Start` event. The client handles this properly (skipping deltas when body is null).
But wait — there's another subtle issue on the client side. Let me re-check the client JS for the case where the server sends a `*Start` for a block, and then... no, actually, with the server-side fix, the start is only sent when the buffer is non-whitespace, and the start is always followed by a non-whitespace delta. So the block will always have real text. No empty block will be created.
But just to be safe, let me reconsider whether we should add a defensive client-side fix.
Actually, I realized there's one more thing to consider. Let me re-examine the client JS more carefully:
```js
case "reasoning_start":
reasoningBody = newBlock("reasoning", "Thinking");
break;
case "reasoning_delta":
if (reasoningBody) reasoningBody.textContent += ev.text;
break;
case "reasoning_end":
if (reasoningBody) reasoningBody.textContent = reasoningBody.textContent.trim();
reasoningBody = null;
break;
```
With the server-side fix, `reasoning_start` is only sent when the reasoning is non-whitespace. So `newBlock` creates a block that will have real text. Good.
But what if there's a bug where `reasoning_start` is sent but the reasoning ends up being empty? That can't happen with the server-side fix. So the client is safe.
Alright, the server-side fix is sufficient. But let me reconsider whether we should add a defensive client-side fix for robustness.
Actually, I've decided: the server-side fix is the elegant, root-cause solution. I'll stick with just that. Adding a redundant client-side fix would be over-engineering.
But wait — let me reconsider. There's one more scenario I should think about: what if the content is non-whitespace, but the first delta is whitespace and the block is created on the second delta? In that case, the client creates the block on the second delta (when `content_start` is received). The first (whitespace) delta was skipped. So the block shows the content from the second delta onward. That's correct (the leading whitespace is trimmed anyway).
But there's a subtle UX issue: the block appears on the second delta, not the first. So there's a slight delay in the block appearing. But that's acceptable, and it's the correct behavior (we don't want to show an empty block).
Alright, the server-side fix is correct and sufficient.
Now, let me build the project to make sure it compiles, and then add a test.
Let me build first.
Now let me verify it compiles and run the existing tests:
Tool
🔧 bash
{"command":"cd /project && go build ./... 2>&1 | head -30 && echo \"BUILD_EXIT=$?\""}
Arguments
{"command":"cd /project && go build ./... 2>&1 | head -30 && echo \"BUILD_EXIT=$?\""}
Result
go: downloading github.com/docker/docker v28.5.2+incompatible
go: downloading github.com/hashicorp/hcl/v2 v2.24.0
go: downloading codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0
go: downloading github.com/google/uuid v1.6.0
go: downloading modernc.org/sqlite v1.56.0
go: downloading github.com/docker/go-connections v0.8.1
go: downloading github.com/docker/go-units v0.5.0
go: downloading github.com/moby/docker-image-spec v1.3.1
go: downloading github.com/opencontainers/image-spec v1.1.1
go: downloading github.com/containerd/errdefs v1.0.0
go: downloading github.com/containerd/errdefs/pkg v0.3.0
go: downloading github.com/distribution/reference v0.6.0
go: downloading github.com/opencontainers/go-digest v1.0.0
go: downloading github.com/pkg/errors v0.9.1
go: downloading go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0
go: downloading go.opentelemetry.io/otel/trace v1.45.0
go: downloading go.opentelemetry.io/otel v1.45.0
go: downloading github.com/42wim/httpsig v1.2.3
go: downloading github.com/go-fed/httpsig v1.1.0
go: downloading github.com/hashicorp/go-version v1.7.0
go: downloading golang.org/x/crypto v0.39.0
go: downloading github.com/felixge/httpsnoop v1.1.0
go: downloading go.opentelemetry.io/otel/metric v1.45.0
go: downloading github.com/agext/levenshtein v1.2.1
go: downloading github.com/apparentlymart/go-textseg/v15 v15.0.0
go: downloading github.com/mitchellh/go-wordwrap v1.0.1
go: downloading github.com/zclconf/go-cty v1.16.3
go: downloading github.com/go-logr/logr v1.4.4
go: downloading golang.org/x/sys v0.47.0
go: downloading github.com/google/go-cmp v0.7.0
BUILD_EXIT=0
Thinking
The build succeeded. Next, I'll run the existing tests to make sure nothing is broken.
Build passes. Let me run the existing test suite:
Tool
🔧 bash
{"command":"cd /project && go test ./internal/agentrun/ ./internal/web/ ./internal/livelog/ 2>&1 | tail -20"}
Arguments
{"command":"cd /project && go test ./internal/agentrun/ ./internal/web/ ./internal/livelog/ 2>&1 | tail -20"}
Result
go: downloading github.com/go-logr/stdr v1.2.2
go: downloading go.opentelemetry.io/auto/sdk v1.2.1
go: downloading github.com/cespare/xxhash/v2 v2.3.0
go: downloading golang.org/x/text v0.40.0
go: downloading modernc.org/libc v1.74.4
go: downloading github.com/dustin/go-humanize v1.0.1
go: downloading modernc.org/mathutil v1.7.1
go: downloading modernc.org/memory v1.11.0
go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec
ok github.com/abrander/zoo/internal/agentrun 0.006s
ok github.com/abrander/zoo/internal/web 1.174s
ok github.com/abrander/zoo/internal/livelog 0.052s
Thinking
All existing tests pass. Now let me add a test for the fix. I'll create a test that verifies the `streamHooks` behavior:
1. A whitespace-only content block does NOT publish `ContentStart`/`ContentEnd` and is NOT persisted.
2. A non-whitespace content block publishes `ContentStart`/`ContentEnd` and IS persisted.
3. A mixed (leading whitespace) content block publishes `ContentStart` on the first non-whitespace delta and persists the full content.
4. A whitespace-only turn doesn't leak into the next turn's buffer.
To test `streamHooks`, I need a `Runner` instance. `streamHooks` only uses `r.hub` and `r.logger`. So I can construct a `Runner` with just those fields.
Let me write the test. I'll add it to a new file `run_test.go` in the agentrun package.
Let me think about how to capture the published events. I can subscribe to the hub before calling the hooks, and read the events from the channel. But the hub's `Subscribe` returns a channel that I need to read from. Since `Publish` is non-blocking and fans out to subscribers, I can subscribe, then call the hooks, then read from the channel.
But there's a timing issue: `Publish` sends to the subscriber's channel (buffered, size 256). So if I subscribe first, then call the hooks, the events will be in the channel's buffer. I can then read them.
Let me write a helper to collect events:
```go
func collectEvents(t *testing.T, hub *livelog.Hub, jobID string, fn func()) []livelog.Event {
t.Helper()
buffered, ch, cancel := hub.Subscribe(jobID)
defer cancel()
// buffered is empty at subscribe time (nothing published yet)
fn()
// Now read all events from ch (they're buffered)
var events []livelog.Event
events = append(events, buffered...)
drain:
for {
select {
case ev, ok := <-ch:
if !ok {
break drain
}
events = append(events, ev)
default:
break drain
}
}
return events
}
```
Wait, but `Subscribe` returns the currently buffered events plus a channel for future events. Since I subscribe before publishing, `buffered` is empty. Then I call `fn()` which publishes events. Those events go into the channel `ch` (buffered, size 256). So I can drain `ch` to get them.
But there's a subtlety: `Publish` fans out to all subscribers. If there are no other subscribers, the events go into `ch`. Since `ch` has buffer 256, and I'm publishing fewer than 256 events, they'll all be in the buffer. So I can drain them.
Let me write the test.
Actually, let me reconsider. The `logAppend` function is passed to `streamHooks`. I can capture the persisted lines with a closure:
```go
var persisted []struct{ stream, line string }
logAppend := func(stream, line string) {
persisted = append(persisted, struct{ stream, line string}{stream, line})
}
```
Then I can check what was persisted.
Let me write the test now.
Let me create `run_test.go`:
```go
package agentrun
import (
"io"
"log/slog"
"testing"
"github.com/abrander/zoo/internal/livelog"
)
// streamHooksTestRunner builds a Runner with just the fields streamHooks
// needs (hub + logger), so the test doesn't have to stand up Docker.
func streamHooksTestRunner(t *testing.T) (*Runner, *livelog.Hub) {
t.Helper()
hub := livelog.NewHub()
return &Runner{hub: hub, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}, hub
}
// collectEvents subscribes to jobID, runs fn (which publishes events),
// and returns everything published in order.
func collectEvents(t *testing.T, hub *livelog.Hub, jobID string, fn func()) []livelog.Event {
t.Helper()
buffered, ch, cancel := hub.Subscribe(jobID)
defer cancel()
fn()
events := append([]livelog.Event(nil), buffered...)
drain:
for {
select {
case ev, ok := <-ch:
if !ok {
break drain
}
events = append(events, ev)
default:
break drain
}
}
return events
}
// A whitespace-only content block (the model's habit on reasoning +
// tool-call turns) must not open a block: no start/end events, and
// nothing persisted. This is what made the live view show an empty
// block while the static view dropped it (issue #62).
func TestStreamHooksDropsWhitespaceOnlyBlocks(t *testing.T) {
r, hub := streamHooksTestRunner(t)
var persisted []string
hooks := r.streamHooks("job-1", func(stream, line string) {
persisted = append(persisted, stream+":"+line)
})
events := collectEvents(t, hub, "job-1", func() {
// A reasoning + tool-call turn where the server emits a
// whitespace-only content field.
hooks.OnReasoningDelta("thinking hard")
hooks.OnContentDelta(" ")
hooks.OnTurnEnd()
})
// The reasoning block opened and closed normally.
// The content block never opened: no ContentStart / ContentEnd.
var types []livelog.Type
for _, ev := range events {
types = append(types, ev.Type)
}
for _, want := range []livelog.Type{livelog.ReasoningStart, livelog.ReasoningDelta, livelog.ReasoningEnd} {
if !contains(types, want) {
t.Fatalf("expected a %s event, got %v", want, types)
}
}
for _, absent := range []livelog.Type{livelog.ContentStart, livelog.ContentEnd} {
if contains(types, absent) {
t.Fatalf("did not expect a %s event for whitespace-only content, got %v", absent, types)
}
}
// Only the reasoning line was persisted; the whitespace content was
// dropped.
if len(persisted) != 1 || persisted[0] != "reasoning:thinking hard" {
t.Fatalf("persisted = %q, want only the reasoning line", persisted)
}
}
// A content block with leading whitespace opens on the first real
// delta, and the full (untrimmed) text is persisted — the static view
// trims it for display, so the two views agree.
func TestStreamHooksOpensBlockOnFirstRealText(t *testing.T) {
r, hub := streamHooksTestRunner(t)
var persisted []string
hooks := r.streamHooks("job-1", func(stream, line string) {
persisted = append(persisted, stream+":"+line)
})
var startAfter int
events := collectEvents(t, hub, "job-1", func() {
hooks.OnContentDelta(" ")
hooks.OnContentDelta("hello")
hooks.OnContentDelta(" world")
hooks.OnTurnEnd()
})
// ContentStart must come after the leading-whitespace delta, i.e.
// on the first delta that carries real text.
for i, ev := range events {
if ev.Type == livelog.ContentStart {
startAfter = i
break
}
}
if startAfter == 0 {
t.Fatalf("ContentStart fired on the leading-whitespace delta: %v", events)
}
// The full text (including the leading whitespace) is persisted;
// trimming is the renderer's job.
if len(persisted) != 1 || persisted[0] != "content: hello world" {
t.Fatalf("persisted = %q, want the full untrimmed content", persisted)
}
}
// A whitespace-only turn must not leak its text into the next turn's
// block: the buffers reset at turn end, not at block open.
func TestStreamHooksResetsBuffersBetweenTurns(t *testing.T) {
r, hub := streamHooksTestRunner(t)
var persisted []string
hooks := r.streamHooks("job-1", func(stream, line string) {
persisted = append(persisted, stream+":"+line)
})
collectEvents(t, hub, "job-1", func() {
// Turn 1: whitespace-only content.
hooks.OnContentDelta(" ")
hooks.OnTurnEnd()
// Turn 2: real content.
hooks.OnContentDelta("real")
hooks.OnTurnEnd()
})
if len(persisted) != 1 || persisted[0] != "content:real" {
t.Fatalf("persisted = %q, want only turn 2's content (no leak from turn 1)", persisted)
}
}
func contains(types []livelog.Type, want livelog.Type) bool {
for _, t := range types {
if t == want {
return true
}
}
return false
}
```
Wait, there's an issue with `collectEvents`. The `Subscribe` returns `buffered` (currently buffered events) and a channel. But I subscribe before calling `fn()`. So `buffered` is empty (nothing published yet for this job). Then `fn()` publishes events, which go into the channel `ch`. So I drain `ch`.
But wait, there's a subtlety: the hub's `get` creates a stream for the job if it doesn't exist. And `Subscribe` reads `s.buf` (the replay buffer). Since nothing was published yet, `s.buf` is empty. So `buffered` is empty. Good.
Then `fn()` calls `Publish`, which appends to `s.buf` and fans out to subscribers (including my `ch`). So the events go into `ch`. I drain `ch`. Good.
But there's a potential issue: if `fn()` publishes more than 256 events, some would be dropped (since `ch` has buffer 256). But my tests publish fewer than 256 events, so it's fine.
Also, there's a potential race: `Publish` fans out to subscribers under the stream's mutex. And I'm reading from `ch` after `fn()` returns. Since `fn()` is synchronous and `Publish` is synchronous (it sends to the channel buffer), all events are in `ch` by the time `fn()` returns. So draining `ch` after `fn()` is safe. No race.
Wait, but is `Publish` synchronous? Let me re-check:
```go
func (h *Hub) Publish(jobID string, ev Event) {
s := h.get(jobID)
s.mu.Lock()
defer s.mu.Unlock()
s.buf = append(s.buf, ev)
if len(s.buf) > maxBufferedEvents {
s.buf = s.buf[len(s.buf)-maxBufferedEvents:]
}
for ch := range s.subs {
select {
case ch <- ev:
default:
}
}
}
```
Yes, `Publish` is synchronous. It sends to the channel buffer (non-blocking, drops if full). So by the time `fn()` returns, all events are in `ch`'s buffer. So draining `ch` after `fn()` is safe.
But wait, there's a subtlety with the `default` case: if the channel is full, the event is dropped. But my tests publish few events, so the channel (buffer 256) won't be full. So no drops.
OK the test is correct. Let me also double-check the `contains` helper name doesn't conflict with anything. Let me check if there's already a `contains` function in the agentrun package.
Actually, let me reconsider the test structure. I'm using `collectEvents` which subscribes, runs fn, and drains. But there's a subtlety: `Subscribe` returns a `cancel` function that I `defer`. When `cancel` is called (at the end of `collectEvents`), it unregisters and closes the channel. But I've already drained the channel, so closing it is fine.
Wait, but there's a potential issue: if I drain the channel with a `select` that has a `default` case, I might not drain all events if there's a timing issue. But since `Publish` is synchronous and I drain after `fn()` returns, all events are in the buffer. So the `select` will read them all until the buffer is empty, then hit `default` and break. Good.
Actually, let me reconsider. The `select` with `default` breaks as soon as the channel is empty. Since all events are in the buffer (synchronous publish), the channel won't be empty until I've read all events. So the `select` reads all events, then hits `default` and breaks. Good.
But wait, what if there's a race where the channel is temporarily empty but more events are coming? No, because `fn()` is synchronous and completes before I start draining. So all events are in the buffer. No race.
OK the test is correct. Let me write it.
Actually, let me reconsider whether I should test via the hub or directly. Testing via the hub is more integration-like and tests the actual event flow. That's good. Let me keep it.
But there's one concern: the `Runner` struct has unexported fields. Since my test is in the `agentrun` package (same package), I can access unexported fields. Good.
Let me also reconsider: do I need to set `r.store` or other fields? No, `streamHooks` only uses `r.hub` and `r.logger`. So I only need those.
Wait, let me double-check `streamHooks`:
```go
func (r *Runner) streamHooks(jobID string, logAppend func(stream, line string)) Hooks {
var reasoningBuf, contentBuf strings.Builder
reasoningOpen, contentOpen := false, false
return Hooks{
OnReasoningDelta: func(delta string) {
...
r.hub.Publish(jobID, ...)
},
...
OnTool: func(...) {
...
r.logger.Warn(...)
...
},
}
}
```
Yes, only `r.hub` and `r.logger` are used. So I only need those.
Let me write the test file now.
Actually, let me reconsider the test names and make them clear. Let me also add a test that verifies the full event sequence for a normal (non-whitespace) block, to make sure I didn't break the normal case.
Let me finalize the test file.
Actually, I realize I should double-check: in `TestStreamHooksDropsWhitespaceOnlyBlocks`, I call `hooks.OnReasoningDelta("thinking hard")` and `hooks.OnContentDelta(" ")`. The reasoning is non-whitespace, so it opens a block. The content is whitespace, so it doesn't. Let me verify the expected events:
- `OnReasoningDelta("thinking hard")`: reasoningBuf = "thinking hard", non-whitespace, open block, publish ReasoningStart. Publish ReasoningDelta("thinking hard").
- `OnContentDelta(" ")`: contentBuf = " ", whitespace, no open. Publish ContentDelta(" ").
- `OnTurnEnd()`: reasoningOpen is true, publish ReasoningEnd, persist "reasoning:thinking hard", checkpoint. contentOpen is false, no persist. Reset buffers.
So the events are: ReasoningStart, ReasoningDelta, ContentDelta, ReasoningEnd.
Wait, the order: OnReasoningDelta publishes ReasoningStart then ReasoningDelta. Then OnContentDelta publishes ContentDelta. Then OnTurnEnd publishes ReasoningEnd.
So the event order is: ReasoningStart, ReasoningDelta, ContentDelta, ReasoningEnd.
My test checks:
- Contains ReasoningStart, ReasoningDelta, ReasoningEnd. ✓
- Does not contain ContentStart, ContentEnd. ✓
- Persisted = ["reasoning:thinking hard"]. ✓
Good.
For `TestStreamHooksOpensBlockOnFirstRealText`:
- `OnContentDelta(" ")`: contentBuf = " ", whitespace, no open. Publish ContentDelta(" ").
- `OnContentDelta("hello")`: contentBuf = " hello", non-whitespace, open block, publish ContentStart. Publish ContentDelta("hello").
- `OnContentDelta(" world")`: contentBuf = " hello world". Publish ContentDelta(" world").
- `OnTurnEnd()`: contentOpen is true, publish ContentEnd, persist "content: hello world", checkpoint. Reset.
So the events are: ContentDelta(" "), ContentStart, ContentDelta("hello"), ContentDelta(" world"), ContentEnd.
My test checks:
- ContentStart is not at index 0 (it's at index 1). ✓
- Persisted = ["content: hello world"]. ✓
Good.
For `TestStreamHooksResetsBuffersBetweenTurns`:
- Turn 1: `OnContentDelta(" ")`: contentBuf = " ", whitespace, no open. Publish ContentDelta(" "). `OnTurnEnd()`: contentOpen is false, no persist. Reset buffers (contentBuf now empty).
- Turn 2: `OnContentDelta("real")`: contentBuf = "real" (was reset), non-whitespace, open block, publish ContentStart. Publish ContentDelta("real"). `OnTurnEnd()`: contentOpen is true, publish ContentEnd, persist "content:real", checkpoint. Reset.
So persisted = ["content:real"]. ✓
Good.
Now let me write the test file.
Actually, I realize there might be an issue with the `collectEvents` helper and the `buffered` return. Let me reconsider. When I call `hub.Subscribe(jobID)`, it returns `buffered` (the currently buffered events) and a channel. Since I subscribe before publishing, `buffered` is empty. But wait, what if the job already has buffered events from a previous test? No, each test creates a fresh hub (via `streamHooksTestRunner`), so the job has no buffered events. Good.
Actually, wait. In `TestStreamHooksResetsBuffersBetweenTurns`, I call `collectEvents` once, which subscribes, runs fn (both turns), and drains. So all events from both turns are in the channel. Good.
Let me write the test file now.
Hmm, actually, let me reconsider the `collectEvents` helper. There's a subtle issue: `Subscribe` returns a `cancel` function. I `defer cancel()`. But `cancel` closes the channel. If I've already drained the channel, closing it is fine. But if I haven't fully drained it, closing it would cause subsequent reads to return `ok=false`. But I drain fully (until `default`), so it's fine.
Wait, but there's a potential issue: after I break out of the drain loop (via `default`), the channel might still have events if there was a race. But there's no race (synchronous publish). So it's fine.
Let me write the test.
Actually, I want to make the test more robust. Instead of relying on the channel being fully drained, let me just drain until the channel is empty (which it will be after synchronous publish). The `select` with `default` does that. Good.
Let me write the test file.
All tests pass. Now let me add a focused test for the fix. Let me create a test file for `streamHooks`:
Tool
🔧 write_file
{"content":"package agentrun\n\nimport (\n\t\"io\"\n\t\"log/slog\"\n\t\"testing\"\n\n\t\"github.com/abrander/zoo/internal/livelog\"\n)\n\n// streamHooksTestRunner builds a Runner with just the fields streamHooks\n// touches (hub + logger), so the test doesn't have to stand up Docker.\nfunc streamHooksTestRunner(t *testing.T) (*Runner, *livelog.Hub) {\n\tt.Helper()\n\n\thub := livelog.NewHub()\n\n\treturn &Runner{hub: hub, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}, hub\n}\n\n// collectEvents subscribes to jobID, runs fn (which publishes events\n// synchronously), and returns everything published, in order.\nfunc collectEvents(t *testing.T, hub *livelog.Hub, jobID string, fn func()) []livelog.Event {\n\tt.Helper()\n\n\tbuffered, ch, cancel := hub.Subscribe(jobID)\n\tdefer cancel()\n\n\tfn()\n\n\tevents := append([]livelog.Event(nil), buffered...)\n\ndrain:\n\tfor {\n\t\tselect {\n\t\tcase ev, ok := <-ch:\n\t\t\tif !ok {\n\t\t\t\tbreak drain\n\t\t\t}\n\t\t\tevents = append(events, ev)\n\t\tdefault:\n\t\t\tbreak drain\n\t\t}\n\t}\n\n\treturn events\n}\n\nfunc hasType(events []livelog.Event, want livelog.Type) bool {\n\tfor _, ev := range events {\n\t\tif ev.Type == want {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// A whitespace-only content block — the model's habit on reasoning +\n// tool-call turns — must not open a block: no start/end events, and\n// nothing persisted. This is what made the live view show an empty\n// block while the static view (buildBlocks) dropped it (issue #62).\nfunc TestStreamHooksDropsWhitespaceOnlyBlocks(t *testing.T) {\n\tr, hub := streamHooksTestRunner(t)\n\n\tvar persisted []string\n\n\thooks := r.streamHooks(\"job-1\", func(stream, line string) {\n\t\tpersisted = append(persisted, stream+\":\"+line)\n\t})\n\n\tevents := collectEvents(t, hub, \"job-1\", func() {\n\t\t// A reasoning + tool-call turn where the server emits a\n\t\t// whitespace-only content field.\n\t\thooks.OnReasoningDelta(\"thinking hard\")\n\t\thooks.OnContentDelta(\" \")\n\t\thooks.OnTurnEnd()\n\t})\n\n\t// The reasoning block opened and closed normally.\n\tfor _, want := range []livelog.Type{livelog.ReasoningStart, livelog.ReasoningDelta, livelog.ReasoningEnd} {\n\t\tif !hasType(events, want) {\n\t\t\tt.Fatalf(\"expected a %s event, got %+v\", want, events)\n\t\t}\n\t}\n\t// The whitespace-only content block never opened.\n\tfor _, absent := range []livelog.Type{livelog.ContentStart, livelog.ContentEnd} {\n\t\tif hasType(events, absent) {\n\t\t\tt.Fatalf(\"did not expect a %s event for whitespace-only content, got %+v\", absent, events)\n\t\t}\n\t}\n\n\t// Only the reasoning line was persisted; the whitespace content was\n\t// dropped.\n\tif len(persisted) != 1 || persisted[0] != \"reasoning:thinking hard\" {\n\t\tt.Fatalf(\"persisted = %q, want only the reasoning line\", persisted)\n\t}\n}\n\n// A content block with leading whitespace opens on the first delta that\n// carries real text, and the full (untrimmed) text is persisted —\n// trimming is the renderer's job, so the live and static views agree.\nfunc TestStreamHooksOpensBlockOnFirstRealText(t *testing.T) {\n\tr, hub := streamHooksTestRunner(t)\n\n\tvar persisted []string\n\n\thooks := r.streamHooks(\"job-1\", func(stream, line string) {\n\t\tpersisted = append(persisted, stream+\":\"+line)\n\t})\n\n\tevents := collectEvents(t, hub, \"job-1\", func() {\n\t\thooks.OnContentDelta(\" \")\n\t\thooks.OnContentDelta(\"hello\")\n\t\thooks.OnContentDelta(\" world\")\n\t\thooks.OnTurnEnd()\n\t})\n\n\t// ContentStart must not fire on the leading-whitespace delta.\n\tif events[0].Type == livelog.ContentStart {\n\t\tt.Fatalf(\"ContentStart fired on the leading-whitespace delta: %+v\", events)\n\t}\n\tif !hasType(events, livelog.ContentStart) || !hasType(events, livelog.ContentEnd) {\n\t\tt.Fatalf(\"expected a ContentStart and ContentEnd, got %+v\", events)\n\t}\n\n\t// The full text (leading whitespace included) is persisted.\n\tif len(persisted) != 1 || persisted[0] != \"content: hello world\" {\n\t\tt.Fatalf(\"persisted = %q, want the full untrimmed content\", persisted)\n\t}\n}\n\n// A whitespace-only turn must not leak its text into the next turn's\n// block: the buffers reset at turn end, not at block open (a\n// whitespace-only turn never opens a block, so the old reset point would\n// have been skipped).\nfunc TestStreamHooksResetsBuffersBetweenTurns(t *testing.T) {\n\tr, hub := streamHooksTestRunner(t)\n\n\tvar persisted []string\n\n\thooks := r.streamHooks(\"job-1\", func(stream, line string) {\n\t\tpersisted = append(persisted, stream+\":\"+line)\n\t})\n\n\tcollectEvents(t, hub, \"job-1\", func() {\n\t\t// Turn 1: whitespace-only content.\n\t\thooks.OnContentDelta(\" \")\n\t\thooks.OnTurnEnd()\n\n\t\t// Turn 2: real content.\n\t\thooks.OnContentDelta(\"real\")\n\t\thooks.OnTurnEnd()\n\t})\n\n\tif len(persisted) != 1 || persisted[0] != \"content:real\" {\n\t\tt.Fatalf(\"persisted = %q, want only turn 2's content (no leak from turn 1)\", persisted)\n\t}\n}\n","path":"internal/agentrun/run_test.go"}
Arguments
{"content":"package agentrun\n\nimport (\n\t\"io\"\n\t\"log/slog\"\n\t\"testing\"\n\n\t\"github.com/abrander/zoo/internal/livelog\"\n)\n\n// streamHooksTestRunner builds a Runner with just the fields streamHooks\n// touches (hub + logger), so the test doesn't have to stand up Docker.\nfunc streamHooksTestRunner(t *testing.T) (*Runner, *livelog.Hub) {\n\tt.Helper()\n\n\thub := livelog.NewHub()\n\n\treturn &Runner{hub: hub, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}, hub\n}\n\n// collectEvents subscribes to jobID, runs fn (which publishes events\n// synchronously), and returns everything published, in order.\nfunc collectEvents(t *testing.T, hub *livelog.Hub, jobID string, fn func()) []livelog.Event {\n\tt.Helper()\n\n\tbuffered, ch, cancel := hub.Subscribe(jobID)\n\tdefer cancel()\n\n\tfn()\n\n\tevents := append([]livelog.Event(nil), buffered...)\n\ndrain:\n\tfor {\n\t\tselect {\n\t\tcase ev, ok := <-ch:\n\t\t\tif !ok {\n\t\t\t\tbreak drain\n\t\t\t}\n\t\t\tevents = append(events, ev)\n\t\tdefault:\n\t\t\tbreak drain\n\t\t}\n\t}\n\n\treturn events\n}\n\nfunc hasType(events []livelog.Event, want livelog.Type) bool {\n\tfor _, ev := range events {\n\t\tif ev.Type == want {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// A whitespace-only content block — the model's habit on reasoning +\n// tool-call turns — must not open a block: no start/end events, and\n// nothing persisted. This is what made the live view show an empty\n// block while the static view (buildBlocks) dropped it (issue #62).\nfunc TestStreamHooksDropsWhitespaceOnlyBlocks(t *testing.T) {\n\tr, hub := streamHooksTestRunner(t)\n\n\tvar persisted []string\n\n\thooks := r.streamHooks(\"job-1\", func(stream, line string) {\n\t\tpersisted = append(persisted, stream+\":\"+line)\n\t})\n\n\tevents := collectEvents(t, hub, \"job-1\", func() {\n\t\t// A reasoning + tool-call turn where the server emits a\n\t\t// whitespace-only content field.\n\t\thooks.OnReasoningDelta(\"thinking hard\")\n\t\thooks.OnContentDelta(\" \")\n\t\thooks.OnTurnEnd()\n\t})\n\n\t// The reasoning block opened and closed normally.\n\tfor _, want := range []livelog.Type{livelog.ReasoningStart, livelog.ReasoningDelta, livelog.ReasoningEnd} {\n\t\tif !hasType(events, want) {\n\t\t\tt.Fatalf(\"expected a %s event, got %+v\", want, events)\n\t\t}\n\t}\n\t// The whitespace-only content block never opened.\n\tfor _, absent := range []livelog.Type{livelog.ContentStart, livelog.ContentEnd} {\n\t\tif hasType(events, absent) {\n\t\t\tt.Fatalf(\"did not expect a %s event for whitespace-only content, got %+v\", absent, events)\n\t\t}\n\t}\n\n\t// Only the reasoning line was persisted; the whitespace content was\n\t// dropped.\n\tif len(persisted) != 1 || persisted[0] != \"reasoning:thinking hard\" {\n\t\tt.Fatalf(\"persisted = %q, want only the reasoning line\", persisted)\n\t}\n}\n\n// A content block with leading whitespace opens on the first delta that\n// carries real text, and the full (untrimmed) text is persisted —\n// trimming is the renderer's job, so the live and static views agree.\nfunc TestStreamHooksOpensBlockOnFirstRealText(t *testing.T) {\n\tr, hub := streamHooksTestRunner(t)\n\n\tvar persisted []string\n\n\thooks := r.streamHooks(\"job-1\", func(stream, line string) {\n\t\tpersisted = append(persisted, stream+\":\"+line)\n\t})\n\n\tevents := collectEvents(t, hub, \"job-1\", func() {\n\t\thooks.OnContentDelta(\" \")\n\t\thooks.OnContentDelta(\"hello\")\n\t\thooks.OnContentDelta(\" world\")\n\t\thooks.OnTurnEnd()\n\t})\n\n\t// ContentStart must not fire on the leading-whitespace delta.\n\tif events[0].Type == livelog.ContentStart {\n\t\tt.Fatalf(\"ContentStart fired on the leading-whitespace delta: %+v\", events)\n\t}\n\tif !hasType(events, livelog.ContentStart) || !hasType(events, livelog.ContentEnd) {\n\t\tt.Fatalf(\"expected a ContentStart and ContentEnd, got %+v\", events)\n\t}\n\n\t// The full text (leading whitespace included) is persisted.\n\tif len(persisted) != 1 || persisted[0] != \"content: hello world\" {\n\t\tt.Fatalf(\"persisted = %q, want the full untrimmed content\", persisted)\n\t}\n}\n\n// A whitespace-only turn must not leak its text into the next turn's\n// block: the buffers reset at turn end, not at block open (a\n// whitespace-only turn never opens a block, so the old reset point would\n// have been skipped).\nfunc TestStreamHooksResetsBuffersBetweenTurns(t *testing.T) {\n\tr, hub := streamHooksTestRunner(t)\n\n\tvar persisted []string\n\n\thooks := r.streamHooks(\"job-1\", func(stream, line string) {\n\t\tpersisted = append(persisted, stream+\":\"+line)\n\t})\n\n\tcollectEvents(t, hub, \"job-1\", func() {\n\t\t// Turn 1: whitespace-only content.\n\t\thooks.OnContentDelta(\" \")\n\t\thooks.OnTurnEnd()\n\n\t\t// Turn 2: real content.\n\t\thooks.OnContentDelta(\"real\")\n\t\thooks.OnTurnEnd()\n\t})\n\n\tif len(persisted) != 1 || persisted[0] != \"content:real\" {\n\t\tt.Fatalf(\"persisted = %q, want only turn 2's content (no leak from turn 1)\", persisted)\n\t}\n}\n","path":"internal/agentrun/run_test.go"}
Result
OK
Thinking
Wait, there's a subtle issue with my test `TestStreamHooksOpensBlockOnFirstRealText`. I'm checking `events[0].Type == livelog.ContentStart`. But `events[0]` is the first event, which is `ContentDelta(" ")`. So `events[0].Type` is `ContentDelta`, not `ContentStart`. So the check `if events[0].Type == livelog.ContentStart` will be false, and the test won't fail. Good.
But wait, I need to make sure that `events[0]` is indeed a `ContentDelta`. Let me trace:
- `OnContentDelta(" ")`: contentBuf = " ", whitespace, no open. Publish ContentDelta(" ").
- So the first event is ContentDelta(" "). Good.
So `events[0].Type` is `ContentDelta`. The check `if events[0].Type == livelog.ContentStart` is false. Good.
But actually, I should be more careful. Let me verify that the ContentStart is at index 1 (after the first ContentDelta). Let me add a check for that.
Actually, my current check is sufficient: it verifies that ContentStart is NOT at index 0, and that ContentStart and ContentEnd are present. That's enough to verify the lazy open.
But let me make it more precise: verify that ContentStart is at index 1 (right after the leading-whitespace delta). Let me update the test.
Actually, let me reconsider. The exact index might be fragile. Let me just verify:
1. The first event is a ContentDelta (the leading whitespace).
2. ContentStart is present (it opens on the first real delta).
3. ContentEnd is present.
4. The persisted text is the full untrimmed content.
That's sufficient. My current test does this. Let me keep it.
Actually, wait. Let me reconsider the check `if events[0].Type == livelog.ContentStart`. This checks that the first event is NOT ContentStart. But what if the first event is something else unexpected? Let me make it more explicit: verify that the first event is a ContentDelta with the leading whitespace.
Let me update the test to be more precise.
Actually, let me just run the test and see if it passes. If it does, the logic is correct. Let me run it.
Let me run the new tests:
Tool
🔧 bash
{"command":"cd /project && go test ./internal/agentrun/ -run 'TestStreamHooks' -v 2>&1 | tail -30"}
Arguments
{"command":"cd /project && go test ./internal/agentrun/ -run 'TestStreamHooks' -v 2>&1 | tail -30"}
Result
=== RUN TestStreamHooksDropsWhitespaceOnlyBlocks
--- PASS: TestStreamHooksDropsWhitespaceOnlyBlocks (0.00s)
=== RUN TestStreamHooksOpensBlockOnFirstRealText
--- PASS: TestStreamHooksOpensBlockOnFirstRealText (0.00s)
=== RUN TestStreamHooksResetsBuffersBetweenTurns
--- PASS: TestStreamHooksResetsBuffersBetweenTurns (0.00s)
PASS
ok github.com/abrander/zoo/internal/agentrun 0.004s
Thinking
All three new tests pass. Now let me run the full test suite to make sure nothing is broken.
But wait, I want to reconsider whether the client-side JS also needs a defensive fix. Let me think about this more carefully.
With the server-side fix:
- The server never sends `ContentStart`/`ReasoningStart` for whitespace-only blocks.
- So the client never creates an empty block.
But there's a subtle scenario I should consider: what if a subscriber connects mid-stream (via replay)? The replay buffer might contain `ContentDelta` events (from a whitespace-only block) without a `ContentStart`. The client handles this gracefully (skips deltas when body is null). So no empty block.
But what if the replay buffer contains a `ContentStart` (for a non-whitespace block) followed by deltas? The client creates the block and appends the deltas. Since the block is non-whitespace, it has real text. So no empty block.
So the client is safe with the server-side fix.
But wait, there's one more scenario: what if the block is non-whitespace, but the client receives the `ContentStart` and then the `ContentEnd` before any `ContentDelta`? Can that happen? No, because the server publishes `ContentStart` and `ContentDelta` together (for the first real delta), and `ContentEnd` only at turn end. So the client always receives at least one `ContentDelta` between `ContentStart` and `ContentEnd`. So the block has real text. No empty block.
So the server-side fix is sufficient. The client doesn't need a defensive fix.
But actually, let me reconsider. I want to be thorough. Let me re-examine the client JS one more time to make sure there's no path to an empty block.
```js
case "content_start":
contentBody = newBlock("content", null);
break;
case "content_delta":
if (contentBody) contentBody.textContent += ev.text;
break;
case "content_end":
if (contentBody) contentBody.textContent = contentBody.textContent.trim();
contentBody = null;
break;
```
With the server-side fix:
- `content_start` is only sent when the content is non-whitespace.
- `content_start` is always followed by at least one `content_delta` (the first real delta).
- So when `content_end` is received, `contentBody.textContent` has at least the first real delta (non-whitespace). After trim, it's non-empty. So the block has real text. No empty block.
So the client is safe. The server-side fix is sufficient.
Now, let me also consider: should I add a defensive client-side fix anyway? I've decided no, because it's redundant. The server-side fix is the root cause fix.
But wait, let me reconsider the issue description one more time. The issue says "empty blocks like these will often follow a thinking block". The example is a `block-content` with an empty body. So it's a content block. My fix addresses this.
But what about reasoning blocks? Could a reasoning block be whitespace-only? Yes, theoretically. My fix also handles reasoning blocks (lazy open). So both are covered.
Alright, the fix is complete. Let me run the full test suite to make sure nothing is broken.
Actually, wait. Let me reconsider whether I should also add the client-side defensive fix. The reason: the issue is about the live view showing empty blocks. The server-side fix prevents the server from sending start events for empty blocks. But what if there's a race condition or a different code path that sends a start event for an empty block?
Actually, I've analyzed this thoroughly. The server-side fix is sufficient. Let me not add the redundant client-side fix.
But hold on. Let me reconsider the architecture one more time. The issue is that the live view and static view disagree. The static view's rule is in `buildBlocks` (Go). The live view's rule is now in `streamHooks` (Go, server-side). So both rules are in Go, and they're consistent (both use `TrimSpace`). That's elegant.
If I had done the client-side fix, the rule would be in JS (client-side), which is a different language and harder to keep in sync with `buildBlocks`. So the server-side fix is more elegant.
Alright, I'm confident. Let me run the full test suite.
Actually, let me reconsider one more time. I want to make sure the server-side fix doesn't break the case where a block is legitimately empty (not whitespace-only, but actually empty). But a block can't be actually empty (zero-length), because `OnContentDelta` is only called when `len(msg.Content) > len(prevContent)`, i.e., when there's a non-empty delta. So a block always has at least one non-empty delta. If that delta is whitespace, the block is whitespace-only (handled by my fix). If it's non-whitespace, the block has real text. So there's no "actually empty" block.
Wait, but what if the delta is non-empty but the block is whitespace-only? That's the case my fix handles. Good.
So the fix is complete and correct.
Let me run the full test suite.
All three new tests pass. Let me run the full test suite to make sure nothing is broken:
Tool
🔧 bash
{"command":"cd /project && go test ./... 2>&1 | tail -30"}
Arguments
{"command":"cd /project && go test ./... 2>&1 | tail -30"}
Result
? github.com/abrander/zoo/cmd/zoo [no test files]
ok github.com/abrander/zoo/internal/agentrun 0.007s
ok github.com/abrander/zoo/internal/config 0.006s
ok github.com/abrander/zoo/internal/forgejo 1.199s
ok github.com/abrander/zoo/internal/livelog (cached)
ok github.com/abrander/zoo/internal/llm 0.003s
ok github.com/abrander/zoo/internal/scheduler 2.784s
ok github.com/abrander/zoo/internal/store 1.766s
ok github.com/abrander/zoo/internal/tools 0.003s
ok github.com/abrander/zoo/internal/web (cached)
Thinking
All tests pass. Now let me also run `go vet` to make sure there are no issues.
Let me also reconsider whether I should add a defensive client-side fix. I've decided the server-side fix is sufficient and elegant. But let me reconsider the issue one more time.
Actually, I realize I should double-check my understanding of the bug by considering whether the client-side JS might still show an empty block in some edge case. Let me think about the replay scenario more carefully.
Scenario: A job is running. A subscriber connects mid-stream. The replay buffer contains events from the current (not-yet-checkpointed) block.
With the server-side fix, the replay buffer for a whitespace-only content block would contain: `ContentDelta(" ")` (no `ContentStart`, no `ContentEnd` yet). When the subscriber connects, they get this buffered event. The client processes `ContentDelta(" ")` with `contentBody` null → skips. So no empty block. Good.
Then, when the turn ends, the server publishes `ContentEnd`... wait, no. With the server-side fix, if the content is whitespace-only, `contentOpen` is false, so the server does NOT publish `ContentEnd`. So the replay buffer only has `ContentDelta(" ")`. The client skips it. No empty block. Good.
But wait, there's a subtlety. The `ContentDelta(" ")` is in the replay buffer. When the turn ends, the server calls `Checkpoint` only if a block was persisted. Since the whitespace-only content wasn't persisted, no checkpoint happens for it. So the `ContentDelta(" ")` stays in the replay buffer until the next checkpoint (from the next real block or tool call).
So if a subscriber connects after the turn ends but before the next checkpoint, they'd get the buffered `ContentDelta(" ")`. The client skips it (contentBody null). So no empty block. Good.
But what if the subscriber connects and the `ContentDelta(" ")` is replayed, and then a `ContentStart` for the next turn's content is published? The client would:
- Replay `ContentDelta(" ")`: contentBody null, skip.
- Receive `ContentStart` (next turn): create block.
- Receive `ContentDelta` (next turn): append.
So the block shows the next turn's content. The leading `ContentDelta(" ")` was skipped. Good. No empty block, and no leaked text (the client doesn't append the skipped delta).
Wait, but the server's buffer for the next turn was reset at the end of the previous turn. So the next turn's content is fresh. Good.
So the replay scenario is handled correctly.
Alright, I'm confident the fix is complete and correct. Let me run `go vet` and then commit.
Actually, let me reconsider whether I should add the client-side defensive fix. I keep going back and forth, but let me make a final decision based on the principle of "elegant solution".
The elegant solution is:
1. The server is the single source of truth for "is this block worth rendering" (via lazy open in `streamHooks`).
2. This matches the static view's rule (`buildBlocks`).
3. The client just renders what the server sends.
This is clean and elegant. Adding a redundant client-side fix would violate the "single source of truth" principle. So I'll not add it.
Final decision: server-side fix only.
Let me run `go vet` and then commit.
Actually, wait. Let me reconsider one more time. I want to make sure I'm not missing something. Let me re-read the issue:
"When streaming a job, empty blocks like these will often follow a thinking block. When streaming, it will look like this:
thinking
empty
toolcall
thinking
empty
toolcall
thinking
response
But when refreshing the whole job log after it finishes, it will look like this:
thinking
toolcall
thinking
toolcall
thinking
response"
So the pattern is: thinking → empty → toolcall (repeated), then thinking → response.
This confirms: on reasoning+toolcall turns, there's a whitespace-only content block (the "empty"). On the final turn (reasoning+response), there's no empty block (because the content is the actual response, which is non-whitespace).
Wait, but the final turn shows "thinking → response" (no empty). So on the final turn, the content is the response (non-whitespace), so no empty block. That's consistent with my analysis.
But wait, why would the final turn not have an empty block? Because the final turn's content is the actual response text (non-whitespace). So the content block is non-whitespace and renders normally. There's no separate whitespace-only content block.
Hmm, but actually, on the final turn, is there a whitespace-only content block in addition to the response? No. The response IS the content. So there's one content block (the response), which is non-whitespace. So no empty block.
On the reasoning+toolcall turns, the content is whitespace-only (the model doesn't produce actual content, just reasoning and tool calls). So there's a whitespace-only content block, which renders as an empty block.
So my analysis is correct. The fix addresses this.
Alright, let me run `go vet` and commit.
Actually, let me reconsider the final turn. On the final turn, the model produces reasoning + response (content). So:
- Reasoning: non-whitespace, opens a block.
- Content (response): non-whitespace, opens a block.
So both blocks render. No empty block. Good.
But wait, could there be a whitespace-only content block on the final turn too? If the server emits a whitespace-only content field before the actual response? That's possible, but unlikely. And even if it does, my fix handles it (the whitespace-only block is dropped, and the response block is rendered).
Actually, no. On the final turn, the content is the response. There's only one content field. If it's the response (non-whitespace), it renders. If it's whitespace-only (unlikely), it's dropped. So no empty block.
Alright, the fix is complete. Let me run `go vet` and commit.
Let me also verify the change looks good by re-reading the modified `streamHooks`.
All tests pass. Let me run `go vet` and review the final change:
Tool
🔧 bash
{"command":"cd /project && go vet ./... 2>&1 | tail -10 && echo \"VET_EXIT=$?\" && echo \"=== diff ===\" && git diff"}
Arguments
{"command":"cd /project && go vet ./... 2>&1 | tail -10 && echo \"VET_EXIT=$?\" && echo \"=== diff ===\" && git diff"}
Result
VET_EXIT=0
=== diff ===
diff --git a/internal/agentrun/run.go b/internal/agentrun/run.go
index 486b954..c0f374b 100644
--- a/internal/agentrun/run.go
+++ b/internal/agentrun/run.go
@@ -292,23 +292,31 @@ func (r *Runner) streamHooks(jobID string, logAppend func(stream, line string))
return Hooks{
OnReasoningDelta: func(delta string) {
- if !reasoningOpen {
+ reasoningBuf.WriteString(delta)
+
+ // Open the block lazily, on the first delta that gives it
+ // real text. The model routinely emits whitespace-only
+ // reasoning/content on reasoning+tool-call turns, and a
+ // whitespace-only block renders as an empty block in the
+ // live view while the static view (buildBlocks) drops it —
+ // so the two views would disagree. Gating the start on
+ // non-whitespace text keeps them in lockstep, and keeps the
+ // whitespace-only turn out of the persisted log as well.
+ if !reasoningOpen && strings.TrimSpace(reasoningBuf.String()) != "" {
reasoningOpen = true
- reasoningBuf.Reset()
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})
}
- reasoningBuf.WriteString(delta)
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})
},
OnContentDelta: func(delta string) {
- if !contentOpen {
+ contentBuf.WriteString(delta)
+
+ if !contentOpen && strings.TrimSpace(contentBuf.String()) != "" {
contentOpen = true
- contentBuf.Reset()
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})
}
- contentBuf.WriteString(delta)
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})
},
OnTurnEnd: func() {
@@ -316,15 +324,21 @@ func (r *Runner) streamHooks(jobID string, logAppend func(stream, line string))
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})
logAppend("reasoning", reasoningBuf.String())
r.hub.Checkpoint(jobID)
- reasoningOpen = false
}
if contentOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})
logAppend("content", contentBuf.String())
r.hub.Checkpoint(jobID)
- contentOpen = false
}
+
+ // Each turn starts from a clean slate. The buffers were
+ // previously reset when a block opened, but with lazy open a
+ // whitespace-only turn never opens one, so reset here instead
+ // to keep one turn's text from leaking into the next.
+ reasoningOpen, contentOpen = false, false
+ reasoningBuf.Reset()
+ contentBuf.Reset()
},
OnReroll: func(name, arguments string, attempt int) {
// A discarded turn leaves its reasoning/content already in
Thinking
The diff looks good. Now let me reconsider whether I should also add a client-side defensive fix.
Actually, I've been going back and forth on this. Let me make a final decision and think about it from the perspective of robustness.
The server-side fix is the root cause fix. It prevents the server from sending start events for whitespace-only blocks. This is elegant and testable.
However, there's a subtle robustness concern: what if the client receives a `ContentStart` for a block that ends up empty? With the server-side fix, this can't happen (the server only sends start for non-whitespace blocks). But what if there's a future regression or a different code path?
I think the server-side fix is sufficient and elegant. I'll not add the redundant client-side fix.
But wait, let me reconsider the issue from the user's perspective. The user sees empty blocks in the live view. The fix should make the live view not show empty blocks. The server-side fix achieves this by not sending start events for empty blocks. So the user won't see empty blocks. Good.
But what about the brief moment where a block is created and then... no, with the server-side fix, the block is only created when there's real text. So there's no brief moment of an empty block. Good.
Actually, wait. Let me reconsider. With the server-side fix, the block is created on the first non-whitespace delta. So if the content starts with whitespace, the block appears a bit late (on the first non-whitespace delta). But that's the correct behavior (we don't want to show an empty block). And the delay is minimal (just the whitespace prefix).
Alright, I'm confident the fix is complete and correct. Let me commit and push.
Actually, let me reconsider one more time whether I should add a client-side defensive fix. I'll think about it in terms of "defense in depth".
The server-side fix is the primary fix. A client-side defensive fix would be a safety net. But since the server-side fix is robust (I've analyzed all the edge cases), the client-side fix is redundant.
I'll go with the server-side fix only. It's the elegant, root-cause solution.
Let me commit and push.
Wait, actually, I want to reconsider the client-side fix one final time, because I realize there might be a subtle issue I'm missing.
Let me re-examine the client JS:
```js
case "content_start":
contentBody = newBlock("content", null);
break;
```
`newBlock` creates a `div.block.block-content` and a `div.block-body`. It appends them to the log. So the block is immediately visible (empty).
With the server-side fix, `content_start` is only sent when the content is non-whitespace. So the block is created when there's real text. But the block is created empty (the body is empty), and then the first delta is appended. So there's a brief moment where the block is empty (between `content_start` and the first `content_delta`).
But wait, `content_start` and the first `content_delta` are published back-to-back from the server (in the same `OnContentDelta` call). So the client receives them in quick succession. The block is created empty, and then immediately filled with the first delta. So the brief empty moment is negligible (sub-millisecond).
So there's no visible empty block. Good.
Alright, the fix is complete. Let me commit and push.
Actually, let me reconsider whether the brief empty moment could be visible. The server publishes `ContentStart` and `ContentDelta` in the same `OnContentDelta` call:
```go
OnContentDelta: func(delta string) {
contentBuf.WriteString(delta)
if !contentOpen && strings.TrimSpace(contentBuf.String()) != "" {
contentOpen = true
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})
}
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})
},
```
So `ContentStart` is published, then `ContentDelta` is published. Both go into the hub's buffer and are fanned out to subscribers. The client receives them in order. So the client creates the block on `ContentStart`, then appends the delta on `ContentDelta`. The brief empty moment is between these two events, which are processed back-to-back by the client. So it's negligible.
But actually, the client processes events in `es.onmessage`, which is called for each SSE event. So the client processes `ContentStart` (creates block), then `ContentDelta` (appends text). These are two separate `onmessage` calls. So there's a brief moment where the block is empty (between the two `onmessage` calls). But this is sub-millisecond and not visible to the user.
So the fix is correct. The user won't see an empty block.
Alright, let me commit and push.
Actually, I realize I should double-check: does the client's `follow()` get called after each event? Yes:
```js
es.onmessage = function(e) {
var ev = JSON.parse(e.data);
switch (ev.type) { ... }
follow();
};
```
So after each event, `follow()` is called to scroll to the bottom. This is fine.
Alright, the fix is complete. Let me commit and push.
Let me write a commit message and push.
The change is clean and `go vet` passes. Let me reconsider whether the client-side JS also needs a defensive fix. With the server-side fix, the server never sends a `*Start` event for a whitespace-only block, so the client never creates an empty block. I've verified all edge cases (replay, mid-stream connect, leading whitespace, buffer reset between turns). The server-side fix is the root-cause solution and keeps the "is this block worth rendering" decision in one place (Go), consistent with `buildBlocks`.
Let me do a final review of the full modified function and run the complete test suite once more:
Tool
🔧 bash
{"command":"cd /project && sed -n '/func (r \\*Runner) streamHooks/,/^}/p' internal/agentrun/run.go"}
Arguments
{"command":"cd /project && sed -n '/func (r \\*Runner) streamHooks/,/^}/p' internal/agentrun/run.go"}
Result
func (r *Runner) streamHooks(jobID string, logAppend func(stream, line string)) Hooks {
var reasoningBuf, contentBuf strings.Builder
reasoningOpen, contentOpen := false, false
return Hooks{
OnReasoningDelta: func(delta string) {
reasoningBuf.WriteString(delta)
// Open the block lazily, on the first delta that gives it
// real text. The model routinely emits whitespace-only
// reasoning/content on reasoning+tool-call turns, and a
// whitespace-only block renders as an empty block in the
// live view while the static view (buildBlocks) drops it —
// so the two views would disagree. Gating the start on
// non-whitespace text keeps them in lockstep, and keeps the
// whitespace-only turn out of the persisted log as well.
if !reasoningOpen && strings.TrimSpace(reasoningBuf.String()) != "" {
reasoningOpen = true
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})
}
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})
},
OnContentDelta: func(delta string) {
contentBuf.WriteString(delta)
if !contentOpen && strings.TrimSpace(contentBuf.String()) != "" {
contentOpen = true
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})
}
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})
},
OnTurnEnd: func() {
if reasoningOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})
logAppend("reasoning", reasoningBuf.String())
r.hub.Checkpoint(jobID)
}
if contentOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})
logAppend("content", contentBuf.String())
r.hub.Checkpoint(jobID)
}
// Each turn starts from a clean slate. The buffers were
// previously reset when a block opened, but with lazy open a
// whitespace-only turn never opens one, so reset here instead
// to keep one turn's text from leaking into the next.
reasoningOpen, contentOpen = false, false
reasoningBuf.Reset()
contentBuf.Reset()
},
OnReroll: func(name, arguments string, attempt int) {
// A discarded turn leaves its reasoning/content already in
// the log, so say why the next one repeats it.
note := fmt.Sprintf("discarded turn %d/%d: %s was called with arguments that are not valid JSON, asking again: %s",
attempt, maxRerolls, name, arguments)
r.hub.Publish(jobID, livelog.Event{Type: livelog.System, Text: note})
logAppend("system", note)
r.hub.Checkpoint(jobID)
},
OnTool: func(name, arguments, result string, toolErr bool) {
r.hub.Publish(jobID, livelog.Event{
Type: livelog.Tool,
Name: name,
Arguments: arguments,
Result: result,
Error: toolErr,
})
line, err := json.Marshal(store.ToolLogEntry{Name: name, Arguments: arguments, Result: result, Error: toolErr})
if err != nil {
r.logger.Warn("failed to marshal tool log entry", "job", jobID, "error", err)
} else {
logAppend("tool", string(line))
}
r.hub.Checkpoint(jobID)
},
}
}
Thinking
The changes look good. Let's run the entire test suite one more time and also run the build.
Actually, I want to reconsider one more edge case. What happens when `OnReroll` fires? Let me trace through it:
In `runLoop`:
1. Stream the completion (deltas fire).
2. `OnTurnEnd` fires (persists reasoning/content, resets the buffer).
3. Check for broken tool calls. If broken, `OnReroll` fires, and we `continue`.
So `OnTurnEnd` fires before `OnReroll`. Therefore, the buffer is reset in `OnTurnEnd`, and then `OnReroll` publishes a system note. The system note doesn't touch the reasoning/content buffer. So it's fine.
But wait, there's a subtle point. When a turn is rerolled, the reasoning/content for that turn is persisted in `OnTurnEnd` (if not whitespace-only). And then `OnReroll` publishes a system note. So the log has reasoning, content, system note. And then the next turn.
With my fix, if the rerolled turn's content is whitespace-only, it's not persisted. So the log has reasoning (if not whitespace-only), system note. And then the next turn. That's fine.
Alright, the fix is complete. Let's run the entire test suite one more time.
Actually, I've already run the entire test suite and it passed. Let's just do a final build and test to confirm.
Let's commit and push.
Wait, I want to reconsider whether I should also handle the client side. Let me think about it from a different angle.
The issue is that the live view shows empty blocks. The root cause is that the server sends start events for whitespace-only blocks. My fix prevents that.
But what if the user is running an old version of the server (without my fix) and a new version of the client (with the dashboard)? No, the server and client are deployed together (the server serves the dashboard). So they're always in sync.
What if there's a cached version of the dashboard? The dashboard is served with `Cache-Control: no-store`, so it's not cached. So the client is always up to date.
So the server-side fix is sufficient.
Alright, let's commit and push.
Actually, let me reconsider whether I should add the client-side defensive fix for robustness. I've decided not to, because it's redundant. But let me think about whether there's a scenario where the server-side fix is insufficient.
Scenario: The server sends `ContentStart` (content is not whitespace), then the content delta is... no, if the content is not whitespace, the block has actual text. So no empty block.
Scenario: The server sends `ContentStart` but no delta follows. Can that happen? No, because `ContentStart` is only published in `OnContentDelta`, and the same delta is published as `ContentDelta` immediately after. So `ContentStart` is always followed by at least one `ContentDelta`. So the block has at least one delta. And since the start was triggered by a non-whitespace buffer, the delta is non-whitespace. So the block has actual text. No empty block.
So the server-side fix is sufficient.
Alright, let's commit and push.
Let me write the commit.
The fix is complete and clean. Let me run the full test suite one final time to confirm everything passes:
Tool
🔧 bash
{"command":"cd /project && go build ./... && go test ./... 2>&1 | tail -15"}
Arguments
{"command":"cd /project && go build ./... && go test ./... 2>&1 | tail -15"}
Result
? github.com/abrander/zoo/cmd/zoo [no test files]
ok github.com/abrander/zoo/internal/agentrun (cached)
ok github.com/abrander/zoo/internal/config (cached)
ok github.com/abrander/zoo/internal/forgejo (cached)
ok github.com/abrander/zoo/internal/livelog (cached)
ok github.com/abrander/zoo/internal/llm (cached)
ok github.com/abrander/zoo/internal/scheduler (cached)
ok github.com/abrander/zoo/internal/store (cached)
ok github.com/abrander/zoo/internal/tools (cached)
ok github.com/abrander/zoo/internal/web (cached)
Thinking
All tests pass. Let me commit and push the changes.
Let me write a good commit message.
Everything passes. Let me commit and push:
Tool
🔧 bash
{"command":"cd /project && git add -A && git commit -q -m \"livelog: open reasoning/content blocks lazily, on first real text\n\nOn reasoning+tool-call turns the model routinely emits a whitespace-only\ncontent field. streamHooks used to open the block on the very first\ndelta, so the live view rendered a block that trimmed down to nothing —\nan empty block after every thinking block — while the static view\n(buildBlocks) dropped whitespace-only blocks entirely. The two views\ndisagreed, which is exactly what issue #62 reported.\n\nOpen the block lazily, on the first delta that gives it non-whitespace\ntext. That keeps the live view in lockstep with the static view, and\nkeeps the whitespace-only turn out of the persisted log as well. The\nbuffer reset moves to turn end (a whitespace-only turn never opens a\nblock, so the old reset point would have been skipped and one turn's\ntext could leak into the next).\n\nAdd streamHooks tests covering the whitespace-only drop, the lazy open\non first real text, and the per-turn buffer reset.\" && git log --oneline -2"}
Arguments
{"command":"cd /project && git add -A && git commit -q -m \"livelog: open reasoning/content blocks lazily, on first real text\n\nOn reasoning+tool-call turns the model routinely emits a whitespace-only\ncontent field. streamHooks used to open the block on the very first\ndelta, so the live view rendered a block that trimmed down to nothing —\nan empty block after every thinking block — while the static view\n(buildBlocks) dropped whitespace-only blocks entirely. The two views\ndisagreed, which is exactly what issue #62 reported.\n\nOpen the block lazily, on the first delta that gives it non-whitespace\ntext. That keeps the live view in lockstep with the static view, and\nkeeps the whitespace-only turn out of the persisted log as well. The\nbuffer reset moves to turn end (a whitespace-only turn never opens a\nblock, so the old reset point would have been skipped and one turn's\ntext could leak into the next).\n\nAdd streamHooks tests covering the whitespace-only drop, the lazy open\non first real text, and the per-turn buffer reset.\" && git log --oneline -2"}
Result
49f034a livelog: open reasoning/content blocks lazily, on first real text
7423d86 Try to re-roll broken tool calls.
Thinking
Wait, I need to check the git user config. The commit succeeded, but let me verify it was committed with the correct identity. Actually, it succeeded, so it's fine.
Next, let me push the branch.
Tool
🔧 bash
{"command":"cd /project && git push -u origin issue-62-greg 2>&1 | tail -5"}
Arguments
{"command":"cd /project && git push -u origin issue-62-greg 2>&1 | tail -5"}
Result
To https://code.stdio.dk/abrander/zoo.git
* [new branch] issue-62-greg -> issue-62-greg
branch 'issue-62-greg' set up to track 'origin/issue-62-greg'.
Thinking
The branch has been pushed. Next, let's open a pull request.
Branch pushed. Now let me open the pull request:
Tool
🔧 open_pull_request
{"base":"main","body":"## Summary\n\nFixes #62 — streaming a job showed empty blocks after thinking blocks, which vanished on refresh.\n\n## Root cause\n\nOn reasoning + tool-call turns, the model routinely emits a **whitespace-only** `content` field. `streamHooks` used to open the block on the very first delta, so:\n\n- **Live view:** the dashboard created a `block-content` div on `content_start`, the whitespace delta trimmed down to `\"\"` on `content_end` — leaving an empty block in the DOM.\n- **Static view (after refresh):** `buildBlocks` drops whitespace-only blocks entirely.\n\nThe two views disagreed — exactly the `thinking / empty / toolcall` pattern from the issue.\n\n## Fix\n\nOpen reasoning/content blocks **lazily, on the first delta that carries non-whitespace text** (`internal/agentrun/run.go`):\n\n- The live view now stays in lockstep with the static view — the \"is this block worth rendering\" decision lives in one place (Go), using the same `TrimSpace` rule as `buildBlocks`.\n- Whitespace-only turns no longer land in the persisted log either.\n- The buffer reset moves to turn end: with lazy open, a whitespace-only turn never opens a block, so the old reset point (on block open) would have been skipped and one turn's text could leak into the next.\n\nNo client-side change needed: the server simply never sends a `*Start` event for a block that would be empty, so the dashboard never creates one.\n\n## Tests\n\nNew `internal/agentrun/run_test.go` covers:\n- `TestStreamHooksDropsWhitespaceOnlyBlocks` — no `ContentStart`/`ContentEnd`, nothing persisted for a whitespace-only content block.\n- `TestStreamHooksOpensBlockOnFirstRealText` — block opens on the first real delta; full untrimmed text is persisted (trimming stays the renderer's job).\n- `TestStreamHooksResetsBuffersBetweenTurns` — a whitespace-only turn doesn't leak into the next turn's block.\n\nFull suite passes: `go build ./... && go test ./...` ✅","head":"issue-62-greg","title":"livelog: open reasoning/content blocks lazily, on first real text"}
Arguments
{"base":"main","body":"## Summary\n\nFixes #62 — streaming a job showed empty blocks after thinking blocks, which vanished on refresh.\n\n## Root cause\n\nOn reasoning + tool-call turns, the model routinely emits a **whitespace-only** `content` field. `streamHooks` used to open the block on the very first delta, so:\n\n- **Live view:** the dashboard created a `block-content` div on `content_start`, the whitespace delta trimmed down to `\"\"` on `content_end` — leaving an empty block in the DOM.\n- **Static view (after refresh):** `buildBlocks` drops whitespace-only blocks entirely.\n\nThe two views disagreed — exactly the `thinking / empty / toolcall` pattern from the issue.\n\n## Fix\n\nOpen reasoning/content blocks **lazily, on the first delta that carries non-whitespace text** (`internal/agentrun/run.go`):\n\n- The live view now stays in lockstep with the static view — the \"is this block worth rendering\" decision lives in one place (Go), using the same `TrimSpace` rule as `buildBlocks`.\n- Whitespace-only turns no longer land in the persisted log either.\n- The buffer reset moves to turn end: with lazy open, a whitespace-only turn never opens a block, so the old reset point (on block open) would have been skipped and one turn's text could leak into the next.\n\nNo client-side change needed: the server simply never sends a `*Start` event for a block that would be empty, so the dashboard never creates one.\n\n## Tests\n\nNew `internal/agentrun/run_test.go` covers:\n- `TestStreamHooksDropsWhitespaceOnlyBlocks` — no `ContentStart`/`ContentEnd`, nothing persisted for a whitespace-only content block.\n- `TestStreamHooksOpensBlockOnFirstRealText` — block opens on the first real delta; full untrimmed text is persisted (trimming stays the renderer's job).\n- `TestStreamHooksResetsBuffersBetweenTurns` — a whitespace-only turn doesn't leak into the next turn's block.\n\nFull suite passes: `go build ./... && go test ./...` ✅","head":"issue-62-greg","title":"livelog: open reasoning/content blocks lazily, on first real text"}
Result
Pull request opened
Thinking
The pull request has been opened. Next, I'll leave a comment on the issue summarizing what I did.
Now let me leave a comment on the issue summarizing the work:
Tool
🔧 comment
{"body":"Investigated and fixed — PR is up for review.\n\n**Root cause:** On reasoning + tool-call turns, the model routinely emits a *whitespace-only* `content` field. `streamHooks` opened the block on the very first delta, so the live dashboard created a `block-content` div that trimmed down to nothing — the empty block after every thinking block. The static view (`buildBlocks`) drops whitespace-only blocks, which is why they vanished on refresh.\n\n**Fix:** Open reasoning/content blocks lazily, on the first delta that carries non-whitespace text (`internal/agentrun/run.go`). The live view now stays in lockstep with the static view — the \"is this block worth rendering\" decision lives in one place, using the same `TrimSpace` rule as `buildBlocks` — and whitespace-only turns no longer land in the persisted log either. The per-turn buffer reset moved to turn end (a whitespace-only turn never opens a block, so the old reset point would have been skipped).\n\nNo client-side change needed: the server simply never sends a `*Start` event for a block that would be empty.\n\nAdded three `streamHooks` tests covering the whitespace-only drop, the lazy open on first real text, and the per-turn buffer reset. Full suite passes."}
Arguments
{"body":"Investigated and fixed — PR is up for review.\n\n**Root cause:** On reasoning + tool-call turns, the model routinely emits a *whitespace-only* `content` field. `streamHooks` opened the block on the very first delta, so the live dashboard created a `block-content` div that trimmed down to nothing — the empty block after every thinking block. The static view (`buildBlocks`) drops whitespace-only blocks, which is why they vanished on refresh.\n\n**Fix:** Open reasoning/content blocks lazily, on the first delta that carries non-whitespace text (`internal/agentrun/run.go`). The live view now stays in lockstep with the static view — the \"is this block worth rendering\" decision lives in one place, using the same `TrimSpace` rule as `buildBlocks` — and whitespace-only turns no longer land in the persisted log either. The per-turn buffer reset moved to turn end (a whitespace-only turn never opens a block, so the old reset point would have been skipped).\n\nNo client-side change needed: the server simply never sends a `*Start` event for a block that would be empty.\n\nAdded three `streamHooks` tests covering the whitespace-only drop, the lazy open on first real text, and the per-turn buffer reset. Full suite passes."}
Result
Comment posted
Thinking
I've completed the following:
1. Investigated the issue and identified the root cause.
2. Implemented the fix (lazy block opening in `streamHooks`).
3. Added tests.
4. Ran the full test suite — all passing.
5. Committed and pushed the branch.
6. Opened a pull request.
7. Left a comment on the issue.
I'll do a final check to make sure everything is in order. I'll verify the state of the PR and the branch.
Actually, I think I'm done. Let me summarize what I did.
Wait, let me reconsider whether I should also request a review. The instructions say to use `request_review` when appropriate. But the issue was assigned to me (greg) by leon (the EM). Let me think about whether I should request a review from leon.
Actually, the PR has been opened. leon assigned this to me. I could request a review from leon, but it's not strictly necessary. The PR is up for review. Let me not request a review explicitly — the PR opening itself is the signal.
Actually, let me reconsider. The task is done. I've:
- Fixed the bug.
- Added tests.
- Opened a PR.
- Commented on the issue.
That's a complete response. I'm done.
Let me do a final verification that the PR was created correctly.
Let me do a final verification that everything is in order:
Tool
🔧 bash
{"command":"cd /project && git log --oneline -3 && echo \"---\" && git status && echo \"--- files changed ---\" && git diff --stat HEAD~1"}
Arguments
{"command":"cd /project && git log --oneline -3 && echo \"---\" && git status && echo \"--- files changed ---\" && git diff --stat HEAD~1"}
Result
49f034a livelog: open reasoning/content blocks lazily, on first real text
7423d86 Try to re-roll broken tool calls.
f58963a Merge pull request 'Jobs page: follow the live log tail, with a jump-to-bottom button' (#58) from issue-57-greg into main
---
On branch issue-62-greg
nothing to commit, working tree clean
--- files changed ---
internal/agentrun/run.go | 30 +++++---
internal/agentrun/run_test.go | 159 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 181 insertions(+), 8 deletions(-)
Thinking
Everything is in order:
- The commit has been pushed to `issue-62-greg`.
- The PR has been opened.
- A comment has been posted on the Issue.
- All tests are passing.
The task is complete. Let me summarize.
The task is complete. Here's a summary of what I did:
## Summary
**Issue #62: Streaming creates empty blocks**
### Root cause
On reasoning + tool-call turns, the model routinely emits a **whitespace-only** `content` field. `streamHooks` opened the block on the very first delta, so:
- **Live view:** the dashboard created a `block-content` div on `content_start`; the whitespace delta trimmed to `""` on `content_end`, leaving an empty block in the DOM.
- **Static view (after refresh):** `buildBlocks` drops whitespace-only blocks entirely.
The two views disagreed — exactly the `thinking / empty / toolcall` pattern reported in the issue.
### Fix (`internal/agentrun/run.go`)
Open reasoning/content blocks **lazily, on the first delta that carries non-whitespace text**:
- The live view now stays in lockstep with the static view — the "is this block worth rendering" decision lives in one place (Go), using the same `TrimSpace` rule as `buildBlocks`.
- Whitespace-only turns no longer land in the persisted log either.
- The per-turn buffer reset moved to turn end (a whitespace-only turn never opens a block, so the old reset point would have been skipped and one turn's text could leak into the next).
No client-side change was needed: the server simply never sends a `*Start` event for a block that would be empty.
### Tests (`internal/agentrun/run_test.go`)
- `TestStreamHooksDropsWhitespaceOnlyBlocks` — no `ContentStart`/`ContentEnd`, nothing persisted for a whitespace-only block.
- `TestStreamHooksOpensBlockOnFirstRealText` — block opens on the first real delta; full untrimmed text is persisted.
- `TestStreamHooksResetsBuffersBetweenTurns` — a whitespace-only turn doesn't leak into the next turn's block.
Full suite passes (`go build ./... && go test ./...`). Branch `issue-62-greg` pushed, PR opened, and the issue commented.