From 51092e33244d77987d54fccfdff10f7c10fd64b6 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 2 Dec 2025 14:54:35 +0800 Subject: [PATCH] Refactor assistant model and enhance data handling - Removed the GetByConnector method from the Assistant model to streamline the retrieval process. - Introduced new fields for connector options and prompt presets, allowing for more flexible configurations. - Updated the Map method to include additional fields such as connector options and prompt presets. - Enhanced the Clone method to support deep copying of new fields. - Improved the Update method to handle updates for the new source, connector options, and prompt presets fields. - Refactored tests to ensure comprehensive coverage of the new functionalities and maintain clarity in the assistant structure. --- agent/assistant/assistant.go | 123 +++-- agent/assistant/load.go | 145 +++++- agent/assistant/load_test.go | 826 ++++++++++++++++-------------- agent/assistant/source.go | 36 ++ agent/store/types/convert.go | 53 ++ agent/store/types/convert_test.go | 267 ++++++++++ 6 files changed, 1019 insertions(+), 431 deletions(-) create mode 100644 agent/assistant/source.go diff --git a/agent/assistant/assistant.go b/agent/assistant/assistant.go index 46d06eb1..7fe86c47 100644 --- a/agent/assistant/assistant.go +++ b/agent/assistant/assistant.go @@ -15,31 +15,6 @@ func Get(id string) (*Assistant, error) { return LoadStore(id) } -// GetByConnector get the assistant by connector -func GetByConnector(connector string, name string) (*Assistant, error) { - id := "connector:" + connector - - assistant, exists := loaded.Get(id) - if exists { - return assistant, nil - } - - data := map[string]interface{}{ - "assistant_id": id, - "connector": connector, - "description": "Default assistant for " + connector, - "name": name, - "type": "assistant", - } - - assistant, err := loadMap(data) - if err != nil { - return nil, err - } - loaded.Put(assistant) - return assistant, nil -} - // GetPlaceholder returns the placeholder of the assistant func (ast *Assistant) GetPlaceholder(locale string) *store.Placeholder { @@ -88,30 +63,33 @@ func (ast *Assistant) Map() map[string]interface{} { } return map[string]interface{}{ - "assistant_id": ast.ID, - "type": ast.Type, - "name": ast.Name, - "readonly": ast.Readonly, - "public": ast.Public, - "share": ast.Share, - "avatar": ast.Avatar, - "connector": ast.Connector, - "path": ast.Path, - "built_in": ast.BuiltIn, - "sort": ast.Sort, - "description": ast.Description, - "options": ast.Options, - "prompts": ast.Prompts, - "kb": ast.KB, - "mcp": ast.MCP, - "workflow": ast.Workflow, - "tags": ast.Tags, - "mentionable": ast.Mentionable, - "automated": ast.Automated, - "placeholder": ast.Placeholder, - "locales": ast.Locales, - "created_at": store.ToMySQLTime(ast.CreatedAt), - "updated_at": store.ToMySQLTime(ast.UpdatedAt), + "assistant_id": ast.ID, + "type": ast.Type, + "name": ast.Name, + "readonly": ast.Readonly, + "public": ast.Public, + "share": ast.Share, + "avatar": ast.Avatar, + "connector": ast.Connector, + "connector_options": ast.ConnectorOptions, + "path": ast.Path, + "built_in": ast.BuiltIn, + "sort": ast.Sort, + "description": ast.Description, + "options": ast.Options, + "prompts": ast.Prompts, + "prompt_presets": ast.PromptPresets, + "source": ast.Source, + "kb": ast.KB, + "mcp": ast.MCP, + "workflow": ast.Workflow, + "tags": ast.Tags, + "mentionable": ast.Mentionable, + "automated": ast.Automated, + "placeholder": ast.Placeholder, + "locales": ast.Locales, + "created_at": store.ToMySQLTime(ast.CreatedAt), + "updated_at": store.ToMySQLTime(ast.UpdatedAt), } } @@ -173,6 +151,7 @@ func (ast *Assistant) Clone() *Assistant { Share: ast.Share, Mentionable: ast.Mentionable, Automated: ast.Automated, + Source: ast.Source, CreatedAt: ast.CreatedAt, UpdatedAt: ast.UpdatedAt, }, @@ -245,6 +224,31 @@ func (ast *Assistant) Clone() *Assistant { copy(clone.Prompts, ast.Prompts) } + // Deep copy prompt presets + if ast.PromptPresets != nil { + clone.PromptPresets = make(map[string][]store.Prompt) + for k, v := range ast.PromptPresets { + prompts := make([]store.Prompt, len(v)) + copy(prompts, v) + clone.PromptPresets[k] = prompts + } + } + + // Deep copy connector options + if ast.ConnectorOptions != nil { + clone.ConnectorOptions = &store.ConnectorOptions{ + Optional: ast.ConnectorOptions.Optional, + } + if ast.ConnectorOptions.Connectors != nil { + clone.ConnectorOptions.Connectors = make([]string, len(ast.ConnectorOptions.Connectors)) + copy(clone.ConnectorOptions.Connectors, ast.ConnectorOptions.Connectors) + } + if ast.ConnectorOptions.Filters != nil { + clone.ConnectorOptions.Filters = make([]store.ModelCapability, len(ast.ConnectorOptions.Filters)) + copy(clone.ConnectorOptions.Filters, ast.ConnectorOptions.Filters) + } + } + // Deep copy workflow if ast.Workflow != nil { clone.Workflow = &store.Workflow{} @@ -341,6 +345,27 @@ func (ast *Assistant) Update(data map[string]interface{}) error { if v, ok := data["options"].(map[string]interface{}); ok { ast.Options = v } + if v, ok := data["source"].(string); ok { + ast.Source = v + } + + // ConnectorOptions + if v, has := data["connector_options"]; has { + connOpts, err := store.ToConnectorOptions(v) + if err != nil { + return err + } + ast.ConnectorOptions = connOpts + } + + // PromptPresets + if v, has := data["prompt_presets"]; has { + presets, err := store.ToPromptPresets(v) + if err != nil { + return err + } + ast.PromptPresets = presets + } // KB if v, has := data["kb"]; has { diff --git a/agent/assistant/load.go b/agent/assistant/load.go index c46c541c..c65bbeaf 100644 --- a/agent/assistant/load.go +++ b/agent/assistant/load.go @@ -268,7 +268,7 @@ func LoadPath(path string) (*Assistant, error) { updatedAt := int64(0) - // prompts + // prompts (default prompts from prompts.yml) promptsfile := filepath.Join(path, "prompts.yml") if has, _ := app.Exists(promptsfile); has { prompts, ts, err := loadPrompts(promptsfile, path) @@ -280,6 +280,19 @@ func LoadPath(path string) (*Assistant, error) { updatedAt = ts } + // prompt_presets (from prompts directory, key is filename without extension) + promptsDir := filepath.Join(path, "prompts") + if has, _ := app.Exists(promptsDir); has { + presets, ts, err := loadPromptPresets(promptsDir, path) + if err != nil { + return nil, err + } + if len(presets) > 0 { + data["prompt_presets"] = presets + updatedAt = max(updatedAt, ts) + } + } + // load script scriptfile := filepath.Join(path, "src", "index.ts") if has, _ := app.Exists(scriptfile); has { @@ -419,6 +432,15 @@ func loadMap(data map[string]interface{}) (*Assistant, error) { assistant.Connector = connector } + // connector_options + if connOpts, has := data["connector_options"]; has { + opts, err := store.ToConnectorOptions(connOpts) + if err != nil { + return nil, err + } + assistant.ConnectorOptions = opts + } + // tags if v, has := data["tags"]; has { switch vv := v.(type) { @@ -510,6 +532,20 @@ func loadMap(data map[string]interface{}) (*Assistant, error) { } } + // prompt_presets + if presets, has := data["prompt_presets"]; has { + promptPresets, err := store.ToPromptPresets(presets) + if err != nil { + return nil, err + } + assistant.PromptPresets = promptPresets + } + + // source (hook script code) - store the source code + if source, ok := data["source"].(string); ok { + assistant.Source = source + } + // tools - deprecated, now handled by MCP // if tools, has := data["tools"]; has { // ... removed ... @@ -542,7 +578,29 @@ func loadMap(data map[string]interface{}) (*Assistant, error) { assistant.Workflow = wf } - // script + // uses (wrapper configurations for vision, audio, etc.) + if uses, has := data["uses"]; has { + switch v := uses.(type) { + case *context.Uses: + assistant.Uses = v + case context.Uses: + assistant.Uses = &v + default: + raw, err := jsoniter.Marshal(v) + if err != nil { + return nil, err + } + var usesConfig context.Uses + err = jsoniter.Unmarshal(raw, &usesConfig) + if err != nil { + return nil, err + } + assistant.Uses = &usesConfig + } + } + + // script loading priority: script field > source field + // If script field exists, use it; otherwise try source field if data["script"] != nil { switch v := data["script"].(type) { case string: @@ -557,6 +615,13 @@ func loadMap(data map[string]interface{}) (*Assistant, error) { case *v8.Script: assistant.Script = &hook.Script{Script: v} } + } else if assistant.Source != "" { + // Load from source field if script is not provided + script, err := loadSource(assistant.Source, assistant.ID) + if err != nil { + return nil, err + } + assistant.Script = script } // created_at @@ -603,6 +668,7 @@ func loadPrompts(file string, root string) (string, int64, error) { return "", 0, err } + // Replace @assets/xxx references with file content re := regexp.MustCompile(`@assets/([^\s]+\.(md|yml|yaml|json|txt))`) prompts = re.ReplaceAllFunc(prompts, func(s []byte) []byte { asset := re.FindStringSubmatch(string(s))[1] @@ -623,6 +689,81 @@ func loadPrompts(file string, root string) (string, int64, error) { return string(prompts), ts.UnixNano(), nil } +// loadPromptPresets loads prompt presets from the prompts directory +// Supports multi-level directories, key is path with "/" replaced by "." +// e.g., prompts/chat/default.yml -> "chat.default" +func loadPromptPresets(dir string, root string) (map[string][]store.Prompt, int64, error) { + app, err := fs.Get("app") + if err != nil { + return nil, 0, err + } + + // Read directory recursively - returns full paths relative to app root + files, err := app.ReadDir(dir, true) + if err != nil { + return nil, 0, err + } + + presets := make(map[string][]store.Prompt) + var latestTs int64 + + for _, file := range files { + // Only process .yml/.yaml files + if !strings.HasSuffix(file, ".yml") && !strings.HasSuffix(file, ".yaml") { + continue + } + + // file is already full path relative to app root (e.g., /assistants/tests/fullfields/prompts/chat/friendly.yml) + ts, err := app.ModTime(file) + if err != nil { + return nil, 0, err + } + if ts.UnixNano() > latestTs { + latestTs = ts.UnixNano() + } + + // Read file content directly + content, err := app.ReadFile(file) + if err != nil { + return nil, 0, err + } + + // Replace @assets/xxx references with file content + re := regexp.MustCompile(`@assets/([^\s]+\.(md|yml|yaml|json|txt))`) + content = re.ReplaceAllFunc(content, func(s []byte) []byte { + asset := re.FindStringSubmatch(string(s))[1] + assetFile := filepath.Join(root, "assets", asset) + assetContent, err := app.ReadFile(assetFile) + if err != nil { + return []byte("") + } + // Add proper YAML formatting for content + lines := strings.Split(string(assetContent), "\n") + formattedContent := "|\n" + for _, line := range lines { + formattedContent += " " + line + "\n" + } + return []byte(formattedContent) + }) + + // Parse prompts + var prompts []store.Prompt + err = yaml.Unmarshal(content, &prompts) + if err != nil { + return nil, 0, fmt.Errorf("failed to parse prompt preset %s: %w", file, err) + } + + // Build key: get relative path from dir, remove extension and replace "/" with "." + // e.g., "/assistants/tests/fullfields/prompts/chat/friendly.yml" -> "chat.friendly" + relPath := strings.TrimPrefix(file, dir+"/") + key := strings.TrimSuffix(relPath, filepath.Ext(relPath)) + key = strings.ReplaceAll(key, "/", ".") + presets[key] = prompts + } + + return presets, latestTs, nil +} + func loadScript(file string, root string) (*hook.Script, int64, error) { app, err := fs.Get("app") diff --git a/agent/assistant/load_test.go b/agent/assistant/load_test.go index 3a047782..b5e2b35a 100644 --- a/agent/assistant/load_test.go +++ b/agent/assistant/load_test.go @@ -1,435 +1,501 @@ package assistant -// func prepare(t *testing.T) { -// test.Prepare(t, config.Conf) -// } +import ( + "testing" -// func TestLoad_LoadPath(t *testing.T) { -// prepare(t) -// defer test.Clean() + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + store "github.com/yaoapp/yao/agent/store/types" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/test" +) -// assistant, err := LoadPath("/assistants/modi") -// if err != nil { -// t.Fatal(err) -// } +func prepare(t *testing.T) { + test.Prepare(t, config.Conf) +} -// // Validate basic properties -// assert.NotNil(t, assistant) -// assert.Equal(t, "modi", assistant.ID) -// assert.Equal(t, "Modi", assistant.Name) -// assert.Equal(t, "https://api.dicebear.com/7.x/bottts/svg?seed=Modi", assistant.Avatar) -// assert.Equal(t, "deepseek", assistant.Connector) -// assert.NotNil(t, assistant.Prompts) -// assert.NotNil(t, assistant.Script) +// TestLoadPath tests loading assistant from path +func TestLoadPath(t *testing.T) { + prepare(t) + defer test.Clean() -// // Test non-existent assistant -// _, err = LoadPath("/assistants/non-existent") -// assert.Error(t, err) -// } + t.Run("LoadFullFieldsAssistant", func(t *testing.T) { + assistant, err := LoadPath("/assistants/tests/fullfields") + require.NoError(t, err) + require.NotNil(t, assistant) -// func TestLoad_LoadStore(t *testing.T) { -// prepare(t) -// defer test.Clean() + // Basic fields + assert.Equal(t, "tests.fullfields", assistant.ID) + assert.Equal(t, "Full Fields Test Assistant", assistant.Name) + assert.Equal(t, "assistant", assistant.Type) + assert.Equal(t, "/api/__yao/app/icons/app.png", assistant.Avatar) + assert.Equal(t, "gpt-4o", assistant.Connector) + assert.Equal(t, "/assistants/tests/fullfields", assistant.Path) + assert.Equal(t, "Test assistant with all available fields for unit testing", assistant.Description) -// // Test with nil storage -// _, err := LoadStore("test-id") -// assert.Error(t, err) -// assert.Contains(t, err.Error(), "storage is not set") + // Boolean fields + assert.True(t, assistant.Public) + assert.True(t, assistant.Readonly) + assert.True(t, assistant.Mentionable) + assert.False(t, assistant.Automated) -// // Setup mock storage -// mockStore := &mockStore{ -// data: map[string]map[string]interface{}{ -// "test-id": { -// "assistant_id": "test-id", -// "name": "Test Assistant", -// "avatar": "test-avatar", -// "connector": "gpt-3_5-turbo", -// }, -// }, -// } -// SetStorage(mockStore) -// defer SetStorage(nil) + // Share field + assert.Equal(t, "team", assistant.Share) -// // Test loading from store -// assistant, err := LoadStore("test-id") -// assert.NoError(t, err) -// assert.NotNil(t, assistant) -// assert.Equal(t, "test-id", assistant.ID) -// assert.Equal(t, "Test Assistant", assistant.Name) -// assert.Equal(t, "test-avatar", assistant.Avatar) -// assert.Equal(t, "gpt-3_5-turbo", assistant.Connector) + // Sort field + assert.Equal(t, 100, assistant.Sort) -// // Test cache functionality -// assistant2, err := LoadStore("test-id") -// assert.NoError(t, err) -// assert.Equal(t, assistant, assistant2) // Should be the same instance from cache + // Tags + assert.NotNil(t, assistant.Tags) + assert.Contains(t, assistant.Tags, "Test") + assert.Contains(t, assistant.Tags, "Development") + assert.Contains(t, assistant.Tags, "FullFields") -// // Test non-existent assistant -// _, err = LoadStore("non-existent") -// assert.Error(t, err) -// } + // Options + assert.NotNil(t, assistant.Options) + assert.Equal(t, 0.7, assistant.Options["temperature"]) + assert.Equal(t, float64(2000), assistant.Options["max_tokens"]) -// func TestLoad_Cache(t *testing.T) { -// prepare(t) -// defer test.Clean() + // Prompts (default prompts from prompts.yml) + assert.NotNil(t, assistant.Prompts) + assert.GreaterOrEqual(t, len(assistant.Prompts), 1) + assert.Equal(t, "system", assistant.Prompts[0].Role) -// // Clear any existing cache first -// ClearCache() + // Script (from src/index.ts) + assert.NotNil(t, assistant.Script) + }) -// // Test cache operations -// SetCache(2) // Set small cache size for testing -// assert.Equal(t, 2, loaded.capacity, "Cache capacity should be 2") + t.Run("LoadConnectorOptions", func(t *testing.T) { + assistant, err := LoadPath("/assistants/tests/fullfields") + require.NoError(t, err) + require.NotNil(t, assistant) -// // Create test assistants -// assistant1 := &Assistant{ID: "id1", Name: "Assistant 1"} -// assistant2 := &Assistant{ID: "id2", Name: "Assistant 2"} -// assistant3 := &Assistant{ID: "id3", Name: "Assistant 3"} + // ConnectorOptions + assert.NotNil(t, assistant.ConnectorOptions) + assert.True(t, assistant.ConnectorOptions.Optional) + assert.NotNil(t, assistant.ConnectorOptions.Connectors) + assert.Contains(t, assistant.ConnectorOptions.Connectors, "gpt-4o") + assert.Contains(t, assistant.ConnectorOptions.Connectors, "gpt-4o-mini") + assert.Contains(t, assistant.ConnectorOptions.Connectors, "deepseek") + assert.NotNil(t, assistant.ConnectorOptions.Filters) + assert.Len(t, assistant.ConnectorOptions.Filters, 2) + }) -// // Test Put and Get -// loaded.Put(assistant1) -// assert.Equal(t, 1, loaded.Len(), "Cache should have 1 item") + t.Run("LoadPromptPresets", func(t *testing.T) { + assistant, err := LoadPath("/assistants/tests/fullfields") + require.NoError(t, err) + require.NotNil(t, assistant) -// loaded.Put(assistant2) -// assert.Equal(t, 2, loaded.Len(), "Cache should have 2 items") + // PromptPresets (from prompts directory) + assert.NotNil(t, assistant.PromptPresets) -// // Test cache hit -// cached, exists := loaded.Get("id1") -// assert.True(t, exists) -// assert.Equal(t, assistant1, cached) + // Top-level presets: chat.yml -> "chat", task.yml -> "task" + chatPreset, hasChat := assistant.PromptPresets["chat"] + assert.True(t, hasChat, "Should have 'chat' preset") + assert.NotEmpty(t, chatPreset) -// // Test cache eviction (LRU) -// // At this point: assistant1 is most recently used (due to Get), then assistant2 -// loaded.Put(assistant3) // This should evict assistant2 since it's least recently used -// assert.Equal(t, 2, loaded.Len(), "Cache should still have 2 items") -// _, exists = loaded.Get("id2") -// assert.False(t, exists, "assistant2 should have been evicted (least recently used)") -// _, exists = loaded.Get("id1") -// assert.True(t, exists, "assistant1 should still be in cache (was accessed recently)") -// _, exists = loaded.Get("id3") -// assert.True(t, exists, "assistant3 should be in cache (most recently added)") + taskPreset, hasTask := assistant.PromptPresets["task"] + assert.True(t, hasTask, "Should have 'task' preset") + assert.NotEmpty(t, taskPreset) -// // Test clear cache -// ClearCache() -// assert.Nil(t, loaded) + // Nested presets: chat/friendly.yml -> "chat.friendly" + friendlyPreset, hasFriendly := assistant.PromptPresets["chat.friendly"] + assert.True(t, hasFriendly, "Should have 'chat.friendly' preset") + assert.NotEmpty(t, friendlyPreset) -// // Test setting new cache capacity -// SetCache(100) -// assert.NotNil(t, loaded) -// } + professionalPreset, hasProfessional := assistant.PromptPresets["chat.professional"] + assert.True(t, hasProfessional, "Should have 'chat.professional' preset") + assert.NotEmpty(t, professionalPreset) -// func TestLoad_Validate(t *testing.T) { -// tests := []struct { -// name string -// ast *Assistant -// wantErr bool -// }{ -// { -// name: "valid assistant", -// ast: &Assistant{ -// ID: "test-id", -// Name: "Test Assistant", -// Connector: "test-connector", -// }, -// wantErr: false, -// }, -// { -// name: "missing id", -// ast: &Assistant{ -// Name: "Test Assistant", -// Connector: "test-connector", -// }, -// wantErr: true, -// }, -// { -// name: "missing name", -// ast: &Assistant{ -// ID: "test-id", -// Connector: "test-connector", -// }, -// wantErr: true, -// }, -// { -// name: "missing connector", -// ast: &Assistant{ -// ID: "test-id", -// Name: "Test Assistant", -// }, -// wantErr: true, -// }, -// } + // task/analysis.yml -> "task.analysis" + analysisPreset, hasAnalysis := assistant.PromptPresets["task.analysis"] + assert.True(t, hasAnalysis, "Should have 'task.analysis' preset") + assert.NotEmpty(t, analysisPreset) + }) -// for _, tt := range tests { -// t.Run(tt.name, func(t *testing.T) { -// err := tt.ast.Validate() -// if (err != nil) != tt.wantErr { -// t.Errorf("Assistant.Validate() error = %v, wantErr %v", err, tt.wantErr) -// } -// }) -// } -// } + t.Run("LoadKnowledgeBase", func(t *testing.T) { + assistant, err := LoadPath("/assistants/tests/fullfields") + require.NoError(t, err) + require.NotNil(t, assistant) -// func TestLoad_Clone(t *testing.T) { -// // Create a test assistant with all fields populated -// original := &Assistant{ -// ID: "test-id", -// Type: "test-type", -// Name: "Test Assistant", -// Avatar: "test-avatar", -// Connector: "test-connector", -// Path: "test-path", -// BuiltIn: true, -// Sort: 1, -// Description: "test description", -// Tags: []string{"tag1", "tag2"}, -// Readonly: true, -// Mentionable: true, -// Automated: true, -// Options: map[string]interface{}{"key": "value"}, -// Prompts: []Prompt{{Role: "system", Content: "test"}}, -// Workflow: map[string]interface{}{"step": "test"}, -// } + // KB + assert.NotNil(t, assistant.KB) + assert.NotNil(t, assistant.KB.Collections) + assert.Contains(t, assistant.KB.Collections, "test-collection") + assert.NotNil(t, assistant.KB.Options) + assert.Equal(t, float64(5), assistant.KB.Options["top_k"]) + }) -// // Clone the assistant -// clone := original.Clone() + t.Run("LoadMCPServers", func(t *testing.T) { + assistant, err := LoadPath("/assistants/tests/fullfields") + require.NoError(t, err) + require.NotNil(t, assistant) -// // Verify all fields are correctly cloned -// assert.Equal(t, original.ID, clone.ID) -// assert.Equal(t, original.Type, clone.Type) -// assert.Equal(t, original.Name, clone.Name) -// assert.Equal(t, original.Avatar, clone.Avatar) -// assert.Equal(t, original.Connector, clone.Connector) -// assert.Equal(t, original.Path, clone.Path) -// assert.Equal(t, original.BuiltIn, clone.BuiltIn) -// assert.Equal(t, original.Sort, clone.Sort) -// assert.Equal(t, original.Description, clone.Description) -// assert.Equal(t, original.Tags, clone.Tags) -// assert.Equal(t, original.Readonly, clone.Readonly) -// assert.Equal(t, original.Mentionable, clone.Mentionable) -// assert.Equal(t, original.Automated, clone.Automated) -// assert.Equal(t, original.Options, clone.Options) -// assert.Equal(t, original.Prompts, clone.Prompts) -// assert.Equal(t, original.Workflow, clone.Workflow) + // MCP + assert.NotNil(t, assistant.MCP) + assert.NotNil(t, assistant.MCP.Servers) + assert.Len(t, assistant.MCP.Servers, 1) + assert.Equal(t, "echo", assistant.MCP.Servers[0].ServerID) + assert.Contains(t, assistant.MCP.Servers[0].Tools, "ping") + assert.Contains(t, assistant.MCP.Servers[0].Tools, "echo") + }) -// // Verify deep copy by modifying original -// original.Tags[0] = "modified" -// original.Options["key"] = "modified" -// original.Workflow["step"] = "modified" -// assert.NotEqual(t, original.Tags[0], clone.Tags[0]) -// assert.NotEqual(t, original.Options["key"], clone.Options["key"]) -// assert.NotEqual(t, original.Workflow["step"], clone.Workflow["step"]) + t.Run("LoadWorkflow", func(t *testing.T) { + assistant, err := LoadPath("/assistants/tests/fullfields") + require.NoError(t, err) + require.NotNil(t, assistant) -// // Test nil case -// var nilAssistant *Assistant -// assert.Nil(t, nilAssistant.Clone()) -// } + // Workflow + assert.NotNil(t, assistant.Workflow) + assert.NotNil(t, assistant.Workflow.Workflows) + assert.Contains(t, assistant.Workflow.Workflows, "test-workflow") + assert.NotNil(t, assistant.Workflow.Options) + assert.Equal(t, float64(10), assistant.Workflow.Options["max_steps"]) + }) -// func TestLoad_Update(t *testing.T) { -// // Create a test assistant -// ast := &Assistant{ -// ID: "test-id", -// Name: "Original Name", -// Connector: "original-connector", -// } + t.Run("LoadPlaceholder", func(t *testing.T) { + assistant, err := LoadPath("/assistants/tests/fullfields") + require.NoError(t, err) + require.NotNil(t, assistant) -// // Test updating various fields -// updates := map[string]interface{}{ -// "name": "Updated Name", -// "avatar": "updated-avatar", -// "description": "Updated description", -// "connector": "updated-connector", -// "type": "updated-type", -// "sort": 2, -// "mentionable": true, -// "automated": true, -// "tags": []string{"new-tag"}, -// "options": map[string]interface{}{"new": "value"}, -// } + // Placeholder + assert.NotNil(t, assistant.Placeholder) + assert.Equal(t, "Full Fields Test", assistant.Placeholder.Title) + assert.Equal(t, "Test assistant with complete field coverage", assistant.Placeholder.Description) + assert.NotNil(t, assistant.Placeholder.Prompts) + assert.Len(t, assistant.Placeholder.Prompts, 3) + }) -// err := ast.Update(updates) -// assert.NoError(t, err) + t.Run("LoadLocales", func(t *testing.T) { + assistant, err := LoadPath("/assistants/tests/fullfields") + require.NoError(t, err) + require.NotNil(t, assistant) -// // Verify updates -// assert.Equal(t, "Updated Name", ast.Name) -// assert.Equal(t, "updated-avatar", ast.Avatar) -// assert.Equal(t, "Updated description", ast.Description) -// assert.Equal(t, "updated-connector", ast.Connector) -// assert.Equal(t, "updated-type", ast.Type) -// assert.Equal(t, 2, ast.Sort) -// assert.True(t, ast.Mentionable) -// assert.True(t, ast.Automated) -// assert.Equal(t, []string{"new-tag"}, ast.Tags) -// assert.Equal(t, map[string]interface{}{"new": "value"}, ast.Options) + // Locales + assert.NotNil(t, assistant.Locales) -// // Test nil assistant -// var nilAssistant *Assistant -// err = nilAssistant.Update(updates) -// assert.Error(t, err) + enLocale, hasEn := assistant.Locales["en-us"] + assert.True(t, hasEn, "Should have en-us locale") + assert.NotNil(t, enLocale) -// // Test invalid update that would make the assistant invalid -// invalidUpdates := map[string]interface{}{ -// "name": "", -// } -// err = ast.Update(invalidUpdates) -// assert.Error(t, err) -// } + zhLocale, hasZh := assistant.Locales["zh-cn"] + assert.True(t, hasZh, "Should have zh-cn locale") + assert.NotNil(t, zhLocale) + }) -// func TestLoadBuiltIn(t *testing.T) { -// prepare(t) -// defer test.Clean() + t.Run("LoadNonExistentAssistant", func(t *testing.T) { + _, err := LoadPath("/assistants/non-existent") + assert.Error(t, err) + }) +} -// // Clear any existing cache and storage -// ClearCache() -// SetStorage(nil) +// TestLoadPathMCPTest tests loading the MCP test assistant +func TestLoadPathMCPTest(t *testing.T) { + prepare(t) + defer test.Clean() -// // Create a mock store to verify built-in assistants are saved -// mockStore := &mockStore{ -// data: make(map[string]map[string]interface{}), -// } -// SetStorage(mockStore) -// SetCache(100) + assistant, err := LoadPath("/assistants/tests/mcptest") + require.NoError(t, err) + require.NotNil(t, assistant) -// // Test loading built-in assistants -// err := LoadBuiltIn() -// assert.NoError(t, err) + assert.Equal(t, "tests.mcptest", assistant.ID) + assert.Equal(t, "MCP Test Assistant", assistant.Name) + assert.Equal(t, "gpt-4o", assistant.Connector) -// // Verify Modi assistant was loaded -// assistant, exists := loaded.Get("modi") -// assert.True(t, exists, "Modi assistant should be loaded in cache") -// if exists { -// assert.Equal(t, "modi", assistant.ID) -// assert.Equal(t, "Modi", assistant.Name) -// assert.Equal(t, "deepseek", assistant.Connector) -// assert.True(t, assistant.BuiltIn) -// assert.True(t, assistant.Readonly) -// assert.NotNil(t, assistant.Prompts) -// assert.NotNil(t, assistant.Script) -// } + // MCP configuration + assert.NotNil(t, assistant.MCP) + assert.Len(t, assistant.MCP.Servers, 1) + assert.Equal(t, "echo", assistant.MCP.Servers[0].ServerID) -// } + // Locales + assert.NotNil(t, assistant.Locales) + assert.Contains(t, assistant.Locales, "en-us") + assert.Contains(t, assistant.Locales, "zh-cn") +} -// // mockStore implements store.Store interface for testing -// type mockStore struct { -// data map[string]map[string]interface{} -// } +// TestLoadPathBuildRequest tests loading the build request test assistant +func TestLoadPathBuildRequest(t *testing.T) { + prepare(t) + defer test.Clean() -// func (m *mockStore) GetAssistant(id string, locale ...string) (map[string]interface{}, error) { -// if data, ok := m.data[id]; ok { -// return data, nil -// } -// return nil, fmt.Errorf("assistant not found: %s", id) -// } + assistant, err := LoadPath("/assistants/tests/buildrequest") + require.NoError(t, err) + require.NotNil(t, assistant) -// // Add other required interface methods with empty implementations -// func (m *mockStore) GetThread(id string) (map[string]interface{}, error) { return nil, nil } -// func (m *mockStore) GetMessage(id string) (map[string]interface{}, error) { return nil, nil } -// func (m *mockStore) GetFile(id string) (map[string]interface{}, error) { return nil, nil } -// func (m *mockStore) CreateAssistant(data map[string]interface{}) (map[string]interface{}, error) { -// return nil, nil -// } -// func (m *mockStore) CreateThread(data map[string]interface{}) (map[string]interface{}, error) { -// return nil, nil -// } -// func (m *mockStore) CreateMessage(data map[string]interface{}) (map[string]interface{}, error) { -// return nil, nil -// } -// func (m *mockStore) CreateFile(data map[string]interface{}) (map[string]interface{}, error) { -// return nil, nil -// } -// func (m *mockStore) UpdateAssistant(id string, data map[string]interface{}) error { return nil } -// func (m *mockStore) UpdateThread(id string, data map[string]interface{}) error { return nil } -// func (m *mockStore) UpdateMessage(id string, data map[string]interface{}) error { return nil } -// func (m *mockStore) UpdateFile(id string, data map[string]interface{}) error { return nil } -// func (m *mockStore) DeleteAssistant(id string) error { return nil } -// func (m *mockStore) DeleteThread(id string) error { return nil } -// func (m *mockStore) DeleteMessage(id string) error { return nil } -// func (m *mockStore) DeleteFile(id string) error { return nil } -// func (m *mockStore) ListAssistants(query map[string]interface{}) ([]map[string]interface{}, error) { -// return nil, nil -// } -// func (m *mockStore) ListThreads(query map[string]interface{}) ([]map[string]interface{}, error) { -// return nil, nil -// } -// func (m *mockStore) ListMessages(query map[string]interface{}) ([]map[string]interface{}, error) { -// return nil, nil -// } -// func (m *mockStore) ListFiles(query map[string]interface{}) ([]map[string]interface{}, error) { -// return nil, nil -// } -// func (m *mockStore) DeleteAllChats(id string) error { return nil } -// func (m *mockStore) DeleteChat(id string, chatID string) error { return nil } -// func (m *mockStore) GetAssistants(filter store.AssistantFilter, locale ...string) (*store.AssistantResponse, error) { -// return nil, nil -// } -// func (m *mockStore) GetChat(id string, chatID string, locale ...string) (*store.ChatInfo, error) { -// return nil, nil -// } -// func (m *mockStore) GetChatWithFilter(id string, chatID string, filter store.ChatFilter, locale ...string) (*store.ChatInfo, error) { -// return nil, nil -// } -// func (m *mockStore) GetChats(id string, filter store.ChatFilter, locale ...string) (*store.ChatGroupResponse, error) { -// return nil, nil -// } -// func (m *mockStore) GetHistory(id string, chatID string, locale ...string) ([]map[string]interface{}, error) { -// return nil, nil -// } -// func (m *mockStore) GetHistoryWithFilter(id string, chatID string, filter store.ChatFilter, locale ...string) ([]map[string]interface{}, error) { -// return nil, nil -// } -// func (m *mockStore) SaveAssistant(assistant map[string]interface{}) (interface{}, error) { -// return nil, nil -// } -// func (m *mockStore) SaveHistory(sid string, messages []map[string]interface{}, cid string, context map[string]interface{}) error { -// return nil -// } -// func (m *mockStore) UpdateChatTitle(sid string, cid string, title string) error { return nil } -// func (m *mockStore) DeleteAssistants(filter store.AssistantFilter) (int64, error) { return 0, nil } -// func (m *mockStore) GetAssistantTags(locale ...string) ([]store.Tag, error) { -// return []store.Tag{}, nil -// } + assert.Equal(t, "tests.buildrequest", assistant.ID) + assert.Equal(t, "Build Request Test", assistant.Name) -// // Attachment related methods -// func (m *mockStore) SaveAttachment(attachment map[string]interface{}) (interface{}, error) { -// return attachment["file_id"], nil -// } + // Script should be loaded + assert.NotNil(t, assistant.Script) -// func (m *mockStore) DeleteAttachment(fileID string) error { -// return nil -// } + // Options + assert.NotNil(t, assistant.Options) + assert.Equal(t, 0.5, assistant.Options["temperature"]) +} -// func (m *mockStore) GetAttachments(filter store.AttachmentFilter, locale ...string) (*store.AttachmentResponse, error) { -// return &store.AttachmentResponse{}, nil -// } +// TestCache tests the assistant cache functionality +func TestCache(t *testing.T) { + // Clear any existing cache + ClearCache() -// func (m *mockStore) GetAttachment(fileID string, locale ...string) (map[string]interface{}, error) { -// return nil, nil -// } + // Set small cache for testing + SetCache(3) + assert.NotNil(t, loaded) -// func (m *mockStore) DeleteAttachments(filter store.AttachmentFilter) (int64, error) { -// return 0, nil -// } + // Create test assistants + ast1 := &Assistant{AssistantModel: store.AssistantModel{ID: "id1", Name: "Assistant 1"}} + ast2 := &Assistant{AssistantModel: store.AssistantModel{ID: "id2", Name: "Assistant 2"}} + ast3 := &Assistant{AssistantModel: store.AssistantModel{ID: "id3", Name: "Assistant 3"}} + ast4 := &Assistant{AssistantModel: store.AssistantModel{ID: "id4", Name: "Assistant 4"}} -// // Knowledge related methods -// func (m *mockStore) SaveKnowledge(knowledge map[string]interface{}) (interface{}, error) { -// return knowledge["collection_id"], nil -// } + t.Run("PutAndGet", func(t *testing.T) { + loaded.Put(ast1) + assert.Equal(t, 1, loaded.Len()) -// func (m *mockStore) DeleteKnowledge(collectionID string) error { -// return nil -// } + cached, exists := loaded.Get("id1") + assert.True(t, exists) + assert.Equal(t, ast1, cached) + }) -// func (m *mockStore) GetKnowledges(filter store.KnowledgeFilter, locale ...string) (*store.KnowledgeResponse, error) { -// return &store.KnowledgeResponse{}, nil -// } + t.Run("CacheEviction", func(t *testing.T) { + loaded.Put(ast2) + loaded.Put(ast3) + assert.Equal(t, 3, loaded.Len()) -// func (m *mockStore) GetKnowledge(collectionID string, locale ...string) (map[string]interface{}, error) { -// return nil, nil -// } + // Access ast1 to make it recently used + loaded.Get("id1") -// func (m *mockStore) DeleteKnowledges(filter store.KnowledgeFilter) (int64, error) { -// return 0, nil -// } + // Add ast4, should evict ast2 (least recently used) + loaded.Put(ast4) + assert.Equal(t, 3, loaded.Len()) -// // Close closes the store and releases any resources -// func (m *mockStore) Close() error { -// return nil -// } + _, exists := loaded.Get("id2") + assert.False(t, exists, "ast2 should be evicted") + + _, exists = loaded.Get("id1") + assert.True(t, exists, "ast1 should still exist") + + _, exists = loaded.Get("id4") + assert.True(t, exists, "ast4 should exist") + }) + + t.Run("ClearCache", func(t *testing.T) { + ClearCache() + assert.Nil(t, loaded) + }) + + t.Run("SetCacheAfterClear", func(t *testing.T) { + SetCache(100) + assert.NotNil(t, loaded) + }) +} + +// TestClone tests the assistant Clone method +func TestClone(t *testing.T) { + prepare(t) + defer test.Clean() + + t.Run("CloneFullFieldsAssistant", func(t *testing.T) { + original, err := LoadPath("/assistants/tests/fullfields") + require.NoError(t, err) + + clone := original.Clone() + require.NotNil(t, clone) + + // Basic fields should be equal + assert.Equal(t, original.ID, clone.ID) + assert.Equal(t, original.Name, clone.Name) + assert.Equal(t, original.Type, clone.Type) + assert.Equal(t, original.Connector, clone.Connector) + assert.Equal(t, original.Description, clone.Description) + + // Verify deep copy - modifying original should not affect clone + if len(original.Tags) > 0 { + originalTag := original.Tags[0] + original.Tags[0] = "modified" + assert.NotEqual(t, original.Tags[0], clone.Tags[0]) + original.Tags[0] = originalTag // restore + } + + if original.Options != nil { + original.Options["test_key"] = "test_value" + _, exists := clone.Options["test_key"] + assert.False(t, exists, "Clone should not have modified key") + delete(original.Options, "test_key") // cleanup + } + }) + + t.Run("CloneNil", func(t *testing.T) { + var nilAssistant *Assistant + assert.Nil(t, nilAssistant.Clone()) + }) +} + +// TestUpdate tests the assistant Update method +func TestUpdate(t *testing.T) { + prepare(t) + defer test.Clean() + + t.Run("UpdateBasicFields", func(t *testing.T) { + assistant, err := LoadPath("/assistants/tests/fullfields") + require.NoError(t, err) + + updates := map[string]interface{}{ + "name": "Updated Name", + "description": "Updated description", + "tags": []string{"updated", "tags"}, + } + + err = assistant.Update(updates) + require.NoError(t, err) + + assert.Equal(t, "Updated Name", assistant.Name) + assert.Equal(t, "Updated description", assistant.Description) + assert.Equal(t, []string{"updated", "tags"}, assistant.Tags) + }) + + t.Run("UpdateConnectorOptions", func(t *testing.T) { + assistant, err := LoadPath("/assistants/tests/fullfields") + require.NoError(t, err) + + updates := map[string]interface{}{ + "connector_options": map[string]interface{}{ + "optional": false, + "connectors": []string{"new-connector"}, + }, + } + + err = assistant.Update(updates) + require.NoError(t, err) + + assert.NotNil(t, assistant.ConnectorOptions) + assert.False(t, assistant.ConnectorOptions.Optional) + assert.Contains(t, assistant.ConnectorOptions.Connectors, "new-connector") + }) + + t.Run("UpdatePromptPresets", func(t *testing.T) { + assistant, err := LoadPath("/assistants/tests/fullfields") + require.NoError(t, err) + + updates := map[string]interface{}{ + "prompt_presets": map[string]interface{}{ + "custom": []map[string]interface{}{ + {"role": "system", "content": "Custom preset"}, + }, + }, + } + + err = assistant.Update(updates) + require.NoError(t, err) + + assert.NotNil(t, assistant.PromptPresets) + customPreset, exists := assistant.PromptPresets["custom"] + assert.True(t, exists) + assert.Len(t, customPreset, 1) + }) + + t.Run("UpdateSource", func(t *testing.T) { + assistant, err := LoadPath("/assistants/tests/fullfields") + require.NoError(t, err) + + updates := map[string]interface{}{ + "source": "function Create(ctx, messages) { return { messages: messages }; }", + } + + err = assistant.Update(updates) + require.NoError(t, err) + + assert.Equal(t, "function Create(ctx, messages) { return { messages: messages }; }", assistant.Source) + }) + + t.Run("UpdateNilAssistant", func(t *testing.T) { + var nilAssistant *Assistant + err := nilAssistant.Update(map[string]interface{}{"name": "test"}) + assert.Error(t, err) + }) +} + +// TestMap tests the assistant Map method +func TestMap(t *testing.T) { + prepare(t) + defer test.Clean() + + assistant, err := LoadPath("/assistants/tests/fullfields") + require.NoError(t, err) + + m := assistant.Map() + require.NotNil(t, m) + + // Check all fields are present + assert.Equal(t, assistant.ID, m["assistant_id"]) + assert.Equal(t, assistant.Name, m["name"]) + assert.Equal(t, assistant.Type, m["type"]) + assert.Equal(t, assistant.Connector, m["connector"]) + assert.Equal(t, assistant.Description, m["description"]) + assert.Equal(t, assistant.Path, m["path"]) + assert.Equal(t, assistant.Tags, m["tags"]) + assert.Equal(t, assistant.Options, m["options"]) + assert.Equal(t, assistant.Prompts, m["prompts"]) + assert.Equal(t, assistant.KB, m["kb"]) + assert.Equal(t, assistant.MCP, m["mcp"]) + assert.Equal(t, assistant.Workflow, m["workflow"]) + assert.Equal(t, assistant.Placeholder, m["placeholder"]) + assert.Equal(t, assistant.Locales, m["locales"]) + + // New fields + assert.Equal(t, assistant.ConnectorOptions, m["connector_options"]) + assert.Equal(t, assistant.PromptPresets, m["prompt_presets"]) + assert.Equal(t, assistant.Source, m["source"]) +} + +// TestValidate tests the assistant Validate method +func TestValidate(t *testing.T) { + tests := []struct { + name string + ast *Assistant + wantErr bool + }{ + { + name: "ValidAssistant", + ast: &Assistant{ + AssistantModel: store.AssistantModel{ + ID: "test-id", + Name: "Test Assistant", + Connector: "gpt-4o", + }, + }, + wantErr: false, + }, + { + name: "MissingID", + ast: &Assistant{ + AssistantModel: store.AssistantModel{ + Name: "Test Assistant", + Connector: "gpt-4o", + }, + }, + wantErr: true, + }, + { + name: "MissingName", + ast: &Assistant{ + AssistantModel: store.AssistantModel{ + ID: "test-id", + Connector: "gpt-4o", + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.ast.Validate() + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/agent/assistant/source.go b/agent/assistant/source.go new file mode 100644 index 00000000..a218ffb9 --- /dev/null +++ b/agent/assistant/source.go @@ -0,0 +1,36 @@ +package assistant + +import ( + "fmt" + "time" + + v8 "github.com/yaoapp/gou/runtime/v8" + "github.com/yaoapp/yao/agent/assistant/hook" +) + +// loadSource loads hook script from source code string +// The source field stores TypeScript code directly +// Priority: script field > source field (if script exists, source is ignored) +func loadSource(source string, assistantID string) (*hook.Script, error) { + if source == "" { + return nil, nil + } + + // Generate a virtual file path for the script + file := fmt.Sprintf("assistants/%s/source.ts", assistantID) + + script, err := v8.MakeScript([]byte(source), file, 5*time.Second, true) + if err != nil { + return nil, fmt.Errorf("failed to compile source script: %w", err) + } + + return &hook.Script{Script: script}, nil +} + +// TODO: Future enhancement - support multiple files merged with special comment delimiter +// Format: // file: index.ts +// This would allow splitting large scripts into multiple logical files while storing as single source +// func loadSourceMultiFile(source string, assistantID string) (*hook.Script, error) { +// // Parse source by "// file: xxx.ts" delimiter +// // Merge and compile +// } diff --git a/agent/store/types/convert.go b/agent/store/types/convert.go index d8a59b44..4c39859c 100644 --- a/agent/store/types/convert.go +++ b/agent/store/types/convert.go @@ -462,3 +462,56 @@ func ParseModelID(modelID string) string { } return parts[len(parts)-1] } + +// ToConnectorOptions converts various types to ConnectorOptions +func ToConnectorOptions(v interface{}) (*ConnectorOptions, error) { + if v == nil { + return nil, nil + } + + switch opts := v.(type) { + case *ConnectorOptions: + return opts, nil + + case ConnectorOptions: + return &opts, nil + + default: + raw, err := jsoniter.Marshal(opts) + if err != nil { + return nil, fmt.Errorf("connector_options format error: %s", err.Error()) + } + + var connOpts ConnectorOptions + err = jsoniter.Unmarshal(raw, &connOpts) + if err != nil { + return nil, fmt.Errorf("connector_options format error: %s", err.Error()) + } + return &connOpts, nil + } +} + +// ToPromptPresets converts various types to map[string][]Prompt +func ToPromptPresets(v interface{}) (map[string][]Prompt, error) { + if v == nil { + return nil, nil + } + + switch presets := v.(type) { + case map[string][]Prompt: + return presets, nil + + default: + raw, err := jsoniter.Marshal(presets) + if err != nil { + return nil, fmt.Errorf("prompt_presets format error: %s", err.Error()) + } + + var result map[string][]Prompt + err = jsoniter.Unmarshal(raw, &result) + if err != nil { + return nil, fmt.Errorf("prompt_presets format error: %s", err.Error()) + } + return result, nil + } +} diff --git a/agent/store/types/convert_test.go b/agent/store/types/convert_test.go index 3fbfce1f..67c97aa9 100644 --- a/agent/store/types/convert_test.go +++ b/agent/store/types/convert_test.go @@ -1148,6 +1148,273 @@ func TestModelID(t *testing.T) { }) } +// TestToConnectorOptions tests the ToConnectorOptions conversion function +func TestToConnectorOptions(t *testing.T) { + t.Run("NilInput", func(t *testing.T) { + result, err := ToConnectorOptions(nil) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if result != nil { + t.Errorf("Expected nil result, got: %v", result) + } + }) + + t.Run("ConnectorOptionsPointer", func(t *testing.T) { + opts := &ConnectorOptions{ + Optional: true, + Connectors: []string{"openai", "anthropic"}, + Filters: []ModelCapability{CapVision, CapToolCalls}, + } + result, err := ToConnectorOptions(opts) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if result != opts { + t.Errorf("Expected same pointer") + } + }) + + t.Run("ConnectorOptionsValue", func(t *testing.T) { + opts := ConnectorOptions{ + Optional: true, + Connectors: []string{"openai", "anthropic"}, + Filters: []ModelCapability{CapVision, CapToolCalls}, + } + result, err := ToConnectorOptions(opts) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if !result.Optional { + t.Error("Expected Optional to be true") + } + if len(result.Connectors) != 2 { + t.Errorf("Expected 2 connectors, got %d", len(result.Connectors)) + } + if len(result.Filters) != 2 { + t.Errorf("Expected 2 filters, got %d", len(result.Filters)) + } + }) + + t.Run("MapInput", func(t *testing.T) { + data := map[string]interface{}{ + "optional": true, + "connectors": []string{"openai", "anthropic", "azure"}, + "filters": []string{"vision", "tool_calls", "audio"}, + } + result, err := ToConnectorOptions(data) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if !result.Optional { + t.Error("Expected Optional to be true") + } + if len(result.Connectors) != 3 { + t.Errorf("Expected 3 connectors, got %d", len(result.Connectors)) + } + if len(result.Filters) != 3 { + t.Errorf("Expected 3 filters, got %d", len(result.Filters)) + } + }) + + t.Run("MapInputOptionalOnly", func(t *testing.T) { + data := map[string]interface{}{ + "optional": true, + } + result, err := ToConnectorOptions(data) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if !result.Optional { + t.Error("Expected Optional to be true") + } + if result.Connectors != nil { + t.Error("Expected Connectors to be nil") + } + if result.Filters != nil { + t.Error("Expected Filters to be nil") + } + }) + + t.Run("InvalidInput", func(t *testing.T) { + // Test with data that can't be marshaled + invalidData := make(chan int) + _, err := ToConnectorOptions(invalidData) + if err == nil { + t.Error("Expected error for invalid input") + } + }) + + t.Run("InvalidJSONUnmarshal", func(t *testing.T) { + // Test with data that marshals but can't unmarshal to ConnectorOptions + data := map[string]interface{}{ + "invalid_field": "should cause unmarshal to fail gracefully", + } + result, err := ToConnectorOptions(data) + // Should not error, just return empty ConnectorOptions + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if result == nil { + t.Error("Expected non-nil result") + } + }) +} + +// TestToPromptPresets tests the ToPromptPresets conversion function +func TestToPromptPresets(t *testing.T) { + t.Run("NilInput", func(t *testing.T) { + result, err := ToPromptPresets(nil) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if result != nil { + t.Errorf("Expected nil result, got: %v", result) + } + }) + + t.Run("MapStringPromptSlice", func(t *testing.T) { + presets := map[string][]Prompt{ + "chat": { + {Role: "system", Content: "You are a chat assistant"}, + }, + "task": { + {Role: "system", Content: "You are a task assistant"}, + }, + } + result, err := ToPromptPresets(presets) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if len(result) != 2 { + t.Errorf("Expected 2 presets, got %d", len(result)) + } + if len(result["chat"]) != 1 { + t.Errorf("Expected 1 chat prompt, got %d", len(result["chat"])) + } + if len(result["task"]) != 1 { + t.Errorf("Expected 1 task prompt, got %d", len(result["task"])) + } + }) + + t.Run("MapInput", func(t *testing.T) { + data := map[string]interface{}{ + "chat": []interface{}{ + map[string]interface{}{"role": "system", "content": "Chat mode system prompt"}, + map[string]interface{}{"role": "user", "content": "Example user message"}, + }, + "task": []interface{}{ + map[string]interface{}{"role": "system", "content": "Task mode system prompt"}, + }, + "analyze": []interface{}{ + map[string]interface{}{"role": "system", "content": "Analyze mode system prompt"}, + }, + } + result, err := ToPromptPresets(data) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if len(result) != 3 { + t.Errorf("Expected 3 presets, got %d", len(result)) + } + if len(result["chat"]) != 2 { + t.Errorf("Expected 2 chat prompts, got %d", len(result["chat"])) + } + if len(result["task"]) != 1 { + t.Errorf("Expected 1 task prompt, got %d", len(result["task"])) + } + if len(result["analyze"]) != 1 { + t.Errorf("Expected 1 analyze prompt, got %d", len(result["analyze"])) + } + }) + + t.Run("EmptyMap", func(t *testing.T) { + data := map[string]interface{}{} + result, err := ToPromptPresets(data) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if result == nil { + t.Error("Expected non-nil result") + } + if len(result) != 0 { + t.Errorf("Expected empty map, got %d entries", len(result)) + } + }) + + t.Run("SinglePreset", func(t *testing.T) { + data := map[string]interface{}{ + "default": []interface{}{ + map[string]interface{}{"role": "system", "content": "Default prompt"}, + }, + } + result, err := ToPromptPresets(data) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if len(result) != 1 { + t.Errorf("Expected 1 preset, got %d", len(result)) + } + if _, ok := result["default"]; !ok { + t.Error("Expected 'default' key in result") + } + }) + + t.Run("InvalidInput", func(t *testing.T) { + // Test with data that can't be marshaled + invalidData := make(chan int) + _, err := ToPromptPresets(invalidData) + if err == nil { + t.Error("Expected error for invalid input") + } + }) + + t.Run("InvalidJSONUnmarshal", func(t *testing.T) { + // Test with data that marshals but can't unmarshal to map[string][]Prompt + // This is a string that can be marshaled but won't unmarshal to the expected type + data := "not a map" + _, err := ToPromptPresets(data) + if err == nil { + t.Error("Expected error for invalid JSON unmarshal") + } + }) + + t.Run("PromptWithAllFields", func(t *testing.T) { + data := map[string]interface{}{ + "advanced": []interface{}{ + map[string]interface{}{ + "role": "system", + "content": "Advanced system prompt", + "name": "system-prompt", + }, + map[string]interface{}{ + "role": "user", + "content": "User example", + "name": "user-example", + }, + map[string]interface{}{ + "role": "assistant", + "content": "Assistant response", + "name": "assistant-response", + }, + }, + } + result, err := ToPromptPresets(data) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if len(result["advanced"]) != 3 { + t.Errorf("Expected 3 prompts in advanced, got %d", len(result["advanced"])) + } + if result["advanced"][0].Role != "system" { + t.Errorf("Expected role 'system', got '%s'", result["advanced"][0].Role) + } + if result["advanced"][0].Content != "Advanced system prompt" { + t.Errorf("Expected content 'Advanced system prompt', got '%s'", result["advanced"][0].Content) + } + }) +} + // TestParseModelID tests the ParseModelID function func TestParseModelID(t *testing.T) { t.Run("ValidModelID", func(t *testing.T) {