Initial commit on Forgejo
Some checks failed
CI and releases / Test and vet (push) Failing after 1s
CI and releases / Build release binaries (push) Has been skipped
CI and releases / Publish GitHub release (push) Has been skipped

Fresh repository history for elektrine/tarakan-client hosted at
https://git.elektrine.com/elektrine/tarakan-client.
This commit is contained in:
Maxfield Luke 2026-07-29 04:56:36 -04:00
commit 58b29a7f84
70 changed files with 12994 additions and 0 deletions

280
internal/agent/agent.go Normal file
View file

@ -0,0 +1,280 @@
package agent
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
)
var ErrUnavailable = errors.New("agent is unavailable")
// lookPath is indirected so provider detection can be exercised in tests
// without depending on what is installed on the host.
var lookPath = exec.LookPath
// Kind distinguishes an agentic CLI (which reads the repository itself) from
// an HTTP model endpoint (which needs the repository packed into the prompt).
const (
KindCLI = "cli"
KindHTTP = "http"
)
type Provider struct {
Name string `json:"name"`
Kind string `json:"kind,omitempty"`
Command string `json:"command,omitempty"`
Description string `json:"description"`
Path string `json:"path,omitempty"`
// HTTP providers only.
BaseURL string `json:"base_url,omitempty"`
Model string `json:"model,omitempty"`
APIKeyEnv string `json:"api_key_env,omitempty"`
}
type Request struct {
Prompt string
Directory string
// Progress, if set, is called with status lines (and agent stderr when streaming).
// Must be safe to call from a background goroutine.
Progress func(string)
}
type Registry struct {
providers []Provider
}
// Detect discovers every review backend available in this environment: the
// agentic CLIs on $PATH, plus the HTTP model endpoints (Ollama, OpenRouter)
// that are configured and reachable.
func Detect() Registry {
// Order is preference: Default() takes the first one that is installed, and
// the TUI lists them in this order. Kimi leads deliberately.
known := []Provider{
{Name: "kimi", Kind: KindCLI, Command: "kimi", Description: "Kimi Code"},
{Name: "claude", Kind: KindCLI, Command: "claude", Description: "Claude Code"},
{Name: "codex", Kind: KindCLI, Command: "codex", Description: "OpenAI Codex"},
{Name: "grok", Kind: KindCLI, Command: "grok", Description: "Grok Build"},
}
available := make([]Provider, 0, len(known)+2)
for _, provider := range known {
if path, err := exec.LookPath(provider.Command); err == nil {
provider.Path = path
available = append(available, provider)
}
}
available = append(available, detectHTTPProviders(os.Getenv)...)
return Registry{providers: available}
}
func (r Registry) Providers() []Provider {
return append([]Provider(nil), r.providers...)
}
func (r Registry) Find(name string) (Provider, bool) {
name = strings.ToLower(strings.TrimSpace(name))
for _, provider := range r.providers {
if provider.Name == name {
return provider, true
}
}
// --agent kimi prefers the CLI; fall back to Moonshot HTTP when only the API key is set.
if name == "kimi" {
for _, provider := range r.providers {
if provider.Name == "kimi-http" {
return provider, true
}
}
}
return Provider{}, false
}
func (r Registry) Default() (Provider, bool) {
if len(r.providers) == 0 {
return Provider{}, false
}
return r.providers[0], true
}
// ModelIdentifier is the model string recorded on a submitted review. HTTP
// providers know their exact model; CLI agents report the tool name because
// the underlying model is the CLI's own concern.
func (p Provider) ModelIdentifier() string {
if p.Kind == KindHTTP {
return p.Model
}
return p.Name
}
// WithModel returns a copy of the provider using a caller-supplied model. It
// only affects HTTP providers; CLI agents choose their own model.
func (p Provider) WithModel(model string) Provider {
if model == "" || p.Kind != KindHTTP {
return p
}
p.Model = model
return p
}
func Run(ctx context.Context, provider Provider, request Request) (string, error) {
switch provider.Kind {
case KindHTTP:
return runHTTP(ctx, provider, request)
default:
return runCLI(ctx, provider, request)
}
}
func runCLI(ctx context.Context, provider Provider, request Request) (string, error) {
if provider.Path == "" {
return "", ErrUnavailable
}
// CLI agents that expose structured event streams get live tool activity
// in Progress (same transcript UX for grok / claude / codex / kimi).
switch provider.Name {
case "grok":
return runGrok(ctx, provider, request)
case "claude":
return runClaude(ctx, provider, request)
case "codex":
return runCodex(ctx, provider, request)
case "kimi":
return runKimi(ctx, provider, request)
}
args, err := arguments(provider.Name, securityPrompt(request.Prompt))
if err != nil {
return "", err
}
command := exec.CommandContext(ctx, provider.Path, args...)
command.Dir = request.Directory
command.Env = subprocessEnvironment(os.Environ())
if request.Progress == nil {
output, err := command.CombinedOutput()
if err != nil {
return string(output), fmt.Errorf("%s failed: %w", provider.Description, err)
}
return strings.TrimSpace(string(output)), nil
}
return runCLIStreaming(command, provider.Description, request.Progress)
}
// runCLIStreaming tees stdout+stderr into the returned buffer while forwarding
// non-empty lines to progress (prefixed) so the TUI can show agent activity.
func runCLIStreaming(command *exec.Cmd, description string, progress func(string)) (string, error) {
stdout, err := command.StdoutPipe()
if err != nil {
return "", err
}
stderr, err := command.StderrPipe()
if err != nil {
return "", err
}
if err := command.Start(); err != nil {
return "", fmt.Errorf("%s failed to start: %w", description, err)
}
var (
buf bytes.Buffer
mu sync.Mutex
wg sync.WaitGroup
)
scan := func(r io.Reader, isErr bool) {
defer wg.Done()
scanner := bufio.NewScanner(r)
// Agents can emit long JSON lines.
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
mu.Lock()
buf.WriteString(line)
buf.WriteByte('\n')
mu.Unlock()
trimmed := strings.TrimSpace(line)
if trimmed == "" || progress == nil {
continue
}
// Skip huge JSON blobs in the status line; keep short activity.
if len(trimmed) > 200 || strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
if isErr {
progress(description + " … (working)")
}
continue
}
if isErr {
progress(description + ": " + trimmed)
} else {
progress(description + ": " + trimmed)
}
}
}
wg.Add(2)
go scan(stdout, false)
go scan(stderr, true)
wg.Wait()
waitErr := command.Wait()
output := strings.TrimSpace(buf.String())
if waitErr != nil {
return output, fmt.Errorf("%s failed: %w", description, waitErr)
}
return output, nil
}
// subprocessEnvironment prevents the agent process-and therefore untrusted
// repository instructions-from reading credentials for the Tarakan service.
// Provider CLIs keep their normal environment and their own authentication.
func subprocessEnvironment(environment []string) []string {
filtered := make([]string, 0, len(environment))
for _, entry := range environment {
name, _, _ := strings.Cut(entry, "=")
if strings.HasPrefix(strings.ToUpper(name), "TARAKAN_") {
continue
}
filtered = append(filtered, entry)
}
return filtered
}
func arguments(provider, prompt string) ([]string, error) {
switch provider {
case "claude":
return []string{"-p", prompt}, nil
case "codex":
return []string{"exec", prompt}, nil
case "grok":
return []string{"-p", prompt}, nil
case "kimi":
// Print mode: non-interactive, auto-approves tools for this invocation.
return []string{
"--print",
"--prompt", prompt,
"--yolo",
"--final-message-only",
}, nil
default:
return nil, fmt.Errorf("unknown agent provider %q", provider)
}
}
// reviewInstruction is the read-only security-review directive shared by every
// backend. CLI agents receive it inline; HTTP models receive it as the system
// message.
const reviewInstruction = "You are contributing a read-only security review of the current repository. " +
"Do not edit files, commit changes, access secrets, or interact with external systems. " +
"Clearly separate verified findings from hypotheses and include file paths and evidence."
func securityPrompt(userPrompt string) string {
return reviewInstruction + "\n\nUser request:\n" + userPrompt
}

View file

@ -0,0 +1,116 @@
package agent
import (
"os"
"strings"
"testing"
)
func TestArguments(t *testing.T) {
tests := []struct {
provider string
first string
}{
{provider: "claude", first: "-p"},
{provider: "codex", first: "exec"},
}
for _, test := range tests {
t.Run(test.provider, func(t *testing.T) {
args, err := arguments(test.provider, "prompt")
if err != nil {
t.Fatal(err)
}
if len(args) != 2 || args[0] != test.first || args[1] != "prompt" {
t.Fatalf("arguments = %#v", args)
}
})
}
// Grok uses a dedicated streaming runner (session id + streaming-json).
if args, err := arguments("grok", "prompt"); err != nil || len(args) < 2 || args[0] != "-p" {
t.Fatalf("grok base args = %#v err=%v", args, err)
}
args, err := arguments("kimi", "prompt")
if err != nil {
t.Fatal(err)
}
joined := strings.Join(args, " ")
for _, need := range []string{"--print", "--prompt", "prompt", "--yolo", "--final-message-only"} {
if !strings.Contains(joined, need) {
t.Fatalf("kimi args missing %q: %#v", need, args)
}
}
}
func TestParseKimiStreamLine(t *testing.T) {
ev := parseKimiStreamLine(`{"role":"assistant","content":"finding json"}`)
if !ev.ok || ev.final != "finding json" {
t.Fatalf("assistant message = %#v", ev)
}
ev = parseKimiStreamLine(`{"type":"tool_use","name":"Read"}`)
if !ev.ok || !strings.Contains(ev.activity, "Read") {
t.Fatalf("tool use = %#v", ev)
}
ev = parseKimiStreamLine(`{"choices":[{"delta":{"content":"hi"}}]}`)
if !ev.ok || ev.text != "hi" {
t.Fatalf("delta = %#v", ev)
}
}
func TestSecurityPromptEnforcesReadOnlyReview(t *testing.T) {
prompt := securityPrompt("inspect auth")
for _, expected := range []string{"read-only", "Do not edit files", "inspect auth"} {
if !strings.Contains(prompt, expected) {
t.Fatalf("prompt does not contain %q", expected)
}
}
}
func TestSubprocessEnvironmentRemovesTarakanSecrets(t *testing.T) {
environment := subprocessEnvironment([]string{
"PATH=/usr/bin",
"TARAKAN_API_TOKEN=do-not-leak",
"tarakan_url=https://tarakan.lol",
"HOME=/home/test",
})
joined := strings.Join(environment, "\n")
if strings.Contains(strings.ToUpper(joined), "TARAKAN_") {
t.Fatalf("Tarakan variable leaked into subprocess environment: %s", joined)
}
for _, expected := range []string{"PATH=/usr/bin", "HOME=/home/test"} {
if !strings.Contains(joined, expected) {
t.Fatalf("environment does not contain %q: %s", expected, joined)
}
}
}
// Preference is expressed as the order of the known provider list, so a
// reorder is easy to make by accident. Kimi leads.
func TestRegistryPrefersKimi(t *testing.T) {
r := Registry{providers: []Provider{
{Name: "kimi", Kind: KindCLI},
{Name: "claude", Kind: KindCLI},
}}
got, ok := r.Default()
if !ok || got.Name != "kimi" {
t.Fatalf("Default() = %q (ok=%v), want kimi", got.Name, ok)
}
}
func TestKnownProviderOrderPutsKimiFirst(t *testing.T) {
// Detect() filters by what is installed, so assert the declared order
// rather than the detected one.
src, err := os.ReadFile("agent.go")
if err != nil {
t.Fatalf("read agent.go: %v", err)
}
body := string(src)
kimi := strings.Index(body, `{Name: "kimi"`)
for _, other := range []string{"claude", "codex", "grok"} {
if at := strings.Index(body, `{Name: "`+other+`"`); kimi == -1 || at == -1 || kimi > at {
t.Fatalf("kimi must be declared before %s in the known providers list", other)
}
}
}

View file

@ -0,0 +1,281 @@
package agent
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"sync"
)
// runClaude streams Claude Code stream-json events so the TUI can show tool
// use (Read, Bash, Task/subagents) while the run is in progress.
func runClaude(ctx context.Context, provider Provider, request Request) (string, error) {
if provider.Path == "" {
return "", ErrUnavailable
}
args := []string{
"-p", securityPrompt(request.Prompt),
"--output-format", "stream-json",
"--verbose",
// Isolated snapshot; unattended tool use for the review run.
"--dangerously-skip-permissions",
}
command := exec.CommandContext(ctx, provider.Path, args...)
command.Dir = request.Directory
command.Env = subprocessEnvironment(os.Environ())
stdout, err := command.StdoutPipe()
if err != nil {
return "", err
}
var stderrBuf bytes.Buffer
command.Stderr = &stderrBuf
if err := command.Start(); err != nil {
return "", fmt.Errorf("%s failed to start: %w", provider.Description, err)
}
progress := request.Progress
report := func(line string) {
if progress == nil {
return
}
if line = strings.TrimSpace(line); line != "" {
progress(line)
}
}
var (
lastFooter string
footerMu sync.Mutex
)
reportFooter := func(line string) {
if progress == nil {
return
}
footerMu.Lock()
defer footerMu.Unlock()
if line == lastFooter {
return
}
lastFooter = line
progress(line)
}
var (
finalResult string
textBuf strings.Builder
seen = map[string]struct{}{}
)
emit := func(line string) {
for _, part := range strings.Split(line, "\n") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
key := normalizeActivityKey(part)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
report(part)
}
}
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for scanner.Scan() {
ev := parseClaudeStreamLine(scanner.Text())
if !ev.ok {
continue
}
if ev.activity != "" {
emit(ev.activity)
}
if ev.footer != "" {
reportFooter(ev.footer)
}
if ev.assistantText != "" {
textBuf.WriteString(ev.assistantText)
}
if ev.finalResult != "" {
finalResult = ev.finalResult
}
}
_ = scanner.Err()
waitErr := command.Wait()
output := strings.TrimSpace(finalResult)
if output == "" {
output = strings.TrimSpace(textBuf.String())
}
if waitErr != nil {
errText := strings.TrimSpace(stderrBuf.String())
if output == "" && errText != "" {
output = errText
}
return output, fmt.Errorf("%s failed: %w", provider.Description, waitErr)
}
return output, nil
}
type claudeStreamEvent struct {
ok bool
activity string
footer string
assistantText string
finalResult string
}
func parseClaudeStreamLine(line string) claudeStreamEvent {
var row map[string]any
if err := json.Unmarshal([]byte(line), &row); err != nil {
return claudeStreamEvent{}
}
typ, _ := row["type"].(string)
switch typ {
case "system":
if sub, _ := row["subtype"].(string); sub == "init" {
return claudeStreamEvent{ok: true, activity: "→ Claude session started"}
}
case "assistant":
msg, _ := row["message"].(map[string]any)
content, _ := msg["content"].([]any)
var acts []string
var texts []string
footer := ""
for _, block := range content {
b, _ := block.(map[string]any)
switch b["type"] {
case "tool_use":
name, _ := b["name"].(string)
input, _ := b["input"].(map[string]any)
if label := formatClaudeTool(name, input); label != "" {
acts = append(acts, "→ "+label)
}
case "text":
if t, _ := b["text"].(string); strings.TrimSpace(t) != "" {
texts = append(texts, t)
footer = "… writing response"
}
case "thinking":
footer = "… thinking"
}
}
return claudeStreamEvent{
ok: true,
activity: strings.Join(acts, "\n"),
footer: footer,
assistantText: strings.Join(texts, ""),
}
case "user":
msg, _ := row["message"].(map[string]any)
content, _ := msg["content"].([]any)
for _, block := range content {
b, _ := block.(map[string]any)
if b["type"] == "tool_result" {
return claudeStreamEvent{ok: true, footer: "… tool finished"}
}
}
case "result":
ev := claudeStreamEvent{ok: true}
if r, _ := row["result"].(string); strings.TrimSpace(r) != "" {
ev.finalResult = r
}
if isErr, _ := row["is_error"].(bool); isErr {
if e, _ := row["error"].(string); e != "" {
ev.activity = "Claude error: " + e
}
}
return ev
case "stream_event":
return claudeStreamEvent{ok: true, footer: "… streaming"}
}
return claudeStreamEvent{}
}
func formatClaudeTool(name string, input map[string]any) string {
name = strings.TrimSpace(name)
if input == nil {
input = map[string]any{}
}
switch name {
case "Read", "read_file":
path := firstString(input, "file_path", "path", "target_file")
if path != "" {
return "Read " + path
}
return "Read file"
case "Bash", "bash", "Shell":
cmd := firstString(input, "command")
desc := firstString(input, "description")
if desc != "" {
return "Shell: " + desc
}
if cmd != "" {
return "Shell: " + truncateRunes(cmd, 80)
}
return "Shell"
case "Grep", "grep":
pat := firstString(input, "pattern")
path := firstString(input, "path")
if pat != "" && path != "" {
return "Grep " + quoteShort(pat) + " in " + path
}
if pat != "" {
return "Grep " + quoteShort(pat)
}
return "Grep"
case "Glob", "glob":
pat := firstString(input, "pattern", "glob_pattern")
if pat != "" {
return "Glob " + pat
}
return "Glob"
case "Edit", "Write", "MultiEdit", "NotebookEdit":
path := firstString(input, "file_path", "path")
if path != "" {
return "Edit " + path
}
return "Edit file"
case "Task", "Agent", "TaskCreate":
desc := firstString(input, "description", "prompt")
sub := firstString(input, "subagent_type", "agent")
if sub != "" && desc != "" {
return "Subagent " + sub + ": " + truncateRunes(desc, 60)
}
if sub != "" {
return "Subagent " + sub
}
if desc != "" {
return "Subagent: " + truncateRunes(desc, 60)
}
return "Subagent"
case "WebSearch", "WebFetch":
q := firstString(input, "query", "url")
if q != "" {
return name + ": " + truncateRunes(q, 60)
}
return name
case "LS", "list_dir":
path := firstString(input, "path", "target_directory")
if path != "" {
return "List " + path
}
return "List directory"
default:
if name == "" {
return ""
}
for _, k := range []string{"file_path", "path", "command", "query", "description", "pattern"} {
if v := firstString(input, k); v != "" {
return name + " " + truncateRunes(v, 60)
}
}
return name
}
}

View file

@ -0,0 +1,60 @@
package agent
import (
"strings"
"testing"
)
func TestParseClaudeStreamToolUse(t *testing.T) {
line := `{"type":"assistant","session_id":"s","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"sample.txt"}}]}}`
ev := parseClaudeStreamLine(line)
if !ev.ok || !strings.Contains(ev.activity, "Read sample.txt") {
t.Fatalf("ev=%+v", ev)
}
}
func TestParseClaudeStreamResult(t *testing.T) {
line := `{"type":"result","subtype":"success","result":"all good","is_error":false}`
ev := parseClaudeStreamLine(line)
if !ev.ok || ev.finalResult != "all good" {
t.Fatalf("ev=%+v", ev)
}
}
func TestParseClaudeStreamSubagent(t *testing.T) {
line := `{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Task","input":{"description":"find auth","subagent_type":"Explore"}}]}}`
ev := parseClaudeStreamLine(line)
if !ev.ok || !strings.Contains(ev.activity, "Subagent") {
t.Fatalf("ev=%+v", ev)
}
}
func TestParseCodexCommandExecution(t *testing.T) {
line := `{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"/usr/bin/bash -lc \"sed -n '1,200p' sample.txt\"","status":"in_progress"}}`
ev := parseCodexStreamLine(line)
if !ev.ok || !strings.Contains(ev.activity, "Shell:") || !strings.Contains(ev.activity, "sample.txt") {
t.Fatalf("ev=%+v", ev)
}
}
func TestParseCodexAgentMessage(t *testing.T) {
line := `{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"hello world"}}`
ev := parseCodexStreamLine(line)
if !ev.ok || ev.message != "hello world" {
t.Fatalf("ev=%+v", ev)
}
}
func TestSimplifyCodexCommand(t *testing.T) {
got := simplifyCodexCommand(`/usr/bin/bash -lc "ls -la"`)
if got != "ls -la" {
t.Fatalf("got %q", got)
}
}
func TestFormatClaudeToolBash(t *testing.T) {
got := formatClaudeTool("Bash", map[string]any{"command": "git status", "description": "check git"})
if got != "Shell: check git" {
t.Fatalf("got %q", got)
}
}

View file

@ -0,0 +1,213 @@
package agent
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"sync"
)
// runCodex streams Codex exec --json events (command executions, messages)
// into Progress for live TUI visibility.
func runCodex(ctx context.Context, provider Provider, request Request) (string, error) {
if provider.Path == "" {
return "", ErrUnavailable
}
args := []string{
"exec",
"--json",
"--skip-git-repo-check",
// Read-only sandbox matches Tarakan's isolated review snapshot.
"--sandbox", "read-only",
securityPrompt(request.Prompt),
}
command := exec.CommandContext(ctx, provider.Path, args...)
command.Dir = request.Directory
command.Env = subprocessEnvironment(os.Environ())
// Codex may try to read stdin when it thinks input is piped.
command.Stdin = bytes.NewReader(nil)
stdout, err := command.StdoutPipe()
if err != nil {
return "", err
}
var stderrBuf bytes.Buffer
command.Stderr = &stderrBuf
if err := command.Start(); err != nil {
return "", fmt.Errorf("%s failed to start: %w", provider.Description, err)
}
progress := request.Progress
report := func(line string) {
if progress == nil {
return
}
if line = strings.TrimSpace(line); line != "" {
progress(line)
}
}
var (
lastFooter string
footerMu sync.Mutex
)
reportFooter := func(line string) {
if progress == nil {
return
}
footerMu.Lock()
defer footerMu.Unlock()
if line == lastFooter {
return
}
lastFooter = line
progress(line)
}
var (
lastMessage string
seen = map[string]struct{}{}
)
emit := func(line string) {
key := normalizeActivityKey(line)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
report(line)
}
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for scanner.Scan() {
ev := parseCodexStreamLine(scanner.Text())
if !ev.ok {
continue
}
if ev.activity != "" {
emit(ev.activity)
}
if ev.footer != "" {
reportFooter(ev.footer)
}
if ev.message != "" {
lastMessage = ev.message
}
}
_ = scanner.Err()
waitErr := command.Wait()
output := strings.TrimSpace(lastMessage)
if waitErr != nil {
errText := strings.TrimSpace(stderrBuf.String())
if output == "" && errText != "" {
output = errText
}
return output, fmt.Errorf("%s failed: %w", provider.Description, waitErr)
}
return output, nil
}
type codexStreamEvent struct {
ok bool
activity string
footer string
message string
}
func parseCodexStreamLine(line string) codexStreamEvent {
var row map[string]any
if err := json.Unmarshal([]byte(line), &row); err != nil {
return codexStreamEvent{}
}
typ, _ := row["type"].(string)
switch typ {
case "thread.started":
return codexStreamEvent{ok: true, activity: "→ Codex session started"}
case "turn.started":
return codexStreamEvent{ok: true, footer: "… turn started"}
case "turn.completed":
return codexStreamEvent{ok: true, footer: "… turn completed"}
case "item.started", "item.completed":
item, _ := row["item"].(map[string]any)
if item == nil {
return codexStreamEvent{}
}
return formatCodexItem(typ == "item.completed", item)
case "error":
if msg, _ := row["message"].(string); msg != "" {
return codexStreamEvent{ok: true, activity: "Codex error: " + msg}
}
}
return codexStreamEvent{}
}
func formatCodexItem(completed bool, item map[string]any) codexStreamEvent {
itemType, _ := item["type"].(string)
prefix := "→ "
if completed {
prefix = "✓ "
}
switch itemType {
case "command_execution":
cmd := firstString(item, "command")
// Codex often wraps: /usr/bin/bash -lc "..."
cmd = simplifyCodexCommand(cmd)
status, _ := item["status"].(string)
if status == "failed" || status == "error" {
prefix = "✗ "
}
if cmd != "" {
return codexStreamEvent{ok: true, activity: prefix + "Shell: " + truncateRunes(cmd, 100)}
}
return codexStreamEvent{ok: true, activity: prefix + "Shell"}
case "file_change", "file_edit":
path := firstString(item, "path", "file", "filename")
if path != "" {
return codexStreamEvent{ok: true, activity: prefix + "Edit " + path}
}
return codexStreamEvent{ok: true, activity: prefix + "Edit file"}
case "agent_message", "message":
text := firstString(item, "text", "content")
if text != "" {
return codexStreamEvent{ok: true, footer: "… writing response", message: text}
}
return codexStreamEvent{ok: true, footer: "… writing response"}
case "reasoning", "thought":
return codexStreamEvent{ok: true, footer: "… thinking"}
case "mcp_tool_call", "tool_call":
name := firstString(item, "name", "tool", "tool_name")
if name != "" {
return codexStreamEvent{ok: true, activity: prefix + name}
}
case "todo_list", "web_search":
return codexStreamEvent{ok: true, activity: prefix + itemType}
default:
if itemType != "" {
// Unknown item types still surface so new Codex versions stay visible.
summary := itemType
if cmd := firstString(item, "command", "path", "text"); cmd != "" {
summary += " " + truncateRunes(cmd, 60)
}
return codexStreamEvent{ok: true, activity: prefix + summary}
}
}
return codexStreamEvent{}
}
func simplifyCodexCommand(cmd string) string {
cmd = strings.TrimSpace(cmd)
// /usr/bin/bash -lc "real command"
for _, prefix := range []string{`/usr/bin/bash -lc "`, `bash -lc "`, `/bin/bash -lc "`} {
if strings.HasPrefix(cmd, prefix) && strings.HasSuffix(cmd, `"`) {
inner := strings.TrimSuffix(strings.TrimPrefix(cmd, prefix), `"`)
return strings.ReplaceAll(inner, `\"`, `"`)
}
}
return cmd
}

View file

@ -0,0 +1,515 @@
package agent
import (
"bufio"
"bytes"
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
// runGrok runs Grok Build headless with streaming-json and surfaces live tool
// activity (reads, shell, subagents) via Progress by tailing the session log.
func runGrok(ctx context.Context, provider Provider, request Request) (string, error) {
if provider.Path == "" {
return "", ErrUnavailable
}
sessionID := newSessionUUID()
cwd := request.Directory
if cwd == "" {
cwd, _ = os.Getwd()
}
args := []string{
"-p", securityPrompt(request.Prompt),
"--output-format", "streaming-json",
"--session-id", sessionID,
// Unattended inside Tarakan's disposable snapshot.
// Important: "dontAsk" DENIES tools that would prompt (shell, etc.) and
// ends the turn as permission_cancelled. Use bypassPermissions so the
// agent can actually finish a security review.
"--always-approve",
"--permission-mode", "bypassPermissions",
}
command := exec.CommandContext(ctx, provider.Path, args...)
command.Dir = cwd
command.Env = subprocessEnvironment(os.Environ())
stdout, err := command.StdoutPipe()
if err != nil {
return "", err
}
// Keep stderr for failure diagnostics; do not mix into the answer.
var stderrBuf bytes.Buffer
command.Stderr = &stderrBuf
if err := command.Start(); err != nil {
return "", fmt.Errorf("%s failed to start: %w", provider.Description, err)
}
// Independent of parent cancel so we can drain session logs after exit.
watchCtx, watchCancel := context.WithCancel(context.Background())
defer watchCancel()
var (
textBuf strings.Builder
textMu sync.Mutex
lastFooter string
footerMu sync.Mutex
)
progress := request.Progress
report := func(line string) {
if progress == nil {
return
}
line = strings.TrimSpace(line)
if line == "" {
return
}
progress(line)
}
// Footer-only pulse for token-level chatter (starts with ellipsis).
reportFooter := func(line string) {
if progress == nil {
return
}
footerMu.Lock()
defer footerMu.Unlock()
if line == lastFooter {
return
}
lastFooter = line
progress(line)
}
watchDone := make(chan struct{})
go func() {
defer close(watchDone)
watchGrokSessionActivity(watchCtx, cwd, sessionID, report)
}()
// Heartbeat so the TUI does not look frozen during long reasoning.
heartbeatDone := make(chan struct{})
go func() {
ticker := time.NewTicker(8 * time.Second)
defer ticker.Stop()
n := 0
for {
select {
case <-heartbeatDone:
return
case <-ticker.C:
n++
reportFooter(fmt.Sprintf("… Grok still working (%ds)", n*8))
}
}
}()
// Parse streaming-json for the final answer (and light live text pulse).
var stopReason string
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
for scanner.Scan() {
line := scanner.Text()
var event struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
Msg string `json:"message"`
StopReason string `json:"stopReason"`
}
if err := json.Unmarshal([]byte(line), &event); err != nil {
continue
}
switch event.Type {
case "text":
chunk := jsonString(event.Data)
if chunk == "" {
continue
}
textMu.Lock()
textBuf.WriteString(chunk)
textMu.Unlock()
reportFooter("… writing response")
case "thought":
reportFooter("… thinking")
case "error":
msg := event.Msg
if msg == "" {
msg = string(event.Data)
}
if msg != "" {
report("Grok error: " + msg)
}
case "end":
stopReason = event.StopReason
// finished
}
}
_ = scanner.Err()
close(heartbeatDone)
waitErr := command.Wait()
// Let the session tail catch trailing tool events, then stop watching.
select {
case <-watchDone:
case <-time.After(1200 * time.Millisecond):
watchCancel()
<-watchDone
}
textMu.Lock()
output := strings.TrimSpace(textBuf.String())
textMu.Unlock()
if waitErr != nil {
errText := strings.TrimSpace(stderrBuf.String())
if output == "" && errText != "" {
output = errText
}
return output, fmt.Errorf("%s failed: %w", provider.Description, waitErr)
}
// Permission / user cancel ends the turn without a useful document.
if isGrokCancelledStop(stopReason) || looksLikePermissionCancel(output) {
msg := "Grok turn was cancelled (often a tool permission). Re-run; shell tools need bypassPermissions."
if stopReason != "" {
msg = "Grok turn cancelled (" + stopReason + ")"
}
report(msg)
if output == "" {
return output, fmt.Errorf("%s", msg)
}
return output, fmt.Errorf("%s", msg)
}
if output == "" {
return "", fmt.Errorf("%s finished with no response text", provider.Description)
}
return output, nil
}
func isGrokCancelledStop(reason string) bool {
r := strings.ToLower(strings.TrimSpace(reason))
return strings.Contains(r, "cancel") || strings.Contains(r, "permission")
}
func looksLikePermissionCancel(output string) bool {
o := strings.ToLower(output)
return strings.Contains(o, "user cancelled") || strings.Contains(o, "permission_cancelled")
}
// watchGrokSessionActivity tails ~/.grok/sessions/<cwd-key>/<id>/updates.jsonl
// and emits human-readable tool/subagent lines.
func watchGrokSessionActivity(ctx context.Context, cwd, sessionID string, report func(string)) {
path := grokUpdatesPath(cwd, sessionID)
// Wait for the file (session may take a moment to create).
deadline := time.Now().Add(30 * time.Second)
for {
if ctx.Err() != nil {
return
}
if _, err := os.Stat(path); err == nil {
break
}
if time.Now().After(deadline) {
return
}
select {
case <-ctx.Done():
return
case <-time.After(50 * time.Millisecond):
}
}
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
reader := bufio.NewReader(f)
seen := make(map[string]struct{})
idleRounds := 0
for {
if ctx.Err() != nil {
return
}
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
idleRounds++
// Parent closes watch shortly after process exit; keep reading
// a bit so late tool_completed lines show up.
if idleRounds > 40 {
return
}
select {
case <-ctx.Done():
return
case <-time.After(50 * time.Millisecond):
}
continue
}
return
}
idleRounds = 0
line = strings.TrimSpace(line)
if line == "" {
continue
}
if msg, ok := formatGrokUpdateLine(line); ok {
key := normalizeActivityKey(msg)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
report(msg)
}
}
}
func grokUpdatesPath(cwd, sessionID string) string {
home, err := os.UserHomeDir()
if err != nil {
home = os.Getenv("HOME")
}
return filepath.Join(home, ".grok", "sessions", encodeGrokSessionDir(cwd), sessionID, "updates.jsonl")
}
// encodeGrokSessionDir matches Grok's session folder naming: each / → %2F.
func encodeGrokSessionDir(cwd string) string {
abs, err := filepath.Abs(cwd)
if err == nil {
cwd = abs
}
return strings.ReplaceAll(cwd, "/", "%2F")
}
// formatGrokUpdateLine turns one updates.jsonl row into a UI status line.
func formatGrokUpdateLine(raw string) (string, bool) {
var row struct {
Params struct {
Update map[string]any `json:"update"`
} `json:"params"`
}
if err := json.Unmarshal([]byte(raw), &row); err != nil {
return "", false
}
u := row.Params.Update
if u == nil {
return "", false
}
sessionUpdate, _ := u["sessionUpdate"].(string)
switch sessionUpdate {
case "tool_call", "tool_call_update":
return formatGrokToolUpdate(u)
default:
return "", false
}
}
func formatGrokToolUpdate(u map[string]any) (string, bool) {
rawInput, _ := u["rawInput"].(map[string]any)
meta, _ := u["_meta"].(map[string]any)
toolMeta, _ := meta["x.ai/tool"].(map[string]any)
name, _ := toolMeta["name"].(string)
title, _ := u["title"].(string)
title = strings.TrimSpace(title)
if name == "" {
name = title
}
// Always prefer structured input when present so grep titles that are just
// the raw pattern ("password|secret|…") become "Grep `password|…`".
label := formatGrokToolFromInput(name, rawInput)
if label == "" {
label = formatGrokToolFromInput(title, rawInput)
}
if label == "" && title != "" && !isBareToolName(title) && !looksLikeRegexPattern(title) {
label = title
}
if label == "" {
// Incomplete early event (title=grep, no input yet) - skip.
return "", false
}
status, _ := u["status"].(string)
switch status {
case "completed":
return "✓ " + label, true
case "failed", "error", "cancelled":
return "✗ " + label, true
default:
return "→ " + label, true
}
}
func isBareToolName(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case "read_file", "list_dir", "grep", "run_terminal_command", "spawn_subagent",
"web_search", "search_replace", "write", "read", "bash", "shell", "task":
return true
default:
return false
}
}
// looksLikeRegexPattern is true when Grok uses the grep pattern as the tool title
// (e.g. "password|secret|api_key") instead of "Grep …".
func looksLikeRegexPattern(s string) bool {
s = strings.TrimSpace(s)
if s == "" {
return false
}
// Human titles usually include a verb + path ("Read `foo`"); bare patterns
// are full of alternation / escapes.
return strings.Contains(s, "|") || strings.Contains(s, `\.`) || strings.Contains(s, ".*") ||
strings.Contains(s, `\(`) || strings.HasPrefix(s, "^")
}
func formatGrokToolFromInput(name string, input map[string]any) string {
name = strings.TrimSpace(name)
if input == nil {
input = map[string]any{}
}
switch name {
case "read_file", "ReadFile":
path := firstString(input, "target_file", "path", "file")
if path != "" {
return "Read " + path
}
return ""
case "list_dir":
path := firstString(input, "target_directory", "path")
if path != "" {
return "List " + path
}
return ""
case "grep", "Grep":
pat := firstString(input, "pattern")
path := firstString(input, "path")
glob := firstString(input, "glob")
if pat != "" && path != "" {
return "Grep " + quoteShort(pat) + " in " + path
}
if pat != "" && glob != "" {
return "Grep " + quoteShort(pat) + " (" + glob + ")"
}
if pat != "" {
return "Grep " + quoteShort(pat)
}
return ""
case "run_terminal_command":
cmd := firstString(input, "command")
desc := firstString(input, "description")
if desc != "" {
return "Shell: " + desc
}
if cmd != "" {
return "Shell: " + truncateRunes(cmd, 80)
}
return ""
case "spawn_subagent", "Task":
desc := firstString(input, "description", "prompt")
kind := firstString(input, "subagent_type", "agent")
if kind != "" && desc != "" {
return "Subagent " + kind + ": " + truncateRunes(desc, 60)
}
if kind != "" {
return "Subagent " + kind
}
if desc != "" {
return "Subagent: " + truncateRunes(desc, 60)
}
return ""
case "web_search":
q := firstString(input, "query")
if q != "" {
return "Web search: " + truncateRunes(q, 60)
}
return "Web search"
case "search_replace", "write":
path := firstString(input, "file_path", "path")
if path != "" {
return "Edit " + path
}
return "Edit file"
default:
if name == "" {
return ""
}
// Generic: show tool name + one interesting arg if any.
for _, k := range []string{"path", "target_file", "command", "query", "description"} {
if v := firstString(input, k); v != "" {
return name + " " + truncateRunes(v, 60)
}
}
return name
}
}
func firstString(m map[string]any, keys ...string) string {
for _, k := range keys {
if v, ok := m[k]; ok {
switch t := v.(type) {
case string:
if s := strings.TrimSpace(t); s != "" {
return s
}
}
}
}
return ""
}
func quoteShort(s string) string {
s = truncateRunes(s, 40)
return "`" + s + "`"
}
func truncateRunes(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
if max <= 1 {
return string(r[:max])
}
return string(r[:max-1]) + "…"
}
// normalizeActivityKey collapses "→ Read `x`" / "✓ Read x" into one dedupe key
// so we do not double-log the same tool under slightly different titles.
func normalizeActivityKey(msg string) string {
msg = strings.TrimSpace(msg)
for _, p := range []string{"→ ", "✓ ", "✗ "} {
msg = strings.TrimPrefix(msg, p)
}
msg = strings.ReplaceAll(msg, "`", "")
return strings.ToLower(strings.Join(strings.Fields(msg), " "))
}
func jsonString(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
return strings.Trim(string(raw), `"`)
}
func newSessionUUID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// Extremely unlikely; fall back to time-based uniqueness.
return fmt.Sprintf("00000000-0000-4000-8000-%012x", time.Now().UnixNano()&0xffffffffffff)
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}

View file

@ -0,0 +1,50 @@
//go:build live
package agent
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func TestGrokLiveToolProgress(t *testing.T) {
if _, err := exec.LookPath("grok"); err != nil {
t.Skip("grok not installed")
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "sample.txt"), []byte("hello world\n"), 0o600); err != nil {
t.Fatal(err)
}
path, _ := exec.LookPath("grok")
p := Provider{Name: "grok", Kind: KindCLI, Command: "grok", Description: "Grok Build", Path: path}
var mu sync.Mutex
var lines []string
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
out, err := Run(ctx, p, Request{
Prompt: "Read sample.txt and reply with only the file contents.",
Directory: dir,
Progress: func(s string) {
mu.Lock()
lines = append(lines, s)
mu.Unlock()
t.Log("progress:", s)
},
})
if err != nil {
t.Fatalf("run: %v out=%q", err, out)
}
joined := strings.Join(lines, "\n")
if !strings.Contains(joined, "Read") && !strings.Contains(joined, "sample.txt") {
t.Fatalf("expected read activity in progress:\n%s", joined)
}
if !strings.Contains(out, "hello") {
t.Fatalf("output = %q", out)
}
}

View file

@ -0,0 +1,72 @@
package agent
import (
"strings"
"testing"
)
func TestFormatGrokUpdateLineToolCall(t *testing.T) {
raw := `{"timestamp":1,"method":"session/update","params":{"sessionId":"x","update":{"sessionUpdate":"tool_call_update","toolCallId":"c1","kind":"read","title":"Read sample.txt","locations":[{"path":"sample.txt"}],"status":"in_progress","rawInput":{"target_file":"sample.txt"},"_meta":{"x.ai/tool":{"name":"read_file"}}}}}`
msg, ok := formatGrokUpdateLine(raw)
if !ok {
t.Fatal("expected tool line")
}
if !strings.Contains(msg, "Read") || !strings.Contains(msg, "sample.txt") {
t.Fatalf("msg = %q", msg)
}
if !strings.HasPrefix(msg, "→ ") {
t.Fatalf("expected arrow prefix, got %q", msg)
}
}
func TestFormatGrokUpdateLineGrepPatternTitle(t *testing.T) {
// Grok often sets title to the raw pattern; we should still show "Grep …".
raw := `{"params":{"update":{"sessionUpdate":"tool_call_update","title":"password|secret|api_key","rawInput":{"variant":"Grep","pattern":"password|secret|api_key","glob":"*.ex"},"_meta":{"x.ai/tool":{"name":"grep"}}}}}`
msg, ok := formatGrokUpdateLine(raw)
if !ok || !strings.Contains(msg, "Grep") || strings.HasPrefix(msg, "→ password") {
t.Fatalf("msg=%q ok=%v", msg, ok)
}
}
func TestFormatGrokUpdateLineSkipsBareToolName(t *testing.T) {
raw := `{"params":{"update":{"sessionUpdate":"tool_call","title":"grep"}}}`
if _, ok := formatGrokUpdateLine(raw); ok {
t.Fatal("bare grep title without input should be skipped")
}
}
func TestFormatGrokUpdateLineCompleted(t *testing.T) {
raw := `{"params":{"update":{"sessionUpdate":"tool_call_update","title":"Execute ls -la","status":"completed"}}}`
msg, ok := formatGrokUpdateLine(raw)
if !ok || !strings.HasPrefix(msg, "✓ ") {
t.Fatalf("msg=%q ok=%v", msg, ok)
}
}
func TestFormatGrokToolFromInputSubagent(t *testing.T) {
got := formatGrokToolFromInput("spawn_subagent", map[string]any{
"subagent_type": "explore",
"description": "find auth handlers",
})
if !strings.Contains(got, "Subagent") || !strings.Contains(got, "explore") {
t.Fatalf("got %q", got)
}
}
func TestEncodeGrokSessionDir(t *testing.T) {
got := encodeGrokSessionDir("/tmp/foo")
if got != "%2Ftmp%2Ffoo" && !strings.HasSuffix(got, "%2Ftmp%2Ffoo") {
// Abs path may prefix differently; require slash encoding.
if !strings.Contains(got, "%2F") {
t.Fatalf("got %q", got)
}
}
}
func TestNewSessionUUIDShape(t *testing.T) {
id := newSessionUUID()
parts := strings.Split(id, "-")
if len(parts) != 5 {
t.Fatalf("uuid = %q", id)
}
}

308
internal/agent/http.go Normal file
View file

@ -0,0 +1,308 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
)
const (
defaultOllamaHost = "http://localhost:11434"
defaultOllamaModel = "llama3.1"
openRouterBaseURL = "https://openrouter.ai/api/v1"
defaultOpenRouterModel = "openai/gpt-4o-mini"
// Moonshot / Kimi OpenAI-compatible API (used when the kimi CLI is absent).
moonshotBaseURL = "https://api.moonshot.ai/v1"
defaultMoonshotModel = "kimi-k2.5"
// A single repository bundle is bounded so it stays within a model's
// context window and a review call stays affordable.
maxFileBytes = 64 << 10
maxTotalBytes = 384 << 10
)
// getenv abstracts os.Getenv so detection is testable.
type getenv func(string) string
// detectHTTPProviders returns the configured OpenAI-compatible model endpoints.
// Ollama appears when its daemon is plausibly present (the CLI is installed or
// a host is configured); OpenRouter appears when an API key is set.
func detectHTTPProviders(env getenv) []Provider {
var providers []Provider
if ollamaConfigured(env) {
providers = append(providers, Provider{
Name: "ollama",
Kind: KindHTTP,
Description: "Ollama (local)",
BaseURL: strings.TrimRight(firstNonEmpty(env("OLLAMA_HOST"), defaultOllamaHost), "/") + "/v1",
Model: firstNonEmpty(env("OLLAMA_MODEL"), defaultOllamaModel),
})
}
if env("OPENROUTER_API_KEY") != "" {
providers = append(providers, Provider{
Name: "openrouter",
Kind: KindHTTP,
Description: "OpenRouter",
BaseURL: openRouterBaseURL,
Model: firstNonEmpty(env("OPENROUTER_MODEL"), defaultOpenRouterModel),
APIKeyEnv: "OPENROUTER_API_KEY",
})
}
// Prefer the kimi CLI when installed; fall back to the Moonshot HTTP API.
if env("MOONSHOT_API_KEY") != "" || env("KIMI_API_KEY") != "" {
apiKeyEnv := "MOONSHOT_API_KEY"
if env("MOONSHOT_API_KEY") == "" {
apiKeyEnv = "KIMI_API_KEY"
}
providers = append(providers, Provider{
Name: "kimi-http",
Kind: KindHTTP,
Description: "Kimi (Moonshot API)",
BaseURL: strings.TrimRight(firstNonEmpty(env("MOONSHOT_BASE_URL"), moonshotBaseURL), "/"),
Model: firstNonEmpty(env("MOONSHOT_MODEL"), env("KIMI_MODEL"), defaultMoonshotModel),
APIKeyEnv: apiKeyEnv,
})
}
return providers
}
func ollamaConfigured(env getenv) bool {
if env("OLLAMA_HOST") != "" || env("OLLAMA_MODEL") != "" {
return true
}
_, err := lookPath("ollama")
return err == nil
}
// chatMessage, chatRequest, and chatResponse cover the subset of the OpenAI
// chat-completions schema that Ollama and OpenRouter both implement.
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Stream bool `json:"stream"`
}
type chatResponse struct {
Choices []struct {
Message chatMessage `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
func runHTTP(ctx context.Context, provider Provider, request Request) (string, error) {
if provider.Model == "" {
return "", fmt.Errorf("%s: no model configured", provider.Description)
}
apiKey := ""
if provider.APIKeyEnv != "" {
if apiKey = os.Getenv(provider.APIKeyEnv); apiKey == "" {
return "", fmt.Errorf("%s: set %s", provider.Description, provider.APIKeyEnv)
}
}
if request.Progress != nil {
// HTTP backends pack the tree into the prompt (no per-tool stream).
request.Progress("→ Packing repository context for " + provider.Description)
}
bundle, err := gatherRepositoryContext(request.Directory)
if err != nil {
return "", fmt.Errorf("read repository for review: %w", err)
}
if request.Progress != nil {
request.Progress("→ Calling " + provider.Description + " (" + provider.Model + ")…")
request.Progress("… waiting for model (HTTP providers do not stream file reads)")
}
payload := chatRequest{
Model: provider.Model,
Stream: false,
Messages: []chatMessage{
{Role: "system", Content: reviewInstruction},
{Role: "user", Content: bundle + "\n\nUser request:\n" + request.Prompt},
},
}
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
endpoint := strings.TrimRight(provider.BaseURL, "/") + "/chat/completions"
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return "", err
}
httpRequest.Header.Set("Content-Type", "application/json")
if apiKey != "" {
httpRequest.Header.Set("Authorization", "Bearer "+apiKey)
// OpenRouter attributes traffic by these headers; harmless elsewhere.
httpRequest.Header.Set("X-Title", "Tarakan")
httpRequest.Header.Set("HTTP-Referer", "https://tarakan.lol")
}
response, err := http.DefaultClient.Do(httpRequest)
if err != nil {
return "", fmt.Errorf("%s request failed: %w", provider.Description, err)
}
defer response.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 8<<20))
if err != nil {
return "", err
}
var decoded chatResponse
_ = json.Unmarshal(responseBody, &decoded)
if response.StatusCode < 200 || response.StatusCode >= 300 {
return "", fmt.Errorf("%s returned %s: %s", provider.Description, response.Status, errorDetail(decoded, responseBody))
}
if decoded.Error != nil && decoded.Error.Message != "" {
return "", fmt.Errorf("%s error: %s", provider.Description, decoded.Error.Message)
}
if len(decoded.Choices) == 0 {
return "", fmt.Errorf("%s returned no content", provider.Description)
}
return strings.TrimSpace(decoded.Choices[0].Message.Content), nil
}
func errorDetail(decoded chatResponse, raw []byte) string {
if decoded.Error != nil && decoded.Error.Message != "" {
return decoded.Error.Message
}
detail := strings.TrimSpace(string(raw))
if len(detail) > 500 {
detail = detail[:500] + "…"
}
return detail
}
// gatherRepositoryContext packs the repository's source files into one text
// bundle for a model that cannot read the disk itself. Binary, oversized, and
// vendored files are skipped, and the total is bounded; when the budget runs
// out the bundle is truncated with a note rather than failing.
func gatherRepositoryContext(root string) (string, error) {
if root == "" {
return "", fmt.Errorf("no repository directory")
}
var files []string
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return nil
}
if entry.IsDir() {
if skipDir(entry.Name()) && path != root {
return filepath.SkipDir
}
return nil
}
if !entry.Type().IsRegular() {
return nil
}
files = append(files, path)
return nil
})
if err != nil {
return "", err
}
sort.Strings(files)
var builder strings.Builder
total := 0
included := 0
truncated := false
for _, path := range files {
if total >= maxTotalBytes {
truncated = true
break
}
info, err := os.Stat(path)
if err != nil || info.Size() == 0 || info.Size() > maxFileBytes {
continue
}
content, err := os.ReadFile(path)
if err != nil || isBinary(content) {
continue
}
if total+len(content) > maxTotalBytes {
truncated = true
break
}
relative, err := filepath.Rel(root, path)
if err != nil {
relative = path
}
fmt.Fprintf(&builder, "=== %s ===\n%s\n\n", filepath.ToSlash(relative), content)
total += len(content)
included++
}
if included == 0 {
return "Repository source (no readable text files found):", nil
}
header := fmt.Sprintf("Repository source (%d files", included)
if truncated {
header += ", truncated to fit the review budget"
}
header += "):\n\n"
return header + strings.TrimRight(builder.String(), "\n"), nil
}
func skipDir(name string) bool {
switch name {
case ".git", "node_modules", "vendor", "dist", "build", "target",
".venv", "venv", "__pycache__", ".next", ".turbo", ".cache",
"coverage", ".idea", ".vscode":
return true
default:
return false
}
}
func isBinary(content []byte) bool {
limit := len(content)
if limit > 8000 {
limit = 8000
}
return bytes.IndexByte(content[:limit], 0) >= 0
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}

221
internal/agent/http_test.go Normal file
View file

@ -0,0 +1,221 @@
package agent
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func envFrom(pairs map[string]string) getenv {
return func(key string) string { return pairs[key] }
}
func TestDetectHTTPProvidersFromEnv(t *testing.T) {
// Neither configured, and no ollama binary: nothing detected.
original := lookPath
lookPath = func(string) (string, error) { return "", os.ErrNotExist }
defer func() { lookPath = original }()
if got := detectHTTPProviders(envFrom(nil)); len(got) != 0 {
t.Fatalf("expected no HTTP providers, got %#v", got)
}
providers := detectHTTPProviders(envFrom(map[string]string{
"OLLAMA_MODEL": "qwen2.5-coder",
"OLLAMA_HOST": "http://127.0.0.1:11434",
"OPENROUTER_API_KEY": "sk-or-test",
"OPENROUTER_MODEL": "anthropic/claude-3.5-sonnet",
"MOONSHOT_API_KEY": "sk-ms-test",
"MOONSHOT_MODEL": "kimi-k2.5",
}))
if len(providers) != 3 {
t.Fatalf("expected ollama + openrouter + kimi-http, got %#v", providers)
}
ollama := providers[0]
if ollama.Name != "ollama" || ollama.Kind != KindHTTP {
t.Fatalf("unexpected ollama provider: %#v", ollama)
}
if ollama.BaseURL != "http://127.0.0.1:11434/v1" {
t.Fatalf("ollama base URL = %q", ollama.BaseURL)
}
if ollama.Model != "qwen2.5-coder" || ollama.APIKeyEnv != "" {
t.Fatalf("ollama config = %#v", ollama)
}
openrouter := providers[1]
if openrouter.Name != "openrouter" || openrouter.APIKeyEnv != "OPENROUTER_API_KEY" {
t.Fatalf("unexpected openrouter provider: %#v", openrouter)
}
if openrouter.BaseURL != openRouterBaseURL || openrouter.Model != "anthropic/claude-3.5-sonnet" {
t.Fatalf("openrouter config = %#v", openrouter)
}
kimiHTTP := providers[2]
if kimiHTTP.Name != "kimi-http" || kimiHTTP.APIKeyEnv != "MOONSHOT_API_KEY" {
t.Fatalf("unexpected kimi-http provider: %#v", kimiHTTP)
}
if kimiHTTP.BaseURL != moonshotBaseURL || kimiHTTP.Model != "kimi-k2.5" {
t.Fatalf("kimi-http config = %#v", kimiHTTP)
}
}
func TestDetectOllamaDefaultsWhenBinaryPresent(t *testing.T) {
original := lookPath
lookPath = func(string) (string, error) { return "/usr/bin/ollama", nil }
defer func() { lookPath = original }()
providers := detectHTTPProviders(envFrom(nil))
if len(providers) != 1 || providers[0].Name != "ollama" {
t.Fatalf("expected default ollama, got %#v", providers)
}
if providers[0].BaseURL != defaultOllamaHost+"/v1" || providers[0].Model != defaultOllamaModel {
t.Fatalf("ollama defaults = %#v", providers[0])
}
}
func TestRunHTTPOllamaNoAuth(t *testing.T) {
var captured chatRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Errorf("unexpected path %q", r.URL.Path)
}
if auth := r.Header.Get("Authorization"); auth != "" {
t.Errorf("ollama must not send auth, got %q", auth)
}
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &captured)
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":" Found a SQL injection. "}}]}`)
}))
defer server.Close()
dir := writeRepo(t, map[string]string{"main.go": "package main // exec(userInput)"})
provider := Provider{Name: "ollama", Kind: KindHTTP, Description: "Ollama", BaseURL: server.URL + "/v1", Model: "llama3.1"}
out, err := runHTTP(context.Background(), provider, Request{Prompt: "review auth", Directory: dir})
if err != nil {
t.Fatal(err)
}
if out != "Found a SQL injection." {
t.Fatalf("output = %q", out)
}
if captured.Model != "llama3.1" || len(captured.Messages) != 2 {
t.Fatalf("request = %#v", captured)
}
if captured.Messages[0].Role != "system" || !strings.Contains(captured.Messages[0].Content, "read-only") {
t.Fatalf("system message = %#v", captured.Messages[0])
}
if !strings.Contains(captured.Messages[1].Content, "main.go") ||
!strings.Contains(captured.Messages[1].Content, "review auth") {
t.Fatalf("user message missing repo context or prompt: %q", captured.Messages[1].Content)
}
}
func TestRunHTTPOpenRouterRequiresKey(t *testing.T) {
dir := writeRepo(t, map[string]string{"a.py": "print(1)"})
provider := Provider{Name: "openrouter", Kind: KindHTTP, Description: "OpenRouter", BaseURL: "https://openrouter.ai/api/v1", Model: "x", APIKeyEnv: "OPENROUTER_API_KEY"}
os.Unsetenv("OPENROUTER_API_KEY")
if _, err := runHTTP(context.Background(), provider, Request{Prompt: "p", Directory: dir}); err == nil {
t.Fatal("expected error when API key is unset")
}
}
func TestRunHTTPOpenRouterSendsBearer(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer sk-or-secret" {
t.Errorf("authorization = %q", got)
}
io.WriteString(w, `{"choices":[{"message":{"content":"ok"}}]}`)
}))
defer server.Close()
dir := writeRepo(t, map[string]string{"a.py": "print(1)"})
t.Setenv("OPENROUTER_API_KEY", "sk-or-secret")
provider := Provider{Name: "openrouter", Kind: KindHTTP, Description: "OpenRouter", BaseURL: server.URL, Model: "x", APIKeyEnv: "OPENROUTER_API_KEY"}
if _, err := runHTTP(context.Background(), provider, Request{Prompt: "p", Directory: dir}); err != nil {
t.Fatal(err)
}
}
func TestRunHTTPSurfacesAPIError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
io.WriteString(w, `{"error":{"message":"model not found"}}`)
}))
defer server.Close()
dir := writeRepo(t, map[string]string{"a.py": "print(1)"})
provider := Provider{Name: "ollama", Kind: KindHTTP, Description: "Ollama", BaseURL: server.URL, Model: "missing"}
_, err := runHTTP(context.Background(), provider, Request{Prompt: "p", Directory: dir})
if err == nil || !strings.Contains(err.Error(), "model not found") {
t.Fatalf("expected API error surfaced, got %v", err)
}
}
func TestGatherRepositoryContextBoundsAndFilters(t *testing.T) {
dir := writeRepo(t, map[string]string{
"keep.go": "package main",
"nested/util.js": "export const x = 1",
"node_modules/dep/i.js": "should be skipped",
"bin.dat": "text\x00binary",
filepath.Join("big.txt"): strings.Repeat("A", maxFileBytes+1),
})
bundle, err := gatherRepositoryContext(dir)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"=== keep.go ===", "=== nested/util.js ==="} {
if !strings.Contains(bundle, want) {
t.Errorf("bundle missing %q", want)
}
}
for _, unwanted := range []string{"node_modules", "bin.dat", "big.txt"} {
if strings.Contains(bundle, unwanted) {
t.Errorf("bundle should have skipped %q", unwanted)
}
}
}
func TestModelIdentifierAndWithModel(t *testing.T) {
cli := Provider{Name: "claude", Kind: KindCLI}
if cli.ModelIdentifier() != "claude" {
t.Fatalf("cli identifier = %q", cli.ModelIdentifier())
}
if got := cli.WithModel("x"); got.Model != "" {
t.Fatalf("WithModel must not touch CLI providers: %#v", got)
}
http := Provider{Name: "ollama", Kind: KindHTTP, Model: "llama3.1"}
if http.ModelIdentifier() != "llama3.1" {
t.Fatalf("http identifier = %q", http.ModelIdentifier())
}
if got := http.WithModel("qwen"); got.Model != "qwen" {
t.Fatalf("WithModel = %#v", got)
}
}
func writeRepo(t *testing.T, files map[string]string) string {
t.Helper()
root := t.TempDir()
for name, content := range files {
full := filepath.Join(root, name)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
return root
}

View file

@ -0,0 +1,219 @@
package agent
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"sync"
)
// runKimi runs Kimi Code headless in print mode.
//
// With --final-message-only the CLI emits only the assistant answer (best for
// Review Format JSON). When stream-json is available and Progress is set, we
// use streaming so the TUI can show tool activity.
func runKimi(ctx context.Context, provider Provider, request Request) (string, error) {
if provider.Path == "" {
return "", ErrUnavailable
}
prompt := securityPrompt(request.Prompt)
cwd := request.Directory
if cwd == "" {
cwd, _ = os.Getwd()
}
// Prefer stream-json when the UI wants progress; otherwise quiet text.
if request.Progress != nil {
return runKimiStreaming(ctx, provider, prompt, cwd, request.Progress)
}
return runKimiFinal(ctx, provider, prompt, cwd)
}
func runKimiFinal(ctx context.Context, provider Provider, prompt, cwd string) (string, error) {
args := []string{
"--print",
"--prompt", prompt,
"--yolo",
"--final-message-only",
"--work-dir", cwd,
}
command := exec.CommandContext(ctx, provider.Path, args...)
command.Dir = cwd
command.Env = subprocessEnvironment(os.Environ())
output, err := command.CombinedOutput()
if err != nil {
return string(output), fmt.Errorf("%s failed: %w\n%s", provider.Description, err, strings.TrimSpace(string(output)))
}
return strings.TrimSpace(string(output)), nil
}
func runKimiStreaming(ctx context.Context, provider Provider, prompt, cwd string, progress func(string)) (string, error) {
args := []string{
"--print",
"--prompt", prompt,
"--yolo",
"--output-format", "stream-json",
"--work-dir", cwd,
}
command := exec.CommandContext(ctx, provider.Path, args...)
command.Dir = cwd
command.Env = subprocessEnvironment(os.Environ())
stdout, err := command.StdoutPipe()
if err != nil {
return "", err
}
var stderrBuf bytes.Buffer
command.Stderr = &stderrBuf
if err := command.Start(); err != nil {
return "", fmt.Errorf("%s failed to start: %w", provider.Description, err)
}
report := func(line string) {
if progress == nil {
return
}
if line = strings.TrimSpace(line); line != "" {
progress(line)
}
}
var (
textBuf strings.Builder
mu sync.Mutex
final string
)
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
ev := parseKimiStreamLine(line)
if !ev.ok {
continue
}
if ev.activity != "" {
report(ev.activity)
}
if ev.text != "" {
mu.Lock()
textBuf.WriteString(ev.text)
mu.Unlock()
}
if ev.final != "" {
final = ev.final
}
}
waitErr := command.Wait()
out := strings.TrimSpace(final)
if out == "" {
mu.Lock()
out = strings.TrimSpace(textBuf.String())
mu.Unlock()
}
if waitErr != nil {
detail := strings.TrimSpace(stderrBuf.String())
if detail != "" {
return out, fmt.Errorf("%s failed: %w\n%s", provider.Description, waitErr, detail)
}
return out, fmt.Errorf("%s failed: %w", provider.Description, waitErr)
}
return out, nil
}
type kimiStreamEvent struct {
ok bool
activity string
text string
final string
}
// parseKimiStreamLine maps Kimi print stream-json lines into progress + text.
// The wire format is evolving; we accept common role/type shapes and ignore the rest.
func parseKimiStreamLine(line string) kimiStreamEvent {
line = strings.TrimSpace(line)
if line == "" || !strings.HasPrefix(line, "{") {
return kimiStreamEvent{}
}
var raw map[string]any
if err := json.Unmarshal([]byte(line), &raw); err != nil {
return kimiStreamEvent{}
}
// OpenAI-ish chat chunk: {"choices":[{"delta":{"content":"..."}}]}
if choices, ok := raw["choices"].([]any); ok && len(choices) > 0 {
if choice, ok := choices[0].(map[string]any); ok {
if delta, ok := choice["delta"].(map[string]any); ok {
if content, ok := delta["content"].(string); ok && content != "" {
return kimiStreamEvent{ok: true, text: content}
}
}
if msg, ok := choice["message"].(map[string]any); ok {
if content, ok := msg["content"].(string); ok && content != "" {
return kimiStreamEvent{ok: true, text: content, final: content}
}
}
}
}
// role/content messages
if role, _ := raw["role"].(string); role == "assistant" {
if content, ok := raw["content"].(string); ok && content != "" {
return kimiStreamEvent{ok: true, text: content, final: content}
}
}
// type-tagged events (tool use, assistant text)
typ, _ := raw["type"].(string)
switch typ {
case "assistant", "message", "agent_message", "text":
if content := kimiFirstString(raw, "text", "content", "message"); content != "" {
return kimiStreamEvent{ok: true, text: content, final: content}
}
case "tool_use", "tool_call", "tool":
name := kimiFirstString(raw, "name", "tool", "tool_name")
if name == "" {
if input, ok := raw["input"].(map[string]any); ok {
name = kimiFirstString(input, "name", "command", "path")
}
}
if name != "" {
return kimiStreamEvent{ok: true, activity: "Kimi: " + name}
}
return kimiStreamEvent{ok: true, activity: "Kimi … (tool)"}
case "result", "final", "final_message":
if content := kimiFirstString(raw, "result", "text", "content", "message"); content != "" {
return kimiStreamEvent{ok: true, text: content, final: content}
}
}
// Tool name at top level without type
if name := kimiFirstString(raw, "tool_name", "name"); name != "" && raw["type"] == nil {
if _, isMsg := raw["role"]; !isMsg {
return kimiStreamEvent{ok: true, activity: "Kimi: " + name}
}
}
return kimiStreamEvent{}
}
func kimiFirstString(m map[string]any, keys ...string) string {
for _, key := range keys {
if v, ok := m[key].(string); ok {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
}
return ""
}