diff --git a/agent/assistant/load.go b/agent/assistant/load.go index e62b6789..c6c8d524 100644 --- a/agent/assistant/load.go +++ b/agent/assistant/load.go @@ -131,6 +131,11 @@ func SetStorage(s store.Store) { storage = s } +// GetStorage returns the storage (for testing purposes) +func GetStorage() store.Store { + return storage +} + // SetModelCapabilities set the model capabilities configuration func SetModelCapabilities(capabilities map[string]gouOpenAI.Capabilities) { modelCapabilities = capabilities @@ -214,6 +219,15 @@ func LoadStore(id string) (*Assistant, error) { // Create assistant from store model assistant = &Assistant{AssistantModel: *storeModel} + // Load script from source field if present + if assistant.Source != "" { + script, err := loadSource(assistant.Source, assistant.ID) + if err != nil { + return nil, err + } + assistant.Script = script + } + // Initialize the assistant err = assistant.initialize() if err != nil { diff --git a/agent/assistant/load_store_test.go b/agent/assistant/load_store_test.go new file mode 100644 index 00000000..3eaba542 --- /dev/null +++ b/agent/assistant/load_store_test.go @@ -0,0 +1,937 @@ +package assistant_test + +import ( + stdContext "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yaoapp/yao/agent/assistant" + "github.com/yaoapp/yao/agent/context" + store "github.com/yaoapp/yao/agent/store/types" + "github.com/yaoapp/yao/agent/testutils" + "github.com/yaoapp/yao/openapi/oauth/types" +) + +// TestLoadStoreWithSource tests loading assistant from database with Source field +func TestLoadStoreWithSource(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean(t) + + // Create assistant with Source + assistantID := "test.store-with-source" + now := time.Now().UnixNano() + + ast := &assistant.Assistant{ + AssistantModel: store.AssistantModel{ + ID: assistantID, + Name: "Test Assistant With Source", + Type: "assistant", + Connector: "gpt-4o", + Description: "Test assistant loaded from store with source code", + Prompts: []store.Prompt{ + {Role: "system", Content: "You are a helpful assistant."}, + }, + Options: map[string]interface{}{ + "temperature": 0.7, + }, + Tags: []string{"Test", "Source"}, + // Simple Create hook that returns null + Source: ` +// @ts-nocheck +function Create(ctx, messages) { + return null; +} +`, + CreatedAt: now, + UpdatedAt: now, + }, + } + + // Save to database + err := ast.Save() + require.NoError(t, err) + + // Cleanup after test + defer func() { + storage := assistant.GetStorage() + if storage != nil { + storage.DeleteAssistant(assistantID) + } + assistant.GetCache().Clear() + }() + + // Clear cache to ensure fresh load from database + assistant.GetCache().Clear() + + // Load from store + loaded, err := assistant.Get(assistantID) + require.NoError(t, err) + require.NotNil(t, loaded) + + // Verify basic fields + assert.Equal(t, assistantID, loaded.ID) + assert.Equal(t, "Test Assistant With Source", loaded.Name) + assert.Equal(t, "assistant", loaded.Type) + assert.Equal(t, "Test assistant loaded from store with source code", loaded.Description) + + // Verify prompts + require.NotNil(t, loaded.Prompts) + assert.Len(t, loaded.Prompts, 1) + assert.Equal(t, "system", loaded.Prompts[0].Role) + assert.Equal(t, "You are a helpful assistant.", loaded.Prompts[0].Content) + + // Verify options + assert.NotNil(t, loaded.Options) + assert.Equal(t, 0.7, loaded.Options["temperature"]) + + // Verify tags + assert.NotNil(t, loaded.Tags) + assert.Contains(t, loaded.Tags, "Test") + assert.Contains(t, loaded.Tags, "Source") + + // Verify script was compiled from source + assert.NotNil(t, loaded.Script, "Script should be compiled from Source field") + + // Verify source is stored + assert.NotEmpty(t, loaded.Source) +} + +// TestLoadStoreWithoutSource tests loading assistant from database without Source field +func TestLoadStoreWithoutSource(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean(t) + + // Create assistant without Source + assistantID := "test.store-without-source" + now := time.Now().UnixNano() + + ast := &assistant.Assistant{ + AssistantModel: store.AssistantModel{ + ID: assistantID, + Name: "Test Assistant Without Source", + Type: "assistant", + Connector: "gpt-4o", + Description: "Test assistant loaded from store without source code", + Prompts: []store.Prompt{ + {Role: "system", Content: "You are a helpful assistant without hooks."}, + }, + Options: map[string]interface{}{ + "temperature": 0.5, + "max_tokens": 1000, + }, + Tags: []string{"Test", "NoSource"}, + CreatedAt: now, + UpdatedAt: now, + }, + } + + // Save to database + err := ast.Save() + require.NoError(t, err) + + // Cleanup after test + defer func() { + storage := assistant.GetStorage() + if storage != nil { + storage.DeleteAssistant(assistantID) + } + assistant.GetCache().Clear() + }() + + // Clear cache to ensure fresh load from database + assistant.GetCache().Clear() + + // Load from store + loaded, err := assistant.Get(assistantID) + require.NoError(t, err) + require.NotNil(t, loaded) + + // Verify basic fields + assert.Equal(t, assistantID, loaded.ID) + assert.Equal(t, "Test Assistant Without Source", loaded.Name) + assert.Equal(t, "assistant", loaded.Type) + assert.Equal(t, "Test assistant loaded from store without source code", loaded.Description) + + // Verify prompts + require.NotNil(t, loaded.Prompts) + assert.Len(t, loaded.Prompts, 1) + assert.Equal(t, "system", loaded.Prompts[0].Role) + + // Verify options + assert.NotNil(t, loaded.Options) + assert.Equal(t, 0.5, loaded.Options["temperature"]) + assert.Equal(t, float64(1000), loaded.Options["max_tokens"]) + + // Verify tags + assert.NotNil(t, loaded.Tags) + assert.Contains(t, loaded.Tags, "Test") + assert.Contains(t, loaded.Tags, "NoSource") + + // Verify script is nil (no source) + assert.Nil(t, loaded.Script, "Script should be nil when no Source field") + assert.Empty(t, loaded.Source) +} + +// newStoreTestContext creates a Context for testing with commonly used fields pre-populated. +func newStoreTestContext(chatID, assistantID string) *context.Context { + return &context.Context{ + Context: stdContext.Background(), + ChatID: chatID, + AssistantID: assistantID, + Connector: "", + Locale: "en-us", + Theme: "light", + Client: context.Client{ + Type: "web", + UserAgent: "TestAgent/1.0", + IP: "127.0.0.1", + }, + Referer: context.RefererAPI, + Accept: context.AcceptWebCUI, + Route: "", + Metadata: make(map[string]interface{}), + Authorized: &types.AuthorizedInfo{ + Subject: "test-user", + ClientID: "test-client-id", + Scope: "openid profile email", + SessionID: "test-session-id", + UserID: "test-user-123", + TeamID: "test-team-456", + TenantID: "test-tenant-789", + }, + } +} + +// TestLoadStoreWithSourceExecuteHook tests that Source-based script is properly compiled and can execute +func TestLoadStoreWithSourceExecuteHook(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean(t) + + // Create assistant with a working Create hook + assistantID := "test.store-source-hook" + now := time.Now().UnixNano() + + ast := &assistant.Assistant{ + AssistantModel: store.AssistantModel{ + ID: assistantID, + Name: "Test Source Hook", + Type: "assistant", + Connector: "gpt-4o", + Prompts: []store.Prompt{ + {Role: "system", Content: "Default prompt"}, + }, + // Create hook that modifies temperature and adds metadata + Source: ` +// @ts-nocheck +function Create(ctx: any, messages: any[]): any { + return { + temperature: 0.9, + metadata: { + hook_executed: true, + chat_id: ctx.chat_id + } + }; +} +`, + CreatedAt: now, + UpdatedAt: now, + }, + } + + // Save to database + err := ast.Save() + require.NoError(t, err) + + // Cleanup after test + defer func() { + storage := assistant.GetStorage() + if storage != nil { + storage.DeleteAssistant(assistantID) + } + assistant.GetCache().Clear() + }() + + // Clear cache + assistant.GetCache().Clear() + + // Load from store + loaded, err := assistant.Get(assistantID) + require.NoError(t, err) + require.NotNil(t, loaded) + require.NotNil(t, loaded.Script, "Script should be compiled from Source") + + // Verify the script object exists and is usable + assert.NotNil(t, loaded.Script.Script) + + // Execute the Create hook + ctx := newStoreTestContext("test-chat-id", assistantID) + messages := []context.Message{{Role: "user", Content: "Hello"}} + + res, err := loaded.Script.Create(ctx, messages) + require.NoError(t, err, "Create hook should execute without error") + require.NotNil(t, res, "Create hook should return a response") + + // Verify temperature was set + require.NotNil(t, res.Temperature, "Temperature should be set") + assert.Equal(t, 0.9, *res.Temperature, "Temperature should be 0.9") + + // Verify metadata was set + require.NotNil(t, res.Metadata, "Metadata should be set") + assert.Equal(t, true, res.Metadata["hook_executed"], "hook_executed should be true") + assert.Equal(t, "test-chat-id", res.Metadata["chat_id"], "chat_id should match context") +} + +// TestLoadStoreWithPromptPresets tests loading assistant with prompt presets from database +func TestLoadStoreWithPromptPresets(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean(t) + + assistantID := "test.store-with-presets" + now := time.Now().UnixNano() + + ast := &assistant.Assistant{ + AssistantModel: store.AssistantModel{ + ID: assistantID, + Name: "Test With Presets", + Type: "assistant", + Connector: "gpt-4o", + Prompts: []store.Prompt{ + {Role: "system", Content: "Default prompt"}, + }, + PromptPresets: map[string][]store.Prompt{ + "friendly": { + {Role: "system", Content: "You are a friendly assistant."}, + }, + "professional": { + {Role: "system", Content: "You are a professional assistant."}, + }, + "mode.casual": { + {Role: "system", Content: "You are a casual assistant."}, + }, + }, + CreatedAt: now, + UpdatedAt: now, + }, + } + + err := ast.Save() + require.NoError(t, err) + + defer func() { + storage := assistant.GetStorage() + if storage != nil { + storage.DeleteAssistant(assistantID) + } + assistant.GetCache().Clear() + }() + + assistant.GetCache().Clear() + + loaded, err := assistant.Get(assistantID) + require.NoError(t, err) + require.NotNil(t, loaded) + + // Verify prompt presets + require.NotNil(t, loaded.PromptPresets) + assert.Len(t, loaded.PromptPresets, 3) + + friendlyPreset, ok := loaded.PromptPresets["friendly"] + assert.True(t, ok) + assert.Len(t, friendlyPreset, 1) + assert.Equal(t, "You are a friendly assistant.", friendlyPreset[0].Content) + + professionalPreset, ok := loaded.PromptPresets["professional"] + assert.True(t, ok) + assert.Len(t, professionalPreset, 1) + assert.Equal(t, "You are a professional assistant.", professionalPreset[0].Content) + + casualPreset, ok := loaded.PromptPresets["mode.casual"] + assert.True(t, ok) + assert.Len(t, casualPreset, 1) + assert.Equal(t, "You are a casual assistant.", casualPreset[0].Content) +} + +// TestLoadStoreWithDisableGlobalPrompts tests loading assistant with disable_global_prompts flag +func TestLoadStoreWithDisableGlobalPrompts(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean(t) + + assistantID := "test.store-disable-global" + now := time.Now().UnixNano() + + ast := &assistant.Assistant{ + AssistantModel: store.AssistantModel{ + ID: assistantID, + Name: "Test Disable Global Prompts", + Type: "assistant", + Connector: "gpt-4o", + DisableGlobalPrompts: true, + Prompts: []store.Prompt{ + {Role: "system", Content: "Only this prompt should be used."}, + }, + CreatedAt: now, + UpdatedAt: now, + }, + } + + err := ast.Save() + require.NoError(t, err) + + defer func() { + storage := assistant.GetStorage() + if storage != nil { + storage.DeleteAssistant(assistantID) + } + assistant.GetCache().Clear() + }() + + assistant.GetCache().Clear() + + loaded, err := assistant.Get(assistantID) + require.NoError(t, err) + require.NotNil(t, loaded) + + assert.True(t, loaded.DisableGlobalPrompts) +} + +// TestLoadStoreCaching tests that loaded assistants are cached +func TestLoadStoreCaching(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean(t) + + assistantID := "test.store-caching" + now := time.Now().UnixNano() + + ast := &assistant.Assistant{ + AssistantModel: store.AssistantModel{ + ID: assistantID, + Name: "Test Caching", + Type: "assistant", + Connector: "gpt-4o", + CreatedAt: now, + UpdatedAt: now, + }, + } + + err := ast.Save() + require.NoError(t, err) + + defer func() { + storage := assistant.GetStorage() + if storage != nil { + storage.DeleteAssistant(assistantID) + } + assistant.GetCache().Clear() + }() + + assistant.GetCache().Clear() + + // First load + ast1, err := assistant.Get(assistantID) + require.NoError(t, err) + require.NotNil(t, ast1) + + // Second load - should be from cache + ast2, err := assistant.Get(assistantID) + require.NoError(t, err) + require.NotNil(t, ast2) + + // Should be the same instance (from cache) + assert.Same(t, ast1, ast2) +} + +// TestLoadStoreNotFound tests loading non-existent assistant +func TestLoadStoreNotFound(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean(t) + + assistant.GetCache().Clear() + + _, err := assistant.Get("non-existent-assistant-id-12345") + assert.Error(t, err) +} + +// TestLoadStoreWithAllFields tests loading assistant with comprehensive fields +func TestLoadStoreWithAllFields(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean(t) + + assistantID := "test.store-all-fields" + now := time.Now().UnixNano() + + ast := &assistant.Assistant{ + AssistantModel: store.AssistantModel{ + ID: assistantID, + Name: "Test All Fields", + Type: "assistant", + Avatar: "/api/icons/test.png", + Connector: "gpt-4o", + Description: "Test assistant with all fields", + Tags: []string{"Test", "AllFields", "Complete"}, + Readonly: true, + Public: true, + Share: "team", + Mentionable: true, + Automated: false, + Sort: 100, + Options: map[string]interface{}{ + "temperature": 0.8, + "max_tokens": 2000, + }, + Prompts: []store.Prompt{ + {Role: "system", Content: "You are a test assistant."}, + {Role: "system", Content: "Follow all instructions carefully."}, + }, + PromptPresets: map[string][]store.Prompt{ + "default": { + {Role: "system", Content: "Default mode prompt."}, + }, + }, + DisableGlobalPrompts: true, + Placeholder: &store.Placeholder{ + Title: "Test Placeholder", + Description: "This is a test placeholder", + Prompts: []string{"Test prompt 1", "Test prompt 2"}, + }, + Source: ` +// @ts-nocheck +function Create(ctx: any, messages: any[]): any { + return { + temperature: 0.5, + metadata: { + assistant_name: "Test All Fields", + executed: true + } + }; +} +`, + CreatedAt: now, + UpdatedAt: now, + }, + } + + err := ast.Save() + require.NoError(t, err) + + defer func() { + storage := assistant.GetStorage() + if storage != nil { + storage.DeleteAssistant(assistantID) + } + assistant.GetCache().Clear() + }() + + assistant.GetCache().Clear() + + loaded, err := assistant.Get(assistantID) + require.NoError(t, err) + require.NotNil(t, loaded) + + // Verify all fields + assert.Equal(t, assistantID, loaded.ID) + assert.Equal(t, "Test All Fields", loaded.Name) + assert.Equal(t, "assistant", loaded.Type) + assert.Equal(t, "/api/icons/test.png", loaded.Avatar) + assert.Equal(t, "Test assistant with all fields", loaded.Description) + + // Boolean fields + assert.True(t, loaded.Readonly) + assert.True(t, loaded.Public) + assert.Equal(t, "team", loaded.Share) + assert.True(t, loaded.Mentionable) + assert.False(t, loaded.Automated) + assert.True(t, loaded.DisableGlobalPrompts) + assert.Equal(t, 100, loaded.Sort) + + // Tags + assert.Len(t, loaded.Tags, 3) + assert.Contains(t, loaded.Tags, "Test") + assert.Contains(t, loaded.Tags, "AllFields") + assert.Contains(t, loaded.Tags, "Complete") + + // Options + assert.Equal(t, 0.8, loaded.Options["temperature"]) + assert.Equal(t, float64(2000), loaded.Options["max_tokens"]) + + // Prompts + assert.Len(t, loaded.Prompts, 2) + + // Prompt presets + assert.NotNil(t, loaded.PromptPresets) + assert.Contains(t, loaded.PromptPresets, "default") + + // Placeholder + assert.NotNil(t, loaded.Placeholder) + assert.Equal(t, "Test Placeholder", loaded.Placeholder.Title) + assert.Equal(t, "This is a test placeholder", loaded.Placeholder.Description) + assert.Len(t, loaded.Placeholder.Prompts, 2) + + // Script from source + assert.NotNil(t, loaded.Script) + assert.NotEmpty(t, loaded.Source) + + // Execute the Create hook to verify it works + ctx := newStoreTestContext("test-chat-all-fields", assistantID) + messages := []context.Message{{Role: "user", Content: "Test message"}} + + res, err := loaded.Script.Create(ctx, messages) + require.NoError(t, err, "Create hook should execute without error") + require.NotNil(t, res, "Create hook should return a response") + + // Verify hook returned expected values + require.NotNil(t, res.Temperature, "Temperature should be set") + assert.Equal(t, 0.5, *res.Temperature, "Temperature should be 0.5") + + require.NotNil(t, res.Metadata, "Metadata should be set") + assert.Equal(t, "Test All Fields", res.Metadata["assistant_name"], "assistant_name should match") + assert.Equal(t, true, res.Metadata["executed"], "executed should be true") +} + +// TestLoadStoreHookWithTypeScript tests that TypeScript features work in Source field +func TestLoadStoreHookWithTypeScript(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean(t) + + assistantID := "test.store-typescript-hook" + now := time.Now().UnixNano() + + ast := &assistant.Assistant{ + AssistantModel: store.AssistantModel{ + ID: assistantID, + Name: "Test TypeScript Hook", + Type: "assistant", + Connector: "gpt-4o", + Prompts: []store.Prompt{ + {Role: "system", Content: "Default prompt"}, + }, + // TypeScript code with type annotations and interfaces + Source: ` +// TypeScript interfaces +interface CreateContext { + chat_id: string; + assistant_id: string; + locale: string; + authorized?: { + user_id: string; + team_id: string; + }; +} + +interface Message { + role: string; + content: string | object; +} + +interface CreateResponse { + temperature?: number; + messages?: Message[]; + metadata?: Record; +} + +// Create hook with full TypeScript syntax +function Create(ctx: CreateContext, messages: Message[]): CreateResponse | null { + // Type-safe access to context + const chatId: string = ctx.chat_id || "unknown"; + const locale: string = ctx.locale || "en-us"; + const userId: string = ctx.authorized?.user_id || "anonymous"; + + // Process messages + const userMessages: Message[] = messages.filter((m: Message) => m.role === "user"); + const messageCount: number = userMessages.length; + + // Return typed response + return { + temperature: 0.7, + messages: [ + { + role: "system", + content: "TypeScript hook executed successfully" + } + ], + metadata: { + chat_id: chatId, + locale: locale, + user_id: userId, + message_count: messageCount, + typescript_features: true + } + }; +} +`, + CreatedAt: now, + UpdatedAt: now, + }, + } + + err := ast.Save() + require.NoError(t, err) + + defer func() { + storage := assistant.GetStorage() + if storage != nil { + storage.DeleteAssistant(assistantID) + } + assistant.GetCache().Clear() + }() + + assistant.GetCache().Clear() + + loaded, err := assistant.Get(assistantID) + require.NoError(t, err) + require.NotNil(t, loaded) + require.NotNil(t, loaded.Script, "Script should be compiled from TypeScript Source") + + // Execute the Create hook + ctx := newStoreTestContext("ts-test-chat", assistantID) + messages := []context.Message{ + {Role: "user", Content: "Hello"}, + {Role: "assistant", Content: "Hi there"}, + {Role: "user", Content: "How are you?"}, + } + + res, err := loaded.Script.Create(ctx, messages) + require.NoError(t, err, "TypeScript Create hook should execute without error") + require.NotNil(t, res, "Create hook should return a response") + + // Verify temperature + require.NotNil(t, res.Temperature) + assert.Equal(t, 0.7, *res.Temperature) + + // Verify messages + require.Len(t, res.Messages, 1) + assert.Equal(t, context.RoleSystem, res.Messages[0].Role) + assert.Equal(t, "TypeScript hook executed successfully", res.Messages[0].Content) + + // Verify metadata + require.NotNil(t, res.Metadata) + assert.Equal(t, "ts-test-chat", res.Metadata["chat_id"]) + assert.Equal(t, "en-us", res.Metadata["locale"]) + assert.Equal(t, "test-user-123", res.Metadata["user_id"]) + assert.Equal(t, float64(2), res.Metadata["message_count"]) // 2 user messages + assert.Equal(t, true, res.Metadata["typescript_features"]) +} + +// TestLoadStoreHookReturnNull tests that hook returning null works correctly +func TestLoadStoreHookReturnNull(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean(t) + + assistantID := "test.store-hook-null" + now := time.Now().UnixNano() + + ast := &assistant.Assistant{ + AssistantModel: store.AssistantModel{ + ID: assistantID, + Name: "Test Hook Return Null", + Type: "assistant", + Connector: "gpt-4o", + Source: ` +function Create(ctx: any, messages: any[]): any { + // Return null to indicate no modifications + return null; +} +`, + CreatedAt: now, + UpdatedAt: now, + }, + } + + err := ast.Save() + require.NoError(t, err) + + defer func() { + storage := assistant.GetStorage() + if storage != nil { + storage.DeleteAssistant(assistantID) + } + assistant.GetCache().Clear() + }() + + assistant.GetCache().Clear() + + loaded, err := assistant.Get(assistantID) + require.NoError(t, err) + require.NotNil(t, loaded) + require.NotNil(t, loaded.Script) + + ctx := newStoreTestContext("null-test-chat", assistantID) + messages := []context.Message{{Role: "user", Content: "Hello"}} + + res, err := loaded.Script.Create(ctx, messages) + require.NoError(t, err, "Hook returning null should not error") + assert.Nil(t, res, "Hook returning null should return nil response") +} + +// TestLoadStoreHookWithPromptPreset tests that hook can return prompt_preset +func TestLoadStoreHookWithPromptPreset(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean(t) + + assistantID := "test.store-hook-preset" + now := time.Now().UnixNano() + + ast := &assistant.Assistant{ + AssistantModel: store.AssistantModel{ + ID: assistantID, + Name: "Test Hook Prompt Preset", + Type: "assistant", + Connector: "gpt-4o", + Prompts: []store.Prompt{ + {Role: "system", Content: "Default prompt"}, + }, + PromptPresets: map[string][]store.Prompt{ + "friendly": { + {Role: "system", Content: "You are a friendly assistant."}, + }, + "professional": { + {Role: "system", Content: "You are a professional assistant."}, + }, + }, + Source: ` +function Create(ctx: any, messages: any[]): any { + // Check first message to determine preset + const firstMsg = messages[0]; + if (firstMsg && typeof firstMsg.content === "string") { + if (firstMsg.content.includes("friendly")) { + return { prompt_preset: "friendly" }; + } + if (firstMsg.content.includes("professional")) { + return { prompt_preset: "professional" }; + } + } + return null; +} +`, + CreatedAt: now, + UpdatedAt: now, + }, + } + + err := ast.Save() + require.NoError(t, err) + + defer func() { + storage := assistant.GetStorage() + if storage != nil { + storage.DeleteAssistant(assistantID) + } + assistant.GetCache().Clear() + }() + + assistant.GetCache().Clear() + + loaded, err := assistant.Get(assistantID) + require.NoError(t, err) + require.NotNil(t, loaded) + require.NotNil(t, loaded.Script) + + // Test friendly preset selection + t.Run("SelectFriendlyPreset", func(t *testing.T) { + ctx := newStoreTestContext("preset-test-1", assistantID) + messages := []context.Message{{Role: "user", Content: "Be friendly please"}} + + res, err := loaded.Script.Create(ctx, messages) + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, "friendly", res.PromptPreset) + }) + + // Test professional preset selection + t.Run("SelectProfessionalPreset", func(t *testing.T) { + ctx := newStoreTestContext("preset-test-2", assistantID) + messages := []context.Message{{Role: "user", Content: "Be professional"}} + + res, err := loaded.Script.Create(ctx, messages) + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, "professional", res.PromptPreset) + }) + + // Test no preset (returns null) + t.Run("NoPreset", func(t *testing.T) { + ctx := newStoreTestContext("preset-test-3", assistantID) + messages := []context.Message{{Role: "user", Content: "Hello"}} + + res, err := loaded.Script.Create(ctx, messages) + require.NoError(t, err) + assert.Nil(t, res) + }) +} + +// TestLoadStoreHookDisableGlobalPrompts tests that hook can disable global prompts +func TestLoadStoreHookDisableGlobalPrompts(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean(t) + + assistantID := "test.store-hook-disable-global" + now := time.Now().UnixNano() + + ast := &assistant.Assistant{ + AssistantModel: store.AssistantModel{ + ID: assistantID, + Name: "Test Hook Disable Global", + Type: "assistant", + Connector: "gpt-4o", + Source: ` +function Create(ctx: any, messages: any[]): any { + const firstMsg = messages[0]; + if (firstMsg && typeof firstMsg.content === "string") { + if (firstMsg.content.includes("disable_global")) { + return { disable_global_prompts: true }; + } + if (firstMsg.content.includes("enable_global")) { + return { disable_global_prompts: false }; + } + } + return null; +} +`, + CreatedAt: now, + UpdatedAt: now, + }, + } + + err := ast.Save() + require.NoError(t, err) + + defer func() { + storage := assistant.GetStorage() + if storage != nil { + storage.DeleteAssistant(assistantID) + } + assistant.GetCache().Clear() + }() + + assistant.GetCache().Clear() + + loaded, err := assistant.Get(assistantID) + require.NoError(t, err) + require.NotNil(t, loaded) + require.NotNil(t, loaded.Script) + + // Test disable global prompts + t.Run("DisableGlobalPrompts", func(t *testing.T) { + ctx := newStoreTestContext("disable-test-1", assistantID) + messages := []context.Message{{Role: "user", Content: "disable_global prompts"}} + + res, err := loaded.Script.Create(ctx, messages) + require.NoError(t, err) + require.NotNil(t, res) + require.NotNil(t, res.DisableGlobalPrompts) + assert.True(t, *res.DisableGlobalPrompts) + }) + + // Test enable global prompts + t.Run("EnableGlobalPrompts", func(t *testing.T) { + ctx := newStoreTestContext("disable-test-2", assistantID) + messages := []context.Message{{Role: "user", Content: "enable_global prompts"}} + + res, err := loaded.Script.Create(ctx, messages) + require.NoError(t, err) + require.NotNil(t, res) + require.NotNil(t, res.DisableGlobalPrompts) + assert.False(t, *res.DisableGlobalPrompts) + }) +} diff --git a/agent/assistant/source.go b/agent/assistant/source.go index a218ffb9..f2468e90 100644 --- a/agent/assistant/source.go +++ b/agent/assistant/source.go @@ -2,6 +2,7 @@ package assistant import ( "fmt" + "strings" "time" v8 "github.com/yaoapp/gou/runtime/v8" @@ -9,17 +10,19 @@ import ( ) // loadSource loads hook script from source code string -// The source field stores TypeScript code directly +// The source field stores TypeScript code directly (but without imports) // Priority: script field > source field (if script exists, source is ignored) +// Note: Uses MakeScriptInMemory which supports TypeScript syntax without file resolution. 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) + // Use virtual .ts path for TypeScript support + // MakeScriptInMemory handles TypeScript transform without file system access + virtualFile := fmt.Sprintf("assistants/%s/source.ts", strings.ReplaceAll(assistantID, ".", "/")) - script, err := v8.MakeScript([]byte(source), file, 5*time.Second, true) + script, err := v8.MakeScriptInMemory([]byte(source), virtualFile, 5*time.Second, true) if err != nil { return nil, fmt.Errorf("failed to compile source script: %w", err) }