Files
gnoma/internal/provider/subprocess/stream_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

91 lines
2.4 KiB
Go

package subprocess
import (
"context"
"os/exec"
"runtime"
"testing"
"somegit.dev/Owlibou/gnoma/internal/stream"
)
// TestSubprocessStream_EchoShell runs a real subprocess (printf) and verifies
// the stream delivers the expected events.
func TestSubprocessStream_EchoShell(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell printf not available on windows")
}
// Feed a vibe-format line through a printf subprocess.
// We use vibe format because it's the simplest (no "done" event needed).
line := `{"role":"assistant","content":"hello from subprocess","reasoning_content":null,"tool_calls":null,"message_id":"abc"}`
cmd := exec.Command("sh", "-c", "printf '%s\n'", line)
// Actually build the printf command with the correct argument
cmd = exec.CommandContext(context.Background(), "sh", "-c",
`printf '{"role":"assistant","content":"hello from subprocess","reasoning_content":null,"tool_calls":null,"message_id":"abc"}\n'`)
s, err := newSubprocessStream(context.Background(), cmd, newVibeParser())
if err != nil {
t.Fatal(err)
}
defer s.Close()
var texts []string
for s.Next() {
ev := s.Current()
if ev.Type == stream.EventTextDelta {
texts = append(texts, ev.Text)
}
}
if err := s.Err(); err != nil {
t.Fatalf("stream error: %v", err)
}
if len(texts) == 0 {
t.Fatal("no text events received")
}
if texts[0] != "hello from subprocess" {
t.Errorf("got text %q, want %q", texts[0], "hello from subprocess")
}
}
func TestSubprocessStream_ContextCancel(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("sleep not available on windows")
}
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, "sh", "-c", "sleep 30")
s, err := newSubprocessStream(ctx, cmd, newVibeParser())
if err != nil {
t.Fatal(err)
}
defer s.Close()
cancel()
// Drain — should stop quickly due to context cancellation.
for s.Next() {
}
// No error assertion: context cancel may or may not propagate as stream error.
}
func TestSubprocessStream_ProcessError(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("sh not available on windows")
}
cmd := exec.CommandContext(context.Background(), "sh", "-c", "exit 1")
s, err := newSubprocessStream(context.Background(), cmd, newVibeParser())
if err != nil {
t.Fatal(err)
}
defer s.Close()
for s.Next() {
}
// A non-zero exit should surface as a stream error.
if s.Err() == nil {
t.Error("expected stream error for non-zero exit, got nil")
}
}