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.
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.
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

View File

@ -68,10 +68,12 @@ type Agent struct {
onProcStart func(agentID string, pid int, tool, cmd string) // optional callback for proc start
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
chatLog []byte // append-only chat log buffer
chatStart int // byte offset of first byte in chatLog (for truncation accounting)
rawLog []byte // JSONL format (one Block per line)
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
chatCond *sync.Cond // signaled on new chat data
chatSignalMu sync.Mutex
@ -313,8 +315,10 @@ func (ag *Agent) Clear() error {
}
ag.history = nil
ag.chatMu.Lock()
ag.chatLog = nil
ag.chatStart = 0
ag.rawLog = nil
ag.rawStart = 0
ag.textLog = nil
ag.textStart = 0
ag.chatVers++
ag.chatMu.Unlock()
ag.chatCond.Broadcast()

View File

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

View File

@ -1,9 +1,10 @@
package agent
import (
"strings"
"sync"
"testing"
"ollie/format"
)
// 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")
// Simulate chat log with blocks
chatData := `[[[user#abc12345]]]
Hello
[[[end]]]
[[[assistant#def67890]]]
Hi there
[[[end]]]
[[[tool:shell#11223344]]]
` + "```" + `
output here
` + "```" + `
[[[end]]]
`
ag.AppendChat([]byte(chatData))
ag.AppendBlock(format.Block{Role: "user", ID: "abc12345", Content: "Hello"})
ag.AppendBlock(format.Block{Role: "assistant", ID: "def67890", Content: "Hi there"})
ag.AppendBlock(format.Block{Role: "tool", ID: "11223344", Content: "output"})
tests := []struct {
blockID string
wantFound bool
wantPart string
}{
{"abc12345", true, "[[[user#abc12345]]]"},
{"def67890", true, "[[[assistant#def67890]]]"},
{"11223344", true, "[[[tool:shell#11223344]]]"},
{"notfound", false, ""},
{"", false, ""},
ag.ChatMu().RLock()
raw := ag.RawLog()
ag.ChatMu().RUnlock()
// Should contain valid JSONL
if len(raw) == 0 {
t.Fatal("raw log should not be empty")
}
for _, tt := range tests {
block, found := ag.ChatSearchByID(tt.blockID)
if found != tt.wantFound {
t.Errorf("ChatSearchByID(%q): got found=%v, want %v", tt.blockID, found, tt.wantFound)
// Parse and verify
lines := 0
for _, line := range splitLines(raw) {
if len(line) == 0 {
continue
}
if found && !strings.Contains(string(block), tt.wantPart) {
t.Errorf("ChatSearchByID(%q): block missing %q, got:\n%s", tt.blockID, tt.wantPart, block)
lines++
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")
chatData := `[[[user#aabbccdd]]]
test content
more lines
[[[end]]]
`
ag.AppendChat([]byte(chatData))
ag.AppendBlock(format.Block{Role: "user", ID: "abc12345", Content: "Hello"})
ag.AppendBlock(format.Block{Role: "assistant", ID: "def67890", Content: "Hi there"})
block, found := ag.ChatSearchByID("aabbccdd")
// Find existing block
block, found := ag.BlockByID("def67890")
if !found {
t.Fatal("block should be found")
}
blockStr := string(block)
if !strings.HasPrefix(blockStr, "[[[user#aabbccdd]]]") {
t.Errorf("block should start with header, got: %s", blockStr)
if block.Role != "assistant" || block.Content != "Hi there" {
t.Errorf("wrong block content: %+v", block)
}
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) {
ag := testAgent("session1", "agent1")
// Block ID is full 8 chars
chatData := `[[[user#12345678]]]
content
[[[end]]]
`
ag.AppendChat([]byte(chatData))
// Should not find partial match
_, found := ag.ChatSearchByID("1234")
// Not found
_, found = ag.BlockByID("notfound")
if found {
t.Error("partial block ID should not match")
t.Error("nonexistent block should not be found")
}
// Should find exact match
_, found = ag.ChatSearchByID("12345678")
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")
// Empty ID
_, found = ag.BlockByID("")
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")
// Block with name component (role:name#id format)
chatData := `[[[tool:file_read#abcd1234]]]
` + "```" + `
file contents
` + "```" + `
[[[end]]]
`
ag.AppendChat([]byte(chatData))
// Add partial block first
ag.AppendBlock(format.Block{Role: "assistant", ID: "abc12345", Content: "H", Partial: true})
// Then final
ag.AppendBlock(format.Block{Role: "assistant", ID: "abc12345", Content: "Hello"})
block, found := ag.ChatSearchByID("abcd1234")
block, found := ag.BlockByID("abc12345")
if !found {
t.Fatal("named block should be found")
t.Fatal("block should be found")
}
if !strings.Contains(string(block), "tool:file_read") {
t.Errorf("block should include role:name, got: %s", block)
if block.Content != "Hello" {
t.Errorf("should find final block, not partial: %+v", block)
}
}
func TestChatSearchByID_ResponseIDBlock(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) {
func TestAppendBlock_SignalsBroadcast(t *testing.T) {
ag := testAgent("session1", "agent1")
signalCh := ag.ChatSignal()
// Append data
ag.AppendChat([]byte("test"))
// Append block
ag.AppendBlock(format.Block{Role: "user", ID: "test", Content: "hi"})
// Signal channel should be closed
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")
// Initial read with no base - should return empty and current offset
data, base, err := ag.ChatRead("")
data, base, err := ag.RawLogRead("")
if err != nil {
t.Fatalf("ChatRead error: %v", err)
t.Fatalf("RawLogRead error: %v", err)
}
if len(data) != 0 {
t.Errorf("initial read should be empty, got: %s", data)
}
// Append some data
ag.AppendChat([]byte("hello"))
// Append a block
ag.AppendBlock(format.Block{Role: "user", ID: "test", Content: "hello"})
// Read with previous base - should get new data
data, newBase, err := ag.ChatRead(base)
data, newBase, err := ag.RawLogRead(base)
if err != nil {
t.Fatalf("ChatRead error: %v", err)
t.Fatalf("RawLogRead error: %v", err)
}
if string(data) != "hello" {
t.Errorf("expected 'hello', got: %s", data)
if len(data) == 0 {
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
data, _, err = ag.ChatRead(newBase)
data, _, err = ag.RawLogRead(newBase)
if err != nil {
t.Fatalf("ChatRead error: %v", err)
t.Fatalf("RawLogRead error: %v", err)
}
if len(data) != 0 {
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.
//
// Converts agent events (streaming text, tool calls, tool results) into
// formatted output written to the chat log. The output handler is set up
// once at agent construction.
// JSONL blocks written to the chat log. Handles streaming by emitting
// partial blocks that are replaced by final blocks.
package agent
import (
"fmt"
"strings"
"ollie/cmd/olliesrv/internal/backend"
"ollie/format"
)
// initChatHandler sets up the agent's output handler to write to its own chat log.
// Called once at construction.
func (ag *Agent) initChatHandler() {
var streamingRole string
var streamingID string
var streamingContent strings.Builder
var streamingResponseID string
var streamingFence string
var streamingBlockID string
var streamingFormat 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) {
switch ev.Role {
case "assistant":
if streamingRole != ev.Role || (ev.Role == "assistant" && ev.ResponseID != streamingResponseID) {
if streamingRole != "" {
if streamingFence != "" {
ag.AppendChat([]byte("\n" + streamingFence + "\n"))
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)))
// New response or different response ID = new block
if streamingRole != ev.Role || ev.ResponseID != streamingResponseID {
if streamingID != "" {
closeBlock()
}
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":
if streamingRole != ev.Role {
if streamingRole != "" {
if streamingFence != "" {
ag.AppendChat([]byte("\n" + streamingFence + "\n"))
streamingFence = ""
}
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
if streamingID != "" {
closeBlock()
}
blockID := ag.NextBlockID()
ag.AppendChat([]byte(fmt.Sprintf("[[[reasoning#%s]]]\n", blockID)))
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":
if streamingRole == "tool" {
if ev.Content == "" {
if streamingFence != "" {
ag.AppendChat([]byte("\n" + streamingFence + "\n"))
streamingFence = ""
}
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
streamingRole = ""
// Empty content = end of streaming tool output
closeBlock()
} else {
ag.AppendChat([]byte(ev.Content))
// Append to streaming tool content
streamingContent.WriteString(ev.Content)
flushPartial()
}
return
}
if streamingRole != "" {
if streamingFence != "" {
ag.AppendChat([]byte("\n" + streamingFence + "\n"))
streamingFence = ""
}
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
streamingRole = ""
if streamingID != "" {
closeBlock()
}
if ev.Content == "" {
// Streaming tool output: write header with blockID
blockID := ag.NextBlockID()
ag.AppendChat([]byte(fmt.Sprintf("[[[tool:%s#%s]]]\n```%s\n", ev.Name, blockID, ev.OutputFormat)))
streamingFence = format.ToolFence
// Start streaming tool output
streamingRole = "tool"
streamingBlockID = blockID
streamingID = ag.NextBlockID()
streamingFormat = ev.OutputFormat
streamingContent.Reset()
} else {
// 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":
if streamingRole == "retry" {
ag.AppendChat([]byte("\n" + ev.Content))
streamingContent.WriteString("\n" + ev.Content)
flushPartial()
return
}
if streamingRole != "" {
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
streamingRole = ""
if streamingID != "" {
closeBlock()
}
blockID := ag.NextBlockID()
ag.AppendChat([]byte(fmt.Sprintf("[[[retry#%s]]]\n", blockID)))
streamingRole = "retry"
streamingBlockID = blockID
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ""))
streamingID = ag.NextBlockID()
streamingContent.Reset()
streamingContent.WriteString(ev.Content)
flushPartial()
case "call":
if streamingRole != "" {
if streamingFence != "" {
ag.AppendChat([]byte("\n" + streamingFence + "\n"))
streamingFence = ""
}
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
streamingRole = ""
if streamingID != "" {
closeBlock()
}
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:
if streamingRole != "" {
if streamingFence != "" {
ag.AppendChat([]byte("\n" + streamingFence + "\n"))
streamingFence = ""
}
ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
streamingRole = ""
// error, info, stalled, etc.
if streamingID != "" {
closeBlock()
}
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++
}
// 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:] {
switch m.Role {
case "system":
// Skip
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":
if m.Reasoning != "" {
blockID := ag.NextBlockID()
ag.AppendChat([]byte(fmt.Sprintf("[[[reasoning#%s]]]\n%s\n%s\n", blockID, m.Reasoning, format.EndTag)))
ag.AppendBlock(format.Block{
Role: "reasoning",
ID: ag.NextBlockID(),
Content: m.Reasoning,
})
}
blockID := ag.NextBlockID()
if m.ID != "" {
ag.AppendChat([]byte(fmt.Sprintf("[[[assistant:%s#%s]]]\n", m.ID, blockID)))
} else {
ag.AppendChat([]byte(fmt.Sprintf("[[[assistant#%s]]]\n", blockID)))
}
if m.Content != "" {
ag.AppendChat([]byte(m.Content + "\n"))
}
ag.AppendChat([]byte(format.EndTag + "\n"))
ag.AppendBlock(format.Block{
Role: "assistant",
ID: ag.NextBlockID(),
Content: m.Content,
})
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":
name := callNames[m.ToolCallID]
if name == "" {
name = m.ToolCallID
}
ag.AppendChat(format.FormatEvent("tool", name, m.Content, "", ag.NextBlockID()))
ag.AppendBlock(format.Block{
Role: "tool",
ID: ag.NextBlockID(),
Content: m.Content,
})
}
}
}

View File

@ -147,11 +147,16 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
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
// overflow so the retry starts from a clean state.

View File

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

View File

@ -20,6 +20,7 @@ import (
"ollie/cmd/olliesrv/internal/backend"
"ollie/cmd/olliesrv/internal/metrics"
"ollie/cmd/olliesrv/internal/session"
"ollie/format"
"ollie/util"
"ollie/virtfs"
)
@ -687,15 +688,14 @@ func buildSessionChildren(
defer cancel()
session.PublishEvent("session."+s.ID+".agent."+ag.ID()+".new", "")
subPrompt := "You are a sub-agent. Your task is below. " +
"Execute it completely using tools — investigate, implement, verify. " +
"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
ag.Submit(subCtx, subPrompt)
ag.EnsureTrailingNewline()
reply := ag.Reply()
s.RemoveAgent(ag.ID())
session.PublishEvent("session."+s.ID+".agent."+ag.ID()+".kill", "")
subPrompt := "You are a sub-agent. Your task is below. " +
"Execute it completely using tools — investigate, implement, verify. " +
"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
ag.Submit(subCtx, subPrompt)
reply := ag.Reply()
s.RemoveAgent(ag.ID())
session.PublishEvent("session."+s.ID+".agent."+ag.ID()+".kill", "")
if reply == "" {
return []byte("(no reply)\n"), nil
@ -767,7 +767,6 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
} else {
startAsync(s.Ctx, func() {
a.Submit(s.Ctx, text)
a.EnsureTrailingNewline()
})
}
return []byte("ok\n"), nil
@ -786,7 +785,6 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
}
startAsync(s.Ctx, func() {
a.Submit(s.Ctx, input)
a.EnsureTrailingNewline()
})
return nil
}),
@ -798,106 +796,90 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
return nil
}
if err := a.Queue(input); err != nil {
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")
return err
}
}
return nil
}),
virtfs.Read(func() ([]byte, error) {
item, ok := a.PopQueue()
if !ok {
return nil, nil
}
return []byte(item), nil
}),
),
virtfs.FileNode("chat", 0440, // read-only, owner + group
virtfs.StatOverride(chatStat),
virtfs.Read(func() ([]byte, error) {
a.ChatMu().RLock()
defer a.ChatMu().RUnlock()
return stripMarkers(a.ChatLog()), 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),
// 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)
}
}) {
return fmt.Errorf("async work limit reached")
}
}
return nil
}),
virtfs.Read(func() ([]byte, error) {
item, ok := a.PopQueue()
if !ok {
return nil, nil
}
return []byte(item), nil
}),
),
virtfs.FileNode("chat.search", 0660, // rdwr - write blockId, read block
virtfs.Doc("Search chat by block ID. Write ID, read block content."),
// log.raw — JSONL (one Block per line), for GUI/programmatic access
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) {
blockID := strings.TrimSpace(string(data))
block, found := a.ChatSearchByID(blockID)
block, found := a.BlockByID(blockID)
if !found {
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.Doc("Current agent state (idle, calling, thinking, paused)"),
virtfs.Read(func() ([]byte, error) {
return []byte(a.State() + "\n"), nil
}),
),
virtfs.FileNode("status", 0444,
virtfs.Doc("Human-readable status: current activity and elapsed time"),
virtfs.Read(func() ([]byte, error) {
return []byte(a.Status() + "\n"), nil
}),
),
virtfs.FileNode("log", 0440, // read-only, owner + group
virtfs.Read(func() ([]byte, error) {
const maxWindow = 64 * 1024
a.ChatMu().RLock()
log := a.ChatLog()
start := 0
if len(log) > maxWindow {
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.Doc("Current agent state (idle, calling, thinking, paused)"),
virtfs.Read(func() ([]byte, error) {
return []byte(a.State() + "\n"), nil
}),
),
virtfs.FileNode("status", 0444,
virtfs.Doc("Human-readable status: current activity and elapsed time"),
virtfs.Read(func() ([]byte, error) {
return []byte(a.Status() + "\n"), 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.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)
}
input := strings.TrimSpace(string(data))
if input == "" {
if input == "" {
return nil
}
startAsync(s.Ctx, func() {
target.Submit(s.Ctx, input)
})
return nil
}
startAsync(s.Ctx, func() {
target.Submit(s.Ctx, input)
target.EnsureTrailingNewline()
})
return nil
},
})
}
return out, 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{
{"kill", "destroy this agent", func(_ []string) ([]byte, error) {
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 |
| `prompt` | write | Submit a prompt to this agent |
| `fifo` | r/w | Prompt queue. Write: enqueue. Read: dequeue. |
| `chat` | read | Conversation (filtered text, streamable) |
| `chat.raw` | read | Full conversation with block markers |
| `log` | read | Conversation (filtered text, last 64KB) |
| `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) |
| `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 |

View File

@ -4,7 +4,7 @@
# Then reload: cat $HOME/lib/plumbing | 9p write plumb/rules
# 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
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

View File

@ -27,7 +27,7 @@
# o myproj ls List session contents
# o myproj/coding new Create session+agent (cwd=$PWD)
# 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
@ -512,13 +512,13 @@ cmd_tui() {
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
local ctx="$s/$a"
tmux new-session -d -s "$tmux_session" -c "$HOME"
tmux set-option -t "$tmux_session" mouse on
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%
sleep 0.2
@ -631,7 +631,7 @@ Commands (availability depends on context):
bypass Show pending bypass request (session-level)
approve [id] Approve 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:
read [-l] <path> Read a file (-l: loop)
@ -648,7 +648,7 @@ Examples:
o myproj/coding new ~/src # create with specific cwd
o myproj ls # list session contents
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 approve # approve pending bypass
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/{aname}/prompt` | w | Queue a user turn. |
| `agent/{aname}/fifo` | r/w | Prompt queue. |
| `agent/{aname}/chat` | r | Filtered streaming chat output. |
| `agent/{aname}/chat.raw` | r | Full streaming output with markers. |
| `agent/{aname}/log` | r | Filtered text conversation (last 64KB). |
| `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}/log` | r | Conversation snapshot. |
| `agent/{aname}/plan` | r/w | Persistent planning scratch space. |
| `agent/{aname}/cfg` | r/w | Agent configuration. |
| `agent/{aname}/ctl` | rdwr | Agent controls and queries. |
@ -106,7 +106,7 @@ Submit and observe work:
```sh
echo 'fix the bug in main.go' \
| 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
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
├─ file, selection, cursor, project, or diff
└─ 9P client → agent/.../prompt
├─ chat / chat.raw
├─ log / log.raw
├─ state
├─ status
└─ ctl

View File

@ -94,7 +94,7 @@ For the public protocol and namespace, see [`architecture-9p.md`](architecture-9
### URL Scheme
```
ollie://session/agent#block
ollie://session/agent#blockId
ollie://session/agent
ollie://session
```
@ -103,10 +103,12 @@ ollie://session
|---|---|
| `session` | Session name (required) |
| `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:
- `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://default` — switch to session "default"
@ -117,7 +119,7 @@ Examples:
```
# ollie:// URLs go to the ollie port
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
```
@ -140,7 +142,7 @@ cat $HOME/lib/plumbing | 9p write plumb/rules
From any Plan 9 application (acme, sam, rc, etc.):
```sh
plumb 'ollie://default/main#100'
plumb 'ollie://default/main#a1b2c3d4'
```
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 property string blockType
required property string blockName
required property var blockSegments
required property string blockRenderedHtml
required property int index
required property string blockId
// 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 checkBookmarked: function(sid, aid, bid) { return false }
property bool isBookmarked: checkBookmarked(ollie.activeSessionId, ollie.activeAgentId, blockId)
@ -82,8 +81,8 @@ Column {
case "user": return "\uD83E\uDDD1 You"
case "assistant": return "\uD83E\uDD16 Ollie"
case "reasoning": return "Reasoning"
case "call": return "\u25B6\uFE0F " + blockName
case "tool": return "\uD83D\uDDA5\uFE0F " + blockName
case "call": return "\u25B6\uFE0F Call"
case "tool": return "\uD83D\uDDA5\uFE0F Tool"
case "error": return "\u274C Error"
case "info": return "\u2139\uFE0F Info"
case "retry": return "Retry"
@ -200,7 +199,7 @@ Column {
} else {
var sessionName = ollie.activeSessionName || ollie.activeSessionId
var agentName = ollie.activeAgentName || ollie.activeAgentId
var preview = getBlockPlainText() || blockName
var preview = getBlockPlainText() || blockType
root.addBookmark(
ollie.activeSessionId,
sessionName,
@ -208,7 +207,6 @@ Column {
agentName,
root.blockId,
blockType,
blockName,
preview
)
}

View File

@ -1,7 +1,6 @@
#include "chatblockmodel.h"
#include <QTextDocument>
#include <QRegularExpressionMatch>
#include <QApplication>
#include <QPalette>
#include <QNetworkAccessManager>
@ -10,6 +9,9 @@
#include <QUrl>
#include <QUrlQuery>
#include <QCryptographicHash>
#include <QJsonDocument>
#include <QJsonObject>
#include <QRegularExpression>
#include <KSyntaxHighlighting/Repository>
#include <KSyntaxHighlighting/Definition>
@ -81,34 +83,24 @@ private:
// --- Static helpers ---
static ChatBlock::Type blockTypeFromString(const QString &typeStr)
ChatBlock::Type ChatBlockModel::blockTypeFromString(const QString &role)
{
if (typeStr == "user") return ChatBlock::User;
if (typeStr == "assistant") return ChatBlock::Assistant;
if (typeStr == "reasoning") return ChatBlock::Reasoning;
if (typeStr == "call") return ChatBlock::Call;
if (typeStr == "tool") return ChatBlock::Tool;
if (typeStr == "error") return ChatBlock::Error;
if (typeStr == "info") return ChatBlock::Info;
if (typeStr == "retry") return ChatBlock::Retry;
if (role == "user") return ChatBlock::User;
if (role == "assistant") return ChatBlock::Assistant;
if (role == "context") return ChatBlock::Context;
if (role == "reasoning") return ChatBlock::Reasoning;
if (role == "call") return ChatBlock::Call;
if (role == "tool") return ChatBlock::Tool;
if (role == "error") return ChatBlock::Error;
if (role == "info") return ChatBlock::Info;
if (role == "retry") return ChatBlock::Retry;
if (role == "interrupt") return ChatBlock::Interrupt;
return ChatBlock::Default;
}
// --- Block Parsing ---
const QRegularExpression ChatBlockModel::s_headerRe(
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"(^```$)");
// Code fence regex (used for content rendering, not block parsing)
static const QRegularExpression s_fenceOpenRe(R"(^```([A-Za-z0-9_+.-]*).*$)");
static const QRegularExpression s_fenceCloseRe(R"(^```$)");
ChatBlockModel::ChatBlockModel(QObject *parent)
: QAbstractListModel(parent)
@ -141,6 +133,7 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const
switch (b.type) {
case ChatBlock::User: return QStringLiteral("user");
case ChatBlock::Assistant: return QStringLiteral("assistant");
case ChatBlock::Context: return QStringLiteral("context");
case ChatBlock::Reasoning: return QStringLiteral("reasoning");
case ChatBlock::Call: return QStringLiteral("call");
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");
}
return {};
case BlockNameRole:
return b.name;
case ContentRole:
return b.content;
case RenderedHtmlRole:
@ -161,6 +152,8 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const
return segmentsToVariant(b.segments);
case BlockIdRole:
return b.blockId;
case PartialRole:
return b.partial;
}
return {};
}
@ -189,44 +182,88 @@ QHash<int, QByteArray> ChatBlockModel::roleNames() const
{
return {
{BlockTypeRole, "blockType"},
{BlockNameRole, "blockName"},
{ContentRole, "blockContent"},
{RenderedHtmlRole, "blockRenderedHtml"},
{SegmentsRole, "blockSegments"},
{BlockIdRole, "blockId"},
{PartialRole, "blockPartial"},
};
}
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;
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);
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
// so the UI shows text as it arrives, not just after newlines.
if (!m_lineBuffer.isEmpty() && !m_blocks.isEmpty() && m_state == State::BlockEnd) {
int blockIndex = m_blocks.size() - 1;
ChatBlock &last = m_blocks[blockIndex];
QJsonObject obj = doc.object();
QString role = obj["role"].toString();
QString id = obj["id"].toString();
QString content = obj["content"].toString();
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
QList<BlockSegment> savedSegments = last.segments;
QString savedHtml = last.renderedHtml;
qDebug() << "ChatBlockModel::processJsonLine role=" << role << "id=" << id << "content.len=" << content.length();
renderLastSegmentPartial(blockIndex, m_lineBuffer);
// Skip context blocks from rendering (context is for LLM, not display)
if (role == "context") {
return;
}
// Emit the partial-updated view
QModelIndex idx = index(blockIndex);
emit dataChanged(idx, idx, {RenderedHtmlRole, SegmentsRole});
// Find existing block with same ID (for streaming updates)
int existingIdx = indexForBlockId(id);
// Restore — partial line not committed yet
last.segments = savedSegments;
last.renderedHtml = savedHtml;
if (existingIdx >= 0) {
// Update existing block
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();
m_blocks.clear();
m_lineBuffer.clear();
m_inContext = false;
m_state = State::BlockStart;
m_inCodeFence = false;
m_fenceLanguage.clear();
m_fenceContent.clear();
m_pendingDiagrams.clear();
m_inFlightRequests.clear();
m_blockIdCounter = 0; // Reset so same chat produces same IDs
endResetModel();
emit countChanged();
}
@ -418,6 +449,37 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex)
return;
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;
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) {
// Still inside a fence — render what we have (incomplete)
// Unclosed fence (streaming)
flushFence(false);
} else {
flushProse();
@ -514,209 +576,6 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex)
block.renderedHtml = html;
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)
@ -725,345 +584,156 @@ void ChatBlockModel::renderLastSegmentPartial(int blockIndex, const QString &par
return;
ChatBlock &block = m_blocks[blockIndex];
BlockRenderState &rs = block.renderState;
if (rs.inFence) {
// Show partial line appended to current fence — use cached state
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();
// 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>");
}
// Append partial line to content and re-render
QString tempContent = block.content;
if (!tempContent.isEmpty() && !tempContent.endsWith('\n')) {
tempContent += partialLine;
} else {
// Show partial line appended to current prose/table
if (rs.inTable) {
// Currently building a table — show partial line as part of the table
if (isTableLine(partialLine)) {
QStringList preview = rs.tableLines;
preview.append(partialLine);
if (!block.segments.isEmpty())
block.segments.last().html = renderTable(preview);
tempContent += partialLine;
}
block.content = tempContent;
rebuildBlockHtml(blockIndex);
}
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 {
// Partial line breaks the table — just show it as escaped text after
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);
html += QStringLiteral("<div>[Loading diagram...]</div>");
}
} else {
const QString defaultColor = getDefaultTextColor();
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);
}
html += seg.html;
}
}
rebuildRenderedHtmlFromSegments(block);
block.renderedHtml = html;
}
void ChatBlockModel::processLine(const QString &line)
{
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 ---
// --- Diagram support ---
bool ChatBlockModel::isDiagramLanguage(const QString &lang) const
{
const QString l = lang.toLower();
return l == QStringLiteral("plantuml") ||
l == QStringLiteral("mermaid") ||
l == QStringLiteral("puml");
return lang == QStringLiteral("plantuml") ||
lang == QStringLiteral("mermaid") ||
lang == QStringLiteral("dot") ||
lang == QStringLiteral("graphviz");
}
QString ChatBlockModel::diagramPlaceholder(const QString &lang) const
{
const QString defaultColor = getDefaultTextColor();
return QStringLiteral("<div style=\"padding:8px;border:1px dashed ") + defaultColor +
QStringLiteral(";color:") + defaultColor +
QStringLiteral(";font-style:italic;\">Loading ") +
lang.toHtmlEscaped() + QStringLiteral(" diagram...</div>");
return QStringLiteral("<div style=\"padding:8px;background:#f0f0f0;border:1px solid #ccc;\">")
+ QStringLiteral("[Rendering ") + lang.toHtmlEscaped() + QStringLiteral(" diagram...]</div>");
}
QString ChatBlockModel::diagramErrorHtml(const QString &error) const
{
return QStringLiteral("<div style=\"padding:8px;border:1px solid red;color:red;\">") +
QStringLiteral("Diagram error: ") + error.toHtmlEscaped() +
QStringLiteral("</div>");
return QStringLiteral("<div style=\"padding:8px;background:#fee;border:1px solid #c00;color:#c00;\">")
+ QStringLiteral("Diagram error: ") + error.toHtmlEscaped() + QStringLiteral("</div>");
}
QString ChatBlockModel::diagramSvgHtml(const QByteArray &svg) const
{
// Embed SVG inline as data URI
const QString base64 = QString::fromLatin1(svg.toBase64());
return QStringLiteral("<img src=\"data:image/svg+xml;base64,") + base64 +
QStringLiteral("\" style=\"max-width:100%;\" />");
return QStringLiteral("<img src=\"data:image/svg+xml;base64,")
+ QString::fromLatin1(svg.toBase64())
+ QStringLiteral("\" style=\"max-width:100%;\" />");
}
QString ChatBlockModel::diagramPngHtml(const QByteArray &png) const
{
// Embed PNG inline as data URI
const QString base64 = QString::fromLatin1(png.toBase64());
return QStringLiteral("<img src=\"data:image/png;base64,") + base64 +
QStringLiteral("\" style=\"max-width:100%;\" />");
return QStringLiteral("<img src=\"data:image/png;base64,")
+ QString::fromLatin1(png.toBase64())
+ QStringLiteral("\" style=\"max-width:100%;\" />");
}
QString ChatBlockModel::diagramJpegHtml(const QByteArray &jpeg) const
{
// Embed JPEG inline as data URI
const QString base64 = QString::fromLatin1(jpeg.toBase64());
return QStringLiteral("<img src=\"data:image/jpeg;base64,") + base64 +
QStringLiteral("\" style=\"max-width:100%;\" />");
return QStringLiteral("<img src=\"data:image/jpeg;base64,")
+ QString::fromLatin1(jpeg.toBase64())
+ QStringLiteral("\" style=\"max-width:100%;\" />");
}
void ChatBlockModel::requestDiagram(const QString &lang, const QString &code, int blockIndex)
{
const QString requestKey = lang + QStringLiteral(":") +
QString::fromLatin1(QCryptographicHash::hash(
code.toUtf8(), QCryptographicHash::Md5).toHex());
// Hash the request for deduplication
const QString hashInput = lang + QStringLiteral(":") + code;
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.
if (!m_pendingDiagrams.contains(requestKey))
m_pendingDiagrams[requestKey] = QList<int>();
if (!m_pendingDiagrams[requestKey].contains(blockIndex))
m_pendingDiagrams[requestKey].append(blockIndex);
if (m_inFlightRequests.contains(requestKey))
// Track which blocks want this diagram
m_pendingDiagrams[hash].append(blockIndex);
// Don't re-request if already in flight
if (m_inFlightRequests.contains(hash))
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;
request.setAttribute(QNetworkRequest::User, requestKey);
request.setTransferTimeout(10000);
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("text/plain"));
request.setAttribute(QNetworkRequest::User, hash);
const QString langLower = lang.toLower();
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;
}
m_netManager->post(request, code.toUtf8());
}
void ChatBlockModel::onDiagramReply(QNetworkReply *reply)
{
reply->deleteLater();
const QString cacheKey = reply->request().attribute(QNetworkRequest::User).toString();
m_inFlightRequests.remove(cacheKey);
const QString hash = reply->request().attribute(QNetworkRequest::User).toString();
m_inFlightRequests.remove(hash);
QByteArray imageData;
bool success = false;
QString errorMsg;
QList<int> indices = m_pendingDiagrams.take(hash);
if (indices.isEmpty())
return;
if (reply->error() == QNetworkReply::NoError) {
imageData = reply->readAll();
QString resultHtml;
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 (imageData.startsWith("\xff\xd8\xff")) {
// JPEG image
success = true;
imageData.prepend("JPEG:");
} else if (imageData.startsWith("\x89PNG")) {
// PNG image
success = true;
imageData.prepend("PNG:");
} else if (imageData.contains("<svg") || imageData.startsWith("<?xml")) {
// SVG image
success = true;
imageData.prepend("SVG:");
if (contentType.contains(QStringLiteral("svg"))) {
resultHtml = diagramSvgHtml(data);
} else if (contentType.contains(QStringLiteral("png"))) {
resultHtml = diagramPngHtml(data);
} else if (contentType.contains(QStringLiteral("jpeg")) || contentType.contains(QStringLiteral("jpg"))) {
resultHtml = diagramJpegHtml(data);
} else {
errorMsg = QStringLiteral("Invalid response (not SVG, PNG, or JPEG)");
resultHtml = diagramErrorHtml(QStringLiteral("Unknown content type: ") + contentType);
}
} else {
errorMsg = reply->errorString();
}
if (success) {
// Keep the result only on the waiting segments; do not cache diagrams.
} else {
// 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())
// Update all blocks waiting for this diagram
for (int idx : indices) {
if (idx < 0 || idx >= m_blocks.size())
continue;
ChatBlock &block = m_blocks[blockIndex];
BlockSegment *matchingSegment = nullptr;
for (BlockSegment &segment : block.segments) {
if (segment.type != BlockSegment::Diagram || !segment.diagramLoading)
continue;
const QString segmentKey = segment.diagramLang + QStringLiteral(":") +
QString::fromLatin1(QCryptographicHash::hash(
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;
ChatBlock &block = m_blocks[idx];
for (BlockSegment &seg : block.segments) {
if (seg.type == BlockSegment::Diagram && seg.diagramLoading) {
seg.diagramSvg = resultHtml;
seg.diagramLoading = false;
if (reply->error() != QNetworkReply::NoError) {
seg.diagramError = reply->errorString();
}
}
}
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);
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 <QString>
#include <QStringList>
#include <QRegularExpression>
#include <QHash>
#include <QSet>
#include <QVariantList>
@ -31,7 +30,7 @@ struct BlockSegment {
};
// 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 {
bool inFence = false; // Currently inside a code fence
QString fenceLang; // Language of the open fence
@ -49,6 +48,7 @@ struct ChatBlock {
enum Type {
User,
Assistant,
Context,
Reasoning,
Call,
Tool,
@ -60,12 +60,14 @@ struct ChatBlock {
};
Type type = Default;
QString name;
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
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
@ -74,19 +76,13 @@ class ChatBlockModel : public QAbstractListModel
Q_PROPERTY(int count READ rowCount NOTIFY countChanged)
public:
// Two-state parser for [[[blockname]]] ... [[[end]]] blocks.
enum class State {
BlockStart, // Waiting for a block header
BlockEnd // Collecting content until [[[end]]]
};
enum Roles {
BlockTypeRole = Qt::UserRole + 1,
BlockNameRole,
ContentRole,
RenderedHtmlRole,
SegmentsRole, // QVariantList of segment maps
BlockIdRole, // Stable block identifier
PartialRole, // True if streaming (not final)
};
explicit ChatBlockModel(QObject *parent = nullptr);
@ -108,10 +104,8 @@ private slots:
void onDiagramReply(QNetworkReply *reply);
private:
void processLine(const QString &line);
void appendContent(const QString &line);
void processJsonLine(const QByteArray &line);
void rebuildBlockHtml(int blockIndex);
void incrementalAppend(int blockIndex, const QString &line);
void renderLastSegmentPartial(int blockIndex, const QString &partialLine);
static void rebuildRenderedHtmlFromSegments(ChatBlock &block);
@ -135,31 +129,19 @@ private:
// Convert segments to QVariantList for QML
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;
QNetworkAccessManager *m_netManager = nullptr;
QList<ChatBlock> m_blocks;
QString m_lineBuffer;
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;
QByteArray m_lineBuffer; // Buffer for incomplete JSON lines
// Pending requests: hash -> block indices waiting for this diagram
QHash<QString, QList<int>> m_pendingDiagrams;
// Track which requests are in flight
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

View File

@ -10,7 +10,6 @@
#include <QQmlContext>
#include <QQuickStyle>
#include <QStandardPaths>
#include <QUrl>
#include "ollie9pclient.h"
#include "sessionmodel.h"
@ -55,29 +54,6 @@ int main(int argc, char *argv[])
QObject::connect(ollie, &Ollie9pClient::chatReceived,
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("sessionModel", sessionModel);
engine.rootContext()->setContextProperty("chatModel", chatModel);

View File

@ -14,12 +14,12 @@ ApplicationWindow {
font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize
// 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 {
id: bookmarksModel
}
function addBookmark(sessionId, sessionName, agentId, agentName, blockId, blockType, blockName, preview) {
function addBookmark(sessionId, sessionName, agentId, agentName, blockId, blockType, preview) {
// Check for duplicate
for (var i = 0; i < bookmarksModel.count; i++) {
var b = bookmarksModel.get(i)
@ -34,7 +34,6 @@ ApplicationWindow {
agentName: agentName,
blockId: blockId,
blockType: blockType,
blockName: blockName,
preview: preview.substring(0, 80) + (preview.length > 80 ? "…" : "")
})
return true
@ -74,7 +73,17 @@ ApplicationWindow {
var agentName = match[2] ? decodeURIComponent(match[2]) : ""
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
for (var i = 0; i < sessions.length; 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
root.show()
root.raise()

View File

@ -468,7 +468,8 @@ QString Ollie9pClient::readLogForSession(const QString &sessionId, const QString
if (sessionId.isEmpty() || agentId.isEmpty()) return {};
if (!m_9p || !m_9p->isConnected()) return {};
// 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));
}
@ -1075,13 +1076,20 @@ QVariantList Ollie9pClient::listAgentsInSession(const QString &sessionId)
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();
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();
}
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());
qDebug("fetchBlock: got %lld bytes", (long long)out.size());
return QString::fromUtf8(out);
}
@ -1124,7 +1132,7 @@ void Ollie9pClient::startActiveAgentStreams()
if (!m_daemonConnected) 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
checkPendingBypass();

View File

@ -113,7 +113,7 @@ public:
Q_INVOKABLE bool removePeer(const QString &sessionId, const QString &agentId, const QString &peerName);
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);
signals: