feat: Ollama/gemma4 compat — /init flow, stream filter, safety fixes

provider/openai:
- Fix doubled tool call args (argsComplete flag): Ollama sends complete
  args in the first streaming chunk then repeats them as delta, causing
  doubled JSON and 400 errors in elfs
- Handle fs: prefix (gemma4 uses fs:grep instead of fs.grep)
- Add Reasoning field support for Ollama thinking output

cmd/gnoma:
- Early TTY detection so logger is created with correct destination
  before any component gets a reference to it (fixes slog WARN bleed
  into TUI textarea)

permission:
- Exempt spawn_elfs and agent tools from safety scanner: elf prompt
  text may legitimately mention .env/.ssh/credentials patterns and
  should not be blocked

tui/app:
- /init retry chain: no-tool-calls → spawn_elfs nudge → write nudge
  (ask for plain text output) → TUI fallback write from streamBuf
- looksLikeAgentsMD + extractMarkdownDoc: validate and clean fallback
  content before writing (reject refusals, strip narrative preambles)
- Collapse thinking output to 3 lines; ctrl+o to expand (live stream
  and committed messages)
- Stream-level filter for model pseudo-tool-call blocks: suppresses
  <<tool_code>>...</tool_code>> and <<function_call>>...<tool_call|>
  from entering streamBuf across chunk boundaries
- sanitizeAssistantText regex covers both block formats
- Reset streamFilterClose at every turn start
This commit is contained in:
2026-04-05 19:24:51 +02:00
parent 14b88cadcc
commit cb2d63d06f
51 changed files with 2855 additions and 353 deletions

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"testing"
gnomactx "somegit.dev/Owlibou/gnoma/internal/context"
"somegit.dev/Owlibou/gnoma/internal/message"
"somegit.dev/Owlibou/gnoma/internal/provider"
"somegit.dev/Owlibou/gnoma/internal/stream"
@@ -446,6 +447,109 @@ func TestEngine_Reset(t *testing.T) {
}
}
func TestEngine_Reset_ClearsContextWindow(t *testing.T) {
ctxWindow := gnomactx.NewWindow(gnomactx.WindowConfig{MaxTokens: 200_000})
mp := &mockProvider{
name: "test",
streams: []stream.Stream{
newEventStream(message.StopEndTurn, "",
stream.Event{Type: stream.EventTextDelta, Text: "hi"},
),
},
}
e, _ := New(Config{
Provider: mp,
Tools: tool.NewRegistry(),
Context: ctxWindow,
})
e.Submit(context.Background(), "hello", nil)
if len(ctxWindow.Messages()) == 0 {
t.Fatal("context window should have messages before reset")
}
e.Reset()
if len(ctxWindow.Messages()) != 0 {
t.Errorf("context window should be empty after reset, got %d messages", len(ctxWindow.Messages()))
}
}
func TestSubmit_ContextWindowTracksUserAndToolMessages(t *testing.T) {
reg := tool.NewRegistry()
reg.Register(&mockTool{
name: "bash",
execFn: func(_ context.Context, _ json.RawMessage) (tool.Result, error) {
return tool.Result{Output: "output"}, nil
},
})
mp := &mockProvider{
name: "test",
streams: []stream.Stream{
newEventStream(message.StopToolUse, "model",
stream.Event{Type: stream.EventToolCallStart, ToolCallID: "tc1", ToolCallName: "bash"},
stream.Event{Type: stream.EventToolCallDone, ToolCallID: "tc1", Args: json.RawMessage(`{"command":"ls"}`)},
stream.Event{Type: stream.EventUsage, Usage: &message.Usage{InputTokens: 100, OutputTokens: 20}},
),
newEventStream(message.StopEndTurn, "model",
stream.Event{Type: stream.EventTextDelta, Text: "Done."},
),
},
}
ctxWindow := gnomactx.NewWindow(gnomactx.WindowConfig{MaxTokens: 200_000})
e, _ := New(Config{
Provider: mp,
Tools: reg,
Context: ctxWindow,
})
_, err := e.Submit(context.Background(), "list files", nil)
if err != nil {
t.Fatalf("Submit: %v", err)
}
allMsgs := ctxWindow.AllMessages()
// Expect: user msg, assistant (tool call), tool results, assistant (final)
if len(allMsgs) < 4 {
t.Errorf("context window has %d messages, want at least 4 (user+assistant+tool_results+assistant)", len(allMsgs))
for i, m := range allMsgs {
t.Logf(" [%d] role=%s content=%s", i, m.Role, m.TextContent())
}
}
// First message should be user
if len(allMsgs) > 0 && allMsgs[0].Role != message.RoleUser {
t.Errorf("allMsgs[0].Role = %q, want user", allMsgs[0].Role)
}
}
func TestSubmit_TrackerReflectsInputTokens(t *testing.T) {
// Verify the tracker is set from InputTokens (not accumulated).
// After 3 rounds, tracker should equal last round's InputTokens+OutputTokens,
// not the sum of all rounds.
ctxWindow := gnomactx.NewWindow(gnomactx.WindowConfig{MaxTokens: 200_000})
mp := &mockProvider{
name: "test",
streams: []stream.Stream{
newEventStream(message.StopEndTurn, "",
stream.Event{Type: stream.EventUsage, Usage: &message.Usage{InputTokens: 100, OutputTokens: 50}},
stream.Event{Type: stream.EventTextDelta, Text: "a"},
),
},
}
e, _ := New(Config{Provider: mp, Tools: tool.NewRegistry(), Context: ctxWindow})
e.Submit(context.Background(), "hi", nil)
// Tracker should be InputTokens + OutputTokens = 150, not more
used := ctxWindow.Tracker().Used()
if used != 150 {
t.Errorf("tracker = %d, want 150 (InputTokens+OutputTokens, not cumulative)", used)
}
}
func TestSubmit_CumulativeUsage(t *testing.T) {
mp := &mockProvider{
name: "test",