|
|
|
@@ -6,6 +6,7 @@ import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"strconv"
|
|
|
|
@@ -213,6 +214,7 @@ func setupCaptainAssistantHandlerTestWithProvider(t *testing.T, provider llm.Pro
|
|
|
|
|
&model.Inbox{},
|
|
|
|
|
&model.CaptainAssistant{},
|
|
|
|
|
&model.CaptainInbox{},
|
|
|
|
|
&model.CaptainAssistantResponse{},
|
|
|
|
|
))
|
|
|
|
|
t.Cleanup(func() {
|
|
|
|
|
sqlDB, _ := db.DB()
|
|
|
|
@@ -358,7 +360,7 @@ func TestCaptainAssistantHandler_AccountScopedShowAndInboxBinding(t *testing.T)
|
|
|
|
|
assert.Equal(t, http.StatusNoContent, w.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCaptainAssistantHandler_PlaygroundLegacyNoLLMFallback(t *testing.T) {
|
|
|
|
|
func TestCaptainAssistantHandler_PlaygroundProviderMissingFailsClosed(t *testing.T) {
|
|
|
|
|
router, db := setupCaptainAssistantHandlerTest(t)
|
|
|
|
|
account := seedCaptainAssistantAccount(t, db, "Captain Org")
|
|
|
|
|
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Description: "Support", Config: json.RawMessage(`{"model":"gpt-test"}`), Status: model.AssistantStatusActive}
|
|
|
|
@@ -372,18 +374,12 @@ func TestCaptainAssistantHandler_PlaygroundLegacyNoLLMFallback(t *testing.T) {
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
w := captainAssistantJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/captain/assistants/%d/playground", account.ID, assistant.ID), body)
|
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
|
|
|
|
|
|
var payload map[string]any
|
|
|
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
|
|
|
|
|
assert.NotContains(t, payload, "success")
|
|
|
|
|
assert.NotContains(t, payload, "data")
|
|
|
|
|
assert.Equal(t, "Captain assistant response generation is not configured for this account.", payload["content"])
|
|
|
|
|
assert.NotContains(t, payload, "response")
|
|
|
|
|
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
|
|
|
|
assert.Contains(t, w.Body.String(), "COPILOT_NOT_CONFIGURED")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCaptainAssistantHandler_PlaygroundDefaultsHistoryAndScopesAccount(t *testing.T) {
|
|
|
|
|
router, db := setupCaptainAssistantHandlerTest(t)
|
|
|
|
|
router, db := setupCaptainAssistantHandlerTestWithProvider(t, &captainPlaygroundFakeProvider{content: "Assistant response"})
|
|
|
|
|
account := seedCaptainAssistantAccount(t, db, "Account One")
|
|
|
|
|
otherAccount := seedCaptainAssistantAccount(t, db, "Account Two")
|
|
|
|
|
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Description: "Support", Config: json.RawMessage(`{}`), Status: model.AssistantStatusActive}
|
|
|
|
@@ -434,8 +430,8 @@ func TestCaptainAssistantHandler_PlaygroundV2AppendsCurrentMessageOnce(t *testin
|
|
|
|
|
assert.Equal(t, "Hello assistant", provider.lastRequest.Messages[1].Content)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCaptainAssistantHandler_PlaygroundV2ProviderErrorReturnsChatwootFallback(t *testing.T) {
|
|
|
|
|
provider := &captainPlaygroundFakeProvider{err: errors.New("provider unavailable")}
|
|
|
|
|
func TestCaptainAssistantHandler_PlaygroundV2ProviderErrorFailsClosed(t *testing.T) {
|
|
|
|
|
provider := &captainPlaygroundFakeProvider{err: &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connection refused")}}
|
|
|
|
|
router, db := setupCaptainAssistantHandlerTestWithProvider(t, provider)
|
|
|
|
|
account := seedCaptainAssistantAccount(t, db, "Captain Org")
|
|
|
|
|
account.FeatureFlags = `{"captain_integration_v2":true}`
|
|
|
|
@@ -445,23 +441,83 @@ func TestCaptainAssistantHandler_PlaygroundV2ProviderErrorReturnsChatwootFallbac
|
|
|
|
|
|
|
|
|
|
body := map[string]any{"message_content": "Hello assistant"}
|
|
|
|
|
w := captainAssistantJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/captain/assistants/%d/playground", account.ID, assistant.ID), body)
|
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
|
assert.Equal(t, http.StatusBadGateway, w.Code)
|
|
|
|
|
assert.Contains(t, w.Body.String(), "COPILOT_PROVIDER_UNREACHABLE")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var payload map[string]any
|
|
|
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
|
|
|
|
|
assert.Equal(t, "conversation_handoff", payload["response"])
|
|
|
|
|
assert.Equal(t, false, payload["handoff_tool_called"])
|
|
|
|
|
assert.Contains(t, payload["reasoning"], "Error occurred: llm generation failed: provider unavailable")
|
|
|
|
|
assert.NotContains(t, payload, "content")
|
|
|
|
|
assert.NotContains(t, payload, "success")
|
|
|
|
|
assert.NotContains(t, payload, "data")
|
|
|
|
|
func TestCaptainAssistantHandler_PlaygroundEmbeddingFailureFailsClosed(t *testing.T) {
|
|
|
|
|
provider := &captainPlaygroundFakeProvider{embeddingErr: &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connection refused")}}
|
|
|
|
|
router, db := setupCaptainAssistantHandlerTestWithProvider(t, provider)
|
|
|
|
|
account := seedCaptainAssistantAccount(t, db, "Captain Org")
|
|
|
|
|
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Config: json.RawMessage(`{"feature_faq":true}`), Status: model.AssistantStatusActive}
|
|
|
|
|
require.NoError(t, db.Create(assistant).Error)
|
|
|
|
|
|
|
|
|
|
w := captainAssistantJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/captain/assistants/%d/playground", account.ID, assistant.ID), map[string]any{"message_content": "Hello assistant"})
|
|
|
|
|
assert.Equal(t, http.StatusBadGateway, w.Code)
|
|
|
|
|
assert.Contains(t, w.Body.String(), "COPILOT_PROVIDER_UNREACHABLE")
|
|
|
|
|
assert.Zero(t, provider.calls)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCaptainAssistantHandler_PlaygroundEmptyEmbeddingFailsClosed(t *testing.T) {
|
|
|
|
|
for name, embeddingResponse := range map[string]*llm.EmbeddingResponse{
|
|
|
|
|
"nil response": nil,
|
|
|
|
|
"empty data": {},
|
|
|
|
|
"empty first vector": {Data: []llm.EmbeddingData{{}}},
|
|
|
|
|
} {
|
|
|
|
|
t.Run(name, func(t *testing.T) {
|
|
|
|
|
provider := &captainPlaygroundFakeProvider{embeddingResponse: embeddingResponse, embeddingResponseSet: true}
|
|
|
|
|
router, db := setupCaptainAssistantHandlerTestWithProvider(t, provider)
|
|
|
|
|
account := seedCaptainAssistantAccount(t, db, "Captain Org")
|
|
|
|
|
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Config: json.RawMessage(`{"feature_faq":true}`), Status: model.AssistantStatusActive}
|
|
|
|
|
require.NoError(t, db.Create(assistant).Error)
|
|
|
|
|
|
|
|
|
|
w := captainAssistantJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/captain/assistants/%d/playground", account.ID, assistant.ID), map[string]any{"message_content": "Hello assistant"})
|
|
|
|
|
assert.Equal(t, http.StatusBadGateway, w.Code)
|
|
|
|
|
assert.Contains(t, w.Body.String(), "COPILOT_PROVIDER_UNREACHABLE")
|
|
|
|
|
assert.Zero(t, provider.calls)
|
|
|
|
|
assert.Equal(t, 1, provider.embeddingCalls)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCaptainAssistantHandler_PlaygroundFAQStoreFailureFailsClosed(t *testing.T) {
|
|
|
|
|
provider := &captainPlaygroundFakeProvider{embeddingResponse: &llm.EmbeddingResponse{Data: []llm.EmbeddingData{{Embedding: []float64{0.1, 0.2, 0.3}}}}}
|
|
|
|
|
router, db := setupCaptainAssistantHandlerTestWithProvider(t, provider)
|
|
|
|
|
account := seedCaptainAssistantAccount(t, db, "Captain Org")
|
|
|
|
|
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Config: json.RawMessage(`{"feature_faq":true}`), Status: model.AssistantStatusActive}
|
|
|
|
|
require.NoError(t, db.Create(assistant).Error)
|
|
|
|
|
require.NoError(t, db.Create(&model.CaptainAssistantResponse{
|
|
|
|
|
AccountID: account.ID, AssistantID: assistant.ID, Question: "FAQ", Answer: "Answer", Status: model.ResponseStatusApproved,
|
|
|
|
|
}).Error)
|
|
|
|
|
require.NoError(t, db.Migrator().DropTable(&model.CaptainAssistantResponse{}))
|
|
|
|
|
|
|
|
|
|
w := captainAssistantJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/captain/assistants/%d/playground", account.ID, assistant.ID), map[string]any{"message_content": "Hello assistant"})
|
|
|
|
|
assert.Equal(t, http.StatusBadGateway, w.Code)
|
|
|
|
|
assert.Contains(t, w.Body.String(), "COPILOT_PROVIDER_UNREACHABLE")
|
|
|
|
|
assert.Zero(t, provider.calls)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCaptainAssistantHandler_PlaygroundDisabledAssistantFailsClosed(t *testing.T) {
|
|
|
|
|
router, db := setupCaptainAssistantHandlerTestWithProvider(t, &captainPlaygroundFakeProvider{content: "must not run"})
|
|
|
|
|
account := seedCaptainAssistantAccount(t, db, "Captain Org")
|
|
|
|
|
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Status: model.AssistantStatusArchived}
|
|
|
|
|
require.NoError(t, db.Create(assistant).Error)
|
|
|
|
|
|
|
|
|
|
body := map[string]any{"message_content": "Hello assistant"}
|
|
|
|
|
w := captainAssistantJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/captain/assistants/%d/playground", account.ID, assistant.ID), body)
|
|
|
|
|
assert.Equal(t, http.StatusConflict, w.Code)
|
|
|
|
|
assert.Contains(t, w.Body.String(), "CAPTAIN_ASSISTANT_DISABLED")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type captainPlaygroundFakeProvider struct {
|
|
|
|
|
content string
|
|
|
|
|
err error
|
|
|
|
|
calls int
|
|
|
|
|
lastRequest llm.ChatRequest
|
|
|
|
|
content string
|
|
|
|
|
err error
|
|
|
|
|
embeddingResponse *llm.EmbeddingResponse
|
|
|
|
|
embeddingResponseSet bool
|
|
|
|
|
embeddingErr error
|
|
|
|
|
embeddingCalls int
|
|
|
|
|
calls int
|
|
|
|
|
lastRequest llm.ChatRequest
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *captainPlaygroundFakeProvider) ChatCompletion(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) {
|
|
|
|
@@ -474,6 +530,10 @@ func (p *captainPlaygroundFakeProvider) ChatCompletion(ctx context.Context, req
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *captainPlaygroundFakeProvider) CreateEmbedding(ctx context.Context, req llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) {
|
|
|
|
|
p.embeddingCalls++
|
|
|
|
|
if p.embeddingErr != nil || p.embeddingResponseSet || p.embeddingResponse != nil {
|
|
|
|
|
return p.embeddingResponse, p.embeddingErr
|
|
|
|
|
}
|
|
|
|
|
return &llm.EmbeddingResponse{}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|