Migrate chat log to JSONL format

Server changes:
- format/block.go: Block struct with JSONL marshaling, RenderBlock for text
- Deleted format/event.go, format.go, format_test.go (old delimiter format)
- agent/chat.go: Dual logs (rawLog JSONL + textLog rendered text)
- agent/chatlog.go: Streaming via flushPartial/closeBlock, skip internal events
- fs/spec.go: New files log.raw (JSONL), log (text), block (ID lookup)
- Removed chat/chat.raw/chat.search from namespace

GUI changes:
- chatblockmodel: JSONL parsing via QJsonDocument, removed regex state machine
- ollie9pclient: Read from log.raw for replay, removed old streaming code
- Added Context type to block enum, filter context/state/usage from display

Docs updated: AGENTS.md, system_prompt.md, architecture-*.md, scripts/o
This commit is contained in:
Levi Neely 2026-10-09 18:52:40 +02:00
parent e53d612bdb
commit 57c40d541e
25 changed files with 798 additions and 1478 deletions

View File

@ -121,7 +121,11 @@ This is a standing preference, not a per-task instruction. Apply it without aski
11. **Peers**: Persistent agents in the same session can be linked via `peeradd`. Links are bidirectional. Agents communicate by writing to `peer/{name}`, which delivers to the target's prompt handler. Only declared peers can be messaged — the `peer/` directory is the access control surface. 11. **Peers**: Persistent agents in the same session can be linked via `peeradd`. Links are bidirectional. Agents communicate by writing to `peer/{name}`, which delivers to the target's prompt handler. Only declared peers can be messaged — the `peer/` directory is the access control surface.
12. **9P namespace declaration**: `cmd/olliesrv/internal/fs/spec.go` declares the olliesrv namespace. The toolsrv namespace is declared by `cmd/toolsrv/p9.go` using `cmd/toolsrv/internal/server.Spec`; process state and handlers are in `cmd/toolsrv/internal/server/`. Both use the `virtfs` EDSL and `virtfs.BuildTree()`. 12. **9P namespace declaration**: `cmd/olliesrv/internal/fs/spec.go` declares the olliesrv namespace. The toolsrv namespace is declared by `cmd/toolsrv/p9.go` using `cmd/toolsrv/internal/server.Spec`; process state and handlers are in `cmd/toolsrv/internal/server/`. Both use the `virtfs` EDSL and `virtfs.BuildTree()`.
13. **Working directory (default + override)**: Session cwd is **required** at session creation and is the inheritance root for every agent — the sane default so many agents can work in one directory with zero per-agent config. Agent cwd is an **optional** per-agent override (empty = inherit the session cwd). Because the per-session `toolsrv` sets `cmd.Dir` per tool call, per-agent cwd needs no extra process: `toolsrv` keeps an in-memory `agentCWD` map resolved per call from the `agent=` field (`cmd/toolsrv/internal/server/proc.go`), falling back to the session-level global cwd. The override is set only over the controlled `olliesrv`→`toolsrv` ctl channel (`agentcwd <id> <dir>`), never embedded in a tool-call payload, so the model cannot influence where its own tools run. The map is process-local, so `Agent.SyncCwdToToolServer` re-pushes the override on every (re)connect — profile switch, resume, restore — matching how env and tools are resynced. Set/clear via the agent `cfg` (`cwd=...`) or ctl (`cwd [<dir>|-]`); the GUI Agent Settings dialog shows `(inherit: <sessionCwd>)` as the placeholder. 13. **Working directory (default + override)**: Session cwd is **required** at session creation and is the inheritance root for every agent — the sane default so many agents can work in one directory with zero per-agent config. Agent cwd is an **optional** per-agent override (empty = inherit the session cwd). Because the per-session `toolsrv` sets `cmd.Dir` per tool call, per-agent cwd needs no extra process: `toolsrv` keeps an in-memory `agentCWD` map resolved per call from the `agent=` field (`cmd/toolsrv/internal/server/proc.go`), falling back to the session-level global cwd. The override is set only over the controlled `olliesrv`→`toolsrv` ctl channel (`agentcwd <id> <dir>`), never embedded in a tool-call payload, so the model cannot influence where its own tools run. The map is process-local, so `Agent.SyncCwdToToolServer` re-pushes the override on every (re)connect — profile switch, resume, restore — matching how env and tools are resynced. Set/clear via the agent `cfg` (`cwd=...`) or ctl (`cwd [<dir>|-]`); the GUI Agent Settings dialog shows `(inherit: <sessionCwd>)` as the placeholder.
14. **Chat log files**: Each agent exposes multiple views of its conversation history. `chat` and `chat.raw` are **blocking streams** — reads wait for new data, making them suitable for live tailing but not for one-shot inspection. Use `log` for non-blocking reads (returns last 64KB). `chat.search` is an rdwr file: write a block ID, read the matching block. Block IDs are 8-char hex strings generated deterministically from `sha256(sessionID + agentID + counter)`. Block format: `[[[role:name#blockID]]]` header, content, `[[[end]]]` terminator. 14. **Chat log files**: Each agent exposes three views of its conversation history via the 9P namespace:
- `log.raw` — JSONL stream (one JSON object per line). Blocking; suitable for live tailing. Each line is `{"role":"...","id":"...","content":"..."}` with optional `name`, `format`, and `partial` fields. Partial blocks (`"partial":true`) represent in-progress streaming; they are replaced by the final block when complete.
- `log` — Rendered text (last 64KB). Non-blocking; suitable for one-shot inspection. Filters out reasoning, call, and tool blocks; shows only user/assistant/context content.
- `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)`.
## Where to Start ## Where to Start

View File

@ -68,10 +68,12 @@ type Agent struct {
onProcStart func(agentID string, pid int, tool, cmd string) // optional callback for proc start onProcStart func(agentID string, pid int, tool, cmd string) // optional callback for proc start
onClear func(agentID string) // optional callback for clear onClear func(agentID string) // optional callback for clear
// Chat log — the rendered output stream, exposed via 9P. // Chat logs — dual format: JSONL for programmatic access, plain text for humans.
chatMu sync.RWMutex chatMu sync.RWMutex
chatLog []byte // append-only chat log buffer rawLog []byte // JSONL format (one Block per line)
chatStart int // byte offset of first byte in chatLog (for truncation accounting) rawStart int // byte offset of first byte in rawLog (for truncation accounting)
textLog []byte // Rendered plain text
textStart int // byte offset of first byte in textLog
chatVers uint32 // incremented on each append for change detection chatVers uint32 // incremented on each append for change detection
chatCond *sync.Cond // signaled on new chat data chatCond *sync.Cond // signaled on new chat data
chatSignalMu sync.Mutex chatSignalMu sync.Mutex
@ -313,8 +315,10 @@ func (ag *Agent) Clear() error {
} }
ag.history = nil ag.history = nil
ag.chatMu.Lock() ag.chatMu.Lock()
ag.chatLog = nil ag.rawLog = nil
ag.chatStart = 0 ag.rawStart = 0
ag.textLog = nil
ag.textStart = 0
ag.chatVers++ ag.chatVers++
ag.chatMu.Unlock() ag.chatMu.Unlock()
ag.chatCond.Broadcast() ag.chatCond.Broadcast()

View File

@ -1,8 +1,10 @@
// chat.go — Chat log storage and streaming. // chat.go — Chat log storage and streaming.
// //
// The chat log is an append-only buffer of formatted output. Readers can // Two logs are maintained:
// stream from any offset; StreamChat returns a read function that blocks // - rawLog: JSONL format (one Block per line) for programmatic access
// until data is available. // - textLog: Rendered plain text for human reading
//
// Both are append-only buffers with overflow handling.
package agent package agent
@ -13,16 +15,14 @@ import (
"fmt" "fmt"
"sync" "sync"
"sync/atomic" "sync/atomic"
"ollie/format"
) )
// --- Chat log methods --- const maxLogBytes = 16 * 1024 * 1024
const maxChatLogBytes = 16 * 1024 * 1024
// NextBlockID returns the next deterministic block ID and increments the counter. // NextBlockID returns the next deterministic block ID and increments the counter.
// Block IDs are sha256(sessionID + agentID + counter)[:8] — deterministic given // Block IDs are sha256(sessionID + agentID + counter)[:8].
// the same session, agent, and sequence. Counter starts at 0 for fresh agents,
// or at the replayed block count after session restore.
func (ag *Agent) NextBlockID() string { func (ag *Agent) NextBlockID() string {
n := atomic.AddUint64(&ag.blockCounter, 1) - 1 n := atomic.AddUint64(&ag.blockCounter, 1) - 1
h := sha256.New() h := sha256.New()
@ -34,20 +34,17 @@ func (ag *Agent) NextBlockID() string {
return hex.EncodeToString(h.Sum(nil))[:8] return hex.EncodeToString(h.Sum(nil))[:8]
} }
// AppendChat appends data to the chat log and notifies stream readers. // AppendBlock writes a block to both logs and notifies stream readers.
func (ag *Agent) AppendChat(data []byte) { func (ag *Agent) AppendBlock(b format.Block) {
if len(data) == 0 { raw := format.MarshalBlock(b)
return text := []byte(format.RenderBlock(b))
}
ag.chatMu.Lock() ag.chatMu.Lock()
ag.chatLog = append(ag.chatLog, data...) ag.rawLog = appendWithOverflow(ag.rawLog, raw, maxLogBytes, &ag.rawStart)
if len(ag.chatLog) > maxChatLogBytes { ag.textLog = appendWithOverflow(ag.textLog, text, maxLogBytes, &ag.textStart)
drop := len(ag.chatLog) - maxChatLogBytes
ag.chatLog = append([]byte(nil), ag.chatLog[drop:]...)
ag.chatStart += drop
}
ag.chatVers++ ag.chatVers++
ag.chatMu.Unlock() ag.chatMu.Unlock()
ag.chatCond.Broadcast() ag.chatCond.Broadcast()
ag.chatSignalMu.Lock() ag.chatSignalMu.Lock()
close(ag.chatSignalCh) close(ag.chatSignalCh)
@ -55,14 +52,14 @@ func (ag *Agent) AppendChat(data []byte) {
ag.chatSignalMu.Unlock() ag.chatSignalMu.Unlock()
} }
// EnsureTrailingNewline appends a newline if the log doesn't end with one. func appendWithOverflow(log, data []byte, max int, start *int) []byte {
func (ag *Agent) EnsureTrailingNewline() { log = append(log, data...)
ag.chatMu.Lock() if len(log) > max {
if len(ag.chatLog) > 0 && ag.chatLog[len(ag.chatLog)-1] != '\n' { drop := len(log) - max
ag.chatLog = append(ag.chatLog, '\n') log = append([]byte(nil), log[drop:]...)
*start += drop
} }
ag.chatVers++ return log
ag.chatMu.Unlock()
} }
// ChatMu returns the chat log mutex for external locking (streaming). // ChatMu returns the chat log mutex for external locking (streaming).
@ -79,72 +76,71 @@ func (ag *Agent) ChatSignal() <-chan struct{} {
return ch return ch
} }
// ChatLog returns the raw chat log bytes (caller must hold ChatMu.RLock). // RawLog returns the JSONL log bytes (caller must hold ChatMu.RLock).
func (ag *Agent) ChatLog() []byte { return ag.chatLog } func (ag *Agent) RawLog() []byte { return ag.rawLog }
// ChatRead returns new chat data since the given offset (base). // TextLog returns the rendered text log bytes (caller must hold ChatMu.RLock).
// Returns (data, nextBase, error). If no new data, returns empty data. func (ag *Agent) TextLog() []byte { return ag.textLog }
func (ag *Agent) ChatRead(base string) ([]byte, string, error) {
// RawLogRead returns new JSONL data since the given offset.
func (ag *Agent) RawLogRead(base string) ([]byte, string, error) {
return ag.logRead(base, &ag.rawLog, &ag.rawStart)
}
// TextLogRead returns new rendered text since the given offset.
func (ag *Agent) TextLogRead(base string) ([]byte, string, error) {
return ag.logRead(base, &ag.textLog, &ag.textStart)
}
func (ag *Agent) logRead(base string, log *[]byte, start *int) ([]byte, string, error) {
var offset int var offset int
if base != "" { if base != "" {
fmt.Sscanf(base, "%d", &offset) fmt.Sscanf(base, "%d", &offset)
} else { } else {
// Empty base = start from beginning (for replay)
ag.chatMu.RLock() ag.chatMu.RLock()
offset = len(ag.chatLog) + ag.chatStart offset = *start
ag.chatMu.RUnlock() ag.chatMu.RUnlock()
} }
ag.chatMu.RLock() ag.chatMu.RLock()
if offset < ag.chatStart { if offset < *start {
offset = ag.chatStart offset = *start
} }
localOffset := offset - ag.chatStart localOffset := offset - *start
log := ag.chatLog if len(*log) <= localOffset {
if len(log) <= localOffset {
ag.chatMu.RUnlock() ag.chatMu.RUnlock()
return nil, fmt.Sprintf("%d", offset), nil return nil, fmt.Sprintf("%d", offset), nil
} }
data := make([]byte, len(log)-localOffset) data := make([]byte, len(*log)-localOffset)
copy(data, log[localOffset:]) copy(data, (*log)[localOffset:])
newOffset := ag.chatStart + len(log) newOffset := *start + len(*log)
ag.chatMu.RUnlock() ag.chatMu.RUnlock()
return data, fmt.Sprintf("%d", newOffset), nil return data, fmt.Sprintf("%d", newOffset), nil
} }
// ChatSearchByID returns the block content for the given block ID. // BlockByID returns the block with the given ID from the raw log.
// Returns the block text (without markers) and true if found, empty and false otherwise. func (ag *Agent) BlockByID(blockID string) (format.Block, bool) {
func (ag *Agent) ChatSearchByID(blockID string) ([]byte, bool) {
if blockID == "" { if blockID == "" {
return nil, false return format.Block{}, false
} }
ag.chatMu.RLock() ag.chatMu.RLock()
log := ag.chatLog raw := ag.rawLog
ag.chatMu.RUnlock() ag.chatMu.RUnlock()
// Parse blocks looking for the target ID // Scan JSONL lines for matching ID
// Format: [[[role:name#id]]] ... [[[end]]] for _, line := range bytes.Split(raw, []byte("\n")) {
target := "#" + blockID + "]]]" if len(line) == 0 {
idx := bytes.Index(log, []byte(target)) continue
if idx < 0 { }
return nil, false b, err := format.UnmarshalBlock(line)
if err != nil {
continue
}
if b.ID == blockID && !b.Partial {
return b, true
}
} }
return format.Block{}, false
// Find the start of this block header
headerStart := bytes.LastIndex(log[:idx], []byte("[[["))
if headerStart < 0 {
return nil, false
}
// Find [[[end]]] after the header
endMarker := []byte("[[[end]]]")
endIdx := bytes.Index(log[idx:], endMarker)
if endIdx < 0 {
return nil, false
}
blockEnd := idx + endIdx + len(endMarker)
// Extract block including header and end marker
block := log[headerStart:blockEnd]
return block, true
} }
// --- Plan methods --- // --- Plan methods ---

View File

@ -1,9 +1,10 @@
package agent package agent
import ( import (
"strings"
"sync" "sync"
"testing" "testing"
"ollie/format"
) )
// testAgent creates a minimal Agent for chat/block testing. // testAgent creates a minimal Agent for chat/block testing.
@ -81,192 +82,94 @@ func TestNextBlockID_Length(t *testing.T) {
} }
} }
func TestChatSearchByID_Found(t *testing.T) { func TestAppendBlock_JSONL(t *testing.T) {
ag := testAgent("session1", "agent1") ag := testAgent("session1", "agent1")
// Simulate chat log with blocks ag.AppendBlock(format.Block{Role: "user", ID: "abc12345", Content: "Hello"})
chatData := `[[[user#abc12345]]] ag.AppendBlock(format.Block{Role: "assistant", ID: "def67890", Content: "Hi there"})
Hello ag.AppendBlock(format.Block{Role: "tool", ID: "11223344", Content: "output"})
[[[end]]]
[[[assistant#def67890]]]
Hi there
[[[end]]]
[[[tool:shell#11223344]]]
` + "```" + `
output here
` + "```" + `
[[[end]]]
`
ag.AppendChat([]byte(chatData))
tests := []struct { ag.ChatMu().RLock()
blockID string raw := ag.RawLog()
wantFound bool ag.ChatMu().RUnlock()
wantPart string
}{ // Should contain valid JSONL
{"abc12345", true, "[[[user#abc12345]]]"}, if len(raw) == 0 {
{"def67890", true, "[[[assistant#def67890]]]"}, t.Fatal("raw log should not be empty")
{"11223344", true, "[[[tool:shell#11223344]]]"},
{"notfound", false, ""},
{"", false, ""},
} }
for _, tt := range tests { // Parse and verify
block, found := ag.ChatSearchByID(tt.blockID) lines := 0
if found != tt.wantFound { for _, line := range splitLines(raw) {
t.Errorf("ChatSearchByID(%q): got found=%v, want %v", tt.blockID, found, tt.wantFound) if len(line) == 0 {
continue continue
} }
if found && !strings.Contains(string(block), tt.wantPart) { lines++
t.Errorf("ChatSearchByID(%q): block missing %q, got:\n%s", tt.blockID, tt.wantPart, block) b, err := format.UnmarshalBlock(line)
if err != nil {
t.Errorf("failed to parse JSONL line: %v", err)
} }
if b.ID == "" {
t.Error("block should have ID")
}
}
if lines != 3 {
t.Errorf("expected 3 blocks, got %d", lines)
} }
} }
func TestChatSearchByID_ReturnsFullBlock(t *testing.T) { func TestBlockByID(t *testing.T) {
ag := testAgent("session1", "agent1") ag := testAgent("session1", "agent1")
chatData := `[[[user#aabbccdd]]] ag.AppendBlock(format.Block{Role: "user", ID: "abc12345", Content: "Hello"})
test content ag.AppendBlock(format.Block{Role: "assistant", ID: "def67890", Content: "Hi there"})
more lines
[[[end]]]
`
ag.AppendChat([]byte(chatData))
block, found := ag.ChatSearchByID("aabbccdd") // Find existing block
block, found := ag.BlockByID("def67890")
if !found { if !found {
t.Fatal("block should be found") t.Fatal("block should be found")
} }
if block.Role != "assistant" || block.Content != "Hi there" {
blockStr := string(block) t.Errorf("wrong block content: %+v", block)
if !strings.HasPrefix(blockStr, "[[[user#aabbccdd]]]") {
t.Errorf("block should start with header, got: %s", blockStr)
} }
if !strings.HasSuffix(blockStr, "[[[end]]]") {
t.Errorf("block should end with end marker, got: %s", blockStr)
}
if !strings.Contains(blockStr, "test content") {
t.Errorf("block should contain content")
}
}
func TestChatSearchByID_PartialMatch(t *testing.T) { // Not found
ag := testAgent("session1", "agent1") _, found = ag.BlockByID("notfound")
// Block ID is full 8 chars
chatData := `[[[user#12345678]]]
content
[[[end]]]
`
ag.AppendChat([]byte(chatData))
// Should not find partial match
_, found := ag.ChatSearchByID("1234")
if found { if found {
t.Error("partial block ID should not match") t.Error("nonexistent block should not be found")
} }
// Should find exact match // Empty ID
_, found = ag.ChatSearchByID("12345678") _, found = ag.BlockByID("")
if !found {
t.Error("exact block ID should match")
}
}
func TestChatSearchByID_MultipleBlocks(t *testing.T) {
ag := testAgent("session1", "agent1")
// Multiple blocks with different IDs
chatData := `[[[user#aaaaaaaa]]]
first
[[[end]]]
[[[assistant#bbbbbbbb]]]
second
[[[end]]]
[[[user#cccccccc]]]
third
[[[end]]]
`
ag.AppendChat([]byte(chatData))
// Search for middle block
block, found := ag.ChatSearchByID("bbbbbbbb")
if !found {
t.Fatal("middle block should be found")
}
blockStr := string(block)
if !strings.Contains(blockStr, "[[[assistant#bbbbbbbb]]]") {
t.Errorf("should find correct block, got: %s", blockStr)
}
if strings.Contains(blockStr, "first") || strings.Contains(blockStr, "third") {
t.Errorf("should not include content from other blocks")
}
}
func TestChatSearchByID_NoEndMarker(t *testing.T) {
ag := testAgent("session1", "agent1")
// Block without end marker (incomplete/streaming)
chatData := `[[[user#12345678]]]
content
`
ag.AppendChat([]byte(chatData))
// Should not find block without end marker
_, found := ag.ChatSearchByID("12345678")
if found { if found {
t.Error("block without end marker should not be found") t.Error("empty block ID should not be found")
} }
} }
func TestChatSearchByID_NamedBlock(t *testing.T) { func TestBlockByID_SkipsPartial(t *testing.T) {
ag := testAgent("session1", "agent1") ag := testAgent("session1", "agent1")
// Block with name component (role:name#id format) // Add partial block first
chatData := `[[[tool:file_read#abcd1234]]] ag.AppendBlock(format.Block{Role: "assistant", ID: "abc12345", Content: "H", Partial: true})
` + "```" + ` // Then final
file contents ag.AppendBlock(format.Block{Role: "assistant", ID: "abc12345", Content: "Hello"})
` + "```" + `
[[[end]]]
`
ag.AppendChat([]byte(chatData))
block, found := ag.ChatSearchByID("abcd1234") block, found := ag.BlockByID("abc12345")
if !found { if !found {
t.Fatal("named block should be found") t.Fatal("block should be found")
} }
if !strings.Contains(string(block), "tool:file_read") { if block.Content != "Hello" {
t.Errorf("block should include role:name, got: %s", block) t.Errorf("should find final block, not partial: %+v", block)
} }
} }
func TestChatSearchByID_ResponseIDBlock(t *testing.T) { func TestAppendBlock_SignalsBroadcast(t *testing.T) {
ag := testAgent("session1", "agent1")
// Assistant block with response ID (assistant:responseID#blockID format)
chatData := `[[[assistant:resp_123#aabbccdd]]]
response content
[[[end]]]
`
ag.AppendChat([]byte(chatData))
block, found := ag.ChatSearchByID("aabbccdd")
if !found {
t.Fatal("assistant block with response ID should be found")
}
if !strings.Contains(string(block), "assistant:resp_123#aabbccdd") {
t.Errorf("block should include response ID, got: %s", block)
}
}
func TestAppendChat_SignalsBroadcast(t *testing.T) {
ag := testAgent("session1", "agent1") ag := testAgent("session1", "agent1")
signalCh := ag.ChatSignal() signalCh := ag.ChatSignal()
// Append data // Append block
ag.AppendChat([]byte("test")) ag.AppendBlock(format.Block{Role: "user", ID: "test", Content: "hi"})
// Signal channel should be closed // Signal channel should be closed
select { select {
@ -286,36 +189,99 @@ func TestAppendChat_SignalsBroadcast(t *testing.T) {
} }
} }
func TestChatRead_Streaming(t *testing.T) { func TestRawLogRead_Streaming(t *testing.T) {
ag := testAgent("session1", "agent1") ag := testAgent("session1", "agent1")
// Initial read with no base - should return empty and current offset // Initial read with no base - should return empty and current offset
data, base, err := ag.ChatRead("") data, base, err := ag.RawLogRead("")
if err != nil { if err != nil {
t.Fatalf("ChatRead error: %v", err) t.Fatalf("RawLogRead error: %v", err)
} }
if len(data) != 0 { if len(data) != 0 {
t.Errorf("initial read should be empty, got: %s", data) t.Errorf("initial read should be empty, got: %s", data)
} }
// Append some data // Append a block
ag.AppendChat([]byte("hello")) ag.AppendBlock(format.Block{Role: "user", ID: "test", Content: "hello"})
// Read with previous base - should get new data // Read with previous base - should get new data
data, newBase, err := ag.ChatRead(base) data, newBase, err := ag.RawLogRead(base)
if err != nil { if err != nil {
t.Fatalf("ChatRead error: %v", err) t.Fatalf("RawLogRead error: %v", err)
} }
if string(data) != "hello" { if len(data) == 0 {
t.Errorf("expected 'hello', got: %s", data) t.Error("should have new data")
}
// Verify it's valid JSON
b, err := format.UnmarshalBlock(data[:len(data)-1]) // strip trailing newline
if err != nil {
t.Errorf("data should be valid JSONL: %v", err)
}
if b.Content != "hello" {
t.Errorf("wrong content: %s", b.Content)
} }
// Read again with new base - should be empty // Read again with new base - should be empty
data, _, err = ag.ChatRead(newBase) data, _, err = ag.RawLogRead(newBase)
if err != nil { if err != nil {
t.Fatalf("ChatRead error: %v", err) t.Fatalf("RawLogRead error: %v", err)
} }
if len(data) != 0 { if len(data) != 0 {
t.Errorf("no new data, should be empty, got: %s", data) t.Errorf("no new data, should be empty, got: %s", data)
} }
} }
func TestTextLog_RendersHumanReadable(t *testing.T) {
ag := testAgent("session1", "agent1")
ag.AppendBlock(format.Block{Role: "user", ID: "a", Content: "Hello"})
ag.AppendBlock(format.Block{Role: "assistant", ID: "b", Content: "Hi there"})
ag.AppendBlock(format.Block{Role: "call", ID: "c", Name: "shell", Content: `{"cmd":"ls"}`})
ag.ChatMu().RLock()
text := string(ag.TextLog())
ag.ChatMu().RUnlock()
// Should have user and assistant content
if !contains(text, "Hello") {
t.Error("should contain user message")
}
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")
}
}
// Helper to split JSONL
func splitLines(data []byte) [][]byte {
var lines [][]byte
start := 0
for i, b := range data {
if b == '\n' {
lines = append(lines, data[start:i])
start = i + 1
}
}
if start < len(data) {
lines = append(lines, data[start:])
}
return lines
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && findSubstring(s, substr)))
}
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@ -1,138 +1,158 @@
// chatlog.go — Chat output formatting and event handling. // chatlog.go — Chat output formatting and event handling.
// //
// Converts agent events (streaming text, tool calls, tool results) into // Converts agent events (streaming text, tool calls, tool results) into
// formatted output written to the chat log. The output handler is set up // JSONL blocks written to the chat log. Handles streaming by emitting
// once at agent construction. // partial blocks that are replaced by final blocks.
package agent package agent
import ( import (
"fmt" "strings"
"ollie/cmd/olliesrv/internal/backend" "ollie/cmd/olliesrv/internal/backend"
"ollie/format" "ollie/format"
) )
// initChatHandler sets up the agent's output handler to write to its own chat log. // initChatHandler sets up the agent's output handler to write to its own chat log.
// Called once at construction.
func (ag *Agent) initChatHandler() { func (ag *Agent) initChatHandler() {
var streamingRole string var streamingRole string
var streamingID string
var streamingContent strings.Builder
var streamingResponseID string var streamingResponseID string
var streamingFence string var streamingFormat string
var streamingBlockID string
// flushPartial emits a partial block with current content.
flushPartial := func() {
if streamingID == "" {
return
}
ag.AppendBlock(format.Block{
Role: streamingRole,
ID: streamingID,
Content: streamingContent.String(),
Format: streamingFormat,
Partial: true,
})
}
// closeBlock emits the final block and resets streaming state.
closeBlock := func() {
if streamingID == "" {
return
}
ag.AppendBlock(format.Block{
Role: streamingRole,
ID: streamingID,
Content: streamingContent.String(),
Format: streamingFormat,
Partial: false,
})
streamingRole = ""
streamingID = ""
streamingContent.Reset()
streamingFormat = ""
}
ag.output = func(ev Event) { ag.output = func(ev Event) {
switch ev.Role { switch ev.Role {
case "assistant": case "assistant":
if streamingRole != ev.Role || (ev.Role == "assistant" && ev.ResponseID != streamingResponseID) { // New response or different response ID = new block
if streamingRole != "" { if streamingRole != ev.Role || ev.ResponseID != streamingResponseID {
if streamingFence != "" { if streamingID != "" {
ag.AppendChat([]byte("\n" + streamingFence + "\n")) closeBlock()
streamingFence = ""
}
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
}
blockID := ag.NextBlockID()
if ev.Role == "assistant" && ev.ResponseID != "" {
// Include both responseID and blockID: [[[assistant:responseID#blockID]]]
ag.AppendChat([]byte(fmt.Sprintf("[[[assistant:%s#%s]]]\n", ev.ResponseID, blockID)))
streamingResponseID = ev.ResponseID
} else {
ag.AppendChat([]byte(fmt.Sprintf("[[[assistant#%s]]]\n", blockID)))
} }
streamingRole = ev.Role streamingRole = ev.Role
streamingBlockID = blockID streamingID = ag.NextBlockID()
streamingResponseID = ev.ResponseID
streamingContent.Reset()
} }
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, "")) streamingContent.WriteString(ev.Content)
flushPartial()
case "reasoning": case "reasoning":
if streamingRole != ev.Role { if streamingRole != ev.Role {
if streamingRole != "" { if streamingID != "" {
if streamingFence != "" { closeBlock()
ag.AppendChat([]byte("\n" + streamingFence + "\n"))
streamingFence = ""
}
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
} }
blockID := ag.NextBlockID()
ag.AppendChat([]byte(fmt.Sprintf("[[[reasoning#%s]]]\n", blockID)))
streamingRole = ev.Role streamingRole = ev.Role
streamingBlockID = blockID streamingID = ag.NextBlockID()
streamingContent.Reset()
} }
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, "")) streamingContent.WriteString(ev.Content)
flushPartial()
case "tool": case "tool":
if streamingRole == "tool" { if streamingRole == "tool" {
if ev.Content == "" { if ev.Content == "" {
if streamingFence != "" { // Empty content = end of streaming tool output
ag.AppendChat([]byte("\n" + streamingFence + "\n")) closeBlock()
streamingFence = ""
}
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
streamingRole = ""
} else { } else {
ag.AppendChat([]byte(ev.Content)) // Append to streaming tool content
streamingContent.WriteString(ev.Content)
flushPartial()
} }
return return
} }
if streamingRole != "" { if streamingID != "" {
if streamingFence != "" { closeBlock()
ag.AppendChat([]byte("\n" + streamingFence + "\n"))
streamingFence = ""
}
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
streamingRole = ""
} }
if ev.Content == "" { if ev.Content == "" {
// Streaming tool output: write header with blockID // Start streaming tool output
blockID := ag.NextBlockID()
ag.AppendChat([]byte(fmt.Sprintf("[[[tool:%s#%s]]]\n```%s\n", ev.Name, blockID, ev.OutputFormat)))
streamingFence = format.ToolFence
streamingRole = "tool" streamingRole = "tool"
streamingBlockID = blockID streamingID = ag.NextBlockID()
streamingFormat = ev.OutputFormat
streamingContent.Reset()
} else { } else {
// Complete tool output in one event // Complete tool output in one event
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ag.NextBlockID())) ag.AppendBlock(format.Block{
Role: "tool",
ID: ag.NextBlockID(),
Content: ev.Content,
Format: ev.OutputFormat,
})
} }
case "retry": case "retry":
if streamingRole == "retry" { if streamingRole == "retry" {
ag.AppendChat([]byte("\n" + ev.Content)) streamingContent.WriteString("\n" + ev.Content)
flushPartial()
return return
} }
if streamingRole != "" { if streamingID != "" {
ag.AppendChat([]byte("\n" + format.EndTag + "\n")) closeBlock()
streamingRole = ""
} }
blockID := ag.NextBlockID()
ag.AppendChat([]byte(fmt.Sprintf("[[[retry#%s]]]\n", blockID)))
streamingRole = "retry" streamingRole = "retry"
streamingBlockID = blockID streamingID = ag.NextBlockID()
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, "")) streamingContent.Reset()
streamingContent.WriteString(ev.Content)
flushPartial()
case "call": case "call":
if streamingRole != "" { if streamingID != "" {
if streamingFence != "" { closeBlock()
ag.AppendChat([]byte("\n" + streamingFence + "\n"))
streamingFence = ""
}
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
streamingRole = ""
} }
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ag.NextBlockID())) ag.AppendBlock(format.Block{
Role: "call",
ID: ag.NextBlockID(),
Name: ev.Name,
Content: ev.Content,
})
case "state", "usage", "limitretry":
// Internal events for status/stats, not chat display — skip
return
default: default:
if streamingRole != "" { // error, info, stalled, etc.
if streamingFence != "" { if streamingID != "" {
ag.AppendChat([]byte("\n" + streamingFence + "\n")) closeBlock()
streamingFence = ""
}
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
streamingRole = ""
} }
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ag.NextBlockID())) ag.AppendBlock(format.Block{
Role: ev.Role,
ID: ag.NextBlockID(),
Content: ev.Content,
})
} }
_ = streamingBlockID // suppress unused warning (available for future use)
} }
} }
@ -147,45 +167,43 @@ func (ag *Agent) ReplayMessages(messages []backend.Message) {
start++ start++
} }
// Build a lookup from ToolCallID → tool name.
callNames := make(map[string]string)
for _, m := range messages[start:] {
for _, tc := range m.ToolCalls {
if tc.ID != "" {
callNames[tc.ID] = tc.Name
}
}
}
for _, m := range messages[start:] { for _, m := range messages[start:] {
switch m.Role { switch m.Role {
case "system": case "system":
// Skip
case "user": case "user":
ag.AppendChat(format.FormatEvent("user", "", m.Content, "", ag.NextBlockID())) ag.AppendBlock(format.Block{
Role: "user",
ID: ag.NextBlockID(),
Content: m.Content,
})
case "assistant": case "assistant":
if m.Reasoning != "" { if m.Reasoning != "" {
blockID := ag.NextBlockID() ag.AppendBlock(format.Block{
ag.AppendChat([]byte(fmt.Sprintf("[[[reasoning#%s]]]\n%s\n%s\n", blockID, m.Reasoning, format.EndTag))) Role: "reasoning",
ID: ag.NextBlockID(),
Content: m.Reasoning,
})
} }
blockID := ag.NextBlockID() ag.AppendBlock(format.Block{
if m.ID != "" { Role: "assistant",
ag.AppendChat([]byte(fmt.Sprintf("[[[assistant:%s#%s]]]\n", m.ID, blockID))) ID: ag.NextBlockID(),
} else { Content: m.Content,
ag.AppendChat([]byte(fmt.Sprintf("[[[assistant#%s]]]\n", blockID))) })
}
if m.Content != "" {
ag.AppendChat([]byte(m.Content + "\n"))
}
ag.AppendChat([]byte(format.EndTag + "\n"))
for _, tc := range m.ToolCalls { for _, tc := range m.ToolCalls {
ag.AppendChat(format.FormatEvent("call", tc.Name, string(tc.Arguments), "", ag.NextBlockID())) ag.AppendBlock(format.Block{
Role: "call",
ID: ag.NextBlockID(),
Name: tc.Name,
Content: string(tc.Arguments),
})
} }
case "tool": case "tool":
name := callNames[m.ToolCallID] ag.AppendBlock(format.Block{
if name == "" { Role: "tool",
name = m.ToolCallID ID: ag.NextBlockID(),
} Content: m.Content,
ag.AppendChat(format.FormatEvent("tool", name, m.Content, "", ag.NextBlockID())) })
} }
} }
} }

View File

@ -147,11 +147,16 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
contextParts = append(contextParts, toolHints) contextParts = append(contextParts, toolHints)
} }
} }
if len(contextParts) > 0 {
input = "<context>\n" + strings.Join(contextParts, "\n") + "\n</context>\n\n" + input
}
ag.emit(Event{Role: "user", Content: input}) // Emit context as separate block (filtered by GUI), then user message.
// History still gets the combined input for LLM context.
userInput := input
if len(contextParts) > 0 {
contextBlock := strings.Join(contextParts, "\n")
ag.emit(Event{Role: "context", Content: contextBlock})
input = "<context>\n" + contextBlock + "\n</context>\n\n" + userInput
}
ag.emit(Event{Role: "user", Content: userInput})
// Snapshot messages before this turn modifies them. Restored on context // Snapshot messages before this turn modifies them. Restored on context
// overflow so the retry starts from a clean state. // overflow so the retry starts from a clean state.

View File

@ -3,11 +3,11 @@
# Requires: running olliesrv, existing agent with chat history # Requires: running olliesrv, existing agent with chat history
# #
# This test verifies: # This test verifies:
# 1. Block IDs can be extracted from chat log # 1. Block IDs can be extracted from log.raw (JSONL)
# 2. chat.search finds blocks by exact ID # 2. block file returns JSON for exact ID
# 3. Partial IDs are rejected # 3. Partial IDs are rejected
# 4. Non-existent IDs return proper error # 4. Non-existent IDs return proper error
# 5. Different block types (user, assistant, call, tool, error) work # 5. Different block types (user, assistant, call, tool) work
set -e set -e
@ -16,25 +16,26 @@ AGENT="src:ollie"
echo "=== Block ID E2E Test ===" echo "=== Block ID E2E Test ==="
# Get some block IDs from the log # Get some block IDs from the JSONL log
echo "Extracting block IDs from log..." echo "Extracting block IDs from log.raw..."
BLOCK_IDS=$(o read session/$SESSION/agent/$AGENT/log 2>&1 | strings | grep -oE '\[\[\[[a-z]+[:#][^]]+#([a-f0-9]{8})\]\]\]' | sed 's/.*#\([a-f0-9]\{8\}\)\]\]\]/\1/' | tail -5) BLOCK_IDS=$(o read session/$SESSION/agent/$AGENT/log.raw 2>&1 | grep -oE '"id":"[a-f0-9]{8}"' | sed 's/"id":"//;s/"//' | tail -5)
if [ -z "$BLOCK_IDS" ]; then if [ -z "$BLOCK_IDS" ]; then
echo "FAIL: No block IDs found in log" echo "FAIL: No block IDs found in log.raw"
exit 1 exit 1
fi fi
echo "Found block IDs: $(echo $BLOCK_IDS | tr '\n' ' ')" echo "Found block IDs: $(echo $BLOCK_IDS | tr '\n' ' ')"
# Test 1: Search for existing blocks # Test 1: Lookup existing blocks
echo "" echo ""
echo "Test 1: Search for existing blocks..." echo "Test 1: Lookup existing blocks via block file..."
for bid in $BLOCK_IDS; do for bid in $BLOCK_IDS; do
result=$(o rdwr session/$SESSION/agent/$AGENT/chat.search "$bid" 2>&1) result=$(o rdwr session/$SESSION/agent/$AGENT/block "$bid" 2>&1)
if echo "$result" | grep -q "\[\[\[.*#$bid\]\]\]"; then if echo "$result" | grep -q '"id":"'$bid'"'; then
echo " PASS: Found block $bid" echo " PASS: Found block $bid"
else else
echo " FAIL: Block $bid not found or malformed" echo " FAIL: Block $bid not found or malformed"
echo " Got: $result"
exit 1 exit 1
fi fi
done done
@ -44,7 +45,7 @@ echo ""
echo "Test 2: Partial ID rejection..." echo "Test 2: Partial ID rejection..."
FIRST_ID=$(echo "$BLOCK_IDS" | head -1) FIRST_ID=$(echo "$BLOCK_IDS" | head -1)
PARTIAL=${FIRST_ID:0:4} PARTIAL=${FIRST_ID:0:4}
if o rdwr session/$SESSION/agent/$AGENT/chat.search "$PARTIAL" 2>&1 | grep -q "block not found"; then if o rdwr session/$SESSION/agent/$AGENT/block "$PARTIAL" 2>&1 | grep -q "block not found"; then
echo " PASS: Partial ID '$PARTIAL' correctly rejected" echo " PASS: Partial ID '$PARTIAL' correctly rejected"
else else
echo " FAIL: Partial ID '$PARTIAL' should be rejected" echo " FAIL: Partial ID '$PARTIAL' should be rejected"
@ -54,22 +55,22 @@ fi
# Test 3: Non-existent ID # Test 3: Non-existent ID
echo "" echo ""
echo "Test 3: Non-existent ID..." echo "Test 3: Non-existent ID..."
if o rdwr session/$SESSION/agent/$AGENT/chat.search "deadbeef" 2>&1 | grep -q "block not found"; then if o rdwr session/$SESSION/agent/$AGENT/block "deadbeef" 2>&1 | grep -q "block not found"; then
echo " PASS: Non-existent ID 'deadbeef' correctly rejected" echo " PASS: Non-existent ID 'deadbeef' correctly rejected"
else else
echo " FAIL: Non-existent ID should return error" echo " FAIL: Non-existent ID should return error"
exit 1 exit 1
fi fi
# Test 4: Block content includes header and end marker # Test 4: Block JSON structure validation
echo "" echo ""
echo "Test 4: Block structure validation..." echo "Test 4: Block JSON structure validation..."
FIRST_ID=$(echo "$BLOCK_IDS" | head -1) FIRST_ID=$(echo "$BLOCK_IDS" | head -1)
BLOCK=$(o rdwr session/$SESSION/agent/$AGENT/chat.search "$FIRST_ID" 2>&1) BLOCK=$(o rdwr session/$SESSION/agent/$AGENT/block "$FIRST_ID" 2>&1)
if echo "$BLOCK" | grep -q "^\[\[\[" && echo "$BLOCK" | grep -q "\[\[\[end\]\]\]$"; then if echo "$BLOCK" | grep -qE '^\{"role":"[^"]+","id":"[a-f0-9]{8}","content":'; then
echo " PASS: Block has proper header and end marker" echo " PASS: Block has proper JSON structure with role, id, content"
else else
echo " FAIL: Block missing header or end marker" echo " FAIL: Block missing required JSON fields"
echo " Got: $BLOCK" echo " Got: $BLOCK"
exit 1 exit 1
fi fi

View File

@ -20,6 +20,7 @@ import (
"ollie/cmd/olliesrv/internal/backend" "ollie/cmd/olliesrv/internal/backend"
"ollie/cmd/olliesrv/internal/metrics" "ollie/cmd/olliesrv/internal/metrics"
"ollie/cmd/olliesrv/internal/session" "ollie/cmd/olliesrv/internal/session"
"ollie/format"
"ollie/util" "ollie/util"
"ollie/virtfs" "ollie/virtfs"
) )
@ -687,15 +688,14 @@ func buildSessionChildren(
defer cancel() defer cancel()
session.PublishEvent("session."+s.ID+".agent."+ag.ID()+".new", "") session.PublishEvent("session."+s.ID+".agent."+ag.ID()+".new", "")
subPrompt := "You are a sub-agent. Your task is below. " + subPrompt := "You are a sub-agent. Your task is below. " +
"Execute it completely using tools — investigate, implement, verify. " + "Execute it completely using tools — investigate, implement, verify. " +
"Do NOT respond with a plan or intentions. Do NOT say what you will do. " + "Do NOT respond with a plan or intentions. Do NOT say what you will do. " +
"Call tools. Do the work. Your final text response must summarize what you ACCOMPLISHED.\n\n" + req.Prompt "Call tools. Do the work. Your final text response must summarize what you ACCOMPLISHED.\n\n" + req.Prompt
ag.Submit(subCtx, subPrompt) ag.Submit(subCtx, subPrompt)
ag.EnsureTrailingNewline() reply := ag.Reply()
reply := ag.Reply() s.RemoveAgent(ag.ID())
s.RemoveAgent(ag.ID()) session.PublishEvent("session."+s.ID+".agent."+ag.ID()+".kill", "")
session.PublishEvent("session."+s.ID+".agent."+ag.ID()+".kill", "")
if reply == "" { if reply == "" {
return []byte("(no reply)\n"), nil return []byte("(no reply)\n"), nil
@ -767,7 +767,6 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
} else { } else {
startAsync(s.Ctx, func() { startAsync(s.Ctx, func() {
a.Submit(s.Ctx, text) a.Submit(s.Ctx, text)
a.EnsureTrailingNewline()
}) })
} }
return []byte("ok\n"), nil return []byte("ok\n"), nil
@ -786,7 +785,6 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
} }
startAsync(s.Ctx, func() { startAsync(s.Ctx, func() {
a.Submit(s.Ctx, input) a.Submit(s.Ctx, input)
a.EnsureTrailingNewline()
}) })
return nil return nil
}), }),
@ -798,106 +796,90 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
return nil return nil
} }
if err := a.Queue(input); err != nil { if err := a.Queue(input); err != nil {
return err return err
}
// If the agent is idle, trigger processing.
if !a.IsRunning() {
if !startAsync(s.Ctx, func() {
if next, ok := a.PopQueue(); ok {
a.Submit(s.Ctx, next)
a.EnsureTrailingNewline()
}
}) {
return fmt.Errorf("async work limit reached")
} }
} // If the agent is idle, trigger processing.
return nil if !a.IsRunning() {
}), if !startAsync(s.Ctx, func() {
virtfs.Read(func() ([]byte, error) { if next, ok := a.PopQueue(); ok {
item, ok := a.PopQueue() a.Submit(s.Ctx, next)
if !ok { }
return nil, nil }) {
} return fmt.Errorf("async work limit reached")
return []byte(item), nil }
}), }
), return nil
virtfs.FileNode("chat", 0440, // read-only, owner + group }),
virtfs.StatOverride(chatStat), virtfs.Read(func() ([]byte, error) {
virtfs.Read(func() ([]byte, error) { item, ok := a.PopQueue()
a.ChatMu().RLock() if !ok {
defer a.ChatMu().RUnlock() return nil, nil
return stripMarkers(a.ChatLog()), nil }
}), return []byte(item), nil
virtfs.Stream(func(base string) ([]byte, string, error) { }),
data, nextBase, err := a.ChatRead(base)
if err != nil || len(data) == 0 {
return data, nextBase, err
}
stripped := stripMarkers(data)
if len(stripped) > 0 {
return stripped, nextBase, nil
}
return nil, nextBase, nil
}, a.ChatSignal),
),
virtfs.FileNode("chat.raw", 0440, // read-only, owner + group
virtfs.StatOverride(chatStat),
virtfs.Read(func() ([]byte, error) {
a.ChatMu().RLock()
defer a.ChatMu().RUnlock()
log := a.ChatLog()
data := make([]byte, len(log))
copy(data, log)
return data, nil
}),
virtfs.Stream(a.ChatRead, a.ChatSignal),
), ),
virtfs.FileNode("chat.search", 0660, // rdwr - write blockId, read block // log.raw — JSONL (one Block per line), for GUI/programmatic access
virtfs.Doc("Search chat by block ID. Write ID, read block content."), virtfs.FileNode("log.raw", 0440,
virtfs.Doc("JSONL chat log. One JSON block per line."),
virtfs.StatOverride(chatStat),
virtfs.Read(func() ([]byte, error) {
a.ChatMu().RLock()
defer a.ChatMu().RUnlock()
raw := a.RawLog()
data := make([]byte, len(raw))
copy(data, raw)
return data, nil
}),
),
// log — Rendered plain text for TUI/humans (non-blocking read)
virtfs.FileNode("log", 0440,
virtfs.Doc("Rendered chat log (plain text). Last 64KB."),
virtfs.Read(func() ([]byte, error) {
const maxWindow = 64 * 1024
a.ChatMu().RLock()
text := a.TextLog()
start := 0
if len(text) > maxWindow {
start = len(text) - maxWindow
}
data := make([]byte, len(text)-start)
copy(data, text[start:])
a.ChatMu().RUnlock()
return data, nil
}),
),
// block — Read block by ID (rdwr: write ID, read JSON block)
virtfs.FileNode("block", 0660,
virtfs.Doc("Get block by ID. Write block ID, read JSON block."),
virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) { virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
blockID := strings.TrimSpace(string(data)) blockID := strings.TrimSpace(string(data))
block, found := a.ChatSearchByID(blockID) block, found := a.BlockByID(blockID)
if !found { if !found {
return nil, fmt.Errorf("block not found: %s", blockID) return nil, fmt.Errorf("block not found: %s", blockID)
} }
return block, nil return format.MarshalBlock(block), nil
}), }),
), ),
virtfs.FileNode("state", 0444, // world-readable (non-sensitive) virtfs.FileNode("state", 0444, // world-readable (non-sensitive)
virtfs.Doc("Current agent state (idle, calling, thinking, paused)"), virtfs.Doc("Current agent state (idle, calling, thinking, paused)"),
virtfs.Read(func() ([]byte, error) { virtfs.Read(func() ([]byte, error) {
return []byte(a.State() + "\n"), nil return []byte(a.State() + "\n"), nil
}), }),
), ),
virtfs.FileNode("status", 0444, virtfs.FileNode("status", 0444,
virtfs.Doc("Human-readable status: current activity and elapsed time"), virtfs.Doc("Human-readable status: current activity and elapsed time"),
virtfs.Read(func() ([]byte, error) { virtfs.Read(func() ([]byte, error) {
return []byte(a.Status() + "\n"), nil return []byte(a.Status() + "\n"), nil
}), }),
), ),
virtfs.FileNode("log", 0440, // read-only, owner + group virtfs.FileNode("plan", 0660, // owner + group (frontend needs access)
virtfs.Read(func() ([]byte, error) { virtfs.Read(func() ([]byte, error) {
const maxWindow = 64 * 1024 return a.Plan(), nil
a.ChatMu().RLock() }),
log := a.ChatLog() virtfs.Write(func(data []byte) error {
start := 0 a.SetPlan(data)
if len(log) > maxWindow { return nil
start = len(log) - maxWindow }),
}
data := make([]byte, len(log)-start)
copy(data, log[start:])
a.ChatMu().RUnlock()
return data, nil
}),
),
virtfs.FileNode("plan", 0660, // owner + group (frontend needs access)
virtfs.Read(func() ([]byte, error) {
return a.Plan(), nil
}),
virtfs.Write(func(data []byte) error {
a.SetPlan(data)
return nil
}),
), ),
virtfs.FileNode("cfg", 0640, // owner rw, group r virtfs.FileNode("cfg", 0640, // owner rw, group r
virtfs.Read(func() ([]byte, error) { virtfs.Read(func() ([]byte, error) {
@ -965,21 +947,20 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
return fmt.Errorf("peer %q not found", name) return fmt.Errorf("peer %q not found", name)
} }
input := strings.TrimSpace(string(data)) input := strings.TrimSpace(string(data))
if input == "" { if input == "" {
return nil
}
startAsync(s.Ctx, func() {
target.Submit(s.Ctx, input)
})
return nil return nil
} },
startAsync(s.Ctx, func() { })
target.Submit(s.Ctx, input) }
target.EnsureTrailingNewline() return out, nil
}) }),
return nil virtfs.FileNode("ctl", 0660, // owner + group (frontend needs access)
}, virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
})
}
return out, nil
}),
virtfs.FileNode("ctl", 0660, // owner + group (frontend needs access)
virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
return dispatch([]ctlCmd{ return dispatch([]ctlCmd{
{"kill", "destroy this agent", func(_ []string) ([]byte, error) { {"kill", "destroy this agent", func(_ []string) ([]byte, error) {
s.RemoveAgent(a.ID()) s.RemoveAgent(a.ID())

View File

@ -205,8 +205,9 @@ Use `client_9p` for all 9P operations. Your session ID is `${OLLIE_SESSION_ID}`.
| `plan` | r/w | Markdown checklist; survives compaction | | `plan` | r/w | Markdown checklist; survives compaction |
| `prompt` | write | Submit a prompt to this agent | | `prompt` | write | Submit a prompt to this agent |
| `fifo` | r/w | Prompt queue. Write: enqueue. Read: dequeue. | | `fifo` | r/w | Prompt queue. Write: enqueue. Read: dequeue. |
| `chat` | read | Conversation (filtered text, streamable) | | `log` | read | Conversation (filtered text, last 64KB) |
| `chat.raw` | read | Full conversation with block markers | | `log.raw` | read | Full conversation as JSONL stream |
| `block` | rdwr | Lookup block by ID (write ID, read JSON) |
| `state` | read | Current agent state (idle, calling, thinking, paused) | | `state` | read | Current agent state (idle, calling, thinking, paused) |
| `cfg` | r/w | Agent config (key=value: backend, model, cwd, temperature, etc.) | | `cfg` | r/w | Agent config (key=value: backend, model, cwd, temperature, etc.) |
| `ctl` | rdwr | Control: stop, compact, clear, inject, agent, model, tools, tool_load, tool_unload, cwd, name, peeradd, peerdel, peers, proc | | `ctl` | rdwr | Control: stop, compact, clear, inject, agent, model, tools, tool_load, tool_unload, cwd, name, peeradd, peerdel, peers, proc |

View File

@ -4,7 +4,7 @@
# Then reload: cat $HOME/lib/plumbing | 9p write plumb/rules # Then reload: cat $HOME/lib/plumbing | 9p write plumb/rules
# ollie:// URLs go to the ollie port # ollie:// URLs go to the ollie port
# Format: ollie://session/agent#block # Format: ollie://session/agent#blockId (blockId is 8-char hex)
type is text type is text
data matches 'ollie://[a-zA-Z0-9_\-.:]+(/[a-zA-Z0-9_\-.:]+)?(#[0-9]+)?' data matches 'ollie://[a-zA-Z0-9_\-.:]+(/[a-zA-Z0-9_\-.:]+)?(#[a-f0-9]+)?'
plumb to ollie plumb to ollie

View File

@ -27,7 +27,7 @@
# o myproj ls List session contents # o myproj ls List session contents
# o myproj/coding new Create session+agent (cwd=$PWD) # o myproj/coding new Create session+agent (cwd=$PWD)
# o myproj/coding prompt Interactive prompt # o myproj/coding prompt Interactive prompt
# o myproj/coding read chat Read chat output # o myproj/coding read log Read text log output
set -euo pipefail set -euo pipefail
@ -512,13 +512,13 @@ cmd_tui() {
tmux kill-session -t "${tmux_session}" 2>/dev/null || true tmux kill-session -t "${tmux_session}" 2>/dev/null || true
# Layout: chat (top 80%), prompt (bottom 20%) # Layout: log (top 80%), prompt (bottom 20%)
# Use slash separator - session names don't contain slashes, agent names might contain colons # Use slash separator - session names don't contain slashes, agent names might contain colons
local ctx="$s/$a" local ctx="$s/$a"
tmux new-session -d -s "$tmux_session" -c "$HOME" tmux new-session -d -s "$tmux_session" -c "$HOME"
tmux set-option -t "$tmux_session" mouse on tmux set-option -t "$tmux_session" mouse on
sleep 0.3 # Wait for shell to initialize sleep 0.3 # Wait for shell to initialize
tmux send-keys -t "$tmux_session" "o '$ctx' log -n 1000; o '$ctx' read chat" Enter tmux send-keys -t "$tmux_session" "o '$ctx' log -n 1000; o '$ctx' read log.raw" Enter
tmux split-window -t "$tmux_session" -v -l 20% tmux split-window -t "$tmux_session" -v -l 20%
sleep 0.2 sleep 0.2
@ -631,7 +631,7 @@ Commands (availability depends on context):
bypass Show pending bypass request (session-level) bypass Show pending bypass request (session-level)
approve [id] Approve pending bypass request (session-level) approve [id] Approve pending bypass request (session-level)
deny [id] Deny pending bypass request (session-level) deny [id] Deny pending bypass request (session-level)
read/write <path> Access agent files (chat, cfg, etc.) read/write <path> Access agent files (log, cfg, etc.)
Common: Common:
read [-l] <path> Read a file (-l: loop) read [-l] <path> Read a file (-l: loop)
@ -648,7 +648,7 @@ Examples:
o myproj/coding new ~/src # create with specific cwd o myproj/coding new ~/src # create with specific cwd
o myproj ls # list session contents o myproj ls # list session contents
o myproj/coding prompt # interactive prompt o myproj/coding prompt # interactive prompt
o myproj/coding read chat # read chat stream o myproj/coding read log # read text log
o myproj bypass # show pending bypass details o myproj bypass # show pending bypass details
o myproj approve # approve pending bypass o myproj approve # approve pending bypass
o myproj approve 42 # approve specific request by id o myproj approve 42 # approve specific request by id

View File

@ -87,10 +87,10 @@ echo "name=coding cwd=$PWD" | ollie-9p write session/myproj/agent/new
| `agent/idx` | r | Agent index: `session-id\tagent-id\tagent-name\tparent-id\tdepth\tstate`. | | `agent/idx` | r | Agent index: `session-id\tagent-id\tagent-name\tparent-id\tdepth\tstate`. |
| `agent/{aname}/prompt` | w | Queue a user turn. | | `agent/{aname}/prompt` | w | Queue a user turn. |
| `agent/{aname}/fifo` | r/w | Prompt queue. | | `agent/{aname}/fifo` | r/w | Prompt queue. |
| `agent/{aname}/chat` | r | Filtered streaming chat output. | | `agent/{aname}/log` | r | Filtered text conversation (last 64KB). |
| `agent/{aname}/chat.raw` | r | Full streaming output with markers. | | `agent/{aname}/log.raw` | r | Full JSONL streaming output. |
| `agent/{aname}/block` | rdwr | Lookup block by ID (write ID, read JSON). |
| `agent/{aname}/state` | r | Current agent state (idle, calling, thinking, paused). | | `agent/{aname}/state` | r | Current agent state (idle, calling, thinking, paused). |
| `agent/{aname}/log` | r | Conversation snapshot. |
| `agent/{aname}/plan` | r/w | Persistent planning scratch space. | | `agent/{aname}/plan` | r/w | Persistent planning scratch space. |
| `agent/{aname}/cfg` | r/w | Agent configuration. | | `agent/{aname}/cfg` | r/w | Agent configuration. |
| `agent/{aname}/ctl` | rdwr | Agent controls and queries. | | `agent/{aname}/ctl` | rdwr | Agent controls and queries. |
@ -106,7 +106,7 @@ Submit and observe work:
```sh ```sh
echo 'fix the bug in main.go' \ echo 'fix the bug in main.go' \
| ollie-9p write session/myproj/agent/coding/prompt | ollie-9p write session/myproj/agent/coding/prompt
ollie-9p read session/myproj/agent/coding/chat ollie-9p read session/myproj/agent/coding/log.raw
# Wait for any agent state to change # Wait for any agent state to change
ollie-9p read event | grep "\.state" ollie-9p read event | grep "\.state"

View File

@ -8,7 +8,7 @@ An integration reads editor state, writes a prompt to an agent, and reads the re
editor or IDE editor or IDE
├─ file, selection, cursor, project, or diff ├─ file, selection, cursor, project, or diff
└─ 9P client → agent/.../prompt └─ 9P client → agent/.../prompt
├─ chat / chat.raw ├─ log / log.raw
├─ state ├─ state
├─ status ├─ status
└─ ctl └─ ctl

View File

@ -94,7 +94,7 @@ For the public protocol and namespace, see [`architecture-9p.md`](architecture-9
### URL Scheme ### URL Scheme
``` ```
ollie://session/agent#block ollie://session/agent#blockId
ollie://session/agent ollie://session/agent
ollie://session ollie://session
``` ```
@ -103,10 +103,12 @@ ollie://session
|---|---| |---|---|
| `session` | Session name (required) | | `session` | Session name (required) |
| `agent` | Agent name (optional) | | `agent` | Agent name (optional) |
| `#block` | Block index to scroll to (optional) | | `#blockId` | 8-character hex block ID to fetch (optional) |
Block IDs are deterministic SHA256-derived identifiers. Use `log.raw` (JSONL) to see block IDs in the `"id"` field.
Examples: Examples:
- `ollie://default/main#42` — switch to session "default", agent "main", scroll to block 42 - `ollie://default/main#a1b2c3d4` — fetch block "a1b2c3d4" from agent "main" in session "default"
- `ollie://myproject/src:kate-custom` — switch to agent "src:kate-custom" in session "myproject" - `ollie://myproject/src:kate-custom` — switch to agent "src:kate-custom" in session "myproject"
- `ollie://default` — switch to session "default" - `ollie://default` — switch to session "default"
@ -117,7 +119,7 @@ Examples:
``` ```
# ollie:// URLs go to the ollie port # ollie:// URLs go to the ollie port
type is text type is text
data matches 'ollie://[a-zA-Z0-9_\-.:]+(/[a-zA-Z0-9_\-.:]+)?(#[0-9]+)?' data matches 'ollie://[a-zA-Z0-9_\-.:]+(/[a-zA-Z0-9_\-.:]+)?(#[a-f0-9]+)?'
plumb to ollie plumb to ollie
``` ```
@ -140,7 +142,7 @@ cat $HOME/lib/plumbing | 9p write plumb/rules
From any Plan 9 application (acme, sam, rc, etc.): From any Plan 9 application (acme, sam, rc, etc.):
```sh ```sh
plumb 'ollie://default/main#100' plumb 'ollie://default/main#a1b2c3d4'
``` ```
Or right-click an `ollie://` URL in acme/sam and plumb it. Or right-click an `ollie://` URL in acme/sam and plumb it.

68
format/block.go Normal file
View File

@ -0,0 +1,68 @@
// Package format provides chat log serialization.
//
// The chat log uses JSON Lines format: one JSON object per line.
// Each line is a complete, self-contained block.
//
// For streaming, blocks can be partial (Partial=true). The GUI shows
// partial blocks immediately; when the final block arrives (same ID,
// Partial=false), it replaces the partial version.
package format
import (
"encoding/json"
)
// Block represents a single chat log entry.
type Block struct {
Role string `json:"role"` // user, assistant, context, reasoning, call, tool, error, info, retry, stalled
ID string `json:"id"` // 8-char hex block ID
Content string `json:"content,omitempty"` // Block content (text, args for call, output for tool)
Name string `json:"name,omitempty"` // Tool/function name (for call blocks)
Format string `json:"format,omitempty"` // Output format hint (for tool blocks)
Partial bool `json:"partial,omitempty"` // True if streaming (not final)
}
// MarshalBlock returns a JSONL line for the block (with trailing newline).
func MarshalBlock(b Block) []byte {
data, _ := json.Marshal(b)
return append(data, '\n')
}
// UnmarshalBlock parses a JSONL line into a Block.
func UnmarshalBlock(line []byte) (Block, error) {
var b Block
err := json.Unmarshal(line, &b)
return b, err
}
// RenderBlock returns human-readable text for a block (for log file).
// Partial blocks are skipped (only final blocks render).
func RenderBlock(b Block) string {
if b.Partial {
return ""
}
switch b.Role {
case "user":
return "You: " + b.Content + "\n\n"
case "assistant":
return b.Content + "\n\n"
case "context":
return "" // Hidden from rendered log
case "reasoning":
return "" // Hidden from rendered log
case "call":
return "" // Hidden from rendered log
case "tool":
return "" // Hidden from rendered log
case "error":
return "Error: " + b.Content + "\n\n"
case "info":
return "Info: " + b.Content + "\n\n"
case "retry":
return "Retry: " + b.Content + "\n\n"
case "stalled":
return "[Stalled]\n\n"
default:
return b.Content + "\n\n"
}
}

View File

@ -1,67 +0,0 @@
package format
import (
"fmt"
"strings"
)
// FormatEvent converts event data to bytes for appending to a chat log.
// Streaming roles (assistant, reasoning, retry) return only the content chunk;
// the caller is responsible for writing the [[[role]]] header for those.
// If blockID is non-empty, it is included in the block header.
func FormatEvent(role, name, content, outputFormat, blockID string) []byte {
switch role {
case "user":
if blockID != "" {
return []byte(fmt.Sprintf("[[[user#%s]]]\n", blockID) + content + "\n" + EndTag + "\n")
}
return []byte(UserTag + "\n" + content + "\n" + EndTag + "\n")
case "assistant":
return []byte(content)
case "reasoning":
return []byte(content)
case "call":
args := strings.Join(strings.Fields(content), " ")
if blockID != "" {
return []byte(fmt.Sprintf("[[[call:%s#%s]]]\n", name, blockID) + "```\n" + args + "\n```\n" + EndTag + "\n")
}
return []byte(CallDelim(name) + "```\n" + args + "\n```\n" + EndTag + "\n")
case "tool":
fence := "```"
if outputFormat != "" {
fence += outputFormat
}
if blockID != "" {
return []byte(fmt.Sprintf("[[[tool:%s#%s]]]\n", name, blockID) + fence + "\n" + strings.TrimRight(content, "\n") + "\n```\n" + EndTag + "\n")
}
return []byte(ToolDelim(name) + fence + "\n" + strings.TrimRight(content, "\n") + "\n```\n" + EndTag + "\n")
case "error":
r := "error"
if name != "" {
r = "error:" + name
}
if blockID != "" {
return []byte(fmt.Sprintf("[[[%s#%s]]]\n", r, blockID) + content + "\n" + EndTag + "\n")
}
return []byte(RoleDelim(r) + "\n" + content + "\n" + EndTag + "\n")
case "info":
if blockID != "" {
return []byte(fmt.Sprintf("[[[info#%s]]]\n", blockID) + content + "\n" + EndTag + "\n")
}
return []byte(RoleDelim("info") + "\n" + content + "\n" + EndTag + "\n")
case "retry":
return []byte(content)
case "stalled":
if blockID != "" {
return []byte(fmt.Sprintf("[[[stalled#%s]]]\n", blockID) + EndTag + "\n")
}
return []byte(RoleDelim("stalled") + "\n" + EndTag + "\n")
case "maxsteps":
if blockID != "" {
return []byte(fmt.Sprintf("[[[maxsteps#%s]]]\n", blockID) + content + "\n" + EndTag + "\n")
}
return []byte(RoleDelim("maxsteps") + "\n" + content + "\n" + EndTag + "\n")
default:
return nil
}
}

View File

@ -1,107 +0,0 @@
package format
import (
"fmt"
"strings"
)
// Formatting constants for the 9P chat log protocol.
// Used by fs.NewEventHandler and fs.replayMessagesToLog.
//
// Block format: [[[role:name#blockId]]] or [[[role#blockId]]]
// The blockId is a hex string that uniquely identifies the block.
const (
// EndTag marks the end of a message block.
EndTag = "[[[end]]]"
// RoleTag formats a role delimiter, e.g. "[[[user]]]".
RoleTag = "[[[%s]]]"
// ToolFence is the opening fence for tool output blocks.
ToolFence = "```\n"
// AssistantHeaderPrefix is prepended to assistant headers when a response ID is present.
AssistantHeaderPrefix = "[[[assistant:"
// AssistantTag is the bare assistant header (no response ID).
AssistantTag = "[[[assistant]]]"
// UserTag is the user role delimiter.
UserTag = "[[[user]]]"
// RetryTag is the retry role delimiter.
RetryTag = "[[[retry]]]"
// ToolDelimPrefix is the prefix for tool delimiters, e.g. "[[[tool:shell]]]".
ToolDelimPrefix = "[[[tool:"
// ToolTag is the bare tool role delimiter (no name).
ToolTag = "[[[tool]]]"
// CallDelimPrefix is the prefix for call delimiters, e.g. "[[[call:file_read]]]".
CallDelimPrefix = "[[[call:"
)
// RoleDelim returns a formatted role delimiter.
func RoleDelim(role string) string {
return fmt.Sprintf(RoleTag, role)
}
// ToolDelim returns a formatted tool delimiter.
func ToolDelim(name string) string {
return fmt.Sprintf(ToolDelimPrefix+"%s]]]\n", name)
}
// CallDelim returns a formatted call delimiter.
func CallDelim(name string) string {
return fmt.Sprintf(CallDelimPrefix+"%s]]]\n", name)
}
// BlockDelim returns a formatted block delimiter with a block ID.
// Format: [[[role#blockId]]] or [[[role:name#blockId]]]
func BlockDelim(role, name, blockID string) string {
if name != "" {
return fmt.Sprintf("[[[%s:%s#%s]]]\n", role, name, blockID)
}
return fmt.Sprintf("[[[%s#%s]]]\n", role, blockID)
}
// BlockHeader is a single-line [[[...]]] marker that opens a log block.
type BlockHeader struct {
Role string // e.g. "user", "assistant", "tool", "retry", or "tool:shell"
Name string // for tool/call: the sub-name (e.g. "shell"), empty for bare roles
BlockID string // unique block identifier (hex), empty if not present
}
// ParseBlockHeader extracts a BlockHeader from a line like "[[[tool:shell#a1b2]]]"
// or "[[[assistant]]]". Returns nil if the line is not a valid block header.
func ParseBlockHeader(line string) *BlockHeader {
const prefix, suffix = "[[[", "]]]"
if !strings.HasPrefix(line, prefix) || !strings.HasSuffix(line, suffix) {
return nil
}
inner := strings.TrimPrefix(line, prefix)
inner = strings.TrimSuffix(inner, suffix)
if inner == "" {
return nil
}
bh := &BlockHeader{Role: inner}
// Extract blockId if present (after #)
if idx := strings.LastIndex(inner, "#"); idx >= 0 {
bh.BlockID = inner[idx+1:]
inner = inner[:idx]
bh.Role = inner
}
// Extract name if present (after :)
if idx := strings.Index(inner, ":"); idx >= 0 {
bh.Role = inner[:idx]
bh.Name = inner[idx+1:]
}
return bh
}
// IsEndMarker reports whether the line is exactly the [[[end]]] closing marker.
func IsEndMarker(line string) bool {
return line == EndTag
}

View File

@ -1,188 +0,0 @@
package format
import (
"strings"
"testing"
)
func TestParseBlockHeader(t *testing.T) {
tests := []struct {
line string
wantNil bool
wantRole string
wantName string
wantID string
}{
// Valid headers
{"[[[user]]]", false, "user", "", ""},
{"[[[user#abc12345]]]", false, "user", "", "abc12345"},
{"[[[assistant]]]", false, "assistant", "", ""},
{"[[[assistant#def67890]]]", false, "assistant", "", "def67890"},
{"[[[assistant:resp_123#aabbccdd]]]", false, "assistant", "resp_123", "aabbccdd"},
{"[[[tool:shell]]]", false, "tool", "shell", ""},
{"[[[tool:shell#11223344]]]", false, "tool", "shell", "11223344"},
{"[[[call:file_read]]]", false, "call", "file_read", ""},
{"[[[call:file_read#99887766]]]", false, "call", "file_read", "99887766"},
{"[[[reasoning]]]", false, "reasoning", "", ""},
{"[[[reasoning#aabbcc00]]]", false, "reasoning", "", "aabbcc00"},
{"[[[retry]]]", false, "retry", "", ""},
{"[[[end]]]", false, "end", "", ""},
{"[[[error]]]", false, "error", "", ""},
{"[[[error:file_read#12345678]]]", false, "error", "file_read", "12345678"},
{"[[[info]]]", false, "info", "", ""},
{"[[[stalled]]]", false, "stalled", "", ""},
// Invalid headers
{"", true, "", "", ""},
{"[[[]]]", true, "", "", ""},
{"not a header", true, "", "", ""},
{"[[user]]", true, "", "", ""},
{"[[[user", true, "", "", ""},
{"user]]]", true, "", "", ""},
}
for _, tt := range tests {
bh := ParseBlockHeader(tt.line)
if tt.wantNil {
if bh != nil {
t.Errorf("ParseBlockHeader(%q): expected nil, got %+v", tt.line, bh)
}
continue
}
if bh == nil {
t.Errorf("ParseBlockHeader(%q): expected non-nil", tt.line)
continue
}
if bh.Role != tt.wantRole {
t.Errorf("ParseBlockHeader(%q): role = %q, want %q", tt.line, bh.Role, tt.wantRole)
}
if bh.Name != tt.wantName {
t.Errorf("ParseBlockHeader(%q): name = %q, want %q", tt.line, bh.Name, tt.wantName)
}
if bh.BlockID != tt.wantID {
t.Errorf("ParseBlockHeader(%q): blockID = %q, want %q", tt.line, bh.BlockID, tt.wantID)
}
}
}
func TestBlockDelim(t *testing.T) {
tests := []struct {
role, name, blockID string
want string
}{
{"user", "", "abc12345", "[[[user#abc12345]]]\n"},
{"assistant", "", "def67890", "[[[assistant#def67890]]]\n"},
{"tool", "shell", "11223344", "[[[tool:shell#11223344]]]\n"},
{"call", "file_read", "99887766", "[[[call:file_read#99887766]]]\n"},
{"error", "test", "aabbccdd", "[[[error:test#aabbccdd]]]\n"},
}
for _, tt := range tests {
got := BlockDelim(tt.role, tt.name, tt.blockID)
if got != tt.want {
t.Errorf("BlockDelim(%q, %q, %q) = %q, want %q", tt.role, tt.name, tt.blockID, got, tt.want)
}
}
}
func TestFormatEvent_WithBlockID(t *testing.T) {
tests := []struct {
role, name, content, outputFormat, blockID string
wantContains []string
}{
{
"user", "", "hello", "", "abc12345",
[]string{"[[[user#abc12345]]]", "hello", "[[[end]]]"},
},
{
"call", "shell", `{"cmd": "ls"}`, "", "def67890",
[]string{"[[[call:shell#def67890]]]", `{"cmd": "ls"}`, "[[[end]]]"},
},
{
"tool", "shell", "output", "text", "11223344",
[]string{"[[[tool:shell#11223344]]]", "output", "[[[end]]]"},
},
{
"error", "", "something failed", "", "aabbccdd",
[]string{"[[[error#aabbccdd]]]", "something failed", "[[[end]]]"},
},
{
"error", "test", "something failed", "", "eeff0011",
[]string{"[[[error:test#eeff0011]]]", "something failed", "[[[end]]]"},
},
{
"info", "", "status update", "", "22334455",
[]string{"[[[info#22334455]]]", "status update", "[[[end]]]"},
},
{
"stalled", "", "", "", "66778899",
[]string{"[[[stalled#66778899]]]", "[[[end]]]"},
},
{
"maxsteps", "", "limit reached", "", "aabbcc00",
[]string{"[[[maxsteps#aabbcc00]]]", "limit reached", "[[[end]]]"},
},
}
for _, tt := range tests {
got := string(FormatEvent(tt.role, tt.name, tt.content, tt.outputFormat, tt.blockID))
for _, want := range tt.wantContains {
if !strings.Contains(got, want) {
t.Errorf("FormatEvent(%q, %q, ...): missing %q in:\n%s", tt.role, tt.name, want, got)
}
}
}
}
func TestIsEndMarker(t *testing.T) {
tests := []struct {
line string
want bool
}{
{"[[[end]]]", true},
{"[[[end]]", false},
{"[[end]]]", false},
{"[[[END]]]", false},
{"[[[end]]] ", false},
{" [[[end]]]", false},
{"", false},
}
for _, tt := range tests {
got := IsEndMarker(tt.line)
if got != tt.want {
t.Errorf("IsEndMarker(%q) = %v, want %v", tt.line, got, tt.want)
}
}
}
func TestRoundtrip_ParseBlockHeader(t *testing.T) {
// Test that BlockDelim output can be parsed by ParseBlockHeader
tests := []struct {
role, name, blockID string
}{
{"user", "", "abc12345"},
{"tool", "shell", "def67890"},
{"call", "file_read", "11223344"},
}
for _, tt := range tests {
delim := BlockDelim(tt.role, tt.name, tt.blockID)
// Trim the trailing newline for parsing
delim = strings.TrimSuffix(delim, "\n")
bh := ParseBlockHeader(delim)
if bh == nil {
t.Errorf("ParseBlockHeader(BlockDelim(%q, %q, %q)) returned nil", tt.role, tt.name, tt.blockID)
continue
}
if bh.Role != tt.role {
t.Errorf("roundtrip role: got %q, want %q", bh.Role, tt.role)
}
if bh.Name != tt.name {
t.Errorf("roundtrip name: got %q, want %q", bh.Name, tt.name)
}
if bh.BlockID != tt.blockID {
t.Errorf("roundtrip blockID: got %q, want %q", bh.BlockID, tt.blockID)
}
}
}

View File

@ -14,14 +14,13 @@ Column {
// Required properties from model // Required properties from model
required property string blockType required property string blockType
required property string blockName
required property var blockSegments required property var blockSegments
required property string blockRenderedHtml required property string blockRenderedHtml
required property int index required property int index
required property string blockId required property string blockId
// Bookmark functions (connected from parent) // Bookmark functions (connected from parent)
property var addBookmark: function(sid, sn, aid, an, bid, bt, bn, p) { return false } property var addBookmark: function(sid, sn, aid, an, bid, bt, p) { return false }
property var removeBookmark: function(sid, aid, bid) { return false } property var removeBookmark: function(sid, aid, bid) { return false }
property var checkBookmarked: function(sid, aid, bid) { return false } property var checkBookmarked: function(sid, aid, bid) { return false }
property bool isBookmarked: checkBookmarked(ollie.activeSessionId, ollie.activeAgentId, blockId) property bool isBookmarked: checkBookmarked(ollie.activeSessionId, ollie.activeAgentId, blockId)
@ -82,8 +81,8 @@ Column {
case "user": return "\uD83E\uDDD1 You" case "user": return "\uD83E\uDDD1 You"
case "assistant": return "\uD83E\uDD16 Ollie" case "assistant": return "\uD83E\uDD16 Ollie"
case "reasoning": return "Reasoning" case "reasoning": return "Reasoning"
case "call": return "\u25B6\uFE0F " + blockName case "call": return "\u25B6\uFE0F Call"
case "tool": return "\uD83D\uDDA5\uFE0F " + blockName case "tool": return "\uD83D\uDDA5\uFE0F Tool"
case "error": return "\u274C Error" case "error": return "\u274C Error"
case "info": return "\u2139\uFE0F Info" case "info": return "\u2139\uFE0F Info"
case "retry": return "Retry" case "retry": return "Retry"
@ -200,7 +199,7 @@ Column {
} else { } else {
var sessionName = ollie.activeSessionName || ollie.activeSessionId var sessionName = ollie.activeSessionName || ollie.activeSessionId
var agentName = ollie.activeAgentName || ollie.activeAgentId var agentName = ollie.activeAgentName || ollie.activeAgentId
var preview = getBlockPlainText() || blockName var preview = getBlockPlainText() || blockType
root.addBookmark( root.addBookmark(
ollie.activeSessionId, ollie.activeSessionId,
sessionName, sessionName,
@ -208,7 +207,6 @@ Column {
agentName, agentName,
root.blockId, root.blockId,
blockType, blockType,
blockName,
preview preview
) )
} }

View File

@ -1,7 +1,6 @@
#include "chatblockmodel.h" #include "chatblockmodel.h"
#include <QTextDocument> #include <QTextDocument>
#include <QRegularExpressionMatch>
#include <QApplication> #include <QApplication>
#include <QPalette> #include <QPalette>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
@ -10,6 +9,9 @@
#include <QUrl> #include <QUrl>
#include <QUrlQuery> #include <QUrlQuery>
#include <QCryptographicHash> #include <QCryptographicHash>
#include <QJsonDocument>
#include <QJsonObject>
#include <QRegularExpression>
#include <KSyntaxHighlighting/Repository> #include <KSyntaxHighlighting/Repository>
#include <KSyntaxHighlighting/Definition> #include <KSyntaxHighlighting/Definition>
@ -81,34 +83,24 @@ private:
// --- Static helpers --- // --- Static helpers ---
static ChatBlock::Type blockTypeFromString(const QString &typeStr) ChatBlock::Type ChatBlockModel::blockTypeFromString(const QString &role)
{ {
if (typeStr == "user") return ChatBlock::User; if (role == "user") return ChatBlock::User;
if (typeStr == "assistant") return ChatBlock::Assistant; if (role == "assistant") return ChatBlock::Assistant;
if (typeStr == "reasoning") return ChatBlock::Reasoning; if (role == "context") return ChatBlock::Context;
if (typeStr == "call") return ChatBlock::Call; if (role == "reasoning") return ChatBlock::Reasoning;
if (typeStr == "tool") return ChatBlock::Tool; if (role == "call") return ChatBlock::Call;
if (typeStr == "error") return ChatBlock::Error; if (role == "tool") return ChatBlock::Tool;
if (typeStr == "info") return ChatBlock::Info; if (role == "error") return ChatBlock::Error;
if (typeStr == "retry") return ChatBlock::Retry; if (role == "info") return ChatBlock::Info;
if (role == "retry") return ChatBlock::Retry;
if (role == "interrupt") return ChatBlock::Interrupt;
return ChatBlock::Default; return ChatBlock::Default;
} }
// --- Block Parsing --- // Code fence regex (used for content rendering, not block parsing)
static const QRegularExpression s_fenceOpenRe(R"(^```([A-Za-z0-9_+.-]*).*$)");
const QRegularExpression ChatBlockModel::s_headerRe( static const QRegularExpression s_fenceCloseRe(R"(^```$)");
R"(^\[\[\[([^\]]+)\]\]\]$)");
const QRegularExpression ChatBlockModel::s_endRe(
R"(^\[\[\[end\]\]\]$)");
// Matches opening fence: ```lang or just ```
const QRegularExpression ChatBlockModel::s_fenceOpenRe(
R"(^```([A-Za-z0-9_+.-]*).*$)");
// Matches closing fence: ``` on its own line
const QRegularExpression ChatBlockModel::s_fenceCloseRe(
R"(^```$)");
ChatBlockModel::ChatBlockModel(QObject *parent) ChatBlockModel::ChatBlockModel(QObject *parent)
: QAbstractListModel(parent) : QAbstractListModel(parent)
@ -141,6 +133,7 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const
switch (b.type) { switch (b.type) {
case ChatBlock::User: return QStringLiteral("user"); case ChatBlock::User: return QStringLiteral("user");
case ChatBlock::Assistant: return QStringLiteral("assistant"); case ChatBlock::Assistant: return QStringLiteral("assistant");
case ChatBlock::Context: return QStringLiteral("context");
case ChatBlock::Reasoning: return QStringLiteral("reasoning"); case ChatBlock::Reasoning: return QStringLiteral("reasoning");
case ChatBlock::Call: return QStringLiteral("call"); case ChatBlock::Call: return QStringLiteral("call");
case ChatBlock::Tool: return QStringLiteral("tool"); case ChatBlock::Tool: return QStringLiteral("tool");
@ -151,8 +144,6 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const
case ChatBlock::Default: return QStringLiteral("default"); case ChatBlock::Default: return QStringLiteral("default");
} }
return {}; return {};
case BlockNameRole:
return b.name;
case ContentRole: case ContentRole:
return b.content; return b.content;
case RenderedHtmlRole: case RenderedHtmlRole:
@ -161,6 +152,8 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const
return segmentsToVariant(b.segments); return segmentsToVariant(b.segments);
case BlockIdRole: case BlockIdRole:
return b.blockId; return b.blockId;
case PartialRole:
return b.partial;
} }
return {}; return {};
} }
@ -189,44 +182,88 @@ QHash<int, QByteArray> ChatBlockModel::roleNames() const
{ {
return { return {
{BlockTypeRole, "blockType"}, {BlockTypeRole, "blockType"},
{BlockNameRole, "blockName"},
{ContentRole, "blockContent"}, {ContentRole, "blockContent"},
{RenderedHtmlRole, "blockRenderedHtml"}, {RenderedHtmlRole, "blockRenderedHtml"},
{SegmentsRole, "blockSegments"}, {SegmentsRole, "blockSegments"},
{BlockIdRole, "blockId"}, {BlockIdRole, "blockId"},
{PartialRole, "blockPartial"},
}; };
} }
void ChatBlockModel::appendData(const QString &text) void ChatBlockModel::appendData(const QString &text)
{ {
m_lineBuffer += text; qDebug() << "ChatBlockModel::appendData len=" << text.length() << "text=" << text.left(200);
m_lineBuffer += text.toUtf8();
// Process complete JSONL lines
int idx; int idx;
while ((idx = m_lineBuffer.indexOf('\n')) >= 0) { while ((idx = m_lineBuffer.indexOf('\n')) >= 0) {
QString line = m_lineBuffer.left(idx); QByteArray line = m_lineBuffer.left(idx);
m_lineBuffer = m_lineBuffer.mid(idx + 1); m_lineBuffer = m_lineBuffer.mid(idx + 1);
processLine(line); if (!line.isEmpty()) {
qDebug() << "ChatBlockModel: processing line:" << line.left(100);
processJsonLine(line);
}
}
}
void ChatBlockModel::processJsonLine(const QByteArray &line)
{
QJsonParseError error;
QJsonDocument doc = QJsonDocument::fromJson(line, &error);
if (error.error != QJsonParseError::NoError || !doc.isObject()) {
qWarning() << "ChatBlockModel: invalid JSON line:" << error.errorString();
return;
} }
// For streaming: update current block with partial line buffer content QJsonObject obj = doc.object();
// so the UI shows text as it arrives, not just after newlines. QString role = obj["role"].toString();
if (!m_lineBuffer.isEmpty() && !m_blocks.isEmpty() && m_state == State::BlockEnd) { QString id = obj["id"].toString();
int blockIndex = m_blocks.size() - 1; QString content = obj["content"].toString();
ChatBlock &last = m_blocks[blockIndex]; QString name = obj["name"].toString();
QString format = obj["format"].toString();
bool partial = obj["partial"].toBool(false);
// Save current segment state, render with partial line, then restore qDebug() << "ChatBlockModel::processJsonLine role=" << role << "id=" << id << "content.len=" << content.length();
QList<BlockSegment> savedSegments = last.segments;
QString savedHtml = last.renderedHtml;
renderLastSegmentPartial(blockIndex, m_lineBuffer); // Skip context blocks from rendering (context is for LLM, not display)
if (role == "context") {
return;
}
// Emit the partial-updated view // Find existing block with same ID (for streaming updates)
QModelIndex idx = index(blockIndex); int existingIdx = indexForBlockId(id);
emit dataChanged(idx, idx, {RenderedHtmlRole, SegmentsRole});
// Restore — partial line not committed yet if (existingIdx >= 0) {
last.segments = savedSegments; // Update existing block
last.renderedHtml = savedHtml; ChatBlock &block = m_blocks[existingIdx];
block.content = content;
block.partial = partial;
rebuildBlockHtml(existingIdx);
QModelIndex idx = index(existingIdx);
emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole, SegmentsRole, PartialRole});
} else {
// New block
int newIdx = m_blocks.size();
beginInsertRows(QModelIndex(), newIdx, newIdx);
ChatBlock block;
block.type = blockTypeFromString(role);
block.blockId = id;
block.content = content;
block.name = name;
block.format = format;
block.partial = partial;
m_blocks.append(block);
endInsertRows();
emit countChanged();
// Render after insertion so rebuildBlockHtml can find it
rebuildBlockHtml(newIdx);
QModelIndex idx = index(newIdx);
emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole, SegmentsRole});
} }
} }
@ -235,14 +272,8 @@ void ChatBlockModel::reset()
beginResetModel(); beginResetModel();
m_blocks.clear(); m_blocks.clear();
m_lineBuffer.clear(); m_lineBuffer.clear();
m_inContext = false;
m_state = State::BlockStart;
m_inCodeFence = false;
m_fenceLanguage.clear();
m_fenceContent.clear();
m_pendingDiagrams.clear(); m_pendingDiagrams.clear();
m_inFlightRequests.clear(); m_inFlightRequests.clear();
m_blockIdCounter = 0; // Reset so same chat produces same IDs
endResetModel(); endResetModel();
emit countChanged(); emit countChanged();
} }
@ -418,6 +449,37 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex)
return; return;
ChatBlock &block = m_blocks[blockIndex]; ChatBlock &block = m_blocks[blockIndex];
// For call blocks, show tool name and JSON formatted args
if (block.type == ChatBlock::Call) {
QString html = QStringLiteral("<div style=\"color:") + getDefaultTextColor() + QStringLiteral(";\">");
if (!block.name.isEmpty()) {
html += QStringLiteral("<b>") + block.name.toHtmlEscaped() + QStringLiteral("</b><br/>");
}
html += highlightCode(block.content, QStringLiteral("json"));
html += QStringLiteral("</div>");
block.renderedHtml = html;
BlockSegment seg;
seg.type = BlockSegment::Text;
seg.html = html;
block.segments = {seg};
return;
}
// For tool blocks, highlight with format hint
if (block.type == ChatBlock::Tool) {
QString lang = block.format.isEmpty() ? QStringLiteral("text") : block.format;
block.renderedHtml = highlightCode(block.content, lang);
BlockSegment seg;
seg.type = BlockSegment::Text;
seg.html = block.renderedHtml;
block.segments = {seg};
return;
}
// For other blocks, render content with code fence detection
QString html; QString html;
QList<BlockSegment> segments; QList<BlockSegment> segments;
@ -504,9 +566,9 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex)
} }
} }
// Handle unclosed fence (streaming) or remaining prose // Handle any remaining content
if (inFence) { if (inFence) {
// Still inside a fence — render what we have (incomplete) // Unclosed fence (streaming)
flushFence(false); flushFence(false);
} else { } else {
flushProse(); flushProse();
@ -514,209 +576,6 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex)
block.renderedHtml = html; block.renderedHtml = html;
block.segments = segments; block.segments = segments;
// Reset incremental state to match the full rebuild
block.renderState = BlockRenderState();
}
void ChatBlockModel::appendContent(const QString &line)
{
if (m_blocks.isEmpty())
return;
int blockIndex = m_blocks.size() - 1;
ChatBlock &last = m_blocks[blockIndex];
if (!last.content.isEmpty())
last.content += '\n';
last.content += line;
incrementalAppend(blockIndex, line);
QModelIndex idx = index(blockIndex);
emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole, SegmentsRole});
}
void ChatBlockModel::rebuildRenderedHtmlFromSegments(ChatBlock &block)
{
QString html;
for (const BlockSegment &seg : block.segments)
html += seg.html;
block.renderedHtml = html;
}
void ChatBlockModel::incrementalAppend(int blockIndex, const QString &line)
{
ChatBlock &block = m_blocks[blockIndex];
BlockRenderState &rs = block.renderState;
rs.committedLineCount++;
if (!rs.inFence) {
// Check for fence open
auto match = s_fenceOpenRe.match(line);
if (match.hasMatch()) {
// Flush any accumulated table into a finalized segment
if (rs.inTable && !rs.tableLines.isEmpty()) {
if (!block.segments.isEmpty())
block.segments.last().html = renderTable(rs.tableLines);
rs.tableLines.clear();
rs.inTable = false;
}
// Flush any accumulated prose into a finalized text segment
if (!rs.proseLines.isEmpty()) {
// The last segment should already be Text with these prose lines;
// it was being built up incrementally. Mark it done.
rs.proseLines.clear();
}
rs.inFence = true;
rs.fenceLang = match.captured(1);
rs.fenceLines.clear();
// Add a new Text segment for the code fence (will be rebuilt each line)
BlockSegment seg;
seg.type = BlockSegment::Text;
seg.html = highlightCode(QString(), rs.fenceLang);
block.segments.append(seg);
block.renderedHtml += seg.html;
return;
}
if (isTableLine(line)) {
// Transitioning from prose to table — flush prose, start table segment
if (!rs.inTable) {
if (!rs.proseLines.isEmpty())
rs.proseLines.clear();
rs.inTable = true;
rs.tableLines.clear();
rs.tableLines.append(line);
// Start a new segment for the table
BlockSegment seg;
seg.type = BlockSegment::Text;
seg.html = renderTable(rs.tableLines);
block.segments.append(seg);
} else {
// Continue accumulating table lines
rs.tableLines.append(line);
if (!block.segments.isEmpty())
block.segments.last().html = renderTable(rs.tableLines);
}
rebuildRenderedHtmlFromSegments(block);
return;
}
// Non-table prose line
if (rs.inTable) {
// Table just ended — finalize the table segment, start new prose
rs.tableLines.clear();
rs.inTable = false;
}
// Normal prose line — extend or create last text segment
rs.proseLines.append(line);
// Incremental: escape only the new line and append to existing segment
const QString defaultColor = getDefaultTextColor();
const QString escapedLine = line.toHtmlEscaped();
// Only extend the last segment if it is an in-progress prose segment
// (identified by its trailing </div>). A finalized code fence segment
// ends in </pre> and must not be overwritten.
bool canExtend = !block.segments.isEmpty()
&& block.segments.last().type == BlockSegment::Text
&& block.segments.last().html.endsWith(QStringLiteral("</div>"));
if (canExtend) {
QString &segHtml = block.segments.last().html;
segHtml.chop(6); // remove trailing </div>
segHtml += '\n' + escapedLine + QStringLiteral("</div>");
} else {
// Start a new prose segment
rs.proseLines.clear();
rs.proseLines.append(line);
BlockSegment seg;
seg.type = BlockSegment::Text;
seg.html = QStringLiteral("<div style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
+ escapedLine + QStringLiteral("</div>");
block.segments.append(seg);
}
// Rebuild renderedHtml from segments (concatenation is cheap)
rebuildRenderedHtmlFromSegments(block);
} else {
// Inside a fence — check for close
if (s_fenceCloseRe.match(line).hasMatch()) {
// Close the fence — finalize the segment
rs.inFence = false;
const QString code = rs.fenceLines.join('\n');
if (isDiagramLanguage(rs.fenceLang)) {
// Replace last segment with a diagram segment
BlockSegment seg;
seg.type = BlockSegment::Diagram;
seg.diagramLang = rs.fenceLang;
seg.diagramSource = code;
seg.html = highlightCode(code, rs.fenceLang);
seg.diagramLoading = true;
requestDiagram(rs.fenceLang, code, blockIndex);
// Replace the in-progress fence segment
if (!block.segments.isEmpty())
block.segments.last() = seg;
else
block.segments.append(seg);
} else {
// Normal code: finalize highlighted segment
QString codeHtml = highlightCode(code, rs.fenceLang);
if (!block.segments.isEmpty())
block.segments.last().html = codeHtml;
}
rs.fenceLang.clear();
rs.fenceLines.clear();
rebuildRenderedHtmlFromSegments(block);
} else {
// Append line to fence — incrementally highlight only the new line
rs.fenceLines.append(line);
// Get highlighter definition + theme
auto definition = m_repo->definitionForName(rs.fenceLang);
if (!definition.isValid())
definition = m_repo->definitionForFileName(QStringLiteral("file.") + rs.fenceLang);
if (definition.isValid()) {
const bool dark = QApplication::palette().color(QPalette::Window).lightnessF() < 0.5;
const auto hlTheme = dark
? m_repo->defaultTheme(KSyntaxHighlighting::Repository::DarkTheme)
: m_repo->defaultTheme(KSyntaxHighlighting::Repository::LightTheme);
HtmlHighlighter highlighter;
highlighter.setDefinition(definition);
highlighter.setTheme(hlTheme);
highlighter.defaultColor = getDefaultTextColor();
// Add line break before new line (if not the first)
if (!rs.hlHtml.isEmpty())
rs.hlHtml += QStringLiteral("<br/>");
// Process only the new line, continuing from saved state
rs.hlState = highlighter.processLine(line, rs.hlState);
highlighter.endLine();
rs.hlHtml += highlighter.html;
if (!block.segments.isEmpty())
block.segments.last().html = highlightCodeWrapped(rs.hlHtml);
} else {
// No syntax def — just escape
if (!rs.hlHtml.isEmpty())
rs.hlHtml += '\n';
rs.hlHtml += line.toHtmlEscaped();
const QString defaultColor = getDefaultTextColor();
if (!block.segments.isEmpty())
block.segments.last().html = QStringLiteral("<pre style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
+ rs.hlHtml + QStringLiteral("</pre>");
}
rebuildRenderedHtmlFromSegments(block);
}
}
} }
void ChatBlockModel::renderLastSegmentPartial(int blockIndex, const QString &partialLine) void ChatBlockModel::renderLastSegmentPartial(int blockIndex, const QString &partialLine)
@ -725,345 +584,156 @@ void ChatBlockModel::renderLastSegmentPartial(int blockIndex, const QString &par
return; return;
ChatBlock &block = m_blocks[blockIndex]; ChatBlock &block = m_blocks[blockIndex];
BlockRenderState &rs = block.renderState;
// Append partial line to content and re-render
if (rs.inFence) { QString tempContent = block.content;
// Show partial line appended to current fence — use cached state if (!tempContent.isEmpty() && !tempContent.endsWith('\n')) {
auto definition = m_repo->definitionForName(rs.fenceLang); tempContent += partialLine;
if (!definition.isValid())
definition = m_repo->definitionForFileName(QStringLiteral("file.") + rs.fenceLang);
if (definition.isValid()) {
const bool dark = QApplication::palette().color(QPalette::Window).lightnessF() < 0.5;
const auto hlTheme = dark
? m_repo->defaultTheme(KSyntaxHighlighting::Repository::DarkTheme)
: m_repo->defaultTheme(KSyntaxHighlighting::Repository::LightTheme);
HtmlHighlighter highlighter;
highlighter.setDefinition(definition);
highlighter.setTheme(hlTheme);
highlighter.defaultColor = getDefaultTextColor();
// Process partial line from saved state (don't save back)
QString partialHtml = rs.hlHtml;
if (!partialHtml.isEmpty())
partialHtml += QStringLiteral("<br/>");
highlighter.processLine(partialLine, rs.hlState);
highlighter.endLine();
partialHtml += highlighter.html;
if (!block.segments.isEmpty())
block.segments.last().html = highlightCodeWrapped(partialHtml);
} else {
// No definition — simple escape
QString inner = rs.hlHtml;
if (!inner.isEmpty())
inner += '\n';
inner += partialLine.toHtmlEscaped();
const QString defaultColor = getDefaultTextColor();
if (!block.segments.isEmpty())
block.segments.last().html = QStringLiteral("<pre style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
+ inner + QStringLiteral("</pre>");
}
} else { } else {
// Show partial line appended to current prose/table tempContent += partialLine;
if (rs.inTable) { }
// Currently building a table — show partial line as part of the table
if (isTableLine(partialLine)) { block.content = tempContent;
QStringList preview = rs.tableLines; rebuildBlockHtml(blockIndex);
preview.append(partialLine); }
if (!block.segments.isEmpty())
block.segments.last().html = renderTable(preview); void ChatBlockModel::rebuildRenderedHtmlFromSegments(ChatBlock &block)
{
QString html;
for (const BlockSegment &seg : block.segments) {
if (seg.type == BlockSegment::Diagram) {
if (!seg.diagramSvg.isEmpty()) {
html += seg.diagramSvg;
} else if (!seg.diagramError.isEmpty()) {
html += QStringLiteral("<div style=\"color:red;\">") + seg.diagramError.toHtmlEscaped() + QStringLiteral("</div>");
} else { } else {
// Partial line breaks the table — just show it as escaped text after html += QStringLiteral("<div>[Loading diagram...]</div>");
const QString defaultColor = getDefaultTextColor();
const QString escapedPartial = partialLine.toHtmlEscaped();
BlockSegment seg;
seg.type = BlockSegment::Text;
seg.html = QStringLiteral("<div style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
+ escapedPartial + QStringLiteral("</div>");
block.segments.append(seg);
} }
} else { } else {
const QString defaultColor = getDefaultTextColor(); html += seg.html;
const QString escapedPartial = partialLine.toHtmlEscaped();
if (!block.segments.isEmpty() && block.segments.last().type == BlockSegment::Text) {
// Temporarily append to the segment (caller will restore)
QString &segHtml = block.segments.last().html;
if (segHtml.endsWith(QStringLiteral("</div>"))) {
segHtml.chop(6);
segHtml += '\n' + escapedPartial + QStringLiteral("</div>");
} else {
segHtml = escapeForHtml((rs.proseLines + QStringList{partialLine}).join('\n'));
}
} else {
BlockSegment seg;
seg.type = BlockSegment::Text;
seg.html = QStringLiteral("<div style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
+ escapedPartial + QStringLiteral("</div>");
block.segments.append(seg);
}
} }
} }
rebuildRenderedHtmlFromSegments(block); block.renderedHtml = html;
} }
void ChatBlockModel::processLine(const QString &line) // --- Diagram support ---
{
switch (m_state) {
case State::BlockStart: {
// [[[blockname]]] or [[[blockname#blockId]]] starts a new block
QRegularExpressionMatch match = s_headerRe.match(line);
if (match.hasMatch()) {
QString blockName = match.captured(1);
beginInsertRows(QModelIndex(), m_blocks.size(), m_blocks.size());
ChatBlock newBlock;
// Extract blockId if present (after #)
int hashIdx = blockName.lastIndexOf('#');
if (hashIdx >= 0) {
newBlock.blockId = blockName.mid(hashIdx + 1);
blockName = blockName.left(hashIdx);
} else {
// Fallback: generate client-side ID for legacy blocks without server IDs
QString idSource = QString::number(m_blockIdCounter++) + blockName;
newBlock.blockId = QString::fromLatin1(
QCryptographicHash::hash(idSource.toUtf8(), QCryptographicHash::Md5).toHex().left(12));
}
newBlock.type = blockTypeFromString(blockName.section(':', 0, 0));
newBlock.name = blockName.section(':', 1);
m_blocks.append(newBlock);
endInsertRows();
emit countChanged();
m_state = State::BlockEnd;
}
// Lines outside blocks are ignored
return;
}
case State::BlockEnd:
// Hide prompt context blocks from the rendered chat, matching olliesrv.
if (line.trimmed() == QStringLiteral("<context>")) {
m_inContext = true;
return;
}
if (line.trimmed() == QStringLiteral("</context>")) {
m_inContext = false;
return;
}
if (m_inContext)
return;
// [[[end]]] closes the current block
if (s_endRe.match(line).hasMatch()) {
// Emit final update so QML picks up the completed segment state.
if (!m_blocks.isEmpty()) {
int blockIndex = m_blocks.size() - 1;
QModelIndex idx = index(blockIndex);
emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole, SegmentsRole});
}
m_state = State::BlockStart;
return;
}
appendContent(line);
return;
}
}
// --- Diagram rendering ---
bool ChatBlockModel::isDiagramLanguage(const QString &lang) const bool ChatBlockModel::isDiagramLanguage(const QString &lang) const
{ {
const QString l = lang.toLower(); return lang == QStringLiteral("plantuml") ||
return l == QStringLiteral("plantuml") || lang == QStringLiteral("mermaid") ||
l == QStringLiteral("mermaid") || lang == QStringLiteral("dot") ||
l == QStringLiteral("puml"); lang == QStringLiteral("graphviz");
} }
QString ChatBlockModel::diagramPlaceholder(const QString &lang) const QString ChatBlockModel::diagramPlaceholder(const QString &lang) const
{ {
const QString defaultColor = getDefaultTextColor(); return QStringLiteral("<div style=\"padding:8px;background:#f0f0f0;border:1px solid #ccc;\">")
return QStringLiteral("<div style=\"padding:8px;border:1px dashed ") + defaultColor + + QStringLiteral("[Rendering ") + lang.toHtmlEscaped() + QStringLiteral(" diagram...]</div>");
QStringLiteral(";color:") + defaultColor +
QStringLiteral(";font-style:italic;\">Loading ") +
lang.toHtmlEscaped() + QStringLiteral(" diagram...</div>");
} }
QString ChatBlockModel::diagramErrorHtml(const QString &error) const QString ChatBlockModel::diagramErrorHtml(const QString &error) const
{ {
return QStringLiteral("<div style=\"padding:8px;border:1px solid red;color:red;\">") + return QStringLiteral("<div style=\"padding:8px;background:#fee;border:1px solid #c00;color:#c00;\">")
QStringLiteral("Diagram error: ") + error.toHtmlEscaped() + + QStringLiteral("Diagram error: ") + error.toHtmlEscaped() + QStringLiteral("</div>");
QStringLiteral("</div>");
} }
QString ChatBlockModel::diagramSvgHtml(const QByteArray &svg) const QString ChatBlockModel::diagramSvgHtml(const QByteArray &svg) const
{ {
// Embed SVG inline as data URI return QStringLiteral("<img src=\"data:image/svg+xml;base64,")
const QString base64 = QString::fromLatin1(svg.toBase64()); + QString::fromLatin1(svg.toBase64())
return QStringLiteral("<img src=\"data:image/svg+xml;base64,") + base64 + + QStringLiteral("\" style=\"max-width:100%;\" />");
QStringLiteral("\" style=\"max-width:100%;\" />");
} }
QString ChatBlockModel::diagramPngHtml(const QByteArray &png) const QString ChatBlockModel::diagramPngHtml(const QByteArray &png) const
{ {
// Embed PNG inline as data URI return QStringLiteral("<img src=\"data:image/png;base64,")
const QString base64 = QString::fromLatin1(png.toBase64()); + QString::fromLatin1(png.toBase64())
return QStringLiteral("<img src=\"data:image/png;base64,") + base64 + + QStringLiteral("\" style=\"max-width:100%;\" />");
QStringLiteral("\" style=\"max-width:100%;\" />");
} }
QString ChatBlockModel::diagramJpegHtml(const QByteArray &jpeg) const QString ChatBlockModel::diagramJpegHtml(const QByteArray &jpeg) const
{ {
// Embed JPEG inline as data URI return QStringLiteral("<img src=\"data:image/jpeg;base64,")
const QString base64 = QString::fromLatin1(jpeg.toBase64()); + QString::fromLatin1(jpeg.toBase64())
return QStringLiteral("<img src=\"data:image/jpeg;base64,") + base64 + + QStringLiteral("\" style=\"max-width:100%;\" />");
QStringLiteral("\" style=\"max-width:100%;\" />");
} }
void ChatBlockModel::requestDiagram(const QString &lang, const QString &code, int blockIndex) void ChatBlockModel::requestDiagram(const QString &lang, const QString &code, int blockIndex)
{ {
const QString requestKey = lang + QStringLiteral(":") + // Hash the request for deduplication
QString::fromLatin1(QCryptographicHash::hash( const QString hashInput = lang + QStringLiteral(":") + code;
code.toUtf8(), QCryptographicHash::Md5).toHex()); const QString hash = QString::fromLatin1(
QCryptographicHash::hash(hashInput.toUtf8(), QCryptographicHash::Sha256).toHex().left(16));
// Track duplicate requests only while this local render is in flight. // Track which blocks want this diagram
if (!m_pendingDiagrams.contains(requestKey)) m_pendingDiagrams[hash].append(blockIndex);
m_pendingDiagrams[requestKey] = QList<int>();
if (!m_pendingDiagrams[requestKey].contains(blockIndex)) // Don't re-request if already in flight
m_pendingDiagrams[requestKey].append(blockIndex); if (m_inFlightRequests.contains(hash))
if (m_inFlightRequests.contains(requestKey))
return; return;
m_inFlightRequests.insert(hash);
m_inFlightRequests.insert(requestKey); // Request from local diagram renderer (ollie-diagram service)
QUrl url(QStringLiteral("http://localhost:18019/render"));
QUrlQuery query;
query.addQueryItem(QStringLiteral("lang"), lang);
url.setQuery(query);
QNetworkRequest request; QNetworkRequest request(url);
request.setAttribute(QNetworkRequest::User, requestKey); request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("text/plain"));
request.setTransferTimeout(10000); request.setAttribute(QNetworkRequest::User, hash);
const QString langLower = lang.toLower(); m_netManager->post(request, code.toUtf8());
if (langLower == QStringLiteral("plantuml") || langLower == QStringLiteral("puml")) {
// PlantUML server accepts POST with raw source
request.setUrl(QUrl(QStringLiteral("http://localhost:8080/svg")));
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("text/plain"));
m_netManager->post(request, code.toUtf8());
} else if (langLower == QStringLiteral("mermaid")) {
// Mermaid-ink expects base64 in URL path, /img/ for PNG output
// (SVG uses foreignObject which Qt's SVG renderer doesn't support)
const QByteArray encoded = code.toUtf8().toBase64();
request.setUrl(QUrl(QStringLiteral("http://localhost:8081/img/") + QString::fromLatin1(encoded)));
m_netManager->get(request);
} else {
m_inFlightRequests.remove(requestKey);
return;
}
} }
void ChatBlockModel::onDiagramReply(QNetworkReply *reply) void ChatBlockModel::onDiagramReply(QNetworkReply *reply)
{ {
reply->deleteLater(); reply->deleteLater();
const QString cacheKey = reply->request().attribute(QNetworkRequest::User).toString(); const QString hash = reply->request().attribute(QNetworkRequest::User).toString();
m_inFlightRequests.remove(cacheKey); m_inFlightRequests.remove(hash);
QByteArray imageData; QList<int> indices = m_pendingDiagrams.take(hash);
bool success = false; if (indices.isEmpty())
QString errorMsg; return;
if (reply->error() == QNetworkReply::NoError) { QString resultHtml;
imageData = reply->readAll(); if (reply->error() != QNetworkReply::NoError) {
resultHtml = diagramErrorHtml(reply->errorString());
} else {
const QByteArray data = reply->readAll();
const QString contentType = reply->header(QNetworkRequest::ContentTypeHeader).toString();
// Check for JPEG/PNG (mermaid) or SVG (plantuml) if (contentType.contains(QStringLiteral("svg"))) {
if (imageData.startsWith("\xff\xd8\xff")) { resultHtml = diagramSvgHtml(data);
// JPEG image } else if (contentType.contains(QStringLiteral("png"))) {
success = true; resultHtml = diagramPngHtml(data);
imageData.prepend("JPEG:"); } else if (contentType.contains(QStringLiteral("jpeg")) || contentType.contains(QStringLiteral("jpg"))) {
} else if (imageData.startsWith("\x89PNG")) { resultHtml = diagramJpegHtml(data);
// PNG image
success = true;
imageData.prepend("PNG:");
} else if (imageData.contains("<svg") || imageData.startsWith("<?xml")) {
// SVG image
success = true;
imageData.prepend("SVG:");
} else { } else {
errorMsg = QStringLiteral("Invalid response (not SVG, PNG, or JPEG)"); resultHtml = diagramErrorHtml(QStringLiteral("Unknown content type: ") + contentType);
} }
} else {
errorMsg = reply->errorString();
} }
if (success) { // Update all blocks waiting for this diagram
// Keep the result only on the waiting segments; do not cache diagrams. for (int idx : indices) {
} else { if (idx < 0 || idx >= m_blocks.size())
// The error is applied directly to waiting segments below.
}
// Apply the response directly to waiting segments. Nothing is retained after
// this request completes; a later occurrence renders through the local server.
const QList<int> blocks = m_pendingDiagrams.take(cacheKey);
for (int blockIndex : blocks) {
if (blockIndex < 0 || blockIndex >= m_blocks.size())
continue; continue;
ChatBlock &block = m_blocks[blockIndex]; ChatBlock &block = m_blocks[idx];
BlockSegment *matchingSegment = nullptr; for (BlockSegment &seg : block.segments) {
for (BlockSegment &segment : block.segments) { if (seg.type == BlockSegment::Diagram && seg.diagramLoading) {
if (segment.type != BlockSegment::Diagram || !segment.diagramLoading) seg.diagramSvg = resultHtml;
continue; seg.diagramLoading = false;
const QString segmentKey = segment.diagramLang + QStringLiteral(":") + if (reply->error() != QNetworkReply::NoError) {
QString::fromLatin1(QCryptographicHash::hash( seg.diagramError = reply->errorString();
segment.diagramSource.toUtf8(), QCryptographicHash::Md5).toHex());
if (segmentKey == cacheKey) {
matchingSegment = &segment;
break;
}
}
// A block can be rebuilt while a request is in flight. In that case
// the segment still represents this request, but its source may have
// been normalized by the parser. Use the sole pending diagram rather
// than leaving the UI loading forever.
if (!matchingSegment) {
for (BlockSegment &segment : block.segments) {
if (segment.type == BlockSegment::Diagram && segment.diagramLoading) {
if (matchingSegment) {
matchingSegment = nullptr;
break;
}
matchingSegment = &segment;
} }
} }
} }
if (!matchingSegment)
continue;
BlockSegment &segment = *matchingSegment;
segment.diagramLoading = false;
if (!success) {
segment.diagramError = errorMsg;
segment.html = diagramErrorHtml(errorMsg);
} else if (imageData.startsWith("JPEG:")) {
const QByteArray data = imageData.mid(5);
segment.diagramSvg = QStringLiteral("data:image/jpeg;base64,") +
QString::fromLatin1(data.toBase64());
segment.html = diagramJpegHtml(data);
} else if (imageData.startsWith("PNG:")) {
const QByteArray data = imageData.mid(4);
segment.diagramSvg = QStringLiteral("data:image/png;base64,") +
QString::fromLatin1(data.toBase64());
segment.html = diagramPngHtml(data);
} else {
const QByteArray data = imageData.startsWith("SVG:") ? imageData.mid(4) : imageData;
segment.diagramSvg = QStringLiteral("data:image/svg+xml;base64,") +
QString::fromLatin1(data.toBase64());
segment.html = diagramSvgHtml(data);
}
rebuildRenderedHtmlFromSegments(block); rebuildRenderedHtmlFromSegments(block);
QModelIndex idx = index(blockIndex);
emit dataChanged(idx, idx, {RenderedHtmlRole, SegmentsRole}); QModelIndex modelIdx = index(idx);
emit dataChanged(modelIdx, modelIdx, {RenderedHtmlRole, SegmentsRole});
} }
} }

View File

@ -6,7 +6,6 @@
#include <KSyntaxHighlighting/State> #include <KSyntaxHighlighting/State>
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <QRegularExpression>
#include <QHash> #include <QHash>
#include <QSet> #include <QSet>
#include <QVariantList> #include <QVariantList>
@ -31,7 +30,7 @@ struct BlockSegment {
}; };
// Incremental rendering state for a block — tracks where we left off so // Incremental rendering state for a block — tracks where we left off so
// appendContent() can extend the last segment without reparsing from scratch. // content updates can extend the last segment without reparsing from scratch.
struct BlockRenderState { struct BlockRenderState {
bool inFence = false; // Currently inside a code fence bool inFence = false; // Currently inside a code fence
QString fenceLang; // Language of the open fence QString fenceLang; // Language of the open fence
@ -49,6 +48,7 @@ struct ChatBlock {
enum Type { enum Type {
User, User,
Assistant, Assistant,
Context,
Reasoning, Reasoning,
Call, Call,
Tool, Tool,
@ -60,12 +60,14 @@ struct ChatBlock {
}; };
Type type = Default; Type type = Default;
QString name;
QString content; QString content;
QString renderedHtml; // Legacy: full HTML (kept for compatibility) QString renderedHtml; // Full HTML render of content
QList<BlockSegment> segments; // Structured segments for interactive rendering QList<BlockSegment> segments; // Structured segments for interactive rendering
BlockRenderState renderState; // Incremental rendering state BlockRenderState renderState; // Incremental rendering state
QString blockId; // Stable identifier (hash of type+name+first N chars of content) QString blockId; // Stable identifier (8-char hex from server)
QString name; // For call blocks: tool name
QString format; // For tool blocks: output format hint
bool partial = false; // True if streaming (not final)
}; };
class ChatBlockModel : public QAbstractListModel class ChatBlockModel : public QAbstractListModel
@ -74,19 +76,13 @@ class ChatBlockModel : public QAbstractListModel
Q_PROPERTY(int count READ rowCount NOTIFY countChanged) Q_PROPERTY(int count READ rowCount NOTIFY countChanged)
public: public:
// Two-state parser for [[[blockname]]] ... [[[end]]] blocks.
enum class State {
BlockStart, // Waiting for a block header
BlockEnd // Collecting content until [[[end]]]
};
enum Roles { enum Roles {
BlockTypeRole = Qt::UserRole + 1, BlockTypeRole = Qt::UserRole + 1,
BlockNameRole,
ContentRole, ContentRole,
RenderedHtmlRole, RenderedHtmlRole,
SegmentsRole, // QVariantList of segment maps SegmentsRole, // QVariantList of segment maps
BlockIdRole, // Stable block identifier BlockIdRole, // Stable block identifier
PartialRole, // True if streaming (not final)
}; };
explicit ChatBlockModel(QObject *parent = nullptr); explicit ChatBlockModel(QObject *parent = nullptr);
@ -108,10 +104,8 @@ private slots:
void onDiagramReply(QNetworkReply *reply); void onDiagramReply(QNetworkReply *reply);
private: private:
void processLine(const QString &line); void processJsonLine(const QByteArray &line);
void appendContent(const QString &line);
void rebuildBlockHtml(int blockIndex); void rebuildBlockHtml(int blockIndex);
void incrementalAppend(int blockIndex, const QString &line);
void renderLastSegmentPartial(int blockIndex, const QString &partialLine); void renderLastSegmentPartial(int blockIndex, const QString &partialLine);
static void rebuildRenderedHtmlFromSegments(ChatBlock &block); static void rebuildRenderedHtmlFromSegments(ChatBlock &block);
@ -135,31 +129,19 @@ private:
// Convert segments to QVariantList for QML // Convert segments to QVariantList for QML
QVariantList segmentsToVariant(const QList<BlockSegment> &segments) const; QVariantList segmentsToVariant(const QList<BlockSegment> &segments) const;
State m_state = State::BlockStart; // Map block type string to enum
static ChatBlock::Type blockTypeFromString(const QString &role);
KSyntaxHighlighting::Repository *m_repo = nullptr; KSyntaxHighlighting::Repository *m_repo = nullptr;
QNetworkAccessManager *m_netManager = nullptr; QNetworkAccessManager *m_netManager = nullptr;
QList<ChatBlock> m_blocks; QList<ChatBlock> m_blocks;
QString m_lineBuffer; QByteArray m_lineBuffer; // Buffer for incomplete JSON lines
quint64 m_blockIdCounter = 0; // Monotonic counter for stable block IDs
// Context blocks are injected into prompts but hidden from chat rendering.
bool m_inContext = false;
// Code fence state (for streaming)
bool m_inCodeFence = false;
QString m_fenceLanguage;
QString m_fenceContent;
// Pending requests: hash -> block indices waiting for this diagram // Pending requests: hash -> block indices waiting for this diagram
QHash<QString, QList<int>> m_pendingDiagrams; QHash<QString, QList<int>> m_pendingDiagrams;
// Track which requests are in flight // Track which requests are in flight
QSet<QString> m_inFlightRequests; QSet<QString> m_inFlightRequests;
static const QRegularExpression s_headerRe;
static const QRegularExpression s_endRe;
static const QRegularExpression s_fenceOpenRe;
static const QRegularExpression s_fenceCloseRe;
}; };
#endif // CHATBLOCKMODEL_H #endif // CHATBLOCKMODEL_H

View File

@ -10,7 +10,6 @@
#include <QQmlContext> #include <QQmlContext>
#include <QQuickStyle> #include <QQuickStyle>
#include <QStandardPaths> #include <QStandardPaths>
#include <QUrl>
#include "ollie9pclient.h" #include "ollie9pclient.h"
#include "sessionmodel.h" #include "sessionmodel.h"
@ -55,29 +54,6 @@ int main(int argc, char *argv[])
QObject::connect(ollie, &Ollie9pClient::chatReceived, QObject::connect(ollie, &Ollie9pClient::chatReceived,
chatModel, &ChatBlockModel::appendData); chatModel, &ChatBlockModel::appendData);
// Handle ollie:// URLs from plumber — fetch block and display in chat
QObject::connect(plumber, &Plumber::ollieMessage, [ollie, chatModel](const QString &url) {
// Parse ollie://session/agent#blockid
QUrl parsed(url);
if (parsed.scheme() != QStringLiteral("ollie"))
return;
QString path = parsed.path();
if (path.startsWith('/'))
path = path.mid(1);
QStringList parts = path.split('/');
if (parts.size() < 2)
return;
QString sessionId = parts.value(0);
QString agentId = parts.value(1);
QString blockId = parsed.fragment();
if (blockId.isEmpty())
return;
QString block = ollie->fetchBlock(sessionId, agentId, blockId);
if (!block.isEmpty())
chatModel->appendData(block);
});
engine.rootContext()->setContextProperty("ollie", ollie); engine.rootContext()->setContextProperty("ollie", ollie);
engine.rootContext()->setContextProperty("sessionModel", sessionModel); engine.rootContext()->setContextProperty("sessionModel", sessionModel);
engine.rootContext()->setContextProperty("chatModel", chatModel); engine.rootContext()->setContextProperty("chatModel", chatModel);

View File

@ -14,12 +14,12 @@ ApplicationWindow {
font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize
// Bookmarks model (non-persistent, GUI state only) // Bookmarks model (non-persistent, GUI state only)
// Each entry: { sessionId, sessionName, agentId, agentName, blockId, blockType, blockName, preview } // Each entry: { sessionId, sessionName, agentId, agentName, blockId, blockType, preview }
ListModel { ListModel {
id: bookmarksModel id: bookmarksModel
} }
function addBookmark(sessionId, sessionName, agentId, agentName, blockId, blockType, blockName, preview) { function addBookmark(sessionId, sessionName, agentId, agentName, blockId, blockType, preview) {
// Check for duplicate // Check for duplicate
for (var i = 0; i < bookmarksModel.count; i++) { for (var i = 0; i < bookmarksModel.count; i++) {
var b = bookmarksModel.get(i) var b = bookmarksModel.get(i)
@ -34,7 +34,6 @@ ApplicationWindow {
agentName: agentName, agentName: agentName,
blockId: blockId, blockId: blockId,
blockType: blockType, blockType: blockType,
blockName: blockName,
preview: preview.substring(0, 80) + (preview.length > 80 ? "…" : "") preview: preview.substring(0, 80) + (preview.length > 80 ? "…" : "")
}) })
return true return true
@ -74,7 +73,17 @@ ApplicationWindow {
var agentName = match[2] ? decodeURIComponent(match[2]) : "" var agentName = match[2] ? decodeURIComponent(match[2]) : ""
var blockId = match[3] || "" var blockId = match[3] || ""
// Find session by name and switch to it // If blockId is present, fetch and append the block content to current chat
// Don't navigate — user wants to see the block in their current context
if (blockId) {
var block = ollie.fetchBlock(sessionName, agentName, blockId)
if (block) {
chatModel.appendData(block)
}
return
}
// No blockId — navigate to session/agent
var sessions = ollie.sessions var sessions = ollie.sessions
for (var i = 0; i < sessions.length; i++) { for (var i = 0; i < sessions.length; i++) {
var s = sessions[i] var s = sessions[i]
@ -92,13 +101,6 @@ ApplicationWindow {
} }
} }
// If blockId specified, scroll to it after a short delay
if (blockId) {
Qt.callLater(function() {
chatPane.scrollToBlockId(blockId)
})
}
// Raise window // Raise window
root.show() root.show()
root.raise() root.raise()

View File

@ -468,7 +468,8 @@ QString Ollie9pClient::readLogForSession(const QString &sessionId, const QString
if (sessionId.isEmpty() || agentId.isEmpty()) return {}; if (sessionId.isEmpty() || agentId.isEmpty()) return {};
if (!m_9p || !m_9p->isConnected()) return {}; if (!m_9p || !m_9p->isConnected()) return {};
// Use immutable IDs — the 9P namespace resolves them via aliases. // Use immutable IDs — the 9P namespace resolves them via aliases.
QString path = "session/" + sessionId + "/agent/" + agentId + "/log"; // Read JSONL from log.raw for the chat model (not plain text log).
QString path = "session/" + sessionId + "/agent/" + agentId + "/log.raw";
return QString::fromUtf8(m_9p->read(path)); return QString::fromUtf8(m_9p->read(path));
} }
@ -1075,13 +1076,20 @@ QVariantList Ollie9pClient::listAgentsInSession(const QString &sessionId)
QString Ollie9pClient::fetchBlock(const QString &sessionId, const QString &agentId, const QString &blockId) QString Ollie9pClient::fetchBlock(const QString &sessionId, const QString &agentId, const QString &blockId)
{ {
if (sessionId.isEmpty() || agentId.isEmpty() || blockId.isEmpty()) qDebug("fetchBlock: session=%s agent=%s block=%s", qPrintable(sessionId), qPrintable(agentId), qPrintable(blockId));
if (sessionId.isEmpty() || agentId.isEmpty() || blockId.isEmpty()) {
qDebug("fetchBlock: empty parameter");
return QString(); return QString();
if (!m_9p || !m_9p->isConnected()) }
if (!m_9p || !m_9p->isConnected()) {
qDebug("fetchBlock: not connected (m_9p=%p connected=%d)", (void*)m_9p, m_9p ? m_9p->isConnected() : 0);
return QString(); return QString();
}
QString path = QStringLiteral("session/%1/agent/%2/chat.search").arg(sessionId, agentId); QString path = QStringLiteral("session/%1/agent/%2/block").arg(sessionId, agentId);
qDebug("fetchBlock: rdwr path=%s", qPrintable(path));
QByteArray out = m_9p->rdwr(path, blockId.toUtf8()); QByteArray out = m_9p->rdwr(path, blockId.toUtf8());
qDebug("fetchBlock: got %lld bytes", (long long)out.size());
return QString::fromUtf8(out); return QString::fromUtf8(out);
} }
@ -1124,7 +1132,7 @@ void Ollie9pClient::startActiveAgentStreams()
if (!m_daemonConnected) return; if (!m_daemonConnected) return;
if (m_activeSessionId.isEmpty() || m_agentId.isEmpty()) return; if (m_activeSessionId.isEmpty() || m_agentId.isEmpty()) return;
m_chat->start(agentPath() + "/chat.raw"); m_chat->start(agentPath() + "/log.raw");
// Check for pending bypass request on this session // Check for pending bypass request on this session
checkPendingBypass(); checkPendingBypass();

View File

@ -113,7 +113,7 @@ public:
Q_INVOKABLE bool removePeer(const QString &sessionId, const QString &agentId, const QString &peerName); Q_INVOKABLE bool removePeer(const QString &sessionId, const QString &agentId, const QString &peerName);
Q_INVOKABLE QVariantList listAgentsInSession(const QString &sessionId); Q_INVOKABLE QVariantList listAgentsInSession(const QString &sessionId);
// Block lookup via chat.search // Block lookup via block file (rdwr: write ID, read JSON)
Q_INVOKABLE QString fetchBlock(const QString &sessionId, const QString &agentId, const QString &blockId); Q_INVOKABLE QString fetchBlock(const QString &sessionId, const QString &agentId, const QString &blockId);
signals: signals: