Initial commit on Forgejo
Fresh repository history for elektrine/tarakan-client hosted at https://git.elektrine.com/elektrine/tarakan-client.
This commit is contained in:
commit
58b29a7f84
70 changed files with 12994 additions and 0 deletions
280
internal/agent/agent.go
Normal file
280
internal/agent/agent.go
Normal 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
|
||||
}
|
||||
116
internal/agent/agent_test.go
Normal file
116
internal/agent/agent_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
281
internal/agent/claude_stream.go
Normal file
281
internal/agent/claude_stream.go
Normal 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
|
||||
}
|
||||
}
|
||||
60
internal/agent/cli_stream_test.go
Normal file
60
internal/agent/cli_stream_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
213
internal/agent/codex_stream.go
Normal file
213
internal/agent/codex_stream.go
Normal 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
|
||||
}
|
||||
515
internal/agent/grok_stream.go
Normal file
515
internal/agent/grok_stream.go
Normal 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:])
|
||||
}
|
||||
50
internal/agent/grok_stream_live_test.go
Normal file
50
internal/agent/grok_stream_live_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
72
internal/agent/grok_stream_test.go
Normal file
72
internal/agent/grok_stream_test.go
Normal 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
308
internal/agent/http.go
Normal 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
221
internal/agent/http_test.go
Normal 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
|
||||
}
|
||||
219
internal/agent/kimi_stream.go
Normal file
219
internal/agent/kimi_stream.go
Normal 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 ""
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue