Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions internal/chat/approval.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions internal/chat/approval_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package chat

import (
"strings"
"sync"
"testing"
"time"
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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 '...'")
}
}
34 changes: 33 additions & 1 deletion internal/chat/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand All @@ -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()

Expand All @@ -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()
},
}
Expand Down
18 changes: 10 additions & 8 deletions internal/chat/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions internal/chat/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
27 changes: 23 additions & 4 deletions internal/chat/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)}
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading