From 6211a6cdee06a966cededc89d1823d8f81493d8c Mon Sep 17 00:00:00 2001 From: Ollie Agent Date: Sat, 10 Oct 2026 16:05:05 +0200 Subject: [PATCH] Surface tool calls, output, and bypass status in log/chat views The filtered text views (log, chat) hid call and tool blocks entirely and had no visibility into bypass requests, which were only published as events. RenderBlock now renders tool calls (name + args), tool output, and bypass request/resolution notices; reasoning and context stay hidden. SetBypassPending/ResolveBypass append bypass and bypass-resolved blocks to the requesting agent so clients without an event stream (TUI, o script) see pending approvals and outcomes. --- AGENTS.md | 2 +- cmd/olliesrv/internal/agent/chat_test.go | 9 ++- cmd/olliesrv/internal/session/session.go | 19 +++++++ format/block.go | 11 +++- format/block_test.go | 72 ++++++++++++++++++++++++ 5 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 format/block_test.go diff --git a/AGENTS.md b/AGENTS.md index c8fd0ee..308d759 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,7 +124,7 @@ This is a standing preference, not a per-task instruction. Apply it without aski 14. **Chat log files**: Each agent exposes views of its conversation history via the 9P namespace: - `chat.raw` — authoritative live JSONL stream. On open it replays the finalized history (one line per block), then streams live deltas: the current in-flight partial block whenever it changes, and finalized blocks as they are appended. GUI clients consume this and must not poll `log.raw`. Partials are delivered out-of-band (never persisted) and collapse by block ID on the client, so reconnects stay O(history), not O(streaming chunks). - `log.raw` — JSONL snapshot (one JSON object per line) of **finalized** blocks only. One-shot read: returns the full history and EOFs. Used for explicit history loads (initial populate, bookmark reload), never polled for live updates. Each line is `{"role":"...","id":"...","content":"..."}` with optional `name` and `format` fields. - - `log` — Rendered text snapshot (last 64KB). Non-blocking; suitable for one-shot inspection. Filters out reasoning, call, and tool blocks; shows only user/assistant/context content. + - `log` — Rendered text snapshot (last 64KB). Non-blocking; suitable for one-shot inspection. Filters out reasoning and context blocks; shows user/assistant text, tool calls (`→ name args`), tool output, and bypass request/resolution notices. - `chat` — Rendered text stream. Blocking; suitable for TUI live tailing. Same filtering as `log`. - `block` — Rdwr lookup: write a block ID (8-char hex), read the matching block as JSON. Returns the full block object or an error if not found. Block IDs are 8-char hex strings generated deterministically from `sha256(sessionID + agentID + counter)`. diff --git a/cmd/olliesrv/internal/agent/chat_test.go b/cmd/olliesrv/internal/agent/chat_test.go index 21178df..877785d 100644 --- a/cmd/olliesrv/internal/agent/chat_test.go +++ b/cmd/olliesrv/internal/agent/chat_test.go @@ -250,9 +250,12 @@ func TestTextLog_RendersHumanReadable(t *testing.T) { if !contains(text, "Hi there") { t.Error("should contain assistant message") } - // Should NOT have call content (hidden from rendered log) - if contains(text, "shell") || contains(text, "cmd") { - t.Error("call content should be hidden") + // Tool calls are now surfaced in the rendered log (name + args). + if !contains(text, "shell") { + t.Error("call should show tool name") + } + if !contains(text, "cmd") { + t.Error("call should show tool args") } } diff --git a/cmd/olliesrv/internal/session/session.go b/cmd/olliesrv/internal/session/session.go index df7d0fe..7a17aec 100644 --- a/cmd/olliesrv/internal/session/session.go +++ b/cmd/olliesrv/internal/session/session.go @@ -12,6 +12,7 @@ import ( "ollie/cmd/olliesrv/internal/agent" "ollie/cmd/olliesrv/internal/backend" "ollie/cmd/olliesrv/internal/toolclient" + "ollie/format" olog "ollie/log" "ollie/toolsrv/protocol" "ollie/util" @@ -271,6 +272,15 @@ func (s *Session) SetBypassPending(req *protocol.BypassRequest, dialFn func() *t } // Event topic: session.{sid}.agent.{aid}.bypass.request PublishEvent("session."+s.ID+".agent."+agentID+".bypass.request", req.ID+"\t"+cmd+"\t"+req.Cwd) + + // Surface in the filtered chat/log text views (which have no event stream). + if ag := s.FindAgent(agentID); ag != nil { + ag.AppendBlock(format.Block{ + Role: "bypass", + ID: ag.NextBlockID(), + Content: cmd, + }) + } } // BypassPending returns all pending bypass requests. @@ -324,6 +334,15 @@ func (s *Session) ResolveBypass(id string, approved bool) error { } PublishEvent("session."+s.ID+".agent."+agentID+".bypass.resolved", req.ID+"\t"+action) + // Surface resolution in the filtered chat/log text views. + if ag := s.FindAgent(agentID); ag != nil { + ag.AppendBlock(format.Block{ + Role: "bypass-resolved", + ID: ag.NextBlockID(), + Content: action, + }) + } + // Send resolution to toolsrv if dialFn != nil { conn := dialFn() diff --git a/format/block.go b/format/block.go index 9c3dbae..9d7242a 100644 --- a/format/block.go +++ b/format/block.go @@ -51,9 +51,16 @@ func RenderBlock(b Block) string { case "reasoning": return "" // Hidden from rendered log case "call": - return "" // Hidden from rendered log + if b.Content != "" { + return "→ " + b.Name + " " + b.Content + "\n\n" + } + return "→ " + b.Name + "\n\n" case "tool": - return "" // Hidden from rendered log + return b.Content + "\n\n" + case "bypass": + return "⚠ bypass requested (needs approval): " + b.Content + "\n\n" + case "bypass-resolved": + return "bypass " + b.Content + "\n\n" case "error": return "Error: " + b.Content + "\n\n" case "info": diff --git a/format/block_test.go b/format/block_test.go new file mode 100644 index 0000000..8e8d8da --- /dev/null +++ b/format/block_test.go @@ -0,0 +1,72 @@ +package format + +import ( + "strings" + "testing" +) + +func TestRenderBlock_SurfacesToolsAndBypass(t *testing.T) { + tests := []struct { + name string + block Block + contains []string + empty bool + }{ + { + name: "call shows name and args", + block: Block{Role: "call", Name: "shell", Content: `{"cmd":"ls"}`}, + contains: []string{"shell", "cmd"}, + }, + { + name: "call without args shows name", + block: Block{Role: "call", Name: "memory_wake"}, + contains: []string{"memory_wake"}, + }, + { + name: "tool output rendered", + block: Block{Role: "tool", Content: "total 0\nfile.txt"}, + contains: []string{"file.txt"}, + }, + { + name: "bypass request flagged for approval", + block: Block{Role: "bypass", Content: "rm -rf /tmp/x"}, + contains: []string{"approval", "rm -rf /tmp/x"}, + }, + { + name: "bypass resolution rendered", + block: Block{Role: "bypass-resolved", Content: "approved"}, + contains: []string{"approved"}, + }, + { + name: "reasoning hidden", + block: Block{Role: "reasoning", Content: "secret thoughts"}, + empty: true, + }, + { + name: "context hidden", + block: Block{Role: "context", Content: "system context"}, + empty: true, + }, + { + name: "partial skipped", + block: Block{Role: "assistant", Content: "streaming", Partial: true}, + empty: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := RenderBlock(tt.block) + if tt.empty { + if got != "" { + t.Errorf("expected empty, got %q", got) + } + return + } + for _, want := range tt.contains { + if !strings.Contains(got, want) { + t.Errorf("RenderBlock = %q, want substring %q", got, want) + } + } + }) + } +}