package main import ( "bufio" "bytes" "context" "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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), "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(context.Background(), "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(context.Background(), "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(context.Background(), "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) } } 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) } } // ---------- 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(context.Background(), &Cfg{}, nil); err == nil || !strings.Contains(err.Error(), "no system message") { t.Errorf("expected no-system-message error, got %v", err) } 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) } } func TestCompactNoSystem(t *testing.T) { msgs, _, err := compact(context.Background(), &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(context.Background(), &Cfg{}, []Message{{Role: "user", Content: strp("x")}}) if err2 == nil || !strings.Contains(err2.Error(), "no system message") { t.Errorf("expected error when first message is not system, got %v", err2) } if len(msgs2) != 1 { t.Errorf("expected original messages returned, got %d", len(msgs2)) } 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)) } } // ---------- 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(context.Background(), &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(context.Background(), &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(context.Background(), &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(context.Background(), &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(context.Background(), &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(context.Background(), &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(context.Background(), &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(context.Background(), &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) { // 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 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":"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) } })) defer srv.Close() cfg := defCfg cfg.Endpoint = srv.URL cfg.Stream = false cfg.APIKey = "-" msgs, _, err := AL(context.Background(), &cfg, []Message{{Role: "user", Content: strp("go")}}, "sys", MAX_DEPTH) if err != nil { t.Fatalf("AL: %v", err) } 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) } 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(context.Background(), &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 = "-" orig := []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("hello world")}} s, err := summarize(context.Background(), &cfg, orig) if err != nil { t.Fatalf("summarize: %v", err) } if s != "the summary" { t.Errorf("summary = %q", s) } } func TestCompactHappyPath(t *testing.T) { var receivedMessages []Message 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) receivedMessages = req.Messages 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(context.Background(), &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]) } // 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]) } } // ---------- 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(context.Background(), &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") } } // ---------- 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 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]) } } 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) } } 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) } } func TestDirectShellExecution(t *testing.T) { cmd := "echo direct_exec_test" res := shell(context.Background(), cmd, 10) 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") } } 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...) msgs, _, err := AL(ctx, &cfg, inputMsgs, "sys", 0) 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() }() _, _, err := llm(ctx, &cfg, []Message{{Role: "user", Content: strp("hi")}}, nil) 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) } } 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) if s1 != "[tokens: 1000 prompt (800 cached, 200 uncached) + 200 completion | context: 1000/200000 (0.5%)]" { t.Errorf("formatUsage u1 = %q", s1) } // Without cached tokens u2 := Usage{PromptTokens: 120000, CompletionTokens: 500, TotalTokens: 120500} s2 := formatUsage(u2, 200000) if s2 != "[tokens: 120000 prompt + 500 completion | context: 120000/200000 (60.0%)]" { t.Errorf("formatUsage u2 = %q", s2) } } 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) } // Case 2: Config does not specify context_window -> default 200000 cfg2 := defCfg cfg2.Endpoint = srv.URL cfg2.Raw = map[string]string{} if cw := fetchContextWindow(&cfg2); cw != 200000 { t.Errorf("expected default 200000, got %d", cw) } }