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.
This commit is contained in:
Ollie Agent 2026-10-10 16:05:05 +02:00
parent a4972cd456
commit 6211a6cdee
5 changed files with 107 additions and 6 deletions

View File

@ -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)`.

View File

@ -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")
}
}

View File

@ -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()

View File

@ -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":

72
format/block_test.go Normal file
View File

@ -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)
}
}
})
}
}