Files
gnoma/internal/provider/subprocess/format_test.go
T
vikingowl 44d0bdc032 feat(provider): subprocess CLI provider for claude, gemini, vibe
Adds internal/provider/subprocess — a provider.Provider that spawns CLI
agents (claude, gemini, vibe) as subprocesses and streams their output.

- FormatParser interface + three parsers for claude-stream-json,
  gemini-stream-json, and vibe-streaming formats; fixtures captured from
  real binaries
- subprocessStream: pull-based stream.Stream over subprocess stdout with
  bounded stderr capture (8KB) and guarded reap() to prevent double-Wait
- DiscoverCLIAgents: parallel PATH scan with 10s timeout, stable ordering
- Provider: only the last user message is passed as --prompt; all other
  request fields (history, tools, system prompt) are intentionally ignored
  (see package doc)
- main.go: discover and register CLI arms at startup; TODO(P0c) for
  tier-based routing to enforce preference order explicitly
2026-05-07 14:29:34 +02:00

312 lines
7.9 KiB
Go

package subprocess
import (
"os"
"strings"
"testing"
"somegit.dev/Owlibou/gnoma/internal/message"
"somegit.dev/Owlibou/gnoma/internal/stream"
)
// loadFixture reads testdata/<name>.jsonl and returns non-empty lines.
func loadFixture(t *testing.T, name string) [][]byte {
t.Helper()
data, err := os.ReadFile("testdata/" + name + ".jsonl")
if err != nil {
t.Fatalf("load fixture %s: %v", name, err)
}
var lines [][]byte
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line != "" {
lines = append(lines, []byte(line))
}
}
return lines
}
// collectEvents runs all fixture lines through a parser and returns all emitted events.
func collectEvents(t *testing.T, p FormatParser, lines [][]byte) []stream.Event {
t.Helper()
var events []stream.Event
for _, line := range lines {
evts, err := p.ParseLine(line)
if err != nil {
t.Errorf("ParseLine(%s): %v", string(line), err)
continue
}
events = append(events, evts...)
}
events = append(events, p.Done()...)
return events
}
// --- claude-stream-json ---
func TestClaudeParser_ExtractsTextDelta(t *testing.T) {
p := newClaudeParser()
line := []byte(`{"type":"assistant","message":{"model":"claude-sonnet-4-6","content":[{"type":"text","text":"hello world"}],"usage":{"input_tokens":5,"output_tokens":2}}}`)
evts, err := p.ParseLine(line)
if err != nil {
t.Fatal(err)
}
if len(evts) == 0 {
t.Fatal("expected at least one event")
}
if evts[0].Type != stream.EventTextDelta {
t.Errorf("got type %v, want EventTextDelta", evts[0].Type)
}
if evts[0].Text != "hello world" {
t.Errorf("got text %q, want %q", evts[0].Text, "hello world")
}
}
func TestClaudeParser_ExtractsUsageFromResult(t *testing.T) {
p := newClaudeParser()
line := []byte(`{"type":"result","subtype":"success","is_error":false,"stop_reason":"end_turn","usage":{"input_tokens":10,"output_tokens":5},"result":"hello"}`)
evts, err := p.ParseLine(line)
if err != nil {
t.Fatal(err)
}
var usageEvt *stream.Event
for i := range evts {
if evts[i].Type == stream.EventUsage {
usageEvt = &evts[i]
}
}
if usageEvt == nil {
t.Fatal("no EventUsage emitted")
}
if usageEvt.Usage == nil {
t.Fatal("Usage is nil")
}
if usageEvt.Usage.InputTokens != 10 {
t.Errorf("input_tokens: got %d, want 10", usageEvt.Usage.InputTokens)
}
if usageEvt.Usage.OutputTokens != 5 {
t.Errorf("output_tokens: got %d, want 5", usageEvt.Usage.OutputTokens)
}
if usageEvt.StopReason != message.StopEndTurn {
t.Errorf("stop_reason: got %v, want StopEndTurn", usageEvt.StopReason)
}
}
func TestClaudeParser_IgnoresSystemAndRateLimit(t *testing.T) {
p := newClaudeParser()
system := []byte(`{"type":"system","subtype":"init","model":"claude-sonnet-4-6"}`)
rateLimit := []byte(`{"type":"rate_limit_event","rate_limit_info":{}}`)
for _, line := range [][]byte{system, rateLimit} {
evts, err := p.ParseLine(line)
if err != nil {
t.Errorf("ParseLine(%s): unexpected error: %v", line, err)
}
if len(evts) != 0 {
t.Errorf("expected no events for %s, got %d", line, len(evts))
}
}
}
func TestClaudeParser_ErrorResult(t *testing.T) {
p := newClaudeParser()
line := []byte(`{"type":"result","subtype":"error_during_run","is_error":true,"usage":{"input_tokens":5,"output_tokens":0}}`)
evts, err := p.ParseLine(line)
if err != nil {
t.Fatal(err)
}
var hasError bool
for _, e := range evts {
if e.Type == stream.EventError {
hasError = true
}
}
if !hasError {
t.Error("expected EventError for is_error=true result")
}
}
func TestClaudeParser_FixtureFile(t *testing.T) {
lines := loadFixture(t, "claude")
p := newClaudeParser()
evts := collectEvents(t, p, lines)
var textEvts, usageEvts int
for _, e := range evts {
switch e.Type {
case stream.EventTextDelta:
textEvts++
if e.Text == "" {
t.Error("EventTextDelta with empty text")
}
case stream.EventUsage:
usageEvts++
}
}
if textEvts == 0 {
t.Error("no EventTextDelta from claude fixture")
}
if usageEvts == 0 {
t.Error("no EventUsage from claude fixture")
}
}
// --- gemini-stream-json ---
func TestGeminiParser_ExtractsTextDelta(t *testing.T) {
p := newGeminiParser()
line := []byte(`{"type":"message","role":"assistant","content":"hello world","delta":true}`)
evts, err := p.ParseLine(line)
if err != nil {
t.Fatal(err)
}
if len(evts) == 0 {
t.Fatal("expected at least one event")
}
if evts[0].Type != stream.EventTextDelta {
t.Errorf("got type %v, want EventTextDelta", evts[0].Type)
}
if evts[0].Text != "hello world" {
t.Errorf("got text %q, want %q", evts[0].Text, "hello world")
}
}
func TestGeminiParser_ExtractsUsageFromResult(t *testing.T) {
p := newGeminiParser()
line := []byte(`{"type":"result","status":"success","stats":{"input_tokens":100,"output_tokens":20}}`)
evts, err := p.ParseLine(line)
if err != nil {
t.Fatal(err)
}
var usageEvt *stream.Event
for i := range evts {
if evts[i].Type == stream.EventUsage {
usageEvt = &evts[i]
}
}
if usageEvt == nil {
t.Fatal("no EventUsage emitted")
}
if usageEvt.Usage.InputTokens != 100 {
t.Errorf("input_tokens: got %d, want 100", usageEvt.Usage.InputTokens)
}
if usageEvt.Usage.OutputTokens != 20 {
t.Errorf("output_tokens: got %d, want 20", usageEvt.Usage.OutputTokens)
}
}
func TestGeminiParser_IgnoresUserMessages(t *testing.T) {
p := newGeminiParser()
line := []byte(`{"type":"message","role":"user","content":"say hi"}`)
evts, err := p.ParseLine(line)
if err != nil {
t.Fatal(err)
}
if len(evts) != 0 {
t.Errorf("expected no events for user message, got %d", len(evts))
}
}
func TestGeminiParser_FixtureFile(t *testing.T) {
lines := loadFixture(t, "gemini")
p := newGeminiParser()
evts := collectEvents(t, p, lines)
var textEvts, usageEvts int
for _, e := range evts {
switch e.Type {
case stream.EventTextDelta:
textEvts++
case stream.EventUsage:
usageEvts++
}
}
if textEvts == 0 {
t.Error("no EventTextDelta from gemini fixture")
}
if usageEvts == 0 {
t.Error("no EventUsage from gemini fixture")
}
}
// --- vibe-streaming (mistral) ---
func TestVibeParser_ExtractsTextDelta(t *testing.T) {
p := newVibeParser()
line := []byte(`{"role":"assistant","content":"hello world","reasoning_content":null,"tool_calls":null,"message_id":"abc123"}`)
evts, err := p.ParseLine(line)
if err != nil {
t.Fatal(err)
}
if len(evts) == 0 {
t.Fatal("expected at least one event")
}
if evts[0].Type != stream.EventTextDelta {
t.Errorf("got type %v, want EventTextDelta", evts[0].Type)
}
if evts[0].Text != "hello world" {
t.Errorf("got text %q, want %q", evts[0].Text, "hello world")
}
}
func TestVibeParser_ExtractsThinkingDelta(t *testing.T) {
p := newVibeParser()
line := []byte(`{"role":"assistant","content":"hello","reasoning_content":"I should say hi","tool_calls":null,"message_id":"abc123"}`)
evts, err := p.ParseLine(line)
if err != nil {
t.Fatal(err)
}
var hasThinking bool
for _, e := range evts {
if e.Type == stream.EventThinkingDelta {
hasThinking = true
if e.Text == "" {
t.Error("EventThinkingDelta with empty text")
}
}
}
if !hasThinking {
t.Error("expected EventThinkingDelta when reasoning_content is set")
}
}
func TestVibeParser_IgnoresSystemAndUser(t *testing.T) {
p := newVibeParser()
for _, line := range []string{
`{"role":"system","content":"You are Vibe...","message_id":null}`,
`{"role":"user","content":"say hi","message_id":"abc"}`,
} {
evts, err := p.ParseLine([]byte(line))
if err != nil {
t.Errorf("ParseLine(%s): unexpected error: %v", line, err)
}
if len(evts) != 0 {
t.Errorf("expected no events for role=%s, got %d", line[:20], len(evts))
}
}
}
func TestVibeParser_FixtureFile(t *testing.T) {
lines := loadFixture(t, "vibe")
p := newVibeParser()
evts := collectEvents(t, p, lines)
var textEvts int
for _, e := range evts {
if e.Type == stream.EventTextDelta {
textEvts++
}
}
if textEvts == 0 {
t.Error("no EventTextDelta from vibe fixture")
}
}