Refactor: AI provider and executor multi-tenancy support

- Updated AI providers and tests for context/tenant awareness
- Refactored tool executor for multi-tenant state handling
- Added new tests for Docker control and update tools
This commit is contained in:
rcourtman 2026-01-22 16:51:45 +00:00
parent f378a36a5e
commit defe298ddd
8 changed files with 590 additions and 0 deletions

View file

@ -393,3 +393,78 @@ func TestAnthropicClient_ModelsEndpoint_Invalid(t *testing.T) {
t.Errorf("Expected default models endpoint for invalid baseURL, got %s", endpoint)
}
}
func TestAnthropicClient_ChatStream_ToolUse(t *testing.T) {
stream := []string{
`{"type":"message_start","message":{"usage":{"input_tokens":5}}}`,
`{"type":"content_block_start","content_block":{"type":"text"}}`,
`{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hi "}}`,
`{"type":"content_block_stop"}`,
`{"type":"content_block_start","content_block":{"type":"tool_use","id":"tool_1","name":"get_time"}}`,
`{"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"{\"tz\":\"UTC\"}"}}`,
`{"type":"content_block_stop"}`,
`{"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":7}}`,
`{"type":"message_stop"}`,
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
for _, event := range stream {
_, _ = w.Write([]byte("data: " + event + "\n\n"))
w.(http.Flusher).Flush()
}
}))
defer server.Close()
client := NewAnthropicClientWithBaseURL("test-key", "claude-3-5-sonnet", server.URL, 0)
var content string
var done DoneEvent
var doneCalled bool
var toolStarts int
err := client.ChatStream(context.Background(), ChatRequest{
Messages: []Message{{Role: "user", Content: "Hi"}},
}, func(event StreamEvent) {
switch event.Type {
case "content":
if data, ok := event.Data.(ContentEvent); ok {
content += data.Text
}
case "tool_start":
toolStarts++
case "done":
if data, ok := event.Data.(DoneEvent); ok {
done = data
doneCalled = true
}
}
})
if err != nil {
t.Fatalf("ChatStream: %v", err)
}
if content != "Hi " {
t.Fatalf("content = %q", content)
}
if toolStarts != 1 {
t.Fatalf("toolStarts = %d, want 1", toolStarts)
}
if !doneCalled {
t.Fatalf("done event not called")
}
if done.StopReason != "tool_use" || len(done.ToolCalls) != 1 {
t.Fatalf("unexpected done: %+v", done)
}
if done.ToolCalls[0].Name != "get_time" || done.InputTokens != 5 || done.OutputTokens != 7 {
t.Fatalf("unexpected tool call or usage: %+v", done)
}
}
func TestAnthropicClient_SupportsThinking(t *testing.T) {
client := NewAnthropicClient("test-key", "claude-3-5-sonnet", 0)
if client.SupportsThinking("claude-3-5-sonnet") {
t.Fatal("expected SupportsThinking to be false")
}
}

View file

@ -55,6 +55,76 @@ func TestGeminiClient_Name(t *testing.T) {
}
}
func TestGeminiClient_ChatStream(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "streamGenerateContent") {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
events := []string{
`{"candidates":[{"content":{"parts":[{"text":"Hello"}]}}],"usageMetadata":{"promptTokenCount":2,"candidatesTokenCount":3}}`,
`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_time","args":{"tz":"UTC"}}}]},"finishReason":"STOP"}]}`,
}
for _, event := range events {
w.Write([]byte("data: " + event + "\n\n"))
w.(http.Flusher).Flush()
}
}))
defer server.Close()
client := NewGeminiClient("test-key", "gemini-pro", server.URL, 0)
var content string
var done DoneEvent
var doneCalled bool
var toolStarts int
err := client.ChatStream(context.Background(), ChatRequest{
Messages: []Message{{Role: "user", Content: "Hi"}},
}, func(event StreamEvent) {
switch event.Type {
case "content":
if data, ok := event.Data.(ContentEvent); ok {
content += data.Text
}
case "tool_start":
toolStarts++
case "done":
if data, ok := event.Data.(DoneEvent); ok {
done = data
doneCalled = true
}
}
})
if err != nil {
t.Fatalf("ChatStream: %v", err)
}
if content != "Hello" {
t.Fatalf("content = %q", content)
}
if toolStarts != 1 {
t.Fatalf("toolStarts = %d, want 1", toolStarts)
}
if !doneCalled {
t.Fatalf("done event not called")
}
if done.StopReason != "tool_use" || len(done.ToolCalls) != 1 {
t.Fatalf("unexpected done: %+v", done)
}
}
func TestGeminiClient_SupportsThinking(t *testing.T) {
client := NewGeminiClient("key", "gemini-pro", "", 0)
if client.SupportsThinking("gemini-pro") {
t.Fatal("expected SupportsThinking to be false")
}
}
func TestGeminiClient_Chat_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {

View file

@ -71,3 +71,43 @@ func TestIsNotable_Integration(t *testing.T) {
t.Logf("Cache size: %d", len(cache.data))
cache.mu.RUnlock()
}
func TestGetModelFamilyAliases(t *testing.T) {
tests := []struct {
model string
want []string
}{
{"claude-3-5-haiku", []string{"claude-haiku-4-5", "claude-3-5-haiku-latest"}},
{"claude-3-5-sonnet", []string{"claude-sonnet-4-5", "claude-3-5-sonnet-latest"}},
{"claude-3-opus", []string{"claude-opus-4-5", "claude-opus-4-1", "claude-opus-4-0"}},
{"gpt-4o", []string{"gpt-4o", "gpt-4o-2024-11-20"}},
{"o1-mini", []string{"o1", "o1-pro", "o3-mini"}},
{"unknown", nil},
}
for _, tt := range tests {
got := getModelFamilyAliases(tt.model)
if len(tt.want) == 0 && len(got) != 0 {
t.Fatalf("model %s: expected no aliases, got %+v", tt.model, got)
}
if len(tt.want) > 0 && len(got) != len(tt.want) {
t.Fatalf("model %s: expected %d aliases, got %d", tt.model, len(tt.want), len(got))
}
}
}
func TestParseFlexibleDate_Invalid(t *testing.T) {
_, err := parseFlexibleDate("not-a-date")
if err == nil {
t.Fatal("expected error for invalid date")
}
}
func TestIsRecentlyReleased(t *testing.T) {
if !isRecentlyReleased("2100-01-01", "") {
t.Fatal("expected future release date to be notable")
}
if isRecentlyReleased("2000-01-01", "") {
t.Fatal("expected old release date to be not notable")
}
}

View file

@ -157,3 +157,74 @@ func TestNewOllamaClient_Normalization(t *testing.T) {
assert.Equal(t, tt.expect, client.baseURL)
}
}
func TestOllamaClient_Chat_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/chat", r.URL.Path)
var req ollamaRequest
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
assert.False(t, req.Stream)
assert.Equal(t, "llama3", req.Model)
require.Len(t, req.Tools, 1)
assert.Equal(t, "function", req.Tools[0].Type)
assert.Equal(t, "get_time", req.Tools[0].Function.Name)
_ = json.NewEncoder(w).Encode(ollamaResponse{
Model: "llama3",
Message: ollamaMessageResp{
Role: "assistant",
Content: "Hello",
ToolCalls: []ollamaToolCall{
{Function: ollamaFunctionCall{Name: "get_time", Arguments: map[string]interface{}{"tz": "UTC"}}},
},
},
DoneReason: "stop",
PromptEvalCount: 2,
EvalCount: 3,
})
}))
defer server.Close()
client := NewOllamaClient("llama3", server.URL, 0)
resp, err := client.Chat(context.Background(), ChatRequest{
Messages: []Message{{Role: "user", Content: "Hi"}},
Tools: []Tool{
{
Name: "get_time",
Description: "get time",
InputSchema: map[string]interface{}{"type": "object"},
},
{
Type: "web_search_20250305",
Name: "web_search",
},
},
})
require.NoError(t, err)
assert.Equal(t, "Hello", resp.Content)
assert.Equal(t, "tool_use", resp.StopReason)
require.Len(t, resp.ToolCalls, 1)
assert.Equal(t, "get_time", resp.ToolCalls[0].Name)
assert.Equal(t, 2, resp.InputTokens)
assert.Equal(t, 3, resp.OutputTokens)
}
func TestOllamaClient_TestConnection(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/version", r.URL.Path)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewOllamaClient("llama3", server.URL, 0)
err := client.TestConnection(context.Background())
require.NoError(t, err)
}
func TestOllamaClient_SupportsThinking(t *testing.T) {
client := NewOllamaClient("llama3", "http://localhost:11434", 0)
if client.SupportsThinking("llama3") {
t.Fatal("expected SupportsThinking to be false")
}
}

View file

@ -213,3 +213,129 @@ func TestOpenAIClient_ListModels(t *testing.T) {
assert.Equal(t, "gpt-4", models[0].ID)
assert.Equal(t, "gpt-3.5-turbo", models[1].ID)
}
func TestOpenAIClient_Chat_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/v1/chat/completions", r.URL.Path)
assert.Equal(t, "Bearer sk-test", r.Header.Get("Authorization"))
var req openaiRequest
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
assert.Equal(t, "gpt-4", req.Model)
assert.Equal(t, 123, req.MaxCompletionTokens)
assert.Equal(t, 0.7, req.Temperature)
require.Len(t, req.Tools, 1)
assert.Equal(t, "function", req.Tools[0].Type)
assert.Equal(t, "get_time", req.Tools[0].Function.Name)
assert.Equal(t, "auto", req.ToolChoice)
_ = json.NewEncoder(w).Encode(openaiResponse{
ID: "chatcmpl-1",
Model: "gpt-4",
Choices: []openaiChoice{
{
Message: openaiRespMsg{Role: "assistant", Content: "Hello"},
FinishReason: "stop",
},
},
Usage: openaiUsage{PromptTokens: 2, CompletionTokens: 3},
})
}))
defer server.Close()
client := NewOpenAIClient("sk-test", "gpt-4", server.URL, 0)
resp, err := client.Chat(context.Background(), ChatRequest{
System: "You are helpful",
MaxTokens: 123,
Temperature: 0.7,
Messages: []Message{
{Role: "user", Content: "Hi"},
},
Tools: []Tool{
{
Name: "get_time",
Description: "get time",
InputSchema: map[string]interface{}{"type": "object"},
},
{
Type: "web_search_20250305",
Name: "web_search",
},
},
})
require.NoError(t, err)
assert.Equal(t, "Hello", resp.Content)
assert.Equal(t, 2, resp.InputTokens)
assert.Equal(t, 3, resp.OutputTokens)
}
func TestOpenAIClient_Chat_GPT52NonChat(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/v1/chat/completions", r.URL.Path)
var req openaiCompletionsRequest
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
assert.Equal(t, "gpt-5.2-pro", req.Model)
assert.Contains(t, req.Prompt, "System: sys")
assert.Contains(t, req.Prompt, "user: hi")
assert.Equal(t, 55, req.MaxCompletionTokens)
_ = json.NewEncoder(w).Encode(openaiResponse{
ID: "cmpl-1",
Model: "gpt-5.2-pro",
Choices: []openaiChoice{
{
Text: "Answer",
FinishReason: "stop",
},
},
Usage: openaiUsage{PromptTokens: 3, CompletionTokens: 4},
})
}))
defer server.Close()
client := NewOpenAIClient("sk-test", "gpt-5.2-pro", server.URL, 0)
resp, err := client.Chat(context.Background(), ChatRequest{
System: "sys",
MaxTokens: 55,
Messages: []Message{
{Role: "user", Content: "hi"},
},
})
require.NoError(t, err)
assert.Equal(t, "Answer", resp.Content)
}
func TestOpenAIClient_HelperFlags(t *testing.T) {
client := NewOpenAIClient("sk", "gpt-4", "https://api.openai.com", 0)
assert.True(t, client.requiresMaxCompletionTokens("o1-mini"))
assert.False(t, client.requiresMaxCompletionTokens("gpt-4"))
assert.True(t, client.isGPT52NonChat("gpt-5.2-pro"))
assert.False(t, client.isGPT52NonChat("gpt-5.2-chat-latest"))
}
func TestOpenAIClient_SupportsThinking(t *testing.T) {
client := NewOpenAIClient("sk", "deepseek-reasoner", "https://api.deepseek.com", 0)
assert.True(t, client.SupportsThinking("deepseek-reasoner"))
client = NewOpenAIClient("sk", "gpt-4", "https://api.openai.com", 0)
assert.False(t, client.SupportsThinking("gpt-4"))
}
func TestOpenAIClient_TestConnection(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/v1/models", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"data": []map[string]interface{}{
{"id": "gpt-4", "object": "model", "created": 123, "owned_by": "openai"},
},
})
}))
defer server.Close()
client := NewOpenAIClient("sk-test", "gpt-4", server.URL, 0)
err := client.TestConnection(context.Background())
require.NoError(t, err)
}

View file

@ -0,0 +1,130 @@
package tools
import (
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResolveDockerContainer(t *testing.T) {
t.Run("NoStateProvider", func(t *testing.T) {
exec := NewPulseToolExecutor(ExecutorConfig{})
_, _, err := exec.resolveDockerContainer("web", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "state provider not available")
})
t.Run("HostMatch", func(t *testing.T) {
state := models.StateSnapshot{
DockerHosts: []models.DockerHost{
{
ID: "host1",
Hostname: "dock1",
Containers: []models.DockerContainer{
{ID: "abc123", Name: "web"},
},
},
},
}
exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: state}})
container, host, err := exec.resolveDockerContainer("web", "dock1")
require.NoError(t, err)
assert.Equal(t, "web", container.Name)
assert.Equal(t, "host1", host.ID)
})
t.Run("HostNotFound", func(t *testing.T) {
state := models.StateSnapshot{
DockerHosts: []models.DockerHost{
{
ID: "host1",
Hostname: "dock1",
Containers: []models.DockerContainer{
{ID: "abc123", Name: "web"},
},
},
},
}
exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: state}})
_, _, err := exec.resolveDockerContainer("web", "missing")
require.Error(t, err)
assert.Contains(t, err.Error(), "not found on host")
})
t.Run("MultipleHosts", func(t *testing.T) {
state := models.StateSnapshot{
DockerHosts: []models.DockerHost{
{
ID: "host1",
DisplayName: "Dock One",
Containers: []models.DockerContainer{
{ID: "abcdef", Name: "web"},
},
},
{
ID: "host2",
DisplayName: "Dock Two",
Containers: []models.DockerContainer{
{ID: "abc999", Name: "web"},
},
},
},
}
exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: state}})
_, _, err := exec.resolveDockerContainer("web", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "multiple Docker hosts")
assert.Contains(t, err.Error(), "Dock One")
})
t.Run("IDPrefixMatch", func(t *testing.T) {
state := models.StateSnapshot{
DockerHosts: []models.DockerHost{
{
ID: "host1",
Hostname: "dock1",
Containers: []models.DockerContainer{
{ID: "abcdef", Name: "web"},
},
},
},
}
exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: state}})
container, _, err := exec.resolveDockerContainer("abc", "")
require.NoError(t, err)
assert.Equal(t, "abcdef", container.ID)
})
}
func TestGetAgentHostnameForDockerHost(t *testing.T) {
t.Run("NoAgentServer", func(t *testing.T) {
exec := NewPulseToolExecutor(ExecutorConfig{})
host := &models.DockerHost{Hostname: "dock1"}
assert.Equal(t, "dock1", exec.getAgentHostnameForDockerHost(host))
})
t.Run("AgentIDMatch", func(t *testing.T) {
agentSrv := &mockAgentServer{agents: []agentexec.ConnectedAgent{
{AgentID: "agent1", Hostname: "node1"},
}}
exec := NewPulseToolExecutor(ExecutorConfig{AgentServer: agentSrv})
host := &models.DockerHost{AgentID: "agent1", Hostname: "dock1"}
assert.Equal(t, "node1", exec.getAgentHostnameForDockerHost(host))
})
t.Run("FallbackHostname", func(t *testing.T) {
agentSrv := &mockAgentServer{agents: []agentexec.ConnectedAgent{
{AgentID: "agent2", Hostname: "node2"},
}}
exec := NewPulseToolExecutor(ExecutorConfig{AgentServer: agentSrv})
host := &models.DockerHost{AgentID: "agent1", Hostname: "dock1"}
assert.Equal(t, "dock1", exec.getAgentHostnameForDockerHost(host))
})
}

View file

@ -0,0 +1,77 @@
package tools
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExecuteCheckDockerUpdates(t *testing.T) {
ctx := context.Background()
exec := NewPulseToolExecutor(ExecutorConfig{})
result, err := exec.executeCheckDockerUpdates(ctx, map[string]interface{}{})
require.NoError(t, err)
assert.Equal(t, "Docker update checking not available. Ensure updates provider is configured.", result.Content[0].Text)
updatesProvider := &mockUpdatesProvider{}
exec = NewPulseToolExecutor(ExecutorConfig{UpdatesProvider: updatesProvider})
result, err = exec.executeCheckDockerUpdates(ctx, map[string]interface{}{})
require.NoError(t, err)
assert.True(t, result.IsError)
assert.Contains(t, result.Content[0].Text, "host is required")
state := models.StateSnapshot{
DockerHosts: []models.DockerHost{
{ID: "host1", Hostname: "dock1", DisplayName: "Dock One"},
},
}
exec = NewPulseToolExecutor(ExecutorConfig{
UpdatesProvider: updatesProvider,
StateProvider: &mockStateProvider{state: state},
ControlLevel: ControlLevelSuggest,
})
result, err = exec.executeCheckDockerUpdates(ctx, map[string]interface{}{
"host": "dock1",
})
require.NoError(t, err)
assert.Contains(t, result.Content[0].Text, "POST /api/agents/docker/hosts/host1/check-updates")
updatesProvider.On("TriggerUpdateCheck", "host1").Return(DockerCommandStatus{
ID: "cmd1",
Type: "check",
Status: "queued",
}, nil).Once()
exec = NewPulseToolExecutor(ExecutorConfig{
UpdatesProvider: updatesProvider,
StateProvider: &mockStateProvider{state: state},
})
result, err = exec.executeCheckDockerUpdates(ctx, map[string]interface{}{
"host": "dock1",
})
require.NoError(t, err)
var resp DockerCheckUpdatesResponse
require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp))
assert.Equal(t, "host1", resp.HostID)
assert.Equal(t, "Dock One", resp.HostName)
assert.Equal(t, "cmd1", resp.CommandID)
updatesProvider.On("TriggerUpdateCheck", "host1").Return(DockerCommandStatus{}, errors.New("boom")).Once()
exec = NewPulseToolExecutor(ExecutorConfig{
UpdatesProvider: updatesProvider,
StateProvider: &mockStateProvider{state: state},
})
result, err = exec.executeCheckDockerUpdates(ctx, map[string]interface{}{
"host": "dock1",
})
require.NoError(t, err)
assert.Contains(t, result.Content[0].Text, "Failed to trigger update check")
}

View file

@ -189,6 +189,7 @@ func NewPulseToolExecutor(cfg ExecutorConfig) *PulseToolExecutor {
backupProvider: cfg.BackupProvider,
storageProvider: cfg.StorageProvider,
diskHealthProvider: cfg.DiskHealthProvider,
updatesProvider: cfg.UpdatesProvider,
metadataUpdater: cfg.MetadataUpdater,
findingsManager: cfg.FindingsManager,
agentProfileManager: cfg.AgentProfileManager,