2026-08-11 14:33:45 +03:00
package main
import (
"bufio"
"bytes"
2026-08-15 17:09:26 +03:00
"context"
2026-08-11 14:33:45 +03:00
"encoding/json"
"errors"
2026-09-01 09:17:47 +03:00
"fmt"
2026-09-08 21:25:29 +03:00
"io"
2026-09-01 11:02:25 +03:00
"net"
2026-08-11 14:33:45 +03:00
"net/http"
"net/http/httptest"
"os"
"path/filepath"
2026-09-01 09:17:47 +03:00
"strconv"
2026-08-11 14:33:45 +03:00
"strings"
"sync"
"testing"
"time"
)
// ---------- helpers ----------
func testHome ( t * testing . T ) string {
t . Helper ()
h := t . TempDir ()
t . Setenv ( "HOME" , h )
return h
}
func writeCfg ( t * testing . T , content string ) string {
t . Helper ()
p := filepath . Join ( t . TempDir (), "model.cfg" )
if err := os . WriteFile ( p , [] byte ( content ), 0644 ); err != nil {
t . Fatalf ( "writeCfg: %v" , err )
}
return p
}
func writeSession ( t * testing . T , dir , id string , msgs [] Message ) {
t . Helper ()
s := Session { ID : id , Created : "2026-01-01 00:00:00" , Summary : "s" , Messages : msgs }
b , err := json . Marshal ( s )
if err != nil {
t . Fatalf ( "writeSession marshal: %v" , err )
}
if err := os . WriteFile ( filepath . Join ( dir , id + ".json" ), b , 0644 ); err != nil {
t . Fatalf ( "writeSession: %v" , err )
}
}
// ---------- parseStream ----------
func TestParseStreamReasoningNoDuplication ( t * testing . T ) {
// Simulate SSE stream where chunk 1 has reasoning_content, chunk 2 has reasoning_content,
// chunk 3 has content (and NO reasoning_content), chunk 4 has tool_calls (and NO reasoning_content)
sseData := strings . Join ([] string {
`data: {"choices":[{"delta":{"reasoning_content":"Thinking step 1. "}}]}` ,
`data: {"choices":[{"delta":{"reasoning_content":"Thinking step 2."}}]}` ,
`data: {"choices":[{"delta":{"content":"Hello user"}}]}` ,
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"shell_exec","arguments":"{\"command\":\"ls\"}"}}]}}]}` ,
`data: [DONE]` ,
}, "\n" )
2026-08-16 08:12:49 +03:00
msg , _ , err := parseStream ( context . Background (), bytes . NewBufferString ( sseData ))
2026-08-11 14:33:45 +03:00
if err != nil {
t . Fatalf ( "unexpected parseStream error: %v" , err )
}
expectedReasoning := "Thinking step 1. Thinking step 2."
if msg . ReasoningContent != expectedReasoning {
t . Errorf ( "expected ReasoningContent %q, got %q" , expectedReasoning , msg . ReasoningContent )
}
expectedContent := "Hello user"
if msg . Content == nil || * msg . Content != expectedContent {
t . Errorf ( "expected Content %q, got %v" , expectedContent , msg . Content )
}
if len ( msg . ToolCalls ) != 1 {
t . Fatalf ( "expected 1 tool call, got %d" , len ( msg . ToolCalls ))
}
if msg . ToolCalls [ 0 ]. Function . Name != "shell_exec" {
t . Errorf ( "expected tool call function name shell_exec, got %q" , msg . ToolCalls [ 0 ]. Function . Name )
}
}
func TestParseStreamReasoningAlias ( t * testing . T ) {
// Test reasoning field alias
sseData := strings . Join ([] string {
`data: {"choices":[{"delta":{"reasoning":"Thought A. "}}]}` ,
`data: {"choices":[{"delta":{"reasoning":"Thought B."}}]}` ,
`data: {"choices":[{"delta":{"content":"Result"}}]}` ,
`data: [DONE]` ,
}, "\n" )
2026-08-16 08:12:49 +03:00
msg , _ , err := parseStream ( context . Background (), bytes . NewBufferString ( sseData ))
2026-08-11 14:33:45 +03:00
if err != nil {
t . Fatalf ( "unexpected parseStream error: %v" , err )
}
expectedReasoning := "Thought A. Thought B."
if msg . ReasoningContent != expectedReasoning {
t . Errorf ( "expected ReasoningContent %q, got %q" , expectedReasoning , msg . ReasoningContent )
}
}
func TestParseStreamContentOnly ( t * testing . T ) {
sseData := strings . Join ([] string {
`data: {"choices":[{"delta":{"content":"Hello"}}]}` ,
`data: {"choices":[{"delta":{"content":" world"}}]}` ,
`data: [DONE]` ,
}, "\n" )
2026-08-16 08:12:49 +03:00
msg , _ , err := parseStream ( context . Background (), bytes . NewBufferString ( sseData ))
2026-08-11 14:33:45 +03:00
if err != nil {
t . Fatalf ( "unexpected parseStream error: %v" , err )
}
if msg . Content == nil || * msg . Content != "Hello world" {
t . Errorf ( "expected Content %q, got %v" , "Hello world" , msg . Content )
}
if msg . ReasoningContent != "" {
t . Errorf ( "expected empty ReasoningContent, got %q" , msg . ReasoningContent )
}
if len ( msg . ToolCalls ) != 0 {
t . Errorf ( "expected no tool calls, got %d" , len ( msg . ToolCalls ))
}
}
func TestParseStreamReasoningOnly ( t * testing . T ) {
sseData := strings . Join ([] string {
`data: {"choices":[{"delta":{"reasoning_content":"Just thinking."}}]}` ,
`data: [DONE]` ,
}, "\n" )
2026-08-16 08:12:49 +03:00
msg , _ , err := parseStream ( context . Background (), bytes . NewBufferString ( sseData ))
2026-08-11 14:33:45 +03:00
if err != nil {
t . Fatalf ( "unexpected parseStream error: %v" , err )
}
if msg . ReasoningContent != "Just thinking." {
t . Errorf ( "expected ReasoningContent %q, got %q" , "Just thinking." , msg . ReasoningContent )
}
if msg . Content != nil {
t . Errorf ( "expected nil Content, got %q" , * msg . Content )
}
}
func TestParseStreamEmpty ( t * testing . T ) {
for _ , in := range [] string { "" , "\n\n" , "event: message\n\n" } {
2026-08-16 08:12:49 +03:00
msg , _ , err := parseStream ( context . Background (), strings . NewReader ( in ))
2026-08-11 14:33:45 +03:00
if err != nil {
t . Fatalf ( "unexpected parseStream error for input %q: %v" , in , err )
}
if msg . Content != nil || msg . ReasoningContent != "" || len ( msg . ToolCalls ) != 0 {
t . Errorf ( "expected empty Message for input %q, got %+v" , in , msg )
}
}
}
func TestParseStreamToolCallSplitAcrossChunks ( t * testing . T ) {
sseData := strings . Join ([] string {
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"shell_","arguments":""}}]}}]}` ,
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"exec","arguments":"{\"com"}}]}}]}` ,
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"mand\":\"ls\"}"}}]}}]}` ,
`data: [DONE]` ,
}, "\n" )
2026-08-16 08:12:49 +03:00
msg , _ , err := parseStream ( context . Background (), bytes . NewBufferString ( sseData ))
2026-08-11 14:33:45 +03:00
if err != nil {
t . Fatalf ( "unexpected parseStream error: %v" , err )
}
if len ( msg . ToolCalls ) != 1 {
t . Fatalf ( "expected 1 tool call, got %d" , len ( msg . ToolCalls ))
}
tc := msg . ToolCalls [ 0 ]
if tc . ID != "call_1" {
t . Errorf ( "expected id call_1, got %q" , tc . ID )
}
if tc . Function . Name != "shell_exec" {
t . Errorf ( "expected name shell_exec, got %q" , tc . Function . Name )
}
if tc . Function . Arguments != `{"command":"ls"}` {
t . Errorf ( "expected args %q, got %q" , `{"command":"ls"}` , tc . Function . Arguments )
}
}
func TestParseStreamMultipleToolCallsKeepFirstAppearanceOrder ( t * testing . T ) {
sseData := strings . Join ([] string {
2026-09-01 09:17:47 +03:00
`data: {"choices":[{"delta":{"tool_calls":[{"index":1,"id":"c2","function":{"name":"write_file","arguments":"{\"path\":\"a.txt\",\"content\":\"hello\"}"}}]}}]}` ,
2026-08-11 14:33:45 +03:00
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"shell_exec","arguments":"{\"command\":\"ls\"}"}}]}}]}` ,
`data: [DONE]` ,
}, "\n" )
2026-08-16 08:12:49 +03:00
msg , _ , err := parseStream ( context . Background (), bytes . NewBufferString ( sseData ))
2026-08-11 14:33:45 +03:00
if err != nil {
t . Fatalf ( "unexpected parseStream error: %v" , err )
}
if len ( msg . ToolCalls ) != 2 {
t . Fatalf ( "expected 2 tool calls, got %d" , len ( msg . ToolCalls ))
}
// order follows first appearance: index 1 before index 0
if msg . ToolCalls [ 0 ]. ID != "c2" || msg . ToolCalls [ 1 ]. ID != "c1" {
t . Errorf ( "expected order [c2 c1], got [%s %s]" , msg . ToolCalls [ 0 ]. ID , msg . ToolCalls [ 1 ]. ID )
}
}
func TestParseStreamJunkAndNoChoicesIgnored ( t * testing . T ) {
sseData := strings . Join ([] string {
`event: message` ,
`data: {"foo":"bar"}` ,
`data: {"choices":[]}` ,
`data: {"choices":[{"delta":{"content":"x"}}]}` ,
`data: [DONE]` ,
}, "\n" )
2026-08-16 08:12:49 +03:00
msg , _ , err := parseStream ( context . Background (), bytes . NewBufferString ( sseData ))
2026-08-11 14:33:45 +03:00
if err != nil {
t . Fatalf ( "unexpected parseStream error: %v" , err )
}
if msg . Content == nil || * msg . Content != "x" {
t . Errorf ( "expected Content %q, got %v" , "x" , msg . Content )
}
}
func TestParseStreamReasoningAfterContent ( t * testing . T ) {
sseData := strings . Join ([] string {
`data: {"choices":[{"delta":{"content":"A"}}]}` ,
`data: {"choices":[{"delta":{"reasoning":"B"}}]}` ,
`data: [DONE]` ,
}, "\n" )
2026-08-16 08:12:49 +03:00
msg , _ , err := parseStream ( context . Background (), bytes . NewBufferString ( sseData ))
2026-08-11 14:33:45 +03:00
if err != nil {
t . Fatalf ( "unexpected parseStream error: %v" , err )
}
if msg . Content == nil || * msg . Content != "A" {
t . Errorf ( "expected Content %q, got %v" , "A" , msg . Content )
}
if msg . ReasoningContent != "B" {
t . Errorf ( "expected ReasoningContent %q, got %q" , "B" , msg . ReasoningContent )
}
}
// ---------- prompt / getCfg / atoiD ----------
2026-09-01 09:17:47 +03:00
func TestDefaultSystemPrompt ( t * testing . T ) {
if ! strings . Contains ( defaultSystemPrompt , "You are Bantam, a tiny, powerful AI agent." ) {
t . Errorf ( "expected prompt to contain base description, got: %q" , defaultSystemPrompt )
2026-08-11 14:33:45 +03:00
}
2026-09-01 09:17:47 +03:00
if ! strings . Contains ( defaultSystemPrompt , "shell_exec" ) || ! strings . Contains ( defaultSystemPrompt , "write_file" ) {
t . Errorf ( "expected prompt to list shell_exec and write_file tools, got: %q" , defaultSystemPrompt )
2026-08-11 14:33:45 +03:00
}
2026-09-01 09:17:47 +03:00
if strings . Contains ( defaultSystemPrompt , "run_subagent" ) {
t . Errorf ( "prompt should not mention run_subagent: %q" , defaultSystemPrompt )
2026-08-11 14:33:45 +03:00
}
2026-09-01 09:17:47 +03:00
if ! strings . Contains ( defaultSystemPrompt , "AGENTS.md" ) {
t . Errorf ( "expected prompt to mention AGENTS.md, got: %q" , defaultSystemPrompt )
}
if strings . Contains ( defaultSystemPrompt , "GEMINI.md" ) || strings . Contains ( defaultSystemPrompt , "CLAUDE.md" ) {
t . Errorf ( "prompt should not mention GEMINI.md or CLAUDE.md: %q" , defaultSystemPrompt )
2026-08-11 14:33:45 +03:00
}
}
2026-09-10 12:20:41 +03:00
func clearBantamEnv ( t * testing . T ) {
for _ , k := range [] string {
"BANTAM_ENDPOINT" , "BANTAM_MODEL" , "BANTAM_TEMP" , "BANTAM_TEMPERATURE" ,
"BANTAM_API_KEY" , "BANTAM_STREAM" , "BANTAM_COLOR" , "BANTAM_NO_COLOR" ,
"BANTAM_TIMEOUT" , "BANTAM_SHELL_TIMEOUT" , "BANTAM_MAX_AL_ITERATIONS" ,
"BANTAM_CONTEXT_WINDOW" , "BANTAM_REASONING_EFFORT" , "BANTAM_TOOLS_DIR" ,
"BANTAM_SKILLS_DIR" ,
} {
t . Setenv ( k , "" )
}
}
2026-08-11 14:33:45 +03:00
func TestGetCfgDefaults ( t * testing . T ) {
2026-09-10 12:20:41 +03:00
clearBantamEnv ( t )
2026-08-11 14:33:45 +03:00
cfg := getCfg ( filepath . Join ( t . TempDir (), "missing.cfg" ))
if cfg . Endpoint != defCfg . Endpoint || cfg . Model != defCfg . Model || cfg . APIKey != defCfg . APIKey {
t . Errorf ( "defaults mismatch: %+v" , cfg )
}
if cfg . Temperature != 0.7 || cfg . Timeout != 300 || cfg . ShellTimeout != 120 || cfg . MaxALIterations != 1000 {
t . Errorf ( "default numeric values mismatch: %+v" , cfg )
}
if ! cfg . Stream || cfg . Color != "auto" {
t . Errorf ( "default stream/color mismatch: %+v" , cfg )
}
2026-09-02 08:58:28 +03:00
if cfg . Raw [ "reasoning_effort" ] != "high" {
t . Errorf ( "default reasoning_effort = %q, want high" , cfg . Raw [ "reasoning_effort" ])
}
2026-08-11 14:33:45 +03:00
}
func TestGetCfgParsesFile ( t * testing . T ) {
2026-09-10 12:20:41 +03:00
clearBantamEnv ( t )
2026-08-11 14:33:45 +03:00
p := writeCfg ( t , strings . Join ([] string {
"endpoint=http://localhost:9999/v1" ,
"model=test-model" ,
"temperature=0.5" ,
"api_key=secret" ,
"stream=false" ,
"color=never" ,
"timeout=42" ,
"shell_timeout=7" ,
"max_al_iterations=9" ,
"" ,
}, "\n" ))
cfg := getCfg ( p )
if cfg . Endpoint != "http://localhost:9999/v1" {
t . Errorf ( "endpoint: got %q" , cfg . Endpoint )
}
if cfg . Model != "test-model" {
t . Errorf ( "model: got %q" , cfg . Model )
}
if cfg . Temperature != 0.5 {
t . Errorf ( "temperature: got %v" , cfg . Temperature )
}
if cfg . APIKey != "secret" {
t . Errorf ( "api_key: got %q" , cfg . APIKey )
}
if cfg . Stream {
t . Errorf ( "stream: expected false" )
}
if cfg . Color != "never" {
t . Errorf ( "color: got %q" , cfg . Color )
}
if cfg . Timeout != 42 || cfg . ShellTimeout != 7 || cfg . MaxALIterations != 9 {
t . Errorf ( "timeouts: got %+v" , cfg )
}
}
func TestGetCfgIgnoresCommentsBlankAndInvalid ( t * testing . T ) {
2026-09-10 12:20:41 +03:00
clearBantamEnv ( t )
2026-08-11 14:33:45 +03:00
p := writeCfg ( t , strings . Join ([] string {
"# comment" ,
"" ,
"no-equals-line" ,
"temperature=abc" ,
"timeout=xyz" ,
"shell_timeout=" ,
"max_al_iterations=1.5" ,
"stream=banana" ,
"color=" ,
"" ,
}, "\n" ))
cfg := getCfg ( p )
if cfg . Temperature != 0.7 {
t . Errorf ( "invalid temperature should keep default, got %v" , cfg . Temperature )
}
if cfg . Timeout != 300 || cfg . ShellTimeout != 120 || cfg . MaxALIterations != 1000 {
t . Errorf ( "invalid timeouts should keep defaults, got %+v" , cfg )
}
if cfg . Stream {
t . Errorf ( "stream=banana should parse as false (matches Python/Perl)" )
}
if cfg . Color != "" {
t . Errorf ( "empty color should stay empty (matches other ports), got %q" , cfg . Color )
}
}
func TestGetCfgStreamTruthyVariants ( t * testing . T ) {
2026-09-10 12:20:41 +03:00
clearBantamEnv ( t )
2026-08-11 14:33:45 +03:00
for _ , tc := range [] struct { v , want string }{
{ "true" , "true" }, { "1" , "true" }, { "yes" , "true" },
{ "false" , "false" }, { "TRUE" , "false" }, { "0" , "false" },
} {
p := writeCfg ( t , "stream=" + tc . v + "\n" )
got := getCfg ( p ). Stream
want := tc . want == "true"
if got != want {
t . Errorf ( "stream=%s: got %v, want %v" , tc . v , got , want )
}
}
}
func TestGetCfgAPIKeyEnvFallback ( t * testing . T ) {
2026-09-10 12:20:41 +03:00
clearBantamEnv ( t )
2026-09-10 11:49:54 +03:00
t . Setenv ( "BANTAM_API_KEY" , "sk-env" )
2026-08-11 14:33:45 +03:00
t . Setenv ( "HOME" , t . TempDir ())
// no api_key line at all -> env fallback (documented behavior)
if got := getCfg ( writeCfg ( t , "model=m\n" )). APIKey ; got != "sk-env" {
t . Errorf ( "no api_key + env: got %q, want sk-env" , got )
}
// api_key=- -> env fallback
if got := getCfg ( writeCfg ( t , "api_key=-\n" )). APIKey ; got != "sk-env" {
t . Errorf ( "api_key=- + env: got %q, want sk-env" , got )
}
// api_key= (empty) -> env fallback
if got := getCfg ( writeCfg ( t , "api_key=\n" )). APIKey ; got != "sk-env" {
t . Errorf ( "api_key= + env: got %q, want sk-env" , got )
}
// explicit key wins over env
if got := getCfg ( writeCfg ( t , "api_key=real\n" )). APIKey ; got != "real" {
t . Errorf ( "explicit api_key: got %q, want real" , got )
}
// no env and no key -> stays "-"
2026-09-10 11:49:54 +03:00
t . Setenv ( "BANTAM_API_KEY" , "" )
2026-08-11 14:33:45 +03:00
if got := getCfg ( writeCfg ( t , "api_key=-\n" )). APIKey ; got != "-" {
t . Errorf ( "api_key=- without env: got %q, want -" , got )
}
if got := getCfg ( writeCfg ( t , "model=m\n" )). APIKey ; got != "-" {
t . Errorf ( "no api_key without env: got %q, want -" , got )
}
}
2026-09-10 11:49:54 +03:00
func TestGetCfgEnvOverriddenByFile ( t * testing . T ) {
2026-09-10 12:20:41 +03:00
// File values override environment variables.
clearBantamEnv ( t )
2026-09-10 11:49:54 +03:00
t . Setenv ( "BANTAM_ENDPOINT" , "http://env-endpoint/v1" )
t . Setenv ( "BANTAM_MODEL" , "env-model" )
t . Setenv ( "BANTAM_TEMP" , "0.9" )
2026-09-10 12:20:41 +03:00
t . Setenv ( "BANTAM_STREAM" , "false" )
t . Setenv ( "BANTAM_COLOR" , "never" )
t . Setenv ( "BANTAM_TIMEOUT" , "45" )
t . Setenv ( "BANTAM_SHELL_TIMEOUT" , "15" )
t . Setenv ( "BANTAM_MAX_AL_ITERATIONS" , "50" )
t . Setenv ( "BANTAM_CONTEXT_WINDOW" , "100000" )
t . Setenv ( "BANTAM_REASONING_EFFORT" , "low" )
t . Setenv ( "BANTAM_TOOLS_DIR" , "/env/tools" )
t . Setenv ( "BANTAM_SKILLS_DIR" , "/env/skills" )
2026-09-10 12:28:00 +03:00
p := filepath . Join ( t . TempDir (), ".bantam.cfg" )
if err := os . WriteFile ( p , [] byte ( strings . Join ([] string {
2026-09-10 11:49:54 +03:00
"endpoint=http://file-endpoint/v1" ,
"model=file-model" ,
"temperature=0.1" ,
2026-09-10 12:20:41 +03:00
"stream=true" ,
"color=always" ,
"timeout=300" ,
"shell_timeout=120" ,
"max_al_iterations=1000" ,
"context_window=200000" ,
"reasoning_effort=high" ,
"bantam_tools_dir=/file/tools" ,
"bantam_skills_dir=/file/skills" ,
2026-09-10 12:28:00 +03:00
}, "\n" )), 0644 ); err != nil {
t . Fatal ( err )
}
2026-09-10 11:49:54 +03:00
cfg := getCfg ( p )
if cfg . Endpoint != "http://file-endpoint/v1" {
t . Errorf ( "endpoint: got %q, want file value" , cfg . Endpoint )
}
if cfg . Model != "file-model" {
t . Errorf ( "model: got %q, want file value" , cfg . Model )
}
if cfg . Temperature != 0.1 {
t . Errorf ( "temperature: got %v, want file value" , cfg . Temperature )
}
2026-09-10 12:20:41 +03:00
if ! cfg . Stream {
t . Errorf ( "stream: got %v, want true from file" , cfg . Stream )
}
if cfg . Color != "always" {
t . Errorf ( "color: got %q, want always from file" , cfg . Color )
}
if cfg . Timeout != 300 {
t . Errorf ( "timeout: got %d, want 300 from file" , cfg . Timeout )
}
if cfg . ShellTimeout != 120 {
t . Errorf ( "shell_timeout: got %d, want 120 from file" , cfg . ShellTimeout )
}
if cfg . MaxALIterations != 1000 {
t . Errorf ( "max_al_iterations: got %d, want 1000 from file" , cfg . MaxALIterations )
}
if cfg . ContextWindow != 200000 {
t . Errorf ( "context_window: got %d, want 200000 from file" , cfg . ContextWindow )
}
if cfg . Raw [ "reasoning_effort" ] != "high" {
t . Errorf ( "reasoning_effort: got %q, want high from file" , cfg . Raw [ "reasoning_effort" ])
}
if toolsDir ( & cfg ) != "/file/tools" {
t . Errorf ( "toolsDir: got %q, want /file/tools" , toolsDir ( & cfg ))
}
if skillsDir ( & cfg ) != "/file/skills" {
t . Errorf ( "skillsDir: got %q, want /file/skills" , skillsDir ( & cfg ))
}
2026-09-10 11:49:54 +03:00
}
func TestGetCfgEnvFallbackWhenNoFile ( t * testing . T ) {
2026-09-10 12:20:41 +03:00
clearBantamEnv ( t )
2026-09-10 11:49:54 +03:00
t . Setenv ( "BANTAM_ENDPOINT" , "http://env-endpoint/v1" )
t . Setenv ( "BANTAM_MODEL" , "env-model" )
t . Setenv ( "BANTAM_TEMP" , "0.42" )
2026-09-10 12:20:41 +03:00
t . Setenv ( "BANTAM_STREAM" , "false" )
t . Setenv ( "BANTAM_COLOR" , "never" )
t . Setenv ( "BANTAM_TIMEOUT" , "45" )
t . Setenv ( "BANTAM_SHELL_TIMEOUT" , "15" )
t . Setenv ( "BANTAM_MAX_AL_ITERATIONS" , "50" )
t . Setenv ( "BANTAM_CONTEXT_WINDOW" , "100000" )
t . Setenv ( "BANTAM_REASONING_EFFORT" , "low" )
t . Setenv ( "BANTAM_TOOLS_DIR" , "/env/tools" )
t . Setenv ( "BANTAM_SKILLS_DIR" , "/env/skills" )
2026-09-10 11:49:54 +03:00
t . Setenv ( "BANTAM_API_KEY" , "" )
cfg := getCfg ( filepath . Join ( t . TempDir (), "missing.cfg" ))
if cfg . Endpoint != "http://env-endpoint/v1" || cfg . Model != "env-model" || cfg . Temperature != 0.42 {
t . Errorf ( "env fallback mismatch: %+v" , cfg )
}
2026-09-10 12:20:41 +03:00
if cfg . Stream {
t . Errorf ( "stream: got %v, want false from env" , cfg . Stream )
}
if cfg . Color != "never" {
t . Errorf ( "color: got %q, want never from env" , cfg . Color )
}
if cfg . Timeout != 45 || cfg . ShellTimeout != 15 || cfg . MaxALIterations != 50 || cfg . ContextWindow != 100000 {
t . Errorf ( "numeric env fallback mismatch: %+v" , cfg )
}
if cfg . Raw [ "reasoning_effort" ] != "low" {
t . Errorf ( "reasoning_effort: got %q, want low from env" , cfg . Raw [ "reasoning_effort" ])
}
if toolsDir ( & cfg ) != "/env/tools" {
t . Errorf ( "toolsDir: got %q, want /env/tools" , toolsDir ( & cfg ))
}
if skillsDir ( & cfg ) != "/env/skills" {
t . Errorf ( "skillsDir: got %q, want /env/skills" , skillsDir ( & cfg ))
}
2026-09-10 11:49:54 +03:00
}
2026-09-10 12:28:00 +03:00
func TestGetCfgModelCfgOverriddenByEnvWhenBantamCfgAbsent ( t * testing . T ) {
clearBantamEnv ( t )
t . Setenv ( "BANTAM_ENDPOINT" , "http://env-endpoint/v1" )
t . Setenv ( "BANTAM_MODEL" , "env-model" )
t . Setenv ( "BANTAM_TEMP" , "0.9" )
t . Setenv ( "BANTAM_TOOLS_DIR" , "/env/tools" )
t . Setenv ( "BANTAM_SKILLS_DIR" , "/env/skills" )
dir := t . TempDir ()
modelCfg := filepath . Join ( dir , "model.cfg" )
if err := os . WriteFile ( modelCfg , [] byte ( "endpoint=http://file-endpoint/v1\nmodel=file-model\ntemperature=0.1\nbantam_tools_dir=/file/tools\nbantam_skills_dir=/file/skills\n" ), 0644 ); err != nil {
t . Fatal ( err )
}
// When .bantam.cfg is absent, model.cfg yields to existing BANTAM_* env vars:
cfg := getCfg ( modelCfg )
if cfg . Endpoint != "http://env-endpoint/v1" {
t . Errorf ( "endpoint: got %q, want env value" , cfg . Endpoint )
}
if cfg . Model != "env-model" {
t . Errorf ( "model: got %q, want env value" , cfg . Model )
}
if cfg . Temperature != 0.9 {
t . Errorf ( "temperature: got %v, want env value" , cfg . Temperature )
}
if toolsDir ( & cfg ) != "/env/tools" {
t . Errorf ( "toolsDir: got %q, want /env/tools" , toolsDir ( & cfg ))
}
if skillsDir ( & cfg ) != "/env/skills" {
t . Errorf ( "skillsDir: got %q, want /env/skills" , skillsDir ( & cfg ))
}
}
func TestGetCfgBantamCfgOverridesEnvAndModelCfg ( t * testing . T ) {
clearBantamEnv ( t )
t . Setenv ( "BANTAM_ENDPOINT" , "http://env-endpoint/v1" )
t . Setenv ( "BANTAM_MODEL" , "env-model" )
t . Setenv ( "BANTAM_TEMP" , "0.9" )
dir := t . TempDir ()
modelCfg := filepath . Join ( dir , "model.cfg" )
if err := os . WriteFile ( modelCfg , [] byte ( "endpoint=http://model-endpoint/v1\nmodel=model-model\ncolor=never\n" ), 0644 ); err != nil {
t . Fatal ( err )
}
bantamCfg := filepath . Join ( dir , ".bantam.cfg" )
if err := os . WriteFile ( bantamCfg , [] byte ( "model=bantam-model\n" ), 0644 ); err != nil {
t . Fatal ( err )
}
cfg := getCfg ( bantamCfg )
// bantam.cfg overrides env and model.cfg for model:
if cfg . Model != "bantam-model" {
t . Errorf ( "model: got %q, want bantam-model" , cfg . Model )
}
// endpoint falls back to env:
if cfg . Endpoint != "http://env-endpoint/v1" {
t . Errorf ( "endpoint: got %q, want env-endpoint" , cfg . Endpoint )
}
// color falls back to model.cfg since not in bantam.cfg or env:
if cfg . Color != "never" {
t . Errorf ( "color: got %q, want never from model.cfg" , cfg . Color )
}
}
2026-08-11 14:33:45 +03:00
func TestAtoiD ( t * testing . T ) {
cases := [] struct {
s string
d , w int
}{
{ "42" , 0 , 42 }, { "-3" , 0 , - 3 }, { " 7 " , 0 , 7 },
{ "abc" , 5 , 5 }, { "" , 5 , 5 }, { "1.5" , 5 , 5 },
}
for _ , c := range cases {
if got := atoiD ( c . s , c . d ); got != c . w {
t . Errorf ( "atoiD(%q, %d) = %d, want %d" , c . s , c . d , got , c . w )
}
}
}
// ---------- sanitizeMessages / isInvalidAssistantErr ----------
func TestSanitizeMessages ( t * testing . T ) {
msgs := [] Message {
{
Role : "assistant" ,
ToolCalls : [] ToolCall {
{
ID : "tc1" ,
Type : "function" ,
Function : struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{
Name : "shell_exec" ,
Arguments : `{"command": "ls"` , // invalid JSON: missing closing brace
},
},
},
},
}
sanitizeMessages ( msgs )
if msgs [ 0 ]. ToolCalls [ 0 ]. Function . Arguments == `{"command": "ls"` {
t . Errorf ( "expected arguments to be sanitized to valid JSON, but remained raw" )
}
if ! strings . Contains ( msgs [ 0 ]. ToolCalls [ 0 ]. Function . Arguments , "invalid_raw" ) {
t . Errorf ( "expected sanitized arguments to contain invalid_raw, got: %q" , msgs [ 0 ]. ToolCalls [ 0 ]. Function . Arguments )
}
}
func TestSanitizeMessagesLeavesValidAndOtherRolesAlone ( t * testing . T ) {
validArgs := `{"command":"ls"}`
msgs := [] Message {
{ Role : "user" , Content : strp ( "hi" )},
{ Role : "assistant" , Content : strp ( "ok" ), ToolCalls : [] ToolCall {{ ID : "a" , Type : "function" , Function : struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{ Name : "shell_exec" , Arguments : validArgs }}}},
{ Role : "assistant" , Content : strp ( "no calls" )},
{ Role : "tool" , ToolCallID : "a" , Content : strp ( "out" )},
}
sanitizeMessages ( msgs )
if msgs [ 1 ]. ToolCalls [ 0 ]. Function . Arguments != validArgs {
t . Errorf ( "valid args were altered: %q" , msgs [ 1 ]. ToolCalls [ 0 ]. Function . Arguments )
}
if msgs [ 0 ]. Content == nil || * msgs [ 0 ]. Content != "hi" {
t . Errorf ( "user message altered: %+v" , msgs [ 0 ])
}
if len ( msgs [ 2 ]. ToolCalls ) != 0 || msgs [ 3 ]. Content == nil {
t . Errorf ( "unexpected alteration: %+v" , msgs )
}
}
func TestIsInvalidAssistantErr ( t * testing . T ) {
cases := [] struct {
msg string
want bool
}{
{ "HTTP 400: [invalid_request_error] Invalid assistant message: content or tool_calls must be set" , true },
{ "HTTP 400: [invalid_request_error] Invalid assistant message: content or tool_calls must be set (HTTP 400)" , true },
{ "HTTP 400: [invalid_request_error] Invalid assistant message" , true },
{ "HTTP 400: content or tool_calls must be set" , true },
{ "HTTP 500: internal server error" , false },
{ "HTTP 429: rate limited" , false },
{ "network error: connection refused" , false },
}
for _ , c := range cases {
if got := isInvalidAssistantErr ( errors . New ( c . msg )); got != c . want {
t . Errorf ( "isInvalidAssistantErr(%q) = %v, want %v" , c . msg , got , c . want )
}
}
}
// ---------- shell ----------
func TestShellBasic ( t * testing . T ) {
2026-08-15 17:09:26 +03:00
res := shell ( context . Background (), "echo hi" , 10 )
2026-08-11 14:33:45 +03:00
if ! strings . Contains ( res , "hi" ) || ! strings . HasSuffix ( res , "exit: 0" ) {
t . Errorf ( "shell(echo hi) = %q" , res )
}
}
func TestShellExitCodeAndStderr ( t * testing . T ) {
2026-08-15 17:09:26 +03:00
res := shell ( context . Background (), "echo out; echo err >&2; exit 7" , 10 )
2026-08-11 14:33:45 +03:00
if ! strings . Contains ( res , "out" ) || ! strings . Contains ( res , "err" ) || ! strings . HasSuffix ( res , "exit: 7" ) {
t . Errorf ( "shell multi = %q" , res )
}
}
func TestShellUnknownCommand ( t * testing . T ) {
2026-08-15 17:09:26 +03:00
res := shell ( context . Background (), "definitely_not_a_command_xyz" , 10 )
2026-08-11 14:33:45 +03:00
if ! strings . Contains ( res , "exit: 127" ) {
t . Errorf ( "expected exit 127, got %q" , res )
}
}
func TestShellTimeout ( t * testing . T ) {
2026-08-15 17:09:26 +03:00
res := shell ( context . Background (), "sleep 5" , 1 )
2026-08-11 14:33:45 +03:00
if ! strings . Contains ( res , "[shell timeout after 1s]" ) || ! strings . HasSuffix ( res , "exit: -1" ) {
t . Errorf ( "expected timeout marker and exit -1, got %q" , res )
}
}
2026-08-15 17:09:26 +03:00
func TestShellContextCancellation ( t * testing . T ) {
ctx , cancel := context . WithCancel ( context . Background ())
go func () {
time . Sleep ( 50 * time . Millisecond )
cancel ()
}()
res := shell ( ctx , "sleep 5" , 10 )
if ! strings . Contains ( res , "[interrupted]" ) || ! strings . HasSuffix ( res , "exit: -1" ) {
t . Errorf ( "expected interrupted result on cancellation, got %q" , res )
}
}
2026-08-11 14:33:45 +03:00
// ---------- last / summary ----------
func TestLast ( t * testing . T ) {
cases := [] struct {
name string
msgs [] Message
want string
}{
{ "empty" , nil , "" },
{ "no assistant" , [] Message {{ Role : "user" , Content : strp ( "u" )}}, "" },
{ "last assistant wins" , [] Message {
{ Role : "assistant" , Content : strp ( "first" )},
{ Role : "tool" , ToolCallID : "x" , Content : strp ( "r" )},
{ Role : "assistant" , Content : strp ( "second" )},
}, "second" },
{ "nil and empty content skipped" , [] Message {
{ Role : "assistant" },
{ Role : "assistant" , Content : strp ( "" )},
{ Role : "assistant" , Content : strp ( "real" )},
}, "real" },
}
for _ , c := range cases {
if got := last ( c . msgs ); got != c . want {
t . Errorf ( "%s: last() = %q, want %q" , c . name , got , c . want )
}
}
}
func TestSummary ( t * testing . T ) {
cases := [] struct {
name string
msgs [] Message
want string
}{
{ "empty" , nil , "(empty session)" },
{ "system only" , [] Message {{ Role : "system" , Content : strp ( "sys" )}}, "(empty session)" },
{ "whitespace user skipped" , [] Message {{ Role : "user" , Content : strp ( " " )}}, "(empty session)" },
{ "first user wins" , [] Message {
{ Role : "user" , Content : strp ( "hello world" )},
{ Role : "user" , Content : strp ( "second" )},
}, "hello world" },
{ "long truncated" , [] Message {{ Role : "user" , Content : strp ( strings . Repeat ( "a" , 100 ))}}, strings . Repeat ( "a" , 80 ) + "..." },
}
for _ , c := range cases {
if got := summary ( c . msgs ); got != c . want {
t . Errorf ( "%s: summary() = %q, want %q" , c . name , got , c . want )
}
}
}
// ---------- homeDir / sdir / fileExists ----------
func TestHomeDir ( t * testing . T ) {
t . Setenv ( "HOME" , "/tmp/bantam-test-home" )
if got := homeDir (); got != "/tmp/bantam-test-home" {
t . Errorf ( "homeDir() = %q" , got )
}
t . Setenv ( "HOME" , "" )
if got := homeDir (); got != "." {
t . Errorf ( "homeDir() with empty HOME = %q, want ." , got )
}
}
func TestSdirCreatesDir ( t * testing . T ) {
h := testHome ( t )
d := sdir ()
want := filepath . Join ( h , ".bantam" , "sessions" )
if d != want {
t . Errorf ( "sdir() = %q, want %q" , d , want )
}
if fi , err := os . Stat ( d ); err != nil || ! fi . IsDir () {
t . Errorf ( "sdir() did not create directory: %v" , err )
}
}
func TestFileExists ( t * testing . T ) {
p := filepath . Join ( t . TempDir (), "f" )
if fileExists ( p ) {
t . Errorf ( "fileExists(%q) = true before creation" , p )
}
if err := os . WriteFile ( p , [] byte ( "x" ), 0644 ); err != nil {
t . Fatalf ( "write: %v" , err )
}
if ! fileExists ( p ) {
t . Errorf ( "fileExists(%q) = false after creation" , p )
}
}
// ---------- sessions ----------
2026-09-01 09:46:13 +03:00
func TestProjectID ( t * testing . T ) {
pid := projectID ()
if len ( pid ) != 32 {
t . Fatalf ( "expected 32-char hex MD5, got %q (len %d)" , pid , len ( pid ))
}
for _ , r := range pid {
if ! strings . ContainsRune ( "0123456789abcdef" , r ) {
t . Fatalf ( "invalid hex char %c in %q" , r , pid )
}
}
}
2026-08-11 14:33:45 +03:00
func TestSaveSessionAndLoad ( t * testing . T ) {
h := testHome ( t )
msgs := [] Message {
{ Role : "system" , Content : strp ( "sys" )},
{ Role : "user" , Content : strp ( "hello" )},
}
2026-09-01 09:46:13 +03:00
// Default session ID = project MD5
sid , sm := saveSession ( msgs , "" )
2026-08-11 14:33:45 +03:00
if sm != "hello" {
t . Errorf ( "summary = %q, want hello" , sm )
}
2026-09-01 09:46:13 +03:00
if sid != projectID () {
t . Fatalf ( "expected sid = %s, got %s" , projectID (), sid )
2026-08-11 14:33:45 +03:00
}
if ! fileExists ( filepath . Join ( h , ".bantam" , "sessions" , sid + ".json" )) {
t . Errorf ( "session file not written" )
}
loaded , err := loadSession ( sid )
if err != nil {
t . Fatalf ( "loadSession: %v" , err )
}
if len ( loaded ) != 2 || loaded [ 1 ]. Role != "user" || * loaded [ 1 ]. Content != "hello" {
t . Errorf ( "loaded messages mismatch: %+v" , loaded )
}
2026-09-01 09:46:13 +03:00
// Custom session ID
sid2 , _ := saveSession ( msgs , "custom-id" )
if sid2 != "custom-id" {
t . Errorf ( "expected sid2 = custom-id, got %s" , sid2 )
2026-08-11 14:33:45 +03:00
}
2026-09-01 09:46:13 +03:00
if ! fileExists ( filepath . Join ( h , ".bantam" , "sessions" , "custom-id.json" )) {
t . Errorf ( "custom-id.json file not written" )
}
loaded2 , err := loadSession ( "custom-id" )
if err != nil || len ( loaded2 ) != 2 {
t . Fatalf ( "loadSession(custom-id) failed: %v" , err )
2026-08-11 14:33:45 +03:00
}
}
func TestSessionsSortAndFilter ( t * testing . T ) {
h := testHome ( t )
d := filepath . Join ( h , ".bantam" , "sessions" )
if err := os . MkdirAll ( d , 0755 ); err != nil {
t . Fatalf ( "mkdir: %v" , err )
}
writeSession ( t , d , "b" , [] Message {{ Role : "user" , Content : strp ( "u" )}})
writeSession ( t , d , "a" , [] Message {{ Role : "user" , Content : strp ( "u" )}})
os . WriteFile ( filepath . Join ( d , "junk.txt" ), [] byte ( "nope" ), 0644 )
os . WriteFile ( filepath . Join ( d , "corrupt.json" ), [] byte ( "not json" ), 0644 )
os . Mkdir ( filepath . Join ( d , "subdir" ), 0755 )
ss := sessions ()
if len ( ss ) != 2 {
t . Fatalf ( "expected 2 sessions, got %d" , len ( ss ))
}
if ss [ 0 ]. ID != "b" || ss [ 1 ]. ID != "a" {
t . Errorf ( "expected descending order [b a], got [%s %s]" , ss [ 0 ]. ID , ss [ 1 ]. ID )
}
}
func TestLoadSessionExactPrefixAmbiguousNotFound ( t * testing . T ) {
h := testHome ( t )
d := filepath . Join ( h , ".bantam" , "sessions" )
if err := os . MkdirAll ( d , 0755 ); err != nil {
t . Fatalf ( "mkdir: %v" , err )
}
writeSession ( t , d , "aaa" , [] Message {{ Role : "user" , Content : strp ( "one" )}})
writeSession ( t , d , "aab" , [] Message {{ Role : "user" , Content : strp ( "two" )}})
writeSession ( t , d , "zzz" , [] Message {{ Role : "user" , Content : strp ( "three" )}})
if _ , err := loadSession ( "aaa" ); err != nil {
t . Errorf ( "exact match failed: %v" , err )
}
if _ , err := loadSession ( "zz" ); err != nil {
t . Errorf ( "unique prefix failed: %v" , err )
}
if _ , err := loadSession ( "aa" ); err == nil || ! strings . Contains ( err . Error (), "ambiguous" ) {
t . Errorf ( "expected ambiguous error, got %v" , err )
}
if _ , err := loadSession ( "qq" ); err == nil || ! strings . Contains ( err . Error (), "not found" ) {
t . Errorf ( "expected not found error, got %v" , err )
}
}
func TestAutosave ( t * testing . T ) {
h := testHome ( t )
msgs := [] Message {{ Role : "user" , Content : strp ( "turn" )}}
autosave ( msgs )
2026-09-01 09:46:13 +03:00
pid := projectID ()
p := filepath . Join ( h , ".bantam" , "sessions" , pid + ".json" )
2026-08-11 14:33:45 +03:00
if ! fileExists ( p ) {
2026-09-01 09:46:13 +03:00
t . Fatalf ( "%s.json not written" , pid )
2026-08-11 14:33:45 +03:00
}
2026-09-01 09:46:13 +03:00
loaded , err := loadSession ( pid )
2026-08-11 14:33:45 +03:00
if err != nil {
2026-09-01 09:46:13 +03:00
t . Fatalf ( "loadSession(%s): %v" , pid , err )
2026-08-11 14:33:45 +03:00
}
if len ( loaded ) != 1 || * loaded [ 0 ]. Content != "turn" {
t . Errorf ( "autosave messages mismatch: %+v" , loaded )
}
}
// ---------- summarize / compact (error paths only, no network) ----------
func TestSummarizeEmpty ( t * testing . T ) {
2026-08-16 08:12:49 +03:00
if _ , err := summarize ( context . Background (), & Cfg {}, nil ); err == nil || ! strings . Contains ( err . Error (), "no system message" ) {
t . Errorf ( "expected no-system-message error, got %v" , err )
2026-08-11 14:33:45 +03:00
}
2026-08-16 08:12:49 +03:00
if _ , err := summarize ( context . Background (), & Cfg {}, [] Message {{ Role : "system" , Content : strp ( "sys" )}}); err == nil || ! strings . Contains ( err . Error (), "nothing to compact" ) {
t . Errorf ( "expected nothing-to-compact error for system-only conversation, got %v" , err )
2026-08-11 14:33:45 +03:00
}
}
func TestCompactNoSystem ( t * testing . T ) {
2026-08-15 17:09:26 +03:00
msgs , _ , err := compact ( context . Background (), & Cfg {}, nil )
2026-08-11 14:33:45 +03:00
if err == nil || ! strings . Contains ( err . Error (), "no system message" ) {
t . Errorf ( "expected no-system error, got %v" , err )
}
if msgs != nil {
t . Errorf ( "expected original messages on error" )
}
2026-08-15 17:09:26 +03:00
msgs2 , _ , err2 := compact ( context . Background (), & Cfg {}, [] Message {{ Role : "user" , Content : strp ( "x" )}})
2026-08-16 08:12:49 +03:00
if err2 == nil || ! strings . Contains ( err2 . Error (), "no system message" ) {
t . Errorf ( "expected error when first message is not system, got %v" , err2 )
2026-08-11 14:33:45 +03:00
}
if len ( msgs2 ) != 1 {
t . Errorf ( "expected original messages returned, got %d" , len ( msgs2 ))
}
2026-08-16 08:12:49 +03:00
msgs3 , _ , err3 := compact ( context . Background (), & Cfg {}, [] Message {{ Role : "system" , Content : strp ( "sys" )}})
if err3 == nil || ! strings . Contains ( err3 . Error (), "nothing to compact" ) {
t . Errorf ( "expected nothing to compact error, got %v" , err3 )
}
if len ( msgs3 ) != 1 {
t . Errorf ( "expected original messages returned, got %d" , len ( msgs3 ))
}
2026-08-11 14:33:45 +03:00
}
// ---------- history ----------
func TestHistoryRoundTrip ( t * testing . T ) {
oldHist , oldHistF := hist , histF
t . Cleanup ( func () { hist , histF = oldHist , oldHistF })
hist , histF = nil , ""
h := testHome ( t )
loadHistory ()
if histF != filepath . Join ( h , ".bantam_history" ) {
t . Errorf ( "histF = %q" , histF )
}
if len ( hist ) != 0 {
t . Errorf ( "expected empty history, got %v" , hist )
}
addHistory ( "one" )
addHistory ( "one" ) // duplicate ignored
addHistory ( "two" )
addHistory ( "" ) // empty ignored
if len ( hist ) != 2 || hist [ 0 ] != "one" || hist [ 1 ] != "two" {
t . Errorf ( "hist = %v" , hist )
}
saveHistory ()
hist = nil
loadHistory ()
if len ( hist ) != 2 || hist [ 0 ] != "one" || hist [ 1 ] != "two" {
t . Errorf ( "history not reloaded: %v" , hist )
}
}
// ---------- visibleLen / textPos ----------
func TestVisibleLen ( t * testing . T ) {
cases := [] struct {
s string
want int
}{
{ "" , 0 },
{ "hello" , 5 },
{ "héllo" , 5 },
{ "🙂x" , 2 },
{ "\033[31mred\033[0m" , 3 },
{ "a\001\033[1m\002b\001\033[0m\002c" , 3 },
}
for _ , c := range cases {
if got := visibleLen ( c . s ); got != c . want {
t . Errorf ( "visibleLen(%q) = %d, want %d" , c . s , got , c . want )
}
}
}
func TestTextPos ( t * testing . T ) {
cases := [] struct {
pl , W int
s string
pos int
r , col int
}{
{ 0 , 80 , "hello" , 3 , 0 , 3 },
{ 5 , 80 , "hello" , 0 , 0 , 5 },
{ 0 , 80 , "ab\ncd" , 4 , 1 , 1 },
{ 0 , 80 , "ab\ncd" , 5 , 1 , 2 },
{ 0 , 5 , "abcde" , 5 , 0 , 4 }, // 5th char sits at last column, next would wrap
{ 0 , 5 , "abcdef" , 6 , 1 , 1 }, // wrap to next row
{ 0 , 80 , "" , 0 , 0 , 0 },
}
for _ , c := range cases {
r , col := textPos ( c . pl , c . W , c . s , c . pos )
if r != c . r || col != c . col {
t . Errorf ( "textPos(%d,%d,%q,%d) = (%d,%d), want (%d,%d)" , c . pl , c . W , c . s , c . pos , r , col , c . r , c . col )
}
}
}
// ---------- editor.histNav ----------
func TestHistNav ( t * testing . T ) {
e := & editor { hpos : - 1 , hist : [] string { "first" , "second" }, buf : [] rune ( "draft" ), pos : 5 }
e . histNav ( true ) // up: newest entry
if e . hpos != 1 || string ( e . buf ) != "second" || e . draft != "draft" || e . pos != len ( e . buf ) {
t . Errorf ( "after first up: hpos=%d buf=%q draft=%q pos=%d" , e . hpos , string ( e . buf ), e . draft , e . pos )
}
e . histNav ( true ) // up again
if e . hpos != 0 || string ( e . buf ) != "first" {
t . Errorf ( "after second up: hpos=%d buf=%q" , e . hpos , string ( e . buf ))
}
e . histNav ( true ) // at oldest, stays
if e . hpos != 0 || string ( e . buf ) != "first" {
t . Errorf ( "after third up: hpos=%d buf=%q" , e . hpos , string ( e . buf ))
}
e . histNav ( false ) // down
if e . hpos != 1 || string ( e . buf ) != "second" {
t . Errorf ( "after down: hpos=%d buf=%q" , e . hpos , string ( e . buf ))
}
e . histNav ( false ) // down past end -> restore draft
if e . hpos != - 1 || string ( e . buf ) != "draft" {
t . Errorf ( "after down to draft: hpos=%d buf=%q" , e . hpos , string ( e . buf ))
}
e . histNav ( false ) // no-op when not navigating
if e . hpos != - 1 || string ( e . buf ) != "draft" {
t . Errorf ( "after extra down: hpos=%d buf=%q" , e . hpos , string ( e . buf ))
}
empty := & editor { hpos : - 1 }
empty . histNav ( true )
if empty . hpos != - 1 {
t . Errorf ( "histNav with empty history changed hpos to %d" , empty . hpos )
}
}
// ---------- readPlain / readLine ----------
func TestReadPlain ( t * testing . T ) {
oldStdin := stdin
t . Cleanup ( func () { stdin = oldStdin })
stdin = bufio . NewReader ( strings . NewReader ( "hello\n" ))
got , ok := readPlain ( "> " )
if ! ok || got != "hello" {
t . Errorf ( "readPlain = (%q, %v), want (hello, true)" , got , ok )
}
stdin = bufio . NewReader ( strings . NewReader ( "no-newline" ))
got , ok = readPlain ( "> " )
if ! ok || got != "no-newline" {
t . Errorf ( "readPlain no-newline = (%q, %v)" , got , ok )
}
stdin = bufio . NewReader ( strings . NewReader ( "" ))
got , ok = readPlain ( "> " )
if ok || got != "" {
t . Errorf ( "readPlain EOF = (%q, %v), want (\"\", false)" , got , ok )
}
}
func TestReadLineFallsBackToPlainWhenNotTTY ( t * testing . T ) {
if isTerminal ( int ( os . Stdin . Fd ())) {
t . Skip ( "stdin is a terminal; readLine would enter raw mode" )
}
oldStdin := stdin
t . Cleanup ( func () { stdin = oldStdin })
stdin = bufio . NewReader ( strings . NewReader ( "line\n" ))
got , ok := readLine ( "> " )
if ! ok || got != "line" {
t . Errorf ( "readLine = (%q, %v), want (line, true)" , got , ok )
}
}
// ---------- colors ----------
func TestColorHelperC ( t * testing . T ) {
old := COL
t . Cleanup ( func () { COL = old })
COL = false
if got := c ( "x" , 31 ); got != "x" {
t . Errorf ( "c with COL=false = %q" , got )
}
COL = true
if got := c ( "x" , 31 ); got != "\033[31mx\033[0m" {
t . Errorf ( "c(x,31) = %q" , got )
}
if got := c ( "x" , 1 , 32 ); got != "\033[1;32mx\033[0m" {
t . Errorf ( "c(x,1,32) = %q" , got )
}
if got := c ( "x" ); got != "x" {
t . Errorf ( "c(x) with no codes = %q" , got )
}
}
func TestCol ( t * testing . T ) {
t . Setenv ( "NO_COLOR" , "" )
t . Setenv ( "BANTAM_NO_COLOR" , "" )
if ! col ( Cfg { Color : "always" }) {
t . Errorf ( "color=always should be true" )
}
if col ( Cfg { Color : "never" }) {
t . Errorf ( "color=never should be false" )
}
if col ( Cfg { Color : "auto" }) {
t . Errorf ( "color=auto should be false when stdout is not a TTY" )
}
if col ( Cfg { Color : "garbage" }) {
t . Errorf ( "unknown color value should fall back to TTY detection" )
}
t . Setenv ( "NO_COLOR" , "1" )
if col ( Cfg { Color : "always" }) {
t . Errorf ( "NO_COLOR should override color=always" )
}
}
// ---------- llm / AL / summarize / compact via httptest (no real network) ----------
func TestLLMNonStreamingAndHeaders ( t * testing . T ) {
var gotPath , gotAuth , gotUA string
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
gotPath = r . URL . Path
gotAuth = r . Header . Get ( "Authorization" )
gotUA = r . Header . Get ( "User-Agent" )
w . Write ([] byte ( `{"choices":[{"message":{"role":"assistant","content":"hi","reasoning_content":"think"}}]}` ))
}))
defer srv . Close ()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Stream = false
cfg . APIKey = "secret"
2026-08-16 08:12:49 +03:00
m , _ , err := llm ( context . Background (), & cfg , [] Message {{ Role : "user" , Content : strp ( "hi" )}}, TOOLS )
2026-08-11 14:33:45 +03:00
if err != nil {
t . Fatalf ( "llm: %v" , err )
}
if gotPath != "/chat/completions" {
t . Errorf ( "path = %q" , gotPath )
}
if gotAuth != "Bearer secret" {
t . Errorf ( "auth = %q" , gotAuth )
}
if ! strings . Contains ( gotUA , "Bantam/1.0" ) {
t . Errorf ( "user-agent = %q" , gotUA )
}
if m . Content == nil || * m . Content != "hi" || m . ReasoningContent != "think" {
t . Errorf ( "message = %+v" , m )
}
}
func TestLLMNoAuthHeaderWhenNoKey ( t * testing . T ) {
var gotAuth string
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
gotAuth = r . Header . Get ( "Authorization" )
w . Write ([] byte ( `{"choices":[{"message":{"role":"assistant","content":"x"}}]}` ))
}))
defer srv . Close ()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Stream = false
cfg . APIKey = "-"
2026-08-16 08:12:49 +03:00
if _ , _ , err := llm ( context . Background (), & cfg , [] Message {{ Role : "user" , Content : strp ( "hi" )}}, nil ); err != nil {
2026-08-11 14:33:45 +03:00
t . Fatalf ( "llm: %v" , err )
}
if gotAuth != "" {
t . Errorf ( "expected no Authorization header, got %q" , gotAuth )
}
}
func TestLLMStreaming ( t * testing . T ) {
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
w . Header (). Set ( "Content-Type" , "text/event-stream" )
w . Write ([] byte ( "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"R\"}}]}\n\n" ))
w . Write ([] byte ( "data: {\"choices\":[{\"delta\":{\"content\":\"C\"}}]}\n\n" ))
w . Write ([] byte ( "data: [DONE]\n\n" ))
}))
defer srv . Close ()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Stream = true
cfg . APIKey = "-"
2026-08-16 08:12:49 +03:00
m , _ , err := llm ( context . Background (), & cfg , [] Message {{ Role : "user" , Content : strp ( "hi" )}}, nil )
2026-08-11 14:33:45 +03:00
if err != nil {
t . Fatalf ( "llm: %v" , err )
}
if m . Content == nil || * m . Content != "C" {
t . Errorf ( "content = %v" , m . Content )
}
if m . ReasoningContent != "R" {
t . Errorf ( "reasoning = %q" , m . ReasoningContent )
}
}
func TestLLM4xxReturnsImmediately ( t * testing . T ) {
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
w . WriteHeader ( 400 )
w . Write ([] byte ( `{"error":"Invalid assistant message: content or tool_calls must be set"}` ))
}))
defer srv . Close ()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Stream = false
cfg . APIKey = "-"
start := time . Now ()
2026-08-16 08:12:49 +03:00
_ , _ , err := llm ( context . Background (), & cfg , [] Message {{ Role : "user" , Content : strp ( "hi" )}}, nil )
2026-08-11 14:33:45 +03:00
if err == nil || ! strings . Contains ( err . Error (), "Invalid assistant message" ) {
t . Fatalf ( "expected 400 error, got %v" , err )
}
if time . Since ( start ) > time . Second {
t . Errorf ( "4xx should not be retried, took %v" , time . Since ( start ))
}
}
func TestLLMRetriesOn5xxThenSucceeds ( t * testing . T ) {
var calls int
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
calls ++
if calls < 3 {
w . WriteHeader ( 500 )
w . Write ([] byte ( "boom" ))
return
}
w . Write ([] byte ( `{"choices":[{"message":{"role":"assistant","content":"ok"}}]}` ))
}))
defer srv . Close ()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Stream = false
cfg . APIKey = "-"
2026-08-16 08:12:49 +03:00
m , _ , err := llm ( context . Background (), & cfg , [] Message {{ Role : "user" , Content : strp ( "hi" )}}, nil )
2026-08-11 14:33:45 +03:00
if err != nil {
t . Fatalf ( "llm after retries: %v" , err )
}
if calls != 3 {
t . Errorf ( "expected 3 calls, got %d" , calls )
}
if m . Content == nil || * m . Content != "ok" {
t . Errorf ( "content = %v" , m . Content )
}
}
func TestLLMEmptyChoices ( t * testing . T ) {
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
w . Write ([] byte ( `{"choices":[]}` ))
}))
defer srv . Close ()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Stream = false
cfg . APIKey = "-"
2026-08-16 08:12:49 +03:00
if _ , _ , err := llm ( context . Background (), & cfg , [] Message {{ Role : "user" , Content : strp ( "hi" )}}, nil ); err == nil || ! strings . Contains ( err . Error (), "empty choices" ) {
2026-08-11 14:33:45 +03:00
t . Errorf ( "expected empty choices error, got %v" , err )
}
}
func TestALToolLoop ( t * testing . T ) {
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
var req struct {
Messages [] Message `json:"messages"`
}
if err := json . NewDecoder ( r . Body ). Decode ( & req ); err != nil {
t . Errorf ( "decode request: %v" , err )
}
for _ , m := range req . Messages {
if m . Role == "tool" {
w . Write ([] byte ( `{"choices":[{"message":{"role":"assistant","content":"done"}}]}` ))
return
}
}
w . Write ([] byte ( `{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"shell_exec","arguments":"{\"command\":\"echo hello\"}"}}]}}]}` ))
}))
defer srv . Close ()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Stream = false
cfg . APIKey = "-"
2026-09-01 09:17:47 +03:00
msgs , _ , err := AL ( context . Background (), & cfg , [] Message {{ Role : "system" , Content : strp ( "sys" )}, { Role : "user" , Content : strp ( "run" )}})
2026-08-11 14:33:45 +03:00
if err != nil {
t . Fatalf ( "AL: %v" , err )
}
if got := last ( msgs ); got != "done" {
t . Errorf ( "last = %q, want done" , got )
}
var toolMsgs int
for _ , m := range msgs {
if m . Role == "tool" {
toolMsgs ++
if m . ToolCallID != "c1" {
t . Errorf ( "tool msg tool_call_id = %q" , m . ToolCallID )
}
if m . Content == nil || ! strings . Contains ( * m . Content , "hello" ) || ! strings . Contains ( * m . Content , "exit: 0" ) {
t . Errorf ( "tool result = %v" , m . Content )
}
}
}
if toolMsgs != 1 {
t . Errorf ( "expected 1 tool message, got %d" , toolMsgs )
}
}
2026-08-22 20:40:24 +03:00
func TestALReasoningOnlyAutoContinue ( t * testing . T ) {
// First response is reasoning-only (no content, no tool calls); the agent
// must auto-append a "continue" user message and keep looping until a real
// answer arrives.
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
var req struct {
Messages [] Message `json:"messages"`
}
if err := json . NewDecoder ( r . Body ). Decode ( & req ); err != nil {
t . Errorf ( "decode request: %v" , err )
}
for _ , m := range req . Messages {
if m . Role == "user" && m . Content != nil && strings . TrimSpace ( * m . Content ) == "continue" {
w . Write ([] byte ( `{"choices":[{"message":{"role":"assistant","content":"final answer"}}]}` ))
return
}
}
w . Write ([] byte ( `{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"thinking hard"}}]}` ))
}))
defer srv . Close ()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Stream = false
cfg . APIKey = "-"
2026-09-01 09:17:47 +03:00
msgs , _ , err := AL ( context . Background (), & cfg , [] Message {{ Role : "system" , Content : strp ( "sys" )}, { Role : "user" , Content : strp ( "start" )}})
2026-08-22 20:40:24 +03:00
if err != nil {
t . Fatalf ( "AL: %v" , err )
}
if got := last ( msgs ); got != "final answer" {
t . Errorf ( "last = %q, want %q" , got , "final answer" )
}
var continues int
for _ , m := range msgs {
if m . Role == "user" && m . Content != nil && strings . TrimSpace ( * m . Content ) == "continue" {
continues ++
}
}
if continues != 1 {
t . Errorf ( "expected exactly 1 auto continue message, got %d" , continues )
}
}
2026-09-01 09:17:47 +03:00
func TestWriteFile ( t * testing . T ) {
tmp := t . TempDir ()
target := filepath . Join ( tmp , "sub" , "dir" , "test.txt" )
// 1. Create file and write initial content
res , err := writeFile ( target , 0 , 0 , "Hello World" )
if err != nil {
t . Fatalf ( "writeFile create: %v" , err )
}
if ! strings . Contains ( res , "Successfully wrote 11 bytes" ) {
t . Errorf ( "unexpected res: %q" , res )
}
data , err := os . ReadFile ( target )
if err != nil || string ( data ) != "Hello World" {
t . Fatalf ( "read = %q, want Hello World" , string ( data ))
}
// 2. Overwrite / replace "World" with "Bantam" (offset 6, del_bytes 5)
res , err = writeFile ( target , 6 , 5 , "Bantam" )
if err != nil {
t . Fatalf ( "writeFile replace: %v" , err )
}
data , _ = os . ReadFile ( target )
if string ( data ) != "Hello Bantam" {
t . Fatalf ( "read = %q, want Hello Bantam" , string ( data ))
}
// 3. Insert without deletion (offset 5, del_bytes 0, content " dear")
res , err = writeFile ( target , 5 , 0 , " dear" )
if err != nil {
t . Fatalf ( "writeFile insert: %v" , err )
}
data , _ = os . ReadFile ( target )
if string ( data ) != "Hello dear Bantam" {
t . Fatalf ( "read = %q, want Hello dear Bantam" , string ( data ))
}
// 4. Pure deletion (offset 5, del_bytes 5, content "")
res , err = writeFile ( target , 5 , 5 , "" )
if err != nil {
t . Fatalf ( "writeFile delete: %v" , err )
}
data , _ = os . ReadFile ( target )
if string ( data ) != "Hello Bantam" {
t . Fatalf ( "read = %q, want Hello Bantam" , string ( data ))
}
2026-09-01 09:46:13 +03:00
// 5. Append directly to EOF (offset = len(data), del_bytes 0)
res , err = writeFile ( target , len ( data ), 0 , " rocks" )
if err != nil {
t . Fatalf ( "writeFile append: %v" , err )
}
data , _ = os . ReadFile ( target )
if string ( data ) != "Hello Bantam rocks" {
t . Fatalf ( "read = %q, want Hello Bantam rocks" , string ( data ))
}
// 6. Offset beyond file length -> padded with null bytes
res , err = writeFile ( target , 25 , 0 , "end" )
2026-09-01 09:17:47 +03:00
if err != nil {
t . Fatalf ( "writeFile beyond len: %v" , err )
}
data , _ = os . ReadFile ( target )
2026-09-01 09:46:13 +03:00
if len ( data ) != 28 || ! strings . HasSuffix ( string ( data ), "end" ) {
t . Fatalf ( "read length = %d, want 28" , len ( data ))
2026-09-01 09:17:47 +03:00
}
2026-09-01 09:46:13 +03:00
// 7. Error on empty path
2026-09-01 09:17:47 +03:00
_ , err = writeFile ( "" , 0 , 0 , "abc" )
if err == nil {
t . Fatalf ( "expected error for empty path" )
}
}
func TestALWriteFile ( t * testing . T ) {
tmp := t . TempDir ()
target := filepath . Join ( tmp , "out.txt" )
2026-08-11 14:33:45 +03:00
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
var req struct {
Messages [] Message `json:"messages"`
}
json . NewDecoder ( r . Body ). Decode ( & req )
2026-09-01 09:17:47 +03:00
for _ , m := range req . Messages {
if m . Role == "tool" {
w . Write ([] byte ( `{"choices":[{"message":{"role":"assistant","content":"file is ready"}}]}` ))
return
2026-08-11 14:33:45 +03:00
}
}
2026-09-01 09:17:47 +03:00
args , _ := json . Marshal ( map [ string ] any {
"path" : target ,
"offset" : 0 ,
"del_bytes" : 0 ,
"content" : "sample file content" ,
})
w . Write ([] byte ( fmt . Sprintf ( `{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"write_file","arguments":%s}}]}}]}` , strconv . Quote ( string ( args )))))
2026-08-11 14:33:45 +03:00
}))
defer srv . Close ()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Stream = false
cfg . APIKey = "-"
2026-09-01 09:17:47 +03:00
msgs , _ , err := AL ( context . Background (), & cfg , [] Message {{ Role : "system" , Content : strp ( "sys" )}, { Role : "user" , Content : strp ( "write a file" )}})
2026-08-11 14:33:45 +03:00
if err != nil {
t . Fatalf ( "AL: %v" , err )
}
2026-09-01 09:17:47 +03:00
if got := last ( msgs ); got != "file is ready" {
t . Errorf ( "last = %q, want 'file is ready'" , got )
2026-08-11 14:33:45 +03:00
}
2026-09-01 09:17:47 +03:00
content , err := os . ReadFile ( target )
if err != nil || string ( content ) != "sample file content" {
t . Errorf ( "file content = %q, want 'sample file content'" , string ( content ))
2026-08-11 14:33:45 +03:00
}
}
func TestALStripsInvalidAssistantAndRetries ( t * testing . T ) {
var n int
var mu sync . Mutex
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
mu . Lock ()
n ++
cur := n
mu . Unlock ()
switch cur {
case 1 :
w . Write ([] byte ( `{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"shell_exec","arguments":"{\"command\":\"echo x\"}"}}]}}]}` ))
case 2 :
w . WriteHeader ( 400 )
w . Write ([] byte ( `{"error":"Invalid assistant message: content or tool_calls must be set"}` ))
case 3 :
w . Write ([] byte ( `{"choices":[{"message":{"role":"assistant","content":"recovered"}}]}` ))
default :
t . Errorf ( "unexpected request #%d" , cur )
}
}))
defer srv . Close ()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Stream = false
cfg . APIKey = "-"
2026-09-01 09:17:47 +03:00
msgs , _ , err := AL ( context . Background (), & cfg , [] Message {{ Role : "system" , Content : strp ( "sys" )}, { Role : "user" , Content : strp ( "go" )}})
2026-08-11 14:33:45 +03:00
if err != nil {
t . Fatalf ( "AL: %v" , err )
}
if got := last ( msgs ); got != "recovered" {
t . Errorf ( "last = %q, want recovered" , got )
}
for _ , m := range msgs {
if len ( m . ToolCalls ) > 0 {
t . Errorf ( "expected malformed assistant tool-call message to be stripped, found %+v" , m )
}
}
}
func TestSummarizeHappyPath ( t * testing . T ) {
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
w . Write ([] byte ( `{"choices":[{"message":{"role":"assistant","content":"the summary"}}]}` ))
}))
defer srv . Close ()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Stream = false
cfg . APIKey = "-"
2026-08-16 08:12:49 +03:00
orig := [] Message {{ Role : "system" , Content : strp ( "sys" )}, { Role : "user" , Content : strp ( "hello world" )}}
s , err := summarize ( context . Background (), & cfg , orig )
2026-08-11 14:33:45 +03:00
if err != nil {
t . Fatalf ( "summarize: %v" , err )
}
if s != "the summary" {
t . Errorf ( "summary = %q" , s )
}
}
func TestCompactHappyPath ( t * testing . T ) {
2026-08-16 08:12:49 +03:00
var receivedMessages [] Message
2026-08-11 14:33:45 +03:00
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
2026-08-16 08:12:49 +03:00
var req struct {
Messages [] Message `json:"messages"`
}
json . NewDecoder ( r . Body ). Decode ( & req )
receivedMessages = req . Messages
2026-08-11 14:33:45 +03:00
w . Write ([] byte ( `{"choices":[{"message":{"role":"assistant","content":"the summary"}}]}` ))
}))
defer srv . Close ()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Stream = false
cfg . APIKey = "-"
orig := [] Message {{ Role : "system" , Content : strp ( "sys" )}, { Role : "user" , Content : strp ( "hello world" )}}
2026-08-15 17:09:26 +03:00
msgs , sm , err := compact ( context . Background (), & cfg , orig )
2026-08-11 14:33:45 +03:00
if err != nil {
t . Fatalf ( "compact: %v" , err )
}
if sm != "the summary" {
t . Errorf ( "summary = %q" , sm )
}
if len ( msgs ) != 2 || msgs [ 0 ]. Role != "system" || * msgs [ 0 ]. Content != "sys" {
t . Errorf ( "compacted messages = %+v" , msgs )
}
if msgs [ 1 ]. Role != "user" || msgs [ 1 ]. Content == nil || ! strings . Contains ( * msgs [ 1 ]. Content , "the summary" ) {
t . Errorf ( "continuation message = %+v" , msgs [ 1 ])
}
2026-08-16 08:12:49 +03:00
// Verify request sent to LLM contains the original conversation prefix plus compaction prompt
if len ( receivedMessages ) != 3 {
t . Fatalf ( "expected 3 messages sent to LLM, got %d" , len ( receivedMessages ))
}
if receivedMessages [ 0 ]. Role != "system" || * receivedMessages [ 0 ]. Content != "sys" {
t . Errorf ( "message 0 mismatch: %+v" , receivedMessages [ 0 ])
}
if receivedMessages [ 1 ]. Role != "user" || * receivedMessages [ 1 ]. Content != "hello world" {
t . Errorf ( "message 1 mismatch: %+v" , receivedMessages [ 1 ])
}
if receivedMessages [ 2 ]. Role != "user" || ! strings . Contains ( * receivedMessages [ 2 ]. Content , "compaction engine" ) {
t . Errorf ( "message 2 mismatch (expected compaction prompt): %+v" , receivedMessages [ 2 ])
}
2026-08-11 14:33:45 +03:00
}
2026-08-15 08:27:24 +03:00
// ---------- setCfg and LLM parameter forwarding ----------
func TestSetCfgUpdatesAndAppends ( t * testing . T ) {
2026-09-10 12:20:41 +03:00
clearBantamEnv ( t )
2026-08-15 08:27:24 +03:00
p := filepath . Join ( t . TempDir (), "model.cfg" )
if err := os . WriteFile ( p , [] byte ( "model=old-model\ntemperature=0.5\n" ), 0644 ); err != nil {
t . Fatalf ( "WriteFile: %v" , err )
}
if err := setCfg ( p , "model" , "new-model" ); err != nil {
t . Fatalf ( "setCfg update: %v" , err )
}
if err := setCfg ( p , "reasoning_effort" , "high" ); err != nil {
t . Fatalf ( "setCfg append: %v" , err )
}
cfg := getCfg ( p )
if cfg . Model != "new-model" {
t . Errorf ( "Model = %q, want new-model" , cfg . Model )
}
if cfg . Raw [ "reasoning_effort" ] != "high" {
t . Errorf ( "Raw[reasoning_effort] = %q, want high" , cfg . Raw [ "reasoning_effort" ])
}
if cfg . Temperature != 0.5 {
t . Errorf ( "Temperature = %v, want 0.5" , cfg . Temperature )
}
}
func TestLLMForwardsRelevantParameters ( t * testing . T ) {
2026-09-10 12:20:41 +03:00
clearBantamEnv ( t )
2026-08-15 08:27:24 +03:00
var received map [ string ] any
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
json . NewDecoder ( r . Body ). Decode ( & received )
w . Write ([] byte ( `{"choices":[{"message":{"role":"assistant","content":"ok"}}]}` ))
}))
defer srv . Close ()
cfgFile := writeCfg ( t , strings . Join ([] string {
"endpoint=" + srv . URL ,
"model=custom-llm" ,
"temperature=0.3" ,
"stream=false" ,
"reasoning_effort=medium" ,
"top_p=0.95" ,
"max_tokens=4096" ,
"color=always" ,
"timeout=100" ,
}, "\n" ))
cfg := getCfg ( cfgFile )
2026-08-16 08:12:49 +03:00
_ , _ , err := llm ( context . Background (), & cfg , [] Message {{ Role : "user" , Content : strp ( "hi" )}}, nil )
2026-08-15 08:27:24 +03:00
if err != nil {
t . Fatalf ( "llm: %v" , err )
}
if received [ "model" ] != "custom-llm" {
t . Errorf ( "model = %v, want custom-llm" , received [ "model" ])
}
if received [ "temperature" ] != 0.3 {
t . Errorf ( "temperature = %v, want 0.3" , received [ "temperature" ])
}
if received [ "stream" ] != false {
t . Errorf ( "stream = %v, want false" , received [ "stream" ])
}
if received [ "reasoning_effort" ] != "medium" {
t . Errorf ( "reasoning_effort = %v, want medium" , received [ "reasoning_effort" ])
}
if received [ "top_p" ] != 0.95 {
t . Errorf ( "top_p = %v, want 0.95" , received [ "top_p" ])
}
if received [ "max_tokens" ] != float64 ( 4096 ) {
t . Errorf ( "max_tokens = %v, want 4096" , received [ "max_tokens" ])
}
if _ , exists := received [ "color" ]; exists {
t . Errorf ( "color should not be forwarded to OpenAI endpoint" )
}
if _ , exists := received [ "timeout" ]; exists {
t . Errorf ( "timeout should not be forwarded to OpenAI endpoint" )
}
if _ , exists := received [ "endpoint" ]; exists {
t . Errorf ( "endpoint should not be forwarded to OpenAI endpoint" )
}
}
2026-08-15 08:50:39 +03:00
// ---------- Markdown rendering tests ----------
func TestRenderInline ( t * testing . T ) {
COL = true
defer func () { COL = false }()
// Code
out := renderInline ( "Use `go test -v` command" )
if ! strings . Contains ( out , "\033[33mgo test -v\033[0m" ) {
t . Errorf ( "renderInline code = %q" , out )
}
// Bold
out = renderInline ( "This is **bold** text" )
if ! strings . Contains ( out , "\033[1mbold\033[0m" ) {
t . Errorf ( "renderInline bold = %q" , out )
}
// Italic
out = renderInline ( "This is *italic* text" )
if ! strings . Contains ( out , "\033[3mitalic\033[0m" ) {
t . Errorf ( "renderInline italic = %q" , out )
}
// Bold + Italic
out = renderInline ( "This is ***important*** text" )
if ! strings . Contains ( out , "\033[1;3mimportant\033[0m" ) {
t . Errorf ( "renderInline bold+italic = %q" , out )
}
// Strikethrough
out = renderInline ( "This is ~~deleted~~ text" )
if ! strings . Contains ( out , "\033[9mdeleted\033[0m" ) {
t . Errorf ( "renderInline strikethrough = %q" , out )
}
// Link
out = renderInline ( "Visit [Go](https://go.dev) site" )
if ! strings . Contains ( out , "\033[4;36mGo\033[0m" ) || ! strings . Contains ( out , "https://go.dev" ) {
t . Errorf ( "renderInline link = %q" , out )
}
// Code shielding (asterisks inside code should not become italic)
out = renderInline ( "Run `foo * bar` now" )
if ! strings . Contains ( out , "\033[33mfoo * bar\033[0m" ) {
t . Errorf ( "renderInline code shield = %q" , out )
}
}
func TestRenderMDBlocks ( t * testing . T ) {
COL = true
defer func () { COL = false }()
// Headings
h1 := renderMD ( "# Title One" )
if ! strings . Contains ( h1 , "Title One" ) || ! strings . Contains ( h1 , "\033[35m■ \033[0m" ) {
t . Errorf ( "renderMD H1 = %q" , h1 )
}
h2 := renderMD ( "## Subtitle" )
if ! strings . Contains ( h2 , "Subtitle" ) || ! strings . Contains ( h2 , "\033[34m▲ \033[0m" ) {
t . Errorf ( "renderMD H2 = %q" , h2 )
}
h3 := renderMD ( "### Section" )
if ! strings . Contains ( h3 , "Section" ) || ! strings . Contains ( h3 , "\033[32m● \033[0m" ) {
t . Errorf ( "renderMD H3 = %q" , h3 )
}
// Code block
codeMD := "```go\nfunc main() {\n println(1)\n}\n```"
renderedCode := renderMD ( codeMD )
if ! strings . Contains ( renderedCode , "[ go ]" ) || ! strings . Contains ( renderedCode , "println(1)" ) {
t . Errorf ( "renderMD code block = %q" , renderedCode )
}
// Lists
ul := renderMD ( "- Item A\n- Item B" )
if ! strings . Contains ( ul , "• " ) || ! strings . Contains ( ul , "Item A" ) {
t . Errorf ( "renderMD unordered list = %q" , ul )
}
ol := renderMD ( "1. Step 1\n2. Step 2" )
if ! strings . Contains ( ol , "1. " ) || ! strings . Contains ( ol , "Step 1" ) {
t . Errorf ( "renderMD ordered list = %q" , ol )
}
tasks := renderMD ( "- [ ] Pending\n- [x] Finished" )
if ! strings . Contains ( tasks , "☐ " ) || ! strings . Contains ( tasks , "☑ " ) {
t . Errorf ( "renderMD task list = %q" , tasks )
}
// Blockquote
bq := renderMD ( "> Important quote" )
if ! strings . Contains ( bq , "▎ " ) || ! strings . Contains ( bq , "Important quote" ) {
t . Errorf ( "renderMD blockquote = %q" , bq )
}
// Horizontal rule
hr := renderMD ( "---" )
if ! strings . Contains ( hr , "────" ) {
t . Errorf ( "renderMD hr = %q" , hr )
}
// Table
tbl := renderMD ( "| Col A | Col B |\n|---|---|\n| Val 1 | Val 2 |" )
if ! strings . Contains ( tbl , "Col A" ) || ! strings . Contains ( tbl , "Val 1" ) {
t . Errorf ( "renderMD table = %q" , tbl )
}
}
2026-08-15 08:55:41 +03:00
func TestRenderTableFormatting ( t * testing . T ) {
COL = true
defer func () { COL = false }()
raw := strings . Join ([] string {
"| Name | Role | Location |" ,
"| :--- | :---: | ---: |" ,
"| Alice | `Lead` | New York |" ,
"| Bob | Developer | London |" ,
}, "\n" )
rendered := renderMD ( raw )
lines := strings . Split ( rendered , "\n" )
if len ( lines ) != 6 {
t . Fatalf ( "expected 6 table lines (top, header, mid, row1, row2, bot), got %d:\n%s" , len ( lines ), rendered )
}
if ! strings . HasPrefix ( lines [ 0 ], "\033[2m┌" ) || ! strings . HasSuffix ( lines [ 0 ], "┐\033[0m" ) {
t . Errorf ( "top border = %q" , lines [ 0 ])
}
if ! strings . Contains ( lines [ 1 ], "Name" ) || ! strings . Contains ( lines [ 1 ], "Role" ) || ! strings . Contains ( lines [ 1 ], "Location" ) {
t . Errorf ( "header row = %q" , lines [ 1 ])
}
if ! strings . HasPrefix ( lines [ 2 ], "\033[2m├" ) || ! strings . HasSuffix ( lines [ 2 ], "┤\033[0m" ) {
t . Errorf ( "mid border = %q" , lines [ 2 ])
}
if ! strings . Contains ( lines [ 3 ], "Alice" ) || ! strings . Contains ( lines [ 3 ], "New York" ) {
t . Errorf ( "row 1 = %q" , lines [ 3 ])
}
if ! strings . Contains ( lines [ 4 ], "Bob" ) || ! strings . Contains ( lines [ 4 ], "London" ) {
t . Errorf ( "row 2 = %q" , lines [ 4 ])
}
if ! strings . HasPrefix ( lines [ 5 ], "\033[2m└" ) || ! strings . HasSuffix ( lines [ 5 ], "┘\033[0m" ) {
t . Errorf ( "bot border = %q" , lines [ 5 ])
}
}
2026-08-15 09:00:48 +03:00
func TestWrapCell ( t * testing . T ) {
COL = true
defer func () { COL = false }()
lines := wrapCell ( "Short text" , 20 )
if len ( lines ) != 1 || lines [ 0 ] != "Short text" {
t . Errorf ( "wrapCell short = %v" , lines )
}
lines = wrapCell ( "The quick brown fox jumps over the lazy dog" , 15 )
if len ( lines ) < 3 {
t . Errorf ( "wrapCell long = %v" , lines )
}
for _ , l := range lines {
if visibleLen ( l ) > 15 {
t . Errorf ( "line %q visible length = %d > 15" , l , visibleLen ( l ))
}
}
// With ANSI escape codes
styled := "\033[1;36mAlice In Wonderland\033[0m"
lines = wrapCell ( styled , 10 )
if len ( lines ) != 2 {
t . Errorf ( "wrapCell styled count = %d, lines = %v" , len ( lines ), lines )
}
for _ , l := range lines {
if visibleLen ( l ) > 10 {
t . Errorf ( "styled line %q length = %d > 10" , l , visibleLen ( l ))
}
}
}
func TestRenderTableWidthConstraint ( t * testing . T ) {
COL = true
defer func () { COL = false }()
// Create an extra-wide table with long text
raw := strings . Join ([] string {
"| Long Column Header One | Extremely Long Column Header Two That Would Definitely Overflow | Another Very Wide Column Header Three |" ,
"|---|---|---|" ,
"| Some detailed explanation that is very long and has lots of words in it | Another paragraph of text that continues on and on without stopping | Final column with even more long descriptions |" ,
}, "\n" )
rendered := renderMD ( raw )
lines := strings . Split ( rendered , "\n" )
maxW := termWidth ()
if maxW < 20 { maxW = 80 }
for i , ln := range lines {
vl := visibleLen ( ln )
if vl > maxW {
t . Errorf ( "table line %d visible length = %d, exceeds max width %d:\n%s" , i , vl , maxW , ln )
}
}
// Ensure content was wrapped rather than dropped
if ! strings . Contains ( rendered , "detailed" ) || ! strings . Contains ( rendered , "explanation" ) || ! strings . Contains ( rendered , "paragraph" ) {
t . Errorf ( "rendered table should contain all words wrapped across lines:\n%s" , rendered )
}
}
2026-08-15 08:50:39 +03:00
func TestRenderMDNoColorFallback ( t * testing . T ) {
COL = false
raw := "# Heading\n**bold** and `code`\n- list item"
out := renderMD ( raw )
if out != raw {
t . Errorf ( "renderMD with COL=false should return raw text, got %q" , out )
}
}
2026-08-15 16:58:25 +03:00
func TestDirectShellExecution ( t * testing . T ) {
cmd := "echo direct_exec_test"
2026-08-15 17:09:26 +03:00
res := shell ( context . Background (), cmd , 10 )
2026-08-15 16:58:25 +03:00
if ! strings . HasPrefix ( res , "direct_exec_test" ) || ! strings . Contains ( res , "exit: 0" ) {
t . Errorf ( "direct shell exec failed, got %q" , res )
}
// Verify that command stripping preserves arguments
u := "! echo hello world"
stripped := strings . TrimSpace ( strings . TrimPrefix ( u , "!" ))
if stripped != "echo hello world" {
t . Errorf ( "stripped command = %q, want %q" , stripped , "echo hello world" )
}
}
2026-08-15 17:09:26 +03:00
func TestALContextCancellation ( t * testing . T ) {
done := make ( chan struct {})
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
select {
case <- r . Context (). Done ():
case <- done :
}
}))
defer func () {
close ( done )
srv . CloseClientConnections ()
srv . Close ()
}()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Stream = false
cfg . APIKey = "-"
ctx , cancel := context . WithCancel ( context . Background ())
go func () {
time . Sleep ( 30 * time . Millisecond )
cancel ()
}()
origMsgs := [] Message {{ Role : "system" , Content : strp ( "sys" )}, { Role : "user" , Content : strp ( "hello" )}}
inputMsgs := append ([] Message {}, origMsgs ... )
2026-09-01 09:17:47 +03:00
msgs , _ , err := AL ( ctx , & cfg , inputMsgs )
2026-08-15 17:09:26 +03:00
if err == nil {
t . Fatalf ( "expected context cancellation error, got nil" )
}
if ! errors . Is ( err , context . Canceled ) && ctx . Err () == nil {
t . Errorf ( "expected context.Canceled, got %v" , err )
}
// Verify that input messages slice was not mutated with partial assistant messages
if len ( msgs ) != len ( origMsgs ) {
t . Errorf ( "expected %d messages after cancellation, got %d: %+v" , len ( origMsgs ), len ( msgs ), msgs )
}
}
func TestLLMContextCancellation ( t * testing . T ) {
done := make ( chan struct {})
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
select {
case <- r . Context (). Done ():
case <- done :
}
}))
defer func () {
close ( done )
srv . CloseClientConnections ()
srv . Close ()
}()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Stream = false
cfg . APIKey = "-"
ctx , cancel := context . WithCancel ( context . Background ())
go func () {
time . Sleep ( 30 * time . Millisecond )
cancel ()
}()
2026-08-16 08:12:49 +03:00
_ , _ , err := llm ( ctx , & cfg , [] Message {{ Role : "user" , Content : strp ( "hi" )}}, nil )
2026-08-15 17:09:26 +03:00
if err == nil {
t . Fatalf ( "expected context cancellation error, got nil" )
}
if ! errors . Is ( err , context . Canceled ) && ctx . Err () == nil {
t . Errorf ( "expected context.Canceled, got %v" , err )
}
}
2026-08-16 08:12:49 +03:00
func TestTokenCounterAndUsageNonStreaming ( t * testing . T ) {
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
w . Write ([] byte ( `{
"choices":[{"message":{"role":"assistant","content":"hello world"}}],
"usage":{
"prompt_tokens": 120,
"completion_tokens": 30,
"total_tokens": 150,
"prompt_tokens_details": {"cached_tokens": 80}
}
}` ))
}))
defer srv . Close ()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Stream = false
cfg . APIKey = "-"
m , u , err := llm ( context . Background (), & cfg , [] Message {{ Role : "user" , Content : strp ( "hi" )}}, nil )
if err != nil {
t . Fatalf ( "llm: %v" , err )
}
if m . Content == nil || * m . Content != "hello world" {
t . Errorf ( "unexpected content: %+v" , m . Content )
}
if u . PromptTokens != 120 || u . CompletionTokens != 30 || u . TotalTokens != 150 {
t . Errorf ( "unexpected usage: %+v" , u )
}
if u . Cached () != 80 {
t . Errorf ( "expected cached tokens 80, got %d" , u . Cached ())
}
}
func TestTokenCounterAndUsageStreaming ( t * testing . T ) {
sseData := strings . Join ([] string {
`data: {"choices":[{"delta":{"content":"streaming "}}]}` ,
`data: {"choices":[{"delta":{"content":"response"}}]}` ,
`data: {"choices":[],"usage":{"prompt_tokens":250,"completion_tokens":45,"total_tokens":295,"cached_tokens":100}}` ,
`data: [DONE]` ,
}, "\n" )
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
w . Header (). Set ( "Content-Type" , "text/event-stream" )
w . Write ([] byte ( sseData ))
}))
defer srv . Close ()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Stream = true
cfg . APIKey = "-"
m , u , err := llm ( context . Background (), & cfg , [] Message {{ Role : "user" , Content : strp ( "hi" )}}, nil )
if err != nil {
t . Fatalf ( "llm streaming: %v" , err )
}
if m . Content == nil || * m . Content != "streaming response" {
t . Errorf ( "unexpected content: %+v" , m . Content )
}
if u . PromptTokens != 250 || u . CompletionTokens != 45 || u . TotalTokens != 295 {
t . Errorf ( "unexpected usage: %+v" , u )
}
if u . Cached () != 100 {
t . Errorf ( "expected cached tokens 100, got %d" , u . Cached ())
}
}
func TestFormatUsage ( t * testing . T ) {
// With cached tokens
u1 := Usage { PromptTokens : 1000 , CompletionTokens : 200 , TotalTokens : 1200 , CachedTokens : 800 }
s1 := formatUsage ( u1 , 200000 )
2026-09-06 10:57:09 +03:00
if s1 != "[1000 prompt (800 cached, 200 uncached) + 200 completion | context: 1000/200000 (0.5%)]" {
2026-08-16 08:12:49 +03:00
t . Errorf ( "formatUsage u1 = %q" , s1 )
}
// Without cached tokens
u2 := Usage { PromptTokens : 120000 , CompletionTokens : 500 , TotalTokens : 120500 }
s2 := formatUsage ( u2 , 200000 )
2026-09-06 10:57:09 +03:00
if s2 != "[120000 prompt + 500 completion | context: 120000/200000 (60.0%)]" {
2026-08-16 08:12:49 +03:00
t . Errorf ( "formatUsage u2 = %q" , s2 )
}
}
2026-08-28 09:30:48 +03:00
func TestListModels ( t * testing . T ) {
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
if r . URL . Path != "/models" {
http . NotFound ( w , r )
return
}
if r . Header . Get ( "Authorization" ) != "Bearer secret" {
w . WriteHeader ( 401 )
return
}
w . Write ([] byte ( `{
"data": [
{"id": "alpha"},
{"id": "my-target-model"},
{"id": "beta"}
]
}` ))
}))
defer srv . Close ()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Model = "my-target-model"
cfg . APIKey = "secret"
out , err := listModels ( & cfg )
if err != nil {
t . Fatalf ( "unexpected error: %v" , err )
}
lines := strings . Split ( strings . TrimRight ( out , "\n" ), "\n" )
var got [] string
for _ , l := range lines {
if l == "" { continue }
got = append ( got , l )
}
want := [] string { " alpha" , "* my-target-model" , " beta" }
if len ( got ) != len ( want ) {
t . Fatalf ( "expected %d lines, got %d: %q" , len ( want ), len ( got ), out )
}
for i := range want {
if got [ i ] != want [ i ] {
t . Errorf ( "line %d: expected %q, got %q" , i , want [ i ], got [ i ])
}
}
}
func TestListModelsHTTPError ( t * testing . T ) {
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
w . WriteHeader ( 500 )
}))
defer srv . Close ()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . APIKey = "-"
if _ , err := listModels ( & cfg ); err == nil {
t . Fatalf ( "expected error on HTTP 500, got nil" )
}
}
2026-08-16 08:12:49 +03:00
func TestFetchContextWindowFromModelsAPI ( t * testing . T ) {
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
if r . URL . Path != "/models" {
http . NotFound ( w , r )
return
}
w . Write ([] byte ( `{
"data": [
{"id": "other-model", "context_window": 32000},
{"id": "my-target-model", "max_context_length": 131072}
]
}` ))
}))
defer srv . Close ()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Model = "my-target-model"
cfg . APIKey = "-"
cw := fetchContextWindow ( & cfg )
if cw != 131072 {
t . Errorf ( "expected context window 131072 from /models API, got %d" , cw )
}
}
func TestFetchContextWindowFallbackConfig ( t * testing . T ) {
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
http . Error ( w , "server error" , 500 )
}))
defer srv . Close ()
// Case 1: Config specifies context_window
cfg1 := defCfg
cfg1 . Endpoint = srv . URL
cfg1 . Raw = map [ string ] string { "context_window" : "65536" }
if cw := fetchContextWindow ( & cfg1 ); cw != 65536 {
t . Errorf ( "expected fallback to raw context_window 65536, got %d" , cw )
}
2026-08-22 20:40:24 +03:00
// Case 2: Config does not specify context_window -> default 262144
2026-08-16 08:12:49 +03:00
cfg2 := defCfg
cfg2 . Endpoint = srv . URL
cfg2 . Raw = map [ string ] string {}
2026-08-22 20:40:24 +03:00
if cw := fetchContextWindow ( & cfg2 ); cw != 262144 {
t . Errorf ( "expected default 262144, got %d" , cw )
2026-08-16 08:12:49 +03:00
}
}
2026-08-18 09:24:42 +03:00
func TestFilterText ( t * testing . T ) {
cases := [] struct {
name string
input string
expected string
}{
{
name : "ASCII printable, spaces, tabs, newlines" ,
input : "Hello World!\t123\nLine 2 ~`@#$%" ,
expected : "Hello World!\t123\nLine 2 ~`@#$%" ,
},
{
name : "CRLF normalization" ,
input : "line1\r\nline2\r\n" ,
expected : "line1\nline2\n" ,
},
{
name : "Unicode printable letters, numbers, punctuation" ,
input : "こんにちは世界! Привет мир! 123 αβγ €$¥" ,
expected : "こんにちは世界! Привет мир! 123 αβγ €$¥" ,
},
{
name : "Control characters stripped" ,
input : "null\x00bell\x07esc\x1b[31mred\x1b[0m\x7fdel" ,
expected : "nullbellesc[31mred[0mdel" ,
},
{
name : "Zero-width and format characters stripped" ,
input : "hidden\u200Binjection\u200Cand\u200Djoiner\uFEFFbom\u202Ebidi\U000E0001tag" ,
expected : "hiddeninjectionandjoinerbombiditag" ,
},
{
name : "Space-like unicode characters stripped" ,
input : "nbsp\u00A0space\u2000enquad\u2001emquad\u2009thin\u202Fnarrow\u3000ideo\u1680ogham\u2028lsep\u2029psep" ,
expected : "nbspspaceenquademquadthinnarrowideooghamlseppsep" ,
},
}
for _ , tc := range cases {
t . Run ( tc . name , func ( t * testing . T ) {
got := filterText ( tc . input )
if got != tc . expected {
t . Errorf ( "filterText(%q) = %q, expected %q" , tc . input , got , tc . expected )
}
})
}
}
func TestSanitizeMessagesWithInvisibles ( t * testing . T ) {
msgs := [] Message {
{
Role : "assistant" ,
ToolCalls : [] ToolCall {
{
ID : "call_1" ,
Type : "function" ,
Function : struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{
Name : "shell_exec" ,
Arguments : "{\"command\": \"cat\u200B \u00A0file.txt\"}" ,
},
},
},
},
{
Role : "tool" ,
ToolCallID : "call_1" ,
Content : strp ( "output\u200B\x00with\u00A0invisible\r\nexit: 0" ),
},
}
sanitizeMessages ( msgs )
tcArgs := msgs [ 0 ]. ToolCalls [ 0 ]. Function . Arguments
if strings . Contains ( tcArgs , "\u200B" ) || strings . Contains ( tcArgs , "\u00A0" ) {
t . Errorf ( "Tool call arguments still contain invisible characters: %q" , tcArgs )
}
toolContent := * msgs [ 1 ]. Content
if strings . Contains ( toolContent , "\u200B" ) || strings . Contains ( toolContent , "\x00" ) || strings . Contains ( toolContent , "\u00A0" ) || strings . Contains ( toolContent , "\r" ) {
t . Errorf ( "Tool content still contains invisible characters: %q" , toolContent )
}
if ! strings . Contains ( toolContent , "outputwithinvisible\nexit: 0" ) {
t . Errorf ( "Tool content unexpected: %q" , toolContent )
}
}
2026-08-15 16:58:25 +03:00
2026-08-15 08:50:39 +03:00
2026-08-15 08:55:41 +03:00
2026-08-18 10:17:52 +03:00
// ---------- new coverage from review ----------
2026-09-01 09:17:47 +03:00
// #14: tool call loop token usage must be accumulated into turn usage.
func TestALToolLoopAccumulatesUsage ( t * testing . T ) {
2026-08-18 10:17:52 +03:00
var n int
var mu sync . Mutex
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
mu . Lock ()
n ++
cur := n
mu . Unlock ()
switch cur {
case 1 :
2026-09-01 09:17:47 +03:00
w . Write ([] byte ( `{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"shell_exec","arguments":"{\"command\":\"echo 1\"}"}}]}}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,"prompt_tokens_details":{"cached_tokens":7}}}` ))
2026-08-18 10:17:52 +03:00
case 2 :
2026-09-01 09:17:47 +03:00
w . Write ([] byte ( `{"choices":[{"message":{"role":"assistant","content":"done"}}],"usage":{"prompt_tokens":30,"completion_tokens":4,"total_tokens":34,"prompt_tokens_details":{"cached_tokens":15}}}` ))
2026-08-18 10:17:52 +03:00
default :
t . Errorf ( "unexpected request #%d" , cur )
}
}))
defer srv . Close ()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Stream = false
cfg . APIKey = "-"
2026-09-01 09:17:47 +03:00
_ , usg , err := AL ( context . Background (), & cfg , [] Message {{ Role : "system" , Content : strp ( "sys" )}, { Role : "user" , Content : strp ( "parent task" )}})
2026-08-18 10:17:52 +03:00
if err != nil {
t . Fatalf ( "AL: %v" , err )
}
if usg . PromptTokens != 30 {
t . Errorf ( "PromptTokens = %d, want 30" , usg . PromptTokens )
}
2026-09-01 09:17:47 +03:00
if usg . CompletionTokens != 9 {
t . Errorf ( "CompletionTokens = %d, want 9" , usg . CompletionTokens )
2026-08-18 10:17:52 +03:00
}
2026-09-01 09:17:47 +03:00
if usg . TotalTokens != 49 {
t . Errorf ( "TotalTokens = %d, want 49" , usg . TotalTokens )
2026-08-18 10:17:52 +03:00
}
if usg . Cached () != 15 {
t . Errorf ( "CachedTokens = %d, want 15" , usg . Cached ())
}
}
// #7: invalid-assistant detection must match common provider error variants.
func TestIsInvalidAssistantErrVariants ( t * testing . T ) {
cases := [] struct {
in string
want bool
}{
{ "HTTP 400: {\"error\":\"Invalid assistant message: content or tool_calls must be set\"}" , true },
{ "invalid assistant message: content or tool_calls must be set" , true },
{ "content or tool_calls must be set" , true },
{ "Assistant message content must be set" , true },
{ "tool_calls must be set" , true },
{ "rate limit exceeded" , false },
{ "model overloaded" , false },
{ "" , false },
}
for _ , c := range cases {
if got := isInvalidAssistantErr ( errors . New ( c . in )); got != c . want {
t . Errorf ( "isInvalidAssistantErr(%q) = %v, want %v" , c . in , got , c . want )
}
}
if isInvalidAssistantErr ( nil ) {
t . Errorf ( "isInvalidAssistantErr(nil) = true, want false" )
}
}
// #10: internalKey must reject all agent-internal params and allow forwarding extras.
func TestInternalKey ( t * testing . T ) {
internal := [] string { "endpoint" , "model" , "temperature" , "stream" , "api_key" , "timeout" , "shell_timeout" , "max_al_iterations" , "color" , "context_window" }
for _ , k := range internal {
if ! internalKey ( k ) {
t . Errorf ( "internalKey(%q) = false, want true" , k )
}
}
extra := [] string { "reasoning_effort" , "top_p" , "max_tokens" , "stop" , "frequency_penalty" }
for _ , k := range extra {
if internalKey ( k ) {
t . Errorf ( "internalKey(%q) = true, want false" , k )
}
}
}
// #17: the retry loop must make exactly len(fib)+1 attempts on persistent 5xx
// (initial attempt + one retry per Fibonacci delay) and no extra attempt.
func TestLLMRetryAttemptCount ( t * testing . T ) {
var n int
var mu sync . Mutex
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
mu . Lock ()
n ++
mu . Unlock ()
w . WriteHeader ( 503 )
w . Write ([] byte ( `{"error":"unavailable"}` ))
}))
defer srv . Close ()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Stream = false
cfg . APIKey = "-"
_ , _ , err := llm ( context . Background (), & cfg , [] Message {{ Role : "user" , Content : strp ( "hi" )}}, nil )
if err == nil {
t . Fatalf ( "expected error from persistent 5xx" )
}
fib := [] int { 1 , 1 , 2 , 3 , 5 , 8 , 13 , 21 , 34 }
want := len ( fib ) + 1
if n != want {
t . Errorf ( "attempts = %d, want %d" , n , want )
}
}
// #9: filterText keeps only printable runs plus ASCII space/tab/newline.
func TestFilterTextPrintableOnly ( t * testing . T ) {
in := "ok\t\n" + "a" + "\x00" + "\u200B" + "\u00A0" + "\r" + "b"
got := filterText ( in )
if strings . ContainsAny ( got , "\x00\r" ) || strings . Contains ( got , "\u200B" ) || strings . Contains ( got , "\u00A0" ) {
t . Errorf ( "filterText left control/invisible chars: %q" , got )
}
if got != "ok\t\nab" {
t . Errorf ( "filterText = %q, want %q" , got , "ok\t\nab" )
}
}
2026-09-01 11:02:25 +03:00
func TestProxyFromEnv ( t * testing . T ) {
os . Unsetenv ( "SOCKS_PROXY" )
os . Unsetenv ( "socks_proxy" )
req , _ := http . NewRequest ( "GET" , "http://example.com" , nil )
_ , err := proxyFromEnv ( req )
if err != nil {
t . Fatalf ( "proxyFromEnv err: %v" , err )
}
os . Setenv ( "SOCKS_PROXY" , "127.0.0.1:1080" )
defer os . Unsetenv ( "SOCKS_PROXY" )
u , err := proxyFromEnv ( req )
if err != nil {
t . Fatalf ( "proxyFromEnv with 127.0.0.1:1080: %v" , err )
}
if u == nil || u . Scheme != "socks5" || u . Host != "127.0.0.1:1080" {
t . Fatalf ( "unexpected url: %v" , u )
}
os . Setenv ( "SOCKS_PROXY" , "socks5://localhost:9050" )
u , err = proxyFromEnv ( req )
if err != nil {
t . Fatalf ( "proxyFromEnv with socks5://: %v" , err )
}
if u == nil || u . Scheme != "socks5" || u . Host != "localhost:9050" {
t . Fatalf ( "unexpected url: %v" , u )
}
os . Unsetenv ( "SOCKS_PROXY" )
os . Setenv ( "socks_proxy" , "socks5h://user:pass@127.0.0.1:1080" )
defer os . Unsetenv ( "socks_proxy" )
u , err = proxyFromEnv ( req )
if err != nil {
t . Fatalf ( "proxyFromEnv with socks_proxy: %v" , err )
}
if u == nil || u . Scheme != "socks5h" || u . User . Username () != "user" {
t . Fatalf ( "unexpected url: %v" , u )
}
}
func TestSOCKS5ProxySupport ( t * testing . T ) {
l , err := net . Listen ( "tcp" , "127.0.0.1:0" )
if err != nil {
t . Fatalf ( "listen: %v" , err )
}
defer l . Close ()
handshakeDone := make ( chan bool , 1 )
go func () {
conn , err := l . Accept ()
if err != nil {
return
}
defer conn . Close ()
buf := make ([] byte , 256 )
n , err := conn . Read ( buf )
if err == nil && n >= 2 && buf [ 0 ] == 0x05 {
conn . Write ([] byte { 0x05 , 0x00 })
handshakeDone <- true
}
}()
os . Setenv ( "SOCKS_PROXY" , "socks5://" + l . Addr (). String ())
defer os . Unsetenv ( "SOCKS_PROXY" )
tr := & http . Transport {
Proxy : proxyFromEnv ,
DialContext : ( & net . Dialer { Timeout : 1 * time . Second }). DialContext ,
}
client := & http . Client { Transport : tr , Timeout : 1 * time . Second }
client . Get ( "http://example.com/test" )
select {
case <- handshakeDone :
case <- time . After ( 2 * time . Second ):
t . Fatalf ( "timed out waiting for SOCKS5 handshake through proxy" )
}
}
2026-09-08 21:25:29 +03:00
func TestCleanMessagesForLLMPreservesReasoningContent ( t * testing . T ) {
msgs := [] Message {
{ Role : "user" , Content : strp ( "hello" )},
{ Role : "assistant" , Content : strp ( "done" ), ReasoningContent : "thinking steps" },
{ Role : "tool" , ToolCallID : "tc1" , Content : strp ( "result" )},
}
cleaned := cleanMessagesForLLM ( msgs )
if len ( cleaned ) != 3 {
t . Fatalf ( "expected 3 messages, got %d" , len ( cleaned ))
}
if cleaned [ 1 ]. ReasoningContent != "thinking steps" {
t . Errorf ( "expected ReasoningContent %q, got %q" , "thinking steps" , cleaned [ 1 ]. ReasoningContent )
}
// Verify JSON serialization includes reasoning_content for assistant
b , err := json . Marshal ( cleaned [ 1 ])
if err != nil {
t . Fatalf ( "marshal error: %v" , err )
}
if ! strings . Contains ( string ( b ), `"reasoning_content":"thinking steps"` ) {
t . Errorf ( "expected JSON to contain reasoning_content, got %s" , string ( b ))
}
// Verify JSON serialization omits reasoning_content when empty
bUser , err := json . Marshal ( cleaned [ 0 ])
if err != nil {
t . Fatalf ( "marshal error: %v" , err )
}
if strings . Contains ( string ( bUser ), "reasoning_content" ) {
t . Errorf ( "expected JSON to omit reasoning_content for empty, got %s" , string ( bUser ))
}
}
func TestLLMPreservesReasoningContentInRequestBody ( t * testing . T ) {
var receivedBody [] byte
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
var err error
receivedBody , err = io . ReadAll ( r . Body )
if err != nil {
t . Errorf ( "read body err: %v" , err )
}
w . Header (). Set ( "Content-Type" , "application/json" )
w . Write ([] byte ( `{"choices":[{"message":{"role":"assistant","content":"reply"}}]}` ))
}))
defer srv . Close ()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Stream = false
cfg . APIKey = "-"
msgs := [] Message {
{ Role : "user" , Content : strp ( "call tool" )},
{
Role : "assistant" ,
Content : strp ( "" ),
ReasoningContent : "deep thoughts about tool" ,
ToolCalls : [] ToolCall {
{
ID : "call_1" ,
Type : "function" ,
Function : struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{ Name : "shell_exec" , Arguments : `{"command":"ls"}` },
},
},
},
{ Role : "tool" , ToolCallID : "call_1" , Content : strp ( "file.txt\n\nexit: 0" )},
}
_ , _ , err := llm ( context . Background (), & cfg , msgs , nil )
if err != nil {
t . Fatalf ( "unexpected llm error: %v" , err )
}
var reqPayload struct {
Messages [] map [ string ] any `json:"messages"`
}
if err := json . Unmarshal ( receivedBody , & reqPayload ); err != nil {
t . Fatalf ( "unmarshal request payload: %v" , err )
}
if len ( reqPayload . Messages ) != 3 {
t . Fatalf ( "expected 3 messages in request, got %d" , len ( reqPayload . Messages ))
}
asstMsg := reqPayload . Messages [ 1 ]
rc , ok := asstMsg [ "reasoning_content" ].( string )
if ! ok || rc != "deep thoughts about tool" {
t . Errorf ( "expected assistant message reasoning_content %q, got %v" , "deep thoughts about tool" , asstMsg [ "reasoning_content" ])
}
}
func TestSanitizeMessagesCleansReasoningContent ( t * testing . T ) {
msgs := [] Message {
{ Role : "assistant" , ReasoningContent : "clean\x00\u200B\u00A0reasoning" },
}
sanitizeMessages ( msgs )
if strings . ContainsAny ( msgs [ 0 ]. ReasoningContent , "\x00" ) || strings . Contains ( msgs [ 0 ]. ReasoningContent , "\u200B" ) {
t . Errorf ( "sanitizeMessages did not strip control/invisible characters from ReasoningContent: %q" , msgs [ 0 ]. ReasoningContent )
}
if msgs [ 0 ]. ReasoningContent != "cleanreasoning" {
t . Errorf ( "expected 'cleanreasoning', got %q" , msgs [ 0 ]. ReasoningContent )
}
}
2026-09-09 20:23:37 +03:00
func TestWriteFileOmittedOverwritesWholeFile ( t * testing . T ) {
2026-09-09 09:51:08 +03:00
tmp := t . TempDir ()
target := filepath . Join ( tmp , "test.txt" )
// Create file with initial content
if err := os . WriteFile ( target , [] byte ( "EXISTING" ), 0644 ); err != nil {
t . Fatalf ( "failed to write initial file: %v" , err )
}
2026-09-09 20:23:37 +03:00
// 1. Low-level writeFile with explicit offset 0 / del_bytes 0 still inserts at the start
2026-09-09 09:51:08 +03:00
res , err := writeFile ( target , 0 , 0 , "PREFIX_" )
if err != nil {
t . Fatalf ( "writeFile: %v" , err )
}
if ! strings . Contains ( res , "Successfully wrote 7 bytes" ) {
t . Errorf ( "unexpected res: %q" , res )
}
data , err := os . ReadFile ( target )
if err != nil {
t . Fatalf ( "readFile: %v" , err )
}
if string ( data ) != "PREFIX_EXISTING" {
t . Errorf ( "expected 'PREFIX_EXISTING', got %q" , string ( data ))
}
2026-09-09 20:23:37 +03:00
// 2. AL dispatch with offset and del_bytes omitted: overwrite the entire file
2026-09-09 09:51:08 +03:00
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
var req struct {
Messages [] Message `json:"messages"`
}
json . NewDecoder ( r . Body ). Decode ( & req )
for _ , m := range req . Messages {
if m . Role == "tool" {
w . Write ([] byte ( `{"choices":[{"message":{"role":"assistant","content":"done"}}]}` ))
return
}
}
// Tool call without "offset" parameter
args , _ := json . Marshal ( map [ string ] any {
"path" : target ,
"content" : "START_" ,
})
w . Write ([] byte ( fmt . Sprintf ( `{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"write_file","arguments":%s}}]}}]}` , strconv . Quote ( string ( args )))))
}))
defer srv . Close ()
cfg := defCfg
cfg . Endpoint = srv . URL
cfg . Stream = false
cfg . APIKey = "-"
_ , _ , err = AL ( context . Background (), & cfg , [] Message {{ Role : "user" , Content : strp ( "write" )}})
if err != nil {
t . Fatalf ( "AL: %v" , err )
}
data , err = os . ReadFile ( target )
if err != nil {
t . Fatalf ( "readFile: %v" , err )
}
2026-09-09 20:23:37 +03:00
if string ( data ) != "START_" {
t . Errorf ( "expected 'START_' (full overwrite), got %q" , string ( data ))
2026-09-09 09:51:08 +03:00
}
2026-09-09 20:23:37 +03:00
// 3. AL dispatch with explicit offset 0 / del_bytes 0: insertion semantics preserved
2026-09-09 09:51:08 +03:00
srv2 := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
var req struct {
Messages [] Message `json:"messages"`
}
json . NewDecoder ( r . Body ). Decode ( & req )
for _ , m := range req . Messages {
if m . Role == "tool" {
w . Write ([] byte ( `{"choices":[{"message":{"role":"assistant","content":"done"}}]}` ))
return
}
}
args , _ := json . Marshal ( map [ string ] any {
"path" : target ,
"offset" : 0 ,
"del_bytes" : 0 ,
"content" : "ZERO_" ,
})
w . Write ([] byte ( fmt . Sprintf ( `{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"write_file","arguments":%s}}]}}]}` , strconv . Quote ( string ( args )))))
}))
defer srv2 . Close ()
cfg . Endpoint = srv2 . URL
_ , _ , err = AL ( context . Background (), & cfg , [] Message {{ Role : "user" , Content : strp ( "write zero" )}})
if err != nil {
t . Fatalf ( "AL: %v" , err )
}
data , err = os . ReadFile ( target )
if err != nil {
t . Fatalf ( "readFile: %v" , err )
}
2026-09-09 20:23:37 +03:00
if string ( data ) != "ZERO_START_" {
t . Errorf ( "expected 'ZERO_START_' (insert at start), got %q" , string ( data ))
2026-09-09 09:51:08 +03:00
}
2026-09-09 20:23:37 +03:00
// 4. AL dispatch with explicit offset: null (treated as omitted): full overwrite
2026-09-09 09:51:08 +03:00
srv3 := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
var req struct {
Messages [] Message `json:"messages"`
}
json . NewDecoder ( r . Body ). Decode ( & req )
for _ , m := range req . Messages {
if m . Role == "tool" {
w . Write ([] byte ( `{"choices":[{"message":{"role":"assistant","content":"done"}}]}` ))
return
}
}
args , _ := json . Marshal ( map [ string ] any {
"path" : target ,
"offset" : nil ,
"content" : "NULL_" ,
})
w . Write ([] byte ( fmt . Sprintf ( `{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"write_file","arguments":%s}}]}}]}` , strconv . Quote ( string ( args )))))
}))
defer srv3 . Close ()
cfg . Endpoint = srv3 . URL
_ , _ , err = AL ( context . Background (), & cfg , [] Message {{ Role : "user" , Content : strp ( "write null" )}})
if err != nil {
t . Fatalf ( "AL: %v" , err )
}
data , err = os . ReadFile ( target )
if err != nil {
t . Fatalf ( "readFile: %v" , err )
}
2026-09-09 20:23:37 +03:00
if string ( data ) != "NULL_" {
t . Errorf ( "expected 'NULL_' (full overwrite), got %q" , string ( data ))
2026-09-09 09:51:08 +03:00
}
}
2026-09-10 10:38:36 +03:00
// #18: skillsDir mirrors toolsDir, and skillPrompt composes SKILL.md + prompt.
func TestSkills ( t * testing . T ) {
base := t . TempDir ()
skillDir := filepath . Join ( base , "skills" )
if err := os . MkdirAll ( filepath . Join ( skillDir , "greet" ), 0755 ); err != nil {
t . Fatal ( err )
}
skillMd := "You are a friendly greeter.\n"
if err := os . WriteFile ( filepath . Join ( skillDir , "greet" , "SKILL.md" ), [] byte ( skillMd ), 0644 ); err != nil {
t . Fatal ( err )
}
2026-09-10 11:49:54 +03:00
// skillsDir prefers the config key, then the environment variable.
2026-09-10 12:20:41 +03:00
t . Setenv ( "BANTAM_SKILLS_DIR" , "/env/skills" )
if got := skillsDir ( & Cfg { Raw : map [ string ] string { "bantam_skills_dir" : skillDir }}); got != skillDir {
t . Errorf ( "skillsDir() with config overriding env = %q, want %q" , got , skillDir )
}
if got := skillsDir ( & Cfg { Raw : map [ string ] string {}}); got != "/env/skills" {
t . Errorf ( "skillsDir() with env fallback = %q, want %q" , got , "/env/skills" )
2026-09-10 10:38:36 +03:00
}
t . Setenv ( "BANTAM_SKILLS_DIR" , "" )
if got := skillsDir ( & Cfg { Raw : map [ string ] string { "bantam_skills_dir" : skillDir }}); got != skillDir {
t . Errorf ( "skillsDir() with config = %q, want %q" , got , skillDir )
}
// With a skills dir set, the name is resolved relative to it.
got , err := skillPrompt ( "/skill greet hello there" , & Cfg { Raw : map [ string ] string { "bantam_skills_dir" : skillDir }})
if err != nil {
t . Fatalf ( "skillPrompt(relative): %v" , err )
}
want := skillMd + "\nhello there"
if got != want {
t . Errorf ( "skillPrompt(relative) = %q, want %q" , got , want )
}
// With no skills dir, the name is an absolute path to the skill directory.
got , err = skillPrompt ( "/skill " + filepath . Join ( skillDir , "greet" ) + " just hi" , & Cfg {})
if err != nil {
t . Fatalf ( "skillPrompt(absolute): %v" , err )
}
if got != skillMd + "\njust hi" {
t . Errorf ( "skillPrompt(absolute) = %q, want %q" , got , skillMd + "\njust hi" )
}
// Absolute path directly to a SKILL.md file also works.
got , err = skillPrompt ( "/skill " + filepath . Join ( skillDir , "greet" , "SKILL.md" ), & Cfg {})
if err != nil {
t . Fatalf ( "skillPrompt(file): %v" , err )
}
if got != strings . TrimRight ( skillMd , "\n" ) {
t . Errorf ( "skillPrompt(file) = %q, want %q" , got , strings . TrimRight ( skillMd , "\n" ))
}
// Missing skill reports an error; bare /skill reports usage.
if _ , err := skillPrompt ( "/skill nope" , & Cfg { Raw : map [ string ] string { "bantam_skills_dir" : skillDir }}); err == nil {
t . Error ( "skillPrompt(missing) expected error, got nil" )
}
if _ , err := skillPrompt ( "/skill" , & Cfg {}); err == nil {
t . Error ( "skillPrompt(bare) expected error, got nil" )
}
}
// #19: bare /skill lists skills under the configured dir, or reports none.
func TestListSkills ( t * testing . T ) {
2026-09-10 12:20:41 +03:00
t . Setenv ( "BANTAM_SKILLS_DIR" , "" )
2026-09-10 10:38:36 +03:00
base := t . TempDir ()
skillDir := filepath . Join ( base , "skills" )
if err := os . MkdirAll ( filepath . Join ( skillDir , "alpha" ), 0755 ); err != nil {
t . Fatal ( err )
}
if err := os . MkdirAll ( filepath . Join ( skillDir , "beta" ), 0755 ); err != nil {
t . Fatal ( err )
}
if err := os . WriteFile ( filepath . Join ( skillDir , "alpha" , "SKILL.md" ), [] byte ( "a" ), 0644 ); err != nil {
t . Fatal ( err )
}
if err := os . WriteFile ( filepath . Join ( skillDir , "beta" , "SKILL.md" ), [] byte ( "b" ), 0644 ); err != nil {
t . Fatal ( err )
}
// a directory without SKILL.md must NOT be reported as a skill
if err := os . MkdirAll ( filepath . Join ( skillDir , "notaskill" ), 0755 ); err != nil {
t . Fatal ( err )
}
capture := func () string {
r , w , err := os . Pipe ()
if err != nil {
t . Fatal ( err )
}
old := os . Stdout
os . Stdout = w
listSkills ( & Cfg { Raw : map [ string ] string { "bantam_skills_dir" : skillDir }})
w . Close ()
os . Stdout = old
data , _ := io . ReadAll ( r )
return string ( data )
}
out := capture ()
if ! strings . Contains ( out , "alpha" ) || ! strings . Contains ( out , "beta" ) {
t . Errorf ( "listSkills missing skills, got:\n%s" , out )
}
if strings . Contains ( out , "notaskill" ) {
t . Errorf ( "listSkills reported a non-skill directory, got:\n%s" , out )
}
// No skills dir configured -> clear notice, no panic.
os . Stdout , _ = os . Open ( os . DevNull )
listSkills ( & Cfg {})
os . Stdout . Close ()
}
2026-09-10 12:20:41 +03:00
func TestToolsDir ( t * testing . T ) {
t . Setenv ( "BANTAM_TOOLS_DIR" , "/env/tools" )
if got := toolsDir ( & Cfg { Raw : map [ string ] string { "bantam_tools_dir" : "/config/tools" }}); got != "/config/tools" {
t . Errorf ( "toolsDir() with config overriding env = %q, want /config/tools" , got )
}
if got := toolsDir ( & Cfg { Raw : map [ string ] string {}}); got != "/env/tools" {
t . Errorf ( "toolsDir() with env fallback = %q, want /env/tools" , got )
}
t . Setenv ( "BANTAM_TOOLS_DIR" , "" )
if got := toolsDir ( & Cfg { Raw : map [ string ] string { "bantam_tools_dir" : "/config/tools" }}); got != "/config/tools" {
t . Errorf ( "toolsDir() with config = %q, want /config/tools" , got )
}
if got := toolsDir ( & Cfg {}); got != "" {
t . Errorf ( "toolsDir() empty = %q, want empty" , got )
}
}