diff --git a/internal/chat/approval.go b/internal/chat/approval.go index d9bbe49..445d844 100644 --- a/internal/chat/approval.go +++ b/internal/chat/approval.go @@ -11,8 +11,9 @@ const approvalTimeout = 5 * time.Minute // gatedTools lists tools that require user approval before execution. var gatedTools = map[string]bool{ - "send_email": true, - "create_event": true, + "send_email": true, + "create_event": true, + "send_slack_message": true, } // IsGated returns true if the tool requires user approval. @@ -133,6 +134,19 @@ func BuildPreview(call ToolCall) map[string]any { if desc, ok := call.Args["description"].(string); ok { preview["description"] = desc } + case "send_slack_message": + if ch, ok := call.Args["channel"].(string); ok { + preview["channel"] = ch + } + if text, ok := call.Args["text"].(string); ok { + if len(text) > 200 { + text = text[:200] + "..." + } + preview["text"] = text + } + if threadTS, ok := call.Args["thread_ts"].(string); ok && threadTS != "" { + preview["thread_ts"] = threadTS + } } return preview diff --git a/internal/chat/approval_test.go b/internal/chat/approval_test.go index b2f0c2d..5e3013d 100644 --- a/internal/chat/approval_test.go +++ b/internal/chat/approval_test.go @@ -1,6 +1,7 @@ package chat import ( + "strings" "sync" "testing" "time" @@ -201,6 +202,31 @@ func TestPendingApproval_WaitConcurrent(t *testing.T) { wg.Wait() } +func TestIsGated_SlackMessage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + toolName string + want bool + }{ + {"send_slack_message is gated", "send_slack_message", true}, + {"list_slack_channels is not gated", "list_slack_channels", false}, + {"read_slack_messages is not gated", "read_slack_messages", false}, + {"search_slack is not gated", "search_slack", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := IsGated(tt.toolName) + if got != tt.want { + t.Errorf("IsGated(%q) = %v, want %v", tt.toolName, got, tt.want) + } + }) + } +} + func TestBuildPreview(t *testing.T) { t.Parallel() @@ -292,3 +318,50 @@ func TestBuildPreview(t *testing.T) { }) } } + +func TestBuildPreview_SlackMessage(t *testing.T) { + t.Parallel() + + preview := BuildPreview(ToolCall{ + Name: "send_slack_message", + Args: map[string]any{ + "channel": "#engineering", + "text": "Hello team!", + "thread_ts": "1234567890.123456", + }, + }) + + if preview["channel"] != "#engineering" { + t.Errorf("channel = %v, want %q", preview["channel"], "#engineering") + } + if preview["text"] != "Hello team!" { + t.Errorf("text = %v, want %q", preview["text"], "Hello team!") + } + if preview["thread_ts"] != "1234567890.123456" { + t.Errorf("thread_ts = %v, want %q", preview["thread_ts"], "1234567890.123456") + } +} + +func TestBuildPreview_SlackMessage_LongText(t *testing.T) { + t.Parallel() + + longText := strings.Repeat("a", 300) + preview := BuildPreview(ToolCall{ + Name: "send_slack_message", + Args: map[string]any{ + "channel": "#general", + "text": longText, + }, + }) + + text, ok := preview["text"].(string) + if !ok { + t.Fatal("text is not a string") + } + if len(text) > 203 { // 200 + "..." + t.Errorf("text length = %d, want <= 203", len(text)) + } + if !strings.HasSuffix(text, "...") { + t.Error("text should end with '...'") + } +} diff --git a/internal/chat/chat.go b/internal/chat/chat.go index 5c54c63..6841f48 100644 --- a/internal/chat/chat.go +++ b/internal/chat/chat.go @@ -9,9 +9,36 @@ import ( browserpkg "github.com/nylas/cli/internal/adapters/browser" "github.com/nylas/cli/internal/adapters/config" + "github.com/nylas/cli/internal/adapters/keyring" + slackadapter "github.com/nylas/cli/internal/adapters/slack" "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/ports" ) +// trySlackClient attempts to create a Slack client from stored credentials. +// Returns nil if Slack is not configured (not an error). +func trySlackClient() ports.SlackClient { + token := os.Getenv("SLACK_USER_TOKEN") + if token == "" { + store, err := keyring.NewSecretStore(config.DefaultConfigDir()) + if err != nil { + return nil + } + token, err = store.Get("slack_user_token") + if err != nil || token == "" { + return nil + } + } + + cfg := slackadapter.DefaultConfig() + cfg.UserToken = token + client, err := slackadapter.NewClient(cfg) + if err != nil { + return nil + } + return client +} + // NewChatCmd creates the chat command. func NewChatCmd() *cobra.Command { var ( @@ -73,6 +100,8 @@ and perform actions on your behalf through a web-based chat interface.`, return err } + slackClient := trySlackClient() + // Set up conversation storage chatDir := filepath.Join(config.DefaultConfigDir(), "chat", "conversations") memory, err := NewMemoryStore(chatDir) @@ -85,6 +114,9 @@ and perform actions on your behalf through a web-based chat interface.`, fmt.Printf("Starting Nylas Chat at %s\n", url) fmt.Printf("Agent: %s\n", agent) + if slackClient != nil { + fmt.Println("Slack: connected") + } fmt.Println("Press Ctrl+C to stop") fmt.Println() @@ -96,7 +128,7 @@ and perform actions on your behalf through a web-based chat interface.`, } } - server := NewServer(addr, agent, agents, nylasClient, grantID, memory) + server := NewServer(addr, agent, agents, nylasClient, grantID, memory, slackClient) return server.Start() }, } diff --git a/internal/chat/context.go b/internal/chat/context.go index aaa062c..d55c275 100644 --- a/internal/chat/context.go +++ b/internal/chat/context.go @@ -15,17 +15,19 @@ const ( // ContextBuilder constructs prompts with conversation context and manages compaction. type ContextBuilder struct { - agent *Agent - memory *MemoryStore - grantID string + agent *Agent + memory *MemoryStore + grantID string + hasSlack bool } // NewContextBuilder creates a new ContextBuilder. -func NewContextBuilder(agent *Agent, memory *MemoryStore, grantID string) *ContextBuilder { +func NewContextBuilder(agent *Agent, memory *MemoryStore, grantID string, hasSlack bool) *ContextBuilder { return &ContextBuilder{ - agent: agent, - memory: memory, - grantID: grantID, + agent: agent, + memory: memory, + grantID: grantID, + hasSlack: hasSlack, } } @@ -38,7 +40,7 @@ func (c *ContextBuilder) BuildPrompt(conv *Conversation, newMessage string) stri var sb strings.Builder // System prompt - sb.WriteString(BuildSystemPrompt(c.grantID, c.agent.Type)) + sb.WriteString(BuildSystemPrompt(c.grantID, c.agent.Type, c.hasSlack)) sb.WriteString("\n---\n\n") // Include conversation summary if available diff --git a/internal/chat/context_test.go b/internal/chat/context_test.go index 8dd590b..4f8bdf9 100644 --- a/internal/chat/context_test.go +++ b/internal/chat/context_test.go @@ -12,7 +12,7 @@ func TestContextBuilder_BuildPrompt(t *testing.T) { agent := &Agent{Type: AgentClaude, Path: "/usr/bin/claude"} store := setupMemoryStore(t) grantID := "test-grant-123" - builder := NewContextBuilder(agent, store, grantID) + builder := NewContextBuilder(agent, store, grantID, false) tests := []struct { name string @@ -124,7 +124,7 @@ func TestContextBuilder_BuildPrompt(t *testing.T) { func TestContextBuilder_NeedsCompaction(t *testing.T) { agent := &Agent{Type: AgentClaude, Path: "/usr/bin/claude"} store := setupMemoryStore(t) - builder := NewContextBuilder(agent, store, "test-grant") + builder := NewContextBuilder(agent, store, "test-grant", false) tests := []struct { name string @@ -246,7 +246,7 @@ func TestContextBuilder_NeedsCompaction(t *testing.T) { func TestContextBuilder_findSplitIndex(t *testing.T) { agent := &Agent{Type: AgentClaude, Path: "/usr/bin/claude"} store := setupMemoryStore(t) - builder := NewContextBuilder(agent, store, "test-grant") + builder := NewContextBuilder(agent, store, "test-grant", false) tests := []struct { name string @@ -372,7 +372,7 @@ func TestContextBuilder_findSplitIndex(t *testing.T) { func TestContextBuilder_BuildPrompt_Structure(t *testing.T) { agent := &Agent{Type: AgentClaude, Path: "/usr/bin/claude"} store := setupMemoryStore(t) - builder := NewContextBuilder(agent, store, "test-grant") + builder := NewContextBuilder(agent, store, "test-grant", false) conv := &Conversation{ ID: "conv_test", diff --git a/internal/chat/executor.go b/internal/chat/executor.go index c28882f..8a91354 100644 --- a/internal/chat/executor.go +++ b/internal/chat/executor.go @@ -12,12 +12,18 @@ import ( // ToolExecutor dispatches tool calls to the Nylas API. type ToolExecutor struct { client ports.NylasClient + slack ports.SlackClient grantID string } // NewToolExecutor creates a new ToolExecutor. -func NewToolExecutor(client ports.NylasClient, grantID string) *ToolExecutor { - return &ToolExecutor{client: client, grantID: grantID} +func NewToolExecutor(client ports.NylasClient, grantID string, slack ports.SlackClient) *ToolExecutor { + return &ToolExecutor{client: client, grantID: grantID, slack: slack} +} + +// HasSlack returns true if Slack integration is available. +func (e *ToolExecutor) HasSlack() bool { + return e.slack != nil } // Execute runs a tool call and returns the result. @@ -39,6 +45,19 @@ func (e *ToolExecutor) Execute(ctx context.Context, call ToolCall) ToolResult { return e.listContacts(ctx, call.Args) case "list_folders": return e.listFolders(ctx) + // Slack tools + case "list_slack_channels": + return e.listSlackChannels(ctx, call.Args) + case "read_slack_messages": + return e.readSlackMessages(ctx, call.Args) + case "read_slack_thread": + return e.readSlackThread(ctx, call.Args) + case "search_slack": + return e.searchSlack(ctx, call.Args) + case "send_slack_message": + return e.sendSlackMessage(ctx, call.Args) + case "list_slack_users": + return e.listSlackUsers(ctx, call.Args) default: return ToolResult{Name: call.Name, Error: fmt.Sprintf("unknown tool: %s", call.Name)} } @@ -160,8 +179,8 @@ func (e *ToolExecutor) searchEmails(ctx context.Context, args map[string]any) To } params := &domain.MessageQueryParams{ - Limit: 10, - SearchQuery: query, + Limit: 10, + Subject: query, } if v, ok := args["limit"]; ok { diff --git a/internal/chat/executor_operations_test.go b/internal/chat/executor_operations_test.go index 4466459..18d9d84 100644 --- a/internal/chat/executor_operations_test.go +++ b/internal/chat/executor_operations_test.go @@ -16,7 +16,7 @@ import ( func TestSendEmail_Success(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) client.SendMessageFunc = func(ctx context.Context, grantID string, req *domain.SendMessageRequest) (*domain.Message, error) { assert.Equal(t, "test@example.com", req.To[0].Email) @@ -63,7 +63,7 @@ func TestSendEmail_MissingParams(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) result := executor.Execute(context.Background(), ToolCall{ Name: "send_email", @@ -77,7 +77,7 @@ func TestSendEmail_MissingParams(t *testing.T) { func TestSendEmail_Error(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) client.SendMessageFunc = func(ctx context.Context, grantID string, req *domain.SendMessageRequest) (*domain.Message, error) { return nil, errors.New("send failed") @@ -125,7 +125,7 @@ func TestListEvents_Success(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) client.GetEventsFunc = func(ctx context.Context, grantID, calendarID string, params *domain.EventQueryParams) ([]domain.Event, error) { assert.Equal(t, tt.wantCalID, calendarID) @@ -158,7 +158,7 @@ func TestListEvents_Success(t *testing.T) { func TestListEvents_Error(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) client.GetEventsFunc = func(ctx context.Context, grantID, calendarID string, params *domain.EventQueryParams) ([]domain.Event, error) { return nil, errors.New("calendar not found") @@ -174,7 +174,7 @@ func TestListEvents_Error(t *testing.T) { func TestCreateEvent_Success(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) client.CreateEventFunc = func(ctx context.Context, grantID, calendarID string, req *domain.CreateEventRequest) (*domain.Event, error) { assert.Equal(t, "primary", calendarID) @@ -209,7 +209,7 @@ func TestCreateEvent_Success(t *testing.T) { func TestCreateEvent_CustomCalendar(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) client.CreateEventFunc = func(ctx context.Context, grantID, calendarID string, req *domain.CreateEventRequest) (*domain.Event, error) { assert.Equal(t, "work-calendar", calendarID) @@ -243,7 +243,7 @@ func TestCreateEvent_MissingParams(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) result := executor.Execute(context.Background(), ToolCall{ Name: "create_event", @@ -270,7 +270,7 @@ func TestCreateEvent_InvalidTimeFormat(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) result := executor.Execute(context.Background(), ToolCall{ Name: "create_event", @@ -288,7 +288,7 @@ func TestCreateEvent_InvalidTimeFormat(t *testing.T) { func TestCreateEvent_Error(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) client.CreateEventFunc = func(ctx context.Context, grantID, calendarID string, req *domain.CreateEventRequest) (*domain.Event, error) { return nil, errors.New("calendar permission denied") @@ -356,7 +356,7 @@ func TestListContacts_Success(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) client.GetContactsFunc = func(ctx context.Context, grantID string, params *domain.ContactQueryParams) ([]domain.Contact, error) { assert.Equal(t, tt.wantLimit, params.Limit) @@ -424,7 +424,7 @@ func TestListContacts_NameFormatting(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) client.GetContactsFunc = func(ctx context.Context, grantID string, params *domain.ContactQueryParams) ([]domain.Contact, error) { return []domain.Contact{tt.contact}, nil @@ -450,7 +450,7 @@ func TestListContacts_NameFormatting(t *testing.T) { func TestListContacts_Error(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) client.GetContactsFunc = func(ctx context.Context, grantID string, params *domain.ContactQueryParams) ([]domain.Contact, error) { return nil, errors.New("contacts not available") @@ -466,7 +466,7 @@ func TestListContacts_Error(t *testing.T) { func TestListFolders_Success(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) client.GetFoldersFunc = func(ctx context.Context, grantID string) ([]domain.Folder, error) { return []domain.Folder{ @@ -497,7 +497,7 @@ func TestListFolders_Success(t *testing.T) { func TestListFolders_Error(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) client.GetFoldersFunc = func(ctx context.Context, grantID string) ([]domain.Folder, error) { return nil, errors.New("folders not accessible") diff --git a/internal/chat/executor_slack.go b/internal/chat/executor_slack.go new file mode 100644 index 0000000..a8aa24c --- /dev/null +++ b/internal/chat/executor_slack.go @@ -0,0 +1,534 @@ +package chat + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/nylas/cli/internal/domain" +) + +// requireSlack checks if Slack integration is available and returns an error result if not. +func (e *ToolExecutor) requireSlack(toolName string) (ToolResult, bool) { + if e.slack == nil { + return ToolResult{ + Name: toolName, + Error: "Slack integration not configured", + }, false + } + return ToolResult{}, true +} + +// resolveSlackChannel converts a channel name or ID to a channel ID. +// If input is already an ID (starts with C/G/D and length 9+), returns it directly. +// Otherwise tries: exact match, then prefix match, then search fallback. +func (e *ToolExecutor) resolveSlackChannel(ctx context.Context, input string) (string, error) { + // Check if input is already a channel ID + if isChannelID(input) { + return input, nil + } + + // Treat as channel name - strip # prefix and normalize + name := strings.TrimPrefix(input, "#") + name = strings.ToLower(name) + + // Paginate through user's channels (excludes DMs/IMs for efficient pagination) + // Track best prefix match in case exact match isn't found + var prefixMatch string + cursor := "" + for range 5 { + resp, err := e.slack.ListMyChannels(ctx, &domain.SlackChannelQueryParams{ + Limit: 1000, + ExcludeArchived: true, + Cursor: cursor, + }) + if err != nil { + return "", fmt.Errorf("failed to list channels: %w", err) + } + + for _, ch := range resp.Channels { + chName := strings.ToLower(ch.Name) + if chName == name { + return ch.ID, nil + } + // Track prefix match (e.g. "incident-foo" matches "incident-foo-748") + if prefixMatch == "" && strings.HasPrefix(chName, name) { + prefixMatch = ch.ID + } + } + + if resp.NextCursor == "" { + break + } + cursor = resp.NextCursor + } + + // Use prefix match if found + if prefixMatch != "" { + return prefixMatch, nil + } + + // Fallback: search for a message in the channel to discover its ID + results, err := e.slack.SearchMessages(ctx, "in:#"+name, 1) + if err == nil && len(results) > 0 && results[0].ChannelID != "" { + return results[0].ChannelID, nil + } + + return "", fmt.Errorf("channel not found: %q", input) +} + +// listSlackChannels returns a list of Slack channels accessible to the user. +func (e *ToolExecutor) listSlackChannels(ctx context.Context, args map[string]any) ToolResult { + if result, ok := e.requireSlack("list_slack_channels"); !ok { + return result + } + + // Parse limit + limit := 20 + if l, ok := args["limit"].(float64); ok && l > 0 { + limit = int(l) + } + + // Get channels + resp, err := e.slack.ListMyChannels(ctx, &domain.SlackChannelQueryParams{ + Limit: limit, + ExcludeArchived: true, + }) + if err != nil { + return ToolResult{ + Name: "list_slack_channels", + Error: fmt.Sprintf("failed to list channels: %v", err), + } + } + + // Build response + type channelSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + MemberCount int `json:"member_count"` + Topic string `json:"topic,omitempty"` + } + + channels := make([]channelSummary, len(resp.Channels)) + for i, ch := range resp.Channels { + channels[i] = channelSummary{ + ID: ch.ID, + Name: ch.ChannelDisplayName(), + Type: ch.ChannelType(), + MemberCount: ch.MemberCount, + Topic: ch.Topic, + } + } + + return ToolResult{ + Name: "list_slack_channels", + Data: channels, + } +} + +// readSlackMessages returns messages from a Slack channel with threads expanded inline. +// Falls back to search API when conversations.history returns too few results +// (some private channels have limited history access but full search access). +func (e *ToolExecutor) readSlackMessages(ctx context.Context, args map[string]any) ToolResult { + if result, ok := e.requireSlack("read_slack_messages"); !ok { + return result + } + + // Get required channel parameter + channel, ok := args["channel"].(string) + if !ok || channel == "" { + return ToolResult{ + Name: "read_slack_messages", + Error: "missing required parameter: channel", + } + } + + // Resolve channel name to ID + channelID, err := e.resolveSlackChannel(ctx, channel) + if err != nil { + return ToolResult{ + Name: "read_slack_messages", + Error: err.Error(), + } + } + + // Parse limit + limit := 500 + if l, ok := args["limit"].(float64); ok && l > 0 { + limit = int(l) + } + + // Get messages via conversations.history + resp, err := e.slack.GetMessages(ctx, &domain.SlackMessageQueryParams{ + ChannelID: channelID, + Limit: limit, + }) + if err != nil { + return ToolResult{ + Name: "read_slack_messages", + Error: fmt.Sprintf("failed to get messages: %v", err), + } + } + + // If history returned very few messages, fall back to search API + // (search API has broader access for some channel types like Rootly incident channels) + if len(resp.Messages) < 5 { + searchQuery := buildChannelSearchQuery(channel, channelID) + searchResults, searchErr := e.slack.SearchMessages(ctx, searchQuery, limit) + if searchErr == nil && len(searchResults) > len(resp.Messages) { + return e.formatSearchMessages(searchResults) + } + } + + return e.formatHistoryMessages(ctx, channelID, resp.Messages) +} + +// buildChannelSearchQuery creates a Slack search query to find messages in a channel. +func buildChannelSearchQuery(channel, channelID string) string { + // If original input is a channel name, use it directly + name := strings.TrimPrefix(channel, "#") + if !isChannelID(name) { + return "in:#" + strings.ToLower(name) + } + // For channel IDs, use the Slack channel link format + return "in:<#" + channelID + ">" +} + +// isChannelID returns true if the string looks like a Slack channel ID (C/G/D prefix + 9+ chars). +func isChannelID(s string) bool { + if len(s) < 9 { + return false + } + return s[0] == 'C' || s[0] == 'G' || s[0] == 'D' +} + +// formatSearchMessages formats search results as a ToolResult. +func (e *ToolExecutor) formatSearchMessages(results []domain.SlackMessage) ToolResult { + type msgSummary struct { + ID string `json:"id"` + Username string `json:"username"` + Text string `json:"text"` + Timestamp string `json:"timestamp"` + } + + messages := make([]msgSummary, 0, len(results)) + for _, msg := range results { + text := msg.Text + if len(text) > 500 { + text = text[:500] + "..." + } + messages = append(messages, msgSummary{ + ID: msg.ID, + Username: msg.Username, + Text: text, + Timestamp: msg.Timestamp.Format(time.RFC3339), + }) + } + + return ToolResult{ + Name: "read_slack_messages", + Data: messages, + } +} + +// formatHistoryMessages formats conversation history results with thread expansion. +func (e *ToolExecutor) formatHistoryMessages(ctx context.Context, channelID string, msgs []domain.SlackMessage) ToolResult { + type msgSummary struct { + ID string `json:"id"` + Username string `json:"username"` + Text string `json:"text"` + Timestamp string `json:"timestamp"` + IsReply bool `json:"is_reply,omitempty"` + } + + var messages []msgSummary + for _, msg := range msgs { + text := msg.Text + if len(text) > 500 { + text = text[:500] + "..." + } + + messages = append(messages, msgSummary{ + ID: msg.ID, + Username: msg.Username, + Text: text, + Timestamp: msg.Timestamp.Format(time.RFC3339), + }) + + // Expand thread replies inline + if msg.ReplyCount > 0 { + replies, replyErr := e.slack.GetThreadReplies(ctx, channelID, msg.ID, 100) + if replyErr == nil && len(replies) > 1 { + // Skip first reply (parent message already included) + for _, reply := range replies[1:] { + replyText := reply.Text + if len(replyText) > 500 { + replyText = replyText[:500] + "..." + } + messages = append(messages, msgSummary{ + ID: reply.ID, + Username: reply.Username, + Text: replyText, + Timestamp: reply.Timestamp.Format(time.RFC3339), + IsReply: true, + }) + } + } + } + } + + return ToolResult{ + Name: "read_slack_messages", + Data: messages, + } +} + +// readSlackThread returns messages from a Slack thread. +func (e *ToolExecutor) readSlackThread(ctx context.Context, args map[string]any) ToolResult { + if result, ok := e.requireSlack("read_slack_thread"); !ok { + return result + } + + // Get required parameters + channel, ok := args["channel"].(string) + if !ok || channel == "" { + return ToolResult{ + Name: "read_slack_thread", + Error: "missing required parameter: channel", + } + } + + threadTS, ok := args["thread_ts"].(string) + if !ok || threadTS == "" { + return ToolResult{ + Name: "read_slack_thread", + Error: "missing required parameter: thread_ts", + } + } + + // Resolve channel name to ID + channelID, err := e.resolveSlackChannel(ctx, channel) + if err != nil { + return ToolResult{ + Name: "read_slack_thread", + Error: err.Error(), + } + } + + // Parse limit + limit := 20 + if l, ok := args["limit"].(float64); ok && l > 0 { + limit = int(l) + } + + // Get thread replies + replies, err := e.slack.GetThreadReplies(ctx, channelID, threadTS, limit) + if err != nil { + return ToolResult{ + Name: "read_slack_thread", + Error: fmt.Sprintf("failed to get thread replies: %v", err), + } + } + + // Build response + type replySummary struct { + ID string `json:"id"` + Username string `json:"username"` + Text string `json:"text"` + Timestamp string `json:"timestamp"` + IsReply bool `json:"is_reply"` + } + + messages := make([]replySummary, len(replies)) + for i, msg := range replies { + text := msg.Text + if len(text) > 500 { + text = text[:500] + "..." + } + + messages[i] = replySummary{ + ID: msg.ID, + Username: msg.Username, + Text: text, + Timestamp: msg.Timestamp.Format(time.RFC3339), + IsReply: msg.IsReply, + } + } + + return ToolResult{ + Name: "read_slack_thread", + Data: messages, + } +} + +// searchSlack searches for messages matching a query. +func (e *ToolExecutor) searchSlack(ctx context.Context, args map[string]any) ToolResult { + if result, ok := e.requireSlack("search_slack"); !ok { + return result + } + + // Get required query parameter + query, ok := args["query"].(string) + if !ok || query == "" { + return ToolResult{ + Name: "search_slack", + Error: "missing required parameter: query", + } + } + + // Parse limit + limit := 10 + if l, ok := args["limit"].(float64); ok && l > 0 { + limit = int(l) + } + + // Search messages + results, err := e.slack.SearchMessages(ctx, query, limit) + if err != nil { + return ToolResult{ + Name: "search_slack", + Error: fmt.Sprintf("failed to search messages: %v", err), + } + } + + // Build response + type searchResult struct { + ID string `json:"id"` + ChannelID string `json:"channel_id"` + Username string `json:"username"` + Text string `json:"text"` + Timestamp string `json:"timestamp"` + } + + messages := make([]searchResult, len(results)) + for i, msg := range results { + text := msg.Text + if len(text) > 500 { + text = text[:500] + "..." + } + + messages[i] = searchResult{ + ID: msg.ID, + ChannelID: msg.ChannelID, + Username: msg.Username, + Text: text, + Timestamp: msg.Timestamp.Format(time.RFC3339), + } + } + + return ToolResult{ + Name: "search_slack", + Data: messages, + } +} + +// sendSlackMessage sends a message to a Slack channel. +func (e *ToolExecutor) sendSlackMessage(ctx context.Context, args map[string]any) ToolResult { + if result, ok := e.requireSlack("send_slack_message"); !ok { + return result + } + + // Get required parameters + channel, ok := args["channel"].(string) + if !ok || channel == "" { + return ToolResult{ + Name: "send_slack_message", + Error: "missing required parameter: channel", + } + } + + text, ok := args["text"].(string) + if !ok || text == "" { + return ToolResult{ + Name: "send_slack_message", + Error: "missing required parameter: text", + } + } + + // Resolve channel name to ID + channelID, err := e.resolveSlackChannel(ctx, channel) + if err != nil { + return ToolResult{ + Name: "send_slack_message", + Error: err.Error(), + } + } + + // Get optional thread_ts + threadTS, _ := args["thread_ts"].(string) + + // Send message + req := &domain.SlackSendMessageRequest{ + ChannelID: channelID, + Text: text, + ThreadTS: threadTS, + } + + msg, err := e.slack.SendMessage(ctx, req) + if err != nil { + return ToolResult{ + Name: "send_slack_message", + Error: fmt.Sprintf("failed to send message: %v", err), + } + } + + // Build response + response := map[string]any{ + "id": msg.ID, + "status": "sent", + } + + return ToolResult{ + Name: "send_slack_message", + Data: response, + } +} + +// listSlackUsers returns a list of Slack workspace users. +func (e *ToolExecutor) listSlackUsers(ctx context.Context, args map[string]any) ToolResult { + if result, ok := e.requireSlack("list_slack_users"); !ok { + return result + } + + // Parse limit + limit := 20 + if l, ok := args["limit"].(float64); ok && l > 0 { + limit = int(l) + } + + // Get users + resp, err := e.slack.ListUsers(ctx, limit, "") + if err != nil { + return ToolResult{ + Name: "list_slack_users", + Error: fmt.Sprintf("failed to list users: %v", err), + } + } + + // Build response + type userSummary struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Title string `json:"title,omitempty"` + IsBot bool `json:"is_bot,omitempty"` + } + + users := make([]userSummary, len(resp.Users)) + for i, user := range resp.Users { + users[i] = userSummary{ + ID: user.ID, + Name: user.Name, + DisplayName: user.BestDisplayName(), + Title: user.Title, + IsBot: user.IsBot, + } + } + + return ToolResult{ + Name: "list_slack_users", + Data: users, + } +} diff --git a/internal/chat/executor_slack_ops_test.go b/internal/chat/executor_slack_ops_test.go new file mode 100644 index 0000000..75985b0 --- /dev/null +++ b/internal/chat/executor_slack_ops_test.go @@ -0,0 +1,269 @@ +package chat + +import ( + "context" + "errors" + "testing" + "time" + + slackAdapter "github.com/nylas/cli/internal/adapters/slack" + "github.com/nylas/cli/internal/domain" + "github.com/stretchr/testify/assert" +) + +func TestReadSlackThread(t *testing.T) { + t.Run("success", func(t *testing.T) { + mock := slackAdapter.NewMockClient() + mock.GetThreadRepliesFunc = func(ctx context.Context, channelID, threadTS string, limit int) ([]domain.SlackMessage, error) { + assert.Equal(t, "C123456789", channelID) + assert.Equal(t, "1234.567", threadTS) + assert.Equal(t, 20, limit) + return []domain.SlackMessage{ + {ID: "1234.567", Username: "user1", Text: "Original", Timestamp: time.Now(), IsReply: false}, + {ID: "1234.568", Username: "user2", Text: "Reply", Timestamp: time.Now(), IsReply: true}, + }, nil + } + + executor := newSlackExecutor(mock) + result := executor.readSlackThread(context.Background(), map[string]any{ + "channel": "C123456789", + "thread_ts": "1234.567", + }) + + assert.Empty(t, result.Error) + assert.NotNil(t, result.Data) + }) + + t.Run("missing channel parameter", func(t *testing.T) { + executor := newSlackExecutor(slackAdapter.NewMockClient()) + result := executor.readSlackThread(context.Background(), map[string]any{ + "thread_ts": "1234.567", + }) + + assert.Contains(t, result.Error, "missing required parameter: channel") + }) + + t.Run("missing thread_ts parameter", func(t *testing.T) { + executor := newSlackExecutor(slackAdapter.NewMockClient()) + result := executor.readSlackThread(context.Background(), map[string]any{ + "channel": "C123456789", + }) + + assert.Contains(t, result.Error, "missing required parameter: thread_ts") + }) + + t.Run("API error", func(t *testing.T) { + mock := slackAdapter.NewMockClient() + mock.GetThreadRepliesFunc = func(ctx context.Context, channelID, threadTS string, limit int) ([]domain.SlackMessage, error) { + return nil, errors.New("API error") + } + + executor := newSlackExecutor(mock) + result := executor.readSlackThread(context.Background(), map[string]any{ + "channel": "C123456789", + "thread_ts": "1234.567", + }) + + assert.Contains(t, result.Error, "failed to get thread replies") + }) +} + +func TestSearchSlack(t *testing.T) { + t.Run("success with default limit", func(t *testing.T) { + mock := slackAdapter.NewMockClient() + mock.SearchMessagesFunc = func(ctx context.Context, query string, limit int) ([]domain.SlackMessage, error) { + assert.Equal(t, "test query", query) + assert.Equal(t, 10, limit) + return []domain.SlackMessage{ + { + ID: "1234.567", + ChannelID: "C999", + Username: "testuser", + Text: "Result matching test query", + Timestamp: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), + }, + }, nil + } + + executor := newSlackExecutor(mock) + result := executor.searchSlack(context.Background(), map[string]any{ + "query": "test query", + }) + + assert.Empty(t, result.Error) + assert.NotNil(t, result.Data) + }) + + t.Run("success with custom limit", func(t *testing.T) { + mock := slackAdapter.NewMockClient() + mock.SearchMessagesFunc = func(ctx context.Context, query string, limit int) ([]domain.SlackMessage, error) { + assert.Equal(t, 25, limit) + return []domain.SlackMessage{}, nil + } + + executor := newSlackExecutor(mock) + result := executor.searchSlack(context.Background(), map[string]any{ + "query": "test", + "limit": float64(25), + }) + + assert.Empty(t, result.Error) + }) + + t.Run("missing query parameter", func(t *testing.T) { + executor := newSlackExecutor(slackAdapter.NewMockClient()) + result := executor.searchSlack(context.Background(), map[string]any{}) + + assert.Contains(t, result.Error, "missing required parameter: query") + }) + + t.Run("API error", func(t *testing.T) { + mock := slackAdapter.NewMockClient() + mock.SearchMessagesFunc = func(ctx context.Context, query string, limit int) ([]domain.SlackMessage, error) { + return nil, errors.New("API error") + } + + executor := newSlackExecutor(mock) + result := executor.searchSlack(context.Background(), map[string]any{ + "query": "test", + }) + + assert.Contains(t, result.Error, "failed to search messages") + }) +} + +func TestSendSlackMessage(t *testing.T) { + t.Run("success with channel name", func(t *testing.T) { + mock := slackAdapter.NewMockClient() + mock.ListMyChannelsFunc = func(ctx context.Context, params *domain.SlackChannelQueryParams) (*domain.SlackChannelListResponse, error) { + return &domain.SlackChannelListResponse{ + Channels: []domain.SlackChannel{{ID: "C999", Name: "general"}}, + }, nil + } + mock.SendMessageFunc = func(ctx context.Context, req *domain.SlackSendMessageRequest) (*domain.SlackMessage, error) { + assert.Equal(t, "C999", req.ChannelID) + assert.Equal(t, "Hello world", req.Text) + assert.Empty(t, req.ThreadTS) + return &domain.SlackMessage{ID: "1234.567", ChannelID: req.ChannelID, Text: req.Text}, nil + } + + executor := newSlackExecutor(mock) + result := executor.sendSlackMessage(context.Background(), map[string]any{ + "channel": "#general", + "text": "Hello world", + }) + + assert.Empty(t, result.Error) + assert.NotNil(t, result.Data) + }) + + t.Run("success with thread_ts", func(t *testing.T) { + mock := slackAdapter.NewMockClient() + mock.SendMessageFunc = func(ctx context.Context, req *domain.SlackSendMessageRequest) (*domain.SlackMessage, error) { + assert.Equal(t, "C123456789", req.ChannelID) + assert.Equal(t, "Thread reply", req.Text) + assert.Equal(t, "1234.000", req.ThreadTS) + return &domain.SlackMessage{ID: "1234.567", ThreadTS: "1234.000"}, nil + } + + executor := newSlackExecutor(mock) + result := executor.sendSlackMessage(context.Background(), map[string]any{ + "channel": "C123456789", + "text": "Thread reply", + "thread_ts": "1234.000", + }) + + assert.Empty(t, result.Error) + }) + + t.Run("missing channel parameter", func(t *testing.T) { + executor := newSlackExecutor(slackAdapter.NewMockClient()) + result := executor.sendSlackMessage(context.Background(), map[string]any{"text": "Hello"}) + + assert.Contains(t, result.Error, "missing required parameter: channel") + }) + + t.Run("missing text parameter", func(t *testing.T) { + executor := newSlackExecutor(slackAdapter.NewMockClient()) + result := executor.sendSlackMessage(context.Background(), map[string]any{"channel": "C123456789"}) + + assert.Contains(t, result.Error, "missing required parameter: text") + }) + + t.Run("channel resolution error", func(t *testing.T) { + mock := slackAdapter.NewMockClient() + mock.ListMyChannelsFunc = func(ctx context.Context, params *domain.SlackChannelQueryParams) (*domain.SlackChannelListResponse, error) { + return nil, errors.New("API error") + } + + executor := newSlackExecutor(mock) + result := executor.sendSlackMessage(context.Background(), map[string]any{ + "channel": "#general", + "text": "Hello", + }) + + assert.Contains(t, result.Error, "failed to list channels") + }) + + t.Run("API error", func(t *testing.T) { + mock := slackAdapter.NewMockClient() + mock.SendMessageFunc = func(ctx context.Context, req *domain.SlackSendMessageRequest) (*domain.SlackMessage, error) { + return nil, errors.New("API error") + } + + executor := newSlackExecutor(mock) + result := executor.sendSlackMessage(context.Background(), map[string]any{ + "channel": "C123456789", + "text": "Hello", + }) + + assert.Contains(t, result.Error, "failed to send message") + }) +} + +func TestListSlackUsers(t *testing.T) { + t.Run("success with default limit", func(t *testing.T) { + mock := slackAdapter.NewMockClient() + mock.ListUsersFunc = func(ctx context.Context, limit int, cursor string) (*domain.SlackUserListResponse, error) { + assert.Equal(t, 20, limit) + assert.Empty(t, cursor) + return &domain.SlackUserListResponse{ + Users: []domain.SlackUser{ + {ID: "U123", Name: "user1", DisplayName: "User One", Title: "Engineer", IsBot: false}, + {ID: "U456", Name: "bot", RealName: "Bot User", IsBot: true}, + }, + }, nil + } + + executor := newSlackExecutor(mock) + result := executor.listSlackUsers(context.Background(), map[string]any{}) + + assert.Empty(t, result.Error) + assert.NotNil(t, result.Data) + }) + + t.Run("success with custom limit", func(t *testing.T) { + mock := slackAdapter.NewMockClient() + mock.ListUsersFunc = func(ctx context.Context, limit int, cursor string) (*domain.SlackUserListResponse, error) { + assert.Equal(t, 100, limit) + return &domain.SlackUserListResponse{Users: []domain.SlackUser{}}, nil + } + + executor := newSlackExecutor(mock) + result := executor.listSlackUsers(context.Background(), map[string]any{"limit": float64(100)}) + + assert.Empty(t, result.Error) + }) + + t.Run("API error", func(t *testing.T) { + mock := slackAdapter.NewMockClient() + mock.ListUsersFunc = func(ctx context.Context, limit int, cursor string) (*domain.SlackUserListResponse, error) { + return nil, errors.New("API error") + } + + executor := newSlackExecutor(mock) + result := executor.listSlackUsers(context.Background(), map[string]any{}) + + assert.Contains(t, result.Error, "failed to list users") + }) +} diff --git a/internal/chat/executor_slack_test.go b/internal/chat/executor_slack_test.go new file mode 100644 index 0000000..2845c4d --- /dev/null +++ b/internal/chat/executor_slack_test.go @@ -0,0 +1,468 @@ +package chat + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + nylas "github.com/nylas/cli/internal/adapters/nylas" + slackAdapter "github.com/nylas/cli/internal/adapters/slack" + "github.com/nylas/cli/internal/domain" + "github.com/stretchr/testify/assert" +) + +func newSlackExecutor(slackClient *slackAdapter.MockClient) *ToolExecutor { + return NewToolExecutor(nylas.NewMockClient(), "test-grant", slackClient) +} + +func TestSlackTools_WithoutSlackClient(t *testing.T) { + client := nylas.NewMockClient() + executor := NewToolExecutor(client, "test-grant", nil) + + slackTools := []string{ + "list_slack_channels", + "read_slack_messages", + "read_slack_thread", + "search_slack", + "send_slack_message", + "list_slack_users", + } + + for _, toolName := range slackTools { + t.Run(toolName, func(t *testing.T) { + result := executor.Execute(context.Background(), ToolCall{ + Name: toolName, + Args: map[string]any{}, + }) + + assert.Equal(t, toolName, result.Name) + assert.Contains(t, result.Error, "Slack integration not configured") + }) + } +} + +func TestAvailableTools_WithSlack(t *testing.T) { + toolsWithoutSlack := AvailableTools(false) + toolsWithSlack := AvailableTools(true) + + // Should have 8 base tools without Slack + assert.Equal(t, 8, len(toolsWithoutSlack)) + + // Should have 8 + 6 = 14 tools with Slack + assert.Equal(t, 14, len(toolsWithSlack)) + + // Verify Slack tools are present + toolNames := make(map[string]bool) + for _, tool := range toolsWithSlack { + toolNames[tool.Name] = true + } + + slackTools := []string{ + "list_slack_channels", + "read_slack_messages", + "read_slack_thread", + "search_slack", + "send_slack_message", + "list_slack_users", + } + + for _, slackTool := range slackTools { + assert.True(t, toolNames[slackTool], "expected Slack tool %s to be present", slackTool) + } +} + +func TestResolveSlackChannel(t *testing.T) { + tests := []struct { + name string + input string + mockSetup func(*slackAdapter.MockClient) + expectID string + expectError bool + }{ + { + name: "channel ID passthrough - C prefix", + input: "C123456789", + expectID: "C123456789", + }, + { + name: "channel ID passthrough - G prefix", + input: "G987654321", + expectID: "G987654321", + }, + { + name: "channel ID passthrough - D prefix", + input: "D111222333", + expectID: "D111222333", + }, + { + name: "channel name resolution with #", + input: "#general", + mockSetup: func(m *slackAdapter.MockClient) { + m.ListMyChannelsFunc = func(ctx context.Context, params *domain.SlackChannelQueryParams) (*domain.SlackChannelListResponse, error) { + assert.True(t, params.ExcludeArchived) + assert.Equal(t, 1000, params.Limit) + return &domain.SlackChannelListResponse{ + Channels: []domain.SlackChannel{ + {ID: "C999", Name: "general", IsChannel: true}, + {ID: "C888", Name: "random", IsChannel: true}, + }, + }, nil + } + }, + expectID: "C999", + }, + { + name: "channel name resolution without #", + input: "random", + mockSetup: func(m *slackAdapter.MockClient) { + m.ListMyChannelsFunc = func(ctx context.Context, params *domain.SlackChannelQueryParams) (*domain.SlackChannelListResponse, error) { + return &domain.SlackChannelListResponse{ + Channels: []domain.SlackChannel{ + {ID: "C999", Name: "general", IsChannel: true}, + {ID: "C888", Name: "random", IsChannel: true}, + }, + }, nil + } + }, + expectID: "C888", + }, + { + name: "channel name case insensitive", + input: "#GENERAL", + mockSetup: func(m *slackAdapter.MockClient) { + m.ListMyChannelsFunc = func(ctx context.Context, params *domain.SlackChannelQueryParams) (*domain.SlackChannelListResponse, error) { + return &domain.SlackChannelListResponse{ + Channels: []domain.SlackChannel{ + {ID: "C999", Name: "general", IsChannel: true}, + }, + }, nil + } + }, + expectID: "C999", + }, + { + name: "prefix match when exact match not found", + input: "#incident-20260213-sync-latency", + mockSetup: func(m *slackAdapter.MockClient) { + m.ListMyChannelsFunc = func(ctx context.Context, params *domain.SlackChannelQueryParams) (*domain.SlackChannelListResponse, error) { + return &domain.SlackChannelListResponse{ + Channels: []domain.SlackChannel{ + {ID: "C999", Name: "general"}, + {ID: "C777", Name: "incident-20260213-sync-latency-us-prod-748"}, + }, + }, nil + } + }, + expectID: "C777", + }, + { + name: "exact match preferred over prefix match", + input: "#incident", + mockSetup: func(m *slackAdapter.MockClient) { + m.ListMyChannelsFunc = func(ctx context.Context, params *domain.SlackChannelQueryParams) (*domain.SlackChannelListResponse, error) { + return &domain.SlackChannelListResponse{ + Channels: []domain.SlackChannel{ + {ID: "C111", Name: "incident-20260213-foo"}, + {ID: "C222", Name: "incident"}, + }, + }, nil + } + }, + expectID: "C222", + }, + { + name: "channel found on second page", + input: "#incident-channel", + mockSetup: func(m *slackAdapter.MockClient) { + callCount := 0 + m.ListMyChannelsFunc = func(ctx context.Context, params *domain.SlackChannelQueryParams) (*domain.SlackChannelListResponse, error) { + callCount++ + if callCount == 1 { + return &domain.SlackChannelListResponse{ + Channels: []domain.SlackChannel{{ID: "C999", Name: "general"}}, + NextCursor: "page2", + }, nil + } + return &domain.SlackChannelListResponse{ + Channels: []domain.SlackChannel{{ID: "C777", Name: "incident-channel"}}, + }, nil + } + }, + expectID: "C777", + }, + { + name: "fallback to search when not in channel list", + input: "#incident-20260213-prod", + mockSetup: func(m *slackAdapter.MockClient) { + m.ListMyChannelsFunc = func(ctx context.Context, params *domain.SlackChannelQueryParams) (*domain.SlackChannelListResponse, error) { + return &domain.SlackChannelListResponse{ + Channels: []domain.SlackChannel{{ID: "C999", Name: "general"}}, + }, nil + } + m.SearchMessagesFunc = func(ctx context.Context, query string, limit int) ([]domain.SlackMessage, error) { + assert.Equal(t, "in:#incident-20260213-prod", query) + return []domain.SlackMessage{ + {ID: "msg1", ChannelID: "C555"}, + }, nil + } + }, + expectID: "C555", + }, + { + name: "channel not found after all fallbacks", + input: "#nonexistent", + mockSetup: func(m *slackAdapter.MockClient) { + m.ListMyChannelsFunc = func(ctx context.Context, params *domain.SlackChannelQueryParams) (*domain.SlackChannelListResponse, error) { + return &domain.SlackChannelListResponse{ + Channels: []domain.SlackChannel{{ID: "C999", Name: "general"}}, + }, nil + } + m.SearchMessagesFunc = func(ctx context.Context, query string, limit int) ([]domain.SlackMessage, error) { + return []domain.SlackMessage{}, nil + } + }, + expectError: true, + }, + { + name: "API error", + input: "#general", + mockSetup: func(m *slackAdapter.MockClient) { + m.ListMyChannelsFunc = func(ctx context.Context, params *domain.SlackChannelQueryParams) (*domain.SlackChannelListResponse, error) { + return nil, errors.New("API error") + } + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := slackAdapter.NewMockClient() + if tt.mockSetup != nil { + tt.mockSetup(mock) + } + + executor := newSlackExecutor(mock) + channelID, err := executor.resolveSlackChannel(context.Background(), tt.input) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectID, channelID) + } + }) + } +} + +func TestListSlackChannels(t *testing.T) { + t.Run("success with default limit", func(t *testing.T) { + mock := slackAdapter.NewMockClient() + mock.ListMyChannelsFunc = func(ctx context.Context, params *domain.SlackChannelQueryParams) (*domain.SlackChannelListResponse, error) { + assert.Equal(t, 20, params.Limit) + assert.True(t, params.ExcludeArchived) + return &domain.SlackChannelListResponse{ + Channels: []domain.SlackChannel{ + {ID: "C123", Name: "general", IsChannel: true, MemberCount: 42, Topic: "General discussion"}, + {ID: "C456", Name: "random", IsChannel: true, MemberCount: 10}, + {ID: "D789", IsIM: true, MemberCount: 2}, + }, + }, nil + } + + executor := newSlackExecutor(mock) + result := executor.listSlackChannels(context.Background(), map[string]any{}) + + assert.Empty(t, result.Error) + assert.Equal(t, "list_slack_channels", result.Name) + assert.NotNil(t, result.Data) + }) + + t.Run("success with custom limit", func(t *testing.T) { + mock := slackAdapter.NewMockClient() + mock.ListMyChannelsFunc = func(ctx context.Context, params *domain.SlackChannelQueryParams) (*domain.SlackChannelListResponse, error) { + assert.Equal(t, 50, params.Limit) + return &domain.SlackChannelListResponse{Channels: []domain.SlackChannel{}}, nil + } + + executor := newSlackExecutor(mock) + result := executor.listSlackChannels(context.Background(), map[string]any{"limit": float64(50)}) + + assert.Empty(t, result.Error) + }) + + t.Run("API error", func(t *testing.T) { + mock := slackAdapter.NewMockClient() + mock.ListMyChannelsFunc = func(ctx context.Context, params *domain.SlackChannelQueryParams) (*domain.SlackChannelListResponse, error) { + return nil, errors.New("API error") + } + + executor := newSlackExecutor(mock) + result := executor.listSlackChannels(context.Background(), map[string]any{}) + + assert.Contains(t, result.Error, "failed to list channels") + }) +} + +func TestReadSlackMessages(t *testing.T) { + t.Run("success with thread expansion", func(t *testing.T) { + mock := slackAdapter.NewMockClient() + mock.ListMyChannelsFunc = func(ctx context.Context, params *domain.SlackChannelQueryParams) (*domain.SlackChannelListResponse, error) { + return &domain.SlackChannelListResponse{ + Channels: []domain.SlackChannel{{ID: "C999", Name: "general"}}, + }, nil + } + mock.GetMessagesFunc = func(ctx context.Context, params *domain.SlackMessageQueryParams) (*domain.SlackMessageListResponse, error) { + assert.Equal(t, "C999", params.ChannelID) + assert.Equal(t, 500, params.Limit) + return &domain.SlackMessageListResponse{ + Messages: []domain.SlackMessage{ + {ID: "1234.567", Username: "testuser", Text: "Hello world", Timestamp: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), ReplyCount: 2}, + {ID: "1234.999", Username: "other", Text: "No thread", Timestamp: time.Date(2024, 1, 1, 13, 0, 0, 0, time.UTC)}, + }, + }, nil + } + mock.GetThreadRepliesFunc = func(ctx context.Context, channelID, threadTS string, limit int) ([]domain.SlackMessage, error) { + assert.Equal(t, "C999", channelID) + assert.Equal(t, "1234.567", threadTS) + return []domain.SlackMessage{ + {ID: "1234.567", Username: "testuser", Text: "Hello world", Timestamp: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)}, + {ID: "1234.568", Username: "replier", Text: "Thread reply", Timestamp: time.Date(2024, 1, 1, 12, 1, 0, 0, time.UTC), IsReply: true}, + }, nil + } + + executor := newSlackExecutor(mock) + result := executor.readSlackMessages(context.Background(), map[string]any{"channel": "#general"}) + + assert.Empty(t, result.Error) + assert.NotNil(t, result.Data) + }) + + t.Run("success with channel ID", func(t *testing.T) { + mock := slackAdapter.NewMockClient() + mock.GetMessagesFunc = func(ctx context.Context, params *domain.SlackMessageQueryParams) (*domain.SlackMessageListResponse, error) { + assert.Equal(t, "C123456789", params.ChannelID) + return &domain.SlackMessageListResponse{ + Messages: []domain.SlackMessage{ + {ID: "1", Username: "user1", Text: "msg1", Timestamp: time.Now()}, + {ID: "2", Username: "user2", Text: "msg2", Timestamp: time.Now()}, + {ID: "3", Username: "user3", Text: "msg3", Timestamp: time.Now()}, + {ID: "4", Username: "user4", Text: "msg4", Timestamp: time.Now()}, + {ID: "5", Username: "user5", Text: "msg5", Timestamp: time.Now()}, + }, + }, nil + } + + executor := newSlackExecutor(mock) + result := executor.readSlackMessages(context.Background(), map[string]any{"channel": "C123456789"}) + + assert.Empty(t, result.Error) + }) + + t.Run("search fallback when history returns few messages", func(t *testing.T) { + mock := slackAdapter.NewMockClient() + mock.ListMyChannelsFunc = func(ctx context.Context, params *domain.SlackChannelQueryParams) (*domain.SlackChannelListResponse, error) { + return &domain.SlackChannelListResponse{ + Channels: []domain.SlackChannel{{ID: "C555", Name: "incident-channel"}}, + }, nil + } + mock.GetMessagesFunc = func(ctx context.Context, params *domain.SlackMessageQueryParams) (*domain.SlackMessageListResponse, error) { + // History returns only system messages + return &domain.SlackMessageListResponse{ + Messages: []domain.SlackMessage{ + {ID: "1", Username: "slackbot", Text: "has joined the channel", Timestamp: time.Now()}, + }, + }, nil + } + mock.SearchMessagesFunc = func(ctx context.Context, query string, limit int) ([]domain.SlackMessage, error) { + assert.Equal(t, "in:#incident-channel", query) + return []domain.SlackMessage{ + {ID: "100", Username: "user1", Text: "Incident started", Timestamp: time.Now()}, + {ID: "101", Username: "user2", Text: "Investigating", Timestamp: time.Now()}, + {ID: "102", Username: "user1", Text: "Root cause found", Timestamp: time.Now()}, + }, nil + } + + executor := newSlackExecutor(mock) + result := executor.readSlackMessages(context.Background(), map[string]any{"channel": "#incident-channel"}) + + assert.Empty(t, result.Error) + assert.NotNil(t, result.Data) + }) + + t.Run("no search fallback when history has enough messages", func(t *testing.T) { + mock := slackAdapter.NewMockClient() + searchCalled := false + mock.GetMessagesFunc = func(ctx context.Context, params *domain.SlackMessageQueryParams) (*domain.SlackMessageListResponse, error) { + msgs := make([]domain.SlackMessage, 10) + for i := range 10 { + msgs[i] = domain.SlackMessage{ID: fmt.Sprintf("%d", i), Username: "user", Text: "msg", Timestamp: time.Now()} + } + return &domain.SlackMessageListResponse{Messages: msgs}, nil + } + mock.SearchMessagesFunc = func(ctx context.Context, query string, limit int) ([]domain.SlackMessage, error) { + searchCalled = true + return nil, nil + } + + executor := newSlackExecutor(mock) + result := executor.readSlackMessages(context.Background(), map[string]any{"channel": "C123456789"}) + + assert.Empty(t, result.Error) + assert.False(t, searchCalled, "search should not be called when history has enough messages") + }) + + t.Run("missing channel parameter", func(t *testing.T) { + executor := newSlackExecutor(slackAdapter.NewMockClient()) + result := executor.readSlackMessages(context.Background(), map[string]any{}) + + assert.Contains(t, result.Error, "missing required parameter: channel") + }) + + t.Run("channel resolution error", func(t *testing.T) { + mock := slackAdapter.NewMockClient() + mock.ListMyChannelsFunc = func(ctx context.Context, params *domain.SlackChannelQueryParams) (*domain.SlackChannelListResponse, error) { + return nil, errors.New("API error") + } + + executor := newSlackExecutor(mock) + result := executor.readSlackMessages(context.Background(), map[string]any{"channel": "#general"}) + + assert.Contains(t, result.Error, "failed to list channels") + }) + + t.Run("API error", func(t *testing.T) { + mock := slackAdapter.NewMockClient() + mock.GetMessagesFunc = func(ctx context.Context, params *domain.SlackMessageQueryParams) (*domain.SlackMessageListResponse, error) { + return nil, errors.New("API error") + } + + executor := newSlackExecutor(mock) + result := executor.readSlackMessages(context.Background(), map[string]any{"channel": "C123456789"}) + + assert.Contains(t, result.Error, "failed to get messages") + }) +} + +func TestBuildChannelSearchQuery(t *testing.T) { + tests := []struct { + channel string + channelID string + want string + }{ + {"#general", "C999", "in:#general"}, + {"#GENERAL", "C999", "in:#general"}, + {"random", "C888", "in:#random"}, + {"C123456789", "C123456789", "in:<#C123456789>"}, + {"G987654321", "G987654321", "in:<#G987654321>"}, + } + + for _, tt := range tests { + t.Run(tt.channel, func(t *testing.T) { + got := buildChannelSearchQuery(tt.channel, tt.channelID) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/chat/executor_test.go b/internal/chat/executor_test.go index c38dc71..f79e379 100644 --- a/internal/chat/executor_test.go +++ b/internal/chat/executor_test.go @@ -18,16 +18,18 @@ func TestNewToolExecutor(t *testing.T) { client := nylas.NewMockClient() grantID := "test-grant" - executor := NewToolExecutor(client, grantID) + executor := NewToolExecutor(client, grantID, nil) require.NotNil(t, executor) assert.Equal(t, grantID, executor.grantID) assert.Equal(t, client, executor.client) + assert.Nil(t, executor.slack) + assert.False(t, executor.HasSlack()) } func TestExecute_UnknownTool(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) result := executor.Execute(context.Background(), ToolCall{ Name: "unknown_tool", @@ -59,7 +61,7 @@ func TestExecute_Dispatcher(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) // Set up mock to avoid nil errors client.GetMessagesWithParamsFunc = func(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.Message, error) { @@ -146,7 +148,7 @@ func TestListEmails_Success(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) client.GetMessagesWithParamsFunc = func(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.Message, error) { return tt.messages, nil @@ -205,7 +207,7 @@ func TestListEmails_FromFormatting(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) client.GetMessagesWithParamsFunc = func(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.Message, error) { return []domain.Message{{ID: "msg1", Subject: "Test", Date: now, From: tt.from}}, nil @@ -230,7 +232,7 @@ func TestListEmails_FromFormatting(t *testing.T) { func TestListEmails_Error(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) client.GetMessagesWithParamsFunc = func(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.Message, error) { return nil, errors.New("API error") @@ -292,7 +294,7 @@ func TestReadEmail_Success(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) client.GetMessageFunc = func(ctx context.Context, grantID, messageID string) (*domain.Message, error) { return tt.message, nil @@ -337,7 +339,7 @@ func TestReadEmail_MissingID(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) result := executor.Execute(context.Background(), ToolCall{ Name: "read_email", @@ -353,7 +355,7 @@ func TestReadEmail_MissingID(t *testing.T) { func TestReadEmail_Error(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) client.GetMessageFunc = func(ctx context.Context, grantID, messageID string) (*domain.Message, error) { return nil, errors.New("message not found") @@ -370,10 +372,10 @@ func TestReadEmail_Error(t *testing.T) { func TestSearchEmails_Success(t *testing.T) { now := time.Now() client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) client.GetMessagesWithParamsFunc = func(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.Message, error) { - assert.Equal(t, "budget report", params.SearchQuery) + assert.Equal(t, "budget report", params.Subject) assert.Equal(t, 20, params.Limit) return []domain.Message{ {ID: "msg1", Subject: "Budget Report", Snippet: "Q1 budget", Date: now, From: []domain.EmailParticipant{{Email: "finance@example.com"}}}, @@ -409,7 +411,7 @@ func TestSearchEmails_MissingQuery(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) result := executor.Execute(context.Background(), ToolCall{ Name: "search_emails", @@ -423,7 +425,7 @@ func TestSearchEmails_MissingQuery(t *testing.T) { func TestSearchEmails_Error(t *testing.T) { client := nylas.NewMockClient() - executor := NewToolExecutor(client, "test-grant") + executor := NewToolExecutor(client, "test-grant", nil) client.GetMessagesWithParamsFunc = func(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.Message, error) { return nil, errors.New("search failed") diff --git a/internal/chat/handlers_cmd_test.go b/internal/chat/handlers_cmd_test.go index 383a61a..2f1afca 100644 --- a/internal/chat/handlers_cmd_test.go +++ b/internal/chat/handlers_cmd_test.go @@ -18,7 +18,7 @@ func setupTestServer(t *testing.T) *Server { memory := setupMemoryStore(t) mockClient := nylas.NewMockClient() agent := &Agent{Type: AgentClaude, Version: "1.0"} - executor := NewToolExecutor(mockClient, "test-grant") + executor := NewToolExecutor(mockClient, "test-grant", nil) return &Server{ agent: agent, diff --git a/internal/chat/prompt.go b/internal/chat/prompt.go index 576c4a8..771d820 100644 --- a/internal/chat/prompt.go +++ b/internal/chat/prompt.go @@ -4,17 +4,26 @@ import "strings" // BuildSystemPrompt constructs the system prompt for the AI agent. // It includes identity, available tools, and the text-based tool protocol. -func BuildSystemPrompt(grantID string, agentType AgentType) string { +func BuildSystemPrompt(grantID string, agentType AgentType, hasSlack bool) string { var sb strings.Builder - sb.WriteString("You are a helpful email and calendar assistant powered by the Nylas API.\n") - sb.WriteString("You help users manage their emails, calendar events, and contacts.\n\n") + if hasSlack { + sb.WriteString("You are a helpful email, calendar, and Slack assistant powered by the Nylas API.\n") + sb.WriteString("You help users manage their emails, calendar events, contacts, and Slack messages.\n\n") + } else { + sb.WriteString("You are a helpful email and calendar assistant powered by the Nylas API.\n") + sb.WriteString("You help users manage their emails, calendar events, and contacts.\n\n") + } sb.WriteString("Grant ID: " + grantID + "\n\n") // Tool protocol instructions sb.WriteString("## Tool Usage\n\n") - sb.WriteString("When you need to access the user's email, calendar, or contacts, use the tools below.\n") + if hasSlack { + sb.WriteString("When you need to access the user's email, calendar, contacts, or Slack, use the tools below.\n") + } else { + sb.WriteString("When you need to access the user's email, calendar, or contacts, use the tools below.\n") + } sb.WriteString("To call a tool, output EXACTLY this format on its own line:\n\n") sb.WriteString("TOOL_CALL: {\"name\": \"tool_name\", \"args\": {\"param\": \"value\"}}\n\n") sb.WriteString("IMPORTANT RULES:\n") @@ -26,7 +35,7 @@ func BuildSystemPrompt(grantID string, agentType AgentType) string { sb.WriteString("3. Only make one TOOL_CALL per response. Wait for the result before proceeding.\n\n") // Tool definitions - sb.WriteString(FormatToolsForPrompt(AvailableTools())) + sb.WriteString(FormatToolsForPrompt(AvailableTools(hasSlack))) sb.WriteString("\n") // Context instructions @@ -39,6 +48,9 @@ func BuildSystemPrompt(grantID string, agentType AgentType) string { sb.WriteString("- Use markdown formatting for readability\n") sb.WriteString("- Present email lists as numbered items with sender, subject, and date\n") sb.WriteString("- Present calendar events with time, title, and attendees\n") + if hasSlack { + sb.WriteString("- Present Slack messages with username, timestamp, and content\n") + } sb.WriteString("- Keep responses concise but informative\n") sb.WriteString("- If an error occurs, explain it clearly and suggest alternatives\n") diff --git a/internal/chat/prompt_test.go b/internal/chat/prompt_test.go index 06a1c62..ab260ec 100644 --- a/internal/chat/prompt_test.go +++ b/internal/chat/prompt_test.go @@ -56,7 +56,7 @@ func TestBuildSystemPrompt(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := BuildSystemPrompt(tt.grantID, tt.agentType) + got := BuildSystemPrompt(tt.grantID, tt.agentType, false) // Verify the output is non-empty if got == "" { @@ -76,7 +76,7 @@ func TestBuildSystemPrompt(t *testing.T) { func TestBuildSystemPrompt_Structure(t *testing.T) { grantID := "test-grant" agentType := AgentClaude - prompt := BuildSystemPrompt(grantID, agentType) + prompt := BuildSystemPrompt(grantID, agentType, false) // Verify key sections are present in order sections := []string{ @@ -100,3 +100,37 @@ func TestBuildSystemPrompt_Structure(t *testing.T) { lastIndex = index } } + +func TestBuildSystemPrompt_WithSlack(t *testing.T) { + prompt := BuildSystemPrompt("grant-123", AgentClaude, true) + + // Check for Slack-specific content + if !strings.Contains(prompt, "Slack") { + t.Error("prompt with hasSlack=true should mention Slack") + } + if !strings.Contains(prompt, "list_slack_channels") { + t.Error("prompt with hasSlack=true should include list_slack_channels") + } + if !strings.Contains(prompt, "send_slack_message") { + t.Error("prompt with hasSlack=true should include send_slack_message") + } + if !strings.Contains(prompt, "Slack messages with username") { + t.Error("prompt with hasSlack=true should include Slack message formatting instructions") + } +} + +func TestBuildSystemPrompt_WithoutSlack(t *testing.T) { + prompt := BuildSystemPrompt("grant-123", AgentClaude, false) + + // Check that Slack tools are not included + if strings.Contains(prompt, "list_slack_channels") { + t.Error("prompt with hasSlack=false should not include list_slack_channels") + } + if strings.Contains(prompt, "send_slack_message") { + t.Error("prompt with hasSlack=false should not include send_slack_message") + } + // Should still have email tools + if !strings.Contains(prompt, "list_emails") { + t.Error("prompt should always include email tools") + } +} diff --git a/internal/chat/server.go b/internal/chat/server.go index 7acb800..b779c72 100644 --- a/internal/chat/server.go +++ b/internal/chat/server.go @@ -24,6 +24,8 @@ type Server struct { agents []Agent agentMu sync.RWMutex // protects agent switching nylas ports.NylasClient + slack ports.SlackClient // nil if not configured + hasSlack bool grantID string memory *MemoryStore executor *ToolExecutor @@ -48,15 +50,16 @@ func (s *Server) SetAgent(agentType AgentType) bool { } s.agentMu.Lock() s.agent = agent - s.context = NewContextBuilder(agent, s.memory, s.grantID) + s.context = NewContextBuilder(agent, s.memory, s.grantID, s.hasSlack) s.agentMu.Unlock() return true } // NewServer creates a new chat Server. -func NewServer(addr string, agent *Agent, agents []Agent, nylas ports.NylasClient, grantID string, memory *MemoryStore) *Server { - executor := NewToolExecutor(nylas, grantID) - ctx := NewContextBuilder(agent, memory, grantID) +func NewServer(addr string, agent *Agent, agents []Agent, nylas ports.NylasClient, grantID string, memory *MemoryStore, slack ports.SlackClient) *Server { + hasSlack := slack != nil + executor := NewToolExecutor(nylas, grantID, slack) + ctx := NewContextBuilder(agent, memory, grantID, hasSlack) tmpl, _ := template.New("").ParseFS(templateFiles, "templates/*.gohtml") @@ -65,6 +68,8 @@ func NewServer(addr string, agent *Agent, agents []Agent, nylas ports.NylasClien agent: agent, agents: agents, nylas: nylas, + slack: slack, + hasSlack: hasSlack, grantID: grantID, memory: memory, executor: executor, diff --git a/internal/chat/tools.go b/internal/chat/tools.go index b2cc186..b26c33d 100644 --- a/internal/chat/tools.go +++ b/internal/chat/tools.go @@ -39,9 +39,63 @@ const toolCallPrefix = "TOOL_CALL:" // toolResultPrefix is the marker for returning results. const toolResultPrefix = "TOOL_RESULT:" -// AvailableTools returns the tools exposed to AI agents. -func AvailableTools() []Tool { +// slackTools returns Slack-specific tools. +func slackTools() []Tool { return []Tool{ + { + Name: "list_slack_channels", + Description: "List Slack channels the user is a member of", + Parameters: []ToolParameter{ + {Name: "limit", Type: "number", Description: "Max channels to return (default 20)", Required: false}, + }, + }, + { + Name: "read_slack_messages", + Description: "Read messages from a Slack channel with thread replies expanded inline", + Parameters: []ToolParameter{ + {Name: "channel", Type: "string", Description: "Channel name (e.g. #general) or channel ID", Required: true}, + {Name: "limit", Type: "number", Description: "Max messages to return (default 500)", Required: false}, + }, + }, + { + Name: "read_slack_thread", + Description: "Read replies in a Slack message thread", + Parameters: []ToolParameter{ + {Name: "channel", Type: "string", Description: "Channel name or ID where the thread is", Required: true}, + {Name: "thread_ts", Type: "string", Description: "Thread timestamp of the parent message", Required: true}, + {Name: "limit", Type: "number", Description: "Max replies to return (default 20)", Required: false}, + }, + }, + { + Name: "search_slack", + Description: "Search Slack messages by query (supports from:@user, in:#channel syntax)", + Parameters: []ToolParameter{ + {Name: "query", Type: "string", Description: "Search query string", Required: true}, + {Name: "limit", Type: "number", Description: "Max results to return (default 10)", Required: false}, + }, + }, + { + Name: "send_slack_message", + Description: "Send a message to a Slack channel or reply to a thread", + Parameters: []ToolParameter{ + {Name: "channel", Type: "string", Description: "Channel name or ID to post in", Required: true}, + {Name: "text", Type: "string", Description: "Message text (supports Slack markup)", Required: true}, + {Name: "thread_ts", Type: "string", Description: "Thread timestamp to reply to (optional)", Required: false}, + }, + }, + { + Name: "list_slack_users", + Description: "List members of the Slack workspace", + Parameters: []ToolParameter{ + {Name: "limit", Type: "number", Description: "Max users to return (default 20)", Required: false}, + }, + }, + } +} + +// AvailableTools returns the tools exposed to AI agents. +func AvailableTools(hasSlack bool) []Tool { + tools := []Tool{ { Name: "list_emails", Description: "List recent emails from the user's inbox", @@ -109,6 +163,12 @@ func AvailableTools() []Tool { Parameters: []ToolParameter{}, }, } + + if hasSlack { + tools = append(tools, slackTools()...) + } + + return tools } // ParseToolCalls extracts TOOL_CALL: lines from agent output. diff --git a/internal/chat/tools_test.go b/internal/chat/tools_test.go index 9ec4349..35c0f37 100644 --- a/internal/chat/tools_test.go +++ b/internal/chat/tools_test.go @@ -201,7 +201,7 @@ func TestFormatToolResult(t *testing.T) { } func TestAvailableTools(t *testing.T) { - tools := AvailableTools() + tools := AvailableTools(false) t.Run("returns correct tool count", func(t *testing.T) { // Expecting: list_emails, read_email, search_emails, send_email,