provider/openai: - Fix doubled tool call args (argsComplete flag): Ollama sends complete args in the first streaming chunk then repeats them as delta, causing doubled JSON and 400 errors in elfs - Handle fs: prefix (gemma4 uses fs:grep instead of fs.grep) - Add Reasoning field support for Ollama thinking output cmd/gnoma: - Early TTY detection so logger is created with correct destination before any component gets a reference to it (fixes slog WARN bleed into TUI textarea) permission: - Exempt spawn_elfs and agent tools from safety scanner: elf prompt text may legitimately mention .env/.ssh/credentials patterns and should not be blocked tui/app: - /init retry chain: no-tool-calls → spawn_elfs nudge → write nudge (ask for plain text output) → TUI fallback write from streamBuf - looksLikeAgentsMD + extractMarkdownDoc: validate and clean fallback content before writing (reject refusals, strip narrative preambles) - Collapse thinking output to 3 lines; ctrl+o to expand (live stream and committed messages) - Stream-level filter for model pseudo-tool-call blocks: suppresses <<tool_code>>...</tool_code>> and <<function_call>>...<tool_call|> from entering streamBuf across chunk boundaries - sanitizeAssistantText regex covers both block formats - Reset streamFilterClose at every turn start
335 lines
7.4 KiB
Go
335 lines
7.4 KiB
Go
package bash
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseAliases_BashFormat(t *testing.T) {
|
|
output := `alias gs='git status'
|
|
alias ll='ls -la --color=auto'
|
|
alias gco='git checkout'
|
|
alias ..='cd ..'
|
|
`
|
|
m, err := ParseAliases(output)
|
|
if err != nil {
|
|
t.Fatalf("ParseAliases: %v", err)
|
|
}
|
|
|
|
if m.Len() != 4 {
|
|
t.Errorf("Len() = %d, want 4", m.Len())
|
|
}
|
|
|
|
tests := []struct {
|
|
name, want string
|
|
}{
|
|
{"gs", "git status"},
|
|
{"ll", "ls -la --color=auto"},
|
|
{"gco", "git checkout"},
|
|
{"..", "cd .."},
|
|
}
|
|
for _, tt := range tests {
|
|
got, ok := m.Get(tt.name)
|
|
if !ok {
|
|
t.Errorf("alias %q not found", tt.name)
|
|
continue
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("alias %q = %q, want %q", tt.name, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseAliases_ZshFormat(t *testing.T) {
|
|
// zsh alias -p may omit 'alias ' prefix
|
|
output := `gs='git status'
|
|
ll='ls -la'
|
|
`
|
|
m, err := ParseAliases(output)
|
|
if err != nil {
|
|
t.Fatalf("ParseAliases: %v", err)
|
|
}
|
|
|
|
got, ok := m.Get("gs")
|
|
if !ok || got != "git status" {
|
|
t.Errorf("gs = %q, %v", got, ok)
|
|
}
|
|
}
|
|
|
|
func TestParseAliases_DoubleQuotes(t *testing.T) {
|
|
output := `alias gs="git status"
|
|
`
|
|
m, _ := ParseAliases(output)
|
|
|
|
got, ok := m.Get("gs")
|
|
if !ok || got != "git status" {
|
|
t.Errorf("gs = %q, %v", got, ok)
|
|
}
|
|
}
|
|
|
|
func TestParseAliases_SkipsDangerousExpansions(t *testing.T) {
|
|
output := `alias safe='ls -la'
|
|
alias danger='echo $(whoami)'
|
|
alias backtick='echo ` + "`" + `date` + "`" + `'
|
|
alias ifshack='IFS=: read a b'
|
|
`
|
|
m, _ := ParseAliases(output)
|
|
|
|
if _, ok := m.Get("safe"); !ok {
|
|
t.Error("safe alias should be kept")
|
|
}
|
|
if _, ok := m.Get("danger"); ok {
|
|
t.Error("danger alias ($()) should be filtered")
|
|
}
|
|
if _, ok := m.Get("backtick"); ok {
|
|
t.Error("backtick alias should be filtered")
|
|
}
|
|
if _, ok := m.Get("ifshack"); ok {
|
|
t.Error("IFS alias should be filtered")
|
|
}
|
|
}
|
|
|
|
func TestParseAliases_EmptyAndMalformed(t *testing.T) {
|
|
output := `
|
|
alias gs='git status'
|
|
|
|
not a valid line
|
|
alias =empty_name
|
|
alias noequals
|
|
`
|
|
m, _ := ParseAliases(output)
|
|
|
|
if m.Len() != 1 {
|
|
t.Errorf("Len() = %d, want 1 (only gs)", m.Len())
|
|
}
|
|
}
|
|
|
|
func TestAliasMap_ExpandCommand(t *testing.T) {
|
|
m := NewAliasMap()
|
|
m.mu.Lock()
|
|
m.aliases["ll"] = "ls -la --color=auto"
|
|
m.aliases["gs"] = "git status"
|
|
m.aliases[".."] = "cd .."
|
|
m.mu.Unlock()
|
|
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
// Alias with args
|
|
{"ll /tmp", "ls -la --color=auto /tmp"},
|
|
// Alias without args
|
|
{"gs", "git status"},
|
|
// Alias with trailing whitespace (trimmed)
|
|
{"gs ", "git status"},
|
|
// No alias match — return unchanged
|
|
{"echo hello", "echo hello"},
|
|
// Dotdot alias
|
|
{"..", "cd .."},
|
|
// Empty command
|
|
{"", ""},
|
|
// Only whitespace
|
|
{" ", " "},
|
|
}
|
|
for _, tt := range tests {
|
|
got := m.ExpandCommand(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("ExpandCommand(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAliasMap_ExpandCommand_NoAliases(t *testing.T) {
|
|
m := NewAliasMap()
|
|
got := m.ExpandCommand("echo hello")
|
|
if got != "echo hello" {
|
|
t.Errorf("ExpandCommand = %q, want unchanged", got)
|
|
}
|
|
}
|
|
|
|
func TestAliasMap_All(t *testing.T) {
|
|
m := NewAliasMap()
|
|
m.mu.Lock()
|
|
m.aliases["a"] = "b"
|
|
m.aliases["c"] = "d"
|
|
m.mu.Unlock()
|
|
|
|
all := m.All()
|
|
if len(all) != 2 {
|
|
t.Errorf("len(All()) = %d, want 2", len(all))
|
|
}
|
|
// Verify it's a copy
|
|
all["x"] = "y"
|
|
if m.Len() != 2 {
|
|
t.Error("All() should return a copy, not a reference")
|
|
}
|
|
}
|
|
|
|
func TestStripQuotes(t *testing.T) {
|
|
tests := []struct {
|
|
input, want string
|
|
}{
|
|
{"'hello'", "hello"},
|
|
{`"hello"`, "hello"},
|
|
{"hello", "hello"},
|
|
{"'h'", "h"},
|
|
{"''", ""},
|
|
{`""`, ""},
|
|
{"'mismatched\"", "'mismatched\""},
|
|
{"x", "x"},
|
|
{"", ""},
|
|
}
|
|
for _, tt := range tests {
|
|
got := stripQuotes(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("stripQuotes(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseFishAliases(t *testing.T) {
|
|
output := `alias gs 'git status'
|
|
alias ll 'ls -la'
|
|
alias gco "git checkout"
|
|
`
|
|
m, err := ParseFishAliases(output)
|
|
if err != nil {
|
|
t.Fatalf("ParseFishAliases: %v", err)
|
|
}
|
|
|
|
if m.Len() != 3 {
|
|
t.Errorf("Len() = %d, want 3", m.Len())
|
|
}
|
|
|
|
got, ok := m.Get("gs")
|
|
if !ok || got != "git status" {
|
|
t.Errorf("gs = %q, %v", got, ok)
|
|
}
|
|
got, ok = m.Get("gco")
|
|
if !ok || got != "git checkout" {
|
|
t.Errorf("gco = %q, %v", got, ok)
|
|
}
|
|
}
|
|
|
|
func TestShellBaseName(t *testing.T) {
|
|
tests := []struct {
|
|
input, want string
|
|
}{
|
|
{"/bin/bash", "bash"},
|
|
{"/usr/bin/zsh", "zsh"},
|
|
{"/usr/local/bin/fish", "fish"},
|
|
{"bash", "bash"},
|
|
{"/bin/sh", "sh"},
|
|
}
|
|
for _, tt := range tests {
|
|
got := shellBaseName(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("shellBaseName(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAliasCommandFor(t *testing.T) {
|
|
tests := []struct {
|
|
shell string
|
|
want string
|
|
}{
|
|
{"bash", "alias -p 2>/dev/null; true"},
|
|
{"zsh", "alias 2>/dev/null; true"},
|
|
{"fish", "alias 2>/dev/null; true"},
|
|
{"sh", "alias -p 2>/dev/null; true"},
|
|
{"unknown", "alias 2>/dev/null; true"},
|
|
}
|
|
for _, tt := range tests {
|
|
got := aliasCommandFor(tt.shell)
|
|
if got != tt.want {
|
|
t.Errorf("aliasCommandFor(%q) = %q, want %q", tt.shell, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHarvestAliases_Integration(t *testing.T) {
|
|
// This actually runs the user's shell — skip in CI
|
|
if testing.Short() {
|
|
t.Skip("skipping alias harvest in short mode")
|
|
}
|
|
|
|
m, err := HarvestAliases(context.Background())
|
|
if err != nil {
|
|
// Non-fatal: harvesting may fail in some environments
|
|
t.Logf("HarvestAliases: %v (non-fatal)", err)
|
|
}
|
|
t.Logf("Harvested %d aliases", m.Len())
|
|
for name, exp := range m.All() {
|
|
t.Logf(" %s → %s", name, exp)
|
|
}
|
|
}
|
|
|
|
func TestAliasMap_AliasSummary(t *testing.T) {
|
|
m := NewAliasMap()
|
|
m.mu.Lock()
|
|
m.aliases["find"] = "fd"
|
|
m.aliases["grep"] = "rg --color=auto"
|
|
m.aliases["ls"] = "ls --color=auto" // flag-only, same command — should be excluded
|
|
m.aliases["ll"] = "ls -la" // replacement to different command — included
|
|
m.mu.Unlock()
|
|
|
|
summary := m.AliasSummary()
|
|
|
|
if summary == "" {
|
|
t.Fatal("AliasSummary should return non-empty string")
|
|
}
|
|
|
|
for _, want := range []string{"find → fd", "grep → rg", "ll → ls"} {
|
|
if !strings.Contains(summary, want) {
|
|
t.Errorf("AliasSummary missing %q, got: %q", want, summary)
|
|
}
|
|
}
|
|
|
|
// ls → ls (flag-only) should NOT appear
|
|
if strings.Contains(summary, "ls → ls") {
|
|
t.Errorf("AliasSummary should exclude flag-only aliases (ls → ls), got: %q", summary)
|
|
}
|
|
}
|
|
|
|
func TestAliasMap_AliasSummary_Empty(t *testing.T) {
|
|
m := NewAliasMap()
|
|
m.mu.Lock()
|
|
m.aliases["ls"] = "ls --color=auto" // same base command, flags only — excluded
|
|
m.mu.Unlock()
|
|
|
|
if got := m.AliasSummary(); got != "" {
|
|
t.Errorf("AliasSummary for same-command aliases should be empty, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestAliasMap_AliasSummary_Nil(t *testing.T) {
|
|
var m *AliasMap
|
|
if got := m.AliasSummary(); got != "" {
|
|
t.Errorf("nil AliasMap.AliasSummary() should return empty, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBashTool_WithAliases(t *testing.T) {
|
|
aliases := NewAliasMap()
|
|
aliases.mu.Lock()
|
|
aliases.aliases["ll"] = "ls -la"
|
|
aliases.mu.Unlock()
|
|
|
|
b := New(WithAliases(aliases))
|
|
|
|
// "ll /tmp" should expand to "ls -la /tmp" and execute
|
|
result, err := b.Execute(context.Background(), []byte(`{"command":"ll /tmp"}`))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
// Should produce output (ls -la /tmp lists files)
|
|
if result.Output == "" {
|
|
t.Error("expected output from expanded alias")
|
|
}
|
|
if result.Metadata["blocked"] == true {
|
|
t.Error("expanded alias should not be blocked")
|
|
}
|
|
}
|