Files
bantam/main_test.go
T

1495 lines
46 KiB
Go
Raw Normal View History

2026-08-11 14:33:45 +03:00
package main
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"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")
msg, err := parseStream(bytes.NewBufferString(sseData))
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")
msg, err := parseStream(bytes.NewBufferString(sseData))
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")
msg, err := parseStream(bytes.NewBufferString(sseData))
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")
msg, err := parseStream(bytes.NewBufferString(sseData))
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"} {
msg, err := parseStream(strings.NewReader(in))
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")
msg, err := parseStream(bytes.NewBufferString(sseData))
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{
`data: {"choices":[{"delta":{"tool_calls":[{"index":1,"id":"c2","function":{"name":"run_subagent","arguments":"{\"prompt\":\"p\"}"}}]}}]}`,
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"shell_exec","arguments":"{\"command\":\"ls\"}"}}]}}]}`,
`data: [DONE]`,
}, "\n")
msg, err := parseStream(bytes.NewBufferString(sseData))
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")
msg, err := parseStream(bytes.NewBufferString(sseData))
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")
msg, err := parseStream(bytes.NewBufferString(sseData))
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 ----------
func TestDefaultSystemPromptFallback(t *testing.T) {
p := prompt("non_existent_file.txt")
if !strings.Contains(p, "You are Bantam, a tiny, powerful AI agent.") {
t.Errorf("expected prompt to contain base description, got: %q", p)
}
if !strings.Contains(p, "shell_exec") || !strings.Contains(p, "run_subagent") {
t.Errorf("expected prompt to list shell_exec and run_subagent tools, got: %q", p)
}
}
func TestPromptReadsAndTrimsFile(t *testing.T) {
p := filepath.Join(t.TempDir(), "sys.txt")
if err := os.WriteFile(p, []byte(" hello\nworld \n"), 0644); err != nil {
t.Fatalf("write: %v", err)
}
got := prompt(p)
if got != "hello\nworld" {
t.Errorf("expected trimmed content, got %q", got)
}
}
func TestGetCfgDefaults(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "")
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)
}
}
func TestGetCfgParsesFile(t *testing.T) {
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) {
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) {
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) {
t.Setenv("OPENAI_API_KEY", "sk-env")
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 "-"
t.Setenv("OPENAI_API_KEY", "")
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)
}
}
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) {
res := shell("echo hi", 10)
if !strings.Contains(res, "hi") || !strings.HasSuffix(res, "exit: 0") {
t.Errorf("shell(echo hi) = %q", res)
}
}
func TestShellExitCodeAndStderr(t *testing.T) {
res := shell("echo out; echo err >&2; exit 7", 10)
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) {
res := shell("definitely_not_a_command_xyz", 10)
if !strings.Contains(res, "exit: 127") {
t.Errorf("expected exit 127, got %q", res)
}
}
func TestShellTimeout(t *testing.T) {
res := shell("sleep 5", 1)
if !strings.Contains(res, "[shell timeout after 1s]") || !strings.HasSuffix(res, "exit: -1") {
t.Errorf("expected timeout marker and exit -1, got %q", res)
}
}
// ---------- 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 ----------
func TestSaveSessionAndLoad(t *testing.T) {
h := testHome(t)
msgs := []Message{
{Role: "system", Content: strp("sys")},
{Role: "user", Content: strp("hello")},
}
sid, sm := saveSession(msgs)
if sm != "hello" {
t.Errorf("summary = %q, want hello", sm)
}
if sid == "" {
t.Fatalf("empty session id")
}
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)
}
}
func TestSaveSessionCollisionSuffix(t *testing.T) {
testHome(t)
base := time.Now().Format("20060102-150405")
if err := os.WriteFile(filepath.Join(sdir(), base+".json"), []byte("{}"), 0644); err != nil {
t.Fatalf("write: %v", err)
}
sid, _ := saveSession([]Message{{Role: "user", Content: strp("x")}})
if sid != base+"-1" {
t.Errorf("expected collision suffix %q, got %q", base+"-1", sid)
}
}
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)
p := filepath.Join(h, ".bantam", "sessions", "autosave.json")
if !fileExists(p) {
t.Fatalf("autosave.json not written")
}
loaded, err := loadSession("autosave")
if err != nil {
t.Fatalf("loadSession(autosave): %v", err)
}
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) {
if _, err := summarize(&Cfg{}, nil); err == nil || !strings.Contains(err.Error(), "no conversation") {
t.Errorf("expected no-conversation error, got %v", err)
}
if _, err := summarize(&Cfg{}, []Message{{Role: "system", Content: strp("sys")}}); err == nil {
t.Errorf("expected error for system-only conversation")
}
}
func TestCompactNoSystem(t *testing.T) {
msgs, _, err := compact(&Cfg{}, nil)
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")
}
msgs2, _, err2 := compact(&Cfg{}, []Message{{Role: "user", Content: strp("x")}})
if err2 == nil {
t.Errorf("expected error when first message is not system")
}
if len(msgs2) != 1 {
t.Errorf("expected original messages returned, got %d", len(msgs2))
}
}
// ---------- 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"
m, err := llm(&cfg, []Message{{Role: "user", Content: strp("hi")}}, TOOLS)
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 = "-"
if _, err := llm(&cfg, []Message{{Role: "user", Content: strp("hi")}}, nil); err != nil {
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 = "-"
m, err := llm(&cfg, []Message{{Role: "user", Content: strp("hi")}}, nil)
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()
_, err := llm(&cfg, []Message{{Role: "user", Content: strp("hi")}}, nil)
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 = "-"
m, err := llm(&cfg, []Message{{Role: "user", Content: strp("hi")}}, nil)
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 = "-"
if _, err := llm(&cfg, []Message{{Role: "user", Content: strp("hi")}}, nil); err == nil || !strings.Contains(err.Error(), "empty choices") {
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 = "-"
msgs, err := AL(&cfg, []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("run")}}, "sys", 0)
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)
}
}
func TestALRunSubagent(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()
var req struct {
Messages []Message `json:"messages"`
}
json.NewDecoder(r.Body).Decode(&req)
switch cur {
case 1: // parent's first call -> delegate to subagent
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"run_subagent","arguments":"{\"prompt\":\"inner task\"}"}}]}}]}`))
case 2: // child's call -> verify it got the child system prompt, then finish
isChild := false
for _, m := range req.Messages {
if m.Role == "system" && m.Content != nil && strings.Contains(*m.Content, "Important: this is a child agent") {
isChild = true
}
}
if !isChild {
t.Errorf("request #2 did not carry the child system prompt")
}
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"child done"}}]}`))
case 3: // parent's second call -> finish
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"parent done"}}]}`))
default:
t.Errorf("unexpected request #%d", cur)
}
}))
defer srv.Close()
cfg := defCfg
cfg.Endpoint = srv.URL
cfg.Stream = false
cfg.APIKey = "-"
msgs, err := AL(&cfg, []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("parent task")}}, "sys", 0)
if err != nil {
t.Fatalf("AL: %v", err)
}
if got := last(msgs); got != "parent done" {
t.Errorf("last = %q, want parent done", got)
}
found := false
for _, m := range msgs {
if m.Role == "tool" && m.Content != nil && strings.Contains(*m.Content, "child done") {
found = true
}
}
if !found {
t.Errorf("expected subagent result 'child done' in tool messages")
}
}
func TestALSubagentDepthLimit(t *testing.T) {
2026-08-11 14:37:03 +03:00
// depth >= MAX_DEPTH must not spawn a child request; the LLM gets the
// depth-limit tool result and is expected to acknowledge and finish.
var n int
var mu sync.Mutex
2026-08-11 14:33:45 +03:00
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2026-08-11 14:37:03 +03:00
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":"run_subagent","arguments":"{\"prompt\":\"deep\"}"}}]}}]}`))
case 2:
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"done"}}]}`))
default:
t.Errorf("unexpected request #%d (child must not be spawned)", cur)
}
2026-08-11 14:33:45 +03:00
}))
defer srv.Close()
cfg := defCfg
cfg.Endpoint = srv.URL
cfg.Stream = false
cfg.APIKey = "-"
msgs, err := AL(&cfg, []Message{{Role: "user", Content: strp("go")}}, "sys", MAX_DEPTH)
if err != nil {
t.Fatalf("AL: %v", err)
}
2026-08-11 14:37:03 +03:00
if n != 2 {
t.Errorf("expected exactly 2 LLM requests, got %d", n)
}
if got := last(msgs); got != "done" {
t.Errorf("last = %q, want done", got)
}
2026-08-11 14:33:45 +03:00
found := false
for _, m := range msgs {
if m.Role == "tool" && m.Content != nil && strings.Contains(*m.Content, "depth limit") {
found = true
}
}
if !found {
t.Errorf("expected depth-limit tool result")
}
}
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 = "-"
msgs, err := AL(&cfg, []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("go")}}, "sys", 0)
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 = "-"
s, err := summarize(&cfg, []Message{{Role: "user", Content: strp("hello world")}})
if err != nil {
t.Fatalf("summarize: %v", err)
}
if s != "the summary" {
t.Errorf("summary = %q", s)
}
}
func TestCompactHappyPath(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 = "-"
orig := []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("hello world")}}
msgs, sm, err := compact(&cfg, orig)
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-15 08:27:24 +03:00
// ---------- setCfg and LLM parameter forwarding ----------
func TestSetCfgUpdatesAndAppends(t *testing.T) {
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) {
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)
_, err := llm(&cfg, []Message{{Role: "user", Content: strp("hi")}}, nil)
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)
}
}
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)
}
}