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